Skip to content

Commit 846cbdc

Browse files
author
임선규
committed
feat: 산불위험지수 카드 연동
1 parent 2e5a9fb commit 846cbdc

8 files changed

Lines changed: 459 additions & 20 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { useEffect, useState } from 'react';
2+
import { getForestFireRisk, type ForestFireRiskData, type ForestFireRiskLevel } from '../services/forestFireRiskApi';
3+
4+
interface ForestFireRiskCardProps {
5+
cityLabel: string;
6+
humidity: number;
7+
windSpeed: number;
8+
}
9+
10+
const LEVEL_CLASS: Record<ForestFireRiskLevel, { bg: string; border: string; text: string; badge: string }> = {
11+
낮음: {
12+
bg: 'bg-green-900/20',
13+
border: 'border-green-500/20',
14+
text: 'text-green-400',
15+
badge: 'bg-green-500/20 text-green-300',
16+
},
17+
보통: {
18+
bg: 'bg-amber-900/20',
19+
border: 'border-amber-500/20',
20+
text: 'text-amber-400',
21+
badge: 'bg-amber-500/20 text-amber-300',
22+
},
23+
높음: {
24+
bg: 'bg-orange-900/25',
25+
border: 'border-orange-500/30',
26+
text: 'text-orange-400',
27+
badge: 'bg-orange-500/20 text-orange-300',
28+
},
29+
'매우 높음': {
30+
bg: 'bg-red-900/30',
31+
border: 'border-red-500/30',
32+
text: 'text-red-400',
33+
badge: 'bg-red-500/20 text-red-300',
34+
},
35+
};
36+
37+
function fallbackLevel(humidity: number, windSpeed: number): ForestFireRiskLevel {
38+
if (humidity < 35 || windSpeed >= 10) return '높음';
39+
if (humidity < 50 || windSpeed >= 7) return '보통';
40+
return '낮음';
41+
}
42+
43+
function formatTime(value: string): string {
44+
if (!value) return '';
45+
const digits = value.replace(/\D/g, '');
46+
if (digits.length >= 10) {
47+
return `${digits.slice(4, 6)}/${digits.slice(6, 8)} ${digits.slice(8, 10)}시`;
48+
}
49+
if (digits.length === 8) {
50+
return `${digits.slice(4, 6)}/${digits.slice(6, 8)}`;
51+
}
52+
return value;
53+
}
54+
55+
function formatFetchedAt(value: string): string {
56+
const date = new Date(value);
57+
if (Number.isNaN(date.getTime())) return '';
58+
return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
59+
}
60+
61+
export default function ForestFireRiskCard({ cityLabel, humidity, windSpeed }: ForestFireRiskCardProps) {
62+
const [risk, setRisk] = useState<ForestFireRiskData | null>(null);
63+
const [loading, setLoading] = useState(true);
64+
65+
useEffect(() => {
66+
let alive = true;
67+
setLoading(true);
68+
getForestFireRisk(cityLabel)
69+
.then(data => {
70+
if (alive) setRisk(data);
71+
})
72+
.catch(() => {
73+
if (alive) setRisk(null);
74+
})
75+
.finally(() => {
76+
if (alive) setLoading(false);
77+
});
78+
79+
return () => {
80+
alive = false;
81+
};
82+
}, [cityLabel]);
83+
84+
const level = risk?.level || fallbackLevel(humidity, windSpeed);
85+
const tone = LEVEL_CLASS[level];
86+
87+
return (
88+
<div className={`rounded-xl p-5 border flex-1 ${tone.bg} ${tone.border}`}>
89+
<div className="flex items-start justify-between gap-3">
90+
<div>
91+
<p className="text-[10px] uppercase tracking-widest font-bold text-on-surface-variant">🔥 산불위험지수</p>
92+
<p className="text-xs text-on-surface-variant mt-1">{cityLabel} · 산림청 예보</p>
93+
</div>
94+
<span className={`px-2 py-1 rounded text-[10px] font-black ${tone.badge}`}>
95+
{level}
96+
</span>
97+
</div>
98+
99+
{loading ? (
100+
<div className="mt-4 space-y-2">
101+
<div className="h-8 w-24 rounded bg-white/10 animate-pulse" />
102+
<div className="h-3 w-40 rounded bg-white/10 animate-pulse" />
103+
</div>
104+
) : risk ? (
105+
<>
106+
<div className="flex items-end gap-2 mt-3">
107+
<p className={`text-4xl font-extrabold font-headline tabular-nums ${tone.text}`}>
108+
{Math.round(risk.value)}
109+
</p>
110+
<p className="text-xs text-on-surface-variant mb-1">관내 최대</p>
111+
</div>
112+
<div className="grid grid-cols-3 gap-2 mt-4 text-center">
113+
<div className="bg-black/10 rounded-lg p-2">
114+
<p className="text-[10px] text-on-surface-variant">최소</p>
115+
<p className="text-sm font-bold text-on-surface tabular-nums">{risk.min ?? '-'}</p>
116+
</div>
117+
<div className="bg-black/10 rounded-lg p-2">
118+
<p className="text-[10px] text-on-surface-variant">평균</p>
119+
<p className="text-sm font-bold text-on-surface tabular-nums">{risk.avg ?? '-'}</p>
120+
</div>
121+
<div className="bg-black/10 rounded-lg p-2">
122+
<p className="text-[10px] text-on-surface-variant">최대</p>
123+
<p className="text-sm font-bold text-on-surface tabular-nums">{risk.max ?? risk.value}</p>
124+
</div>
125+
</div>
126+
<p className="text-[10px] text-on-surface-variant mt-3">
127+
{risk.forecastTime ? `예보 ${formatTime(risk.forecastTime)}` : `갱신 ${formatFetchedAt(risk.fetchedAt)}`}
128+
{risk.staleAt ? ' · 캐시 데이터' : ''}
129+
</p>
130+
</>
131+
) : (
132+
<>
133+
<p className={`text-3xl font-extrabold mt-3 font-headline ${tone.text}`}>
134+
설정 대기
135+
</p>
136+
<p className="text-xs text-on-surface-variant mt-1">
137+
공식 API 키 등록 후 자동으로 표시됩니다.
138+
</p>
139+
<p className="text-xs text-on-surface-variant mt-2">
140+
임시 참고: 습도 {humidity}% · 풍속 {windSpeed}m/s
141+
</p>
142+
</>
143+
)}
144+
145+
{!loading && windSpeed > 10 && (
146+
<p className="text-xs text-amber-300 mt-2 font-bold">💨 강풍 시 산불 확산 주의</p>
147+
)}
148+
</div>
149+
);
150+
}

