perf: reduce idle CPU from pulse animation loop

- Add 60s startup cooldown to suppress pulse during initial data hydration
- Consolidate pulse lifecycle into syncPulseAnimation() across all setters
- Replace static predicates (h.level=high, c.maxSeverity=high) with dynamic-only
  checks: recent news, recent ACLED riots (<2h), breaking hotspots
- Exclude GDELT riots from pulse gating (noisy, not actionable)
- Reduce pulse interval from 250ms to 500ms (halves layer rebuild rate)
- Pause pulse when render is paused (country brief overlay)
- Add latestRiotEventTimeMs to MapProtestCluster with raw protest fallback
- Add Playwright tests for startup cooldown and lifecycle start/stop/GDELT exclusion
This commit is contained in:
Elie Habib
2026-02-15 15:17:07 +04:00
parent 96c6208bb0
commit 3d16f3c4e1
5 changed files with 246 additions and 20 deletions

View File

@@ -20,7 +20,18 @@ type HarnessWindow = Window & {
variant: 'full' | 'tech';
seedAllDynamicData: () => void;
setProtestsScenario: (scenario: 'alpha' | 'beta') => void;
setPulseProtestsScenario: (
scenario:
| 'none'
| 'recent-acled-riot'
| 'recent-gdelt-riot'
| 'recent-protest'
) => void;
setNewsPulseScenario: (scenario: 'none' | 'recent' | 'stale') => void;
setHotspotActivityScenario: (scenario: 'none' | 'breaking') => void;
forcePulseStartupElapsed: () => void;
resetPulseStartupTime: () => void;
isPulseAnimationRunning: () => boolean;
setZoom: (zoom: number) => void;
setLayersForSnapshot: (enabledLayers: string[]) => void;
setCamera: (camera: { lon: number; lat: number; zoom: number }) => void;
@@ -270,6 +281,96 @@ test.describe('DeckGL map harness', () => {
}
});
test('suppresses pulse animation during startup cooldown even with recent signals', async ({
page,
}) => {
await waitForHarnessReady(page);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setHotspotActivityScenario('none');
w.__mapHarness?.setPulseProtestsScenario('none');
w.__mapHarness?.setNewsPulseScenario('none');
w.__mapHarness?.resetPulseStartupTime();
w.__mapHarness?.setNewsPulseScenario('recent');
});
await page.waitForTimeout(800);
const isRunning = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
expect(isRunning).toBe(false);
});
test('starts and stops pulse on dynamic signals and ignores gdelt-only riot recency', async ({
page,
}) => {
await waitForHarnessReady(page);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setHotspotActivityScenario('none');
w.__mapHarness?.setPulseProtestsScenario('none');
w.__mapHarness?.setNewsPulseScenario('none');
w.__mapHarness?.forcePulseStartupElapsed();
w.__mapHarness?.setNewsPulseScenario('recent');
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 10000 })
.toBe(true);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setNewsPulseScenario('stale');
w.__mapHarness?.setHotspotActivityScenario('none');
w.__mapHarness?.setPulseProtestsScenario('none');
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 10000 })
.toBe(false);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setPulseProtestsScenario('recent-gdelt-riot');
});
await page.waitForTimeout(800);
const gdeltPulseRunning = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
expect(gdeltPulseRunning).toBe(false);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setPulseProtestsScenario('recent-acled-riot');
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 10000 })
.toBe(true);
});
test('matches golden screenshots per layer and zoom', async ({ page }) => {
test.setTimeout(180_000);

View File

@@ -598,6 +598,7 @@ export class App {
public async openCountryBriefByCode(code: string, country: string): Promise<void> {
if (!this.countryBriefPage) return;
this.map?.setRenderPaused(true);
// Normalize to canonical name (GeoJSON may use "United States of America" etc.)
const canonicalName = TIER1_COUNTRIES[code] || App.resolveCountryName(code);

View File

@@ -256,6 +256,7 @@ export class DeckGLMap {
private lastSCBoundsKey = '';
private lastSCMask = '';
private newsPulseIntervalId: ReturnType<typeof setInterval> | null = null;
private readonly startupTime = Date.now();
private lastCableHighlightSignature = '';
private lastPipelineHighlightSignature = '';
private debouncedRebuildLayers: () => void;
@@ -271,10 +272,12 @@ export class DeckGLMap {
this.rebuildDatacenterSupercluster();
this.debouncedRebuildLayers = debounce(() => {
if (this.renderPaused) return;
this.maplibreMap?.resize();
this.deckOverlay?.setProps({ layers: this.buildLayers() });
}, 150);
this.rafUpdateLayers = rafSchedule(() => {
if (this.renderPaused) return;
this.deckOverlay?.setProps({ layers: this.buildLayers() });
});
@@ -618,6 +621,11 @@ export class DeckGLMap {
const verifiedCount = Number(props.verifiedCount ?? 0);
const totalFatalities = Number(props.totalFatalities ?? 0);
const clusterCount = Number(f.properties.point_count ?? items.length);
const latestRiotEventTimeMs = items.reduce((max, it) => {
if (it.eventType !== 'riot' || it.sourceType === 'gdelt') return max;
const ts = it.time.getTime();
return Number.isFinite(ts) ? Math.max(max, ts) : max;
}, 0);
return {
id: `pc-${f.properties.cluster_id}`,
lat: coords[1], lon: coords[0],
@@ -626,6 +634,7 @@ export class DeckGLMap {
country: String(props.country ?? items[0]?.country ?? ''),
maxSeverity: maxSev as 'low' | 'medium' | 'high',
hasRiot: riotCount > 0,
latestRiotEventTimeMs: latestRiotEventTimeMs || undefined,
totalFatalities,
riotCount,
highSeverityCount,
@@ -638,6 +647,10 @@ export class DeckGLMap {
id: `pp-${f.properties.index}`, lat: item.lat, lon: item.lon,
count: 1, items: [item], country: item.country,
maxSeverity: item.severity, hasRiot: item.eventType === 'riot',
latestRiotEventTimeMs:
item.eventType === 'riot' && item.sourceType !== 'gdelt' && Number.isFinite(item.time.getTime())
? item.time.getTime()
: undefined,
totalFatalities: item.fatalities ?? 0,
riotCount: item.eventType === 'riot' ? 1 : 0,
highSeverityCount: item.severity === 'high' ? 1 : 0,
@@ -1877,19 +1890,50 @@ export class DeckGLMap {
private pulseTime = 0;
private needsPulseAnimation(): boolean {
return this.hasRecentNews(Date.now())
|| this.protestClusters.some(c => c.maxSeverity === 'high' || c.hasRiot)
|| this.hotspots.some(h => h.level === 'high' || h.hasBreaking);
private canPulse(now = Date.now()): boolean {
return now - this.startupTime > 60_000;
}
private hasRecentRiot(now = Date.now(), windowMs = 2 * 60 * 60 * 1000): boolean {
const hasRecentClusterRiot = this.protestClusters.some(c =>
c.hasRiot && c.latestRiotEventTimeMs != null && (now - c.latestRiotEventTimeMs) < windowMs
);
if (hasRecentClusterRiot) return true;
// Fallback to raw protests because syncPulseAnimation can run before cluster data refreshes.
return this.protests.some((p) => {
if (p.eventType !== 'riot' || p.sourceType === 'gdelt') return false;
const ts = p.time.getTime();
return Number.isFinite(ts) && (now - ts) < windowMs;
});
}
private needsPulseAnimation(now = Date.now()): boolean {
return this.hasRecentNews(now)
|| this.hasRecentRiot(now)
|| this.hotspots.some(h => h.hasBreaking);
}
private syncPulseAnimation(now = Date.now()): void {
if (this.renderPaused) {
if (this.newsPulseIntervalId !== null) this.stopPulseAnimation();
return;
}
const shouldPulse = this.canPulse(now) && this.needsPulseAnimation(now);
if (shouldPulse && this.newsPulseIntervalId === null) {
this.startPulseAnimation();
} else if (!shouldPulse && this.newsPulseIntervalId !== null) {
this.stopPulseAnimation();
}
}
private startPulseAnimation(): void {
if (this.newsPulseIntervalId !== null) return;
const PULSE_UPDATE_INTERVAL_MS = 250;
const PULSE_UPDATE_INTERVAL_MS = 500;
this.newsPulseIntervalId = setInterval(() => {
const now = Date.now();
if (!this.needsPulseAnimation()) {
if (!this.needsPulseAnimation(now)) {
this.pulseTime = now;
this.stopPulseAnimation();
this.rafUpdateLayers();
@@ -2649,7 +2693,14 @@ export class DeckGLMap {
}
public setRenderPaused(paused: boolean): void {
if (this.renderPaused === paused) return;
this.renderPaused = paused;
if (paused) {
this.stopPulseAnimation();
return;
}
this.syncPulseAnimation();
if (!paused && this.renderPending) {
this.renderPending = false;
this.render();
@@ -2657,6 +2708,7 @@ export class DeckGLMap {
}
private updateLayers(): void {
if (this.renderPaused) return;
const startTime = performance.now();
if (this.deckOverlay) {
this.deckOverlay.setProps({ layers: this.buildLayers() });
@@ -2846,9 +2898,7 @@ export class DeckGLMap {
this.protests = events;
this.rebuildProtestSupercluster();
this.render();
if (this.needsPulseAnimation() && this.newsPulseIntervalId === null) {
this.startPulseAnimation();
}
this.syncPulseAnimation();
}
public setFlightDelays(delays: AirportDelayAlert[]): void {
@@ -2912,12 +2962,7 @@ export class DeckGLMap {
this.newsLocations = data;
this.render();
const hasRecent = this.hasRecentNews(now);
if (hasRecent && this.newsPulseIntervalId === null) {
this.startPulseAnimation();
} else if (!hasRecent) {
this.stopPulseAnimation();
}
this.syncPulseAnimation(now);
}
public updateHotspotActivity(news: NewsItem[]): void {
@@ -2952,9 +2997,7 @@ export class DeckGLMap {
});
this.render();
if (this.needsPulseAnimation() && this.newsPulseIntervalId === null) {
this.startPulseAnimation();
}
this.syncPulseAnimation();
}
/** Get news items related to a hotspot by keyword matching */

View File

@@ -45,6 +45,12 @@ import type { WeatherAlert } from '../services/weather';
type Scenario = 'alpha' | 'beta';
type HarnessVariant = 'full' | 'tech';
type HarnessLayerKey = keyof MapLayers;
type PulseProtestScenario =
| 'none'
| 'recent-acled-riot'
| 'recent-gdelt-riot'
| 'recent-protest';
type NewsPulseScenario = 'none' | 'recent' | 'stale';
type LayerSnapshot = {
id: string;
@@ -85,7 +91,12 @@ type MapHarness = {
variant: HarnessVariant;
seedAllDynamicData: () => void;
setProtestsScenario: (scenario: Scenario) => void;
setPulseProtestsScenario: (scenario: PulseProtestScenario) => void;
setNewsPulseScenario: (scenario: NewsPulseScenario) => void;
setHotspotActivityScenario: (scenario: 'none' | 'breaking') => void;
forcePulseStartupElapsed: () => void;
resetPulseStartupTime: () => void;
isPulseAnimationRunning: () => boolean;
setZoom: (zoom: number) => void;
setLayersForSnapshot: (enabledLayers: HarnessLayerKey[]) => void;
setCamera: (camera: CameraState) => void;
@@ -208,7 +219,9 @@ const internals = map as unknown as {
lastClusterState?: Map<string, unknown>;
maplibreMap?: MapLibreMap;
newsLocationFirstSeen?: Map<string, number>;
stopNewsPulseAnimation?: () => void;
newsPulseIntervalId?: ReturnType<typeof setInterval> | null;
startupTime?: number;
stopPulseAnimation?: () => void;
};
const buildLayerState = (enabledLayers: HarnessLayerKey[]): MapLayers => {
@@ -616,6 +629,37 @@ const buildProtests = (scenario: Scenario): SocialUnrestEvent[] => {
];
};
const buildPulseProtests = (scenario: PulseProtestScenario): SocialUnrestEvent[] => {
if (scenario === 'none') return [];
const now = new Date();
const isRiot = scenario !== 'recent-protest';
const sourceType = scenario === 'recent-gdelt-riot' ? 'gdelt' : 'acled';
return [
{
id: `e2e-pulse-protest-${scenario}`,
title: `Pulse Protest ${scenario}`,
summary: `Pulse protest fixture: ${scenario}`,
eventType: isRiot ? 'riot' : 'protest',
city: 'Harness City',
country: 'Harnessland',
lat: 20.1,
lon: 0.2,
time: now,
severity: isRiot ? 'high' : 'medium',
fatalities: isRiot ? 1 : 0,
sources: ['e2e'],
sourceType,
tags: ['e2e', 'pulse'],
actors: ['Harness Group'],
relatedHotspots: [],
confidence: 'high',
validated: true,
},
];
};
const buildHotspotActivityNews = (
scenario: 'none' | 'breaking'
): NewsItem[] => {
@@ -887,7 +931,30 @@ const makeNewsLocationsNonRecent = (): void => {
internals.newsLocationFirstSeen.set(key, now - 120_000);
}
}
internals.stopNewsPulseAnimation?.();
internals.stopPulseAnimation?.();
};
const setNewsPulseScenario = (scenario: NewsPulseScenario): void => {
if (scenario === 'none') {
internals.newsLocationFirstSeen?.clear();
map.setNewsLocations([]);
return;
}
if (scenario === 'recent') {
map.setNewsLocations([
{
lat: 48.85,
lon: 2.35,
title: `Harness Pulse News ${Date.now()}`,
threatLevel: 'high',
},
]);
return;
}
map.setNewsLocations(SEEDED_NEWS_LOCATIONS);
makeNewsLocationsNonRecent();
};
let deterministicVisualModeEnabled = false;
@@ -1011,9 +1078,22 @@ window.__mapHarness = {
setProtestsScenario: (scenario: Scenario): void => {
map.setProtests(buildProtests(scenario));
},
setPulseProtestsScenario: (scenario: PulseProtestScenario): void => {
map.setProtests(buildPulseProtests(scenario));
},
setNewsPulseScenario,
setHotspotActivityScenario: (scenario: 'none' | 'breaking'): void => {
map.updateHotspotActivity(buildHotspotActivityNews(scenario));
},
forcePulseStartupElapsed: (): void => {
internals.startupTime = Date.now() - 61_000;
},
resetPulseStartupTime: (): void => {
internals.startupTime = Date.now();
},
isPulseAnimationRunning: (): boolean => {
return internals.newsPulseIntervalId != null;
},
setZoom: (zoom: number): void => {
map.setZoom(zoom);
map.render();

View File

@@ -1146,6 +1146,7 @@ export interface MapProtestCluster {
country: string;
maxSeverity: 'low' | 'medium' | 'high';
hasRiot: boolean;
latestRiotEventTimeMs?: number;
totalFatalities: number;
riotCount?: number;
highSeverityCount?: number;