mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-17 14:42:20 +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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user