Parent: #164 (LayerFilter plugin). First PR of the revamp; no UI changes.
Problem
Three missing pieces stand between the current flat filter and the
Disasters designs, and they form one coherent machinery layer:
- Filtering has no visible effect — the plugin only announces matches
on the bus. The agreed mechanism is a runtime "shown in layer lists"
flag in core state: the filter writes it in bulk, the layer panel reads
it reactively, and what's drawn on the map is never touched.
- The filter model can't express the Event theme. The agreed model:
the working set is the join of layers and activations (one row per
pairing, plus a row per unpaired layer), and every filter — sector,
hazard, year, and the activation picker itself — is a facet over those
rows, all adapting to each other by one cascade rule.
- Nothing produces those rows. Activations are authored as a GeoJSON
FeatureCollection (STAC datetime conventions) listing each event's
metadata and layer associations; a loader must parse it, join it to the
mission's layers via stable per-layer keys, and derive the computed
values (year spans, active status).
Full data contract: src/essence/Tools/LayerFilter/SCHEMA.md.
Outcome
Listed-flag pipeline (implementation already recovered, tests green —
review-and-land as part of this PR):
- Filtering narrows the layer panel immediately; clearing restores it; map
visibility is user-owned and never modified; the flag is runtime-only
and survives config re-parses; boot never narrows (filter engages on
first user interaction); removed layers clean up their flag.
Faceted engine (pure, app-agnostic, no MMGIS imports, no UI):
- Per facet: available options with counts, computed from rows passing
every other facet (never itself) — narrowing year adapts hazard and
the activation list, and vice versa, without trapping the user.
- Selected values that disappear from availability auto-clear.
- Selection state is per theme and survives theme switches.
- Facets are enumerated (dropdowns) or predicate (arbitrary row test —
the seam a later geocode facet plugs into).
- Multi- vs single-select per facet; case-insensitive value matching.
Catalogue loader + Configure split:
- Two separate configuration fields (themes/filters vs activation
catalogue — different authors, different cadence; catalogue swappable
for a hosted URL later without schema change).
- Catalogue parsed and joined to layers; per-pairing attributes
(default-on, opacity, view override) flow onto rows untouched.
- Derived: year(s) from the full date-range span (a Dec–Feb event matches
both years); active status from end-date absence.
- Graceful degradation: dangling layer keys warn and skip; malformed
catalogue JSON fails soft leaving layer-only themes functional.
Acceptance
Est. PR size: ~600 substantive lines excluding tests (130 recovered +
~250 engine + ~200 loader) — within the ~800 cohesive-unit bound. If it
threatens 1000 during implementation, the loader is the natural split.
Implementation sketch — written as of 61849d9d on 2026-07-23; rough guide, re-verify against latest
Listed flag (recovered, uncommitted on feat/filter-plugin):
Layers_.js: L_.layers.listed (uuid → bool, absent = listed; kept
outside configData because resetConfig re-parses that), providers
layers:getListed / layers:setListed({ updates, source }) (source
reserved for multi-writer arbitration), emits layer:listedChange;
cleanup in removeLayerFromLayersData.
- Gotcha:
layer:listedChange singular naming is deliberate — kept
distinct from the existing layers:listChanged (add/remove). Don't
"fix" it.
LayerManager/adapters/getVisibleLayersWithLegends.ts skips
listed === false; LayerFilter writes the complement map via
buildListedUpdates in emitFilterChange, gated on first interaction.
- Exclude
configure/public/toolConfigs.json from the commit (not ours).
Engine: lib/engine/ — types.ts (Row, FacetDef enumerated|predicate,
ThemeDef, SelectionState), facets.ts (options/counts with own-facet
exemption), derive.ts (yearsFromDatetimes, isActive, case-fold),
engine.ts (apply + invalidate). Row = (layer, activationEdge|null, edgeAttrs); results = distinct layers of surviving rows. Supersedes
lib/utils/matchLayers.ts — keep it until the UI PR migrates, then retire.
Open call: multi-valued tag vs multi-select filter — any-overlap vs
contains-all is undecided; implement any-overlap default, alternative
behind per-facet config, state it in the PR description.
Loader: config fields declared in the plugin config.json variables
(mirror the existing themes-JSON field). Loader in adapters/ (needs
layers:getAllConfigs over the bus — bus-only rule). Pure parsing/
derivation in lib/ for tests. Gotcha: never reference MMGIS layer
name (ingest-generated UUID) from the catalogue — stable key is
layer.properties.key; display_name is NOT unique (nameToUUID is an
array). Keep the loader input a plain FeatureCollection so a future
hosted-URL/STAC source is a swap, not a migration.
Parent: #164 (LayerFilter plugin). First PR of the revamp; no UI changes.
Problem
Three missing pieces stand between the current flat filter and the
Disasters designs, and they form one coherent machinery layer:
on the bus. The agreed mechanism is a runtime "shown in layer lists"
flag in core state: the filter writes it in bulk, the layer panel reads
it reactively, and what's drawn on the map is never touched.
the working set is the join of layers and activations (one row per
pairing, plus a row per unpaired layer), and every filter — sector,
hazard, year, and the activation picker itself — is a facet over those
rows, all adapting to each other by one cascade rule.
FeatureCollection (STAC datetime conventions) listing each event's
metadata and layer associations; a loader must parse it, join it to the
mission's layers via stable per-layer keys, and derive the computed
values (year spans, active status).
Full data contract:
src/essence/Tools/LayerFilter/SCHEMA.md.Outcome
Listed-flag pipeline (implementation already recovered, tests green —
review-and-land as part of this PR):
visibility is user-owned and never modified; the flag is runtime-only
and survives config re-parses; boot never narrows (filter engages on
first user interaction); removed layers clean up their flag.
Faceted engine (pure, app-agnostic, no MMGIS imports, no UI):
every other facet (never itself) — narrowing year adapts hazard and
the activation list, and vice versa, without trapping the user.
the seam a later geocode facet plugs into).
Catalogue loader + Configure split:
catalogue — different authors, different cadence; catalogue swappable
for a hosted URL later without schema change).
(default-on, opacity, view override) flow onto rows untouched.
both years); active status from end-date absence.
catalogue JSON fails soft leaving layer-only themes functional.
Acceptance
toggled-on layers stay drawn throughout; fresh load shows the full
panel.
exemption, auto-clear invalidation, multi-select semantics,
year-boundary span, unpaired-layer rows, empty selection = no
narrowing.
dangling key warns and skips;
end_datetime: null⇒ active;malformed input fails soft.
word (grep-verifiable in review).
purely as configuration in tests.
Est. PR size: ~600 substantive lines excluding tests (130 recovered +
~250 engine + ~200 loader) — within the ~800 cohesive-unit bound. If it
threatens 1000 during implementation, the loader is the natural split.
Implementation sketch — written as of
61849d9don 2026-07-23; rough guide, re-verify against latestListed flag (recovered, uncommitted on
feat/filter-plugin):Layers_.js:L_.layers.listed(uuid → bool, absent = listed; keptoutside
configDatabecauseresetConfigre-parses that), providerslayers:getListed/layers:setListed({ updates, source })(sourcereserved for multi-writer arbitration), emits
layer:listedChange;cleanup in
removeLayerFromLayersData.layer:listedChangesingular naming is deliberate — keptdistinct from the existing
layers:listChanged(add/remove). Don't"fix" it.
LayerManager/adapters/getVisibleLayersWithLegends.tsskipslisted === false;LayerFilterwrites the complement map viabuildListedUpdatesinemitFilterChange, gated on first interaction.configure/public/toolConfigs.jsonfrom the commit (not ours).Engine:
lib/engine/—types.ts(Row, FacetDef enumerated|predicate,ThemeDef, SelectionState),
facets.ts(options/counts with own-facetexemption),
derive.ts(yearsFromDatetimes, isActive, case-fold),engine.ts(apply + invalidate). Row =(layer, activationEdge|null, edgeAttrs); results = distinct layers of surviving rows. Supersedeslib/utils/matchLayers.ts— keep it until the UI PR migrates, then retire.Open call: multi-valued tag vs multi-select filter — any-overlap vs
contains-all is undecided; implement any-overlap default, alternative
behind per-facet config, state it in the PR description.
Loader: config fields declared in the plugin
config.jsonvariables(mirror the existing themes-JSON field). Loader in
adapters/(needslayers:getAllConfigsover the bus — bus-only rule). Pure parsing/derivation in
lib/for tests. Gotcha: never reference MMGIS layername(ingest-generated UUID) from the catalogue — stable key islayer.properties.key;display_nameis NOT unique (nameToUUIDis anarray). Keep the loader input a plain FeatureCollection so a future
hosted-URL/STAC source is a swap, not a migration.