From 3f7a40036aaeec5ed8b371b25bc1aa30789b4b56 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Fri, 24 Apr 2026 11:24:57 +0400 Subject: [PATCH] fix(energy-atlas): gate layer:* CMD+K by current renderer + DeckGL state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/search-manager.ts | 21 ++++++++++- src/components/SearchModal.ts | 23 ++++++++++++ src/config/map-layer-definitions.ts | 54 ++++++++++++++++++++++++----- 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/app/search-manager.ts b/src/app/search-manager.ts index fba1af586..2ac97f45b 100644 --- a/src/app/search-manager.ts +++ b/src/app/search-manager.ts @@ -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; diff --git a/src/components/SearchModal.ts b/src/components/SearchModal.ts index 2ad83bdc1..32b4c3a30 100644 --- a/src/components/SearchModal.ts +++ b/src/components/SearchModal.ts @@ -99,6 +99,14 @@ export class SearchModal { private flightSearchFired = false; private placeholder: string; private activePanelIds: Set = new Set(); + /** + * Caller-supplied predicate that returns true iff a `layer:` 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; }); diff --git a/src/config/map-layer-definitions.ts b/src/config/map-layer-definitions.ts index d27868363..8c03c02d2 100644 --- a/src/config/map-layer-definitions.ts +++ b/src/config/map-layer-definitions.ts @@ -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 = { iranAttacks: def('iranAttacks', '🎯', 'iranAttacks', 'Iran Attacks', ['flat', 'globe'], _desktop ? 'locked' : undefined), @@ -82,13 +95,14 @@ export const LAYER_REGISTRY: Record = { webcams: def('webcams', '📷', 'webcams', 'Live Webcams'), // weatherRadar removed — radar tiles now auto-start when Weather Alerts layer is toggled on diseaseOutbreaks: def('diseaseOutbreaks', '🦠', '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', '🏗', 'storageFacilities', 'Storage Facilities', ['flat']), - fuelShortages: def('fuelShortages', '⚙', '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', '🏗', 'storageFacilities', 'Storage Facilities', ['flat'], undefined, true), + fuelShortages: def('fuelShortages', '⚙', 'fuelShortages', 'Fuel Shortages', ['flat'], undefined, true), }; const VARIANT_LAYER_ORDER: Record> = { @@ -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> = { aviation: ['flights'], flight: ['flights'],