401 lines
15 KiB
TypeScript
Executable File
401 lines
15 KiB
TypeScript
Executable File
interface WeatherData {
|
||
stationName: string;
|
||
location: { lat: number; lon: number };
|
||
currentTime: string;
|
||
wind: {
|
||
speed: number;
|
||
direction: number;
|
||
directionLabel: string;
|
||
speed_1k: number;
|
||
speed_3k: number;
|
||
};
|
||
wave: {
|
||
height: number;
|
||
maxHeight: number;
|
||
period: number;
|
||
direction: string;
|
||
};
|
||
temperature: {
|
||
current: number;
|
||
feelsLike: number;
|
||
};
|
||
pressure: number;
|
||
visibility: number;
|
||
salinity: number;
|
||
astronomy?: {
|
||
sunrise: string;
|
||
sunset: string;
|
||
moonrise: string;
|
||
moonset: string;
|
||
moonPhase: string;
|
||
tidalRange: number;
|
||
};
|
||
alert?: string;
|
||
forecast: WeatherForecast[];
|
||
}
|
||
|
||
interface WeatherForecast {
|
||
time: string;
|
||
hour: string;
|
||
icon: string;
|
||
temperature: number;
|
||
windSpeed: number;
|
||
}
|
||
|
||
interface WeatherRightPanelProps {
|
||
weatherData: WeatherData | null;
|
||
}
|
||
|
||
/** 풍속 등급 색상 */
|
||
function windColor(speed: number): string {
|
||
if (speed >= 14) return '#ef4444';
|
||
if (speed >= 10) return '#f97316';
|
||
if (speed >= 6) return '#eab308';
|
||
return '#22c55e';
|
||
}
|
||
|
||
/** 파고 등급 색상 */
|
||
function waveColor(height: number): string {
|
||
if (height >= 3) return '#ef4444';
|
||
if (height >= 2) return '#f97316';
|
||
if (height >= 1) return '#eab308';
|
||
return '#22c55e';
|
||
}
|
||
|
||
/** 풍향 텍스트 */
|
||
function windDirText(deg: number): string {
|
||
const dirs = [
|
||
'N',
|
||
'NNE',
|
||
'NE',
|
||
'ENE',
|
||
'E',
|
||
'ESE',
|
||
'SE',
|
||
'SSE',
|
||
'S',
|
||
'SSW',
|
||
'SW',
|
||
'WSW',
|
||
'W',
|
||
'WNW',
|
||
'NW',
|
||
'NNW',
|
||
];
|
||
return dirs[Math.round(deg / 22.5) % 16];
|
||
}
|
||
|
||
export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||
if (!weatherData) {
|
||
return (
|
||
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden w-[320px] shrink-0">
|
||
<div className="p-6 text-center">
|
||
<p className="text-fg-disabled text-[13px] font-korean">
|
||
지도에서 해양 지점을 클릭하세요
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const { wind, wave, temperature, pressure, visibility, salinity, astronomy, alert, forecast } =
|
||
weatherData;
|
||
const wSpd = wind.speed;
|
||
const wHgt = wave.height;
|
||
const wTemp = temperature.current;
|
||
const windDir = windDirText(wind.direction);
|
||
|
||
return (
|
||
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden w-[320px] shrink-0">
|
||
{/* 헤더 */}
|
||
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="text-[13px] font-bold text-color-accent font-korean">
|
||
📍 {weatherData.stationName}
|
||
</span>
|
||
<span className="px-1.5 py-px text-[11px] rounded bg-color-accent/15 text-color-accent font-bold">
|
||
기상예보관
|
||
</span>
|
||
</div>
|
||
<p className="text-[11px] text-fg-disabled font-mono">
|
||
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E ·{' '}
|
||
{weatherData.currentTime}
|
||
</p>
|
||
</div>
|
||
|
||
{/* 스크롤 콘텐츠 */}
|
||
<div
|
||
className="flex-1 overflow-auto"
|
||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
|
||
>
|
||
{/* ── 핵심 지표 3칸 카드 ── */}
|
||
<div className="grid grid-cols-3 gap-1 px-3 py-2.5">
|
||
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
|
||
<div className="text-[20px] font-bold font-mono" style={{ color: windColor(wSpd) }}>
|
||
{wSpd.toFixed(1)}
|
||
</div>
|
||
<div className="text-[11px] text-fg-disabled font-korean">풍속 (m/s)</div>
|
||
</div>
|
||
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
|
||
<div className="text-[20px] font-bold font-mono" style={{ color: waveColor(wHgt) }}>
|
||
{wHgt.toFixed(1)}
|
||
</div>
|
||
<div className="text-[11px] text-fg-disabled font-korean">파고 (m)</div>
|
||
</div>
|
||
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
|
||
<div className="text-[20px] font-bold font-mono text-color-accent">
|
||
{wTemp.toFixed(1)}
|
||
</div>
|
||
<div className="text-[11px] text-fg-disabled font-korean">수온 (°C)</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 바람 상세 ── */}
|
||
<div className="px-3 py-2 border-b border-stroke">
|
||
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
|
||
🌬️ 바람 현황
|
||
</div>
|
||
<div className="flex items-center gap-3 mb-2">
|
||
{/* 풍향 컴파스 */}
|
||
<div className="relative w-[50px] h-[50px] shrink-0">
|
||
<svg viewBox="0 0 50 50" className="w-full h-full">
|
||
<circle
|
||
cx="25"
|
||
cy="25"
|
||
r="22"
|
||
fill="none"
|
||
stroke="var(--stroke-default)"
|
||
strokeWidth="1"
|
||
/>
|
||
<circle
|
||
cx="25"
|
||
cy="25"
|
||
r="16"
|
||
fill="none"
|
||
stroke="var(--stroke-default)"
|
||
strokeWidth="0.5"
|
||
strokeDasharray="2 2"
|
||
/>
|
||
{['N', 'E', 'S', 'W'].map((d, i) => {
|
||
const angle = i * 90;
|
||
const rad = ((angle - 90) * Math.PI) / 180;
|
||
const x = 25 + 20 * Math.cos(rad);
|
||
const y = 25 + 20 * Math.sin(rad);
|
||
return (
|
||
<text
|
||
key={d}
|
||
x={x}
|
||
y={y}
|
||
textAnchor="middle"
|
||
dominantBaseline="central"
|
||
fill="var(--fg-disabled)"
|
||
fontSize="6"
|
||
fontWeight="bold"
|
||
>
|
||
{d}
|
||
</text>
|
||
);
|
||
})}
|
||
{/* 풍향 화살표 */}
|
||
<line
|
||
x1="25"
|
||
y1="25"
|
||
x2={25 + 14 * Math.sin((wind.direction * Math.PI) / 180)}
|
||
y2={25 - 14 * Math.cos((wind.direction * Math.PI) / 180)}
|
||
stroke={windColor(wSpd)}
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
/>
|
||
<circle cx="25" cy="25" r="3" fill={windColor(wSpd)} />
|
||
</svg>
|
||
</div>
|
||
<div className="flex-1 grid grid-cols-2 gap-x-3 gap-y-1.5 text-[11px]">
|
||
<div className="flex justify-between">
|
||
<span className="text-fg-disabled">풍향</span>
|
||
<span className="text-fg font-mono font-bold text-[13px]">
|
||
{windDir} {wind.direction}°
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-fg-disabled">기압</span>
|
||
<span className="text-fg font-mono text-[13px]">{pressure} hPa</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-fg-disabled">1k 최고</span>
|
||
<span className="font-mono text-[13px]" style={{ color: windColor(wind.speed_1k) }}>
|
||
{Number(wind.speed_1k).toFixed(1)}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-fg-disabled">3k 평균</span>
|
||
<span className="font-mono text-[13px]" style={{ color: windColor(wind.speed_3k) }}>
|
||
{Number(wind.speed_3k).toFixed(1)}
|
||
</span>
|
||
</div>
|
||
<div className="col-span-2 flex justify-between">
|
||
<span className="text-fg-disabled">가시거리</span>
|
||
<span className="text-fg font-mono text-[13px]">{visibility} km</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 풍속 게이지 바 */}
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 h-[5px] bg-bg-card rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full transition-all"
|
||
style={{
|
||
width: `${Math.min((wSpd / 20) * 100, 100)}%`,
|
||
background: windColor(wSpd),
|
||
}}
|
||
/>
|
||
</div>
|
||
<span className="text-[11px] font-mono text-fg-disabled shrink-0">
|
||
{wSpd.toFixed(1)}/20
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 파도 상세 ── */}
|
||
<div className="px-3 py-2 border-b border-stroke">
|
||
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">🌊 파도</div>
|
||
<div className="grid grid-cols-4 gap-1">
|
||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||
<div className="text-[14px] font-bold font-mono" style={{ color: waveColor(wHgt) }}>
|
||
{wHgt.toFixed(1)}m
|
||
</div>
|
||
<div className="text-[10px] text-fg-disabled">유의파고</div>
|
||
</div>
|
||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||
<div className="text-[14px] font-bold font-mono text-color-danger">
|
||
{wave.maxHeight.toFixed(1)}m
|
||
</div>
|
||
<div className="text-[10px] text-fg-disabled">최고파고</div>
|
||
</div>
|
||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||
<div className="text-[14px] font-bold font-mono text-color-accent">
|
||
{wave.period}s
|
||
</div>
|
||
<div className="text-[10px] text-fg-disabled">주기</div>
|
||
</div>
|
||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||
<div className="text-[14px] font-bold font-mono text-fg">{wave.direction}</div>
|
||
<div className="text-[10px] text-fg-disabled">파향</div>
|
||
</div>
|
||
</div>
|
||
{/* 파고 게이지 바 */}
|
||
<div className="flex items-center gap-2 mt-1.5">
|
||
<div className="flex-1 h-[5px] bg-bg-card rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full transition-all"
|
||
style={{
|
||
width: `${Math.min((wHgt / 5) * 100, 100)}%`,
|
||
background: waveColor(wHgt),
|
||
}}
|
||
/>
|
||
</div>
|
||
<span className="text-[11px] font-mono text-fg-disabled shrink-0">
|
||
{wHgt.toFixed(1)}/5m
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 수온/공기 ── */}
|
||
<div className="px-3 py-2 border-b border-stroke">
|
||
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
|
||
🌡️ 수온 · 공기
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-1">
|
||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||
<div className="text-[14px] font-bold font-mono text-color-accent">
|
||
{wTemp.toFixed(1)}°
|
||
</div>
|
||
<div className="text-[10px] text-fg-disabled">수온</div>
|
||
</div>
|
||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||
<div className="text-[14px] font-bold font-mono text-fg">
|
||
{temperature.feelsLike.toFixed(1)}°
|
||
</div>
|
||
<div className="text-[10px] text-fg-disabled">기온</div>
|
||
</div>
|
||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||
<div className="text-[14px] font-bold font-mono text-fg">{salinity.toFixed(1)}</div>
|
||
<div className="text-[10px] text-fg-disabled">염분(PSU)</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 시간별 예보 ── */}
|
||
<div className="px-3 py-2 border-b border-stroke">
|
||
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
|
||
⏰ 시간별 예보
|
||
</div>
|
||
<div className="grid grid-cols-5 gap-1">
|
||
{forecast.map((f, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex flex-col items-center py-2 px-1 bg-bg-base border border-stroke rounded"
|
||
>
|
||
<span className="text-[11px] text-fg-disabled mb-0.5">{f.hour}</span>
|
||
<span className="text-lg mb-0.5">{f.icon}</span>
|
||
<span className="text-[13px] font-bold text-fg">{f.temperature}°</span>
|
||
<span className="text-[11px] text-fg-disabled font-mono">{f.windSpeed}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 천문/조석 ── */}
|
||
{astronomy && (
|
||
<div className="px-3 py-2 border-b border-stroke">
|
||
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
|
||
☀️ 천문 · 조석
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-1">
|
||
{[
|
||
{ icon: '🌅', label: '일출', value: astronomy.sunrise },
|
||
{ icon: '🌄', label: '일몰', value: astronomy.sunset },
|
||
{ icon: '🌙', label: '월출', value: astronomy.moonrise },
|
||
{ icon: '🌜', label: '월몰', value: astronomy.moonset },
|
||
].map((item, i) => (
|
||
<div key={i} className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||
<div className="text-base mb-0.5">{item.icon}</div>
|
||
<div className="text-[10px] text-fg-disabled">{item.label}</div>
|
||
<div className="text-[13px] font-bold font-mono text-fg">{item.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-2 mt-1.5 px-2 py-1 bg-bg-base border border-stroke rounded text-[11px]">
|
||
<span className="text-sm">🌓</span>
|
||
<span className="text-fg-disabled">{astronomy.moonPhase}</span>
|
||
<span className="ml-auto text-fg font-mono">조차 {astronomy.tidalRange}m</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 날씨 특보 ── */}
|
||
{alert && (
|
||
<div className="px-3 py-2">
|
||
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
|
||
🚨 날씨 특보
|
||
</div>
|
||
<div
|
||
className="px-2.5 py-2 rounded border"
|
||
style={{ background: 'rgba(239,68,68,.06)', borderColor: 'rgba(239,68,68,.2)' }}
|
||
>
|
||
<div className="flex items-center gap-2 text-[11px]">
|
||
<span
|
||
className="px-1.5 py-px rounded text-[11px] font-bold"
|
||
style={{ background: 'rgba(239,68,68,.15)', color: 'var(--color-danger)' }}
|
||
>
|
||
주의
|
||
</span>
|
||
<span className="text-fg font-korean">{alert}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|