Merge pull request 'develop' (#48) from develop into main
Some checks failed
Build and Deploy Wing-Demo / build-and-deploy (push) Failing after 21s
Some checks failed
Build and Deploy Wing-Demo / build-and-deploy (push) Failing after 21s
Reviewed-on: #48
This commit is contained in:
커밋
501602207f
1686
frontend/package-lock.json
generated
1686
frontend/package-lock.json
generated
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -10,6 +10,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@ -17,19 +22,18 @@
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@react-oauth/google": "^0.13.4",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@vis.gl/react-maplibre": "^8.1.0",
|
||||
"axios": "^1.13.5",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.564.0",
|
||||
"maplibre-gl": "^5.19.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@ -1,15 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Polyline, CircleMarker, Circle, Marker, Popup } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { ScatterplotLayer, PathLayer } from '@deck.gl/layers'
|
||||
import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack'
|
||||
|
||||
interface BacktrackReplayOverlayProps {
|
||||
replayShips: ReplayShip[]
|
||||
collisionEvent: CollisionEvent | null
|
||||
replayFrame: number
|
||||
totalFrames: number
|
||||
incidentCoord: { lat: number; lon: number }
|
||||
}
|
||||
import { hexToRgba } from './mapUtils'
|
||||
|
||||
function getInterpolatedPosition(
|
||||
path: ReplayPathPoint[],
|
||||
@ -27,129 +18,132 @@ function getInterpolatedPosition(
|
||||
}
|
||||
}
|
||||
|
||||
export function BacktrackReplayOverlay({
|
||||
replayShips,
|
||||
collisionEvent,
|
||||
replayFrame,
|
||||
totalFrames,
|
||||
incidentCoord,
|
||||
}: BacktrackReplayOverlayProps) {
|
||||
interface BacktrackReplayParams {
|
||||
replayShips: ReplayShip[]
|
||||
collisionEvent: CollisionEvent | null
|
||||
replayFrame: number
|
||||
totalFrames: number
|
||||
incidentCoord: { lat: number; lon: number }
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Ship icons using DivIcon
|
||||
const shipIcons = useMemo(() => {
|
||||
return replayShips.map((ship) =>
|
||||
L.divIcon({
|
||||
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],
|
||||
// Per-ship track lines + waypoints + ship position
|
||||
const allTrackData: Array<{ path: [number, number][]; color: [number, number, number, number] }> = []
|
||||
const allWaypoints: Array<{ position: [number, number]; color: [number, number, number, number] }> = []
|
||||
const allShipPositions: Array<{ position: [number, number]; color: [number, number, number, number]; name: string }> = []
|
||||
|
||||
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: [],
|
||||
})
|
||||
)
|
||||
}, [replayShips])
|
||||
|
||||
// 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 showCollision = progress >= collisionProgress
|
||||
const spillSize = showCollision ? Math.min(500, (progress - collisionProgress) / (1 - collisionProgress) * 500) : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{replayShips.map((ship, shipIdx) => {
|
||||
const pos = getInterpolatedPosition(ship.path, replayFrame, totalFrames)
|
||||
const trackUpToCurrent = ship.path.slice(0, pos.segmentIndex + 2).map(
|
||||
(p, i, arr) => {
|
||||
if (i === arr.length - 1) return [pos.lat, pos.lon] as [number, number]
|
||||
return [p.lat, p.lon] as [number, number]
|
||||
if (showCollision && collisionEvent) {
|
||||
layers.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'bt-collision',
|
||||
data: [{ position: [collisionEvent.position.lon, collisionEvent.position.lat] }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: 12,
|
||||
getFillColor: [239, 68, 68, 80],
|
||||
getLineColor: [239, 68, 68, 200],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
radiusMinPixels: 12,
|
||||
pickable: true,
|
||||
})
|
||||
)
|
||||
|
||||
// Oil spill expansion
|
||||
const spillSize = Math.min(500, ((progress - collisionProgress) / (1 - collisionProgress)) * 500)
|
||||
if (spillSize > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'bt-spill',
|
||||
data: [{ position: [incidentCoord.lon, incidentCoord.lat], radius: spillSize }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: (d: { radius: number }) => d.radius,
|
||||
getFillColor: [249, 115, 22, 50],
|
||||
getLineColor: [249, 115, 22, 100],
|
||||
getLineWidth: 1,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters' as const,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={ship.vesselName}>
|
||||
{/* Track line (dashed) */}
|
||||
<Polyline
|
||||
positions={trackUpToCurrent}
|
||||
pathOptions={{
|
||||
color: ship.color,
|
||||
weight: 2,
|
||||
dashArray: '6, 4',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Waypoint dots */}
|
||||
{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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return layers
|
||||
}
|
||||
|
||||
@ -1,38 +1,56 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, CircleMarker, Circle, Polyline } from 'react-leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import L from 'leaflet'
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
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 { decimalToDMS } from '@common/utils/coordinates'
|
||||
import type { PredictionModel } from '@tabs/prediction/components/OilSpillView'
|
||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
||||
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'
|
||||
|
||||
// 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_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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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> = {
|
||||
'KOSPS': '#06b6d4', // cyan
|
||||
'POSEIDON': '#ef4444', // red
|
||||
'OpenDrift': '#3b82f6', // blue
|
||||
'KOSPS': '#06b6d4',
|
||||
'POSEIDON': '#ef4444',
|
||||
'OpenDrift': '#3b82f6',
|
||||
}
|
||||
|
||||
// 오일펜스 우선순위별 색상/두께
|
||||
@ -92,19 +110,21 @@ interface MapViewProps {
|
||||
}
|
||||
}
|
||||
|
||||
// WMS 레이어에 CSS brightness 필터 적용
|
||||
function WmsBrightnessApplier({ brightness }: { brightness: number }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
const container = map.getContainer()
|
||||
// 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])
|
||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
|
||||
// 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
|
||||
}
|
||||
|
||||
// 팝업 정보
|
||||
interface PopupInfo {
|
||||
longitude: number
|
||||
latitude: number
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
export function MapView({
|
||||
center = DEFAULT_CENTER,
|
||||
zoom = DEFAULT_ZOOM,
|
||||
@ -126,14 +146,16 @@ export function MapView({
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1)
|
||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null)
|
||||
|
||||
const handleMapClick = (position: [number, number]) => {
|
||||
const [lat, lng] = position
|
||||
setCurrentPosition(position)
|
||||
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
setCurrentPosition([lat, lng])
|
||||
if (onMapClick) {
|
||||
onMapClick(lng, lat) // onMapClick expects (lon, lat)
|
||||
}
|
||||
onMapClick(lng, lat)
|
||||
}
|
||||
setPopupInfo(null)
|
||||
}, [onMapClick])
|
||||
|
||||
// 애니메이션 재생 로직
|
||||
useEffect(() => {
|
||||
@ -150,7 +172,7 @@ export function MapView({
|
||||
const next = prev + (1 * playbackSpeed)
|
||||
return next > maxTime ? maxTime : next
|
||||
})
|
||||
}, 200) // 200ms마다 업데이트
|
||||
}, 200)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isPlaying, currentTime, playbackSpeed, oilTrajectory])
|
||||
@ -163,7 +185,7 @@ export function MapView({
|
||||
}
|
||||
}, [oilTrajectory.length])
|
||||
|
||||
// WMS 레이어 목록 생성
|
||||
// WMS 레이어 목록
|
||||
const wmsLayers = useMemo(() => {
|
||||
return Array.from(enabledLayers)
|
||||
.map(layerId => {
|
||||
@ -173,212 +195,311 @@ export function MapView({
|
||||
.filter((l): l is { id: string; wmsLayer: string } => l !== null)
|
||||
}, [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 (
|
||||
<div className="w-full h-full relative">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
className="w-full h-full"
|
||||
zoomControl={false}
|
||||
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
||||
<Map
|
||||
initialViewState={{
|
||||
longitude: center[1],
|
||||
latitude: center[0],
|
||||
zoom: zoom,
|
||||
}}
|
||||
mapStyle={BASE_STYLE}
|
||||
style={{ width: '100%', height: '100%', cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
||||
onClick={handleMapClick}
|
||||
attributionControl={false}
|
||||
>
|
||||
{/* CartoDB Dark Matter Tile Layer */}
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
/>
|
||||
|
||||
{/* WMS 레이어 (투명도 + 밝기 적용) */}
|
||||
{/* WMS 레이어 */}
|
||||
{wmsLayers.map(layer => (
|
||||
<TileLayer
|
||||
<Source
|
||||
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`}
|
||||
attribution='© MPC GeoServer'
|
||||
opacity={layerOpacity / 100}
|
||||
id={`wms-${layer.id}`}
|
||||
type="raster"
|
||||
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) && (
|
||||
<Marker position={[incidentCoord.lat, incidentCoord.lon]}>
|
||||
<Popup>
|
||||
<div className="text-sm">
|
||||
<Marker longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom">
|
||||
<div
|
||||
style={{
|
||||
width: 24, height: 24, background: 'var(--cyan)', borderRadius: '50% 50% 50% 0',
|
||||
transform: 'rotate(-45deg)', border: '2px solid #fff',
|
||||
boxShadow: '0 2px 8px rgba(6,182,212,0.5)',
|
||||
}}
|
||||
/>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{/* 사고 위치 팝업 (클릭 시) */}
|
||||
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !popupInfo && (
|
||||
<Popup longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom" offset={30} closeButton={false} closeOnClick={false}>
|
||||
<div className="text-sm" style={{ color: '#333' }}>
|
||||
<strong>사고 지점</strong>
|
||||
<br />
|
||||
<span className="text-xs text-gray-600">
|
||||
<span className="text-xs" style={{ color: '#666' }}>
|
||||
{decimalToDMS(incidentCoord.lat, true)}
|
||||
<br />
|
||||
{decimalToDMS(incidentCoord.lon, false)}
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-xs text-gray-500" style={{ fontFamily: 'monospace' }}>
|
||||
<span className="text-xs" style={{ fontFamily: 'monospace', color: '#888' }}>
|
||||
({incidentCoord.lat.toFixed(4)}°, {incidentCoord.lon.toFixed(4)}°)
|
||||
</span>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{/* 오일 확산 입자 시각화 (모델별 색상) */}
|
||||
{oilTrajectory
|
||||
.filter(point => point.time <= currentTime)
|
||||
.map((point, idx) => {
|
||||
const modelKey = point.model || Array.from(selectedModels)[0] || 'OpenDrift'
|
||||
const particleColor = MODEL_COLORS[modelKey] || '#3b82f6'
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`particle-${point.model}-${point.particle}-${point.time}-${idx}`}
|
||||
center={[point.lat, point.lon]}
|
||||
radius={2.5}
|
||||
pathOptions={{
|
||||
fillColor: particleColor,
|
||||
fillOpacity: 0.7,
|
||||
color: particleColor,
|
||||
weight: 0.5,
|
||||
opacity: 0.8
|
||||
}}
|
||||
{/* deck.gl 객체 클릭 팝업 */}
|
||||
{popupInfo && (
|
||||
<Popup
|
||||
longitude={popupInfo.longitude}
|
||||
latitude={popupInfo.latitude}
|
||||
anchor="bottom"
|
||||
onClose={() => setPopupInfo(null)}
|
||||
>
|
||||
<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>
|
||||
<div style={{ color: '#333' }}>{popupInfo.content}</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 오일펜스 라인 렌더링 */}
|
||||
{boomLines.map(line => (
|
||||
<Polyline
|
||||
key={line.id}
|
||||
positions={line.coords.map(c => [c.lat, c.lon] as [number, number])}
|
||||
pathOptions={{
|
||||
color: PRIORITY_COLORS[line.priority] || '#f59e0b',
|
||||
weight: PRIORITY_WEIGHTS[line.priority] || 2,
|
||||
opacity: 0.9,
|
||||
dashArray: line.status === 'PLANNED' ? '10, 5' : undefined,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-xs" style={{ fontFamily: 'var(--fK)', minWidth: '140px' }}>
|
||||
<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 && (
|
||||
<>
|
||||
<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>
|
||||
{/* 커스텀 줌 컨트롤 */}
|
||||
<MapControls center={center} zoom={zoom} />
|
||||
</Map>
|
||||
|
||||
{/* 드로잉 모드 안내 */}
|
||||
{isDrawingBoom && (
|
||||
<div className="boom-drawing-indicator">
|
||||
🛡 오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
|
||||
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -405,54 +526,43 @@ export function MapView({
|
||||
onSpeedChange={setPlaybackSpeed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 역추적 리플레이 바 */}
|
||||
{backtrackReplay?.isActive && (
|
||||
<BacktrackReplayBar
|
||||
replayFrame={backtrackReplay.replayFrame}
|
||||
totalFrames={backtrackReplay.totalFrames}
|
||||
ships={backtrackReplay.ships}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 지도 클릭 이벤트 핸들러
|
||||
interface MapClickHandlerProps {
|
||||
onPositionChange: (position: [number, number]) => void
|
||||
}
|
||||
|
||||
function MapClickHandler({ onPositionChange }: MapClickHandlerProps) {
|
||||
useMapEvents({
|
||||
click: (e) => {
|
||||
const { lat, lng } = e.latlng
|
||||
onPositionChange([lat, lng])
|
||||
},
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// 지도 컨트롤 (줌, 레이어 등)
|
||||
function MapControls() {
|
||||
const map = useMap()
|
||||
// 지도 컨트롤 (줌, 위치 초기화)
|
||||
function MapControls({ center, zoom }: { center: [number, number]; zoom: number }) {
|
||||
const { current: map } = useMap()
|
||||
|
||||
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">
|
||||
{/* 줌 인 */}
|
||||
<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"
|
||||
>
|
||||
+
|
||||
</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"
|
||||
>
|
||||
−
|
||||
</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"
|
||||
>
|
||||
🎯
|
||||
🎯
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -469,11 +579,9 @@ interface MapLegendProps {
|
||||
}
|
||||
|
||||
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
|
||||
// HNS 대기확산 범례
|
||||
if (dispersionResult && incidentCoord) {
|
||||
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={{ fontSize: '16px' }}>📍</div>
|
||||
<div>
|
||||
@ -483,8 +591,6 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
|
||||
</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={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위험 구역 범례 */}
|
||||
<div>
|
||||
<h5 className="text-[9px] font-bold text-text-3 mb-2">위험 구역</h5>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@ -518,8 +622,6 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
|
||||
</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={{ fontSize: '12px' }}>🧭</div>
|
||||
<span className="text-[9px] text-text-3">풍향 (방사형)</span>
|
||||
@ -528,13 +630,11 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
|
||||
)
|
||||
}
|
||||
|
||||
// 유류 확산 범례 (유출유 확산예측에만 표시)
|
||||
if (oilTrajectory.length > 0) {
|
||||
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>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* 선택된 모델별 색상 */}
|
||||
{Array.from(selectedModels).map(model => (
|
||||
<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] }} />
|
||||
@ -573,16 +673,11 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
|
||||
)
|
||||
}
|
||||
|
||||
// 범례 없음
|
||||
return null
|
||||
}
|
||||
|
||||
// 좌표 표시
|
||||
interface CoordinateDisplayProps {
|
||||
position: [number, number]
|
||||
}
|
||||
|
||||
function CoordinateDisplay({ position }: CoordinateDisplayProps) {
|
||||
function CoordinateDisplay({ position }: { position: [number, number] }) {
|
||||
const [lat, lng] = position
|
||||
const latDirection = lat >= 0 ? 'N' : 'S'
|
||||
const lngDirection = lng >= 0 ? 'E' : 'W'
|
||||
@ -608,48 +703,28 @@ interface TimelineControlProps {
|
||||
}
|
||||
|
||||
function TimelineControl({
|
||||
currentTime,
|
||||
maxTime,
|
||||
isPlaying,
|
||||
playbackSpeed,
|
||||
onTimeChange,
|
||||
onPlayPause,
|
||||
onSpeedChange
|
||||
currentTime, maxTime, isPlaying, playbackSpeed,
|
||||
onTimeChange, onPlayPause, onSpeedChange
|
||||
}: TimelineControlProps) {
|
||||
const progressPercent = (currentTime / maxTime) * 100
|
||||
|
||||
const handleRewind = () => {
|
||||
onTimeChange(Math.max(0, currentTime - 6))
|
||||
}
|
||||
|
||||
const handleForward = () => {
|
||||
onTimeChange(Math.min(maxTime, currentTime + 6))
|
||||
}
|
||||
|
||||
const handleStart = () => {
|
||||
onTimeChange(0)
|
||||
}
|
||||
|
||||
const handleEnd = () => {
|
||||
onTimeChange(maxTime)
|
||||
}
|
||||
const handleRewind = () => onTimeChange(Math.max(0, currentTime - 6))
|
||||
const handleForward = () => onTimeChange(Math.min(maxTime, currentTime + 6))
|
||||
const handleStart = () => onTimeChange(0)
|
||||
const handleEnd = () => onTimeChange(maxTime)
|
||||
|
||||
const toggleSpeed = () => {
|
||||
const speeds = [1, 2, 4]
|
||||
const currentIndex = speeds.indexOf(playbackSpeed)
|
||||
const nextIndex = (currentIndex + 1) % speeds.length
|
||||
onSpeedChange(speeds[nextIndex])
|
||||
onSpeedChange(speeds[(currentIndex + 1) % speeds.length])
|
||||
}
|
||||
|
||||
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const clickX = e.clientX - rect.left
|
||||
const percent = clickX / rect.width
|
||||
const newTime = Math.round(percent * maxTime)
|
||||
onTimeChange(Math.max(0, Math.min(maxTime, newTime)))
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
onTimeChange(Math.max(0, Math.min(maxTime, Math.round(percent * maxTime))))
|
||||
}
|
||||
|
||||
// 시간 레이블 생성 (0h, 6h, 12h, ...)
|
||||
const timeLabels = []
|
||||
for (let t = 0; t <= maxTime; t += 6) {
|
||||
timeLabels.push(t)
|
||||
@ -657,7 +732,6 @@ function TimelineControl({
|
||||
|
||||
return (
|
||||
<div className="tlb">
|
||||
{/* 재생 컨트롤 */}
|
||||
<div className="tlc">
|
||||
<div className="tb" onClick={handleStart}>⏮</div>
|
||||
<div className="tb" onClick={handleRewind}>◀</div>
|
||||
@ -669,16 +743,10 @@ function TimelineControl({
|
||||
<div style={{ width: '8px' }} />
|
||||
<div className="tb" onClick={toggleSpeed}>{playbackSpeed}×</div>
|
||||
</div>
|
||||
|
||||
{/* 타임라인 트랙 */}
|
||||
<div className="tlt">
|
||||
<div className="tlls">
|
||||
{timeLabels.map(t => (
|
||||
<span
|
||||
key={t}
|
||||
className={`tll ${Math.abs(currentTime - t) < 1 ? 'on' : ''}`}
|
||||
style={{ left: `${(t / maxTime) * 100}%` }}
|
||||
>
|
||||
<span key={t} className={`tll ${Math.abs(currentTime - t) < 1 ? 'on' : ''}`} style={{ left: `${(t / maxTime) * 100}%` }}>
|
||||
{t}h
|
||||
</span>
|
||||
))}
|
||||
@ -687,60 +755,31 @@ function TimelineControl({
|
||||
<div className="tlr">
|
||||
<div className="tlp" style={{ width: `${progressPercent}%` }} />
|
||||
{timeLabels.map(t => (
|
||||
<div
|
||||
key={`marker-${t}`}
|
||||
className={`tlm ${t % 12 === 0 ? 'mj' : ''}`}
|
||||
style={{ left: `${(t / maxTime) * 100}%` }}
|
||||
/>
|
||||
<div key={`marker-${t}`} className={`tlm ${t % 12 === 0 ? 'mj' : ''}`} style={{ left: `${(t / maxTime) * 100}%` }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="tlth" style={{ left: `${progressPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정보 표시 */}
|
||||
<div className="tli">
|
||||
{/* 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="tlss">
|
||||
<div className="tls">
|
||||
<span className="tlsl">진행률</span>
|
||||
<span className="tlsv">{progressPercent.toFixed(0)}%</span>
|
||||
</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 className="tls"><span className="tlsl">진행률</span><span className="tlsv">{progressPercent.toFixed(0)}%</span></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>
|
||||
)
|
||||
}
|
||||
|
||||
// 기상 데이터 타입
|
||||
interface WeatherData {
|
||||
windSpeed: number
|
||||
windDirection: string
|
||||
waveHeight: number
|
||||
waterTemp: number
|
||||
currentSpeed: number
|
||||
currentDirection: string
|
||||
}
|
||||
|
||||
// 좌표 기반 기상 데이터 조회 (Mock 함수)
|
||||
function getWeatherData(position: [number, number]): WeatherData {
|
||||
// 기상 데이터 Mock
|
||||
function getWeatherData(position: [number, number]) {
|
||||
const [lat, lng] = position
|
||||
|
||||
// 좌표 기반으로 변화를 주기 위한 시드값
|
||||
const latSeed = Math.abs(lat * 100) % 10
|
||||
const lngSeed = Math.abs(lng * 100) % 10
|
||||
|
||||
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||
|
||||
return {
|
||||
windSpeed: Number((5 + latSeed).toFixed(1)),
|
||||
windDirection: directions[Math.floor(lngSeed * 0.8)],
|
||||
@ -751,38 +790,25 @@ function getWeatherData(position: [number, number]): WeatherData {
|
||||
}
|
||||
}
|
||||
|
||||
// 기상청 연계 정보
|
||||
interface WeatherInfoPanelProps {
|
||||
position: [number, number]
|
||||
}
|
||||
|
||||
function WeatherInfoPanel({ position }: WeatherInfoPanelProps) {
|
||||
function WeatherInfoPanel({ position }: { position: [number, number] }) {
|
||||
const weather = getWeatherData(position)
|
||||
|
||||
return (
|
||||
<div className="wip">
|
||||
{/* 풍속 */}
|
||||
<div className="wii">
|
||||
<div className="wii-icon">💨</div>
|
||||
<div className="wii-value">{weather.windSpeed} m/s</div>
|
||||
<div className="wii-label">풍속 ({weather.windDirection})</div>
|
||||
</div>
|
||||
|
||||
{/* 파고 */}
|
||||
<div className="wii">
|
||||
<div className="wii-icon">🌊</div>
|
||||
<div className="wii-value">{weather.waveHeight} m</div>
|
||||
<div className="wii-label">파고</div>
|
||||
</div>
|
||||
|
||||
{/* 수온 */}
|
||||
<div className="wii">
|
||||
<div className="wii-icon">🌡</div>
|
||||
<div className="wii-value">{weather.waterTemp}°C</div>
|
||||
<div className="wii-label">수온</div>
|
||||
</div>
|
||||
|
||||
{/* 해류 */}
|
||||
<div className="wii">
|
||||
<div className="wii-icon">🔄</div>
|
||||
<div className="wii-value">{weather.currentSpeed} m/s</div>
|
||||
@ -791,3 +817,32 @@ function WeatherInfoPanel({ position }: WeatherInfoPanelProps) {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
7
frontend/src/common/components/map/mapUtils.ts
Normal file
7
frontend/src/common/components/map/mapUtils.ts
Normal file
@ -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-size: 11px;
|
||||
color: var(--t2);
|
||||
z-index: 1001;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
@ -351,7 +351,7 @@
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
z-index: 1001;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
@ -395,7 +395,7 @@
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 16px;
|
||||
z-index: 1001;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.tlc {
|
||||
@ -753,7 +753,7 @@
|
||||
font-weight: 600;
|
||||
color: var(--boom);
|
||||
font-family: var(--fK);
|
||||
z-index: 1002;
|
||||
z-index: 40;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
animation: fadeSlideDown 0.3s ease;
|
||||
|
||||
@ -1,8 +1,53 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { useMemo, useCallback, useEffect, useRef } from 'react'
|
||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
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 { 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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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 {
|
||||
organizations: AssetOrgCompat[]
|
||||
@ -19,94 +64,62 @@ function AssetMap({
|
||||
regionFilter,
|
||||
onRegionFilterChange,
|
||||
}: AssetMapProps) {
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<L.Map | null>(null)
|
||||
const markersRef = useRef<L.LayerGroup | null>(null)
|
||||
|
||||
// Initialize map once
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current || mapRef.current) return
|
||||
|
||||
const map = L.map(mapContainerRef.current, {
|
||||
center: [35.9, 127.8],
|
||||
zoom: 7,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
})
|
||||
|
||||
// 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(
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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' }
|
||||
const handleClick = useCallback(
|
||||
(org: AssetOrgCompat) => {
|
||||
onSelectOrg(org)
|
||||
},
|
||||
[onSelectOrg],
|
||||
)
|
||||
cm.on('click', () => onSelectOrg(org))
|
||||
|
||||
markersRef.current!.addLayer(cm)
|
||||
const markerLayer = useMemo(() => {
|
||||
return new ScatterplotLayer({
|
||||
id: 'asset-orgs',
|
||||
data: orgs,
|
||||
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
|
||||
getRadius: (d: AssetOrgCompat) => {
|
||||
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7
|
||||
const isSelected = selectedOrg.id === d.id
|
||||
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, onSelectOrg])
|
||||
|
||||
// Pan to selected org
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return
|
||||
mapRef.current.flyTo([selectedOrg.lat, selectedOrg.lng], 10, { duration: 0.8 })
|
||||
}, [selectedOrg])
|
||||
}, [orgs, selectedOrg, handleClick])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<style>{`
|
||||
.asset-map-tooltip {
|
||||
background: rgba(15,21,36,0.92) !important;
|
||||
border: 1px solid rgba(30,42,66,0.8) !important;
|
||||
color: #e4e8f1 !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 4px 8px !important;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important;
|
||||
}
|
||||
.asset-map-tooltip::before {
|
||||
border-top-color: rgba(15,21,36,0.92) !important;
|
||||
}
|
||||
`}</style>
|
||||
<div ref={mapContainerRef} className="w-full h-full" />
|
||||
<Map
|
||||
initialViewState={{ longitude: 127.8, latitude: 35.9, zoom: 7 }}
|
||||
mapStyle={BASE_STYLE}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
>
|
||||
<DeckGLOverlay layers={[markerLayer]} />
|
||||
<FlyToController selectedOrg={selectedOrg} />
|
||||
</Map>
|
||||
|
||||
{/* Region filter overlay */}
|
||||
<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 L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { ScatSegment } from './scatTypes';
|
||||
import { esiColor, jejuCoastCoords } from './scatConstants';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
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 { ScatSegment } from './scatTypes'
|
||||
import { esiColor, jejuCoastCoords, scatDetailData } from './scatConstants'
|
||||
import { hexToRgba } from '@common/components/map/mapUtils'
|
||||
|
||||
interface ScatMapProps {
|
||||
segments: ScatSegment[];
|
||||
selectedSeg: ScatSegment;
|
||||
onSelectSeg: (s: ScatSegment) => void;
|
||||
onOpenPopup: (sn: number) => void;
|
||||
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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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) {
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const markersRef = useRef<L.LayerGroup | null>(null);
|
||||
const [zoom, setZoom] = useState(10);
|
||||
const [zoom, setZoom] = useState(10)
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current || mapRef.current) return;
|
||||
|
||||
const map = L.map(mapContainerRef.current, {
|
||||
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,
|
||||
}).addTo(map);
|
||||
|
||||
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
||||
L.control
|
||||
.attribution({ position: 'bottomleft' })
|
||||
.addAttribution(
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
const handleClick = useCallback(
|
||||
(seg: ScatSegment) => {
|
||||
onSelectSeg(seg)
|
||||
onOpenPopup(seg.id % scatDetailData.length)
|
||||
},
|
||||
[onSelectSeg, onOpenPopup],
|
||||
)
|
||||
.addTo(map);
|
||||
|
||||
map.on('zoomend', () => setZoom(map.getZoom()));
|
||||
|
||||
mapRef.current = map;
|
||||
markersRef.current = L.layerGroup().addTo(map);
|
||||
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
|
||||
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 zs = useMemo(() => getZoomScale(zoom), [zoom])
|
||||
|
||||
// 제주도 해안선 레퍼런스 라인
|
||||
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',
|
||||
},
|
||||
);
|
||||
|
||||
polyline.on('click', () => {
|
||||
onSelectSeg(seg);
|
||||
onOpenPopup(seg.id);
|
||||
});
|
||||
markersRef.current!.addLayer(polyline);
|
||||
|
||||
// 조사 상태 마커 (DivIcon) — 줌 레벨에 따라 표시/크기 조절
|
||||
if (showStatusMarker) {
|
||||
const stColor =
|
||||
seg.status === '완료' ? '#22c55e' : seg.status === '진행중' ? '#eab308' : '#64748b';
|
||||
const stBg =
|
||||
seg.status === '완료'
|
||||
? 'rgba(34,197,94,0.2)'
|
||||
: seg.status === '진행중'
|
||||
? 'rgba(234,179,8,0.2)'
|
||||
: 'rgba(100,116,139,0.2)';
|
||||
const stText = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—';
|
||||
const half = Math.round(markerSize / 2);
|
||||
|
||||
const statusMarker = L.marker([seg.lat, seg.lng], {
|
||||
icon: L.divIcon({
|
||||
className: '',
|
||||
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>`,
|
||||
iconSize: [0, 0],
|
||||
const coastlineLayer = useMemo(
|
||||
() =>
|
||||
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,
|
||||
}),
|
||||
});
|
||||
[],
|
||||
)
|
||||
|
||||
statusMarker.on('click', () => {
|
||||
onSelectSeg(seg);
|
||||
onOpenPopup(seg.id);
|
||||
});
|
||||
markersRef.current!.addLayer(statusMarker);
|
||||
// 선택된 구간 글로우 레이어
|
||||
const glowLayer = useMemo(
|
||||
() =>
|
||||
new PathLayer({
|
||||
id: 'scat-glow',
|
||||
data: [selectedSeg],
|
||||
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale),
|
||||
getColor: [34, 197, 94, 38],
|
||||
getWidth: zs.glowWidth,
|
||||
capRounded: true,
|
||||
jointRounded: true,
|
||||
widthMinPixels: 4,
|
||||
updateTriggers: {
|
||||
getPath: [zs.halfLenScale],
|
||||
getWidth: [zs.glowWidth],
|
||||
},
|
||||
}),
|
||||
[selectedSeg, zs.glowWidth, zs.halfLenScale],
|
||||
)
|
||||
|
||||
// ESI 색상 세그먼트 폴리라인
|
||||
const segPathLayer = useMemo(
|
||||
() =>
|
||||
new PathLayer({
|
||||
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)
|
||||
}
|
||||
});
|
||||
}, [segments, selectedSeg, onSelectSeg, onOpenPopup, zoom]);
|
||||
},
|
||||
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],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
mapRef.current.flyTo([selectedSeg.lat, selectedSeg.lng], 12, { duration: 0.6 });
|
||||
}, [selectedSeg]);
|
||||
// 조사 상태 마커 (줌 >= 11 시 표시)
|
||||
const markerLayer = useMemo(() => {
|
||||
if (!zs.showStatusMarker) return null
|
||||
return new ScatterplotLayer({
|
||||
id: 'scat-status-markers',
|
||||
data: segments,
|
||||
getPosition: (d: ScatSegment) => [d.lng, d.lat],
|
||||
getRadius: zs.markerRadius,
|
||||
getFillColor: (d: ScatSegment) => {
|
||||
if (d.status === '완료') return [34, 197, 94, 51]
|
||||
if (d.status === '진행중') return [234, 179, 8, 51]
|
||||
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 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);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deckLayers: any[] = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const layers: any[] = [coastlineLayer, glowLayer, segPathLayer]
|
||||
if (markerLayer) layers.push(markerLayer)
|
||||
return layers
|
||||
}, [coastlineLayer, glowLayer, segPathLayer, markerLayer])
|
||||
|
||||
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
|
||||
.filter((s) => s.sensitivity === '최상' || s.sensitivity === '상')
|
||||
.reduce((a, s) => a + s.lengthM, 0);
|
||||
const donePct = segments.length > 0 ? Math.round((doneCount / segments.length) * 100) : 0;
|
||||
const progPct = segments.length > 0 ? Math.round((progCount / segments.length) * 100) : 0;
|
||||
const notPct = 100 - donePct - progPct;
|
||||
.filter(s => s.sensitivity === '최상' || s.sensitivity === '상')
|
||||
.reduce((a, s) => a + s.lengthM, 0)
|
||||
const donePct = Math.round((doneCount / segments.length) * 100)
|
||||
const progPct = Math.round((progCount / segments.length) * 100)
|
||||
const notPct = 100 - donePct - progPct
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<style>{`
|
||||
.scat-map-tooltip {
|
||||
background: rgba(15,21,36,0.92) !important;
|
||||
border: 1px solid rgba(30,42,66,0.8) !important;
|
||||
color: #e4e8f1 !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 4px 8px !important;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important;
|
||||
}
|
||||
.scat-map-tooltip::before {
|
||||
border-top-color: rgba(15,21,36,0.92) !important;
|
||||
}
|
||||
`}</style>
|
||||
<div ref={mapContainerRef} className="w-full h-full" />
|
||||
<Map
|
||||
initialViewState={{ longitude: 126.55, latitude: 33.38, zoom: 10 }}
|
||||
mapStyle={BASE_STYLE}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
onZoom={e => setZoom(e.viewState.zoom)}
|
||||
>
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
<FlyToController selectedSeg={selectedSeg} />
|
||||
</Map>
|
||||
|
||||
{/* 호버 툴팁 */}
|
||||
{tooltip && (
|
||||
<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 */}
|
||||
<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">
|
||||
{/* 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="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">
|
||||
ESI 민감도 분류 범례
|
||||
</div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">ESI 민감도 분류 범례</div>
|
||||
{[
|
||||
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
|
||||
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
|
||||
@ -235,10 +304,7 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
|
||||
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-2 py-1 text-[11px]">
|
||||
<span
|
||||
className="w-3.5 h-1.5 rounded-sm flex-shrink-0"
|
||||
style={{ background: item.color }}
|
||||
/>
|
||||
<span 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="ml-auto font-mono text-[10px] text-text-1">{item.esi}</span>
|
||||
</div>
|
||||
@ -247,30 +313,15 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
|
||||
|
||||
{/* 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="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">
|
||||
조사 진행률
|
||||
</div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">조사 진행률</div>
|
||||
<div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${donePct}%`, background: 'var(--green)' }}
|
||||
/>
|
||||
<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 className="h-full transition-all duration-500" style={{ width: `${donePct}%`, background: 'var(--green)' }} />
|
||||
<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 className="flex justify-between mt-1">
|
||||
<span className="text-[9px] font-mono" style={{ color: 'var(--green)' }}>
|
||||
완료 {donePct}%
|
||||
</span>
|
||||
<span className="text-[9px] font-mono" style={{ color: 'var(--orange)' }}>
|
||||
진행 {progPct}%
|
||||
</span>
|
||||
<span className="text-[9px] font-mono" style={{ color: 'var(--green)' }}>완료 {donePct}%</span>
|
||||
<span className="text-[9px] font-mono" style={{ color: 'var(--orange)' }}>진행 {progPct}%</span>
|
||||
<span className="text-[9px] font-mono text-text-3">미조사 {notPct}%</span>
|
||||
</div>
|
||||
<div className="mt-2.5">
|
||||
@ -278,21 +329,11 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
|
||||
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
|
||||
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--green)'],
|
||||
['고민감 구간', `${(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) => (
|
||||
<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]"
|
||||
>
|
||||
<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]">
|
||||
<span className="text-text-2 font-korean">{label}</span>
|
||||
<span
|
||||
className="font-mono font-medium text-[11px]"
|
||||
style={{ color: color || undefined }}
|
||||
>
|
||||
<span className="font-mono font-medium text-[11px]" style={{ color: color || undefined }}>
|
||||
{val}
|
||||
</span>
|
||||
</div>
|
||||
@ -314,7 +355,7 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ScatMap;
|
||||
export default ScatMap
|
||||
|
||||
@ -1,88 +1,171 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Map, useControl } from '@vis.gl/react-maplibre'
|
||||
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 { hexToRgba } from '@common/components/map/mapUtils'
|
||||
|
||||
// ═══ Popup Map (Leaflet) ═══
|
||||
// ── 베이스맵 스타일 ──────────────────────────────────────
|
||||
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,
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
|
||||
}
|
||||
|
||||
function PopupMap({ lat, lng, esi, esiCol, code, name }: { lat: number; lng: number; esi: string; esiCol: string; code: string; name: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<L.Map | null>(null)
|
||||
// ── 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
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
// 이전 맵 제거
|
||||
if (mapRef.current) { mapRef.current.remove(); mapRef.current = null }
|
||||
|
||||
const map = L.map(containerRef.current, {
|
||||
center: [lat, lng],
|
||||
zoom: 15,
|
||||
zoomControl: false,
|
||||
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)
|
||||
|
||||
// 해안 구간 라인 (시뮬레이션)
|
||||
// ── 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][] = [
|
||||
[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],
|
||||
[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],
|
||||
]
|
||||
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],
|
||||
[lng - 0.003, lat - 0.0015],
|
||||
[lng - 0.001, lat - 0.0005],
|
||||
[lng + 0.001, lat + 0.0005],
|
||||
[lng + 0.003, lat + 0.0015],
|
||||
]
|
||||
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)
|
||||
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,
|
||||
}),
|
||||
]
|
||||
|
||||
// 접근 포인트
|
||||
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" />
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══ SCAT Popup Modal ═══
|
||||
|
||||
// ── ScatPopup Modal ──────────────────────────────────────
|
||||
interface ScatPopupProps {
|
||||
data: ScatDetail | null
|
||||
segCode: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ScatPopup({
|
||||
data,
|
||||
segCode,
|
||||
onClose,
|
||||
}: ScatPopupProps) {
|
||||
function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
const [popTab, setPopTab] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
@ -90,24 +173,42 @@ function ScatPopup({
|
||||
if (!data) return null
|
||||
|
||||
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
|
||||
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' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Header */}
|
||||
<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">
|
||||
<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-[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>
|
||||
<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>
|
||||
|
||||
{/* Tabs */}
|
||||
@ -117,7 +218,9 @@ function ScatPopup({
|
||||
key={i}
|
||||
onClick={() => setPopTab(i)}
|
||||
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}
|
||||
@ -137,7 +240,7 @@ function ScatPopup({
|
||||
src={`/scat-photos/${segCode}-1.png`}
|
||||
alt={`${segCode} 해안 조사 사진`}
|
||||
className="w-full h-auto object-contain"
|
||||
onError={(e) => {
|
||||
onError={e => {
|
||||
const target = e.currentTarget
|
||||
target.style.display = 'none'
|
||||
const fallback = target.nextElementSibling as HTMLElement
|
||||
@ -160,10 +263,30 @@ function ScatPopup({
|
||||
</div>
|
||||
{[
|
||||
['유형', 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.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.accessPt, ''],
|
||||
].map(([k, v, cls], i) => (
|
||||
@ -194,7 +317,9 @@ function ScatPopup({
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{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>
|
||||
@ -230,11 +355,18 @@ function ScatPopup({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column - Satellite map */}
|
||||
{/* Right column - Mini map */}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{/* Legend */}
|
||||
@ -296,17 +428,29 @@ function ScatPopup({
|
||||
|
||||
{popTab === 1 && (
|
||||
<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">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div key={i} className="bg-bg-3 border border-border rounded-md p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold font-mono">{h.date}</span>
|
||||
<span 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'
|
||||
}`}>
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useMemo } from 'react'
|
||||
import { ScatterplotLayer } from '@deck.gl/layers'
|
||||
import type { Layer } from '@deck.gl/core'
|
||||
import { hexToRgba } from '@common/components/map/mapUtils'
|
||||
|
||||
interface OceanCurrentData {
|
||||
lat: number
|
||||
@ -16,7 +17,6 @@ interface OceanCurrentLayerProps {
|
||||
|
||||
// 한반도 육지 영역 판별 (간략화된 폴리곤)
|
||||
const isOnLand = (lat: number, lon: number): boolean => {
|
||||
// 한반도 본토 영역 (간략화)
|
||||
const peninsula: [number, number][] = [
|
||||
[38.5, 124.5], [38.5, 128.3],
|
||||
[37.8, 128.8], [37.0, 129.2],
|
||||
@ -43,31 +43,27 @@ const isOnLand = (lat: number, lon: number): boolean => {
|
||||
return inside
|
||||
}
|
||||
|
||||
// 한국 해역의 대략적인 해류 데이터 (실제로는 API에서 가져와야 함)
|
||||
// 한국 해역의 대략적인 해류 데이터 생성
|
||||
const generateOceanCurrentData = (): OceanCurrentData[] => {
|
||||
const data: OceanCurrentData[] = []
|
||||
|
||||
// 격자 형태로 해류 데이터 생성 (실제로는 API에서 받아올 데이터)
|
||||
for (let lat = 33.5; lat <= 38.0; lat += 0.8) {
|
||||
for (let lon = 125.0; lon <= 130.5; lon += 0.8) {
|
||||
// 육지 위의 포인트는 제외 (바다에만 표시)
|
||||
if (isOnLand(lat, lon)) continue
|
||||
|
||||
// 간단한 해류 패턴 시뮬레이션
|
||||
// 동해: 북동진, 서해: 북진, 남해: 동진
|
||||
let direction = 0
|
||||
let speed = 0.3
|
||||
|
||||
if (lon > 128.5) {
|
||||
// 동해 - 북동진하는 동한난류
|
||||
// 동해 — 북동진하는 동한난류
|
||||
direction = 30 + Math.random() * 20
|
||||
speed = 0.4 + Math.random() * 0.3
|
||||
} else if (lon < 126.5) {
|
||||
// 서해 - 북진
|
||||
// 서해 — 북진
|
||||
direction = 350 + Math.random() * 20
|
||||
speed = 0.2 + Math.random() * 0.2
|
||||
} else {
|
||||
// 남해 - 동진
|
||||
// 남해 — 동진
|
||||
direction = 80 + Math.random() * 20
|
||||
speed = 0.3 + Math.random() * 0.3
|
||||
}
|
||||
@ -79,81 +75,74 @@ const generateOceanCurrentData = (): OceanCurrentData[] => {
|
||||
return data
|
||||
}
|
||||
|
||||
// SVG 화살표 생성
|
||||
const createArrowSvg = (speed: number, color: string): string => {
|
||||
const length = Math.min(20 + speed * 30, 50)
|
||||
const width = 2
|
||||
|
||||
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>
|
||||
`
|
||||
// 속도에 따른 hex 색상
|
||||
function getCurrentHexColor(speed: number): string {
|
||||
if (speed > 0.5) return '#ef4444'
|
||||
if (speed > 0.3) return '#f59e0b'
|
||||
return '#3b82f6'
|
||||
}
|
||||
|
||||
export function OceanCurrentLayer({ visible, opacity = 0.7 }: OceanCurrentLayerProps) {
|
||||
const map = useMap()
|
||||
// 해류 데이터는 컴포넌트 외부에서 한 번만 생성 (랜덤이므로 안정화)
|
||||
const OCEAN_CURRENT_DATA = generateOceanCurrentData()
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
/**
|
||||
* 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()
|
||||
const markers: L.Marker[] = []
|
||||
return useMemo(() => {
|
||||
if (!visible) return []
|
||||
|
||||
currentData.forEach((current) => {
|
||||
// 속도에 따른 색상 결정
|
||||
const color =
|
||||
current.speed > 0.5 ? '#ef4444' : current.speed > 0.3 ? '#f59e0b' : '#3b82f6'
|
||||
const data = OCEAN_CURRENT_DATA.map((c) => ({
|
||||
position: [c.lon, c.lat] as [number, number],
|
||||
// 반경: 속도 비례 (5~20km)
|
||||
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)
|
||||
const svgUrl = `data:image/svg+xml;base64,${btoa(arrowSvg)}`
|
||||
|
||||
const icon = L.icon({
|
||||
iconUrl: svgUrl,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 20]
|
||||
})
|
||||
|
||||
const marker = L.marker([current.lat, current.lon], {
|
||||
icon,
|
||||
interactive: false,
|
||||
// 회전 각도 적용
|
||||
rotationAngle: current.direction
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
|
||||
// CSS로 회전 적용
|
||||
marker.on('add', () => {
|
||||
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])
|
||||
return [
|
||||
new ScatterplotLayer({
|
||||
id: 'ocean-current-layer',
|
||||
data,
|
||||
getPosition: (d) => d.position,
|
||||
getRadius: (d) => d.radius,
|
||||
getFillColor: (d) => d.fillColor,
|
||||
getLineColor: (d) => d.lineColor,
|
||||
getLineWidth: 1,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
pickable: false,
|
||||
updateTriggers: {
|
||||
getFillColor: [opacity],
|
||||
getLineColor: [opacity],
|
||||
},
|
||||
}) as unknown as Layer,
|
||||
]
|
||||
}, [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
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { ImageOverlay, useMap } from 'react-leaflet'
|
||||
import { LatLngBounds } from 'leaflet'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Source, Layer } from '@vis.gl/react-maplibre'
|
||||
import type { OceanForecastData } from '../services/khoaApi'
|
||||
|
||||
interface OceanForecastOverlayProps {
|
||||
@ -9,48 +8,62 @@ interface OceanForecastOverlayProps {
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
// 한국 해역 범위 (대략적인 경계)
|
||||
const KOREA_BOUNDS = new LatLngBounds(
|
||||
[33.0, 124.5], // 남서쪽 (제주 남쪽)
|
||||
[38.5, 132.0] // 북동쪽 (동해 북쪽)
|
||||
)
|
||||
// 한국 해역 범위 (MapLibre image source용 좌표 배열)
|
||||
// [left, bottom, right, top] → MapLibre coordinates 순서: [sw, nw, ne, se]
|
||||
// [lon, lat] 순서
|
||||
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({
|
||||
forecast,
|
||||
opacity = 0.6,
|
||||
visible = true
|
||||
visible = true,
|
||||
}: OceanForecastOverlayProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const map = useMap()
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [loadedUrl, setLoadedUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (forecast?.filePath) {
|
||||
// 이미지 미리 로드
|
||||
if (!forecast?.filePath) return
|
||||
let cancelled = false
|
||||
const img = new Image()
|
||||
img.onload = () => setImageLoaded(true)
|
||||
img.onerror = () => setImageLoaded(false)
|
||||
img.onload = () => { if (!cancelled) setLoadedUrl(forecast.filePath) }
|
||||
img.onerror = () => { if (!cancelled) setLoadedUrl(null) }
|
||||
img.src = forecast.filePath
|
||||
} else {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setImageLoaded(false)
|
||||
}
|
||||
return () => { cancelled = true }
|
||||
}, [forecast?.filePath])
|
||||
|
||||
const imageLoaded = !!loadedUrl && loadedUrl === forecast?.filePath
|
||||
|
||||
if (!visible || !forecast || !imageLoaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageOverlay
|
||||
<Source
|
||||
id="ocean-forecast-image"
|
||||
type="image"
|
||||
url={forecast.filePath}
|
||||
bounds={KOREA_BOUNDS}
|
||||
opacity={opacity}
|
||||
zIndex={400} // 지도 위에 표시되지만 마커보다는 아래
|
||||
eventHandlers={{
|
||||
load: () => console.log('해황예보도 이미지 로드 완료'),
|
||||
error: () => console.error('해황예보도 이미지 로드 실패')
|
||||
coordinates={KOREA_IMAGE_COORDINATES}
|
||||
>
|
||||
<Layer
|
||||
id="ocean-forecast-raster"
|
||||
type="raster"
|
||||
paint={{
|
||||
'raster-opacity': opacity,
|
||||
'raster-resampling': 'linear',
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,27 +1,24 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useMemo } from 'react'
|
||||
import { HeatmapLayer } from '@deck.gl/aggregation-layers'
|
||||
import type { Layer } from '@deck.gl/core'
|
||||
|
||||
interface WaterTemperatureLayerProps {
|
||||
visible: boolean
|
||||
opacity?: number
|
||||
}
|
||||
|
||||
// 수온 데이터 생성 (실제로는 API에서 받아야 함)
|
||||
interface TemperaturePoint {
|
||||
lat: number
|
||||
lon: number
|
||||
temp: number
|
||||
}
|
||||
|
||||
// 수온 데이터 생성 (실제로는 API에서 받아야 함)
|
||||
const generateTemperatureData = (): TemperaturePoint[] => {
|
||||
const data: TemperaturePoint[] = []
|
||||
|
||||
// 격자 형태로 수온 데이터 생성
|
||||
for (let lat = 33.0; lat <= 38.5; lat += 0.3) {
|
||||
for (let lon = 124.5; lon <= 131.0; lon += 0.3) {
|
||||
// 간단한 수온 패턴 시뮬레이션
|
||||
// 남쪽이 따뜻하고, 동해가 서해보다 차가움
|
||||
let temp = 8.0
|
||||
|
||||
// 위도에 따른 온도 (남쪽이 따뜻함)
|
||||
@ -34,8 +31,8 @@ const generateTemperatureData = (): TemperaturePoint[] => {
|
||||
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 })
|
||||
}
|
||||
@ -44,127 +41,74 @@ const generateTemperatureData = (): TemperaturePoint[] => {
|
||||
return data
|
||||
}
|
||||
|
||||
// 온도에 따른 색상 반환
|
||||
const getColorForTemperature = (temp: number): string => {
|
||||
// 4°C (진한 파랑) ~ 16°C (빨강) 범위
|
||||
if (temp <= 5) return 'rgba(0, 0, 139, 0.4)' // 진한 파랑
|
||||
if (temp <= 7) return 'rgba(0, 68, 204, 0.4)' // 파랑
|
||||
if (temp <= 9) return 'rgba(51, 153, 255, 0.4)' // 하늘색
|
||||
if (temp <= 11) return 'rgba(102, 204, 102, 0.4)' // 연두색
|
||||
if (temp <= 13) return 'rgba(255, 255, 102, 0.4)' // 노랑
|
||||
if (temp <= 15) return 'rgba(255, 153, 51, 0.4)' // 주황
|
||||
return 'rgba(255, 68, 68, 0.4)' // 빨강
|
||||
// 온도 데이터는 컴포넌트 외부에서 한 번만 생성 (안정화)
|
||||
const TEMPERATURE_DATA = generateTemperatureData()
|
||||
|
||||
// temp → HeatmapLayer weight (0~1 정규화, 범위 4~16°C)
|
||||
function tempToWeight(temp: number): number {
|
||||
const MIN_TEMP = 4
|
||||
const MAX_TEMP = 16
|
||||
return Math.max(0, Math.min(1, (temp - MIN_TEMP) / (MAX_TEMP - MIN_TEMP)))
|
||||
}
|
||||
|
||||
// Custom canvas layer
|
||||
class TemperatureCanvasLayer extends L.Layer {
|
||||
private canvas: HTMLCanvasElement | null = null
|
||||
private data: TemperaturePoint[]
|
||||
private layerOpacity: number
|
||||
/**
|
||||
* useWaterTemperatureLayers — deck.gl HeatmapLayer 배열 반환 훅
|
||||
*
|
||||
* 기존: L.Layer 확장 커스텀 클래스 + Canvas RadialGradient
|
||||
* 전환: @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) {
|
||||
super()
|
||||
this.data = data
|
||||
this.layerOpacity = opacity
|
||||
}
|
||||
return useMemo(() => {
|
||||
if (!visible) return []
|
||||
|
||||
onAdd(map: L.Map): this {
|
||||
this.canvas = L.DomUtil.create('canvas', 'temperature-canvas-layer')
|
||||
const size = map.getSize()
|
||||
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'
|
||||
const data = TEMPERATURE_DATA.map((p) => ({
|
||||
position: [p.lon, p.lat] as [number, number],
|
||||
weight: tempToWeight(p.temp),
|
||||
}))
|
||||
|
||||
map.getPanes().overlayPane?.appendChild(this.canvas)
|
||||
|
||||
map.on('moveend zoom', this.redraw, this)
|
||||
this.redraw()
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
onRemove(map: L.Map): this {
|
||||
if (this.canvas && this.canvas.parentNode) {
|
||||
this.canvas.parentNode.removeChild(this.canvas)
|
||||
}
|
||||
map.off('moveend zoom', this.redraw, this)
|
||||
this.canvas = null
|
||||
return this
|
||||
}
|
||||
|
||||
redraw = () => {
|
||||
if (!this.canvas) return
|
||||
|
||||
const map = this._map
|
||||
if (!map) return
|
||||
|
||||
const size = map.getSize()
|
||||
this.canvas.width = size.x
|
||||
this.canvas.height = size.y
|
||||
|
||||
const ctx = this.canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
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
|
||||
)
|
||||
})
|
||||
}
|
||||
return [
|
||||
new HeatmapLayer({
|
||||
id: 'water-temperature-heatmap',
|
||||
data,
|
||||
getPosition: (d) => d.position,
|
||||
getWeight: (d) => d.weight,
|
||||
radiusPixels: 40,
|
||||
intensity: 1,
|
||||
threshold: 0.03,
|
||||
opacity,
|
||||
colorRange: [
|
||||
// 진한 파랑 (차가움)
|
||||
[0, 0, 139, 255],
|
||||
// 파랑
|
||||
[0, 68, 204, 255],
|
||||
// 하늘색
|
||||
[51, 153, 255, 255],
|
||||
// 연두색
|
||||
[102, 204, 102, 255],
|
||||
// 노랑
|
||||
[255, 255, 102, 255],
|
||||
// 주황
|
||||
[255, 153, 51, 255],
|
||||
// 빨강 (따뜻함)
|
||||
[255, 68, 68, 255],
|
||||
] as [number, number, number, number][],
|
||||
}) as unknown as Layer,
|
||||
]
|
||||
}, [visible, opacity])
|
||||
}
|
||||
|
||||
export function WaterTemperatureLayer({ visible, opacity = 0.5 }: WaterTemperatureLayerProps) {
|
||||
const map = useMap()
|
||||
const layerRef = useRef<TemperatureCanvasLayer | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
if (layerRef.current) {
|
||||
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])
|
||||
|
||||
/**
|
||||
* WaterTemperatureLayer — React 컴포넌트 (null 반환)
|
||||
*
|
||||
* WeatherView에서 useWaterTemperatureLayers()의 결과를 DeckGLOverlay layers에 주입한다.
|
||||
* 이 컴포넌트는 이전 Leaflet 방식과의 호환성을 위해 시그니처를 유지한다.
|
||||
*/
|
||||
export function WaterTemperatureLayer() {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { Circle, Marker } from 'react-leaflet'
|
||||
import { DivIcon } from 'leaflet'
|
||||
import { useMemo } from 'react'
|
||||
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 {
|
||||
id: string
|
||||
@ -19,6 +22,8 @@ interface WeatherStation {
|
||||
current: number
|
||||
feelsLike: number
|
||||
}
|
||||
pressure: number
|
||||
visibility: number
|
||||
}
|
||||
|
||||
interface WeatherMapOverlayProps {
|
||||
@ -28,244 +33,350 @@ interface WeatherMapOverlayProps {
|
||||
selectedStationId: string | null
|
||||
}
|
||||
|
||||
// Create wind arrow icon
|
||||
const createWindArrowIcon = (speed: number, direction: number, isSelected: boolean) => {
|
||||
const size = Math.min(40 + speed * 2, 80)
|
||||
const color = isSelected ? '#06b6d4' : speed > 10 ? '#ef4444' : speed > 7 ? '#f59e0b' : '#3b82f6'
|
||||
|
||||
return new DivIcon({
|
||||
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]
|
||||
})
|
||||
// 풍속에 따른 hex 색상 반환
|
||||
function getWindHexColor(speed: number, isSelected: boolean): string {
|
||||
if (isSelected) return '#06b6d4'
|
||||
if (speed > 10) return '#ef4444'
|
||||
if (speed > 7) return '#f59e0b'
|
||||
return '#3b82f6'
|
||||
}
|
||||
|
||||
// Create weather data label icon (enhanced style similar to KHOA)
|
||||
const createWeatherLabelIcon = (station: WeatherStation, isSelected: boolean) => {
|
||||
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 boxShadow = '0 2px 8px rgba(0,0,0,0.3)'
|
||||
|
||||
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 getWaveHexColor(height: number): string {
|
||||
if (height > 2.5) return '#ef4444'
|
||||
if (height > 1.5) return '#f59e0b'
|
||||
return '#3b82f6'
|
||||
}
|
||||
|
||||
// 수온에 따른 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({
|
||||
stations,
|
||||
enabledLayers,
|
||||
onStationClick,
|
||||
selectedStationId
|
||||
selectedStationId,
|
||||
}: WeatherMapOverlayProps) {
|
||||
// deck.gl 레이어는 useWeatherDeckLayers 훅을 통해 외부로 전달되므로
|
||||
// 이 컴포넌트는 HTML 오버레이(Marker) 부분만 담당
|
||||
return (
|
||||
<>
|
||||
{/* Wind Vectors */}
|
||||
{/* 풍향 화살표 — MapLibre Marker + CSS rotate */}
|
||||
{enabledLayers.has('wind') &&
|
||||
stations.map((station) => {
|
||||
const isSelected = selectedStationId === station.id
|
||||
const color = getWindHexColor(station.wind.speed, isSelected)
|
||||
const size = Math.min(40 + station.wind.speed * 2, 80)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={`wind-${station.id}`}
|
||||
position={[station.location.lat, station.location.lon]}
|
||||
icon={createWindArrowIcon(station.wind.speed, station.wind.direction, isSelected)}
|
||||
eventHandlers={{
|
||||
click: () => onStationClick(station)
|
||||
longitude={station.location.lon}
|
||||
latitude={station.location.lat}
|
||||
anchor="center"
|
||||
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') &&
|
||||
stations.map((station) => {
|
||||
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 (
|
||||
<Marker
|
||||
key={`label-${station.id}`}
|
||||
position={[station.location.lat, station.location.lon]}
|
||||
icon={createWeatherLabelIcon(station, isSelected)}
|
||||
eventHandlers={{
|
||||
click: () => onStationClick(station)
|
||||
longitude={station.location.lon}
|
||||
latitude={station.location.lat}
|
||||
anchor="center"
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Wave Height Circles */}
|
||||
{enabledLayers.has('waves') &&
|
||||
stations.map((station) => {
|
||||
// Color based on wave height
|
||||
const waveColor =
|
||||
station.wave.height > 2.5
|
||||
? '#ef4444'
|
||||
: station.wave.height > 1.5
|
||||
? '#f59e0b'
|
||||
: '#3b82f6'
|
||||
|
||||
const radius = station.wave.height * 15000 // Scale for visualization
|
||||
|
||||
return (
|
||||
<Circle
|
||||
key={`wave-${station.id}`}
|
||||
center={[station.location.lat, station.location.lon]}
|
||||
radius={radius}
|
||||
pathOptions={{
|
||||
fillColor: waveColor,
|
||||
fillOpacity: 0.15,
|
||||
color: waveColor,
|
||||
weight: 2,
|
||||
opacity: 0.6
|
||||
>
|
||||
{/* 관측소명 */}
|
||||
<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,
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => onStationClick(station)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
>
|
||||
{station.name}
|
||||
</div>
|
||||
|
||||
{/* Temperature Circles */}
|
||||
{enabledLayers.has('temperature') &&
|
||||
stations.map((station) => {
|
||||
// Color based on temperature
|
||||
const tempColor =
|
||||
station.temperature.current > 8
|
||||
? '#ef4444'
|
||||
: station.temperature.current > 6
|
||||
? '#f59e0b'
|
||||
: '#3b82f6'
|
||||
|
||||
const radius = 10000 // Fixed size for temp
|
||||
|
||||
return (
|
||||
<Circle
|
||||
key={`temp-${station.id}`}
|
||||
center={[station.location.lat, station.location.lon]}
|
||||
radius={radius}
|
||||
pathOptions={{
|
||||
fillColor: tempColor,
|
||||
fillOpacity: 0.2,
|
||||
color: tempColor,
|
||||
weight: 1.5,
|
||||
opacity: 0.5
|
||||
{/* 수온 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ff6b35 0%, #ff8c42 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)',
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => onStationClick(station)
|
||||
>
|
||||
🌡️
|
||||
</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>
|
||||
|
||||
{/* 파고 */}
|
||||
<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>
|
||||
|
||||
{/* 풍속 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #34d399 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.wind.speed.toFixed(1)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#fff',
|
||||
opacity: 0.9,
|
||||
textShadow: '1px 1px 2px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
m/s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { MapContainer, TileLayer, useMapEvents } from 'react-leaflet'
|
||||
import type { LatLngExpression } from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import type { Layer } from '@deck.gl/core'
|
||||
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { WeatherRightPanel } from './WeatherRightPanel'
|
||||
import { WeatherMapOverlay } from './WeatherMapOverlay'
|
||||
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
|
||||
import { OceanForecastOverlay } from './OceanForecastOverlay'
|
||||
import { OceanCurrentLayer } from './OceanCurrentLayer'
|
||||
import { WaterTemperatureLayer } from './WaterTemperatureLayer'
|
||||
import { useOceanCurrentLayers } from './OceanCurrentLayer'
|
||||
import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
|
||||
import { WindParticleLayer } from './WindParticleLayer'
|
||||
import { useWeatherData } from '../hooks/useWeatherData'
|
||||
import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||
@ -43,8 +45,8 @@ interface WeatherForecast {
|
||||
windSpeed: number
|
||||
}
|
||||
|
||||
// Base weather station locations (weather data will be fetched from API)
|
||||
const baseStations = [
|
||||
// Base weather station locations
|
||||
const BASE_STATIONS = [
|
||||
{ id: 'incheon', name: '인천', location: { lat: 37.45, lon: 126.43 } },
|
||||
{ id: 'ulsan', name: '울산', location: { lat: 35.52, lon: 129.38 } },
|
||||
{ id: 'yeosu', name: '여수', location: { lat: 34.74, lon: 127.75 } },
|
||||
@ -54,102 +56,235 @@ const baseStations = [
|
||||
{ id: 'gunsan', name: '군산', location: { lat: 35.97, lon: 126.7 } },
|
||||
{ id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } },
|
||||
{ id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } },
|
||||
{ id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } }
|
||||
{ id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } },
|
||||
]
|
||||
|
||||
// Generate forecast data based on time offset
|
||||
const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => {
|
||||
const baseHour = parseInt(timeOffset)
|
||||
const forecasts: WeatherForecast[] = []
|
||||
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️']
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const hour = baseHour + i * 3
|
||||
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️']
|
||||
forecasts.push({
|
||||
time: `+${hour}시`,
|
||||
hour: `${hour}시`,
|
||||
icon: icons[i % icons.length],
|
||||
temperature: Math.floor(Math.random() * 5) + 5,
|
||||
windSpeed: Math.floor(Math.random() * 5) + 6
|
||||
windSpeed: Math.floor(Math.random() * 5) + 6,
|
||||
})
|
||||
}
|
||||
|
||||
return forecasts
|
||||
}
|
||||
|
||||
// Map click handler component
|
||||
function MapClickHandler({ onMapClick }: { onMapClick: (lat: number, lon: number) => void }) {
|
||||
useMapEvents({
|
||||
click(e) {
|
||||
onMapClick(e.latlng.lat, e.latlng.lng)
|
||||
}
|
||||
})
|
||||
// CartoDB Dark Matter 스타일 (기존 WeatherView와 동일)
|
||||
const WEATHER_MAP_STYLE: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'carto-dark': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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
|
||||
}
|
||||
|
||||
export function WeatherView() {
|
||||
// Fetch real-time weather data from API
|
||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(baseStations)
|
||||
// 줌 컨트롤
|
||||
function WeatherMapControls() {
|
||||
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"
|
||||
>
|
||||
🎯
|
||||
</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 {
|
||||
selectedForecast,
|
||||
availableTimes,
|
||||
loading: oceanLoading,
|
||||
error: oceanError,
|
||||
selectForecast
|
||||
selectForecast,
|
||||
} = useOceanForecast('KOREA')
|
||||
|
||||
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>(
|
||||
null
|
||||
)
|
||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(
|
||||
new Set(['wind', 'labels'])
|
||||
)
|
||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind', 'labels']))
|
||||
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
||||
|
||||
// Set initial selected station when data loads
|
||||
useEffect(() => {
|
||||
if (weatherStations.length > 0 && !selectedStation) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSelectedStation(weatherStations[0])
|
||||
}
|
||||
}, [weatherStations, selectedStation])
|
||||
// 첫 관측소 자동 선택 (파생 값)
|
||||
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
|
||||
|
||||
const mapCenter: LatLngExpression = [36.5, 127.8]
|
||||
|
||||
const handleStationClick = (station: WeatherStation) => {
|
||||
const handleStationClick = useCallback(
|
||||
(station: WeatherStation) => {
|
||||
setSelectedStation(station)
|
||||
setSelectedLocation(null)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleMapClick = (lat: number, lon: number) => {
|
||||
// Find nearest station
|
||||
const handleMapClick = useCallback(
|
||||
(e: MapLayerMouseEvent) => {
|
||||
const { lat, lng } = e.lngLat
|
||||
if (weatherStations.length === 0) return
|
||||
|
||||
// 가장 가까운 관측소 선택
|
||||
const nearestStation = weatherStations.reduce((nearest, station) => {
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lon, 2)
|
||||
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lng, 2)
|
||||
)
|
||||
const nearestDistance = Math.sqrt(
|
||||
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lon, 2)
|
||||
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2)
|
||||
)
|
||||
return distance < nearestDistance ? station : nearest
|
||||
}, weatherStations[0])
|
||||
|
||||
setSelectedStation(nearestStation)
|
||||
setSelectedLocation({ lat, lon })
|
||||
}
|
||||
setSelectedLocation({ lat, lon: lng })
|
||||
},
|
||||
[weatherStations]
|
||||
)
|
||||
|
||||
const toggleLayer = (layer: string) => {
|
||||
const newLayers = new Set(enabledLayers)
|
||||
if (newLayers.has(layer)) {
|
||||
newLayers.delete(layer)
|
||||
const toggleLayer = useCallback((layer: string) => {
|
||||
setEnabledLayers((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(layer)) {
|
||||
next.delete(layer)
|
||||
} else {
|
||||
newLayers.add(layer)
|
||||
}
|
||||
setEnabledLayers(newLayers)
|
||||
next.add(layer)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const weatherData = selectedStation
|
||||
? {
|
||||
@ -159,14 +294,14 @@ export function WeatherView() {
|
||||
location: selectedLocation || selectedStation.location,
|
||||
currentTime: new Date().toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
minute: '2-digit',
|
||||
}),
|
||||
wind: selectedStation.wind,
|
||||
wave: selectedStation.wave,
|
||||
temperature: selectedStation.temperature,
|
||||
pressure: selectedStation.pressure,
|
||||
visibility: selectedStation.visibility,
|
||||
forecast: generateForecast(timeOffset)
|
||||
forecast: generateForecast(timeOffset),
|
||||
}
|
||||
: null
|
||||
|
||||
@ -177,62 +312,33 @@ export function WeatherView() {
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center border-b border-border bg-bg-1" style={{ flexShrink: 0 }}>
|
||||
<div className="flex items-center gap-2 px-6">
|
||||
{(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => (
|
||||
<button
|
||||
onClick={() => setTimeOffset('0')}
|
||||
key={offset}
|
||||
onClick={() => setTimeOffset(offset)}
|
||||
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
|
||||
timeOffset === '0'
|
||||
timeOffset === offset
|
||||
? 'bg-primary-cyan text-bg-0'
|
||||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
현재
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTimeOffset('3')}
|
||||
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
|
||||
timeOffset === '3'
|
||||
? 'bg-primary-cyan text-bg-0'
|
||||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
+3시간
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTimeOffset('6')}
|
||||
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
|
||||
timeOffset === '6'
|
||||
? 'bg-primary-cyan text-bg-0'
|
||||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
+6시간
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTimeOffset('9')}
|
||||
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
|
||||
timeOffset === '9'
|
||||
? 'bg-primary-cyan text-bg-0'
|
||||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
+9시간
|
||||
{offset === '0' ? '현재' : `+${offset}시간`}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<span className="text-xs text-text-3">
|
||||
{lastUpdate
|
||||
? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
: '데이터 로딩 중...'}
|
||||
</span>
|
||||
{loading && (
|
||||
<div className="w-4 h-4 border-2 border-primary-cyan border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{error && (
|
||||
<span className="text-xs text-status-red">⚠️ {error}</span>
|
||||
)}
|
||||
{error && <span className="text-xs text-status-red">⚠️ {error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -247,52 +353,29 @@ export function WeatherView() {
|
||||
|
||||
{/* Map */}
|
||||
<div className="flex-1 relative">
|
||||
<MapContainer
|
||||
center={mapCenter}
|
||||
zoom={7}
|
||||
style={{ height: '100%', width: '100%', background: '#0a0e1a' }}
|
||||
zoomControl={true}
|
||||
<Map
|
||||
initialViewState={{
|
||||
longitude: WEATHER_MAP_CENTER[0],
|
||||
latitude: WEATHER_MAP_CENTER[1],
|
||||
zoom: WEATHER_MAP_ZOOM,
|
||||
}}
|
||||
mapStyle={WEATHER_MAP_STYLE}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onClick={handleMapClick}
|
||||
attributionControl={false}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
/>
|
||||
|
||||
<MapClickHandler onMapClick={handleMapClick} />
|
||||
|
||||
{/* Weather Overlay */}
|
||||
<WeatherMapOverlay
|
||||
stations={weatherStations}
|
||||
<WeatherMapInner
|
||||
weatherStations={weatherStations}
|
||||
enabledLayers={enabledLayers}
|
||||
onStationClick={handleStationClick}
|
||||
selectedStationId={selectedStation?.id || null}
|
||||
oceanForecastOpacity={oceanForecastOpacity}
|
||||
selectedForecast={selectedForecast}
|
||||
onStationClick={handleStationClick}
|
||||
/>
|
||||
</Map>
|
||||
|
||||
{/* Ocean Forecast Overlay */}
|
||||
<OceanForecastOverlay
|
||||
forecast={selectedForecast}
|
||||
opacity={oceanForecastOpacity}
|
||||
visible={enabledLayers.has('oceanForecast')}
|
||||
/>
|
||||
|
||||
{/* Ocean Current Arrows */}
|
||||
<OceanCurrentLayer visible={enabledLayers.has('oceanCurrent')} opacity={0.7} />
|
||||
|
||||
{/* Water Temperature Heatmap */}
|
||||
<WaterTemperatureLayer
|
||||
visible={enabledLayers.has('waterTemperature')}
|
||||
opacity={0.5}
|
||||
/>
|
||||
|
||||
{/* Windy-style Wind Particle Animation */}
|
||||
<WindParticleLayer
|
||||
visible={enabledLayers.has('windParticle')}
|
||||
stations={weatherStations}
|
||||
/>
|
||||
</MapContainer>
|
||||
|
||||
{/* Layer Controls */}
|
||||
<div className="absolute top-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm">
|
||||
{/* 레이어 컨트롤 */}
|
||||
<div className="absolute top-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="space-y-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
@ -371,7 +454,6 @@ export function WeatherView() {
|
||||
<span className="text-xs text-text-2">🌊 해황예보도</span>
|
||||
</label>
|
||||
|
||||
{/* 투명도 조절 슬라이더 */}
|
||||
{enabledLayers.has('oceanForecast') && (
|
||||
<div className="mt-2 ml-6 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -381,13 +463,16 @@ export function WeatherView() {
|
||||
min="0"
|
||||
max="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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 예보 시간 선택 */}
|
||||
{availableTimes.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-3">예보 시간:</div>
|
||||
@ -397,7 +482,8 @@ export function WeatherView() {
|
||||
key={`${time.day}-${time.hour}`}
|
||||
onClick={() => selectForecast(time.day, time.hour)}
|
||||
className={`w-full px-2 py-1 text-xs rounded transition-colors ${
|
||||
selectedForecast?.day === time.day && selectedForecast?.hour === time.hour
|
||||
selectedForecast?.day === time.day &&
|
||||
selectedForecast?.hour === time.hour
|
||||
? 'bg-primary-cyan text-bg-0 font-semibold'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3'
|
||||
}`}
|
||||
@ -409,15 +495,13 @@ export function WeatherView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oceanLoading && (
|
||||
<div className="text-xs text-text-3">로딩 중...</div>
|
||||
)}
|
||||
{oceanError && (
|
||||
<div className="text-xs text-status-red">오류 발생</div>
|
||||
)}
|
||||
{oceanLoading && <div className="text-xs text-text-3">로딩 중...</div>}
|
||||
{oceanError && <div className="text-xs text-status-red">오류 발생</div>}
|
||||
{selectedForecast && (
|
||||
<div className="text-xs text-text-3 pt-2 border-t border-border">
|
||||
현재: {selectedForecast.name} • {selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)} {selectedForecast.hour}:00
|
||||
현재: {selectedForecast.name} •{' '}
|
||||
{selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '}
|
||||
{selectedForecast.hour}:00
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -426,41 +510,50 @@ export function WeatherView() {
|
||||
</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="space-y-3 text-xs">
|
||||
{/* Wind Speed - Windy style */}
|
||||
{/* 바람 (Windy 스타일) */}
|
||||
<div>
|
||||
<div className="font-semibold text-text-2 mb-1">바람 (m/s)</div>
|
||||
<div className="flex items-center gap-1 h-3 rounded-sm overflow-hidden mb-1">
|
||||
<div className="flex-1 h-full" style={{ background: '#6271b7' }}></div>
|
||||
<div className="flex-1 h-full" style={{ background: '#39a0f6' }}></div>
|
||||
<div className="flex-1 h-full" style={{ background: '#50d591' }}></div>
|
||||
<div className="flex-1 h-full" style={{ background: '#a5e23f' }}></div>
|
||||
<div className="flex-1 h-full" style={{ background: '#fae21e' }}></div>
|
||||
<div className="flex-1 h-full" style={{ background: '#faaa19' }}></div>
|
||||
<div className="flex-1 h-full" style={{ background: '#f05421' }}></div>
|
||||
<div className="flex-1 h-full" style={{ background: '#b41e46' }}></div>
|
||||
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#50d591' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#a5e23f' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#fae21e' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#faaa19' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#f05421' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-text-3" style={{ fontSize: '9px' }}>
|
||||
<span>3</span><span>5</span><span>7</span><span>10</span><span>13</span><span>16</span><span>20+</span>
|
||||
<div
|
||||
className="flex justify-between text-text-3"
|
||||
style={{ fontSize: '9px' }}
|
||||
>
|
||||
<span>3</span>
|
||||
<span>5</span>
|
||||
<span>7</span>
|
||||
<span>10</span>
|
||||
<span>13</span>
|
||||
<span>16</span>
|
||||
<span>20+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wave Height */}
|
||||
{/* 파고 */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-1">파고 (m)</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
<span className="text-text-3">< 1.5: 낮음</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500" />
|
||||
<span className="text-text-3">1.5-2.5: 보통</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<span className="text-text-3">> 2.5: 높음</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -472,7 +565,7 @@ export function WeatherView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Weather Details */}
|
||||
{/* Right Panel */}
|
||||
<WeatherRightPanel weatherData={weatherData} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useMap } from '@vis.gl/react-maplibre'
|
||||
import type { Map as MapLibreMap } from 'maplibre-gl'
|
||||
|
||||
interface WindPoint {
|
||||
lat: number
|
||||
@ -26,14 +26,14 @@ interface WindParticleLayerProps {
|
||||
|
||||
// 풍속 기반 색상 (Windy.com 스타일)
|
||||
function getWindColor(speed: number): string {
|
||||
if (speed < 3) return 'rgba(98, 113, 183, 0.8)' // 연한 파랑 (미풍)
|
||||
if (speed < 5) return 'rgba(57, 160, 246, 0.8)' // 파랑
|
||||
if (speed < 7) return 'rgba(80, 213, 145, 0.8)' // 초록
|
||||
if (speed < 10) return 'rgba(165, 226, 63, 0.8)' // 연두
|
||||
if (speed < 13) return 'rgba(250, 226, 30, 0.8)' // 노랑
|
||||
if (speed < 16) return 'rgba(250, 170, 25, 0.8)' // 주황
|
||||
if (speed < 20) return 'rgba(240, 84, 33, 0.8)' // 빨강
|
||||
return 'rgba(180, 30, 70, 0.8)' // 진빨강 (강풍)
|
||||
if (speed < 3) return 'rgba(98, 113, 183, 0.8)'
|
||||
if (speed < 5) return 'rgba(57, 160, 246, 0.8)'
|
||||
if (speed < 7) return 'rgba(80, 213, 145, 0.8)'
|
||||
if (speed < 10) return 'rgba(165, 226, 63, 0.8)'
|
||||
if (speed < 13) return 'rgba(250, 226, 30, 0.8)'
|
||||
if (speed < 16) return 'rgba(250, 170, 25, 0.8)'
|
||||
if (speed < 20) return 'rgba(240, 84, 33, 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)'
|
||||
}
|
||||
|
||||
// 격자 보간으로 특정 위치의 풍속/풍향 추정
|
||||
// 격자 보간으로 특정 위치의 풍속/풍향 추정 (IDW)
|
||||
function interpolateWind(
|
||||
lat: number,
|
||||
lon: number,
|
||||
@ -65,7 +65,6 @@ function interpolateWind(
|
||||
const dist = Math.sqrt(
|
||||
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)
|
||||
totalWeight += weight
|
||||
weightedSpeed += point.speed * weight
|
||||
@ -76,19 +75,63 @@ function interpolateWind(
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const map = useMap()
|
||||
const { current: mapRef } = useMap()
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const particlesRef = useRef<Particle[]>([])
|
||||
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(() => {
|
||||
const map = mapRef?.getMap()
|
||||
if (!map) return
|
||||
|
||||
if (!visible) {
|
||||
// 비활성화 시 캔버스 제거
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.remove()
|
||||
canvasRef.current = null
|
||||
@ -97,16 +140,9 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps)
|
||||
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()
|
||||
|
||||
// Canvas 생성 또는 재사용
|
||||
let canvas = canvasRef.current
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas')
|
||||
@ -121,57 +157,39 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps)
|
||||
|
||||
const resize = () => {
|
||||
if (!canvas) return
|
||||
const size = map.getSize()
|
||||
canvas.width = size.x
|
||||
canvas.height = size.y
|
||||
const { clientWidth: w, clientHeight: h } = container
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
}
|
||||
resize()
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// 파티클 수
|
||||
const PARTICLE_COUNT = 800
|
||||
const FADE_ALPHA = 0.93
|
||||
initParticles(canvas.width, canvas.height)
|
||||
|
||||
// 파티클 초기화
|
||||
function initParticles() {
|
||||
particlesRef.current = []
|
||||
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()
|
||||
// 오프스크린 캔버스 (트레일 효과)
|
||||
let offCanvas: HTMLCanvasElement | null = null
|
||||
let offCtx: CanvasRenderingContext2D | null = null
|
||||
|
||||
// 배경 그라디언트 렌더링
|
||||
function drawWindOverlay() {
|
||||
if (!ctx || !canvas) return
|
||||
const gridSize = 30
|
||||
|
||||
for (let x = 0; x < canvas.width; x += gridSize) {
|
||||
for (let y = 0; y < canvas.height; y += gridSize) {
|
||||
const latlng = map.containerPointToLatLng(L.point(x + gridSize / 2, y + gridSize / 2))
|
||||
const wind = interpolateWind(latlng.lat, latlng.lng, windPoints)
|
||||
const { lat, lng } = containerPointToLatLng(map!, x + gridSize / 2, y + gridSize / 2)
|
||||
const wind = interpolateWind(lat, lng, windPoints)
|
||||
ctx.fillStyle = getWindBgColor(wind.speed)
|
||||
ctx.fillRect(x, y, gridSize, gridSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 애니메이션 루프
|
||||
let offCanvas: HTMLCanvasElement | null = null
|
||||
let offCtx: CanvasRenderingContext2D | null = null
|
||||
|
||||
function animate() {
|
||||
if (!ctx || !canvas) return
|
||||
|
||||
// 오프스크린 캔버스 (트레일 효과)
|
||||
// 오프스크린 캔버스 크기 동기화
|
||||
if (!offCanvas || offCanvas.width !== canvas.width || offCanvas.height !== canvas.height) {
|
||||
offCanvas = document.createElement('canvas')
|
||||
offCanvas.width = canvas.width
|
||||
@ -187,9 +205,9 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps)
|
||||
offCtx.fillRect(0, 0, offCanvas.width, offCanvas.height)
|
||||
offCtx.globalCompositeOperation = 'source-over'
|
||||
|
||||
const bounds = map.getBounds()
|
||||
// 현재 지도 bounds 확인
|
||||
const bounds = map!.getBounds()
|
||||
|
||||
// 파티클 업데이트 및 렌더링
|
||||
for (const particle of particlesRef.current) {
|
||||
particle.age++
|
||||
|
||||
@ -202,17 +220,17 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps)
|
||||
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.y = Math.random() * canvas.height
|
||||
particle.age = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const wind = interpolateWind(latlng.lat, latlng.lng, windPoints)
|
||||
const wind = interpolateWind(lat, lng, windPoints)
|
||||
const rad = (wind.direction * Math.PI) / 180
|
||||
const pixelSpeed = wind.speed * 0.4
|
||||
|
||||
@ -244,10 +262,10 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps)
|
||||
// 지도 이동/줌 시 리셋
|
||||
const onMoveEnd = () => {
|
||||
resize()
|
||||
initParticles()
|
||||
if (offCanvas) {
|
||||
offCanvas.width = canvas!.width
|
||||
offCanvas.height = canvas!.height
|
||||
if (canvas) initParticles(canvas.width, canvas.height)
|
||||
if (offCanvas && canvas) {
|
||||
offCanvas.width = canvas.width
|
||||
offCanvas.height = canvas.height
|
||||
}
|
||||
}
|
||||
map.on('moveend', onMoveEnd)
|
||||
@ -262,7 +280,9 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps)
|
||||
canvasRef.current = null
|
||||
}
|
||||
}
|
||||
}, [map, visible, stations])
|
||||
// windPoints 배열은 렌더마다 재생성되므로 stations만 의존
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mapRef, visible, stations, initParticles])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user