From 86a0b2276f9a25fdc8f47cb597a719fcbeb312b8 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 22:43:08 +0900 Subject: [PATCH] =?UTF-8?q?fix(web):=20vessel-track=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=ED=99=94=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mercator/Globe track-replay 레이어 충돌 및 setProps 레이스 해결 - track DTO 좌표/시간 정규화 + stale query 응답 무시 - 조회 직후 표시 안정화 및 기본 100x 자동재생 적용 - Global Track Replay 패널 초기 위치 조정 + 헤더 드래그 지원 - liveRenderer batch rendering + trackReplay store 기반 구조 반영 Co-Authored-By: Claude Opus 4.6 --- .../web/src/pages/dashboard/DashboardPage.tsx | 47 +++--- apps/web/src/widgets/map3d/Map3D.tsx | 159 +++++++++++++----- .../widgets/map3d/hooks/useGlobeOverlays.ts | 94 ++++++++++- .../src/widgets/map3d/hooks/useGlobeShips.ts | 105 +++++++++++- .../map3d/hooks/useProjectionToggle.ts | 6 + .../map3d/hooks/useVesselTrackLayer.ts | 1 + 6 files changed, 335 insertions(+), 77 deletions(-) diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index c746e4f..734ae11 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -27,17 +27,18 @@ import { Topbar } from "../../widgets/topbar/Topbar"; import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel"; import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel"; import { VesselList } from "../../widgets/vesselList/VesselList"; -import type { ActiveTrack } from "../../entities/vesselTrack/model/types"; -import { fetchVesselTrack } from "../../entities/vesselTrack/api/fetchTrack"; import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; -import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling"; -import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay"; -import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel"; -import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel"; import { DepthLegend } from "../../widgets/legend/DepthLegend"; import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types"; import type { MapStyleSettings } from "../../features/mapSettings/types"; import { fmtDateTimeFull, fmtIsoFull } from "../../shared/lib/datetime"; +import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService"; +import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore"; +import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel"; +import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling"; +import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay"; +import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel"; +import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel"; import { buildLegacyHitMap, computeCountsByType, @@ -82,6 +83,7 @@ export function DashboardPage() { const { data: legacyData, error: legacyError } = useLegacyVessels(); const { data: subcableData } = useSubcables(); const legacyIndex = useLegacyIndex(legacyData); + const weather = useWeatherPolling(zones); const [mapInstance, setMapInstance] = useState(null); const weatherOverlay = useWeatherOverlay(mapInstance); @@ -142,27 +144,33 @@ export function DashboardPage() { const [selectedCableId, setSelectedCableId] = useState(null); // 항적 (vessel track) - const [activeTrack, setActiveTrack] = useState(null); const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null); const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => { setTrackContextMenu(info); }, []); const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []); const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => { + const trackStore = useTrackQueryStore.getState(); + const queryKey = `${mmsi}:${minutes}:${Date.now()}`; + trackStore.beginQuery(queryKey); + try { - const res = await fetchVesselTrack(mmsi, minutes); - if (res.success && res.data.length > 0) { - const sorted = [...res.data].sort( - (a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(), - ); - setActiveTrack({ mmsi, minutes, points: sorted, fetchedAt: Date.now() }); + const target = targets.find((item) => item.mmsi === mmsi); + const tracks = await queryTrackByMmsi({ + mmsi, + minutes, + shipNameHint: target?.name, + }); + + if (tracks.length > 0) { + trackStore.applyTracksSuccess(tracks, queryKey); } else { - console.warn('Track: no data', res.message); + trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey); } } catch (e) { - console.warn('Track fetch failed:', e); + trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey); } - }, []); + }, [targets]); const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { showShips: true, showDensity: false, showSeamark: false, @@ -762,16 +770,16 @@ export function DashboardPage() { onHoverCable={setHoveredCableId} onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))} mapStyleSettings={mapStyleSettings} - onMapReady={handleMapReady} initialView={mapView} onViewStateChange={setMapView} - activeTrack={activeTrack} + activeTrack={null} trackContextMenu={trackContextMenu} onRequestTrack={handleRequestTrack} onCloseTrackMenu={handleCloseTrackMenu} onOpenTrackMenu={handleOpenTrackMenu} + onMapReady={handleMapReady} /> - + + {selectedLegacyVessel ? ( diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 5fd922b..d29f136 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -26,9 +26,13 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays'; import { useGlobeInteraction } from './hooks/useGlobeInteraction'; import { useDeckLayers } from './hooks/useDeckLayers'; import { useSubcablesLayer } from './hooks/useSubcablesLayer'; -import { useVesselTrackLayer } from './hooks/useVesselTrackLayer'; +import { useTrackReplayLayer } from './hooks/useTrackReplayLayer'; import { useMapStyleSettings } from './hooks/useMapStyleSettings'; import { VesselContextMenu } from './components/VesselContextMenu'; +import { useLiveShipAdapter } from '../../features/liveRenderer/hooks/useLiveShipAdapter'; +import { useLiveShipBatchRender } from '../../features/liveRenderer/hooks/useLiveShipBatchRender'; +import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore'; +import { useTrackReplayDeckLayers } from '../../features/trackReplay/hooks/useTrackReplayDeckLayers'; export type { Map3DSettings, BaseMapId, MapProjectionId } from './types'; @@ -68,7 +72,6 @@ export function Map3D({ onHoverCable, onClickCable, mapStyleSettings, - onMapReady, initialView, onViewStateChange, onGlobeShipsReady, @@ -77,14 +80,8 @@ export function Map3D({ onRequestTrack, onCloseTrackMenu, onOpenTrackMenu, + onMapReady, }: Props) { - void onHoverFleet; - void onClearFleetHover; - void onHoverMmsi; - void onClearMmsiHover; - void onHoverPair; - void onClearPairHover; - // ── Shared refs ────────────────────────────────────────────────────── const containerRef = useRef(null); const mapRef = useRef(null); @@ -201,20 +198,38 @@ export function Map3D({ ); // ── Ship data memos ────────────────────────────────────────────────── - const shipData = useMemo(() => { + const rawShipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); }, [targets]); + const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips); + + const liveShipFeatures = useLiveShipAdapter(rawShipData, legacyHits ?? null); + const { renderedTargets: batchRenderedTargets } = useLiveShipBatchRender( + mapRef, + liveShipFeatures, + rawShipData, + mapSyncEpoch, + ); + + const shipData = useMemo( + () => (hideLiveShips ? [] : rawShipData), + [hideLiveShips, rawShipData], + ); + const shipByMmsi = useMemo(() => { const byMmsi = new Map(); - for (const t of shipData) byMmsi.set(t.mmsi, t); + for (const t of rawShipData) byMmsi.set(t.mmsi, t); return byMmsi; - }, [shipData]); + }, [rawShipData]); const shipLayerData = useMemo(() => { - if (shipData.length === 0) return shipData; - return [...shipData]; - }, [shipData]); + if (hideLiveShips) return []; + // Fallback to raw targets when batch result is temporarily empty + // (e.g. overlay update race or viewport sync delay). + if (batchRenderedTargets.length === 0) return rawShipData; + return [...batchRenderedTargets]; + }, [hideLiveShips, batchRenderedTargets, rawShipData]); const shipHighlightSet = useMemo(() => { const out = new Set(highlightedMmsiSetForShips); @@ -236,6 +251,8 @@ export function Map3D({ return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi)); }, [shipHighlightSet, shipLayerData]); + const trackReplayRenderState = useTrackReplayDeckLayers(); + // ── Deck hover management ──────────────────────────────────────────── const hasAuxiliarySelectModifier = useCallback( (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null): boolean => { @@ -295,22 +312,51 @@ export function Map3D({ ownerKey: null, vesselMmsis: [], }); + const mapDrivenMmsiHoverRef = useRef(false); + const mapDrivenPairHoverRef = useRef(false); + const mapDrivenFleetHoverRef = useRef(false); const clearMapFleetHoverState = useCallback(() => { + const prev = mapFleetHoverStateRef.current; mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] }; setHoveredDeckFleetOwner(null); setHoveredDeckFleetMmsis([]); - }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]); + if ( + mapDrivenFleetHoverRef.current && + (prev.ownerKey != null || prev.vesselMmsis.length > 0) && + hoveredFleetOwnerKey === prev.ownerKey && + equalNumberArrays(hoveredFleetMmsiSet, prev.vesselMmsis) + ) { + onClearFleetHover?.(); + } + mapDrivenFleetHoverRef.current = false; + }, [ + setHoveredDeckFleetOwner, + setHoveredDeckFleetMmsis, + onClearFleetHover, + hoveredFleetOwnerKey, + hoveredFleetMmsiSet, + ]); const clearDeckHoverPairs = useCallback(() => { + const prev = mapDeckPairHoverRef.current; mapDeckPairHoverRef.current = []; setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); - }, [setHoveredDeckPairMmsiSet]); + if (mapDrivenPairHoverRef.current && prev.length > 0 && equalNumberArrays(hoveredPairMmsiSet, prev)) { + onClearPairHover?.(); + } + mapDrivenPairHoverRef.current = false; + }, [setHoveredDeckPairMmsiSet, onClearPairHover, hoveredPairMmsiSet]); const clearDeckHoverMmsi = useCallback(() => { + const prev = mapDeckMmsiHoverRef.current; mapDeckMmsiHoverRef.current = []; setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); - }, [setHoveredDeckMmsiSet]); + if (mapDrivenMmsiHoverRef.current && prev.length > 0 && equalNumberArrays(hoveredMmsiSet, prev)) { + onClearMmsiHover?.(); + } + mapDrivenMmsiHoverRef.current = false; + }, [setHoveredDeckMmsiSet, onClearMmsiHover, hoveredMmsiSet]); const scheduleDeckHoverResolve = useCallback(() => { if (deckHoverRafRef.current != null) return; @@ -336,21 +382,41 @@ export function Map3D({ const setDeckHoverMmsi = useCallback( (next: number[]) => { const normalized = makeUniqueSorted(next); + const prev = mapDeckMmsiHoverRef.current; touchDeckHoverState(normalized.length > 0); setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); mapDeckMmsiHoverRef.current = normalized; + if (!equalNumberArrays(prev, normalized)) { + if (normalized.length > 0) { + mapDrivenMmsiHoverRef.current = true; + onHoverMmsi?.(normalized); + } else if (mapDrivenMmsiHoverRef.current && prev.length > 0) { + onClearMmsiHover?.(); + mapDrivenMmsiHoverRef.current = false; + } + } }, - [setHoveredDeckMmsiSet, touchDeckHoverState], + [setHoveredDeckMmsiSet, touchDeckHoverState, onHoverMmsi, onClearMmsiHover], ); const setDeckHoverPairs = useCallback( (next: number[]) => { const normalized = makeUniqueSorted(next); + const prev = mapDeckPairHoverRef.current; touchDeckHoverState(normalized.length > 0); setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); mapDeckPairHoverRef.current = normalized; + if (!equalNumberArrays(prev, normalized)) { + if (normalized.length > 0) { + mapDrivenPairHoverRef.current = true; + onHoverPair?.(normalized); + } else if (mapDrivenPairHoverRef.current && prev.length > 0) { + onClearPairHover?.(); + mapDrivenPairHoverRef.current = false; + } + } }, - [setHoveredDeckPairMmsiSet, touchDeckHoverState], + [setHoveredDeckPairMmsiSet, touchDeckHoverState, onHoverPair, onClearPairHover], ); const setMapFleetHoverState = useCallback( @@ -364,8 +430,21 @@ export function Map3D({ setHoveredDeckFleetOwner(ownerKey); setHoveredDeckFleetMmsis(normalized); mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized }; + if (ownerKey != null || normalized.length > 0) { + mapDrivenFleetHoverRef.current = true; + onHoverFleet?.(ownerKey, normalized); + } else if (mapDrivenFleetHoverRef.current) { + onClearFleetHover?.(); + mapDrivenFleetHoverRef.current = false; + } }, - [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState], + [ + setHoveredDeckFleetOwner, + setHoveredDeckFleetMmsis, + touchDeckHoverState, + onHoverFleet, + onClearFleetHover, + ], ); // hover RAF cleanup @@ -413,37 +492,37 @@ export function Map3D({ }, [pairLinks]); const pairLinksInteractive = useMemo(() => { - if (!overlays.pairLines || (pairLinks?.length ?? 0) === 0) return []; - if (hoveredPairMmsiSetRef.size < 2) return []; + if ((pairLinks?.length ?? 0) === 0) return []; + if (effectiveHoveredPairMmsiSet.size < 2) return []; const links = pairLinks || []; return links.filter((link) => - hoveredPairMmsiSetRef.has(link.aMmsi) && hoveredPairMmsiSetRef.has(link.bMmsi), + effectiveHoveredPairMmsiSet.has(link.aMmsi) && effectiveHoveredPairMmsiSet.has(link.bMmsi), ); - }, [pairLinks, hoveredPairMmsiSetRef, overlays.pairLines]); + }, [pairLinks, effectiveHoveredPairMmsiSet]); const pairRangesInteractive = useMemo(() => { - if (!overlays.pairRange || pairRanges.length === 0) return []; - if (hoveredPairMmsiSetRef.size < 2) return []; + if (pairRanges.length === 0) return []; + if (effectiveHoveredPairMmsiSet.size < 2) return []; return pairRanges.filter((range) => - hoveredPairMmsiSetRef.has(range.aMmsi) && hoveredPairMmsiSetRef.has(range.bMmsi), + effectiveHoveredPairMmsiSet.has(range.aMmsi) && effectiveHoveredPairMmsiSet.has(range.bMmsi), ); - }, [pairRanges, hoveredPairMmsiSetRef, overlays.pairRange]); + }, [pairRanges, effectiveHoveredPairMmsiSet]); const fcLinesInteractive = useMemo(() => { - if (!overlays.fcLines || fcDashed.length === 0) return []; + if (fcDashed.length === 0) return []; if (highlightedMmsiSetCombined.size === 0) return []; return fcDashed.filter( (line) => [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))), ); - }, [fcDashed, overlays.fcLines, highlightedMmsiSetCombined]); + }, [fcDashed, highlightedMmsiSetCombined]); const fleetCirclesInteractive = useMemo(() => { - if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return []; + if ((fleetCircles?.length ?? 0) === 0) return []; if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return []; const circles = fleetCircles || []; return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis)); - }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]); + }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, highlightedMmsiSetCombined]); // ── Hook orchestration ─────────────────────────────────────────────── const { ensureMercatorOverlay, pulseMapSync } = useMapInit( @@ -480,9 +559,9 @@ export function Map3D({ useGlobeShips( mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { - projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, + projection, settings, shipData: shipLayerData, shipHighlightSet, shipHoverOverlaySet, shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch, - onSelectMmsi, onToggleHighlightMmsi, targets, overlays, + onSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, onGlobeShipsReady, }, @@ -509,7 +588,7 @@ export function Map3D({ useDeckLayers( mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef, { - projection, settings, trackReplayDeckLayers: [], shipLayerData, shipOverlayLayerData, shipData, + projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData, legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges, pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, overlays, shipByMmsi, selectedMmsi, shipHighlightSet, @@ -536,9 +615,9 @@ export function Map3D({ }, ); - useVesselTrackLayer( - mapRef, overlayRef, projectionBusyRef, reorderGlobeFeatureLayers, - { activeTrack, projection, mapSyncEpoch }, + useTrackReplayLayer( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { activeTrack, projection, mapSyncEpoch, renderState: trackReplayRenderState }, ); // 우클릭 컨텍스트 메뉴 — 대상선박(legacyHits)만 허용 @@ -561,7 +640,7 @@ export function Map3D({ // Globe: MapLibre 네이티브 레이어에서 쿼리 const point: [number, number] = [e.offsetX, e.offsetY]; const shipLayerIds = [ - 'ships-globe', 'ships-globe-halo', 'ships-globe-outline', + 'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline', ].filter((id) => map.getLayer(id)); let features: maplibregl.MapGeoJSONFeature[] = []; diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts index db3768b..b2261d6 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts @@ -11,6 +11,7 @@ import { 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'; @@ -66,7 +67,8 @@ export function useGlobeOverlays( const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; - if (projection !== 'globe' || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) { + const pairHoverActive = hoveredPairMmsiList.length >= 2; + if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) { remove(); return; } @@ -140,7 +142,7 @@ export function useGlobeOverlays( return () => { stop(); }; - }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); + }, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); // FC lines useEffect(() => { @@ -157,7 +159,9 @@ export function useGlobeOverlays( const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; - if (projection !== 'globe' || !overlays.fcLines) { + const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); + const fcHoverActive = fleetAwarePairMmsiList.length > 0; + if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) { remove(); return; } @@ -235,7 +239,15 @@ export function useGlobeOverlays( return () => { stop(); }; - }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); + }, [ + projection, + overlays.fcLines, + fcLinks, + hoveredPairMmsiList, + hoveredFleetMmsiList, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); // Fleet circles useEffect(() => { @@ -243,26 +255,35 @@ export function useGlobeOverlays( 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; - if (projection !== 'globe' || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) { + 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: (fleetCircles || []).map((c) => { + features: circles.map((c) => { const ring = circleRingLngLat(c.center, c.radiusNm * 1852); return { type: 'Feature', @@ -280,6 +301,23 @@ export function useGlobeOverlays( }), }; + 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); @@ -289,6 +327,14 @@ export function useGlobeOverlays( 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( @@ -312,6 +358,27 @@ export function useGlobeOverlays( 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); }; @@ -321,7 +388,15 @@ export function useGlobeOverlays( return () => { stop(); }; - }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]); + }, [ + projection, + overlays.fleetCircles, + fleetCircles, + hoveredFleetOwnerKeyList, + hoveredFleetMmsiList, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); // Pair range useEffect(() => { @@ -338,7 +413,8 @@ export function useGlobeOverlays( const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; - if (projection !== 'globe' || !overlays.pairRange) { + const pairHoverActive = hoveredPairMmsiList.length >= 2; + if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) { remove(); return; } @@ -427,7 +503,7 @@ export function useGlobeOverlays( return () => { stop(); }; - }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); + }, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]); // Paint state updates for hover highlights // eslint-disable-next-line react-hooks/preserve-manual-memoization diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts index 5d55bdb..8cbc4ee 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -270,13 +270,14 @@ export function useGlobeShips( const srcId = 'ships-globe-src'; const haloId = 'ships-globe-halo'; const outlineId = 'ships-globe-outline'; + const symbolLiteId = 'ships-globe-lite'; const symbolId = 'ships-globe'; const labelId = 'ships-globe-label'; // 레이어를 제거하지 않고 visibility만 'none'으로 설정 // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지) const hide = () => { - for (const id of [labelId, symbolId, outlineId, haloId]) { + for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) { guardedSetVisibility(map, id, 'none'); } }; @@ -300,10 +301,12 @@ export function useGlobeShips( // → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지 const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none'; const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none'; - if (map.getLayer(symbolId)) { - const changed = map.getLayoutProperty(symbolId, 'visibility') !== visibility; + if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) { + const changed = + map.getLayoutProperty(symbolId, 'visibility') !== visibility || + map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility; if (changed) { - for (const id of [haloId, outlineId, symbolId]) { + for (const id of [haloId, outlineId, symbolLiteId, symbolId]) { guardedSetVisibility(map, id, visibility); } if (projection === 'globe') kickRepaint(map); @@ -342,6 +345,18 @@ export function useGlobeShips( } const before = undefined; + const priorityFilter = [ + 'any', + ['==', ['to-number', ['get', 'permitted'], 0], 1], + ['==', ['to-number', ['get', 'selected'], 0], 1], + ['==', ['to-number', ['get', 'highlighted'], 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], + ] as unknown as unknown[]; if (!map.getLayer(haloId)) { try { @@ -428,6 +443,76 @@ export function useGlobeShips( } // outline: data-driven expressions are static — visibility handled by fast toggle + if (!map.getLayer(symbolLiteId)) { + try { + map.addLayer( + { + id: symbolLiteId, + type: 'symbol', + source: srcId, + minzoom: 6.5, + filter: nonPriorityFilter as never, + layout: { + visibility, + 'symbol-sort-key': 40 as never, + 'icon-image': [ + 'case', + ['==', ['to-number', ['get', 'isAnchored'], 0], 1], + anchoredImgId, + imgId, + ] as never, + 'icon-size': [ + 'interpolate', + ['linear'], + ['zoom'], + 6.5, + ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45], + 8, + ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62], + 10, + ['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72], + 14, + ['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78], + 18, + ['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78], + ] as unknown as number[], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + 'icon-anchor': 'center', + 'icon-rotate': [ + 'case', + ['==', ['to-number', ['get', 'isAnchored'], 0], 1], + 0, + ['to-number', ['get', 'heading'], 0], + ] as never, + 'icon-rotation-alignment': 'map', + 'icon-pitch-alignment': 'map', + }, + paint: { + 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, + 'icon-opacity': [ + 'interpolate', + ['linear'], + ['zoom'], + 6.5, + 0.16, + 8, + 0.34, + 11, + 0.54, + 14, + 0.68, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship lite symbol layer add failed:', e); + } + } + // lite symbol: lower LOD for non-priority vessels in low zoom + if (!map.getLayer(symbolId)) { try { map.addLayer( @@ -435,6 +520,7 @@ export function useGlobeShips( id: symbolId, type: 'symbol', source: srcId, + filter: priorityFilter as never, layout: { visibility, 'symbol-sort-key': [ @@ -475,10 +561,10 @@ export function useGlobeShips( 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, 'icon-opacity': [ 'case', - ['==', ['get', 'permitted'], 1], 1, - ['==', ['get', 'selected'], 1], 0.86, - ['==', ['get', 'highlighted'], 1], 0.82, - 0.66, + ['==', ['get', 'selected'], 1], 1, + ['==', ['get', 'highlighted'], 1], 0.95, + ['==', ['get', 'permitted'], 1], 0.93, + 0.9, ] as never, }, } as unknown as LayerSpecification, @@ -820,13 +906,14 @@ export function useGlobeShips( if (projection !== 'globe' || !settings.showShips) return; const symbolId = 'ships-globe'; + const symbolLiteId = 'ships-globe-lite'; const haloId = 'ships-globe-halo'; const outlineId = 'ships-globe-outline'; const clickedRadiusDeg2 = Math.pow(0.08, 2); const onClick = (e: maplibregl.MapMouseEvent) => { try { - const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); + const layerIds = [symbolId, symbolLiteId, haloId, outlineId].filter((id) => map.getLayer(id)); let feats: unknown[] = []; if (layerIds.length > 0) { try { diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index 4b3e3cc..c38cc38 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -99,6 +99,10 @@ export function useProjectionToggle( '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', @@ -108,6 +112,7 @@ export function useProjectionToggle( 'predict-vectors-hl', 'ships-globe-halo', 'ships-globe-outline', + 'ships-globe-lite', 'ships-globe', 'ships-globe-label', 'ships-globe-hover-halo', @@ -116,6 +121,7 @@ export function useProjectionToggle( 'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml', + 'fleet-circles-ml-fill', 'fleet-circles-ml', ]; diff --git a/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts b/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts index e46bc6f..ce734eb 100644 --- a/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts @@ -107,6 +107,7 @@ const GLOBE_LAYERS: NativeLayerSpec[] = [ const ANIM_CYCLE_SEC = 20; /* ── Hook ──────────────────────────────────────────────────────────── */ +/** @deprecated trackReplay store 엔진으로 이관 완료. 유지보수 호환 용도로만 남겨둔다. */ export function useVesselTrackLayer( mapRef: MutableRefObject, overlayRef: MutableRefObject,