From dc2d85b474a8fb850088919dd39e0d8dfd7693f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 22:54:16 +0000 Subject: [PATCH] fix: cache Global Situation globe layers instead of rebuilding at 60fps SituationGlobe rebuilt its entire layer stack (infra, aurora, country heat, terminator, GDELT, mission ring) with fresh accessor closures on every animation frame, forcing deck.gl to regenerate GPU attribute buffers 60x/sec. Under the extra main-thread load of the rest of the dashboard loading, this starved MapLibre's own render/projection events long enough that the globe kept rendering with flat-map projection math, producing a broken terminator until things settled (~3min cold, ~10s on remount). Add a per-overlay LayerCache (mirroring useAnimationLoop.ts) so these groups only rebuild when their actual inputs change, matching the pattern already used by TacticalMap/OrbitalMap. Satellite interpolation and orbital layers stay uncached since positions genuinely animate every frame. --- .../2026-07-10-situation-globe-frame-cache.md | 97 ++++++++++++ .../src/components/map/SituationGlobe.tsx | 141 ++++++++++++------ 2 files changed, 189 insertions(+), 49 deletions(-) create mode 100644 agent_docs/tasks/2026-07-10-situation-globe-frame-cache.md 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,