Skip to content

Core: map-anchored popup service driven over the event bus - #303

Draft
CarsonDavis wants to merge 27 commits into
developmentfrom
feature/298-map-popup-service
Draft

Core: map-anchored popup service driven over the event bus#303
CarsonDavis wants to merge 27 commits into
developmentfrom
feature/298-map-popup-service

Conversation

@CarsonDavis

@CarsonDavis CarsonDavis commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Adds a core map-anchored popup service driven entirely over the event bus: a plugin sends one serializable map:showPopup request (lat/lng, HTML, up to two labeled buttons) and the core renders, positions, styles, and closes the popup on both map engines. The request's promise stays pending while the popup is open and resolves with the outcome — the core broadcasts nothing on the plugin's behalf. Also fixes the deck.gl adapter's missing move emission (drag frames, programmatic jumps, bearing/pitch sync), which the popup's tracking depends on.

Closes #298

How it works

  • map:showPopup — single global popup slot; a fresh request from any plugin replaces the current one. Payload and response are pure JSON (both survive a future sandboxed-plugin postMessage boundary unchanged). Content HTML is sanitized with DOMPurify (already a dependency) at the bus boundary; button labels render as text, never HTML.
  • The promise is the answer channel: it resolves {action: 'primary' | 'secondary' | 'dismiss' | 'closed'} — which button was pressed, that the user dismissed via X/click-away, or that the popup was closed without the user acting (replaced, map:hidePopup, mission switch). Invalid requests and mount failures reject. Because the response is point-to-point, the confused-deputy and cross-plugin event-spoofing vectors don't exist — no caller-identity machinery needed, ever. A plugin that wants to announce an outcome emits its own namespaced event afterward with its own authority. (Security analysis and option comparison are in the issue's history; the one sandbox-era note is that the future bridge must exempt map:showPopup from any default request timeout.)
  • map:hidePopup — no payload; retracts the current popup, whose request resolves 'closed'.
  • The card portals to <body> and the service does its own lat/lng→viewport projection each move frame. That structurally solves two things at once: popups can float above the panel layer (#map is z-index: 0 under #modern-content's 10), and clicks inside the popup can never reach either engine's click pipeline — no propagation guards needed.
  • Styling is entirely theme tokens (USWDS custom properties with neutral fallbacks for the classic UI); the secondary button is the bordered-neutral style, deliberately not --theme-color-secondary (destructive red in USWDS).

Decisions to review

  1. Click-away arbitration: the map:click subscription attaches one tick after show(), so no event from the popup's own opening gesture can dismiss it — on either engine's emission order (deck.gl fires featureClick before click; Leaflet the reverse). A genuine later click dismisses at the end of its own task; a popup replaced in that window resolves 'closed', so a requester is never left waiting (a gap the original event design had).
  2. map:hidePopup exists although the issue originally said closing needs no plugin-facing hide: AOI: move the analyze/cancel popup onto the core popup service #299's "no orphaned popup when the tool closes right after selecting" is unreachable without a retraction path, and any plugin can already blow the popup away via showPopup, so a naked hide grants no new power.
  3. Leaflet animated zoom hides the card (~250 ms) and repositions at zoomend, because Leaflet suppresses move during its CSS zoom animation and sampling internal pane transforms would couple the core to Leaflet internals. flyTo/pinch/drag track per-frame on both engines; deck.gl tracks through everything. This trades a sliver of the "tracks during zoom" wording for zero engine coupling — flagging for explicit sign-off.
  4. Escape is not a dismiss path (the issue names exactly two: X and click-away) and the card's role is groupdialog over-promised with no focus management. Payload options deliberately not taken: placement, closable, disabled, an ariaLabel field, DOMPurify FORBID_TAGS tightening. All trivially addable when a consumer needs them.

Follow-ups for #299 (recorded here so they don't get lost)

  • AOI awaits the request and branches on the resolved action; call map:hidePopup before its moveend wait and track/cancel its 1500 ms fallback timer.
  • map:addOverlay/removeOverlay are now @deprecated and undocumented; AOI: move the analyze/cancel popup onto the core popup service #299 removes their last plugin consumer (AOI). Full retirement is deferred to a follow-up issue — MapControl's measure-distance label (merged after these branches were cut) still uses them.

Verification

Overlay-mode basemap movement synced the view state silently and left out
bearing and pitch, so anchored consumers subscribed to 'move' never heard
from deck.gl and projection drifted on a rotated or tilted basemap.
A plugin sends one serializable request — lat/lng, sanitized HTML, up to two
naked action events and a dismiss event — and core owns the DOM, the theme
and the lifecycle. The popup hosts on document.body so it paints above the
panel layer and its clicks cannot reach either engine's click pipeline, and
it tracks the anchor on every engine move. A single popup exists at a time;
a mission switch tears it down through the provider cleanups.
Both basemap handlers copied the camera into `_viewState`, but only the
`move` path carried `bearing` and `pitch`, so a rotated or tilted basemap
projected anchors from a stale camera whenever `moveend` was the last event
to land. One `_syncViewState(eventName)` now serves both, and the moveend
path is covered by its own spec.
The dismissal was arbitrated by global bus ordering and a singleton timer
slot, which broke in the two orderings that matter most: deck.gl calls its
feature-click handler before it emits the click, so a popup opened from
`map:featureClick` dismissed itself a tick later, and a click handler left
over from a replaced popup closed its replacement and fired the
replacement's dismiss event.

Each popup now ignores map clicks until the task that opened it has run to
completion, and every deferral checks that its own popup is still the open
one before closing anything. Both orderings are pinned by specs.

The card also carries its own placement now that the zero-size host is
gone: the host's zero width made the card's shrink-to-fit resolve against
nothing, so every popup rendered at `min-width` and `max-width` never
applied. Dropping it leaves one source for the anchor gap and one element
of state. An anchor projected outside the map container hides the card
instead of pinning it to the viewport edge over the panel layer.

Also folds the card builder into the service, drops the Escape listener the
contract does not define, drops the redundant `moveend` subscription (both
engines emit `move` on every camera change that reaches `moveend`), and
ignores an action whose label or event is not a usable string.
A plugin being unloaded had no way to take its popup down: closing was
core-only, so an orphaned popup could outlive the tool that opened it. The
provider takes no payload and fires no dismiss event, matching the silent
close a replacing request already performs.
Covers `map:hidePopup`, the ordering a plugin sees when a click dismisses one
popup while its replacement is still pending, and the action validation. The
overlay providers come back out of the public table: `map:addOverlay` takes a
live `mount` function, which is the boundary this popup service exists to
replace, so documenting it invites new consumers of the contract on its way
out. Escape is gone, and the button row now reads in the order the code runs.
The popup no longer broadcasts caller-named events. `show` returns a promise
that stays pending while the popup is open and resolves with how it closed,
so an outcome reaches only the plugin that asked for it and the core never
emits an event on a caller's behalf.

Actions carry a label only, `dismissEvent` is gone, and a replaced or
retracted popup now tells its requester (`closed`) instead of vanishing
silently. Invalid requests reject rather than returning false.
Every lifecycle path asserts the value its request resolves with, recorded as
a list so a promise that never settles or settles twice fails the test. Adds
coverage for an invalid request rejecting, a replaced popup resolving
`closed`, and a dismissal followed by teardown settling only once.
Rewrites the `map:showPopup` section for the new contract: a request without
event names, the `MapPopupResult` it resolves with, when each action value
occurs, and the rejection cases. Drops the note on namespacing event names
handed to core, which no longer has a referent.
The teardown steps in hide() ran before the settle, so a throw part-way
through would strand the request promise for good: _open is already null,
so no later hide() can reach the record. Settling in a finally makes
exactly-once unconditional instead of contingent on teardown not throwing.
The reject-before-unwind ordering is unchanged — the first settlement is
still the answer.

Also drop the leftover whole-action parameter from buildActionButton, which
reads only the label, and name the button-slot union once.

The track helper's docstring claimed the specs catch a double settlement.
A promise absorbs every settlement after the first, so they cannot; what
they do catch is a missing or wrong first outcome. Say that instead.
The ADR's sandbox bridge sketches a 5s default request timeout. A popup can
stay open for minutes, so a bridge built to the ADR as written would reject
map:showPopup while its popup is still on screen. Record the carve-out where
whoever builds the bridge will read it.

The canonical plugin example also no longer runs as written: the request now
stays pending until the popup closes, so the trailing hidePopup could never
be reached. Store the request, handle its outcome in a then, and move the
retraction into the plugin's own teardown, the way a plugin would structure it.
The action row is a grid of equal `fr` columns rather than a flex row, so it
measures as the widest label instead of the sum of both: the card grows to fit
two full-width buttons, and a label like "Analyze area" no longer wraps. Past
the card's max width an outsized label ellipsizes rather than wrapping.

The primary action now leads the row, and a lone action takes the primary
styling whichever field it arrived in while still reporting that field.
@CarsonDavis
CarsonDavis force-pushed the feature/298-map-popup-service branch from 9c72e45 to cb13772 Compare August 14, 2026 17:14
Drawing a rectangle in the AOI tool drew it, opened the analyze/cancel popup,
and then dropped both on its own. The click that finished the rectangle came
back around as a map click after the popup was already open, and the popup
reads a map click as a dismissal — which is AOI's Cancel, so the selection
went with it.

terra-draw commits a shape on pointerup and the engine hears about that same
gesture's click only afterwards: Leaflet on the native click that follows, and
deck.gl up to 300ms later, because its click recognizer waits for a
double-click to fail before firing. By then the session has ended, so deck's
"am I drawing?" check — cleared synchronously in the finish handler — no longer
covers it, and Leaflet never had such a check at all. The popup's own guard is
one task wide, which a 300ms-late click clears easily.

So the engines now remember, when a session ends, that its closing click may
still be in flight, and drop it. Only that gesture's click can be covered: the
next gesture opens with a pointerdown, which disarms the guard — and in deck's
case cancels the pending click outright. A cancelled session arms it too, since
the last vertex click can still be on its way.

The two identical deck click/hover handlers move out of the standalone and
overlay init paths into one place each, so the check lives at a single site.
The guard covered one trailing click and stood down on the next pointerdown,
but the gesture that finishes a drawing is often a double-click: terra-draw
commits on the first tap, and the second reaches the engine as a further
Leaflet click, or as the onClick deck maps its dblclick recognizer onto a tap
interval after that tap's pointerup. Either way it landed as a map click and
dismissed the popup the drawing had just opened.

Absorb by time instead of by count. A pointerdown inside hammer's tap interval
may still be that second tap, so it leaves the window open; a later one is the
user's own gesture and closes it. Each pointerup inside the window re-opens it
for as long as an engine may take to turn that pointer into a click.

The window now also closes on its own, so a session that ended without a click
at all cannot leave the guard absorbing, and terra-draw's double-click zoom is
held back for as long as the window is open so the same gesture does not zoom
the map as well.
The request type said the primary button is rendered last and the secondary
first. The card leads with the primary, which is what the docs and the spec
already say.
The Leaflet branch subscribed clearOnMapClick straight onto the L.Map, so it
never saw the adapter's guard against the click a drawing ended on: finishing
a drawing deselected whatever feature the user had active. The deck.gl branch
already reached the engine through the addEventListener shim.

The engine reports a click's layer point as plain {x, y}, which L.Bounds would
take for a bounds and throw on, so rebuild it as an L.Point.
There is one popup slot, so map:hidePopup can retract another plugin's popup.
Show the guard that keeps a plugin to its own: ask only while its own request
is still unresolved.
Leaflet fires a map click for every native click and terra-draw's adapter
never stops click propagation, so on the 2D engine each vertex the user placed
reached the bus as a map click — dismissing whatever a plugin had open and
clearing its selection halfway through a drawing. DeckGLAdapter has always
checked the session on its own click path; the Leaflet paths checked only the
end-of-draw guard.

Check the session on both, and only for clicks: the drawing's own events are
emitted through the same wrapper while the session is live.
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.

Core: map-anchored popup service driven over the event bus

1 participant