mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-03 01:08:42 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,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,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,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,
|
||||
|
||||
@@ -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,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,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';
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,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,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';
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user