502 lines
20 KiB
TypeScript
Executable File
502 lines
20 KiB
TypeScript
Executable File
import { useState, useMemo, useCallback } from 'react';
|
||
import { Map, Marker, useControl } from '@vis.gl/react-maplibre';
|
||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||
import type { Layer } from '@deck.gl/core';
|
||
import type { MapLayerMouseEvent } from 'maplibre-gl';
|
||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||
import { useMapStore } from '@common/store/mapStore';
|
||
import { WeatherRightPanel } from './WeatherRightPanel';
|
||
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay';
|
||
// import { OceanForecastOverlay } from './OceanForecastOverlay'
|
||
// import { useOceanCurrentLayers } from './OceanCurrentLayer'
|
||
import { useWaterTemperatureLayers } from './WaterTemperatureLayer';
|
||
import { WindParticleLayer } from './WindParticleLayer';
|
||
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer';
|
||
import { useWeatherData } from '../hooks/useWeatherData';
|
||
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
||
import { WeatherMapControls } from './WeatherMapControls';
|
||
import { degreesToCardinal } from '../services/weatherUtils';
|
||
|
||
type TimeOffset = '0' | '3' | '6' | '9';
|
||
|
||
interface WeatherStation {
|
||
id: string;
|
||
name: string;
|
||
location: { lat: number; lon: number };
|
||
wind: {
|
||
speed: number;
|
||
direction: number;
|
||
speed_1k: number;
|
||
speed_3k: number;
|
||
};
|
||
wave: {
|
||
height: number;
|
||
period: number;
|
||
};
|
||
temperature: {
|
||
current: number;
|
||
feelsLike: number;
|
||
};
|
||
pressure: number;
|
||
visibility: number;
|
||
salinity?: number;
|
||
}
|
||
|
||
interface WeatherForecast {
|
||
time: string;
|
||
hour: string;
|
||
icon: string;
|
||
temperature: number;
|
||
windSpeed: number;
|
||
}
|
||
|
||
// Base weather station locations
|
||
const BASE_STATIONS = [
|
||
{ id: 'incheon', name: '인천', location: { lat: 37.45, lon: 126.43 } },
|
||
{ id: 'ulsan', name: '울산', location: { lat: 35.52, lon: 129.38 } },
|
||
{ id: 'yeosu', name: '여수', location: { lat: 34.74, lon: 127.75 } },
|
||
{ id: 'jeju', name: '제주', location: { lat: 33.51, lon: 126.53 } },
|
||
{ id: 'pohang', name: '포항', location: { lat: 36.03, lon: 129.38 } },
|
||
{ id: 'mokpo', name: '목포', location: { lat: 34.78, lon: 126.38 } },
|
||
{ id: 'gunsan', name: '군산', location: { lat: 35.97, lon: 126.7 } },
|
||
{ id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } },
|
||
{ id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } },
|
||
{ id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } },
|
||
];
|
||
|
||
// Generate forecast data based on time offset
|
||
const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => {
|
||
const baseHour = parseInt(timeOffset);
|
||
const forecasts: WeatherForecast[] = [];
|
||
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️'];
|
||
|
||
for (let i = 0; i < 5; i++) {
|
||
const hour = baseHour + i * 3;
|
||
forecasts.push({
|
||
time: `+${hour}시`,
|
||
hour: `${hour}시`,
|
||
icon: icons[i % icons.length],
|
||
temperature: Math.floor(Math.random() * 5) + 5,
|
||
windSpeed: Math.floor(Math.random() * 5) + 6,
|
||
});
|
||
}
|
||
return forecasts;
|
||
};
|
||
|
||
// 한국 해역 중심 좌표 (한반도 중앙)
|
||
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat]
|
||
const WEATHER_MAP_ZOOM = 7;
|
||
|
||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||
overlay.setProps({ layers });
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역)
|
||
*/
|
||
interface WeatherMapInnerProps {
|
||
weatherStations: WeatherStation[];
|
||
enabledLayers: Set<string>;
|
||
selectedStationId: string | null;
|
||
onStationClick: (station: WeatherStation) => void;
|
||
mapCenter: [number, number];
|
||
mapZoom: number;
|
||
clickedLocation: { lat: number; lon: number } | null;
|
||
}
|
||
|
||
function WeatherMapInner({
|
||
weatherStations,
|
||
enabledLayers,
|
||
selectedStationId,
|
||
onStationClick,
|
||
mapCenter,
|
||
mapZoom,
|
||
clickedLocation,
|
||
}: WeatherMapInnerProps) {
|
||
// deck.gl layers 조합
|
||
const weatherDeckLayers = useWeatherDeckLayers(
|
||
weatherStations,
|
||
enabledLayers,
|
||
selectedStationId,
|
||
onStationClick,
|
||
);
|
||
// const oceanCurrentLayers = useOceanCurrentLayers({
|
||
// visible: enabledLayers.has('oceanCurrent'),
|
||
// opacity: 0.7,
|
||
// })
|
||
const waterTempLayers = useWaterTemperatureLayers({
|
||
visible: enabledLayers.has('waterTemperature'),
|
||
opacity: 0.5,
|
||
});
|
||
|
||
const deckLayers = useMemo(
|
||
() => [...waterTempLayers, ...weatherDeckLayers],
|
||
[waterTempLayers, weatherDeckLayers],
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{/* deck.gl 오버레이 */}
|
||
<DeckGLOverlay layers={deckLayers} />
|
||
|
||
{/* 해황예보도 — 임시 비활성화
|
||
<OceanForecastOverlay
|
||
forecast={selectedForecast}
|
||
opacity={oceanForecastOpacity}
|
||
visible={enabledLayers.has('oceanForecast')}
|
||
/> */}
|
||
|
||
{/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */}
|
||
<OceanCurrentParticleLayer visible={enabledLayers.has('oceanCurrentParticle')} />
|
||
|
||
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
|
||
<WeatherMapOverlay
|
||
stations={weatherStations}
|
||
enabledLayers={enabledLayers}
|
||
onStationClick={onStationClick}
|
||
selectedStationId={selectedStationId}
|
||
/>
|
||
|
||
{/* 바람 파티클 애니메이션 (Canvas 직접 조작) */}
|
||
<WindParticleLayer visible={enabledLayers.has('windParticle')} stations={weatherStations} />
|
||
|
||
{/* 클릭 위치 마커 */}
|
||
{clickedLocation && (
|
||
<Marker longitude={clickedLocation.lon} latitude={clickedLocation.lat} anchor="bottom">
|
||
<div className="flex flex-col items-center pointer-events-none">
|
||
{/* 펄스 링 */}
|
||
<div className="relative flex items-center justify-center">
|
||
<div className="absolute w-8 h-8 rounded-full border-2 border-color-accent animate-ping opacity-60" />
|
||
<div className="w-4 h-4 rounded-full bg-color-accent border-2 border-white shadow-lg" />
|
||
</div>
|
||
{/* 핀 꼬리 */}
|
||
<div className="w-px h-3 bg-color-accent" />
|
||
{/* 좌표 라벨 */}
|
||
<div className="px-2 py-1 bg-bg-base/90 border border-color-accent rounded text-caption text-color-accent whitespace-nowrap backdrop-blur-sm">
|
||
{clickedLocation.lat.toFixed(3)}°N {clickedLocation.lon.toFixed(3)}°E
|
||
</div>
|
||
</div>
|
||
</Marker>
|
||
)}
|
||
|
||
{/* 줌 컨트롤 */}
|
||
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function WeatherView() {
|
||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS);
|
||
const currentMapStyle = useBaseMapStyle();
|
||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||
|
||
// const {
|
||
// selectedForecast,
|
||
// availableTimes,
|
||
// loading: oceanLoading,
|
||
// error: oceanError,
|
||
// selectForecast,
|
||
// } = useOceanForecast('KOREA')
|
||
|
||
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0');
|
||
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null);
|
||
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
|
||
null,
|
||
);
|
||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']));
|
||
// const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
||
|
||
// 첫 관측소 자동 선택 (파생 값)
|
||
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null;
|
||
|
||
const handleStationClick = useCallback((station: WeatherStation) => {
|
||
setSelectedStation(station);
|
||
setSelectedLocation(null);
|
||
}, []);
|
||
|
||
const handleMapClick = useCallback(
|
||
(e: MapLayerMouseEvent) => {
|
||
const { lat, lng } = e.lngLat;
|
||
if (weatherStations.length === 0) return;
|
||
|
||
// 가장 가까운 관측소 선택
|
||
const nearestStation = weatherStations.reduce((nearest, station) => {
|
||
const distance = Math.sqrt(
|
||
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lng, 2),
|
||
);
|
||
const nearestDistance = Math.sqrt(
|
||
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2),
|
||
);
|
||
return distance < nearestDistance ? station : nearest;
|
||
}, weatherStations[0]);
|
||
|
||
setSelectedStation(nearestStation);
|
||
setSelectedLocation({ lat, lon: lng });
|
||
},
|
||
[weatherStations],
|
||
);
|
||
|
||
const toggleLayer = useCallback((layer: string) => {
|
||
setEnabledLayers((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(layer)) {
|
||
next.delete(layer);
|
||
} else {
|
||
next.add(layer);
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const weatherData = selectedStation
|
||
? {
|
||
stationName: selectedLocation
|
||
? `해양 지점 (${selectedLocation.lat.toFixed(2)}°N, ${selectedLocation.lon.toFixed(2)}°E)`
|
||
: selectedStation.name,
|
||
location: selectedLocation || selectedStation.location,
|
||
currentTime: new Date().toLocaleTimeString('ko-KR', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
}),
|
||
wind: {
|
||
...selectedStation.wind,
|
||
directionLabel: degreesToCardinal(selectedStation.wind.direction),
|
||
},
|
||
wave: {
|
||
...selectedStation.wave,
|
||
maxHeight: Number((selectedStation.wave.height * 1.6).toFixed(1)),
|
||
direction: degreesToCardinal(selectedStation.wind.direction + 45),
|
||
},
|
||
temperature: selectedStation.temperature,
|
||
pressure: selectedStation.pressure,
|
||
visibility: selectedStation.visibility,
|
||
salinity: selectedStation.salinity ?? 31.2,
|
||
astronomy: {
|
||
sunrise: '07:12',
|
||
sunset: '17:58',
|
||
moonrise: '19:35',
|
||
moonset: '01:50',
|
||
moonPhase: '상현달 14일',
|
||
tidalRange: 6.7,
|
||
},
|
||
alert: '풍랑주의보 예상 08:00~',
|
||
forecast: generateForecast(timeOffset),
|
||
}
|
||
: null;
|
||
|
||
return (
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* Main Map Area */}
|
||
<div className="flex-1 relative flex flex-col overflow-hidden">
|
||
{/* Tab Navigation */}
|
||
<div className="flex items-center border-b border-stroke bg-bg-surface shrink-0">
|
||
<div className="flex items-center gap-2 px-6">
|
||
{(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => (
|
||
<button
|
||
key={offset}
|
||
onClick={() => setTimeOffset(offset)}
|
||
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
|
||
timeOffset === offset
|
||
? 'bg-color-accent text-bg-0'
|
||
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||
}`}
|
||
>
|
||
{offset === '0' ? '현재' : `+${offset}시간`}
|
||
</button>
|
||
))}
|
||
|
||
<div className="flex items-center gap-2 ml-4">
|
||
<span className="text-xs text-fg-disabled">
|
||
{lastUpdate
|
||
? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})}`
|
||
: '데이터 로딩 중...'}
|
||
</span>
|
||
{loading && (
|
||
<div className="w-4 h-4 border-2 border-color-accent border-t-transparent rounded-full animate-spin" />
|
||
)}
|
||
{error && <span className="text-xs text-color-danger">⚠️ {error}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1" />
|
||
</div>
|
||
|
||
{/* Map */}
|
||
<div className="flex-1 relative">
|
||
<Map
|
||
initialViewState={{
|
||
longitude: WEATHER_MAP_CENTER[0],
|
||
latitude: WEATHER_MAP_CENTER[1],
|
||
zoom: WEATHER_MAP_ZOOM,
|
||
}}
|
||
mapStyle={currentMapStyle}
|
||
className="w-full h-full"
|
||
onClick={handleMapClick}
|
||
attributionControl={false}
|
||
>
|
||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||
<WeatherMapInner
|
||
weatherStations={weatherStations}
|
||
enabledLayers={enabledLayers}
|
||
selectedStationId={selectedStation?.id || null}
|
||
onStationClick={handleStationClick}
|
||
mapCenter={WEATHER_MAP_CENTER}
|
||
mapZoom={WEATHER_MAP_ZOOM}
|
||
clickedLocation={selectedLocation}
|
||
/>
|
||
</Map>
|
||
|
||
{/* 레이어 컨트롤 */}
|
||
<div
|
||
className="absolute top-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
|
||
style={{ padding: '6px 10px' }}
|
||
>
|
||
<div className="text-caption font-semibold text-fg mb-1.5 font-korean">기상 레이어</div>
|
||
<div className="flex flex-col gap-1">
|
||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('windParticle')}
|
||
onChange={() => toggleLayer('windParticle')}
|
||
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
|
||
/>
|
||
<span className="text-caption text-fg-sub">🌬️ 바람 흐름</span>
|
||
</label>
|
||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('wind')}
|
||
onChange={() => toggleLayer('wind')}
|
||
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
|
||
/>
|
||
<span className="text-caption text-fg-sub">🌬️ 바람 벡터</span>
|
||
</label>
|
||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('waves')}
|
||
onChange={() => toggleLayer('waves')}
|
||
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
|
||
/>
|
||
<span className="text-caption text-fg-sub">🌊 파고 분포</span>
|
||
</label>
|
||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('temperature')}
|
||
onChange={() => toggleLayer('temperature')}
|
||
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
|
||
/>
|
||
<span className="text-caption text-fg-sub">🌡️ 수온 분포</span>
|
||
</label>
|
||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('oceanCurrentParticle')}
|
||
onChange={() => toggleLayer('oceanCurrentParticle')}
|
||
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
|
||
/>
|
||
<span className="text-caption text-fg-sub">🌊 해류 흐름</span>
|
||
</label>
|
||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabledLayers.has('waterTemperature')}
|
||
onChange={() => toggleLayer('waterTemperature')}
|
||
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
|
||
/>
|
||
<span className="text-caption text-fg-sub">🌡️ 수온 색상도</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 범례 */}
|
||
<div
|
||
className="absolute bottom-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
|
||
style={{ padding: '6px 10px', maxWidth: 180 }}
|
||
>
|
||
<div className="text-caption font-semibold text-fg mb-1.5 font-korean">기상 범례</div>
|
||
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
|
||
{/* 바람 */}
|
||
<div>
|
||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
||
바람 (m/s)
|
||
</div>
|
||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
||
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#50d591' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#a5e23f' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#fae21e' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#faaa19' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#f05421' }} />
|
||
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
|
||
</div>
|
||
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
|
||
<span>3</span>
|
||
<span>5</span>
|
||
<span>7</span>
|
||
<span>10</span>
|
||
<span>13</span>
|
||
<span>16</span>
|
||
<span>20+</span>
|
||
</div>
|
||
</div>
|
||
{/* 해류 */}
|
||
<div className="pt-1 border-t border-stroke">
|
||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
||
해류 (m/s)
|
||
</div>
|
||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
||
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
|
||
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
|
||
<div className="flex-1 h-full" style={{ background: 'rgb(34, 197, 94)' }} />
|
||
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
|
||
</div>
|
||
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
|
||
<span>0.2</span>
|
||
<span>0.4</span>
|
||
<span>0.6</span>
|
||
<span>0.6+</span>
|
||
</div>
|
||
</div>
|
||
{/* 파고 */}
|
||
<div className="pt-1 border-t border-stroke">
|
||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
||
파고 (m)
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||
<span className="text-fg-disabled"><1.5 낮음</span>
|
||
<div className="w-2 h-2 rounded-full bg-orange-500 ml-1" />
|
||
<span className="text-fg-disabled">~2.5</span>
|
||
<div className="w-2 h-2 rounded-full bg-red-500 ml-1" />
|
||
<span className="text-fg-disabled">>2.5</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="mt-1 pt-1 border-t border-stroke text-fg-disabled font-korean"
|
||
style={{ fontSize: 7 }}
|
||
>
|
||
💡 지도 클릭 → 기상 예보 확인
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right Panel */}
|
||
<WeatherRightPanel weatherData={weatherData} />
|
||
</div>
|
||
);
|
||
}
|