Files
Shadowbroker/frontend/src/components/map/hooks/useViewportBounds.ts
T
Shadowbroker 19fb7f0b1e Fix #288: viewport-scoped live-data for heavy layers only (#294)
Reported by @tg12 in the external security/correctness audit.

Before this change, /api/live-data/{fast,slow} accepted s/w/n/e query
params but their Query() descriptions explicitly said "(ignored)". The
endpoints shipped the full in-memory world dataset on every poll:

    /api/live-data/fast → 16.88 MB
    /api/live-data/slow → 10.12 MB
                          ── 27 MB per poll cycle, regardless of zoom

For a node with N operators each polling at the steady 15s/120s cadence,
this is hundreds of MB/minute of outbound traffic that never gets used —
the GPU just culls everything outside the viewport client-side. On a
Tor-bridged or LTE-backed node, that bandwidth bill is the actual cost.

This change makes the existing s/w/n/e params honored — when all four
bounds are supplied, the backend bbox-filters a curated set of heavy,
density-driven, time-sensitive collections to that viewport (with the
existing 20% padding from _bbox_filter):

    /fast: commercial_flights, military_flights, private_flights,
           private_jets, tracked_flights, ships, cctv, uavs, liveuamap,
           gps_jamming, sigint, trains
    /slow: gdelt, firms_fires, kiwisdr, scanners, psk_reporter

Static reference layers (satellites, datacenters, military_bases,
power_plants, satnogs, weather, news, stocks, etc.) deliberately STAY
world-scale so panning never reveals an "empty world" of infrastructure.
That preserves the no-hostile-UX feel of the existing dashboard.

Behavior contract:

  * Without bbox params (or with a partial bbox), the response is
    byte-for-byte identical to the pre-#288 implementation. No
    behavior change for any existing caller that hasn't opted in.
  * World-scale bbox (lng_span >= 300 or lat_span >= 120) short-circuits
    filtering and shares the global ETag — zoomed-out operators all
    hit the same 304 cache exactly like before.
  * ETag now mixes a 1°-quantized bbox suffix when filtering engages,
    so two viewports never poison each other's 304 cache. Sub-degree
    pans land in the same ETag bucket (i.e. don't bust the cache on
    every mouse drag).

Polling cadence, rate-limit windows, and the 304 short-circuit are all
unchanged. Only the SIZE of the responses changes, and only when the
caller opts in via bounds.

Frontend wiring: useViewportBounds reuses the same coarsened/
expanded bounds it already computes for the AIS /api/viewport POST and
pushes them into a new module-level liveDataViewport store.
useDataPolling reads from that store via appendLiveDataBoundsParams
when building each live-data URL.

Tests cover: no-bbox → world data; bbox → heavy layers filtered;
bbox → reference layers untouched; world-scale bbox → no filter;
partial bbox → treated as no bbox; ETag changes with bbox; sub-degree
pan → same ETag; 304 path works; antimeridian-crossing bbox handled.

Co-authored-by: BigBodyCobain <moatbc@gmail.com>
2026-05-22 00:56:29 -06:00

130 lines
4.5 KiB
TypeScript

