diff --git a/.env.example b/.env.example index 02dc042..98453a6 100644 --- a/.env.example +++ b/.env.example @@ -96,17 +96,22 @@ ADSB_BEAST_STALE_THRESHOLD_SECONDS=30 ADSB_PUBLISH_ONLY_CHANGES=true # Mode D — OpenSky supplement alongside local sources -# Set ADSB_OPENSKY_SUPPLEMENT=true to enable. Local sources (beast/ultrafeeder) -# remain primary; OpenSky fills in aircraft not seen locally within -# ADSB_OPENSKY_STALE_THRESHOLD seconds. +# Enabled by default. Local sources (beast/ultrafeeder) remain primary; OpenSky +# fills in aircraft not seen locally within the effective handover window — +# max(ADSB_OPENSKY_STALE_THRESHOLD, poll interval), capped at 90s. Supplemented +# tracks render dimmed on the map. The 30s cadence below assumes a free OpenSky +# account (set ADSB_OPENSKY_USERNAME / ADSB_OPENSKY_PASSWORD) and gives a smooth +# ~30s handover when BEAST drops into a signal gap. # # Rate limit guidance: -# Anonymous (no credentials): keep ADSB_OPENSKY_INTERVAL >= 220 (~400 req/day) -# Authenticated (free account): 30s is safe (~2880 req/day vs 4000 limit) -# Register free at https://opensky-network.org -ADSB_OPENSKY_SUPPLEMENT=false -ADSB_OPENSKY_INTERVAL=240 -ADSB_OPENSKY_STALE_THRESHOLD=15 +# Authenticated (free account): 30s is safe (~2880 req/day vs 4000 limit). +# Anonymous (no credentials): budget is ~400 req/day — raise +# ADSB_OPENSKY_INTERVAL to >= 220 (handover is then necessarily ~90s), or +# set ADSB_OPENSKY_SUPPLEMENT=false. Register free at +# https://opensky-network.org +ADSB_OPENSKY_SUPPLEMENT=true +ADSB_OPENSKY_INTERVAL=30 +ADSB_OPENSKY_STALE_THRESHOLD=25 ADSB_OPENSKY_RECORD_OBSERVATIONS=true ADSB_OPENSKY_USERNAME= ADSB_OPENSKY_PASSWORD= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bec82f2..965892f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@deck.gl/core": "9.3.1", "@deck.gl/extensions": "9.3.1", "@deck.gl/layers": "9.3.1", + "@deck.gl/mapbox": "9.3.1", "@fontsource/inter": "^5.2.8", "@fontsource/roboto-mono": "^5.2.9", "maplibre-gl": "4.7.1", @@ -1709,6 +1710,20 @@ "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", "license": "ISC" }, + "node_modules/@deck.gl/mapbox": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.3.1.tgz", + "integrity": "sha512-4SgpWMeZiqiZEiz9yPdr89cVRL8HFcvXLxXUA0ExhMreUdNuK/j2OIQHPhw6vp1xCFbJEEqRelQ0pJYkhGDkYw==", + "license": "MIT", + "dependencies": { + "@math.gl/web-mercator": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.3.0", + "@luma.gl/core": "~9.3.2", + "@math.gl/web-mercator": "^4.1.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index 01b419b..945f252 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@deck.gl/core": "9.3.1", "@deck.gl/extensions": "9.3.1", "@deck.gl/layers": "9.3.1", + "@deck.gl/mapbox": "9.3.1", "@fontsource/inter": "^5.2.8", "@fontsource/roboto-mono": "^5.2.9", "maplibre-gl": "4.7.1", diff --git a/frontend/src/components/MapOverlay.tsx b/frontend/src/components/MapOverlay.tsx index 0da7002..e511b47 100644 --- a/frontend/src/components/MapOverlay.tsx +++ b/frontend/src/components/MapOverlay.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import maplibregl from 'maplibre-gl' -import { Deck } from '@deck.gl/core' +import { MapboxOverlay } from '@deck.gl/mapbox' import { useCivicStore } from '../store' import type { Entity, Track, TrafficCamera, EntityTypeFilter, RangeFilter, ReplayData, SystemEvent } from '../store' import { buildEntityLayers } from '../layers/buildEntityLayers' @@ -86,20 +86,13 @@ function buildReplayTracks(data: ReplayData, atMs: number): Record(null) + const deckRef = useRef(null) const layersRef = useRef([]) + // Per-group layer cache so static/slow-changing layers are rebuilt only when + // their inputs change instead of on every animation frame. Reusing the same + // Layer instances lets deck.gl skip re-diffing them entirely. + const layerMemoRef = useRef>({}) const entitiesRef = useRef>({}) const tracksRef = useRef>({}) const pvbRef = useRef>({}) @@ -284,26 +277,18 @@ export function MapOverlay({ map }: Props) { useEffect(() => { const container = map.getContainer() - // Overlay canvas: sits on top of the MapLibre canvas, passes events through. - const canvas = document.createElement('canvas') - canvas.id = 'deck-overlay-canvas' - canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;' - canvas.width = container.clientWidth - canvas.height = container.clientHeight - container.appendChild(canvas) - - // Deck defaults to MapView with flat viewState — no views[] needed. - let deckReady = false - const deck = new Deck({ - canvas, - width: container.clientWidth, - height: container.clientHeight, - controller: false, // MapLibre owns all user input - initialViewState: getViewState(map), - layers: [], - onLoad: () => { deckReady = true }, + // Render Deck.gl through MapLibre's own render loop via MapboxOverlay. Unlike + // a standalone Deck on a separate canvas synced by pushing viewState each rAF + // (which leaves the two canvases a frame out of phase, so icons "jiggle"/swim + // during pan & zoom), the overlay redraws in lockstep with the base map, so + // entities stay glued to the ground. MapLibre still owns all user input. + const overlay = new MapboxOverlay({ + id: 'deck-overlay-canvas', // keeps snapshotExport's canvas lookup working + interleaved: false, + layers: [], }) - deckRef.current = deck + map.addControl(overlay as unknown as maplibregl.IControl) + deckRef.current = overlay // Unified SA Tooltip Bridge const tooltip = document.createElement('div') @@ -318,9 +303,8 @@ export function MapOverlay({ map }: Props) { return } - // 1. Pick from Deck.gl — guard until the GL context is ready - if (!deckReady) return - const picked = deck.pickObject({ x: e.point.x, y: e.point.y, radius: 5 }) + // 1. Pick from Deck.gl (returns null until the overlay GL context is ready) + const picked = overlay.pickObject({ x: e.point.x, y: e.point.y, radius: 5 }) let html = '' @@ -495,8 +479,7 @@ export function MapOverlay({ map }: Props) { // Allow selecting entities and cameras while preserving normal map interaction. const onMapClick = (e: maplibregl.MapMouseEvent) => { if (annotationDrawModeRef.current) return - if (!deckReady) return - const picked = deck.pickObject({ x: e.point.x, y: e.point.y, radius: 10 }) + const picked = overlay.pickObject({ x: e.point.x, y: e.point.y, radius: 10 }) if (!picked) return if (picked.layer?.id === 'camera-points') { const cam = picked.object as TrafficCamera @@ -514,27 +497,45 @@ export function MapOverlay({ map }: Props) { } map.on('click', onMapClick) - // Resize the overlay canvas when the map container resizes - const resizeObserver = new ResizeObserver(() => { - const w = container.clientWidth, h = container.clientHeight - canvas.width = w - canvas.height = h - deck.setProps({ width: w, height: h }) - }) - resizeObserver.observe(container) - let last = performance.now() let lastLayerBuild = 0 const LAYER_BUILD_INTERVAL_MS = 16 + + // When the tab is backgrounded the browser pauses/throttles rAF while the + // WebSocket keeps delivering position updates. On return, re-anchor motion + // smoothing to current server truth instead of extrapolating across the + // whole away-window — otherwise icons drift off and snap back. Clearing PVB + // state makes applyPVB() re-seed each track at its reported position. + const onVisibility = () => { + if (document.visibilityState !== 'visible') return + last = performance.now() + lastLayerBuild = 0 + pvbRef.current = {} + } + document.addEventListener('visibilitychange', onVisibility) + + // Rebuild a layer group only when its inputs change; otherwise reuse the + // cached Layer instances so deck.gl skips re-diffing them on frames where + // only the animated entity/trail layers actually moved. + const memo = layerMemoRef.current + const memoGroup = (name: string, deps: unknown[], build: () => any[]): any[] => { + const cached = memo[name] + if (cached && cached.deps.length === deps.length && cached.deps.every((d, i) => Object.is(d, deps[i]))) { + return cached.layers + } + const layers = build() + memo[name] = { deps, layers } + return layers + } + const tick = (now: number) => { - const dt = now - last + // Clamp dt so a paused/throttled rAF can't fast-forward the pulse phase. + const dt = Math.min(now - last, 100) last = now cycleRef.current = (cycleRef.current + dt / 2000) % 1 // 2-second pulse - // Sync Deck camera every frame — eliminates the 1-frame lag that occurs - // when relying on map.on('render') because that fires after MapLibre paints, - // causing Deck to always be one RAF behind during map movement. - deck.setProps({ viewState: getViewState(map) }) + // No per-frame viewState push: MapboxOverlay tracks the base map's camera + // automatically and redraws in lockstep with it. const shouldRebuildLayers = (now - lastLayerBuild >= LAYER_BUILD_INTERVAL_MS) if (!shouldRebuildLayers) { @@ -665,41 +666,51 @@ export function MapOverlay({ map }: Props) { } const zoom = map.getZoom() + const timeBucket = Math.floor(nowMs / 5000) // coarse clock for stale styling const layers = [ - ...buildCustomLayers(customLayersRef.current), - ...buildGeofenceLayers(geofencesRef.current, geofencesVisibleRef.current), - ...buildObservationRingLayers(DEFAULT_CENTER, OBSERVATION_RANGE_KM, true), - ...buildMeshNodeLayers( - Object.values(entitiesRef.current), - entityFilterRef.current.mesh_node, - nowMs, - zoom, - ), - ...(() => { - const wsGauges = Object.values(entitiesRef.current).filter((e) => e.entity_type === 'stream_gauge') - const fallback = gaugeFallbackRef.current - const source = wsGauges.length > 0 ? wsGauges : fallback - return buildStreamGaugeLayers(source, gaugesVisibleRef.current, zoom) - })(), + ...memoGroup('custom', [customLayersRef.current], + () => buildCustomLayers(customLayersRef.current)), + ...memoGroup('geofence', [geofencesRef.current, geofencesVisibleRef.current], + () => buildGeofenceLayers(geofencesRef.current, geofencesVisibleRef.current)), + ...memoGroup('obsRing', [], + () => buildObservationRingLayers(DEFAULT_CENTER, OBSERVATION_RANGE_KM, true)), + ...memoGroup('mesh', [entitiesRef.current, entityFilterRef.current.mesh_node, zoom, timeBucket], + () => buildMeshNodeLayers( + Object.values(entitiesRef.current), + entityFilterRef.current.mesh_node, + nowMs, + zoom, + )), + ...memoGroup('gauge', [entitiesRef.current, gaugeFallbackRef.current, gaugesVisibleRef.current, zoom], + () => { + const wsGauges = Object.values(entitiesRef.current).filter((e) => e.entity_type === 'stream_gauge') + const source = wsGauges.length > 0 ? wsGauges : gaugeFallbackRef.current + return buildStreamGaugeLayers(source, gaugesVisibleRef.current, zoom) + }), + // Dynamic — rebuilt every frame for PVB motion / pulse animation. ...buildTrailLayers(pvbTracks, sel, trailsVisibleRef.current), ...buildEntityLayers(pvbTracks, sel, cycleRef.current, zoom, missionTagsRef.current), ...buildEventLayers(systemEventsRef.current, nowMs), ...(lightningVisibleRef.current ? buildLightningLayer(lightningRef.current, nowMs, zoom) : []), - ...(camerasVisibleRef.current - ? [buildCameraLayer(camerasRef.current, selectedCamRef.current, zoom)] - : []), - ...buildAnnotationLayers(annotationsRef.current, annotationsVisibleRef.current), - ...buildAnnotationDrawPreviewLayers({ - mode: annotationDrawModeRef.current, - points: annotationDrawPointsRef.current, - cursor: annotationDrawCursorRef.current, - }), + ...memoGroup('camera', [camerasVisibleRef.current, camerasRef.current, selectedCamRef.current, zoom], + () => (camerasVisibleRef.current + ? [buildCameraLayer(camerasRef.current, selectedCamRef.current, zoom)] + : [])), + ...memoGroup('annotation', [annotationsRef.current, annotationsVisibleRef.current], + () => buildAnnotationLayers(annotationsRef.current, annotationsVisibleRef.current)), + ...memoGroup('annotationDraw', + [annotationDrawModeRef.current, annotationDrawPointsRef.current, annotationDrawCursorRef.current], + () => buildAnnotationDrawPreviewLayers({ + mode: annotationDrawModeRef.current, + points: annotationDrawPointsRef.current, + cursor: annotationDrawCursorRef.current, + })), ] layersRef.current = layers - deck.setProps({ layers }) + overlay.setProps({ layers }) rafRef.current = requestAnimationFrame(tick) } @@ -707,12 +718,12 @@ export function MapOverlay({ map }: Props) { return () => { cancelAnimationFrame(rafRef.current) + document.removeEventListener('visibilitychange', onVisibility) map.off('click', onMapClick) map.off('mousemove', onMapMouseMove) - resizeObserver.disconnect() - deck.finalize() - canvas.remove() + map.removeControl(overlay as unknown as maplibregl.IControl) tooltip.remove() + layerMemoRef.current = {} deckRef.current = null pvbRef.current = {} } diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 64ef528..9e1b898 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,8 +1,8 @@ import { useEffect, useRef } from 'react' -import { WS_URL } from '../config' +import { WS_URL, API_BASE } from '../config' import { useCivicStore } from '../store' -import type { EntityTypeFilter } from '../storeTypes' -import { wsTokenParam } from '../auth' +import type { Entity, EntityTypeFilter } from '../storeTypes' +import { wsTokenParam, authHeaders } from '../auth' import { initNotifications, maybeNotify, notifyMeshMessage } from '../notifications' const RECONNECT_DELAY_INITIAL_MS = 1000 @@ -106,6 +106,20 @@ export function useWebSocket() { initNotifications() + // Cold-start prefetch: the WebSocket only delivers initial state after the + // handshake completes and the poller emits its first snapshot (up to a few + // seconds). Seed the store immediately over REST so the map renders right + // away. Guarded on an empty store so it never clobbers live WS data that + // may have already arrived. + fetch(`${API_BASE}/entities`, { headers: authHeaders() }) + .then((r) => (r.ok ? r.json() : null)) + .then((list: Entity[] | null) => { + if (cancelled || !Array.isArray(list) || list.length === 0) return + if (Object.keys(useCivicStore.getState().entities).length > 0) return + setEntities(list) + }) + .catch(() => { /* WS will populate state shortly regardless */ }) + const cleanupInterval = setInterval(() => { purgeStaleEntities() }, 10000) diff --git a/frontend/src/layers/buildEntityLayers.ts b/frontend/src/layers/buildEntityLayers.ts index 37a1e66..bb5fbaf 100644 --- a/frontend/src/layers/buildEntityLayers.ts +++ b/frontend/src/layers/buildEntityLayers.ts @@ -160,6 +160,12 @@ export function buildEntityLayers( if (t.type === 'hazard') return FIRE_ICON_COLOR if (t.type === 'rail') return tagColorMap?.[t.uid] ?? TRAIN_ICON_COLOR if (t.type === 'sensor') return RF_SENSOR_COLOR + // OpenSky-supplemented aircraft are lower-fidelity (coarser, delayed + // positions filling a local BEAST signal gap) — render them dimmed so it + // is visually clear the track is not from the local feed. + if (t.type === 'air' && (t.source ?? '').toLowerCase() === 'opensky') { + return tagColorMap?.[t.uid] ?? entityColor(t, 120) + } return tagColorMap?.[t.uid] ?? entityColor(t) }, getSize: (t) => entityIconSize(selectedUid, t, zoom), @@ -169,7 +175,7 @@ export function buildEntityLayers( updateTriggers: { getIcon: zoom, getAngle: trackArr.map(t => t.courseTrue), - getColor: trackArr.map(t => tagColorMap?.[t.uid]?.join(',') ?? `${t.altMeters + t.speedMs}${t.stationType ?? ''}`), + getColor: trackArr.map(t => tagColorMap?.[t.uid]?.join(',') ?? `${t.altMeters + t.speedMs}${t.stationType ?? ''}${t.source ?? ''}`), getSize: [selectedUid, zoom], }, }) diff --git a/frontend/src/layers/pvb.ts b/frontend/src/layers/pvb.ts index 3a430e8..6695260 100644 --- a/frontend/src/layers/pvb.ts +++ b/frontend/src/layers/pvb.ts @@ -6,6 +6,12 @@ import type { Track } from '../store' const BLEND_WINDOW_MS = 2_000 const OPENSKY_MIN_BLEND_MS = 8_000 const OPENSKY_MAX_BLEND_MS = 25_000 +// Hard ceiling on how far a stale anchor may be extrapolated. Without this, a +// large wall-clock gap (e.g. the rAF loop being paused while the tab is +// backgrounded) would project aircraft forward by minutes, making icons "drift +// away" and then snap back when the tab regains focus. The ceiling is above the +// widest blend window so it never clips normal motion, only pathological gaps. +const MAX_PROJECT_MS = 30_000 export interface PVBState { // Server anchor — position/velocity from the most recent server report @@ -51,7 +57,8 @@ function project( elapsedMs: number, ): [number, number] { if (speedMs < 0.5 || elapsedMs <= 0) return [lon, lat] - return destinationPoint(lon, lat, course, speedMs * elapsedMs / 1_000) + const clamped = Math.min(elapsedMs, MAX_PROJECT_MS) + return destinationPoint(lon, lat, course, speedMs * clamped / 1_000) } function evaluatePVB(state: PVBState, nowMs: number): [number, number] { diff --git a/poller/config.py b/poller/config.py index 34a4a78..6484122 100644 --- a/poller/config.py +++ b/poller/config.py @@ -131,13 +131,16 @@ def regions(self) -> list[RegionConfig]: # Mode D — OpenSky supplement alongside local sources (beast or ultrafeeder). # When enabled, OpenSky polls on its own interval and fills in aircraft not - # seen locally within adsb_opensky_stale_threshold seconds. - adsb_opensky_supplement: bool = False - # Seconds between OpenSky polls. Anonymous budget ~400 req/day; keep >= 220s - # for anonymous use. With credentials 30s is safe (~2880 req/day vs 4000 limit). - adsb_opensky_interval: int = 240 + # seen locally within adsb_opensky_stale_threshold seconds. On by default — + # most OpenSky users register a free account, which makes the fast cadence + # below safe and gives a smooth ~30s handover when BEAST hits a signal gap. + adsb_opensky_supplement: bool = True + # Seconds between OpenSky polls. With credentials 30s is safe (~2880 req/day + # vs 4000 limit). Anonymous (no credentials) budget is ~400 req/day — raise + # this to >= 220s for anonymous use to avoid being rate limited. + adsb_opensky_interval: int = 30 # Seconds since last local sighting before OpenSky may update an aircraft. - adsb_opensky_stale_threshold: int = 15 + adsb_opensky_stale_threshold: int = 25 # Write OpenSky supplement positions to the observations table. adsb_opensky_record_observations: bool = True # Optional OpenSky Network credentials (https://opensky-network.org).