243 lines
7.6 KiB
TypeScript
243 lines
7.6 KiB
TypeScript
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<maplibregl.Map | null>,
|
|
projectionBusyRef: MutableRefObject<boolean>,
|
|
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<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => {
|
|
const features: GeoJSON.Feature<GeoJSON.LineString>[] = [];
|
|
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<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
|
|
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
|
|
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<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
|
|
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
|
|
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<NativeSourceConfig[]>(
|
|
() => [
|
|
{ id: GLOBE_LINE_SRC, data: lineGeoJson },
|
|
{ id: GLOBE_POINT_SRC, data: pointGeoJson },
|
|
{ id: GLOBE_VIRTUAL_SRC, data: virtualGeoJson },
|
|
],
|
|
[lineGeoJson, pointGeoJson, virtualGeoJson],
|
|
);
|
|
|
|
const globeLayers = useMemo<NativeLayerSpec[]>(
|
|
() => [
|
|
{
|
|
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]);
|
|
}
|