diff --git a/agent_docs/tasks/2026-06-12-frontend-performance-optimization.md b/agent_docs/tasks/2026-06-12-frontend-performance-optimization.md new file mode 100644 index 00000000..558e9ce9 --- /dev/null +++ b/agent_docs/tasks/2026-06-12-frontend-performance-optimization.md @@ -0,0 +1,125 @@ +# Frontend Performance Optimization — Rendering Pipeline & Initial Load + +## Issue + +The 3D globe views were saturating browser main threads, and initial page load +shipped the entire application in one bundle: + +1. **Per-frame layer rebuilding.** The `useAnimationLoop` rAF loop calls + `composeAllLayers()` at 60 fps, constructing ~40–50 new deck.gl `Layer` + instances every frame — including static groups (cables, towers, airspace, + NWS alerts, GDELT, clusters, …) whose source data only changes every + 30 s–15 min. Several builders also created **new `data` arrays per frame** + (e.g. `historySegments.filter(...)`), which forces deck.gl to regenerate + and re-upload GPU attribute buffers on every frame. +2. **Terminator recompute.** `getTerminatorLayer()` recomputed a 360-point + night-side polygon and allocated a fresh GeoJSON object on every call + (60×/sec from the rAF loop, plus SituationGlobe's update effect) — a new + `data` reference each frame, so deck.gl re-uploaded it continuously. +3. **Pulse animations at 60 Hz.** Pulsing layers (aurora, jamming, FIRMS, + dark vessels, holding patterns, kiwi node) threaded raw `Date.now()` + through `updateTriggers`, recomputing color attributes every frame. +4. **Monolithic bundle.** `vite.config.ts` had no chunking strategy; App.tsx + eagerly imported TacticalMap/OrbitalMap/IntelGlobe/DashboardView/ + RadioTerminal, so deck.gl (~1 MB), maplibre (~1 MB), mapbox (~1.7 MB) and + their CSS all landed in the initial load. Both map libraries' CSS was + imported unconditionally. Google Fonts were also loaded via a + render-blocking `@import` inside the bundled CSS. + +## Solution + +**Rendering** +- New `frontend/src/layers/layerCache.ts` — a small `LayerCache` keyed + memoizer (`Object.is` over a deps array, same contract as React hooks). + `composeAllLayers()` now wraps every *static* layer group in + `cache.get(key, deps, build)`, so unchanged groups return the **identical + Layer instances** frame-to-frame and deck.gl skips diffing/attribute work + for them entirely. Dynamic groups (entities, satellites, trails) still + rebuild per frame — their positions genuinely change every frame. +- Each `useAnimationLoop` instance owns its own `LayerCache` (deck.gl Layer + objects hold per-overlay internal state and must not be shared). +- Pulse animations are quantized to a 10 Hz tick (`now - (now % 100)`): + visually identical shimmer, but pulsing groups are now cache hits for ~6 + consecutive frames instead of recomputing at 60 fps. +- `TerminatorLayer` memoizes the computed polygon per minute, giving all + callers a stable `data` reference. +- `StarField` varies `ctx.globalAlpha` instead of formatting a new + `rgba(...)` string per star per frame (~320 string allocations/frame). +- `interpolation.ts` hoists the spherical-math constants + (`R_EARTH`, `DEG_PER_RAD`, `RAD_PER_DEG`) out of the per-entity hot path. + +**Loading** +- `vite.config.ts`: function-form `manualChunks` splitting `deck-gl` + (+luma/loaders/math.gl), `maplibre`, `mapbox`, `echarts`(+zrender), and + `react-vendor` into their own cacheable chunks. Vite's virtual + preload-helper is isolated into its own chunk — otherwise Rollup parks it + inside a vendor chunk and the entry transitively preloads that vendor + (this is also why the object form of `manualChunks` was not used). +- `App.tsx`: TacticalMap, OrbitalMap, IntelGlobe, DashboardView and + RadioTerminal are now `lazy()`-loaded through a `lazyView()` helper that + bakes in a Suspense boundary, so the view-switch JSX needed no changes. +- Map library CSS moved into the adapters: `MapboxAdapter` imports only + `mapbox-gl.css`, `MapLibreAdapter` only `maplibre-gl.css` — the unused + library's CSS is never downloaded. +- Google Fonts consolidated into a single `index.html` `` (resolves in + parallel via preconnect); removed the render-blocking `@import` from + `src/index.css`. + +## Changes + +- `frontend/src/layers/layerCache.ts` — **new**: keyed frame-to-frame layer memoizer. +- `frontend/src/layers/composition.ts` — all static layer groups wrapped in + `LayerCache.get()`; pulse `now` quantized to 10 Hz; history-track data + arrays no longer re-filtered per frame. +- `frontend/src/hooks/useAnimationLoop.ts` — owns a per-overlay `LayerCache`, + passes it to `composeAllLayers`. +- `frontend/src/components/map/TerminatorLayer.tsx` — terminator GeoJSON + memoized per minute (stable data reference). +- `frontend/src/components/map/StarField.tsx` — globalAlpha instead of + per-star rgba string formatting. +- `frontend/src/utils/interpolation.ts` — hoisted trig constants. +- `frontend/src/App.tsx` — `lazyView()` helper; 5 heavy views code-split. +- `frontend/src/components/map/{TacticalMap,OrbitalMap,IntelGlobe}.tsx` — + removed unconditional map-library CSS imports. +- `frontend/src/components/map/{MapboxAdapter,MapLibreAdapter}.tsx` — each + imports only its own library's CSS. +- `frontend/vite.config.ts` — manualChunks vendor splitting + preload-helper + isolation; `chunkSizeWarningLimit: 1600`. +- `frontend/index.html` / `frontend/src/index.css` — font loading + consolidated, render-blocking `@import` removed. + +## Verification + +- `pnpm run lint` — clean (0 warnings). +- `pnpm run typecheck` — clean. +- `pnpm run test` — 272/272 passed (20 files). +- `pnpm run build` — verified the chunk graph in `dist/`: + - Entry modulepreloads only `preload-helper` (~1 kB) + `react-vendor` + (192 kB / 60 kB gzip). + - `deck-gl` (1.03 MB), `maplibre` (1.07 MB), `mapbox` (1.72 MB), + `echarts` (1.14 MB) are separate chunks fetched only when the views + that need them mount; mapbox vs maplibre loads only the selected adapter + (CSS included: `mapbox-*.css` 41 kB vs `maplibre-*.css` 70 kB now split). + +## Benefits + +- **Frame time**: per-frame work in the rAF loop drops from rebuilding + ~40–50 layers (plus terminator geometry + several full GPU attribute + re-uploads) to rebuilding only the genuinely dynamic entity/satellite/trail + layers — static groups are reference-equal cache hits deck.gl skips + outright. Pulse-animated groups recompute at 10 Hz instead of 60 Hz. +- **Initial load**: eager JS shrinks from ~5–6 MB of vendor code to ~200 kB + preloaded (entry + react-vendor); the login screen renders without + downloading any map/chart engine. Vendor chunks are stable across app + releases, so returning clients hit the HTTP cache. +- **Latency**: fonts no longer render-block behind the CSS bundle; only one + map engine (and its CSS) is ever downloaded per session. + +## Follow-ups (not in this change) + +- `public/world-countries.json` is 14 MB of uncompressed GeoJSON fetched at + runtime; simplifying geometry (e.g. mapshaper at ~1–5 % retention) or + serving TopoJSON would cut multi-second map start times on slow links. +- Entity interpolation (`processEntityFrame`) is still O(n) on the main + thread per frame; offloading to the existing TAK worker is the next big + rendering win if entity counts grow past ~5k. diff --git a/agent_docs/tasks/2026-06-12-satellite-position-jump-fix.md b/agent_docs/tasks/2026-06-12-satellite-position-jump-fix.md new file mode 100644 index 00000000..50df84cd --- /dev/null +++ b/agent_docs/tasks/2026-06-12-satellite-position-jump-fix.md @@ -0,0 +1,85 @@ +# Satellite Position Jump Fix — Epoch-Anchored Dead Reckoning + +## Issue + +Satellites in globe view loaded, drifted slowly along their orbits, then +periodically **all surged across the globe at once** and settled in new +positions. Reported as a follow-up to the frontend performance task. + +Root cause analysis (full pipeline traced: `space_pulse` SGP4 sweep → +Kafka → broadcast WS → TAK worker → `useEntityWorker` → `interpolatePVB`): + +1. **Receive-time anchoring.** Each satellite message carries `time` (the + SGP4 propagation epoch, ms), but `useEntityWorker` stamped + `serverTime: Date.now()` — the *receive* time. The 15 s sweep is published + in throttled chunks and flows through Kafka/WS, so positions are 1–10 s + old on arrival; satellites rendered persistently behind their true + positions and each new sweep "corrected" them forward. +2. **Wall-clock targets + exponential chase.** `interpolatePVB` eases the + visual toward a target that advances in wall-clock time — even while no + frames render. During the initial-load main-thread stall (the layer + rebuild problem fixed in the same PR), targets marched on; when the frame + rate recovered, every visual closed its accumulated gap at ~70 %/frame — + the synchronized constellation-wide surge. +3. Minor: first-update `expectedInterval` was seeded at 5 s vs the real 15 s + sweep cadence, and satellites had no out-of-order message rejection. + +## Solution + +1. **Epoch-anchored DR** — `DRState` now has two time anchors: + - `serverTime` = the position's source epoch (`entity.time`, guarded by + `drAnchorTime()` against clock skew / wrong units: values outside + `(now − 120 s, now]` fall back to receive time); + - `blendTime` = receive time, used for blend progress (alpha) and the + client continuation projection. + The server projection runs from the epoch, so stale-on-arrival positions + are extrapolated to "now" immediately. New satellites (no prior visual) + seed `blendTime = serverTime`, so they *appear* at their true current + position rather than easing forward from the stale one. +2. **Teleport guard** in `interpolatePVB`: if the visual→target gap exceeds + `max(2°, speed × expectedInterval × 3)` the visual snaps in one frame + instead of racing across the map. The longitude delta is deliberately + unwrapped so antimeridian crossings snap to the far side rather than + smoothing the long way around the globe. +3. **Frame-stall reset** in `useAnimationLoop`: if the raw inter-frame gap + exceeds 1 s (hidden tab, GC, shader compilation), `visualState` is + cleared so the next frame re-seeds every visual directly at its target — + one clean snap instead of a global catch-up surge. +4. Satellites now reject out-of-order/duplicate sweeps + (`lastSourceTime >= entity.time`, same guard the aircraft/ship path had) + and seed `expectedInterval` at the real 15 s sweep cadence. + +## Changes + +- `frontend/src/types.ts` — `DRState.blendTime` added; `serverTime` + documented as the position epoch. +- `frontend/src/utils/interpolation.ts` — dual-anchor projection + (`serverTime` for server projection, `blendTime` for alpha + client + projection); teleport guard with speed-scaled threshold. +- `frontend/src/hooks/useEntityWorker.ts` — `drAnchorTime()` helper; both + the satellite and aircraft/ship branches anchor to `entity.time`; + satellite branch gains out-of-order rejection + `lastSourceTime`; new + entities seed `blendTime = serverTime`. +- `frontend/src/hooks/useAnimationLoop.ts` — clear `visualState` after a + > 1 s frame gap. +- `frontend/src/utils/interpolation.test.ts` — fixture updated for + `blendTime`; 6 new tests covering epoch anchoring (immediate placement, + epoch-vs-receive projection) and the teleport guard (snap above + threshold, smooth below, speed-scaled threshold, antimeridian snap). + +## Verification + +- `pnpm run lint` — clean (0 warnings). +- `pnpm run typecheck` — clean (the new required `DRState.blendTime` field + forced review of every construction site). +- `pnpm run test` — 278/278 passed (272 existing + 6 new). +- `pnpm run build` — production build succeeds. + +## Benefits + +- Satellites appear at their true current position immediately and then + follow their path smoothly — no more constellation-wide fast-forward + after load, tab switches, or main-thread stalls. +- Pipeline latency no longer translates into persistent display lag for any + entity type (aircraft/ships benefit from the same epoch anchoring). +- Out-of-order satellite sweeps can no longer drag positions backwards. diff --git a/frontend/index.html b/frontend/index.html index b4d51c64..29df7f23 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -23,11 +23,16 @@ /> - +