Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/pages/APIs/JavaScript/Main/Event-Bus-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: '<p>Diameter: 12 km</p>', // 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 `<style>` block does not, and neither does anything that would run script, reach the browser's top layer, or navigate the app away — a link that goes somewhere opens in a tab of its own.

#### `map:hidePopup`

Retracts the caller's own popup, which resolves its `map:showPopup` request with `{ action: 'closed' }`. Ownership decides who may *retract* a popup, never who may open one: a request made through a plugin's handle carries the plugin's address, and a hide answers `false` when the slot holds someone else's popup or nothing at all. A plugin can therefore call it blind. A request made straight on `mmgisAPI` carries no address, so it opens a popup any other address-less caller can retract.

Core closes a plugin's card when the plugin is destroyed, so retracting in `destroy()` is a courtesy rather than a duty.

### Layer Providers

| Provider | Params | Returns | Description |
Expand Down
179 changes: 179 additions & 0 deletions src/essence/Basics/MapPopup_/MapPopup.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* Map-anchored popup.
*
* The card is a fixed-position element placed by MapPopup_, which projects the
* popup's lat/lng to a screen point and offsets the card from it. Colors and
* type come from the theme tokens emitted by src/styles/_theme-export.scss.
* The fallbacks only apply in the classic UI, which does not load a theme
* stylesheet.
*/

.mmgis-map-popup {
position: fixed;
top: 0;
left: 0;
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. */
width: max-content;
min-width: 220px;
max-width: 320px;
/* A card is never taller than the room it is placed in: nothing scrolls a
card that hangs past the bottom edge into view, so its actions row would
be out of reach. This is the cap until the anchor first projects;
MapPopup_ then replaces it with the height of the visible map. The 16px
is the 8px margin MapPopup_ keeps at an edge, counted at the top and at
the bottom. The column lets the content take whatever room the actions
row leaves. */
display: flex;
flex-direction: column;
max-height: calc(100vh - 16px);
/* The card is placed by a transform, which makes it the containing block
for absolutely positioned content, so content that escapes the flow
would paint outside the card's border rather than be clipped by it. */
overflow: hidden;
/* Plugin content that positions itself `fixed` is laid out against the
card and clipped to it rather than against the viewport, where an
`inset: 0` would spread it over an app the user could then neither read
nor click. */
contain: paint;
padding: var(--theme-spacing-2, 1rem);
border: 1px solid var(--theme-color-base-lighter, #dfe1e2);
border-radius: var(--theme-radius-0, 0);
background: var(--theme-color-white, #ffffff);
color: var(--theme-color-ink, #1b1b1b);
font-family: var(
--theme-font-ui,
'Public Sans',
system-ui,
-apple-system,
sans-serif
);
font-size: var(--theme-font-size-sm, 0.875rem);
box-shadow: 0 25px 50px -12px var(--theme-color-shadow, rgba(0, 0, 0, 0.15));
}

/* The heading shares the close control's row, so it keeps the room clear of
the control that the body keeps for itself when there is no heading above
it. It sits outside the scrolling body, which is what lets a long body
slide underneath while the heading stays where it is. */
.mmgis-map-popup__title {
padding-right: var(--theme-spacing-3, 1.5rem);
/* An unbreakable token in a heading paints outside the card just as one
in the body does. */
overflow-wrap: anywhere;
font-size: var(--theme-font-size-md, 1rem);
font-weight: var(--theme-font-weight-bold, 700);
line-height: 1.3;
}

.mmgis-map-popup__content {
padding-right: var(--theme-spacing-3, 1.5rem);
/* A single unbreakable token (a URL, a long feature id) would otherwise
paint outside the card's border. */
overflow-wrap: anywhere;
/* Body longer than the capped card scrolls here rather than pushing the
actions row past the bottom of the card. The zero minimum is what lets
this shrink to the room the actions row leaves it. */
min-height: 0;
overflow-y: auto;
}

/* Under a heading the body is already clear of the close control, so it takes
the full width of the card and only the gap the heading leaves it. */
.mmgis-map-popup__title + .mmgis-map-popup__content {
margin-top: var(--theme-spacing-1, 0.5rem);
padding-right: 0;
}

.mmgis-map-popup__close {
position: absolute;
top: var(--theme-spacing-1, 0.5rem);
right: var(--theme-spacing-1, 0.5rem);
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: var(--theme-spacing-05, 0.25rem);
border: 0;
background: none;
color: var(--theme-color-base, #71767a);
font-family: inherit;
font-size: var(--theme-font-size-md, 1rem);
line-height: 1;
cursor: pointer;
}

.mmgis-map-popup__close:hover {
background: var(--theme-color-base-lightest, #f0f0f0);
color: var(--theme-color-ink, #1b1b1b);
}

/* Equal columns, so two buttons come out the same width rather than sized by
their own labels. Grid rather than a flex row: an `fr` track measures as the
widest label in the row, so the card grows to hold both buttons at that
width, where `flex: 1 1 0` items measure as the sum of their own labels and
leave the equal split too narrow for the longer one. */
.mmgis-map-popup__actions {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: var(--theme-spacing-1, 0.5rem);
margin-top: var(--theme-spacing-105, 0.75rem);
}

.mmgis-map-popup__button {
/* A label reads as one line. Once the card is at its max width there is no
room left to grow into, so an outsized label ends in an ellipsis rather
than wrapping or painting outside the button. The zero minimum lets the
column, not the label, decide how much room there is. */
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: 32px;
padding: 0 var(--theme-spacing-105, 0.75rem);
border-radius: var(--theme-radius-0, 0);
font-family: inherit;
font-size: var(--theme-font-size-sm, 0.875rem);
font-weight: var(--theme-font-weight-normal, 400);
cursor: pointer;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}

/* A lone button sits at the end of the row rather than filling it, and stays
inside the card when its label is too long to fit. */
.mmgis-map-popup__button:only-child {
justify-self: end;
max-width: 100%;
}

/* Every control the card can hand focus to draws its own ring: MMGIS resets
the user agent's away globally (src/css/mmgis.css), so a control without a
ring of its own leaves a keyboard user with no idea where they are. */
.mmgis-map-popup__button:focus-visible,
.mmgis-map-popup__close:focus-visible {
outline: 3px solid var(--theme-color-primary-light, #73b3e7);
}

.mmgis-map-popup__button--primary {
border: 1px solid transparent;
background: var(--theme-color-primary, #005ea2);
color: var(--theme-color-white, #ffffff);
}

.mmgis-map-popup__button--primary:hover {
background: var(--theme-color-primary-dark, #1a4480);
}

.mmgis-map-popup__button--secondary {
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 var(--theme-color-shadow, rgba(0, 0, 0, 0.15));
}

.mmgis-map-popup__button--secondary:hover {
background: var(--theme-color-base-lightest, #f0f0f0);
}
Loading