- 지도 스타일 상수를 mapStyles.ts로 추출 - useBaseMapStyle 훅 생성 (mapToggles 기반 스타일 반환) - 9개 탭 컴포넌트의 하드코딩 스타일을 공유 훅으로 교체 - 각 Map에 S57EncOverlay 추가 - 초기 mapToggles를 모두 false로 변경 (기본지도 표시)
164 lines
6.1 KiB
TypeScript
164 lines
6.1 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 'maplibre-gl/dist/maplibre-gl.css'
|
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
|
|
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
|
|
import { useMapStore } from '@common/store/mapStore'
|
|
import type { AssetOrgCompat } from '../services/assetsApi'
|
|
import { typeColor } from './assetTypes'
|
|
import { hexToRgba } from '@common/components/map/mapUtils'
|
|
|
|
// ── 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 currentMapStyle = useBaseMapStyle()
|
|
const mapToggles = useMapStore((s) => s.mapToggles)
|
|
|
|
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={currentMapStyle}
|
|
className="w-full h-full"
|
|
attributionControl={false}
|
|
>
|
|
<S57EncOverlay visible={mapToggles['s57'] ?? 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-color-accent/20 text-color-accent border border-color-accent/40'
|
|
: 'bg-bg-base/80 text-fg-sub border border-stroke hover:bg-bg-surface-hover/80'
|
|
}`}
|
|
>
|
|
{r.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Legend overlay */}
|
|
<div className="absolute bottom-3 left-3 z-[1000] bg-bg-base/90 border border-stroke rounded-sm p-2.5 backdrop-blur-sm">
|
|
<div className="text-[9px] text-fg-disabled 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-fg-sub font-korean">{item.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default AssetMap
|