From 4d67b26ffa630c518d9446945bee750e6db4abc4 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 23:44:19 +0900 Subject: [PATCH] =?UTF-8?q?refactor(map3d):=20useGlobeOverlays=20600?= =?UTF-8?q?=EC=A4=84=20=E2=86=92=20=EC=84=9C=EB=B8=8C=ED=9B=85=202+1?= =?UTF-8?q?=EA=B0=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGlobePairOverlay: pair lines + pair range + paint - useGlobeFcFleetOverlay: fc lines + fleet circles + paint - useGlobeOverlays: 오케스트레이터 (기존 호출부 호환) Co-Authored-By: Claude Opus 4.6 --- .../map3d/hooks/useGlobeFcFleetOverlay.ts | 356 +++++++++++ .../widgets/map3d/hooks/useGlobeOverlays.ts | 601 +----------------- .../map3d/hooks/useGlobePairOverlay.ts | 284 +++++++++ 3 files changed, 663 insertions(+), 578 deletions(-) create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts b/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts new file mode 100644 index 0000000..65c6a7e --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts @@ -0,0 +1,356 @@ +import { useCallback, useEffect, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; +import type { FcLink, FleetCircle } from '../../../features/legacyDashboard/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { DashSeg, MapProjectionId } from '../types'; +import { + FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML, + FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL, + FLEET_FILL_ML_HL, + FLEET_LINE_ML, FLEET_LINE_ML_HL, +} from '../constants'; +import { makeUniqueSorted } from '../lib/setUtils'; +import { + makeFcSegmentFeatureId, + makeFleetCircleFeatureId, +} from '../lib/featureIds'; +import { + makeMmsiAnyEndpointExpr, + makeFleetOwnerMatchExpr, + makeFleetMemberMatchExpr, +} from '../lib/mlExpressions'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { circleRingLngLat } from '../lib/geometry'; +import { guardedSetVisibility } from '../lib/layerHelpers'; +import { dashifyLine } from '../lib/dashifyLine'; + +/** Globe FC lines + fleet circles 오버레이 */ +export function useGlobeFcFleetOverlay( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + overlays: MapToggleState; + fcLinks: FcLink[] | undefined; + fleetCircles: FleetCircle[] | undefined; + projection: MapProjectionId; + mapSyncEpoch: number; + hoveredFleetMmsiList: number[]; + hoveredFleetOwnerKeyList: string[]; + hoveredPairMmsiList: number[]; + }, +) { + const { + overlays, fcLinks, fleetCircles, projection, mapSyncEpoch, + hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, + } = opts; + + // FC lines + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'fc-lines-ml-src'; + const layerId = 'fc-lines-ml'; + + const remove = () => { + guardedSetVisibility(map, layerId, 'none'); + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); + const fcHoverActive = fleetAwarePairMmsiList.length > 0; + if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) { + remove(); + return; + } + + const segs: DashSeg[] = []; + for (const l of fcLinks || []) { + segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); + } + if (segs.length === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: segs.map((s, idx) => ({ + type: 'Feature', + id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx), + geometry: { type: 'LineString', coordinates: [s.from, s.to] }, + properties: { + type: 'fc', + suspicious: s.suspicious, + distanceNm: s.distanceNm, + fcMmsi: s.fromMmsi ?? -1, + otherMmsi: s.toMmsi ?? -1, + }, + })), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('FC lines source setup failed:', e); + return; + } + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, + paint: { + 'line-color': [ + 'case', + ['==', ['get', 'highlighted'], 1], + ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], + ['boolean', ['get', 'suspicious'], false], + FC_LINE_SUSPICIOUS_ML, + FC_LINE_NORMAL_ML, + ] as never, + 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never, + 'line-opacity': 0.9, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('FC lines layer add failed:', e); + } + } else { + guardedSetVisibility(map, layerId, 'visible'); + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [ + projection, + overlays.fcLines, + fcLinks, + hoveredPairMmsiList, + hoveredFleetMmsiList, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); + + // Fleet circles + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'fleet-circles-ml-src'; + const fillSrcId = 'fleet-circles-ml-fill-src'; + const layerId = 'fleet-circles-ml'; + const fillLayerId = 'fleet-circles-ml-fill'; + + const remove = () => { + guardedSetVisibility(map, layerId, 'none'); + guardedSetVisibility(map, fillLayerId, 'none'); + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0; + if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) { + remove(); + return; + } + + const circles = fleetCircles || []; + const isHighlightedFleet = (ownerKey: string, vesselMmsis: number[]) => + hoveredFleetOwnerKeyList.includes(ownerKey) || + (hoveredFleetMmsiList.length > 0 && vesselMmsis.some((mmsi) => hoveredFleetMmsiList.includes(mmsi))); + + const fcLine: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: circles.map((c) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: 'Feature', + id: makeFleetCircleFeatureId(c.ownerKey), + geometry: { type: 'LineString', coordinates: ring }, + properties: { + type: 'fleet', + ownerKey: c.ownerKey, + ownerLabel: c.ownerLabel, + count: c.count, + vesselMmsis: c.vesselMmsis, + highlighted: 0, + }, + }; + }), + }; + + const fcFill: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: circles + .filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis)) + .map((c) => ({ + type: 'Feature', + id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`), + geometry: { + type: 'Polygon', + coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)], + }, + properties: { + ownerKey: c.ownerKey, + }, + })), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fcLine); + else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Fleet circles source setup failed:', e); + return; + } + + try { + const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined; + if (existingFill) existingFill.setData(fcFill); + else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Fleet circles fill source setup failed:', e); + } + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, + paint: { + 'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never, + 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never, + 'line-opacity': 0.85, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Fleet circles layer add failed:', e); + } + } else { + guardedSetVisibility(map, layerId, 'visible'); + } + + if (!map.getLayer(fillLayerId)) { + try { + map.addLayer( + { + id: fillLayerId, + type: 'fill', + source: fillSrcId, + layout: { visibility: fcFill.features.length > 0 ? 'visible' : 'none' }, + paint: { + 'fill-color': FLEET_FILL_ML_HL, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Fleet circles fill layer add failed:', e); + } + } else { + guardedSetVisibility(map, fillLayerId, fcFill.features.length > 0 ? 'visible' : 'none'); + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [ + projection, + overlays.fleetCircles, + fleetCircles, + hoveredFleetOwnerKeyList, + hoveredFleetMmsiList, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); + + // FC + Fleet paint state updates + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const updateFcFleetPaintStates = useCallback(() => { + if (projection !== 'globe' || projectionBusyRef.current) return; + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); + + const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0 + ? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList) + : false; + + const fleetOwnerMatchExpr = + hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false; + const fleetMemberExpr = + hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false; + const fleetHighlightExpr = + hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0 + ? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never) + : false; + + try { + if (map.getLayer('fc-lines-ml')) { + map.setPaintProperty( + 'fc-lines-ml', 'line-color', + ['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'fc-lines-ml', 'line-width', + ['case', fcEndpointHighlightExpr, 2.0, 1.3] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer('fleet-circles-ml')) { + map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never); + map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never); + } + } catch { + // ignore + } + }, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const stop = onMapStyleReady(map, updateFcFleetPaintStates); + updateFcFleetPaintStates(); + return () => { + stop(); + }; + }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateFcFleetPaintStates]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts index b2261d6..6d3918b 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts @@ -1,35 +1,10 @@ -import { useCallback, useEffect, type MutableRefObject } from 'react'; +import type { MutableRefObject } from 'react'; import type maplibregl from 'maplibre-gl'; -import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; -import type { DashSeg, MapProjectionId, PairRangeCircle } from '../types'; -import { - PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML, - PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL, - PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML, - PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL, - FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML, - FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL, - FLEET_FILL_ML_HL, - FLEET_LINE_ML, FLEET_LINE_ML_HL, -} from '../constants'; -import { makeUniqueSorted } from '../lib/setUtils'; -import { - makePairLinkFeatureId, - makeFcSegmentFeatureId, - makeFleetCircleFeatureId, -} from '../lib/featureIds'; -import { - makeMmsiPairHighlightExpr, - makeMmsiAnyEndpointExpr, - makeFleetOwnerMatchExpr, - makeFleetMemberMatchExpr, -} from '../lib/mlExpressions'; -import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; -import { circleRingLngLat } from '../lib/geometry'; -import { guardedSetVisibility } from '../lib/layerHelpers'; -import { dashifyLine } from '../lib/dashifyLine'; +import type { MapProjectionId } from '../types'; +import { useGlobePairOverlay } from './useGlobePairOverlay'; +import { useGlobeFcFleetOverlay } from './useGlobeFcFleetOverlay'; export function useGlobeOverlays( mapRef: MutableRefObject, @@ -47,554 +22,24 @@ export function useGlobeOverlays( hoveredPairMmsiList: number[]; }, ) { - const { - overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch, - hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, - } = opts; + // Pair lines + pair range + useGlobePairOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { + overlays: opts.overlays, + pairLinks: opts.pairLinks, + projection: opts.projection, + mapSyncEpoch: opts.mapSyncEpoch, + hoveredPairMmsiList: opts.hoveredPairMmsiList, + }); - // Pair lines - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = 'pair-lines-ml-src'; - const layerId = 'pair-lines-ml'; - - const remove = () => { - guardedSetVisibility(map, layerId, 'none'); - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - const pairHoverActive = hoveredPairMmsiList.length >= 2; - if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) { - remove(); - return; - } - - const fc: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: (pairLinks || []).map((p) => ({ - type: 'Feature', - id: makePairLinkFeatureId(p.aMmsi, p.bMmsi), - geometry: { type: 'LineString', coordinates: [p.from, p.to] }, - properties: { - type: 'pair', - aMmsi: p.aMmsi, - bMmsi: p.bMmsi, - distanceNm: p.distanceNm, - warn: p.warn, - }, - })), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('Pair lines source setup failed:', e); - return; - } - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: 'line', - source: srcId, - layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': [ - 'case', - ['==', ['get', 'highlighted'], 1], - ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], - ['boolean', ['get', 'warn'], false], - PAIR_LINE_WARN_ML, - PAIR_LINE_NORMAL_ML, - ] as never, - 'line-width': [ - 'case', - ['==', ['get', 'highlighted'], 1], 2.8, - ['boolean', ['get', 'warn'], false], 2.2, - 1.4, - ] as never, - 'line-opacity': 0.9, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('Pair lines layer add failed:', e); - } - } else { - guardedSetVisibility(map, layerId, 'visible'); - } - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); - - // FC lines - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = 'fc-lines-ml-src'; - const layerId = 'fc-lines-ml'; - - const remove = () => { - guardedSetVisibility(map, layerId, 'none'); - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); - const fcHoverActive = fleetAwarePairMmsiList.length > 0; - if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) { - remove(); - return; - } - - const segs: DashSeg[] = []; - for (const l of fcLinks || []) { - segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); - } - if (segs.length === 0) { - remove(); - return; - } - - const fc: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: segs.map((s, idx) => ({ - type: 'Feature', - id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx), - geometry: { type: 'LineString', coordinates: [s.from, s.to] }, - properties: { - type: 'fc', - suspicious: s.suspicious, - distanceNm: s.distanceNm, - fcMmsi: s.fromMmsi ?? -1, - otherMmsi: s.toMmsi ?? -1, - }, - })), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('FC lines source setup failed:', e); - return; - } - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: 'line', - source: srcId, - layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': [ - 'case', - ['==', ['get', 'highlighted'], 1], - ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], - ['boolean', ['get', 'suspicious'], false], - FC_LINE_SUSPICIOUS_ML, - FC_LINE_NORMAL_ML, - ] as never, - 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never, - 'line-opacity': 0.9, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('FC lines layer add failed:', e); - } - } else { - guardedSetVisibility(map, layerId, 'visible'); - } - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.fcLines, - fcLinks, - hoveredPairMmsiList, - hoveredFleetMmsiList, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - // Fleet circles - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = 'fleet-circles-ml-src'; - const fillSrcId = 'fleet-circles-ml-fill-src'; - const layerId = 'fleet-circles-ml'; - const fillLayerId = 'fleet-circles-ml-fill'; - - // fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인 - // 라인만으로 fleet circle 시각화 충분 - - const remove = () => { - guardedSetVisibility(map, layerId, 'none'); - guardedSetVisibility(map, fillLayerId, 'none'); - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0; - if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) { - remove(); - return; - } - - const circles = fleetCircles || []; - const isHighlightedFleet = (ownerKey: string, vesselMmsis: number[]) => - hoveredFleetOwnerKeyList.includes(ownerKey) || - (hoveredFleetMmsiList.length > 0 && vesselMmsis.some((mmsi) => hoveredFleetMmsiList.includes(mmsi))); - - const fcLine: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: circles.map((c) => { - const ring = circleRingLngLat(c.center, c.radiusNm * 1852); - return { - type: 'Feature', - id: makeFleetCircleFeatureId(c.ownerKey), - geometry: { type: 'LineString', coordinates: ring }, - properties: { - type: 'fleet', - ownerKey: c.ownerKey, - ownerLabel: c.ownerLabel, - count: c.count, - vesselMmsis: c.vesselMmsis, - highlighted: 0, - }, - }; - }), - }; - - const fcFill: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: circles - .filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis)) - .map((c) => ({ - type: 'Feature', - id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`), - geometry: { - type: 'Polygon', - coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)], - }, - properties: { - ownerKey: c.ownerKey, - }, - })), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fcLine); - else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('Fleet circles source setup failed:', e); - return; - } - - try { - const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined; - if (existingFill) existingFill.setData(fcFill); - else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('Fleet circles fill source setup failed:', e); - } - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: 'line', - source: srcId, - layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never, - 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never, - 'line-opacity': 0.85, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('Fleet circles layer add failed:', e); - } - } else { - guardedSetVisibility(map, layerId, 'visible'); - } - - if (!map.getLayer(fillLayerId)) { - try { - map.addLayer( - { - id: fillLayerId, - type: 'fill', - source: fillSrcId, - layout: { visibility: fcFill.features.length > 0 ? 'visible' : 'none' }, - paint: { - 'fill-color': FLEET_FILL_ML_HL, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('Fleet circles fill layer add failed:', e); - } - } else { - guardedSetVisibility(map, fillLayerId, fcFill.features.length > 0 ? 'visible' : 'none'); - } - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.fleetCircles, - fleetCircles, - hoveredFleetOwnerKeyList, - hoveredFleetMmsiList, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - // Pair range - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = 'pair-range-ml-src'; - const layerId = 'pair-range-ml'; - - const remove = () => { - guardedSetVisibility(map, layerId, 'none'); - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - const pairHoverActive = hoveredPairMmsiList.length >= 2; - if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) { - remove(); - return; - } - - const ranges: PairRangeCircle[] = []; - for (const p of pairLinks || []) { - const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; - ranges.push({ - center, - radiusNm: Math.max(0.05, p.distanceNm / 2), - warn: p.warn, - aMmsi: p.aMmsi, - bMmsi: p.bMmsi, - distanceNm: p.distanceNm, - }); - } - if (ranges.length === 0) { - remove(); - return; - } - - const fc: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: ranges.map((c) => { - const ring = circleRingLngLat(c.center, c.radiusNm * 1852); - return { - type: 'Feature', - id: makePairLinkFeatureId(c.aMmsi, c.bMmsi), - geometry: { type: 'LineString', coordinates: ring }, - properties: { - type: 'pair-range', - warn: c.warn, - aMmsi: c.aMmsi, - bMmsi: c.bMmsi, - distanceNm: c.distanceNm, - highlighted: 0, - }, - }; - }), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); - } catch (e) { - console.warn('Pair range source setup failed:', e); - return; - } - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: 'line', - source: srcId, - layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, - paint: { - 'line-color': [ - 'case', - ['==', ['get', 'highlighted'], 1], - ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], - ['boolean', ['get', 'warn'], false], - PAIR_RANGE_WARN_ML, - PAIR_RANGE_NORMAL_ML, - ] as never, - 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never, - 'line-opacity': 0.85, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn('Pair range layer add failed:', e); - } - } else { - guardedSetVisibility(map, layerId, 'visible'); - } - - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); - - // Paint state updates for hover highlights - // eslint-disable-next-line react-hooks/preserve-manual-memoization - const updateGlobeOverlayPaintStates = useCallback(() => { - if (projection !== 'globe' || projectionBusyRef.current) return; - - const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; - - const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); - - const pairHighlightExpr = hoveredPairMmsiList.length >= 2 - ? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList) - : false; - - const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0 - ? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList) - : false; - - const fleetOwnerMatchExpr = - hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false; - const fleetMemberExpr = - hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false; - const fleetHighlightExpr = - hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0 - ? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never) - : false; - - try { - if (map.getLayer('pair-lines-ml')) { - map.setPaintProperty( - 'pair-lines-ml', 'line-color', - ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never, - ); - map.setPaintProperty( - 'pair-lines-ml', 'line-width', - ['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never, - ); - } - } catch { - // ignore - } - - try { - if (map.getLayer('fc-lines-ml')) { - map.setPaintProperty( - 'fc-lines-ml', 'line-color', - ['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never, - ); - map.setPaintProperty( - 'fc-lines-ml', 'line-width', - ['case', fcEndpointHighlightExpr, 2.0, 1.3] as never, - ); - } - } catch { - // ignore - } - - try { - if (map.getLayer('pair-range-ml')) { - map.setPaintProperty( - 'pair-range-ml', 'line-color', - ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never, - ); - map.setPaintProperty( - 'pair-range-ml', 'line-width', - ['case', pairHighlightExpr, 1.6, 1.0] as never, - ); - } - } catch { - // ignore - } - - try { - // fleet-circles-ml-fill 제거됨 (vertex 65535 경고 원인) - if (map.getLayer('fleet-circles-ml')) { - map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never); - map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never); - } - } catch { - // ignore - } - }, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]); - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates); - updateGlobeOverlayPaintStates(); - return () => { - stop(); - }; - }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]); + // FC lines + fleet circles + useGlobeFcFleetOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { + overlays: opts.overlays, + fcLinks: opts.fcLinks, + fleetCircles: opts.fleetCircles, + projection: opts.projection, + mapSyncEpoch: opts.mapSyncEpoch, + hoveredFleetMmsiList: opts.hoveredFleetMmsiList, + hoveredFleetOwnerKeyList: opts.hoveredFleetOwnerKeyList, + hoveredPairMmsiList: opts.hoveredPairMmsiList, + }); } diff --git a/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts b/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts new file mode 100644 index 0000000..41176a2 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts @@ -0,0 +1,284 @@ +import { useCallback, useEffect, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; +import type { PairLink } from '../../../features/legacyDashboard/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { MapProjectionId, PairRangeCircle } from '../types'; +import { + PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL, + PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL, +} from '../constants'; +import { makePairLinkFeatureId } from '../lib/featureIds'; +import { makeMmsiPairHighlightExpr } from '../lib/mlExpressions'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { circleRingLngLat } from '../lib/geometry'; +import { guardedSetVisibility } from '../lib/layerHelpers'; + +/** Globe pair lines + pair range 오버레이 */ +export function useGlobePairOverlay( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + overlays: MapToggleState; + pairLinks: PairLink[] | undefined; + projection: MapProjectionId; + mapSyncEpoch: number; + hoveredPairMmsiList: number[]; + }, +) { + const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts; + + // Pair lines + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'pair-lines-ml-src'; + const layerId = 'pair-lines-ml'; + + const remove = () => { + guardedSetVisibility(map, layerId, 'none'); + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + const pairHoverActive = hoveredPairMmsiList.length >= 2; + if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: (pairLinks || []).map((p) => ({ + type: 'Feature', + id: makePairLinkFeatureId(p.aMmsi, p.bMmsi), + geometry: { type: 'LineString', coordinates: [p.from, p.to] }, + properties: { + type: 'pair', + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + warn: p.warn, + }, + })), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Pair lines source setup failed:', e); + return; + } + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, + paint: { + 'line-color': [ + 'case', + ['==', ['get', 'highlighted'], 1], + ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], + ['boolean', ['get', 'warn'], false], + PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML, + ] as never, + 'line-width': [ + 'case', + ['==', ['get', 'highlighted'], 1], 2.8, + ['boolean', ['get', 'warn'], false], 2.2, + 1.4, + ] as never, + 'line-opacity': 0.9, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Pair lines layer add failed:', e); + } + } else { + guardedSetVisibility(map, layerId, 'visible'); + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); + + // Pair range + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'pair-range-ml-src'; + const layerId = 'pair-range-ml'; + + const remove = () => { + guardedSetVisibility(map, layerId, 'none'); + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + const pairHoverActive = hoveredPairMmsiList.length >= 2; + if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) { + remove(); + return; + } + + const ranges: PairRangeCircle[] = []; + for (const p of pairLinks || []) { + const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; + ranges.push({ + center, + radiusNm: Math.max(0.05, p.distanceNm / 2), + warn: p.warn, + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + }); + } + if (ranges.length === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: ranges.map((c) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: 'Feature', + id: makePairLinkFeatureId(c.aMmsi, c.bMmsi), + geometry: { type: 'LineString', coordinates: ring }, + properties: { + type: 'pair-range', + warn: c.warn, + aMmsi: c.aMmsi, + bMmsi: c.bMmsi, + distanceNm: c.distanceNm, + highlighted: 0, + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Pair range source setup failed:', e); + return; + } + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, + paint: { + 'line-color': [ + 'case', + ['==', ['get', 'highlighted'], 1], + ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], + ['boolean', ['get', 'warn'], false], + PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML, + ] as never, + 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never, + 'line-opacity': 0.85, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Pair range layer add failed:', e); + } + } else { + guardedSetVisibility(map, layerId, 'visible'); + } + + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); + + // Pair paint state updates + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const updatePairPaintStates = useCallback(() => { + if (projection !== 'globe' || projectionBusyRef.current) return; + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + const pairHighlightExpr = hoveredPairMmsiList.length >= 2 + ? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList) + : false; + + try { + if (map.getLayer('pair-lines-ml')) { + map.setPaintProperty( + 'pair-lines-ml', 'line-color', + ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'pair-lines-ml', 'line-width', + ['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer('pair-range-ml')) { + map.setPaintProperty( + 'pair-range-ml', 'line-color', + ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'pair-range-ml', 'line-width', + ['case', pairHighlightExpr, 1.6, 1.0] as never, + ); + } + } catch { + // ignore + } + }, [projection, hoveredPairMmsiList]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const stop = onMapStyleReady(map, updatePairPaintStates); + updatePairPaintStates(); + return () => { + stop(); + }; + }, [mapSyncEpoch, hoveredPairMmsiList, projection, updatePairPaintStates]); +}