Skip to content

perf(frontend): cache static deck.gl layers, code-split heavy views, vendor chunking#324

Merged
d3mocide merged 2 commits into
devfrom
claude/app-performance-optimization-ox3i7r
Jun 12, 2026
Merged

perf(frontend): cache static deck.gl layers, code-split heavy views, vendor chunking#324
d3mocide merged 2 commits into
devfrom
claude/app-performance-optimization-ox3i7r

Conversation

@d3mocide

@d3mocide d3mocide commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Summary

Two-front performance pass on the frontend: stop the 3D globe's render loop from rebuilding the world every frame, and stop the initial page load from shipping every map/chart engine up front. A third commit fixes the satellite "catch-up surge" on globe load.

Rendering (globe frame time)

The useAnimationLoop rAF loop was calling composeAllLayers() at 60 fps, constructing ~40–50 new deck.gl Layer instances per frame — including static groups (cables, towers, airspace, NWS, GDELT, clusters, …) whose data changes every 30 s–15 min. Several builders also allocated new data arrays per frame, forcing deck.gl to regenerate and re-upload GPU attribute buffers continuously.

  • New LayerCache (src/layers/layerCache.ts): every static layer group in composeAllLayers() is memoized on a deps array (React-hooks-style Object.is contract). Unchanged groups return the identical Layer instances, so deck.gl skips diffing and attribute work for them outright. Only entity/satellite/trail layers — whose positions genuinely change each frame — still rebuild per frame. Each overlay owns its own cache (Layer objects hold per-deck state).
  • Pulse animations quantized to 10 Hz (aurora, jamming, FIRMS, dark vessels, holding patterns, kiwi node): visually identical shimmer, 6× fewer attribute recomputes.
  • Terminator memoized per minute — it was recomputing a 360-point polygon and allocating a fresh GeoJSON (= full GPU re-upload) 60×/sec.
  • StarField uses globalAlpha instead of formatting ~320 rgba() strings per frame; interpolation hot path no longer recomputes trig constants per entity.

Loading (initial latency)

  • Code-split the five heavy views (TacticalMap, OrbitalMap, IntelGlobe, DashboardView, RadioTerminal) via a lazyView() helper with built-in Suspense — the view-switch JSX is untouched.
  • Vendor chunking in vite.config.ts (function-form manualChunks): deck-gl (1.03 MB), maplibre (1.07 MB), mapbox (1.72 MB), echarts (1.14 MB), react-vendor. 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 (the object form of manualChunks has the same failure mode, which is why the function form is used).
  • Map CSS split: each adapter imports only its own library's stylesheet, so the unused engine's CSS never downloads (previously both were always bundled).
  • Fonts: consolidated into one index.html link resolving in parallel via preconnect; removed the render-blocking @import from the bundled CSS.

Result: the entry now modulepreloads only preload-helper (~1 kB) + react-vendor (60 kB gzip) — down from an initial graph that eagerly included all of deck.gl/maplibre/mapbox (~5 MB+). Vendor chunks are byte-stable across releases, so returning clients hit the HTTP cache.

Satellite catch-up surge fix

Satellites loaded, drifted slowly along their orbits, then periodically all surged across the globe at once. Root causes and fixes:

  • Epoch-anchored dead reckoning. Satellite messages carry time (the SGP4 propagation epoch), but the client anchored DR to receive time — sweep chunking + Kafka + WS delivery make positions 1–10 s old on arrival, so satellites rendered persistently behind reality until the next 15 s sweep "corrected" them. DRState now splits serverTime (position epoch — projection anchor) from blendTime (receive time — blend-progress anchor); new satellites appear at their true, epoch-projected current position immediately. Guarded against clock skew / wrong units (drAnchorTime()).
  • Teleport guard in interpolatePVB: visual→target gaps beyond max(2°, speed × interval × 3) snap 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.
  • Frame-stall reset: after a >1 s rAF gap (hidden tab, GC, shader compilation — the original trigger, amplified by the per-frame layer rebuild this PR also fixes), visual states are cleared so everything re-seeds at its target in one clean snap instead of a global catch-up surge.
  • Satellites also gain out-of-order/duplicate sweep rejection and a correct 15 s expectedInterval seed.

