gc-wing/apps/web/src/widgets/map3d/Map3D.tsx
htlee 7eff97afd4 fix(map): 해저케이블 시인성 개선
- MapLibre 중첩 interpolate 표현식 에러 수정
- 6레이어 구조: hitarea, casing, line, glow, points, label
- 호버 시 flat value 사용 (case 내 interpolate 제거)
- Globe/Mercator 양쪽 프로젝션 레이어 순서 지원
- 진한 색상, 굵은 라인, 포인트 마커로 시인성 향상

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:28:11 +09:00

529 lines
20 KiB
TypeScript

import { MapboxOverlay } from '@deck.gl/mapbox';
import maplibregl from 'maplibre-gl';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AisTarget } from '../../entities/aisTarget/model/types';
import { MaplibreDeckCustomLayer } from './MaplibreDeckCustomLayer';
import type { BaseMapId, Map3DProps, MapProjectionId } from './types';
import type { DashSeg, PairRangeCircle } from './types';
import {
mergeNumberSets,
makeSetSignature,
isFiniteNumber,
toIntMmsi,
makeUniqueSorted,
equalNumberArrays,
} from './lib/setUtils';
import { dashifyLine } from './lib/dashifyLine';
import { useHoverState } from './hooks/useHoverState';
import { useMapInit } from './hooks/useMapInit';
import { useProjectionToggle } from './hooks/useProjectionToggle';
import { useBaseMapToggle } from './hooks/useBaseMapToggle';
import { useFlyTo } from './hooks/useFlyTo';
import { useZonesLayer } from './hooks/useZonesLayer';
import { usePredictionVectors } from './hooks/usePredictionVectors';
import { useGlobeShips } from './hooks/useGlobeShips';
import { useGlobeOverlays } from './hooks/useGlobeOverlays';
import { useGlobeInteraction } from './hooks/useGlobeInteraction';
import { useDeckLayers } from './hooks/useDeckLayers';
import { useSubcablesLayer } from './hooks/useSubcablesLayer';
export type { Map3DSettings, BaseMapId, MapProjectionId } from './types';
type Props = Map3DProps;
export function Map3D({
targets,
zones,
selectedMmsi,
hoveredMmsiSet = [],
hoveredFleetMmsiSet = [],
hoveredPairMmsiSet = [],
hoveredFleetOwnerKey = null,
highlightedMmsiSet = [],
settings,
baseMap,
projection,
overlays,
onSelectMmsi,
onToggleHighlightMmsi,
onViewBboxChange,
legacyHits,
pairLinks,
fcLinks,
fleetCircles,
onProjectionLoadingChange,
fleetFocus,
onHoverFleet,
onClearFleetHover,
onHoverMmsi,
onClearMmsiHover,
onHoverPair,
onClearPairHover,
subcableGeo = null,
hoveredCableId = null,
onHoverCable,
onClickCable,
}: Props) {
void onHoverFleet;
void onClearFleetHover;
void onHoverMmsi;
void onClearMmsiHover;
void onHoverPair;
void onClearPairHover;
// ── Shared refs ──────────────────────────────────────────────────────
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
const overlayInteractionRef = useRef<MapboxOverlay | null>(null);
const globeDeckLayerRef = useRef<MaplibreDeckCustomLayer | null>(null);
const baseMapRef = useRef<BaseMapId>(baseMap);
const projectionRef = useRef<MapProjectionId>(projection);
const projectionBusyRef = useRef(false);
const deckHoverRafRef = useRef<number | null>(null);
const deckHoverHasHitRef = useRef(false);
useEffect(() => { baseMapRef.current = baseMap; }, [baseMap]);
useEffect(() => { projectionRef.current = projection; }, [projection]);
// ── Hover state ──────────────────────────────────────────────────────
const {
setHoveredDeckMmsiSet,
setHoveredDeckPairMmsiSet,
setHoveredDeckFleetOwnerKey,
setHoveredDeckFleetMmsiSet,
hoveredZoneId, setHoveredZoneId,
hoveredMmsiSetRef, hoveredFleetMmsiSetRef, hoveredPairMmsiSetRef,
externalHighlightedSetRef,
hoveredDeckMmsiSetRef, hoveredDeckPairMmsiSetRef, hoveredDeckFleetMmsiSetRef,
hoveredFleetOwnerKeys,
} = useHoverState({
hoveredMmsiSet, hoveredFleetMmsiSet, hoveredPairMmsiSet,
hoveredFleetOwnerKey, highlightedMmsiSet,
});
const fleetFocusId = fleetFocus?.id;
const fleetFocusLon = fleetFocus?.center?.[0];
const fleetFocusLat = fleetFocus?.center?.[1];
const fleetFocusZoom = fleetFocus?.zoom;
// ── Highlight memos ──────────────────────────────────────────────────
const effectiveHoveredPairMmsiSet = useMemo(
() => mergeNumberSets(hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef),
[hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef],
);
const effectiveHoveredFleetMmsiSet = useMemo(
() => mergeNumberSets(hoveredFleetMmsiSetRef, hoveredDeckFleetMmsiSetRef),
[hoveredFleetMmsiSetRef, hoveredDeckFleetMmsiSetRef],
);
const [mapSyncEpoch, setMapSyncEpoch] = useState(0);
const highlightedMmsiSetCombined = useMemo(
() =>
mergeNumberSets(
hoveredMmsiSetRef,
hoveredDeckMmsiSetRef,
externalHighlightedSetRef,
effectiveHoveredFleetMmsiSet,
effectiveHoveredPairMmsiSet,
),
[
hoveredMmsiSetRef,
hoveredDeckMmsiSetRef,
externalHighlightedSetRef,
effectiveHoveredFleetMmsiSet,
effectiveHoveredPairMmsiSet,
],
);
const highlightedMmsiSetForShips = useMemo(
() => (projection === 'globe' ? mergeNumberSets(hoveredMmsiSetRef, externalHighlightedSetRef) : highlightedMmsiSetCombined),
[projection, hoveredMmsiSetRef, externalHighlightedSetRef, highlightedMmsiSetCombined],
);
const hoveredShipSignature = useMemo(
() =>
`${makeSetSignature(hoveredMmsiSetRef)}|${makeSetSignature(externalHighlightedSetRef)}|${makeSetSignature(
hoveredDeckMmsiSetRef,
)}|${makeSetSignature(effectiveHoveredFleetMmsiSet)}|${makeSetSignature(effectiveHoveredPairMmsiSet)}`,
[
hoveredMmsiSetRef,
externalHighlightedSetRef,
hoveredDeckMmsiSetRef,
effectiveHoveredFleetMmsiSet,
effectiveHoveredPairMmsiSet,
],
);
void hoveredShipSignature;
const hoveredPairMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredPairMmsiSet)), [effectiveHoveredPairMmsiSet]);
const hoveredFleetOwnerKeyList = useMemo(() => Array.from(hoveredFleetOwnerKeys).sort(), [hoveredFleetOwnerKeys]);
const hoveredFleetMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredFleetMmsiSet)), [effectiveHoveredFleetMmsiSet]);
const isHighlightedMmsi = useCallback(
(mmsi: number) => highlightedMmsiSetCombined.has(mmsi),
[highlightedMmsiSetCombined],
);
const baseHighlightedMmsiSet = useMemo(() => {
const out = new Set<number>();
if (selectedMmsi != null) out.add(selectedMmsi);
externalHighlightedSetRef.forEach((value) => { out.add(value); });
return out;
}, [selectedMmsi, externalHighlightedSetRef]);
const isBaseHighlightedMmsi = useCallback(
(mmsi: number) => baseHighlightedMmsiSet.has(mmsi),
[baseHighlightedMmsiSet],
);
const isHighlightedPair = useCallback(
(aMmsi: number, bMmsi: number) =>
effectiveHoveredPairMmsiSet.size === 2 &&
effectiveHoveredPairMmsiSet.has(aMmsi) &&
effectiveHoveredPairMmsiSet.has(bMmsi),
[effectiveHoveredPairMmsiSet],
);
const isHighlightedFleet = useCallback(
(ownerKey: string, vesselMmsis: number[]) => {
if (hoveredFleetOwnerKeys.has(ownerKey)) return true;
return vesselMmsis.some((x) => isHighlightedMmsi(x));
},
[hoveredFleetOwnerKeys, isHighlightedMmsi],
);
// ── Ship data memos ──────────────────────────────────────────────────
const shipData = useMemo(() => {
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi));
}, [targets]);
const shipByMmsi = useMemo(() => {
const byMmsi = new Map<number, AisTarget>();
for (const t of shipData) byMmsi.set(t.mmsi, t);
return byMmsi;
}, [shipData]);
const shipLayerData = useMemo(() => {
if (shipData.length === 0) return shipData;
return [...shipData];
}, [shipData]);
const shipHighlightSet = useMemo(() => {
const out = new Set(highlightedMmsiSetForShips);
if (selectedMmsi) out.add(selectedMmsi);
return out;
}, [highlightedMmsiSetForShips, selectedMmsi]);
const shipHoverOverlaySet = useMemo(
() =>
projection === 'globe'
? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet)
: shipHighlightSet,
[projection, highlightedMmsiSetCombined, shipHighlightSet],
);
const shipOverlayLayerData = useMemo(() => {
if (shipLayerData.length === 0) return [];
if (shipHighlightSet.size === 0) return [];
return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi));
}, [shipHighlightSet, shipLayerData]);
// ── Deck hover management ────────────────────────────────────────────
const hasAuxiliarySelectModifier = useCallback(
(ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null): boolean => {
if (!ev) return false;
return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey);
},
[],
);
const toFleetMmsiList = useCallback((value: unknown) => {
if (!Array.isArray(value)) return [];
const out: number[] = [];
for (const item of value) {
const v = toIntMmsi(item);
if (v != null) out.push(v);
}
return out;
}, []);
const onDeckSelectOrHighlight = useCallback(
(info: unknown, allowMultiSelect = false) => {
const obj = info as {
mmsi?: unknown;
srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null;
};
const mmsi = toIntMmsi(obj.mmsi);
if (mmsi == null) return;
const evt = obj.srcEvent ?? null;
const isAux = hasAuxiliarySelectModifier(evt);
if (onToggleHighlightMmsi && isAux) {
onToggleHighlightMmsi(mmsi);
return;
}
if (!allowMultiSelect && selectedMmsi === mmsi) {
onSelectMmsi(null);
return;
}
onSelectMmsi(mmsi);
},
[hasAuxiliarySelectModifier, onSelectMmsi, onToggleHighlightMmsi, selectedMmsi],
);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const setHoveredDeckFleetMmsis = useCallback((next: number[]) => {
const normalized = makeUniqueSorted(next);
setHoveredDeckFleetMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
}, []);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const setHoveredDeckFleetOwner = useCallback((ownerKey: string | null) => {
setHoveredDeckFleetOwnerKey((prev) => (prev === ownerKey ? prev : ownerKey));
}, []);
const mapDeckMmsiHoverRef = useRef<number[]>([]);
const mapDeckPairHoverRef = useRef<number[]>([]);
const mapFleetHoverStateRef = useRef<{ ownerKey: string | null; vesselMmsis: number[] }>({
ownerKey: null,
vesselMmsis: [],
});
const clearMapFleetHoverState = useCallback(() => {
mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] };
setHoveredDeckFleetOwner(null);
setHoveredDeckFleetMmsis([]);
}, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]);
const clearDeckHoverPairs = useCallback(() => {
mapDeckPairHoverRef.current = [];
setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
}, [setHoveredDeckPairMmsiSet]);
const clearDeckHoverMmsi = useCallback(() => {
mapDeckMmsiHoverRef.current = [];
setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
}, [setHoveredDeckMmsiSet]);
const scheduleDeckHoverResolve = useCallback(() => {
if (deckHoverRafRef.current != null) return;
deckHoverRafRef.current = window.requestAnimationFrame(() => {
deckHoverRafRef.current = null;
if (!deckHoverHasHitRef.current) {
clearDeckHoverMmsi();
clearDeckHoverPairs();
clearMapFleetHoverState();
}
deckHoverHasHitRef.current = false;
});
}, [clearDeckHoverMmsi, clearDeckHoverPairs, clearMapFleetHoverState]);
const touchDeckHoverState = useCallback(
(isHover: boolean) => {
if (isHover) deckHoverHasHitRef.current = true;
scheduleDeckHoverResolve();
},
[scheduleDeckHoverResolve],
);
const setDeckHoverMmsi = useCallback(
(next: number[]) => {
const normalized = makeUniqueSorted(next);
touchDeckHoverState(normalized.length > 0);
setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
mapDeckMmsiHoverRef.current = normalized;
},
[setHoveredDeckMmsiSet, touchDeckHoverState],
);
const setDeckHoverPairs = useCallback(
(next: number[]) => {
const normalized = makeUniqueSorted(next);
touchDeckHoverState(normalized.length > 0);
setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
mapDeckPairHoverRef.current = normalized;
},
[setHoveredDeckPairMmsiSet, touchDeckHoverState],
);
const setMapFleetHoverState = useCallback(
(ownerKey: string | null, vesselMmsis: number[]) => {
const normalized = makeUniqueSorted(vesselMmsis);
const prev = mapFleetHoverStateRef.current;
if (prev.ownerKey === ownerKey && equalNumberArrays(prev.vesselMmsis, normalized)) {
return;
}
touchDeckHoverState(!!ownerKey || normalized.length > 0);
setHoveredDeckFleetOwner(ownerKey);
setHoveredDeckFleetMmsis(normalized);
mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized };
},
[setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState],
);
// hover RAF cleanup
useEffect(() => {
return () => {
if (deckHoverRafRef.current != null) {
window.cancelAnimationFrame(deckHoverRafRef.current);
deckHoverRafRef.current = null;
}
deckHoverHasHitRef.current = false;
};
}, []);
// sync external fleet hover state
useEffect(() => {
mapFleetHoverStateRef.current = {
ownerKey: hoveredFleetOwnerKey,
vesselMmsis: hoveredFleetMmsiSet,
};
}, [hoveredFleetOwnerKey, hoveredFleetMmsiSet]);
// ── Overlay data memos ───────────────────────────────────────────────
const fcDashed = useMemo(() => {
const segs: DashSeg[] = [];
for (const l of fcLinks || []) {
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
}
return segs;
}, [fcLinks]);
const pairRanges = useMemo(() => {
const out: PairRangeCircle[] = [];
for (const p of pairLinks || []) {
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2];
out.push({
center,
radiusNm: Math.max(0.05, p.distanceNm / 2),
warn: p.warn,
aMmsi: p.aMmsi,
bMmsi: p.bMmsi,
distanceNm: p.distanceNm,
});
}
return out;
}, [pairLinks]);
const pairLinksInteractive = useMemo(() => {
if (!overlays.pairLines || (pairLinks?.length ?? 0) === 0) return [];
if (hoveredPairMmsiSetRef.size < 2) return [];
const links = pairLinks || [];
return links.filter((link) =>
hoveredPairMmsiSetRef.has(link.aMmsi) && hoveredPairMmsiSetRef.has(link.bMmsi),
);
}, [pairLinks, hoveredPairMmsiSetRef, overlays.pairLines]);
const pairRangesInteractive = useMemo(() => {
if (!overlays.pairRange || pairRanges.length === 0) return [];
if (hoveredPairMmsiSetRef.size < 2) return [];
return pairRanges.filter((range) =>
hoveredPairMmsiSetRef.has(range.aMmsi) && hoveredPairMmsiSetRef.has(range.bMmsi),
);
}, [pairRanges, hoveredPairMmsiSetRef, overlays.pairRange]);
const fcLinesInteractive = useMemo(() => {
if (!overlays.fcLines || fcDashed.length === 0) return [];
if (highlightedMmsiSetCombined.size === 0) return [];
return fcDashed.filter(
(line) =>
[line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))),
);
}, [fcDashed, overlays.fcLines, highlightedMmsiSetCombined]);
const fleetCirclesInteractive = useMemo(() => {
if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return [];
if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return [];
const circles = fleetCircles || [];
return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis));
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]);
// ── Hook orchestration ───────────────────────────────────────────────
const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit(
containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef,
baseMapRef, projectionRef,
{ baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch },
);
const reorderGlobeFeatureLayers = useProjectionToggle(
mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef,
{ projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch },
);
useBaseMapToggle(
mapRef,
{ baseMap, projection, showSeamark: settings.showSeamark, mapSyncEpoch, pulseMapSync },
);
useZonesLayer(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
{ zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch },
);
usePredictionVectors(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
{
overlays, settings, shipData, legacyHits, selectedMmsi,
externalHighlightedSetRef, projection, baseMap, mapSyncEpoch,
},
);
useGlobeShips(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
{
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
onSelectMmsi, onToggleHighlightMmsi, targets, overlays,
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
},
);
useGlobeOverlays(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
{
overlays, pairLinks, fcLinks, fleetCircles, projection,
mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
},
);
useGlobeInteraction(
mapRef, projectionBusyRef,
{
projection, settings, overlays, targets, shipData, shipByMmsi, selectedMmsi,
hoveredZoneId, legacyHits,
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, setHoveredZoneId,
},
);
useDeckLayers(
mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef,
{
projection, settings, shipLayerData, shipOverlayLayerData, shipData,
legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges,
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
isHighlightedFleet, isHighlightedPair, isHighlightedMmsi,
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, projectionRef,
},
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const noopCable = useCallback((_: string | null) => {}, []);
useSubcablesLayer(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
{
subcableGeo: subcableGeo ?? null,
overlays, projection, mapSyncEpoch,
hoveredCableId: hoveredCableId ?? null,
onHoverCable: onHoverCable ?? noopCable,
onClickCable: onClickCable ?? noopCable,
},
);
useFlyTo(
mapRef, projectionRef,
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
);
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
}