Skip to content

AOI: move the analyze/cancel popup onto the core popup service - #309

Closed
CarsonDavis wants to merge 29 commits into
feature/298-map-popup-servicefrom
feature/299-aoi-popup-migration
Closed

AOI: move the analyze/cancel popup onto the core popup service#309
CarsonDavis wants to merge 29 commits into
feature/298-map-popup-servicefrom
feature/299-aoi-popup-migration

Conversation

@CarsonDavis

@CarsonDavis CarsonDavis commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator
Category Lines added %
Tests 846 74.2%
Production code 294 25.8%

The AOI plugin no longer draws its own analyze/cancel card — it asks core for one and core answers with which button was pressed.

Closes #299

The analyze/cancel card is core's popup now

Previously, you picked an area — searched a state, clicked a boundary in inspect mode, drew a shape, uploaded a file — and a small card appeared above it with the area's name, an Analyze area button and a Cancel button. That card was the plugin's own React component, mounted into a core overlay node through a callback core handed out, and its stylesheet lived in the plugin too.

That arrangement can't survive a real plugin boundary. Handing a plugin a DOM node to mount into means the plugin has to ship a card, its markup and its styling, and every fix to any of it lands in one plugin instead of everywhere.

The fix: AOI sends plain data over the bus — a lat/lng, a title and two button labels — and core draws the card, owns its position and lifecycle, and reports back which button was pressed. Same wording, same buttons, same anchor above the area, plus a close X and Escape handling that the old card never had. Analyze area still emits the same plugin:aoi:analysisAOIReady event with the same feature; core's answer only says which button was pressed, so the plugin attaches the feature itself. The old tooltip component and its styles are deleted, and the contract AOI advertises drops a now-unused cancelled event and a dead subscription to tool switches.

Demo mission: yes, searching any state brings the card up, and it still drives the stats and chart panels.

Closing the card now abandons the selection

Previously, an area you had picked stayed picked until you pressed Analyze area or Cancel. There was no X on the card, Escape did nothing, and clicking somewhere else on the map left the card sitting where it was.

Core reports all three of those — the X, Escape, a click anywhere else on the map — as the same thing: the card was dismissed. The issue asked for the X and the map click but left open what a dismissal should do to the area you had picked.

The fix: a dismissal is treated exactly like Cancel. The area and its highlight are dropped and the same cleared event fires as before. This is the one change a user would notice without drawing anything, and it is the reviewer's call to make.

Demo mission: yes — search a state, then click bare map, and the highlight goes away with the card.

Starting a drawing puts the card away, and backing out brings it back

Previously, with an area selected, clicking Rectangle left the old card up for the whole drawing session, offering to analyze the area you were in the middle of replacing. And because a card is a dialog that takes keyboard focus, it swallowed the Escape and Enter that the drawing session itself needs.

Leaving it up stopped being an option once the card belongs to core: a click on the map during a drawing comes back as a dismissal, which would now wipe the selection out from under you.

The fix: the card is retracted when a drawing starts and the old selection is parked rather than dropped. The first vertex you commit is where it actually goes away. Back out before that vertex — Escape, or a session that ends with no shape — and the same card returns, re-anchored to the middle of the current view so it lands on screen even if you panned. The panel now shows whichever shape the engine reports it started, rather than the button that was pressed, so the two cannot disagree.

Demo mission: yes — search a state, click Rectangle (card goes, highlight stays), press Escape before the first corner (card comes back).

No stray cards, and nobody else's card gets taken down

Previously, selecting a far-away area and closing the tool while the camera was still flying produced a card anyway, with buttons wired to a tool that no longer existed. AOI waits for the camera to settle before asking for a card, and the one-shot camera listener and fallback timer doing that waiting were untracked, so nothing could call them off or stop them both firing. Each deferred show is now a tracked slot that teardown, a newer selection, or a failed camera move all disarm, and only one of them can win it.

Previously, with a card already open, clicking another boundary in inspect mode cleared the selection you had just made — that click reached core a beat later as a dismissal of the still-open card. AOI now retracts its own card before it starts the camera move, so there is nothing left for the click to dismiss.

