Skip to content

Add a core-owned, map-anchored popup service driven over the bus - #423

Draft
CarsonDavis wants to merge 6 commits into
feature/350-tool-identity-v2from
feature/298-map-popup-service-v2
Draft

Add a core-owned, map-anchored popup service driven over the bus#423
CarsonDavis wants to merge 6 commits into
feature/350-tool-identity-v2from
feature/298-map-popup-service-v2

Conversation

@CarsonDavis

@CarsonDavis CarsonDavis commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator
Category Lines added %
Tests 882 51.2%
Production code 795 46.2%
Docs 44 2.6%

A plugin can now ask core for an anchored popup card with a plain JSON request and get the outcome back on the request's own promise.

Closes #298

No plugin in this PR opens a card yet; AOI moves onto it in the next PR. On the demo mission, open one from the console with window.mmgisAPI.request('map:showPopup', { latlng: { lat: 18, lng: 77 }, title: 'X', html: '<p>hi</p>', primaryAction: { label: 'Go' } }). That is the address-less path, so map:hidePopup from a tool's handle answers false for it.

Plain-data request, promise answer

Previously an anchored popup meant a plugin handing a live mount function and its own component tree across the core/plugin boundary through map:addOverlay. The payload is now JSON only, and the promise stays pending until the card closes. It resolves primary or secondary for a button, dismiss when the user closes it (X, Escape, a click on empty map), or closed when code does (a replacing request, map:hidePopup, teardown). Nothing is broadcast.

Core owns the card's DOM

Previously each plugin styled its own popup. The card is now plain DOM under the app's theme tokens, and html runs through DOMPurify defaults plus forbidden <style> and popover: inline style and tables survive, <script>, onclick and javascript: are stripped. Links inside open in a new tab.

The card tracks its anchor

Previously only moveend moved AOI's popup, so it snapped at the end of a drag. It now follows move, moveend, zoomend and resize, clamps to the map, and flips below the anchor when it would clip the top. It parks off-screen while the anchor is off-map or a zoom runs: Leaflet's animated zoom emits no move, deck.gl emits every frame and keeps tracking.

Who can close a card, and when core does

map:hidePopup returns false unless the slot holds your own card. Core also closes cards on its own: it hides the card on plugins:destroyed for that owner, and empties the slot on plugins:allDestroyed and on mission re-init. Both are covered by tests only.

Clicks and keys

A click carrying a feature never dismisses the card; a click on empty map does. A card opened from a click does not dismiss itself on that click, because the adapters fan clicks out only to subscribers alive when the click was reported. The card is a role="dialog": focus moves to it, Escape closes it and returns focus, Tab leaves normally, nothing is trapped.

Panels paint above the card

Compact-layout panel regions now get their own stacking level. Previously a card could paint over them.

Decisions to review

  • Address-less callers collapse into one shared identity. Anything requesting on the global bus (an embedding page, core code, a React tool with no minted handle) can retract any other address-less card; the alternative is refusing map:hidePopup with no address.
  • Links in card content are forced into a new tab by a capture-phase listener; only fragment hrefs pass through. A relative or in-app link silently becomes a new browser tab; the alternative is a DOMPurify hook adding target="_blank" rel="noopener", keeping the HTML policy in one place.
  • The service anchors the card itself rather than building on IMapEngine.addOverlay(). It projects with latLngToContainerPoint and subscribes to the camera events directly, so core now carries two lat/lng-anchored overlay paths with different clipping, stacking and click semantics.
  • On zoom the card parks off-screen instead of animating. On Leaflet it vanishes for the whole zoom animation and returns on zoomend while deck.gl tracks every frame; the alternative is Leaflet's zoomanim plus a CSS transform, as Leaflet's own popups do.
  • Click-away is decided synchronously by whether the click hit a feature. A plugin opening a card from an empty-map click has its previous card resolved as dismiss before the new request lands, so a plugin clearing state on dismiss would wipe what its next card just set.

A plugin opens a card by sending plain data to map:showPopup and learns how it closed from the request's own promise. The card is plain DOM under the app's stylesheet, sanitized with DOMPurify's defaults, tracks its anchor across camera moves on both engines, and is dismissed by Escape, its close button, or a click on empty map; a click on a feature never dismisses it. Only the plugin that opened a card can retract it, and core closes a plugin's card when that plugin is destroyed.
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