From 51b05a2cf247deba18b490916f2c05193792d75e Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 24 Jun 2026 09:44:31 -0500 Subject: [PATCH 1/8] filter plugin wip --- .../UserInterface_/UserInterfaceModern_.css | 11 ++ .../Tools/LayerFilter/LayerFilterTool.tsx | 79 ++++++++++ .../LayerFilter/MMGISLayerFilterAdapter.tsx | 66 +++++++++ .../LayerFilter/adapters/emitFilterChange.ts | 33 +++++ .../Tools/LayerFilter/adapters/hooks.ts | 34 +++++ src/essence/Tools/LayerFilter/config.json | 138 ++++++++++++++++++ .../lib/geo/FilterPanel/FilterPanel.tsx | 42 ++++++ src/essence/Tools/LayerFilter/lib/index.ts | 11 ++ .../lib/styles/components-geo/index.scss | 1 + .../styles/components-geo/layer-filter.scss | 71 +++++++++ .../Tools/LayerFilter/lib/styles/index.scss | 1 + .../LayerFilter/lib/styles/scss-imports.d.ts | 3 + src/essence/Tools/LayerFilter/lib/types.ts | 48 ++++++ .../LayerFilter/lib/utils/matchLayers.ts | 57 ++++++++ .../LayerFilterThemesTool.tsx | 41 ++++++ .../MMGISThemeRailAdapter.tsx | 36 +++++ .../LayerFilterThemes/adapters/getThemes.ts | 13 ++ .../Tools/LayerFilterThemes/adapters/hooks.ts | 10 ++ .../Tools/LayerFilterThemes/config.json | 19 +++ .../lib/geo/ThemeRail/ThemeRail.tsx | 44 ++++++ .../Tools/LayerFilterThemes/lib/index.ts | 5 + .../lib/styles/components-geo/index.scss | 1 + .../lib/styles/components-geo/theme-rail.scss | 95 ++++++++++++ .../LayerFilterThemes/lib/styles/index.scss | 1 + .../lib/styles/scss-imports.d.ts | 3 + .../Tools/LayerFilterThemes/lib/types.ts | 6 + 26 files changed, 869 insertions(+) create mode 100644 src/essence/Tools/LayerFilter/LayerFilterTool.tsx create mode 100644 src/essence/Tools/LayerFilter/MMGISLayerFilterAdapter.tsx create mode 100644 src/essence/Tools/LayerFilter/adapters/emitFilterChange.ts create mode 100644 src/essence/Tools/LayerFilter/adapters/hooks.ts create mode 100644 src/essence/Tools/LayerFilter/config.json create mode 100644 src/essence/Tools/LayerFilter/lib/geo/FilterPanel/FilterPanel.tsx create mode 100644 src/essence/Tools/LayerFilter/lib/index.ts create mode 100644 src/essence/Tools/LayerFilter/lib/styles/components-geo/index.scss create mode 100644 src/essence/Tools/LayerFilter/lib/styles/components-geo/layer-filter.scss create mode 100644 src/essence/Tools/LayerFilter/lib/styles/index.scss create mode 100644 src/essence/Tools/LayerFilter/lib/styles/scss-imports.d.ts create mode 100644 src/essence/Tools/LayerFilter/lib/types.ts create mode 100644 src/essence/Tools/LayerFilter/lib/utils/matchLayers.ts create mode 100644 src/essence/Tools/LayerFilterThemes/LayerFilterThemesTool.tsx create mode 100644 src/essence/Tools/LayerFilterThemes/MMGISThemeRailAdapter.tsx create mode 100644 src/essence/Tools/LayerFilterThemes/adapters/getThemes.ts create mode 100644 src/essence/Tools/LayerFilterThemes/adapters/hooks.ts create mode 100644 src/essence/Tools/LayerFilterThemes/config.json create mode 100644 src/essence/Tools/LayerFilterThemes/lib/geo/ThemeRail/ThemeRail.tsx create mode 100644 src/essence/Tools/LayerFilterThemes/lib/index.ts create mode 100644 src/essence/Tools/LayerFilterThemes/lib/styles/components-geo/index.scss create mode 100644 src/essence/Tools/LayerFilterThemes/lib/styles/components-geo/theme-rail.scss create mode 100644 src/essence/Tools/LayerFilterThemes/lib/styles/index.scss create mode 100644 src/essence/Tools/LayerFilterThemes/lib/styles/scss-imports.d.ts create mode 100644 src/essence/Tools/LayerFilterThemes/lib/types.ts diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css index bd83f44d7..8740d781d 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css @@ -55,6 +55,17 @@ transition: width 0.3s cubic-bezier(0.2, 0, 0, 1), height 0.3s cubic-bezier(0.2, 0, 0, 1), flex 0.3s cubic-bezier(0.2, 0, 0, 1); } +/* Full-bleed left region: panels abut with no floating gap or rounded corners, + matching the portal design (rail flush to the screen edge and to the panel + beside it). The default floating margin/radius is kept for other regions. */ +.ui-region-left { + gap: 0; +} +.ui-region-left .ui-panel { + margin: 0; + border-radius: 0; +} + .ui-panel-header { background-color: var(--theme-color-primary, #005ea2); color: var(--theme-color-white, #ffffff); diff --git a/src/essence/Tools/LayerFilter/LayerFilterTool.tsx b/src/essence/Tools/LayerFilter/LayerFilterTool.tsx new file mode 100644 index 000000000..415b4b902 --- /dev/null +++ b/src/essence/Tools/LayerFilter/LayerFilterTool.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { MMGISLayerFilterAdapter } from './MMGISLayerFilterAdapter' +import { mmgisRequest, mmgisProvide, mmgisEmit } from '../_shared/adapters/mmgisAPI' +import type { ThemeDef } from './lib/types' + +type ToolVars = { + themes?: ThemeDef[] + defaultThemeId?: string + themeProperty?: string + width?: number +} + +let _root: Root | null = null + +const LayerFilterTool = { + height: 0, + width: 320 as number | 'full', + vars: {} as ToolVars, + targetId: null as string | null, + made: false, + _cleanups: [] as Array<() => void>, + + initialize: async function () { + try { + this.vars = + (await mmgisRequest('tool:getVars', 'layerfilter')) || {} + if (this.vars.width) this.width = this.vars.width + } catch (err) { + console.warn( + '[LayerFilterTool] tool:getVars unavailable:', + err instanceof Error ? err.message : err, + ) + } + }, + + make: function (targetId?: string) { + this.targetId = typeof targetId === 'string' ? targetId : 'toolPanel' + const container = document.getElementById(this.targetId) + if (!container) { + console.error(`LayerFilterTool: container ${this.targetId} not found`) + return + } + _root = createRoot(container) + _root.render() + this.made = true + + // Expose the theme list (id/label/icon) + default for the rail tool, + // and announce readiness so the rail can (re-)request after we mount. + this._cleanups.push( + mmgisProvide('layerFilter:getThemes', () => ({ + themes: (this.vars.themes || []).map((t) => ({ + id: t.id, + label: t.label, + icon: t.icon, + })), + defaultThemeId: this.vars.defaultThemeId, + })), + ) + mmgisEmit('layerFilter:ready', { timestamp: Date.now() }) + }, + + destroy: function () { + if (_root) { + _root.unmount() + _root = null + } + this._cleanups.forEach((cleanup) => cleanup()) + this._cleanups = [] + this.targetId = null + this.made = false + }, + + getUrlString: function () { + return '' + }, +} + +export default LayerFilterTool diff --git a/src/essence/Tools/LayerFilter/MMGISLayerFilterAdapter.tsx b/src/essence/Tools/LayerFilter/MMGISLayerFilterAdapter.tsx new file mode 100644 index 000000000..4433584a2 --- /dev/null +++ b/src/essence/Tools/LayerFilter/MMGISLayerFilterAdapter.tsx @@ -0,0 +1,66 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { FilterPanel } from './lib' +import type { LayerFilterConfig, FilterSelections } from './lib/types' +import { useMMGISToolVars, useMMGISEvent } from './adapters/hooks' +import { emitFilterChange } from './adapters/emitFilterChange' + +const SELECTED_THEME_EVENT = 'layerFilter:selectedThemeChanged' +// Stable empty object so the emit effect doesn't fire every render. +const EMPTY: FilterSelections = {} + +export function MMGISLayerFilterAdapter() { + const vars = useMMGISToolVars>( + 'layerfilter', + ) + const themes = vars.themes ?? [] + const themeProperty = vars.themeProperty || 'theme' + + const [selectedThemeId, setSelectedThemeId] = useState('') + const [selectionsByTheme, setSelectionsByTheme] = useState< + Record + >({}) + + // Pick the default theme once the config loads. + useEffect(() => { + if (!selectedThemeId && themes.length) { + setSelectedThemeId(vars.defaultThemeId || themes[0].id) + } + }, [themes, vars.defaultThemeId, selectedThemeId]) + + // Follow the rail's selection. + const onThemeChanged = useCallback((payload?: unknown) => { + const id = (payload as { themeId?: string })?.themeId + if (id) setSelectedThemeId(id) + }, []) + useMMGISEvent(SELECTED_THEME_EVENT, onThemeChanged) + + const activeTheme = themes.find((t) => t.id === selectedThemeId) || null + const selections = selectionsByTheme[selectedThemeId] || EMPTY + + // Recompute + emit matches whenever the theme or its selections change. + useEffect(() => { + if (!selectedThemeId) return + void emitFilterChange(themeProperty, selectedThemeId, selections) + }, [selectedThemeId, selections, themeProperty]) + + const handleChange = useCallback( + (property: string, value: string) => { + setSelectionsByTheme((prev) => ({ + ...prev, + [selectedThemeId]: { + ...(prev[selectedThemeId] || {}), + [property]: value, + }, + })) + }, + [selectedThemeId], + ) + + return ( + + ) +} diff --git a/src/essence/Tools/LayerFilter/adapters/emitFilterChange.ts b/src/essence/Tools/LayerFilter/adapters/emitFilterChange.ts new file mode 100644 index 000000000..15dc3086f --- /dev/null +++ b/src/essence/Tools/LayerFilter/adapters/emitFilterChange.ts @@ -0,0 +1,33 @@ +// Compute the matched layer set for the active theme + selections and announce +// it on the bus. Per current scope we only EMIT (and log) — nothing changes +// layers; LayerManager will consume `layerFilter:changed` later. +import { mmgisRequest, mmgisEmit } from '../../_shared/adapters/mmgisAPI' +import { matchLayers, type LayerLike } from '../lib/utils/matchLayers' +import type { FilterSelections } from '../lib/types' + +export interface FilterChangePayload { + themeId: string + selections: FilterSelections + matchedLayerNames: string[] +} + +export async function emitFilterChange( + themeProperty: string, + themeId: string, + selections: FilterSelections, +): Promise { + const configs = await mmgisRequest>( + 'layers:getAllConfigs', + ) + const matchedLayerNames = matchLayers( + configs, + themeProperty, + themeId, + selections, + ) + const payload: FilterChangePayload = { themeId, selections, matchedLayerNames } + mmgisEmit('layerFilter:changed', payload) + // Visibility for now (LayerManager isn't wired to react yet). + console.log('[LayerFilter] layerFilter:changed', payload) + return payload +} diff --git a/src/essence/Tools/LayerFilter/adapters/hooks.ts b/src/essence/Tools/LayerFilter/adapters/hooks.ts new file mode 100644 index 000000000..37af1ce62 --- /dev/null +++ b/src/essence/Tools/LayerFilter/adapters/hooks.ts @@ -0,0 +1,34 @@ +// Small bus hooks, local to this tool (kept self-contained per the monorepo +// tool pattern — no cross-tool imports). +import { useEffect, useState } from 'react' +import { mmgisOn, mmgisRequest } from '../../_shared/adapters/mmgisAPI' + +/** Read this tool's configured variables (themes, themeProperty, …). */ +export function useMMGISToolVars>( + toolName: string, +): T { + const [vars, setVars] = useState({} as T) + useEffect(() => { + let cancelled = false + mmgisRequest('tool:getVars', toolName) + .then((result) => { + if (!cancelled && result) setVars(result) + }) + .catch(() => { + // Handler registered during mission load; if we mount first the + // request rejects — fall back to {} and the UI uses defaults. + }) + return () => { + cancelled = true + } + }, [toolName]) + return vars +} + +/** Subscribe to a bus event for the lifetime of the component. */ +export function useMMGISEvent( + event: string, + handler: (payload?: unknown) => void, +): void { + useEffect(() => mmgisOn(event, handler), [event, handler]) +} diff --git a/src/essence/Tools/LayerFilter/config.json b/src/essence/Tools/LayerFilter/config.json new file mode 100644 index 000000000..7f87f3fa0 --- /dev/null +++ b/src/essence/Tools/LayerFilter/config.json @@ -0,0 +1,138 @@ +{ + "name": "LayerFilter", + "defaultIcon": "filter-outline", + "description": "Two-step, configurable layer filter. Step 1 picks a theme; step 2 shows that theme's filters. Emits the matched layer set on the event bus (does not change layers itself).", + "hasVars": true, + "width": 320, + "height": 0, + "paths": { + "LayerFilterTool": "essence/Tools/LayerFilter/LayerFilterTool" + }, + "metadata": { + "icon": "filter-outline", + "requiredOrientation": "vertical", + "compatiblePositions": ["left", "right"], + "preferredPosition": "left", + "modernLayoutSupport": true, + "width": 320, + "height": 0 + }, + "variables": { + "themeProperty": "theme", + "defaultThemeId": "need", + "themes": [ + { + "id": "need", + "label": "Need", + "icon": "satellite-variant", + "title": "Need", + "description": "Filter by sector to explore Earth-science datasets relevant to your operational needs.", + "filters": [ + { + "id": "sector", + "label": "Sector", + "property": "sector", + "type": "select", + "allLabel": "All Sectors", + "options": [ + { "value": "agriculture", "label": "Agriculture" }, + { "value": "water", "label": "Water Resources" }, + { "value": "energy", "label": "Energy" }, + { "value": "public_health", "label": "Public Health" } + ] + } + ] + }, + { + "id": "hazard", + "label": "Hazard", + "icon": "flash-outline", + "title": "Hazard", + "description": "Explore datasets by hazard type to understand conditions, impacts, and response context.", + "filters": [ + { + "id": "hazard", + "label": "Hazard", + "property": "hazard", + "type": "select", + "allLabel": "All Hazards", + "options": [ + { "value": "flood", "label": "Flood" }, + { "value": "fire", "label": "Fire" }, + { "value": "storm", "label": "Storm" }, + { "value": "earthquake", "label": "Earthquake" } + ] + } + ] + }, + { + "id": "event", + "label": "Event", + "icon": "calendar-outline", + "title": "Event", + "description": "Filter the activation catalogue by hazard, location and year. The count below updates as you narrow the list.", + "filters": [ + { + "id": "hazardType", + "label": "Hazard type", + "property": "hazard", + "type": "select", + "allLabel": "All Hazards", + "options": [ + { "value": "flood", "label": "Flood" }, + { "value": "fire", "label": "Fire" }, + { "value": "storm", "label": "Storm" } + ] + }, + { + "id": "location", + "label": "Location", + "property": "location", + "type": "select", + "allLabel": "All Locations", + "options": [ + { "value": "north_america", "label": "North America" }, + { "value": "south_america", "label": "South America" }, + { "value": "europe", "label": "Europe" }, + { "value": "asia", "label": "Asia" } + ] + }, + { + "id": "year", + "label": "Year", + "property": "year", + "type": "select", + "allLabel": "All Years", + "options": [ + { "value": "2025", "label": "2025" }, + { "value": "2024", "label": "2024" }, + { "value": "2023", "label": "2023" } + ] + } + ] + } + ] + }, + "config": { + "rows": [ + { + "components": [ + { + "field": "variables.themeProperty", + "name": "Theme property", + "description": "The layer.properties key that holds a layer's theme id.", + "type": "text", + "width": 3 + }, + { + "field": "variables.defaultThemeId", + "name": "Default theme", + "description": "Theme id selected on load.", + "type": "text", + "width": 3 + } + ] + } + ] + } +} diff --git a/src/essence/Tools/LayerFilter/lib/geo/FilterPanel/FilterPanel.tsx b/src/essence/Tools/LayerFilter/lib/geo/FilterPanel/FilterPanel.tsx new file mode 100644 index 000000000..f2c927c0e --- /dev/null +++ b/src/essence/Tools/LayerFilter/lib/geo/FilterPanel/FilterPanel.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import type { ThemeDef, FilterSelections } from '../../types' + +export interface FilterPanelProps { + /** The active theme (step 2 content), or null until one is selected. */ + theme: ThemeDef | null + /** Current selections for the active theme: property -> value ("" = all). */ + selections: FilterSelections + onChange: (property: string, value: string) => void +} + +/** Presentational step-2 panel: theme heading + its configured filters. */ +export function FilterPanel({ theme, selections, onChange }: FilterPanelProps) { + if (!theme) return null + + return ( +
+

{theme.title}

+ {theme.description && ( +

{theme.description}

+ )} + + {theme.filters.map((filter) => ( + + ))} +
+ ) +} diff --git a/src/essence/Tools/LayerFilter/lib/index.ts b/src/essence/Tools/LayerFilter/lib/index.ts new file mode 100644 index 000000000..76dfd4ef8 --- /dev/null +++ b/src/essence/Tools/LayerFilter/lib/index.ts @@ -0,0 +1,11 @@ +import './styles/index.scss' + +export { FilterPanel } from './geo/FilterPanel/FilterPanel' +export type { FilterPanelProps } from './geo/FilterPanel/FilterPanel' +export type { + ThemeDef, + FilterDef, + FilterOption, + FilterSelections, + LayerFilterConfig, +} from './types' diff --git a/src/essence/Tools/LayerFilter/lib/styles/components-geo/index.scss b/src/essence/Tools/LayerFilter/lib/styles/components-geo/index.scss new file mode 100644 index 000000000..4cf3e9370 --- /dev/null +++ b/src/essence/Tools/LayerFilter/lib/styles/components-geo/index.scss @@ -0,0 +1 @@ +@import './layer-filter.scss'; diff --git a/src/essence/Tools/LayerFilter/lib/styles/components-geo/layer-filter.scss b/src/essence/Tools/LayerFilter/lib/styles/components-geo/layer-filter.scss new file mode 100644 index 000000000..230bd9861 --- /dev/null +++ b/src/essence/Tools/LayerFilter/lib/styles/components-geo/layer-filter.scss @@ -0,0 +1,71 @@ +// Step-2 filter panel (wider left panel), styled per the Figma "Need" view. +// USWDS --theme-* tokens only. +.blocks-layer-filter { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--theme-spacing-2, 16px); + // Natural content height — this is a stacked tool; the panel body + // (.ui-region-left .ui-panel-body) owns vertical scrolling. Forcing + // height:100% here made the card eat the whole panel and create a scroll. + padding: var(--theme-spacing-2, 16px) var(--theme-spacing-3, 24px); + background: var(--theme-color-white, #ffffff); + color: var(--theme-color-ink, #1b1b1b); + font-family: var(--theme-font-ui); + + &__title { + margin: 0; + color: var(--theme-color-ink, #1b1b1b); + font-size: var(--theme-font-size-lg, 28px); + font-weight: var(--theme-font-weight-bold, 700); + line-height: 1.2; + } + + &__description { + // Pull the description up tight under the title, then let the container + // gap space it from the first filter. + margin: 0; + margin-top: calc(-1 * var(--theme-spacing-1, 8px)); + color: var(--theme-color-base-dark, #565c65); + font-size: var(--theme-font-size-2xs, 14px); + line-height: 1.4; + } + + &__field { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-05, 4px); + } + + &__label { + color: var(--theme-color-base, #71767a); + font-size: var(--theme-font-size-3xs, 13px); + font-weight: var(--theme-font-weight-semibold, 600); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__select { + box-sizing: border-box; + width: 100%; + min-height: 40px; + padding: var(--theme-spacing-1, 8px) var(--theme-spacing-105, 12px); + background: var(--theme-color-white, #ffffff); + border: 1px solid var(--theme-color-base-light, #a9aeb1); + border-radius: var(--theme-radius-md, 4px); + color: var(--theme-color-ink, #1b1b1b); + font-family: var(--theme-font-ui); + font-size: var(--theme-font-size-2xs, 14px); + cursor: pointer; + + &:hover { + border-color: var(--theme-color-base, #71767a); + } + + &:focus { + outline: none; + border-color: var(--theme-color-primary, #137480); + box-shadow: 0 0 0 2px var(--theme-color-primary-light, #97d4ea); + } + } +} diff --git a/src/essence/Tools/LayerFilter/lib/styles/index.scss b/src/essence/Tools/LayerFilter/lib/styles/index.scss new file mode 100644 index 000000000..8f8c34bc7 --- /dev/null +++ b/src/essence/Tools/LayerFilter/lib/styles/index.scss @@ -0,0 +1 @@ +@import './components-geo/index.scss'; diff --git a/src/essence/Tools/LayerFilter/lib/styles/scss-imports.d.ts b/src/essence/Tools/LayerFilter/lib/styles/scss-imports.d.ts new file mode 100644 index 000000000..d90288d42 --- /dev/null +++ b/src/essence/Tools/LayerFilter/lib/styles/scss-imports.d.ts @@ -0,0 +1,3 @@ +// Plain (non-module) .scss imports are side-effect only — webpack handles them +// at build time. This keeps TypeScript happy for `import './styles/index.scss'`. +declare module '*.scss' diff --git a/src/essence/Tools/LayerFilter/lib/types.ts b/src/essence/Tools/LayerFilter/lib/types.ts new file mode 100644 index 000000000..d4f0da7e1 --- /dev/null +++ b/src/essence/Tools/LayerFilter/lib/types.ts @@ -0,0 +1,48 @@ +// Config-driven, generic 2-step filter. Step 1 = theme; step 2 = that theme's +// filters. Everything here is data the plugin renders — it knows nothing about +// what the filters mean. + +export interface FilterOption { + value: string + label: string +} + +/** One step-2 control (currently a select; room for more types later). */ +export interface FilterDef { + id: string + label: string + /** Which `layer.properties` key this filter matches against. */ + property: string + type: 'select' + /** Label for the "no selection / all" option (e.g. "All Sectors"). */ + allLabel?: string + // FLAG (revisit): options are statically listed in config.json for now. + // Values like Location/Year — and arguably all option lists — should instead + // be derived from the data (the distinct `layer.properties[property]` values, + // eventually from STAC) so they stay in sync without hand-maintenance. + options: FilterOption[] +} + +/** One step-1 theme: a rail entry + the step-2 filters it reveals. */ +export interface ThemeDef { + id: string + /** Rail label, e.g. "Need". */ + label: string + /** Rail icon (Material/MDI name). */ + icon?: string + /** Panel heading, e.g. "Need". */ + title: string + /** Panel sub-text under the title. */ + description?: string + filters: FilterDef[] +} + +export interface LayerFilterConfig { + /** `layer.properties` key that holds the theme id (default "theme"). */ + themeProperty: string + defaultThemeId?: string + themes: ThemeDef[] +} + +/** Active step-2 choices: `layer.properties` key → selected value ("" = all). */ +export type FilterSelections = Record diff --git a/src/essence/Tools/LayerFilter/lib/utils/matchLayers.ts b/src/essence/Tools/LayerFilter/lib/utils/matchLayers.ts new file mode 100644 index 000000000..4750a06fd --- /dev/null +++ b/src/essence/Tools/LayerFilter/lib/utils/matchLayers.ts @@ -0,0 +1,57 @@ +// Pure matcher — no MMGIS imports, no DOM, no network. Given the layer configs +// and the active theme + selections, return the names of layers that match. +// +// A layer "matches" when: +// - its properties[themeProperty] equals the selected theme id, AND +// - for every active selection, its properties[property] equals that value. +// An empty/absent selection value means "all" (that filter is ignored). +// Missing properties simply don't match — so it degrades gracefully until STAC +// actually populates `properties`. +import type { FilterSelections } from '../types' + +export interface LayerLike { + properties?: Record +} + +/** + * A layer property matches a target value when it equals it (scalar) or + * contains it (array). Array support lets a layer belong to several themes / + * carry several hazards, e.g. `properties.theme = ['need','hazard','event']`. + */ +function valueMatches(propValue: unknown, target: string): boolean { + if (Array.isArray(propValue)) { + return propValue.map((v) => String(v)).includes(target) + } + return String(propValue ?? '') === target +} + +export function matchLayers( + layerConfigs: Record | null | undefined, + themeProperty: string, + themeId: string, + selections: FilterSelections, +): string[] { + const matched: string[] = [] + if (!layerConfigs) return matched + + for (const [name, cfg] of Object.entries(layerConfigs)) { + const props = (cfg && cfg.properties) || {} + + // Theme gate. + if (themeProperty && !valueMatches(props[themeProperty], themeId)) { + continue + } + + // Every active selection must match. + let ok = true + for (const [property, value] of Object.entries(selections || {})) { + if (value === '' || value == null) continue // "all" + if (!valueMatches(props[property], value)) { + ok = false + break + } + } + if (ok) matched.push(name) + } + return matched +} diff --git a/src/essence/Tools/LayerFilterThemes/LayerFilterThemesTool.tsx b/src/essence/Tools/LayerFilterThemes/LayerFilterThemesTool.tsx new file mode 100644 index 000000000..8632f6ce5 --- /dev/null +++ b/src/essence/Tools/LayerFilterThemes/LayerFilterThemesTool.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { MMGISThemeRailAdapter } from './MMGISThemeRailAdapter' + +let _root: Root | null = null + +const LayerFilterThemesTool = { + height: 0, + width: 66, + targetId: null as string | null, + made: false, + + make: function (targetId?: string) { + this.targetId = typeof targetId === 'string' ? targetId : 'toolPanel' + const container = document.getElementById(this.targetId) + if (!container) { + console.error( + `LayerFilterThemesTool: container ${this.targetId} not found`, + ) + return + } + _root = createRoot(container) + _root.render() + this.made = true + }, + + destroy: function () { + if (_root) { + _root.unmount() + _root = null + } + this.targetId = null + this.made = false + }, + + getUrlString: function () { + return '' + }, +} + +export default LayerFilterThemesTool diff --git a/src/essence/Tools/LayerFilterThemes/MMGISThemeRailAdapter.tsx b/src/essence/Tools/LayerFilterThemes/MMGISThemeRailAdapter.tsx new file mode 100644 index 000000000..907cf2201 --- /dev/null +++ b/src/essence/Tools/LayerFilterThemes/MMGISThemeRailAdapter.tsx @@ -0,0 +1,36 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { ThemeRail } from './lib' +import type { ThemeSummary } from './lib/types' +import { getThemes } from './adapters/getThemes' +import { useMMGISEvent } from './adapters/hooks' +import { mmgisEmit } from '../_shared/adapters/mmgisAPI' + +const SELECTED_THEME_EVENT = 'layerFilter:selectedThemeChanged' + +export function MMGISThemeRailAdapter() { + const [themes, setThemes] = useState([]) + const [selectedId, setSelectedId] = useState('') + + const load = useCallback(async () => { + const { themes: loaded, defaultThemeId } = await getThemes() + setThemes(loaded) + setSelectedId((cur) => cur || defaultThemeId || (loaded[0]?.id ?? '')) + }, []) + + useEffect(() => { + void load() + }, [load]) + + // The LayerFilter (panel) tool provides the themes; if the rail mounts + // first, re-pull once the panel announces it's ready. + useMMGISEvent('layerFilter:ready', () => { + void load() + }) + + const onSelect = useCallback((id: string) => { + setSelectedId(id) + mmgisEmit(SELECTED_THEME_EVENT, { themeId: id }) + }, []) + + return +} diff --git a/src/essence/Tools/LayerFilterThemes/adapters/getThemes.ts b/src/essence/Tools/LayerFilterThemes/adapters/getThemes.ts new file mode 100644 index 000000000..a5a7ef10f --- /dev/null +++ b/src/essence/Tools/LayerFilterThemes/adapters/getThemes.ts @@ -0,0 +1,13 @@ +import { mmgisRequest } from '../../_shared/adapters/mmgisAPI' +import type { ThemeSummary } from '../lib/types' + +export interface ThemesResult { + themes: ThemeSummary[] + defaultThemeId?: string +} + +/** Pull the theme list from the LayerFilter tool over the bus. */ +export async function getThemes(): Promise { + const result = await mmgisRequest('layerFilter:getThemes') + return result || { themes: [] } +} diff --git a/src/essence/Tools/LayerFilterThemes/adapters/hooks.ts b/src/essence/Tools/LayerFilterThemes/adapters/hooks.ts new file mode 100644 index 000000000..14aba525f --- /dev/null +++ b/src/essence/Tools/LayerFilterThemes/adapters/hooks.ts @@ -0,0 +1,10 @@ +import { useEffect } from 'react' +import { mmgisOn } from '../../_shared/adapters/mmgisAPI' + +/** Subscribe to a bus event for the lifetime of the component. */ +export function useMMGISEvent( + event: string, + handler: (payload?: unknown) => void, +): void { + useEffect(() => mmgisOn(event, handler), [event, handler]) +} diff --git a/src/essence/Tools/LayerFilterThemes/config.json b/src/essence/Tools/LayerFilterThemes/config.json new file mode 100644 index 000000000..83cd28eee --- /dev/null +++ b/src/essence/Tools/LayerFilterThemes/config.json @@ -0,0 +1,19 @@ +{ + "name": "LayerFilterThemes", + "defaultIcon": "view-dashboard-outline", + "description": "Theme rail (step 1) for the LayerFilter plugin. Renders the high-level themes and emits the selected theme on the event bus. Pulls its theme list from the LayerFilter tool.", + "width": 66, + "height": 0, + "paths": { + "LayerFilterThemesTool": "essence/Tools/LayerFilterThemes/LayerFilterThemesTool" + }, + "metadata": { + "icon": "view-dashboard-outline", + "requiredOrientation": "vertical", + "compatiblePositions": ["left"], + "preferredPosition": "left", + "modernLayoutSupport": true, + "width": 66, + "height": 0 + } +} diff --git a/src/essence/Tools/LayerFilterThemes/lib/geo/ThemeRail/ThemeRail.tsx b/src/essence/Tools/LayerFilterThemes/lib/geo/ThemeRail/ThemeRail.tsx new file mode 100644 index 000000000..467da27eb --- /dev/null +++ b/src/essence/Tools/LayerFilterThemes/lib/geo/ThemeRail/ThemeRail.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import type { ThemeSummary } from '../../types' + +export interface ThemeRailProps { + themes: ThemeSummary[] + selectedId: string + onSelect: (id: string) => void +} + +/** Presentational green rail: one stacked, selectable entry per theme. */ +export function ThemeRail({ themes, selectedId, onSelect }: ThemeRailProps) { + return ( +
+ {themes.map((theme) => { + const active = theme.id === selectedId + return ( +