develop #48

병합
htlee develop 에서 main 로 3 commits 를 머지했습니다 2026-03-01 03:00:52 +09:00
16개의 변경된 파일4998개의 추가작업 그리고 2040개의 파일을 삭제

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -10,6 +10,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@deck.gl/aggregation-layers": "^9.2.10",
"@deck.gl/core": "^9.2.10",
"@deck.gl/geo-layers": "^9.2.10",
"@deck.gl/layers": "^9.2.10",
"@deck.gl/mapbox": "^9.2.10",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@ -17,19 +22,18 @@
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@react-oauth/google": "^0.13.4", "@react-oauth/google": "^0.13.4",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@vis.gl/react-maplibre": "^8.1.0",
"axios": "^1.13.5", "axios": "^1.13.5",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"maplibre-gl": "^5.19.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

파일 보기

@ -1,15 +1,6 @@
import { useMemo } from 'react' import { ScatterplotLayer, PathLayer } from '@deck.gl/layers'
import { Polyline, CircleMarker, Circle, Marker, Popup } from 'react-leaflet'
import L from 'leaflet'
import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack' import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack'
import { hexToRgba } from './mapUtils'
interface BacktrackReplayOverlayProps {
replayShips: ReplayShip[]
collisionEvent: CollisionEvent | null
replayFrame: number
totalFrames: number
incidentCoord: { lat: number; lon: number }
}
function getInterpolatedPosition( function getInterpolatedPosition(
path: ReplayPathPoint[], path: ReplayPathPoint[],
@ -27,129 +18,132 @@ function getInterpolatedPosition(
} }
} }
export function BacktrackReplayOverlay({ interface BacktrackReplayParams {
replayShips, replayShips: ReplayShip[]
collisionEvent, collisionEvent: CollisionEvent | null
replayFrame, replayFrame: number
totalFrames, totalFrames: number
incidentCoord, incidentCoord: { lat: number; lon: number }
}: BacktrackReplayOverlayProps) { }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createBacktrackLayers(params: BacktrackReplayParams): any[] {
const { replayShips, collisionEvent, replayFrame, totalFrames, incidentCoord } = params
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layers: any[] = []
const progress = replayFrame / totalFrames const progress = replayFrame / totalFrames
// Ship icons using DivIcon // Per-ship track lines + waypoints + ship position
const shipIcons = useMemo(() => { const allTrackData: Array<{ path: [number, number][]; color: [number, number, number, number] }> = []
return replayShips.map((ship) => const allWaypoints: Array<{ position: [number, number]; color: [number, number, number, number] }> = []
L.divIcon({ const allShipPositions: Array<{ position: [number, number]; color: [number, number, number, number]; name: string }> = []
className: 'bt-ship-marker',
html: `
<div class="bt-ship-icon" style="border-color:${ship.color};background:rgba(10,14,26,0.85)">🚢</div>
`,
iconSize: [26, 26],
iconAnchor: [13, 13],
})
)
}, [replayShips])
replayShips.forEach((ship) => {
const pos = getInterpolatedPosition(ship.path, replayFrame, totalFrames)
const trackPath: [number, number][] = ship.path
.slice(0, pos.segmentIndex + 2)
.map((p, i, arr) => {
if (i === arr.length - 1) return [pos.lon, pos.lat]
return [p.lon, p.lat]
})
const rgba = hexToRgba(ship.color, 180)
allTrackData.push({ path: trackPath, color: rgba })
ship.path.slice(0, pos.segmentIndex + 1).forEach((p) => {
allWaypoints.push({ position: [p.lon, p.lat], color: hexToRgba(ship.color, 130) })
})
allShipPositions.push({
position: [pos.lon, pos.lat],
color: hexToRgba(ship.color),
name: ship.vesselName,
})
})
// Track lines
layers.push(
new PathLayer({
id: 'bt-tracks',
data: allTrackData,
getPath: (d: (typeof allTrackData)[0]) => d.path,
getColor: (d: (typeof allTrackData)[0]) => d.color,
getWidth: 2,
getDashArray: [6, 4],
dashJustified: true,
widthMinPixels: 2,
extensions: [],
})
)
// Waypoint dots
layers.push(
new ScatterplotLayer({
id: 'bt-waypoints',
data: allWaypoints,
getPosition: (d: (typeof allWaypoints)[0]) => d.position,
getRadius: 3,
getFillColor: (d: (typeof allWaypoints)[0]) => d.color,
radiusMinPixels: 3,
radiusMaxPixels: 5,
})
)
// Ship position markers
layers.push(
new ScatterplotLayer({
id: 'bt-ships',
data: allShipPositions,
getPosition: (d: (typeof allShipPositions)[0]) => d.position,
getRadius: 8,
getFillColor: (d: (typeof allShipPositions)[0]) => d.color,
getLineColor: [255, 255, 255, 200],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 8,
radiusMaxPixels: 14,
pickable: true,
})
)
// Collision point
const collisionProgress = collisionEvent ? collisionEvent.progressPercent / 100 : 0.75 const collisionProgress = collisionEvent ? collisionEvent.progressPercent / 100 : 0.75
const showCollision = progress >= collisionProgress const showCollision = progress >= collisionProgress
const spillSize = showCollision ? Math.min(500, (progress - collisionProgress) / (1 - collisionProgress) * 500) : 0
return ( if (showCollision && collisionEvent) {
<> layers.push(
{replayShips.map((ship, shipIdx) => { new ScatterplotLayer({
const pos = getInterpolatedPosition(ship.path, replayFrame, totalFrames) id: 'bt-collision',
const trackUpToCurrent = ship.path.slice(0, pos.segmentIndex + 2).map( data: [{ position: [collisionEvent.position.lon, collisionEvent.position.lat] }],
(p, i, arr) => { getPosition: (d: { position: [number, number] }) => d.position,
if (i === arr.length - 1) return [pos.lat, pos.lon] as [number, number] getRadius: 12,
return [p.lat, p.lon] as [number, number] getFillColor: [239, 68, 68, 80],
} getLineColor: [239, 68, 68, 200],
) getLineWidth: 2,
stroked: true,
radiusMinPixels: 12,
pickable: true,
})
)
return ( // Oil spill expansion
<div key={ship.vesselName}> const spillSize = Math.min(500, ((progress - collisionProgress) / (1 - collisionProgress)) * 500)
{/* Track line (dashed) */} if (spillSize > 0) {
<Polyline layers.push(
positions={trackUpToCurrent} new ScatterplotLayer({
pathOptions={{ id: 'bt-spill',
color: ship.color, data: [{ position: [incidentCoord.lon, incidentCoord.lat], radius: spillSize }],
weight: 2, getPosition: (d: { position: [number, number] }) => d.position,
dashArray: '6, 4', getRadius: (d: { radius: number }) => d.radius,
opacity: 0.7, getFillColor: [249, 115, 22, 50],
}} getLineColor: [249, 115, 22, 100],
/> getLineWidth: 1,
stroked: true,
radiusUnits: 'meters' as const,
})
)
}
}
{/* Waypoint dots */} return layers
{ship.path.slice(0, pos.segmentIndex + 1).map((p, i) => (
<CircleMarker
key={`wp-${shipIdx}-${i}`}
center={[p.lat, p.lon]}
radius={3}
pathOptions={{
fillColor: ship.color,
fillOpacity: 0.5,
color: ship.color,
weight: 1,
opacity: 0.6,
}}
/>
))}
{/* Ship marker at current position */}
<Marker
position={[pos.lat, pos.lon]}
icon={shipIcons[shipIdx]}
>
<Popup>
<div style={{ fontFamily: 'var(--fK)', minWidth: '120px' }}>
<strong style={{ color: ship.color }}>{ship.vesselName}</strong>
<br />
<span style={{ fontSize: '11px' }}>
{ship.speedLabels[Math.min(pos.segmentIndex, ship.speedLabels.length - 1)]}
</span>
</div>
</Popup>
</Marker>
</div>
)
})}
{/* Collision point */}
{showCollision && collisionEvent && (
<>
<CircleMarker
center={[collisionEvent.position.lat, collisionEvent.position.lon]}
radius={12}
pathOptions={{
fillColor: '#ef4444',
fillOpacity: 0.3,
color: '#ef4444',
weight: 2,
opacity: 0.8,
}}
>
<Popup>
<div style={{ fontFamily: 'var(--fK)', textAlign: 'center' }}>
<strong style={{ color: '#ef4444' }}>💥 {collisionEvent.timeLabel}</strong>
</div>
</Popup>
</CircleMarker>
{/* Oil spill expansion */}
{spillSize > 0 && (
<Circle
center={[incidentCoord.lat, incidentCoord.lon]}
radius={spillSize}
pathOptions={{
fillColor: '#f97316',
fillOpacity: 0.2,
color: '#f97316',
weight: 1,
opacity: 0.4,
}}
/>
)}
</>
)}
</>
)
} }

파일 보기

