- 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>
480 lines
20 KiB
TypeScript
Executable File
480 lines
20 KiB
TypeScript
Executable File
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='© <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">< 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">> 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>
|
||
)
|
||
}
|