Verification

  • pnpm run lint — clean (0 warnings)
  • pnpm run typecheck — clean
  • pnpm run test — 278/278 passed (6 new tests covering epoch anchoring and the teleport guard)
  • pnpm run build — chunk graph inspected in dist/: vendors are on-demand, entry preloads nothing heavy, mapbox vs maplibre load exclusively

Follow-ups (out of scope)

  • public/world-countries.json is 14 MB of raw GeoJSON fetched at runtime — simplifying geometry (mapshaper/TopoJSON) would cut multi-second map start on slow links.
  • Entity interpolation is still O(n)/frame on the main thread; moving it into the existing TAK worker is the next rendering win if entity counts grow past ~5k.

Task logs: agent_docs/tasks/2026-06-12-frontend-performance-optimization.md, agent_docs/tasks/2026-06-12-satellite-position-jump-fix.md

https://claude.ai/code/session_01BksbmT1NKcWRSiPA9FUh6B

claude added 2 commits June 12, 2026 12:45
…vendor chunking

Rendering:
- Add LayerCache: static layer groups (infra, airspace, weather, GDELT,
  clusters, ...) are memoized across rAF ticks and return identical Layer
  instances while inputs are unchanged, so deck.gl skips diffing and GPU
  attribute regeneration for them; only entity/satellite/trail layers
  rebuild per frame
- Quantize pulse animations (aurora, jamming, FIRMS, dark vessels, holding
  patterns, kiwi node) to 10 Hz ticks instead of per-frame updateTriggers
- Memoize terminator polygon per minute (was recomputed + reallocated 60x/s)
- StarField: globalAlpha instead of per-star rgba string formatting
- interpolation: hoist spherical-math constants out of the per-entity path

Loading:
- Lazy-load TacticalMap, OrbitalMap, IntelGlobe, DashboardView and
  RadioTerminal behind Suspense boundaries
- vite manualChunks: deck.gl, maplibre, mapbox, echarts and react split into
  cacheable on-demand vendor chunks; isolate Vite's preload helper so the
  entry no longer transitively preloads vendors
- Map library CSS moved into the adapters so only the selected engine's
  stylesheet is downloaded
- Consolidate Google Fonts into index.html; drop render-blocking CSS @import

Verified: lint, typecheck, 272/272 unit tests, production build chunk graph
(entry preloads only preload-helper + react-vendor).

https://claude.ai/code/session_01BksbmT1NKcWRSiPA9FUh6B
…ch-up surge

Satellites loaded, drifted slowly, then periodically surged across the
globe in unison. Three root causes, three fixes:

- Anchor dead reckoning to the position's source epoch (entity.time)
  instead of receive time. Sweep chunking + Kafka + WS delivery make
  positions 1-10s old on arrival; receive-time anchors rendered every
  satellite persistently behind reality until the next sweep corrected it.
  DRState now carries serverTime (position epoch, projection anchor) and
  blendTime (receive time, blend-progress anchor). New entities seed
  blendTime = serverTime so they appear at their true current position
  immediately. Guarded against clock skew / wrong units via drAnchorTime().
- Teleport guard in interpolatePVB: gaps beyond max(2 deg, speed x
  interval x 3) snap in one frame instead of racing the visual across the
  map; raw (unwrapped) lon delta so antimeridian crossings snap rather
  than smoothing the long way around.
- Frame-stall reset in useAnimationLoop: after a >1s frame gap (hidden
  tab, GC, shader compilation) clear visualState so visuals re-seed at
  their targets in one clean snap instead of a global catch-up surge.

Also: satellites now reject out-of-order/duplicate sweeps (same
lastSourceTime guard the aircraft/ship path had) and seed
expectedInterval at the real 15s sweep cadence.

Verified: lint, typecheck, 278/278 tests (6 new covering epoch anchoring
and the teleport guard), production build.

https://claude.ai/code/session_01BksbmT1NKcWRSiPA9FUh6B
@d3mocide d3mocide changed the base branch from main to dev June 12, 2026 18:00
@d3mocide d3mocide marked this pull request as ready for review June 12, 2026 18:00
@d3mocide d3mocide merged commit da429c0 into dev Jun 12, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants