gc-wing/apps/web/src/widgets/weatherPanel/WeatherPanel.tsx
htlee cb6493b8a1 feat(weather): 기상 타일 오버레이 + 수역별 날씨 패널
MapTiler Weather SDK 6종 기상 타일 오버레이:
- 바람/기온/강수/기압/레이더/구름 라디오 토글
- 3시간 단위 step 스냅 타임라인 + 드래그 실시간 seek
- 색상 범례, 배속 제어, 투명도 조절
- ServiceWorker 타일 캐시 (cache-first, 최대 2000장)
- SDK 시간 단위(epoch 초) 정합성 보장

Open-Meteo 수역별 기상 패널:
- 4개 수역 centroid 기반 해양/기상 데이터 5분 폴링
- 파고/풍속/수온/너울 카드 UI + 경고 하이라이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:45:07 +09:00

119 lines
4.2 KiB
TypeScript

import { useState } from 'react';
import type { WeatherSnapshot } from '../../entities/weather/model/types';
import {
getWindDirectionLabel,
getWindArrow,
getWaveSeverity,
getWeatherLabel,
} from '../../entities/weather/lib/weatherUtils';
interface WeatherPanelProps {
snapshot: WeatherSnapshot | null;
isLoading: boolean;
error: string | null;
onRefresh: () => void;
}
function fmtTime(ts: number): string {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function fmtVal(v: number | null, unit: string, decimals = 1): string {
if (v == null) return '-';
return `${v.toFixed(decimals)}${unit}`;
}
export function WeatherPanel({ snapshot, isLoading, error, onRefresh }: WeatherPanelProps) {
const [open, setOpen] = useState(false);
return (
<>
<button
className={`weather-gear${open ? ' open' : ''}`}
onClick={() => setOpen((p) => !p)}
title="해양 기상 현황"
type="button"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12h2M6 8c0-2.2 1.8-4 4-4 1.5 0 2.8.8 3.5 2h.5c1.7 0 3 1.3 3 3s-1.3 3-3 3H6c-2.2 0-4-1.8-4-4z" />
<path d="M2 20h2M8 16a3 3 0 0 1 3-3h2a3 3 0 0 1 3 3 3 3 0 0 1-3 3H8a3 3 0 0 1 0-6z" />
</svg>
</button>
{open && (
<div className="weather-panel">
<div className="wp-header">
<span className="wp-title"> </span>
{isLoading && <span className="wp-loading">...</span>}
</div>
{error && <div className="wp-error">{error}</div>}
{snapshot && snapshot.points.map((pt) => {
const severity = getWaveSeverity(pt.waveHeight);
const isWarn = severity === 'rough' || severity === 'severe'
|| (pt.windSpeed != null && pt.windSpeed >= 10);
return (
<div
key={pt.label}
className={`wz-card${isWarn ? ' wz-warn' : ''}`}
style={{ borderLeftColor: pt.color }}
>
<div className="wz-name">{pt.label}</div>
<div className="wz-row">
<span className="wz-item">
<span className="wz-icon">~</span>
<span className="wz-value">{fmtVal(pt.waveHeight, 'm')}</span>
</span>
<span className="wz-item">
<span className="wz-icon">{getWindArrow(pt.windDirection)}</span>
<span className="wz-value">
{fmtVal(pt.windSpeed, 'm/s')} {getWindDirectionLabel(pt.windDirection)}
</span>
</span>
</div>
<div className="wz-row">
<span className="wz-item">
<span className="wz-label"></span>
<span className="wz-value">{fmtVal(pt.seaSurfaceTemp, '°C')}</span>
</span>
<span className="wz-item">
<span className="wz-label"></span>
<span className="wz-value">{fmtVal(pt.swellHeight, 'm')}</span>
</span>
{pt.weatherCode != null && (
<span className="wz-item">
<span className="wz-value wz-weather">{getWeatherLabel(pt.weatherCode)}</span>
</span>
)}
</div>
</div>
);
})}
{!snapshot && !isLoading && !error && (
<div className="wp-empty"> </div>
)}
<div className="wp-footer">
{snapshot && (
<span className="wp-time"> {fmtTime(snapshot.fetchedAt)}</span>
)}
<button
className="wp-refresh"
type="button"
onClick={onRefresh}
disabled={isLoading}
title="새로고침"
>
</button>
</div>
</div>
)}
</>
);
}