gc-wing/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts
htlee 7bca216c53 fix(map): Globe 렌더링 안정화 및 툴팁 유지 개선
- 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>
2026-02-17 16:38:51 +09:00

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,
]);
}