From b1551f800b71ba023dfd37dddf6650dbfda5adfc Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 23:41:29 +0900 Subject: [PATCH] =?UTF-8?q?refactor(map3d):=20useDeckLayers=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EC=83=9D=EC=84=B1=20=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildMercatorDeckLayers: Mercator 모드 Deck.gl 레이어 팩토리 - buildGlobeDeckLayers: Globe 모드 Deck.gl 레이어 팩토리 - useDeckLayers: 오케스트레이션 + 툴팁/클릭 + setProps Co-Authored-By: Claude Opus 4.6 --- .../src/widgets/map3d/hooks/useDeckLayers.ts | 452 ++-------------- .../widgets/map3d/lib/deckLayerFactories.ts | 485 ++++++++++++++++++ 2 files changed, 539 insertions(+), 398 deletions(-) create mode 100644 apps/web/src/widgets/map3d/lib/deckLayerFactories.ts diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index 1eefe43..c580f69 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -1,6 +1,4 @@ import { useEffect, useMemo, type MutableRefObject } from 'react'; -import { HexagonLayer } from '@deck.gl/aggregation-layers'; -import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { type PickingInfo } from '@deck.gl/core'; import type { AisTarget } from '../../../entities/aisTarget/model/types'; @@ -9,39 +7,7 @@ import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDash import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types'; import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; -import { - SHIP_ICON_MAPPING, - FLAT_SHIP_ICON_SIZE, - FLAT_SHIP_ICON_SIZE_SELECTED, - FLAT_SHIP_ICON_SIZE_HIGHLIGHTED, - FLAT_LEGACY_HALO_RADIUS, - FLAT_LEGACY_HALO_RADIUS_SELECTED, - FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED, - EMPTY_MMSI_SET, - DEPTH_DISABLED_PARAMS, - GLOBE_OVERLAY_PARAMS, - HALO_OUTLINE_COLOR, - HALO_OUTLINE_COLOR_SELECTED, - HALO_OUTLINE_COLOR_HIGHLIGHTED, - PAIR_RANGE_NORMAL_DECK, - PAIR_RANGE_WARN_DECK, - PAIR_LINE_NORMAL_DECK, - PAIR_LINE_WARN_DECK, - FC_LINE_NORMAL_DECK, - FC_LINE_SUSPICIOUS_DECK, - FLEET_RANGE_LINE_DECK, - FLEET_RANGE_FILL_DECK, - PAIR_RANGE_NORMAL_DECK_HL, - PAIR_RANGE_WARN_DECK_HL, - PAIR_LINE_NORMAL_DECK_HL, - PAIR_LINE_WARN_DECK_HL, - FC_LINE_NORMAL_DECK_HL, - FC_LINE_SUSPICIOUS_DECK_HL, - FLEET_RANGE_LINE_DECK_HL, - FLEET_RANGE_FILL_DECK_HL, -} from '../constants'; import { toSafeNumber } from '../lib/setUtils'; -import { getDisplayHeading, getShipColor } from '../lib/shipUtils'; import { getShipTooltipHtml, getPairLinkTooltipHtml, @@ -50,7 +16,7 @@ import { getFleetCircleTooltipHtml, } from '../lib/tooltips'; import { sanitizeDeckLayerList } from '../lib/mapCore'; -import { getCachedShipIcon } from '../lib/shipIconCache'; +import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories'; // NOTE: // Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips). @@ -151,343 +117,37 @@ export function useDeckLayers( const deckTarget = ensureMercatorOverlay(); if (!deckTarget) return; - const layers: unknown[] = []; - const overlayParams = DEPTH_DISABLED_PARAMS; - const clearDeckHover = () => { - touchDeckHoverState(false); - }; - const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false); - const shipOtherData: AisTarget[] = []; - const shipTargetData: AisTarget[] = []; - for (const t of shipLayerData) { - if (isTargetShip(t.mmsi)) shipTargetData.push(t); - else shipOtherData.push(t); - } - const shipOverlayOtherData: AisTarget[] = []; - const shipOverlayTargetData: AisTarget[] = []; - for (const t of shipOverlayLayerData) { - if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t); - else shipOverlayOtherData.push(t); - } - - if (settings.showDensity) { - layers.push( - new HexagonLayer({ - id: 'density', - data: shipLayerData, - pickable: true, - extruded: true, - radius: 2500, - elevationScale: 35, - coverage: 0.92, - opacity: 0.35, - getPosition: (d) => [d.lon, d.lat], - }), - ); - } - - if (overlays.pairRange && pairRanges.length > 0) { - layers.push( - new ScatterplotLayer({ - id: 'pair-range', - data: pairRanges, - pickable: true, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: 'meters', - getRadius: (d) => d.radiusNm * 1852, - radiusMinPixels: 10, - lineWidthUnits: 'pixels', - getLineWidth: () => 1, - getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK), - getPosition: (d) => d.center, - onHover: (info) => { - if (!info.object) { clearDeckHover(); return; } - touchDeckHoverState(true); - const p = info.object as PairRangeCircle; - setDeckHoverPairs([p.aMmsi, p.bMmsi]); - setDeckHoverMmsi([p.aMmsi, p.bMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) { onSelectMmsi(null); return; } - const obj = info.object as PairRangeCircle; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.aMmsi); - onToggleHighlightMmsi?.(obj.bMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); - }, - }), - ); - } - - if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { - layers.push( - new LineLayer({ - id: 'pair-lines', - data: pairLinks, - pickable: true, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK), - getWidth: (d) => (d.warn ? 2.2 : 1.4), - widthUnits: 'pixels', - onHover: (info) => { - if (!info.object) { clearDeckHover(); return; } - touchDeckHoverState(true); - const obj = info.object as PairLink; - setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); - setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as PairLink; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.aMmsi); - onToggleHighlightMmsi?.(obj.bMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); - }, - }), - ); - } - - if (overlays.fcLines && fcDashed.length > 0) { - layers.push( - new LineLayer({ - id: 'fc-lines', - data: fcDashed, - pickable: true, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK), - getWidth: () => 1.3, - widthUnits: 'pixels', - onHover: (info) => { - if (!info.object) { clearDeckHover(); return; } - touchDeckHoverState(true); - const obj = info.object as DashSeg; - if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; } - setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); - setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as DashSeg; - if (obj.fromMmsi == null || obj.toMmsi == null) return; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.fromMmsi); - onToggleHighlightMmsi?.(obj.toMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.fromMmsi }); - }, - }), - ); - } - - if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { - layers.push( - new ScatterplotLayer({ - id: 'fleet-circles', - data: fleetCircles, - pickable: true, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: 'meters', - getRadius: (d) => d.radiusNm * 1852, - lineWidthUnits: 'pixels', - getLineWidth: () => 1.1, - getLineColor: () => FLEET_RANGE_LINE_DECK, - getPosition: (d) => d.center, - onHover: (info) => { - if (!info.object) { clearDeckHover(); return; } - touchDeckHoverState(true); - const obj = info.object as FleetCircle; - const list = toFleetMmsiList(obj.vesselMmsis); - setMapFleetHoverState(obj.ownerKey || null, list); - setDeckHoverMmsi(list); - clearDeckHoverPairs(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as FleetCircle; - const list = toFleetMmsiList(obj.vesselMmsis); - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - for (const mmsi of list) onToggleHighlightMmsi?.(mmsi); - return; - } - const first = list[0]; - if (first != null) onDeckSelectOrHighlight({ mmsi: first }); - }, - }), - ); - layers.push( - new ScatterplotLayer({ - id: 'fleet-circles-fill', - data: fleetCircles, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: true, - stroked: false, - radiusUnits: 'meters', - getRadius: (d) => d.radiusNm * 1852, - getFillColor: () => FLEET_RANGE_FILL_DECK, - getPosition: (d) => d.center, - }), - ); - } - - if (settings.showShips) { - const shipOnHover = (info: PickingInfo) => { - if (!info.object) { clearDeckHover(); return; } - touchDeckHoverState(true); - const obj = info.object as AisTarget; - setDeckHoverMmsi([obj.mmsi]); - clearDeckHoverPairs(); - clearMapFleetHoverState(); - }; - const shipOnClick = (info: PickingInfo) => { - if (!info.object) { onSelectMmsi(null); return; } - onDeckSelectOrHighlight( - { - mmsi: (info.object as AisTarget).mmsi, - srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, - }, - true, - ); - }; - - if (shipOtherData.length > 0) { - layers.push( - new IconLayer({ - id: 'ships-other', - data: shipOtherData, - pickable: true, - billboard: false, - parameters: overlayParams, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), - sizeUnits: 'pixels', - getSize: () => FLAT_SHIP_ICON_SIZE, - getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET), - onHover: shipOnHover, - onClick: shipOnClick, - alphaCutoff: 0.05, - }), - ); - } - - if (shipOverlayOtherData.length > 0) { - layers.push( - new IconLayer({ - id: 'ships-overlay-other', - data: shipOverlayOtherData, - pickable: false, - billboard: false, - parameters: overlayParams, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), - sizeUnits: 'pixels', - getSize: (d) => { - if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return 0; - }, - getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet), - alphaCutoff: 0.05, - }), - ); - } - - if (legacyTargetsOrdered.length > 0) { - layers.push( - new ScatterplotLayer({ - id: 'legacy-halo', - data: legacyTargetsOrdered, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: 'pixels', - getRadius: () => FLAT_LEGACY_HALO_RADIUS, - lineWidthUnits: 'pixels', - getLineWidth: () => 2, - getLineColor: () => HALO_OUTLINE_COLOR, - getPosition: (d) => [d.lon, d.lat] as [number, number], - }), - ); - } - - if (shipTargetData.length > 0) { - layers.push( - new IconLayer({ - id: 'ships-target', - data: shipTargetData, - pickable: true, - billboard: false, - parameters: overlayParams, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), - sizeUnits: 'pixels', - getSize: () => FLAT_SHIP_ICON_SIZE, - getColor: (d) => getShipColor(d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET), - onHover: shipOnHover, - onClick: shipOnClick, - alphaCutoff: 0.05, - }), - ); - } - } - - if (pairRangesInteractive.length > 0) { - layers.push(new ScatterplotLayer({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center })); - } - if (pairLinksInteractive.length > 0) { - layers.push(new LineLayer({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' })); - } - if (fcLinesInteractive.length > 0) { - layers.push(new LineLayer({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' })); - } - if (fleetCirclesInteractive.length > 0) { - layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay-fill', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL })); - layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center })); - } - - if (settings.showShips && legacyOverlayTargets.length > 0) { - layers.push(new ScatterplotLayer({ id: 'legacy-halo-overlay', data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); - } - - if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) { - const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)); - layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } })); - } + 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, + }); const normalizedBaseLayers = sanitizeDeckLayerList(layers); const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers); @@ -610,32 +270,28 @@ export function useDeckLayers( return; } - - const overlayParams = GLOBE_OVERLAY_PARAMS; - const globeLayers: unknown[] = []; - - if (overlays.pairRange && pairRanges.length > 0) { - globeLayers.push(new ScatterplotLayer({ id: 'pair-range-globe', data: pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const p = info.object as PairRangeCircle; setDeckHoverPairs([p.aMmsi, p.bMmsi]); setDeckHoverMmsi([p.aMmsi, p.bMmsi]); clearMapFleetHoverState(); } })); - } - - if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { - const links = pairLinks || []; - globeLayers.push(new LineLayer({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as PairLink; setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); clearMapFleetHoverState(); } })); - } - - if (overlays.fcLines && fcDashed.length > 0) { - globeLayers.push(new LineLayer({ id: 'fc-lines-globe', data: fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); clearMapFleetHoverState(); } })); - } - - if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { - const circles = fleetCircles || []; - globeLayers.push(new ScatterplotLayer({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); clearMapFleetHoverState(); return; } touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = toFleetMmsiList(obj.vesselMmsis); setMapFleetHoverState(obj.ownerKey || null, list); setDeckHoverMmsi(list); clearDeckHoverPairs(); } })); - globeLayers.push(new ScatterplotLayer({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center })); - } - - if (settings.showShips && legacyTargetsOrdered.length > 0) { - globeLayers.push(new ScatterplotLayer({ id: 'legacy-halo-globe', data: legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); - } + 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 }; diff --git a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts new file mode 100644 index 0000000..8963e2b --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts @@ -0,0 +1,485 @@ +import { HexagonLayer } from '@deck.gl/aggregation-layers'; +import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers'; +import type { PickingInfo } from '@deck.gl/core'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { DashSeg, PairRangeCircle } from '../types'; +import { + SHIP_ICON_MAPPING, + FLAT_SHIP_ICON_SIZE, + FLAT_SHIP_ICON_SIZE_SELECTED, + FLAT_SHIP_ICON_SIZE_HIGHLIGHTED, + FLAT_LEGACY_HALO_RADIUS, + FLAT_LEGACY_HALO_RADIUS_SELECTED, + FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED, + EMPTY_MMSI_SET, + DEPTH_DISABLED_PARAMS, + GLOBE_OVERLAY_PARAMS, + HALO_OUTLINE_COLOR, + HALO_OUTLINE_COLOR_SELECTED, + HALO_OUTLINE_COLOR_HIGHLIGHTED, + PAIR_RANGE_NORMAL_DECK, + PAIR_RANGE_WARN_DECK, + PAIR_LINE_NORMAL_DECK, + PAIR_LINE_WARN_DECK, + FC_LINE_NORMAL_DECK, + FC_LINE_SUSPICIOUS_DECK, + FLEET_RANGE_LINE_DECK, + FLEET_RANGE_FILL_DECK, + PAIR_RANGE_NORMAL_DECK_HL, + PAIR_RANGE_WARN_DECK_HL, + PAIR_LINE_NORMAL_DECK_HL, + PAIR_LINE_WARN_DECK_HL, + FC_LINE_NORMAL_DECK_HL, + FC_LINE_SUSPICIOUS_DECK_HL, + FLEET_RANGE_LINE_DECK_HL, + FLEET_RANGE_FILL_DECK_HL, +} from '../constants'; +import { getDisplayHeading, getShipColor } from './shipUtils'; +import { getCachedShipIcon } from './shipIconCache'; + +/* ── 공통 콜백 인터페이스 ─────────────────────────────── */ + +interface DeckHoverCallbacks { + touchDeckHoverState: (isHover: boolean) => void; + setDeckHoverPairs: (next: number[]) => void; + setDeckHoverMmsi: (next: number[]) => void; + clearDeckHoverPairs: () => void; + clearMapFleetHoverState: () => void; + setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void; + toFleetMmsiList: (value: unknown) => number[]; +} + +interface DeckSelectCallbacks { + hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean; + onSelectMmsi: (mmsi: number | null) => void; + onToggleHighlightMmsi?: (mmsi: number) => void; + onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void; +} + +/* ── Mercator Deck 레이어 ─────────────────────────────── */ + +export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks { + shipLayerData: AisTarget[]; + shipOverlayLayerData: AisTarget[]; + legacyTargetsOrdered: AisTarget[]; + legacyOverlayTargets: AisTarget[]; + legacyHits: Map | null | undefined; + pairLinks: PairLink[] | undefined; + fcDashed: DashSeg[]; + fleetCircles: FleetCircle[] | undefined; + pairRanges: PairRangeCircle[]; + pairLinksInteractive: PairLink[]; + pairRangesInteractive: PairRangeCircle[]; + fcLinesInteractive: DashSeg[]; + fleetCirclesInteractive: FleetCircle[]; + overlays: MapToggleState; + showDensity: boolean; + showShips: boolean; + selectedMmsi: number | null; + shipHighlightSet: Set; +} + +export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] { + const layers: unknown[] = []; + const overlayParams = DEPTH_DISABLED_PARAMS; + const clearDeckHover = () => { ctx.touchDeckHoverState(false); }; + const isTargetShip = (mmsi: number) => (ctx.legacyHits ? ctx.legacyHits.has(mmsi) : false); + + const shipOtherData: AisTarget[] = []; + const shipTargetData: AisTarget[] = []; + for (const t of ctx.shipLayerData) { + if (isTargetShip(t.mmsi)) shipTargetData.push(t); + else shipOtherData.push(t); + } + const shipOverlayOtherData: AisTarget[] = []; + for (const t of ctx.shipOverlayLayerData) { + if (!isTargetShip(t.mmsi)) shipOverlayOtherData.push(t); + } + + /* ─ density ─ */ + if (ctx.showDensity) { + layers.push( + new HexagonLayer({ + id: 'density', + data: ctx.shipLayerData, + pickable: true, + extruded: true, + radius: 2500, + elevationScale: 35, + coverage: 0.92, + opacity: 0.35, + getPosition: (d) => [d.lon, d.lat], + }), + ); + } + + /* ─ pair range ─ */ + if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'pair-range', + data: ctx.pairRanges, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: 'meters', + getRadius: (d) => d.radiusNm * 1852, + radiusMinPixels: 10, + lineWidthUnits: 'pixels', + getLineWidth: () => 1, + getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK), + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { clearDeckHover(); return; } + ctx.touchDeckHoverState(true); + const p = info.object as PairRangeCircle; + ctx.setDeckHoverPairs([p.aMmsi, p.bMmsi]); + ctx.setDeckHoverMmsi([p.aMmsi, p.bMmsi]); + ctx.clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) { ctx.onSelectMmsi(null); return; } + const obj = info.object as PairRangeCircle; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) { + ctx.onToggleHighlightMmsi?.(obj.aMmsi); + ctx.onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + ctx.onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + }), + ); + } + + /* ─ pair lines ─ */ + if (ctx.overlays.pairLines && (ctx.pairLinks?.length ?? 0) > 0) { + layers.push( + new LineLayer({ + id: 'pair-lines', + data: ctx.pairLinks, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK), + getWidth: (d) => (d.warn ? 2.2 : 1.4), + widthUnits: 'pixels', + onHover: (info) => { + if (!info.object) { clearDeckHover(); return; } + ctx.touchDeckHoverState(true); + const obj = info.object as PairLink; + ctx.setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); + ctx.setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); + ctx.clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as PairLink; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) { + ctx.onToggleHighlightMmsi?.(obj.aMmsi); + ctx.onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + ctx.onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + }), + ); + } + + /* ─ fc lines ─ */ + if (ctx.overlays.fcLines && ctx.fcDashed.length > 0) { + layers.push( + new LineLayer({ + id: 'fc-lines', + data: ctx.fcDashed, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK), + getWidth: () => 1.3, + widthUnits: 'pixels', + onHover: (info) => { + if (!info.object) { clearDeckHover(); return; } + ctx.touchDeckHoverState(true); + const obj = info.object as DashSeg; + if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; } + ctx.setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); + ctx.setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); + ctx.clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as DashSeg; + if (obj.fromMmsi == null || obj.toMmsi == null) return; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) { + ctx.onToggleHighlightMmsi?.(obj.fromMmsi); + ctx.onToggleHighlightMmsi?.(obj.toMmsi); + return; + } + ctx.onDeckSelectOrHighlight({ mmsi: obj.fromMmsi }); + }, + }), + ); + } + + /* ─ fleet circles ─ */ + if (ctx.overlays.fleetCircles && (ctx.fleetCircles?.length ?? 0) > 0) { + layers.push( + new ScatterplotLayer({ + id: 'fleet-circles', + data: ctx.fleetCircles, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: 'meters', + getRadius: (d) => d.radiusNm * 1852, + lineWidthUnits: 'pixels', + getLineWidth: () => 1.1, + getLineColor: () => FLEET_RANGE_LINE_DECK, + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { clearDeckHover(); return; } + ctx.touchDeckHoverState(true); + const obj = info.object as FleetCircle; + const list = ctx.toFleetMmsiList(obj.vesselMmsis); + ctx.setMapFleetHoverState(obj.ownerKey || null, list); + ctx.setDeckHoverMmsi(list); + ctx.clearDeckHoverPairs(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as FleetCircle; + const list = ctx.toFleetMmsiList(obj.vesselMmsis); + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) { + for (const mmsi of list) ctx.onToggleHighlightMmsi?.(mmsi); + return; + } + const first = list[0]; + if (first != null) ctx.onDeckSelectOrHighlight({ mmsi: first }); + }, + }), + ); + layers.push( + new ScatterplotLayer({ + id: 'fleet-circles-fill', + data: ctx.fleetCircles, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: true, + stroked: false, + radiusUnits: 'meters', + getRadius: (d) => d.radiusNm * 1852, + getFillColor: () => FLEET_RANGE_FILL_DECK, + getPosition: (d) => d.center, + }), + ); + } + + /* ─ ships ─ */ + if (ctx.showShips) { + const shipOnHover = (info: PickingInfo) => { + if (!info.object) { clearDeckHover(); return; } + ctx.touchDeckHoverState(true); + const obj = info.object as AisTarget; + ctx.setDeckHoverMmsi([obj.mmsi]); + ctx.clearDeckHoverPairs(); + ctx.clearMapFleetHoverState(); + }; + const shipOnClick = (info: PickingInfo) => { + if (!info.object) { ctx.onSelectMmsi(null); return; } + ctx.onDeckSelectOrHighlight( + { + mmsi: (info.object as AisTarget).mmsi, + srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, + }, + true, + ); + }; + + if (shipOtherData.length > 0) { + layers.push( + new IconLayer({ + id: 'ships-other', + data: shipOtherData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: getCachedShipIcon(), + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => 'ship', + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + sizeUnits: 'pixels', + getSize: () => FLAT_SHIP_ICON_SIZE, + getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET), + onHover: shipOnHover, + onClick: shipOnClick, + alphaCutoff: 0.05, + }), + ); + } + + if (shipOverlayOtherData.length > 0) { + layers.push( + new IconLayer({ + id: 'ships-overlay-other', + data: shipOverlayOtherData, + pickable: false, + billboard: false, + parameters: overlayParams, + iconAtlas: getCachedShipIcon(), + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => 'ship', + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + sizeUnits: 'pixels', + getSize: (d) => { + if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return 0; + }, + getColor: (d) => getShipColor(d, ctx.selectedMmsi, null, ctx.shipHighlightSet), + alphaCutoff: 0.05, + }), + ); + } + + if (ctx.legacyTargetsOrdered.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'legacy-halo', + data: ctx.legacyTargetsOrdered, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: 'pixels', + getRadius: () => FLAT_LEGACY_HALO_RADIUS, + lineWidthUnits: 'pixels', + getLineWidth: () => 2, + getLineColor: () => HALO_OUTLINE_COLOR, + getPosition: (d) => [d.lon, d.lat] as [number, number], + }), + ); + } + + if (shipTargetData.length > 0) { + layers.push( + new IconLayer({ + id: 'ships-target', + data: shipTargetData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: getCachedShipIcon(), + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => 'ship', + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + sizeUnits: 'pixels', + getSize: () => FLAT_SHIP_ICON_SIZE, + getColor: (d) => getShipColor(d, null, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET), + onHover: shipOnHover, + onClick: shipOnClick, + alphaCutoff: 0.05, + }), + ); + } + } + + /* ─ interactive overlays ─ */ + if (ctx.pairRangesInteractive.length > 0) { + layers.push(new ScatterplotLayer({ id: 'pair-range-overlay', data: ctx.pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center })); + } + if (ctx.pairLinksInteractive.length > 0) { + layers.push(new LineLayer({ id: 'pair-lines-overlay', data: ctx.pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' })); + } + if (ctx.fcLinesInteractive.length > 0) { + layers.push(new LineLayer({ id: 'fc-lines-overlay', data: ctx.fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' })); + } + if (ctx.fleetCirclesInteractive.length > 0) { + layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay-fill', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL })); + layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center })); + } + + /* ─ legacy overlay (highlight/selected) ─ */ + if (ctx.showShips && ctx.legacyOverlayTargets.length > 0) { + layers.push(new ScatterplotLayer({ id: 'legacy-halo-overlay', data: ctx.legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); + } + + if (ctx.showShips && ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)).length > 0) { + const shipOverlayTargetData2 = ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)); + layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!ctx.shipHighlightSet.has(d.mmsi) && !(ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, ctx.selectedMmsi, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, ctx.shipHighlightSet); } })); + } + + return layers; +} + +/* ── Globe Deck 오버레이 레이어 ────────────────────────── */ + +export interface GlobeDeckLayerContext { + pairRanges: PairRangeCircle[]; + pairLinks: PairLink[] | undefined; + fcDashed: DashSeg[]; + fleetCircles: FleetCircle[] | undefined; + legacyTargetsOrdered: AisTarget[]; + legacyHits: Map | null | undefined; + overlays: MapToggleState; + showShips: boolean; + selectedMmsi: number | null; + isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean; + isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean; + isHighlightedMmsi: (mmsi: number) => boolean; + touchDeckHoverState: (isHover: boolean) => void; + setDeckHoverPairs: (next: number[]) => void; + setDeckHoverMmsi: (next: number[]) => void; + clearDeckHoverPairs: () => void; + clearDeckHoverMmsi: () => void; + clearMapFleetHoverState: () => void; + setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void; + toFleetMmsiList: (value: unknown) => number[]; +} + +export function buildGlobeDeckLayers(ctx: GlobeDeckLayerContext): unknown[] { + const overlayParams = GLOBE_OVERLAY_PARAMS; + const globeLayers: unknown[] = []; + + if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) { + globeLayers.push(new ScatterplotLayer({ id: 'pair-range-globe', data: ctx.pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = ctx.isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const p = info.object as PairRangeCircle; ctx.setDeckHoverPairs([p.aMmsi, p.bMmsi]); ctx.setDeckHoverMmsi([p.aMmsi, p.bMmsi]); ctx.clearMapFleetHoverState(); } })); + } + + if (ctx.overlays.pairLines && (ctx.pairLinks?.length ?? 0) > 0) { + const links = ctx.pairLinks || []; + globeLayers.push(new LineLayer({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = ctx.isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (ctx.isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const obj = info.object as PairLink; ctx.setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); ctx.setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); ctx.clearMapFleetHoverState(); } })); + } + + if (ctx.overlays.fcLines && ctx.fcDashed.length > 0) { + globeLayers.push(new LineLayer({ id: 'fc-lines-globe', data: ctx.fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => ctx.isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => ctx.isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); ctx.setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); ctx.clearMapFleetHoverState(); } })); + } + + if (ctx.overlays.fleetCircles && (ctx.fleetCircles?.length ?? 0) > 0) { + const circles = ctx.fleetCircles || []; + globeLayers.push(new ScatterplotLayer({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); ctx.clearMapFleetHoverState(); return; } ctx.touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = ctx.toFleetMmsiList(obj.vesselMmsis); ctx.setMapFleetHoverState(obj.ownerKey || null, list); ctx.setDeckHoverMmsi(list); ctx.clearDeckHoverPairs(); } })); + globeLayers.push(new ScatterplotLayer({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center })); + } + + if (ctx.showShips && ctx.legacyTargetsOrdered.length > 0) { + globeLayers.push(new ScatterplotLayer({ id: 'legacy-halo-globe', data: ctx.legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); + } + + return globeLayers; +}