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 type { ApiZoneItem } from '../services/scatApi' import { esiColor, jejuCoastCoords } from './scatConstants' 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: '© OSM © CARTO', }, }, layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }], } interface ScatMapProps { segments: ScatSegment[] zones: ApiZoneItem[] selectedSeg: ScatSegment jurisdictionFilter: string 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(() => new MapboxOverlay({ interleaved: true })) overlay.setProps({ layers }) return null } // ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ───────── function FlyToController({ selectedSeg, zones }: { selectedSeg: ScatSegment; zones: ApiZoneItem[] }) { const { current: map } = useMap() const prevIdRef = useRef(undefined) const prevZonesLenRef = useRef(0) // 선택 구간 변경 시 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]) // 관할해경(zones) 변경 시 지도 중심 이동 useEffect(() => { if (!map || zones.length === 0) return if (prevZonesLenRef.current === zones.length) return prevZonesLenRef.current = zones.length const validZones = zones.filter(z => z.latCenter && z.lngCenter) if (validZones.length === 0) return const avgLat = validZones.reduce((a, z) => a + z.latCenter, 0) / validZones.length const avgLng = validZones.reduce((a, z) => a + z.lngCenter, 0) / validZones.length map.flyTo({ center: [avgLng, avgLat], zoom: 9, duration: 800 }) }, [map, zones]) 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, zones, selectedSeg, jurisdictionFilter, onSelectSeg, onOpenPopup }: ScatMapProps) { const [zoom, setZoom] = useState(10) const [tooltip, setTooltip] = useState(null) const handleClick = useCallback( (seg: ScatSegment) => { onSelectSeg(seg) onOpenPopup(seg.id) }, [onSelectSeg, onOpenPopup], ) const zs = useMemo(() => getZoomScale(zoom), [zoom]) // 제주도 해안선 레퍼런스 라인 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, }), [], ) // 선택된 구간 글로우 레이어 const glowLayer = useMemo( () => new PathLayer({ id: 'scat-glow', data: [selectedSeg], getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale), getColor: [34, 197, 94, 38], getWidth: zs.glowWidth, capRounded: true, jointRounded: true, widthMinPixels: 4, updateTriggers: { getPath: [zs.halfLenScale], getWidth: [zs.glowWidth], }, }), [selectedSeg, zs.glowWidth, zs.halfLenScale], ) // ESI 색상 세그먼트 폴리라인 const segPathLayer = useMemo( () => new PathLayer({ id: 'scat-segments', data: segments, getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale), getColor: (d: ScatSegment) => { const isSelected = selectedSeg.id === d.id const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum) return hexToRgba(hexCol, isSelected ? 242 : 178) }, getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth), capRounded: true, jointRounded: true, widthMinPixels: 1, pickable: true, onHover: (info: { object?: ScatSegment; x: number; y: number }) => { if (info.object) { setTooltip({ x: info.x, y: info.y, seg: info.object }) } else { setTooltip(null) } }, 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], ) // 조사 상태 마커 (줌 >= 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]) // eslint-disable-next-line @typescript-eslint/no-explicit-any const deckLayers: any[] = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const layers: any[] = [coastlineLayer, glowLayer, segPathLayer] if (markerLayer) layers.push(markerLayer) return layers }, [coastlineLayer, glowLayer, segPathLayer, markerLayer]) const doneCount = segments.filter(s => s.status === '완료').length const progCount = segments.filter(s => s.status === '진행중').length const totalLen = segments.reduce((a, s) => a + s.lengthM, 0) const doneLen = segments.filter(s => s.status === '완료').reduce((a, s) => a + s.lengthM, 0) const highSens = segments .filter(s => s.sensitivity === '최상' || s.sensitivity === '상') .reduce((a, s) => a + s.lengthM, 0) const donePct = Math.round((doneCount / segments.length) * 100) const progPct = Math.round((progCount / segments.length) * 100) const notPct = 100 - donePct - progPct return (
{ if (zones.length > 0) { const avgLng = zones.reduce((a, z) => a + z.lngCenter, 0) / zones.length; const avgLat = zones.reduce((a, z) => a + z.latCenter, 0) / zones.length; return { longitude: avgLng, latitude: avgLat, zoom: 10 }; } return { longitude: 126.55, latitude: 33.38, zoom: 10 }; })()} mapStyle={BASE_STYLE} className="w-full h-full" attributionControl={false} onZoom={e => setZoom(e.viewState.zoom)} > {/* 호버 툴팁 */} {tooltip && (
{tooltip.seg.code} {tooltip.seg.area}
ESI {tooltip.seg.esi} · {tooltip.seg.length} ·{' '} {tooltip.seg.status === '완료' ? '✓' : tooltip.seg.status === '진행중' ? '⏳' : '—'}{' '} {tooltip.seg.status}
)} {/* Status chips */}
Pre-SCAT 사전조사
{jurisdictionFilter || '전체'} 관할 해안 · {segments.length}개 구간
{/* Right info cards */}
{/* ESI Legend */}
ESI 민감도 분류 범례
{[ { esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' }, { esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' }, { esi: 'ESI 8', label: '쉘터 암반 해안', color: '#dc2626' }, { esi: 'ESI 7', label: '노출 갯벌', color: '#ef4444' }, { esi: 'ESI 6', label: '자갈·혼합 해안', color: '#f97316' }, { esi: 'ESI 5', label: '혼합 모래/자갈', color: '#fb923c' }, { esi: 'ESI 3-4', label: '모래 해안', color: '#facc15' }, { esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' }, ].map((item, i) => (
{item.label} {item.esi}
))}
{/* Progress */}
조사 진행률
완료 {donePct}% 진행 {progPct}% 미조사 {notPct}%
{[ ['총 해안선', `${(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)'], ].map(([label, val, color], i) => (
{label} {val}
))}
{/* Coordinates */} {/*
위도 {selectedSeg.lat.toFixed(4)}°N 경도 {selectedSeg.lng.toFixed(4)}°E 축척 1:25,000
*/}
) } export default ScatMap