wing-ops/frontend/src/tabs/weather/components/WeatherView.tsx
jeonghyo.k a86188f473 feat(map): 전체 탭 지도 배경 토글 통합 및 기본지도 변경
- 지도 스타일 상수를 mapStyles.ts로 추출
- useBaseMapStyle 훅 생성 (mapToggles 기반 스타일 반환)
- 9개 탭 컴포넌트의 하드코딩 스타일을 공유 훅으로 교체
- 각 Map에 S57EncOverlay 추가
- 초기 mapToggles를 모두 false로 변경 (기본지도 표시)
2026-03-31 17:56:40 +09:00

491 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 컨트롤로 등록)
// 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-[10px] text-color-accent whitespace-nowrap backdrop-blur-sm">
{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">
<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-[9px] 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-[9px] 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-[9px] 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-[9px] 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-[9px] 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-[9px] 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-[9px] 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-[9px] 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">&lt;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">&gt;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>
)
}