지도 엔진을 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>
175 lines
6.4 KiB
TypeScript
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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
},
|
|
},
|
|
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
|
|
}
|
|
|
|
// ── DeckGLOverlay ──────────────────────────────────────
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
|
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
|
overlay.setProps({ layers })
|
|
return null
|
|
}
|
|
|
|
// ── FlyTo Controller ────────────────────────────────────
|
|
function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) {
|
|
const { current: map } = useMap()
|
|
const prevIdRef = useRef<number | undefined>(undefined)
|
|
|
|
useEffect(() => {
|
|
if (!map) return
|
|
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedOrg.id) {
|
|
map.flyTo({ center: [selectedOrg.lng, selectedOrg.lat], zoom: 10, duration: 800 })
|
|
}
|
|
prevIdRef.current = selectedOrg.id
|
|
}, [map, selectedOrg])
|
|
|
|
return null
|
|
}
|
|
|
|
interface AssetMapProps {
|
|
organizations: AssetOrgCompat[]
|
|
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
|