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>
119 lines
4.2 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|