Core has one popup slot, so a blanket hide on teardown would also take down a card another plugin had put there. Every AOI request travels through the plugin's own bus handle, which stamps it with AOI's id, and core honours a hide request only from the card's owner.

Demo mission: yes for the first two — search a far-off state and close the AOI panel immediately, then click boundary after boundary in inspect mode. No for the third; the demo has no second popup consumer.

A drawn line or point can be a selection

Previously, on a mission whose draw shapes include lines or points, you could draw a line, finish it, and get nothing at all: no card, so the line could neither be analyzed nor cleared. The centroid helper only understood points and polygons and the bounds helper only polygons, so a line fell through both.

The fix: both helpers now walk whatever vertices the geometry has, so a line or point gets the same card, anchored at the mean of its vertices. One consequence to know about: a line wider than the current view now moves the camera, where before it never did.

Demo mission: no. The demo leaves the draw shapes at the polygon, rectangle and circle defaults, and search, boundary clicks and uploads all produce polygons.

Decisions to review

  • A drawing session suspends the previous selection, and a cancelled one restores it. The issue put selection behaviour out of scope; the alternatives are to clear the selection outright the moment a drawing starts, or to leave the card up through the session. It matters because the restore path is reachable in ways the issue never describes — leaving Draw mode for Search re-opens the card at the view centre rather than over the area — and because AOI treats any drawing session on the bus as its own, even one another plugin started.
  • Dismissing the card clears the selection, the same as Cancel. The live alternative is to keep the area and its highlight after a dismissal, leaving a selection on screen with no controls attached to it. This is the only place today's behaviour changes for someone who never draws, so it wants an explicit yes.
  • The card is retracted up front on a new selection rather than guarding the dismissal. Correctness rests on core settling the retracted card before the deferred map click goes looking for a popup to dismiss; the alternatives are to ignore dismissals while a show is pending, or to have core suppress the click that opened a card in the first place. If the ordering assumption breaks, the failure is a selection quietly clearing itself.
  • The centroid and bounds helpers were widened from polygons to every geometry. The smaller move was to leave them polygon-only and let lines and points skip the camera step entirely. Two side effects follow: a line selection can now move the camera, and a line boundary now reports a non-zero area, which feeds inspect-layer ordering.
  • The label travels as the card's title, in text, not as markup. The live alternative is to send the label inside the card's HTML body instead of as its text title; keeping it as text makes core's rendering the single escaping point for names that come from boundary properties, search entries and uploaded files. The trade is that AOI's safety now rests on core keeping the title text-only.
  • AOI keeps no record of whether it has a card open. It asks core to hide blind and trusts the owner check, where the alternative is tracking it locally, which the plugin has the state to do. Any request that went out through the bare global instead of the plugin handle would open a card AOI could never retract.
  • plugin:aoi:drawingCancelled is gone from the published contract. Cancel and dismiss both funnel into the cleared event, though core's answer still tells them apart, so the emit could have been kept on the cancel branch. Nothing in the repo listened; an out-of-tree listener loses it silently.
  • Hiding the AOI plugin leaves its card on screen; only unloading closes it. There is no hide-scoped teardown signal to hang a retraction on, and the old overlay behaved the same way, so this is unchanged rather than fixed.

@CarsonDavis
CarsonDavis force-pushed the feature/299-aoi-popup-migration branch from b5e1c36 to e59635c Compare August 11, 2026 22:17
@CarsonDavis
CarsonDavis force-pushed the feature/298-map-popup-service branch from 9c72e45 to cb13772 Compare August 14, 2026 17:14
@CarsonDavis
CarsonDavis force-pushed the feature/299-aoi-popup-migration branch 6 times, most recently from a0216b2 to cc4ed63 Compare August 19, 2026 16:15
@CarsonDavis

CarsonDavis commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up carried over from #303's body (trimmed out of it during review):

Overlay retirementmap:addOverlay/removeOverlay are @deprecated in #303 and this PR removes their last plugin consumer (AOI). Full retirement needs its own follow-up issue once these land: MapControl's measure-distance label (merged after these branches were cut) still uses them.

AOI now asks core for its popup with one serializable request and listens
for the button events on the bus, so the plugin carries no popup DOM, no
popup CSS and no callback across the core boundary.

