fix(resilience): sanitize resilienceScore on non-DeckGL renderers (#2685)

* fix(resilience): sanitize resilienceScore on non-DeckGL renderers

Strip resilienceScore from layer state when DeckGL is not active
(mobile/SVG/globe). Prevents bookmark/URL state leaking an invisible
active layer on renderers that have no resilience choropleth path.

Applied at two levels:
- Constructor: strip from initialState before init
- setLayers: strip on every layer update for renderer switches

* fix(resilience): sync sanitized layer state to app context and storage

MapContainer sanitization was local-only. App state (ctx.mapLayers)
and localStorage still had resilienceScore=true on non-DeckGL renderers,
causing data-loader to schedule unnecessary fetches.

Fix: strip resilienceScore from ctx.mapLayers and storage at two points:
- After MapContainer construction (initial hydration sync-back)
- In panel-layout setLayers (runtime layer updates)

* fix(resilience): guard search-manager toggle and enableLayer for non-DeckGL

- search-manager.ts: prevent resilienceScore from being set to true
  in ctx.mapLayers when DeckGL is not active (generic layer toggle path)
- MapContainer.enableLayer: early-return for resilienceScore on
  non-DeckGL renderers to prevent SVG/globe onLayerChange from
  reinforcing stale state

* fix(resilience): sync ctx.mapLayers after renderer mode switch

switchToGlobe/switchToFlat bypasses panel-layout, so ctx.mapLayers
was not synced after MapContainer internally sanitized the layer
state. Add post-switch sync in event-handlers to strip resilienceScore
from app state and storage when switching to a non-DeckGL renderer.

* test(resilience): add regression coverage for non-DeckGL sanitization

5 new tests covering the resilienceScore sanitization invariant:
- strips on non-DeckGL renderer
- preserves on DeckGL renderer
- doesn't affect other layers
- URL restore + normalize + sanitize chain
- mode switch from DeckGL to globe
This commit is contained in:
Elie Habib
2026-04-04 19:27:37 +04:00
committed by GitHub
parent b5feccbdff
commit 0f0923ec8d
5 changed files with 89 additions and 6 deletions

View File

@@ -1499,6 +1499,10 @@ export class EventHandlerManager implements AppModule {
} else {
this.ctx.map?.switchToFlat();
}
if (this.ctx.mapLayers.resilienceScore && !this.ctx.map?.isDeckGLActive?.()) {
this.ctx.mapLayers = { ...this.ctx.mapLayers, resilienceScore: false };
saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers);
}
});
});
}

View File

@@ -718,6 +718,11 @@ export class PanelLayoutManager implements AppModule {
timeRange: '7d',
}, preferGlobe);
if (this.ctx.mapLayers.resilienceScore && !this.ctx.map.isDeckGLActive?.()) {
this.ctx.mapLayers = { ...this.ctx.mapLayers, resilienceScore: false };
saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers);
}
this.ctx.map.initEscalationGetters();
this.ctx.currentTimeRange = this.ctx.map.getTimeRange();
@@ -1405,7 +1410,10 @@ export class PanelLayoutManager implements AppModule {
}
if (layers) {
const normalized = normalizeExclusiveChoropleths(layers, this.ctx.mapLayers);
let normalized = normalizeExclusiveChoropleths(layers, this.ctx.mapLayers);
if (normalized.resilienceScore && !this.ctx.map.isDeckGLActive?.()) {
normalized = { ...normalized, resilienceScore: false };
}
this.ctx.mapLayers = normalized;
saveToStorage(STORAGE_KEYS.mapLayers, normalized);
this.ctx.map.setLayers(normalized);

View File

@@ -490,9 +490,13 @@ export class SearchManager implements AppModule {
if (!(layerKey in this.ctx.mapLayers)) return;
const variantAllowed = getAllowedLayerKeys((SITE_VARIANT || 'full') as MapVariant);
if (!variantAllowed.has(layerKey)) return;
this.ctx.mapLayers[layerKey] = !this.ctx.mapLayers[layerKey];
let newValue = !this.ctx.mapLayers[layerKey];
if (newValue && layerKey === 'resilienceScore' && !this.ctx.map?.isDeckGLActive?.()) {
newValue = false;
}
this.ctx.mapLayers[layerKey] = newValue;
saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers);
if (this.ctx.mapLayers[layerKey]) {
if (newValue) {
this.ctx.map?.enableLayer(layerKey);
} else {
this.ctx.map?.setLayers(this.ctx.mapLayers);

View File

@@ -149,9 +149,12 @@ export class MapContainer {
this.isMobile = isMobileDevice();
this.useGlobe = preferGlobe && this.hasWebGLSupport();
// Use deck.gl on desktop with WebGL support, SVG on mobile
this.useDeckGL = !this.useGlobe && this.shouldUseDeckGL();
if (!this.useDeckGL && this.initialState.layers?.resilienceScore) {
this.initialState = { ...this.initialState, layers: { ...this.initialState.layers, resilienceScore: false } };
}
this.init();
}
@@ -394,8 +397,9 @@ export class MapContainer {
}
public setLayers(layers: MapLayers): void {
if (this.useGlobe) { this.globeMap?.setLayers(layers); return; }
if (this.useDeckGL) { this.deckGLMap?.setLayers(layers); } else { this.svgMap?.setLayers(layers); }
const sanitized = !this.useDeckGL && layers.resilienceScore ? { ...layers, resilienceScore: false } : layers;
if (this.useGlobe) { this.globeMap?.setLayers(sanitized); return; }
if (this.useDeckGL) { this.deckGLMap?.setLayers(sanitized); } else { this.svgMap?.setLayers(sanitized); }
}
public getState(): MapContainerState {
@@ -831,6 +835,7 @@ export class MapContainer {
// Layer enable/disable and trigger methods
public enableLayer(layer: keyof MapLayers): void {
if (layer === 'resilienceScore' && !this.useDeckGL) return;
if (this.useGlobe) { this.globeMap?.enableLayer(layer); return; }
if (this.useDeckGL) {
this.deckGLMap?.enableLayer(layer);

View File

@@ -69,6 +69,68 @@ describe('resilience choropleth thresholds', () => {
});
});
describe('resilience non-DeckGL sanitization', () => {
function simulateSanitize(layers: Record<string, boolean>, isDeckGLActive: boolean) {
if (layers.resilienceScore && !isDeckGLActive) {
return { ...layers, resilienceScore: false };
}
return { ...layers };
}
it('strips resilienceScore from layer state when DeckGL is not active', () => {
const layers = { ...baseLayers(), resilienceScore: true };
const result = simulateSanitize(layers, false);
assert.equal(result.resilienceScore, false);
});
it('preserves resilienceScore when DeckGL is active', () => {
const layers = { ...baseLayers(), resilienceScore: true };
const result = simulateSanitize(layers, true);
assert.equal(result.resilienceScore, true);
});
it('does not affect other layers when stripping resilienceScore', () => {
const layers = { ...baseLayers(), resilienceScore: true, ciiChoropleth: true, flights: true };
const result = simulateSanitize(layers, false);
assert.equal(result.resilienceScore, false);
assert.equal(result.ciiChoropleth, true);
assert.equal(result.flights, true);
});
it('URL restore with resilienceScore=true on non-DeckGL produces false in sanitized state', () => {
const urlLayers = { ...baseLayers(), resilienceScore: true };
const normalized = normalizeExclusiveChoropleths(urlLayers, null);
const sanitized = simulateSanitize(normalized, false);
assert.equal(sanitized.resilienceScore, false);
});
it('mode switch from DeckGL to globe strips resilienceScore', () => {
const deckGlState = { ...baseLayers(), resilienceScore: true };
const afterSwitch = simulateSanitize(deckGlState, false);
assert.equal(afterSwitch.resilienceScore, false);
});
function baseLayers() {
return {
conflicts: false, bases: false, cables: false, pipelines: false,
hotspots: false, ais: false, nuclear: false, irradiators: false,
radiationWatch: false, sanctions: false, weather: false, economic: false,
waterways: false, outages: false, cyberThreats: false, datacenters: false,
protests: false, flights: false, military: false, natural: false,
spaceports: false, minerals: false, fires: false, ucdpEvents: false,
displacement: false, climate: false, startupHubs: false, cloudRegions: false,
accelerators: false, techHQs: false, techEvents: false, stockExchanges: false,
financialCenters: false, centralBanks: false, commodityHubs: false,
gulfInvestments: false, positiveEvents: false, kindness: false,
happiness: false, speciesRecovery: false, renewableInstallations: false,
tradeRoutes: false, iranAttacks: false, gpsJamming: false, satellites: false,
ciiChoropleth: false, resilienceScore: false, dayNight: false,
miningSites: false, processingPlants: false, commodityPorts: false,
webcams: false, weatherRadar: false, diseaseOutbreaks: false,
};
}
});
describe('resilience choropleth exclusivity', () => {
function baseLayers() {
return {