From 5bcc2c3d928be9dee06c6ab285053bbaf775de51 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Thu, 10 Sep 2026 16:06:58 -0500 Subject: [PATCH 1/3] Add a core-owned, map-anchored popup service driven over the bus 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. --- .../APIs/JavaScript/Main/Event-Bus-API.md | 44 ++ src/essence/Basics/MapPopup_/MapPopup.css | 184 ++++++ src/essence/Basics/MapPopup_/MapPopup_.ts | 616 ++++++++++++++++++ src/essence/Basics/MapPopup_/types.ts | 61 ++ src/essence/Basics/Map_/Map_.js | 37 ++ .../UserInterface_/UserInterfaceModern_.css | 15 + tests/unit/MapPopup_.spec.ts | 614 +++++++++++++++++ tests/unit/pluginTeardownPopup.spec.js | 193 ++++++ 8 files changed, 1764 insertions(+) create mode 100644 src/essence/Basics/MapPopup_/MapPopup.css create mode 100644 src/essence/Basics/MapPopup_/MapPopup_.ts create mode 100644 src/essence/Basics/MapPopup_/types.ts create mode 100644 tests/unit/MapPopup_.spec.ts create mode 100644 tests/unit/pluginTeardownPopup.spec.js diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index 5b88fe9a4..994cdadc1 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -450,6 +450,8 @@ listing. | `map:setView` | `{ center, zoom }` | `true` | Set map view | | `map:fitBounds` | `bounds` | `true` | Fit map to bounds | | `map:panTo` | `{ lat, lng }` | `true` | Pan map to coordinates | +| `map:showPopup` | `MapPopupRequest` | `MapPopupResult` | Show a map-anchored popup at a lat/lng, replacing any current popup. Answers only once the popup closes | +| `map:hidePopup` | none | `boolean` | Retract the caller's own popup, resolving its request with `{ action: 'closed' }`. `false` when the popup showing is someone else's, or there is none | ```javascript // Get current map state @@ -471,6 +473,48 @@ await window.mmgisAPI.request('map:fitBounds', [ await window.mmgisAPI.request('map:panTo', { lat: 45, lng: -120 }) ``` +#### `map:showPopup` + +A map-anchored popup rendered and styled by the core: the plugin sends the content, the core owns the DOM, the theme and the lifecycle. There is a single popup slot and no popup id — a request from any caller replaces the current popup, whose own request then resolves `'closed'`. Nothing about a popup is broadcast on the bus: the outcome travels back on the request's promise, which stays pending for as long as the popup is open. + +```javascript +const api = this.api // injected; address 'crater-info' + +// Pending until the popup closes — hold onto it rather than blocking on it. +const outcome = api.request('map:showPopup', { + latlng: { lat: 45, lng: -120 }, // anchor, tracked as the map moves + title: 'Crater A', // heading, rendered as text + html: '

Diameter: 12 km