The Cancel button's event and the popup's dismissEvent are both
plugin:aoi:drawingCancelled, so core's broadcast replaces the plugin's own
emit and the X and a click away clear the selection like Cancel does.

A single _pendingPopup cancel replaces the untracked moveend one-shot and
its fallback timer, cancelled on a superseding selection and on destroy so
a closed tool can never leave a popup behind. It is armed before the camera
is read rather than after: reading the view is asynchronous, and a teardown
or a superseding selection during that hop has to drop this popup instead
of letting it open later. Every path that opens the popup first checks that
it is still the current pending show, so a moveend, the fallback timer and a
rejected fitBounds arbitrate to exactly one popup.

The tooltip's stylesheet goes with its component: the shared .aoi-tool /
.aoi-tooltip rules keep only the tool, and core's popup carries its own CSS.
Covers the map:showPopup request shape, the deferred show, the
hide-before-show ordering, label escaping, the analyze and cancel
hand-offs, and teardown while a show is still pending.

Also pins the three pending-show defects the migration fixes: two rapid
selections leaving two live one-shots, a superseded selection's rejected
fitBounds opening its own popup, and a pending show surviving destroy.
`map:showPopup` now answers its own request with how the popup closed, so
AOI no longer names events for core to broadcast. The two popup event
constants and their subscriptions are gone; `_showPopup` awaits the
request and branches on the result: a primary press hands the feature to
the analysis consumers, a secondary press and a dismissal (the X or a
click on the map) clear the selection, and `closed` — a retract or a
replacement AOI itself caused — leaves the selection alone. A rejected
request, which is how core reports a malformed request or a mount
failure, is warned about instead of surfacing as an unhandled rejection.

Cancelling a pending show also joins `_clearSelection`, so a cleared
selection cannot leave a popup queued behind the camera.

The spec drives the popup slot the way core does — one popup at a time,
answered on its own promise — and covers each outcome, the rejection
path, and the teardown and supersession cases it already pinned.
Take the up-front retract in `_applySelection` off the list of things that
look deletable: the comment now says outright that it is load-bearing. A
reader who knows `map:showPopup` replaces the open popup for itself can
otherwise conclude the extra `map:hidePopup` is redundant, and it is not —
this selection's popup waits for the camera to settle, so without the
retract the previous popup is still the open one when core's deferred
`map:click` dismissal runs, and it answers as a dismissal that clears the
selection just made.

Drop the `_cancelPendingPopup()` guard from `_clearSelection`. It is dead:
`_showPopup` only ever runs with `_pendingPopup` already null (from
`settled`, which cancels first, or from the branch that never arms one),
`_clearSelection` has a single call site on the popup's outcome, and a
selection that arms a new pending popup retracts the open one first, which
answers it `closed` and never reaches the clear. Its spec case went with
it — it exercised a state no path produces.

Split the outcome and rejection handlers into a two-arg `then`, so a throw
out of an `analysisAOIReady` consumer is no longer logged as a failure to
show the popup.

Also list the one-shot `map:moveend` subscription in the header contract
and drop two fake-api accessors the spec never reads.
The selection path now reads `map:getBounds` before it decides whether to
move the camera, so the fake api answers that request and the flush helper
runs the chain of microtasks it adds instead of a single tick. Without both,
every case here asserts against a selection that has not reached its popup
yet.

Three cases follow from that read. A selection already inside the view opens
its popup immediately, with no fitBounds and nothing subscribed to moveend.
A rejected fitBounds opens the popup once, anchored against the view read a
moment earlier, and the fallback timer that follows adds none. And a torn
down or superseded selection is dropped mid-read, before it ever subscribes.

The teardown case now covers the whole contract rather than the popup alone:
closing the tool, switching away from it and collapsing its panel all reach
the plugin through `destroy()`, which has to leave no selection, no highlight
and no popup behind.
`map:hidePopup` empties core's single popup slot whoever filled it, so AOI
retracting unconditionally would close another plugin's popup on teardown or
on the next selection. Track the popup AOI has on screen — the request that
goes unanswered for exactly as long as it is up — and ask for a hide only
while that is AOI's own. The slot is released however the request answers,
including the 'closed' another plugin's popup produces.

