From 7bca216c532b040b112d1a796247ba4a77d8f9c0 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Feb 2026 16:38:51 +0900 Subject: [PATCH] =?UTF-8?q?fix(map):=20Globe=20=EB=A0=8C=EB=8D=94=EB=A7=81?= =?UTF-8?q?=20=EC=95=88=EC=A0=95=ED=99=94=20=EB=B0=8F=20=ED=88=B4=ED=8C=81?= =?UTF-8?q?=20=EC=9C=A0=EC=A7=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isStyleLoaded() 가드를 try/catch 패턴으로 교체 (AIS poll setData 중 렌더링 차단 방지) - Globe 툴팁 buildTooltipRef 패턴 도입 (AIS poll 주기 변경 시 사라짐 방지) - Globe 우클릭 컨텍스트 메뉴 isStyleLoaded 가드 제거 - 항적 가상 선박을 IconLayer에서 ScatterplotLayer(원형)로 변경 - useNativeMapLayers isStyleLoaded 가드 제거 (항적 레이어 셋업 스킵 방지) Co-Authored-By: Claude Opus 4.6 --- .../trackReplay/layers/trackLayers.ts | 25 +- apps/web/src/widgets/map3d/Map3D.tsx | 43 +-- .../src/widgets/map3d/hooks/useDeckLayers.ts | 9 +- apps/web/src/widgets/map3d/hooks/useFlyTo.ts | 16 +- .../map3d/hooks/useGlobeFcFleetOverlay.ts | 255 +++++++----------- .../map3d/hooks/useGlobeInteraction.ts | 94 +++++-- .../map3d/hooks/useGlobePairOverlay.ts | 213 +++++++-------- .../widgets/map3d/hooks/useGlobeShipHover.ts | 32 ++- .../widgets/map3d/hooks/useGlobeShipLayers.ts | 157 ++++++----- .../widgets/map3d/hooks/useNativeMapLayers.ts | 4 +- .../map3d/hooks/useProjectionToggle.ts | 103 +++---- .../src/widgets/map3d/hooks/useZonesLayer.ts | 7 +- apps/web/src/widgets/map3d/lib/mapCore.ts | 3 - .../src/widgets/map3d/lib/mlExpressions.ts | 60 +++-- 14 files changed, 526 insertions(+), 495 deletions(-) diff --git a/apps/web/src/features/trackReplay/layers/trackLayers.ts b/apps/web/src/features/trackReplay/layers/trackLayers.ts index 640d4ea..502079d 100644 --- a/apps/web/src/features/trackReplay/layers/trackLayers.ts +++ b/apps/web/src/features/trackReplay/layers/trackLayers.ts @@ -1,7 +1,6 @@ -import { IconLayer, PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers'; +import { PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import type { Layer, PickingInfo } from '@deck.gl/core'; -import { DEPTH_DISABLED_PARAMS, SHIP_ICON_MAPPING } from '../../../shared/lib/map/mapConstants'; -import { getCachedShipIcon } from '../../../widgets/map3d/lib/shipIconCache'; +import { DEPTH_DISABLED_PARAMS } from '../../../shared/lib/map/mapConstants'; import { getShipKindColor } from '../lib/adapters'; import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types'; @@ -139,20 +138,21 @@ export function createDynamicTrackLayers(options: { if (showVirtualShip) { layers.push( - new IconLayer({ + new ScatterplotLayer({ id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_SHIP, data: currentPositions, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', getPosition: (d) => d.position, - getSize: 22, - sizeUnits: 'pixels', - getAngle: (d) => -d.heading, - getColor: (d) => { + getFillColor: (d) => { const base = getShipKindColor(d.shipKindCode); - return [base[0], base[1], base[2], 245] as [number, number, number, number]; + return [base[0], base[1], base[2], 230] as [number, number, number, number]; }, + getLineColor: [255, 255, 255, 200], + getRadius: 5, + radiusUnits: 'pixels', + radiusMinPixels: 4, + radiusMaxPixels: 8, + stroked: true, + lineWidthMinPixels: 1, parameters: DEPTH_DISABLED_PARAMS, pickable: true, onHover: (info: PickingInfo) => { @@ -183,6 +183,7 @@ export function createDynamicTrackLayers(options: { getAlignmentBaseline: 'center', getPixelOffset: [14, 0], fontFamily: 'Malgun Gothic, Arial, sans-serif', + fontSettings: { sdf: true }, outlineColor: [2, 6, 23, 220], outlineWidth: 2, parameters: DEPTH_DISABLED_PARAMS, diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index a052910..4a1bf22 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -238,12 +238,15 @@ export function Map3D({ return out; }, [highlightedMmsiSetForShips, selectedMmsi]); + // Globe: 직접 호버/선택된 선박만 hover overlay에 포함 + // 선단/쌍 멤버는 feature-state(outline 색상)로 하이라이트 → hover overlay 불필요 + // → alarm badge 레이어 가림 방지 const shipHoverOverlaySet = useMemo( () => projection === 'globe' - ? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet) + ? mergeNumberSets(shipHighlightSet, hoveredDeckMmsiSetRef) : shipHighlightSet, - [projection, highlightedMmsiSetCombined, shipHighlightSet], + [projection, shipHighlightSet, hoveredDeckMmsiSetRef], ); const shipOverlayLayerData = useMemo(() => { @@ -598,7 +601,7 @@ export function Map3D({ setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier, onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi, - ensureMercatorOverlay, projectionRef, alarmMmsiMap, + ensureMercatorOverlay, alarmMmsiMap, }, ); @@ -633,22 +636,30 @@ export function Map3D({ e.preventDefault(); if (!onOpenTrackMenu) return; const map = mapRef.current; - if (!map || !map.isStyleLoaded() || projectionBusyRef.current) return; + if (!map || projectionBusyRef.current) return; let mmsi: number | null = null; - if (projectionRef.current === 'globe') { - // Globe: MapLibre 네이티브 레이어에서 쿼리 - const point: [number, number] = [e.offsetX, e.offsetY]; - const shipLayerIds = [ - 'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline', - ].filter((id) => map.getLayer(id)); + // Globe/Mercator 공통: MapLibre 레이어에서 bbox 쿼리 (호버 상태 무관) + let shipLayerIds: string[] = []; + try { + shipLayerIds = projectionRef.current === 'globe' + ? [ + 'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline', + 'ships-globe-alarm-pulse', 'ships-globe-alarm-badge', + ].filter((id) => map.getLayer(id)) + : []; + } catch { /* ignore */ } + if (shipLayerIds.length > 0) { + const tolerance = 8; + const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [ + [e.offsetX - tolerance, e.offsetY - tolerance], + [e.offsetX + tolerance, e.offsetY + tolerance], + ]; let features: maplibregl.MapGeoJSONFeature[] = []; try { - if (shipLayerIds.length > 0) { - features = map.queryRenderedFeatures(point, { layers: shipLayerIds }); - } + features = map.queryRenderedFeatures(bbox, { layers: shipLayerIds }); } catch { /* ignore */ } if (features.length > 0) { @@ -656,8 +667,10 @@ export function Map3D({ const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi); if (Number.isFinite(raw) && raw > 0) mmsi = raw; } - } else { - // Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용 + } + + // Mercator fallback: Deck.gl 호버 상태에서 MMSI 참조 + if (mmsi == null && projectionRef.current !== 'globe') { const hovered = hoveredDeckMmsiRef.current; if (hovered.length > 0) mmsi = hovered[0]; } diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index 3df9e4a..46d952c 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -68,7 +68,6 @@ export function useDeckLayers( onSelectMmsi: (mmsi: number | null) => void; onToggleHighlightMmsi?: (mmsi: number) => void; ensureMercatorOverlay: () => MapboxOverlay | null; - projectionRef: MutableRefObject; alarmMmsiMap?: Map; }, ) { @@ -82,7 +81,7 @@ export function useDeckLayers( setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier, onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi, - ensureMercatorOverlay, projectionRef, alarmMmsiMap, + ensureMercatorOverlay, alarmMmsiMap, } = opts; const legacyTargets = useMemo(() => { @@ -219,12 +218,6 @@ export function useDeckLayers( return; } onSelectMmsi(t.mmsi); - const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; - if (projectionRef.current === 'globe') { - map.flyTo(clickOpts); - } else { - map.easeTo(clickOpts); - } } }, }; diff --git a/apps/web/src/widgets/map3d/hooks/useFlyTo.ts b/apps/web/src/widgets/map3d/hooks/useFlyTo.ts index d0c65cb..f143ef6 100644 --- a/apps/web/src/widgets/map3d/hooks/useFlyTo.ts +++ b/apps/web/src/widgets/map3d/hooks/useFlyTo.ts @@ -15,21 +15,7 @@ export function useFlyTo( fleetFocusZoom: number | undefined; }, ) { - const { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts; - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (!selectedMmsi) return; - const t = shipData.find((x) => x.mmsi === selectedMmsi); - if (!t) return; - const flyOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; - if (projectionRef.current === 'globe') { - map.flyTo(flyOpts); - } else { - map.easeTo(flyOpts); - } - }, [selectedMmsi, shipData]); + const { fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts; useEffect(() => { const map = mapRef.current; diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts b/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts index c814f69..e1799e8 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts @@ -7,7 +7,6 @@ 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'; @@ -22,7 +21,6 @@ import { } from '../lib/mlExpressions'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { circleRingLngLat } from '../lib/geometry'; -import { guardedSetVisibility } from '../lib/layerHelpers'; import { dashifyLine } from '../lib/dashifyLine'; // ── Overlay line width constants ── @@ -35,10 +33,10 @@ const FLEET_LINE_W_HL = 3.0; const BREATHE_AMP = 2.0; const BREATHE_PERIOD_MS = 1200; -/** Globe FC lines + fleet circles 오버레이 */ +/** Globe FC lines + fleet circles 오버레이 (stroke only — fill 제거) */ export function useGlobeFcFleetOverlay( mapRef: MutableRefObject, - projectionBusyRef: MutableRefObject, + _projectionBusyRef: MutableRefObject, reorderGlobeFeatureLayers: () => void, opts: { overlays: MapToggleState; @@ -57,7 +55,12 @@ export function useGlobeFcFleetOverlay( } = opts; const breatheRafRef = useRef(0); - // FC lines + // paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용하기 위해 사용 + const paintStateRef = useRef<() => void>(() => {}); + + // ── FC lines 데이터 effect ── + // projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리 + // 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도 useEffect(() => { const map = mapRef.current; if (!map) return; @@ -65,17 +68,11 @@ export function useGlobeFcFleetOverlay( 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(); + if (projection !== 'globe') { + try { + if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); + } catch { /* ignore */ } return; } @@ -84,7 +81,9 @@ export function useGlobeFcFleetOverlay( segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); } if (segs.length === 0) { - remove(); + try { + if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); + } catch { /* ignore */ } return; } @@ -108,12 +107,12 @@ export function useGlobeFcFleetOverlay( 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; + } catch { + return; // 다음 poll에서 재시도 } - if (!map.getLayer(layerId)) { + const needReorder = !map.getLayer(layerId); + if (needReorder) { try { map.addLayer( { @@ -122,74 +121,46 @@ export function useGlobeFcFleetOverlay( 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], FC_LINE_W_HL, FC_LINE_W_NORMAL] as never, - 'line-opacity': 0.9, + 'line-color': FC_LINE_NORMAL_ML, + 'line-width': FC_LINE_W_NORMAL, + 'line-opacity': 0, }, - } as unknown as LayerSpecification, + } as unknown as LayerSpecification, undefined, ); - } catch (e) { - console.warn('FC lines layer add failed:', e); + } catch { + return; // 다음 poll에서 재시도 } - } else { - guardedSetVisibility(map, layerId, 'visible'); + reorderGlobeFeatureLayers(); } - reorderGlobeFeatureLayers(); + paintStateRef.current(); kickRepaint(map); }; + // 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장 const stop = onMapStyleReady(map, ensure); ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.fcLines, - fcLinks, - hoveredPairMmsiList, - hoveredFleetMmsiList, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); + return () => { stop(); }; + }, [projection, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); - // Fleet circles + // ── Fleet circles 데이터 effect (stroke only — fill 제거) ── 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(); + if (projection !== 'globe' || (fleetCircles?.length ?? 0) === 0) { + try { + if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); + } catch { /* ignore */ } 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', @@ -205,47 +176,21 @@ export function useGlobeFcFleetOverlay( 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; + } catch { + return; // 다음 poll에서 재시도 } - 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)) { + const needReorder = !map.getLayer(layerId); + if (needReorder) { try { map.addLayer( { @@ -254,66 +199,34 @@ export function useGlobeFcFleetOverlay( 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], FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never, - 'line-opacity': 0.85, + 'line-color': FLEET_LINE_ML, + 'line-width': FLEET_LINE_W_NORMAL, + 'line-opacity': 0, }, } as unknown as LayerSpecification, undefined, ); - } catch (e) { - console.warn('Fleet circles layer add failed:', e); + } catch { + return; // 다음 poll에서 재시도 } - } else { - guardedSetVisibility(map, layerId, 'visible'); + reorderGlobeFeatureLayers(); } - 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(); + paintStateRef.current(); kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.fleetCircles, - fleetCircles, - hoveredFleetOwnerKeyList, - hoveredFleetMmsiList, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); + return () => { stop(); }; + }, [projection, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]); - // FC + Fleet paint state updates + // ── FC + Fleet paint state update (가시성 + 하이라이트 통합) ── // eslint-disable-next-line react-hooks/preserve-manual-memoization const updateFcFleetPaintStates = useCallback(() => { - if (projection !== 'globe' || projectionBusyRef.current) return; + if (projection !== 'globe') return; const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; + if (!map) return; const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); @@ -330,16 +243,29 @@ export function useGlobeFcFleetOverlay( ? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never) : false; + // ── FC lines ── + const pairActive = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0; + const fcVisible = overlays.fcLines || pairActive; + // ── Fleet circles ── + const fleetActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0; + const fleetVisible = overlays.fleetCircles || fleetActive; 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, FC_LINE_W_HL, FC_LINE_W_NORMAL] as never, - ); + map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0); + if (fcVisible) { + map.setPaintProperty( + 'fc-lines-ml', 'line-color', + fcEndpointHighlightExpr !== false + ? ['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 + : ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'fc-lines-ml', 'line-width', + fcEndpointHighlightExpr !== false + ? ['case', fcEndpointHighlightExpr, FC_LINE_W_HL, FC_LINE_W_NORMAL] as never + : FC_LINE_W_NORMAL, + ); + } } } catch { // ignore @@ -347,25 +273,38 @@ export function useGlobeFcFleetOverlay( 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, FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never); + map.setPaintProperty('fleet-circles-ml', 'line-opacity', fleetVisible ? 0.85 : 0); + if (fleetVisible) { + map.setPaintProperty('fleet-circles-ml', 'line-color', + fleetHighlightExpr !== false + ? ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never + : FLEET_LINE_ML, + ); + map.setPaintProperty('fleet-circles-ml', 'line-width', + fleetHighlightExpr !== false + ? ['case', fleetHighlightExpr, FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never + : FLEET_LINE_W_NORMAL, + ); + } } } catch { // ignore } - }, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]); + kickRepaint(map); + }, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, overlays.fcLines, overlays.fleetCircles]); + + // paintStateRef를 최신 콜백으로 유지 useEffect(() => { - const map = mapRef.current; - if (!map) return; - const stop = onMapStyleReady(map, updateFcFleetPaintStates); - updateFcFleetPaintStates(); - return () => { - stop(); - }; - }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateFcFleetPaintStates]); + paintStateRef.current = updateFcFleetPaintStates; + }, [updateFcFleetPaintStates]); - // Breathing animation for highlighted fc/fleet overlays + // paint state 동기화 + useEffect(() => { + updateFcFleetPaintStates(); + }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, overlays.fcLines, overlays.fleetCircles, updateFcFleetPaintStates, fcLinks, fleetCircles]); + + // ── Breathing animation ── useEffect(() => { const map = mapRef.current; const hasFleetHover = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0; diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts index c31a3ab..ed718a4 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts @@ -14,6 +14,10 @@ import { } from '../lib/tooltips'; import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils'; +// setData() 후 타일 재빌드 중 queryRenderedFeatures가 일시적으로 빈 배열을 반환. +// 즉시 clear 대신 딜레이를 두어 깜박임 방지. +const TOOLTIP_CLEAR_DELAY_MS = 150; + export function useGlobeInteraction( mapRef: MutableRefObject, projectionBusyRef: MutableRefObject, @@ -57,7 +61,7 @@ export function useGlobeInteraction( // eslint-disable-next-line react-hooks/preserve-manual-memoization const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => { const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; + if (!map) return; if (!mapTooltipRef.current) { mapTooltipRef.current = new maplibregl.Popup({ closeButton: false, @@ -74,6 +78,12 @@ export function useGlobeInteraction( mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map); }, []); + // buildGlobeFeatureTooltip을 ref로 관리 — legacyHits/shipByMmsi가 매 AIS poll마다 변경되므로 + // useCallback 의존성으로 넣으면 effect가 재실행되어 cleanup에서 tooltip이 제거됨 + // ref로 관리하면 effect 재실행 없이 항상 최신 함수 참조 + type TooltipFeature = { properties?: Record | null; layer?: { id?: string } } | null | undefined; + const buildTooltipRef = useRef<(feature: TooltipFeature) => { html: string } | null>(() => null); + const buildGlobeFeatureTooltip = useCallback( (feature: { properties?: Record | null; layer?: { id?: string } } | null | undefined) => { if (!feature) return null; @@ -136,17 +146,14 @@ export function useGlobeInteraction( [legacyHits, shipByMmsi], ); + useEffect(() => { + buildTooltipRef.current = buildGlobeFeatureTooltip; + }, [buildGlobeFeatureTooltip]); + useEffect(() => { const map = mapRef.current; if (!map) return; - const clearDeckGlobeHoverState = () => { - clearDeckHoverMmsi(); - clearDeckHoverPairs(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - clearMapFleetHoverState(); - }; - const resetGlobeHoverStates = () => { clearDeckHoverMmsi(); clearDeckHoverPairs(); @@ -155,36 +162,52 @@ export function useGlobeInteraction( }; const normalizeMmsiList = (value: unknown): number[] => { - if (!Array.isArray(value)) return []; + let arr = value; + // MapLibre는 GeoJSON 배열 프로퍼티를 JSON 문자열로 반환할 수 있음 + if (typeof arr === 'string') { + try { arr = JSON.parse(arr); } catch { return []; } + } + if (!Array.isArray(arr)) return []; const out: number[] = []; - for (const n of value) { + for (const n of arr) { const m = toIntMmsi(n); if (m != null) out.push(m); } return out; }; + // 지연 clear 타이머 — setData() 타일 재빌드 중 일시적 빈 결과를 무시 + let clearTimer: ReturnType | null = null; + + const scheduleClear = () => { + if (clearTimer) return; // 이미 예약됨 + clearTimer = setTimeout(() => { + clearTimer = null; + resetGlobeHoverStates(); + clearGlobeTooltip(); + }, TOOLTIP_CLEAR_DELAY_MS); + }; + + const cancelClear = () => { + if (clearTimer) { clearTimeout(clearTimer); clearTimer = null; } + }; + const onMouseMove = (e: maplibregl.MapMouseEvent) => { if (projection !== 'globe') { + cancelClear(); clearGlobeTooltip(); resetGlobeHoverStates(); return; } if (projectionBusyRef.current) { - resetGlobeHoverStates(); - clearGlobeTooltip(); - return; - } - if (!map.isStyleLoaded()) { - clearDeckGlobeHoverState(); - clearGlobeTooltip(); - return; + return; // 전환 중에는 기존 상태 유지 (clear하면 깜박임) } let candidateLayerIds: string[] = []; try { candidateLayerIds = [ 'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline', + 'ships-globe-alarm-pulse', 'ships-globe-alarm-badge', 'pair-lines-ml', 'fc-lines-ml', 'fleet-circles-ml', 'pair-range-ml', @@ -195,14 +218,18 @@ export function useGlobeInteraction( } if (candidateLayerIds.length === 0) { - resetGlobeHoverStates(); - clearGlobeTooltip(); + scheduleClear(); return; } let rendered: Array<{ properties?: Record | null; layer?: { id?: string } }> = []; try { - rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{ + const tolerance = 10; + const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [ + [e.point.x - tolerance, e.point.y - tolerance], + [e.point.x + tolerance, e.point.y + tolerance], + ]; + rendered = map.queryRenderedFeatures(bbox, { layers: candidateLayerIds }) as unknown as Array<{ properties?: Record | null; layer?: { id?: string }; }>; @@ -212,6 +239,7 @@ export function useGlobeInteraction( const priority = [ 'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline', + 'ships-globe-alarm-pulse', 'ships-globe-alarm-badge', 'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml', 'fleet-circles-ml', 'zones-fill', 'zones-line', 'zones-label', @@ -222,18 +250,23 @@ export function useGlobeInteraction( | undefined; if (!first) { - resetGlobeHoverStates(); - clearGlobeTooltip(); + // 피처 없음 — 타일 재빌드 중 일시적 누락일 수 있으므로 지연 clear + scheduleClear(); return; } + // 피처 발견 — 지연 clear 취소 + cancelClear(); + const layerId = first.layer?.id; const props = first.properties || {}; const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-lite' || layerId === 'ships-globe-halo' || - layerId === 'ships-globe-outline'; + layerId === 'ships-globe-outline' || + layerId === 'ships-globe-alarm-pulse' || + layerId === 'ships-globe-alarm-badge'; const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml'; const isFcLayer = layerId === 'fc-lines-ml'; const isFleetLayer = layerId === 'fleet-circles-ml'; @@ -277,7 +310,7 @@ export function useGlobeInteraction( resetGlobeHoverStates(); } - const tooltip = buildGlobeFeatureTooltip(first); + const tooltip = buildTooltipRef.current(first); if (!tooltip) { if (!isZoneLayer) { resetGlobeHoverStates(); @@ -295,6 +328,7 @@ export function useGlobeInteraction( }; const onMouseOut = () => { + cancelClear(); resetGlobeHoverStates(); clearGlobeTooltip(); }; @@ -303,13 +337,14 @@ export function useGlobeInteraction( map.on('mouseout', onMouseOut); return () => { + cancelClear(); map.off('mousemove', onMouseMove); map.off('mouseout', onMouseOut); - clearGlobeTooltip(); + // cleanup에서 tooltip 제거하지 않음 — 의존성 변경(AIS poll 등)으로 effect가 + // 재실행될 때 tooltip이 사라지는 문제 방지. tooltip은 mousemove/mouseout 이벤트가 처리. }; }, [ projection, - buildGlobeFeatureTooltip, clearGlobeTooltip, clearMapFleetHoverState, clearDeckHoverPairs, @@ -319,4 +354,9 @@ export function useGlobeInteraction( setMapFleetHoverState, setGlobeTooltip, ]); + + // 컴포넌트 unmount 시에만 tooltip 제거 + useEffect(() => { + return () => { clearGlobeTooltip(); }; + }, [clearGlobeTooltip]); } diff --git a/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts b/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts index c8ec41b..d2fa944 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts @@ -14,7 +14,6 @@ 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'; // ── Overlay line width constants ── const PAIR_LINE_W_NORMAL = 2.5; @@ -30,7 +29,7 @@ const BREATHE_PERIOD_MS = 1200; /** Globe pair lines + pair range 오버레이 */ export function useGlobePairOverlay( mapRef: MutableRefObject, - projectionBusyRef: MutableRefObject, + _projectionBusyRef: MutableRefObject, reorderGlobeFeatureLayers: () => void, opts: { overlays: MapToggleState; @@ -43,7 +42,12 @@ export function useGlobePairOverlay( const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts; const breatheRafRef = useRef(0); - // Pair lines + // paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용 + const paintStateRef = useRef<() => void>(() => {}); + + // ── Pair lines 데이터 effect ── + // projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리 + // 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도 useEffect(() => { const map = mapRef.current; if (!map) return; @@ -51,16 +55,17 @@ export function useGlobePairOverlay( 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(); + if (projection !== 'globe') { + try { + if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); + } catch { /* ignore */ } + return; + } + if ((pairLinks?.length ?? 0) === 0) { + try { + if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); + } catch { /* ignore */ } return; } @@ -84,12 +89,12 @@ export function useGlobePairOverlay( 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; + } catch { + return; // 다음 poll에서 재시도 } - if (!map.getLayer(layerId)) { + const needReorder = !map.getLayer(layerId); + if (needReorder) { try { map.addLayer( { @@ -98,44 +103,31 @@ export function useGlobePairOverlay( 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], PAIR_LINE_W_HL, - ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, - PAIR_LINE_W_NORMAL, - ] as never, - 'line-opacity': 0.9, + 'line-color': PAIR_LINE_NORMAL_ML, + 'line-width': PAIR_LINE_W_NORMAL, + 'line-opacity': 0, }, - } as unknown as LayerSpecification, + } as unknown as LayerSpecification, undefined, ); - } catch (e) { - console.warn('Pair lines layer add failed:', e); + } catch { + return; // 다음 poll에서 재시도 } - } else { - guardedSetVisibility(map, layerId, 'visible'); + reorderGlobeFeatureLayers(); } - reorderGlobeFeatureLayers(); + // 즉시 올바른 paint state 적용 — 타이밍 간극으로 opacity:0 고착 방지 + paintStateRef.current(); kickRepaint(map); }; + // 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장 const stop = onMapStyleReady(map, ensure); ensure(); - return () => { - stop(); - }; - }, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); + return () => { stop(); }; + }, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); - // Pair range + // ── Pair range 데이터 effect ── useEffect(() => { const map = mapRef.current; if (!map) return; @@ -143,16 +135,11 @@ export function useGlobePairOverlay( 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(); + if (projection !== 'globe') { + try { + if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); + } catch { /* ignore */ } return; } @@ -169,7 +156,9 @@ export function useGlobePairOverlay( }); } if (ranges.length === 0) { - remove(); + try { + if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); + } catch { /* ignore */ } return; } @@ -187,7 +176,6 @@ export function useGlobePairOverlay( aMmsi: c.aMmsi, bMmsi: c.bMmsi, distanceNm: c.distanceNm, - highlighted: 0, }, }; }), @@ -197,12 +185,12 @@ export function useGlobePairOverlay( 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; + } catch { + return; // 다음 poll에서 재시도 } - if (!map.getLayer(layerId)) { + const needReorder = !map.getLayer(layerId); + if (needReorder) { try { map.addLayer( { @@ -215,90 +203,105 @@ export function useGlobePairOverlay( 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], PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never, - 'line-opacity': 0.85, + 'line-color': PAIR_RANGE_NORMAL_ML, + 'line-width': PAIR_RANGE_W_NORMAL, + 'line-opacity': 0, }, } as unknown as LayerSpecification, undefined, ); - } catch (e) { - console.warn('Pair range layer add failed:', e); + } catch { + return; // 다음 poll에서 재시도 } - } else { - guardedSetVisibility(map, layerId, 'visible'); + reorderGlobeFeatureLayers(); } + paintStateRef.current(); kickRepaint(map); }; + // 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장 const stop = onMapStyleReady(map, ensure); ensure(); - return () => { - stop(); - }; - }, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); + return () => { stop(); }; + }, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); - // Pair paint state updates + breathing animation + // ── Pair paint state update (가시성 + 하이라이트 통합) ── + // setLayoutProperty(visibility) 대신 setPaintProperty(line-opacity)로 가시성 제어 + // → style._changed 미트리거 → alarm badge symbol placement 재계산 방지 // eslint-disable-next-line react-hooks/preserve-manual-memoization const updatePairPaintStates = useCallback(() => { - if (projection !== 'globe' || projectionBusyRef.current) return; + if (projection !== 'globe') return; const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; + if (!map) return; - const pairHighlightExpr = hoveredPairMmsiList.length >= 2 + const active = hoveredPairMmsiList.length >= 2; + const pairHighlightExpr = active ? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList) : false; + // ── Pair lines: 가시성 + 하이라이트 ── + const pairLinesVisible = overlays.pairLines || active; 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, PAIR_LINE_W_HL, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never, - ); + map.setPaintProperty('pair-lines-ml', 'line-opacity', pairLinesVisible ? 0.9 : 0); + if (pairLinesVisible) { + map.setPaintProperty( + 'pair-lines-ml', 'line-color', + pairHighlightExpr !== false + ? ['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 + : ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'pair-lines-ml', 'line-width', + pairHighlightExpr !== false + ? ['case', pairHighlightExpr, PAIR_LINE_W_HL, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never + : ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never, + ); + } } } catch { // ignore } + // ── Pair range: 가시성 + 하이라이트 ── + const pairRangeVisible = overlays.pairRange || active; 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, PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never, - ); + map.setPaintProperty('pair-range-ml', 'line-opacity', pairRangeVisible ? 0.85 : 0); + if (pairRangeVisible) { + map.setPaintProperty( + 'pair-range-ml', 'line-color', + pairHighlightExpr !== false + ? ['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 + : ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'pair-range-ml', 'line-width', + pairHighlightExpr !== false + ? ['case', pairHighlightExpr, PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never + : PAIR_RANGE_W_NORMAL, + ); + } } } catch { // ignore } - }, [projection, hoveredPairMmsiList]); + kickRepaint(map); + }, [projection, hoveredPairMmsiList, overlays.pairLines, overlays.pairRange]); + + // paintStateRef를 최신 콜백으로 유지 — useEffect 내에서만 ref 업데이트 (react-hooks/refs 준수) useEffect(() => { - const map = mapRef.current; - if (!map) return; - const stop = onMapStyleReady(map, updatePairPaintStates); - updatePairPaintStates(); - return () => { - stop(); - }; - }, [mapSyncEpoch, hoveredPairMmsiList, projection, updatePairPaintStates]); + paintStateRef.current = updatePairPaintStates; + }, [updatePairPaintStates]); - // Breathing animation for highlighted pair overlays + // paint state 동기화: 호버/토글/epoch 변경 시 즉시 반영 + useEffect(() => { + updatePairPaintStates(); + }, [mapSyncEpoch, hoveredPairMmsiList, projection, overlays.pairLines, overlays.pairRange, updatePairPaintStates, pairLinks]); + + // ── Breathing animation for highlighted pair overlays ── useEffect(() => { const map = mapRef.current; if (!map || hoveredPairMmsiList.length < 2 || projection !== 'globe') { diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts index 0c4c195..52f629e 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts @@ -9,7 +9,7 @@ import { DEG2RAD, } from '../constants'; import { isFiniteNumber } from '../lib/setUtils'; -import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions'; +import { GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR } from '../lib/mlExpressions'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { getDisplayHeading, getGlobeBaseShipColor } from '../lib/shipUtils'; import { ensureFallbackShipImage } from '../lib/globeShipIcon'; @@ -19,7 +19,7 @@ import { guardedSetVisibility } from '../lib/layerHelpers'; /** Globe 호버 오버레이 + 클릭 선택 */ export function useGlobeShipHover( mapRef: MutableRefObject, - projectionBusyRef: MutableRefObject, + _projectionBusyRef: MutableRefObject, reorderGlobeFeatureLayers: () => void, opts: { projection: MapProjectionId; @@ -62,9 +62,6 @@ export function useGlobeShipHover( }; const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { hideHover(); return; @@ -74,7 +71,9 @@ export function useGlobeShipHover( epochRef.current = mapSyncEpoch; } - ensureFallbackShipImage(map, imgId); + try { + ensureFallbackShipImage(map, imgId); + } catch { /* ignore */ } if (!map.hasImage(imgId)) { return; } @@ -166,7 +165,7 @@ export function useGlobeShipHover( ] as never, }, paint: { - 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR, 'circle-color': [ 'case', ['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)', @@ -181,7 +180,7 @@ export function useGlobeShipHover( console.warn('Ship hover halo layer add failed:', e); } } else { - map.setLayoutProperty(haloId, 'visibility', 'visible'); + guardedSetVisibility(map, haloId, 'visible'); } if (!map.getLayer(outlineId)) { @@ -192,7 +191,7 @@ export function useGlobeShipHover( type: 'circle', source: srcId, paint: { - 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR, 'circle-color': 'rgba(0,0,0,0)', 'circle-stroke-color': [ 'case', @@ -222,7 +221,7 @@ export function useGlobeShipHover( console.warn('Ship hover outline layer add failed:', e); } } else { - map.setLayoutProperty(outlineId, 'visibility', 'visible'); + guardedSetVisibility(map, outlineId, 'visible'); } if (!map.getLayer(symbolId)) { @@ -267,7 +266,7 @@ export function useGlobeShipHover( console.warn('Ship hover symbol layer add failed:', e); } } else { - map.setLayoutProperty(symbolId, 'visibility', 'visible'); + guardedSetVisibility(map, symbolId, 'visible'); } if (needReorder) { @@ -301,15 +300,20 @@ export function useGlobeShipHover( const symbolLiteId = 'ships-globe-lite'; const haloId = 'ships-globe-halo'; const outlineId = 'ships-globe-outline'; - const clickedRadiusDeg2 = Math.pow(0.08, 2); + const clickedRadiusDeg2 = Math.pow(0.12, 2); const onClick = (e: maplibregl.MapMouseEvent) => { try { - const layerIds = [symbolId, symbolLiteId, haloId, outlineId].filter((id) => map.getLayer(id)); + const layerIds = [symbolId, symbolLiteId, haloId, outlineId, 'ships-globe-alarm-pulse', 'ships-globe-alarm-badge'].filter((id) => map.getLayer(id)); let feats: unknown[] = []; if (layerIds.length > 0) { try { - feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[]; + const tolerance = 10; + const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [ + [e.point.x - tolerance, e.point.y - tolerance], + [e.point.x + tolerance, e.point.y + tolerance], + ]; + feats = map.queryRenderedFeatures(bbox, { layers: layerIds }) as unknown[]; } catch { feats = []; } diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts index d5b82c4..970642c 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts @@ -60,6 +60,8 @@ export function useGlobeShipLayers( const epochRef = useRef(-1); const breatheRafRef = useRef(0); + const prevGeoJsonRef = useRef(null); + const prevAlarmGeoJsonRef = useRef(null); // Globe GeoJSON을 projection과 무관하게 항상 사전 계산 // Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱 @@ -83,16 +85,13 @@ export function useGlobeShipLayers( 50, 420, ); const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); - const selected = t.mmsi === selectedMmsi; - const highlighted = isBaseHighlightedMmsi(t.mmsi); - const selectedScale = selected ? 1.08 : 1; - const highlightScale = highlighted ? 1.06 : 1; - const iconScale = selected ? selectedScale : highlightScale; - const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); - const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); - const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8); - const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6); - const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0); + // 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리 + // hover overlay 레이어가 확대 + z-priority를 담당 + const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3); + const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45); + const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8); + const iconSize14 = clampNumber(0.85 * sizeScale, 0.45, 2.6); + const iconSize18 = clampNumber(2.5 * sizeScale, 1.0, 6.0); return { type: 'Feature' as const, ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), @@ -109,14 +108,12 @@ export function useGlobeShipLayers( legacy: legacy?.shipCode || null, sog: isFiniteNumber(t.sog) ? t.sog : null, }), - iconSize3: iconSize3 * iconScale, - iconSize7: iconSize7 * iconScale, - iconSize10: iconSize10 * iconScale, - iconSize14: iconSize14 * iconScale, - iconSize18: iconSize18 * iconScale, + iconSize3, + iconSize7, + iconSize10, + iconSize14, + iconSize18, sizeScale, - selected: selected ? 1 : 0, - highlighted: highlighted ? 1 : 0, permitted: legacy ? 1 : 0, code: legacy?.shipCode || '', alarmed: alarmKind ? 1 : 0, @@ -127,7 +124,7 @@ export function useGlobeShipLayers( }; }), }; - }, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi, alarmMmsiMap]); + }, [shipData, legacyHits, alarmMmsiMap]); // Alarm-only GeoJSON — separate source to avoid badge symbol re-placement // when the main ship source updates (position polling) @@ -141,23 +138,20 @@ export function useGlobeShipLayers( .filter((t) => alarmMmsiMap.has(t.mmsi)) .map((t) => { const alarmKind = alarmMmsiMap.get(t.mmsi)!; - const selected = t.mmsi === selectedMmsi; - const highlighted = isBaseHighlightedMmsi(t.mmsi); return { type: 'Feature' as const, + ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] }, properties: { mmsi: t.mmsi, alarmed: 1, alarmBadgeLabel: ALARM_BADGE[alarmKind].label, alarmBadgeColor: ALARM_BADGE[alarmKind].color, - selected: selected ? 1 : 0, - highlighted: highlighted ? 1 : 0, }, }; }), }; - }, [shipData, alarmMmsiMap, selectedMmsi, isBaseHighlightedMmsi]); + }, [shipData, alarmMmsiMap]); // Ships in globe mode useEffect(() => { @@ -235,12 +229,18 @@ export function useGlobeShipLayers( } // 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨) + // 참조 동일성 기반 setData 스킵 — 위치 변경 없는 epoch/설정 변경 시 재전송 방지 const geojson = globeShipGeoJson; + const geoJsonChanged = geojson !== prevGeoJsonRef.current; try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(geojson); - else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification); + if (existing) { + if (geoJsonChanged) existing.setData(geojson); + } else { + map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification); + } + prevGeoJsonRef.current = geojson; } catch (e) { console.warn('Ship source setup failed:', e); return; @@ -249,27 +249,32 @@ export function useGlobeShipLayers( // Alarm source — isolated from main source for stable badge rendering try { const existingAlarm = map.getSource(alarmSrcId) as GeoJSONSource | undefined; - if (existingAlarm) existingAlarm.setData(alarmGeoJson); - else map.addSource(alarmSrcId, { type: 'geojson', data: alarmGeoJson } as GeoJSONSourceSpecification); + const alarmChanged = alarmGeoJson !== prevAlarmGeoJsonRef.current; + if (existingAlarm) { + if (alarmChanged) existingAlarm.setData(alarmGeoJson); + } else { + map.addSource(alarmSrcId, { type: 'geojson', data: alarmGeoJson } as GeoJSONSourceSpecification); + } + prevAlarmGeoJsonRef.current = alarmGeoJson; } catch (e) { console.warn('Alarm source setup failed:', e); } const before = undefined; + let needReorder = false; const priorityFilter = [ 'any', ['==', ['to-number', ['get', 'permitted'], 0], 1], - ['==', ['to-number', ['get', 'selected'], 0], 1], - ['==', ['to-number', ['get', 'highlighted'], 0], 1], + ['==', ['to-number', ['get', 'alarmed'], 0], 1], ] as unknown as unknown[]; const nonPriorityFilter = [ 'all', ['==', ['to-number', ['get', 'permitted'], 0], 0], - ['==', ['to-number', ['get', 'selected'], 0], 0], - ['==', ['to-number', ['get', 'highlighted'], 0], 0], + ['==', ['to-number', ['get', 'alarmed'], 0], 0], ] as unknown as unknown[]; if (!map.getLayer(haloId)) { + needReorder = true; try { map.addLayer( { @@ -280,12 +285,8 @@ export function useGlobeShipLayers( visibility, 'circle-sort-key': [ 'case', - ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120, - ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115, ['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112, ['==', ['get', 'permitted'], 1], 110, - ['==', ['get', 'selected'], 1], 60, - ['==', ['get', 'highlighted'], 1], 55, ['==', ['get', 'alarmed'], 1], 22, 20, ] as never, @@ -295,8 +296,8 @@ export function useGlobeShipLayers( 'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, 'circle-opacity': [ 'case', - ['==', ['get', 'selected'], 1], 0.38, - ['==', ['get', 'highlighted'], 1], 0.34, + ['==', ['feature-state', 'selected'], 1], 0.38, + ['==', ['feature-state', 'highlighted'], 1], 0.34, 0.16, ] as never, }, @@ -309,6 +310,7 @@ export function useGlobeShipLayers( } if (!map.getLayer(outlineId)) { + needReorder = true; try { map.addLayer( { @@ -320,15 +322,15 @@ export function useGlobeShipLayers( 'circle-color': 'rgba(0,0,0,0)', 'circle-stroke-color': [ 'case', - ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', - ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', + ['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)', + ['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)', ['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED, GLOBE_OUTLINE_OTHER, ] as never, 'circle-stroke-width': [ 'case', - ['==', ['get', 'selected'], 1], 3.4, - ['==', ['get', 'highlighted'], 1], 2.7, + ['==', ['feature-state', 'selected'], 1], 3.4, + ['==', ['feature-state', 'highlighted'], 1], 2.7, ['==', ['get', 'permitted'], 1], 1.8, 0.7, ] as never, @@ -338,12 +340,8 @@ export function useGlobeShipLayers( visibility, 'circle-sort-key': [ 'case', - ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130, - ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125, ['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122, ['==', ['get', 'permitted'], 1], 120, - ['==', ['get', 'selected'], 1], 70, - ['==', ['get', 'highlighted'], 1], 65, ['==', ['get', 'alarmed'], 1], 32, 30, ] as never, @@ -359,6 +357,7 @@ export function useGlobeShipLayers( // Alarm pulse circle (above outline, below ship icons) // Uses separate alarm source for stable rendering if (!map.getLayer(pulseId)) { + needReorder = true; try { map.addLayer( { @@ -382,6 +381,7 @@ export function useGlobeShipLayers( } if (!map.getLayer(symbolLiteId)) { + needReorder = true; try { map.addLayer( { @@ -451,6 +451,7 @@ export function useGlobeShipLayers( } if (!map.getLayer(symbolId)) { + needReorder = true; try { map.addLayer( { @@ -462,12 +463,8 @@ export function useGlobeShipLayers( visibility, 'symbol-sort-key': [ 'case', - ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140, - ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135, ['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 132, ['==', ['get', 'permitted'], 1], 130, - ['==', ['get', 'selected'], 1], 80, - ['==', ['get', 'highlighted'], 1], 75, ['==', ['get', 'alarmed'], 1], 47, 45, ] as never, @@ -500,8 +497,8 @@ export function useGlobeShipLayers( 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, 'icon-opacity': [ 'case', - ['==', ['get', 'selected'], 1], 1, - ['==', ['get', 'highlighted'], 1], 0.95, + ['==', ['feature-state', 'selected'], 1], 1, + ['==', ['feature-state', 'highlighted'], 1], 0.95, ['==', ['get', 'permitted'], 1], 0.93, 0.9, ] as never, @@ -517,15 +514,11 @@ export function useGlobeShipLayers( const labelFilter = [ 'all', ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], - [ - 'any', - ['==', ['get', 'permitted'], 1], - ['==', ['get', 'selected'], 1], - ['==', ['get', 'highlighted'], 1], - ], + ['==', ['get', 'permitted'], 1], ] as unknown as unknown[]; if (!map.getLayer(labelId)) { + needReorder = true; try { map.addLayer( { @@ -549,8 +542,8 @@ export function useGlobeShipLayers( paint: { 'text-color': [ 'case', - ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', - ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', + ['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)', + ['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)', 'rgba(226,232,240,0.92)', ] as never, 'text-halo-color': 'rgba(2,6,23,0.85)', @@ -568,6 +561,7 @@ export function useGlobeShipLayers( // Alarm badge symbol (above labels) // Uses separate alarm source for stable rendering if (!map.getLayer(badgeId)) { + needReorder = true; try { map.addLayer( { @@ -600,7 +594,9 @@ export function useGlobeShipLayers( // 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용) onGlobeShipsReady?.(true); - if (projection === 'globe') { + // needReorder: 새 레이어가 생성된 경우에만 reorder 호출 + // 매 AIS poll마다 28개 moveLayer → style._changed 방지 + if (projection === 'globe' && needReorder) { reorderGlobeFeatureLayers(); } kickRepaint(map); @@ -616,14 +612,47 @@ export function useGlobeShipLayers( overlays.shipLabels, globeShipGeoJson, alarmGeoJson, - selectedMmsi, - isBaseHighlightedMmsi, mapSyncEpoch, reorderGlobeFeatureLayers, onGlobeShipsReady, - alarmMmsiMap, ]); + // Feature-state로 상호작용 상태(selected/highlighted) 즉시 반영 — setData 없이 + useEffect(() => { + const map = mapRef.current; + if (!map || projection !== 'globe' || projectionBusyRef.current) return; + if (!map.isStyleLoaded() || !map.getSource('ships-globe-src')) return; + + const raf = requestAnimationFrame(() => { + if (!map.isStyleLoaded()) return; + const src = 'ships-globe-src'; + const alarmSrc = 'ships-globe-alarm-src'; + for (const t of shipData) { + if (!isFiniteNumber(t.mmsi)) continue; + const id = Math.trunc(t.mmsi); + const s = t.mmsi === selectedMmsi ? 1 : 0; + const h = isBaseHighlightedMmsi(t.mmsi) ? 1 : 0; + try { + map.setFeatureState({ source: src, id }, { selected: s, highlighted: h }); + } catch { /* ignore */ } + } + if (map.getSource(alarmSrc) && alarmMmsiMap) { + for (const t of shipData) { + if (!alarmMmsiMap.has(t.mmsi)) continue; + const id = Math.trunc(t.mmsi); + try { + map.setFeatureState( + { source: alarmSrc, id }, + { selected: t.mmsi === selectedMmsi ? 1 : 0, highlighted: isBaseHighlightedMmsi(t.mmsi) ? 1 : 0 }, + ); + } catch { /* ignore */ } + } + } + kickRepaint(map); + }); + return () => cancelAnimationFrame(raf); + }, [projection, selectedMmsi, isBaseHighlightedMmsi, shipData, alarmMmsiMap]); + // Alarm pulse breathing animation (rAF) useEffect(() => { const map = mapRef.current; @@ -645,7 +674,7 @@ export function useGlobeShipLayers( if (map.getLayer('ships-globe-alarm-pulse')) { map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [ 'case', - ['any', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'selected'], 1]], + ['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]], hoverR, normalR, ] as never); diff --git a/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts b/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts index c6096b9..7a7bd38 100644 --- a/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts @@ -108,7 +108,9 @@ export function useNativeMapLayers( // 2. 데이터가 있는 source가 하나도 없으면 종료 const hasData = cfg.sources.some((s) => s.data != null); if (!hasData) return; - if (!map.isStyleLoaded()) return; + // isStyleLoaded() 가드 제거 — AIS poll의 setData()로 인해 + // 일시적으로 false가 되어 데이터 업데이트가 스킵되는 문제 방지. + // 실패 시 try/catch가 처리하고, 다음 deps 변경 시 자연 재시도. try { // 3. Source 생성/업데이트 diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index 14c03a8..7040fcf 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -80,62 +80,67 @@ export function useProjectionToggle( }; }, [clearProjectionBusyTimer, endProjectionLoading]); + const reorderRafRef = useRef(0); + // eslint-disable-next-line react-hooks/preserve-manual-memoization const reorderGlobeFeatureLayers = useCallback(() => { - const map = mapRef.current; - if (!map || projectionRef.current !== 'globe') return; + if (!mapRef.current || projectionRef.current !== 'globe') return; if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; + if (reorderRafRef.current) return; // 이미 스케줄됨 — 프레임당 1회 실행 + reorderRafRef.current = requestAnimationFrame(() => { + reorderRafRef.current = 0; + const m = mapRef.current; + if (!m || !m.isStyleLoaded()) return; - const ordering = [ - 'subcables-hitarea', - 'subcables-casing', - 'subcables-line', - 'subcables-glow', - 'subcables-points', - 'subcables-label', - 'vessel-track-line', - 'vessel-track-line-hitarea', - 'vessel-track-arrow', - 'vessel-track-pts', - 'vessel-track-pts-highlight', - 'track-replay-globe-path', - 'track-replay-globe-points', - 'track-replay-globe-virtual-ship', - 'track-replay-globe-virtual-label', - 'zones-fill', - 'zones-line', - 'zones-label', - 'predict-vectors-outline', - 'predict-vectors', - 'predict-vectors-hl-outline', - 'predict-vectors-hl', - 'ships-globe-halo', - 'ships-globe-outline', - 'ships-globe-alarm-pulse', - 'ships-globe-lite', - 'ships-globe', - 'ships-globe-label', - 'ships-globe-alarm-badge', - 'ships-globe-hover-halo', - 'ships-globe-hover-outline', - 'ships-globe-hover', - 'pair-lines-ml', - 'fc-lines-ml', - 'pair-range-ml', - 'fleet-circles-ml-fill', - 'fleet-circles-ml', - ]; + const ordering = [ + 'subcables-hitarea', + 'subcables-casing', + 'subcables-line', + 'subcables-glow', + 'subcables-points', + 'subcables-label', + 'vessel-track-line', + 'vessel-track-line-hitarea', + 'vessel-track-arrow', + 'vessel-track-pts', + 'vessel-track-pts-highlight', + 'track-replay-globe-path', + 'track-replay-globe-points', + 'track-replay-globe-virtual-ship', + 'track-replay-globe-virtual-label', + 'zones-fill', + 'zones-line', + 'zones-label', + 'fleet-circles-ml', + 'fc-lines-ml', + 'pair-range-ml', + 'pair-lines-ml', + 'predict-vectors-outline', + 'predict-vectors', + 'predict-vectors-hl-outline', + 'predict-vectors-hl', + 'ships-globe-halo', + 'ships-globe-outline', + 'ships-globe-alarm-pulse', + 'ships-globe-lite', + 'ships-globe', + 'ships-globe-label', + 'ships-globe-alarm-badge', + 'ships-globe-hover-halo', + 'ships-globe-hover-outline', + 'ships-globe-hover', + ]; - for (const layerId of ordering) { - try { - if (map.getLayer(layerId)) map.moveLayer(layerId); - } catch { - // ignore + for (const layerId of ordering) { + try { + if (m.getLayer(layerId)) m.moveLayer(layerId); + } catch { + // ignore + } } - } - kickRepaint(map); + kickRepaint(m); + }); }, []); // Projection toggle (mercator <-> globe) diff --git a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts index 645f2c1..ac90c04 100644 --- a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts @@ -13,10 +13,11 @@ import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { guardedSetVisibility } from '../lib/layerHelpers'; /** Globe tessellation에서 vertex 65535 초과를 방지하기 위해 좌표 수를 줄임. - * 수역 폴리곤(해안선 디테일 2100+ vertex)이 globe에서 70,000+로 폭증하므로 - * ring당 최대 maxPts개로 서브샘플링. 라벨 centroid에는 영향 없음. */ + * 수역 폴리곤(해안선 디테일 2100+ vertex)이 globe에서 ~33x로 폭증하므로 + * ring당 최대 maxPts개로 서브샘플링. 라벨 centroid에는 영향 없음. + * 4수역 × 300pts × 33x ≈ 39,600 vertices (< 65535 limit). */ function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson { - const MAX_PTS = 60; + const MAX_PTS = 300; const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => { if (ring.length <= MAX_PTS) return ring; const step = Math.ceil(ring.length / MAX_PTS); diff --git a/apps/web/src/widgets/map3d/lib/mapCore.ts b/apps/web/src/widgets/map3d/lib/mapCore.ts index e95616e..38e7d0b 100644 --- a/apps/web/src/widgets/map3d/lib/mapCore.ts +++ b/apps/web/src/widgets/map3d/lib/mapCore.ts @@ -49,7 +49,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void try { map.off('style.load', runOnce); map.off('styledata', runOnce); - map.off('idle', runOnce); } catch { // ignore } @@ -57,7 +56,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void map.on('style.load', runOnce); map.on('styledata', runOnce); - map.on('idle', runOnce); return () => { if (fired) return; @@ -66,7 +64,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void if (!map) return; map.off('style.load', runOnce); map.off('styledata', runOnce); - map.off('idle', runOnce); } catch { // ignore } diff --git a/apps/web/src/widgets/map3d/lib/mlExpressions.ts b/apps/web/src/widgets/map3d/lib/mlExpressions.ts index 72c3ceb..688bff2 100644 --- a/apps/web/src/widgets/map3d/lib/mlExpressions.ts +++ b/apps/web/src/widgets/map3d/lib/mlExpressions.ts @@ -41,28 +41,46 @@ export function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) { return ['any', ...clauses] as unknown[]; } -export function makeGlobeCircleRadiusExpr() { - const base3 = 4; - const base7 = 6; - const base10 = 8; - const base14 = 12; - const base18 = 32; +// ── Globe circle radius zoom stops ── +// MapLibre 제약: expression 당 zoom-based interpolate는 1개만 허용 +// → 하나의 interpolate 안에서 각 stop 값을 case로 분기 +const ZOOM_LEVELS = [3, 7, 10, 14, 18] as const; +const BASE_VALUES = [4, 6, 8, 12, 32] as const; +const SELECTED_VALUES = [4.6, 6.8, 9.0, 13.5, 36] as const; +const HIGHLIGHTED_VALUES = [4.2, 6.2, 8.2, 12.6, 34] as const; - return [ - 'interpolate', - ['linear'], - ['zoom'], - 3, - ['case', ['==', ['get', 'selected'], 1], 4.6, ['==', ['get', 'highlighted'], 1], 4.2, base3], - 7, - ['case', ['==', ['get', 'selected'], 1], 6.8, ['==', ['get', 'highlighted'], 1], 6.2, base7], - 10, - ['case', ['==', ['get', 'selected'], 1], 9.0, ['==', ['get', 'highlighted'], 1], 8.2, base10], - 14, - ['case', ['==', ['get', 'selected'], 1], 13.5, ['==', ['get', 'highlighted'], 1], 12.6, base14], - 18, - ['case', ['==', ['get', 'selected'], 1], 36, ['==', ['get', 'highlighted'], 1], 34, base18], - ]; +function buildStopsWithCase(getter: (key: string) => unknown[]) { + const stops: unknown[] = ['interpolate', ['linear'], ['zoom']]; + for (let i = 0; i < ZOOM_LEVELS.length; i++) { + stops.push(ZOOM_LEVELS[i]); + stops.push([ + 'case', + ['==', getter('selected'), 1], SELECTED_VALUES[i], + ['==', getter('highlighted'), 1], HIGHLIGHTED_VALUES[i], + BASE_VALUES[i], + ]); + } + return stops; +} + +/** feature-state 기반 — 메인 선박 레이어 (halo, outline) */ +export function makeGlobeCircleRadiusExpr() { + return buildStopsWithCase((key) => ['feature-state', key]); +} + +/** GeoJSON property 기반 — hover overlay 레이어 */ +export function makeGlobeCircleRadiusPropExpr() { + const stops: unknown[] = ['interpolate', ['linear'], ['zoom']]; + for (let i = 0; i < ZOOM_LEVELS.length; i++) { + stops.push(ZOOM_LEVELS[i]); + stops.push([ + 'case', + ['==', ['get', 'selected'], 1], SELECTED_VALUES[i], + HIGHLIGHTED_VALUES[i], + ]); + } + return stops; } export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never; +export const GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR = makeGlobeCircleRadiusPropExpr() as never;