src/components/WeatherDashboard.tsx

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import StaleBadge from './StaleBadge';
1111
import { WindCompass } from './WindCompass';
1212
import WeatherAlertBanner from './WeatherAlertBanner';
1313
import SunTimesCard from './SunTimesCard';
14+
import ForestFireRiskCard from './ForestFireRiskCard';
1415

1516
// Fallback data when API fails
1617
const FALLBACK_WEATHER: CurrentWeather = {
@@ -114,7 +115,7 @@ export default function WeatherDashboard({ city }: WeatherDashboardProps) {
114115
setLoading(false);
115116
}
116117
}
117-
}, [grid.nx, grid.ny, city]);
118+
}, [grid.nx, grid.ny, grid.name]);
118119

119120
useEffect(() => { fetchAll(); }, [fetchAll]);
120121

@@ -329,25 +330,7 @@ export default function WeatherDashboard({ city }: WeatherDashboardProps) {
329330

330331
{/* Side Info Cards */}
331332
<div className="lg:col-span-4 flex flex-col gap-4">
332-
{/* Fire Risk Assessment */}
333-
<div className={`rounded-xl p-5 border flex-1 ${
334-
current.humidity < 35 ? 'bg-red-900/30 border-red-500/30' :
335-
current.humidity < 50 ? 'bg-amber-900/20 border-amber-500/20' :
336-
'bg-green-900/20 border-green-500/20'
337-
}`}>
338-
<p className="text-[10px] uppercase tracking-widest font-bold text-on-surface-variant">🔥 화재 위험도</p>
339-
<p className={`text-3xl font-extrabold mt-2 font-headline ${
340-
current.humidity < 35 ? 'text-red-400' :
341-
current.humidity < 50 ? 'text-amber-400' : 'text-green-400'
342-
}`}>
343-
{current.humidity < 35 ? '높음' : current.humidity < 50 ? '보통' : '낮음'}
344-
</p>
345-
<p className="text-xs text-on-surface-variant mt-1">
346-
습도 {current.humidity}% · 풍속 {current.windSpeed}m/s
347-
</p>
348-
{current.humidity < 35 && <p className="text-xs text-red-300 mt-2 font-bold">⚠️ 건조주의! 화재 확산 위험</p>}
349-
{current.windSpeed > 10 && <p className="text-xs text-amber-300 mt-1 font-bold">💨 강풍! 고층 화재 주의</p>}
350-
</div>
333+
<ForestFireRiskCard cityLabel={grid.name} humidity={current.humidity} windSpeed={current.windSpeed} />
351334

352335
{/* 일출 · 일몰 (위경도 기반 계산, API 불필요) */}
353336
<SunTimesCard city={city} cityLabel={grid.name} />

src/services/apiClient.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,34 @@ export async function fetchWeatherBriefing(stnId: string): Promise<{ briefing: s
489489
return apiFetch<{ briefing: string }>('/api/weather/briefing', { stnId }, { ...WEATHER_OPTS, schema: weatherBriefingSchema });
490490
}
491491

492+
// ═══════ 산불위험예보지수 (3시간 주기, 폴백 최대 6시간) ═══════
493+
export interface ForestFireRiskResponse {
494+
items: ApiRecord[];
495+
totalCount: number;
496+
source?: string;
497+
fetchedAt?: string;
498+
}
499+
500+
const FOREST_FIRE_RISK_OPTS: ApiFetchOptions<ForestFireRiskResponse> = {
501+
cacheTtlMs: 1000 * 60 * 60,
502+
maxStaleMs: 1000 * 60 * 60 * 6,
503+
};
504+
505+
const forestFireRiskResponseSchema = z.object({
506+
items: apiRecordArraySchema,
507+
totalCount: z.coerce.number().catch(0),
508+
source: z.string().optional(),
509+
fetchedAt: z.string().optional(),
510+
}).passthrough() satisfies z.ZodType<ForestFireRiskResponse>;
511+
512+
export async function fetchForestFireRisk(forceRefresh?: boolean): Promise<ForestFireRiskResponse> {
513+
return apiFetch<ForestFireRiskResponse>(
514+
'/api/forest-fire-risk',
515+
{ numOfRows: '1000', pageNo: '1' },
516+
{ ...FOREST_FIRE_RISK_OPTS, forceRefresh, schema: forestFireRiskResponseSchema },
517+
);
518+
}
519+
492520
// ═══════ 대기질 (TTL 짧게 30분, 폴백 최대 6시간) ═══════
493521
const AIR_OPTS: ApiFetchOptions = { cacheTtlMs: 1000 * 60 * 30, maxStaleMs: 1000 * 60 * 60 * 6 };
494522
export async function fetchAirQuality(sido: string): Promise<ApiRecord[]> {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { classifyForestFireRisk, normalizeForestFireRisk } from './forestFireRiskApi';
3+
4+
describe('forest fire risk utilities', () => {
5+
it('classifies official risk index thresholds', () => {
6+
expect(classifyForestFireRisk(50)).toBe('낮음');
7+
expect(classifyForestFireRisk(51)).toBe('보통');
8+
expect(classifyForestFireRisk(66)).toBe('높음');
9+
expect(classifyForestFireRisk(86)).toBe('매우 높음');
10+
});
11+
12+
it('normalizes common English-style response fields', () => {
13+
const risk = normalizeForestFireRisk({
14+
items: [
15+
{ sidoNm: '부산광역시', maxValue: '52', minValue: '6', avgValue: '30', analDate: '202606181500' },
16+
{ sidoNm: '서울특별시', maxValue: '80', minValue: '5', avgValue: '58', analDate: '202606181500' },
17+
],
18+
totalCount: 2,
19+
fetchedAt: '2026-06-18T06:00:00.000Z',
20+
}, '서울');
21+
22+
expect(risk).toMatchObject({
23+
sidoName: '서울특별시',
24+
value: 80,
25+
min: 5,
26+
avg: 58,
27+
max: 80,
28+
level: '높음',
29+
forecastTime: '202606181500',
30+
});
31+
});
32+
33+
it('normalizes Korean-style response fields', () => {
34+
const risk = normalizeForestFireRisk({
35+
items: [
36+
{ 지역: '광주광역시', 최대값: '52', 최소값: '8', 평균값: '34' },
37+
],
38+
totalCount: 1,
39+
}, '광주');
40+
41+
expect(risk?.sidoName).toBe('광주광역시');
42+
expect(risk?.value).toBe(52);
43+
expect(risk?.level).toBe('보통');
44+
});
45+
46+
it('aggregates multiple city district rows conservatively', () => {
47+
const risk = normalizeForestFireRisk({
48+
items: [
49+
{ region: '서울특별시 종로구', maxValue: '70', minValue: '10', avgValue: '40' },
50+
{ region: '서울특별시 강남구', maxValue: '88', minValue: '7', avgValue: '54' },
51+
],
52+
totalCount: 2,
53+
}, '서울');
54+
55+
expect(risk?.value).toBe(88);
56+
expect(risk?.min).toBe(7);
57+
expect(risk?.avg).toBe(47);
58+
expect(risk?.level).toBe('매우 높음');
59+
});
60+
});

0 commit comments

Comments
 (0)