Skip to content

Fix COG tile layers with s3:// URLs being treated as mission-relative - #147

Open
devin-ai-integration[bot] wants to merge 12 commits into
developmentfrom
devin/1790274801-cog-s3-scheme
Open

devin-ai-integration[bot] wants to merge 12 commits into
developmentfrom
devin/1790274801-cog-s3-scheme

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Purpose

A Tile layer with sourceType: COG and an s3://bucket/key.tif URL did not work; users had to hand-write a full https://.../titiler/cog/tiles/...?url=s3://... URL instead.

Root cause: F_.isUrlAbsolute (and its copy in configure/src/core/utils.js) used ^(?:[a-z]+:)?//, so any scheme containing a digit (s3:) was not recognized as absolute. L_.getUrl then prepended the mission path, producing tile requests like:

/titiler/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.webp?url=../../Missions/CogTest/s3://my-bucket/path/file.tif

Proposed Changes

  • [FIX] isUrlAbsolute now accepts any RFC 3986 scheme (^(?:[a-z][a-z0-9+.-]*:)?//) in both Formulae_.js and configure/src/core/utils.js, so s3://, gs://, az://, svn+ssh:// etc. count as absolute.
  • [ADD] Unit tests for the new schemes in tests/unit/formulae.spec.js.

(The Cesium TiTiler-wrapping and url= encoding changes from review were reverted as out of scope for this PR; they are pre-existing behaviours affecting https:// COGs too and can be a follow-up.)

Issues

  • Reported via chat (COG + s3 URL requiring a manual titiler URL).

Testing

  • Reproduced locally with a sourceType: COG, url: s3://my-bucket/path/file.tif tile layer: before the fix tile requests carried url=../../Missions/CogTest/s3://...; after they are .../titiler/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.webp?url=s3://my-bucket/path/file.tif.
  • Recorded browser verification (see PR comment): S3 / relative / https COG URL generation and regular tile layers.
  • PLAYWRIGHT_TEST_UNIT_ONLY=true npx playwright test tests/unit/formulae.spec.js tests/unit/layerSource.spec.js — all pass.

Note for deployers: TITILER_ALLOWED_URL_PATTERNS (default only allows ^https:// and ^/Missions/) must also permit ^s3://... for the proxy to forward s3 URLs to TiTiler.

Link to Devin session: https://nasa-jpl-demo.devinenterprise.com/sessions/e0febe17a9a44e899720ac13eebc34d2
Open in Devin Desktop: https://nasa-jpl-demo.devinenterprise.com/desktop/session/e0febe17a9a44e899720ac13eebc34d2?variant=devin
Requested by: @tariqksoliman

Benziza and others added 5 commits September 22, 2026 16:07
…ility toggles (NASA-AMMOS#1094)

* Fix attachment op lookup broken by the module key rename

`withOp` still read `mods[id].plugin`, the pre-rename key, so it matched
nothing: an attachment's per-feature ops were never found and clearing a
clicked image overlay or model silently did nothing.

Guard both sides of the generated boundary in a test, since a mismatch here
fails as a no-op rather than an error.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Make the plugin families authorable: docs, scaffolds, capability validation

The code grew config/filter/time surfaces, classification capabilities and a
whole attachment family; the authoring materials still described the render
contract. Closes that gap, and moves the CLI out of /plugins so the directory
is containers only.

- plugin-cli/ at the root: cli.js, lib/, and the six scaffolds as real files
  under scaffolds/<type>/, token-substituted into paths and contents. They are
  valid JSON/JS as they sit, so the validator checks them (a broken scaffold
  fails CI, not someone's first plugin). registries.json is CLI config and
  moves with it; plugin-state.json stays in /plugins, where the server reads it.
- create layerattachment, and a layerattachments README with the contract and a
  worked example — it was the one family with neither.
- Validate classification capabilities against a schema: a wrong type or an
  out-of-enum value is an error, an unknown key warns, and an omission core
  acts on (map.stacking, host.order) warns. These previously failed silently
  as a mis-ordered or un-pickable layer.
- Document the config/filter/time surfaces, drop the capture surface and the
  onHostToggle op (neither has a runtime implementation).
- Destroy a layer's attachments when its host is removed for good.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Document the six plugin families and their scaffolds in the docs site

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Layertype file layout: one path segment per surface

The leaf name repeated what the plugin directory already said (Vector/map/vector.js),
and globe/config.js was not the config surface at all but a helper shared by the two
engine modules, one directory away from the real config/vector.js.

- Vector/map.js, config.js, filter.js, time.js; globe/cesium.js, globe/lithosphere.js.
- globe/config.js → globe/layerConfig.js: it builds the engine-neutral globe layer
  descriptor, which is not the `config` surface.
- Non-surface helpers go in lib/ (VectorTile/lib/SimplifiedVectorGrid.js).
- Drop the export default from the layerConfig helpers; every importer uses the
  named exports.
- Vector/map.js declares its ops as named functions like the other 29 modules
  instead of inline methods.

Paths are convention only — the manifest key is what names a surface — so this is
manifests, imports and regenerated registries.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Show the op phase form in the layertype scaffold

The scaffold's bare `make` is shorthand for `{ main }`, which made Vector's
phase object look like a different contract.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drop the undispatched `load` op; derive the CLI's plugin types from one map

`load` was in LAYER_OPS, the validator, the README table and the scaffold, but
core never dispatched it and no type implemented it: data acquisition (including
the dynamic-extent refetch) is core's, in LayerCapturer. The op table also
omitted `render` and `onToggle`, which core does dispatch.

`create`'s valid types and the discovery directories were two hand-kept lists,
so the help text still advertised five families after layerattachment landed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Serialize the specs that regenerate the shared plugin registries

src/pre/*.js and configure/public/*Configs.json are single copies in the repo,
so a worker running the CLI could regenerate away a fixture another worker had
just written and was asserting on (~1 in 3 runs locally; CI uses one worker).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Declare the lock helper's Atomics globals for ESLint

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix the authoring papercuts eight fresh plugin authors hit

Eight sessions each wrote a plugin against this branch with no help beyond the
docs. Every one of them tripped over the same three things:

- `create layertype` printed "implement make in map/<name>.js", a path that
  stopped existing when the layout collapsed to one segment per surface.
- A scaffolded plugin is untracked, since a container is normally its own repo.
  Said so where `create` is documented, rather than leaving it to git status.
- `plugin-registries.json" is `plugin-cli/registries.json`.

Also: a type that `extends` another was warned for capabilities its parent
declares, and the two layer families' templates weren't linked from the
README's Creating Plugins section that lists the other four.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Document extends where the docs already claimed it was

The plugins README points at this file for extends; this file never mentioned
it. The one child of the eight who was told extends existed found out how it
works by reading LayerTypeRegistry and pluginValidation; the others would not
have found it at all, and a new data source is exactly the case it exists for.

Also states what an expanded sublayer must carry, since that was copied from
Vector's STAC path by guess.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Correct the manifest-reference facts authors checked against reality

Every one of these was found by an author who followed the doc and then found
the code disagreed: icons are MDI, not Ionicons (the scaffold itself emits
`mdi mdi-`); the toolbar priority bands are 1-4/101-102/1001-1002, not
'~1001-1020'; `create` takes six types, not four; and the documented way to run
a plugin's own tests fails in global setup without PLAYWRIGHT_TEST_UNIT_ONLY,
which only appeared in a package.json script.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Enforce interaction applicability; document backend; runnable scaffold tests

`applicableLayerTypes` was descriptive: a mission config, a kind preset or a
type's `defaultInteractions` could name an interaction the interaction says it
doesn't handle. updateInteractions now emits APPLICABLE_LAYER_TYPES and the
runner filters the whole pipeline — preamble and postamble included — against
the layer's type chain, so a type that `extends` vector keeps vector's
interactions.

The scaffolded tests were CommonJS assertions about plugin.json sitting beside
ESM modules, and no npm script ran them. They are now ESM, import the module
where the module can load outside the bundler (attachment, interaction,
backend), check that every declared module path resolves — the usual cause of a
plugin that validates but never appears — and run under `test:plugins:unit`.

Backend was the one family with no contract document: adds
plugins/core/backend/README.md (the `s` object, lifecycle ordering, ensureAdmin's
whitelist, envs, models) and gives the scaffold a deliberate auth gate.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Validate the manifest parts that fail quietly

The Configure form a manifest declares was validated only as "is an object":
Maker skips a component type it cannot render and writes wherever `field`
points, so a typo'd type or an attachment field outside its own `configPath`
yields a form that looks right and configures nothing. validateMetaconfig now
checks component types against what Maker renders, field presence, dropdown
options, objectarray shape, grid widths, and that an attachment only writes
inside its configPath.

`validate` also now reports what it could not see before: an enabled plugin
absent from src/pre (valid, but the app never loads it) and a registry import
whose plugin is gone — both meaning activate has not been run.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add the source, legend, onConfigChange and configPath surfaces

Four surfaces the eight authoring experiments asked for, each replacing
something core was deciding on their behalf:

- layertype `source.fetch(layerObj, ctx) -> GeoJSON`, dispatched from
  LayerCapturer for every acquisition. Core keeps the whole dynamic-extent
  policy (extent, debounce/settling, zoom gate, move threshold, request
  staleness, clear/update, reload subscribers) — extracted out of the two
  duplicated branches into capture/dynamicExtent.js so it is testable — so
  `fetch` is a pure "given this, give me GeoJSON" for data that doesn't come
  out of a url core can fetch (POST bodies, headers, pagination, an SDK).
- layertype `legend.derive(layerObj)`, replacing the
  `['image','tile'].includes(type) && cogTransform || type === 'velocity' ||
  (type === 'data' && shader === 'colorize')` list that both the LayersTool and
  the LegendTool carried. Tile/Image/Velocity/Data now declare the surface.
- attachment `onConfigChange(ctx)`, with `mmgisAPI.setLayerAttachmentConfig()`
  as its dispatcher: core writes the new settings into the host's live config at
  the declared configPath, then lets the attachment retune in place rather than
  paying for the default host rebuild.
- interaction `configPath`, so an interaction with per-layer settings is handed
  its own subtree as `ctx.config` instead of digging through layerData.

Also: `raw` (the engine namespace) on both globe contexts, matching `mctx.raw`;
`test:plugins:unit` no longer fails when no plugin ships a @unit spec.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix the doc and scaffold gaps a second authoring round found

Eight plugins were written against this branch by separate agents; these are
the things they had to guess at or work around.

- The layertypes README still said data acquisition is not in the vocabulary
  and belongs in `make`, 75 lines above the `source` surface that replaced
  that advice.
- `gctx.raw` was absent from the gctx table, which still sent globe authors
  to `gctx.renderer` for engine-specific work. Documented, including that the
  two engines' namespaces are symmetric in name only.
- Globe operations are dispatched by layer *name* (only `make` gets a layer
  object), and `setVisibility`/`setOpacity` take the new value positionally.
  None of that was written down; two authors guessed.
- Nothing said how a layer opts into dynamic extent, so `source.fetch` saw a
  null `ctx.view` with no explanation.
- `legend.derive` is handed config, not data; say so, and where render state
  actually lives, instead of leaving authors to stash on the layer.
- `onConfigChange` is passed the built attachment, which the ctx table
  omitted.
- The layertype scaffold's own test asserted `modules.globe[key]` over
  `Object.keys(renderers.globe)`, so it failed for every globe layertype, and
  it assumed `modules` where a single-module `extends` plugin has `module`.
- The scaffold and the attachment README both captured `window.L` at import
  time, which is undefined in the unit test the same scaffold ships. Read the
  global per call instead, and drop `F_` from the example: importing an MMGIS
  singleton pulls jQuery and makes a module un-importable in Node, which
  browser-globals now states rather than implying it protects against that.
- `create` printed `npm run build` for interactions instead of `activate`,
  said nothing when a name collided with an existing plugin in another family
  (a `Curtain` layertype silently shadowed the `Curtain` tool), and left the
  author to discover that a non-core container is untracked here.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add a textarea component, document the metaconfig, isolate ctx.config

A plugin whose main setting is written in another language — the Overpass QL
query one of the authoring experiments built its layertype around — got a
single-line text field, because every free-text component Maker renders is one.
`textarea` is multiline and monospace, sized by an optional `rows`.

The component vocabulary itself was only discoverable by declaring something
invalid and reading the validator's error, so plugins/README.md now has the
metaconfig section that error was standing in for: what a row is, what each
component field does, and what all 23 component types render.

Also: the interaction runner assigned `ctx.config` only for interactions that
declare a `configPath`, so an interaction without one was handed whichever
config the interaction before it in the pipeline had. It now gets null, which
is also what a layer that was never configured yields — the previous test
asserted the leak, so it changes with it.

For the globe, `gctx.raw` on LithoSphere is the globe class and nothing else
(the package exports one thing), so the README now says where the work actually
happens: `renderer` for the live globe and `window.THREE` for geometry, which
MMGIS vendors and puts on the window. An author looked for THREE on `raw`,
didn't find it, and dropped LithoSphere support. `create layertype` scaffolds a
standalone renderer, which is also now called out as the wrong starting point
for a type that draws like one MMGIS already has: extend it instead.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Give interactions a settings form on their own card

An interaction could be handed `ctx.config` but had no way to declare what
an admin should be able to put there, so its settings existed only as prose
in a README. It now declares `config.rows` in the same metaconfig vocabulary
an attachment uses, and Maker renders those rows on the interaction's card in
a layer's Interactions tab, behind a gear — beside where the interaction was
chosen and dragged, rather than in a tab of its own.

Validation follows from where the form lives: rows need a `configPath` (the
runner has nowhere else to read them back from), every `field` must sit inside
it, and `tab`/`tabs` are rejected. A row's `default` remains a form default —
nothing is written until an admin touches the field — so plugins still default
their own values, which the scaffold and README now spell out.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep an interaction's settings when the layer modal trims a layer

Closing a layer modal rebuilds the layer from the fields its layer type's
tabs declare, which is why attachment config paths were already exempted.
An interaction's settings come from its own manifest, so they were written,
rendered, and then silently dropped on close. Interaction manifests now load
into the store alongside the attachment ones, and their config paths are
preserved by the same pass.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Cover an interaction's settings form end to end

No core interaction has per-layer settings, so the fixture interaction is
served in place of the generated registry — a static JSON file, so no plugin
has to be installed to stand in for one. Fails without the layer modal
preserving interaction-owned config paths.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix what a third-party author trips on: names, tabs, core's own tests

Eight fresh authors built a plugin each against this branch's docs. The
contracts held; the tooling around them did not.

- `create FOVWedges` emitted `f_ovwedges` and `fOVWedges` — an acronym is one
  word now, and so is a word with a digit in it (`HTML5Parser`). The printed
  next-steps filenames derive from the same tokens rather than re-deriving
  them wrongly.
- The layerattachment scaffold defaulted to a Configure tab of its own, the
  exact thing its README warns against; it now joins an existing tab, `validate`
  warns when a non-core attachment's `config.tab` matches no core tab, and the
  next steps say so.
- `create`'s activate diff buried what you just made under every plugin there
  is on a first run; it summarises past 8 lines, `--verbose` to list them.

Two of core's own unit assertions failed for a *valid* third-party attachment:
an exact tab list, and a field walk that descended into `objectarray.object`,
whose item fields are relative and so are not paths under `configPath`. Both
keep their invariant (core's tabs present and in order, no duplicate tabs,
every real field owned by the attachment that reads it) while allowing an
installed plugin, with the third-party case asserted rather than assumed.

`enableWhenField` is `{ field, value }`; given a bare path Configure greys the
control out forever, so validation rejects it now.

Docs, all from things an author had to read core source to learn: an
interactions family README (the `ctx` table, phase/order, settings, and what
can't be unit tested); a `Time` section (the TimeControl surface, and that a
feature's timestamps live at the properties `time.startProp`/`endProp` name);
what the inherited vector renderer does with the features a `source.fetch`
returns, including `prop-<name>` styling; `objectarray`'s relative item fields,
`default` vs `defaultChecked`, and that a cleared number arrives as `""`; what
an attachment's returned object may carry and that `ctx.config` is never null in
`make`; and importing `L_` by alias in a globe module.

Also drops two overwritten no-op `TimeControl.subscribe`/`unsubscribe` stubs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix the cross-family seams a multi-plugin feature runs into

A feature that needs a layer type *and* an attachment *and* an interaction was
buildable but had three ways to fail quietly, all found by building eight of them:

- `pluginDependencies` resolved against tools/backend/components/interactions
  only, so an interaction depending on the layer type it was written for was
  reported enabled, validated clean, and then left out of the generated registry.
  Dependency discovery now covers every family, and the warning says the plugin
  never loads instead of leaving that to be inferred.
- `validate --json` was unparseable whenever the manifest validator logged, since
  it logs JSON lines to stdout. Captured and returned as warnings.
- The module validator accepted `export default { … }` but not the named form the
  scaffolds and docs use, and matched examples inside comments.

Also: `syncData` is handed the attachment's `config` and `layerObj`, as `make`
already was, so a redraw need not stash them; stale-registry validation resolves
the imported module rather than its directory, which is what a type switching to
`extends` deletes; and `applicableLayerTypes`/`defaultInteractions` ids that no
enabled plugin provides are reported.

`create layertype --extends <typeId>` scaffolds what the README recommends and
the best plugins are — a manifest with `extends` and one module whose keys are
surfaces, starting at `source.fetch` — instead of a standalone renderer. The
parent is checked as you type it. Local `install` no longer copies `.git`, and
`--container` pins the container name, which plugin ids embed.

The interaction scaffold's spec tested the handler, which cannot be imported in
Node once it imports a singleton; the decisions now live in `logic.js` and that is
what it tests. Both other scaffolds emit a named default export, so a plugin
following them no longer starts with a lint warning.

Docs: the `_legend` entry shape, one container holding a whole feature and how its
plugins reach each other, attachment time (no op needed — `syncData`), `syncData`'s
context, calling your own backend under a subpath, `kindAlias` being an array,
`NODE_ENV` for eslint, and install/container identity.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Let a layer type declare the attachments it comes with

A feature is often a layer type plus an attachment plus an interaction, and the
type is the plugin that knows what the attachment should be. It could already
declare capabilities.defaultInteractions but had no way to say the same about an
attachment, so three separate plugin authors worked around it the same wrong
way: the type's own config rows wrote a string-literal copy of the attachment's
configPath, coupling two plugins through a path either could rename.

capabilities.defaultAttachments maps attachmentId to that attachment's
settings ({} for none), and core hands it to the attachment under the
attachment's own configPath as if an admin had filled the form in. A layer's own
settings sit on top field by field, so changing one thing doesn't lose the rest
and enabled: false opts out. It resolves through configFor/isEnabledOn, which
everything already went through, so no call site changes.

validate warns for a declared attachmentId no enabled plugin provides, and for
one whose applicableLayerTypes excludes the declaring type - a well-formed
default that would silently never apply.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix what round five's authors hit: extends drops inherited operations, empty form fields beat a type's defaults

extends merged parent and child one level per *surface*, so a type adding a
config.normalize silently lost its parent's config.expand — the opposite of
'declare only what differs'. Merging is now per operation (and per engine
under globe), in a pure typeInheritance module.

A layer's attachment settings also overrode a type's defaultAttachments with
'' — which is what Configure writes into a row nobody filled in — leaving the
attachment with no property name at all. Empty (nullish or '') no longer
overrides; false and 0 still do.

Plus the smaller traps: 'create tool XTool' produced XToolTool; activate
reported 'No changes' after a manifest edit that rewrote a registry;
ToolController_.getTool returned a silent no-op stub for a tool that isn't
loaded; the backend scaffold had no models/ despite its README documenting
one; and the docs said nothing about interaction-to-tool calls, stopGuests
being mount-wide and status-blind, failure responses arriving as HTTP 200,
layer types resolving defaultIcon against MUI rather than MDI, or how
L_.layers.attachments is keyed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* A layer type can configure the interactions it ships, not just name them

capabilities.defaultInteractions took interaction ids only, so a type that
shipped an interaction had no way to tell it the property names it had just
fetched — the gap defaultAttachments closed for the other family, and four of
round five's authors worked around it by duplicating those names into a third
subtree or stashing them on the layer under a private key.

An event may now be an object instead of a list:

    "click": { "wind:report": { "speedProp": "windSpeed" } }

Key order is the pipeline order, as the array's is; the settings resolve into
the interaction's own configPath on the way to ctx.config, with the layer's own
settings over them field by field. An interaction is written once and cannot
tell whether a type configured it or an admin did.

The merge rules are now shared with attachments (declaredConfig), validate
cross-checks both forms — including a declared interaction whose
applicableLayerTypes excludes the declaring type — and VectorTile's click, which
had been passing no layerTypeChain, now enforces applicability like every other
click path.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* A mission with a layer of a plugin-provided type can now be saved

Round six's authors could author, validate, activate and register a new
layer type and still not configure a layer with it: both config validators
switched on the layer's type and called anything they did not recognise
unknown. They ask the generated layer-type registry instead, and hold a
plugin type only to the checks it declares for itself (a `source`-backed
type may have no url) — while a type extending a built-in still gets its
parent's field defaults, since it is drawn by the parent's renderer.

Also from that round: a LithoSphere type that registers itself in
gctx.layers now has its destroy/setVisibility/setOpacity dispatched rather
than declared, validated and never called; a type that declares a map
renderer and resolves none says so instead of loading and drawing nothing;
and window.location exists in the unit-test browser globals, so a source
can resolve a relative url under test.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* A plugin can ask for its layer back, and a type can say when it has data

Two surfaces round six asked for, both of which existed as core internals a
plugin had to reach into.

`ctx.refreshLayer()`, on every interaction and attachment: an interaction that
writes a feature to its own backend had to call
`L_.Map_.refreshLayer(layerObj, cb, skipOrderedBringToFront, stopLoops,
resolvedUrl)` to see its own write. The re-acquisition goes through the same
path every other trigger does, and a `source`-backed type now sees it as
`ctx.trigger === 'refresh'` rather than as another `'make'`.

`time.availability(layerObj, ctx)`: `capabilities.time.histogram` validated,
reported true, and meant nothing — TimeUI found histogram sources by matching
`stac-collection:` and `{t}` against the layer's url, so any other endpoint was
silently excluded. The type answers now, with times and counts; core keeps the
window, the binning and the drawing. Tile's two url schemes move into its time
module, and declaring the capability without a `modules.time` to implement it in
is a validation error.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* A refreshed layer's checkbox no longer shows it turned off

Turning the Reference Mission's geodataset time-series layer on and then
moving the time bar left the Layers tool showing it off, while it was on and
drawn.

The refresh swap hides the old layer and shows the rebuilt one with
`ignoreToggleStateChange`, so `L_.layers.on` deliberately never changes \u2014 and
both halves then compute the state to broadcast from that unchanged value and
notify subscribers 'off' twice. Subscribers are what track on-ness (the
checkbox; a dynamic-extent layer's re-query), so a toggle that preserves state
now tells them nothing.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Plugin system: inherited ops, source cancellation/paging, dynamic dropdown options

The four remaining findings from the round-six plugin authoring sessions.

extends: a child's operation is handed the inherited implementation as one
extra last argument, so overriding vector's `config.normalize` to add a field
no longer silently drops the `kind`/`radius` it sets. Phases match by name, a
bare function stays a bare function, and `inherited()` is a safe no-op when the
parent has no such operation.

source.fetch: `ctx.signal` (a layer's next acquisition aborts its last, and an
AbortError is not logged as a failure), `ctx.emit(geojson)` for a paged source
to draw what it has so far, and `ctx.resolveUrl` so a plugin with an endpoint in
its own config doesn't hand-roll mission/ROOT_PATH resolution. Core keeps the
extent, staleness, move threshold and rendering; an emit after the request is
stale is dropped.

Maker: a dropdown may name an `optionsFrom` provider instead of listing options
— `layerProperties` (the layer's own feature property names, from the geodataset
schema or by sampling the file), `layers`, `layerTypes` — so "pick a property of
this layer's data" stops being an unchecked text field. Validated against the
provider names Configure actually has.

Docs: a components family README (when init runs and what exists by then, the
z-index bands, the subscriptions, and why there is no core interaction →
component channel), and why composing across *layers* is unsupported — with the
supportable shape being one layer whose source.fetch acquires both inputs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Plugin system: a third-party layer type is selectable, and duplicate ids fail validate

The layer modal's Layer Type control is a row in the *selected* type's own
manifest, and every core manifest listed the eleven built-in ids literally — so
a plugin type could be authored, validated, activated and registered, and then
never be chosen by an admin. The row asks the registry instead
(`optionsFrom: "layerTypes"`), which is also the only list that stays correct.

Two owners of one `typeId`/`attachmentId` are refused by registry generation but
were reported by `validate` as clean, and a failed generation leaves the previous
registries in place — a green check followed by a build failure, or an app
silently running the last generation. `validate` now reports them, as it already
did for `interactionId`.

Docs: the inherited-op example counted `inherited` from the wrong position
(`config.normalize` is dispatched with one argument, not two), and the per-family
`config` shape (a layer type declares `tabs`, everyone else `rows`) was stated
for interactions only.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Plugin system: acquire another layer's data, reference a layer's fields from a type's declarations, and drop only the offender on activate

- `ctx.acquire(layerName)` on source/interaction/attachment contexts: a
  configured layer's data, acquired through its own layer type, headlessly —
  not turned on, not drawn, no rendered state exposed, and the layer's own live
  acquisition untouched.
- A type's `defaultAttachments`/`defaultInteractions` values may be
  `"$variables.…"` references read off the host layer, so the property an admin
  picks on the type's form isn't typed again on the other plugins'. An
  unanswerable path drops its key, leaving the plugin its own default; `$$`
  escapes a literal `$`.
- `activate` leaves a duplicate-id or bad-`extends` plugin out of the registry
  and regenerates everything else, rather than aborting and silently leaving
  the previous generation of every registry in place.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Do not collect plugin-cli scaffold templates as tests

`npx playwright test` (no path argument) matched
plugin-cli/scaffolds/*/tests/*.spec.js, which are templates a plugin is
created from: their `__name__` placeholders and relative helper paths
only resolve once copied into a plugin directory, so collection failed
with six 'Cannot find module' errors before any test ran.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add geodataset statistics: per-group stats= and dataset-wide field_stats

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.5-20260806 [version bump]

* Restore CRLF line endings in mmgis-openapi.json

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Assert geodataset statistics end to end against a purpose-built geodataset

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Address review: reject digit-prefixed text as numeric, don't half-summarize on append

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Compute field_stats when an append creates the geodataset

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Address review: keep out-of-range numbers out of the cast, lock field_stats merges

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add nullCount and stddev to dataset-wide field_stats

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add dynamicStyle resolver: map a feature property to a style attribute

Pure core of dynamic vector styling. A layer's variables.dynamicStyle
declares a property, an attribute, and how to get from one to the other
(colour ramp, numeric range, or value table); this compiles that into a
function of a feature's properties, once per layer rather than per
feature.

Knows nothing about Leaflet, the globe, or geodatasets — a geodataset's
stored field_stats is only one of the places a numeric domain can come
from, alongside the loaded features and an avg +/- sigma window.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Style 2D vector features from variables.dynamicStyle; drop styleMatching

The legend no longer assigns styles. The ~155-line styleMatching engine
in the per-feature style function is removed in favour of the compiled
resolver, which also means a layer's ramp and domain are worked out once
instead of being regrouped and rebuilt for every feature.

Precedence is unchanged in shape: feature.properties.style still wins,
and a dynamic style now sits between it and the *Prop fields it
generalizes. Layers that request only some of their properties ask for
the ones their rules style by.

Configs carrying styleMatching entries keep loading; those entries now
only describe the legend. Commit 6 adds the one-click conversion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Reach 3D: resolve the dynamic style onto the globe's GeoJSON clone

A styleMatching layer looked right in 2D and default-coloured on the
globe, because those colours were only ever Leaflet options. The globe
styles from feature.properties.style, so toGlobeConfig writes the
resolved values there — onto its own clone, leaving the map's features
(what Draw and export read) alone.

It reuses the resolver 2D compiled, so a domain measured over the loaded
features is the same domain in both views rather than being re-measured
from whatever the clone happens to hold.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Draw the dynamic style in the Legend, from the same compiled rules

The legend reads variables.dynamicStyle rather than a materialized
_legend, and every swatch is coloured by asking the layer's own resolver
what a value looks like — so the legend can't drift from the map, and it
shows the domain actually in use (avg +/- 2 sigma, a geodataset's
whole-dataset extent) instead of a second reading of the config.

Each scale is titled with the property it describes; a rule that drives
weight or radius has nothing a swatch can show and is left out. A layer
that also has configured entries keeps them, with its scale after.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Author the dynamic style in the vector Style tab, and convert legacy legend styling into it

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Let a viewer restyle a dynamic layer, and stretch its scale over the view

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Pick the ramp by its colours, and let the bins be dragged

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add the Reference Mission's dynamic style layer data

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Address review: escape property names, keep the legend read-only, let a failed stats fetch retry

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Address review: the whole-dataset toggle keeps a rule's configured domain source

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Layers Tool: attribute picker, aligned ramp rows, user-settable gate, visual ramp picker in Configure

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Dynamic style: reset to the configured style, rule-backed properties only, and review fixes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Dynamic style: legend follows automatic restyles, and its heading is no longer the composite sentinel

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Hide the Dynamic Style fields a rule has made irrelevant

Maker gains `showIf`, a condition (or array of them) naming a sibling
field and what it must read, and `subheading`, a section header. The
Dynamic Style rule uses both: Property/Values Are, then Defaults, Scale,
No Data and Categorical Mappings - with Sigma only under a mean-sigma
domain, the ramp only for a colour attribute, bins only when discrete,
and the style range only for weight/radius/opacity.

Two review findings, both real:

- In 2D, a feature carrying any `style` of its own skipped dynamic
  styling entirely, so `{radius: 12}` also dropped its fill colour. It
  now merges key by key under the feature's style, as the globe does.

- Converting a legend spaced its colours evenly, so entries at 0, 10 and
  100 rendered as 0%, 50% and 100%. Stops now carry their position, and
  the domain comes from the entries that contributed a colour.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Let a fieldless component sit inside an objectarray

An objectarray read every declared component's field to seed a new item
and to fill the control, so a subheading - which edits nothing and has
no field - took the whole Configure page down with it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Offer only the styles a rule can still produce

A value table of colours compiles to nothing when aimed at weight or
radius, so the layer quietly lost its colouring and its legend. The
runtime picker now offers a categorical rule only the family its
mappings hold.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep a single feature's stats about its group

The window aggregates run after the WHERE clause, so asking for one
feature by id left it alone in its own partition, reporting its value as
the group's min, max and average. The id restriction now waits outside
the query the group is aggregated in.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Sort out three Dynamic Style and statistics snags

A deduplicated request for one feature with statistics matched nothing:
the id was filtered outside the query, and DISTINCT ON had already kept
only its group's highest id. It now narrows the query to the feature's
group instead, which also saves scanning the table.

An objectarray item's field is relative to its item, so an unset one was
reading a like-named key of the layer - a new rule's Values Are showed
the layer's own 'vector'.

Values Are now sits below No Data, so choosing categorical reveals the
mappings directly beneath it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Number objectarray items, and mind the groupless feature

A feature with no group value was matched with = against a null, so
asking for one with statistics returned nothing at all; null groups are
one group to PARTITION BY, so the comparison is now null-safe too.

The searchdropdown was the one case left reading the enclosing config
for an unset objectarray field.

Items of an objectarray look alike, so each now wears its number.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.6-20260807 [version bump]

* Keep the ramp list with its picker, and unmount a ramp that has gone

A portalled list was placed against the viewport once, at opening, so
scrolling the settings pane it was opened in left it behind. It now
measures again while it is open.

A rule that stops wanting a ramp leaves no mount for one, and the stale
root was only let go of when a new mount was found.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Style by a group's statistics, and by as many rules at runtime as configured

A rule aimed at _.stats.depth_m.avg now asks the geodataset for depth_m's
per-group statistics itself, so a feature can be styled by its group rather
than only by itself; an admin can name further fields to summarise.

The LayersTool panel exposes every rule rather than the first, with rules
addable and removable for the session, reports what the layer knows about
the numbers it styles by, and resets from an icon beside its heading - as
the layer's own settings reset now does too.

A rule that widens lines or fades them is drawn in the legend as lines of
that weight or swatches at that opacity, rather than being left out.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep a rule's number span when it is re-aimed at another style

Patching range with undefined erased it, so switching a weight rule to
radius left it mapping through nothing and it stopped styling at all.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Style by a group statistic without typing its path

A dynamic style rule now says what kind of thing it reads - a feature's
own property, or a statistic of its geodataset group - so an admin picks
depth_m and avg rather than writing _.stats.depth_m.avg, and a viewer can
switch that statistic from the Layers Tool. Paths already written keep
working.

Also stops a colour picker inside an objectarray item from falling back to
a like-named colour on the layer being edited.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep the stats heading from standing in for the composite one

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Three review fixes to the runtime style panel

Ramp editors are now cached per layer rather than under a key another
layer's name can prefix, a ramp carrying its own colours is drawn and
kept rather than shown as viridis, and the Stat dropdown leads with the
average it actually defaults to.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Key a rule's fields by what they edit, not where they land

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Key a row's components by where they are declared too

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Measure a group statistic over the groups, not the features

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep a view-measured layer coloured when it's off screen

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Read a group statistic filed under a dotted field name

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Ask for a nested statistics field by its whole name

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Show the bin split the map is actually drawing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Colour a group-statistic layer that has no groups measured yet

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.7-20260810 [version bump]

* Stop a switched-off dynamic style from asking for statistics

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Report a group's sum and deviation, and style by them

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Name the new statistics, and let a viewer aim a numeric attribute

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Recompute an old geodataset's statistics from Configure

A geodataset written before field_stats was kept has no dataset-wide numbers for a dynamic style to stretch over, and an append cannot repair that: it merges only the features it appends. A button per row in Configure's Geodatasets tab now rescans the table in one Postgres pass, flattening nested properties to the same dotted paths and over the same numeric guard ingest used.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Halve a rescan's cost, and show a list field inside a repeatable group

The rescan walked every property recursively and evaluated its numeric guard twice; only nesting needs the walk, and jsonb-to-text extraction happens once now. 200k features x 8 properties: 9.6s -> 5.0s, temp spill 174MB -> 63MB.

A comma-separated list field belonging to a repeatable item read from the top level of what was being configured, so it always rendered blank and typing overwrote what was stored.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep a legend label's exponent, and say what a Stat is measured over

A legend shows units once in its title and takes them off the labels, but what counted as units was everything after the digits - so 1.8e10 read as 1.8, smaller than the 1.0 below it. An exponent now belongs to its number.

The Stat row gains an info icon: a group statistic describes the group, which is easy to read as describing the feature.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Re-read the geodataset entries after a rescan

The entries carry each geodataset's statistics, so the copy in hand described the dataset before the rescan until the page was reloaded.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Colour a view-following layer over the view it is built in

The first compile ran before there was a drawn layer to measure what is inside the map bounds, so a layer stretched over the current view was painted with its whole file's spread until the map was nudged. Verified on a framed pair of features: 48.2-76.4 where it used to be 5.1-103.7.

A restyle also no longer compiles over an empty feature set, which would drop the styling a layer already has.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Dress the Layers Tool's dynamic style panel

The Stat explanation is a real tooltip rather than a native title, as are the stats' 'measured over' notes. Section headings sit at the settings' left edge in sentence case, with the dynamic style's reset beside its name and a labelled Add opposite; rules and stat groups are told apart by the indent of what belongs to them, and a rule between the Stats section spans the panel. Dropdowns, the ramp picker and the bin box all share one width.

Also centres the Filters help icon, which its own margin was pushing 6px below the heading it sits in.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Shorten Configure dynamic style rules and keep a restyled globe layer

Property fills the rest of its row (3-7-2 with Stat) and Domain widens to
half, with three descriptions trimmed, so each rule is shorter.

A Cesium restyle no longer removes the globe layer before rebuilding it -
the rebuild reloads it in place, so a failure cannot leave the globe
without a layer that is still on.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drop legend style matching, and keep a domain to what its source says

Legend entries no longer declare styling at all: the deprecated
'Style Matching' field, the conversion button and its module are gone,
since Dynamic Style covers what they did.

Also from review:
- a feature property named __proto__ accumulated field statistics onto
  Object.prototype instead of into the statistics
- Data Min/Max, which Configure only shows for a literal domain, kept
  pinning the scale after the domain source was changed
- 'Nulls' was measured against a stored feature count that a rescan can
  now correct

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep dynamic style honest about its domain, and draw draped polygon edges

Stats now measure what the chosen domain measures, and switching it
rebuilds the readout. A refreshed layer announces its restyle so the
legend and the Layers Tool follow a time change. Vector layers can list
the properties a viewer may aim a rule at. Cesium refuses outlines on
terrain, so a draped polygon's stroke is drawn as a clamped polyline -
an outline-only polygon was invisible on the globe.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Refresh only the stats a restyle changed

Rebuilding the whole dynamic style block on every restyle destroyed the
input being typed into, so a bin count of more than one digit could not
be entered.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Only rebuild the settings block when there are stats to put in it

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* List selectable properties and stats fields separately

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.8-20260811 [version bump]

* Report the domain of every rule, and stats for what each one colours by

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Make Whole dataset / Current view one setting the rules follow

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Author dynamic style rules in Configure and switch them on and off at runtime

A viewer no longer aims a layer at a whitelisted property: the rules an
admin wrote are the vocabulary, each with an On switch they may flip for
their session. A rule aimed at a text field with no mappings written for
it takes them from the values the layer holds, so a categorical field is
styled rather than silently painting nothing.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Draw a repeatable item's checkbox from what was saved, not its default

A stored false is falsy, so a dynamic style rule left switched off came
back switched on when the layer was reopened.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Stop a feature's style becoming the layer's, and repaint when the last rule goes off

A vector layer's style function used layerObj.style as its scratch and left the
last feature's values there, so the next feature and every rebuild started from
them - a dynamicExtent layer refetching after a zoom drew features the rule
doesn't resolve in the last one's colour and opacity.

Switching every rule off now repaints instead of returning early, the Configure
On field is a switch, and a geodataset without stored statistics says so beside
its Domain choice rather than silently scaling over what is loaded.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep a bin's range whole in the legend

A label like "60 - 80" was split into the number 60 and the units "- 80", so
every intermediate bin was labelled with its lower edge alone. A remainder with
a digit in it is a second number, not a unit.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Read a marker's shape and animation from the feature's own style

The style function no longer leaves the feature's style on the layer, so
pointToLayer must take shapeIcon, shapeProp and animation from what it returns
or a point carrying its own shape would be drawn with the layer's.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Say a scale is missing dataset-wide numbers only where they could exist

A group statistic is of the query by design and a categorical rule spans no
range, so neither is short of anything stored - naming them said a recompute
would help when it wouldn't.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Recognise a geodataset url however its scheme is written

Loading a layer lowercases the scheme, so 'GeoDatasets:x' loads - but its
dataset-wide statistics were never fetched and the note that says so was
suppressed for exactly those layers.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Bound a group statistic by its field's whole-dataset numbers

A group average lies inside the field's own extent and a group spread is at
most half of it, so Whole dataset now gives those rules a scale that doesn't
move as you pan. A sum has no such bound and is still measured over the
groups in hand, which the note now says. Adds a Field Statistics modal to
Configure's Geodatasets tab, and pins a domain typed with no source named.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Register the field statistics modal so it opens and closes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Query the globe's own extent at every zoom, and let a fill-less footprint be clicked

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Label a bin boundary with the value it falls at, and keep a rule's style range positional

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Quieter stat rows, a flush rule switch, and a filter property list that catches up with the layer

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Make the bin values legible

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Give the stop bar room above it and a reset that stays put

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Butt the stop reset against the bar and stop bins at 20

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Requery over whichever engine moved last, not always the map

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Square the rule switch and name the stats' domain in place of a tooltip

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Query the globe's real frustum extent instead of a Leaflet zoom estimate

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Store geodataset properties as jsonb, and gather row actions into a menu

New geodataset tables store their properties as jsonb rather than json, so a
property read is a lookup instead of reparsing the document - measured at
11.2s -> 4.2s for a 200k-feature /get with stats= over the whole dataset.
Existing tables keep json until converted from Configure, since the same
operators read either type.

Configure's Geodatasets rows end in a menu holding Field Statistics,
Recompute Statistics and Convert Properties to jsonb, the last disabled for a
geodataset already storing them that way. /entries reports each geodataset's
properties_type.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.9-20260812 [version bump]

* Make a globe click find its feature when a dynamic style is on

A resolved style is not part of a feature's identity, a rule on one style key alone now repaints the globe's points and lines, and a reload no longer leaves a data source detached or hidden.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Make a globe click's highlight show on the polygon it picked

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Highlight the feature that was clicked, and spin while a requery is out

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Pick the footprint the click is on, and keep ids of different kinds apart

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* List what a style a viewer can't change is styling by

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fit a listed rule to its row, and let an admin hide the stats

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Match on a kind of id both features carry

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Give a globe reload's entities ids of their own

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Record nesting only as deep as a rescan reads it

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep the old features up until the new ones are drawn

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Match the retire call as it is written

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Measure a line as the open path it is

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Refuse a pinned end that crosses the discovered one

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Open the globe onto what the map is showing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Leave the rest of the file as it was

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Free a globe data source the reload replaced

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Hold the row a closing menu is still drawing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Clear a highlight the reload's entities left behind

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Draw a configured image legend beside a scale

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Give the dynamic style tests room for the ramp controls

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.10-20260813 [version bump]

* Validate Missions path on getprofile/getbands/getminmax before GDAL

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.5-20260813 [version bump]

* Drop what nothing calls and share what was written twice

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Put the escaper and the legend builder where they belong

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Send the mission-relative raster path to getminmax for image layers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* File a template geodataset's statistics under the name it was stored as

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep a kebab menu opened again during its own close

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Allow operator-allowlisted remote GDAL datasets via GDAL_ALLOWED_REMOTE_PREFIXES

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Pass remote GDAL urls through unaltered and require allowlist entries to name a host

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Trim comments in validateGdalDatasetPath

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Preserve percent signs in Missions paths and match remote prefixes on a boundary

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Clean the empty values inside an objectarray item too

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Make Leaflet a managed dependency at 1.9.4 and document vendored plugins

- add leaflet@^1.9.4, leaflet-geometryutil, leaflet-polylinedecorator to root deps
- import leaflet + leaflet/dist/leaflet.css in src/index.js; window.L unchanged
- delete vendored leaflet 1.5.1/1.9.2 engine builds, vendored proj4, geometryutil, polylineDecorator
- restore L.DomEvent.fakeStop (removed in 1.9) for leaflet.vectorGrid
- keep pre-1.9 popup content font metrics
- README.md recording upstream version + local divergence per vendored plugin

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove unused vendored Leaflet.ImageOverlay.Rotated

Not imported anywhere; drop its attributions entry and README row too.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Make the fakeStop compatibility shim a no-op so map drag still starts on vector tile features

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update Leaflet and GeometryUtil versions in attributions

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Record the local patches that were in the deleted vendored Leaflet copies

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Sync the lockfile version with the merge

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix geodataset temporal bugs: legacy tables, start-only time filters, recreate with new time mappings

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.11-20260820 [version bump]

* Fix globe layers and time histogram handling

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.12-20260824 [version bump]

* fix(deps): pin svgo override to locked version

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Harden security boundaries and content handling

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Preserve Markdown breaks and restore API rate limit

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Allow development WebSocket origin ports

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Run Playwright tests on supported Node version

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Preserve request size rejection status

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Allow large dataset recreation payloads

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix browser security regressions

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Preserve layer control element IDs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* ci: correct checkout pin comments, bump CI Node to 22, harden head_ref use

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.14-20260825 [version bump]

* deps: pin stac-fastapi-api/types/extensions to 6.2.1

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.15-20260825 [version bump]

* Add Broadcast plugin: push-based layer refresh via POST /api/broadcast/layerUpdate

- New core backend plugin plugins/core/backend/Broadcast mounted at /api/broadcast (ensureAdmin + checkMissionPermission)
- Broadcasts notify-only envelope { info: { type: 'refreshLayer', layerName }, body: { mission } }
- Client handles refreshLayer by reloading the layer(s) via TimeControl_.reloadLayer without refetching the config
- New time.refreshViaBroadcast layer flag skips the setInterval polling for push-managed layers
- OpenAPI + docs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.19-20260902 [version bump]

* Drop time.refreshViaBroadcast switch; push refresh applies to any time-enabled layer, polling unchanged

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add Broadcast plugin tests (unit helpers + /api/broadcast/layerUpdate API spec)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Broadcast: resolve display names to UUIDs, refresh non-time layers via Map_.refreshLayer, report broadcasted:false when wss unavailable

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Re-query layers on turn-on when a refreshLayer broadcast arrived while they were off

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.20-20260903 [version bump]

* Make Broadcast plugin non-overridable

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Move broadcast re-query logic from essence.js into L_.requeryLayers (Layers_/lifecycle/refresh.js)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Broadcast: validate body before checkMissionPermission

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Extract websocket connect/retry/routing into WebSocketService; restore refreshLayer default export

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Document which layer types layerUpdate refreshes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.21-20260904 [version bump]

* Security fix for geodatasets filter handling

* chore: bump version to 5.3.22-20260909 [version bump]

* Bound geodatasets in-filter value count/length; extend tests

* Adjust probe values in in-filter test

* Neutralize test naming for geodatasets in-filter spec

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* DrawTool: scope files by mission with showNullMissionFiles/showMasterFiles toggles

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.23-20260910 [version bump]

* Add missions_viewing: restrict mission visibility and config loading per user under AUTH=local

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.23-20260910 [version bump]

* Landing page: show a message when the user has no viewable missions

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* missions_viewing: SuperAdmin-only updates, follow mission renames, MAIN_MISSION guard, and e2e tests

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* User model: only re-hash password when it actually changed

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Enforce missions_viewing on /Missions/<mission>/ static files under AUTH=local

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Static mission file check: authorize msv.missionFolderName folders, cache per-user lookup, document cross-mission note in Users UI

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Viewable-folder cache: generation guard and post-response invalidation on mission mutations

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Return 403 instead of login page for unauthenticated /Missions file requests

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Styled 403 page (with ROOT_PATH home link) for unauthorized file routes; plain 403 for non-browser clients

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "Merge pull request #130 from JPL-Devin/devin/1788999714-missions-viewing"

This reverts commit 79a09e41c3eb9e3cec062a78c2bc70677d6c30db, reversing
changes made to c666775f1188a1ab09e610f85373a72022f57ea2.

* DrawTool: hide Lead Maps header when no master files are listed

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* DrawTool config: label toggle 'Show Lead Files'

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* DrawTool: await mission migration before makeMasterFiles; follow mission renames in user_files

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* DrawTool: read mission visibility toggles server-side from configs; validate mission on make

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.24-20260915 [version bump]

* DrawTool: show file mission in info modal; leads can reassign it

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* DrawTool: leads can reassign file mission even when other file info is read-only

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* DrawTool: interpolate mission markup directly into file in…
…ASA-AMMOS#1088)

* Name the layertype/attachment Configure form 'config', matching tools

* Declare renderer modules by surface, and make the two new families ordinary plugins

`paths` is the tools/interactions concept — a key there is the export name a
mission config names in `"js"`. Layer types were borrowing the field for render
surfaces and attachments for a constant `"plugin"` wrapper, so the same key
meant three things. Layer types now declare `modules` (nested per globe engine
rather than dotted string keys) and attachments a single `module`.

Also aligns the two families with the older four: they carry `uuid`/`id`, they
version as `"core"` like every other plugin shipped here (rejecting anything
that is neither `"core"` nor semver), and they are discovered by `plugin-cli`,
so they list, show `info`, validate and destroy like any plugin — core's stay
undisableable through `overridable: false` rather than through being invisible.
`activate` regenerates their registries too, so installing one no longer needs a
full build.

Removes `settings`: declared in the contract, validated, resolved by the
generator and exposed as `getSettings()`, but no plugin ever used it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix two Devin Review findings: leaked view listener, doubled gradient build

Velocity's fade-on-move subscription lived in `make`, which re-runs on every
show and time change, so listeners accumulated for the life of the page.
Subscribe once per layer; the handler already resolves the current layer.

A path gradient built its Cesium geometry twice on a host's first toggle-on:
core now makes the map layer before the attachment loop, so the loop shows the
attachment, and the deferred globe branch (keyed on hadToMake) showed it again.
Defer only when that loop didn't run.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix attachment op lookup broken by the module key rename

`withOp` still read `mods[id].plugin`, the pre-rename key, so it matched
nothing: an attachment's per-feature ops were never found and clearing a
clicked image overlay or model silently did nothing.

Guard both sides of the generated boundary in a test, since a mismatch here
fails as a no-op rather than an error.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Make the plugin families authorable: docs, scaffolds, capability validation

The code grew config/filter/time surfaces, classification capabilities and a
whole attachment family; the authoring materials still described the render
contract. Closes that gap, and moves the CLI out of /plugins so the directory
is containers only.

- plugin-cli/ at the root: cli.js, lib/, and the six scaffolds as real files
  under scaffolds/<type>/, token-substituted into paths and contents. They are
  valid JSON/JS as they sit, so the validator checks them (a broken scaffold
  fails CI, not someone's first plugin). registries.json is CLI config and
  moves with it; plugin-state.json stays in /plugins, where the server reads it.
- create layerattachment, and a layerattachments README with the contract and a
  worked example — it was the one family with neither.
- Validate classification capabilities against a schema: a wrong type or an
  out-of-enum value is an error, an unknown key warns, and an omission core
  acts on (map.stacking, host.order) warns. These previously failed silently
  as a mis-ordered or un-pickable layer.
- Document the config/filter/time surfaces, drop the capture surface and the
  onHostToggle op (neither has a runtime implementation).
- Destroy a layer's attachments when its host is removed for good.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Document the six plugin families and their scaffolds in the docs site

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Layertype file layout: one path segment per surface

The leaf name repeated what the plugin directory already said (Vector/map/vector.js),
and globe/config.js was not the config surface at all but a helper shared by the two
engine modules, one directory away from the real config/vector.js.

- Vector/map.js, config.js, filter.js, time.js; globe/cesium.js, globe/lithosphere.js.
- globe/config.js → globe/layerConfig.js: it builds the engine-neutral globe layer
  descriptor, which is not the `config` surface.
- Non-surface helpers go in lib/ (VectorTile/lib/SimplifiedVectorGrid.js).
- Drop the export default from the layerConfig helpers; every importer uses the
  named exports.
- Vector/map.js declares its ops as named functions like the other 29 modules
  instead of inline methods.

Paths are convention only — the manifest key is what names a surface — so this is
manifests, imports and regenerated registries.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Show the op phase form in the layertype scaffold

The scaffold's bare `make` is shorthand for `{ main }`, which made Vector's
phase object look like a different contract.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drop the undispatched `load` op; derive the CLI's plugin types from one map

`load` was in LAYER_OPS, the validator, the README table and the scaffold, but
core never dispatched it and no type implemented it: data acquisition (including
the dynamic-extent refetch) is core's, in LayerCapturer. The op table also
omitted `render` and `onToggle`, which core does dispatch.

`create`'s valid types and the discovery directories were two hand-kept lists,
so the help text still advertised five families after layerattachment landed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Serialize the specs that regenerate the shared plugin registries

src/pre/*.js and configure/public/*Configs.json are single copies in the repo,
so a worker running the CLI could regenerate away a fixture another worker had
just written and was asserting on (~1 in 3 runs locally; CI uses one worker).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Declare the lock helper's Atomics globals for ESLint

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix the authoring papercuts eight fresh plugin authors hit

Eight sessions each wrote a plugin against this branch with no help beyond the
docs. Every one of them tripped over the same three things:

- `create layertype` printed "implement make in map/<name>.js", a path that
  stopped existing when the layout collapsed to one segment per surface.
- A scaffolded plugin is untracked, since a container is normally its own repo.
  Said so where `create` is documented, rather than leaving it to git status.
- `plugin-registries.json" is `plugin-cli/registries.json`.

Also: a type that `extends` another was warned for capabilities its parent
declares, and the two layer families' templates weren't linked from the
README's Creating Plugins section that lists the other four.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Document extends where the docs already claimed it was

The plugins README points at this file for extends; this file never mentioned
it. The one child of the eight who was told extends existed found out how it
works by reading LayerTypeRegistry and pluginValidation; the others would not
have found it at all, and a new data source is exactly the case it exists for.

Also states what an expanded sublayer must carry, since that was copied from
Vector's STAC path by guess.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Correct the manifest-reference facts authors checked against reality

Every one of these was found by an author who followed the doc and then found
the code disagreed: icons are MDI, not Ionicons (the scaffold itself emits
`mdi mdi-`); the toolbar priority bands are 1-4/101-102/1001-1002, not
'~1001-1020'; `create` takes six types, not four; and the documented way to run
a plugin's own tests fails in global setup without PLAYWRIGHT_TEST_UNIT_ONLY,
which only appeared in a package.json script.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Enforce interaction applicability; document backend; runnable scaffold tests

`applicableLayerTypes` was descriptive: a mission config, a kind preset or a
type's `defaultInteractions` could name an interaction the interaction says it
doesn't handle. updateInteractions now emits APPLICABLE_LAYER_TYPES and the
runner filters the whole pipeline — preamble and postamble included — against
the layer's type chain, so a type that `extends` vector keeps vector's
interactions.

The scaffolded tests were CommonJS assertions about plugin.json sitting beside
ESM modules, and no npm script ran them. They are now ESM, import the module
where the module can load outside the bundler (attachment, interaction,
backend), check that every declared module path resolves — the usual cause of a
plugin that validates but never appears — and run under `test:plugins:unit`.

Backend was the one family with no contract document: adds
plugins/core/backend/README.md (the `s` object, lifecycle ordering, ensureAdmin's
whitelist, envs, models) and gives the scaffold a deliberate auth gate.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Validate the manifest parts that fail quietly

The Configure form a manifest declares was validated only as "is an object":
Maker skips a component type it cannot render and writes wherever `field`
points, so a typo'd type or an attachment field outside its own `configPath`
yields a form that looks right and configures nothing. validateMetaconfig now
checks component types against what Maker renders, field presence, dropdown
options, objectarray shape, grid widths, and that an attachment only writes
inside its configPath.

`validate` also now reports what it could not see before: an enabled plugin
absent from src/pre (valid, but the app never loads it) and a registry import
whose plugin is gone — both meaning activate has not been run.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add the source, legend, onConfigChange and configPath surfaces

Four surfaces the eight authoring experiments asked for, each replacing
something core was deciding on their behalf:

- layertype `source.fetch(layerObj, ctx) -> GeoJSON`, dispatched from
  LayerCapturer for every acquisition. Core keeps the whole dynamic-extent
  policy (extent, debounce/settling, zoom gate, move threshold, request
  staleness, clear/update, reload subscribers) — extracted out of the two
  duplicated branches into capture/dynamicExtent.js so it is testable — so
  `fetch` is a pure "given this, give me GeoJSON" for data that doesn't come
  out of a url core can fetch (POST bodies, headers, pagination, an SDK).
- layertype `legend.derive(layerObj)`, replacing the
  `['image','tile'].includes(type) && cogTransform || type === 'velocity' ||
  (type === 'data' && shader === 'colorize')` list that both the LayersTool and
  the LegendTool carried. Tile/Image/Velocity/Data now declare the surface.
- attachment `onConfigChange(ctx)`, with `mmgisAPI.setLayerAttachmentConfig()`
  as its dispatcher: core writes the new settings into the host's live config at
  the declared configPath, then lets the attachment retune in place rather than
  paying for the default host rebuild.
- interaction `configPath`, so an interaction with per-layer settings is handed
  its own subtree as `ctx.config` instead of digging through layerData.

Also: `raw` (the engine namespace) on both globe contexts, matching `mctx.raw`;
`test:plugins:unit` no longer fails when no plugin ships a @unit spec.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix the doc and scaffold gaps a second authoring round found

Eight plugins were written against this branch by separate agents; these are
the things they had to guess at or work around.

- The layertypes README still said data acquisition is not in the vocabulary
  and belongs in `make`, 75 lines above the `source` surface that replaced
  that advice.
- `gctx.raw` was absent from the gctx table, which still sent globe authors
  to `gctx.renderer` for engine-specific work. Documented, including that the
  two engines' namespaces are symmetric in name only.
- Globe operations are dispatched by layer *name* (only `make` gets a layer
  object), and `setVisibility`/`setOpacity` take the new value positionally.
  None of that was written down; two authors guessed.
- Nothing said how a layer opts into dynamic extent, so `source.fetch` saw a
  null `ctx.view` with no explanation.
- `legend.derive` is handed config, not data; say so, and where render state
  actually lives, instead of leaving authors to stash on the layer.
- `onConfigChange` is passed the built attachment, which the ctx table
  omitted.
- The layertype scaffold's own test asserted `modules.globe[key]` over
  `Object.keys(renderers.globe)`, so it failed for every globe layertype, and
  it assumed `modules` where a single-module `extends` plugin has `module`.
- The scaffold and the attachment README both captured `window.L` at import
  time, which is undefined in the unit test the same scaffold ships. Read the
  global per call instead, and drop `F_` from the example: importing an MMGIS
  singleton pulls jQuery and makes a module un-importable in Node, which
  browser-globals now states rather than implying it protects against that.
- `create` printed `npm run build` for interactions instead of `activate`,
  said nothing when a name collided with an existing plugin in another family
  (a `Curtain` layertype silently shadowed the `Curtain` tool), and left the
  author to discover that a non-core container is untracked here.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add a textarea component, document the metaconfig, isolate ctx.config

A plugin whose main setting is written in another language — the Overpass QL
query one of the authoring experiments built its layertype around — got a
single-line text field, because every free-text component Maker renders is one.
`textarea` is multiline and monospace, sized by an optional `rows`.

The component vocabulary itself was only discoverable by declaring something
invalid and reading the validator's error, so plugins/README.md now has the
metaconfig section that error was standing in for: what a row is, what each
component field does, and what all 23 component types render.

Also: the interaction runner assigned `ctx.config` only for interactions that
declare a `configPath`, so an interaction without one was handed whichever
config the interaction before it in the pipeline had. It now gets null, which
is also what a layer that was never configured yields — the previous test
asserted the leak, so it changes with it.

For the globe, `gctx.raw` on LithoSphere is the globe class and nothing else
(the package exports one thing), so the README now says where the work actually
happens: `renderer` for the live globe and `window.THREE` for geometry, which
MMGIS vendors and puts on the window. An author looked for THREE on `raw`,
didn't find it, and dropped LithoSphere support. `create layertype` scaffolds a
standalone renderer, which is also now called out as the wrong starting point
for a type that draws like one MMGIS already has: extend it instead.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Give interactions a settings form on their own card

An interaction could be handed `ctx.config` but had no way to declare what
an admin should be able to put there, so its settings existed only as prose
in a README. It now declares `config.rows` in the same metaconfig vocabulary
an attachment uses, and Maker renders those rows on the interaction's card in
a layer's Interactions tab, behind a gear — beside where the interaction was
chosen and dragged, rather than in a tab of its own.

Validation follows from where the form lives: rows need a `configPath` (the
runner has nowhere else to read them back from), every `field` must sit inside
it, and `tab`/`tabs` are rejected. A row's `default` remains a form default —
nothing is written until an admin touches the field — so plugins still default
their own values, which the scaffold and README now spell out.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep an interaction's settings when the layer modal trims a layer

Closing a layer modal rebuilds the layer from the fields its layer type's
tabs declare, which is why attachment config paths were already exempted.
An interaction's settings come from its own manifest, so they were written,
rendered, and then silently dropped on close. Interaction manifests now load
into the store alongside the attachment ones, and their config paths are
preserved by the same pass.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Cover an interaction's settings form end to end

No core interaction has per-layer settings, so the fixture interaction is
served in place of the generated registry — a static JSON file, so no plugin
has to be installed to stand in for one. Fails without the layer modal
preserving interaction-owned config paths.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix what a third-party author trips on: names, tabs, core's own tests

Eight fresh authors built a plugin each against this branch's docs. The
contracts held; the tooling around them did not.

- `create FOVWedges` emitted `f_ovwedges` and `fOVWedges` — an acronym is one
  word now, and so is a word with a digit in it (`HTML5Parser`). The printed
  next-steps filenames derive from the same tokens rather than re-deriving
  them wrongly.
- The layerattachment scaffold defaulted to a Configure tab of its own, the
  exact thing its README warns against; it now joins an existing tab, `validate`
  warns when a non-core attachment's `config.tab` matches no core tab, and the
  next steps say so.
- `create`'s activate diff buried what you just made under every plugin there
  is on a first run; it summarises past 8 lines, `--verbose` to list them.

Two of core's own unit assertions failed for a *valid* third-party attachment:
an exact tab list, and a field walk that descended into `objectarray.object`,
whose item fields are relative and so are not paths under `configPath`. Both
keep their invariant (core's tabs present and in order, no duplicate tabs,
every real field owned by the attachment that reads it) while allowing an
installed plugin, with the third-party case asserted rather than assumed.

`enableWhenField` is `{ field, value }`; given a bare path Configure greys the
control out forever, so validation rejects it now.

Docs, all from things an author had to read core source to learn: an
interactions family README (the `ctx` table, phase/order, settings, and what
can't be unit tested); a `Time` section (the TimeControl surface, and that a
feature's timestamps live at the properties `time.startProp`/`endProp` name);
what the inherited vector renderer does with the features a `source.fetch`
returns, including `prop-<name>` styling; `objectarray`'s relative item fields,
`default` vs `defaultChecked`, and that a cleared number arrives as `""`; what
an attachment's returned object may carry and that `ctx.config` is never null in
`make`; and importing `L_` by alias in a globe module.

Also drops two overwritten no-op `TimeControl.subscribe`/`unsubscribe` stubs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix the cross-family seams a multi-plugin feature runs into

A feature that needs a layer type *and* an attachment *and* an interaction was
buildable but had three ways to fail quietly, all found by building eight of them:

- `pluginDependencies` resolved against tools/backend/components/interactions
  only, so an interaction depending on the layer type it was written for was
  reported enabled, validated clean, and then left out of the generated registry.
  Dependency discovery now covers every family, and the warning says the plugin
  never loads instead of leaving that to be inferred.
- `validate --json` was unparseable whenever the manifest validator logged, since
  it logs JSON lines to stdout. Captured and returned as warnings.
- The module validator accepted `export default { … }` but not the named form the
  scaffolds and docs use, and matched examples inside comments.

Also: `syncData` is handed the attachment's `config` and `layerObj`, as `make`
already was, so a redraw need not stash them; stale-registry validation resolves
the imported module rather than its directory, which is what a type switching to
`extends` deletes; and `applicableLayerTypes`/`defaultInteractions` ids that no
enabled plugin provides are reported.

`create layertype --extends <typeId>` scaffolds what the README recommends and
the best plugins are — a manifest with `extends` and one module whose keys are
surfaces, starting at `source.fetch` — instead of a standalone renderer. The
parent is checked as you type it. Local `install` no longer copies `.git`, and
`--container` pins the container name, which plugin ids embed.

The interaction scaffold's spec tested the handler, which cannot be imported in
Node once it imports a singleton; the decisions now live in `logic.js` and that is
what it tests. Both other scaffolds emit a named default export, so a plugin
following them no longer starts with a lint warning.

Docs: the `_legend` entry shape, one container holding a whole feature and how its
plugins reach each other, attachment time (no op needed — `syncData`), `syncData`'s
context, calling your own backend under a subpath, `kindAlias` being an array,
`NODE_ENV` for eslint, and install/container identity.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Let a layer type declare the attachments it comes with

A feature is often a layer type plus an attachment plus an interaction, and the
type is the plugin that knows what the attachment should be. It could already
declare capabilities.defaultInteractions but had no way to say the same about an
attachment, so three separate plugin authors worked around it the same wrong
way: the type's own config rows wrote a string-literal copy of the attachment's
configPath, coupling two plugins through a path either could rename.

capabilities.defaultAttachments maps attachmentId to that attachment's
settings ({} for none), and core hands it to the attachment under the
attachment's own configPath as if an admin had filled the form in. A layer's own
settings sit on top field by field, so changing one thing doesn't lose the rest
and enabled: false opts out. It resolves through configFor/isEnabledOn, which
everything already went through, so no call site changes.

validate warns for a declared attachmentId no enabled plugin provides, and for
one whose applicableLayerTypes excludes the declaring type - a well-formed
default that would silently never apply.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix what round five's authors hit: extends drops inherited operations, empty form fields beat a type's defaults

extends merged parent and child one level per *surface*, so a type adding a
config.normalize silently lost its parent's config.expand — the opposite of
'declare only what differs'. Merging is now per operation (and per engine
under globe), in a pure typeInheritance module.

A layer's attachment settings also overrode a type's defaultAttachments with
'' — which is what Configure writes into a row nobody filled in — leaving the
attachment with no property name at all. Empty (nullish or '') no longer
overrides; false and 0 still do.

Plus the smaller traps: 'create tool XTool' produced XToolTool; activate
reported 'No changes' after a manifest edit that rewrote a registry;
ToolController_.getTool returned a silent no-op stub for a tool that isn't
loaded; the backend scaffold had no models/ despite its README documenting
one; and the docs said nothing about interaction-to-tool calls, stopGuests
being mount-wide and status-blind, failure responses arriving as HTTP 200,
layer types resolving defaultIcon against MUI rather than MDI, or how
L_.layers.attachments is keyed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* A layer type can configure the interactions it ships, not just name them

capabilities.defaultInteractions took interaction ids only, so a type that
shipped an interaction had no way to tell it the property names it had just
fetched — the gap defaultAttachments closed for the other family, and four of
round five's authors worked around it by duplicating those names into a third
subtree or stashing them on the layer under a private key.

An event may now be an object instead of a list:

    "click": { "wind:report": { "speedProp": "windSpeed" } }

Key order is the pipeline order, as the array's is; the settings resolve into
the interaction's own configPath on the way to ctx.config, with the layer's own
settings over them field by field. An interaction is written once and cannot
tell whether a type configured it or an admin did.

The merge rules are now shared with attachments (declaredConfig), validate
cross-checks both forms — including a declared interaction whose
applicableLayerTypes excludes the declaring type — and VectorTile's click, which
had been passing no layerTypeChain, now enforces applicability like every other
click path.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* A mission with a layer of a plugin-provided type can now be saved

Round six's authors could author, validate, activate and register a new
layer type and still not configure a layer with it: both config validators
switched on the layer's type and called anything they did not recognise
unknown. They ask the generated layer-type registry instead, and hold a
plugin type only to the checks it declares for itself (a `source`-backed
type may have no url) — while a type extending a built-in still gets its
parent's field defaults, since it is drawn by the parent's renderer.

Also from that round: a LithoSphere type that registers itself in
gctx.layers now has its destroy/setVisibility/setOpacity dispatched rather
than declared, validated and never called; a type that declares a map
renderer and resolves none says so instead of loading and drawing nothing;
and window.location exists in the unit-test browser globals, so a source
can resolve a relative url under test.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* A plugin can ask for its layer back, and a type can say when it has data

Two surfaces round six asked for, both of which existed as core internals a
plugin had to reach into.

`ctx.refreshLayer()`, on every interaction and attachment: an interaction that
writes a feature to its own backend had to call
`L_.Map_.refreshLayer(layerObj, cb, skipOrderedBringToFront, stopLoops,
resolvedUrl)` to see its own write. The re-acquisition goes through the same
path every other trigger does, and a `source`-backed type now sees it as
`ctx.trigger === 'refresh'` rather than as another `'make'`.

`time.availability(layerObj, ctx)`: `capabilities.time.histogram` validated,
reported true, and meant nothing — TimeUI found histogram sources by matching
`stac-collection:` and `{t}` against the layer's url, so any other endpoint was
silently excluded. The type answers now, with times and counts; core keeps the
window, the binning and the drawing. Tile's two url schemes move into its time
module, and declaring the capability without a `modules.time` to implement it in
is a validation error.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* A refreshed layer's checkbox no longer shows it turned off

Turning the Reference Mission's geodataset time-series layer on and then
moving the time bar left the Layers tool showing it off, while it was on and
drawn.

The refresh swap hides the old layer and shows the rebuilt one with
`ignoreToggleStateChange`, so `L_.layers.on` deliberately never changes \u2014 and
both halves then compute the state to broadcast from that unchanged value and
notify subscribers 'off' twice. Subscribers are what track on-ness (the
checkbox; a dynamic-extent layer's re-query), so a toggle that preserves state
now tells them nothing.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Plugin system: inherited ops, source cancellation/paging, dynamic dropdown options

The four remaining findings from the round-six plugin authoring sessions.

extends: a child's operation is handed the inherited implementation as one
extra last argument, so overriding vector's `config.normalize` to add a field
no longer silently drops the `kind`/`radius` it sets. Phases match by name, a
bare function stays a bare function, and `inherited()` is a safe no-op when the
parent has no such operation.

source.fetch: `ctx.signal` (a layer's next acquisition aborts its last, and an
AbortError is not logged as a failure), `ctx.emit(geojson)` for a paged source
to draw what it has so far, and `ctx.resolveUrl` so a plugin with an endpoint in
its own config doesn't hand-roll mission/ROOT_PATH resolution. Core keeps the
extent, staleness, move threshold and rendering; an emit after the request is
stale is dropped.

Maker: a dropdown may name an `optionsFrom` provider instead of listing options
— `layerProperties` (the layer's own feature property names, from the geodataset
schema or by sampling the file), `layers`, `layerTypes` — so "pick a property of
this layer's data" stops being an unchecked text field. Validated against the
provider names Configure actually has.

Docs: a components family README (when init runs and what exists by then, the
z-index bands, the subscriptions, and why there is no core interaction →
component channel), and why composing across *layers* is unsupported — with the
supportable shape being one layer whose source.fetch acquires both inputs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Plugin system: a third-party layer type is selectable, and duplicate ids fail validate

The layer modal's Layer Type control is a row in the *selected* type's own
manifest, and every core manifest listed the eleven built-in ids literally — so
a plugin type could be authored, validated, activated and registered, and then
never be chosen by an admin. The row asks the registry instead
(`optionsFrom: "layerTypes"`), which is also the only list that stays correct.

Two owners of one `typeId`/`attachmentId` are refused by registry generation but
were reported by `validate` as clean, and a failed generation leaves the previous
registries in place — a green check followed by a build failure, or an app
silently running the last generation. `validate` now reports them, as it already
did for `interactionId`.

Docs: the inherited-op example counted `inherited` from the wrong position
(`config.normalize` is dispatched with one argument, not two), and the per-family
`config` shape (a layer type declares `tabs`, everyone else `rows`) was stated
for interactions only.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Plugin system: acquire another layer's data, reference a layer's fields from a type's declarations, and drop only the offender on activate

- `ctx.acquire(layerName)` on source/interaction/attachment contexts: a
  configured layer's data, acquired through its own layer type, headlessly —
  not turned on, not drawn, no rendered state exposed, and the layer's own live
  acquisition untouched.
- A type's `defaultAttachments`/`defaultInteractions` values may be
  `"$variables.…"` references read off the host layer, so the property an admin
  picks on the type's form isn't typed again on the other plugins'. An
  unanswerable path drops its key, leaving the plugin its own default; `$$`
  escapes a literal `$`.
- `activate` leaves a duplicate-id or bad-`extends` plugin out of the registry
  and regenerates everything else, rather than aborting and silently leaving
  the previous generation of every registry in place.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Do not collect plugin-cli scaffold templates as tests

`npx playwright test` (no path argument) matched
plugin-cli/scaffolds/*/tests/*.spec.js, which are templates a plugin is
created from: their `__name__` placeholders and relative helper paths
only resolve once copied into a plugin directory, so collection failed
with six 'Cannot find module' errors before any test ran.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add geodataset statistics: per-group stats= and dataset-wide field_stats

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.5-20260806 [version bump]

* Restore CRLF line endings in mmgis-openapi.json

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Assert geodataset statistics end to end against a purpose-built geodataset

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Address review: reject digit-prefixed text as numeric, don't half-summarize on append

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Compute field_stats when an append creates the geodataset

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Address review: keep out-of-range numbers out of the cast, lock field_stats merges

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add nullCount and stddev to dataset-wide field_stats

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add dynamicStyle resolver: map a feature property to a style attribute

Pure core of dynamic vector styling. A layer's variables.dynamicStyle
declares a property, an attribute, and how to get from one to the other
(colour ramp, numeric range, or value table); this compiles that into a
function of a feature's properties, once per layer rather than per
feature.

Knows nothing about Leaflet, the globe, or geodatasets — a geodataset's
stored field_stats is only one of the places a numeric domain can come
from, alongside the loaded features and an avg +/- sigma window.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Style 2D vector features from variables.dynamicStyle; drop styleMatching

The legend no longer assigns styles. The ~155-line styleMatching engine
in the per-feature style function is removed in favour of the compiled
resolver, which also means a layer's ramp and domain are worked out once
instead of being regrouped and rebuilt for every feature.

Precedence is unchanged in shape: feature.properties.style still wins,
and a dynamic style now sits between it and the *Prop fields it
generalizes. Layers that request only some of their properties ask for
the ones their rules style by.

Configs carrying styleMatching entries keep loading; those entries now
only describe the legend. Commit 6 adds the one-click conversion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Reach 3D: resolve the dynamic style onto the globe's GeoJSON clone

A styleMatching layer looked right in 2D and default-coloured on the
globe, because those colours were only ever Leaflet options. The globe
styles from feature.properties.style, so toGlobeConfig writes the
resolved values there — onto its own clone, leaving the map's features
(what Draw and export read) alone.

It reuses the resolver 2D compiled, so a domain measured over the loaded
features is the same domain in both views rather than being re-measured
from whatever the clone happens to hold.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Draw the dynamic style in the Legend, from the same compiled rules

The legend reads variables.dynamicStyle rather than a materialized
_legend, and every swatch is coloured by asking the layer's own resolver
what a value looks like — so the legend can't drift from the map, and it
shows the domain actually in use (avg +/- 2 sigma, a geodataset's
whole-dataset extent) instead of a second reading of the config.

Each scale is titled with the property it describes; a rule that drives
weight or radius has nothing a swatch can show and is left out. A layer
that also has configured entries keeps them, with its scale after.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Author the dynamic style in the vector Style tab, and convert legacy legend styling into it

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Let a viewer restyle a dynamic layer, and stretch its scale over the view

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Pick the ramp by its colours, and let the bins be dragged

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add the Reference Mission's dynamic style layer data

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Address review: escape property names, keep the legend read-only, let a failed stats fetch retry

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Address review: the whole-dataset toggle keeps a rule's configured domain source

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Layers Tool: attribute picker, aligned ramp rows, user-settable gate, visual ramp picker in Configure

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Dynamic style: reset to the configured style, rule-backed properties only, and review fixes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Dynamic style: legend follows automatic restyles, and its heading is no longer the composite sentinel

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Hide the Dynamic Style fields a rule has made irrelevant

Maker gains `showIf`, a condition (or array of them) naming a sibling
field and what it must read, and `subheading`, a section header. The
Dynamic Style rule uses both: Property/Values Are, then Defaults, Scale,
No Data and Categorical Mappings - with Sigma only under a mean-sigma
domain, the ramp only for a colour attribute, bins only when discrete,
and the style range only for weight/radius/opacity.

Two review findings, both real:

- In 2D, a feature carrying any `style` of its own skipped dynamic
  styling entirely, so `{radius: 12}` also dropped its fill colour. It
  now merges key by key under the feature's style, as the globe does.

- Converting a legend spaced its colours evenly, so entries at 0, 10 and
  100 rendered as 0%, 50% and 100%. Stops now carry their position, and
  the domain comes from the entries that contributed a colour.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Let a fieldless component sit inside an objectarray

An objectarray read every declared component's field to seed a new item
and to fill the control, so a subheading - which edits nothing and has
no field - took the whole Configure page down with it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Offer only the styles a rule can still produce

A value table of colours compiles to nothing when aimed at weight or
radius, so the layer quietly lost its colouring and its legend. The
runtime picker now offers a categorical rule only the family its
mappings hold.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep a single feature's stats about its group

The window aggregates run after the WHERE clause, so asking for one
feature by id left it alone in its own partition, reporting its value as
the group's min, max and average. The id restriction now waits outside
the query the group is aggregated in.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Sort out three Dynamic Style and statistics snags

A deduplicated request for one feature with statistics matched nothing:
the id was filtered outside the query, and DISTINCT ON had already kept
only its group's highest id. It now narrows the query to the feature's
group instead, which also saves scanning the table.

An objectarray item's field is relative to its item, so an unset one was
reading a like-named key of the layer - a new rule's Values Are showed
the layer's own 'vector'.

Values Are now sits below No Data, so choosing categorical reveals the
mappings directly beneath it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Number objectarray items, and mind the groupless feature

A feature with no group value was matched with = against a null, so
asking for one with statistics returned nothing at all; null groups are
one group to PARTITION BY, so the comparison is now null-safe too.

The searchdropdown was the one case left reading the enclosing config
for an unset objectarray field.

Items of an objectarray look alike, so each now wears its number.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.6-20260807 [version bump]

* Keep the ramp list with its picker, and unmount a ramp that has gone

A portalled list was placed against the viewport once, at opening, so
scrolling the settings pane it was opened in left it behind. It now
measures again while it is open.

A rule that stops wanting a ramp leaves no mount for one, and the stale
root was only let go of when a new mount was found.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Style by a group's statistics, and by as many rules at runtime as configured

A rule aimed at _.stats.depth_m.avg now asks the geodataset for depth_m's
per-group statistics itself, so a feature can be styled by its group rather
than only by itself; an admin can name further fields to summarise.

The LayersTool panel exposes every rule rather than the first, with rules
addable and removable for the session, reports what the layer knows about
the numbers it styles by, and resets from an icon beside its heading - as
the layer's own settings reset now does too.

A rule that widens lines or fades them is drawn in the legend as lines of
that weight or swatches at that opacity, rather than being left out.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep a rule's number span when it is re-aimed at another style

Patching range with undefined erased it, so switching a weight rule to
radius left it mapping through nothing and it stopped styling at all.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Style by a group statistic without typing its path

A dynamic style rule now says what kind of thing it reads - a feature's
own property, or a statistic of its geodataset group - so an admin picks
depth_m and avg rather than writing _.stats.depth_m.avg, and a viewer can
switch that statistic from the Layers Tool. Paths already written keep
working.

Also stops a colour picker inside an objectarray item from falling back to
a like-named colour on the layer being edited.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep the stats heading from standing in for the composite one

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Three review fixes to the runtime style panel

Ramp editors are now cached per layer rather than under a key another
layer's name can prefix, a ramp carrying its own colours is drawn and
kept rather than shown as viridis, and the Stat dropdown leads with the
average it actually defaults to.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Key a rule's fields by what they edit, not where they land

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Key a row's components by where they are declared too

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Measure a group statistic over the groups, not the features

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep a view-measured layer coloured when it's off screen

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Read a group statistic filed under a dotted field name

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Ask for a nested statistics field by its whole name

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Show the bin split the map is actually drawing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Colour a group-statistic layer that has no groups measured yet

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.7-20260810 [version bump]

* Stop a switched-off dynamic style from asking for statistics

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Report a group's sum and deviation, and style by them

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Name the new statistics, and let a viewer aim a numeric attribute

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Recompute an old geodataset's statistics from Configure

A geodataset written before field_stats was kept has no dataset-wide numbers for a dynamic style to stretch over, and an append cannot repair that: it merges only the features it appends. A button per row in Configure's Geodatasets tab now rescans the table in one Postgres pass, flattening nested properties to the same dotted paths and over the same numeric guard ingest used.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Halve a rescan's cost, and show a list field inside a repeatable group

The rescan walked every property recursively and evaluated its numeric guard twice; only nesting needs the walk, and jsonb-to-text extraction happens once now. 200k features x 8 properties: 9.6s -> 5.0s, temp spill 174MB -> 63MB.

A comma-separated list field belonging to a repeatable item read from the top level of what was being configured, so it always rendered blank and typing overwrote what was stored.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep a legend label's exponent, and say what a Stat is measured over

A legend shows units once in its title and takes them off the labels, but what counted as units was everything after the digits - so 1.8e10 read as 1.8, smaller than the 1.0 below it. An exponent now belongs to its number.

The Stat row gains an info icon: a group statistic describes the group, which is easy to read as describing the feature.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Re-read the geodataset entries after a rescan

The entries carry each geodataset's statistics, so the copy in hand described the dataset before the rescan until the page was reloaded.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Colour a view-following layer over the view it is built in

The first compile ran before there was a drawn layer to measure what is inside the map bounds, so a layer stretched over the current view was painted with its whole file's spread until the map was nudged. Verified on a framed pair of features: 48.2-76.4 where it used to be 5.1-103.7.

A restyle also no longer compiles over an empty feature set, which would drop the styling a layer already has.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Dress the Layers Tool's dynamic style panel

The Stat explanation is a real tooltip rather than a native title, as are the stats' 'measured over' notes. Section headings sit at the settings' left edge in sentence case, with the dynamic style's reset beside its name and a labelled Add opposite; rules and stat groups are told apart by the indent of what belongs to them, and a rule between the Stats section spans the panel. Dropdowns, the ramp picker and the bin box all share one width.

Also centres the Filters help icon, which its own margin was pushing 6px below the heading it sits in.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Shorten Configure dynamic style rules and keep a restyled globe layer

Property fills the rest of its row (3-7-2 with Stat) and Domain widens to
half, with three descriptions trimmed, so each rule is shorter.

A Cesium restyle no longer removes the globe layer before rebuilding it -
the rebuild reloads it in place, so a failure cannot leave the globe
without a layer that is still on.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drop legend style matching, and keep a domain to what its source says

Legend entries no longer declare styling at all: the deprecated
'Style Matching' field, the conversion button and its module are gone,
since Dynamic Style covers what they did.

Also from review:
- a feature property named __proto__ accumulated field statistics onto
  Object.prototype instead of into the statistics
- Data Min/Max, which Configure only shows for a literal domain, kept
  pinning the scale after the domain source was changed
- 'Nulls' was measured against a stored feature count that a rescan can
  now correct

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep dynamic style honest about its domain, and draw draped polygon edges

Stats now measure what the chosen domain measures, and switching it
rebuilds the readout. A refreshed layer announces its restyle so the
legend and the Layers Tool follow a time change. Vector layers can list
the properties a viewer may aim a rule at. Cesium refuses outlines on
terrain, so a draped polygon's stroke is drawn as a clamped polyline -
an outline-only polygon was invisible on the globe.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Refresh only the stats a restyle changed

Rebuilding the whole dynamic style block on every restyle destroyed the
input being typed into, so a bin count of more than one digit could not
be entered.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Only rebuild the settings block when there are stats to put in it

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* List selectable properties and stats fields separately

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.8-20260811 [version bump]

* Report the domain of every rule, and stats for what each one colours by

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Make Whole dataset / Current view one setting the rules follow

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Author dynamic style rules in Configure and switch them on and off at runtime

A viewer no longer aims a layer at a whitelisted property: the rules an
admin wrote are the vocabulary, each with an On switch they may flip for
their session. A rule aimed at a text field with no mappings written for
it takes them from the values the layer holds, so a categorical field is
styled rather than silently painting nothing.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Draw a repeatable item's checkbox from what was saved, not its default

A stored false is falsy, so a dynamic style rule left switched off came
back switched on when the layer was reopened.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Stop a feature's style becoming the layer's, and repaint when the last rule goes off

A vector layer's style function used layerObj.style as its scratch and left the
last feature's values there, so the next feature and every rebuild started from
them - a dynamicExtent layer refetching after a zoom drew features the rule
doesn't resolve in the last one's colour and opacity.

Switching every rule off now repaints instead of returning early, the Configure
On field is a switch, and a geodataset without stored statistics says so beside
its Domain choice rather than silently scaling over what is loaded.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep a bin's range whole in the legend

A label like "60 - 80" was split into the number 60 and the units "- 80", so
every intermediate bin was labelled with its lower edge alone. A remainder with
a digit in it is a second number, not a unit.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Read a marker's shape and animation from the feature's own style

The style function no longer leaves the feature's style on the layer, so
pointToLayer must take shapeIcon, shapeProp and animation from what it returns
or a point carrying its own shape would be drawn with the layer's.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Say a scale is missing dataset-wide numbers only where they could exist

A group statistic is of the query by design and a categorical rule spans no
range, so neither is short of anything stored - naming them said a recompute
would help when it wouldn't.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Recognise a geodataset url however its scheme is written

Loading a layer lowercases the scheme, so 'GeoDatasets:x' loads - but its
dataset-wide statistics were never fetched and the note that says so was
suppressed for exactly those layers.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Bound a group statistic by its field's whole-dataset numbers

A group average lies inside the field's own extent and a group spread is at
most half of it, so Whole dataset now gives those rules a scale that doesn't
move as you pan. A sum has no such bound and is still measured over the
groups in hand, which the note now says. Adds a Field Statistics modal to
Configure's Geodatasets tab, and pins a domain typed with no source named.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Register the field statistics modal so it opens and closes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Query the globe's own extent at every zoom, and let a fill-less footprint be clicked

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Label a bin boundary with the value it falls at, and keep a rule's style range positional

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Quieter stat rows, a flush rule switch, and a filter property list that catches up with the layer

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Make the bin values legible

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Give the stop bar room above it and a reset that stays put

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Butt the stop reset against the bar and stop bins at 20

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Requery over whichever engine moved last, not always the map

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Square the rule switch and name the stats' domain in place of a tooltip

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Query the globe's real frustum extent instead of a Leaflet zoom estimate

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Store geodataset properties as jsonb, and gather row actions into a menu

New geodataset tables store their properties as jsonb rather than json, so a
property read is a lookup instead of reparsing the document - measured at
11.2s -> 4.2s for a 200k-feature /get with stats= over the whole dataset.
Existing tables keep json until converted from Configure, since the same
operators read either type.

Configure's Geodatasets rows end in a menu holding Field Statistics,
Recompute Statistics and Convert Properties to jsonb, the last disabled for a
geodataset already storing them that way. /entries reports each geodataset's
properties_type.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.9-20260812 [version bump]

* Make a globe click find its feature when a dynamic style is on

A resolved style is not part of a feature's identity, a rule on one style key alone now repaints the globe's points and lines, and a reload no longer leaves a data source detached or hidden.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Make a globe click's highlight show on the polygon it picked

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Highlight the feature that was clicked, and spin while a requery is out

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Pick the footprint the click is on, and keep ids of different kinds apart

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* List what a style a viewer can't change is styling by

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fit a listed rule to its row, and let an admin hide the stats

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Match on a kind of id both features carry

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Give a globe reload's entities ids of their own

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Record nesting only as deep as a rescan reads it

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep the old features up until the new ones are drawn

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Match the retire call as it is written

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Measure a line as the open path it is

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Refuse a pinned end that crosses the discovered one

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Open the globe onto what the map is showing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Leave the rest of the file as it was

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Free a globe data source the reload replaced

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Hold the row a closing menu is still drawing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Clear a highlight the reload's entities left behind

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Draw a configured image legend beside a scale

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Give the dynamic style tests room for the ramp controls

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.10-20260813 [version bump]

* Validate Missions path on getprofile/getbands/getminmax before GDAL

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.5-20260813 [version bump]

* Drop what nothing calls and share what was written twice

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Put the escaper and the legend builder where they belong

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Send the mission-relative raster path to getminmax for image layers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* File a template geodataset's statistics under the name it was stored as

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Keep a kebab menu opened again during its own close

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Allow operator-allowlisted remote GDAL datasets via GDAL_ALLOWED_REMOTE_PREFIXES

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Pass remote GDAL urls through unaltered and require allowlist entries to name a host

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Trim comments in validateGdalDatasetPath

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Preserve percent signs in Missions paths and match remote prefixes on a boundary

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Clean the empty values inside an objectarray item too

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Make Leaflet a managed dependency at 1.9.4 and document vendored plugins

- add leaflet@^1.9.4, leaflet-geometryutil, leaflet-polylinedecorator to root deps
- import leaflet + leaflet/dist/leaflet.css in src/index.js; window.L unchanged
- delete vendored leaflet 1.5.1/1.9.2 engine builds, vendored proj4, geometryutil, polylineDecorator
- restore L.DomEvent.fakeStop (removed in 1.9) for leaflet.vectorGrid
- keep pre-1.9 popup content font metrics
- README.md recording upstream version + local divergence per vendored plugin

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove unused vendored Leaflet.ImageOverlay.Rotated

Not imported anywhere; drop its attributions entry and README row too.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Make the fakeStop compatibility shim a no-op so map drag still starts on vector tile features

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update Leaflet and GeometryUtil versions in attributions

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Record the local patches that were in the deleted vendored Leaflet copies

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Sync the lockfile version with the merge

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix geodataset temporal bugs: legacy tables, start-only time filters, recreate with new time mappings

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.11-20260820 [version bump]

* Fix globe layers and time histogram handling

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.12-20260824 [version bump]

* fix(deps): pin svgo override to locked version

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Harden security boundaries and content handling

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Preserve Markdown breaks and restore API rate limit

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Allow development WebSocket origin ports

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Run Playwright tests on supported Node version

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Preserve request size rejection status

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Allow large dataset recreation payloads

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix browser security regressions

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Preserve layer control element IDs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* ci: correct checkout pin comments, bump CI Node to 22, harden head_ref use

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.14-20260825 [version bump]

* deps: pin stac-fastapi-api/types/extensions to 6.2.1

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.15-20260825 [version bump]

* Add Broadcast plugin: push-based layer refresh via POST /api/broadcast/layerUpdate

- New core backend plugin plugins/core/backend/Broadcast mounted at /api/broadcast (ensureAdmin + checkMissionPermission)
- Broadcasts notify-only envelope { info: { type: 'refreshLayer', layerName }, body: { mission } }
- Client handles refreshLayer by reloading the layer(s) via TimeControl_.reloadLayer without refetching the config
- New time.refreshViaBroadcast layer flag skips the setInterval polling for push-managed layers
- OpenAPI + docs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.19-20260902 [version bump]

* Drop time.refreshViaBroadcast switch; push refresh applies to any time-enabled layer, polling unchanged

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add Broadcast plugin tests (unit helpers + /api/broadcast/layerUpdate API spec)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Broadcast: resolve display names to UUIDs, refresh non-time layers via Map_.refreshLayer, report broadcasted:false when wss unavailable

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add on-map attribution dropdown gated by look.showAttributionsOnMap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.19-20260902 [version bump]

* Re-query layers on turn-on when a refreshLayer broadcast arrived while they were off

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.20-20260903 [version bump]

* Make Broadcast plugin non-overridable

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Move broadcast re-query logic from essence.js into L_.requeryLayers (Layers_/lifecycle/refresh.js)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Broadcast: validate body before checkMissionPermission

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Extract websocket connect/retry/routing into WebSocketService; restore refreshLayer default export

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Document which layer types layerUpdate refreshes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.21-20260904 [version bump]

* Security fix for geodatasets filter handling

* chore: bump version to 5.3.22-20260909 [version bump]

* Bound geodatasets in-filter value count/length; extend tests

* Adjust probe values in in-filter test

* Neutralize test naming for geodatasets in-filter spec

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add missions_viewing: restrict mission visibility and config loading per user under AUTH=local

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.3.23-20260910 [version bump]

* Landing page: show a message when the user has no viewable missions

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* missions_viewing: SuperAdmin-only updates, follow mission renames, MAIN_MISSION guard, and e2e tests

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* User model: only re-hash password when it actually changed

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Enforce missions_viewing on /Missions/<mission>/ static files under AUTH=local

Co-Authored-By: tariq.k…
…AMMOS#1017)

* Add custom GDAL color table support for COG layers via TiTiler

Adds a cogColormapJson layer config field ("Custom Color Table (GDAL)",
in its own section of the COG tab) that is passed through to TiTiler's
`colormap` query parameter. Accepts either a discrete value-to-RGBA map
(e.g. {"1":[10,40,85,255]}) or a list of [[min,max],RGBA] intervals,
letting classified rasters (land cover, sea-ice type, etc.) be colored
by raw pixel value.

Behavior:
- The color table supersedes the named Colormap (colormap_name), and
  the Colormap dropdown disables itself with an explanatory tooltip
  when both a color table and the COG transform are set (via a new
  generic `disableWhen` hook in the configure app's Maker)
- rescale is suppressed when a color table is set: TiTiler applies
  rescale before the colormap lookup, which would remap raw values
  away from the table's keys
- A discrete legend (one labeled color chip per class or interval,
  suffixed with cogUnits) is generated automatically through the
  LegendTool/populateCogScale path, with or without the COG transform
  enabled; fully transparent entries (e.g. nodata) are skipped
- The layer settings panel renders the color table as a swatch strip
  in place of the named-colormap preview image

* Add Custom Color Table (GDAL) demo layer to Reference-Mission blueprint

Adds a "USGS SF DEM — Custom Color Table (GDAL)" tile layer under
Raster Layers → TiTiler Tile Layers that showcases the cogColormapJson
feature: the SF DEM served as a direct COG via TiTiler and colored by
an 8-band interval color table (blue sea level → green → tan → white
peaks), with rescale suppressed and a discrete legend generated
automatically. Off by default; needs WITH_TITILER only (no STAC).

* chore: bump version to 5.4.1-20260916 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tariq Soliman <Tariq.K.Soliman@jpl.nasa.gov>
…lAbsolute

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Author

I'll fix CI failures and address comments from users with write access that start with 'DevinAI' or '@devin'.

  • Disable automatic comment, CI, and merge conflict monitoring

Original prompt from tariq.k.soliman

In MMGIS, a user is trying to set up a Tile layer with sourcetype: COG, and an s3 url.

They said it didn't work and that they had to instead point a full https:///mmgis/titiler/cog?url=s3://... url

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

✅ Version Already Updated

This PR includes a manual version update to 5.4.2-20260922

No automatic version bump needed.

@devin-ai-integration

Copy link
Copy Markdown
Author

Recorded browser verification in a local CogTest mission (Configure edits + mission reloads, actual network requests inspected).

COG URL handling — verified requests
  • Passed: 16 S3 COG tile requests retain url=s3://my-bucket/path/file.tif, no mission prefix.
  • Passed: Configure's cog/info request also retains the raw S3 URL.
  • Passed (regression): foo.tif still resolves to ../../Missions/CogTest/foo.tif.
  • Passed (regression): https://example.com/cog.tif unchanged.
  • Passed (regression): ordinary URL/WMTS tiles return 200 and render.
Configured S3 source Actual browser request
Configured S3 source S3 query without mission prefix

COG 403s were expected (no real S3 object); this verifies URL generation, not raster rendering.

Incidental Configure issue (not from this PR)

"Populate Fields" on a 403 metadata response shows "Successfully populated…" and clears the three required zoom fields.

False success and empty required fields

@tariqksoliman
tariqksoliman marked this pull request as ready for review September 24, 2026 19:28
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 5 commits September 24, 2026 19:37
…source

Globe layer config passed the raw COG source (s3://..., ../../Missions/...)
to Cesium/LithoSphere instead of the /titiler/cog/tiles endpoint the 2D map
builds. Extract buildCogTileUrl into Tile/cog.js and use it from both, and
encode the source as a single url= value (also in the Configure cog/info and
Identifier cog/point requests) so sources with their own query strings
(?versionId=...&...) survive.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
@github-actions

Copy link
Copy Markdown

🤖 Version Auto-Bumped

The version has been automatically incremented to 5.4.3-20260925

This commit was added to your PR branch. When you merge this PR, the new version will be included.


If you want a different version, update package.json manually and push to this PR.

…4801-cog-s3-scheme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

# Conflicts:
#	configure/package.json
#	package.json

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 8 new potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +1543 to +1546
Userfiles.update(
{ mission: null },
{ where: { mission: missionName }, transaction: t }
),

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Deleted mission drawings reappear everywhere

Deleting a mission sets its drawings' mission to null. getfiles includes null-mission drawings by default, so they appear in every remaining mission.

Learn more

Draw files are retained after mission deletion, but clearing their mission changes their meaning. getfiles treats null as a legacy, unscoped file and includes it whenever the mission has not explicitly disabled showNullMissionFiles. A normal deletion therefore broadens the file's display scope to all remaining missions.

Example: A drawing made in mission A has mission: 'A'. Delete A while missions B and C use default Draw settings; that drawing now appears in both B and C.

Recommended fix: Preserve a non-null tombstone mission association for retained files or explicitly archive/delete associated drawings when destroying a mission. Keep null reserved for genuinely legacy unscoped drawings.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +41 to +46
onceSynced: async (s) => {
if (typeof file_histories.up === "function") {
file_histories.up();
}
if (typeof ufiles.up === "function") {
ufiles.up();
await ufiles.up();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Draw requests race the mission migration

On upgrade, onceSynced starts the mission-column migration without blocking requests. setups.synced discards its promise, so early Draw requests fail before the column exists.

Learn more

The Draw model now queries mission on normal file reads and writes. setups.synced invokes lifecycle hooks without awaiting their return values, while server startup opens the HTTP listener independently of the Sequelize sync/migration chain. Thus await ufiles.up() only orders statements inside the Draw hook; it does not prevent HTTP requests from seeing the old schema.

Example: Restart an upgraded installation with an existing user_files table lacking mission. A user opens Draw immediately; getfiles queries the new model before ALTER TABLE finishes and receives a database error.

Recommended fix: Await all onceSynced promises and complete migrations before accepting file requests. Ensure migration failures block readiness rather than being silently logged and ignored.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1430 to +1434
if (DrawTool.filesOn.indexOf(parseInt(fileId)) !== -1)
DrawTool.toggleFile(parseInt(fileId), 'off', true)
DrawTool.getFiles(function () {
DrawTool.populateFiles()
})

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Moved drawing stays selected for edits

When a lead moves an inactive-on-map drawing, currentFileId remains selected after getFiles removes it. New drawings can still target the moved file from the old mission.

Learn more

The move handler clears the on-map file only if its ID occurs in filesOn. The active drawing selection instead lives in currentFileId, and getFiles replaces the visible list without clearing that selection. After a moved file disappears from the list, drawing actions can continue using its ID.

Example: A lead selects file 7 in mission A, toggles its map visibility off, then moves it to mission B. File 7 vanishes from A's list but stays selected as the target of new drawings.

Recommended fix: If the moved file equals currentFileId, clear the active drawing selection regardless of map visibility. Apply this to both save handlers and reload the list after the selection is reset.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +546 to +561
if (isLead && req.body.mission != null && req.body.mission !== "") {
let vars = null;
try {
vars = await getDrawToolVars(req.body.mission);
} catch (err) {
logger("error", "Failed to edit file.", req.originalUrl, req, err);
}
if (vars == null) {
res.send({
status: "failure",
message: "Failed to edit file. Unknown mission.",
body: {},
});
return;
}
toUpdateTo.mission = req.body.mission;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Leads can move private drawings across missions

A lead can submit any drawing ID and destination mission to files/change, even without access to the drawing or either mission. The file becomes visible in the destination mission.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1543 to +1546
Userfiles.update(
{ mission: null },
{ where: { mission: missionName }, transaction: t }
),

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Deleting a mission exposes its drawings elsewhere

Destroying a mission changes its drawings' mission to null. Other missions include null-mission drawings by default, exposing formerly scoped public files.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +118 to +124
if (req.body.mission != null) {
const missionOr = [{ mission: req.body.mission }];
if (showNullMissionFiles) missionOr.push({ mission: null });
if (showMasterFiles) missionOr.push({ is_master: true });
andWhere.push({ [Sequelize.Op.or]: missionOr });
}
if (!showMasterFiles) andWhere.push({ is_master: { [Sequelize.Op.ne]: true } });

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Mission-scoped drawings remain accessible by ID

getfiles filters drawings by mission, but getfile and drawing mutation routes still accept IDs without mission checks. Knowing an ID bypasses the new scope.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +181 to +196
if (req.body.mission != null) {
let vars = null;
try {
vars = await getDrawToolVars(req.body.mission);
} catch (err) {
logger("error", "Failed to make a new file.", req.originalUrl, req, err);
}
if (vars == null) {
res.send({
status: "failure",
message: "Failed to make a new file. Unknown mission.",
body: {},
});
return;
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Any user can create drawings in another mission

files/make validates only mission existence. A caller can supply a different mission and create a public drawing in its Draw list.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +83 to +96
if (this.options.cogColormapJson != null) {
let colormap = this.options.cogColormapJson
if (typeof colormap !== 'string') {
try {
colormap = JSON.stringify(colormap)
} catch (e) {
colormap = null
}
}
if (colormap != null && colormap !== '') {
url += `${
url.indexOf('?') === -1 ? '?' : '&'
}colormap=${encodeURIComponent(colormap)}`
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Unbounded color tables amplify tile requests

cogColormapJson has no size limit before inclusion in every tile URL. Large tables create oversized, repeated proxy requests that fail or consume resources.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants