import { useEffect, useMemo, type MutableRefObject } from 'react'; import type maplibregl from 'maplibre-gl'; import type { ActiveTrack } from '../../../entities/vesselTrack/model/types'; import { convertLegacyTrackPointsToProcessedTrack } from '../../../features/trackReplay/lib/adapters'; import { useTrackQueryStore } from '../../../features/trackReplay/stores/trackQueryStore'; import type { TrackReplayDeckRenderState } from '../../../features/trackReplay/hooks/useTrackReplayDeckLayers'; import { useNativeMapLayers, type NativeLayerSpec, type NativeSourceConfig } from './useNativeMapLayers'; import type { MapProjectionId } from '../types'; import { kickRepaint } from '../lib/mapCore'; const GLOBE_LINE_SRC = 'track-replay-globe-line-src'; const GLOBE_POINT_SRC = 'track-replay-globe-point-src'; const GLOBE_VIRTUAL_SRC = 'track-replay-globe-virtual-src'; const GLOBE_TRACK_LAYER_IDS = { PATH: 'track-replay-globe-path', POINTS: 'track-replay-globe-points', VIRTUAL_SHIP: 'track-replay-globe-virtual-ship', VIRTUAL_LABEL: 'track-replay-globe-virtual-label', } as const; function toFiniteNumber(value: unknown): number | null { if (typeof value === 'number') return Number.isFinite(value) ? value : null; if (typeof value === 'string') { const parsed = Number(value.trim()); return Number.isFinite(parsed) ? parsed : null; } return null; } function normalizeCoordinate(value: unknown): [number, number] | null { if (!Array.isArray(value) || value.length !== 2) return null; const lon = toFiniteNumber(value[0]); const lat = toFiniteNumber(value[1]); if (lon == null || lat == null) return null; return [lon, lat]; } export function useTrackReplayLayer( mapRef: MutableRefObject, projectionBusyRef: MutableRefObject, reorderGlobeFeatureLayers: () => void, opts: { projection: MapProjectionId; mapSyncEpoch: number; activeTrack?: ActiveTrack | null; renderState: TrackReplayDeckRenderState; }, ) { const { projection, mapSyncEpoch, activeTrack = null, renderState } = opts; const { enabledTracks, currentPositions, showPoints, showVirtualShip, showLabels, renderEpoch } = renderState; const setTracks = useTrackQueryStore((state) => state.setTracks); // Backward compatibility path: if legacy activeTrack is provided, load it into the new store. useEffect(() => { if (!activeTrack) return; if (!activeTrack.points || activeTrack.points.length === 0) return; const converted = convertLegacyTrackPointsToProcessedTrack(activeTrack.mmsi, activeTrack.points); if (!converted) return; setTracks([converted]); }, [activeTrack, setTracks]); const lineGeoJson = useMemo>(() => { const features: GeoJSON.Feature[] = []; for (const track of enabledTracks) { const coordinates: [number, number][] = []; for (const coord of track.geometry) { const normalized = normalizeCoordinate(coord); if (normalized) coordinates.push(normalized); } if (coordinates.length < 2) continue; features.push({ type: 'Feature', properties: { vesselId: track.vesselId, shipName: track.shipName, }, geometry: { type: 'LineString', coordinates, }, }); } return { type: 'FeatureCollection', features, }; }, [enabledTracks]); const pointGeoJson = useMemo>(() => { const features: GeoJSON.Feature[] = []; for (const track of enabledTracks) { track.geometry.forEach((coord, index) => { const normalized = normalizeCoordinate(coord); if (!normalized) return; features.push({ type: 'Feature', properties: { vesselId: track.vesselId, shipName: track.shipName, index, }, geometry: { type: 'Point', coordinates: normalized, }, }); }); } return { type: 'FeatureCollection', features, }; }, [enabledTracks]); const virtualGeoJson = useMemo>(() => { const features: GeoJSON.Feature[] = []; for (const position of currentPositions) { const normalized = normalizeCoordinate(position.position); if (!normalized) continue; features.push({ type: 'Feature', properties: { vesselId: position.vesselId, shipName: position.shipName, }, geometry: { type: 'Point', coordinates: normalized, }, }); } return { type: 'FeatureCollection', features, }; }, [currentPositions]); const globeSources = useMemo( () => [ { id: GLOBE_LINE_SRC, data: lineGeoJson }, { id: GLOBE_POINT_SRC, data: pointGeoJson }, { id: GLOBE_VIRTUAL_SRC, data: virtualGeoJson }, ], [lineGeoJson, pointGeoJson, virtualGeoJson], ); const globeLayers = useMemo( () => [ { id: GLOBE_TRACK_LAYER_IDS.PATH, type: 'line', sourceId: GLOBE_LINE_SRC, paint: { 'line-color': '#00d1ff', 'line-width': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 8, 3, 12, 4], 'line-opacity': 0.8, }, layout: { 'line-cap': 'round', 'line-join': 'round', }, }, { id: GLOBE_TRACK_LAYER_IDS.POINTS, type: 'circle', sourceId: GLOBE_POINT_SRC, paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 8, 3, 12, 4], 'circle-color': '#00d1ff', 'circle-opacity': showPoints ? 0.8 : 0, 'circle-stroke-width': 1, 'circle-stroke-color': 'rgba(2,6,23,0.8)', }, }, { id: GLOBE_TRACK_LAYER_IDS.VIRTUAL_SHIP, type: 'circle', sourceId: GLOBE_VIRTUAL_SRC, paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 2.5, 8, 4, 12, 6], 'circle-color': '#f59e0b', 'circle-opacity': showVirtualShip ? 0.9 : 0, 'circle-stroke-width': 1, 'circle-stroke-color': 'rgba(255,255,255,0.8)', }, }, { id: GLOBE_TRACK_LAYER_IDS.VIRTUAL_LABEL, type: 'symbol', sourceId: GLOBE_VIRTUAL_SRC, paint: { 'text-color': 'rgba(226,232,240,0.95)', 'text-opacity': showLabels ? 1 : 0, 'text-halo-color': 'rgba(2,6,23,0.85)', 'text-halo-width': 1, }, layout: { 'text-field': ['get', 'shipName'], 'text-size': 11, 'text-anchor': 'left', 'text-offset': [0.8, 0], }, }, ], [showLabels, showPoints, showVirtualShip], ); const isGlobeVisible = projection === 'globe' && enabledTracks.length > 0; useNativeMapLayers( mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { sources: globeSources, layers: globeLayers, visible: isGlobeVisible, beforeLayer: ['zones-fill', 'zones-line'], }, [projection, mapSyncEpoch, renderEpoch, lineGeoJson, pointGeoJson, virtualGeoJson, showPoints, showVirtualShip, showLabels], ); useEffect(() => { if (projection !== 'globe') return; if (!isGlobeVisible) return; const map = mapRef.current; if (!map || projectionBusyRef.current) return; kickRepaint(map); const id = requestAnimationFrame(() => { kickRepaint(map); }); return () => cancelAnimationFrame(id); }, [projection, isGlobeVisible, renderEpoch, mapRef, projectionBusyRef]); }