A camera step that threw also left `_pendingPopup` pointing at a token
nothing could settle, which claimed a popup was on its way when none was.
Release it from the failure path, and only while it is still that chain's, so
a selection that superseded it keeps its own.

The comment on the deferred show promised more than the code does: the
one-shot settles on the next `map:moveend`, whichever movement produces it.
Say so, and record why it is subscribed before the fit is requested —
`mmgisAPI.request` runs its provider synchronously and a fit with no
transition emits `moveend` from inside that call.
AOI tracked whether the popup on screen was its own so it could stay quiet
otherwise. Core answers that now — it retracts a popup only for the caller
that opened it — so the tracking is gone, and teardown and the up-front
retract in a new selection just ask.

Popup requests go through AOI's own bus handle, which stamps them with AOI's
id; that stamp is the whole reason core can tell. The handle's stand-in gained
a `request` to match, so a tool made without a bus still degrades to no-ops
rather than crashing the first time it reaches for one.

The up-front retract keeps its job of beating the deferred click-away, and is
race-free by construction now: it can only ever close AOI's own popup.
The analyze/cancel card is a title over its two buttons, so the label
travels in the request's `title` field and no html is sent at all. Core
renders a title as text and names the card by it, which is what the
tooltip this replaced announced.
@CarsonDavis
CarsonDavis force-pushed the feature/299-aoi-popup-migration branch from d97bd31 to b550739 Compare August 26, 2026 19:37
…up-migration

# Conflicts:
#	src/essence/Tools/AOI/AOITool.js
A `fitBounds` rejection cannot be cancelled once it is in flight, so it
is the one route into the settle path that outlives the selection that
started it. Left to run it disarms the current selection's pending show
and opens the previous selection's card. The spec holds each fit open so
that rejection can be fired after a second selection has taken over.

The mocked handles stamp their caller the way the real one does, in the
options object, rather than in the argument beside it that the bus now
refuses.
Fold the payload permutations into the main request test, drop the
camera-deferral scenarios aoiSelectionCamera.spec.js already covers, and
keep one pin per ownership outcome.
A drawing session now retracts the previous selection's card and clears
the selection with it: the card held the session's Escape and Enter and
offered to analyze the area being replaced, and it holds the selection's
only controls, so leaving the selection behind would strand it.

Every request AOI makes goes through its plugin handle, so core sees who
asked; on/off stay on the global bus. A LineString or MultiLineString
now gets a vertex-mean centroid, so a drawn line - a supported
drawShapes value - gets its Analyze/Cancel card. selectionTooltipAnchor
is renamed selectionPopupAnchor after the tooltip it no longer anchors.
The fake bus now runs a provider inside `request`, as core does, so the
spec that pins subscribing to `map:moveend` before asking for the fit
fails when the two are swapped. New specs cover the armed disarm - a
teardown or a superseding selection after the camera was read - and the
selection a drawing session takes away. The rejected-show spec restores
a working `map:showPopup` before the second selection, so it can tell
the tool is still usable. Reworded the teardown comment: closing or
unloading the tool reaches destroy(), hiding it does not.
Picking a draw shape retracted the card and dropped the selection with it,
before a single vertex existed, so Escaping back out left nothing to restore.
The selection is now held across the arming, given up on the first committed
vertex, and its card put back when the session ends without one. The shape
`map:drawstart` carries also reaches the panel state, so a mid-session shape
switch no longer drops back to the shape picker while a session is live.
Both now read a selection through one vertex-parts helper, so a line or a
point gets a camera step and the off-screen anchor fallback a polygon gets
instead of skipping straight to an unguarded show. The camera spec's second
moveend also carries a payload unrelated to the centroid it asserts, so a
handler forwarding that payload as the anchor is caught there.
A drawing session that is backed out of now re-anchors the selection's card
against the current view, so a centroid off-screen falls back to the view
centre instead of stranding the selection's only Analyze and Cancel. The
shape-switch flag goes with it: both adapters restart a session without a
cancel of their own, so every cancel that arrives is the user backing out.
@CarsonDavis

Copy link
Copy Markdown
Collaborator Author

Superseded by #424, which carries this migration onto the rebuilt stack (#417#418#419#422#423#424). This branch stays for reference.

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.

1 participant