wing-ops/frontend/src/tabs/assets/components/AssetMap.tsx
htlee 85749c2f68 feat(map): Leaflet → MapLibre GL JS + deck.gl 전환 (Phase 6)
지도 엔진을 Leaflet 1.9에서 MapLibre GL JS 5.x + deck.gl 9.x로 전환.
15개 파일 수정, Leaflet 완전 제거. WebGL 단일 canvas로 z-index 충돌 해결,
유류 입자 ScatterplotLayer GPU 렌더링으로 10~100배 성능 향상.

- MapView.tsx: MapLibre Map + DeckGLOverlay(MapboxOverlay interleaved)
- 유류 입자/오일펜스/HNS: deck.gl ScatterplotLayer/PathLayer
- 역추적 리플레이: createBacktrackLayers() 함수 패턴
- 기상 오버레이: WeatherMapOverlay/OceanCurrent/WindParticle deck.gl 전환
- 수온 히트맵: WaterTemperatureLayer deck.gl ScatterplotLayer
- 해황예보도: MapLibre image source + raster layer
- SCAT/Assets/Incidents: MapLibre Map + deck.gl 레이어
- WMS 밝기: raster-brightness-min/max 네이티브 속성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:48:54 +09:00

175 lines
6.4 KiB
TypeScript

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[]
selectedOrg: AssetOrgCompat
onSelectOrg: (o: AssetOrgCompat) => void
regionFilter: string
onRegionFilterChange: (v: string) => void
}
function AssetMap({
organizations: orgs,
selectedOrg,
onSelectOrg,
regionFilter,
onRegionFilterChange,
}: AssetMapProps) {
const handleClick = useCallback(
(org: AssetOrgCompat) => {
onSelectOrg(org)
},
[onSelectOrg],
)
const markerLayer = useMemo(() => {
return new ScatterplotLayer({
id: 'asset-orgs',
data: orgs,
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
getRadius: (d: AssetOrgCompat) => {
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7
const isSelected = selectedOrg.id === d.id
return isSelected ? baseRadius + 4 : baseRadius
},
getFillColor: (d: AssetOrgCompat) => {
const tc = typeColor(d.type)
const isSelected = selectedOrg.id === d.id
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178)
},
getLineColor: (d: AssetOrgCompat) => {
const tc = typeColor(d.type)
const isSelected = selectedOrg.id === d.id
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200)
},
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
stroked: true,
radiusMinPixels: 4,
radiusMaxPixels: 20,
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: AssetOrgCompat }) => {
if (info.object) handleClick(info.object)
},
updateTriggers: {
getRadius: [selectedOrg.id],
getFillColor: [selectedOrg.id],
getLineColor: [selectedOrg.id],
getLineWidth: [selectedOrg.id],
},
})
}, [orgs, selectedOrg, handleClick])
return (
<div className="w-full h-full relative">
<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">
{[
{ value: 'all', label: '전체' },
{ value: '남해', label: '남해청' },
{ value: '서해', label: '서해청' },
{ value: '중부', label: '중부청' },
{ value: '동해', label: '동해청' },
{ value: '제주', label: '제주청' },
].map(r => (
<button
key={r.value}
onClick={() => onRegionFilterChange(r.value)}
className={`px-2.5 py-1.5 text-[10px] font-bold rounded font-korean transition-colors ${
regionFilter === r.value
? 'bg-primary-cyan/20 text-primary-cyan border border-primary-cyan/40'
: 'bg-bg-0/80 text-text-2 border border-border hover:bg-bg-hover/80'
}`}
>
{r.label}
</button>
))}
</div>
{/* Legend overlay */}
<div className="absolute bottom-3 left-3 z-[1000] bg-bg-0/90 border border-border rounded-sm p-2.5 backdrop-blur-sm">
<div className="text-[9px] text-text-3 font-bold mb-1.5 font-korean"></div>
{[
{ color: '#06b6d4', label: '해경관할' },
{ color: '#3b82f6', label: '해경경찰서' },
{ color: '#22c55e', label: '파출소' },
{ color: '#a855f7', label: '관련기관' },
{ color: '#14b8a6', label: '해양환경공단' },
{ color: '#f59e0b', label: '업체' },
{ color: '#ec4899', label: '지자체' },
{ color: '#8b5cf6', label: '기름저장시설' },
{ color: '#0d9488', label: '정유사' },
{ color: '#64748b', label: '해군' },
{ color: '#6b7280', label: '기타' },
].map((item, i) => (
<div key={i} className="flex items-center gap-1.5 mb-0.5 last:mb-0">
<span className="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0" style={{ background: item.color }} />
<span className="text-[10px] text-text-2 font-korean">{item.label}</span>
</div>
))}
</div>
</div>
)
}
export default AssetMap