A layer attachment is something extra drawn from a host layer's own data:
labels beside its features, a marker at every coordinate, pairing lines to other
layers, uncertainty ellipses, a 3D model at each point, a gradient along a path.
An attachment is not a layer type — it never appears in a mission's layer list.
It hangs off a host layer, is configured inside that host's variables, and is
built, shown, restyled and torn down with it.
This document is the contract. Implement make, declare where your settings live
and where you sit among your host's other attachments, and core does the rest —
with no core changes.
Dispatch lives in
src/essence/Basics/Layers_/display/sublayers.jsand the registry insrc/essence/Basics/Layers_/registry/LayerAttachmentRegistry.js. Start fromnode plugin-cli/cli.js create layerattachment <Name> --container <container>.
| what it is | declares | example | |
|---|---|---|---|
| sublayer | a renderable of its own, listed under the host's Composite Layers and separately toggleable | capabilities.host.order |
Labels, Pairings, PathGradient |
| decoration | a change to how the host draws its own features — nothing is added to the map | capabilities.host.decoratesHost: true |
Bearing (turns the host's markers to a heading) |
A sublayer implements make; a decoration has nothing to make and implements
decorateFeature / globeStyle instead.
plugins/<container>/layerattachments/<Name>/
plugin.json # manifest (identity, configPath, host placement, Configure form)
<name>.js # the one module
tests/<name>.spec.js
An attachment declares a single module, not the modules of a layer type:
it is one renderable that may straddle both engines (an uncertainty ellipse is
a map overlay and two globe layers), so there is nothing to split per
surface. It is also not the paths of tools and interactions, where a key is the
module's export name.
| in the manifest | what it names | example |
|---|---|---|
attachmentId |
the attachment itself — how core dispatches, and the type your make returns |
image_overlays |
configPath |
where its settings live in a host layer's config | variables.markerAttachments.image |
capabilities.host.sublayerKey |
the key it is stored under on its host (L_.layers.attachments[host][key]), defaulting to attachmentId |
models |
They differ because the config structures are older than the plugin system
(markerAttachments, coordinateAttachments, pathAttachments) and mission
configs can't be renamed freely. Declaring all three is what lets core, the
plugin and the Configure page reach the same answer without a naming convention.
Never read the host's config directly — core resolves configPath for you
and hands the result to every operation as ctx.config. That is also how core
answers "does this host want this attachment at all", so your make is only
called for a host that asked: a configPath subtree that exists counts as
enabled unless it says enabled: false.
A host can also have asked through its layer type, which may declare you in
capabilities.defaultAttachments along with the settings it wants (see
../layertypes/README.md). Nothing changes for you —
ctx.config is the type's settings with the layer's own on top, field by field —
but it is worth knowing that a ctx.config you were handed does not imply an
admin ever opened your form, and that your applicableLayerTypes still decides
whether a type may declare you at all.
applicableLayerTypes lists the layer types this attachment applies to; omitting
it means all of them. A type that extends one of those types is included, so a
third-party type inheriting vector inherits its labels and pairings too.
The module is export default { …operations }. Only make is required (and only
for a sublayer). Everything else falls back to a core default, so write an
operation only where the default is wrong — most attachments are an ordinary
map layer and need two or three.
| operation | signature | when it runs | core default if you omit it |
|---|---|---|---|
make |
(ctx) → attachment | false |
the host is built | nothing is added (false) |
syncData |
(attachment, ctx) |
the host's data changed | clearLayers(), then re-addData the host's GeoJSON |
onConfigChange |
(ctx) |
the attachment's settings changed while it was built | rebuild the host layer |
setVisibility |
(attachment, ctx) |
the attachment, or its host, is shown/hidden | add to / remove from the 2D map |
setOpacity |
(attachment, opacity, ctx) |
the host's or the attachment's opacity changed | setOpacity, falling back to setStyle on the layer |
setStyle |
(attachment, ctx) |
feature highlight/fills were reset | no-op |
destroy |
(attachment, ctx) |
the host layer is removed for good | no-op (the map removal already happened) |
onPeerToggle |
(attachment, ctx) |
any other layer was toggled | no-op |
peerFeaturesFor |
(attachment, ctx) → {origin, layerNames, peers} | false |
core needs a feature's related features in other layers | none (no relations) |
decorateFeature |
(ctx) → Object | null |
the host is drawing one of its features | no decoration |
globeStyle |
(ctx) → Object | null |
the host's globe style is being composed | nothing merged in |
makeForFeature |
(ctx) |
a feature was selected and this attachment is configured show: 'click' |
no-op |
clearForFeature |
(ctx) |
nothing is selected anymore | no-op |
Every operation may also be written in the nested { before, main, after } form,
exactly as for layer types — providing main is the override of the core
default, while before/after wrap whatever runs.
make gets the host as it is being built:
function make({ geojson, layerObj, leafletLayerObject, hostLayer, config, siblings }) {
return {
on: config.initialVisibility !== false, // initial visibility
type: 'uncertainty_ellipses', // your attachmentId
geojson,
layer, // what core adds to the map
}
}The returned object is what core stores on the host
(L_.layers.attachments[hostName][sublayerKey], the host keyed as
L_.layers.data keys it and the key your attachmentId unless you declared
capabilities.host.sublayerKey) and hands back to every
per-instance operation, verbatim — so keys beyond the four below survive, and
stashing what a later operation needs on it (_radius, an engine handle, a
feature index) is the intended way to keep state. Return false, not {}, when
there is nothing to add.
| key | meaning |
|---|---|
type |
your attachmentId. Core and UI read this to find their way back to your plugin |
on |
initial visibility. Core adds it to the map only if true |
layer |
the Leaflet layer core adds, removes, orders and opacities for you |
geojson |
the data you built from, so syncData can diff and core can rebuild without re-deriving it. Yours, not the host's — return the subset you actually drew if it differs |
configis your settings subtree — the value at yourconfigPathon the host, or what the host's layer type declared for you incapabilities.defaultAttachments, with the host's own fields on top. Core never callsmakewith anullconfig: an attachment is built only for a host that asked for it, one way or the other, and anenabled-less config counts as enabled (the key's presence is the request). It can still be partial, so default your own values — which is why the scaffold and the examples here writeconfig?.x: it costs nothing and it keeps amake({})in your unit test from throwing.initialVisibilityis a convention rather than a core-read key: every core attachment treatsconfig.initialVisibility === falseas "built but not shown" and nothing else in core looks at it. Follow it, and give it aconfig.rowsentry if an admin should be able to set it.leafletLayerObjectis the host's ownonEachFeature/pointToLayer/style, to reuse so your features look like their host's.refreshLayer()re-acquires the host's data (await ctx.refreshLayer()), for when you have changed what it would be — written to your own backend, say. It is the supported way to ask: the host goes through the same acquisition every other trigger does (asource-backed type seesctx.trigger === 'refresh'), rather than you callingMap_.refreshLayerand its positional internals. Also ononConfigChange.acquire(layerName)is another configured layer's data as GeoJSON (await ctx.acquire('Wind Stations')), for when what you draw beside your host depends on a second layer of the mission. Its own layer type does the acquiring; the layer is not turned on, nothing of it is drawn, and you get a snapshot rather than a handle on its render —nullif it can't be acquired. It is a fetch, so acquire once and hold it. Also ononConfigChange.siblingsis only present if you declaredcapabilities.host.buildsAfterSiblings: true— declare it when you decorate the other attachments rather than the host (Labels labels the coordinate markers too, so it is built last and is handed what exists).
The per-instance operations get the built attachment plus
{ hostName, attachmentName, … }: setVisibility adds visible, globeOnly
and the applyOrder/applyOpacity callbacks core wants run after you show
something; setOpacity adds source: 'host' | 'attachment'; syncData adds the
new geojson, onlyClear, and — as make got them — layerObj, config and
zIndex, so a redraw needs nothing stashed; onPeerToggle adds the layerName that toggled
and its new state; onConfigChange adds config, prevConfig, layerObj and
the built attachment (its signature is (ctx) alone — core dispatches it after
writing the new settings, whether or not an instance exists, so read
ctx.attachment and treat null as "nothing built yet").
onConfigChange is what mmgisAPI.setLayerAttachmentConfig(layerName, attachmentId, config) dispatches — core has already written the new settings
into the host's live config at your configPath, so implement it to retune in
place (a new ramp, a new label property) instead of paying for core's default,
which rebuilds the whole host layer.
The host-scoped operations (decorateFeature, globeStyle, makeForFeature,
clearForFeature) run without an instance — there may not be one — and get
{ layerObj, feature?, config, … } instead.
There is no time operation, and an attachment of a time-enabled layer does not
need one: when the time bar moves, the host reacquires or refilters its features
and core then calls your syncData with the new GeoJSON, so drawing from
ctx.geojson is already time-correct. An attachment that must follow the
playhead itself — fading a trail as time passes, with the host's data
unchanged — subscribes in make and unsubscribes in destroy:
import TimeControl from '@basics/TimeControl_/TimeControl'
function make(ctx) {
const attachment = { type: 'trail', on: true, /* … */ }
const fid = `trail_${ctx.hostName}`
TimeControl.subscribe(fid, () => redraw(attachment, TimeControl.getTime()))
attachment._fid = fid
return attachment
}
function destroy(attachment) {
TimeControl.unsubscribe(attachment._fid)
}Feature timestamps live in the feature's own properties, under the names the
layer configured (layerObj.time.startProp / endProp) — read them with
F_.getIn, never a hardcoded property name.
Capabilities are what core reads while iterating all attachments, where calling an operation would be backwards. They are validated: a wrong type or value is an error, and an unknown key warns (so a typo is loud rather than silently inert).
| capability | what core does with it | default |
|---|---|---|
renderers.map |
whether this attachment draws on the 2D map at all | drawn on the map |
renderers.globe |
which globe engines it draws through | none |
host.order |
build order, which is also render order (later on top) | last, then alphabetical — declare it |
host.sublayerKey |
the key it is stored under on its host | the attachmentId |
host.buildsAfterSiblings |
built after its siblings and handed them as ctx.siblings |
built in order |
host.decoratesHost |
it is a decoration, not a sublayer: never built, never listed, never toggled | it is a sublayer |
globe.suppressesHost |
on the globe this attachment is the host's geometry, so the host must not also be drawn (PathGradient) | host is drawn |
config contributes rows to the Layer modal. Unlike a layer type — which owns
whole tabs — an attachment joins a tab, so it says which one and where:
"config": {
"tab": "Attachment - Markers", // the tab these rows join (created if new)
"tabOrder": 12, // where that tab sits among the modal's tabs
"order": 3, // where these rows sit within the tab
"rows": [ { "subname": "Uncertainty Ellipses", "components": [ … ] } ]
}Every field must be under the manifest's configPath — that is the same
subtree core resolves to ctx.config, so a form writing anywhere else produces
settings nothing reads. <configPath>.enabled is the switch that turns the
attachment on. (An objectarray's item fields are the one exception: they are
relative to the array's own field — see the component table in
../../README.md.)
tab should name a tab that already exists — Attachment - Markers,
Attachment - Coordinates, Attachment - Layers, Attachment - Paths — which is
why the scaffold defaults to one. A name no other attachment declares is not an
error: Configure quietly gives it a tab of its own holding your rows alone, which
an admin has no reason to open. plugins -- validate warns when yours is a tab of
its own, so a typo says so instead of disappearing.
- Manifest (
API/pluginValidation.js, at startup and in the CLI): requiresattachmentId,moduleand aconfigPathundervariables.; checks thecapabilitiesshape against the table above. - Module (
npm run plugins -- validate): statically parses yourexport default {}, requiresmake(unlessdecoratesHost), and rejects unknown operation and phase names — somakeForFeaturesfails loudly instead of never being called.
npm run plugins -- create layerattachment <Name> --container <container>.- Set
configPath,applicableLayerTypesandcapabilities.host.order, and point theconfigrows at that sameconfigPath. - Implement
makefromctx.geojson, returning{ on, type, geojson, layer }. - Add other operations only where a core default is wrong.
npm run plugins -- validate, thennpm run plugins -- activateto regeneratesrc/pre/layerattachments.js— never hand-edit it.- Enable it on a layer in Configure and check it draws, hides, syncs and survives a layer removal.
A ring of a configurable radius around each of the host's point features. It is
an ordinary map layer, so make is the whole plugin.
// plugins/mine/layerattachments/RadiusRings/plugin.json
{
"name": "RadiusRings",
"type": "layerattachment",
"attachmentId": "radius_rings",
"version": "1.0.0",
"tier": "community",
"overridable": true,
"configPath": "variables.layerAttachments.radiusRings",
"description": "A ring of a fixed radius around each of the host's point features.",
"capabilities": {
"renderers": { "map": { "engines": ["leaflet"] }, "globe": false },
"host": { "order": 50 }
},
"applicableLayerTypes": ["vector", "query"],
"config": {
"tab": "Attachment - Markers",
"tabOrder": 12,
"order": 9,
"rows": [
{
"subname": "Radius Rings",
"components": [
{ "field": "variables.layerAttachments.radiusRings.enabled",
"name": "Enabled", "type": "switch", "width": 3, "defaultChecked": false },
{ "field": "variables.layerAttachments.radiusRings.radiusMeters",
"name": "Radius (m)", "type": "number", "width": 3 }
]
}
]
},
"module": "./radiusRings"
}// plugins/mine/layerattachments/RadiusRings/radiusRings.js
// Read the Leaflet global per call, not at import time, so the module can be
// imported outside the browser (`npm run test:plugins:unit` does exactly that).
// Importing an MMGIS singleton — `F_` included — pulls jQuery and makes the
// module un-importable in a unit test, so this one stays dependency-free.
const leaflet = () => window.L
const num = (v, fallback) => (Number.isFinite(parseFloat(v)) ? parseFloat(v) : fallback)
const ringsOf = (geojson, radius) =>
(geojson?.features || [])
.filter((f) => f.geometry?.type === 'Point')
.map((f) => {
const [lng, lat] = f.geometry.coordinates
return leaflet().circle([lat, lng], { radius, fill: false })
})
function make({ geojson, config }) {
const radius = num(config?.radiusMeters, 10)
return {
on: config?.initialVisibility !== false,
type: 'radius_rings',
geojson,
layer: leaflet().layerGroup(ringsOf(geojson, radius)),
}
}
/**
* The host's data changed. The core default re-adds GeoJSON to the attachment's
* layer, which is right for a `L.geoJson` attachment; these are derived circles
* in a layerGroup, so they are rebuilt instead.
*/
function syncData(attachment, { geojson, onlyClear, config }) {
attachment.layer.clearLayers()
if (onlyClear) return
ringsOf(geojson, num(config?.radiusMeters, 10)).forEach((ring) =>
attachment.layer.addLayer(ring)
)
}
export default { make, syncData }That is the whole plugin: it is added to and removed from the map with its host,
follows its opacity, is listed and toggleable under the host's Composite Layers,
and is offered on every vector and query layer (and anything extending them)
— none of which it had to implement.
{ "name": "UncertaintyEllipses", "type": "layerattachment", "attachmentId": "uncertainty_ellipses", "configPath": "variables.markerAttachments.uncertainty", "capabilities": { "renderers": { "map": { "engines": ["leaflet"] }, "globe": { "engines": ["lithosphere"] } }, "host": { "order": 2 } }, "applicableLayerTypes": ["vector", "query"], "module": "./uncertaintyEllipses" }