fix(energy-atlas): gate layer:* CMD+K by current renderer + DeckGL state

Reviewer follow-up on PR #3366: the previous fix restricted
LAYER_REGISTRY renderers to ['flat'] so the globe-mode layer picker
hides storageFacilities / fuelShortages toggles. But CMD+K was still
callable — SearchModal.matchCommands didn't filter `layer:*` commands
by renderer, so a user could CMD+K "storage layer" in globe or SVG
mode and trigger a silent no-op.

Fix — centralize "can this layer render right now?" in one helper:

- Add `deckGLOnly?: boolean` to LayerDefinition. `renderers: ['flat']`
  is not enough because `'flat'` covers both DeckGL-flat and SVG-flat,
  and the SVG/mobile fallback has no render path for either layer.
  Mark both as `deckGLOnly: true`.
- New `isLayerExecutable(key, renderer, isDeckGLActive)` helper in
  map-layer-definitions.ts. Returns true iff renderers include the
  current renderer AND (if deckGLOnly) DeckGL is active.
- `SearchModal.setLayerExecutableFn(fn)`: caller-supplied predicate
  used in both `matchCommands` (search results) and
  `renderAllCommandsList` (full picker).
- `search-manager` wires the predicate using `ctx.map.isGlobeMode()`
  + `ctx.map.isDeckGLActive()`, and also adds a symmetric guard in
  the `layer:` dispatch case so direct activations (keyboard
  accelerator, programmatic invocation) bail the same way.

Pre-existing resilienceScore DeckGL gate at search-manager:494 kept as
a belt-and-suspenders — the new isLayerExecutable check already
covers it since resilienceScore has `renderers: ['flat']` (though it
lacks deckGLOnly). Left the specific check in place to avoid scope
creep on a working guard.

Typecheck clean, 6694/6694 tests pass.
This commit is contained in:
Elie Habib
2026-04-24 11:24:57 +04:00
parent 756519cd28
commit 3f7a40036a
3 changed files with 89 additions and 9 deletions

View File

