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
125 changes: 125 additions & 0 deletions agent_docs/tasks/2026-06-12-frontend-performance-optimization.md
Original file line number Diff line number Diff line change
@@ -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` `<link>` (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.
85 changes: 85 additions & 0 deletions agent_docs/tasks/2026-06-12-satellite-position-jump-fix.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 7 additions & 2 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@
/>
<meta property="og:type" content="website" />
<link rel="icon" type="image/png" href="/favicon.png" />
<!-- Importing JetBrains Mono from Google Fonts (or similar mono) -->
<!--
All Google Fonts load from here (one combined request) rather than via
@import inside the bundled CSS — a CSS @import chains a render-blocking
fetch behind the stylesheet download; this link resolves in parallel
with the JS modules thanks to the preconnect hints.
-->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Space+Grotesk:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap"
rel="stylesheet"
/>
<style>
Expand Down
41 changes: 35 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import type { FeatureCollection } from "geojson";
import { AlertTriangle, CheckCircle2, ExternalLink, Globe, Loader2, Plane, Radar, Ship, X, XCircle } from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ComponentProps, type ComponentType } from "react";
import { getSetupStatus } from "./api/auth";
import { fetchMissionH3Risk, type RiskSeverity } from "./api/h3Risk";
import RadioTerminal from "./components/js8call/RadioTerminal";
import { IntelSidebar } from "./components/layouts/IntelSidebar";
import { MainHud } from "./components/layouts/MainHud";
import { OrbitalSidebarLeft } from "./components/layouts/OrbitalSidebarLeft";
import { SidebarLeft } from "./components/layouts/SidebarLeft";
import { SidebarRight } from "./components/layouts/SidebarRight";
import { TopBar } from "./components/layouts/TopBar";
import { IntelGlobe } from "./components/map/IntelGlobe";
import { OrbitalMap } from "./components/map/OrbitalMap";
import TacticalMap from "./components/map/TacticalMap";
import { DashboardView } from "./components/views/DashboardView";
import { LoginView } from "./components/views/LoginView";
import { AIAnalystPanel } from "./components/widgets/AIAnalystPanel";
import { AnalysisFormatter } from "./components/widgets/AnalysisFormatter";
Expand Down Expand Up @@ -55,6 +50,40 @@ interface IntelArticleContent {
const StatsDashboardView = lazy(() => import('./components/views/StatsDashboardView'));
const LinkageAuditView = lazy(() => import('./components/views/LinkageAuditView'));

// ── Code-split heavy views ──────────────────────────────────────────────────
// The map/globe views pull in deck.gl + maplibre/mapbox (multi-MB chunks) and
// the radio terminal pulls its own widget tree. Loading them on demand keeps
// the login screen and app shell out from under that cost. Each wrapper
// carries its own Suspense boundary so the view-switch JSX stays untouched.
function lazyView<T extends ComponentType<any>>(
importer: () => Promise<{ default: T }>,
) {
const LazyComp = lazy(importer);
return function LazyViewBoundary(props: ComponentProps<T>) {
return (
<Suspense fallback={<div className="absolute inset-0 bg-[#050505]" />}>
<LazyComp {...props} />
</Suspense>
);
};
}

const TacticalMap = lazyView(() => import("./components/map/TacticalMap"));
const OrbitalMap = lazyView(() =>
import("./components/map/OrbitalMap").then((m) => ({ default: m.OrbitalMap })),
);
const IntelGlobe = lazyView(() =>
import("./components/map/IntelGlobe").then((m) => ({ default: m.IntelGlobe })),
);
const DashboardView = lazyView(() =>
import("./components/views/DashboardView").then((m) => ({
default: m.DashboardView,
})),
);
const RadioTerminal = lazyView(
() => import("./components/js8call/RadioTerminal"),
);

function AuthenticatedApp() {

type RegionalRiskResponse = {
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/map/IntelGlobe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
*/
import { MapboxOverlay } from "@deck.gl/mapbox";
import type { FeatureCollection } from "geojson";
import "maplibre-gl/dist/maplibre-gl.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchH3Risk, type H3RiskCellData } from "../../api/h3Risk";
import { buildH3RiskLayer } from "../../layers/buildH3RiskLayer";
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/map/MapLibreAdapter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { MapboxOverlay } from "@deck.gl/mapbox";
// CSS lives with the adapter so only the selected map library's styles are
// loaded — the lazy chunk for the unused adapter (and its CSS) never downloads.
import "maplibre-gl/dist/maplibre-gl.css";
import type { CustomLayerInterface, StyleSpecification } from "maplibre-gl";
import { forwardRef, useEffect, useRef } from "react";
import {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/map/MapboxAdapter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { MapboxOverlay } from "@deck.gl/mapbox";
// CSS lives with the adapter so only the selected map library's styles are
// loaded — the lazy chunk for the unused adapter (and its CSS) never downloads.
import "mapbox-gl/dist/mapbox-gl.css";
import type { StyleSpecification } from "mapbox-gl";
import { forwardRef, useEffect, useMemo, useRef } from "react";
import {
Expand Down
2 changes: 0 additions & 2 deletions frontend/src/components/map/OrbitalMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import React, {
import type { MapRef } from "react-map-gl/maplibre";

import type { FeatureCollection } from "geojson";
import "maplibre-gl/dist/maplibre-gl.css";
import "mapbox-gl/dist/mapbox-gl.css";
import { CoTEntity, JS8Station, MissionProps, RFSite, GroundTrackPoint, SatNOGSStation } from "../../types";
import { MapTooltip } from "./MapTooltip";
import { MapContextMenu } from "./MapContextMenu";
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/components/map/StarField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,17 @@ export function StarField({ active, contained = false }: StarFieldProps) {

ctx.clearRect(0, 0, W, H);

// Draw stars with subtle twinkling
// Draw stars with subtle twinkling — vary globalAlpha instead of
// formatting a new rgba() string per star per frame
ctx.fillStyle = '#ffffff';
for (const star of stars) {
const t = Math.sin(frame * star.twinkleSpeed + star.twinklePhase);
const opacity = star.baseOpacity * (0.65 + 0.35 * t);
ctx.fillStyle = `rgba(255, 255, 255, ${opacity.toFixed(3)})`;
ctx.globalAlpha = star.baseOpacity * (0.65 + 0.35 * t);
ctx.beginPath();
ctx.arc(star.x * W, star.y * H, star.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;

frame++;
animId = requestAnimationFrame(render);
Expand Down
2 changes: 0 additions & 2 deletions frontend/src/components/map/TacticalMap.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type { FeatureCollection } from "geojson";
import "mapbox-gl/dist/mapbox-gl.css";
import "maplibre-gl/dist/maplibre-gl.css";
import React, {
MutableRefObject,
Suspense,
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/components/map/TerminatorLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,24 @@ function computeTerminator(date: Date) {
} as TerminatorGeoJson;
}

// The terminator only moves meaningfully once per minute, but this function is
// called from per-frame layer composition. Memoize the (360-point) polygon so
// repeat calls within the same minute return the identical GeoJSON object —
// a stable `data` reference also lets deck.gl skip re-uploading attributes.
let cachedMinute = 0;
let cachedGeoJson: TerminatorGeoJson | null = null;

export function getTerminatorLayer(visible: boolean) {
// We use Date.now() rounded to nearest minute to avoid constant re-renders
// For a pure layer creator function, we calculate the current terminator
const now = new Date();
now.setSeconds(0, 0);

const terminatorGeoJson = computeTerminator(now);
if (!cachedGeoJson || cachedMinute !== now.getTime()) {
cachedMinute = now.getTime();
cachedGeoJson = computeTerminator(now);
}
const terminatorGeoJson = cachedGeoJson;

return new GeoJsonLayer({
id: 'terminator-layer',
Expand Down
Loading
Loading