mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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', '🎯', 'iranAttacks', 'Iran Attacks', ['flat', 'globe'], _desktop ? 'locked' : undefined),
|
||||
@@ -82,13 +95,14 @@ export const LAYER_REGISTRY: Record<keyof MapLayers, LayerDefinition> = {
|
||||
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<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'],
|
||||
|
||||
Reference in New Issue
Block a user