From 04e97156edeaa2a78ac27db5ec70974b047a822a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 16:29:38 +0000 Subject: [PATCH 1/2] Fix map drift on tab return, speed cold start, mark OpenSky tracks Map animation / perf and multi-source fallback improvements: - Fix aircraft "drift away then snap back" after the browser tab is backgrounded. The rAF loop pauses while hidden but the WebSocket keeps delivering updates, so on return PVB extrapolated each track forward by the entire away-window. Add a visibilitychange handler that re-anchors motion smoothing to current server truth on resume, clamp the pulse dt, and add a hard MAX_PROJECT_MS ceiling on extrapolation as a backstop. - Speed up cold start with a REST prefetch of /entities so the map renders immediately instead of waiting for the WS handshake + first poller snapshot. Guarded on an empty store so it never clobbers live WS data. - Visually mark OpenSky-supplemented aircraft (dimmed icon) so it is clear when a track is lower-fidelity gap-fill rather than the local BEAST feed. - Document the recommended authenticated OpenSky setup for smooth ~30s Beast-gap handover in .env.example. --- .env.example | 17 +++++++++++++---- frontend/src/components/MapOverlay.tsx | 18 +++++++++++++++++- frontend/src/hooks/useWebSocket.ts | 20 +++++++++++++++++--- frontend/src/layers/buildEntityLayers.ts | 8 +++++++- frontend/src/layers/pvb.ts | 9 ++++++++- 5 files changed, 62 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 02dc042..fc4ff75 100644 --- a/.env.example +++ b/.env.example @@ -97,13 +97,22 @@ 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. +# 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. # # 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) +# Anonymous (no credentials): keep ADSB_OPENSKY_INTERVAL >= 220 (~400 req/day). +# Handover during a BEAST gap is necessarily slow (~90s) at this cadence. +# Authenticated (free account): 30s is safe (~2880 req/day vs 4000 limit) and +# gives a smooth ~30s handover when BEAST drops into a signal gap. # Register free at https://opensky-network.org +# +# Recommended authenticated setup for smooth gap-fill: +# ADSB_OPENSKY_SUPPLEMENT=true +# ADSB_OPENSKY_INTERVAL=30 +# ADSB_OPENSKY_STALE_THRESHOLD=25 +# ADSB_OPENSKY_USERNAME / ADSB_OPENSKY_PASSWORD set ADSB_OPENSKY_SUPPLEMENT=false ADSB_OPENSKY_INTERVAL=240 ADSB_OPENSKY_STALE_THRESHOLD=15 diff --git a/frontend/src/components/MapOverlay.tsx b/frontend/src/components/MapOverlay.tsx index 0da7002..92f4741 100644 --- a/frontend/src/components/MapOverlay.tsx +++ b/frontend/src/components/MapOverlay.tsx @@ -526,8 +526,23 @@ export function MapOverlay({ map }: Props) { 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) + 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 @@ -707,6 +722,7 @@ export function MapOverlay({ map }: Props) { return () => { cancelAnimationFrame(rafRef.current) + document.removeEventListener('visibilitychange', onVisibility) map.off('click', onMapClick) map.off('mousemove', onMapMouseMove) resizeObserver.disconnect() 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] { From c9c2e525a98440bd34ab127be21c2cbf01f4cbc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 16:51:43 +0000 Subject: [PATCH 2/2] Fix map jiggle via MapboxOverlay, memoize layers, default OpenSky on - Eliminate the icon "jiggle/swim" during pan & zoom. The standalone Deck instance on a separate canvas was synced to MapLibre by pushing viewState every rAF, leaving the two canvases a frame out of phase. Replace it with @deck.gl/mapbox MapboxOverlay added as a map control, so Deck renders in lockstep with the base map's render loop. Removes the per-frame viewState push and manual canvas resize; preserves the deck-overlay-canvas id so the snapshot export keeps working, and routes picking through overlay.pickObject. - Memoize static / slow-changing layer groups (custom, geofence, observation rings, mesh, stream gauges, cameras, annotations) so they rebuild only when their inputs change instead of on every animation frame. Reused Layer instances let deck.gl skip re-diffing them; only the PVB/pulse-animated entity, trail, event and lightning layers rebuild each frame. - Enable the OpenSky supplement by default with a 30s cadence and 25s gap handover (assumes a free OpenSky account, which most users register). Anonymous users are advised to raise the interval or disable it. --- .env.example | 32 +++--- frontend/package-lock.json | 15 +++ frontend/package.json | 1 + frontend/src/components/MapOverlay.tsx | 151 ++++++++++++------------- poller/config.py | 15 ++- 5 files changed, 112 insertions(+), 102 deletions(-) diff --git a/.env.example b/.env.example index fc4ff75..98453a6 100644 --- a/.env.example +++ b/.env.example @@ -96,26 +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 the -# effective handover window — max(ADSB_OPENSKY_STALE_THRESHOLD, poll interval), -# capped at 90s. Supplemented tracks render dimmed on the map. +# 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). -# Handover during a BEAST gap is necessarily slow (~90s) at this cadence. -# Authenticated (free account): 30s is safe (~2880 req/day vs 4000 limit) and -# gives a smooth ~30s handover when BEAST drops into a signal gap. -# Register free at https://opensky-network.org -# -# Recommended authenticated setup for smooth gap-fill: -# ADSB_OPENSKY_SUPPLEMENT=true -# ADSB_OPENSKY_INTERVAL=30 -# ADSB_OPENSKY_STALE_THRESHOLD=25 -# ADSB_OPENSKY_USERNAME / ADSB_OPENSKY_PASSWORD set -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 92f4741..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,15 +497,6 @@ 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 @@ -540,16 +514,28 @@ export function MapOverlay({ map }: Props) { } 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) => { // 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) { @@ -680,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) } @@ -725,10 +721,9 @@ export function MapOverlay({ map }: Props) { 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/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).