@ -1,38 +1,56 @@
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo, useEffect, useCallback } from 'react'
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, CircleMarker, Circle, Polyline } from 'react-leaflet' import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
import 'leaflet/dist/leaflet.css' import { MapboxOverlay } from '@deck.gl/mapbox'
import L from 'leaflet' import { ScatterplotLayer, PathLayer } from '@deck.gl/layers'
import type { PickingInfo } from '@deck.gl/core'
import type { StyleSpecification } from 'maplibre-gl'
import type { MapLayerMouseEvent } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { layerDatabase } from '@common/services/layerService' import { layerDatabase } from '@common/services/layerService'
import { decimalToDMS } from '@common/utils/coordinates' import { decimalToDMS } from '@common/utils/coordinates'
import type { PredictionModel } from '@tabs/prediction/components/OilSpillView' import type { PredictionModel } from '@tabs/prediction/components/OilSpillView'
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { BacktrackReplayOverlay } from './BacktrackReplayOverlay' import { createBacktrackLayers } from './BacktrackReplayOverlay'
import { hexToRgba } from './mapUtils'
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080' const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'
// Fix Leaflet default icon issue
import icon from 'leaflet/dist/images/marker-icon.png'
import iconShadow from 'leaflet/dist/images/marker-shadow.png'
const DefaultIcon = L.icon({
iconUrl: icon,
shadowUrl: iconShadow,
iconSize: [25, 41],
iconAnchor: [12, 41],
})
L.Marker.prototype.options.icon = DefaultIcon
// 남해안 중심 좌표 (여수 앞바다) // 남해안 중심 좌표 (여수 앞바다)
const DEFAULT_CENTER: [number, number] = [34.5, 127.8] const DEFAULT_CENTER: [number, number] = [34.5, 127.8]
const DEFAULT_ZOOM = 10 const DEFAULT_ZOOM = 10
// CartoDB Dark Matter 스타일
const BASE_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.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 MODEL_COLORS: Record<PredictionModel, string> = { const MODEL_COLORS: Record<PredictionModel, string> = {
'KOSPS': '#06b6d4', // cyan 'KOSPS': '#06b6d4',
'POSEIDON': '#ef4444', // red 'POSEIDON': '#ef4444',
'OpenDrift': '#3b82f6', // blue 'OpenDrift': '#3b82f6',
} }
// 오일펜스 우선순위별 색상/두께 // 오일펜스 우선순위별 색상/두께
@ -92,19 +110,21 @@ interface MapViewProps {
} }
} }
// WMS 레이어에 CSS brightness 필터 적용 // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
function WmsBrightnessApplier({ brightness }: { brightness: number }) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const map = useMap() function DeckGLOverlay({ layers }: { layers: any[] }) {
useEffect(() => { const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
const container = map.getContainer() overlay.setProps({ layers })
// WMS 타일 이미지에만 필터 적용 (베이스맵 제외)
const imgs = container.querySelectorAll<HTMLElement>('.leaflet-tile-pane .leaflet-layer:not(:first-child) img')
const filterVal = `brightness(${brightness / 50})`
imgs.forEach(img => { img.style.filter = filterVal })
}, [map, brightness])
return null return null
} }
// 팝업 정보
interface PopupInfo {
longitude: number
latitude: number
content: React.ReactNode
}
export function MapView({ export function MapView({
center = DEFAULT_CENTER, center = DEFAULT_CENTER,
zoom = DEFAULT_ZOOM, zoom = DEFAULT_ZOOM,
@ -126,14 +146,16 @@ export function MapView({
const [currentTime, setCurrentTime] = useState(0) const [currentTime, setCurrentTime] = useState(0)
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [playbackSpeed, setPlaybackSpeed] = useState(1) const [playbackSpeed, setPlaybackSpeed] = useState(1)
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null)
const handleMapClick = (position: [number, number]) => { const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
const [lat, lng] = position const { lng, lat } = e.lngLat
setCurrentPosition(position) setCurrentPosition([lat, lng])
if (onMapClick) { if (onMapClick) {
onMapClick(lng, lat) // onMapClick expects (lon, lat) onMapClick(lng, lat)
} }
} setPopupInfo(null)
}, [onMapClick])
// 애니메이션 재생 로직 // 애니메이션 재생 로직
useEffect(() => { useEffect(() => {
@ -150,7 +172,7 @@ export function MapView({
const next = prev + (1 * playbackSpeed) const next = prev + (1 * playbackSpeed)
return next > maxTime ? maxTime : next return next > maxTime ? maxTime : next
}) })
}, 200) // 200ms마다 업데이트 }, 200)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [isPlaying, currentTime, playbackSpeed, oilTrajectory]) }, [isPlaying, currentTime, playbackSpeed, oilTrajectory])
@ -163,7 +185,7 @@ export function MapView({
} }
}, [oilTrajectory.length]) }, [oilTrajectory.length])
// WMS 레이어 목록 생성 // WMS 레이어 목록
const wmsLayers = useMemo(() => { const wmsLayers = useMemo(() => {
return Array.from(enabledLayers) return Array.from(enabledLayers)
.map(layerId => { .map(layerId => {
@ -173,212 +195,311 @@ export function MapView({
.filter((l): l is { id: string; wmsLayer: string } => l !== null) .filter((l): l is { id: string; wmsLayer: string } => l !== null)
}, [enabledLayers]) }, [enabledLayers])
// WMS 밝기 값 (MapLibre raster paint)
const wmsBrightnessMax = Math.min(layerBrightness / 50, 2)
const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0
const wmsOpacity = layerOpacity / 100
// deck.gl 레이어 구축
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers = useMemo((): any[] => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any[] = []
// --- 유류 확산 입자 (ScatterplotLayer) ---
const visibleParticles = oilTrajectory.filter(p => p.time <= currentTime)
if (visibleParticles.length > 0) {
result.push(
new ScatterplotLayer({
id: 'oil-particles',
data: visibleParticles,
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
getRadius: 3,
getFillColor: (d: (typeof visibleParticles)[0]) => {
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
},
radiusMinPixels: 2.5,
radiusMaxPixels: 5,
pickable: true,
onClick: (info: PickingInfo) => {
if (info.object) {
const d = info.object as (typeof visibleParticles)[0]
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
setPopupInfo({
longitude: d.lon,
latitude: d.lat,
content: (
<div className="text-xs">
<strong>{modelKey} #{(d.particle ?? 0) + 1}</strong>
<br />
: +{d.time}h
<br />
: {d.lat.toFixed(4)}°, {d.lon.toFixed(4)}°
</div>
),
})
}
},
updateTriggers: {
getFillColor: [selectedModels],
},
})
)
}
// --- 오일펜스 라인 (PathLayer) ---
if (boomLines.length > 0) {
result.push(
new PathLayer({
id: 'boom-lines',
data: boomLines,
getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]),
getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230),
getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2,
getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : null,
dashJustified: true,
widthMinPixels: 2,
widthMaxPixels: 6,
pickable: true,
onClick: (info: PickingInfo) => {
if (info.object) {
const d = info.object as BoomLine
setPopupInfo({
longitude: info.coordinate?.[0] ?? 0,
latitude: info.coordinate?.[1] ?? 0,
content: (
<div className="text-xs" style={{ fontFamily: 'var(--fK)', minWidth: '140px' }}>
<strong style={{ color: PRIORITY_COLORS[d.priority] }}>{d.name}</strong>
<br />
: {PRIORITY_LABELS[d.priority] || d.priority}
<br />
: {d.length.toFixed(0)}m
<br />
: {d.angle.toFixed(0)}°
<br />
: {d.efficiency}%
</div>
),
})
}
},
})
)
// 오일펜스 끝점 마커
const endpoints: Array<{ position: [number, number]; color: [number, number, number, number] }> = []
boomLines.forEach(line => {
if (line.coords.length >= 2) {
const c = hexToRgba(PRIORITY_COLORS[line.priority] || '#f59e0b', 230)
endpoints.push({ position: [line.coords[0].lon, line.coords[0].lat], color: c })
endpoints.push({ position: [line.coords[line.coords.length - 1].lon, line.coords[line.coords.length - 1].lat], color: c })
}
})
if (endpoints.length > 0) {
result.push(
new ScatterplotLayer({
id: 'boom-endpoints',
data: endpoints,
getPosition: (d: (typeof endpoints)[0]) => d.position,
getRadius: 5,
getFillColor: (d: (typeof endpoints)[0]) => d.color,
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 5,
radiusMaxPixels: 8,
})
)
}
}
// --- 드로잉 미리보기 ---
if (isDrawingBoom && drawingPoints.length > 0) {
result.push(
new PathLayer({
id: 'drawing-preview',
data: [{ path: drawingPoints.map(c => [c.lon, c.lat] as [number, number]) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [245, 158, 11, 200],
getWidth: 3,
getDashArray: [10, 6],
dashJustified: true,
widthMinPixels: 3,
})
)
result.push(
new ScatterplotLayer({
id: 'drawing-points',
data: drawingPoints.map(c => ({ position: [c.lon, c.lat] as [number, number] })),
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 4,
getFillColor: [245, 158, 11, 255],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 4,
radiusMaxPixels: 6,
})
)
}
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
if (dispersionResult && incidentCoord) {
const zones = dispersionResult.zones.map((zone, idx) => ({
position: [incidentCoord.lon, incidentCoord.lat] as [number, number],
radius: zone.radius,
fillColor: hexToRgba(zone.color, 100),
lineColor: hexToRgba(zone.color, 180),
level: zone.level,
idx,
}))
result.push(
new ScatterplotLayer({
id: 'hns-zones',
data: zones,
getPosition: (d: (typeof zones)[0]) => d.position,
getRadius: (d: (typeof zones)[0]) => d.radius,
getFillColor: (d: (typeof zones)[0]) => d.fillColor,
getLineColor: (d: (typeof zones)[0]) => d.lineColor,
getLineWidth: 2,
stroked: true,
radiusUnits: 'meters' as const,
pickable: true,
onClick: (info: PickingInfo) => {
if (info.object) {
const d = info.object as (typeof zones)[0]
setPopupInfo({
longitude: incidentCoord.lon,
latitude: incidentCoord.lat,
content: (
<div className="text-xs" style={{ fontFamily: 'var(--fK)' }}>
<strong style={{ color: 'var(--orange)' }}>{d.level}</strong>
<br />
: {dispersionResult.substance}
<br />
: {dispersionResult.concentration[d.level]}
<br />
: {d.radius}m
</div>
),
})
}
},
})
)
}
// --- 역추적 리플레이 ---
if (backtrackReplay?.isActive) {
result.push(...createBacktrackLayers({
replayShips: backtrackReplay.ships,
collisionEvent: backtrackReplay.collisionEvent,
replayFrame: backtrackReplay.replayFrame,
totalFrames: backtrackReplay.totalFrames,
incidentCoord: backtrackReplay.incidentCoord,
}))
}
return result
}, [
oilTrajectory, currentTime, selectedModels,
boomLines, isDrawingBoom, drawingPoints,
dispersionResult, incidentCoord, backtrackReplay,
])
return ( return (
<div className="w-full h-full relative"> <div className="w-full h-full relative">
<MapContainer <Map
center={center} initialViewState={{
zoom={zoom} longitude: center[1],
className="w-full h-full" latitude: center[0],
zoomControl={false} zoom: zoom,
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }} }}
mapStyle={BASE_STYLE}
style={{ width: '100%', height: '100%', cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
onClick={handleMapClick}
attributionControl={false}
> >
{/* CartoDB Dark Matter Tile Layer */} {/* WMS 레이어 */}
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
{/* WMS 레이어 (투명도 + 밝기 적용) */}
{wmsLayers.map(layer => ( {wmsLayers.map(layer => (
<TileLayer <Source
key={layer.id} key={layer.id}
url={`${GEOSERVER_URL}/geoserver/gwc/service/wms?service=WMS&version=1.1.0&request=GetMap&layers=${layer.wmsLayer}&styles=&bbox={bbox}&width=256&height=256&srs=EPSG:3857&format=image/png&transparent=true`} id={`wms-${layer.id}`}
attribution='&copy; MPC GeoServer' type="raster"
opacity={layerOpacity / 100} tiles={[
/> `${GEOSERVER_URL}/geoserver/gwc/service/wms?service=WMS&version=1.1.0&request=GetMap&layers=${layer.wmsLayer}&styles=&bbox={bbox-epsg-3857}&width=256&height=256&srs=EPSG:3857&format=image/png&transparent=true`
]}
tileSize={256}
>
<Layer
id={`wms-layer-${layer.id}`}
type="raster"
paint={{
'raster-opacity': wmsOpacity,
'raster-brightness-min': wmsBrightnessMin,
'raster-brightness-max': Math.min(wmsBrightnessMax, 1),
}}
/>
</Source>
))} ))}
<WmsBrightnessApplier brightness={layerBrightness} />
{/* 사고 위치 마커 */} {/* deck.gl 오버레이 */}
<DeckGLOverlay layers={deckLayers} />
{/* 사고 위치 마커 (MapLibre Marker) */}
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && ( {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
<Marker position={[incidentCoord.lat, incidentCoord.lon]}> <Marker longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom">
<Popup> <div
<div className="text-sm"> style={{
<strong> </strong> width: 24, height: 24, background: 'var(--cyan)', borderRadius: '50% 50% 50% 0',
<br /> transform: 'rotate(-45deg)', border: '2px solid #fff',
<span className="text-xs text-gray-600"> boxShadow: '0 2px 8px rgba(6,182,212,0.5)',
{decimalToDMS(incidentCoord.lat, true)} }}
<br /> />
{decimalToDMS(incidentCoord.lon, false)}
</span>
<br />
<span className="text-xs text-gray-500" style={{ fontFamily: 'monospace' }}>
({incidentCoord.lat.toFixed(4)}°, {incidentCoord.lon.toFixed(4)}°)
</span>
</div>
</Popup>
</Marker> </Marker>
)} )}
{/* 오일 확산 입자 시각화 (모델별 색상) */} {/* 사고 위치 팝업 (클릭 시) */}
{oilTrajectory {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !popupInfo && (
.filter(point => point.time <= currentTime) <Popup longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom" offset={30} closeButton={false} closeOnClick={false}>
.map((point, idx) => { <div className="text-sm" style={{ color: '#333' }}>
const modelKey = point.model || Array.from(selectedModels)[0] || 'OpenDrift' <strong> </strong>
const particleColor = MODEL_COLORS[modelKey] || '#3b82f6' <br />
return ( <span className="text-xs" style={{ color: '#666' }}>
<CircleMarker {decimalToDMS(incidentCoord.lat, true)}
key={`particle-${point.model}-${point.particle}-${point.time}-${idx}`} <br />
center={[point.lat, point.lon]} {decimalToDMS(incidentCoord.lon, false)}
radius={2.5} </span>
pathOptions={{ <br />
fillColor: particleColor, <span className="text-xs" style={{ fontFamily: 'monospace', color: '#888' }}>
fillOpacity: 0.7, ({incidentCoord.lat.toFixed(4)}°, {incidentCoord.lon.toFixed(4)}°)
color: particleColor, </span>
weight: 0.5, </div>
opacity: 0.8 </Popup>
}} )}
>
<Popup>
<div className="text-xs">
<strong>{modelKey} #{(point.particle ?? 0) + 1}</strong>
<br />
: +{point.time}h
<br />
: {point.lat.toFixed(4)}°, {point.lon.toFixed(4)}°
</div>
</Popup>
</CircleMarker>
)
})}
{/* 오일펜스 라인 렌더링 */} {/* deck.gl 객체 클릭 팝업 */}
{boomLines.map(line => ( {popupInfo && (
<Polyline <Popup
key={line.id} longitude={popupInfo.longitude}
positions={line.coords.map(c => [c.lat, c.lon] as [number, number])} latitude={popupInfo.latitude}
pathOptions={{ anchor="bottom"
color: PRIORITY_COLORS[line.priority] || '#f59e0b', onClose={() => setPopupInfo(null)}
weight: PRIORITY_WEIGHTS[line.priority] || 2,
opacity: 0.9,
dashArray: line.status === 'PLANNED' ? '10, 5' : undefined,
}}
> >
<Popup> <div style={{ color: '#333' }}>{popupInfo.content}</div>
<div className="text-xs" style={{ fontFamily: 'var(--fK)', minWidth: '140px' }}> </Popup>
<strong style={{ color: PRIORITY_COLORS[line.priority] }}>{line.name}</strong>
<br />
: {PRIORITY_LABELS[line.priority] || line.priority}
<br />
: {line.length.toFixed(0)}m
<br />
: {line.angle.toFixed(0)}°
<br />
: {line.efficiency}%
</div>
</Popup>
</Polyline>
))}
{/* 오일펜스 끝점 마커 */}
{boomLines.map(line =>
line.coords.length >= 2
? [line.coords[0], line.coords[line.coords.length - 1]].map((pt, i) => (
<CircleMarker
key={`${line.id}-ep-${i}`}
center={[pt.lat, pt.lon]}
radius={5}
pathOptions={{
fillColor: PRIORITY_COLORS[line.priority] || '#f59e0b',
fillOpacity: 0.9,
color: '#fff',
weight: 2,
}}
/>
))
: null
)} )}
{/* 드로잉 미리보기 */} {/* 커스텀 줌 컨트롤 */}
{isDrawingBoom && drawingPoints.length > 0 && ( <MapControls center={center} zoom={zoom} />
<> </Map>
<Polyline
positions={drawingPoints.map(c => [c.lat, c.lon] as [number, number])}
pathOptions={{
color: '#f59e0b',
weight: 3,
dashArray: '10, 6',
opacity: 0.8,
}}
/>
{drawingPoints.map((pt, i) => (
<CircleMarker
key={`draw-pt-${i}`}
center={[pt.lat, pt.lon]}
radius={4}
pathOptions={{
fillColor: '#f59e0b',
fillOpacity: 1,
color: '#fff',
weight: 2,
}}
/>
))}
</>
)}
{/* HNS 대기확산 결과 시각화 */}
{dispersionResult && incidentCoord && dispersionResult.zones.map((zone, idx) => (
<Circle
key={`dispersion-zone-${zone.level}-${idx}`}
center={[incidentCoord.lat, incidentCoord.lon]}
radius={zone.radius}
pathOptions={{
fillColor: zone.color,
fillOpacity: 0.4,
color: zone.color,
weight: 2,
opacity: 0.7
}}
>
<Popup>
<div className="text-xs" style={{ fontFamily: 'var(--fK)' }}>
<strong style={{ color: 'var(--orange)' }}>{zone.level}</strong>
<br />
: {dispersionResult.substance}
<br />
: {dispersionResult.concentration[zone.level]}
<br />
: {zone.radius}m
<br />
: {zone.angle}°
</div>
</Popup>
</Circle>
))}
{/* 역추적 리플레이 오버레이 */}
{backtrackReplay?.isActive && (
<BacktrackReplayOverlay
replayShips={backtrackReplay.ships}
collisionEvent={backtrackReplay.collisionEvent}
replayFrame={backtrackReplay.replayFrame}
totalFrames={backtrackReplay.totalFrames}
incidentCoord={backtrackReplay.incidentCoord}
/>
)}
{/* 지도 클릭 이벤트 핸들러 */}
<MapClickHandler onPositionChange={handleMapClick} />
{/* 커스텀 컨트롤 */}
<MapControls />
</MapContainer>
{/* 드로잉 모드 안내 */} {/* 드로잉 모드 안내 */}
{isDrawingBoom && ( {isDrawingBoom && (
<div className="boom-drawing-indicator"> <div className="boom-drawing-indicator">
🛡 ({drawingPoints.length} ) ({drawingPoints.length} )
</div> </div>
)} )}
@ -405,54 +526,43 @@ export function MapView({
onSpeedChange={setPlaybackSpeed} onSpeedChange={setPlaybackSpeed}
/> />
)} )}
{/* 역추적 리플레이 바 */}
{backtrackReplay?.isActive && (
<BacktrackReplayBar
replayFrame={backtrackReplay.replayFrame}
totalFrames={backtrackReplay.totalFrames}
ships={backtrackReplay.ships}
/>
)}
</div> </div>
) )
} }
// 지도 클릭 이벤트 핸들러 // 지도 컨트롤 (줌, 위치 초기화)
interface MapClickHandlerProps { function MapControls({ center, zoom }: { center: [number, number]; zoom: number }) {
onPositionChange: (position: [number, number]) => void const { current: map } = useMap()
}
function MapClickHandler({ onPositionChange }: MapClickHandlerProps) {
useMapEvents({
click: (e) => {
const { lat, lng } = e.latlng
onPositionChange([lat, lng])
},
})
return null
}
// 지도 컨트롤 (줌, 레이어 등)
function MapControls() {
const map = useMap()
return ( return (
<div className="leaflet-top leaflet-left" style={{ marginTop: '16px', marginLeft: '16px', zIndex: 1001 }}> <div style={{ position: 'absolute', top: 16, left: 16, zIndex: 10 }}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/* 줌 인 */}
<button <button
onClick={() => map.zoomIn()} onClick={() => map?.zoomIn()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base" className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
> >
+ +
</button> </button>
{/* 줌 아웃 */}
<button <button
onClick={() => map.zoomOut()} onClick={() => map?.zoomOut()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base" className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
> >
</button> </button>
{/* 현재 위치로 */}
<button <button
onClick={() => map.setView(DEFAULT_CENTER, DEFAULT_ZOOM)} onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm" className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
> >
🎯 &#x1F3AF;
</button> </button>
</div> </div>
</div> </div>
@ -469,11 +579,9 @@ interface MapLegendProps {
} }
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) { function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
// HNS 대기확산 범례
if (dispersionResult && incidentCoord) { if (dispersionResult && incidentCoord) {
return ( return (
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-border rounded-lg p-3.5 z-[1001] min-w-[200px]" style={{ fontFamily: 'var(--fK)' }}> <div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-border rounded-lg p-3.5 min-w-[200px]" style={{ fontFamily: 'var(--fK)', zIndex: 20 }}>
{/* 헤더 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '10px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '10px' }}>
<div style={{ fontSize: '16px' }}>📍</div> <div style={{ fontSize: '16px' }}>📍</div>
<div> <div>
@ -483,8 +591,6 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
</div> </div>
</div> </div>
</div> </div>
{/* 사고 정보 */}
<div style={{ background: 'rgba(249,115,22,0.08)', padding: '8px', borderRadius: '6px', marginBottom: '8px', fontSize: '9px', color: 'var(--t2)' }}> <div style={{ background: 'rgba(249,115,22,0.08)', padding: '8px', borderRadius: '6px', marginBottom: '8px', fontSize: '9px', color: 'var(--t2)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
<span style={{ color: 'var(--t3)' }}></span> <span style={{ color: 'var(--t3)' }}></span>
@ -499,8 +605,6 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
<span style={{ fontWeight: 600, color: 'var(--cyan)' }}>{dispersionResult.zones.length}</span> <span style={{ fontWeight: 600, color: 'var(--cyan)' }}>{dispersionResult.zones.length}</span>
</div> </div>
</div> </div>
{/* 위험 구역 범례 */}
<div> <div>
<h5 className="text-[9px] font-bold text-text-3 mb-2"> </h5> <h5 className="text-[9px] font-bold text-text-3 mb-2"> </h5>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@ -518,8 +622,6 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
</div> </div>
</div> </div>
</div> </div>
{/* 풍향 표시 */}
<div style={{ marginTop: '8px', padding: '6px', background: 'rgba(168,85,247,0.08)', borderRadius: '4px', display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ marginTop: '8px', padding: '6px', background: 'rgba(168,85,247,0.08)', borderRadius: '4px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ fontSize: '12px' }}>🧭</div> <div style={{ fontSize: '12px' }}>🧭</div>
<span className="text-[9px] text-text-3"> ()</span> <span className="text-[9px] text-text-3"> ()</span>
@ -528,13 +630,11 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
) )
} }
// 유류 확산 범례 (유출유 확산예측에만 표시)
if (oilTrajectory.length > 0) { if (oilTrajectory.length > 0) {
return ( return (
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-md p-3.5 z-[1001] min-w-[180px]"> <div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-md p-3.5 min-w-[180px]" style={{ zIndex: 20 }}>
<h4 className="text-[11px] font-bold uppercase tracking-wider text-text-3 mb-2.5"></h4> <h4 className="text-[11px] font-bold uppercase tracking-wider text-text-3 mb-2.5"></h4>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{/* 선택된 모델별 색상 */}
{Array.from(selectedModels).map(model => ( {Array.from(selectedModels).map(model => (
<div key={model} className="flex items-center gap-2 text-xs text-text-2"> <div key={model} className="flex items-center gap-2 text-xs text-text-2">
<div className="w-3.5 h-3.5 rounded-full" style={{ background: MODEL_COLORS[model] }} /> <div className="w-3.5 h-3.5 rounded-full" style={{ background: MODEL_COLORS[model] }} />
@ -573,16 +673,11 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
) )
} }
// 범례 없음
return null return null
} }
// 좌표 표시 // 좌표 표시
interface CoordinateDisplayProps { function CoordinateDisplay({ position }: { position: [number, number] }) {
position: [number, number]
}
function CoordinateDisplay({ position }: CoordinateDisplayProps) {
const [lat, lng] = position const [lat, lng] = position
const latDirection = lat >= 0 ? 'N' : 'S' const latDirection = lat >= 0 ? 'N' : 'S'
const lngDirection = lng >= 0 ? 'E' : 'W' const lngDirection = lng >= 0 ? 'E' : 'W'
@ -608,48 +703,28 @@ interface TimelineControlProps {
} }
function TimelineControl({ function TimelineControl({
currentTime, currentTime, maxTime, isPlaying, playbackSpeed,
maxTime, onTimeChange, onPlayPause, onSpeedChange
isPlaying,
playbackSpeed,
onTimeChange,
onPlayPause,
onSpeedChange
}: TimelineControlProps) { }: TimelineControlProps) {
const progressPercent = (currentTime / maxTime) * 100 const progressPercent = (currentTime / maxTime) * 100
const handleRewind = () => { const handleRewind = () => onTimeChange(Math.max(0, currentTime - 6))
onTimeChange(Math.max(0, currentTime - 6)) const handleForward = () => onTimeChange(Math.min(maxTime, currentTime + 6))
} const handleStart = () => onTimeChange(0)
const handleEnd = () => onTimeChange(maxTime)
const handleForward = () => {
onTimeChange(Math.min(maxTime, currentTime + 6))
}
const handleStart = () => {
onTimeChange(0)
}
const handleEnd = () => {
onTimeChange(maxTime)
}
const toggleSpeed = () => { const toggleSpeed = () => {
const speeds = [1, 2, 4] const speeds = [1, 2, 4]
const currentIndex = speeds.indexOf(playbackSpeed) const currentIndex = speeds.indexOf(playbackSpeed)
const nextIndex = (currentIndex + 1) % speeds.length onSpeedChange(speeds[(currentIndex + 1) % speeds.length])
onSpeedChange(speeds[nextIndex])
} }
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect() const rect = e.currentTarget.getBoundingClientRect()
const clickX = e.clientX - rect.left const percent = (e.clientX - rect.left) / rect.width
const percent = clickX / rect.width onTimeChange(Math.max(0, Math.min(maxTime, Math.round(percent * maxTime))))
const newTime = Math.round(percent * maxTime)
onTimeChange(Math.max(0, Math.min(maxTime, newTime)))
} }
// 시간 레이블 생성 (0h, 6h, 12h, ...)
const timeLabels = [] const timeLabels = []
for (let t = 0; t <= maxTime; t += 6) { for (let t = 0; t <= maxTime; t += 6) {
timeLabels.push(t) timeLabels.push(t)
@ -657,7 +732,6 @@ function TimelineControl({
return ( return (
<div className="tlb"> <div className="tlb">
{/* 재생 컨트롤 */}
<div className="tlc"> <div className="tlc">
<div className="tb" onClick={handleStart}></div> <div className="tb" onClick={handleStart}></div>
<div className="tb" onClick={handleRewind}></div> <div className="tb" onClick={handleRewind}></div>
@ -669,16 +743,10 @@ function TimelineControl({
<div style={{ width: '8px' }} /> <div style={{ width: '8px' }} />
<div className="tb" onClick={toggleSpeed}>{playbackSpeed}×</div> <div className="tb" onClick={toggleSpeed}>{playbackSpeed}×</div>
</div> </div>
{/* 타임라인 트랙 */}
<div className="tlt"> <div className="tlt">
<div className="tlls"> <div className="tlls">
{timeLabels.map(t => ( {timeLabels.map(t => (
<span <span key={t} className={`tll ${Math.abs(currentTime - t) < 1 ? 'on' : ''}`} style={{ left: `${(t / maxTime) * 100}%` }}>
key={t}
className={`tll ${Math.abs(currentTime - t) < 1 ? 'on' : ''}`}
style={{ left: `${(t / maxTime) * 100}%` }}
>
{t}h {t}h
</span> </span>
))} ))}
@ -687,60 +755,31 @@ function TimelineControl({
<div className="tlr"> <div className="tlr">
<div className="tlp" style={{ width: `${progressPercent}%` }} /> <div className="tlp" style={{ width: `${progressPercent}%` }} />
{timeLabels.map(t => ( {timeLabels.map(t => (
<div <div key={`marker-${t}`} className={`tlm ${t % 12 === 0 ? 'mj' : ''}`} style={{ left: `${(t / maxTime) * 100}%` }} />
key={`marker-${t}`}
className={`tlm ${t % 12 === 0 ? 'mj' : ''}`}
style={{ left: `${(t / maxTime) * 100}%` }}
/>
))} ))}
</div> </div>
<div className="tlth" style={{ left: `${progressPercent}%` }} /> <div className="tlth" style={{ left: `${progressPercent}%` }} />
</div> </div>
</div> </div>
{/* 정보 표시 */}
<div className="tli"> <div className="tli">
{/* eslint-disable-next-line react-hooks/purity */} {/* eslint-disable-next-line react-hooks/purity */}
<div className="tlct">+{currentTime.toFixed(0)}h {new Date(Date.now() + currentTime * 3600000).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} KST</div> <div className="tlct">+{currentTime.toFixed(0)}h {new Date(Date.now() + currentTime * 3600000).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} KST</div>
<div className="tlss"> <div className="tlss">
<div className="tls"> <div className="tls"><span className="tlsl"></span><span className="tlsv">{progressPercent.toFixed(0)}%</span></div>
<span className="tlsl"></span> <div className="tls"><span className="tlsl"></span><span className="tlsv">{playbackSpeed}×</span></div>
<span className="tlsv">{progressPercent.toFixed(0)}%</span> <div className="tls"><span className="tlsl"></span><span className="tlsv">{currentTime.toFixed(0)}/{maxTime}h</span></div>
</div>
<div className="tls">
<span className="tlsl"></span>
<span className="tlsv">{playbackSpeed}×</span>
</div>
<div className="tls">
<span className="tlsl"></span>
<span className="tlsv">{currentTime.toFixed(0)}/{maxTime}h</span>
</div>
</div> </div>
</div> </div>
</div> </div>
) )
} }
// 기상 데이터 타입 // 기상 데이터 Mock
interface WeatherData { function getWeatherData(position: [number, number]) {
windSpeed: number
windDirection: string
waveHeight: number
waterTemp: number
currentSpeed: number
currentDirection: string
}
// 좌표 기반 기상 데이터 조회 (Mock 함수)
function getWeatherData(position: [number, number]): WeatherData {
const [lat, lng] = position const [lat, lng] = position
// 좌표 기반으로 변화를 주기 위한 시드값
const latSeed = Math.abs(lat * 100) % 10 const latSeed = Math.abs(lat * 100) % 10
const lngSeed = Math.abs(lng * 100) % 10 const lngSeed = Math.abs(lng * 100) % 10
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
return { return {
windSpeed: Number((5 + latSeed).toFixed(1)), windSpeed: Number((5 + latSeed).toFixed(1)),
windDirection: directions[Math.floor(lngSeed * 0.8)], windDirection: directions[Math.floor(lngSeed * 0.8)],
@ -751,38 +790,25 @@ function getWeatherData(position: [number, number]): WeatherData {
} }
} }
// 기상청 연계 정보 function WeatherInfoPanel({ position }: { position: [number, number] }) {
interface WeatherInfoPanelProps {
position: [number, number]
}
function WeatherInfoPanel({ position }: WeatherInfoPanelProps) {
const weather = getWeatherData(position) const weather = getWeatherData(position)
return ( return (
<div className="wip"> <div className="wip">
{/* 풍속 */}
<div className="wii"> <div className="wii">
<div className="wii-icon">💨</div> <div className="wii-icon">💨</div>
<div className="wii-value">{weather.windSpeed} m/s</div> <div className="wii-value">{weather.windSpeed} m/s</div>
<div className="wii-label"> ({weather.windDirection})</div> <div className="wii-label"> ({weather.windDirection})</div>
</div> </div>
{/* 파고 */}
<div className="wii"> <div className="wii">
<div className="wii-icon">🌊</div> <div className="wii-icon">🌊</div>
<div className="wii-value">{weather.waveHeight} m</div> <div className="wii-value">{weather.waveHeight} m</div>
<div className="wii-label"></div> <div className="wii-label"></div>
</div> </div>
{/* 수온 */}
<div className="wii"> <div className="wii">
<div className="wii-icon">🌡</div> <div className="wii-icon">🌡</div>
<div className="wii-value">{weather.waterTemp}°C</div> <div className="wii-value">{weather.waterTemp}°C</div>
<div className="wii-label"></div> <div className="wii-label"></div>
</div> </div>
{/* 해류 */}
<div className="wii"> <div className="wii">
<div className="wii-icon">🔄</div> <div className="wii-icon">🔄</div>
<div className="wii-value">{weather.currentSpeed} m/s</div> <div className="wii-value">{weather.currentSpeed} m/s</div>
@ -791,3 +817,32 @@ function WeatherInfoPanel({ position }: WeatherInfoPanelProps) {
</div> </div>
) )
} }
// 역추적 리플레이 컨트롤 바 (HTML 오버레이)
function BacktrackReplayBar({ replayFrame, totalFrames, ships }: { replayFrame: number; totalFrames: number; ships: ReplayShip[] }) {
const progress = (replayFrame / totalFrames) * 100
return (
<div style={{
position: 'absolute', bottom: 80, left: '50%', transform: 'translateX(-50%)',
background: 'rgba(10,14,26,0.92)', backdropFilter: 'blur(12px)',
border: '1px solid var(--bdL)', borderRadius: '10px',
padding: '12px 18px', zIndex: 50,
display: 'flex', alignItems: 'center', gap: '16px', minWidth: '340px',
}}>
<div style={{ fontSize: '14px', color: 'var(--purple)', fontFamily: 'var(--fM)', fontWeight: 700 }}>
{progress.toFixed(0)}%
</div>
<div style={{ flex: 1, height: '4px', background: 'var(--bd)', borderRadius: '2px', position: 'relative' }}>
<div style={{ width: `${progress}%`, height: '100%', background: 'linear-gradient(90deg, var(--purple), var(--cyan))', borderRadius: '2px', transition: 'width 0.05s' }} />
</div>
<div style={{ display: 'flex', gap: '6px' }}>
{ships.map(s => (
<div key={s.vesselName} style={{
width: 8, height: 8, borderRadius: '50%', background: s.color,
border: '1px solid rgba(255,255,255,0.3)',
}} title={s.vesselName} />
))}
</div>
</div>
)
}

파일 보기

@ -0,0 +1,7 @@
/** hex 색상(#rrggbb)을 deck.gl용 RGBA 배열로 변환 */
export function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return [r, g, b, alpha]
}

파일 보기

@ -331,7 +331,7 @@
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 11px; font-size: 11px;
color: var(--t2); color: var(--t2);
z-index: 1001; z-index: 20;
display: flex; display: flex;
gap: 16px; gap: 16px;
} }
@ -351,7 +351,7 @@
border: 1px solid var(--bd); border: 1px solid var(--bd);
border-radius: 8px; border-radius: 8px;
padding: 12px 14px; padding: 12px 14px;
z-index: 1001; z-index: 20;
display: flex; display: flex;
gap: 20px; gap: 20px;
} }
@ -395,7 +395,7 @@
align-items: center; align-items: center;
padding: 0 20px; padding: 0 20px;
gap: 16px; gap: 16px;
z-index: 1001; z-index: 30;
} }
.tlc { .tlc {
@ -753,7 +753,7 @@
font-weight: 600; font-weight: 600;
color: var(--boom); color: var(--boom);
font-family: var(--fK); font-family: var(--fK);
z-index: 1002; z-index: 40;
white-space: nowrap; white-space: nowrap;
pointer-events: none; pointer-events: none;
animation: fadeSlideDown 0.3s ease; animation: fadeSlideDown 0.3s ease;

파일 보기

@ -1,8 +1,53 @@
import { useEffect, useRef } from 'react' import { useMemo, useCallback, useEffect, useRef } from 'react'
import L from 'leaflet' import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
import 'leaflet/dist/leaflet.css' import { MapboxOverlay } from '@deck.gl/mapbox'
import { ScatterplotLayer } from '@deck.gl/layers'
import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import type { AssetOrgCompat } from '../services/assetsApi' import type { AssetOrgCompat } from '../services/assetsApi'
import { typeColor } from './assetTypes' import { typeColor } from './assetTypes'
import { hexToRgba } from '@common/components/map/mapUtils'
const BASE_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
}
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
// ── FlyTo Controller ────────────────────────────────────
function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) {
const { current: map } = useMap()
const prevIdRef = useRef<number | undefined>(undefined)
useEffect(() => {
if (!map) return
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedOrg.id) {
map.flyTo({ center: [selectedOrg.lng, selectedOrg.lat], zoom: 10, duration: 800 })
}
prevIdRef.current = selectedOrg.id
}, [map, selectedOrg])
return null
}
interface AssetMapProps { interface AssetMapProps {
organizations: AssetOrgCompat[] organizations: AssetOrgCompat[]
@ -19,94 +64,62 @@ function AssetMap({
regionFilter, regionFilter,
onRegionFilterChange, onRegionFilterChange,
}: AssetMapProps) { }: AssetMapProps) {
const mapContainerRef = useRef<HTMLDivElement>(null) const handleClick = useCallback(
const mapRef = useRef<L.Map | null>(null) (org: AssetOrgCompat) => {
const markersRef = useRef<L.LayerGroup | null>(null) onSelectOrg(org)
},
[onSelectOrg],
)
// Initialize map once const markerLayer = useMemo(() => {
useEffect(() => { return new ScatterplotLayer({
if (!mapContainerRef.current || mapRef.current) return id: 'asset-orgs',
data: orgs,
const map = L.map(mapContainerRef.current, { getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
center: [35.9, 127.8], getRadius: (d: AssetOrgCompat) => {
zoom: 7, const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7
zoomControl: false, const isSelected = selectedOrg.id === d.id
attributionControl: false, return isSelected ? baseRadius + 4 : baseRadius
},
getFillColor: (d: AssetOrgCompat) => {
const tc = typeColor(d.type)
const isSelected = selectedOrg.id === d.id
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178)
},
getLineColor: (d: AssetOrgCompat) => {
const tc = typeColor(d.type)
const isSelected = selectedOrg.id === d.id
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200)
},
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
stroked: true,
radiusMinPixels: 4,
radiusMaxPixels: 20,
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: AssetOrgCompat }) => {
if (info.object) handleClick(info.object)
},
updateTriggers: {
getRadius: [selectedOrg.id],
getFillColor: [selectedOrg.id],
getLineColor: [selectedOrg.id],
getLineWidth: [selectedOrg.id],
},
}) })
}, [orgs, selectedOrg, handleClick])
// Dark-themed OpenStreetMap tiles
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map)
L.control.zoom({ position: 'topright' }).addTo(map)
L.control.attribution({ position: 'bottomright' }).addAttribution(
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>'
).addTo(map)
mapRef.current = map
markersRef.current = L.layerGroup().addTo(map)
return () => {
map.remove()
mapRef.current = null
markersRef.current = null
}
}, [])
// Update markers when orgs or selectedOrg changes
useEffect(() => {
if (!mapRef.current || !markersRef.current) return
markersRef.current.clearLayers()
orgs.forEach(org => {
const isSelected = selectedOrg.id === org.id
const tc = typeColor(org.type)
const radius = org.pinSize === 'hq' ? 14 : org.pinSize === 'lg' ? 10 : 7
const cm = L.circleMarker([org.lat, org.lng], {
radius: isSelected ? radius + 4 : radius,
fillColor: isSelected ? tc.selected : tc.bg,
color: isSelected ? tc.selected : tc.border,
weight: isSelected ? 3 : 2,
fillOpacity: isSelected ? 0.9 : 0.7,
})
cm.bindTooltip(
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
<div style="font-weight:700;font-size:11px;">${org.name}</div>
<div style="font-size:10px;opacity:0.7;">${org.type} · ${org.totalAssets}</div>
</div>`,
{ permanent: org.pinSize === 'hq' || isSelected, direction: 'top', offset: [0, -radius - 2], className: 'asset-map-tooltip' }
)
cm.on('click', () => onSelectOrg(org))
markersRef.current!.addLayer(cm)
})
}, [orgs, selectedOrg, onSelectOrg])
// Pan to selected org
useEffect(() => {
if (!mapRef.current) return
mapRef.current.flyTo([selectedOrg.lat, selectedOrg.lng], 10, { duration: 0.8 })
}, [selectedOrg])
return ( return (
<div className="w-full h-full relative"> <div className="w-full h-full relative">
<style>{` <Map
.asset-map-tooltip { initialViewState={{ longitude: 127.8, latitude: 35.9, zoom: 7 }}
background: rgba(15,21,36,0.92) !important; mapStyle={BASE_STYLE}
border: 1px solid rgba(30,42,66,0.8) !important; style={{ width: '100%', height: '100%' }}
color: #e4e8f1 !important; attributionControl={false}
border-radius: 6px !important; >
padding: 4px 8px !important; <DeckGLOverlay layers={[markerLayer]} />
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important; <FlyToController selectedOrg={selectedOrg} />
} </Map>
.asset-map-tooltip::before {
border-top-color: rgba(15,21,36,0.92) !important;
}
`}</style>
<div ref={mapContainerRef} className="w-full h-full" />
{/* Region filter overlay */} {/* Region filter overlay */}
<div className="absolute top-3 left-3 z-[1000] flex gap-1"> <div className="absolute top-3 left-3 z-[1000] flex gap-1">

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,210 +1,281 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import L from 'leaflet'; import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
import 'leaflet/dist/leaflet.css'; import { MapboxOverlay } from '@deck.gl/mapbox'
import type { ScatSegment } from './scatTypes'; import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'
import { esiColor, jejuCoastCoords } from './scatConstants'; import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import type { ScatSegment } from './scatTypes'
import { esiColor, jejuCoastCoords, scatDetailData } from './scatConstants'
import { hexToRgba } from '@common/components/map/mapUtils'
interface ScatMapProps { const BASE_STYLE: StyleSpecification = {
segments: ScatSegment[]; version: 8,
selectedSeg: ScatSegment; sources: {
onSelectSeg: (s: ScatSegment) => void; 'carto-dark': {
onOpenPopup: (sn: number) => void; type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
} }
interface ScatMapProps {
segments: ScatSegment[]
selectedSeg: ScatSegment
onSelectSeg: (s: ScatSegment) => void
onOpenPopup: (idx: number) => void
}
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
function FlyToController({ selectedSeg }: { selectedSeg: ScatSegment }) {
const { current: map } = useMap()
const prevIdRef = useRef<number | undefined>(undefined)
useEffect(() => {
if (!map) return
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) {
map.flyTo({ center: [selectedSeg.lng, selectedSeg.lat], zoom: 12, duration: 600 })
}
prevIdRef.current = selectedSeg.id
}, [map, selectedSeg])
return null
}
// ── 줌 기반 스케일 계산 ─────────────────────────────────
function getZoomScale(zoom: number) {
const zScale = Math.max(0, zoom - 9) / 5
return {
polyWidth: 1 + zScale * 4,
selPolyWidth: 2 + zScale * 5,
glowWidth: 4 + zScale * 14,
halfLenScale: 0.15 + zScale * 0.85,
markerRadius: Math.round(6 + zScale * 16),
showStatusMarker: zoom >= 11,
}
}
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
function buildSegCoords(seg: ScatSegment, halfLenScale: number): [number, number][] {
const coastIdx = seg.id % (jejuCoastCoords.length - 1)
const [clat1, clng1] = jejuCoastCoords[coastIdx]
const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length]
const dlat = clat2 - clat1
const dlng = clng2 - clng1
const dist = Math.sqrt(dlat * dlat + dlng * dlng)
const nDlat = dist > 0 ? dlat / dist : 0
const nDlng = dist > 0 ? dlng / dist : 1
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale
return [
[seg.lng - nDlng * halfLen, seg.lat - nDlat * halfLen],
[seg.lng, seg.lat],
[seg.lng + nDlng * halfLen, seg.lat + nDlat * halfLen],
]
}
// ── 툴팁 상태 ───────────────────────────────────────────
interface TooltipState {
x: number
y: number
seg: ScatSegment
}
// ── ScatMap ─────────────────────────────────────────────
function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapProps) { function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapProps) {
const mapContainerRef = useRef<HTMLDivElement>(null); const [zoom, setZoom] = useState(10)
const mapRef = useRef<L.Map | null>(null); const [tooltip, setTooltip] = useState<TooltipState | null>(null)
const markersRef = useRef<L.LayerGroup | null>(null);
const [zoom, setZoom] = useState(10);
useEffect(() => { const handleClick = useCallback(
if (!mapContainerRef.current || mapRef.current) return; (seg: ScatSegment) => {
onSelectSeg(seg)
onOpenPopup(seg.id % scatDetailData.length)
},
[onSelectSeg, onOpenPopup],
)
const map = L.map(mapContainerRef.current, { const zs = useMemo(() => getZoomScale(zoom), [zoom])
center: [33.38, 126.55],
zoom: 10,
zoomControl: false,
attributionControl: false,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { // 제주도 해안선 레퍼런스 라인
maxZoom: 19, const coastlineLayer = useMemo(
}).addTo(map); () =>
new PathLayer({
id: 'jeju-coastline',
data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [6, 182, 212, 46],
getWidth: 1.5,
getDashArray: [8, 6],
dashJustified: true,
widthMinPixels: 1,
}),
[],
)
L.control.zoom({ position: 'bottomright' }).addTo(map); // 선택된 구간 글로우 레이어
L.control const glowLayer = useMemo(
.attribution({ position: 'bottomleft' }) () =>
.addAttribution( new PathLayer({
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', id: 'scat-glow',
) data: [selectedSeg],
.addTo(map); getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale),
getColor: [34, 197, 94, 38],
map.on('zoomend', () => setZoom(map.getZoom())); getWidth: zs.glowWidth,
capRounded: true,
mapRef.current = map; jointRounded: true,
markersRef.current = L.layerGroup().addTo(map); widthMinPixels: 4,
updateTriggers: {
setTimeout(() => map.invalidateSize(), 100); getPath: [zs.halfLenScale],
getWidth: [zs.glowWidth],
return () => {
map.remove();
mapRef.current = null;
markersRef.current = null;
};
}, []);
useEffect(() => {
if (!mapRef.current || !markersRef.current) return;
markersRef.current.clearLayers();
// 줌 기반 스케일 계수 (zoom 10=아일랜드뷰 → 14+=클로즈업)
const zScale = Math.max(0, zoom - 9) / 5; // 0 at z9, 1 at z14
const polyWeight = 1 + zScale * 4; // 1 ~ 5
const selPolyWeight = 2 + zScale * 5; // 2 ~ 7
const glowWeight = 4 + zScale * 14; // 4 ~ 18
const halfLenScale = 0.15 + zScale * 0.85; // 0.15 ~ 1.0
const markerSize = Math.round(6 + zScale * 16); // 6px ~ 22px
const markerBorder = zoom >= 13 ? 2 : 1;
const markerFontSize = Math.round(4 + zScale * 6); // 4px ~ 10px
const showStatusMarker = zoom >= 11;
const showStatusText = zoom >= 13;
// 제주도 해안선 레퍼런스 라인
const coastline = L.polyline(jejuCoastCoords as [number, number][], {
color: 'rgba(6, 182, 212, 0.18)',
weight: 1.5,
dashArray: '8, 6',
});
markersRef.current.addLayer(coastline);
segments.forEach((seg, segIdx) => {
const isSelected = selectedSeg.id === seg.id;
const color = esiColor(seg.esiNum);
// 해안선 방향 계산 (세그먼트 폴리라인 각도 결정)
const coastIdx = segIdx % (jejuCoastCoords.length - 1);
const [clat1, clng1] = jejuCoastCoords[coastIdx];
const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length];
const dlat = clat2 - clat1;
const dlng = clng2 - clng1;
const dist = Math.sqrt(dlat * dlat + dlng * dlng);
const nDlat = dist > 0 ? dlat / dist : 0;
const nDlng = dist > 0 ? dlng / dist : 1;
// 구간 길이를 위경도 단위로 변환 (줌 레벨에 따라 스케일링)
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale;
// 해안선 방향을 따라 폴리라인 좌표 생성
const segCoords: [number, number][] = [
[seg.lat - nDlat * halfLen, seg.lng - nDlng * halfLen],
[seg.lat, seg.lng],
[seg.lat + nDlat * halfLen, seg.lng + nDlng * halfLen],
];
// 선택된 구간 글로우 효과
if (isSelected) {
const glow = L.polyline(segCoords, {
color: '#22c55e',
weight: glowWeight,
opacity: 0.15,
lineCap: 'round',
});
markersRef.current!.addLayer(glow);
}
// ESI 색상 구간 폴리라인
const polyline = L.polyline(segCoords, {
color: isSelected ? '#22c55e' : color,
weight: isSelected ? selPolyWeight : polyWeight,
opacity: isSelected ? 0.95 : 0.7,
lineCap: 'round',
lineJoin: 'round',
});
const statusIcon = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—';
polyline.bindTooltip(
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
<div style="font-weight:700;font-size:11px;">${seg.code} ${seg.area}</div>
<div style="font-size:10px;opacity:0.7;">ESI ${seg.esi} · ${seg.length} · ${statusIcon} ${seg.status}</div>
</div>`,
{
permanent: isSelected,
direction: 'top',
offset: [0, -10],
className: 'scat-map-tooltip',
}, },
); }),
[selectedSeg, zs.glowWidth, zs.halfLenScale],
)
polyline.on('click', () => { // ESI 색상 세그먼트 폴리라인
onSelectSeg(seg); const segPathLayer = useMemo(
onOpenPopup(seg.id); () =>
}); new PathLayer({
markersRef.current!.addLayer(polyline); id: 'scat-segments',
data: segments,
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale),
getColor: (d: ScatSegment) => {
const isSelected = selectedSeg.id === d.id
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum)
return hexToRgba(hexCol, isSelected ? 242 : 178)
},
getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth),
capRounded: true,
jointRounded: true,
widthMinPixels: 1,
pickable: true,
onHover: (info: { object?: ScatSegment; x: number; y: number }) => {
if (info.object) {
setTooltip({ x: info.x, y: info.y, seg: info.object })
} else {
setTooltip(null)
}
},
onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object)
},
updateTriggers: {
getColor: [selectedSeg.id],
getWidth: [selectedSeg.id, zs.selPolyWidth, zs.polyWidth],
getPath: [zs.halfLenScale],
},
}),
[segments, selectedSeg, zs, handleClick],
)
// 조사 상태 마커 (DivIcon) — 줌 레벨에 따라 표시/크기 조절 // 조사 상태 마커 (줌 >= 11 시 표시)
if (showStatusMarker) { const markerLayer = useMemo(() => {
const stColor = if (!zs.showStatusMarker) return null
seg.status === '완료' ? '#22c55e' : seg.status === '진행중' ? '#eab308' : '#64748b'; return new ScatterplotLayer({
const stBg = id: 'scat-status-markers',
seg.status === '완료' data: segments,
? 'rgba(34,197,94,0.2)' getPosition: (d: ScatSegment) => [d.lng, d.lat],
: seg.status === '진행중' getRadius: zs.markerRadius,
? 'rgba(234,179,8,0.2)' getFillColor: (d: ScatSegment) => {
: 'rgba(100,116,139,0.2)'; if (d.status === '완료') return [34, 197, 94, 51]
const stText = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—'; if (d.status === '진행중') return [234, 179, 8, 51]
const half = Math.round(markerSize / 2); return [100, 116, 139, 51]
},
getLineColor: (d: ScatSegment) => {
if (d.status === '완료') return [34, 197, 94, 200]
if (d.status === '진행중') return [234, 179, 8, 200]
return [100, 116, 139, 200]
},
getLineWidth: 1,
stroked: true,
radiusMinPixels: 4,
radiusMaxPixels: 22,
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object)
},
updateTriggers: {
getRadius: [zs.markerRadius],
},
})
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick])
const statusMarker = L.marker([seg.lat, seg.lng], { // eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: L.divIcon({ const deckLayers: any[] = useMemo(() => {
className: '', // eslint-disable-next-line @typescript-eslint/no-explicit-any
html: `<div style="width:${markerSize}px;height:${markerSize}px;border-radius:50%;background:${stBg};border:${markerBorder}px solid ${stColor};display:flex;align-items:center;justify-content:center;font-size:${markerFontSize}px;color:${stColor};transform:translate(-${half}px,-${half}px);backdrop-filter:blur(4px);box-shadow:0 0 ${Math.round(markerSize / 3)}px ${stBg}">${showStatusText ? stText : ''}</div>`, const layers: any[] = [coastlineLayer, glowLayer, segPathLayer]
iconSize: [0, 0], if (markerLayer) layers.push(markerLayer)
}), return layers
}); }, [coastlineLayer, glowLayer, segPathLayer, markerLayer])
statusMarker.on('click', () => { const doneCount = segments.filter(s => s.status === '완료').length
onSelectSeg(seg); const progCount = segments.filter(s => s.status === '진행중').length
onOpenPopup(seg.id); const totalLen = segments.reduce((a, s) => a + s.lengthM, 0)
}); const doneLen = segments.filter(s => s.status === '완료').reduce((a, s) => a + s.lengthM, 0)
markersRef.current!.addLayer(statusMarker);
}
});
}, [segments, selectedSeg, onSelectSeg, onOpenPopup, zoom]);
useEffect(() => {
if (!mapRef.current) return;
mapRef.current.flyTo([selectedSeg.lat, selectedSeg.lng], 12, { duration: 0.6 });
}, [selectedSeg]);
const doneCount = segments.filter((s) => s.status === '완료').length;
const progCount = segments.filter((s) => s.status === '진행중').length;
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0);
const doneLen = segments.filter((s) => s.status === '완료').reduce((a, s) => a + s.lengthM, 0);
const highSens = segments const highSens = segments
.filter((s) => s.sensitivity === '최상' || s.sensitivity === '상') .filter(s => s.sensitivity === '최상' || s.sensitivity === '상')
.reduce((a, s) => a + s.lengthM, 0); .reduce((a, s) => a + s.lengthM, 0)
const donePct = segments.length > 0 ? Math.round((doneCount / segments.length) * 100) : 0; const donePct = Math.round((doneCount / segments.length) * 100)
const progPct = segments.length > 0 ? Math.round((progCount / segments.length) * 100) : 0; const progPct = Math.round((progCount / segments.length) * 100)
const notPct = 100 - donePct - progPct; const notPct = 100 - donePct - progPct
return ( return (
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden">
<style>{` <Map
.scat-map-tooltip { initialViewState={{ longitude: 126.55, latitude: 33.38, zoom: 10 }}
background: rgba(15,21,36,0.92) !important; mapStyle={BASE_STYLE}
border: 1px solid rgba(30,42,66,0.8) !important; style={{ width: '100%', height: '100%' }}
color: #e4e8f1 !important; attributionControl={false}
border-radius: 6px !important; onZoom={e => setZoom(e.viewState.zoom)}
padding: 4px 8px !important; >
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important; <DeckGLOverlay layers={deckLayers} />
} <FlyToController selectedSeg={selectedSeg} />
.scat-map-tooltip::before { </Map>
border-top-color: rgba(15,21,36,0.92) !important;
} {/* 호버 툴팁 */}
`}</style> {tooltip && (
<div ref={mapContainerRef} className="w-full h-full" /> <div
className="pointer-events-none"
style={{
position: 'absolute',
left: tooltip.x + 12,
top: tooltip.y - 48,
background: 'rgba(15,21,36,0.92)',
border: '1px solid rgba(30,42,66,0.8)',
color: '#e4e8f1',
borderRadius: 6,
padding: '4px 8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
fontSize: 11,
fontFamily: "'Noto Sans KR', sans-serif",
zIndex: 1000,
whiteSpace: 'nowrap',
}}
>
<div style={{ fontWeight: 700 }}>
{tooltip.seg.code} {tooltip.seg.area}
</div>
<div style={{ fontSize: 10, opacity: 0.7 }}>
ESI {tooltip.seg.esi} · {tooltip.seg.length} ·{' '}
{tooltip.seg.status === '완료' ? '✓' : tooltip.seg.status === '진행중' ? '⏳' : '—'}{' '}
{tooltip.seg.status}
</div>
</div>
)}
{/* Status chips */} {/* Status chips */}
<div className="absolute top-3.5 left-3.5 flex gap-2 z-[1000]"> <div className="absolute top-3.5 left-3.5 flex gap-2 z-[1000]">
@ -221,9 +292,7 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
<div className="absolute top-3.5 right-3.5 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin"> <div className="absolute top-3.5 right-3.5 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
{/* ESI Legend */} {/* ESI Legend */}
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]"> <div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5"> <div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">ESI </div>
ESI
</div>
{[ {[
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' }, { esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' }, { esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
@ -235,10 +304,7 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' }, { esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
].map((item, i) => ( ].map((item, i) => (
<div key={i} className="flex items-center gap-2 py-1 text-[11px]"> <div key={i} className="flex items-center gap-2 py-1 text-[11px]">
<span <span className="w-3.5 h-1.5 rounded-sm flex-shrink-0" style={{ background: item.color }} />
className="w-3.5 h-1.5 rounded-sm flex-shrink-0"
style={{ background: item.color }}
/>
<span className="text-text-2 font-korean">{item.label}</span> <span className="text-text-2 font-korean">{item.label}</span>
<span className="ml-auto font-mono text-[10px] text-text-1">{item.esi}</span> <span className="ml-auto font-mono text-[10px] text-text-1">{item.esi}</span>
</div> </div>
@ -247,30 +313,15 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
{/* Progress */} {/* Progress */}
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]"> <div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5"> <div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5"> </div>
</div>
<div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1"> <div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
<div <div className="h-full transition-all duration-500" style={{ width: `${donePct}%`, background: 'var(--green)' }} />
className="h-full transition-all duration-500" <div className="h-full transition-all duration-500" style={{ width: `${progPct}%`, background: 'var(--orange)' }} />
style={{ width: `${donePct}%`, background: 'var(--green)' }} <div className="h-full transition-all duration-500" style={{ width: `${notPct}%`, background: 'var(--bd)' }} />
/>
<div
className="h-full transition-all duration-500"
style={{ width: `${progPct}%`, background: 'var(--orange)' }}
/>
<div
className="h-full transition-all duration-500"
style={{ width: `${notPct}%`, background: 'var(--bd)' }}
/>
</div> </div>
<div className="flex justify-between mt-1"> <div className="flex justify-between mt-1">
<span className="text-[9px] font-mono" style={{ color: 'var(--green)' }}> <span className="text-[9px] font-mono" style={{ color: 'var(--green)' }}> {donePct}%</span>
{donePct}% <span className="text-[9px] font-mono" style={{ color: 'var(--orange)' }}> {progPct}%</span>
</span>
<span className="text-[9px] font-mono" style={{ color: 'var(--orange)' }}>
{progPct}%
</span>
<span className="text-[9px] font-mono text-text-3"> {notPct}%</span> <span className="text-[9px] font-mono text-text-3"> {notPct}%</span>
</div> </div>
<div className="mt-2.5"> <div className="mt-2.5">
@ -278,21 +329,11 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''], ['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--green)'], ['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--green)'],
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--red)'], ['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--red)'],
[ ['방제 우선 구간', `${segments.filter(s => s.sensitivity === '최상').length}`, 'var(--orange)'],
'방제 우선 구간',
`${segments.filter((s) => s.sensitivity === '최상').length}`,
'var(--orange)',
],
].map(([label, val, color], i) => ( ].map(([label, val, color], i) => (
<div <div key={i} className="flex justify-between py-1.5 border-b border-[rgba(30,42,74,0.3)] last:border-b-0 text-[11px]">
key={i}
className="flex justify-between py-1.5 border-b border-[rgba(30,42,74,0.3)] last:border-b-0 text-[11px]"
>
<span className="text-text-2 font-korean">{label}</span> <span className="text-text-2 font-korean">{label}</span>
<span <span className="font-mono font-medium text-[11px]" style={{ color: color || undefined }}>
className="font-mono font-medium text-[11px]"
style={{ color: color || undefined }}
>
{val} {val}
</span> </span>
</div> </div>
@ -314,7 +355,7 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
</span> </span>
</div> </div>
</div> </div>
); )
} }
export default ScatMap; export default ScatMap

파일 보기

@ -1,88 +1,171 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect } from 'react'
import L from 'leaflet' import { Map, useControl } from '@vis.gl/react-maplibre'
import 'leaflet/dist/leaflet.css' import { MapboxOverlay } from '@deck.gl/mapbox'
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'
import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import type { ScatDetail } from './scatTypes' import type { ScatDetail } from './scatTypes'
import { hexToRgba } from '@common/components/map/mapUtils'
// ═══ Popup Map (Leaflet) ═══ // ── 베이스맵 스타일 ──────────────────────────────────────
const BASE_STYLE: StyleSpecification = {
function PopupMap({ lat, lng, esi, esiCol, code, name }: { lat: number; lng: number; esi: string; esiCol: string; code: string; name: string }) { version: 8,
const containerRef = useRef<HTMLDivElement>(null) sources: {
const mapRef = useRef<L.Map | null>(null) 'carto-dark': {
type: 'raster',
useEffect(() => { tiles: [
if (!containerRef.current) return 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
// 이전 맵 제거 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
if (mapRef.current) { mapRef.current.remove(); mapRef.current = null } 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
const map = L.map(containerRef.current, { tileSize: 256,
center: [lat, lng], },
zoom: 15, },
zoomControl: false, layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
attributionControl: false,
})
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 }).addTo(map)
L.control.zoom({ position: 'topright' }).addTo(map)
// 해안 구간 라인 (시뮬레이션)
const segLine: [number, number][] = [
[lat - 0.002, lng - 0.004],
[lat - 0.001, lng - 0.002],
[lat, lng],
[lat + 0.001, lng + 0.002],
[lat + 0.002, lng + 0.004],
]
L.polyline(segLine, { color: esiCol, weight: 5, opacity: 0.8 }).addTo(map)
// 조사 경로 라인
const surveyRoute: [number, number][] = [
[lat - 0.0015, lng - 0.003],
[lat - 0.0005, lng - 0.001],
[lat + 0.0005, lng + 0.001],
[lat + 0.0015, lng + 0.003],
]
L.polyline(surveyRoute, { color: '#3b82f6', weight: 2, opacity: 0.6, dashArray: '6, 4' }).addTo(map)
// 메인 마커
L.circleMarker([lat, lng], {
radius: 10, fillColor: esiCol, color: '#fff', weight: 2, fillOpacity: 0.9,
}).bindTooltip(
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
<div style="font-weight:700;font-size:11px;">${code} ${name}</div>
<div style="font-size:10px;opacity:0.7;">ESI ${esi}</div>
</div>`,
{ permanent: true, direction: 'top', offset: [0, -12], className: 'scat-map-tooltip' }
).addTo(map)
// 접근 포인트
L.circleMarker([lat - 0.0015, lng - 0.003], {
radius: 6, fillColor: '#eab308', color: '#eab308', weight: 1, fillOpacity: 0.7,
}).bindTooltip('접근 포인트', { direction: 'bottom', className: 'scat-map-tooltip' }).addTo(map)
mapRef.current = map
return () => { map.remove(); mapRef.current = null }
}, [lat, lng, esi, esiCol, code, name])
return <div ref={containerRef} className="w-full h-full" />
} }
// ═══ SCAT Popup Modal ═══ // ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
// ── PopupMap (미니맵) ────────────────────────────────────
function PopupMap({
lat,
lng,
esiCol,
code,
name,
esi,
}: {
lat: number
lng: number
esiCol: string
code: string
name: string
esi: string
}) {
// 해안 구간 라인 (시뮬레이션) — [lng, lat] 순서
const segLine: [number, number][] = [
[lng - 0.004, lat - 0.002],
[lng - 0.002, lat - 0.001],
[lng, lat],
[lng + 0.002, lat + 0.001],
[lng + 0.004, lat + 0.002],
]
// 조사 경로 라인
const surveyRoute: [number, number][] = [
[lng - 0.003, lat - 0.0015],
[lng - 0.001, lat - 0.0005],
[lng + 0.001, lat + 0.0005],
[lng + 0.003, lat + 0.0015],
]
const deckLayers = [
// 조사 경로 (파란 점선)
new PathLayer({
id: 'survey-route',
data: [{ path: surveyRoute }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [59, 130, 246, 153],
getWidth: 2,
getDashArray: [6, 4],
dashJustified: true,
widthMinPixels: 1,
}),
// 해안 구간 라인 (ESI 색상)
new PathLayer({
id: 'seg-line',
data: [{ path: segLine }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: hexToRgba(esiCol, 204),
getWidth: 5,
capRounded: true,
widthMinPixels: 3,
}),
// 접근 포인트 (노란 점)
new ScatterplotLayer({
id: 'access-point',
data: [{ position: [lng - 0.003, lat - 0.0015] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 6,
getFillColor: hexToRgba('#eab308', 178),
getLineColor: hexToRgba('#eab308', 200),
getLineWidth: 1,
stroked: true,
radiusMinPixels: 4,
radiusMaxPixels: 8,
}),
// 메인 마커 (ESI 색상 원)
new ScatterplotLayer({
id: 'main-marker',
data: [{ position: [lng, lat] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 10,
getFillColor: hexToRgba(esiCol, 229),
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 8,
radiusMaxPixels: 14,
}),
]
return (
<div className="relative w-full h-full">
<Map
key={`${lat}-${lng}`}
initialViewState={{ longitude: lng, latitude: lat, zoom: 15 }}
mapStyle={BASE_STYLE}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
<DeckGLOverlay layers={deckLayers} />
</Map>
{/* 마커 레이블 오버레이 */}
<div
className="pointer-events-none absolute"
style={{
left: '50%',
top: 'calc(50% - 44px)',
transform: 'translateX(-50%)',
background: 'rgba(15,21,36,0.92)',
border: '1px solid rgba(30,42,66,0.8)',
color: '#e4e8f1',
borderRadius: 6,
padding: '3px 7px',
fontSize: 10,
fontFamily: "'Noto Sans KR', sans-serif",
whiteSpace: 'nowrap',
zIndex: 10,
textAlign: 'center',
}}
>
<div style={{ fontWeight: 700, fontSize: 11 }}>{code} {name}</div>
<div style={{ opacity: 0.7 }}>ESI {esi}</div>
</div>
</div>
)
}
// ── ScatPopup Modal ──────────────────────────────────────
interface ScatPopupProps { interface ScatPopupProps {
data: ScatDetail | null data: ScatDetail | null
segCode: string segCode: string
onClose: () => void onClose: () => void
} }
function ScatPopup({ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
data,
segCode,
onClose,
}: ScatPopupProps) {
const [popTab, setPopTab] = useState(0) const [popTab, setPopTab] = useState(0)
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler) document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler)
}, [onClose]) }, [onClose])
@ -90,24 +173,42 @@ function ScatPopup({
if (!data) return null if (!data) return null
return ( return (
<div className="fixed inset-0 bg-[rgba(5,8,18,0.75)] backdrop-blur-md z-[9999] flex items-center justify-center" onClick={onClose}> <div
className="fixed inset-0 bg-[rgba(5,8,18,0.75)] backdrop-blur-md z-[9999] flex items-center justify-center"
onClick={onClose}
>
<div <div
className="w-[92%] max-w-[1200px] h-[90vh] bg-bg-1 border border-border rounded-xl shadow-[0_24px_64px_rgba(0,0,0,0.5)] flex flex-col overflow-hidden" className="w-[92%] max-w-[1200px] h-[90vh] bg-bg-1 border border-border rounded-xl shadow-[0_24px_64px_rgba(0,0,0,0.5)] flex flex-col overflow-hidden"
style={{ animation: 'spIn 0.3s ease' }} style={{ animation: 'spIn 0.3s ease' }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<style>{` <style>{`
@keyframes spIn { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } @keyframes spIn {
from { opacity: 0; transform: scale(0.95) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
`}</style> `}</style>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span className="text-[13px] font-bold text-status-green font-mono px-2.5 py-1 bg-[rgba(34,197,94,0.1)] border border-[rgba(34,197,94,0.25)] rounded-sm">{data.code}</span> <span className="text-[13px] font-bold text-status-green font-mono px-2.5 py-1 bg-[rgba(34,197,94,0.1)] border border-[rgba(34,197,94,0.25)] rounded-sm">
{data.code}
</span>
<span className="text-base font-bold font-korean">{data.name}</span> <span className="text-base font-bold font-korean">{data.name}</span>
<span className="text-[11px] font-bold px-2.5 py-0.5 rounded-xl text-white" style={{ background: data.esiColor }}>ESI {data.esi}</span> <span
className="text-[11px] font-bold px-2.5 py-0.5 rounded-xl text-white"
style={{ background: data.esiColor }}
>
ESI {data.esi}
</span>
</div> </div>
<button onClick={onClose} className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-[rgba(239,68,68,0.15)] hover:text-status-red hover:border-[rgba(239,68,68,0.3)] transition-colors text-lg"></button> <button
onClick={onClose}
className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-[rgba(239,68,68,0.15)] hover:text-status-red hover:border-[rgba(239,68,68,0.3)] transition-colors text-lg"
>
</button>
</div> </div>
{/* Tabs */} {/* Tabs */}
@ -117,7 +218,9 @@ function ScatPopup({
key={i} key={i}
onClick={() => setPopTab(i)} onClick={() => setPopTab(i)}
className={`px-5 py-3 text-xs font-semibold font-korean border-b-2 transition-colors cursor-pointer ${ className={`px-5 py-3 text-xs font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
popTab === i ? 'text-status-green border-status-green' : 'text-text-3 border-transparent hover:text-text-2' popTab === i
? 'text-status-green border-status-green'
: 'text-text-3 border-transparent hover:text-text-2'
}`} }`}
> >
{label} {label}
@ -137,7 +240,7 @@ function ScatPopup({
src={`/scat-photos/${segCode}-1.png`} src={`/scat-photos/${segCode}-1.png`}
alt={`${segCode} 해안 조사 사진`} alt={`${segCode} 해안 조사 사진`}
className="w-full h-auto object-contain" className="w-full h-auto object-contain"
onError={(e) => { onError={e => {
const target = e.currentTarget const target = e.currentTarget
target.style.display = 'none' target.style.display = 'none'
const fallback = target.nextElementSibling as HTMLElement const fallback = target.nextElementSibling as HTMLElement
@ -160,10 +263,30 @@ function ScatPopup({
</div> </div>
{[ {[
['유형', data.type, ''], ['유형', data.type, ''],
['기질', data.substrate, data.esiColor === '#dc2626' || data.esiColor === '#991b1b' ? 'text-status-red' : data.esiColor === '#f97316' ? 'text-status-orange' : ''], [
'기질',
data.substrate,
data.esiColor === '#dc2626' || data.esiColor === '#991b1b'
? 'text-status-red'
: data.esiColor === '#f97316'
? 'text-status-orange'
: '',
],
['구간 길이', data.length, ''], ['구간 길이', data.length, ''],
['민감도', data.sensitivity, data.sensitivity === '상' || data.sensitivity === '최상' ? 'text-status-red' : data.sensitivity === '중' ? 'text-status-orange' : 'text-status-green'], [
['조사 상태', data.status, data.status === '완료' ? 'text-status-green' : data.status === '진행중' ? 'text-status-orange' : ''], '민감도',
data.sensitivity,
data.sensitivity === '상' || data.sensitivity === '최상'
? 'text-status-red'
: data.sensitivity === '중'
? 'text-status-orange'
: 'text-status-green',
],
[
'조사 상태',
data.status,
data.status === '완료' ? 'text-status-green' : data.status === '진행중' ? 'text-status-orange' : '',
],
['접근성', data.access, ''], ['접근성', data.access, ''],
['접근 포인트', data.accessPt, ''], ['접근 포인트', data.accessPt, ''],
].map(([k, v, cls], i) => ( ].map(([k, v, cls], i) => (
@ -194,7 +317,9 @@ function ScatPopup({
</div> </div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{data.cleanup.map((c, i) => ( {data.cleanup.map((c, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-0.5 bg-[rgba(255,255,255,0.06)] border border-[rgba(255,255,255,0.10)] rounded text-[10px] text-text-1 font-medium font-korean">{c}</span> <span key={i} className="inline-flex items-center gap-1 px-2 py-0.5 bg-[rgba(255,255,255,0.06)] border border-[rgba(255,255,255,0.10)] rounded text-[10px] text-text-1 font-medium font-korean">
{c}
</span>
))} ))}
</div> </div>
</div> </div>
@ -230,11 +355,18 @@ function ScatPopup({
</div> </div>
</div> </div>
{/* Right column - Satellite map */} {/* Right column - Mini map */}
<div className="flex-1 overflow-y-auto p-5 px-6 scrollbar-thin"> <div className="flex-1 overflow-y-auto p-5 px-6 scrollbar-thin">
{/* Leaflet Map */} {/* MapLibre 미니맵 */}
<div className="w-full aspect-[4/3] bg-bg-0 border border-border rounded-md mb-4 overflow-hidden relative"> <div className="w-full aspect-[4/3] bg-bg-0 border border-border rounded-md mb-4 overflow-hidden relative">
<PopupMap lat={data.lat} lng={data.lng} esi={data.esi} esiCol={data.esiColor} code={data.code} name={data.name} /> <PopupMap
lat={data.lat}
lng={data.lng}
esi={data.esi}
esiCol={data.esiColor}
code={data.code}
name={data.name}
/>
</div> </div>
{/* Legend */} {/* Legend */}
@ -296,17 +428,29 @@ function ScatPopup({
{popTab === 1 && ( {popTab === 1 && (
<div className="p-6 overflow-y-auto h-full scrollbar-thin"> <div className="p-6 overflow-y-auto h-full scrollbar-thin">
<div className="text-sm font-bold font-korean mb-4">{data.code} {data.name} </div> <div className="text-sm font-bold font-korean mb-4">
{data.code} {data.name}
</div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{[ {[
{ date: '2026-01-15', team: '제주해경 방제과', type: 'Pre-SCAT', status: '완료', note: '초기 사전조사 실시. ESI 확인.' }, {
date: '2026-01-15',
team: '제주해경 방제과',
type: 'Pre-SCAT',
status: '완료',
note: '초기 사전조사 실시. ESI 확인.',
},
].map((h, i) => ( ].map((h, i) => (
<div key={i} className="bg-bg-3 border border-border rounded-md p-4"> <div key={i} className="bg-bg-3 border border-border rounded-md p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold font-mono">{h.date}</span> <span className="text-xs font-bold font-mono">{h.date}</span>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-lg ${ <span
h.type === 'Pre-SCAT' ? 'bg-[rgba(34,197,94,0.15)] text-status-green' : 'bg-[rgba(59,130,246,0.15)] text-primary-blue' className={`text-[10px] font-bold px-2 py-0.5 rounded-lg ${
}`}> h.type === 'Pre-SCAT'
? 'bg-[rgba(34,197,94,0.15)] text-status-green'
: 'bg-[rgba(59,130,246,0.15)] text-primary-blue'
}`}
>
{h.type} {h.type}
</span> </span>
</div> </div>

파일 보기

@ -1,12 +1,13 @@
import { useEffect } from 'react' import { useMemo } from 'react'
import { useMap } from 'react-leaflet' import { ScatterplotLayer } from '@deck.gl/layers'
import L from 'leaflet' import type { Layer } from '@deck.gl/core'
import { hexToRgba } from '@common/components/map/mapUtils'
interface OceanCurrentData { interface OceanCurrentData {
lat: number lat: number
lon: number lon: number
direction: number // 0-360도 direction: number // 0-360도
speed: number // m/s speed: number // m/s
} }
interface OceanCurrentLayerProps { interface OceanCurrentLayerProps {
@ -16,7 +17,6 @@ interface OceanCurrentLayerProps {
// 한반도 육지 영역 판별 (간략화된 폴리곤) // 한반도 육지 영역 판별 (간략화된 폴리곤)
const isOnLand = (lat: number, lon: number): boolean => { const isOnLand = (lat: number, lon: number): boolean => {
// 한반도 본토 영역 (간략화)
const peninsula: [number, number][] = [ const peninsula: [number, number][] = [
[38.5, 124.5], [38.5, 128.3], [38.5, 124.5], [38.5, 128.3],
[37.8, 128.8], [37.0, 129.2], [37.8, 128.8], [37.0, 129.2],
@ -43,31 +43,27 @@ const isOnLand = (lat: number, lon: number): boolean => {
return inside return inside
} }
// 한국 해역의 대략적인 해류 데이터 (실제로는 API에서 가져와야 함) // 한국 해역의 대략적인 해류 데이터 생성
const generateOceanCurrentData = (): OceanCurrentData[] => { const generateOceanCurrentData = (): OceanCurrentData[] => {
const data: OceanCurrentData[] = [] const data: OceanCurrentData[] = []
// 격자 형태로 해류 데이터 생성 (실제로는 API에서 받아올 데이터)
for (let lat = 33.5; lat <= 38.0; lat += 0.8) { for (let lat = 33.5; lat <= 38.0; lat += 0.8) {
for (let lon = 125.0; lon <= 130.5; lon += 0.8) { for (let lon = 125.0; lon <= 130.5; lon += 0.8) {
// 육지 위의 포인트는 제외 (바다에만 표시)
if (isOnLand(lat, lon)) continue if (isOnLand(lat, lon)) continue
// 간단한 해류 패턴 시뮬레이션
// 동해: 북동진, 서해: 북진, 남해: 동진
let direction = 0 let direction = 0
let speed = 0.3 let speed = 0.3
if (lon > 128.5) { if (lon > 128.5) {
// 동해 - 북동진하는 동한난류 // 동해 북동진하는 동한난류
direction = 30 + Math.random() * 20 direction = 30 + Math.random() * 20
speed = 0.4 + Math.random() * 0.3 speed = 0.4 + Math.random() * 0.3
} else if (lon < 126.5) { } else if (lon < 126.5) {
// 서해 - 북진 // 서해 북진
direction = 350 + Math.random() * 20 direction = 350 + Math.random() * 20
speed = 0.2 + Math.random() * 0.2 speed = 0.2 + Math.random() * 0.2
} else { } else {
// 남해 - 동진 // 남해 동진
direction = 80 + Math.random() * 20 direction = 80 + Math.random() * 20
speed = 0.3 + Math.random() * 0.3 speed = 0.3 + Math.random() * 0.3
} }
@ -79,81 +75,74 @@ const generateOceanCurrentData = (): OceanCurrentData[] => {
return data return data
} }
// SVG 화살표 생성 // 속도에 따른 hex 색상
const createArrowSvg = (speed: number, color: string): string => { function getCurrentHexColor(speed: number): string {
const length = Math.min(20 + speed * 30, 50) if (speed > 0.5) return '#ef4444'
const width = 2 if (speed > 0.3) return '#f59e0b'
return '#3b82f6'
return `
<svg width="${length}" height="${length}" viewBox="0 0 ${length} ${length}" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arrowhead-${color.replace('#', '')}" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<polygon points="0 0, 10 3, 0 6" fill="${color}" />
</marker>
</defs>
<line
x1="${length / 2}"
y1="${length}"
x2="${length / 2}"
y2="${length * 0.3}"
stroke="${color}"
stroke-width="${width}"
marker-end="url(#arrowhead-${color.replace('#', '')})"
opacity="0.8"
/>
</svg>
`
} }
export function OceanCurrentLayer({ visible, opacity = 0.7 }: OceanCurrentLayerProps) { // 해류 데이터는 컴포넌트 외부에서 한 번만 생성 (랜덤이므로 안정화)
const map = useMap() const OCEAN_CURRENT_DATA = generateOceanCurrentData()
useEffect(() => { // eslint-disable-next-line react-refresh/only-export-components
if (!visible) return /**
* OceanCurrentLayer deck.gl ScatterplotLayer
*
* 기존: Leaflet Marker + SVG DivIcon + CSS rotate
* 전환: ScatterplotLayer (=, =)
*
* 한계: deck.gl ScatterplotLayer는
* - (0~90°):
* - (90~180°):
* - (180~270°):
* - (270~360°):
* (5~20km)
*/
// eslint-disable-next-line react-refresh/only-export-components
export function useOceanCurrentLayers(props: OceanCurrentLayerProps): Layer[] {
const { visible, opacity = 0.7 } = props
const currentData = generateOceanCurrentData() return useMemo(() => {
const markers: L.Marker[] = [] if (!visible) return []
currentData.forEach((current) => { const data = OCEAN_CURRENT_DATA.map((c) => ({
// 속도에 따른 색상 결정 position: [c.lon, c.lat] as [number, number],
const color = // 반경: 속도 비례 (5~20km)
current.speed > 0.5 ? '#ef4444' : current.speed > 0.3 ? '#f59e0b' : '#3b82f6' radius: 5000 + c.speed * 25000,
fillColor: hexToRgba(getCurrentHexColor(c.speed), Math.round(opacity * 180)),
lineColor: hexToRgba(getCurrentHexColor(c.speed), Math.round(opacity * 230)),
}))
const arrowSvg = createArrowSvg(current.speed, color) return [
const svgUrl = `data:image/svg+xml;base64,${btoa(arrowSvg)}` new ScatterplotLayer({
id: 'ocean-current-layer',
const icon = L.icon({ data,
iconUrl: svgUrl, getPosition: (d) => d.position,
iconSize: [40, 40], getRadius: (d) => d.radius,
iconAnchor: [20, 20] getFillColor: (d) => d.fillColor,
}) getLineColor: (d) => d.lineColor,
getLineWidth: 1,
const marker = L.marker([current.lat, current.lon], { stroked: true,
icon, radiusUnits: 'meters',
interactive: false, pickable: false,
// 회전 각도 적용 updateTriggers: {
rotationAngle: current.direction getFillColor: [opacity],
// eslint-disable-next-line @typescript-eslint/no-explicit-any getLineColor: [opacity],
} as any) },
}) as unknown as Layer,
// CSS로 회전 적용 ]
marker.on('add', () => { }, [visible, opacity])
const element = marker.getElement() }
if (element) {
element.style.transform += ` rotate(${current.direction}deg)`
element.style.opacity = opacity.toString()
}
})
marker.addTo(map)
markers.push(marker)
})
// Cleanup
return () => {
markers.forEach((marker) => marker.remove())
}
}, [map, visible, opacity])
/**
* OceanCurrentLayer React (null , layers는 useOceanCurrentLayers로 )
*
* WeatherView에서 deck.gl layers useOceanCurrentLayers() .
* Leaflet .
*/
export function OceanCurrentLayer(props: OceanCurrentLayerProps) {
// visible 상태 변화에 따른 side-effect 없음 — 렌더링은 useOceanCurrentLayers 훅이 담당
void props
return null return null
} }

파일 보기

@ -1,6 +1,5 @@
import { ImageOverlay, useMap } from 'react-leaflet'
import { LatLngBounds } from 'leaflet'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Source, Layer } from '@vis.gl/react-maplibre'
import type { OceanForecastData } from '../services/khoaApi' import type { OceanForecastData } from '../services/khoaApi'
interface OceanForecastOverlayProps { interface OceanForecastOverlayProps {
@ -9,48 +8,62 @@ interface OceanForecastOverlayProps {
visible?: boolean visible?: boolean
} }
// 한국 해역 범위 (대략적인 경계) // 한국 해역 범위 (MapLibre image source용 좌표 배열)
const KOREA_BOUNDS = new LatLngBounds( // [left, bottom, right, top] → MapLibre coordinates 순서: [sw, nw, ne, se]
[33.0, 124.5], // 남서쪽 (제주 남쪽) // [lon, lat] 순서
[38.5, 132.0] // 북동쪽 (동해 북쪽) const KOREA_IMAGE_COORDINATES: [[number, number], [number, number], [number, number], [number, number]] = [
) [124.5, 33.0], // 남서 (제주 남쪽)
[124.5, 38.5], // 북서
[132.0, 38.5], // 북동 (동해 북쪽)
[132.0, 33.0], // 남동
]
/**
* OceanForecastOverlay
*
* 기존: react-leaflet ImageOverlay + LatLngBounds
* : @vis.gl/react-maplibre Source(type=image) + Layer(type=raster)
*
* MapLibre image source는 Map
*/
export function OceanForecastOverlay({ export function OceanForecastOverlay({
forecast, forecast,
opacity = 0.6, opacity = 0.6,
visible = true visible = true,
}: OceanForecastOverlayProps) { }: OceanForecastOverlayProps) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars const [loadedUrl, setLoadedUrl] = useState<string | null>(null)
const map = useMap()
const [imageLoaded, setImageLoaded] = useState(false)
useEffect(() => { useEffect(() => {
if (forecast?.filePath) { if (!forecast?.filePath) return
// 이미지 미리 로드 let cancelled = false
const img = new Image() const img = new Image()
img.onload = () => setImageLoaded(true) img.onload = () => { if (!cancelled) setLoadedUrl(forecast.filePath) }
img.onerror = () => setImageLoaded(false) img.onerror = () => { if (!cancelled) setLoadedUrl(null) }
img.src = forecast.filePath img.src = forecast.filePath
} else { return () => { cancelled = true }
// eslint-disable-next-line react-hooks/set-state-in-effect
setImageLoaded(false)
}
}, [forecast?.filePath]) }, [forecast?.filePath])
const imageLoaded = !!loadedUrl && loadedUrl === forecast?.filePath
if (!visible || !forecast || !imageLoaded) { if (!visible || !forecast || !imageLoaded) {
return null return null
} }
return ( return (
<ImageOverlay <Source
id="ocean-forecast-image"
type="image"
url={forecast.filePath} url={forecast.filePath}
bounds={KOREA_BOUNDS} coordinates={KOREA_IMAGE_COORDINATES}
opacity={opacity} >
zIndex={400} // 지도 위에 표시되지만 마커보다는 아래 <Layer
eventHandlers={{ id="ocean-forecast-raster"
load: () => console.log('해황예보도 이미지 로드 완료'), type="raster"
error: () => console.error('해황예보도 이미지 로드 실패') paint={{
}} 'raster-opacity': opacity,
/> 'raster-resampling': 'linear',
}}
/>
</Source>
) )
} }

파일 보기

@ -1,27 +1,24 @@
import { useEffect, useRef } from 'react' import { useMemo } from 'react'
import { useMap } from 'react-leaflet' import { HeatmapLayer } from '@deck.gl/aggregation-layers'
import L from 'leaflet' import type { Layer } from '@deck.gl/core'
interface WaterTemperatureLayerProps { interface WaterTemperatureLayerProps {
visible: boolean visible: boolean
opacity?: number opacity?: number
} }
// 수온 데이터 생성 (실제로는 API에서 받아야 함)
interface TemperaturePoint { interface TemperaturePoint {
lat: number lat: number
lon: number lon: number
temp: number temp: number
} }
// 수온 데이터 생성 (실제로는 API에서 받아야 함)
const generateTemperatureData = (): TemperaturePoint[] => { const generateTemperatureData = (): TemperaturePoint[] => {
const data: TemperaturePoint[] = [] const data: TemperaturePoint[] = []
// 격자 형태로 수온 데이터 생성
for (let lat = 33.0; lat <= 38.5; lat += 0.3) { for (let lat = 33.0; lat <= 38.5; lat += 0.3) {
for (let lon = 124.5; lon <= 131.0; lon += 0.3) { for (let lon = 124.5; lon <= 131.0; lon += 0.3) {
// 간단한 수온 패턴 시뮬레이션
// 남쪽이 따뜻하고, 동해가 서해보다 차가움
let temp = 8.0 let temp = 8.0
// 위도에 따른 온도 (남쪽이 따뜻함) // 위도에 따른 온도 (남쪽이 따뜻함)
@ -34,8 +31,8 @@ const generateTemperatureData = (): TemperaturePoint[] => {
temp += 1.0 temp += 1.0
} }
// 약간의 랜덤성 // 약간의 분포 변화
temp += (Math.random() - 0.5) * 1.5 temp += Math.sin(lat * 3.14) * 0.5 + Math.cos(lon * 2.0) * 0.5
data.push({ lat, lon, temp }) data.push({ lat, lon, temp })
} }
@ -44,127 +41,74 @@ const generateTemperatureData = (): TemperaturePoint[] => {
return data return data
} }
// 온도에 따른 색상 반환 // 온도 데이터는 컴포넌트 외부에서 한 번만 생성 (안정화)
const getColorForTemperature = (temp: number): string => { const TEMPERATURE_DATA = generateTemperatureData()
// 4°C (진한 파랑) ~ 16°C (빨강) 범위
if (temp <= 5) return 'rgba(0, 0, 139, 0.4)' // 진한 파랑 // temp → HeatmapLayer weight (0~1 정규화, 범위 4~16°C)
if (temp <= 7) return 'rgba(0, 68, 204, 0.4)' // 파랑 function tempToWeight(temp: number): number {
if (temp <= 9) return 'rgba(51, 153, 255, 0.4)' // 하늘색 const MIN_TEMP = 4
if (temp <= 11) return 'rgba(102, 204, 102, 0.4)' // 연두색 const MAX_TEMP = 16
if (temp <= 13) return 'rgba(255, 255, 102, 0.4)' // 노랑 return Math.max(0, Math.min(1, (temp - MIN_TEMP) / (MAX_TEMP - MIN_TEMP)))
if (temp <= 15) return 'rgba(255, 153, 51, 0.4)' // 주황
return 'rgba(255, 68, 68, 0.4)' // 빨강
} }
// Custom canvas layer /**
class TemperatureCanvasLayer extends L.Layer { * useWaterTemperatureLayers deck.gl HeatmapLayer
private canvas: HTMLCanvasElement | null = null *
private data: TemperaturePoint[] * 기존: L.Layer + Canvas RadialGradient
private layerOpacity: number * : @deck.gl/aggregation-layers HeatmapLayer
*
* ( ):
* ()
*/
// eslint-disable-next-line react-refresh/only-export-components
export function useWaterTemperatureLayers(props: WaterTemperatureLayerProps): Layer[] {
const { visible, opacity = 0.5 } = props
constructor(data: TemperaturePoint[], opacity: number) { return useMemo(() => {
super() if (!visible) return []
this.data = data
this.layerOpacity = opacity
}
onAdd(map: L.Map): this { const data = TEMPERATURE_DATA.map((p) => ({
this.canvas = L.DomUtil.create('canvas', 'temperature-canvas-layer') position: [p.lon, p.lat] as [number, number],
const size = map.getSize() weight: tempToWeight(p.temp),
this.canvas.width = size.x }))
this.canvas.height = size.y
this.canvas.style.position = 'absolute'
this.canvas.style.pointerEvents = 'none'
this.canvas.style.zIndex = '400'
map.getPanes().overlayPane?.appendChild(this.canvas) return [
new HeatmapLayer({
map.on('moveend zoom', this.redraw, this) id: 'water-temperature-heatmap',
this.redraw() data,
getPosition: (d) => d.position,
return this getWeight: (d) => d.weight,
} radiusPixels: 40,
intensity: 1,
onRemove(map: L.Map): this { threshold: 0.03,
if (this.canvas && this.canvas.parentNode) { opacity,
this.canvas.parentNode.removeChild(this.canvas) colorRange: [
} // 진한 파랑 (차가움)
map.off('moveend zoom', this.redraw, this) [0, 0, 139, 255],
this.canvas = null // 파랑
return this [0, 68, 204, 255],
} // 하늘색
[51, 153, 255, 255],
redraw = () => { // 연두색
if (!this.canvas) return [102, 204, 102, 255],
// 노랑
const map = this._map [255, 255, 102, 255],
if (!map) return // 주황
[255, 153, 51, 255],
const size = map.getSize() // 빨강 (따뜻함)
this.canvas.width = size.x [255, 68, 68, 255],
this.canvas.height = size.y ] as [number, number, number, number][],
}) as unknown as Layer,
const ctx = this.canvas.getContext('2d') ]
if (!ctx) return }, [visible, opacity])
ctx.clearRect(0, 0, size.x, size.y)
// 각 온도 포인트를 그림
this.data.forEach((point) => {
const latLng = L.latLng(point.lat, point.lon)
const pixelPoint = map.latLngToContainerPoint(latLng)
const radius = 25 // 각 포인트의 영향 반경
const gradient = ctx.createRadialGradient(
pixelPoint.x,
pixelPoint.y,
0,
pixelPoint.x,
pixelPoint.y,
radius
)
const color = getColorForTemperature(point.temp)
gradient.addColorStop(0, color)
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)')
ctx.fillStyle = gradient
ctx.globalAlpha = this.layerOpacity
ctx.fillRect(
pixelPoint.x - radius,
pixelPoint.y - radius,
radius * 2,
radius * 2
)
})
}
} }
export function WaterTemperatureLayer({ visible, opacity = 0.5 }: WaterTemperatureLayerProps) { /**
const map = useMap() * WaterTemperatureLayer React (null )
const layerRef = useRef<TemperatureCanvasLayer | null>(null) *
* WeatherView에서 useWaterTemperatureLayers() DeckGLOverlay layers에 .
useEffect(() => { * Leaflet .
if (!visible) { */
if (layerRef.current) { export function WaterTemperatureLayer() {
map.removeLayer(layerRef.current)
layerRef.current = null
}
return
}
const temperatureData = generateTemperatureData()
const layer = new TemperatureCanvasLayer(temperatureData, opacity)
layer.addTo(map)
layerRef.current = layer
return () => {
if (layerRef.current) {
map.removeLayer(layerRef.current)
layerRef.current = null
}
}
}, [map, visible, opacity])
return null return null
} }

파일 보기

@ -1,5 +1,8 @@
import { Circle, Marker } from 'react-leaflet' import { useMemo } from 'react'
import { DivIcon } from 'leaflet' import { Marker } from '@vis.gl/react-maplibre'
import { ScatterplotLayer } from '@deck.gl/layers'
import type { Layer } from '@deck.gl/core'
import { hexToRgba } from '@common/components/map/mapUtils'
interface WeatherStation { interface WeatherStation {
id: string id: string
@ -19,6 +22,8 @@ interface WeatherStation {
current: number current: number
feelsLike: number feelsLike: number
} }
pressure: number
visibility: number
} }
interface WeatherMapOverlayProps { interface WeatherMapOverlayProps {
@ -28,244 +33,350 @@ interface WeatherMapOverlayProps {
selectedStationId: string | null selectedStationId: string | null
} }
// Create wind arrow icon // 풍속에 따른 hex 색상 반환
const createWindArrowIcon = (speed: number, direction: number, isSelected: boolean) => { function getWindHexColor(speed: number, isSelected: boolean): string {
const size = Math.min(40 + speed * 2, 80) if (isSelected) return '#06b6d4'
const color = isSelected ? '#06b6d4' : speed > 10 ? '#ef4444' : speed > 7 ? '#f59e0b' : '#3b82f6' if (speed > 10) return '#ef4444'
if (speed > 7) return '#f59e0b'
return new DivIcon({ return '#3b82f6'
html: `
<div style="
width: ${size}px;
height: ${size}px;
transform: rotate(${direction}deg);
display: flex;
align-items: center;
justify-content: center;
">
<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));">
<path
d="M12 2L12 20M12 2L8 6M12 2L16 6M12 20L8 16M12 20L16 16"
stroke="${color}"
stroke-width="2.5"
fill="none"
stroke-linecap="round"
/>
<circle cx="12" cy="12" r="3" fill="${color}" opacity="0.8"/>
</svg>
</div>
`,
className: 'wind-arrow-icon',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
})
} }
// Create weather data label icon (enhanced style similar to KHOA) // 파고에 따른 hex 색상 반환
const createWeatherLabelIcon = (station: WeatherStation, isSelected: boolean) => { function getWaveHexColor(height: number): string {
const boxBg = isSelected ? 'rgba(6, 182, 212, 0.85)' : 'rgba(255, 255, 255, 0.1)' if (height > 2.5) return '#ef4444'
const boxBorder = isSelected ? '#06b6d4' : 'rgba(255, 255, 255, 0.3)' if (height > 1.5) return '#f59e0b'
const boxShadow = '0 2px 8px rgba(0,0,0,0.3)' return '#3b82f6'
return new DivIcon({
html: `
<div style="
background: ${boxBg};
border: 2px solid ${boxBorder};
border-radius: 10px;
padding: 8px;
font-family: system-ui, -apple-system, sans-serif;
box-shadow: ${boxShadow};
display: flex;
flex-direction: column;
gap: 4px;
min-width: 70px;
">
<!-- -->
<div style="
text-align: center;
font-size: 12px;
font-weight: bold;
color: ${isSelected ? '#000' : '#fff'};
text-shadow: 1px 1px 3px rgba(0,0,0,0.7);
padding-bottom: 4px;
border-bottom: 1px solid ${isSelected ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.3)'};
margin-bottom: 2px;
">${station.name}</div>
<!-- -->
<div style="display: flex; align-items: center; gap: 6px;">
<div style="
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: white;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
">🌡</div>
<div style="display: flex; align-items: baseline; gap: 2px;">
<span style="font-size: 14px; font-weight: bold; color: #fff; text-shadow: 1px 1px 2px rgba(0,0,0,0.5);">${station.temperature.current.toFixed(1)}</span>
<span style="font-size: 10px; color: #fff; opacity: 0.9; text-shadow: 1px 1px 2px rgba(0,0,0,0.5);">°C</span>
</div>
</div>
<!-- -->
<div style="display: flex; align-items: center; gap: 6px;">
<div style="
background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%);
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: white;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
">🌊</div>
<div style="display: flex; align-items: baseline; gap: 2px;">
<span style="font-size: 14px; font-weight: bold; color: #fff; text-shadow: 1px 1px 2px rgba(0,0,0,0.5);">${station.wave.height.toFixed(1)}</span>
<span style="font-size: 10px; color: #fff; opacity: 0.9; text-shadow: 1px 1px 2px rgba(0,0,0,0.5);">m</span>
</div>
</div>
<!-- -->
<div style="display: flex; align-items: center; gap: 6px;">
<div style="
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: white;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
">💨</div>
<div style="display: flex; align-items: baseline; gap: 2px;">
<span style="font-size: 14px; font-weight: bold; color: #fff; text-shadow: 1px 1px 2px rgba(0,0,0,0.5);">${station.wind.speed.toFixed(1)}</span>
<span style="font-size: 10px; color: #fff; opacity: 0.9; text-shadow: 1px 1px 2px rgba(0,0,0,0.5);">m/s</span>
</div>
</div>
</div>
`,
className: 'weather-label-icon',
iconSize: [100, 130],
iconAnchor: [50, 65]
})
} }
// 수온에 따른 hex 색상 반환
function getTempHexColor(temp: number): string {
if (temp > 8) return '#ef4444'
if (temp > 6) return '#f59e0b'
return '#3b82f6'
}
/**
* WeatherMapOverlay
*
* - deck.gl (ScatterplotLayer) layers prop으로
* - (HTML) MapLibre Marker로
* - CSS rotate가 MapLibre Marker로
*/
export function WeatherMapOverlay({ export function WeatherMapOverlay({
stations, stations,
enabledLayers, enabledLayers,
onStationClick, onStationClick,
selectedStationId selectedStationId,
}: WeatherMapOverlayProps) { }: WeatherMapOverlayProps) {
// deck.gl 레이어는 useWeatherDeckLayers 훅을 통해 외부로 전달되므로
// 이 컴포넌트는 HTML 오버레이(Marker) 부분만 담당
return ( return (
<> <>
{/* Wind Vectors */} {/* 풍향 화살표 — MapLibre Marker + CSS rotate */}
{enabledLayers.has('wind') && {enabledLayers.has('wind') &&
stations.map((station) => { stations.map((station) => {
const isSelected = selectedStationId === station.id const isSelected = selectedStationId === station.id
const color = getWindHexColor(station.wind.speed, isSelected)
const size = Math.min(40 + station.wind.speed * 2, 80)
return ( return (
<Marker <Marker
key={`wind-${station.id}`} key={`wind-${station.id}`}
position={[station.location.lat, station.location.lon]} longitude={station.location.lon}
icon={createWindArrowIcon(station.wind.speed, station.wind.direction, isSelected)} latitude={station.location.lat}
eventHandlers={{ anchor="center"
click: () => onStationClick(station) onClick={() => onStationClick(station)}
}} >
/> <div
style={{
width: size,
height: size,
transform: `rotate(${station.wind.direction}deg)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
>
<path
d="M12 2L12 20M12 2L8 6M12 2L16 6M12 20L8 16M12 20L16 16"
stroke={color}
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
/>
<circle cx="12" cy="12" r="3" fill={color} opacity="0.8" />
</svg>
</div>
</Marker>
) )
})} })}
{/* Weather Data Labels */} {/* 기상 데이터 라벨 — MapLibre Marker */}
{enabledLayers.has('labels') && {enabledLayers.has('labels') &&
stations.map((station) => { stations.map((station) => {
const isSelected = selectedStationId === station.id const isSelected = selectedStationId === station.id
const boxBg = isSelected ? 'rgba(6, 182, 212, 0.85)' : 'rgba(255, 255, 255, 0.1)'
const boxBorder = isSelected ? '#06b6d4' : 'rgba(255, 255, 255, 0.3)'
const textColor = isSelected ? '#000' : '#fff'
return ( return (
<Marker <Marker
key={`label-${station.id}`} key={`label-${station.id}`}
position={[station.location.lat, station.location.lon]} longitude={station.location.lon}
icon={createWeatherLabelIcon(station, isSelected)} latitude={station.location.lat}
eventHandlers={{ anchor="center"
click: () => onStationClick(station) onClick={() => onStationClick(station)}
}} >
/> <div
) style={{
})} background: boxBg,
border: `2px solid ${boxBorder}`,
borderRadius: 10,
padding: 8,
fontFamily: 'system-ui, -apple-system, sans-serif',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
display: 'flex',
flexDirection: 'column',
gap: 4,
minWidth: 70,
cursor: 'pointer',
}}
>
{/* 관측소명 */}
<div
style={{
textAlign: 'center',
fontSize: 12,
fontWeight: 'bold',
color: textColor,
textShadow: '1px 1px 3px rgba(0,0,0,0.7)',
paddingBottom: 4,
borderBottom: `1px solid ${isSelected ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.3)'}`,
marginBottom: 2,
}}
>
{station.name}
</div>
{/* Wave Height Circles */} {/* 수온 */}
{enabledLayers.has('waves') && <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
stations.map((station) => { <div
// Color based on wave height style={{
const waveColor = background: 'linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%)',
station.wave.height > 2.5 width: 24,
? '#ef4444' height: 24,
: station.wave.height > 1.5 borderRadius: '50%',
? '#f59e0b' display: 'flex',
: '#3b82f6' alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'white',
fontWeight: 'bold',
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
}}
>
🌡
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span
style={{
fontSize: 14,
fontWeight: 'bold',
color: '#fff',
textShadow: '1px 1px 2px rgba(0,0,0,0.5)',
}}
>
{station.temperature.current.toFixed(1)}
</span>
<span
style={{
fontSize: 10,
color: '#fff',
opacity: 0.9,
textShadow: '1px 1px 2px rgba(0,0,0,0.5)',
}}
>
°C
</span>
</div>
</div>
const radius = station.wave.height * 15000 // Scale for visualization {/* 파고 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div
style={{
background: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)',
width: 24,
height: 24,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'white',
fontWeight: 'bold',
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
}}
>
🌊
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span
style={{
fontSize: 14,
fontWeight: 'bold',
color: '#fff',
textShadow: '1px 1px 2px rgba(0,0,0,0.5)',
}}
>
{station.wave.height.toFixed(1)}
</span>
<span
style={{
fontSize: 10,
color: '#fff',
opacity: 0.9,
textShadow: '1px 1px 2px rgba(0,0,0,0.5)',
}}
>
m
</span>
</div>
</div>
return ( {/* 풍속 */}
<Circle <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
key={`wave-${station.id}`} <div
center={[station.location.lat, station.location.lon]} style={{
radius={radius} background: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
pathOptions={{ width: 24,
fillColor: waveColor, height: 24,
fillOpacity: 0.15, borderRadius: '50%',
color: waveColor, display: 'flex',
weight: 2, alignItems: 'center',
opacity: 0.6 justifyContent: 'center',
}} fontSize: 11,
eventHandlers={{ color: 'white',
click: () => onStationClick(station) fontWeight: 'bold',
}} boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
/> }}
) >
})} 💨
</div>
{/* Temperature Circles */} <div style={{ display: 'flex', alignItems: 'baseline', gap: 2 }}>
{enabledLayers.has('temperature') && <span
stations.map((station) => { style={{
// Color based on temperature fontSize: 14,
const tempColor = fontWeight: 'bold',
station.temperature.current > 8 color: '#fff',
? '#ef4444' textShadow: '1px 1px 2px rgba(0,0,0,0.5)',
: station.temperature.current > 6 }}
? '#f59e0b' >
: '#3b82f6' {station.wind.speed.toFixed(1)}
</span>
const radius = 10000 // Fixed size for temp <span
style={{
return ( fontSize: 10,
<Circle color: '#fff',
key={`temp-${station.id}`} opacity: 0.9,
center={[station.location.lat, station.location.lon]} textShadow: '1px 1px 2px rgba(0,0,0,0.5)',
radius={radius} }}
pathOptions={{ >
fillColor: tempColor, m/s
fillOpacity: 0.2, </span>
color: tempColor, </div>
weight: 1.5, </div>
opacity: 0.5 </div>
}} </Marker>
eventHandlers={{
click: () => onStationClick(station)
}}
/>
) )
})} })}
</> </>
) )
} }
/**
* WeatherMapOverlay에 deck.gl
* WeatherView의 DeckGLOverlay layers spread하여
*/
// eslint-disable-next-line react-refresh/only-export-components
export function useWeatherDeckLayers(
stations: WeatherStation[],
enabledLayers: Set<string>,
selectedStationId: string | null,
onStationClick: (station: WeatherStation) => void
): Layer[] {
return useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: Layer[] = []
// 파고 분포 ScatterplotLayer (Circle 대체, 반경 = 파고 * 15km)
if (enabledLayers.has('waves')) {
const waveData = stations.map((s) => ({
position: [s.location.lon, s.location.lat] as [number, number],
radius: s.wave.height * 15000,
fillColor: hexToRgba(getWaveHexColor(s.wave.height), 38), // fillOpacity 0.15
lineColor: hexToRgba(getWaveHexColor(s.wave.height), 153), // opacity 0.6
station: s,
}))
result.push(
new ScatterplotLayer({
id: 'weather-wave-circles',
data: waveData,
getPosition: (d) => d.position,
getRadius: (d) => d.radius,
getFillColor: (d) => d.fillColor,
getLineColor: (d) => d.lineColor,
getLineWidth: 2,
stroked: true,
radiusUnits: 'meters',
pickable: true,
onClick: (info) => {
if (info.object) onStationClick(info.object.station)
},
}) as unknown as Layer
)
}
// 수온 분포 ScatterplotLayer (Circle 대체, 고정 반경 10km)
if (enabledLayers.has('temperature')) {
const tempData = stations.map((s) => ({
position: [s.location.lon, s.location.lat] as [number, number],
fillColor: hexToRgba(getTempHexColor(s.temperature.current), 51), // fillOpacity 0.2
lineColor: hexToRgba(getTempHexColor(s.temperature.current), 128), // opacity 0.5
station: s,
}))
result.push(
new ScatterplotLayer({
id: 'weather-temp-circles',
data: tempData,
getPosition: (d) => d.position,
getRadius: 10000,
getFillColor: (d) => d.fillColor,
getLineColor: (d) => d.lineColor,
getLineWidth: 1,
stroked: true,
radiusUnits: 'meters',
pickable: true,
onClick: (info) => {
if (info.object) onStationClick(info.object.station)
},
updateTriggers: {
getFillColor: [selectedStationId],
getLineColor: [selectedStationId],
},
}) as unknown as Layer
)
}
return result
}, [stations, enabledLayers, selectedStationId, onStationClick])
}

파일 보기

@ -1,12 +1,14 @@
import { useState, useEffect } from 'react' import { useState, useMemo, useCallback } from 'react'
import { MapContainer, TileLayer, useMapEvents } from 'react-leaflet' import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
import type { LatLngExpression } from 'leaflet' import { MapboxOverlay } from '@deck.gl/mapbox'
import 'leaflet/dist/leaflet.css' 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 { WeatherRightPanel } from './WeatherRightPanel'
import { WeatherMapOverlay } from './WeatherMapOverlay' import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
import { OceanForecastOverlay } from './OceanForecastOverlay' import { OceanForecastOverlay } from './OceanForecastOverlay'
import { OceanCurrentLayer } from './OceanCurrentLayer' import { useOceanCurrentLayers } from './OceanCurrentLayer'
import { WaterTemperatureLayer } from './WaterTemperatureLayer' import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
import { WindParticleLayer } from './WindParticleLayer' import { WindParticleLayer } from './WindParticleLayer'
import { useWeatherData } from '../hooks/useWeatherData' import { useWeatherData } from '../hooks/useWeatherData'
import { useOceanForecast } from '../hooks/useOceanForecast' import { useOceanForecast } from '../hooks/useOceanForecast'
@ -43,8 +45,8 @@ interface WeatherForecast {
windSpeed: number windSpeed: number
} }
// Base weather station locations (weather data will be fetched from API) // Base weather station locations
const baseStations = [ const BASE_STATIONS = [
{ id: 'incheon', name: '인천', location: { lat: 37.45, lon: 126.43 } }, { id: 'incheon', name: '인천', location: { lat: 37.45, lon: 126.43 } },
{ id: 'ulsan', name: '울산', location: { lat: 35.52, lon: 129.38 } }, { id: 'ulsan', name: '울산', location: { lat: 35.52, lon: 129.38 } },
{ id: 'yeosu', name: '여수', location: { lat: 34.74, lon: 127.75 } }, { id: 'yeosu', name: '여수', location: { lat: 34.74, lon: 127.75 } },
@ -54,102 +56,235 @@ const baseStations = [
{ id: 'gunsan', name: '군산', location: { lat: 35.97, lon: 126.7 } }, { id: 'gunsan', name: '군산', location: { lat: 35.97, lon: 126.7 } },
{ id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } }, { id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } },
{ id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } }, { id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } },
{ id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } } { id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } },
] ]
// Generate forecast data based on time offset // Generate forecast data based on time offset
const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => { const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => {
const baseHour = parseInt(timeOffset) const baseHour = parseInt(timeOffset)
const forecasts: WeatherForecast[] = [] const forecasts: WeatherForecast[] = []
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️']
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
const hour = baseHour + i * 3 const hour = baseHour + i * 3
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️']
forecasts.push({ forecasts.push({
time: `+${hour}`, time: `+${hour}`,
hour: `${hour}`, hour: `${hour}`,
icon: icons[i % icons.length], icon: icons[i % icons.length],
temperature: Math.floor(Math.random() * 5) + 5, temperature: Math.floor(Math.random() * 5) + 5,
windSpeed: Math.floor(Math.random() * 5) + 6 windSpeed: Math.floor(Math.random() * 5) + 6,
}) })
} }
return forecasts return forecasts
} }
// Map click handler component // CartoDB Dark Matter 스타일 (기존 WeatherView와 동일)
function MapClickHandler({ onMapClick }: { onMapClick: (lat: number, lon: number) => void }) { const WEATHER_MAP_STYLE: StyleSpecification = {
useMapEvents({ version: 8,
click(e) { sources: {
onMapClick(e.latlng.lat, e.latlng.lng) '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 return null
} }
export function WeatherView() { // 줌 컨트롤
// Fetch real-time weather data from API function WeatherMapControls() {
const { weatherStations, loading, error, lastUpdate } = useWeatherData(baseStations) const { current: map } = useMap()
return (
<div style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }}>
<div className="flex flex-col gap-2">
<button
onClick={() => map?.zoomIn()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
>
+
</button>
<button
onClick={() => map?.zoomOut()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
>
</button>
<button
onClick={() =>
map?.flyTo({ center: WEATHER_MAP_CENTER, zoom: WEATHER_MAP_ZOOM, duration: 1000 })
}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
>
&#x1F3AF;
</button>
</div>
</div>
)
}
/**
* WeatherMapInner Map (useMap / useControl )
*/
interface WeatherMapInnerProps {
weatherStations: WeatherStation[]
enabledLayers: Set<string>
selectedStationId: string | null
oceanForecastOpacity: number
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
onStationClick: (station: WeatherStation) => void
}
function WeatherMapInner({
weatherStations,
enabledLayers,
selectedStationId,
oceanForecastOpacity,
selectedForecast,
onStationClick,
}: 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(
() => [...oceanCurrentLayers, ...waterTempLayers, ...weatherDeckLayers],
[oceanCurrentLayers, waterTempLayers, weatherDeckLayers]
)
return (
<>
{/* deck.gl 오버레이 */}
<DeckGLOverlay layers={deckLayers} />
{/* 해황예보도 — MapLibre image source + raster layer */}
<OceanForecastOverlay
forecast={selectedForecast}
opacity={oceanForecastOpacity}
visible={enabledLayers.has('oceanForecast')}
/>
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
<WeatherMapOverlay
stations={weatherStations}
enabledLayers={enabledLayers}
onStationClick={onStationClick}
selectedStationId={selectedStationId}
/>
{/* 바람 파티클 애니메이션 (Canvas 직접 조작) */}
<WindParticleLayer
visible={enabledLayers.has('windParticle')}
stations={weatherStations}
/>
{/* 줌 컨트롤 */}
<WeatherMapControls />
</>
)
}
export function WeatherView() {
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
// Fetch ocean forecast data from KHOA API
const { const {
selectedForecast, selectedForecast,
availableTimes, availableTimes,
loading: oceanLoading, loading: oceanLoading,
error: oceanError, error: oceanError,
selectForecast selectForecast,
} = useOceanForecast('KOREA') } = useOceanForecast('KOREA')
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0') const [timeOffset, setTimeOffset] = useState<TimeOffset>('0')
const [selectedStation, setSelectedStation] = useState<WeatherStation | null>(null) const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null)
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>( const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
null null
) )
const [enabledLayers, setEnabledLayers] = useState<Set<string>>( const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind', 'labels']))
new Set(['wind', 'labels'])
)
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6) const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
// Set initial selected station when data loads // 첫 관측소 자동 선택 (파생 값)
useEffect(() => { const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
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 = useCallback(
(station: WeatherStation) => {
setSelectedStation(station)
setSelectedLocation(null)
},
[]
)
const handleStationClick = (station: WeatherStation) => { const handleMapClick = useCallback(
setSelectedStation(station) (e: MapLayerMouseEvent) => {
setSelectedLocation(null) const { lat, lng } = e.lngLat
} if (weatherStations.length === 0) return
const handleMapClick = (lat: number, lon: number) => { // 가장 가까운 관측소 선택
// Find nearest station const nearestStation = weatherStations.reduce((nearest, station) => {
const nearestStation = weatherStations.reduce((nearest, station) => { const distance = Math.sqrt(
const distance = Math.sqrt( Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lng, 2)
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lon, 2) )
) const nearestDistance = Math.sqrt(
const nearestDistance = Math.sqrt( Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2)
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lon, 2) )
) return distance < nearestDistance ? station : nearest
return distance < nearestDistance ? station : nearest }, weatherStations[0])
}, weatherStations[0])
setSelectedStation(nearestStation) setSelectedStation(nearestStation)
setSelectedLocation({ lat, lon }) setSelectedLocation({ lat, lon: lng })
} },
[weatherStations]
)
const toggleLayer = (layer: string) => { const toggleLayer = useCallback((layer: string) => {
const newLayers = new Set(enabledLayers) setEnabledLayers((prev) => {
if (newLayers.has(layer)) { const next = new Set(prev)
newLayers.delete(layer) if (next.has(layer)) {
} else { next.delete(layer)
newLayers.add(layer) } else {
} next.add(layer)
setEnabledLayers(newLayers) }
} return next
})
}, [])
const weatherData = selectedStation const weatherData = selectedStation
? { ? {
@ -159,14 +294,14 @@ export function WeatherView() {
location: selectedLocation || selectedStation.location, location: selectedLocation || selectedStation.location,
currentTime: new Date().toLocaleTimeString('ko-KR', { currentTime: new Date().toLocaleTimeString('ko-KR', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit',
}), }),
wind: selectedStation.wind, wind: selectedStation.wind,
wave: selectedStation.wave, wave: selectedStation.wave,
temperature: selectedStation.temperature, temperature: selectedStation.temperature,
pressure: selectedStation.pressure, pressure: selectedStation.pressure,
visibility: selectedStation.visibility, visibility: selectedStation.visibility,
forecast: generateForecast(timeOffset) forecast: generateForecast(timeOffset),
} }
: null : null
@ -177,62 +312,33 @@ export function WeatherView() {
{/* Tab Navigation */} {/* Tab Navigation */}
<div className="flex items-center border-b border-border bg-bg-1" style={{ flexShrink: 0 }}> <div className="flex items-center border-b border-border bg-bg-1" style={{ flexShrink: 0 }}>
<div className="flex items-center gap-2 px-6"> <div className="flex items-center gap-2 px-6">
<button {(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => (
onClick={() => setTimeOffset('0')} <button
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${ key={offset}
timeOffset === '0' onClick={() => setTimeOffset(offset)}
? 'bg-primary-cyan text-bg-0' className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover' timeOffset === offset
}`} ? 'bg-primary-cyan text-bg-0'
> : 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
</button> >
<button {offset === '0' ? '현재' : `+${offset}시간`}
onClick={() => setTimeOffset('3')} </button>
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"> <div className="flex items-center gap-2 ml-4">
<span className="text-xs text-text-3"> <span className="text-xs text-text-3">
{lastUpdate {lastUpdate
? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', { ? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit',
})}` })}`
: '데이터 로딩 중...'} : '데이터 로딩 중...'}
</span> </span>
{loading && ( {loading && (
<div className="w-4 h-4 border-2 border-primary-cyan border-t-transparent rounded-full animate-spin" /> <div className="w-4 h-4 border-2 border-primary-cyan border-t-transparent rounded-full animate-spin" />
)} )}
{error && ( {error && <span className="text-xs text-status-red"> {error}</span>}
<span className="text-xs text-status-red"> {error}</span>
)}
</div> </div>
</div> </div>
@ -247,52 +353,29 @@ export function WeatherView() {
{/* Map */} {/* Map */}
<div className="flex-1 relative"> <div className="flex-1 relative">
<MapContainer <Map
center={mapCenter} initialViewState={{
zoom={7} longitude: WEATHER_MAP_CENTER[0],
style={{ height: '100%', width: '100%', background: '#0a0e1a' }} latitude: WEATHER_MAP_CENTER[1],
zoomControl={true} zoom: WEATHER_MAP_ZOOM,
}}
mapStyle={WEATHER_MAP_STYLE}
style={{ width: '100%', height: '100%' }}
onClick={handleMapClick}
attributionControl={false}
> >
<TileLayer <WeatherMapInner
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" weatherStations={weatherStations}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/>
<MapClickHandler onMapClick={handleMapClick} />
{/* Weather Overlay */}
<WeatherMapOverlay
stations={weatherStations}
enabledLayers={enabledLayers} enabledLayers={enabledLayers}
onStationClick={handleStationClick}
selectedStationId={selectedStation?.id || null} selectedStationId={selectedStation?.id || null}
oceanForecastOpacity={oceanForecastOpacity}
selectedForecast={selectedForecast}
onStationClick={handleStationClick}
/> />
</Map>
{/* Ocean Forecast Overlay */} {/* 레이어 컨트롤 */}
<OceanForecastOverlay <div className="absolute top-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm" style={{ zIndex: 10 }}>
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="text-sm font-semibold text-text-1 mb-3"> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
@ -371,7 +454,6 @@ export function WeatherView() {
<span className="text-xs text-text-2">🌊 </span> <span className="text-xs text-text-2">🌊 </span>
</label> </label>
{/* 투명도 조절 슬라이더 */}
{enabledLayers.has('oceanForecast') && ( {enabledLayers.has('oceanForecast') && (
<div className="mt-2 ml-6 space-y-2"> <div className="mt-2 ml-6 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -381,13 +463,16 @@ export function WeatherView() {
min="0" min="0"
max="100" max="100"
value={oceanForecastOpacity * 100} value={oceanForecastOpacity * 100}
onChange={(e) => setOceanForecastOpacity(Number(e.target.value) / 100)} onChange={(e) =>
setOceanForecastOpacity(Number(e.target.value) / 100)
}
className="flex-1 h-1 bg-bg-3 rounded-lg appearance-none cursor-pointer" 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> <span className="text-xs text-text-3 w-8">
{Math.round(oceanForecastOpacity * 100)}%
</span>
</div> </div>
{/* 예보 시간 선택 */}
{availableTimes.length > 0 && ( {availableTimes.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
<div className="text-xs text-text-3"> :</div> <div className="text-xs text-text-3"> :</div>
@ -397,7 +482,8 @@ export function WeatherView() {
key={`${time.day}-${time.hour}`} key={`${time.day}-${time.hour}`}
onClick={() => selectForecast(time.day, time.hour)} onClick={() => selectForecast(time.day, time.hour)}
className={`w-full px-2 py-1 text-xs rounded transition-colors ${ className={`w-full px-2 py-1 text-xs rounded transition-colors ${
selectedForecast?.day === time.day && selectedForecast?.hour === time.hour selectedForecast?.day === time.day &&
selectedForecast?.hour === time.hour
? 'bg-primary-cyan text-bg-0 font-semibold' ? 'bg-primary-cyan text-bg-0 font-semibold'
: 'bg-bg-2 text-text-3 hover:bg-bg-3' : 'bg-bg-2 text-text-3 hover:bg-bg-3'
}`} }`}
@ -409,15 +495,13 @@ export function WeatherView() {
</div> </div>
)} )}
{oceanLoading && ( {oceanLoading && <div className="text-xs text-text-3"> ...</div>}
<div className="text-xs text-text-3"> ...</div> {oceanError && <div className="text-xs text-status-red"> </div>}
)}
{oceanError && (
<div className="text-xs text-status-red"> </div>
)}
{selectedForecast && ( {selectedForecast && (
<div className="text-xs text-text-3 pt-2 border-t border-border"> <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 : {selectedForecast.name} {' '}
{selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '}
{selectedForecast.hour}:00
</div> </div>
)} )}
</div> </div>
@ -426,41 +510,50 @@ export function WeatherView() {
</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="absolute bottom-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm" style={{ zIndex: 10 }}>
<div className="text-sm font-semibold text-text-1 mb-3"> </div> <div className="text-sm font-semibold text-text-1 mb-3"> </div>
<div className="space-y-3 text-xs"> <div className="space-y-3 text-xs">
{/* Wind Speed - Windy style */} {/* 바람 (Windy 스타일) */}
<div> <div>
<div className="font-semibold text-text-2 mb-1"> (m/s)</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 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: '#6271b7' }} />
<div className="flex-1 h-full" style={{ background: '#39a0f6' }}></div> <div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
<div className="flex-1 h-full" style={{ background: '#50d591' }}></div> <div className="flex-1 h-full" style={{ background: '#50d591' }} />
<div className="flex-1 h-full" style={{ background: '#a5e23f' }}></div> <div className="flex-1 h-full" style={{ background: '#a5e23f' }} />
<div className="flex-1 h-full" style={{ background: '#fae21e' }}></div> <div className="flex-1 h-full" style={{ background: '#fae21e' }} />
<div className="flex-1 h-full" style={{ background: '#faaa19' }}></div> <div className="flex-1 h-full" style={{ background: '#faaa19' }} />
<div className="flex-1 h-full" style={{ background: '#f05421' }}></div> <div className="flex-1 h-full" style={{ background: '#f05421' }} />
<div className="flex-1 h-full" style={{ background: '#b41e46' }}></div> <div className="flex-1 h-full" style={{ background: '#b41e46' }} />
</div> </div>
<div className="flex justify-between text-text-3" style={{ fontSize: '9px' }}> <div
<span>3</span><span>5</span><span>7</span><span>10</span><span>13</span><span>16</span><span>20+</span> 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>
</div> </div>
{/* Wave Height */} {/* 파고 */}
<div className="pt-2 border-t border-border"> <div className="pt-2 border-t border-border">
<div className="font-semibold text-text-2 mb-1"> (m)</div> <div className="font-semibold text-text-2 mb-1"> (m)</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500"></div> <div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-text-3">&lt; 1.5: 낮음</span> <span className="text-text-3">&lt; 1.5: 낮음</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-orange-500"></div> <div className="w-3 h-3 rounded-full bg-orange-500" />
<span className="text-text-3">1.5-2.5: 보통</span> <span className="text-text-3">1.5-2.5: 보통</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500"></div> <div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-text-3">&gt; 2.5: 높음</span> <span className="text-text-3">&gt; 2.5: 높음</span>
</div> </div>
</div> </div>
@ -472,7 +565,7 @@ export function WeatherView() {
</div> </div>
</div> </div>
{/* Right Panel - Weather Details */} {/* Right Panel */}
<WeatherRightPanel weatherData={weatherData} /> <WeatherRightPanel weatherData={weatherData} />
</div> </div>
) )

파일 보기

@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef, useCallback } from 'react'
import { useMap } from 'react-leaflet' import { useMap } from '@vis.gl/react-maplibre'
import L from 'leaflet' import type { Map as MapLibreMap } from 'maplibre-gl'
interface WindPoint { interface WindPoint {
lat: number lat: number
@ -26,14 +26,14 @@ interface WindParticleLayerProps {
// 풍속 기반 색상 (Windy.com 스타일) // 풍속 기반 색상 (Windy.com 스타일)
function getWindColor(speed: number): string { function getWindColor(speed: number): string {
if (speed < 3) return 'rgba(98, 113, 183, 0.8)' // 연한 파랑 (미풍) if (speed < 3) return 'rgba(98, 113, 183, 0.8)'
if (speed < 5) return 'rgba(57, 160, 246, 0.8)' // 파랑 if (speed < 5) return 'rgba(57, 160, 246, 0.8)'
if (speed < 7) return 'rgba(80, 213, 145, 0.8)' // 초록 if (speed < 7) return 'rgba(80, 213, 145, 0.8)'
if (speed < 10) return 'rgba(165, 226, 63, 0.8)' // 연두 if (speed < 10) return 'rgba(165, 226, 63, 0.8)'
if (speed < 13) return 'rgba(250, 226, 30, 0.8)' // 노랑 if (speed < 13) return 'rgba(250, 226, 30, 0.8)'
if (speed < 16) return 'rgba(250, 170, 25, 0.8)' // 주황 if (speed < 16) return 'rgba(250, 170, 25, 0.8)'
if (speed < 20) return 'rgba(240, 84, 33, 0.8)' // 빨강 if (speed < 20) return 'rgba(240, 84, 33, 0.8)'
return 'rgba(180, 30, 70, 0.8)' // 진빨강 (강풍) return 'rgba(180, 30, 70, 0.8)'
} }
// 풍속 기반 배경 색상 (반투명 오버레이) // 풍속 기반 배경 색상 (반투명 오버레이)
@ -48,7 +48,7 @@ function getWindBgColor(speed: number): string {
return 'rgba(180, 30, 70, 0.12)' return 'rgba(180, 30, 70, 0.12)'
} }
// 격자 보간으로 특정 위치의 풍속/풍향 추정 // 격자 보간으로 특정 위치의 풍속/풍향 추정 (IDW)
function interpolateWind( function interpolateWind(
lat: number, lat: number,
lon: number, lon: number,
@ -65,7 +65,6 @@ function interpolateWind(
const dist = Math.sqrt( const dist = Math.sqrt(
Math.pow(point.lat - lat, 2) + Math.pow(point.lon - lon, 2) Math.pow(point.lat - lat, 2) + Math.pow(point.lon - lon, 2)
) )
// IDW (Inverse Distance Weighting)
const weight = 1 / Math.pow(Math.max(dist, 0.01), 2) const weight = 1 / Math.pow(Math.max(dist, 0.01), 2)
totalWeight += weight totalWeight += weight
weightedSpeed += point.speed * weight weightedSpeed += point.speed * weight
@ -76,19 +75,63 @@ function interpolateWind(
} }
const speed = weightedSpeed / totalWeight const speed = weightedSpeed / totalWeight
const direction = (Math.atan2(weightedDx / totalWeight, -weightedDy / totalWeight) * 180) / Math.PI const direction =
(Math.atan2(weightedDx / totalWeight, -weightedDy / totalWeight) * 180) / Math.PI
return { speed, direction: (direction + 360) % 360 } return { speed, direction: (direction + 360) % 360 }
} }
// MapLibre map.unproject()를 통해 픽셀 → 경위도 변환
function containerPointToLatLng(
map: MapLibreMap,
x: number,
y: number
): { lat: number; lng: number } {
const lngLat = map.unproject([x, y])
return { lat: lngLat.lat, lng: lngLat.lng }
}
const PARTICLE_COUNT = 800
const FADE_ALPHA = 0.93
/**
* WindParticleLayer
*
* 기존: Canvas 2D + requestAnimationFrame + map.containerPointToLatLng() (Leaflet)
* 전환: Canvas 2D + requestAnimationFrame + map.unproject() (MapLibre)
*
* @vis.gl/react-maplibre의 useMap() MapLibre Map
* Canvas는 MapLibre absolute
*/
export function WindParticleLayer({ visible, stations }: WindParticleLayerProps) { export function WindParticleLayer({ visible, stations }: WindParticleLayerProps) {
const map = useMap() const { current: mapRef } = useMap()
const canvasRef = useRef<HTMLCanvasElement | null>(null) const canvasRef = useRef<HTMLCanvasElement | null>(null)
const particlesRef = useRef<Particle[]>([]) const particlesRef = useRef<Particle[]>([])
const animFrameRef = useRef<number>(0) const animFrameRef = useRef<number>(0)
const windPoints: WindPoint[] = stations.map((s) => ({
lat: s.location.lat,
lon: s.location.lon,
speed: s.wind.speed,
direction: s.wind.direction,
}))
const initParticles = useCallback((width: number, height: number) => {
particlesRef.current = []
for (let i = 0; i < PARTICLE_COUNT; i++) {
particlesRef.current.push({
x: Math.random() * width,
y: Math.random() * height,
age: Math.floor(Math.random() * 80),
maxAge: 60 + Math.floor(Math.random() * 40),
})
}
}, [])
useEffect(() => { useEffect(() => {
const map = mapRef?.getMap()
if (!map) return
if (!visible) { if (!visible) {
// 비활성화 시 캔버스 제거
if (canvasRef.current) { if (canvasRef.current) {
canvasRef.current.remove() canvasRef.current.remove()
canvasRef.current = null canvasRef.current = null
@ -97,16 +140,9 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps)
return return
} }
// 관측소 데이터를 WindPoint로 변환
const windPoints: WindPoint[] = stations.map(s => ({
lat: s.location.lat,
lon: s.location.lon,
speed: s.wind.speed,
direction: s.wind.direction
}))
// Canvas 생성
const container = map.getContainer() const container = map.getContainer()
// Canvas 생성 또는 재사용
let canvas = canvasRef.current let canvas = canvasRef.current
if (!canvas) { if (!canvas) {
canvas = document.createElement('canvas') canvas = document.createElement('canvas')
@ -121,57 +157,39 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps)
const resize = () => { const resize = () => {
if (!canvas) return if (!canvas) return
const size = map.getSize() const { clientWidth: w, clientHeight: h } = container
canvas.width = size.x canvas.width = w
canvas.height = size.y canvas.height = h
} }
resize() resize()
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
if (!ctx) return if (!ctx) return
// 파티클 수 initParticles(canvas.width, canvas.height)
const PARTICLE_COUNT = 800
const FADE_ALPHA = 0.93
// 파티클 초기화 // 오프스크린 캔버스 (트레일 효과)
function initParticles() { let offCanvas: HTMLCanvasElement | null = null
particlesRef.current = [] let offCtx: CanvasRenderingContext2D | null = null
if (!canvas) return
for (let i = 0; i < PARTICLE_COUNT; i++) {
particlesRef.current.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
age: Math.floor(Math.random() * 80),
maxAge: 60 + Math.floor(Math.random() * 40)
})
}
}
initParticles()
// 배경 그라디언트 렌더링
function drawWindOverlay() { function drawWindOverlay() {
if (!ctx || !canvas) return if (!ctx || !canvas) return
const gridSize = 30 const gridSize = 30
for (let x = 0; x < canvas.width; x += gridSize) { for (let x = 0; x < canvas.width; x += gridSize) {
for (let y = 0; y < canvas.height; y += gridSize) { for (let y = 0; y < canvas.height; y += gridSize) {
const latlng = map.containerPointToLatLng(L.point(x + gridSize / 2, y + gridSize / 2)) const { lat, lng } = containerPointToLatLng(map!, x + gridSize / 2, y + gridSize / 2)
const wind = interpolateWind(latlng.lat, latlng.lng, windPoints) const wind = interpolateWind(lat, lng, windPoints)
ctx.fillStyle = getWindBgColor(wind.speed) ctx.fillStyle = getWindBgColor(wind.speed)
ctx.fillRect(x, y, gridSize, gridSize) ctx.fillRect(x, y, gridSize, gridSize)
} }
} }
} }
// 애니메이션 루프
let offCanvas: HTMLCanvasElement | null = null
let offCtx: CanvasRenderingContext2D | null = null
function animate() { function animate() {
if (!ctx || !canvas) return if (!ctx || !canvas) return
// 오프스크린 캔버스 (트레일 효과) // 오프스크린 캔버스 크기 동기화
if (!offCanvas || offCanvas.width !== canvas.width || offCanvas.height !== canvas.height) { if (!offCanvas || offCanvas.width !== canvas.width || offCanvas.height !== canvas.height) {
offCanvas = document.createElement('canvas') offCanvas = document.createElement('canvas')
offCanvas.width = canvas.width offCanvas.width = canvas.width
@ -187,9 +205,9 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps)
offCtx.fillRect(0, 0, offCanvas.width, offCanvas.height) offCtx.fillRect(0, 0, offCanvas.width, offCanvas.height)
offCtx.globalCompositeOperation = 'source-over' offCtx.globalCompositeOperation = 'source-over'
const bounds = map.getBounds() // 현재 지도 bounds 확인
const bounds = map!.getBounds()
// 파티클 업데이트 및 렌더링
for (const particle of particlesRef.current) { for (const particle of particlesRef.current) {
particle.age++ particle.age++
@ -202,17 +220,17 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps)
continue continue
} }
const latlng = map.containerPointToLatLng(L.point(particle.x, particle.y)) const { lat, lng } = containerPointToLatLng(map!, particle.x, particle.y)
// 화면 밖이면 리셋 // 화면 밖이면 리셋
if (!bounds.contains(latlng)) { if (!bounds.contains([lng, lat])) {
particle.x = Math.random() * canvas.width particle.x = Math.random() * canvas.width
particle.y = Math.random() * canvas.height particle.y = Math.random() * canvas.height
particle.age = 0 particle.age = 0
continue continue
} }
const wind = interpolateWind(latlng.lat, latlng.lng, windPoints) const wind = interpolateWind(lat, lng, windPoints)
const rad = (wind.direction * Math.PI) / 180 const rad = (wind.direction * Math.PI) / 180
const pixelSpeed = wind.speed * 0.4 const pixelSpeed = wind.speed * 0.4
@ -244,10 +262,10 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps)
// 지도 이동/줌 시 리셋 // 지도 이동/줌 시 리셋
const onMoveEnd = () => { const onMoveEnd = () => {
resize() resize()
initParticles() if (canvas) initParticles(canvas.width, canvas.height)
if (offCanvas) { if (offCanvas && canvas) {
offCanvas.width = canvas!.width offCanvas.width = canvas.width
offCanvas.height = canvas!.height offCanvas.height = canvas.height
} }
} }
map.on('moveend', onMoveEnd) map.on('moveend', onMoveEnd)
@ -262,7 +280,9 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps)
canvasRef.current = null canvasRef.current = null
} }
} }
}, [map, visible, stations]) // windPoints 배열은 렌더마다 재생성되므로 stations만 의존
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapRef, visible, stations, initParticles])
return null return null
} }