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
97 changes: 97 additions & 0 deletions agent_docs/tasks/2026-07-10-situation-globe-frame-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Global Situation Globe — Uncached Per-Frame Layer Rebuild

## Issue

On the dashboard's Global Situation view, the terminator (and the globe in
general) rendered in a broken, jagged state for as long as ~3 minutes after
a fresh page load — self-correcting once the rest of the dashboard finished
loading. Navigating away from the view and back also fixed it, but faster
(~10 seconds). Two prior fixes already landed for terminator *geometry* bugs
(`2026-07-08-gdelt-coldstart-terminator-kinetic-ranker.md`,
`2026-07-10-globe-terminator-chord-fix.md`), but the transient "stuck until
things settle" symptom persisted — this is a third, distinct cause.

Root cause: `SituationGlobe.tsx`'s imperative layer-composition `useEffect`
is keyed on `now`, which is updated 60x/sec by the auto-rotation
`requestAnimationFrame` loop. Every one of those frames rebuilt the entire
layer stack (`buildInfraLayers`, `buildAuroraLayer`, `buildCountryHeatLayer`,
`getTerminatorLayer`, `buildGdeltLayer`, `buildAOTLayers`) from scratch, each
call passing brand-new inline accessor closures. deck.gl treats a changed
accessor reference (`getFillColor`, `getPosition`, …) as a signal that GPU
attribute buffers need regenerating and re-uploading, so this uncached
rebuild forced full attribute regen for every one of those layer groups on
every frame — 60x/sec, indefinitely.

