mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1146,6 +1146,7 @@ export interface MapProtestCluster {
|
||||
country: string;
|
||||
maxSeverity: 'low' | 'medium' | 'high';
|
||||
hasRiot: boolean;
|
||||
latestRiotEventTimeMs?: number;
|
||||
totalFatalities: number;
|
||||
riotCount?: number;
|
||||
highSeverityCount?: number;
|
||||
|
||||
Reference in New Issue
Block a user