Skip to content

Finalize layertype pluginization: attachments, capabilities, config/filter/time surfaces, extends #115 - #1036

Merged
tariqksoliman merged 1645 commits into
NASA-AMMOS:developmentfrom
JPL-Devin:development
Aug 12, 2026
Merged

Finalize layertype pluginization: attachments, capabilities, config/filter/time surfaces, extends #115#1036
tariqksoliman merged 1645 commits into
NASA-AMMOS:developmentfrom
JPL-Devin:development

Conversation

@tariqksoliman

Copy link
Copy Markdown
Member

With Devin: JPL-Devin#115

Purpose

#112 moved map-render dispatch behind LayerTypeRegistry/LayerInterface, but layer-type-specific behavior was still spread across core: ~94 comparisons of a layer's type against built-in ids, globe engine configs built inline in two duplicated blocks in visibility.js, filterer selection keyed by type, and all seven layer attachments constructed by ~1140 lines of hardcoded core code. This finishes the job: a layer type (or attachment) owns everything specific to it, core only dispatches, and manifest capabilities answer the classification questions core must ask while iterating every layer.

The invariant is now enforced by a test rather than by convention: no file under src/ may compare a layer's or attachment's type against a built-in id, or single out a built-in attachment by name (.pairings, sub === 'image_overlays', ['models'].includes(sub)).

Proposed Changes

