wing-ops/frontend/src/tabs/scat/components/ScatMap.tsx

409 lines
16 KiB
TypeScript

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 '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 { ScatSegment } from './scatTypes';
import type { ApiZoneItem } from '../services/scatApi';
import { esiColor } from './scatConstants';
import { hexToRgba } from '@common/components/map/mapUtils';
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<MapboxOverlay>(() => 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<number | undefined>(undefined);
const prevZonesLenRef = useRef<number>(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,
segments: ScatSegment[],
): [number, number][] {
const idx = segments.indexOf(seg);
const prev = idx > 0 ? segments[idx - 1] : seg;
const next = idx < segments.length - 1 ? segments[idx + 1] : seg;
const dlat = next.lat - prev.lat;
const dlng = next.lng - prev.lng;
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 currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
const [zoom, setZoom] = useState(10);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const handleClick = useCallback(
(seg: ScatSegment) => {
onSelectSeg(seg);
onOpenPopup(seg.id);
},
[onSelectSeg, onOpenPopup],
);
const zs = useMemo(() => getZoomScale(zoom), [zoom]);
// 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정
// 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, segments),
getColor: [34, 197, 94, 38],
getWidth: zs.glowWidth,
capRounded: true,
jointRounded: true,
widthMinPixels: 4,
updateTriggers: {
getPath: [zs.halfLenScale],
getWidth: [zs.glowWidth],
},
}),
[selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
);
// ESI 색상 세그먼트 폴리라인
const segPathLayer = useMemo(
() =>
new PathLayer({
id: 'scat-segments',
data: segments,
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
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[] = [glowLayer, segPathLayer];
if (markerLayer) layers.push(markerLayer);
return layers;
}, [glowLayer, segPathLayer, markerLayer]);
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);
return (
<div className="absolute inset-0 overflow-hidden">
<Map
initialViewState={(() => {
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={currentMapStyle}
className="w-full h-full"
attributionControl={false}
onZoom={(e) => setZoom(e.viewState.zoom)}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={deckLayers} />
<FlyToController selectedSeg={selectedSeg} zones={zones} />
</Map>
{/* 호버 툴팁 */}
{tooltip && (
<div
className="pointer-events-none text-fg rounded-[6px] px-2 py-1 font-korean text-label-2"
style={{
position: 'absolute',
left: tooltip.x + 12,
top: tooltip.y - 48,
background: 'color-mix(in srgb, var(--bg-base) 92%, transparent)',
border: '1px solid var(--stroke-default)',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: 1000,
whiteSpace: 'nowrap',
}}
>
<div className="font-bold">
{tooltip.seg.code} {tooltip.seg.area}
</div>
<div className="text-caption opacity-70">
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]">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub font-korean">
<span className="w-1.5 h-1.5 rounded-full bg-color-success shadow-[0_0_6px_var(--color-success)]" />
Pre-SCAT
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub font-korean">
{jurisdictionFilter || '전체'} · {segments.length}
</div>
</div>
{/* Right info cards */}
<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-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
ESI
</div>
{[
{ 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) => (
<div key={i} className="flex items-center gap-2 py-1 text-label-2">
<span
className="w-3.5 h-1.5 rounded-sm flex-shrink-0"
style={{ background: item.color }}
/>
<span className="text-fg-sub font-korean">{item.label}</span>
<span className="ml-auto font-mono text-caption text-fg">{item.esi}</span>
</div>
))}
</div>
{/* Progress */}
<div className="bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled 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(--color-success)' }}
/>
<div
className="h-full transition-all duration-500"
style={{ width: `${progPct}%`, background: 'var(--color-warning)' }}
/>
<div
className="h-full transition-all duration-500"
style={{ width: `${notPct}%`, background: 'var(--stroke-default)' }}
/>
</div> */}
{/* <div className="flex justify-between mt-1">
<span className="text-caption font-mono text-color-success">완료 {donePct}%</span>
<span className="text-caption font-mono text-color-warning">진행 {progPct}%</span>
<span className="text-caption font-mono text-fg-disabled">미조사 {notPct}%</span>
</div> */}
<div className="mt-2.5">
{[
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--color-success)'],
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--color-danger)'],
[
'방제 우선 구간',
`${segments.filter((s) => s.sensitivity === '최상').length}`,
'var(--color-warning)',
],
].map(([label, val, color], i) => (
<div
key={i}
className="flex justify-between py-1.5 border-b border-stroke last:border-b-0 text-label-2"
>
<span className="text-fg-sub font-korean">{label}</span>
<span
className="font-mono font-medium text-label-2"
style={{ color: color || undefined }}
>
{val}
</span>
</div>
))}
</div>
</div>
</div>
{/* Coordinates */}
{/* <div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-stroke rounded-sm px-3 py-1.5 font-mono text-label-2 text-fg-sub flex gap-3.5">
<span>
위도 <span className="text-color-success font-medium">{selectedSeg.lat.toFixed(4)}°N</span>
</span>
<span>
경도 <span className="text-color-success font-medium">{selectedSeg.lng.toFixed(4)}°E</span>
</span>
<span>
축척 <span className="text-color-success font-medium">1:25,000</span>
</span>
</div> */}
</div>
);
}
export default ScatMap;