feat(map): Leaflet → MapLibre GL JS + deck.gl 전환 (Phase 6) #47

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

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. 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],
})
)
}, [replayShips])
// 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: [],
})
)
// 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,
})
)
return (
<div key={ship.vesselName}>
{/* Track line (dashed) */}
<Polyline
positions={trackUpToCurrent}
pathOptions={{
color: ship.color,
weight: 2,
dashArray: '6, 4',
opacity: 0.7,
}}
/>
// 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,
})
)
}
}
{/* 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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
},
},
layers: [
{
id: 'carto-dark-layer',
type: 'raster',
source: 'carto-dark',
minzoom: 0,
maxzoom: 22,
},
],
}
// 모델별 색상 매핑
const MODEL_COLORS: Record<PredictionModel, string> = {
'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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
{/* WMS 레이어 (투명도 + 밝기 적용) */}
{/* 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='&copy; 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">
<strong> </strong>
<br />
<span className="text-xs text-gray-600">
{decimalToDMS(incidentCoord.lat, true)}
<br />
{decimalToDMS(incidentCoord.lon, false)}
</span>
<br />
<span className="text-xs text-gray-500" style={{ fontFamily: 'monospace' }}>
({incidentCoord.lat.toFixed(4)}°, {incidentCoord.lon.toFixed(4)}°)
</span>
</div>
</Popup>
<Marker 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>
)}
{/* 오일 확산 입자 시각화 (모델별 색상) */}
{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
}}
>
<Popup>
<div className="text-xs">
<strong>{modelKey} #{(point.particle ?? 0) + 1}</strong>
<br />
: +{point.time}h
<br />
: {point.lat.toFixed(4)}°, {point.lon.toFixed(4)}°
</div>
</Popup>
</CircleMarker>
)
})}
{/* 사고 위치 팝업 (클릭 시) */}
{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" style={{ color: '#666' }}>
{decimalToDMS(incidentCoord.lat, true)}
<br />
{decimalToDMS(incidentCoord.lon, false)}
</span>
<br />
<span className="text-xs" style={{ fontFamily: 'monospace', color: '#888' }}>
({incidentCoord.lat.toFixed(4)}°, {incidentCoord.lon.toFixed(4)}°)
</span>
</div>
</Popup>
)}
{/* 오일펜스 라인 렌더링 */}
{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,
}}
{/* deck.gl 객체 클릭 팝업 */}
{popupInfo && (
<Popup
longitude={popupInfo.longitude}
latitude={popupInfo.latitude}
anchor="bottom"
onClose={() => setPopupInfo(null)}
>
<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
<div style={{ color: '#333' }}>{popupInfo.content}</div>
</Popup>
)}
{/* 드로잉 미리보기 */}
{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"
>
🎯
&#x1F3AF;
</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>
)
}