import { useCallback, useRef, useState } from 'react';
import type { RefObject } from 'react';
import type { MapRef } from 'react-map-gl/maplibre';
import { API_BASE } from '@/lib/api';
import {
coarsenViewBounds,
expandBoundsToRadius,
normalizeViewBounds,
type ViewBounds,
} from '@/lib/viewportPrivacy';
import { setLiveDataBounds } from '@/lib/liveDataViewport';
const VIEWPORT_POST_DEBOUNCE_MS = 2500;
const VIEWPORT_POST_MIN_INTERVAL_MS = 12000;
const VIEWPORT_CHANGE_EPSILON = 1.5;
export const VIEWPORT_COMMITTED_EVENT = 'shadowbroker:viewport-committed';
function boundsChanged(a: ViewBounds | null, b: ViewBounds): boolean {
if (!a) return true;
return (
Math.abs(a.south - b.south) > VIEWPORT_CHANGE_EPSILON ||
Math.abs(a.west - b.west) > VIEWPORT_CHANGE_EPSILON ||
Math.abs(a.north - b.north) > VIEWPORT_CHANGE_EPSILON ||
Math.abs(a.east - b.east) > VIEWPORT_CHANGE_EPSILON
);
}
export function useViewportBounds(
mapRef: RefObject<MapRef | null>,
viewBoundsRef?: { current: ViewBounds | null },
backendViewportSyncEnabled: boolean = true,
) {
// Viewport bounds for culling off-screen features [west, south, east, north]
const [mapBounds, setMapBounds] = useState<[number, number, number, number]>([
-180, -90, 180, 90,
]);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastPostedBoundsRef = useRef<ViewBounds | null>(null);
const lastPostedAtRef = useRef(0);
const lastCommittedBoundsRef = useRef<ViewBounds | null>(null);
const updateBounds = useCallback(() => {
const map = mapRef.current?.getMap();
if (!map) return;
const b = map.getBounds();
const latRange = b.getNorth() - b.getSouth();
const lngRange = b.getEast() - b.getWest();
const buf = 0.2; // 20% buffer
setMapBounds([
b.getWest() - lngRange * buf,
b.getSouth() - latRange * buf,
b.getEast() + lngRange * buf,
b.getNorth() + latRange * buf,
]);
const normalized = normalizeViewBounds({
south: b.getSouth(),
west: b.getWest(),
north: b.getNorth(),
east: b.getEast(),
});
const preloadBounds = coarsenViewBounds(expandBoundsToRadius(normalized));
if (viewBoundsRef && 'current' in viewBoundsRef) {
viewBoundsRef.current = preloadBounds;
}
if (boundsChanged(lastCommittedBoundsRef.current, preloadBounds)) {
lastCommittedBoundsRef.current = preloadBounds;
window.dispatchEvent(new CustomEvent(VIEWPORT_COMMITTED_EVENT));
}
// Issue #288: hand the same coarsened/expanded bounds to the live-data
// poller so heavy collections in /api/live-data/{fast,slow} can be
// scoped to the visible region. Static reference layers are unaffected
// — see backend _FAST_BBOX_HEAVY_KEYS / _SLOW_BBOX_HEAVY_KEYS.
setLiveDataBounds({
south: preloadBounds.south,
west: preloadBounds.west,
north: preloadBounds.north,
east: preloadBounds.east,
});
// Debounce POSTing viewport bounds to backend for dynamic AIS stream filtering
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
if (!backendViewportSyncEnabled) {
lastPostedBoundsRef.current = null;
lastPostedAtRef.current = 0;
return;
}
const now = Date.now();
if (
!boundsChanged(lastPostedBoundsRef.current, preloadBounds) &&
now - lastPostedAtRef.current < VIEWPORT_POST_MIN_INTERVAL_MS
) {
return;
}
if (now - lastPostedAtRef.current < VIEWPORT_POST_MIN_INTERVAL_MS) {
return;
}
lastPostedBoundsRef.current = preloadBounds;
lastPostedAtRef.current = now;
fetch(`${API_BASE}/api/viewport`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
s: preloadBounds.south,
w: preloadBounds.west,
n: preloadBounds.north,
e: preloadBounds.east,
}),
}).catch((e) => console.error('Failed to update backend viewport:', e));
}, VIEWPORT_POST_DEBOUNCE_MS);
}, [backendViewportSyncEnabled, mapRef, viewBoundsRef]);
const inView = useCallback(
(lat: number, lng: number) =>
lng >= mapBounds[0] && lng <= mapBounds[2] && lat >= mapBounds[1] && lat <= mapBounds[3],
[mapBounds],
);
const scheduleBoundsUpdate = useCallback(() => {
updateBounds();
}, [updateBounds]);
return { mapBounds, inView, updateBounds, scheduleBoundsUpdate };
}