diff --git a/apps/web/src/features/aisPolling/useAisTargetPolling.ts b/apps/web/src/features/aisPolling/useAisTargetPolling.ts index eec745e..8524e65 100644 --- a/apps/web/src/features/aisPolling/useAisTargetPolling.ts +++ b/apps/web/src/features/aisPolling/useAisTargetPolling.ts @@ -18,6 +18,7 @@ export type AisPollingSnapshot = { export type AisPollingOptions = { initialMinutes?: number; + bootstrapMinutes?: number; incrementalMinutes?: number; intervalMs?: number; retentionMinutes?: number; @@ -43,10 +44,10 @@ function upsertByMmsi(store: Map, rows: AisTarget[]) { continue; } - // Prefer newer records if the upstream ever returns stale items. - const prevTs = prev.messageTimestamp ?? ""; - const nextTs = r.messageTimestamp ?? ""; - if (nextTs && prevTs && nextTs < prevTs) continue; + // Keep newer rows only. If backend returns same/older timestamp, skip. + const prevTs = Date.parse(prev.messageTimestamp || ""); + const nextTs = Date.parse(r.messageTimestamp || ""); + if (Number.isFinite(prevTs) && Number.isFinite(nextTs) && nextTs <= prevTs) continue; store.set(r.mmsi, r); upserted += 1; @@ -112,6 +113,7 @@ function pruneStore(store: Map, retentionMinutes: number, bbo export function useAisTargetPolling(opts: AisPollingOptions = {}) { const initialMinutes = opts.initialMinutes ?? 60; + const bootstrapMinutes = opts.bootstrapMinutes ?? initialMinutes; const incrementalMinutes = opts.incrementalMinutes ?? 1; const intervalMs = opts.intervalMs ?? 60_000; const retentionMinutes = opts.retentionMinutes ?? initialMinutes; @@ -122,7 +124,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { const radiusMeters = opts.radiusMeters; const storeRef = useRef>(new Map()); - const inFlightRef = useRef(false); + const generationRef = useRef(0); const [rev, setRev] = useState(0); const [snapshot, setSnapshot] = useState({ @@ -142,10 +144,9 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { let cancelled = false; const controller = new AbortController(); + const generation = ++generationRef.current; - async function run(minutes: number) { - if (inFlightRef.current) return; - inFlightRef.current = true; + async function run(minutes: number, context: "bootstrap" | "initial" | "incremental") { try { setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null })); @@ -159,7 +160,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { }, controller.signal, ); - if (cancelled) return; + if (cancelled || generation !== generationRef.current) return; const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data); const deleted = pruneStore(storeRef.current, retentionMinutes, bbox); @@ -179,14 +180,12 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { }); setRev((r) => r + 1); } catch (e) { - if (cancelled) return; + if (cancelled || generation !== generationRef.current) return; setSnapshot((s) => ({ ...s, - status: "error", + status: context === "incremental" ? s.status : "error", error: e instanceof Error ? e.message : String(e), })); - } finally { - inFlightRef.current = false; } } @@ -205,15 +204,30 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { }); setRev((r) => r + 1); - void run(initialMinutes); - const id = window.setInterval(() => void run(incrementalMinutes), intervalMs); + void run(bootstrapMinutes, "bootstrap"); + if (bootstrapMinutes !== initialMinutes) { + void run(initialMinutes, "initial"); + } + + const id = window.setInterval(() => void run(incrementalMinutes, "incremental"), intervalMs); return () => { cancelled = true; controller.abort(); window.clearInterval(id); }; - }, [initialMinutes, incrementalMinutes, intervalMs, retentionMinutes, bbox, centerLon, centerLat, radiusMeters, enabled]); + }, [ + initialMinutes, + bootstrapMinutes, + incrementalMinutes, + intervalMs, + retentionMinutes, + bbox, + centerLon, + centerLat, + radiusMeters, + enabled, + ]); const targets = useMemo(() => { // `rev` is a version counter so we recompute the array snapshot when the store changes. diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index f0533fd..ee949ac 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -76,6 +76,7 @@ export function DashboardPage() { const { targets, snapshot } = useAisTargetPolling({ initialMinutes: 60, + bootstrapMinutes: 10, incrementalMinutes: 2, intervalMs: 60_000, retentionMinutes: 90, diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index fae9006..c8acd5b 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -87,10 +87,84 @@ function kickRepaint(map: maplibregl.Map | null) { } } +function onMapStyleReady(map: maplibregl.Map, callback: () => void) { + if (map.isStyleLoaded()) { + callback(); + return () => { + // noop + }; + } + + let fired = false; + const runOnce = () => { + if (fired || !map.isStyleLoaded()) return; + fired = true; + callback(); + try { + map.off("style.load", runOnce); + map.off("styledata", runOnce); + map.off("idle", runOnce); + } catch { + // ignore + } + }; + + map.on("style.load", runOnce); + map.on("styledata", runOnce); + map.on("idle", runOnce); + + return () => { + if (fired) return; + fired = true; + try { + map.off("style.load", runOnce); + map.off("styledata", runOnce); + map.off("idle", runOnce); + } catch { + // ignore + } + }; +} + const DEG2RAD = Math.PI / 180; const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); +function rgbToHex(rgb: [number, number, number]) { + const toHex = (v: number) => { + const clamped = Math.max(0, Math.min(255, Math.round(v))); + return clamped.toString(16).padStart(2, "0"); + }; + + return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`; +} + +function lightenColor(rgb: [number, number, number], ratio = 0.32) { + const out = rgb.map((v) => Math.round(v + (255 - v) * ratio) as number) as [number, number, number]; + return out; +} + +function getGlobeShipColor({ + selected, + legacy, + sog, +}: { + selected: boolean; + legacy: string | null; + sog: number | null; +}) { + if (selected) return "rgba(255,255,255,0.98)"; + if (legacy) { + const rgb = LEGACY_CODE_COLORS[legacy]; + if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); + } + + if (!isFiniteNumber(sog)) return "rgba(100,116,139,0.75)"; + if (sog >= 10) return "#3b82f6"; + if (sog >= 1) return "#22c55e"; + return "rgba(100,116,139,0.75)"; +} + const LEGACY_CODE_COLORS: Record = { PT: [30, 64, 175], // #1e40af "PT-S": [234, 88, 12], // #ea580c @@ -100,15 +174,6 @@ const LEGACY_CODE_COLORS: Record = { FC: [245, 158, 11], // #f59e0b }; -const LEGACY_CODE_HEX: Record = { - PT: "#1e40af", - "PT-S": "#ea580c", - GN: "#10b981", - OT: "#8b5cf6", - PS: "#ef4444", - FC: "#f59e0b", -}; - const DEPTH_DISABLED_PARAMS = { // In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated. // For 2D overlays like zones/icons/halos we want stable painter's-order rendering @@ -480,6 +545,14 @@ export function Map3D({ const projectionRef = useRef(projection); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); + const pulseMapSync = () => { + setMapSyncEpoch((prev) => prev + 1); + requestAnimationFrame(() => { + kickRepaint(mapRef.current); + setMapSyncEpoch((prev) => prev + 1); + }); + }; + useEffect(() => { showSeamarkRef.current = settings.showSeamark; }, [settings.showSeamark]); @@ -561,7 +634,7 @@ export function Map3D({ } // Ensure the seamark raster overlay exists even when using MapTiler vector styles. - map.on("style.load", () => { + onMapStyleReady(map, () => { applyProjection(); // Globe deck layer lives inside the style and must be re-added after any style swap. if (projectionRef.current === "globe" && globeDeckLayerRef.current && !map!.getLayer(globeDeckLayerRef.current.id)) { @@ -599,16 +672,16 @@ export function Map3D({ } } - map.once("load", () => { - if (showSeamarkRef.current) { - try { - ensureSeamarkOverlay(map!, "bathymetry-lines"); - } catch { - // ignore - } - applySeamarkOpacity(); + map.once("load", () => { + if (showSeamarkRef.current) { + try { + ensureSeamarkOverlay(map!, "bathymetry-lines"); + } catch { + // ignore } - }); + applySeamarkOpacity(); + } + }); })(); return () => { @@ -658,7 +731,7 @@ export function Map3D({ } const next = projection; - try { + try { map.setProjection({ type: next }); map.setRenderWorldCopies(next !== "globe"); } catch (e) { @@ -740,20 +813,20 @@ export function Map3D({ } catch { // ignore } - - setMapSyncEpoch((prev) => prev + 1); + pulseMapSync(); }; if (map.isStyleLoaded()) syncProjectionAndDeck(); - else map.once("style.load", syncProjectionAndDeck); + else { + const stop = onMapStyleReady(map, syncProjectionAndDeck); + return () => { + cancelled = true; + stop(); + }; + } return () => { cancelled = true; - try { - map.off("style.load", syncProjectionAndDeck); - } catch { - // ignore - } }; }, [projection]); @@ -764,6 +837,7 @@ export function Map3D({ let cancelled = false; const controller = new AbortController(); + let stop: (() => void) | null = null; (async () => { try { @@ -772,10 +846,10 @@ export function Map3D({ // Disable style diff to avoid warnings with custom layers (Deck MapboxOverlay) and // to ensure a clean rebuild when switching between very different styles. map.setStyle(style, { diff: false }); - map.once("style.load", () => { + stop = onMapStyleReady(map, () => { kickRepaint(map); requestAnimationFrame(() => kickRepaint(map)); - setMapSyncEpoch((prev) => prev + 1); + pulseMapSync(); }); } catch (e) { if (cancelled) return; @@ -786,6 +860,7 @@ export function Map3D({ return () => { cancelled = true; controller.abort(); + stop?.(); }; }, [baseMap]); @@ -845,14 +920,9 @@ export function Map3D({ } }; - if (map.isStyleLoaded()) apply(); - map.on("style.load", apply); + const stop = onMapStyleReady(map, apply); return () => { - try { - map.off("style.load", apply); - } catch { - // ignore - } + stop(); }; }, [projection, baseMap, mapSyncEpoch]); @@ -975,14 +1045,9 @@ export function Map3D({ } }; - if (map.isStyleLoaded()) ensure(); - map.on("style.load", ensure); + const stop = onMapStyleReady(map, ensure); return () => { - try { - map.off("style.load", ensure); - } catch { - // ignore - } + stop(); }; }, [zones, overlays.zones, projection, baseMap, mapSyncEpoch]); @@ -1040,22 +1105,9 @@ export function Map3D({ ctx.fillRect(size / 2 - 8, 34, 16, 18); const img = ctx.getImageData(0, 0, size, size); - map.addImage(imgId, img, { pixelRatio: 2 }); + map.addImage(imgId, img, { pixelRatio: 2, sdf: true }); }; - const speedColorExpr: unknown[] = [ - "case", - [">=", ["to-number", ["get", "sog"]], 10], - "#3b82f6", - [">=", ["to-number", ["get", "sog"]], 1], - "#22c55e", - "#64748b", - ]; - - const codeColorExpr: unknown[] = ["match", ["get", "code"]]; - for (const [k, hex] of Object.entries(LEGACY_CODE_HEX)) codeColorExpr.push(k, hex); - codeColorExpr.push(speedColorExpr); - const ensure = () => { if (!map.isStyleLoaded()) return; @@ -1099,6 +1151,11 @@ export function Map3D({ name: t.name || "", cog: cogNorm, sog: isFiniteNumber(t.sog) ? t.sog : 0, + shipColor: getGlobeShipColor({ + selected, + legacy: legacy?.shipCode || null, + sog: isFiniteNumber(t.sog) ? t.sog : null, + }), iconSize3, iconSize7, iconSize10, @@ -1149,7 +1206,7 @@ export function Map3D({ layout: { visibility }, paint: { "circle-radius": circleRadius as never, - "circle-color": codeColorExpr as never, + "circle-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "circle-opacity": 0.22, }, } as unknown as LayerSpecification, @@ -1177,7 +1234,7 @@ export function Map3D({ paint: { "circle-radius": circleRadius as never, "circle-color": "rgba(0,0,0,0)", - "circle-stroke-color": codeColorExpr as never, + "circle-stroke-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "circle-stroke-width": [ "case", ["boolean", ["get", "permitted"], false], @@ -1229,10 +1286,13 @@ export function Map3D({ "icon-rotate": ["to-number", ["get", "cog"], 0], // Keep the icon on the sea surface. "icon-rotation-alignment": "map", - "icon-pitch-alignment": "map", + "icon-pitch-alignment": "viewport", }, paint: { + "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "icon-opacity": ["case", ["==", ["get", "selected"], 1], 1.0, 0.92], + "icon-halo-color": "rgba(15,23,42,0.25)", + "icon-halo-width": 1, }, } as unknown as LayerSpecification, before, @@ -1252,14 +1312,9 @@ export function Map3D({ kickRepaint(map); }; - ensure(); - map.on("style.load", ensure); + const stop = onMapStyleReady(map, ensure); return () => { - try { - map.off("style.load", ensure); - } catch { - // ignore - } + stop(); }; }, [projection, settings.showShips, targets, legacyHits, selectedMmsi, mapSyncEpoch]); @@ -1277,9 +1332,19 @@ export function Map3D({ const onClick = (e: maplibregl.MapMouseEvent) => { try { const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); - const feats = layerIds.length > 0 ? map.queryRenderedFeatures(e.point, { layers: layerIds }) : []; + let feats: unknown[] = []; + if (layerIds.length > 0) { + try { + feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[]; + } catch { + feats = []; + } + } const f = feats?.[0]; - const props = (f?.properties || {}) as Record; + const props = ((f as { properties?: Record } | undefined)?.properties || {}) as Record< + string, + unknown + >; const mmsi = Number(props.mmsi); if (Number.isFinite(mmsi)) { onSelectMmsi(mmsi); @@ -1399,14 +1464,10 @@ export function Map3D({ kickRepaint(map); }; + const stop = onMapStyleReady(map, ensure); ensure(); - map.on("style.load", ensure); return () => { - try { - map.off("style.load", ensure); - } catch { - // ignore - } + stop(); remove(); }; }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch]); @@ -1495,14 +1556,10 @@ export function Map3D({ kickRepaint(map); }; + const stop = onMapStyleReady(map, ensure); ensure(); - map.on("style.load", ensure); return () => { - try { - map.off("style.load", ensure); - } catch { - // ignore - } + stop(); remove(); }; }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch]); @@ -1582,14 +1639,10 @@ export function Map3D({ kickRepaint(map); }; + const stop = onMapStyleReady(map, ensure); ensure(); - map.on("style.load", ensure); return () => { - try { - map.off("style.load", ensure); - } catch { - // ignore - } + stop(); remove(); }; }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch]); @@ -1684,14 +1737,10 @@ export function Map3D({ kickRepaint(map); }; + const stop = onMapStyleReady(map, ensure); ensure(); - map.on("style.load", ensure); return () => { - try { - map.off("style.load", ensure); - } catch { - // ignore - } + stop(); remove(); }; }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch]);