import { useEffect, type MutableRefObject } from 'react'; import type maplibregl from 'maplibre-gl'; import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; import type { AisTarget } from '../../../entities/aisTarget/model/types'; import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { Map3DSettings, MapProjectionId } from '../types'; import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from '../../../shared/lib/map/palette'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { destinationPointLngLat } from '../lib/geometry'; import { isFiniteNumber } from '../lib/setUtils'; import { toValidBearingDeg, lightenColor } from '../lib/shipUtils'; export function usePredictionVectors( mapRef: MutableRefObject, projectionBusyRef: MutableRefObject, reorderGlobeFeatureLayers: () => void, opts: { overlays: MapToggleState; settings: Map3DSettings; shipData: AisTarget[]; legacyHits: Map | null | undefined; selectedMmsi: number | null; externalHighlightedSetRef: Set; projection: MapProjectionId; baseMap: string; mapSyncEpoch: number; }, ) { const { overlays, settings, shipData, legacyHits, selectedMmsi, externalHighlightedSetRef, projection, baseMap, mapSyncEpoch } = opts; useEffect(() => { const map = mapRef.current; if (!map) return; const srcId = 'predict-vectors-src'; const outlineId = 'predict-vectors-outline'; const lineId = 'predict-vectors'; const hlOutlineId = 'predict-vectors-hl-outline'; const hlId = 'predict-vectors-hl'; const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; const visibility = overlays.predictVectors ? 'visible' : 'none'; const horizonMinutes = 15; const horizonSeconds = horizonMinutes * 60; const metersPerSecondPerKnot = 0.514444; const features: GeoJSON.Feature[] = []; if (overlays.predictVectors && settings.showShips && shipData.length > 0) { for (const t of shipData) { const legacy = legacyHits?.get(t.mmsi) ?? null; const isTarget = !!legacy; const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi; const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi); if (!isTarget && !isSelected && !isPinnedHighlight) continue; const sog = isFiniteNumber(t.sog) ? t.sog : null; const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading); if (sog == null || bearing == null) continue; if (sog < 0.2) continue; const distM = sog * metersPerSecondPerKnot * horizonSeconds; if (!Number.isFinite(distM) || distM <= 0) continue; const to = destinationPointLngLat([t.lon, t.lat], bearing, distM); const baseRgb = isTarget ? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ''] ?? OTHER_AIS_SPEED_RGB.moving : OTHER_AIS_SPEED_RGB.moving; const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62); const alpha = isTarget ? 0.72 : 0.52; const alphaHl = isTarget ? 0.92 : 0.84; const hl = isSelected || isPinnedHighlight ? 1 : 0; features.push({ type: 'Feature', id: `pred-${t.mmsi}`, geometry: { type: 'LineString', coordinates: [[t.lon, t.lat], to] }, properties: { mmsi: t.mmsi, minutes: horizonMinutes, sog, cog: bearing, target: isTarget ? 1 : 0, hl, color: rgbaCss(rgb, alpha), colorHl: rgbaCss(rgb, alphaHl), }, }); } } const fc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features }; try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; if (existing) existing.setData(fc); else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); } catch (e) { console.warn('Prediction vector source setup failed:', e); return; } const ensureLayer = (id: string, paint: LayerSpecification['paint'], filter: unknown[]) => { if (!map.getLayer(id)) { try { map.addLayer( { id, type: 'line', source: srcId, filter: filter as never, layout: { visibility, 'line-cap': 'round', 'line-join': 'round', }, paint, } as unknown as LayerSpecification, undefined, ); } catch (e) { console.warn('Prediction vector layer add failed:', e); } } else { try { map.setLayoutProperty(id, 'visibility', visibility); map.setFilter(id, filter as never); if (paint && typeof paint === 'object') { for (const [key, value] of Object.entries(paint)) { map.setPaintProperty(id, key as never, value as never); } } } catch { // ignore } } }; const baseFilter = ['==', ['to-number', ['get', 'hl'], 0], 0] as unknown as unknown[]; const hlFilter = ['==', ['to-number', ['get', 'hl'], 0], 1] as unknown as unknown[]; ensureLayer( outlineId, { 'line-color': 'rgba(2,6,23,0.86)', 'line-width': 4.8, 'line-opacity': 1, 'line-blur': 0.2, 'line-dasharray': [1.2, 1.8] as never, } as never, baseFilter, ); ensureLayer( lineId, { 'line-color': ['coalesce', ['get', 'color'], 'rgba(226,232,240,0.62)'] as never, 'line-width': 2.4, 'line-opacity': 1, 'line-dasharray': [1.2, 1.8] as never, } as never, baseFilter, ); ensureLayer( hlOutlineId, { 'line-color': 'rgba(2,6,23,0.92)', 'line-width': 6.4, 'line-opacity': 1, 'line-blur': 0.25, 'line-dasharray': [1.2, 1.8] as never, } as never, hlFilter, ); ensureLayer( hlId, { 'line-color': ['coalesce', ['get', 'colorHl'], ['get', 'color'], 'rgba(241,245,249,0.92)'] as never, 'line-width': 3.6, 'line-opacity': 1, 'line-dasharray': [1.2, 1.8] as never, } as never, hlFilter, ); reorderGlobeFeatureLayers(); kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); return () => { stop(); }; }, [ overlays.predictVectors, settings.showShips, shipData, legacyHits, selectedMmsi, externalHighlightedSetRef, projection, baseMap, mapSyncEpoch, reorderGlobeFeatureLayers, ]); }