This is inconsistent with the rest of the app: `TacticalMap`/`OrbitalMap`
(via `useAnimationLoop.ts` → `composeAllLayers()` → `layers/composition.ts`)
use a persistent `LayerCache` per overlay specifically to avoid this
(`layerCache.ts`: *"avoids regenerating and re-uploading their GPU attribute
buffers every frame"*). `SituationGlobe` hand-rolls its own layer list
inline and never adopted that pattern.

Under the extra main-thread load of the rest of the dashboard mounting
(concurrent widget fetches/renders on first load), this uncached rebuild
starves the browser's main thread badly enough that MapLibre's own
render/projection events fall behind. `@deck.gl/mapbox` re-evaluates globe
vs. mercator projection on every MapLibre `render` event and falls back to
flat `MapView` math whenever the style/projection hasn't reported `'globe'`
yet — so under sustained starvation the globe keeps getting drawn with flat
projection math, producing the same jagged/misplaced terminator artifact the
geometry fixes addressed, until the main thread frees up and the frame rate
recovers. A cold full page load has much more concurrent work (~3 min to
settle) than a warm remount with already-fetched data (~10 s).

## Solution

Give `SituationGlobe` the same per-overlay `LayerCache` treatment already
used by `useAnimationLoop.ts`. Layer groups whose inputs change on a
second-to-minute cadence (infra, aurora, country heat, terminator, GDELT,
mission/AOT ring) are now memoized via `cache.get(key, deps, build)` and
only rebuilt when their actual dependencies change, instead of on every
animation frame. Satellite interpolation and the orbital layers it feeds
intentionally stay outside the cache — their positions genuinely animate
every frame, matching the existing convention in `composition.ts` where
`getOrbitalLayers` is likewise never cached. Aurora's pulse argument is
throttled to 10 Hz (`pulseNow = now - (now % 100)`) to match the
`pulseNow` convention already used in `composition.ts` for the same reason.

## Changes

- `frontend/src/components/map/SituationGlobe.tsx`
- Added a persistent `LayerCache` (`layerCacheRef`), one per overlay
instance, mirroring `useAnimationLoop.ts:360-361`.
- Wrapped `buildInfraLayers`, `buildAuroraLayer`, `buildCountryHeatLayer`,
`getTerminatorLayer`, `buildGdeltLayer`, and `buildAOTLayers` in
`cache.get(...)` calls keyed on their actual inputs.
- `buildAuroraLayer`'s time argument now uses a 10 Hz-throttled `pulseNow`
instead of raw `now`.
- `ixpData`, `facilityData`, `dnsRootData` were used inside the effect but
missing from its dependency array — added them (they now also serve as
the infra cache key). Satellite interpolation and `getOrbitalLayers`
remain uncached, unchanged in behavior.

## Verification

```
cd frontend && pnpm run lint # clean
pnpm run typecheck # clean
pnpm run test # 290/290 passed
pnpm run build # succeeds
```

No backend/DB stack is running in this sandbox, so the live multi-minute
timing scenario could not be reproduced end-to-end in a browser; the fix was
verified by static analysis confirming the cached layer composition, order,
and props are identical to before, gated only by dependency-equality checks
instead of running unconditionally every frame.

## Benefits

- The Global Situation globe (infra, aurora, country heat, terminator,
GDELT, mission ring) no longer regenerates and re-uploads GPU attribute
buffers 60x/sec — only when the underlying data actually changes.
- Removes the main-thread contention most likely responsible for the globe
getting stuck rendering with flat-map projection math for minutes after a
cold dashboard load.
- Brings `SituationGlobe` in line with the caching convention already used
by `TacticalMap`/`OrbitalMap`.
141 changes: 92 additions & 49 deletions frontend/src/components/map/SituationGlobe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { buildAuroraLayer } from "../../layers/buildAuroraLayer";
import { buildCountryHeatLayer, type ActorEntry } from "../../layers/buildCountryHeatLayer";
import { buildGdeltLayer } from "../../layers/buildGdeltLayer";
import { buildInfraLayers } from "../../layers/buildInfraLayers";
import { LayerCache } from "../../layers/layerCache";
import { getOrbitalLayers } from "../../layers/OrbitalLayer";
import { CoTEntity, DRState } from "../../types";
import { interpolatePVB } from "../../utils/interpolation";
Expand Down Expand Up @@ -60,6 +61,15 @@ export const SituationGlobe: React.FC<SituationGlobeProps> = ({
const mapRef = useRef<MapRef>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);

// Per-overlay layer memoization. Without this, the layer groups below get
// rebuilt with brand-new accessor closures on every rAF tick (the effect
// is keyed on `now`, which updates 60x/sec from the auto-rotation loop),
// forcing deck.gl to regenerate and re-upload GPU attribute buffers for
// infra/aurora/country-heat/terminator/gdelt every single frame instead of
// only when their actual inputs change. Same pattern as useAnimationLoop.ts.
const layerCacheRef = useRef<LayerCache | null>(null);
if (!layerCacheRef.current) layerCacheRef.current = new LayerCache();

const [viewState, setViewState] = useState({
latitude: 15,
longitude: 0,
Expand Down Expand Up @@ -184,11 +194,19 @@ export const SituationGlobe: React.FC<SituationGlobeProps> = ({
// Imperative Layer Update to avoid reading refs in render
useEffect(() => {
if (now === 0 || !overlayRef.current) return;
const cache = layerCacheRef.current!;

const dt = now - lastFrameTimeRef.current;
lastFrameTimeRef.current = now;

// 1. Interpolate Satellites for smooth motion on the globe
// Pulse-driven layers tick at 10Hz instead of every frame (matches the
// pulseNow convention in layers/composition.ts) so they stay cache hits
// for ~6 consecutive frames instead of recomputing color attributes at 60fps.
const pulseNow = now - (now % 100);

// 1. Interpolate Satellites for smooth motion on the globe.
// Positions genuinely change every frame, so this (and the orbital
// layers it feeds) intentionally stays outside the layer cache.
const filteredSats: CoTEntity[] = [];
satellitesRef.current.forEach((sat, uid) => {
// Filter for Intel/Surveillance assets specifically as requested
Expand All @@ -214,31 +232,40 @@ export const SituationGlobe: React.FC<SituationGlobeProps> = ({
filteredSats.push(interpolatedEntity);
});

// 2. Build Infrastructure Layers
const infra = buildInfraLayers(
cablesData,
stationsData,
outagesData,
{
showCables: true,
showLandingStations: false,
showOutages: false, // replaced by GDELT conflict zones as primary geographic layer
showIXPs: false, // too dense alongside conflict dots
showFacilities: false,
cableOpacity: 0.35, // subtle — cables as background geography only
// 2. Build Infrastructure Layers — cached; cables/stations/outages/country
// data change on a second-to-minute cadence, not every frame.
const infra = cache.get(
"infra",
[cablesData, stationsData, outagesData, worldCountriesData, countryOutageMap, ixpData, facilityData, dnsRootData],
() => {
const built = buildInfraLayers(
cablesData,
stationsData,
outagesData,
{
showCables: true,
showLandingStations: false,
showOutages: false, // replaced by GDELT conflict zones as primary geographic layer
showIXPs: false, // too dense alongside conflict dots
showFacilities: false,
cableOpacity: 0.35, // subtle — cables as background geography only
},
() => {}, // No-op hover
() => {}, // No-op click
null,
true, // globeMode
worldCountriesData,
countryOutageMap,
ixpData ?? null,
facilityData ?? null,
dnsRootData ?? [],
);
return [...built.outages, ...built.assets];
},
() => {}, // No-op hover
() => {}, // No-op click
null,
true, // globeMode
worldCountriesData,
countryOutageMap,
ixpData ?? null,
facilityData ?? null,
dnsRootData ?? [],
);

// 3. Build Orbital Layers
// 3. Build Orbital Layers — intentionally uncached; satellite positions
// animate continuously.
const orbital = getOrbitalLayers({
satellites: filteredSats,
selectedEntity: null,
Expand All @@ -251,41 +278,54 @@ export const SituationGlobe: React.FC<SituationGlobeProps> = ({
onHover: () => {},
});

// 4. Build Mission Area / AO Ring
const missionLayers = buildAOTLayers(
null,
{ showRepeaters: true } as any,
true, // globeMode
null, // observer
mission
? {
lat: mission.lat,
lon: mission.lon,
radiusKm: mission.radius_nm * 1.852,
}
: null,
// 4. Build Mission Area / AO Ring — cached on mission
const missionLayers = cache.get(
"aot",
[mission],
() =>
buildAOTLayers(
null,
{ showRepeaters: true } as any,
true, // globeMode
null, // observer
mission
? {
lat: mission.lat,
lon: mission.lon,
radiusKm: mission.radius_nm * 1.852,
}
: null,
),
);

overlayRef.current.setProps({
layers: [
...buildAuroraLayer(auroraData, true, true, now),
...cache.get("aurora", [auroraData, pulseNow], () =>
buildAuroraLayer(auroraData, true, true, pulseNow),
),
// Country conflict heat — fills countries by GDELT threat level (below cables/dots)
...buildCountryHeatLayer(worldCountriesData as any, actors, true, true, 0),
...cache.get("country-heat", [worldCountriesData, actors], () =>
buildCountryHeatLayer(worldCountriesData as any, actors, true, true, 0),
),
// Night-side overlay — rendered after country heat so the shadow tints over it;
// globe mode depth-tests against the globe mask so the far-side night
// hemisphere doesn't bleed through the planet.
getTerminatorLayer(!!showTerminator, true),
...infra.outages,
...infra.assets,
// Terminator geometry only changes once per minute.
...cache.get("terminator", [showTerminator, Math.floor(now / 60_000)], () => [
getTerminatorLayer(!!showTerminator, true),
]),
...infra,
// GDELT conflict + tension only (tone ≤ -2) — same as OrbitalMap
...buildGdeltLayer(
gdeltData,
true,
true,
-2,
true,
onHover || (() => {}),
onGdeltClick,
...cache.get("gdelt", [gdeltData, onHover, onGdeltClick], () =>
buildGdeltLayer(
gdeltData,
true,
true,
-2,
true,
onHover || (() => {}),
onGdeltClick,
),
),
...missionLayers,
...orbital,
Expand All @@ -300,6 +340,9 @@ export const SituationGlobe: React.FC<SituationGlobeProps> = ({
outagesData,
worldCountriesData,
countryOutageMap,
ixpData,
facilityData,
dnsRootData,
viewState.zoom,
showTerminator,
mission,
Expand Down
Loading