|
| 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 | +} |
0 commit comments