파일 보기

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
}
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
// ── FlyTo Controller ────────────────────────────────────
function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) {
const { current: map } = useMap()
const prevIdRef = useRef<number | undefined>(undefined)
useEffect(() => {
if (!map) return
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedOrg.id) {
map.flyTo({ center: [selectedOrg.lng, selectedOrg.lat], zoom: 10, duration: 800 })
}
prevIdRef.current = selectedOrg.id
}, [map, selectedOrg])
return null
}
interface AssetMapProps {
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)
const handleClick = useCallback(
(org: AssetOrgCompat) => {
onSelectOrg(org)
},
[onSelectOrg],
)
// 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,
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],
},
})
// Dark-themed OpenStreetMap tiles
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map)
L.control.zoom({ position: 'topright' }).addTo(map)
L.control.attribution({ position: 'bottomright' }).addAttribution(
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>'
).addTo(map)
mapRef.current = map
markersRef.current = L.layerGroup().addTo(map)
return () => {
map.remove()
mapRef.current = null
markersRef.current = null
}
}, [])
// Update markers when orgs or selectedOrg changes
useEffect(() => {
if (!mapRef.current || !markersRef.current) return
markersRef.current.clearLayers()
orgs.forEach(org => {
const isSelected = selectedOrg.id === org.id
const tc = typeColor(org.type)
const radius = org.pinSize === 'hq' ? 14 : org.pinSize === 'lg' ? 10 : 7
const cm = L.circleMarker([org.lat, org.lng], {
radius: isSelected ? radius + 4 : radius,
fillColor: isSelected ? tc.selected : tc.bg,
color: isSelected ? tc.selected : tc.border,
weight: isSelected ? 3 : 2,
fillOpacity: isSelected ? 0.9 : 0.7,
})
cm.bindTooltip(
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
<div style="font-weight:700;font-size:11px;">${org.name}</div>
<div style="font-size:10px;opacity:0.7;">${org.type} · ${org.totalAssets}</div>
</div>`,
{ permanent: org.pinSize === 'hq' || isSelected, direction: 'top', offset: [0, -radius - 2], className: 'asset-map-tooltip' }
)
cm.on('click', () => onSelectOrg(org))
markersRef.current!.addLayer(cm)
})
}, [orgs, selectedOrg, onSelectOrg])
// Pan to selected org
useEffect(() => {
if (!mapRef.current) return
mapRef.current.flyTo([selectedOrg.lat, selectedOrg.lng], 10, { duration: 0.8 })
}, [selectedOrg])
}, [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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
}
interface ScatMapProps {
segments: ScatSegment[]
selectedSeg: ScatSegment
onSelectSeg: (s: ScatSegment) => void
onOpenPopup: (idx: number) => void
}
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
function FlyToController({ selectedSeg }: { selectedSeg: ScatSegment }) {
const { current: map } = useMap()
const prevIdRef = useRef<number | undefined>(undefined)
useEffect(() => {
if (!map) return
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) {
map.flyTo({ center: [selectedSeg.lng, selectedSeg.lat], zoom: 12, duration: 600 })
}
prevIdRef.current = selectedSeg.id
}, [map, selectedSeg])
return null
}
// ── 줌 기반 스케일 계산 ─────────────────────────────────
function getZoomScale(zoom: number) {
const zScale = Math.max(0, zoom - 9) / 5
return {
polyWidth: 1 + zScale * 4,
selPolyWidth: 2 + zScale * 5,
glowWidth: 4 + zScale * 14,
halfLenScale: 0.15 + zScale * 0.85,
markerRadius: Math.round(6 + zScale * 16),
showStatusMarker: zoom >= 11,
}
}
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
function buildSegCoords(seg: ScatSegment, halfLenScale: number): [number, number][] {
const coastIdx = seg.id % (jejuCoastCoords.length - 1)
const [clat1, clng1] = jejuCoastCoords[coastIdx]
const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length]
const dlat = clat2 - clat1
const dlng = clng2 - clng1
const dist = Math.sqrt(dlat * dlat + dlng * dlng)
const nDlat = dist > 0 ? dlat / dist : 0
const nDlng = dist > 0 ? dlng / dist : 1
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale
return [
[seg.lng - nDlng * halfLen, seg.lat - nDlat * halfLen],
[seg.lng, seg.lat],
[seg.lng + nDlng * halfLen, seg.lat + nDlat * halfLen],
]
}
// ── 툴팁 상태 ───────────────────────────────────────────
interface TooltipState {
x: number
y: number
seg: ScatSegment
}
// ── ScatMap ─────────────────────────────────────────────
function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapProps) {
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 handleClick = useCallback(
(seg: ScatSegment) => {
onSelectSeg(seg)
onOpenPopup(seg.id % scatDetailData.length)
},
[onSelectSeg, onOpenPopup],
)
const map = L.map(mapContainerRef.current, {
center: [33.38, 126.55],
zoom: 10,
zoomControl: false,
attributionControl: false,
});
const zs = useMemo(() => getZoomScale(zoom), [zoom])
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
// 제주도 해안선 레퍼런스 라인
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,
}),
[],
)
L.control.zoom({ position: 'bottomright' }).addTo(map);
L.control
.attribution({ position: 'bottomleft' })
.addAttribution(
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
)
.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 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',
// 선택된 구간 글로우 레이어
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],
)
polyline.on('click', () => {
onSelectSeg(seg);
onOpenPopup(seg.id);
});
markersRef.current!.addLayer(polyline);
// 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)
}
},
onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object)
},
updateTriggers: {
getColor: [selectedSeg.id],
getWidth: [selectedSeg.id, zs.selPolyWidth, zs.polyWidth],
getPath: [zs.halfLenScale],
},
}),
[segments, selectedSeg, zs, handleClick],
)
// 조사 상태 마커 (DivIcon) — 줌 레벨에 따라 표시/크기 조절
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);
// 조사 상태 마커 (줌 >= 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 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],
}),
});
// 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])
statusMarker.on('click', () => {
onSelectSeg(seg);
onOpenPopup(seg.id);
});
markersRef.current!.addLayer(statusMarker);
}
});
}, [segments, selectedSeg, onSelectSeg, onOpenPopup, zoom]);
useEffect(() => {
if (!mapRef.current) return;
mapRef.current.flyTo([selectedSeg.lat, selectedSeg.lng], 12, { duration: 0.6 });
}, [selectedSeg]);
const doneCount = segments.filter((s) => s.status === '완료').length;
const progCount = segments.filter((s) => s.status === '진행중').length;
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0);
const doneLen = segments.filter((s) => s.status === '완료').reduce((a, s) => a + s.lengthM, 0);
const 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) ═══
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)
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)
// 해안 구간 라인 (시뮬레이션)
const segLine: [number, number][] = [
[lat - 0.002, lng - 0.004],
[lat - 0.001, lng - 0.002],
[lat, lng],
[lat + 0.001, lng + 0.002],
[lat + 0.002, lng + 0.004],
]
L.polyline(segLine, { color: esiCol, weight: 5, opacity: 0.8 }).addTo(map)
// 조사 경로 라인
const surveyRoute: [number, number][] = [
[lat - 0.0015, lng - 0.003],
[lat - 0.0005, lng - 0.001],
[lat + 0.0005, lng + 0.001],
[lat + 0.0015, lng + 0.003],
]
L.polyline(surveyRoute, { color: '#3b82f6', weight: 2, opacity: 0.6, dashArray: '6, 4' }).addTo(map)
// 메인 마커
L.circleMarker([lat, lng], {
radius: 10, fillColor: esiCol, color: '#fff', weight: 2, fillOpacity: 0.9,
}).bindTooltip(
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
<div style="font-weight:700;font-size:11px;">${code} ${name}</div>
<div style="font-size:10px;opacity:0.7;">ESI ${esi}</div>
</div>`,
{ permanent: true, direction: 'top', offset: [0, -12], className: 'scat-map-tooltip' }
).addTo(map)
// 접근 포인트
L.circleMarker([lat - 0.0015, lng - 0.003], {
radius: 6, fillColor: '#eab308', color: '#eab308', weight: 1, fillOpacity: 0.7,
}).bindTooltip('접근 포인트', { direction: 'bottom', className: 'scat-map-tooltip' }).addTo(map)
mapRef.current = map
return () => { map.remove(); mapRef.current = null }
}, [lat, lng, esi, esiCol, code, name])
return <div ref={containerRef} className="w-full h-full" />
// ── 베이스맵 스타일 ──────────────────────────────────────
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' }],
}
// ═══ SCAT Popup Modal ═══
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
// ── PopupMap (미니맵) ────────────────────────────────────
function PopupMap({
lat,
lng,
esiCol,
code,
name,
esi,
}: {
lat: number
lng: number
esiCol: string
code: string
name: string
esi: string
}) {
// 해안 구간 라인 (시뮬레이션) — [lng, lat] 순서
const segLine: [number, number][] = [
[lng - 0.004, lat - 0.002],
[lng - 0.002, lat - 0.001],
[lng, lat],
[lng + 0.002, lat + 0.001],
[lng + 0.004, lat + 0.002],
]
// 조사 경로 라인
const surveyRoute: [number, number][] = [
[lng - 0.003, lat - 0.0015],
[lng - 0.001, lat - 0.0005],
[lng + 0.001, lat + 0.0005],
[lng + 0.003, lat + 0.0015],
]
const deckLayers = [
// 조사 경로 (파란 점선)
new PathLayer({
id: 'survey-route',
data: [{ path: surveyRoute }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [59, 130, 246, 153],
getWidth: 2,
getDashArray: [6, 4],
dashJustified: true,
widthMinPixels: 1,
}),
// 해안 구간 라인 (ESI 색상)
new PathLayer({
id: 'seg-line',
data: [{ path: segLine }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: hexToRgba(esiCol, 204),
getWidth: 5,
capRounded: true,
widthMinPixels: 3,
}),
// 접근 포인트 (노란 점)
new ScatterplotLayer({
id: 'access-point',
data: [{ position: [lng - 0.003, lat - 0.0015] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 6,
getFillColor: hexToRgba('#eab308', 178),
getLineColor: hexToRgba('#eab308', 200),
getLineWidth: 1,
stroked: true,
radiusMinPixels: 4,
radiusMaxPixels: 8,
}),
// 메인 마커 (ESI 색상 원)
new ScatterplotLayer({
id: 'main-marker',
data: [{ position: [lng, lat] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 10,
getFillColor: hexToRgba(esiCol, 229),
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 8,
radiusMaxPixels: 14,
}),
]
return (
<div className="relative w-full h-full">
<Map
key={`${lat}-${lng}`}
initialViewState={{ longitude: lng, latitude: lat, zoom: 15 }}
mapStyle={BASE_STYLE}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
<DeckGLOverlay layers={deckLayers} />
</Map>
{/* 마커 레이블 오버레이 */}
<div
className="pointer-events-none absolute"
style={{
left: '50%',
top: 'calc(50% - 44px)',
transform: 'translateX(-50%)',
background: 'rgba(15,21,36,0.92)',
border: '1px solid rgba(30,42,66,0.8)',
color: '#e4e8f1',
borderRadius: 6,
padding: '3px 7px',
fontSize: 10,
fontFamily: "'Noto Sans KR', sans-serif",
whiteSpace: 'nowrap',
zIndex: 10,
textAlign: 'center',
}}
>
<div style={{ fontWeight: 700, fontSize: 11 }}>{code} {name}</div>
<div style={{ opacity: 0.7 }}>ESI {esi}</div>
</div>
</div>
)
}
// ── ScatPopup Modal ──────────────────────────────────────
interface ScatPopupProps {
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,12 +1,13 @@
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
lon: number
direction: number // 0-360도
speed: number // m/s
speed: number // m/s
}
interface OceanCurrentLayerProps {
@ -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) {
// 이미지 미리 로드
const img = new Image()
img.onload = () => setImageLoaded(true)
img.onerror = () => setImageLoaded(false)
img.src = forecast.filePath
} else {
// eslint-disable-next-line react-hooks/set-state-in-effect
setImageLoaded(false)
}
if (!forecast?.filePath) return
let cancelled = false
const img = new Image()
img.onload = () => { if (!cancelled) setLoadedUrl(forecast.filePath) }
img.onerror = () => { if (!cancelled) setLoadedUrl(null) }
img.src = forecast.filePath
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',
}}
>
{/* 관측소명 */}
<div
style={{
textAlign: 'center',
fontSize: 12,
fontWeight: 'bold',
color: textColor,
textShadow: '1px 1px 3px rgba(0,0,0,0.7)',
paddingBottom: 4,
borderBottom: `1px solid ${isSelected ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.3)'}`,
marginBottom: 2,
}}
>
{station.name}
</div>
{/* Wave Height Circles */}
{enabledLayers.has('waves') &&
stations.map((station) => {
// Color based on wave height
const waveColor =
station.wave.height > 2.5
? '#ef4444'
: station.wave.height > 1.5
? '#f59e0b'
: '#3b82f6'
{/* 수온 */}
<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)',
}}
>
🌡
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span
style={{
fontSize: 14,
fontWeight: 'bold',
color: '#fff',
textShadow: '1px 1px 2px rgba(0,0,0,0.5)',
}}
>
{station.temperature.current.toFixed(1)}
</span>
<span
style={{
fontSize: 10,
color: '#fff',
opacity: 0.9,
textShadow: '1px 1px 2px rgba(0,0,0,0.5)',
}}
>
°C
</span>
</div>
</div>
const radius = station.wave.height * 15000 // Scale for visualization
{/* 파고 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div
style={{
background: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)',
width: 24,
height: 24,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'white',
fontWeight: 'bold',
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
}}
>
🌊
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span
style={{
fontSize: 14,
fontWeight: 'bold',
color: '#fff',
textShadow: '1px 1px 2px rgba(0,0,0,0.5)',
}}
>
{station.wave.height.toFixed(1)}
</span>
<span
style={{
fontSize: 10,
color: '#fff',
opacity: 0.9,
textShadow: '1px 1px 2px rgba(0,0,0,0.5)',
}}
>
m
</span>
</div>
</div>
return (
<Circle
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
}}
eventHandlers={{
click: () => onStationClick(station)
}}
/>
)
})}
{/* 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
}}
eventHandlers={{
click: () => onStationClick(station)
}}
/>
{/* 풍속 */}
<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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
},
},
layers: [
{
id: 'carto-dark-layer',
type: 'raster',
source: 'carto-dark',
minzoom: 0,
maxzoom: 22,
},
],
}
// 한국 해역 중심 좌표 (한반도 중앙)
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5] // [lng, lat]
const WEATHER_MAP_ZOOM = 7
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
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"
>
&#x1F3AF;
</button>
</div>
</div>
)
}
/**
* WeatherMapInner Map (useMap / useControl )
*/
interface WeatherMapInnerProps {
weatherStations: WeatherStation[]
enabledLayers: Set<string>
selectedStationId: string | null
oceanForecastOpacity: number
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
onStationClick: (station: WeatherStation) => void
}
function WeatherMapInner({
weatherStations,
enabledLayers,
selectedStationId,
oceanForecastOpacity,
selectedForecast,
onStationClick,
}: WeatherMapInnerProps) {
// deck.gl layers 조합
const weatherDeckLayers = useWeatherDeckLayers(
weatherStations,
enabledLayers,
selectedStationId,
onStationClick
)
const oceanCurrentLayers = useOceanCurrentLayers({
visible: enabledLayers.has('oceanCurrent'),
opacity: 0.7,
})
const waterTempLayers = useWaterTemperatureLayers({
visible: enabledLayers.has('waterTemperature'),
opacity: 0.5,
})
const deckLayers = useMemo(
() => [...oceanCurrentLayers, ...waterTempLayers, ...weatherDeckLayers],
[oceanCurrentLayers, waterTempLayers, weatherDeckLayers]
)
return (
<>
{/* deck.gl 오버레이 */}
<DeckGLOverlay layers={deckLayers} />
{/* 해황예보도 — MapLibre image source + raster layer */}
<OceanForecastOverlay
forecast={selectedForecast}
opacity={oceanForecastOpacity}
visible={enabledLayers.has('oceanForecast')}
/>
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
<WeatherMapOverlay
stations={weatherStations}
enabledLayers={enabledLayers}
onStationClick={onStationClick}
selectedStationId={selectedStationId}
/>
{/* 바람 파티클 애니메이션 (Canvas 직접 조작) */}
<WindParticleLayer
visible={enabledLayers.has('windParticle')}
stations={weatherStations}
/>
{/* 줌 컨트롤 */}
<WeatherMapControls />
</>
)
}
export function WeatherView() {
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
// Fetch ocean forecast data from KHOA API
const {
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 = useCallback(
(station: WeatherStation) => {
setSelectedStation(station)
setSelectedLocation(null)
},
[]
)
const handleStationClick = (station: WeatherStation) => {
setSelectedStation(station)
setSelectedLocation(null)
}
const handleMapClick = useCallback(
(e: MapLayerMouseEvent) => {
const { lat, lng } = e.lngLat
if (weatherStations.length === 0) return
const handleMapClick = (lat: number, lon: number) => {
// Find nearest station
const nearestStation = weatherStations.reduce((nearest, station) => {
const distance = Math.sqrt(
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lon, 2)
)
const nearestDistance = Math.sqrt(
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lon, 2)
)
return distance < nearestDistance ? station : nearest
}, weatherStations[0])
// 가장 가까운 관측소 선택
const nearestStation = weatherStations.reduce((nearest, station) => {
const distance = Math.sqrt(
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lng, 2)
)
const nearestDistance = Math.sqrt(
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2)
)
return distance < nearestDistance ? station : nearest
}, weatherStations[0])
setSelectedStation(nearestStation)
setSelectedLocation({ lat, lon })
}
setSelectedStation(nearestStation)
setSelectedLocation({ lat, lon: lng })
},
[weatherStations]
)
const toggleLayer = (layer: string) => {
const newLayers = new Set(enabledLayers)
if (newLayers.has(layer)) {
newLayers.delete(layer)
} else {
newLayers.add(layer)
}
setEnabledLayers(newLayers)
}
const toggleLayer = useCallback((layer: string) => {
setEnabledLayers((prev) => {
const next = new Set(prev)
if (next.has(layer)) {
next.delete(layer)
} else {
next.add(layer)
}
return next
})
}, [])
const weatherData = selectedStation
? {
@ -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">
<button
onClick={() => setTimeOffset('0')}
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
timeOffset === '0'
? 'bg-primary-cyan text-bg-0'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
</button>
<button
onClick={() => setTimeOffset('3')}
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
timeOffset === '3'
? 'bg-primary-cyan text-bg-0'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
+3
</button>
<button
onClick={() => setTimeOffset('6')}
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
timeOffset === '6'
? 'bg-primary-cyan text-bg-0'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
+6
</button>
<button
onClick={() => setTimeOffset('9')}
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
timeOffset === '9'
? 'bg-primary-cyan text-bg-0'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
+9
</button>
{(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => (
<button
key={offset}
onClick={() => setTimeOffset(offset)}
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
timeOffset === offset
? 'bg-primary-cyan text-bg-0'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
{offset === '0' ? '현재' : `+${offset}시간`}
</button>
))}
<div className="flex items-center gap-2 ml-4">
<span className="text-xs text-text-3">
{lastUpdate
? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit'
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='&copy; <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">&lt; 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">&gt; 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
}