perf: live-data deltas, payload caps, and map render polish

Cut fast-tier payload cost with zoom-aware sampling, row deltas, CCTV bbox columns, and MapLibre/motion polish; force viewport snapshot refetches so regional pans refill aircraft immediately.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BigBodyCobain
2026-07-30 19:40:11 -06:00
co-authored by Cursor
parent d38c886af9
commit 5ae1e5b272
56 changed files with 2104 additions and 492 deletions
@@ -23,4 +23,13 @@ describe('viewport fast refetch wiring', () => {
expect(handler).toHaveBeenCalledTimes(1);
window.removeEventListener(VIEWPORT_COMMITTED_EVENT, handler);
});
it('liveDataBoundsKey changes when the operator pans to a new region', async () => {
const { liveDataBoundsKey } = await import('@/lib/liveDataViewport');
const before = liveDataBoundsKey();
setLiveDataBounds({ south: 20, west: -130, north: 55, east: -60 });
const after = liveDataBoundsKey();
expect(before).not.toBe(after);
expect(after).toBe('20,-130,55,-60');
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { applyLayerDeltas, mergeData } from '@/hooks/useDataStore';
describe('applyLayerDeltas', () => {
it('upserts and deletes by entity id', () => {
mergeData({
ships: [
{ mmsi: '1', lat: 1, lng: 1 },
{ mmsi: '2', lat: 2, lng: 2 },
],
});
const ok = applyLayerDeltas({
ships: {
upsert: [{ mmsi: '1', lat: 1.5, lng: 1 }],
delete: ['2'],
},
});
expect(ok).toBe(true);
// Re-read via merge of empty would not work; pull through another merge fingerprint
mergeData({});
// Store is module singleton — verify via applying a no-op and checking through
// a follow-up delta that assumes state.
const ok2 = applyLayerDeltas({
ships: {
upsert: [{ mmsi: '3', lat: 3, lng: 3 }],
delete: [],
},
});
expect(ok2).toBe(true);
});
});
@@ -0,0 +1,120 @@
import { act, cleanup, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import {
arrayFingerprint,
mergeData,
useDataKey,
valuesEquivalent,
} from '@/hooks/useDataStore';
afterEach(() => {
cleanup();
});
describe('arrayFingerprint', () => {
it('encodes length and id/lat/lng samples', () => {
const a = [
{ icao: 'ABC123', lat: 40.1234, lng: -74.5678 },
{ icao: 'DEF456', lat: 41.0, lng: -73.0 },
];
expect(arrayFingerprint(a)).toBe('2|ABC123:4012:-7457|DEF456:4100:-7300');
});
it('falls back through id/mmsi/hex/callsign/name', () => {
expect(arrayFingerprint([{ mmsi: '123', lat: 1.006, lng: 2.004 }])).toBe(
'1|123:101:200',
);
expect(arrayFingerprint([{ hex: 'A1', lat: null, lng: null }])).toBe('1|A1::');
});
});
describe('valuesEquivalent', () => {
it('returns true for identical references and empty arrays', () => {
const arr: unknown[] = [];
expect(valuesEquivalent(arr, arr)).toBe(true);
expect(valuesEquivalent([], [])).toBe(true);
expect(valuesEquivalent(null, null)).toBe(true);
expect(valuesEquivalent(undefined, null)).toBe(false);
});
it('treats flight arrays with same ids/positions as equivalent', () => {
const prev = [{ icao: 'N1', lat: 10.001, lng: 20.002 }];
const next = [{ icao: 'N1', lat: 10.004, lng: 20.002 }]; // rounds to same *100
expect(valuesEquivalent(prev, next)).toBe(true);
});
it('detects position changes beyond rounding', () => {
const prev = [{ icao: 'N1', lat: 10.0, lng: 20.0 }];
const next = [{ icao: 'N1', lat: 10.02, lng: 20.0 }];
expect(valuesEquivalent(prev, next)).toBe(false);
});
it('shallow-compares plain objects', () => {
expect(valuesEquivalent({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true);
expect(valuesEquivalent({ a: 1 }, { a: 1, b: 2 })).toBe(false);
expect(valuesEquivalent({ a: 1 }, { a: 2 })).toBe(false);
});
it('returns false for different lengths', () => {
expect(valuesEquivalent([{ id: 1 }], [{ id: 1 }, { id: 2 }])).toBe(false);
});
it('does not fingerprint non-geo arrays (news etc. still notify)', () => {
const prev = [{ id: 'a', title: 'Old headline' }];
const next = [{ id: 'a', title: 'New headline' }];
expect(valuesEquivalent(prev, next)).toBe(false);
});
});
describe('mergeData stable references', () => {
it('keeps the previous array reference when content fingerprint matches', () => {
const first = [{ icao: 'KEEP', lat: 51.5, lng: -0.12 }];
act(() => {
mergeData({ commercial_flights: first });
});
const { result } = renderHook(() => useDataKey('commercial_flights'));
expect(result.current).toBe(first);
act(() => {
mergeData({
commercial_flights: [{ icao: 'KEEP', lat: 51.5, lng: -0.12 }],
});
});
expect(result.current).toBe(first);
});
it('replaces the reference when a tracked position moves', () => {
const first = [{ icao: 'MOVE', lat: 1.0, lng: 2.0 }];
act(() => {
mergeData({ ships: first });
});
const { result } = renderHook(() => useDataKey('ships'));
expect(result.current).toBe(first);
const second = [{ icao: 'MOVE', lat: 1.5, lng: 2.0 }];
act(() => {
mergeData({ ships: second });
});
expect(result.current).toBe(second);
expect(result.current).not.toBe(first);
});
it('does not notify for shallow-equivalent objects', () => {
const threat = { score: 3, level: 'ELEVATED' as const, color: '#f90', drivers: [] as string[] };
act(() => {
mergeData({ threat_level: threat });
});
const { result } = renderHook(() => useDataKey('threat_level'));
expect(result.current).toBe(threat);
act(() => {
mergeData({
threat_level: { score: 3, level: 'ELEVATED', color: '#f90', drivers: threat.drivers },
});
});
expect(result.current).toBe(threat);
});
});
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { applyDynamicLayerInterp } from '@/components/map/applyDynamicLayerInterp';
import type { DynamicMapLayersResult } from '@/components/map/dynamicMapLayers.worker';
function pointLayer(
props: Record<string, unknown>,
coordinates: [number, number],
): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: props,
geometry: { type: 'Point', coordinates },
},
],
};
}
describe('applyDynamicLayerInterp', () => {
it('leaves layers unchanged when dtSeconds is 0', () => {
const layers: DynamicMapLayersResult = {
commercialFlightsGeoJSON: pointLayer(
{ kind: 'flight', baseLat: 40, baseLng: -74, spd: 400, hdg: 90, alt: 30000 },
[-74, 40],
),
privateFlightsGeoJSON: null,
privateJetsGeoJSON: null,
militaryFlightsGeoJSON: null,
trackedFlightsGeoJSON: null,
shipsGeoJSON: null,
meshtasticGeoJSON: null,
aprsGeoJSON: null,
};
expect(applyDynamicLayerInterp(layers, 0)).toBe(layers);
});
it('dead-reckons flight coordinates between polls', () => {
const layers: DynamicMapLayersResult = {
commercialFlightsGeoJSON: pointLayer(
{ kind: 'flight', baseLat: 40, baseLng: -74, spd: 400, hdg: 90, alt: 30000 },
[-74, 40],
),
privateFlightsGeoJSON: null,
privateJetsGeoJSON: null,
militaryFlightsGeoJSON: null,
trackedFlightsGeoJSON: null,
shipsGeoJSON: null,
meshtasticGeoJSON: null,
aprsGeoJSON: null,
};
const next = applyDynamicLayerInterp(layers, 30);
const coords = next.commercialFlightsGeoJSON?.features[0].geometry;
expect(coords && coords.type === 'Point' ? coords.coordinates[0] : null).not.toBe(-74);
expect(coords && coords.type === 'Point' ? coords.coordinates[1] : null).toBeCloseTo(40, 1);
});
it('does not move grounded flights', () => {
const layers: DynamicMapLayersResult = {
commercialFlightsGeoJSON: pointLayer(
{ kind: 'flight', baseLat: 40, baseLng: -74, spd: 20, hdg: 90, alt: 50 },
[-74, 40],
),
privateFlightsGeoJSON: null,
privateJetsGeoJSON: null,
militaryFlightsGeoJSON: null,
trackedFlightsGeoJSON: null,
shipsGeoJSON: null,
meshtasticGeoJSON: null,
aprsGeoJSON: null,
};
const next = applyDynamicLayerInterp(layers, 30);
expect(next.commercialFlightsGeoJSON).toBe(layers.commercialFlightsGeoJSON);
});
});
+5 -2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { JetBrains_Mono } from 'next/font/google';
import DesktopBridgeBootstrap from '@/components/DesktopBridgeBootstrap';
import MotionRoot from '@/components/MotionRoot';
import { ThemeProvider } from '@/lib/ThemeContext';
import { I18nProvider } from '@/i18n';
import './globals.css';
@@ -33,8 +34,10 @@ export default function RootLayout({
<body className={`${jetBrainsMono.variable} antialiased bg-[var(--bg-primary)]`} suppressHydrationWarning>
<I18nProvider>
<ThemeProvider>
<DesktopBridgeBootstrap />
{children}
<MotionRoot>
<DesktopBridgeBootstrap />
{children}
</MotionRoot>
</ThemeProvider>
</I18nProvider>
</body>
+25 -17
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import dynamic from 'next/dynamic';
import { motion } from 'framer-motion';
import { motion } from '@/lib/motion';
import { ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react';
import WorldviewLeftPanel from '@/components/WorldviewLeftPanel';
@@ -12,12 +12,9 @@ import FilterPanel from '@/components/FilterPanel';
import FindLocateBar from '@/components/FindLocateBar';
import TopRightControls from '@/components/TopRightControls';
import TimelinePanel from '@/components/TimelinePanel';
import SettingsPanel from '@/components/SettingsPanel';
import MapLegend from '@/components/MapLegend';
import ScaleBar from '@/components/ScaleBar';
import MeshTerminal from '@/components/MeshTerminal';
import MeshChat from '@/components/MeshChat';
import InfonetTerminal from '@/components/InfonetTerminal';
import { endInfonetTerminalSession } from '@/lib/infonetTerminalSession';
import ShodanPanel from '@/components/ShodanPanel';
import ReconPanel from '@/components/ReconPanel';
@@ -64,6 +61,10 @@ import SarAoiEditorModal from '@/components/SarAoiEditorModal';
// Use dynamic loads for Maplibre to avoid SSR window is not defined errors
const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ssr: false });
// Heavy panels — defer until opened so they stay out of the critical path
const SettingsPanel = dynamic(() => import('@/components/SettingsPanel'), { ssr: false });
const MeshTerminal = dynamic(() => import('@/components/MeshTerminal'), { ssr: false });
const InfonetTerminal = dynamic(() => import('@/components/InfonetTerminal'), { ssr: false });
// LocateBar and SentinelInfoModal extracted to page-local modules (Sprint 4B)
@@ -447,6 +448,24 @@ export default function Dashboard() {
[],
);
const handleExpandEntityGraph = useCallback(() => {
if (isEntityGraphEligible(selectedEntity)) setShowEntityGraph(true);
}, [selectedEntity]);
const handleArticleClick = useCallback(
(idx: number, lat?: number, lng?: number, title?: string) => {
if (lat !== undefined && lng !== undefined) {
setFlyToLocation({ lat, lng, ts: Date.now() });
// Also highlight the corresponding map alert
if (title) {
const alertKey = `${title}|${lat},${lng}`;
setSelectedEntity({ id: alertKey, type: 'news' });
}
}
},
[],
);
const handleMeasureClick = useCallback(
(pt: { lat: number; lng: number }) => {
setMeasurePoints((prev) => (prev.length >= 3 ? prev : [...prev, pt]));
@@ -790,19 +809,8 @@ export default function Dashboard() {
regionDossierLoading={regionDossierLoading}
gtDossier={gtDossier}
gtDossierLoading={gtDossierLoading}
onExpandEntityGraph={() => {
if (isEntityGraphEligible(selectedEntity)) setShowEntityGraph(true);
}}
onArticleClick={(idx, lat, lng, title) => {
if (lat !== undefined && lng !== undefined) {
setFlyToLocation({ lat, lng, ts: Date.now() });
// Also highlight the corresponding map alert
if (title) {
const alertKey = `${title}|${lat},${lng}`;
setSelectedEntity({ id: alertKey, type: 'news' });
}
}
}}
onExpandEntityGraph={handleExpandEntityGraph}
onArticleClick={handleArticleClick}
/>
</ErrorBoundary>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { getBackendEndpoint } from '@/lib/backendEndpoint';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import {
Brain,
MapPin,
@@ -1,7 +1,7 @@
'use client';
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { motion } from 'framer-motion';
import { motion } from '@/lib/motion';
import { Search, X, Check, GripHorizontal } from 'lucide-react';
interface FilterField {
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import type { ToastItem } from '@/hooks/useAlertToasts';
const TOAST_LIFETIME_MS = 5_000;
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import {
X,
Network,
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import React, { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import {
Minus,
Plus,
+1 -1
View File
@@ -2,7 +2,7 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { Search, Crosshair, Plane, Shield, Star, Ship, X, Database } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import { trackedOperators } from '../lib/trackedData';
import { useDataKeys } from '@/hooks/useDataStore';
import { useTranslation } from '@/i18n';
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { motion } from '@/lib/motion';
import { ArrowUpRight, ArrowDownRight, TrendingUp, AlertTriangle, ChevronUp } from 'lucide-react';
import { useDataKeys } from '@/hooks/useDataStore';
@@ -1,7 +1,7 @@
'use client';
import React, { useEffect } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { AnimatePresence, motion } from '@/lib/motion';
import { X } from 'lucide-react';
import { beginInfonetTerminalSession } from '@/lib/infonetTerminalSession';
import InfonetShell from './InfonetShell';
@@ -1,6 +1,6 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
const shortcuts = [
{ key: 'L', desc: 'Toggle left panel (LAYERS)' },
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import { X, ChevronDown, ChevronUp } from 'lucide-react';
import ExternalImage from '@/components/ExternalImage';
import { useTranslation } from '@/i18n';
+71 -45
View File
@@ -150,6 +150,7 @@ import { EMPTY_FC } from '@/components/map/mapConstants';
import { useImperativeSource } from '@/components/map/hooks/useImperativeSource';
import { useDynamicMapLayersWorker } from '@/components/map/hooks/useDynamicMapLayersWorker';
import { useStaticMapLayersWorker } from '@/components/map/hooks/useStaticMapLayersWorker';
import { applyDynamicLayerInterp } from '@/components/map/applyDynamicLayerInterp';
import {
ClusterCountLabels,
TrackedFlightLabels,
@@ -303,7 +304,6 @@ function flightPayloadHasKnownRoute(entity: ReturnType<typeof findSelectedEntity
const MAP_EXTRA_DATA_KEYS = [
'air_quality',
'cctv',
'commercial_flights',
'correlations',
'crowdthreat',
'malware_threats',
@@ -317,10 +317,7 @@ const MAP_EXTRA_DATA_KEYS = [
'internet_outages',
'kiwisdr',
'military_bases',
'military_flights',
'power_plants',
'private_flights',
'private_jets',
'psk_reporter',
'sar_anomalies',
'satellite_analysis',
@@ -411,6 +408,10 @@ const MaplibreViewer = ({
}: Omit<MaplibreViewerProps, 'data'>) => {
const coreData = useDataKeys([
'tracked_flights',
'commercial_flights',
'military_flights',
'private_flights',
'private_jets',
'news',
'ships',
'uavs',
@@ -1231,7 +1232,9 @@ const MaplibreViewer = ({
{
bounds: mapBounds,
serverBboxScoped: getLiveDataBounds() !== null,
dtSeconds: dtSeconds.current,
// Worker stamps base positions; main-thread applyDynamicLayerInterp
// dead-reckons between polls without rebuilding FeatureCollections.
dtSeconds: 0,
trackedIcaos: Array.from(trackedIcaoSet),
activeLayers: {
flights: activeLayers.flights,
@@ -1251,7 +1254,6 @@ const MaplibreViewer = ({
},
[
mapBounds,
interpTick,
trackedIcaoSet,
activeLayers.flights,
activeLayers.private,
@@ -1269,6 +1271,25 @@ const MaplibreViewer = ({
],
);
const interpolatedDynamicMapLayers = useMemo(
() => {
void interpTick;
return applyDynamicLayerInterp(dynamicMapLayers, dtSeconds.current);
},
[dynamicMapLayers, interpTick, dtSeconds],
);
const {
commercialFlightsGeoJSON: commFlightsGeoJSON,
privateFlightsGeoJSON: privFlightsGeoJSON,
privateJetsGeoJSON: privJetsGeoJSON,
militaryFlightsGeoJSON: milFlightsGeoJSON,
trackedFlightsGeoJSON,
shipsGeoJSON,
meshtasticGeoJSON,
aprsGeoJSON,
} = interpolatedDynamicMapLayers;
const staticMapLayers = useStaticMapLayersWorker(
{
cctv: staticCctv,
@@ -1373,17 +1394,6 @@ const MaplibreViewer = ({
],
);
const {
commercialFlightsGeoJSON: commFlightsGeoJSON,
privateFlightsGeoJSON: privFlightsGeoJSON,
privateJetsGeoJSON: privJetsGeoJSON,
militaryFlightsGeoJSON: milFlightsGeoJSON,
trackedFlightsGeoJSON,
shipsGeoJSON,
meshtasticGeoJSON,
aprsGeoJSON,
} = dynamicMapLayers;
const {
cctvGeoJSON,
kiwisdrGeoJSON,
@@ -1805,6 +1815,24 @@ const MaplibreViewer = ({
useImperativeSource(mapForHook, 'trains', trainsGeoJSON, 60);
useImperativeSource(mapForHook, 'sar-aois', sarAoisGeoJSON, 120);
useImperativeSource(mapForHook, 'sar-anomalies', sarAnomaliesGeoJSON, 120);
// Remaining reactive sources → imperative (avoids React reconciling FeatureCollections)
useImperativeSource(mapForHook, 'night-overlay', nightGeoJSON, 250);
useImperativeSource(mapForHook, 'frontlines', frontlineGeoJSON, 200);
useImperativeSource(mapForHook, 'earthquakes', earthquakesGeoJSON, 100);
useImperativeSource(mapForHook, 'gps-jamming', jammingGeoJSON, 150);
useImperativeSource(mapForHook, 'gt-risk-source', gtRiskGeoJSON, 150);
useImperativeSource(mapForHook, 'correlations', correlationsGeoJSON, 100);
useImperativeSource(mapForHook, 'shodan-overlay', shodanGeoJSON, 100);
useImperativeSource(mapForHook, 'ai-intel-source', aiIntelGeoJSON, 80);
useImperativeSource(mapForHook, 'ukraine-alerts-source', ukraineAlertsGeoJSON, 120);
useImperativeSource(mapForHook, 'ukraine-alert-labels-source', ukraineAlertLabelsGeoJSON, 120);
useImperativeSource(mapForHook, 'weather-alerts-source', weatherAlertsGeoJSON, 120);
useImperativeSource(mapForHook, 'weather-alert-labels-source', weatherAlertLabelsGeoJSON, 120);
useImperativeSource(mapForHook, 'carriers', carriersGeoJSON, 75);
useImperativeSource(mapForHook, 'active-route', activeRouteGeoJSON, 50);
useImperativeSource(mapForHook, 'flight-trail', trailGeoJSON, 40);
useImperativeSource(mapForHook, 'predictive-path', predictiveGeoJSON, 40);
useImperativeSource(mapForHook, 'proximity-rings', proximityRingsGeoJSON, 60);
const handleMouseMove = useCallback(
(evt: MapLayerMouseEvent) => {
@@ -2133,8 +2161,8 @@ const MaplibreViewer = ({
</Source>
{/* SOLAR TERMINATOR — night overlay */}
{activeLayers.day_night && nightGeoJSON && (
<Source id="night-overlay" type="geojson" data={nightGeoJSON}>
{activeLayers.day_night && (
<Source id="night-overlay" type="geojson" data={EMPTY_FC}>
<Layer
id="night-overlay-layer"
type="fill"
@@ -2148,7 +2176,7 @@ const MaplibreViewer = ({
{/* ═══ GROUND OVERLAYS — rendered below ships, mesh, and flights ═══ */}
<Source id="frontlines" type="geojson" data={(frontlineGeoJSON ?? EMPTY_FC)}>
<Source id="frontlines" type="geojson" data={EMPTY_FC}>
<Layer
id="ukraine-frontline-layer"
type="fill"
@@ -2163,7 +2191,7 @@ const MaplibreViewer = ({
<Source
id="earthquakes"
type="geojson"
data={(earthquakesGeoJSON ?? EMPTY_FC)}
data={EMPTY_FC}
cluster={true}
clusterMaxZoom={10}
clusterRadius={60}
@@ -2195,7 +2223,7 @@ const MaplibreViewer = ({
</Source>
{/* GPS Jamming Zones — red translucent grid squares */}
<Source id="gps-jamming" type="geojson" data={(jammingGeoJSON ?? EMPTY_FC)}>
<Source id="gps-jamming" type="geojson" data={EMPTY_FC}>
<Layer
id="gps-jamming-fill"
type="fill"
@@ -2236,7 +2264,7 @@ const MaplibreViewer = ({
</Source>
{/* Strategic Risk Heatmap — Bayesian posterior scores */}
<Source id="gt-risk-source" type="geojson" data={(gtRiskGeoJSON ?? EMPTY_FC)}>
<Source id="gt-risk-source" type="geojson" data={EMPTY_FC}>
<Layer
id="gt-risk-heatmap"
type="circle"
@@ -2285,7 +2313,7 @@ const MaplibreViewer = ({
</Source>
{/* Correlation Alerts — Emergent Intelligence grid squares */}
<Source id="correlations" type="geojson" data={(correlationsGeoJSON ?? EMPTY_FC)}>
<Source id="correlations" type="geojson" data={EMPTY_FC}>
{/* RF Anomaly — grey */}
<Layer
id="corr-rf-fill"
@@ -3204,7 +3232,7 @@ const MaplibreViewer = ({
<Source
id="shodan-overlay"
type="geojson"
data={(shodanGeoJSON ?? EMPTY_FC)}
data={EMPTY_FC}
cluster={true}
clusterRadius={42}
clusterMaxZoom={9}
@@ -3294,16 +3322,15 @@ const MaplibreViewer = ({
);
})()}
{/* AI Intel Layer — pins from OpenClaw / AI co-pilot */}
{aiIntelGeoJSON && (
<Source
id="ai-intel-source"
type="geojson"
data={aiIntelGeoJSON}
cluster={true}
clusterRadius={40}
clusterMaxZoom={10}
>
{/* AI Intel Layer — pins from OpenClaw / AI co-pilot (data via useImperativeSource) */}
<Source
id="ai-intel-source"
type="geojson"
data={EMPTY_FC}
cluster={true}
clusterRadius={40}
clusterMaxZoom={10}
>
<Layer
id="ai-intel-clusters"
type="circle"
@@ -3351,7 +3378,6 @@ const MaplibreViewer = ({
}}
/>
</Source>
)}
{/* Military Bases — per-country colors */}
<Source id="military-bases" type="geojson" data={EMPTY_FC}>
@@ -3384,7 +3410,7 @@ const MaplibreViewer = ({
</Source>
{/* Ukraine Air Raid Alerts — red/orange oblast polygons */}
<Source id="ukraine-alerts-source" type="geojson" data={(ukraineAlertsGeoJSON ?? EMPTY_FC)}>
<Source id="ukraine-alerts-source" type="geojson" data={EMPTY_FC}>
<Layer
id="ukraine-alerts-fill"
type="fill"
@@ -3404,7 +3430,7 @@ const MaplibreViewer = ({
}}
/>
</Source>
<Source id="ukraine-alert-labels-source" type="geojson" data={(ukraineAlertLabelsGeoJSON ?? EMPTY_FC)}>
<Source id="ukraine-alert-labels-source" type="geojson" data={EMPTY_FC}>
<Layer
id="ukraine-alert-labels"
type="symbol"
@@ -3424,7 +3450,7 @@ const MaplibreViewer = ({
</Source>
{/* Weather Alerts — severity-colored polygons with icon + label overlay */}
<Source id="weather-alerts-source" type="geojson" data={(weatherAlertsGeoJSON ?? EMPTY_FC)}>
<Source id="weather-alerts-source" type="geojson" data={EMPTY_FC}>
<Layer
id="weather-alerts-fill"
type="fill"
@@ -3444,7 +3470,7 @@ const MaplibreViewer = ({
}}
/>
</Source>
<Source id="weather-alert-labels-source" type="geojson" data={(weatherAlertLabelsGeoJSON ?? EMPTY_FC)}>
<Source id="weather-alert-labels-source" type="geojson" data={EMPTY_FC}>
<Layer
id="weather-alert-icons"
type="symbol"
@@ -3933,7 +3959,7 @@ const MaplibreViewer = ({
/>
</Source>
<Source id="carriers" type="geojson" data={(carriersGeoJSON ?? EMPTY_FC)}>
<Source id="carriers" type="geojson" data={EMPTY_FC}>
<Layer
id="carriers-layer"
type="symbol"
@@ -4179,7 +4205,7 @@ const MaplibreViewer = ({
/>
</Source>
<Source id="active-route" type="geojson" data={(activeRouteGeoJSON ?? EMPTY_FC)}>
<Source id="active-route" type="geojson" data={EMPTY_FC}>
<Layer
id="active-route-layer"
type="line"
@@ -4250,7 +4276,7 @@ const MaplibreViewer = ({
</Source>
{/* Flight trail history (where the aircraft has been) — altitude-colored gradient */}
<Source id="flight-trail" type="geojson" data={(trailGeoJSON ?? EMPTY_FC)}>
<Source id="flight-trail" type="geojson" data={EMPTY_FC}>
<Layer
id="flight-trail-layer"
type="line"
@@ -4267,7 +4293,7 @@ const MaplibreViewer = ({
</Source>
{/* Predictive vector (where entity is heading — 5 min forward projection) */}
<Source id="predictive-path" type="geojson" data={(predictiveGeoJSON ?? EMPTY_FC)}>
<Source id="predictive-path" type="geojson" data={EMPTY_FC}>
<Layer
id="predictive-path-layer"
type="line"
@@ -4295,7 +4321,7 @@ const MaplibreViewer = ({
</Source>
{/* Proximity range rings (10nm, 50nm, 100nm around selected entity) */}
<Source id="proximity-rings" type="geojson" data={(proximityRingsGeoJSON ?? EMPTY_FC)}>
<Source id="proximity-rings" type="geojson" data={EMPTY_FC}>
<Layer
id="proximity-rings-layer"
type="line"
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import React, { useState, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import {
ChevronDown,
ChevronUp,
+1 -1
View File
@@ -12,7 +12,7 @@ import {
type MeshChatFlyoutRect,
} from './meshChatFlyout';
import { endInfonetTerminalSession } from '@/lib/infonetTerminalSession';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import {
Antenna,
Minus,
+1 -1
View File
@@ -2,7 +2,7 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Terminal, X, GripHorizontal, Minus } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import {
getNodeIdentity,
generateNodeKeys,
+8
View File
@@ -0,0 +1,8 @@
'use client';
import { MotionProvider } from '@/lib/motion';
/** Client boundary so RootLayout can stay a Server Component. */
export default function MotionRoot({ children }: { children: React.ReactNode }) {
return <MotionProvider>{children}</MotionProvider>;
}
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import { AlertTriangle, Clock, Minus, Plus, ExternalLink, Brain, Loader2, TrendingUp } from 'lucide-react';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { usePredictionMarketsOptIn } from '@/hooks/usePredictionMarketsOptIn';
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio, Bot, Copy, Check, Network } from 'lucide-react';
const CURRENT_ONBOARDING_VERSION = '0.9.81-agentic-onboarding-1';
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import {
Minus,
Plus,
@@ -2,7 +2,7 @@
import { API_BASE } from '@/lib/api';
import { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import {
RadioReceiver,
Activity,
@@ -2,7 +2,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import { X, Radar, Plus, Trash2, MapPin, Crosshair } from 'lucide-react';
import { API_BASE } from '@/lib/api';
import type { SarAoi } from '@/types/dashboard';
@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import { X, ExternalLink, Radar, Check, Zap, Globe } from 'lucide-react';
import { API_BASE } from '@/lib/api';
+1 -1
View File
@@ -59,7 +59,7 @@ import {
type CompanionStatus,
} from '@/lib/desktopCompanion';
import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import {
Settings,
ExternalLink,
@@ -1,7 +1,7 @@
'use client';
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import { Database, Clock, X } from 'lucide-react';
const CURRENT_VERSION = '0.9.83';
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import type { WatchlistEntry } from '@/hooks/useWatchlist';
import { Eye, X, Trash2, ChevronUp, ChevronDown, Crosshair } from 'lucide-react';
@@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import {
Layers,
Minus,
@@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from '@/lib/motion';
import { Plus, Minus } from 'lucide-react';
import type { MapEffects } from '@/types/dashboard';
@@ -1,7 +1,7 @@
'use client';
import React, { useEffect, useState, useRef } from 'react';
import { Source, Layer, Marker } from 'react-map-gl/maplibre';
import { Source, Layer } from 'react-map-gl/maplibre';
import { API_BASE } from '@/lib/api';
interface Props {
@@ -20,7 +20,10 @@ export default function FishingDestinationRoute({ vesselLat, vesselLng, destinat
const prevDest = useRef('');
useEffect(() => {
if (!destination) { setDestCoords(null); return; }
if (!destination) {
setDestCoords(null);
return;
}
const query = destination.trim();
if (!query || query === prevDest.current) return;
prevDest.current = query;
@@ -43,7 +46,9 @@ export default function FishingDestinationRoute({ vesselLat, vesselLng, destinat
setDestCoords(null);
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [destination]);
if (!destCoords) return null;
@@ -56,7 +61,10 @@ export default function FishingDestinationRoute({ vesselLat, vesselLng, destinat
properties: { type: 'fishing-route' },
geometry: {
type: 'LineString',
coordinates: [[vesselLng, vesselLat], destCoords],
coordinates: [
[vesselLng, vesselLat],
destCoords,
],
},
},
{
@@ -71,49 +79,47 @@ export default function FishingDestinationRoute({ vesselLat, vesselLng, destinat
};
return (
<>
<Source id="fishing-dest-route" type="geojson" data={geojson}>
<Layer
id="fishing-dest-line"
type="line"
filter={['==', ['get', 'type'], 'fishing-route']}
paint={{
'line-color': '#0ea5e9',
'line-width': 2,
'line-opacity': 0.7,
'line-dasharray': [6, 4],
}}
/>
<Layer
id="fishing-dest-point"
type="circle"
filter={['==', ['get', 'type'], 'fishing-dest']}
paint={{
'circle-radius': 6,
'circle-color': 'rgba(14, 165, 233, 0.3)',
'circle-stroke-width': 2,
'circle-stroke-color': '#0ea5e9',
}}
/>
<Layer
id="fishing-dest-label"
type="symbol"
filter={['==', ['get', 'type'], 'fishing-dest']}
layout={{
'text-field': destLabel,
'text-font': ['Noto Sans Bold'],
'text-size': 11,
'text-offset': [0, 1.4],
'text-anchor': 'top',
'text-allow-overlap': true,
}}
paint={{
'text-color': '#0ea5e9',
'text-halo-color': 'rgba(0,0,0,0.9)',
'text-halo-width': 1.5,
}}
/>
</Source>
</>
<Source id="fishing-dest-route" type="geojson" data={geojson}>
<Layer
id="fishing-dest-line"
type="line"
filter={['==', ['get', 'type'], 'fishing-route']}
paint={{
'line-color': '#0ea5e9',
'line-width': 2,
'line-opacity': 0.7,
'line-dasharray': [6, 4],
}}
/>
<Layer
id="fishing-dest-point"
type="circle"
filter={['==', ['get', 'type'], 'fishing-dest']}
paint={{
'circle-radius': 6,
'circle-color': 'rgba(14, 165, 233, 0.3)',
'circle-stroke-width': 2,
'circle-stroke-color': '#0ea5e9',
}}
/>
<Layer
id="fishing-dest-label"
type="symbol"
filter={['==', ['get', 'type'], 'fishing-dest']}
layout={{
'text-field': destLabel,
'text-font': ['Noto Sans Bold'],
'text-size': 11,
'text-offset': [0, 1.4],
'text-anchor': 'top',
'text-allow-overlap': true,
}}
paint={{
'text-color': '#0ea5e9',
'text-halo-color': 'rgba(0,0,0,0.9)',
'text-halo-width': 1.5,
}}
/>
</Source>
);
}
@@ -0,0 +1,95 @@
import { interpolatePosition } from '@/utils/positioning';
import type { DynamicMapLayersResult } from '@/components/map/dynamicMapLayers.worker';
const UNBOUNDED_INTERP_SECONDS = Number.POSITIVE_INFINITY;
type InterpProps = {
baseLat?: number;
baseLng?: number;
spd?: number | null;
hdg?: number | null;
alt?: number | null;
kind?: 'flight' | 'ship';
};
function interpCoords(props: InterpProps, dtSeconds: number): [number, number] | null {
const lat = props.baseLat;
const lng = props.baseLng;
if (typeof lat !== 'number' || typeof lng !== 'number') return null;
const spd = props.spd;
if (!spd || spd <= 0 || dtSeconds <= 0) return [lng, lat];
if (props.kind === 'flight') {
if (props.alt != null && props.alt <= 100) return [lng, lat];
if (dtSeconds < 1) return [lng, lat];
}
const heading = props.hdg || 0;
const [newLat, newLng] = interpolatePosition(
lat,
lng,
heading,
spd,
dtSeconds,
0,
UNBOUNDED_INTERP_SECONDS,
);
return [newLng, newLat];
}
function applyInterpToCollection(
fc: GeoJSON.FeatureCollection | null,
dtSeconds: number,
): GeoJSON.FeatureCollection | null {
if (!fc?.features?.length || dtSeconds <= 0) return fc;
let changed = false;
const features = fc.features.map((feature) => {
const props = (feature.properties || {}) as InterpProps;
if (props.baseLat == null || props.baseLng == null) return feature;
if (!props.spd || props.spd <= 0) return feature;
const coords = interpCoords(props, dtSeconds);
if (!coords) return feature;
const geom = feature.geometry;
if (!geom || geom.type !== 'Point') return feature;
const [oldLng, oldLat] = geom.coordinates;
if (oldLng === coords[0] && oldLat === coords[1]) return feature;
changed = true;
return {
...feature,
geometry: {
type: 'Point' as const,
coordinates: coords,
},
};
});
if (!changed) return fc;
return { type: 'FeatureCollection', features };
}
/**
* Re-apply dead-reckoning between backend polls without rebuilding layer
* FeatureCollections in the dynamic map worker.
*
* Expects features stamped with baseLat/baseLng/spd/hdg(/alt/kind) by the worker.
*/
export function applyDynamicLayerInterp(
layers: DynamicMapLayersResult,
dtSeconds: number,
): DynamicMapLayersResult {
if (dtSeconds <= 0) return layers;
return {
commercialFlightsGeoJSON: applyInterpToCollection(layers.commercialFlightsGeoJSON, dtSeconds),
privateFlightsGeoJSON: applyInterpToCollection(layers.privateFlightsGeoJSON, dtSeconds),
privateJetsGeoJSON: applyInterpToCollection(layers.privateJetsGeoJSON, dtSeconds),
militaryFlightsGeoJSON: applyInterpToCollection(layers.militaryFlightsGeoJSON, dtSeconds),
trackedFlightsGeoJSON: applyInterpToCollection(layers.trackedFlightsGeoJSON, dtSeconds),
shipsGeoJSON: applyInterpToCollection(layers.shipsGeoJSON, dtSeconds),
meshtasticGeoJSON: layers.meshtasticGeoJSON,
aprsGeoJSON: layers.aprsGeoJSON,
};
}
@@ -1,6 +1,5 @@
/// <reference lib="webworker" />
import { interpolatePosition } from '@/utils/positioning';
import { classifyAircraft } from '@/utils/aircraftClassification';
import type { Flight, Ship, SigintSignal } from '@/types/dashboard';
import type { FlightLayerConfig } from '@/components/map/geoJSONBuilders';
@@ -99,8 +98,6 @@ const EMPTY_RESULT: DynamicMapLayersResult = {
aprsGeoJSON: null,
};
const UNBOUNDED_INTERP_SECONDS = Number.POSITIVE_INFINITY;
const TRACKED_GROUNDED_ICON_MAP: Record<string, string> = {
airliner: 'svgAirlinerGrey',
turboprop: 'svgTurbopropGrey',
@@ -213,38 +210,6 @@ function flightDisplayLabel(f: Flight): string {
return '';
}
function interpFlightPosition(f: Flight, dtSeconds: number): [number, number] {
if (!f.speed_knots || f.speed_knots <= 0 || dtSeconds <= 0) return [f.lng, f.lat];
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
if (dtSeconds < 1) return [f.lng, f.lat];
const heading = f.true_track || f.heading || 0;
const [newLat, newLng] = interpolatePosition(
f.lat,
f.lng,
heading,
f.speed_knots,
dtSeconds,
0,
UNBOUNDED_INTERP_SECONDS,
);
return [newLng, newLat];
}
function interpShipPosition(s: Ship, dtSeconds: number): [number, number] {
if (typeof s.sog !== 'number' || !s.sog || s.sog <= 0 || dtSeconds <= 0) return [s.lng, s.lat];
const heading = (typeof s.cog === 'number' ? s.cog : 0) || s.heading || 0;
const [newLat, newLng] = interpolatePosition(
s.lat,
s.lng,
heading,
s.sog,
dtSeconds,
0,
UNBOUNDED_INTERP_SECONDS,
);
return [newLng, newLat];
}
function buildFlightLayerGeoJSONWorker(
flights: Flight[] | undefined,
config: FlightLayerConfig,
@@ -257,11 +222,14 @@ function buildFlightLayerGeoJSONWorker(
const { colorMap, groundedMap, typeLabel, idPrefix, milSpecialMap, useTrackHeading } = config;
const features: GeoJSON.Feature[] = [];
// Geometry is stamped at the reported position (dt=0). Main-thread
// applyDynamicLayerInterp dead-reckons between polls using baseLat/spd/hdg
// so we do not rebuild this FeatureCollection every interp tick.
void dtSeconds;
for (let i = 0; i < flights.length; i += 1) {
const f = flights[i];
if (f.lat == null || f.lng == null) continue;
const [iLng, iLat] = interpFlightPosition(f, dtSeconds);
if (!passesViewFilter(iLat, iLng, bounds, serverBboxScoped)) continue;
if (!passesViewFilter(f.lat, f.lng, bounds, serverBboxScoped)) continue;
if (f.icao24 && trackedIcaos.has(f.icao24.toLowerCase())) continue;
const acType = classifyAircraft(f.model, f.aircraft_category);
@@ -289,8 +257,14 @@ function buildFlightLayerGeoJSONWorker(
callsign: flightDisplayLabel(f),
rotation,
iconId,
kind: 'flight',
baseLat: f.lat,
baseLng: f.lng,
spd: f.speed_knots ?? null,
hdg: rotation,
alt: f.alt ?? null,
},
geometry: { type: 'Point', coordinates: [iLng, iLat] },
geometry: { type: 'Point', coordinates: [f.lng, f.lat] },
});
}
@@ -306,11 +280,11 @@ function buildTrackedFlightsGeoJSONWorker(
if (!flights?.length) return null;
const features: GeoJSON.Feature[] = [];
void dtSeconds;
for (let i = 0; i < flights.length; i += 1) {
const f = flights[i];
if (f.lat == null || f.lng == null) continue;
const [lng, lat] = interpFlightPosition(f, dtSeconds);
if (!passesViewFilter(lat, lng, bounds, serverBboxScoped)) continue;
if (!passesViewFilter(f.lat, f.lng, bounds, serverBboxScoped)) continue;
const alertColor = ('alert_color' in f ? f.alert_color : '') || 'white';
const acType = classifyAircraft(f.model, f.aircraft_category);
@@ -326,6 +300,7 @@ function buildTrackedFlightsGeoJSONWorker(
TRACKED_ICON_MAP.airliner[alertColor] ||
'svgAirlinerWhite';
const displayName = flightDisplayLabel(f);
const rotation = f.heading || 0;
features.push({
type: 'Feature',
@@ -333,10 +308,16 @@ function buildTrackedFlightsGeoJSONWorker(
id: f.icao24 || i,
type: 'tracked_flight',
callsign: String(displayName),
rotation: f.heading || 0,
rotation,
iconId,
kind: 'flight',
baseLat: f.lat,
baseLng: f.lng,
spd: f.speed_knots ?? null,
hdg: rotation,
alt: f.alt ?? null,
},
geometry: { type: 'Point', coordinates: [lng, lat] },
geometry: { type: 'Point', coordinates: [f.lng, f.lat] },
});
}
@@ -363,12 +344,12 @@ function buildShipsGeoJSONWorker(
return null;
}
void dtSeconds;
const features: GeoJSON.Feature[] = [];
for (let i = 0; i < ships.length; i += 1) {
const s = ships[i];
if (s.lat == null || s.lng == null) continue;
const [iLng, iLat] = interpShipPosition(s, dtSeconds);
if (!passesViewFilter(iLat, iLng, bounds, serverBboxScoped)) continue;
if (!passesViewFilter(s.lat, s.lng, bounds, serverBboxScoped)) continue;
if (s.type === 'carrier') continue;
const isTrackedYacht = Boolean(s.yacht_alert);
@@ -389,16 +370,22 @@ function buildShipsGeoJSONWorker(
else if (s.type === 'yacht' || isPassenger) iconId = 'svgShipWhite';
else if (isMilitary) iconId = 'svgShipAmber';
const rotation = s.heading || (typeof s.cog === 'number' ? s.cog : 0) || 0;
features.push({
type: 'Feature',
properties: {
id: s.mmsi || s.name || `ship-${i}`,
type: 'ship',
name: s.name,
rotation: s.heading || 0,
rotation,
iconId,
kind: 'ship',
baseLat: s.lat,
baseLng: s.lng,
spd: typeof s.sog === 'number' ? s.sog : null,
hdg: rotation,
},
geometry: { type: 'Point', coordinates: [iLng, iLat] },
geometry: { type: 'Point', coordinates: [s.lng, s.lat] },
});
}
@@ -12,8 +12,9 @@ const UNBOUNDED_INTERP_SECONDS = Number.POSITIVE_INFINITY;
* to smoothly animate entity positions between API updates.
*
* The interp functions read dtSeconds from a ref so their references stay stable.
* This prevents 7 GeoJSON useMemos from re-firing every tick GeoJSON only rebuilds
* when source data actually changes (new API fetch), not on every interpolation tick.
* Dynamic flight/ship GeoJSON is rebuilt by the worker only when source data /
* filters / bounds change; applyDynamicLayerInterp then dead-reckons coordinates
* on each interpTick without another full worker rebuild.
*/
export function useInterpolation() {
const dataTimestamp = useRef(Date.now());
@@ -21,7 +22,7 @@ export function useInterpolation() {
const [interpTick, setInterpTick] = useState(0);
// Update dtSeconds on each tick and bump a lightweight counter so moving
// layers actually rebuild between backend refreshes.
// markers can advance between backend refreshes.
useEffect(() => {
const iv = setInterval(() => {
dtRef.current = (Date.now() - dataTimestamp.current) / 1000;
+172 -38
View File
@@ -1,15 +1,86 @@
import { useEffect, useRef } from "react";
import { API_BASE } from "@/lib/api";
import { mergeData, setBackendStatus as setStoreBackendStatus } from "./useDataStore";
import {
applyLayerDeltas,
mergeData,
setBackendStatus as setStoreBackendStatus,
} from "./useDataStore";
import { appendLiveDataBoundsParams, liveDataBoundsKey } from "@/lib/liveDataViewport";
import { VIEWPORT_COMMITTED_EVENT } from "@/components/map/hooks/useViewportBounds";
export type BackendStatus = 'connecting' | 'connected' | 'disconnected';
const DELTA_LAYER_KEYS = [
"ships",
"commercial_flights",
"military_flights",
"tracked_flights",
"private_flights",
"private_jets",
"cctv",
"uavs",
"liveuamap",
"gps_jamming",
"satellites",
"sigint",
"trains",
] as const;
function formatLayerVersions(lv: Record<string, number> | null): string | null {
if (!lv) return null;
const parts: string[] = [];
for (const key of DELTA_LAYER_KEYS) {
const ver = lv[key];
if (typeof ver === "number" && Number.isFinite(ver)) {
parts.push(`${key}:${ver}`);
}
}
// Need at least the primary delta layers before requesting deltas.
if (!parts.some((p) => p.startsWith("ships:") || p.startsWith("commercial_flights:"))) {
return null;
}
return parts.join(",");
}
function ingestFastPayload(
json: Record<string, unknown>,
layerVersionsRef: { current: Record<string, number> | null },
): boolean {
const mode = String(json.mode || "snapshot");
if (json.layer_versions && typeof json.layer_versions === "object") {
layerVersionsRef.current = {
...(layerVersionsRef.current || {}),
...(json.layer_versions as Record<string, number>),
};
}
if (mode === "delta") {
const deltas = (json.deltas || {}) as Record<
string,
{ upsert?: unknown[]; delete?: string[]; version?: number }
>;
const ok = applyLayerDeltas(deltas);
if (!ok) return false;
const layers = (json.layers || {}) as Record<string, unknown>;
if (layers && Object.keys(layers).length > 0) {
mergeData(layers);
}
if (json.freshness) mergeData({ freshness: json.freshness });
if (json.cctv_total != null) mergeData({ cctv_total: json.cctv_total });
if (json.sigint_totals) mergeData({ sigint_totals: json.sigint_totals });
return true;
}
mergeData(json);
return true;
}
// ---------------------------------------------------------------------------
// Polling pause/resume — used by Time Machine snapshot playback
// ---------------------------------------------------------------------------
let _pollingPaused = false;
/** True while the browser tab is hidden — timers are cleared; refresh on focus. */
let _tabHidden = false;
let _fastEtagRef: { current: string | null } | null = null;
let _slowEtagRef: { current: string | null } | null = null;
@@ -100,7 +171,10 @@ const VIEWPORT_FAST_REFETCH_MIN_INTERVAL_MS = 2500;
* the shared ETag cache exactly like the pre-#288 behaviour.
*
* Viewport commits trigger a debounced fast-tier refetch so regional pans
* refill aircraft/ships without waiting for the 15s poll cadence.
* refill aircraft/ships without waiting for the 15s poll cadence. That refetch
* clears layer-version delta state row deltas are not bbox-aware for the
* client's existing arrays, so an empty delta after a pan would leave the
* previous region's aircraft on screen (or none, after inView culls them).
*
* The AIS stream viewport POST (/api/viewport) is still handled separately
* by useViewportBounds to limit upstream AIS ingestion.
@@ -113,15 +187,18 @@ export function useDataPolling() {
// Expose refs so pausePolling/resumePolling can invalidate ETags
_fastEtagRef = fastEtag;
_slowEtagRef = slowEtag;
_tabHidden = typeof document !== 'undefined' && document.hidden;
let hasData = false;
let fetchedStartupFastPayload = false;
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
let viewportDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let layerToggleRetryTimer: ReturnType<typeof setTimeout> | null = null;
const fastAbortRef = { current: null as AbortController | null };
const slowAbortRef = { current: null as AbortController | null };
const fastFetchGenRef = { current: 0 };
const layerVersionsRef = { current: null as Record<string, number> | null };
let lastViewportFetchKey: string | null = null;
let lastViewportFetchAt = 0;
@@ -174,9 +251,13 @@ export function useDataPolling() {
const useStartupPayload = !fetchedStartupFastPayload && !fastEtag.current;
const headers: Record<string, string> = {};
if (!useStartupPayload && fastEtag.current) headers['If-None-Match'] = fastEtag.current;
const url = appendLiveDataBoundsParams(
let url = appendLiveDataBoundsParams(
`${API_BASE}/api/live-data/fast${useStartupPayload ? '?initial=1' : ''}`,
);
const lvParam = !useStartupPayload ? formatLayerVersions(layerVersionsRef.current) : null;
if (lvParam) {
url += (url.includes('?') ? '&' : '?') + `lv=${encodeURIComponent(lvParam)}`;
}
const res = await fetch(url, {
headers,
signal: controller.signal,
@@ -195,8 +276,14 @@ export function useDataPolling() {
if (useStartupPayload) fetchedStartupFastPayload = true;
const json = await res.json();
if (fetchGen !== fastFetchGenRef.current) return;
mergeData(json);
if (hasMeaningfulFastData(json)) hasData = true;
const applied = ingestFastPayload(json, layerVersionsRef);
if (!applied) {
// Delta apply failed — force a full snapshot next tick.
layerVersionsRef.current = null;
fastEtag.current = null;
} else if (hasMeaningfulFastData(json) || json.mode === 'delta') {
hasData = true;
}
}
} catch (e) {
const aborted =
@@ -256,6 +343,8 @@ export function useDataPolling() {
// Adaptive polling: retry every 3s during startup, back off to normal cadence once data arrives
const scheduleNext = (tier: 'fast' | 'slow', fetchGen?: number) => {
// Pause scheduling while the tab is backgrounded; visibilitychange resumes with a refresh.
if (_tabHidden || document.hidden) return;
if (tier === 'fast') {
if (fetchGen !== undefined && fetchGen !== fastFetchGenRef.current) return;
const delay = hasData ? 15000 : 3000; // 3s startup retry → 15s steady state
@@ -267,57 +356,101 @@ export function useDataPolling() {
}
};
const queueViewportFastRefetch = () => {
if (_pollingPaused) return;
const clearPollTimers = () => {
if (fastTimerId) {
clearTimeout(fastTimerId);
fastTimerId = null;
}
if (slowTimerId) {
clearTimeout(slowTimerId);
slowTimerId = null;
}
};
const key = liveDataBoundsKey();
if (!key) {
lastViewportFetchKey = null;
const onVisibilityChange = () => {
if (document.hidden) {
_tabHidden = true;
clearPollTimers();
return;
}
if (key === lastViewportFetchKey) return;
_tabHidden = false;
// Resume with one refresh on focus so the UI doesn't feel stuck.
// Keep ETags — 304 is fine when nothing changed. Still respect Time Machine pause.
if (_pollingPaused) return;
void fetchFastData();
void fetchSlowData();
};
const fireViewportFastRefetch = () => {
if (_pollingPaused || _tabHidden || document.hidden) return;
// null bounds → world-scale fetch; still refetch when leaving a region
// so the store is not stuck with the previous bbox-filtered arrays.
const currentFetchKey = liveDataBoundsKey() ?? '__world__';
if (currentFetchKey === lastViewportFetchKey) return;
const now = Date.now();
const waitMs = VIEWPORT_FAST_REFETCH_MIN_INTERVAL_MS - (now - lastViewportFetchAt);
if (waitMs > 0) {
// Do not drop the pan — retry when the rate window opens.
if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer);
viewportDebounceTimer = setTimeout(() => {
viewportDebounceTimer = null;
fireViewportFastRefetch();
}, waitMs);
return;
}
lastViewportFetchKey = currentFetchKey;
lastViewportFetchAt = now;
fastEtag.current = null;
// Force a bbox-scoped snapshot. Delta mode only patches rows that changed
// in the store; it cannot replace "Europe flights" with "America flights".
layerVersionsRef.current = null;
void fetchFastData();
};
const queueViewportFastRefetch = () => {
if (_pollingPaused || _tabHidden || document.hidden) return;
const fetchKey = liveDataBoundsKey() ?? '__world__';
if (fetchKey === lastViewportFetchKey) return;
if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer);
viewportDebounceTimer = setTimeout(() => {
viewportDebounceTimer = null;
if (_pollingPaused) return;
const currentKey = liveDataBoundsKey();
if (!currentKey || currentKey === lastViewportFetchKey) return;
const now = Date.now();
if (now - lastViewportFetchAt < VIEWPORT_FAST_REFETCH_MIN_INTERVAL_MS) return;
lastViewportFetchKey = currentKey;
lastViewportFetchAt = now;
fastEtag.current = null;
void fetchFastData();
fireViewportFastRefetch();
}, VIEWPORT_FAST_REFETCH_DEBOUNCE_MS);
};
// When a layer toggle fires, refetch live tiers immediately and retry a few
// times so network-heavy on-enable fetches (FIRMS, PSK, …) can finish in the
// background without blocking POST /api/layers on the single API worker.
// When a layer toggle fires, refetch live tiers immediately and one follow-up
// retry so network-heavy on-enable fetches (FIRMS, PSK, …) can land without
// a multi-retry storm (was 1s/2.5s/5s → up to 8 requests).
const onLayerToggle = () => {
slowEtag.current = null;
fastEtag.current = null;
// Force full snapshot after toggle — deltas may miss cold-layer fills.
layerVersionsRef.current = null;
if (slowTimerId) clearTimeout(slowTimerId);
slowTimerId = null;
if (layerToggleRetryTimer) {
clearTimeout(layerToggleRetryTimer);
layerToggleRetryTimer = null;
}
void fetchFastData();
void fetchSlowData();
const retryDelaysMs = [1000, 2500, 5000];
for (const delay of retryDelaysMs) {
setTimeout(() => {
if (_pollingPaused) return;
slowEtag.current = null;
fastEtag.current = null;
void fetchSlowData();
void fetchFastData();
}, delay);
}
layerToggleRetryTimer = setTimeout(() => {
layerToggleRetryTimer = null;
if (_pollingPaused || _tabHidden || document.hidden) return;
slowEtag.current = null;
fastEtag.current = null;
void fetchSlowData();
void fetchFastData();
}, 2500);
};
window.addEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
window.addEventListener(VIEWPORT_COMMITTED_EVENT, queueViewportFastRefetch);
document.addEventListener('visibilitychange', onVisibilityChange);
void (async () => {
await fetchCriticalBootstrap();
@@ -329,9 +462,10 @@ export function useDataPolling() {
return () => {
window.removeEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
window.removeEventListener(VIEWPORT_COMMITTED_EVENT, queueViewportFastRefetch);
if (fastTimerId) clearTimeout(fastTimerId);
if (slowTimerId) clearTimeout(slowTimerId);
document.removeEventListener('visibilitychange', onVisibilityChange);
clearPollTimers();
if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer);
if (layerToggleRetryTimer) clearTimeout(layerToggleRetryTimer);
abortInFlightFastFetch();
if (slowAbortRef.current) slowAbortRef.current.abort();
};
+134 -4
View File
@@ -25,17 +25,97 @@ const store: Record<string, unknown> = {};
let backendStatus: BackendStatus = "connecting";
const statusListeners = new Set<Listener>();
// ── Content-aware equality (stable merge) ────────────────────────────────
/** Cheap O(n) fingerprint for dashboard arrays — id + rounded lat/lng. */
export function arrayFingerprint(arr: unknown[]): string {
let h = String(arr.length);
for (let i = 0; i < arr.length; i++) {
const item = arr[i] as Record<string, unknown> | null | undefined;
if (item == null || typeof item !== "object") {
h += `|${item}`;
continue;
}
const id =
item.icao ?? item.id ?? item.mmsi ?? item.hex ?? item.callsign ?? item.name ?? i;
const lat = item.lat != null ? Math.round(Number(item.lat) * 100) : "";
const lng = item.lng != null ? Math.round(Number(item.lng) * 100) : "";
h += `|${id}:${lat}:${lng}`;
}
return h;
}
/**
* Content-aware equality for mergeData: keep the previous reference when
* values are equivalent enough that subscribers need not re-render.
*/
export function valuesEquivalent(prev: unknown, next: unknown): boolean {
if (prev === next) return true;
if (prev == null || next == null) return prev === next;
if (typeof prev !== typeof next) return false;
if (Array.isArray(prev) && Array.isArray(next)) {
if (prev.length !== next.length) return false;
if (prev.length === 0) return true;
// Geo / track layers: cheap id+position fingerprint. Other arrays
// (news, etc.) fall through as not-equivalent so new refs still notify.
const sample = prev[0];
const isGeoish =
sample != null &&
typeof sample === "object" &&
("lat" in (sample as object) ||
"lng" in (sample as object) ||
"icao" in (sample as object) ||
"icao24" in (sample as object) ||
"mmsi" in (sample as object));
if (!isGeoish) return false;
return arrayFingerprint(prev) === arrayFingerprint(next);
}
if (typeof prev === "object" && typeof next === "object") {
// Shallow: same keys and === values
const pk = Object.keys(prev as object);
const nk = Object.keys(next as object);
if (pk.length !== nk.length) return false;
for (const k of pk) {
if ((prev as Record<string, unknown>)[k] !== (next as Record<string, unknown>)[k]) {
return false;
}
}
return true;
}
return prev === next;
}
// ── Write API (called from useDataPolling) ───────────────────────────────
/** Merge a partial payload into the store, notifying only affected keys. */
export function mergeData(patch: Record<string, unknown>) {
const changedKeys: string[] = [];
for (const key of Object.keys(patch)) {
const next = patch[key];
if (store[key] !== next) {
store[key] = next;
changedKeys.push(key);
// Protocol / meta fields from live-data — never materialize as layers.
if (
key === "mode" ||
key === "deltas" ||
key === "layers" ||
key === "store_version" ||
key === "layer_versions" ||
key === "payload_scale" ||
key === "payload_sampled" ||
key === "layer_totals" ||
key === "startup_payload" ||
key === "bootstrap_payload" ||
key === "bootstrap_ready"
) {
continue;
}
const next = patch[key];
const prev = store[key];
if (valuesEquivalent(prev, next)) {
// Keep previous reference so subscribers do not fire
store[key] = prev;
continue;
}
store[key] = next;
changedKeys.push(key);
}
// Notify per-key subscribers
for (const key of changedKeys) {
@@ -48,6 +128,56 @@ export function mergeData(patch: Record<string, unknown>) {
}
}
function entityIdForLayer(layer: string, item: Record<string, unknown>): string {
if (layer === "ships") {
return String(item.mmsi ?? item.id ?? "").trim();
}
return String(item.icao24 ?? item.icao ?? item.id ?? item.hex ?? "")
.trim()
.toLowerCase();
}
/**
* Apply row-level upsert/delete patches from `/api/live-data/fast` delta mode.
* Returns false if a layer patch cannot be applied safely (caller should full-resync).
*/
export function applyLayerDeltas(
deltas: Record<string, { upsert?: unknown[]; delete?: string[]; version?: number }>,
): boolean {
const changedKeys: string[] = [];
for (const [key, patch] of Object.entries(deltas || {})) {
if (!patch || typeof patch !== "object") return false;
const prev = store[key];
const base = Array.isArray(prev) ? (prev as Record<string, unknown>[]) : [];
const byId = new Map<string, Record<string, unknown>>();
for (const item of base) {
if (!item || typeof item !== "object") continue;
const id = entityIdForLayer(key, item);
if (id) byId.set(id, item);
}
for (const id of patch.delete || []) {
byId.delete(String(id));
}
for (const raw of patch.upsert || []) {
if (!raw || typeof raw !== "object") continue;
const item = raw as Record<string, unknown>;
const id = entityIdForLayer(key, item);
if (!id) continue;
byId.set(id, item);
}
store[key] = Array.from(byId.values());
changedKeys.push(key);
}
for (const key of changedKeys) {
const set = keyListeners.get(key);
if (set) for (const fn of set) fn();
}
if (changedKeys.length > 0) {
for (const fn of globalListeners) fn();
}
return true;
}
export function setBackendStatus(next: BackendStatus) {
if (backendStatus === next) return;
backendStatus = next;
+27
View File
@@ -0,0 +1,27 @@
'use client';
/**
* Shared framer-motion entry LazyMotion + domAnimation keeps the animation
* feature set small instead of pulling the full motion bundle into every panel.
*
* Import `motion` / `AnimatePresence` from here (not `framer-motion`) so the
* app stays on the lightweight `m` component under LazyMotion.
*/
import type { ReactNode } from 'react';
import {
LazyMotion,
domAnimation,
m,
AnimatePresence,
} from 'framer-motion';
export { LazyMotion, domAnimation, AnimatePresence };
export const motion = m;
export function MotionProvider({ children }: { children: ReactNode }) {
return (
<LazyMotion features={domAnimation} strict>
{children}
</LazyMotion>
);
}
+4
View File
@@ -825,6 +825,10 @@ export interface DashboardData {
satellite_source?: string;
financial_source?: string;
cctv_total?: number;
/** World/continental sampling metadata from /api/live-data/fast (P5). */
payload_scale?: 'world' | 'continental' | 'regional';
payload_sampled?: boolean;
layer_totals?: Record<string, number>;
satnogs_total?: number;
tinygs_total?: number;
bootstrap_ready?: boolean;