wing-ops/frontend/src/tabs/weather/components/WeatherView.tsx
htlee f099ff29b1 refactor(frontend): 탭 단위 패키지 구조 전환 (tabs/)
- 11개 탭 디렉토리 생성: tabs/{prediction,hns,rescue,weather,incidents,aerial,board,reports,assets,scat,admin}/
- 51개 컴포넌트를 역할 기반(views/, analysis/, layout/) → 탭 기반(tabs/) 구조로 이동
- weather 탭에 전용 hooks/, services/ 포함
- incidents 탭에 전용 services/ 포함
- 공통 지도 컴포넌트(MapView, BacktrackReplay)를 common/components/map/으로 이동
- 각 탭에 index.ts 생성하여 View 컴포넌트 re-export
- App.tsx import를 @tabs/ alias 사용으로 변경
- 전체 import 경로 수정 (탭 내부 상대경로, 외부 @common/ alias)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:08:34 +09:00

480 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, useEffect } from 'react'
import { MapContainer, TileLayer, useMapEvents } from 'react-leaflet'
import type { LatLngExpression } from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { WeatherRightPanel } from './WeatherRightPanel'
import { WeatherMapOverlay } from './WeatherMapOverlay'
import { OceanForecastOverlay } from './OceanForecastOverlay'
import { OceanCurrentLayer } from './OceanCurrentLayer'
import { WaterTemperatureLayer } from './WaterTemperatureLayer'
import { WindParticleLayer } from './WindParticleLayer'
import { useWeatherData } from '../hooks/useWeatherData'
import { useOceanForecast } from '../hooks/useOceanForecast'
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
}
interface WeatherForecast {
time: string
hour: string
icon: string
temperature: number
windSpeed: number
}
// Base weather station locations (weather data will be fetched from API)
const baseStations = [
{ 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[] = []
for (let i = 0; i < 5; i++) {
const hour = baseHour + i * 3
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️']
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
}
// Map click handler component
function MapClickHandler({ onMapClick }: { onMapClick: (lat: number, lon: number) => void }) {
useMapEvents({
click(e) {
onMapClick(e.latlng.lat, e.latlng.lng)
}
})
return null
}
export function WeatherView() {
// Fetch real-time weather data from API
const { weatherStations, loading, error, lastUpdate } = useWeatherData(baseStations)
// Fetch ocean forecast data from KHOA API
const {
selectedForecast,
availableTimes,
loading: oceanLoading,
error: oceanError,
selectForecast
} = useOceanForecast('KOREA')
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0')
const [selectedStation, setSelectedStation] = useState<WeatherStation | null>(null)
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
null
)
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(
new Set(['wind', 'labels'])
)
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
// Set initial selected station when data loads
useEffect(() => {
if (weatherStations.length > 0 && !selectedStation) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelectedStation(weatherStations[0])
}
}, [weatherStations, selectedStation])
const mapCenter: LatLngExpression = [36.5, 127.8]
const handleStationClick = (station: WeatherStation) => {
setSelectedStation(station)
setSelectedLocation(null)
}
const handleMapClick = (lat: number, lon: number) => {
// Find nearest station
const nearestStation = weatherStations.reduce((nearest, station) => {
const distance = Math.sqrt(
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lon, 2)
)
const nearestDistance = Math.sqrt(
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lon, 2)
)
return distance < nearestDistance ? station : nearest
}, weatherStations[0])
setSelectedStation(nearestStation)
setSelectedLocation({ lat, lon })
}
const toggleLayer = (layer: string) => {
const newLayers = new Set(enabledLayers)
if (newLayers.has(layer)) {
newLayers.delete(layer)
} else {
newLayers.add(layer)
}
setEnabledLayers(newLayers)
}
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,
wave: selectedStation.wave,
temperature: selectedStation.temperature,
pressure: selectedStation.pressure,
visibility: selectedStation.visibility,
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" style={{ flexShrink: 0 }}>
<div className="flex items-center gap-2 px-6">
<button
onClick={() => setTimeOffset('0')}
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
timeOffset === '0'
? 'bg-primary-cyan text-bg-0'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
</button>
<button
onClick={() => setTimeOffset('3')}
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
timeOffset === '3'
? 'bg-primary-cyan text-bg-0'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
+3
</button>
<button
onClick={() => setTimeOffset('6')}
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
timeOffset === '6'
? 'bg-primary-cyan text-bg-0'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
+6
</button>
<button
onClick={() => setTimeOffset('9')}
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
timeOffset === '9'
? 'bg-primary-cyan text-bg-0'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
+9
</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 className="px-6">
<button className="px-4 py-2 text-xs font-semibold rounded bg-status-red text-white hover:opacity-90 transition-opacity">
🚨
</button>
</div>
</div>
{/* Map */}
<div className="flex-1 relative">
<MapContainer
center={mapCenter}
zoom={7}
style={{ height: '100%', width: '100%', background: '#0a0e1a' }}
zoomControl={true}
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/>
<MapClickHandler onMapClick={handleMapClick} />
{/* Weather Overlay */}
<WeatherMapOverlay
stations={weatherStations}
enabledLayers={enabledLayers}
onStationClick={handleStationClick}
selectedStationId={selectedStation?.id || null}
/>
{/* Ocean Forecast Overlay */}
<OceanForecastOverlay
forecast={selectedForecast}
opacity={oceanForecastOpacity}
visible={enabledLayers.has('oceanForecast')}
/>
{/* Ocean Current Arrows */}
<OceanCurrentLayer visible={enabledLayers.has('oceanCurrent')} opacity={0.7} />
{/* Water Temperature Heatmap */}
<WaterTemperatureLayer
visible={enabledLayers.has('waterTemperature')}
opacity={0.5}
/>
{/* Windy-style Wind Particle Animation */}
<WindParticleLayer
visible={enabledLayers.has('windParticle')}
stations={weatherStations}
/>
</MapContainer>
{/* Layer Controls */}
<div className="absolute top-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm">
<div className="text-sm font-semibold text-text-1 mb-3"> </div>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('windParticle')}
onChange={() => toggleLayer('windParticle')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
/>
<span className="text-xs text-text-2">🌬 </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('wind')}
onChange={() => toggleLayer('wind')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
/>
<span className="text-xs text-text-2">🌬 </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('labels')}
onChange={() => toggleLayer('labels')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
/>
<span className="text-xs text-text-2">📊 </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('waves')}
onChange={() => toggleLayer('waves')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
/>
<span className="text-xs text-text-2">🌊 </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('temperature')}
onChange={() => toggleLayer('temperature')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
/>
<span className="text-xs text-text-2">🌡 </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('oceanCurrent')}
onChange={() => toggleLayer('oceanCurrent')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
/>
<span className="text-xs text-text-2">🌊 </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('waterTemperature')}
onChange={() => toggleLayer('waterTemperature')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
/>
<span className="text-xs text-text-2">🌡 </span>
</label>
{/* 해황예보도 레이어 */}
<div className="pt-2 mt-2 border-t border-border">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('oceanForecast')}
onChange={() => toggleLayer('oceanForecast')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
/>
<span className="text-xs text-text-2">🌊 </span>
</label>
{/* 투명도 조절 슬라이더 */}
{enabledLayers.has('oceanForecast') && (
<div className="mt-2 ml-6 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-text-3">:</span>
<input
type="range"
min="0"
max="100"
value={oceanForecastOpacity * 100}
onChange={(e) => setOceanForecastOpacity(Number(e.target.value) / 100)}
className="flex-1 h-1 bg-bg-3 rounded-lg appearance-none cursor-pointer"
/>
<span className="text-xs text-text-3 w-8">{Math.round(oceanForecastOpacity * 100)}%</span>
</div>
{/* 예보 시간 선택 */}
{availableTimes.length > 0 && (
<div className="space-y-1">
<div className="text-xs text-text-3"> :</div>
<div className="max-h-32 overflow-y-auto space-y-1">
{availableTimes.map((time) => (
<button
key={`${time.day}-${time.hour}`}
onClick={() => selectForecast(time.day, time.hour)}
className={`w-full px-2 py-1 text-xs rounded transition-colors ${
selectedForecast?.day === time.day && selectedForecast?.hour === time.hour
? 'bg-primary-cyan text-bg-0 font-semibold'
: 'bg-bg-2 text-text-3 hover:bg-bg-3'
}`}
>
{time.label}
</button>
))}
</div>
</div>
)}
{oceanLoading && (
<div className="text-xs text-text-3"> ...</div>
)}
{oceanError && (
<div className="text-xs text-status-red"> </div>
)}
{selectedForecast && (
<div className="text-xs text-text-3 pt-2 border-t border-border">
: {selectedForecast.name} {selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)} {selectedForecast.hour}:00
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Legend */}
<div className="absolute bottom-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm">
<div className="text-sm font-semibold text-text-1 mb-3"> </div>
<div className="space-y-3 text-xs">
{/* Wind Speed - Windy style */}
<div>
<div className="font-semibold text-text-2 mb-1"> (m/s)</div>
<div className="flex items-center gap-1 h-3 rounded-sm overflow-hidden mb-1">
<div className="flex-1 h-full" style={{ background: '#6271b7' }}></div>
<div className="flex-1 h-full" style={{ background: '#39a0f6' }}></div>
<div className="flex-1 h-full" style={{ background: '#50d591' }}></div>
<div className="flex-1 h-full" style={{ background: '#a5e23f' }}></div>
<div className="flex-1 h-full" style={{ background: '#fae21e' }}></div>
<div className="flex-1 h-full" style={{ background: '#faaa19' }}></div>
<div className="flex-1 h-full" style={{ background: '#f05421' }}></div>
<div className="flex-1 h-full" style={{ background: '#b41e46' }}></div>
</div>
<div className="flex justify-between text-text-3" style={{ fontSize: '9px' }}>
<span>3</span><span>5</span><span>7</span><span>10</span><span>13</span><span>16</span><span>20+</span>
</div>
</div>
{/* Wave Height */}
<div className="pt-2 border-t border-border">
<div className="font-semibold text-text-2 mb-1"> (m)</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
<span className="text-text-3">&lt; 1.5: 낮음</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-orange-500"></div>
<span className="text-text-3">1.5-2.5: 보통</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<span className="text-text-3">&gt; 2.5: 높음</span>
</div>
</div>
</div>
<div className="mt-3 pt-3 border-t border-border text-xs text-text-3">
💡
</div>
</div>
</div>
</div>
{/* Right Panel - Weather Details */}
<WeatherRightPanel weatherData={weatherData} />
</div>
)
}