Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion configure/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "configure",
"version": "4.2.11-20260611",
"version": "4.2.12-20260629",
"homepage": "./configure/build",
"private": true,
"dependencies": {
Expand Down
17 changes: 17 additions & 0 deletions configure/src/metaconfigs/layer-tile-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,23 @@
}
]
},
{
"name": "Properties",
"rows": [
{
"components": [
{
"field": "properties",
"name": "Properties (JSON)",
"description": "Filterable metadata used by the LayerFilter plugin, e.g. { \"theme\": [\"hazard\"], \"hazard\": \"flood\", \"sector\": \"water\", \"location\": \"north_america\", \"year\": \"2024\" }. Usually populated from STAC. Keys are the layer.properties keys the LayerFilter plugin matches on.",
"type": "json",
"width": 12,
"height": "240px"
}
]
}
]
},
{
"name": "Information",
"rows": [
Expand Down
17 changes: 17 additions & 0 deletions configure/src/metaconfigs/layer-vector-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,23 @@
}
]
},
{
"name": "Properties",
"rows": [
{
"components": [
{
"field": "properties",
"name": "Properties (JSON)",
"description": "Filterable metadata used by the LayerFilter plugin, e.g. { \"theme\": [\"hazard\"], \"hazard\": \"flood\", \"sector\": \"water\", \"location\": \"north_america\", \"year\": \"2024\" }. Usually populated from STAC. Keys are the layer.properties keys the LayerFilter plugin matches on.",
"type": "json",
"width": 12,
"height": "240px"
}
]
}
]
},
{
"name": "Information",
"rows": [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mmgis",
"version": "4.2.11-20260611",
"version": "4.2.12-20260629",
"description": "A web-based mapping and localization solution for science operation on planetary missions.",
"homepage": "build",
"repository": {
Expand Down
6 changes: 6 additions & 0 deletions src/essence/Basics/TimeControl_/TimeControl.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ var TimeControl = {
}
return false
}),
// Expose the mission's configured time settings (incl.
// initialstart/initialend) so plugins can read the overall range
// even when the live time window isn't set (time.enabled: false).
window.mmgisAPI.provide('time:getConfig', () =>
L_.configData && L_.configData.time ? L_.configData.time : null
),
]
}
},
Expand Down
11 changes: 11 additions & 0 deletions src/essence/Basics/UserInterface_/UserInterfaceModern_.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
79 changes: 79 additions & 0 deletions src/essence/Tools/LayerFilter/LayerFilterTool.tsx
Original file line number Diff line number Diff line change
@@ -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<ToolVars>('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(<MMGISLayerFilterAdapter />)
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
112 changes: 112 additions & 0 deletions src/essence/Tools/LayerFilter/MMGISLayerFilterAdapter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { FilterPanel } from './lib'
import type {
LayerFilterConfig,
FilterSelections,
FilterOption,
} from './lib/types'
import { useMMGISToolVars, useMMGISEvent } from './adapters/hooks'
import { emitFilterChange } from './adapters/emitFilterChange'
import { resolveOptions, type TimeConfigLike } from './lib/utils/resolveOptions'
import type { LayerLike } from './lib/utils/matchLayers'
import { mmgisRequest } from '../_shared/adapters/mmgisAPI'
import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady'

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<LayerFilterConfig & Record<string, unknown>>(
'layerfilter',
)
const themes = vars.themes ?? []
const themeProperty = vars.themeProperty || 'theme'

const [selectedThemeId, setSelectedThemeId] = useState('')
const [selectionsByTheme, setSelectionsByTheme] = useState<
Record<string, FilterSelections>
>({})

// Data the filter derives from: layer configs (matching + data-driven
// options) and the mission's configured time range (year options).
const [layerConfigs, setLayerConfigs] = useState<Record<
string,
LayerLike
> | null>(null)
const [timeConfig, setTimeConfig] = useState<TimeConfigLike | null>(null)

const loadLayerConfigs = useCallback(() => {
void mmgisRequest<Record<string, LayerLike>>('layers:getAllConfigs').then(
(r) => {
if (r) setLayerConfigs(r)
},
)
}, [])
const loadTimeConfig = useCallback(() => {
void mmgisRequest<TimeConfigLike>('time:getConfig').then((r) => {
if (r) setTimeConfig(r)
})
}, [])
// These handlers register during mission load; wait until they exist so we
// don't fetch too early and silently get null (then never retry).
useMMGISHandlerReady('layers:getAllConfigs', loadLayerConfigs)
useMMGISHandlerReady('time:getConfig', loadTimeConfig)

// 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

// Resolve each filter's options (config override → time → data).
const optionsByFilter = useMemo(() => {
const map: Record<string, FilterOption[]> = {}
if (activeTheme) {
for (const f of activeTheme.filters) {
map[f.id] = resolveOptions(f, layerConfigs, timeConfig)
}
}
return map
}, [activeTheme, layerConfigs, timeConfig])

// Recompute + emit matches whenever the theme, its selections, or the
// loaded layer configs change.
useEffect(() => {
if (!selectedThemeId) return
emitFilterChange(themeProperty, selectedThemeId, selections, layerConfigs)
}, [selectedThemeId, selections, themeProperty, layerConfigs])

const handleChange = useCallback(
(property: string, value: string) => {
setSelectionsByTheme((prev) => ({
...prev,
[selectedThemeId]: {
...(prev[selectedThemeId] || {}),
[property]: value,
},
}))
},
[selectedThemeId],
)

return (
<FilterPanel
theme={activeTheme}
selections={selections}
optionsByFilter={optionsByFilter}
onChange={handleChange}
/>
)
}
33 changes: 33 additions & 0 deletions src/essence/Tools/LayerFilter/adapters/emitFilterChange.ts
Original file line number Diff line number Diff line change
@@ -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. The layer
// configs are passed in (the adapter loads them once for both matching and
// option derivation).
import { 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 function emitFilterChange(
themeProperty: string,
themeId: string,
selections: FilterSelections,
layerConfigs: Record<string, LayerLike> | null | undefined,
): FilterChangePayload {
const matchedLayerNames = matchLayers(
layerConfigs,
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
}
34 changes: 34 additions & 0 deletions src/essence/Tools/LayerFilter/adapters/hooks.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Record<string, unknown>>(
toolName: string,
): T {
const [vars, setVars] = useState<T>({} as T)
useEffect(() => {
let cancelled = false
mmgisRequest<T>('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])
}
Loading