diff --git a/agent_docs/tasks/2026-07-10-situation-globe-frame-cache.md b/agent_docs/tasks/2026-07-10-situation-globe-frame-cache.md new file mode 100644 index 0000000..59840bf --- /dev/null +++ b/agent_docs/tasks/2026-07-10-situation-globe-frame-cache.md @@ -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`. diff --git a/frontend/src/components/map/SituationGlobe.tsx b/frontend/src/components/map/SituationGlobe.tsx index 398e346..e58aa15 100644 --- a/frontend/src/components/map/SituationGlobe.tsx +++ b/frontend/src/components/map/SituationGlobe.tsx @@ -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"; @@ -60,6 +61,15 @@ export const SituationGlobe: React.FC = ({ const mapRef = useRef(null); const overlayRef = useRef(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(null); + if (!layerCacheRef.current) layerCacheRef.current = new LayerCache(); + const [viewState, setViewState] = useState({ latitude: 15, longitude: 0, @@ -184,11 +194,19 @@ export const SituationGlobe: React.FC = ({ // 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 @@ -214,31 +232,40 @@ export const SituationGlobe: React.FC = ({ 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, @@ -251,41 +278,54 @@ export const SituationGlobe: React.FC = ({ 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, @@ -300,6 +340,9 @@ export const SituationGlobe: React.FC = ({ outagesData, worldCountriesData, countryOutageMap, + ixpData, + facilityData, + dnsRootData, viewState.zoom, showTerminator, mission,