wing-ops/frontend/src/tabs/weather/components/WeatherView.tsx

486 lines
19 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 컨트롤로 등록)
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 border border-color-accent rounded text-caption text-color-accent whitespace-nowrap shadow-md">
{clickedLocation.lat.toFixed(3)}°N&nbsp;{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 pt-2 pb-2">
<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-caption 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-caption 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-caption 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 border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5">
<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 border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5 max-w-[180px]">
<div className="text-caption text-fg mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1.5 text-[8px]">
{/* 바람 */}
<div>
<div className="text-fg-sub mb-0.5"> (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 text-[7px]">
<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="text-fg-sub mb-0.5"> (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 text-[7px]">
<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="text-fg-sub mb-0.5"> (m)</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-color-info" />
<span className="text-fg-disabled">&lt;1.5 </span>
<div className="w-2 h-2 rounded-full bg-color-warning ml-1" />
<span className="text-fg-disabled">~2.5</span>
<div className="w-2 h-2 rounded-full bg-color-danger ml-1" />
<span className="text-fg-disabled">&gt;2.5</span>
</div>
</div>
</div>
<div className="mt-1 pt-1 border-t border-stroke text-fg-disabled text-[7px] font-korean">
💡
</div>
</div>
</div>
</div>
{/* Right Panel */}
<WeatherRightPanel weatherData={weatherData} />
</div>
);
}