@@ -6,7 +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 { getAllowedLayerKeys, isLayerExecutable } from '@/config/map-layer-definitions';
import type { MapRenderer } 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';
@@ -211,6 +212,18 @@ export class SearchManager implements AppModule {
this.ctx.searchModal.setActivePanels(
Object.entries(this.ctx.panelSettings).filter(([, v]) => v.enabled).map(([k]) => k)
);
// Filter CMD+K layer commands by whether the layer can render under the
// current map renderer + DeckGL state. Without this, globe-mode and
// SVG/mobile users see toggles that silently no-op on activation (e.g.
// `layer:storageFacilities` in globe mode, or any `deckGLOnly` layer on
// the SVG fallback).
this.ctx.searchModal.setLayerExecutableFn((layerKey) => {
const key = (LAYER_KEY_MAP[layerKey] || layerKey) as keyof MapLayers;
if (!(key in this.ctx.mapLayers)) return false;
const renderer: MapRenderer = this.ctx.map?.isGlobeMode?.() ? 'globe' : 'flat';
const isDeckGL = this.ctx.map?.isDeckGLActive?.() ?? false;
return isLayerExecutable(key, renderer, isDeckGL);
});
this.ctx.searchModal.setOnSelect((result) => this.handleSearchResult(result));
this.ctx.searchModal.setOnCommand((cmd) => this.handleCommand(cmd));
@@ -490,6 +503,12 @@ 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;
// Renderer / DeckGL gate. Mirrors the filter applied in SearchModal
// so direct activation paths (keyboard-accelerator, programmatic
// dispatch, etc.) don't flip a layer on that can't render.
const renderer: MapRenderer = this.ctx.map?.isGlobeMode?.() ? 'globe' : 'flat';
const isDeckGL = this.ctx.map?.isDeckGLActive?.() ?? false;
if (!isLayerExecutable(layerKey, renderer, isDeckGL)) return;
let newValue = !this.ctx.mapLayers[layerKey];
if (newValue && layerKey === 'resilienceScore' && !this.ctx.map?.isDeckGLActive?.()) {
newValue = false;

View File

@@ -99,6 +99,14 @@ export class SearchModal {
private flightSearchFired = false;
private placeholder: string;
private activePanelIds: Set<string> = new Set();
/**
* Caller-supplied predicate that returns true iff a `layer:<key>` command
* can actually execute right now (current renderer supports the layer +
* DeckGL gate for DeckGL-only layers). Hooked from SearchManager so
* renderer knowledge lives in one place. Defaults to "always true" when
* not set (back-compat for any instantiator that doesn't wire it).
*/
private layerExecutableFn: (layerKey: string) => boolean = () => true;
private isMobile: boolean;
/** When true, results area shows the full command list (opt-in). Sourced from getAllCommands(); no separate list to maintain. */
private showingAllCommands = false;
@@ -143,6 +151,10 @@ export class SearchModal {
this.activePanelIds = new Set(panelIds);
}
public setLayerExecutableFn(fn: (layerKey: string) => boolean): void {
this.layerExecutableFn = fn;
}
public open(): void {
if (this.closeTimeoutId) {
clearTimeout(this.closeTimeoutId);
@@ -278,6 +290,14 @@ export class SearchModal {
const panelId = cmd.id.slice(6);
if (!this.activePanelIds.has(panelId)) continue;
}
// Hide layer commands whose layer can't render under the current
// map renderer / DeckGL mode. Without this, CMD+K surfaces toggles
// that silently no-op (e.g. storageFacilities in globe mode, or
// flat-only DeckGL layers while on the SVG/mobile fallback).
if (cmd.id.startsWith('layer:')) {
const layerKey = cmd.id.slice(6);
if (!this.layerExecutableFn(layerKey)) continue;
}
const label = resolveCommandLabel(cmd).toLowerCase();
const allTerms = [...cmd.keywords, label];
let bestScore = 0;
@@ -505,6 +525,9 @@ export class SearchModal {
const panelId = cmd.id.slice(6);
if (!this.activePanelIds.has(panelId)) return false;
}
if (cmd.id.startsWith('layer:')) {
if (!this.layerExecutableFn(cmd.id.slice(6))) return false;
}
return true;
});

View File

@@ -14,6 +14,14 @@ export interface LayerDefinition {
fallbackLabel: string;
renderers: MapRenderer[];
premium?: 'locked' | 'enhanced';
/**
* When true, this layer only renders under DeckGL — neither the SVG/mobile
* fallback in Map.ts nor the WebGL GlobeMap has a code path for its data.
* `renderers: ['flat']` is not sufficient because `'flat'` covers both
* DeckGL-flat and SVG-flat. Consumers (layer picker, CMD+K dispatcher)
* must additionally gate on `isDeckGLActive()` for these layers.
*/
deckGLOnly?: boolean;
}
const def = (
@@ -23,7 +31,12 @@ const def = (
fallbackLabel: string,
renderers: MapRenderer[] = ['flat', 'globe'],
premium?: 'locked' | 'enhanced',
): LayerDefinition => ({ key, icon, i18nSuffix, fallbackLabel, renderers, ...(premium && { premium }) });
deckGLOnly?: boolean,
): LayerDefinition => ({
key, icon, i18nSuffix, fallbackLabel, renderers,
...(premium && { premium }),
...(deckGLOnly && { deckGLOnly: true }),
});
export const LAYER_REGISTRY: Record<keyof MapLayers, LayerDefinition> = {
iranAttacks: def('iranAttacks', '&#127919;', 'iranAttacks', 'Iran Attacks', ['flat', 'globe'], _desktop ? 'locked' : undefined),
@@ -82,13 +95,14 @@ export const LAYER_REGISTRY: Record<keyof MapLayers, LayerDefinition> = {
webcams: def('webcams', '&#128247;', 'webcams', 'Live Webcams'),
// weatherRadar removed — radar tiles now auto-start when Weather Alerts layer is toggled on
diseaseOutbreaks: def('diseaseOutbreaks', '&#129440;', 'diseaseOutbreaks', 'Disease Outbreaks'),
// DeckGL-only layers. GlobeMap.ts has no renderer support (no branch in
// ensureStaticDataForLayer / no entry in the layer-channel map), so we
// restrict the declared renderers to ['flat'] — the layer picker hides
// the toggle in globe mode rather than exposing a no-op. Restore
// ['flat', 'globe'] when GlobeMap gets real rendering for these assets.
storageFacilities: def('storageFacilities', '&#127959;', 'storageFacilities', 'Storage Facilities', ['flat']),
fuelShortages: def('fuelShortages', '&#9881;', 'fuelShortages', 'Fuel Shortages', ['flat']),
// DeckGL-only layers. `renderers: ['flat']` hides them from the globe
// picker (GlobeMap has no branch in ensureStaticDataForLayer / no entry
// in the layer-channel map). `deckGLOnly: true` also hides them from
// the SVG/mobile fallback's CMD+K dispatch (Map.ts has no SVG render
// path for either marker/pin type). Restore to `['flat', 'globe']`
// without `deckGLOnly` once both renderers gain real support.
storageFacilities: def('storageFacilities', '&#127959;', 'storageFacilities', 'Storage Facilities', ['flat'], undefined, true),
fuelShortages: def('fuelShortages', '&#9881;', 'fuelShortages', 'Fuel Shortages', ['flat'], undefined, true),
};
const VARIANT_LAYER_ORDER: Record<MapVariant, Array<keyof MapLayers>> = {
@@ -156,6 +170,30 @@ export function sanitizeLayersForVariant(layers: MapLayers, variant: MapVariant)
return sanitized;
}
/**
* Checks whether a layer can actually render under the given renderer +
* DeckGL state. Used by both the layer picker UI and the CMD+K dispatcher
* to hide / silently-skip toggles that would be a no-op.
*
* Rules:
* - The layer's declared `renderers` must include `currentRenderer`
* (catches globe toggles for flat-only layers).
* - If `deckGLOnly: true`, the SVG/mobile fallback can't render either,
* so DeckGL must be active (catches flat-only layers whose data
* shape is DeckGL-specific — see storageFacilities, fuelShortages).
*/
export function isLayerExecutable(
layerKey: keyof MapLayers,
currentRenderer: MapRenderer,
isDeckGLActive: boolean,
): boolean {
const def = LAYER_REGISTRY[layerKey];
if (!def) return false;
if (!def.renderers.includes(currentRenderer)) return false;
if (def.deckGLOnly && !isDeckGLActive) return false;
return true;
}
export const LAYER_SYNONYMS: Record<string, Array<keyof MapLayers>> = {
aviation: ['flights'],
flight: ['flights'],