- isStyleLoaded() 가드를 try/catch 패턴으로 교체 (AIS poll setData 중 렌더링 차단 방지) - Globe 툴팁 buildTooltipRef 패턴 도입 (AIS poll 주기 변경 시 사라짐 방지) - Globe 우클릭 컨텍스트 메뉴 isStyleLoaded 가드 제거 - 항적 가상 선박을 IconLayer에서 ScatterplotLayer(원형)로 변경 - useNativeMapLayers isStyleLoaded 가드 제거 (항적 레이어 셋업 스킵 방지) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
|
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
|
import { type PickingInfo } from '@deck.gl/core';
|
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
|
import { ScatterplotLayer } from '@deck.gl/layers';
|
|
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
|
|
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
|
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
|
|
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
|
import { toSafeNumber } from '../lib/setUtils';
|
|
import {
|
|
getShipTooltipHtml,
|
|
getPairLinkTooltipHtml,
|
|
getFcLinkTooltipHtml,
|
|
getRangeTooltipHtml,
|
|
getFleetCircleTooltipHtml,
|
|
} from '../lib/tooltips';
|
|
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
|
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
|
|
|
|
// NOTE:
|
|
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
|
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
|
|
const ENABLE_GLOBE_DECK_OVERLAYS = false;
|
|
|
|
|
|
export function useDeckLayers(
|
|
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
|
|
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
|
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
|
|
projectionBusyRef: MutableRefObject<boolean>,
|
|
opts: {
|
|
projection: MapProjectionId;
|
|
settings: Map3DSettings;
|
|
trackReplayDeckLayers: unknown[];
|
|
shipLayerData: AisTarget[];
|
|
shipOverlayLayerData: AisTarget[];
|
|
shipData: AisTarget[];
|
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
|
pairLinks: PairLink[] | undefined;
|
|
fcLinks: FcLink[] | undefined;
|
|
fcDashed: DashSeg[];
|
|
fleetCircles: FleetCircle[] | undefined;
|
|
pairRanges: PairRangeCircle[];
|
|
pairLinksInteractive: PairLink[];
|
|
pairRangesInteractive: PairRangeCircle[];
|
|
fcLinesInteractive: DashSeg[];
|
|
fleetCirclesInteractive: FleetCircle[];
|
|
overlays: MapToggleState;
|
|
shipByMmsi: Map<number, AisTarget>;
|
|
selectedMmsi: number | null;
|
|
shipHighlightSet: Set<number>;
|
|
isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean;
|
|
isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean;
|
|
isHighlightedMmsi: (mmsi: number) => boolean;
|
|
clearDeckHoverPairs: () => void;
|
|
clearDeckHoverMmsi: () => void;
|
|
clearMapFleetHoverState: () => void;
|
|
setDeckHoverPairs: (next: number[]) => void;
|
|
setDeckHoverMmsi: (next: number[]) => void;
|
|
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
|
|
toFleetMmsiList: (value: unknown) => number[];
|
|
touchDeckHoverState: (isHover: boolean) => void;
|
|
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
|
onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void;
|
|
onSelectMmsi: (mmsi: number | null) => void;
|
|
onToggleHighlightMmsi?: (mmsi: number) => void;
|
|
ensureMercatorOverlay: () => MapboxOverlay | null;
|
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
|
},
|
|
) {
|
|
const {
|
|
projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
|
|
legacyHits, pairLinks, 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, alarmMmsiMap,
|
|
} = opts;
|
|
|
|
const legacyTargets = useMemo(() => {
|
|
if (!legacyHits) return [];
|
|
return shipData.filter((t) => legacyHits.has(t.mmsi));
|
|
}, [shipData, legacyHits]);
|
|
|
|
const legacyTargetsOrdered = useMemo(() => {
|
|
if (legacyTargets.length === 0) return legacyTargets;
|
|
const layer = [...legacyTargets];
|
|
layer.sort((a, b) => a.mmsi - b.mmsi);
|
|
return layer;
|
|
}, [legacyTargets]);
|
|
|
|
const legacyOverlayTargets = useMemo(() => {
|
|
if (shipHighlightSet.size === 0) return [];
|
|
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
|
}, [legacyTargets, shipHighlightSet]);
|
|
|
|
const alarmTargets = useMemo(() => {
|
|
if (!alarmMmsiMap || alarmMmsiMap.size === 0) return [];
|
|
return shipData.filter((t) => alarmMmsiMap.has(t.mmsi));
|
|
}, [shipData, alarmMmsiMap]);
|
|
|
|
const mercatorLayersRef = useRef<unknown[]>([]);
|
|
const alarmRafRef = useRef(0);
|
|
|
|
// Mercator Deck layers
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
if (projection !== 'mercator' || projectionBusyRef.current) {
|
|
if (projection !== 'mercator') {
|
|
try {
|
|
if (overlayRef.current) overlayRef.current.setProps({ layers: [] } as never);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const deckTarget = ensureMercatorOverlay();
|
|
if (!deckTarget) return;
|
|
|
|
const layers = buildMercatorDeckLayers({
|
|
shipLayerData,
|
|
shipOverlayLayerData,
|
|
legacyTargetsOrdered,
|
|
legacyOverlayTargets,
|
|
legacyHits,
|
|
pairLinks,
|
|
fcDashed,
|
|
fleetCircles,
|
|
pairRanges,
|
|
pairLinksInteractive,
|
|
pairRangesInteractive,
|
|
fcLinesInteractive,
|
|
fleetCirclesInteractive,
|
|
overlays,
|
|
showDensity: settings.showDensity,
|
|
showShips: settings.showShips,
|
|
selectedMmsi,
|
|
shipHighlightSet,
|
|
touchDeckHoverState,
|
|
setDeckHoverPairs,
|
|
setDeckHoverMmsi,
|
|
clearDeckHoverPairs,
|
|
clearMapFleetHoverState,
|
|
setMapFleetHoverState,
|
|
toFleetMmsiList,
|
|
hasAuxiliarySelectModifier,
|
|
onSelectMmsi,
|
|
onToggleHighlightMmsi,
|
|
onDeckSelectOrHighlight,
|
|
alarmTargets,
|
|
alarmMmsiMap,
|
|
alarmPulseRadius: 8,
|
|
alarmPulseHoverRadius: 12,
|
|
});
|
|
|
|
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
|
|
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
|
|
const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]);
|
|
mercatorLayersRef.current = normalizedLayers;
|
|
const deckProps = {
|
|
layers: normalizedLayers,
|
|
getTooltip: (info: PickingInfo) => {
|
|
if (!info.object) return null;
|
|
if (info.layer && info.layer.id === 'density') {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const o: any = info.object;
|
|
const n = Array.isArray(o?.points) ? o.points.length : 0;
|
|
return { text: `AIS density: ${n}` };
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const obj: any = info.object;
|
|
if (typeof obj.mmsi === 'number') {
|
|
return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits });
|
|
}
|
|
if (info.layer && info.layer.id === 'pair-lines') {
|
|
const aMmsi = toSafeNumber(obj.aMmsi);
|
|
const bMmsi = toSafeNumber(obj.bMmsi);
|
|
if (aMmsi == null || bMmsi == null) return null;
|
|
return getPairLinkTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi });
|
|
}
|
|
if (info.layer && info.layer.id === 'fc-lines') {
|
|
const fcMmsi = toSafeNumber(obj.fcMmsi);
|
|
const otherMmsi = toSafeNumber(obj.otherMmsi);
|
|
if (fcMmsi == null || otherMmsi == null) return null;
|
|
return getFcLinkTooltipHtml({ suspicious: !!obj.suspicious, distanceNm: toSafeNumber(obj.distanceNm), fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi });
|
|
}
|
|
if (info.layer && info.layer.id === 'pair-range') {
|
|
const aMmsi = toSafeNumber(obj.aMmsi);
|
|
const bMmsi = toSafeNumber(obj.bMmsi);
|
|
if (aMmsi == null || bMmsi == null) return null;
|
|
return getRangeTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits });
|
|
}
|
|
if (info.layer && info.layer.id === 'fleet-circles') {
|
|
return getFleetCircleTooltipHtml({ ownerKey: String(obj.ownerKey ?? ''), ownerLabel: String(obj.ownerKey ?? ''), count: Number(obj.count ?? 0) });
|
|
}
|
|
return null;
|
|
},
|
|
onClick: (info: PickingInfo) => {
|
|
if (!info.object) { onSelectMmsi(null); return; }
|
|
if (info.layer && info.layer.id === 'density') return;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const obj: any = info.object;
|
|
if (typeof obj.mmsi === 'number') {
|
|
const t = obj as AisTarget;
|
|
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
|
|
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
|
|
onToggleHighlightMmsi?.(t.mmsi);
|
|
return;
|
|
}
|
|
onSelectMmsi(t.mmsi);
|
|
}
|
|
},
|
|
};
|
|
|
|
try {
|
|
deckTarget.setProps(deckProps as never);
|
|
} catch (e) {
|
|
console.error('Failed to apply base mercator deck props. Keeping previous layer set.', e);
|
|
}
|
|
}, [
|
|
ensureMercatorOverlay,
|
|
projection,
|
|
shipLayerData,
|
|
shipByMmsi,
|
|
pairRanges,
|
|
pairLinks,
|
|
fcDashed,
|
|
fleetCircles,
|
|
legacyTargetsOrdered,
|
|
legacyHits,
|
|
legacyOverlayTargets,
|
|
shipOverlayLayerData,
|
|
pairRangesInteractive,
|
|
pairLinksInteractive,
|
|
fcLinesInteractive,
|
|
fleetCirclesInteractive,
|
|
overlays.pairRange,
|
|
overlays.pairLines,
|
|
overlays.fcLines,
|
|
overlays.fleetCircles,
|
|
overlays.shipLabels,
|
|
settings.showDensity,
|
|
settings.showShips,
|
|
trackReplayDeckLayers,
|
|
onDeckSelectOrHighlight,
|
|
onSelectMmsi,
|
|
onToggleHighlightMmsi,
|
|
setDeckHoverPairs,
|
|
clearMapFleetHoverState,
|
|
setDeckHoverMmsi,
|
|
clearDeckHoverMmsi,
|
|
toFleetMmsiList,
|
|
touchDeckHoverState,
|
|
hasAuxiliarySelectModifier,
|
|
alarmTargets,
|
|
alarmMmsiMap,
|
|
]);
|
|
|
|
// Mercator alarm pulse breathing animation (rAF)
|
|
useEffect(() => {
|
|
if (projection !== 'mercator' || !alarmMmsiMap || alarmMmsiMap.size === 0 || alarmTargets.length === 0) {
|
|
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
|
alarmRafRef.current = 0;
|
|
return;
|
|
}
|
|
|
|
const animate = () => {
|
|
// 프로젝션 전환 중에는 overlay에 접근하지 않음 — WebGL 자원 무효화 방지
|
|
if (projectionBusyRef.current) {
|
|
alarmRafRef.current = requestAnimationFrame(animate);
|
|
return;
|
|
}
|
|
const currentOverlay = overlayRef.current;
|
|
if (!currentOverlay) {
|
|
alarmRafRef.current = requestAnimationFrame(animate);
|
|
return;
|
|
}
|
|
|
|
const t = (Math.sin(Date.now() / 1500 * Math.PI * 2) + 1) / 2;
|
|
const normalR = 8 + t * 6;
|
|
const hoverR = 12 + t * 6;
|
|
|
|
const pulseLyr = new ScatterplotLayer<AisTarget>({
|
|
id: 'alarm-pulse',
|
|
data: alarmTargets,
|
|
pickable: false,
|
|
billboard: false,
|
|
filled: true,
|
|
stroked: false,
|
|
radiusUnits: 'pixels',
|
|
getRadius: (d) => {
|
|
const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi);
|
|
return isHover ? hoverR : normalR;
|
|
},
|
|
getFillColor: (d) => {
|
|
const kind = alarmMmsiMap.get(d.mmsi);
|
|
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
|
|
},
|
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
|
updateTriggers: { getRadius: [normalR, hoverR] },
|
|
});
|
|
|
|
const updated = mercatorLayersRef.current.map((l) =>
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
|
|
);
|
|
|
|
try {
|
|
currentOverlay.setProps({ layers: updated } as never);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
alarmRafRef.current = requestAnimationFrame(animate);
|
|
};
|
|
alarmRafRef.current = requestAnimationFrame(animate);
|
|
return () => {
|
|
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
|
alarmRafRef.current = 0;
|
|
};
|
|
}, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet]);
|
|
|
|
// Globe Deck overlay
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map || projection !== 'globe' || projectionBusyRef.current) return;
|
|
const deckTarget = globeDeckLayerRef.current;
|
|
if (!deckTarget) return;
|
|
|
|
if (!ENABLE_GLOBE_DECK_OVERLAYS) {
|
|
try {
|
|
deckTarget.setProps({ layers: [], getTooltip: undefined, onClick: undefined } as never);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return;
|
|
}
|
|
|
|
const globeLayers = buildGlobeDeckLayers({
|
|
pairRanges,
|
|
pairLinks,
|
|
fcDashed,
|
|
fleetCircles,
|
|
legacyTargetsOrdered,
|
|
legacyHits,
|
|
overlays,
|
|
showShips: settings.showShips,
|
|
selectedMmsi,
|
|
isHighlightedFleet,
|
|
isHighlightedPair,
|
|
isHighlightedMmsi,
|
|
touchDeckHoverState,
|
|
setDeckHoverPairs,
|
|
setDeckHoverMmsi,
|
|
clearDeckHoverPairs,
|
|
clearDeckHoverMmsi,
|
|
clearMapFleetHoverState,
|
|
setMapFleetHoverState,
|
|
toFleetMmsiList,
|
|
});
|
|
|
|
const normalizedLayers = sanitizeDeckLayerList(globeLayers);
|
|
const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined };
|
|
|
|
try {
|
|
deckTarget.setProps(globeDeckProps as never);
|
|
} catch (e) {
|
|
console.error('Failed to apply globe deck props. Falling back to empty deck layer set.', e);
|
|
try {
|
|
deckTarget.setProps({ ...globeDeckProps, layers: [] as unknown[] } as never);
|
|
} catch {
|
|
// Ignore secondary failure.
|
|
}
|
|
}
|
|
}, [
|
|
projection,
|
|
pairRanges,
|
|
pairLinks,
|
|
fcDashed,
|
|
fleetCircles,
|
|
legacyTargetsOrdered,
|
|
overlays.pairRange,
|
|
overlays.pairLines,
|
|
overlays.fcLines,
|
|
overlays.fleetCircles,
|
|
settings.showShips,
|
|
selectedMmsi,
|
|
isHighlightedFleet,
|
|
isHighlightedPair,
|
|
clearDeckHoverPairs,
|
|
clearDeckHoverMmsi,
|
|
clearMapFleetHoverState,
|
|
setDeckHoverPairs,
|
|
setDeckHoverMmsi,
|
|
setMapFleetHoverState,
|
|
toFleetMmsiList,
|
|
touchDeckHoverState,
|
|
legacyHits,
|
|
]);
|
|
}
|