Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
15 changes: 15 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
169 changes: 90 additions & 79 deletions frontend/src/components/MapOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -86,20 +86,13 @@ function buildReplayTracks(data: ReplayData, atMs: number): Record<string, Track
return result
}

function getViewState(map: maplibregl.Map) {
const { lng, lat } = map.getCenter()
return {
longitude: lng,
latitude: lat,
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing(),
}
}

export function MapOverlay({ map }: Props) {
const deckRef = useRef<Deck | null>(null)
const deckRef = useRef<MapboxOverlay | null>(null)
const layersRef = useRef<any[]>([])
// 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<Record<string, { deps: unknown[]; layers: any[] }>>({})
const entitiesRef = useRef<Record<string, Entity>>({})
const tracksRef = useRef<Record<string, Track>>({})
const pvbRef = useRef<Record<string, PVBState>>({})
Expand Down Expand Up @@ -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')
Expand All @@ -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 = ''

Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -665,54 +666,64 @@ 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)
}
rafRef.current = requestAnimationFrame(tick)

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 = {}
}
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/hooks/useWebSocket.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/layers/buildEntityLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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],
},
})
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/layers/pvb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] {
Expand Down
Loading
Loading