', // body, sanitized by the core + primaryAction: { label: 'Analyze' }, + secondaryAction: { label: 'Cancel' } +}) + +outcome.then(({ action }) => { + if (action === 'primary') analyze() + else if (action === 'secondary' || action === 'dismiss') clearSelection() + // 'closed': replaced or retracted — nothing for this plugin to undo. +}, showError) +``` + +`latlng` is required, and so is one of `title` and `html` — buttons are not content, so a request holding neither is rejected. `title` is rendered as text, never as markup. `primaryAction` and `secondaryAction` each carry a `label` and nothing else; a lone action takes the primary styling whichever field it arrived in, and still answers with its own slot. + +The result is `{ action }`: + +| `action` | Meaning | +|----------|---------| +| `'primary'` | The primary button was pressed | +| `'secondary'` | The secondary button was pressed | +| `'dismiss'` | The user dismissed the popup with the X, with Escape, or with a click on empty map. A click that lands on a feature does not dismiss the card | +| `'closed'` | The popup went away without the user acting on it: another `map:showPopup` replaced it, `map:hidePopup` retracted it, the plugin that opened it was destroyed, or the mission switched | + +`html` is sanitized with DOMPurify's defaults before it reaches the DOM. Inline `style` attributes, tables, images and lists survive; a `

A

Cell
Crater A', + }) + + const markup = body() + // A card is plain DOM in the app's document, so an author's stylesheet + // would be a stylesheet for the whole page. + expect(markup).not.toContain('Cell') + expect(markup).toContain('
  • One
  • ') + expect(markup).toContain('alt="Crater A"') + }) + + it('refuses script, handlers, and urls that run something', () => { + show(engine, { + html: '

    Crater A

    go', + }) + + const markup = body() + expect(markup).toContain('Crater A') + expect(markup).not.toContain('onclick') + expect(markup).not.toContain('alert') + expect(markup).not.toContain('javascript:') + }) + + // A link inside a card would navigate the whole app away by default, + // taking the map, the session, and every other plugin with it. + it('refuses at the click anything that would navigate in place', () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + show(engine, { html: 'go' }) + + const click = new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + document.querySelector('a')!.dispatchEvent(click) + + expect(click.defaultPrevented).toBe(true) + expect(open).toHaveBeenCalledWith( + 'https://example.test', + '_blank', + 'noopener,noreferrer' + ) + open.mockRestore() + }) + + it('gives focus back to whatever had it when the popup opened', async () => { + const opener = document.createElement('button') + document.body.appendChild(opener) + opener.focus() + + const outcome = show(engine) + // Focus lands on the card rather than a control inside it, so a screen + // reader reads the card's own name first. + expect(document.activeElement).toBe(card()) + + clickOn(closeButton()) + await nextTick() + + expect(document.activeElement).toBe(opener) + expect(outcome).toEqual(['dismiss']) + }) + + // A card inside the container would hand the map its own clicks: the + // engines listen on the container they were given, so a press on a button + // would read as a press on the map. + it('puts the card beside the map container rather than inside it', () => { + const host = document.createElement('div') + host.appendChild(engine.engine.getContainer()) + document.body.appendChild(host) + + show(engine) + + expect(card().parentElement).toBe(host) + expect(engine.engine.getContainer().contains(card())).toBe(false) + expect(host.lastElementChild).toBe(card()) + }) +}) diff --git a/tests/unit/pluginTeardownPopup.spec.js b/tests/unit/pluginTeardownPopup.spec.js new file mode 100644 index 000000000..380603aea --- /dev/null +++ b/tests/unit/pluginTeardownPopup.spec.js @@ -0,0 +1,193 @@ +import { describe, test, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' + +// Viewer_ pulls in Photosphere/ModelViewer/PDFViewer, which are JSX written in +// .js files that vite's import-analysis can't parse. Nothing here needs the +// real viewers, so stub the aggregator to keep Map_'s import chain parseable. +vi.mock('../../src/essence/Basics/Viewer_/Viewer_', () => ({ default: {} })) + +// The controller resolves a tool's module binding against the real registry. +// Two inert tools are enough to tell a card's owner from a bystander. +vi.mock('../../src/pre/tools', () => ({ + toolModules: { + CraterTool: { make: () => {}, destroy: () => {} }, + DrawTool: { make: () => {}, destroy: () => {} }, + }, +})) + +const { default: Map_ } = await import('../../src/essence/Basics/Map_/Map_') +const { default: L_ } = await import('../../src/essence/Basics/Layers_/Layers_') +const { mapEngineRegistry } = await import( + '../../src/essence/Basics/MapEngines/index' +) +const { mmgisAPI } = await import('../../src/essence/mmgisAPI/mmgisAPI') +const { toolModules } = await import('../../src/pre/tools') +const { default: ToolControllerModern_ } = await import( + '../../src/essence/Basics/ToolController_/ToolControllerModern_' +) + +/** + * The popup service run the way the app runs it: the real controller and the + * real bus into the real providers `Map_.init` registers, with only the map + * engine a stand-in. Ownership is decided in `MapPopup_` and stamped by the + * handle the controller injects, and teardown is announced on the bus with + * neither side naming the other — so the joins are what these specs pin. + */ + +/** Enough of an IMapEngine for `Map_.init` to finish and a popup to mount. */ +function makeStubEngine() { + const container = document.createElement('div') + container.getBoundingClientRect = () => ({ + top: 0, + left: 0, + width: 800, + height: 600, + }) + return { + engineType: 'stub', + init() {}, + destroy() {}, + getNativeMap: () => ({}), + on() {}, + off() {}, + setView() {}, + invalidateSize() {}, + getZoom: () => 5, + getCenter: () => ({ lat: 0, lng: 0 }), + getBounds: () => null, + getLayers: () => [], + getContainer: () => container, + latLngToContainerPoint: () => ({ x: 400, y: 300 }), + } +} + +const popupRequest = { latlng: { lat: 45, lng: -120 }, html: '

    Crater A

    ' } + +const cardCount = () => document.body.querySelectorAll('.mmgis-map-popup').length + +/** Let the bus promises settle. */ +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +/** Load a tool through the controller, which mints and injects its handle. */ +function loadTool(module, address) { + const target = document.createElement('div') + target.id = `${address}-target` + document.body.appendChild(target) + ToolControllerModern_.loadTool( + { id: address, name: address, module }, + target.id + ) +} + +/** Open a popup as a loaded tool would: through the handle core gave it. */ +async function showPopupAs(module) { + const outcome = { result: null } + const settled = toolModules[module].api + .request('map:showPopup', popupRequest) + .then((result) => { + outcome.result = result + }) + await flush() + return { outcome, settled } +} + +beforeAll(() => { + // Map_ captured `window.L` at import; init writes onto it. + window.L.DomEvent = { fakeStop: () => {} } + window.mmgisAPI = mmgisAPI + + L_.configData = { msv: { mapEngine: 'stub' }, look: {} } + L_.layers = { data: {}, layer: {}, dataFlat: [], nameToUUID: {} } + L_.view = [0, 0, 5] + L_.FUTURES = { mapView: null } + L_.UserInterface_ = { isMobile: true } + + class StubAdapter { + constructor() { + return makeStubEngine() + } + } + mapEngineRegistry.register('stub', StubAdapter) + + Map_.init(() => {}) +}) + +afterAll(() => { + delete window.mmgisAPI +}) + +describe('a plugin being torn down', () => { + afterEach(() => { + // loadedTools and the lifecycle registries are module-level + // singletons, so a tool one test leaves loaded is a tool the next + // inherits — and so is the popup slot. + ToolControllerModern_.destroyAllTools() + document.querySelectorAll('[id$="-target"]').forEach((el) => el.remove()) + }) + + test("takes the destroyed plugin's card with it and answers its request", async () => { + loadTool('CraterTool', 'crater') + const { outcome, settled } = await showPopupAs('CraterTool') + expect(cardCount()).toBe(1) + + // The plugin is gone before it could retract the card itself, so core + // empties the slot on the teardown it hears announced. + expect(ToolControllerModern_.unloadPlugin('crater')).toBe(true) + + expect(cardCount()).toBe(0) + await settled + expect(outcome.result).toEqual({ action: 'closed' }) + }) + + test("leaves another plugin's card standing", async () => { + loadTool('CraterTool', 'crater') + loadTool('DrawTool', 'draw') + const { outcome } = await showPopupAs('DrawTool') + expect(cardCount()).toBe(1) + + // The card belongs to a plugin that is still alive to stand behind it, + // and the owner check is what tells the two apart. + expect(ToolControllerModern_.unloadPlugin('crater')).toBe(true) + + await flush() + expect(cardCount()).toBe(1) + expect(outcome.result).toBeNull() + }) + + test('a full teardown empties the slot whoever owned it', async () => { + loadTool('CraterTool', 'crater') + // Opened without a handle, the way an embedding page or one of the + // React tools opens one, so no per-plugin teardown can be matched to + // it. A layout re-render destroys every tool without going near + // `Map_`, and the collective signal is what empties the slot. + const outcome = { result: null } + const settled = mmgisAPI + .request('map:showPopup', popupRequest) + .then((result) => { + outcome.result = result + }) + await flush() + expect(cardCount()).toBe(1) + + ToolControllerModern_.destroyAllTools() + + expect(cardCount()).toBe(0) + await settled + expect(outcome.result).toEqual({ action: 'closed' }) + }) + + test('drops the popup when the map it is anchored to is re-initialised', async () => { + loadTool('CraterTool', 'crater') + const { outcome, settled } = await showPopupAs('CraterTool') + expect(cardCount()).toBe(1) + + // Switching missions re-runs `Map_.init`, which destroys the engine the + // card is anchored to and its subscriptions along with it. The card is + // hosted beside the map container, so nothing else takes it down: it + // has to leave with the map rather than hang over the new one. + Map_.init(() => {}) + + expect(cardCount()).toBe(0) + await settled + expect(outcome.result).toEqual({ action: 'closed' }) + }) +}) From 8ce428820ff4021dee26a6f6944e9db3f702aeda Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Thu, 10 Sep 2026 16:29:37 -0500 Subject: [PATCH 2/3] Trim the popup module's comments, unwind on every close path, and tighten its tests --- src/essence/Basics/MapPopup_/MapPopup.css | 7 +- src/essence/Basics/MapPopup_/MapPopup_.ts | 228 +++++----------- src/essence/Basics/Map_/Map_.js | 14 +- .../UserInterface_/UserInterfaceModern_.css | 11 +- tests/unit/MapPopup_.spec.ts | 249 +++++++++++------- tests/unit/pluginTeardownPopup.spec.js | 29 +- 6 files changed, 253 insertions(+), 285 deletions(-) diff --git a/src/essence/Basics/MapPopup_/MapPopup.css b/src/essence/Basics/MapPopup_/MapPopup.css index 05d07b8a9..aa75dcf1e 100644 --- a/src/essence/Basics/MapPopup_/MapPopup.css +++ b/src/essence/Basics/MapPopup_/MapPopup.css @@ -12,11 +12,6 @@ position: fixed; top: 0; left: 0; - /* A positioned box at `z-index: auto`: it paints in the positioned step, - above every non-positioned sibling whatever tree order says, so the - card covers the map without asking for a level. Anything the app means - to paint over the card — the panel regions, a modal — has to be - positioned itself and carry a level of its own. */ box-sizing: border-box; /* Shrink to fit the content: the card is placed by a transform, so it has no containing block to resolve a percentage width against. */ @@ -176,7 +171,7 @@ border: 1px solid var(--theme-color-base-lighter, #dfe1e2); background: var(--theme-color-white, #ffffff); color: var(--theme-color-ink, #1b1b1b); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + box-shadow: 0 1px 2px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); } .mmgis-map-popup__button--secondary:hover { diff --git a/src/essence/Basics/MapPopup_/MapPopup_.ts b/src/essence/Basics/MapPopup_/MapPopup_.ts index 45ec9069b..78a4b05ec 100644 --- a/src/essence/Basics/MapPopup_/MapPopup_.ts +++ b/src/essence/Basics/MapPopup_/MapPopup_.ts @@ -11,36 +11,18 @@ const ANCHOR_GAP = 12 const VIEWPORT_MARGIN = 8 /** - * Where a card waits when it has nowhere on screen to be: mid-zoom, once the - * anchor has panned off the map, and before the engine can project it at all. - * - * A transform rather than a `visibility`, which plugin content is free to set - * back to `visible` on itself, or a `display: none`, which zeroes the box - * `_reposition` measures to work out where the card goes when it returns. + * Where a card waits when it has nowhere on screen to be: mid-zoom, panned off + * the map, or not yet projectable. Not a `display: none`, which would zero the + * box `_reposition` measures. */ const PARKED = 'translate(-100000px, -100000px)' /** - * How plugin content is sanitized: DOMPurify's own defaults — curated upstream - * and kept current by the caret range the dependency floats on — plus the two - * capabilities the shape of a card asks it to name outright. - * - * What the defaults leave is that an author owns the inside of their card and - * nothing else. Form controls and a `` arrive as inert content, since - * the contract carries no script, and a `
    ` that tries to submit is - * stopped at the click by {@link guardNavigation}. + * Defaults, plus `