wing-ops/frontend/src/tabs/weather/components/WeatherView.tsx
jeonghyo.k e32c630da5 chore(weather): feature/cctv-hns-enhancements 머지 충돌 해결
WeatherRightPanel.tsx 충돌을 HEAD(feature/report) 기준으로 해결:
- WindCompass/ProgressBar/StatCard 재사용 컴포넌트 유지
- w-[380px] 너비 및 여유 패딩(px-5) 유지
- astronomy/alert props 기반 동적 데이터 유지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 14:35:31 +09:00

518 lines
20 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 { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
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'
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
}
const CARDINAL_LABELS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] as const
function degreesToCardinal(deg: number): string {
const idx = Math.round(((deg % 360) + 360) % 360 / 22.5) % 16
return CARDINAL_LABELS[idx]
}
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
}
// CartoDB Dark Matter 스타일 (기존 WeatherView와 동일)
const WEATHER_MAP_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
],
tileSize: 256,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
},
},
layers: [
{
id: 'carto-dark-layer',
type: 'raster',
source: 'carto-dark',
minzoom: 0,
maxzoom: 22,
},
],
}
// 한국 해역 중심 좌표 (한반도 중앙)
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-primary-cyan animate-ping opacity-60" />
<div className="w-4 h-4 rounded-full bg-primary-cyan border-2 border-white shadow-lg" />
</div>
{/* 핀 꼬리 */}
<div className="w-px h-3 bg-primary-cyan" />
{/* 좌표 라벨 */}
<div className="px-2 py-1 bg-bg-0/90 border border-primary-cyan rounded text-[10px] text-primary-cyan 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 {
// 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-border bg-bg-1 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-primary-cyan text-bg-0'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
{offset === '0' ? '현재' : `+${offset}시간`}
</button>
))}
<div className="flex items-center gap-2 ml-4">
<span className="text-xs text-text-3">
{lastUpdate
? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
})}`
: '데이터 로딩 중...'}
</span>
{loading && (
<div className="w-4 h-4 border-2 border-primary-cyan border-t-transparent rounded-full animate-spin" />
)}
{error && <span className="text-xs text-status-red"> {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={WEATHER_MAP_STYLE}
className="w-full h-full"
onClick={handleMapClick}
attributionControl={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-1/85 border border-border rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px' }}>
<div className="text-[9px] font-semibold text-text-1 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-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
/>
<span className="text-[9px] text-text-2">🌬 </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-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
/>
<span className="text-[9px] text-text-2">🌬 </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-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
/>
<span className="text-[9px] text-text-2">🌊 </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-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
/>
<span className="text-[9px] text-text-2">🌡 </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-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
/>
<span className="text-[9px] text-text-2">🌊 </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-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
/>
<span className="text-[9px] text-text-2">🌡 </span>
</label>
</div>
</div>
{/* 범례 */}
<div className="absolute bottom-4 left-4 bg-bg-1/85 border border-border rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px', maxWidth: 180 }}>
<div className="text-[9px] font-semibold text-text-1 mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
{/* 바람 */}
<div>
<div className="font-semibold text-text-2 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-text-3" 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-border">
<div className="font-semibold text-text-2 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-text-3" 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-border">
<div className="font-semibold text-text-2 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-text-3">&lt;1.5 </span>
<div className="w-2 h-2 rounded-full bg-orange-500 ml-1" />
<span className="text-text-3">~2.5</span>
<div className="w-2 h-2 rounded-full bg-red-500 ml-1" />
<span className="text-text-3">&gt;2.5</span>
</div>
</div>
</div>
<div className="mt-1 pt-1 border-t border-border text-text-3 font-korean" style={{ fontSize: 7 }}>
💡
</div>
</div>
</div>
</div>
{/* Right Panel */}
<WeatherRightPanel weatherData={weatherData} />
</div>
)
}