Layer types own their behavior

  • [CHANGE] setVisibility now receives a real context ({visible, wasNeverOn, firstTimeOn, hadToMake, globeOnly, source, ...}), plus onToggle as sugar for setVisibility.after, so the per-type toggle work (Vector's time-filter + initial-filter submit, Velocity's remake, Video's mute, Image's recolor) lives in plugins/core/layertypes/*/map/*.js.
  • [CHANGE] Globe layers are built by the plugin, not by core. visibility.js no longer builds LithoSphere/Cesium configs or calls litho.addLayer — each type has globe/config.js + globe/{cesium,lithosphere}/*.js and goes through GlobeRenderer. The two duplicated core blocks had already diverged; where they disagreed the toggle path won (addVisible was applying tile-only throughTileServer/COG: URL rewriting to model/3dtiles).
  • [ADD] config, filter and time surfaces: STAC expansion / kind / throughTileServer normalization and URL resolution (config), LocalFilterer/GeodatasetFilterer/ESFilterer selection (filter), and time availability + WMS param stamping (time).
  • [ADD] extends (one level) so a type inherits every surface and capability it doesn't define.
  • [ADD] Single-file plugins: "module": "./x.js" exporting {map, globe, config, filter, time}. Every op except make has a correct-but-generic core default, so a working type can be one file.
  • [ADD] capabilities fields for the questions core asks while partitioning all layers — structural, map.stacking/redrawOnReorder/tracksLoad/refreshByRemake/stacEndpoint/picking/styling, time.histogram, defaultInteractions — replacing the type lists in ordering, picking, header handling and TimeUI eligibility. capabilities.time/filtering/identify were declared in all 11 manifests and read nowhere; they're consumed now.
  • [ADD] Layertype/attachment plugins can declare npm dependencies (resolve-plugin-deps.js and checkPluginDependencies skipped both containers).
  • [FIX] expandLayers recursed into sublayers without awaiting itself. Latent upstream, but fatal once config.expand made the loop async: dataFlat was still filling when Map_.init ran and only 4 of 66 Reference Mission layers were made.
  • [CHANGE] Removed the vestigial type === 'point' check in resetLayerFills. point was a vector alias whose makeLayer case was dropped in eb11ff87, so such a layer has never entered L_.layers.layer since.

Attachments own their construction

  • [CHANGE] All seven attachment constructors moved out of LayerConstructors.js (−1692 lines) into their plugins' make. Core's constructSublayers now asks LayerAttachmentRegistry which attachments apply to the host's type, in which order, and which need their siblings:

    const ids = LayerAttachmentRegistry.orderedFor(layerObj.type)
    ids.forEach((id) => { sublayers[sublayerKey(id)] = false })
    // build, deferring buildsAfterSiblings ids (labels) until the rest exist
  • [ADD] Attachment ops for the behavior that was left in core: syncData (UncertaintyEllipses' globe teardown, ImageOverlays' z-index), setStyle (CoordinateMarkers' highlight restyle), setOpacity (UncertaintyEllipses' scaled stroke/fill, ImageOverlays' DOM opacity), onPeerToggle (Pairings reacting to a layer it watches), and peerFeaturesFor.

  • [ADD] Host metadata in each attachment manifest: render order, sublayerKey (the model attachment is still stored under the legacy models key), buildsAfterSiblings (labels).

  • [CHANGE] Photosphere.js no longer reads .pairings state or computes pair az/el itself. It asks L_.getPeerFeatures(layerName, feature, {originOffset}), which dispatches to whichever attachment implements peerFeaturesFor, and plots what comes back.

  • [CHANGE] The gradient-polyline globe add/remove (including the in-flight-build guard) moved from sublayers.js into the PathGradient plugin; gradient_polyline remains a GlobeRenderer primitive kind.

Attachments own their settings, their click-mode half and their identity

  • [ADD] configPath in every attachment manifest (e.g. image_overlaysvariables.markerAttachments.image). An attachment's id, its key on the host and its config key are three different names; declaring the third lets core answer "does this host want this attachment?" once instead of seven copies of F_.getIn(...) + enabled === true || enabled == null.
  • [ADD] Bearing is an attachment (the 8th) rather than a hardcoded branch in pointToLayer. It decorates its host instead of building a sublayer, so it declares host.decoratesHost and implements decorateFeature/globeStyle in place of make — and consequently never appears in the host's Composite Layers list.
  • [CHANGE] applicableLayerTypes is resolved through one level of extends, and the five attachments that declared no hosts now declare ["vector","query"] — the only hosts that have ever constructed attachments. Configure therefore stops offering attachment settings on vectortile and model layers, where nothing was ever built; values already saved there are preserved on save rather than trimmed.
  • [ADD] A config block in every attachment manifest, replacing the four Attachment - * tab-sets copy-pasted into the Vector, VectorTile, Query and Model metaconfigs (three of which had already drifted). Configure fetches layerAttachmentConfigs.json and composes the rows into the same four tabs, so a third-party attachment gets settings UI without editing any layer type.
  • [CHANGE] WaypointImage/WaypointModel no longer reimplement the image/model config with their own defaults next to the attachment's. They call L_.makeFeatureAttachment(id, ...), and the plugin owns the show: 'click' half exactly as it owns the always-on half; deselection is L_.clearFeatureAttachments() instead of core knowing the two temp-layer names.
  • [CHANGE] The LayersTool's attachment tooltip comes from the manifest description (per-instance title still overrides), so fixing a description fixes the UI.

Enforcement

  • [ADD] tests/unit/layerTypeBranchGuardrail.spec.js — no built-in type comparison and no built-in attachment singled out by name under src/. Geometry types ('Point'), time.type, filter value types and menu-item types are inherently out of scope since it only inspects layer/attachment type comparisons.
  • [ADD] tests/unit/layerTypeInheritance.spec.js, and updateLayerAttachments.spec.js assertions that every attachment has a valid make, declares a unique host order, and keeps its legacy sublayer key.
  • [CHANGE] pluginValidation.js validates per surface (a buildConfig in a map module is an error), enum-checks the new capability fields, and requires make on an attachment — except a host decoration, which has nothing to make.
  • [ADD] tests/unit/layerAttachmentTabs.spec.js — every field the composed Configure tabs write lives under some attachment's declared configPath, a type inherits its parent's attachment settings, and a host no attachment applies to gets none.

Authoring: docs, scaffolds and a loud contract

The code grew four new surfaces, a capability vocabulary and a whole family; the authoring materials still described #112's render contract, so the safe path for a new plugin was "copy an existing one".

  • [CHANGE] plugin-cli/ is a root-level directory (cli.js, lib/, scaffolds/<type>/), leaving /plugins for containers and plugin-state.json only. registries.json is CLI config and moves with it; the server still reads state from /plugins, so discovery is untouched. npm run plugins -- <cmd> is unchanged.
  • [CHANGE] The six scaffolds are real files under plugin-cli/scaffolds/<type>/ instead of ~500 lines of string arrays inside the CLI. create copies the tree, substituting the name into paths and contents (__Name__, __name__, __flatname__, __snake_name__, __colon_name__). Being valid JSON/JS as they sit, they go through the same validator as any plugin — a broken scaffold now fails tests rather than someone's first plugin.
  • [ADD] create layerattachment, and plugins/core/layerattachments/README.md with the contract, every op and the core default it replaces, the capability table and a worked example. It was the one family with neither a scaffold nor a README.
  • [ADD] Capability validation against a schema: a wrong leaf type or an out-of-enum value is an error, an unknown key warns (forward compatibility), and an omission core acts on (map.stacking on a map-rendering type, host.order on a non-decoration attachment) warns. These previously failed as a silently mis-ordered or un-pickable layer.
  • [CHANGE] Layer scaffolds implement only make (plus destroy for a type) and list the rest as commented ops with the default each would replace — an empty setOpacity silently overrides a working core default, so over-implementation is the failure mode worth designing against.
  • [ADD] The config/filter/time surfaces documented as contracts in the layertypes README, and the capture surface removed from it (it never existed). Same for the onHostToggle attachment op — no runtime dispatch, and a toggled host already reaches its attachments as setVisibility.
  • [CHANGE] Dropped the load op (superseded by the source surface below). It 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 on pan/zoom, is core's (Layers_/capture/LayerCapturer), and it is deliberately staying there rather than becoming a per-type op. The op table also omitted render and onToggle, which core does dispatch.
  • [CHANGE] One path segment per surface: Vector/map.js, config.js, filter.js, globe/{cesium,lithosphere}.js, non-surface helpers under lib/. The leaf name repeated the plugin directory (Vector/map/vector.js), and globe/config.js — the shared engine-neutral layer descriptor, now globe/layerConfig.js — read as the formal config surface one directory away. Manifest keys are unchanged, since the surface comes from the key and never the path. The dead export defaults in those helpers are gone.
  • [CHANGE] create's valid types and the discovery directories were two hand-kept lists, so the help text still advertised five families after layerattachment landed; both now derive from one map.
  • [CHANGE] The layertype scaffold shows the phase form ({ before, main, after }, plus afterCommit on make) as a comment beside its bare make. A bare function is sugar for { main: fn }, which made Vector's phase object look like a different contract.
  • [FIX] LayerAttachmentRegistry.withOp still read the pre-rename paths.plugin key, so it matched no attachment and clearing a clicked overlay/model silently did nothing. A test now covers both sides of that generated boundary, since a mismatch there fails as a no-op rather than an error.
  • [FIX] Destroy a layer's attachments when its host is permanently removed; anything an attachment holds outside a map layer (a globe layer, a DOM element) was leaked.
  • [FIX] The specs that regenerate the shared registries (src/pre/*.js, configure/public/*Configs.json — single copies in the repo) now hold a lock across generate-then-assert. A worker running the CLI could otherwise regenerate away a fixture another worker had just written and was asserting on: ~1 run in 3 locally, invisible on CI, which uses one worker.

Four surfaces the authoring experiments asked for

Eight throwaway plugins were written against this branch by separate agents (a globe extrusion renderer, an OGC API Features source, a time-series attachment, an interaction, a backend, a Configure-facing type, …). None had to touch core. What they did hit was core deciding something on their behalf with no way to participate:

  • [ADD] layertype source.fetch(layerObj, ctx) -> GeoJSON, dispatched from LayerCapturer on every acquisition (initial make, refresh interval, time requery, dynamic-extent pan). A url string can't express a POST body, auth headers, pagination or an SDK call, so a source that isn't "GET this url" had no home. Core keeps the entire dynamic-extent policy — extent (map or globe), debounce/settling, zoom gate, move threshold (including the /z suffix), request staleness, clear-then-update, reload fan-out — which is why this is not the load op returning: the two duplicated ~150-line branches in captureVector are now one core path plus capture/dynamicExtent.js, and fetch is a pure "given this config and this ctx, give me GeoJSON".

    // ctx: { url, trigger, view, dynamicExtent, crsCode, time, filters, spatialFilter }
    async function fetch(layerObj, ctx) {
        const bbox = ctx.view && [ctx.view.minx, ctx.view.miny, ctx.view.maxx, ctx.view.maxy]
        return await get(layerObj.url, { bbox, ...ctx.time })
    }
  • [ADD] layertype legend.derive(layerObj), replacing this, carried in both the LayersTool and the LegendTool (twice, in the second):

    if ((['image', 'tile'].includes(type) && cogTransform) || type === 'velocity' ||
        (type === 'data' && F_.getIn(layer, 'variables.shader.type') === 'colorize'))

    Configured legends (legend url, variables.legend) stay core's. This is for a legend that is the render — a single-band COG's colormap over its rescale range, a shader's ramp, a velocity magnitude scale — which only the type knows it has. Tile, Image, Velocity and Data declare it; derive returning false means "not this layer after all".

  • [ADD] attachment onConfigChange(ctx) and mmgisAPI.setLayerAttachmentConfig(layerName, attachmentId, config). Retuning a live attachment (a gradient's ramp, a label's property) had no path at all: core writes the new settings into the host's live config at the attachment's declared configPath — so everything that reads settings, the attachment included, sees them — then dispatches. The default is the blunt but always-correct host rebuild, so declaring the op is an optimization rather than a requirement.

  • [ADD] interaction configPath, so an interaction with per-layer settings is handed its own subtree as ctx.config instead of knowing where in layerData it lives — the same declaration attachments already make.

  • [ADD] raw (the engine namespace) on both globe contexts, matching mctx.raw on the map. A globe module could reach Cesium/LithoSphere only by importing them itself.

  • [FIX] test:plugins:unit failed with "No tests found" whenever no plugin shipped a @unit spec — which is every state of this repo, since the tagged specs only exist in generated plugins.

Issues

Testing

  • npm run test:unit: 1033 passed (was 967; the new ones cover capability validation, the config.rows metaconfig schema, stale-registry detection, every scaffold generating a plugin that validates, the attachment op lookup below, and the four new surfaces — the dynamic-extent policy source.fetch sits inside is tested directly in tests/unit/layerSource.spec.js: staleness, thresholds, /z scaling, ctx construction). npm run test:ci: passing. npm run plugins -- validate: all 58 plugins valid (1 pre-existing dependency warning for ChemistryUseChemistryTool). npm run build and the Configure build: compiled successfully. ESLint on changed files: no new errors (the 7 'L' is not defined in Photosphere.js are pre-existing and unchanged on origin/development).
  • Reference Mission verified side-by-side against origin/development (feature on :8889, base on :8891) via scripted browser runs:
    • Same 66/66 layers made, same layer/toggle state, no console errors.
    • Each attachment-bearing layer toggled on and its attachment record dumped (keys, order, sublayerType, child count, globe layer ids): byte-identical output on both branches for uncertainty ellipses, labels, coordinate markers, image overlays, path gradient (2D + 3D) and pairings.
    • getPeerFeatures returns the same paired-layer and peer counts as recomputing from the attachment record directly.

devin-ai-integration Bot and others added 30 commits July 7, 2026 17:19
…ng button

- Remove .searchBarExpanded blue border/box-shadow on panel open
- min-width 280px → 293px (+13px)
- .searchTimeWarning align-items: center (vertically centers button)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…der, +7px wider

- .searchCompactBar gets permanent 2px transparent bottom border to
  prevent layout shift when focus/active state adds the accent color
- .searchBarFocused (panel open) applies same tint+underline as active
- min-width 293px → 300px (+7px)

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

- Add maybeShowTimeWarning helper that queries geodataset without time
  bounds, compares result extent to current slider, only shows warning
  if features exist outside the range
- Fix case-sensitive regex search missing start_time/end_time columns

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

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Adds searchGeneration ref that increments on each handleSearch call.
tryHighlight and trySelect check the generation and bail if a newer
search has started — prevents re-selecting a previous feature.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
When a value only exists in geodataset layers, doWithSearch was still
running on all vector layers in the group. If a vector layer happened
to have a match (e.g. same search text), it would re-select that
feature — overriding the resetLayerFills() and showing a stale
selection. Now filters vecLayers by sourceLayers (same as geoSearchTargets).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
When toggling geodataset layers on, addGeoJSONData / updateVectorLayer
re-selects L_.activeFeature from a previous search — causing stale
highlights. Clearing activeFeature at the start of select mode
prevents layer reloads from re-selecting the old feature.

Also filters vector layers by sourceLayers in group select mode so
doWithSearch only runs on layers that actually have the clicked value.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…e, multi-field search, test corrections, variable naming

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

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

feat: Global Feature Search — unified panel, field search, operator selection, layer management
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…lowercase

fix: lowercase COG colormap_name before passing to TiTiler
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…ution

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
devin-ai-integration Bot and others added 19 commits August 4, 2026 20:37
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>
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>
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>
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>
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>
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>
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>
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>
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>
…, 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>
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>
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>
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>
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>
…pdown 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>
…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>
…ds 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>
`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>
…rtype-plugins

Finalize layertype pluginization: attachments, capabilities, config/filter/time surfaces, extends
@tariqksoliman tariqksoliman self-assigned this Aug 5, 2026
@tariqksoliman tariqksoliman added the enhancement For making an existing feature better label Aug 5, 2026
@tariqksoliman
tariqksoliman requested review from ac-61, jdrodjpl and jtroberts and removed request for jdrodjpl August 5, 2026 23:08
@tariqksoliman
tariqksoliman merged commit 48dd496 into NASA-AMMOS:development Aug 12, 2026
5 of 8 checks passed
@github-project-automation github-project-automation Bot moved this to Done in MMGIS Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement For making an existing feature better

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant