mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(map): prevent ghost layers that render without a toggle (#1264)
* fix(map): prevent ghost layers that render without a toggle Layers enabled in variant defaults but missing from VARIANT_LAYER_ORDER rendered on the map with no UI toggle to turn them off. Commodity variant had 6 ghost layers including undersea cables. Add sanitizeLayersForVariant() guardrail that forces any layer not in the variant's allowed list to false — applied at both fresh init and localStorage load. Replace the fragile happy-only hardcoded blocklist with this generic mechanism. Add regression test covering all 10 variant×platform combinations. * fix(map): sanctions renderers + sanitize URL-derived layers - Fix sanctions layer having empty renderers[] — getLayersForVariant filtered it out so it had no toggle despite being in VARIANT_LAYER_ORDER - Apply sanitizeLayersForVariant() after URL state merge, replacing fragile hardcoded tech/happy blocklists — deeplinks can no longer enable layers outside the variant's allowed set - Add renderer coverage to guardrail test (11 cases) * fix(map): remove no-op sanctions toggle + guard search-manager layer mutations - sanctions has no DeckGL/Globe renderer (only SVG map country fills), so revert renderers to [] and remove from VARIANT_LAYER_ORDER — no more no-op toggle in desktop mode - Set sanctions defaults to false across all variants (SVG map has its own toggle list independent of VARIANT_LAYER_ORDER) - Guard search-manager layer commands (layers:all, presets, layer:*) against enabling off-variant layers via getAllowedLayerKeys() - Add renderer-coverage assertion to guardrail test * fix(map): make layer sanitizer renderer-aware for SVG-only layers sanctions is implemented in SVG map (country fills) but not DeckGL/Globe. Previous commit removed it from VARIANT_LAYER_ORDER, causing the sanitizer to strip it on reload — breaking SVG map state persistence. Add SVG_ONLY_LAYERS concept: layers allowed by the sanitizer but excluded from DeckGL/Globe toggle UI. sanctions restored to defaults for full, finance, and commodity variants where the SVG map exposes it. - getAllowedLayerKeys() now includes SVG_ONLY_LAYERS - VARIANT_LAYER_ORDER remains DeckGL/Globe-only (renderer test enforced) - Guardrail test updated to check both sources
This commit is contained in:
32
src/App.ts
32
src/App.ts
@@ -8,6 +8,8 @@ import {
|
||||
STORAGE_KEYS,
|
||||
SITE_VARIANT,
|
||||
} from '@/config';
|
||||
import { sanitizeLayersForVariant } from '@/config/map-layer-definitions';
|
||||
import type { MapVariant } from '@/config/map-layer-definitions';
|
||||
import { initDB, cleanOldSnapshots, isAisConfigured, initAisStream, isOutagesConfigured, disconnectAisStream } from '@/services';
|
||||
import { mlWorker } from '@/services/ml-worker';
|
||||
import { getAiFlowSettings, subscribeAiFlowChange, isHeadlineMemoryEnabled } from '@/services/ai-flow-settings';
|
||||
@@ -91,15 +93,13 @@ export class App {
|
||||
localStorage.removeItem(PANEL_ORDER_KEY + '-bottom');
|
||||
localStorage.removeItem(PANEL_ORDER_KEY + '-bottom-set');
|
||||
localStorage.removeItem(PANEL_SPANS_KEY);
|
||||
mapLayers = { ...defaultLayers };
|
||||
mapLayers = sanitizeLayersForVariant({ ...defaultLayers }, currentVariant as MapVariant);
|
||||
panelSettings = { ...DEFAULT_PANELS };
|
||||
} else {
|
||||
mapLayers = loadFromStorage<MapLayers>(STORAGE_KEYS.mapLayers, defaultLayers);
|
||||
// Happy variant: force non-happy layers off even if localStorage has stale true values
|
||||
if (currentVariant === 'happy') {
|
||||
const unhappyLayers: (keyof MapLayers)[] = ['conflicts', 'bases', 'hotspots', 'nuclear', 'irradiators', 'sanctions', 'military', 'protests', 'pipelines', 'waterways', 'ais', 'flights', 'spaceports', 'minerals', 'natural', 'fires', 'outages', 'cyberThreats', 'weather', 'economic', 'cables', 'datacenters', 'ucdpEvents', 'displacement', 'climate', 'iranAttacks'];
|
||||
unhappyLayers.forEach(layer => { mapLayers[layer] = false; });
|
||||
}
|
||||
mapLayers = sanitizeLayersForVariant(
|
||||
loadFromStorage<MapLayers>(STORAGE_KEYS.mapLayers, defaultLayers),
|
||||
currentVariant as MapVariant,
|
||||
);
|
||||
panelSettings = loadFromStorage<Record<string, PanelConfig>>(
|
||||
STORAGE_KEYS.panels,
|
||||
DEFAULT_PANELS
|
||||
@@ -187,22 +187,8 @@ export class App {
|
||||
|
||||
let initialUrlState: ParsedMapUrlState | null = parseMapUrlState(window.location.search, mapLayers);
|
||||
if (initialUrlState.layers) {
|
||||
if (currentVariant === 'tech') {
|
||||
const geoLayers: (keyof MapLayers)[] = ['conflicts', 'bases', 'hotspots', 'nuclear', 'irradiators', 'sanctions', 'military', 'protests', 'pipelines', 'waterways', 'ais', 'flights', 'spaceports', 'minerals'];
|
||||
const urlLayers = initialUrlState.layers;
|
||||
geoLayers.forEach(layer => {
|
||||
urlLayers[layer] = false;
|
||||
});
|
||||
}
|
||||
// For happy variant, force off all non-happy layers (including natural events)
|
||||
if (currentVariant === 'happy') {
|
||||
const unhappyLayers: (keyof MapLayers)[] = ['conflicts', 'bases', 'hotspots', 'nuclear', 'irradiators', 'sanctions', 'military', 'protests', 'pipelines', 'waterways', 'ais', 'flights', 'spaceports', 'minerals', 'natural', 'fires', 'outages', 'cyberThreats', 'weather', 'economic', 'cables', 'datacenters', 'ucdpEvents', 'displacement', 'climate', 'iranAttacks'];
|
||||
const urlLayers = initialUrlState.layers;
|
||||
unhappyLayers.forEach(layer => {
|
||||
urlLayers[layer] = false;
|
||||
});
|
||||
}
|
||||
mapLayers = initialUrlState.layers;
|
||||
mapLayers = sanitizeLayersForVariant(initialUrlState.layers, currentVariant as MapVariant);
|
||||
initialUrlState.layers = mapLayers;
|
||||
}
|
||||
if (!CYBER_LAYER_ENABLED) {
|
||||
mapLayers.cyberThreats = false;
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { Command } from '@/config/commands';
|
||||
import { SearchModal } from '@/components';
|
||||
import { CIIPanel } from '@/components';
|
||||
import { SITE_VARIANT, STORAGE_KEYS } from '@/config';
|
||||
import { getAllowedLayerKeys } from '@/config/map-layer-definitions';
|
||||
import type { MapVariant } from '@/config/map-layer-definitions';
|
||||
import { LAYER_PRESETS, LAYER_KEY_MAP } from '@/config/commands';
|
||||
import { calculateCII, TIER1_COUNTRIES } from '@/services/country-instability';
|
||||
import { CURATED_COUNTRIES } from '@/config/countries';
|
||||
@@ -397,9 +399,11 @@ export class SearchManager implements AppModule {
|
||||
break;
|
||||
|
||||
case 'layers': {
|
||||
const allowed = getAllowedLayerKeys((SITE_VARIANT || 'full') as MapVariant);
|
||||
if (action === 'all') {
|
||||
for (const key of Object.keys(this.ctx.mapLayers))
|
||||
this.ctx.mapLayers[key as keyof MapLayers] = true;
|
||||
for (const key of Object.keys(this.ctx.mapLayers)) {
|
||||
this.ctx.mapLayers[key as keyof MapLayers] = allowed.has(key as keyof MapLayers);
|
||||
}
|
||||
} else if (action === 'none') {
|
||||
for (const key of Object.keys(this.ctx.mapLayers))
|
||||
this.ctx.mapLayers[key as keyof MapLayers] = false;
|
||||
@@ -408,8 +412,9 @@ export class SearchManager implements AppModule {
|
||||
if (preset) {
|
||||
for (const key of Object.keys(this.ctx.mapLayers))
|
||||
this.ctx.mapLayers[key as keyof MapLayers] = false;
|
||||
for (const layer of preset)
|
||||
this.ctx.mapLayers[layer] = true;
|
||||
for (const layer of preset) {
|
||||
if (allowed.has(layer)) this.ctx.mapLayers[layer] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers);
|
||||
@@ -420,6 +425,8 @@ export class SearchManager implements AppModule {
|
||||
case 'layer': {
|
||||
const layerKey = (LAYER_KEY_MAP[action] || action) as keyof MapLayers;
|
||||
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];
|
||||
saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers);
|
||||
if (this.ctx.mapLayers[layerKey]) {
|
||||
|
||||
@@ -84,8 +84,8 @@ const VARIANT_LAYER_ORDER: Record<MapVariant, Array<keyof MapLayers>> = {
|
||||
'ais', 'tradeRoutes', 'flights', 'protests',
|
||||
'ucdpEvents', 'displacement', 'climate', 'weather',
|
||||
'outages', 'cyberThreats', 'natural', 'fires',
|
||||
'waterways', 'economic', 'minerals', 'gpsJamming',
|
||||
'ciiChoropleth', 'dayNight',
|
||||
'waterways', 'economic', 'minerals',
|
||||
'gpsJamming', 'ciiChoropleth', 'dayNight',
|
||||
],
|
||||
tech: [
|
||||
'startupHubs', 'techHQs', 'accelerators', 'cloudRegions',
|
||||
@@ -105,10 +105,17 @@ const VARIANT_LAYER_ORDER: Record<MapVariant, Array<keyof MapLayers>> = {
|
||||
commodity: [
|
||||
'miningSites', 'processingPlants', 'commodityPorts', 'commodityHubs',
|
||||
'minerals', 'pipelines', 'waterways', 'tradeRoutes',
|
||||
'ais', 'economic', 'fires', 'climate',
|
||||
'natural', 'weather', 'outages', 'dayNight',
|
||||
],
|
||||
};
|
||||
|
||||
const SVG_ONLY_LAYERS: Partial<Record<MapVariant, Array<keyof MapLayers>>> = {
|
||||
full: ['sanctions'],
|
||||
finance: ['sanctions'],
|
||||
commodity: ['sanctions'],
|
||||
};
|
||||
|
||||
const I18N_PREFIX = 'components.deckgl.layers.';
|
||||
|
||||
export function getLayersForVariant(variant: MapVariant, renderer: MapRenderer): LayerDefinition[] {
|
||||
@@ -118,6 +125,21 @@ export function getLayersForVariant(variant: MapVariant, renderer: MapRenderer):
|
||||
.filter(d => d.renderers.includes(renderer));
|
||||
}
|
||||
|
||||
export function getAllowedLayerKeys(variant: MapVariant): Set<keyof MapLayers> {
|
||||
const keys = new Set(VARIANT_LAYER_ORDER[variant] ?? VARIANT_LAYER_ORDER.full);
|
||||
for (const k of SVG_ONLY_LAYERS[variant] ?? []) keys.add(k);
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function sanitizeLayersForVariant(layers: MapLayers, variant: MapVariant): MapLayers {
|
||||
const allowed = getAllowedLayerKeys(variant);
|
||||
const sanitized = { ...layers };
|
||||
for (const key of Object.keys(sanitized) as Array<keyof MapLayers>) {
|
||||
if (!allowed.has(key)) sanitized[key] = false;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function resolveLayerLabel(def: LayerDefinition, tFn?: (key: string) => string): string {
|
||||
if (tFn) {
|
||||
const translated = tFn(I18N_PREFIX + def.i18nSuffix);
|
||||
|
||||
@@ -230,8 +230,8 @@ const TECH_MAP_LAYERS: MapLayers = {
|
||||
nuclear: false,
|
||||
irradiators: false,
|
||||
sanctions: false,
|
||||
weather: true,
|
||||
economic: true,
|
||||
weather: false,
|
||||
economic: false,
|
||||
waterways: false,
|
||||
outages: true,
|
||||
cyberThreats: false,
|
||||
@@ -652,10 +652,10 @@ const COMMODITY_MAP_LAYERS: MapLayers = {
|
||||
|
||||
conflicts: false,
|
||||
bases: false,
|
||||
cables: true,
|
||||
cables: false,
|
||||
pipelines: true,
|
||||
hotspots: false,
|
||||
ais: true, // Commodity shipping, tanker routes, bulk carriers
|
||||
ais: true,
|
||||
nuclear: false,
|
||||
irradiators: false,
|
||||
sanctions: true,
|
||||
@@ -671,7 +671,7 @@ const COMMODITY_MAP_LAYERS: MapLayers = {
|
||||
natural: true,
|
||||
spaceports: false,
|
||||
minerals: true,
|
||||
fires: true, // Fires near mining/forestry operations
|
||||
fires: true,
|
||||
// Data source layers
|
||||
ucdpEvents: false,
|
||||
displacement: false,
|
||||
|
||||
124
tests/variant-layer-guardrail.test.mjs
Normal file
124
tests/variant-layer-guardrail.test.mjs
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Guardrail: every layer enabled by default in a variant's MapLayers
|
||||
* MUST be in VARIANT_LAYER_ORDER (DeckGL/Globe toggle) or SVG_ONLY_LAYERS
|
||||
* (SVG fallback toggle). Layers in VARIANT_LAYER_ORDER must have at least
|
||||
* one DeckGL/Globe renderer so getLayersForVariant() returns them.
|
||||
*
|
||||
* Without this, layers render but have no UI toggle → users can't turn them off.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const SRC = new URL('../src/config/', import.meta.url);
|
||||
|
||||
const layerDefsSource = readFileSync(new URL('map-layer-definitions.ts', SRC), 'utf8');
|
||||
const panelsSource = readFileSync(new URL('panels.ts', SRC), 'utf8');
|
||||
|
||||
function extractRecordBlock(source, name) {
|
||||
const re = new RegExp(`(?:const|export const)\\s+${name}[^=]*=\\s*\\{([\\s\\S]*?)\\n\\};`);
|
||||
const match = source.match(re);
|
||||
if (!match) return null;
|
||||
const body = match[1];
|
||||
const result = {};
|
||||
const variantRe = /(\w+):\s*\[([\s\S]*?)\]/g;
|
||||
let m;
|
||||
while ((m = variantRe.exec(body)) !== null) {
|
||||
const keys = m[2].match(/'(\w+)'/g)?.map(s => s.replace(/'/g, '')) ?? [];
|
||||
result[m[1]] = new Set(keys);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractLayerRenderers(source) {
|
||||
const result = {};
|
||||
const registryMatch = source.match(/LAYER_REGISTRY[^=]*=\s*\{([\s\S]*?)\n\};/);
|
||||
if (!registryMatch) throw new Error('Could not find LAYER_REGISTRY');
|
||||
const body = registryMatch[1];
|
||||
const defRe = /def\(\s*'(\w+)'[^)]*\)/g;
|
||||
let m;
|
||||
while ((m = defRe.exec(body)) !== null) {
|
||||
const key = m[1];
|
||||
const fullCall = m[0];
|
||||
const renderersMatch = fullCall.match(/\[([^\]]*)\]\s*(?:,\s*(?:'[^']*'|undefined))?\s*\)/);
|
||||
if (renderersMatch) {
|
||||
const renderers = renderersMatch[1].match(/'(\w+)'/g)?.map(s => s.replace(/'/g, '')) ?? [];
|
||||
result[key] = renderers;
|
||||
} else {
|
||||
result[key] = ['flat', 'globe'];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractEnabledLayers(source, constName) {
|
||||
const re = new RegExp(`const ${constName}[^=]*=\\s*\\{([\\s\\S]*?)\\};`);
|
||||
const match = source.match(re);
|
||||
if (!match) throw new Error(`Could not find ${constName}`);
|
||||
const enabled = [];
|
||||
const lineRe = /(\w+):\s*true/g;
|
||||
let m;
|
||||
while ((m = lineRe.exec(match[1])) !== null) {
|
||||
enabled.push(m[1]);
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
const variantOrder = extractRecordBlock(layerDefsSource, 'VARIANT_LAYER_ORDER');
|
||||
const svgOnlyLayers = extractRecordBlock(layerDefsSource, 'SVG_ONLY_LAYERS') ?? {};
|
||||
const layerRenderers = extractLayerRenderers(layerDefsSource);
|
||||
|
||||
function getAllowedForVariant(variant) {
|
||||
const allowed = new Set(variantOrder[variant] ?? []);
|
||||
for (const k of svgOnlyLayers[variant] ?? []) allowed.add(k);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
const VARIANT_DEFAULTS = {
|
||||
full: { desktop: 'FULL_MAP_LAYERS', mobile: 'FULL_MOBILE_MAP_LAYERS' },
|
||||
tech: { desktop: 'TECH_MAP_LAYERS', mobile: 'TECH_MOBILE_MAP_LAYERS' },
|
||||
finance: { desktop: 'FINANCE_MAP_LAYERS', mobile: 'FINANCE_MOBILE_MAP_LAYERS' },
|
||||
happy: { desktop: 'HAPPY_MAP_LAYERS', mobile: 'HAPPY_MOBILE_MAP_LAYERS' },
|
||||
commodity: { desktop: 'COMMODITY_MAP_LAYERS', mobile: 'COMMODITY_MOBILE_MAP_LAYERS' },
|
||||
};
|
||||
|
||||
describe('variant layer guardrail', () => {
|
||||
for (const [variant, { desktop, mobile }] of Object.entries(VARIANT_DEFAULTS)) {
|
||||
const allowed = getAllowedForVariant(variant);
|
||||
if (allowed.size === 0) continue;
|
||||
|
||||
it(`${variant} desktop: no enabled layer without a toggle`, () => {
|
||||
const enabled = extractEnabledLayers(panelsSource, desktop);
|
||||
const orphans = enabled.filter(k => !allowed.has(k));
|
||||
assert.deepStrictEqual(
|
||||
orphans, [],
|
||||
`${variant} desktop has layers enabled but NOT in VARIANT_LAYER_ORDER or SVG_ONLY_LAYERS (no toggle): ${orphans.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it(`${variant} mobile: no enabled layer without a toggle`, () => {
|
||||
const enabled = extractEnabledLayers(panelsSource, mobile);
|
||||
const orphans = enabled.filter(k => !allowed.has(k));
|
||||
assert.deepStrictEqual(
|
||||
orphans, [],
|
||||
`${variant} mobile has layers enabled but NOT in VARIANT_LAYER_ORDER or SVG_ONLY_LAYERS (no toggle): ${orphans.join(', ')}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('every layer in VARIANT_LAYER_ORDER has at least one DeckGL/Globe renderer', () => {
|
||||
const noRenderer = [];
|
||||
for (const [variant, keys] of Object.entries(variantOrder)) {
|
||||
for (const key of keys) {
|
||||
const renderers = layerRenderers[key];
|
||||
if (!renderers || renderers.length === 0) {
|
||||
noRenderer.push(`${variant}:${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
noRenderer, [],
|
||||
`Layers in VARIANT_LAYER_ORDER with empty renderers (getLayersForVariant filters them out → no toggle): ${noRenderer.join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user