diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index 148ac01d35..f847de1772 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -1,818 +1,843 @@ ---- -layout: page -title: Event Bus API -permalink: /apis/javascript/event-bus -parent: Main -grand_parent: JavaScript API -nav_order: 1 ---- - -# Event Bus API - -The MMGIS Event Bus provides a lightweight pub/sub and request/response system for plugin communication. It enables decoupled communication between tools, modules, and external integrations. - -## Overview - -The Event Bus is built on [mitt](https://github.com/developit/mitt) and exposes two communication patterns: - -1. **Pub/Sub (Events)**: Fire-and-forget event broadcasting -2. **Request/Response (Providers)**: Synchronous or async data requests - -All methods are available on `window.mmgisAPI`. - ---- - -## Pub/Sub API - -### `mmgisAPI.on(event, handler)` - -Subscribe to an event. - -**Parameters:** -- `event` (string): Event name using namespace:action format -- `handler` (function): Callback function receiving event data - -**Returns:** Unsubscribe function - -```javascript -// Subscribe to layer visibility changes -const unsubscribe = window.mmgisAPI.on('layer:visibilityChange', (data) => { - console.log(`Layer ${data.layerName} is now ${data.on ? 'visible' : 'hidden'}`) -}) - -// Later, unsubscribe when done -unsubscribe() -``` - -### `mmgisAPI.off(event, handler)` - -Unsubscribe from an event (alternative to using the returned unsubscribe function). - -**Parameters:** -- `event` (string): Event name -- `handler` (function): The same handler function passed to `on()` - -```javascript -function myHandler(data) { - console.log(data) -} - -window.mmgisAPI.on('tool:change', myHandler) - -// Later -window.mmgisAPI.off('tool:change', myHandler) -``` - -### `mmgisAPI.emit(event, data)` - -Emit an event to all subscribers. - -**Parameters:** -- `event` (string): Event name -- `data` (any): Data to pass to subscribers - -```javascript -// Emit a custom event from your plugin -window.mmgisAPI.emit('plugin:myPlugin:dataUpdated', { - timestamp: Date.now(), - values: [1, 2, 3] -}) -``` - ---- - -## Request/Response API - -### `mmgisAPI.provide(name, handler)` - -Register a provider that responds to requests. - -**Parameters:** -- `name` (string): Provider name using namespace:action format -- `handler` (function): Handler function that returns data (can be async) - -**Returns:** Cleanup function to remove the provider - -```javascript -// Register a provider in your plugin -const cleanup = window.mmgisAPI.provide('plugin:myPlugin:getData', (params) => { - return { - value: params.id * 2, - timestamp: Date.now() - } -}) - -// Later, remove the provider -cleanup() -``` - -### `mmgisAPI.request(name, params)` - -Request data from a provider. - -**Parameters:** -- `name` (string): Provider name -- `params` (any): Parameters to pass to the provider - -**Returns:** Promise resolving to the provider's response - -**Throws:** Error if no provider is registered for the name - -```javascript -// Request data from a provider -try { - const center = await window.mmgisAPI.request('map:getCenter') - console.log(`Map center: ${center.lat}, ${center.lng}`) -} catch (err) { - console.error('Provider not available:', err.message) -} -``` - -### `mmgisAPI.hasHandler(name)` - -Check if a provider is registered. - -**Parameters:** -- `name` (string): Provider name - -**Returns:** boolean - -```javascript -if (window.mmgisAPI.hasHandler('map:getCenter')) { - const center = await window.mmgisAPI.request('map:getCenter') -} -``` - -This asks whether this build of core offers the capability, not whether core is -currently in a state to serve it. The panel and plugin providers, for instance, -register at page load and read `true` with no layout mounted at all. Use it to -guard against a core too old to know the name; read the request's own answer — -an empty listing, or a `layout-inactive` refusal — for whether there is -anything there right now. - ---- - -## Plugin Scoped API - -MMGIS automatically injects a scoped API into each tool as `this.api`. This API automatically prefixes event and provider names with `plugin:{pluginId}:`, where `pluginId` is derived from the tool's module name (e.g., `DrawTool` → `draw`). - -> **Note:** Each plugin must have a unique ID. Multiple instances of the same plugin in a mission are not currently supported. If two plugins share the same ID, their events and providers will collide. This constraint is not currently enforced at runtime but may be in a future version. - -The scoped API is available on `this.api` in your tool's `initialize()` and `make()` functions: - -```javascript -const MyTool = { - initialize() { - // this.api is automatically available - this.api.provide('getData', () => this.data) - }, - - make() { - this.api.emit('ready', { timestamp: Date.now() }) - } -} -``` - -### Scoped `emit(event, data)` - -Emit an event with auto-prefixed name. - -```javascript -const api = window.mmgisAPI.forPlugin('myPlugin') - -// This emits 'plugin:myPlugin:dataUpdated' -api.emit('dataUpdated', { value: 42 }) - -// Subscribers listen using the full path -window.mmgisAPI.on('plugin:myPlugin:dataUpdated', (data) => { - console.log(data.value) // 42 -}) -``` - -### Scoped `provide(name, handler)` - -Register a provider with auto-prefixed name. - -**Returns:** Cleanup function - -```javascript -const api = window.mmgisAPI.forPlugin('myPlugin') - -// This registers 'plugin:myPlugin:getData' -const cleanup = api.provide('getData', (params) => { - return { result: params.input * 2 } -}) - -// Callers request using the full path -const data = await window.mmgisAPI.request('plugin:myPlugin:getData', { input: 21 }) -console.log(data.result) // 42 - -// Later, remove the provider -cleanup() -``` - -### Metadata Properties - -The scoped API also exposes metadata: - -```javascript -const api = window.mmgisAPI.forPlugin('myPlugin') - -console.log(api.pluginId) // 'myPlugin' -console.log(api.prefix) // 'plugin:myPlugin:' -``` - -### Complete Plugin Example - -```javascript -// MyPluginTool.js - this.api is automatically injected by ToolController -const MyPluginTool = { - _cleanups: [], - - initialize() { - // this.api is available here (auto-injected) - // Register providers (auto-prefixed to 'plugin:myplugin:getData') - this._cleanups.push( - this.api.provide('getData', () => this.data), - this.api.provide('setData', (newData) => { this.data = newData }) - ) - - // Subscribe to events (use full paths via mmgisAPI) - this._cleanups.push( - window.mmgisAPI.on('layer:visibilityChange', this.handleLayerChange.bind(this)) - ) - }, - - make() { - // this.api also available here - this.api.emit('ready', { timestamp: Date.now() }) - }, - - destroy() { - this._cleanups.forEach(fn => fn()) - this._cleanups = [] - }, - - notifyUpdate() { - // Emit events (auto-prefixed to 'plugin:myplugin:updated') - this.api.emit('updated', { timestamp: Date.now() }) - } -} -``` - ---- - -## Available Events - -### Layer Events - -| Event | Payload | Description | -|-------|---------|-------------| -| `layer:visibilityChange` | `{ layerName, on }` | Fired when a layer is toggled on/off | -| `layer:refreshStatusChange` | `{ layerName, failed }` | Fired when a layer refresh succeeds or fails | -| `layer:headerStateChange` | `{ header_id, onState }` | Fired when a layer group header is expanded/collapsed | -| `layers:orderChanged` | `{ order }` | Fired after the draw order changes: a reorder, or a layer added or removed. `order` lists layer UUIDs top first | - -```javascript -window.mmgisAPI.on('layer:visibilityChange', ({ layerName, on }) => { - console.log(`${layerName} visibility: ${on}`) -}) -``` - -### Tool Events - -| Event | Payload | Description | -|-------|---------|-------------| -| `tool:change` | `{ toolName }` | Fired when the active tool changes | - -```javascript -window.mmgisAPI.on('tool:change', ({ toolName }) => { - console.log(`Active tool: ${toolName}`) -}) -``` - -### Feature Events - -| Event | Payload | Description | -|-------|---------|-------------| -| `feature:active` | `{ layerName, feature, layer }` | Fired when a feature becomes active/selected | - -```javascript -window.mmgisAPI.on('feature:active', ({ layerName, feature }) => { - console.log(`Selected feature in ${layerName}:`, feature.properties) -}) -``` - -### Time Events - -| Event | Payload | Description | -|-------|---------|-------------| -| `time:changed` | `{ startTime, currentTime, endTime }` | Fired after a time change is committed, carrying the new committed state. Pairs with `time:changeRequested` | -| `time:changeRequested` | `{ startTime, currentTime, endTime }` | Emitted by a plugin to ask core to commit a new time window. ISO 8601 strings. Ignored while time is disabled for the mission | -| `time:toggleUI` | `{ active }` | Fired when time UI is shown/hidden | - -```javascript -window.mmgisAPI.on('time:changed', ({ startTime, currentTime, endTime }) => { - console.log(`Time range: ${startTime} - ${endTime}`) -}) - -// Ask core to move the current time. Core commits it and broadcasts the -// result on 'time:changed' — including back to the plugin that asked. -window.mmgisAPI.emit('time:changeRequested', { - startTime: '2024-01-01T00:00:00.000Z', - endTime: '2024-12-31T23:59:59.000Z', - currentTime: '2024-06-15T12:00:00.000Z' -}) -``` - -### Legend Events - -| Event | Payload | Description | -|-------|---------|-------------| -| `legend:made` | `{ layerName, id, legendData }` | Fired when a legend is created | - -```javascript -window.mmgisAPI.on('legend:made', ({ layerName, legendData }) => { - console.log(`Legend created for ${layerName}`) -}) -``` - -### Panel and Plugin Events - -| Event | Payload | Description | -|-------|---------|-------------| -| `panels:changed` | `{ panels }` | Fired whenever the panel layout changes — a panel registered or unregistered, changed state, lost a tool, or was resized — and once with an empty listing when the layout is torn down | -| `plugins:changed` | `{ plugins }` | Fired whenever a plugin is shown, hidden, loaded or unloaded by command, once after a batch of plugins loads with the layout, and once with an empty listing when the layout is torn down | - -`panels` carries the same listing [`panels:getAll`](#panel-and-plugin-providers) -returns, and `plugins` the same listing `plugins:getAll` returns, so there is -nothing to re-request. Setting something to the state it already holds is a -quiet no-op — no event fires. - -Tearing a layout down — a mission swap, for instance — fires each event once -more, carrying an empty listing. The bus outlives the layout, so a plugin that -subscribed is still subscribed after the panels and plugins it was following -are gone; the empty listing is what tells it so. Only that final listing goes -out, never the intermediate half-dismantled layouts, and a request made from -here on agrees with it: `panels:getAll` and `plugins:getAll` return empty -listings and every panel or plugin command refuses with `layout-inactive`. - -So a subscriber cannot assume a panel or plugin it knows about is still in the -listing it receives: - -```javascript -window.mmgisAPI.on('panels:changed', ({ panels }) => { - const mine = panels.find((p) => p.id === 'left-panel') - // Absent once the panel is unregistered, and once the layout is torn down. - if (mine == null) return - console.log(`left-panel is now ${mine.state}`) -}) -``` - -A component that seeds from `panels:getAll` and also subscribes to -`panels:changed` must guard the seed so it cannot overwrite state an event -has already delivered — the request can resolve after a later event lands. - -### WebSocket Events - -| Event | Payload | Description | -|-------|---------|-------------| -| `websocket:change` | `{ data }` | Fired on WebSocket messages | - ---- - -## Available Providers - -### Map Providers - -| Provider | Params | Returns | Description | -|----------|--------|---------|-------------| -| `map:getCenter` | none | `{ lat, lng }` | Get map center coordinates | -| `map:getBounds` | none | `LatLngBounds` | Get map bounds | -| `map:getZoom` | none | `number` | Get current zoom level | -| `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 card, replacing any current one. Answers only once it closes. `latlng` is required, and so is one of `title` (text) and `html` (sanitized); each action carries a `label`, and focus lands on the first one when the card opens | -| `map:hidePopup` | none | `true` | Take the current card down, resolving its request with `{ action: 'closed' }` | -| `map:getEngineType` | none | `'leaflet' \| 'deckgl' \| null` | Which engine is drawing the map, for a plugin whose behaviour differs by engine. Null until the mission has built one | - -```javascript -// Get current map state -const center = await window.mmgisAPI.request('map:getCenter') -const zoom = await window.mmgisAPI.request('map:getZoom') -const bounds = await window.mmgisAPI.request('map:getBounds') - -// Modify map view -await window.mmgisAPI.request('map:setView', { - center: { lat: 45, lng: -120 }, - zoom: 10 -}) - -await window.mmgisAPI.request('map:fitBounds', [ - [44, -121], - [46, -119] -]) - -await window.mmgisAPI.request('map:panTo', { lat: 45, lng: -120 }) - -// Pending until the card closes — hold onto it rather than awaiting inline. -window.mmgisAPI.request('map:showPopup', { - latlng: { lat: 45, lng: -120 }, - title: 'Crater A', - html: '

Diameter: 12 km

', - primaryAction: { label: 'Analyze' } -}).then(({ action }) => { if (action === 'primary') analyze() }) -``` - -`map:showPopup` resolves `{ action }` once the card closes: - -| `action` | Meaning | -|----------|---------| -| `'primary'` / `'secondary'` | That action's button was pressed | -| `'dismiss'` | The map library took the card down: its own close control, or a click on the map | -| `'closed'` | Code took it down: another `map:showPopup` replaced it, `map:hidePopup` retracted it, or the map was re-initialized | - -### Layer Providers - -| Provider | Params | Returns | Description | -|----------|--------|---------|-------------| -| `layers:getAll` | none | `string[]` | Get all layer names | -| `layers:getVisible` | none | `object` | Get visibility state of all layers. | -| `layers:getConfig` | `layerUUID` | `object \| null` | Get layer configuration. The answer is a copy: to change a layer, use `layers:updateConfig` | -| `layers:toggle` | `layerUUID` | `boolean \| null` | Toggle layer visibility | -| `layers:getOrder` | none | `string[]` | Draw order as layer UUIDs, top first, headers excluded | -| `layers:setOrder` | `{ order }` | `boolean` | Apply a full permutation of the draw order (UUIDs, or display names where the name is unique). Returns `false` and changes nothing if `order` is not an array or a layer is missing, unknown, or repeated. On Leaflet, vectors always draw above rasters whatever the order says | -| `layers:getLegend` | `layerUUID` (optional, UUID or display name) | `LayerLegend \| null` for one layer (`null` if unknown), or `Record` keyed by UUID when called with no argument | Get a layer's resolved legend: `type` (`'gradient' \| 'categorical' \| 'text' \| 'none'`), `stops` (resolved CSS colors or null), `min`/`max` (numbers or null when unset), `unit` (`{ label }` or null), `swatches` (`{ color, label }[]` or null), and `colormap` (name or null). A layer whose legend fails to build answers `type: 'none'` | -| `layers:getTemporalExtent` | `layerUUID` (optional) | `{ start, end, interval, periodAnchor } \| null`, or a UUID-keyed map of them | When the layer has data: `start`/`end` are ISO datetimes (or `null`) with `now`-style policies in `dataStartTime`/`dataEndTime` resolved at the moment of asking, and a periodic layer's `end` floored to its last step. `interval` is the layer's parsed `time.interval` as `{ years, months, weeks, days, hours, minutes, seconds }`, or `null` when it declares none or an unparseable one. `periodAnchor` is the ISO instant the layer's requested periods step from, or `null` when it requests none. Called without a layer, answers for every layer. A tile, vector or vector tile layer with an `interval` of an hour or more requests only the period holding the cursor, not the whole Time Control window, unless the layer lists Data Dates (`time.dataDates`): see `getLayerStartTime` in the Main JavaScript API | - -```javascript -// Get layer information -const allLayers = await window.mmgisAPI.request('layers:getAll') -const visibleLayers = await window.mmgisAPI.request('layers:getVisible') -const config = await window.mmgisAPI.request('layers:getConfig', 'myLayerName') -const legends = await window.mmgisAPI.request('layers:getLegend') // keyed by UUID - -// Toggle a layer -const newState = await window.mmgisAPI.request('layers:toggle', 'myLayerName') - -// Move the bottom layer to the top -const order = await window.mmgisAPI.request('layers:getOrder') -const applied = await window.mmgisAPI.request('layers:setOrder', { - order: [order[order.length - 1], ...order.slice(0, -1)], -}) -``` - -### Time Providers - -| Provider | Params | Returns | Description | -|----------|--------|---------|-------------| -| `time:isEnabled` | none | `boolean` | Whether the mission has time enabled. The getters below return `null` both when time is off and when it is on but not yet seeded | -| `time:getCurrent` | none | `string` | Get current time | -| `time:getStart` | none | `string` | Get start time | -| `time:getEnd` | none | `string` | Get end time | -| `time:getCurrentFormatted` | none | `string` | Current time rendered through the mission's `time.format` (a d3 time format specifier such as `%Y-%m-%d`); `null` until time is enabled and seeded | -| `time:formatTime` | `string \| number` | `string` | A caller-supplied time rendered through that same mission format; `null` for a missing or unparseable time | -| `time:getTimeZone` | none | `string` | Name of the zone `time:getCurrentFormatted` and `time:formatTime` write in (`'UTC'`), for labelling the times they return | -| `time:getMode` | none | `string` | Mode of the bottom Time UI bar: `'range'` or `'point'` (in point mode `time:getStart` is the epoch, not a real start); `null` until time is enabled and seeded, and when the bar isn't mounted (mobile and the modern layout) | -| `time:set` | `{ startTime, endTime, currentTime, ... }` | `boolean` | Set time range | - -```javascript -// Get time state -const timeEnabled = await window.mmgisAPI.request('time:isEnabled') -const current = await window.mmgisAPI.request('time:getCurrent') -const start = await window.mmgisAPI.request('time:getStart') -const end = await window.mmgisAPI.request('time:getEnd') -const mode = await window.mmgisAPI.request('time:getMode') // 'range' | 'point' | null - -// Set time range -await window.mmgisAPI.request('time:set', { - startTime: '2024-01-01T00:00:00Z', - endTime: '2024-12-31T23:59:59Z', - currentTime: '2024-06-15T12:00:00Z' -}) -``` - -### Globe Providers - -| Provider | Params | Returns | Description | -|----------|--------|---------|-------------| -| `globe:getMouse` | none | `{ lng, lat } \| null` | Get mouse position on globe | -| `globe:getZoom` | none | `number \| null` | Get globe zoom level | - -```javascript -const mousePos = await window.mmgisAPI.request('globe:getMouse') -if (mousePos) { - console.log(`Mouse at: ${mousePos.lat}, ${mousePos.lng}`) -} -``` - -### Panel and Plugin Providers - -| Provider | Params | Returns | Description | -|----------|--------|---------|-------------| -| `panels:getAll` | none | `PanelInfo[]` | Every panel in the layout, ordered by priority | -| `panels:setState` | `{ panelId, state }` | `CommandResult` | Move a panel to `collapsed`, `expanded`, `iconified` or `focused` | -| `panels:show` | `{ panelId }` | `CommandResult` | Restore a collapsed panel to its last visible state | -| `panels:hide` | `{ panelId }` | `CommandResult` | Collapse a panel without destroying its contents | -| `plugins:getAll` | none | `PluginInfo[]` | Every plugin the layout knows, with its lifecycle state | -| `plugins:setState` | `{ pluginId, state }` | `CommandResult` | Move a plugin to `unloaded`, `hidden` or `visible` | -| `plugins:show` | `{ pluginId }` | `CommandResult` | Reveal a plugin, loading it first if it is unloaded | -| `plugins:hide` | `{ pluginId }` | `CommandResult` | Hide a plugin, preserving its instance and state | - -Each `PanelInfo` carries what it takes to target one: `id`, `position`, current -`state`, and the `toolIds` it holds — which is how a plugin recognizes the panel -it lives in. -Each `PluginInfo` carries `id` and `state`. - -`show` and `hide` are thin sugar over `setState` — they resolve a target state -rather than flipping whatever they find. `panels:show` restores the panel's -`lastVisibleState`; `plugins:show` resolves to `visible`, loading the plugin -first if it is unloaded. `panels:hide` and `plugins:hide` always target -`collapsed`/`hidden`. - -`panels:show` only ever lifts a panel out of `collapsed`. Anything else already -counts as visible, so the panel is left exactly where it is and the result -reports `changed: false` — which is what stops it from moving a panel the user -expanded down to whatever smaller state the configuration defaults to. - -`iconified` counts as visible under that rule, and every stock layout starts its -left panel there. So "reveal the panel I live in" is two steps, not one: show -it, then read the `state` the result carries and ask for a larger one if an -icon rail is not enough. Reading the result is the general pattern — `show` -reports where the panel actually ended up, which is not always where the caller -wanted it. - -```javascript -const shown = await window.mmgisAPI.request('panels:show', { panelId: 'left-panel' }) -if (shown.ok && shown.state === 'iconified') { - await window.mmgisAPI.request('panels:setState', { - panelId: 'left-panel', - state: 'expanded', - }) -} -``` - -Every command returns a `CommandResult` rather than a bare boolean: - -```ts -{ ok: true, state: 'collapsed', changed: true } -{ ok: false, reason: 'not-found' | 'state-not-allowed' | 'no-visible-state' - | 'layout-inactive' | 'bad-request' | 'load-failed' - | 'transition-failed' } -``` - -`not-found` is an unknown id. `state-not-allowed` is a transition the panel's -own configuration forbids — a float panel refusing `iconified`, for example. -`no-visible-state` is a `panels:show` with no allowed state left to restore to. -`bad-request` is a malformed payload: a missing or non-string id, or a state -name outside the vocabulary in the table above. Note the difference between the -last two — a state that does not exist anywhere is `bad-request`, your mistake, -while a real state this particular target forbids is `state-not-allowed`. The -payload is judged first, so a command carrying both an unknown id and an -unrecognised state reports `bad-request`. `load-failed` is any `plugins:*` command -that must load an unloaded plugin first and fails to. `transition-failed` is -core failing to carry out a transition it accepted — distinct from -`not-found`, which means the id named nothing. `layout-inactive` covers -two cases a caller cannot distinguish — a core too old to have registered the -handler, or a core that has it but currently has no layout mounted — and -deliberately collapses them, since both mean the same thing to a caller: there -is nothing to command. - -Commands name the state they want rather than flipping whatever they find, so a -control drawn from a listing read moments earlier still lands on the state the -click asked for, and a failed request is safe to retry. Asking for the state -something already holds succeeds with `changed: false` and broadcasts nothing. - -```javascript -const panels = await window.mmgisAPI.request('panels:getAll') -const mine = panels.find((p) => p.toolIds.includes('myPlugin')) - -const result = await window.mmgisAPI.request( - mine.state === 'collapsed' ? 'panels:show' : 'panels:hide', - { panelId: mine.id } -) -if (!result.ok) console.warn(`Refused: ${result.reason}`) -``` - -### App Providers - -| Provider | Params | Returns | Description | -|----------|--------|---------|-------------| -| `app:getMissionPath` | none | `string` | Get current mission path | - -```javascript -const missionPath = await window.mmgisAPI.request('app:getMissionPath') -console.log(`Current mission: ${missionPath}`) -``` - -### Plugin Providers - -| Provider | Params | Returns | Description | -|----------|--------|---------|-------------| -| `plugin:info:showFeature` | `{ layerName, layer }` | `boolean` | Show feature in InfoTool | -| `plugin:info:getCurrentFeature` | none | `{ layerName, feature } \| null` | Get currently displayed feature | -| `plugin:draw:getActiveFeature` | none | `feature \| null` | Get active draw feature | -| `plugin:draw:getFilesOn` | none | `string[]` | Get enabled draw files | -| `plugin:layers:populateCogScale` | `layerName` | `true` | Update COG scale UI | - ---- - -## Event Naming Conventions - -Events and providers follow a namespace:action pattern: - -- **Core namespaces**: `map:`, `layers:`, `time:`, `globe:`, `panels:`, `plugins:`, `app:` -- **Plugin namespaces**: `plugin:pluginName:` -- **Custom namespaces**: `custom:yourNamespace:` - -Examples: -- `map:getCenter` - Core map provider -- `layer:visibilityChange` - Core layer event -- `plugin:draw:getActiveFeature` - DrawTool plugin provider -- `custom:analytics:trackEvent` - Custom event for external integration - ---- - -## Config Action Strings - -Some tools accept an action as a plain string in their mission configuration — -Title's action buttons, for instance — rather than wiring a handler in code. -These strings resolve through one shared resolver -(`src/essence/Tools/_shared/actions/resolveAction.ts`), so config authors read -the same request names documented above instead of a second, tool-specific -vocabulary. - -| Form | Resolves to | -|------|-------------| -| `https://…` or `http://…` | Opens in a new tab (`noopener,noreferrer`) | -| `panels:show:` / `panels:hide:` | `request('panels:show' \| 'panels:hide', { panelId })` | -| `plugins:show:` / `plugins:hide:` | `request('plugins:show' \| 'plugins:hide', { pluginId })` | -| anything else | `emit(action)` as a plain event | - -```javascript -// Title tool config -{ - "actionButtonLink": "panels:hide:left-panel" -} -``` - -Only these four single-target verbs are expressible as a config string — -`setState` takes both a target and a state, which a colon-delimited string has -no escaping rules for. A mission that needs a specific target state does it in -code, not config. - -A target may itself contain colons; everything after the verb is taken as the -target whole, so `panels:hide:group:left` hides a panel literally named -`group:left`, not a group called `left`. Under a reserved namespace (`panels:`, -`plugins:`, `core:`, matched exactly — `Panels:hide:left` is not treated as a -core action), an unrecognized verb or a missing target is reported with -`console.warn` naming the supported actions, rather than silently falling -through to `emit` — a typo under a reserved namespace is a config mistake -worth surfacing, not a custom event. - -`core:` is reserved outright: it defines no verbs, so any `core::` -string — including the `core:showPlugin:`, `core:togglePanel`, and -`core:unloadPlugin` syntax some mission configs still use — is reported with -`console.warn` and the list of supported actions above, rather than falling -through to a plain `emit` that nothing listens for. There is no drop-in -replacement for every verb: panels only expose separate show/hide actions, -and unloading a plugin needs a state argument that a colon-delimited string -can't carry — those calls belong in code, not a config string. - -A string with no colon at all has no namespace, so it resolves like any other -non-core action — a plain `emit` of exactly what was written. That is legal -but seldom what an author means: every tool publishes under -`plugin::`, so a bare word like `refresh` emits an event named -`refresh` that no tool listener is subscribed to. It is emitted anyway and -reported with `console.warn`, naming the fully qualified form instead — -otherwise the button looks wired up and does nothing. - ---- - -## Best Practices - -### 1. Always Unsubscribe - -Store unsubscribe functions and call them during cleanup: - -```javascript -const MyPlugin = { - _unsubscribers: [], - - init() { - this._unsubscribers.push( - window.mmgisAPI.on('layer:visibilityChange', this.handleLayerChange), - window.mmgisAPI.on('time:changed', this.handleTimeChange) - ) - }, - - destroy() { - this._unsubscribers.forEach(unsub => unsub()) - this._unsubscribers = [] - } -} -``` - -### 2. Check Provider Availability - -Use `hasHandler` before making requests to optional providers: - -```javascript -if (window.mmgisAPI.hasHandler('plugin:optional:getData')) { - const data = await window.mmgisAPI.request('plugin:optional:getData') -} -``` - -### 3. Handle Provider Re-initialization - -When registering providers, clean up previous registrations: - -```javascript -let _providerCleanups = [] - -function initProviders() { - if (window.mmgisAPI) { - // Clean up previous providers - _providerCleanups.forEach(cleanup => cleanup()) - _providerCleanups = [ - window.mmgisAPI.provide('plugin:myPlugin:getData', handler1), - window.mmgisAPI.provide('plugin:myPlugin:setData', handler2), - ] - } -} -``` - -### 4. Use `this.api` for Auto-Namespacing - -Tools have a scoped API automatically injected as `this.api`. Use it for emitting events and providing handlers: - -```javascript -// In your tool - this.api is auto-injected by ToolController -this.api.emit('updated', data) // -> 'plugin:mytool:updated' -this.api.provide('getData', () => myData) // -> 'plugin:mytool:getData' - -// For subscribing/requesting, use mmgisAPI directly with full paths -window.mmgisAPI.on('layer:visibilityChange', handler) -window.mmgisAPI.request('plugin:draw:getData') -``` - -See [Plugin Scoped API](#plugin-scoped-api) for full details. - -### 5. Graceful Degradation - -Check if `mmgisAPI` exists before using it: - -```javascript -if (window.mmgisAPI) { - window.mmgisAPI.on('layer:visibilityChange', handler) -} -``` - ---- - -## Migration from Legacy Patterns - -### From `L_.subscribe*` / `L_.unsubscribe*` - -```javascript -// Old pattern -L_.subscribeOnLayerToggle('MyPlugin', handler) -L_.unsubscribeOnLayerToggle('MyPlugin') - -// New pattern -const unsub = window.mmgisAPI.on('layer:visibilityChange', handler) -unsub() // to unsubscribe -``` - -### From `document.addEventListener` with CustomEvents - -```javascript -// Old pattern -document.addEventListener('toolChange', handler) -document.removeEventListener('toolChange', handler) - -// New pattern -const unsub = window.mmgisAPI.on('tool:change', handler) -unsub() // to unsubscribe -``` - -### From Direct `Map_.map.*` Calls - -```javascript -// Old pattern -Map_.map.fitBounds(bounds) -Map_.map.panTo(coords) -const zoom = Map_.map.getZoom() - -// New pattern -await window.mmgisAPI.request('map:fitBounds', bounds) -await window.mmgisAPI.request('map:panTo', coords) -const zoom = await window.mmgisAPI.request('map:getZoom') -``` - ---- - -## Backward Compatibility - -The legacy patterns (`L_.subscribe*`, `document.addEventListener`, `Map_.map.*`) continue to work alongside the new Event Bus API. Both systems emit events in parallel during the transition period. +--- +layout: page +title: Event Bus API +permalink: /apis/javascript/event-bus +parent: Main +grand_parent: JavaScript API +nav_order: 1 +--- + +# Event Bus API + +The MMGIS Event Bus provides a lightweight pub/sub and request/response system for plugin communication. It enables decoupled communication between tools, modules, and external integrations. + +## Overview + +The Event Bus is built on [mitt](https://github.com/developit/mitt) and exposes two communication patterns: + +1. **Pub/Sub (Events)**: Fire-and-forget event broadcasting +2. **Request/Response (Providers)**: Synchronous or async data requests + +All methods are available on `window.mmgisAPI`. + +--- + +## Pub/Sub API + +### `mmgisAPI.on(event, handler)` + +Subscribe to an event. + +**Parameters:** +- `event` (string): Event name using namespace:action format +- `handler` (function): Callback function receiving event data + +**Returns:** Unsubscribe function + +```javascript +// Subscribe to layer visibility changes +const unsubscribe = window.mmgisAPI.on('layer:visibilityChange', (data) => { + console.log(`Layer ${data.layerName} is now ${data.on ? 'visible' : 'hidden'}`) +}) + +// Later, unsubscribe when done +unsubscribe() +``` + +### `mmgisAPI.off(event, handler)` + +Unsubscribe from an event (alternative to using the returned unsubscribe function). + +**Parameters:** +- `event` (string): Event name +- `handler` (function): The same handler function passed to `on()` + +```javascript +function myHandler(data) { + console.log(data) +} + +window.mmgisAPI.on('tool:change', myHandler) + +// Later +window.mmgisAPI.off('tool:change', myHandler) +``` + +### `mmgisAPI.emit(event, data)` + +Emit an event to all subscribers. + +**Parameters:** +- `event` (string): Event name +- `data` (any): Data to pass to subscribers + +```javascript +// Emit a custom event from your plugin +window.mmgisAPI.emit('plugin:myPlugin:dataUpdated', { + timestamp: Date.now(), + values: [1, 2, 3] +}) +``` + +--- + +## Request/Response API + +### `mmgisAPI.provide(name, handler)` + +Register a provider that responds to requests. + +**Parameters:** +- `name` (string): Provider name using namespace:action format +- `handler` (function): Handler function that returns data (can be async) + +**Returns:** Cleanup function to remove the provider + +```javascript +// Register a provider in your plugin +const cleanup = window.mmgisAPI.provide('plugin:myPlugin:getData', (params) => { + return { + value: params.id * 2, + timestamp: Date.now() + } +}) + +// Later, remove the provider +cleanup() +``` + +### `mmgisAPI.request(name, params)` + +Request data from a provider. + +**Parameters:** +- `name` (string): Provider name +- `params` (any): Parameters to pass to the provider + +**Returns:** Promise resolving to the provider's response + +**Throws:** Error if no provider is registered for the name + +```javascript +// Request data from a provider +try { + const center = await window.mmgisAPI.request('map:getCenter') + console.log(`Map center: ${center.lat}, ${center.lng}`) +} catch (err) { + console.error('Provider not available:', err.message) +} +``` + +### `mmgisAPI.hasHandler(name)` + +Check if a provider is registered. + +**Parameters:** +- `name` (string): Provider name + +**Returns:** boolean + +```javascript +if (window.mmgisAPI.hasHandler('map:getCenter')) { + const center = await window.mmgisAPI.request('map:getCenter') +} +``` + +This asks whether this build of core offers the capability, not whether core is +currently in a state to serve it. The panel and plugin providers, for instance, +register at page load and read `true` with no layout mounted at all. Use it to +guard against a core too old to know the name; read the request's own answer — +an empty listing, or a `layout-inactive` refusal — for whether there is +anything there right now. + +--- + +## Plugin Scoped API + +MMGIS automatically injects a scoped API into each tool as `this.api`. This API automatically prefixes event and provider names with `plugin:{pluginId}:`, where `pluginId` is derived from the tool's module name (e.g., `DrawTool` → `draw`). + +> **Note:** Each plugin must have a unique ID. Multiple instances of the same plugin in a mission are not currently supported. If two plugins share the same ID, their events and providers will collide. This constraint is not currently enforced at runtime but may be in a future version. + +The scoped API is available on `this.api` in your tool's `initialize()` and `make()` functions: + +```javascript +const MyTool = { + initialize() { + // this.api is automatically available + this.api.provide('getData', () => this.data) + }, + + make() { + this.api.emit('ready', { timestamp: Date.now() }) + } +} +``` + +### Scoped `emit(event, data)` + +Emit an event with auto-prefixed name. + +```javascript +const api = window.mmgisAPI.forPlugin('myPlugin') + +// This emits 'plugin:myPlugin:dataUpdated' +api.emit('dataUpdated', { value: 42 }) + +// Subscribers listen using the full path +window.mmgisAPI.on('plugin:myPlugin:dataUpdated', (data) => { + console.log(data.value) // 42 +}) +``` + +### Scoped `provide(name, handler)` + +Register a provider with auto-prefixed name. + +**Returns:** Cleanup function + +```javascript +const api = window.mmgisAPI.forPlugin('myPlugin') + +// This registers 'plugin:myPlugin:getData' +const cleanup = api.provide('getData', (params) => { + return { result: params.input * 2 } +}) + +// Callers request using the full path +const data = await window.mmgisAPI.request('plugin:myPlugin:getData', { input: 21 }) +console.log(data.result) // 42 + +// Later, remove the provider +cleanup() +``` + +### Metadata Properties + +The scoped API also exposes metadata: + +```javascript +const api = window.mmgisAPI.forPlugin('myPlugin') + +console.log(api.pluginId) // 'myPlugin' +console.log(api.prefix) // 'plugin:myPlugin:' +``` + +### Complete Plugin Example + +```javascript +// MyPluginTool.js - this.api is automatically injected by ToolController +const MyPluginTool = { + _cleanups: [], + + initialize() { + // this.api is available here (auto-injected) + // Register providers (auto-prefixed to 'plugin:myplugin:getData') + this._cleanups.push( + this.api.provide('getData', () => this.data), + this.api.provide('setData', (newData) => { this.data = newData }) + ) + + // Subscribe to events (use full paths via mmgisAPI) + this._cleanups.push( + window.mmgisAPI.on('layer:visibilityChange', this.handleLayerChange.bind(this)) + ) + }, + + make() { + // this.api also available here + this.api.emit('ready', { timestamp: Date.now() }) + }, + + destroy() { + this._cleanups.forEach(fn => fn()) + this._cleanups = [] + }, + + notifyUpdate() { + // Emit events (auto-prefixed to 'plugin:myplugin:updated') + this.api.emit('updated', { timestamp: Date.now() }) + } +} +``` + +--- + +## Available Events + +### Layer Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `layer:visibilityChange` | `{ layerName, on }` | Fired when a layer is toggled on/off | +| `layer:refreshStatusChange` | `{ layerName, failed }` | Fired when a layer refresh succeeds or fails | +| `layer:headerStateChange` | `{ header_id, onState }` | Fired when a layer group header is expanded/collapsed | +| `layers:orderChanged` | `{ order }` | Fired after the draw order changes: a reorder, or a layer added or removed. `order` lists layer UUIDs top first | + +```javascript +window.mmgisAPI.on('layer:visibilityChange', ({ layerName, on }) => { + console.log(`${layerName} visibility: ${on}`) +}) +``` + +### Map Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `map:move` | engine view event | Fired on every frame the camera moves, including moves made in code | +| `map:moveend` | engine view event | Fired once the camera settles after a move | +| `map:mousemove` | `{ latlng, containerPoint, ... }` | Fired as the pointer moves over the map. `containerPoint` is in CSS pixels from the map's top-left corner | +| `map:mouseout` | none | Fired when the pointer leaves the map. On Leaflet it is missed when the pointer leaves across a feature that listens for hovers, so a plugin there should not rely on it to forget the pointer | + +```javascript +window.mmgisAPI.on('map:mousemove', async ({ containerPoint }) => { + const metersPerPixel = await window.mmgisAPI.request('map:getMetersPerPixel', containerPoint) +}) +``` + +### Tool Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `tool:change` | `{ toolName }` | Fired when the active tool changes | + +```javascript +window.mmgisAPI.on('tool:change', ({ toolName }) => { + console.log(`Active tool: ${toolName}`) +}) +``` + +### Feature Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `feature:active` | `{ layerName, feature, layer }` | Fired when a feature becomes active/selected | + +```javascript +window.mmgisAPI.on('feature:active', ({ layerName, feature }) => { + console.log(`Selected feature in ${layerName}:`, feature.properties) +}) +``` + +### Time Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `time:changed` | `{ startTime, currentTime, endTime }` | Fired after a time change is committed, carrying the new committed state. Pairs with `time:changeRequested` | +| `time:changeRequested` | `{ startTime, currentTime, endTime }` | Emitted by a plugin to ask core to commit a new time window. ISO 8601 strings. Ignored while time is disabled for the mission | +| `time:toggleUI` | `{ active }` | Fired when time UI is shown/hidden | + +```javascript +window.mmgisAPI.on('time:changed', ({ startTime, currentTime, endTime }) => { + console.log(`Time range: ${startTime} - ${endTime}`) +}) + +// Ask core to move the current time. Core commits it and broadcasts the +// result on 'time:changed' — including back to the plugin that asked. +window.mmgisAPI.emit('time:changeRequested', { + startTime: '2024-01-01T00:00:00.000Z', + endTime: '2024-12-31T23:59:59.000Z', + currentTime: '2024-06-15T12:00:00.000Z' +}) +``` + +### Legend Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `legend:made` | `{ layerName, id, legendData }` | Fired when a legend is created | + +```javascript +window.mmgisAPI.on('legend:made', ({ layerName, legendData }) => { + console.log(`Legend created for ${layerName}`) +}) +``` + +### Panel and Plugin Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `panels:changed` | `{ panels }` | Fired whenever the panel layout changes — a panel registered or unregistered, changed state, lost a tool, or was resized — and once with an empty listing when the layout is torn down | +| `plugins:changed` | `{ plugins }` | Fired whenever a plugin is shown, hidden, loaded or unloaded by command, once after a batch of plugins loads with the layout, and once with an empty listing when the layout is torn down | + +`panels` carries the same listing [`panels:getAll`](#panel-and-plugin-providers) +returns, and `plugins` the same listing `plugins:getAll` returns, so there is +nothing to re-request. Setting something to the state it already holds is a +quiet no-op — no event fires. + +Tearing a layout down — a mission swap, for instance — fires each event once +more, carrying an empty listing. The bus outlives the layout, so a plugin that +subscribed is still subscribed after the panels and plugins it was following +are gone; the empty listing is what tells it so. Only that final listing goes +out, never the intermediate half-dismantled layouts, and a request made from +here on agrees with it: `panels:getAll` and `plugins:getAll` return empty +listings and every panel or plugin command refuses with `layout-inactive`. + +So a subscriber cannot assume a panel or plugin it knows about is still in the +listing it receives: + +```javascript +window.mmgisAPI.on('panels:changed', ({ panels }) => { + const mine = panels.find((p) => p.id === 'left-panel') + // Absent once the panel is unregistered, and once the layout is torn down. + if (mine == null) return + console.log(`left-panel is now ${mine.state}`) +}) +``` + +A component that seeds from `panels:getAll` and also subscribes to +`panels:changed` must guard the seed so it cannot overwrite state an event +has already delivered — the request can resolve after a later event lands. + +### WebSocket Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `websocket:change` | `{ data }` | Fired on WebSocket messages | + +--- + +## Available Providers + +### Map Providers + +| Provider | Params | Returns | Description | +|----------|--------|---------|-------------| +| `map:getCenter` | none | `{ lat, lng }` | Get map center coordinates | +| `map:getBounds` | none | `LatLngBounds` | Get map bounds | +| `map:getZoom` | none | `number` | Get current zoom level | +| `map:getMetersPerPixel` | `{ x, y }` (optional) | `number \| null` | Ground metres per CSS pixel at that map container point, or at the map's centre when omitted. Null before the map exists, for a point outside the map or off a Web Mercator world, or when the planet radius is not configured | +| `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 card, replacing any current one. Answers only once it closes. `latlng` is required, and so is one of `title` (text) and `html` (sanitized); each action carries a `label`, and focus lands on the first one when the card opens | +| `map:hidePopup` | none | `true` | Take the current card down, resolving its request with `{ action: 'closed' }` | +| `map:getScreenshot` | `{ scaleAt }` (optional) | `MapScreenshotResult` | The map as a PNG: `{ blob, mimeType, extension, width, height, metersPerPixel, scaleAnchor }`. `metersPerPixel` is the ground metres one *image* pixel spans at `scaleAt` (a page position, `{ clientX, clientY }`), or near the image's bottom-left corner without it. It is null when the map is tilted more than 10°, when the view spans too wide a range of latitudes for one scale, or when the map cannot measure it. `scaleAnchor` is `scaleAt` in image pixels, absent without it | +| `map:getEngineType` | none | `'leaflet' \| 'deckgl' \| null` | Which engine is drawing the map, for a plugin whose behaviour differs by engine. Null until the mission has built one | + +```javascript +// Get current map state +const center = await window.mmgisAPI.request('map:getCenter') +const zoom = await window.mmgisAPI.request('map:getZoom') +const bounds = await window.mmgisAPI.request('map:getBounds') + +// Modify map view +await window.mmgisAPI.request('map:setView', { + center: { lat: 45, lng: -120 }, + zoom: 10 +}) + +await window.mmgisAPI.request('map:fitBounds', [ + [44, -121], + [46, -119] +]) + +await window.mmgisAPI.request('map:panTo', { lat: 45, lng: -120 }) + +// Pending until the card closes — hold onto it rather than awaiting inline. +window.mmgisAPI.request('map:showPopup', { + latlng: { lat: 45, lng: -120 }, + title: 'Crater A', + html: '

Diameter: 12 km

', + primaryAction: { label: 'Analyze' } +}).then(({ action }) => { if (action === 'primary') analyze() }) +``` + +`map:showPopup` resolves `{ action }` once the card closes: + +| `action` | Meaning | +|----------|---------| +| `'primary'` / `'secondary'` | That action's button was pressed | +| `'dismiss'` | The map library took the card down: its own close control, or a click on the map | +| `'closed'` | Code took it down: another `map:showPopup` replaced it, `map:hidePopup` retracted it, or the map was re-initialized | + +### Layer Providers + +| Provider | Params | Returns | Description | +|----------|--------|---------|-------------| +| `layers:getAll` | none | `string[]` | Get all layer names | +| `layers:getVisible` | none | `object` | Get visibility state of all layers. | +| `layers:getConfig` | `layerUUID` | `object \| null` | Get layer configuration. The answer is a copy: to change a layer, use `layers:updateConfig` | +| `layers:toggle` | `layerUUID` | `boolean \| null` | Toggle layer visibility | +| `layers:getOrder` | none | `string[]` | Draw order as layer UUIDs, top first, headers excluded | +| `layers:setOrder` | `{ order }` | `boolean` | Apply a full permutation of the draw order (UUIDs, or display names where the name is unique). Returns `false` and changes nothing if `order` is not an array or a layer is missing, unknown, or repeated. On Leaflet, vectors always draw above rasters whatever the order says | +| `layers:getLegend` | `layerUUID` (optional, UUID or display name) | `LayerLegend \| null` for one layer (`null` if unknown), or `Record` keyed by UUID when called with no argument | Get a layer's resolved legend: `type` (`'gradient' \| 'categorical' \| 'text' \| 'none'`), `stops` (resolved CSS colors or null), `min`/`max` (numbers or null when unset), `unit` (`{ label }` or null), `swatches` (`{ color, label }[]` or null), and `colormap` (name or null). A layer whose legend fails to build answers `type: 'none'` | +| `layers:getTemporalExtent` | `layerUUID` (optional) | `{ start, end, interval, periodAnchor } \| null`, or a UUID-keyed map of them | When the layer has data: `start`/`end` are ISO datetimes (or `null`) with `now`-style policies in `dataStartTime`/`dataEndTime` resolved at the moment of asking, and a periodic layer's `end` floored to its last step. `interval` is the layer's parsed `time.interval` as `{ years, months, weeks, days, hours, minutes, seconds }`, or `null` when it declares none or an unparseable one. `periodAnchor` is the ISO instant the layer's requested periods step from, or `null` when it requests none. Called without a layer, answers for every layer. A tile, vector or vector tile layer with an `interval` of an hour or more requests only the period holding the cursor, not the whole Time Control window, unless the layer lists Data Dates (`time.dataDates`): see `getLayerStartTime` in the Main JavaScript API | + +```javascript +// Get layer information +const allLayers = await window.mmgisAPI.request('layers:getAll') +const visibleLayers = await window.mmgisAPI.request('layers:getVisible') +const config = await window.mmgisAPI.request('layers:getConfig', 'myLayerName') +const legends = await window.mmgisAPI.request('layers:getLegend') // keyed by UUID + +// Toggle a layer +const newState = await window.mmgisAPI.request('layers:toggle', 'myLayerName') + +// Move the bottom layer to the top +const order = await window.mmgisAPI.request('layers:getOrder') +const applied = await window.mmgisAPI.request('layers:setOrder', { + order: [order[order.length - 1], ...order.slice(0, -1)], +}) +``` + +### Time Providers + +| Provider | Params | Returns | Description | +|----------|--------|---------|-------------| +| `time:isEnabled` | none | `boolean` | Whether the mission has time enabled. The getters below return `null` both when time is off and when it is on but not yet seeded | +| `time:getCurrent` | none | `string` | Get current time | +| `time:getStart` | none | `string` | Get start time | +| `time:getEnd` | none | `string` | Get end time | +| `time:getCurrentFormatted` | none | `string` | Current time rendered through the mission's `time.format` (a d3 time format specifier such as `%Y-%m-%d`); `null` until time is enabled and seeded | +| `time:formatTime` | `string \| number` | `string` | A caller-supplied time rendered through that same mission format; `null` for a missing or unparseable time | +| `time:getTimeZone` | none | `string` | Name of the zone `time:getCurrentFormatted` and `time:formatTime` write in (`'UTC'`), for labelling the times they return | +| `time:getMode` | none | `string` | Mode of the bottom Time UI bar: `'range'` or `'point'` (in point mode `time:getStart` is the epoch, not a real start); `null` until time is enabled and seeded, and when the bar isn't mounted (mobile and the modern layout) | +| `time:set` | `{ startTime, endTime, currentTime, ... }` | `boolean` | Set time range | + +```javascript +// Get time state +const timeEnabled = await window.mmgisAPI.request('time:isEnabled') +const current = await window.mmgisAPI.request('time:getCurrent') +const start = await window.mmgisAPI.request('time:getStart') +const end = await window.mmgisAPI.request('time:getEnd') +const mode = await window.mmgisAPI.request('time:getMode') // 'range' | 'point' | null + +// Set time range +await window.mmgisAPI.request('time:set', { + startTime: '2024-01-01T00:00:00Z', + endTime: '2024-12-31T23:59:59Z', + currentTime: '2024-06-15T12:00:00Z' +}) +``` + +### Globe Providers + +| Provider | Params | Returns | Description | +|----------|--------|---------|-------------| +| `globe:getMouse` | none | `{ lng, lat } \| null` | Get mouse position on globe | +| `globe:getZoom` | none | `number \| null` | Get globe zoom level | + +```javascript +const mousePos = await window.mmgisAPI.request('globe:getMouse') +if (mousePos) { + console.log(`Mouse at: ${mousePos.lat}, ${mousePos.lng}`) +} +``` + +### Panel and Plugin Providers + +| Provider | Params | Returns | Description | +|----------|--------|---------|-------------| +| `panels:getAll` | none | `PanelInfo[]` | Every panel in the layout, ordered by priority | +| `panels:setState` | `{ panelId, state }` | `CommandResult` | Move a panel to `collapsed`, `expanded`, `iconified` or `focused` | +| `panels:show` | `{ panelId }` | `CommandResult` | Restore a collapsed panel to its last visible state | +| `panels:hide` | `{ panelId }` | `CommandResult` | Collapse a panel without destroying its contents | +| `plugins:getAll` | none | `PluginInfo[]` | Every plugin the layout knows, with its lifecycle state | +| `plugins:setState` | `{ pluginId, state }` | `CommandResult` | Move a plugin to `unloaded`, `hidden` or `visible` | +| `plugins:show` | `{ pluginId }` | `CommandResult` | Reveal a plugin, loading it first if it is unloaded | +| `plugins:hide` | `{ pluginId }` | `CommandResult` | Hide a plugin, preserving its instance and state | + +Each `PanelInfo` carries what it takes to target one: `id`, `position`, current +`state`, and the `toolIds` it holds — which is how a plugin recognizes the panel +it lives in. +Each `PluginInfo` carries `id` and `state`. + +`show` and `hide` are thin sugar over `setState` — they resolve a target state +rather than flipping whatever they find. `panels:show` restores the panel's +`lastVisibleState`; `plugins:show` resolves to `visible`, loading the plugin +first if it is unloaded. `panels:hide` and `plugins:hide` always target +`collapsed`/`hidden`. + +`panels:show` only ever lifts a panel out of `collapsed`. Anything else already +counts as visible, so the panel is left exactly where it is and the result +reports `changed: false` — which is what stops it from moving a panel the user +expanded down to whatever smaller state the configuration defaults to. + +`iconified` counts as visible under that rule, and every stock layout starts its +left panel there. So "reveal the panel I live in" is two steps, not one: show +it, then read the `state` the result carries and ask for a larger one if an +icon rail is not enough. Reading the result is the general pattern — `show` +reports where the panel actually ended up, which is not always where the caller +wanted it. + +```javascript +const shown = await window.mmgisAPI.request('panels:show', { panelId: 'left-panel' }) +if (shown.ok && shown.state === 'iconified') { + await window.mmgisAPI.request('panels:setState', { + panelId: 'left-panel', + state: 'expanded', + }) +} +``` + +Every command returns a `CommandResult` rather than a bare boolean: + +```ts +{ ok: true, state: 'collapsed', changed: true } +{ ok: false, reason: 'not-found' | 'state-not-allowed' | 'no-visible-state' + | 'layout-inactive' | 'bad-request' | 'load-failed' + | 'transition-failed' } +``` + +`not-found` is an unknown id. `state-not-allowed` is a transition the panel's +own configuration forbids — a float panel refusing `iconified`, for example. +`no-visible-state` is a `panels:show` with no allowed state left to restore to. +`bad-request` is a malformed payload: a missing or non-string id, or a state +name outside the vocabulary in the table above. Note the difference between the +last two — a state that does not exist anywhere is `bad-request`, your mistake, +while a real state this particular target forbids is `state-not-allowed`. The +payload is judged first, so a command carrying both an unknown id and an +unrecognised state reports `bad-request`. `load-failed` is any `plugins:*` command +that must load an unloaded plugin first and fails to. `transition-failed` is +core failing to carry out a transition it accepted — distinct from +`not-found`, which means the id named nothing. `layout-inactive` covers +two cases a caller cannot distinguish — a core too old to have registered the +handler, or a core that has it but currently has no layout mounted — and +deliberately collapses them, since both mean the same thing to a caller: there +is nothing to command. + +Commands name the state they want rather than flipping whatever they find, so a +control drawn from a listing read moments earlier still lands on the state the +click asked for, and a failed request is safe to retry. Asking for the state +something already holds succeeds with `changed: false` and broadcasts nothing. + +```javascript +const panels = await window.mmgisAPI.request('panels:getAll') +const mine = panels.find((p) => p.toolIds.includes('myPlugin')) + +const result = await window.mmgisAPI.request( + mine.state === 'collapsed' ? 'panels:show' : 'panels:hide', + { panelId: mine.id } +) +if (!result.ok) console.warn(`Refused: ${result.reason}`) +``` + +### Export Providers + +Provided by a plugin, and asked for by the share and export actions. Any plugin that draws the matching element on the map can provide one, so plugins doing the same job can be swapped. + +| Provider | Params | Returns | Description | +|----------|--------|---------|-------------| +| `export:getScaleBar` | none | `{ units, at } \| null` | The on-screen scale bar's settings while one is drawn, otherwise null. `units` is `'metric'` or `'imperial'`; `at` is the page position (`{ clientX, clientY }`) of the bar's backing box's bottom-left corner, where an exported image draws and measures its own bar | + +### App Providers + +| Provider | Params | Returns | Description | +|----------|--------|---------|-------------| +| `app:getMissionPath` | none | `string` | Get current mission path | + +```javascript +const missionPath = await window.mmgisAPI.request('app:getMissionPath') +console.log(`Current mission: ${missionPath}`) +``` + +### Plugin Providers + +| Provider | Params | Returns | Description | +|----------|--------|---------|-------------| +| `plugin:info:showFeature` | `{ layerName, layer }` | `boolean` | Show feature in InfoTool | +| `plugin:info:getCurrentFeature` | none | `{ layerName, feature } \| null` | Get currently displayed feature | +| `plugin:draw:getActiveFeature` | none | `feature \| null` | Get active draw feature | +| `plugin:draw:getFilesOn` | none | `string[]` | Get enabled draw files | +| `plugin:layers:populateCogScale` | `layerName` | `true` | Update COG scale UI | + +--- + +## Event Naming Conventions + +Events and providers follow a namespace:action pattern: + +- **Core namespaces**: `map:`, `layers:`, `time:`, `globe:`, `panels:`, `plugins:`, `app:`, `export:` +- **Plugin namespaces**: `plugin:pluginName:` +- **Custom namespaces**: `custom:yourNamespace:` + +Examples: +- `map:getCenter` - Core map provider +- `layer:visibilityChange` - Core layer event +- `plugin:draw:getActiveFeature` - DrawTool plugin provider +- `custom:analytics:trackEvent` - Custom event for external integration + +--- + +## Config Action Strings + +Some tools accept an action as a plain string in their mission configuration — +Title's action buttons, for instance — rather than wiring a handler in code. +These strings resolve through one shared resolver +(`src/essence/Tools/_shared/actions/resolveAction.ts`), so config authors read +the same request names documented above instead of a second, tool-specific +vocabulary. + +| Form | Resolves to | +|------|-------------| +| `https://…` or `http://…` | Opens in a new tab (`noopener,noreferrer`) | +| `panels:show:` / `panels:hide:` | `request('panels:show' \| 'panels:hide', { panelId })` | +| `plugins:show:` / `plugins:hide:` | `request('plugins:show' \| 'plugins:hide', { pluginId })` | +| anything else | `emit(action)` as a plain event | + +```javascript +// Title tool config +{ + "actionButtonLink": "panels:hide:left-panel" +} +``` + +Only these four single-target verbs are expressible as a config string — +`setState` takes both a target and a state, which a colon-delimited string has +no escaping rules for. A mission that needs a specific target state does it in +code, not config. + +A target may itself contain colons; everything after the verb is taken as the +target whole, so `panels:hide:group:left` hides a panel literally named +`group:left`, not a group called `left`. Under a reserved namespace (`panels:`, +`plugins:`, `core:`, matched exactly — `Panels:hide:left` is not treated as a +core action), an unrecognized verb or a missing target is reported with +`console.warn` naming the supported actions, rather than silently falling +through to `emit` — a typo under a reserved namespace is a config mistake +worth surfacing, not a custom event. + +`core:` is reserved outright: it defines no verbs, so any `core::` +string — including the `core:showPlugin:`, `core:togglePanel`, and +`core:unloadPlugin` syntax some mission configs still use — is reported with +`console.warn` and the list of supported actions above, rather than falling +through to a plain `emit` that nothing listens for. There is no drop-in +replacement for every verb: panels only expose separate show/hide actions, +and unloading a plugin needs a state argument that a colon-delimited string +can't carry — those calls belong in code, not a config string. + +A string with no colon at all has no namespace, so it resolves like any other +non-core action — a plain `emit` of exactly what was written. That is legal +but seldom what an author means: every tool publishes under +`plugin::`, so a bare word like `refresh` emits an event named +`refresh` that no tool listener is subscribed to. It is emitted anyway and +reported with `console.warn`, naming the fully qualified form instead — +otherwise the button looks wired up and does nothing. + +--- + +## Best Practices + +### 1. Always Unsubscribe + +Store unsubscribe functions and call them during cleanup: + +```javascript +const MyPlugin = { + _unsubscribers: [], + + init() { + this._unsubscribers.push( + window.mmgisAPI.on('layer:visibilityChange', this.handleLayerChange), + window.mmgisAPI.on('time:changed', this.handleTimeChange) + ) + }, + + destroy() { + this._unsubscribers.forEach(unsub => unsub()) + this._unsubscribers = [] + } +} +``` + +### 2. Check Provider Availability + +Use `hasHandler` before making requests to optional providers: + +```javascript +if (window.mmgisAPI.hasHandler('plugin:optional:getData')) { + const data = await window.mmgisAPI.request('plugin:optional:getData') +} +``` + +### 3. Handle Provider Re-initialization + +When registering providers, clean up previous registrations: + +```javascript +let _providerCleanups = [] + +function initProviders() { + if (window.mmgisAPI) { + // Clean up previous providers + _providerCleanups.forEach(cleanup => cleanup()) + _providerCleanups = [ + window.mmgisAPI.provide('plugin:myPlugin:getData', handler1), + window.mmgisAPI.provide('plugin:myPlugin:setData', handler2), + ] + } +} +``` + +### 4. Use `this.api` for Auto-Namespacing + +Tools have a scoped API automatically injected as `this.api`. Use it for emitting events and providing handlers: + +```javascript +// In your tool - this.api is auto-injected by ToolController +this.api.emit('updated', data) // -> 'plugin:mytool:updated' +this.api.provide('getData', () => myData) // -> 'plugin:mytool:getData' + +// For subscribing/requesting, use mmgisAPI directly with full paths +window.mmgisAPI.on('layer:visibilityChange', handler) +window.mmgisAPI.request('plugin:draw:getData') +``` + +See [Plugin Scoped API](#plugin-scoped-api) for full details. + +### 5. Graceful Degradation + +Check if `mmgisAPI` exists before using it: + +```javascript +if (window.mmgisAPI) { + window.mmgisAPI.on('layer:visibilityChange', handler) +} +``` + +--- + +## Migration from Legacy Patterns + +### From `L_.subscribe*` / `L_.unsubscribe*` + +```javascript +// Old pattern +L_.subscribeOnLayerToggle('MyPlugin', handler) +L_.unsubscribeOnLayerToggle('MyPlugin') + +// New pattern +const unsub = window.mmgisAPI.on('layer:visibilityChange', handler) +unsub() // to unsubscribe +``` + +### From `document.addEventListener` with CustomEvents + +```javascript +// Old pattern +document.addEventListener('toolChange', handler) +document.removeEventListener('toolChange', handler) + +// New pattern +const unsub = window.mmgisAPI.on('tool:change', handler) +unsub() // to unsubscribe +``` + +### From Direct `Map_.map.*` Calls + +```javascript +// Old pattern +Map_.map.fitBounds(bounds) +Map_.map.panTo(coords) +const zoom = Map_.map.getZoom() + +// New pattern +await window.mmgisAPI.request('map:fitBounds', bounds) +await window.mmgisAPI.request('map:panTo', coords) +const zoom = await window.mmgisAPI.request('map:getZoom') +``` + +--- + +## Backward Compatibility + +The legacy patterns (`L_.subscribe*`, `document.addEventListener`, `Map_.map.*`) continue to work alongside the new Event Bus API. Both systems emit events in parallel during the transition period. diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 22fb775c81..3b05af6ad5 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -271,6 +271,24 @@ const isTerraDrawLayer = (id: string): boolean => id.startsWith(`${TERRA_DRAW_PR */ const UNRANKED_Z_INDEX = Number.MAX_SAFE_INTEGER +/** What deck.gl's controller is doing with the camera, as it reports it. */ +type DeckInteractionState = { + inTransition?: boolean + isDragging?: boolean + isPanning?: boolean + isRotating?: boolean + isZooming?: boolean +} + +// Whether a gesture, its inertia or a transition is still moving the camera. +const isInteracting = (state: DeckInteractionState | undefined): boolean => + !!state && + !!(state.inTransition || state.isDragging || state.isPanning || state.isRotating || state.isZooming) + +// Whether deck.gl will animate to this view state, reporting each frame. +const isAnimated = ({ transitionDuration }: DeckViewState): boolean => + transitionDuration === 'auto' || (transitionDuration ?? 0) > 0 + function canvasToPngScreenshot(canvas: HTMLCanvasElement): Promise { return new Promise((resolve, reject) => { if (typeof canvas.toBlob !== 'function') { @@ -466,6 +484,16 @@ export class DeckGLAdapter implements IMapEngine { * `move` those copies raise is ignored rather than echoed back. */ private _sbsSyncing = false + /** + * Whether a standalone `Deck` camera has moved since the last `'moveend'`, + * so a controller going idle reports the end of a move exactly once. + */ + private _cameraUnsettled = false + /** The last viewport built for `_viewState`, reused by {@link _currentViewport}. */ + private _viewportCache: { + viewState: DeckViewState + viewport: ReturnType + } | null = null /** * The basemap constructor and options the panes rebuild from. Overlay mode @@ -510,9 +538,9 @@ export class DeckGLAdapter implements IMapEngine { /** * Bound handler kept as a class field for clean removal. - * Silently keeps `_viewState` in sync during animations so that + * Keeps `_viewState` in sync during animations so that * {@link projectCoordinates} and {@link unprojectCoordinates} remain - * accurate while the camera is moving. + * accurate while the camera is moving, and emits `'move'`. */ private _onBasemapMove = (): void => { if (this._sbsSyncing) return @@ -526,6 +554,7 @@ export class DeckGLAdapter implements IMapEngine { pitch: this._basemap!.getPitch(), } if (this._comparisonEnabled) this._syncComparisonCamera() + this._emitEvent('move', this._viewState) } /** @@ -568,6 +597,7 @@ export class DeckGLAdapter implements IMapEngine { throw new Error(`DeckGLAdapter: container element #${options.containerId} not found`) } this._container = container + container.addEventListener('mouseleave', this._onContainerMouseLeave) this._minZoom = options.minZoom ?? 0 this._maxZoom = options.maxZoom ?? 20 this._maxBounds = options.maxBounds ?? null @@ -614,6 +644,7 @@ export class DeckGLAdapter implements IMapEngine { this._drawEndClick.dispose() this._drawPointers.stop() + this._container?.removeEventListener('mouseleave', this._onContainerMouseLeave) if (this._terraDraw) { this._terraDrawListeners.forEach((off) => { try { off() } catch { /* ignore */ } }) @@ -1715,13 +1746,34 @@ export class DeckGLAdapter implements IMapEngine { return pick ? [pickInfoToResult(pick)] : [] } + /** + * The viewport for the current camera, or for `zoom` when given. The + * current one is shared until the camera or the container size changes, + * so a burst of projections in one frame builds it once. + */ + private _currentViewport(zoom?: number): ReturnType { + if (zoom != null) return makeViewport(this._viewState, this._container, zoom) + const cached = this._viewportCache + if ( + cached && + cached.viewState === this._viewState && + cached.viewport.width === this._container.offsetWidth && + cached.viewport.height === this._container.offsetHeight + ) { + return cached.viewport + } + const viewport = makeViewport(this._viewState, this._container) + this._viewportCache = { viewState: this._viewState, viewport } + return viewport + } + /** * Project geographic coordinates to container pixel coordinates using the * current viewport. Pass `zoom` to override the viewport zoom. */ projectCoordinates(latLng: LatLngLike, zoom?: number): PointLike { const { lat, lng } = resolveLatLng(latLng) - const [x, y] = makeViewport(this._viewState, this._container, zoom).project([ + const [x, y] = this._currentViewport(zoom).project([ lng, lat, ]) as [number, number] @@ -1730,7 +1782,7 @@ export class DeckGLAdapter implements IMapEngine { unprojectCoordinates(point: PointLike, zoom?: number): LatLngLike { const { x, y } = resolvePoint(point) - const [lng, lat] = makeViewport(this._viewState, this._container, zoom).unproject([ + const [lng, lat] = this._currentViewport(zoom).unproject([ x, y, ]) as [number, number] @@ -1741,6 +1793,11 @@ export class DeckGLAdapter implements IMapEngine { return this.unprojectCoordinates(point) } + /** deck.gl's map view and its basemaps are always Web Mercator. */ + isWebMercator(): boolean { + return true + } + latLngToContainerPoint(latLng: LatLngLike): PointLike { return this.projectCoordinates(latLng) } @@ -2166,13 +2223,20 @@ export class DeckGLAdapter implements IMapEngine { views: new MapView({ repeat: true }), viewState: this._viewState, layers: [], - onViewStateChange: ({ viewState }: { viewState: DeckViewState }) => { + onViewStateChange: ({ + viewState, + interactionState, + }: { + viewState: DeckViewState + interactionState?: DeckInteractionState + }) => { if (this._sbsSyncing) return this._viewState = this._clampToMaxBounds(viewState) pane.deck?.setProps({ viewState: this._viewState } as any) this._syncComparisonCamera(pane) - this._emitEvent('moveend', this._viewState) + this._onDeckCameraChange(interactionState) }, + onInteractionStateChange: this._onDeckInteractionChange, } as any) } @@ -2231,9 +2295,8 @@ export class DeckGLAdapter implements IMapEngine { * Adopt a pane's camera as the shared one and copy it everywhere else. * * Either pane may be dragged, so whichever moved becomes the source and the - * rest follow. `settled` separates the frames during a gesture — which only - * need the cameras to stay locked — from its end, which is what the rest of - * MMGIS listens for. + * rest follow. `settled` separates the frames during a gesture, reported + * as `'move'`, from its end, reported as `'moveend'`. */ private _onPaneCameraChange(source: SideBySidePane, settled: boolean): void { if (this._sbsSyncing || !source.map) return @@ -2249,6 +2312,7 @@ export class DeckGLAdapter implements IMapEngine { } this._syncComparisonCamera(source) if (settled) this._emitEvent('moveend', this._viewState) + else this._emitEvent('move', this._viewState) } /** Finalize both panes, detach their listeners and remove their DOM nodes. */ @@ -2287,13 +2351,20 @@ export class DeckGLAdapter implements IMapEngine { controller: true, layers: [], viewState: this._viewState, - onViewStateChange: ({ viewState }: { viewState: DeckViewState }) => { + onViewStateChange: ({ + viewState, + interactionState, + }: { + viewState: DeckViewState + interactionState?: DeckInteractionState + }) => { const clamped = this._clampToMaxBounds(viewState) this._viewState = clamped this._deckSetProps({ viewState: clamped }) if (this._comparisonEnabled) this._syncComparisonCamera() - this._emitEvent('moveend', clamped) + this._onDeckCameraChange(interactionState) }, + onInteractionStateChange: this._onDeckInteractionChange, onClick: this._onPointerClick, onHover: this._onPointerHover, getCursor: this._cursorFor, @@ -2330,6 +2401,32 @@ export class DeckGLAdapter implements IMapEngine { isHovering: boolean }): string => (isDragging ? 'grabbing' : isHovering ? 'pointer' : 'grab') + /** + * Reports a standalone `Deck` camera change: `'move'` on every frame, and + * `'moveend'` once the controller is idle (no gesture, inertia or + * transition under way), as Leaflet and MapLibre do. + */ + private _onDeckCameraChange(interaction: DeckInteractionState | undefined): void { + this._emitEvent('move', this._viewState) + this._cameraUnsettled = true + if (!isInteracting(interaction)) this._settleCamera() + } + + private _onDeckInteractionChange = (interaction: DeckInteractionState): void => { + if (!isInteracting(interaction)) this._settleCamera() + } + + private _settleCamera(): void { + if (!this._cameraUnsettled) return + this._cameraUnsettled = false + this._emitEvent('moveend', this._viewState) + } + + /** Reports the pointer leaving the map as `'mouseout'`, as Leaflet does. */ + private _onContainerMouseLeave = (): void => { + this._emitEvent('mouseout') + } + private _onPointerHover = (info: PickingInfo): void => { if (this._drawingShape) return this._featureHoverHandler?.(pickInfoToResult(info)) @@ -2449,9 +2546,12 @@ export class DeckGLAdapter implements IMapEngine { } this._viewState = nextState this._deckSetProps({ viewState: nextState }) - // A zero-duration move raises no `onViewStateChange`, so this is the - // only place the comparison surfaces hear about it. + // An animated move reports itself through `onViewStateChange` frame by + // frame. A zero-duration one raises nothing, so this is the only place + // the comparison surfaces and the engine's listeners hear about it. + if (isAnimated(nextState)) return if (this._comparisonEnabled) this._syncComparisonCamera() + this._onDeckCameraChange(undefined) } /** diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index 429cabd265..e160871e4e 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -666,6 +666,16 @@ export default class LeafletAdapter implements IMapEngine, IMapEn return { lat: latLng.lat, lng: latLng.lng } } + /** + * Leaflet's default CRS, or a custom one built from a `+proj=merc` + * definition. + */ + isWebMercator(): boolean { + const crs = this._map?.options?.crs + if (typeof crs?.projString === 'string') return /\+proj=merc\b/.test(crs.projString) + return crs === L.CRS.EPSG3857 + } + /** * Convert geographic coordinates to container pixel position */ diff --git a/src/essence/Basics/MapEngines/IMapEngine.ts b/src/essence/Basics/MapEngines/IMapEngine.ts index 9b093e9f49..bc980b200c 100644 --- a/src/essence/Basics/MapEngines/IMapEngine.ts +++ b/src/essence/Basics/MapEngines/IMapEngine.ts @@ -23,6 +23,13 @@ export interface MapScreenshotResult { extension: 'png' width: number height: number + /** Ground metres per *image* pixel at `scaleAnchor`, or near the image's + * bottom-left corner without one, at capture time. Stamped by mmgisAPI, + * not by the engine; null or absent when the map cannot measure it. */ + metersPerPixel?: number | null + /** The image pixel `metersPerPixel` was measured at, when the capture + * was asked to measure at a page position; otherwise null or absent. */ + scaleAnchor?: { x: number; y: number } | null } /** @@ -364,6 +371,13 @@ export interface IMapEngine< */ containerPointToLatLng(point: PointLike): LatLngLike + /** + * Whether the map draws in Web Mercator. Its world ends at about ±85° + * latitude, and points past the world's top or bottom edge unproject + * toward the pole rather than failing. + */ + isWebMercator(): boolean + /** * Convert geographic coordinates to container pixel position. */ diff --git a/src/essence/Basics/MapEngines/mapScale.ts b/src/essence/Basics/MapEngines/mapScale.ts new file mode 100644 index 0000000000..b9e1d8b3da --- /dev/null +++ b/src/essence/Basics/MapEngines/mapScale.ts @@ -0,0 +1,153 @@ +import F_ from '../Formulae_/Formulae_' +import type { IMapEngine, MapScreenshotResult } from './IMapEngine' +import type { LatLng } from './types/geometry' + +type ScaleEngine = Pick< + IMapEngine, + 'containerPointToLatLng' | 'getContainer' | 'getViewState' | 'isWebMercator' +> + +/** A position on the page, in CSS pixels, as a DOM rectangle or event gives it. */ +export type ClientPoint = { clientX: number; clientY: number } + +/** A position in the map container, in CSS pixels from its top-left corner. */ +export type ContainerPoint = { x: number; y: number } + +// The scale is the ground length of a short horizontal segment on screen. +// Short keeps it local to the point; a long one at world zooms would measure a +// great-circle chord rather than the distance along the screen row. +const PROBE_PX = 10 + +// Web Mercator's world ends at this latitude. Points past the world's top or +// bottom edge unproject toward the pole rather than failing, so a latitude +// beyond it marks a point with no ground under it. +const MERCATOR_MAX_LAT = 85.0511287798 + +/** Tilt, in degrees, beyond which an export carries no scale. A tilted map's scale + * shrinks toward the camera and grows toward the horizon (by about a quarter + * at the bottom edge at 45°), so no single bar describes the view. */ +export const MAX_SCALE_PITCH_DEG = 10 + +/** How far in from the image's bottom-left corner an export measures its + * scale when no point is given: about where it draws its scale bar. */ +export const DEFAULT_SCALE_INSET_PX = 16 + +/** The most the scale may differ, as a ratio, between the top and bottom of + * the view before an export carries no scale. Web Mercator's scale grows with + * 1/cos(latitude), so this allows a view from 0° to about 48°, or from 45° to + * about 62°, and rules out continent- and world-wide views. */ +export const MAX_SCALE_SPREAD = 1.5 + +// Points arrive over the bus, so anything but a finite point is ignored. +const isContainerPoint = (at: ContainerPoint | undefined): at is ContainerPoint => + !!at && Number.isFinite(at.x) && Number.isFinite(at.y) + +const isClientPoint = (at: ClientPoint | undefined): at is ClientPoint => + !!at && Number.isFinite(at.clientX) && Number.isFinite(at.clientY) + +type ScaleReading = { + metersPerPixel: number + /** The container point, in CSS pixels, the reading describes. */ + x: number + y: number +} + +// Ground metres per CSS pixel at container point (x, y). Null where the +// engine cannot place the points. +function scaleAt(engine: ScaleEngine, x: number, y: number): number | null { + // Both adapters answer in the object form. + const a = engine.containerPointToLatLng({ x, y }) as LatLng + const b = engine.containerPointToLatLng({ x: x + PROBE_PX, y }) as LatLng + const meters = F_.lngLatDistBetween(a.lng, a.lat, b.lng, b.lat) + return Number.isFinite(meters) && meters > 0 ? meters / PROBE_PX : null +} + +// Rows, as fractions of the map's height, the scale is sampled on to judge +// how much it varies over the view. The middle row matters: a view centred +// on the equator has the same scale at its top and bottom. +const SPREAD_ROWS = [0, 0.25, 0.5, 0.75, 1] + +function readScale(engine: ScaleEngine, at?: ClientPoint): ScaleReading | null { + try { + const rect = engine.getContainer().getBoundingClientRect() + if (!(rect.width > 0 && rect.height > 0)) return null + if (Math.abs(engine.getViewState().pitch ?? 0) > MAX_SCALE_PITCH_DEG) return null + const given = isClientPoint(at) + const x = given ? at.clientX - rect.left : DEFAULT_SCALE_INSET_PX + const y = given ? at.clientY - rect.top : rect.height - DEFAULT_SCALE_INSET_PX + const metersPerPixel = scaleAt(engine, x, y) + if (metersPerPixel == null) return null + // Zoomed out far enough, the view spans latitudes whose scales differ + // widely and no single bar describes it. The spread only grows as the + // map zooms out: rows past a Mercator world's top or bottom edge + // unproject toward the pole, where the scale falls toward zero. Rows + // the engine cannot place (off a custom projection's extent) do not + // count against it. + const top = DEFAULT_SCALE_INSET_PX + const span = rect.height - 2 * DEFAULT_SCALE_INSET_PX + const scales = [ + metersPerPixel, + ...SPREAD_ROWS.map((f) => scaleAt(engine, x, top + f * span)), + ].filter((s): s is number => s != null) + if (Math.max(...scales) / Math.min(...scales) > MAX_SCALE_SPREAD) return null + return { metersPerPixel, x, y } + } catch (err) { + console.warn('Map screenshot: measuring the scale failed', err) + return null + } +} + +/** + * Ground metres per CSS pixel at container point `at`, or at the map's centre + * when `at` is omitted. Unprojects two points PROBE_PX apart and measures the + * great-circle distance between them on the mission's planet, so it works for + * any engine. The scale is local to the point, so it holds at any zoom and on + * a tilted map. + * + * Null when the map has no size, the point is outside the map or off a Web + * Mercator world, the engine cannot place the points, or the planet radius is + * not configured. + */ +export function metersPerPixelAt(engine: ScaleEngine, at?: ContainerPoint): number | null { + try { + const rect = engine.getContainer().getBoundingClientRect() + if (!(rect.width > 0 && rect.height > 0)) return null + const { x, y } = isContainerPoint(at) ? at : { x: rect.width / 2, y: rect.height / 2 } + if (x < 0 || y < 0 || x > rect.width || y > rect.height) return null + const ground = engine.containerPointToLatLng({ x, y }) as LatLng + if (engine.isWebMercator() && !(Math.abs(ground.lat) <= MERCATOR_MAX_LAT)) return null + return scaleAt(engine, x, y) + } catch { + return null + } +} + +/** + * Captures the map and stamps the screenshot with the ground metres one + * *image* pixel spans at `scaleAt`, or near the image's bottom-left corner + * without it. With `scaleAt`, `scaleAnchor` gives that point in image pixels. + * The scale is read before the capture starts, so it matches the captured + * view; the image-to-CSS pixel ratio comes from the image's width against the + * map's. + */ +export async function captureScreenshotWithScale( + engine: ScaleEngine & Pick, + scaleAt?: ClientPoint +): Promise { + const reading = readScale(engine, scaleAt) + const cssWidth = reading == null ? 0 : engine.getContainer().clientWidth + const screenshot = await engine.captureScreenshot() + const ratio = + reading != null && cssWidth > 0 && screenshot.width > 0 + ? screenshot.width / cssWidth + : null + if (ratio == null) return { ...screenshot, metersPerPixel: null } + const result: MapScreenshotResult = { + ...screenshot, + metersPerPixel: reading!.metersPerPixel / ratio, + } + if (isClientPoint(scaleAt)) { + result.scaleAnchor = { x: reading!.x * ratio, y: reading!.y * ratio } + } + return result +} diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 30e9ecade7..eec2375163 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -1,6 +1,7 @@ import $ from 'jquery' import { isStaticBuild } from '../../../pre/capabilities' import F_ from '../Formulae_/Formulae_' +import { metersPerPixelAt } from '../MapEngines/mapScale' import L_ from '../Layers_/Layers_' import ServiceUrls from '../ServiceUrls/ServiceUrls' import { captureVector } from '../Layers_/LayerCapturer' @@ -260,6 +261,7 @@ let Map_ = { window.mmgisAPI.provide('map:getCenter', () => engine.getCenter()), window.mmgisAPI.provide('map:getBounds', () => engine.getBounds()), window.mmgisAPI.provide('map:getZoom', () => engine.getZoom()), + window.mmgisAPI.provide('map:getMetersPerPixel', (at) => metersPerPixelAt(engine, at)), window.mmgisAPI.provide('map:setView', ({ center, zoom } = {}) => { if (!center) return false engine.setView(center, zoom) @@ -410,6 +412,7 @@ let Map_ = { // Pointer streams consumed by plugins (e.g. measure tools, overlays). reEmit('click', 'map:click') reEmit('mousemove', 'map:mousemove') + reEmit('mouseout', 'map:mouseout') // Feature click → bus. Plugins (e.g. AOI Inspect) consume // `map:featureClick` to react to clicks on layers created via diff --git a/src/essence/Tools/MapControl/config.json b/src/essence/Tools/MapControl/config.json index 12b957f439..e9bb84403c 100644 --- a/src/essence/Tools/MapControl/config.json +++ b/src/essence/Tools/MapControl/config.json @@ -62,7 +62,7 @@ { "field": "variables.showShare", "name": "Share", - "description": "Show the share button (copy link, export PNG/PDF).", + "description": "Show the share button (copy link, export PNG/PDF). While the ScaleBar tool shows a scale bar on the map, exports draw a matching one.", "type": "checkbox", "width": 3, "defaultChecked": true diff --git a/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx b/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx new file mode 100644 index 0000000000..06032575f3 --- /dev/null +++ b/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx @@ -0,0 +1,148 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { ScaleBar } from './lib' +import { + buildScaleBarModel, + resolveScaleUnits, + SCALE_BAR_MAX_WIDTH_PX, + type ScaleBarModel, +} from '../_shared/scale/scaleBarModel' +import { useMMGISToolVars } from '../_shared/adapters/useMMGISToolVars' +import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady' +import { useMMGISEvent } from '../_shared/adapters/useMMGISEvent' +import { + mmgisGetEngineType, + mmgisGetMetersPerPixel, + mmgisProvide, + EXPORT_SCALE_BAR_REQUEST, + type ScaleBarSettings, +} from '../_shared/adapters/mmgisAPI' + +type ToolVars = { + units?: unknown +} + +// The bottom-left corner of the bar's backing box, where an export draws its +// own backing and measures its scale. Undefined while no bar is drawn, or +// under a display:none ancestor (a hidden plugin or collapsed panel). +const backingCorner = (host: HTMLElement | null) => { + const bar = host?.querySelector('.blocks-scale-bar') + if (!bar || bar.getClientRects().length === 0) return undefined + const rect = bar.getBoundingClientRect() + return { clientX: rect.left, clientY: rect.bottom } +} + +// The pointer's position in the map container, from a 'map:mousemove' event. +const containerPointOf = (event: unknown): { x: number; y: number } | null => { + const p = (event as { containerPoint?: { x?: unknown; y?: unknown } } | undefined) + ?.containerPoint + return p && Number.isFinite(p.x) && Number.isFinite(p.y) + ? { x: p.x as number, y: p.y as number } + : null +} + +// Below this change in length, in CSS pixels, a redraw would look the same. +const WIDTH_TOLERANCE_PX = 0.25 + +// Whether two models draw the same bar, so a frame that changes neither the +// label nor the visible length skips the render. +const drawsSameBar = (a: ScaleBarModel | null, b: ScaleBarModel | null): boolean => { + if (!a || !b) return a === b + return a.label === b.label && Math.abs(a.widthPx - b.widthPx) < WIDTH_TOLERANCE_PX +} + +export function MMGISScaleBarAdapter() { + const vars = useMMGISToolVars('scalebar') + const units = resolveScaleUnits(vars.units) + const [model, setModel] = useState(null) + const hostRef = useRef(null) + + // The bar shows the scale under the pointer, and the scale at the map's + // centre while the pointer is off the map. Leaflet does not report the + // pointer leaving the map when it leaves across a feature that listens + // for hovers, so there the bar always shows the centre's scale. + const followsPointer = useRef(false) + const pointer = useRef<{ x: number; y: number } | null>(null) + // Reads can settle out of order; only the latest one is shown. + const latestRead = useRef(0) + + const refresh = useCallback(async () => { + const read = ++latestRead.current + let next: number | null = null + try { + next = await mmgisGetMetersPerPixel(pointer.current ?? undefined) + // A pointer off the map's ground (past a world's edge) has no + // scale of its own, so the centre's stands in. + if (next == null && pointer.current) next = await mmgisGetMetersPerPixel() + } catch (err) { + console.warn('ScaleBar: reading the map scale failed', err) + } + if (read !== latestRead.current) return + const nextModel = buildScaleBarModel(next, units, SCALE_BAR_MAX_WIDTH_PX) + setModel((prev) => (drawsSameBar(prev, nextModel) ? prev : nextModel)) + }, [units]) + // The engine is built by the time the map can measure its scale. This + // reruns when the units change, which redraws the bar in them. + const onMapReady = useCallback(async () => { + try { + followsPointer.current = (await mmgisGetEngineType()) !== 'leaflet' + } catch { + followsPointer.current = false + } + await refresh() + }, [refresh]) + useMMGISHandlerReady('map:getMetersPerPixel', onMapReady) + // Engines raise 'map:move' at most once per rendered frame, so reading + // on it directly keeps the bar in step with the map. + useMMGISEvent('map:move', refresh) + useMMGISEvent('map:moveend', refresh) + + const onPointerMove = useCallback( + (event?: unknown) => { + if (!followsPointer.current) return + pointer.current = containerPointOf(event) + void refresh() + }, + [refresh], + ) + const onPointerOut = useCallback(() => { + if (!pointer.current) return + pointer.current = null + void refresh() + }, [refresh]) + useMMGISEvent('map:mousemove', onPointerMove) + useMMGISEvent('map:mouseout', onPointerOut) + + // A map with no size yet has no scale; the layout dispatches 'resize' + // whenever the map area changes size, often several times in a row, so + // the read waits for the next frame. + const frame = useRef(null) + const refreshNextFrame = useCallback(() => { + if (frame.current != null) return + frame.current = requestAnimationFrame(() => { + frame.current = null + void refresh() + }) + }, [refresh]) + useEffect(() => { + window.addEventListener('resize', refreshNextFrame) + return () => { + window.removeEventListener('resize', refreshNextFrame) + if (frame.current != null) cancelAnimationFrame(frame.current) + } + }, [refreshNextFrame]) + + useEffect( + () => + mmgisProvide(EXPORT_SCALE_BAR_REQUEST, (): ScaleBarSettings | null => { + const at = backingCorner(hostRef.current) + return at ? { units, at } : null + }), + [units], + ) + + return ( +
+ +
+ ) +} diff --git a/src/essence/Tools/ScaleBar/ScaleBarTool.tsx b/src/essence/Tools/ScaleBar/ScaleBarTool.tsx new file mode 100644 index 0000000000..3a2b4f71f1 --- /dev/null +++ b/src/essence/Tools/ScaleBar/ScaleBarTool.tsx @@ -0,0 +1,70 @@ +/** + * ScaleBarTool — MMGIS tool wrapper for the map scale bar. + * + * Placement is layout-owned: the layout assigns the tool a panel (typically a + * `float-bottom-left` floating panel over the map) and calls make(targetId); + * the React root mounts into that container. + * + * All MMGIS bus access lives in MMGISScaleBarAdapter. + */ +import React from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { MMGISScaleBarAdapter } from './MMGISScaleBarAdapter' + +const HOST_ID = 'scaleBar-tool-host' + +let _root: Root | null = null +let _host: HTMLDivElement | null = null + +const ScaleBarTool = { + height: 0, + width: 0, + targetId: null as string | null, + made: false, + + make(targetId?: string) { + this.targetId = typeof targetId === 'string' ? targetId : 'toolPanel' + const container = document.getElementById(this.targetId) + if (!container) { + console.error(`ScaleBarTool: container ${this.targetId} not found`) + return + } + + // Double-make guard: fully tear down the previous mount. + if (_root) { + _root.unmount() + _root = null + } + if (_host && _host.parentNode) { + _host.parentNode.removeChild(_host) + } + + _host = document.createElement('div') + _host.id = HOST_ID + _host.className = 'scaleBar-tool-host' + container.appendChild(_host) + + _root = createRoot(_host) + _root.render() + this.made = true + }, + + destroy() { + if (_root) { + _root.unmount() + _root = null + } + if (_host && _host.parentNode) { + _host.parentNode.removeChild(_host) + } + _host = null + this.targetId = null + this.made = false + }, + + getUrlString() { + return '' + }, +} + +export default ScaleBarTool diff --git a/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx b/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx new file mode 100644 index 0000000000..0367c91a8c --- /dev/null +++ b/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx @@ -0,0 +1,283 @@ +import React from 'react' +import { act } from 'react' +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' +import { MMGISScaleBarAdapter } from '../MMGISScaleBarAdapter' +import { mount } from '../../_shared/__tests__/reactHarness' + +/** + * The adapter against a fake bus: tool variables and the map's scale come + * from `request`, bus events are captured from `on` so a spec can fire them, + * and whatever the adapter `provide`s is kept so a spec can call it as an + * export would. + */ + +let vars: Record +let engineType: 'leaflet' | 'deckgl' | null +// The map's scale, or the scale at a requested container point. +let metersPerPixel: number | null | ((at?: { x: number; y: number }) => number | null) +let listeners: Map void>> +let provided: Map unknown> + +const fire = async (event: string, payload?: unknown) => { + await act(async () => { + for (const handler of listeners.get(event) ?? []) handler(payload) + }) +} + +// Lets a read scheduled for the next animation frame run and settle. +const nextFrame = async () => { + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)) + }) +} + +const requestMock = () => + (window as unknown as { mmgisAPI: { request: ReturnType } }) + .mmgisAPI.request + +const scaleReads = () => + requestMock().mock.calls.filter(([name]) => name === 'map:getMetersPerPixel') + .length + +const label = (container: HTMLElement) => + container.querySelector('.blocks-scale-bar__label')?.textContent ?? null + +// jsdom does no layout, so nothing has client rects; a bar the page shows +// is simulated by giving its backing box one. +const showBar = (container: HTMLElement) => { + const bar = container.querySelector('.blocks-scale-bar') as HTMLElement + bar.getClientRects = () => [{}] as unknown as DOMRectList + return bar +} + +beforeEach(() => { + vars = {} + engineType = 'deckgl' + metersPerPixel = 1 + listeners = new Map() + provided = new Map() + ;(window as { mmgisAPI?: unknown }).mmgisAPI = { + request: vi.fn(async (name: string, arg?: unknown) => { + if (name === 'tool:getVars') return vars + if (name === 'map:getEngineType') return engineType + if (name === 'map:getMetersPerPixel') { + return typeof metersPerPixel === 'function' + ? metersPerPixel(arg as { x: number; y: number } | undefined) + : metersPerPixel + } + return null + }), + hasHandler: () => true, + emit: vi.fn(), + on: (event: string, handler: (payload?: unknown) => void) => { + if (!listeners.has(event)) listeners.set(event, new Set()) + listeners.get(event)!.add(handler) + return () => listeners.get(event)!.delete(handler) + }, + provide: (name: string, handler: (...args: unknown[]) => unknown) => { + provided.set(name, handler) + return () => provided.delete(name) + }, + } +}) + +afterEach(() => { + delete (window as { mmgisAPI?: unknown }).mmgisAPI + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +describe('MMGISScaleBarAdapter', () => { + test('a mission that never chose units gets metric', async () => { + const { container, unmount } = await mount() + expect(label(container)).toBe('100 m') + const rule = container.querySelector('.blocks-scale-bar__rule') as HTMLElement + expect(rule.style.width).toBe('100px') + await unmount() + }) + + test('imperial units label the bar in feet', async () => { + vars = { units: 'imperial' } + const { container, unmount } = await mount() + expect(label(container)).toBe('200 ft') + await unmount() + }) + + test.each(['map:move', 'map:moveend'])( + 'rereads the scale on %s', + async (event) => { + const { container, unmount } = await mount() + metersPerPixel = 10 + await fire(event) + expect(label(container)).toBe('1 km') + await unmount() + }, + ) + + test('reads the scale once per resize burst', async () => { + const { unmount } = await mount() + const before = scaleReads() + window.dispatchEvent(new Event('resize')) + window.dispatchEvent(new Event('resize')) + window.dispatchEvent(new Event('resize')) + await nextFrame() + expect(scaleReads()).toBe(before + 1) + await unmount() + }) + + // A map laid out after the plugin mounts has no scale at first. + test('rereads the scale when the map area resizes', async () => { + metersPerPixel = null + const { container, unmount } = await mount() + expect(container.querySelector('.blocks-scale-bar')).toBeNull() + metersPerPixel = 1 + window.dispatchEvent(new Event('resize')) + await nextFrame() + expect(label(container)).toBe('100 m') + await unmount() + }) + + test('measures at the map centre before the pointer is on the map', async () => { + const { unmount } = await mount() + await fire('map:moveend') + expect(requestMock()).toHaveBeenLastCalledWith('map:getMetersPerPixel', undefined) + await unmount() + }) + + describe('with the pointer on the map', () => { + const pointerAt = (x: number, y: number) => + fire('map:mousemove', { containerPoint: { x, y } }) + + test('shows the scale under the pointer', async () => { + metersPerPixel = (at) => (at?.y === 300 ? 10 : 1) + const { container, unmount } = await mount() + expect(label(container)).toBe('100 m') + await pointerAt(40, 300) + expect(requestMock()).toHaveBeenLastCalledWith('map:getMetersPerPixel', { + x: 40, + y: 300, + }) + expect(label(container)).toBe('1 km') + await unmount() + }) + + // A scroll zoom keeps the pointer still while the ground under it + // changes, so moves are measured where the pointer rests. + test('keeps measuring at the pointer while the map moves', async () => { + const { unmount } = await mount() + await pointerAt(40, 300) + await fire('map:move') + expect(requestMock()).toHaveBeenLastCalledWith('map:getMetersPerPixel', { + x: 40, + y: 300, + }) + await unmount() + }) + + test('returns to the map centre once the pointer leaves', async () => { + metersPerPixel = (at) => (at ? 10 : 1) + const { container, unmount } = await mount() + await pointerAt(40, 300) + expect(label(container)).toBe('1 km') + await fire('map:mouseout') + expect(requestMock()).toHaveBeenLastCalledWith('map:getMetersPerPixel', undefined) + expect(label(container)).toBe('100 m') + await unmount() + }) + + // Leaflet misses the pointer leaving across a hoverable feature, which + // would leave the bar measuring at the map's edge. + test('stays at the map centre on Leaflet', async () => { + engineType = 'leaflet' + metersPerPixel = (at) => (at ? 10 : 1) + const { container, unmount } = await mount() + const before = scaleReads() + await pointerAt(40, 300) + expect(scaleReads()).toBe(before) + await fire('map:move') + expect(requestMock()).toHaveBeenLastCalledWith('map:getMetersPerPixel', undefined) + expect(label(container)).toBe('100 m') + await unmount() + }) + + // Past a zoomed-out world's edge there is no ground under the pointer. + test("shows the centre's scale where the pointer has none", async () => { + metersPerPixel = (at) => (at ? null : 10) + const { container, unmount } = await mount() + await pointerAt(40, 5) + expect(label(container)).toBe('1 km') + await unmount() + }) + }) + + // A pan along a row barely changes the scale; the bar keeps its state so + // React can skip the render. + test('keeps the bar when a frame changes neither label nor visible length', async () => { + const { container, unmount } = await mount() + const rule = () => + (container.querySelector('.blocks-scale-bar__rule') as HTMLElement).style.width + expect(rule()).toBe('100px') + metersPerPixel = 1.0001 + await fire('map:moveend') + expect(rule()).toBe('100px') + metersPerPixel = 1.2 + await fire('map:moveend') + expect(rule()).not.toBe('100px') + await unmount() + }) + + test('an unknown scale shows no bar', async () => { + metersPerPixel = null + const { container, unmount } = await mount() + expect(container.querySelector('.blocks-scale-bar')).toBeNull() + await unmount() + }) + + describe('export settings', () => { + const settings = () => { + const handler = provided.get('export:getScaleBar') + if (!handler) throw new Error('getSettings was not provided') + return handler() + } + + test('answer with the units while the bar is on screen', async () => { + vars = { units: 'imperial' } + const { container, unmount } = await mount() + const bar = showBar(container) + bar.getBoundingClientRect = () => ({ left: 24, bottom: 580 }) as DOMRect + // The backing box's corner lets the export draw and measure its + // bar there too. + expect(settings()).toEqual({ + units: 'imperial', + at: { clientX: 24, clientY: 580 }, + }) + await unmount() + }) + + // A hidden plugin stays mounted; its bar is not on the map, so it + // must not appear in the export either. + test('answer null while the bar is hidden', async () => { + const { unmount } = await mount() + expect(settings()).toBeNull() + await unmount() + }) + + // The host stays laid out while the scale is unknown and no bar is + // drawn, so the export must not draw one either. + test('answer null while no bar is drawn', async () => { + metersPerPixel = null + const { container, unmount } = await mount() + const host = container.querySelector('.blocks-scale-bar-host') as HTMLElement + host.getClientRects = () => [{}] as unknown as DOMRectList + expect(settings()).toBeNull() + await unmount() + }) + + test('stop answering once the plugin is gone', async () => { + const { unmount } = await mount() + expect(provided.has('export:getScaleBar')).toBe(true) + await unmount() + expect(provided.has('export:getScaleBar')).toBe(false) + }) + }) +}) diff --git a/src/essence/Tools/ScaleBar/config.json b/src/essence/Tools/ScaleBar/config.json new file mode 100644 index 0000000000..8cbc9079db --- /dev/null +++ b/src/essence/Tools/ScaleBar/config.json @@ -0,0 +1,46 @@ +{ + "defaultIcon": "ruler", + "description": "A scale bar for the 2D map, in metric or imperial units. Exported PNGs and PDFs carry the same bar.", + "descriptionFull": { + "title": "Shows a scale bar, typically in a float-bottom-left panel over the map. The bar is labelled with a round distance (1, 2, 5, 10, ...), in metres and kilometres or feet and miles, and gives the scale under the pointer, or at the map's centre while the pointer is off the map. While the bar is shown, PNG and PDF exports from the Share button draw a matching bar at the same spot on the map, measured there; leave the tool out of the layout for no bar on the map or in exports. Exports carry no bar when the map is tilted more than 10° or zoomed out so far that the scale varies widely across the view.", + "example": { + "units": "metric" + } + }, + "hasVars": true, + "name": "ScaleBar", + "toolbarPriority": 6, + "paths": { + "ScaleBarTool": "essence/Tools/ScaleBar/ScaleBarTool" + }, + "metadata": { + "icon": "ruler", + "compatiblePositions": [ + "float-top-left", + "float-top-center", + "float-top-right", + "float-bottom-left", + "float-bottom-center", + "float-bottom-right" + ], + "preferredPosition": "float-bottom-left", + "modernLayoutSupport": true + }, + "config": { + "rows": [ + { + "components": [ + { + "field": "variables.units", + "name": "Units", + "description": "Metric labels the bar in metres and kilometres; imperial in feet and miles. Exports use the same units.", + "type": "dropdown", + "width": 4, + "options": ["metric", "imperial"], + "default": "metric" + } + ] + } + ] + } +} diff --git a/src/essence/Tools/ScaleBar/lib/ScaleBar.tsx b/src/essence/Tools/ScaleBar/lib/ScaleBar.tsx new file mode 100644 index 0000000000..a74e10f275 --- /dev/null +++ b/src/essence/Tools/ScaleBar/lib/ScaleBar.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import type { ScaleBarModel } from '../../_shared/scale/scaleBarModel' + +export type ScaleBarProps = { + model: ScaleBarModel | null +} + +/** + * A bracket `model.widthPx` CSS pixels long with its distance above it. + * Renders nothing while the scale is unknown. + */ +export function ScaleBar({ model }: ScaleBarProps) { + if (!model) return null + return ( +
+ {model.label} +
+
+ ) +} diff --git a/src/essence/Tools/ScaleBar/lib/index.ts b/src/essence/Tools/ScaleBar/lib/index.ts new file mode 100644 index 0000000000..aed107676c --- /dev/null +++ b/src/essence/Tools/ScaleBar/lib/index.ts @@ -0,0 +1,4 @@ +export { ScaleBar, type ScaleBarProps } from './ScaleBar' + +// Side-effect import of compiled styles +import './styles/index.scss' diff --git a/src/essence/Tools/ScaleBar/lib/styles/index.scss b/src/essence/Tools/ScaleBar/lib/styles/index.scss new file mode 100644 index 0000000000..058f939469 --- /dev/null +++ b/src/essence/Tools/ScaleBar/lib/styles/index.scss @@ -0,0 +1,29 @@ +/* Scale bar: a bracket under its distance; the rule's width is set inline. */ +.blocks-scale-bar { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: var(--theme-spacing-05, 0.25rem) var(--theme-spacing-1, 0.5rem); + background: rgba(255, 255, 255, 0.8); + border-radius: var(--theme-radius-sm, 2px); + font-family: var(--theme-font-ui, 'Public Sans Web', 'Helvetica Neue', sans-serif); + font-size: var(--theme-font-size-3xs, 0.75rem); + font-weight: var(--theme-font-weight-semibold, 600); + line-height: 1.2; + color: var(--theme-color-ink, #1b1b1b); + pointer-events: none; + user-select: none; + + &__label { + padding-left: 8px; + white-space: nowrap; + } + + &__rule { + box-sizing: border-box; + height: 8px; + border: 2px solid currentColor; + border-top: none; + } +} diff --git a/src/essence/Tools/ShareExport/config.json b/src/essence/Tools/ShareExport/config.json index 6e143af5aa..97a1626674 100644 --- a/src/essence/Tools/ShareExport/config.json +++ b/src/essence/Tools/ShareExport/config.json @@ -9,7 +9,7 @@ "defaultIcon": "share-variant", "description": "A \"Share map\" button that opens a menu to share the current view as a link, PNG, or PDF.", "descriptionFull": { - "title": "Adds a \"Share map\" button, typically placed in a floating panel over the map (e.g. float-top-right). Clicking it opens a compact dropdown menu. \"Copy link to view\" copies a complete, self-contained URL (layers, place, zoom, time) to the clipboard. \"Export as PNG\" downloads a snapshot of the current map, and \"Export as PDF\" embeds that same snapshot centered on a portrait page. Both exports append a legend band below the map — the name of every layer that is toggled on, with its color bar, range and unit — which can be toggled off. The PNG and PDF items can each be toggled on or off per-dashboard; the share link is always available.", + "title": "Adds a \"Share map\" button, typically placed in a floating panel over the map (e.g. float-top-right). Clicking it opens a compact dropdown menu. \"Copy link to view\" copies a complete, self-contained URL (layers, place, zoom, time) to the clipboard. \"Export as PNG\" downloads a snapshot of the current map, and \"Export as PDF\" embeds that same snapshot centered on a portrait page. Both exports append a legend band below the map — the name of every layer that is toggled on, with its color bar, range and unit — which can be toggled off. While the ScaleBar tool shows a scale bar on the map, both exports draw a matching bar at the same spot on the map. The PNG and PDF items can each be toggled on or off per-dashboard; the share link is always available.", "example": { "exportPng": true, "exportPdf": true, diff --git a/src/essence/Tools/_shared/adapters/mmgisAPI.ts b/src/essence/Tools/_shared/adapters/mmgisAPI.ts index 3e8c27afad..4345f21391 100644 --- a/src/essence/Tools/_shared/adapters/mmgisAPI.ts +++ b/src/essence/Tools/_shared/adapters/mmgisAPI.ts @@ -1,4 +1,5 @@ import type { LayerLegend } from '../../../Basics/Layers_/legend/types' +import type { ScaleUnits } from '../scale/scaleBarModel' type EventCleanup = () => void @@ -26,8 +27,16 @@ export type MapScreenshotResult = { extension: 'png' width: number height: number + /** Ground metres per image pixel at the requested `scaleAt`, or near the + * image's bottom-left corner without it, at capture time. */ + metersPerPixel?: number | null + /** `scaleAt` in image pixels; null or absent when none was requested. */ + scaleAnchor?: { x: number; y: number } | null } +/** A position on the page, in CSS pixels, as a DOM rectangle or event gives it. */ +export type ClientPoint = { clientX: number; clientY: number } + /** The subset of a mission layer config that plugins read off the bus. */ export type LayerConfig = { name?: string @@ -149,11 +158,47 @@ export const mmgisWriteCoordinateURL = (): Promise => { return mmgisRequestIfProvided('map:writeCoordinateURL') } -/** The current map as a PNG Blob plus image metadata. */ -export const mmgisGetMapScreenshot = (): Promise< - MapScreenshotResult | null -> => { - return mmgisRequestIfProvided('map:getScreenshot') +/** + * The current map as a PNG Blob plus image metadata. `scaleAt` picks the page + * position its `metersPerPixel` is measured at. + */ +export const mmgisGetMapScreenshot = (options?: { + scaleAt?: ClientPoint +}): Promise => { + return mmgisRequestIfProvided('map:getScreenshot', options) +} + +/** + * Ground metres per CSS pixel at a map container point (e.g. a pointer + * event's `containerPoint`), or at the map's centre when `at` is omitted. Null + * before the map exists or where it cannot measure. + */ +export const mmgisGetMetersPerPixel = ( + at?: { x: number; y: number }, +): Promise => { + return mmgisRequestIfProvided('map:getMetersPerPixel', at) +} + +/** Which engine draws the map; null until the mission has built one. */ +export const mmgisGetEngineType = (): Promise<'leaflet' | 'deckgl' | null> => { + return mmgisRequestIfProvided<'leaflet' | 'deckgl'>('map:getEngineType') +} + +export type ScaleBarSettings = { + units: ScaleUnits + /** Where the on-screen bar's backing box has its bottom-left corner on + * the page, so an export can draw and measure its bar at the same spot. */ + at?: ClientPoint +} + +/** Provided by whichever plugin draws a scale bar on the map, and asked by + * exports so they carry a matching bar. */ +export const EXPORT_SCALE_BAR_REQUEST = 'export:getScaleBar' + +/** The on-screen scale bar's settings while one is drawn, otherwise null, so + * exports carry a bar exactly when the map shows one. */ +export const mmgisGetScaleBarSettings = (): Promise => { + return mmgisRequestIfProvided(EXPORT_SCALE_BAR_REQUEST) } /** View metadata (mission, time, center, zoom); fields null until loaded. */ diff --git a/src/essence/Tools/_shared/adapters/shareActions.ts b/src/essence/Tools/_shared/adapters/shareActions.ts index b14d0a356f..39c5e3c822 100644 --- a/src/essence/Tools/_shared/adapters/shareActions.ts +++ b/src/essence/Tools/_shared/adapters/shareActions.ts @@ -3,12 +3,19 @@ import { mmgisGetMapScreenshot, mmgisGetViewState, mmgisCopyText, + mmgisGetScaleBarSettings, + type ClientPoint, type MapScreenshotResult, + type ScaleBarSettings, type ViewState, } from './mmgisAPI' import { buildSharePdf, type JsPdfLike } from './sharePdf' import { blobToDataUrl, downloadBlob } from './download' -import { composeExportImage } from '../legend/composeExportImage' +import { + composeExportImage, + type ComposeDeps, + type ExportScaleBar, +} from '../legend/composeExportImage' import { getExportLegendModel, type ExportLegendModel, @@ -65,37 +72,98 @@ export async function copyShareLink( return url } -export type LegendDeps = { +export type DecorationDeps = { includeLegend?: boolean getLegendModel?: () => Promise + getScaleBarSettings?: () => Promise compose?: ( screenshot: MapScreenshotResult, - model: ExportLegendModel, + model: ExportLegendModel | null, + deps?: ComposeDeps, ) => Promise } -// The export must never fail because the legend did — deliver the plain map -// and log, rather than surfacing a broken download. -async function applyLegend( +type GetScreenshot = (options?: { + scaleAt?: ClientPoint +}) => Promise + +async function readScaleBarSettings( + getScaleBarSettings: () => Promise, +): Promise { + try { + return await getScaleBarSettings() + } catch (err) { + console.warn('Share export: scale bar omitted', err) + return null + } +} + +// Uses the capture's own scale, which is in image pixels and of that exact +// view, measured where the on-screen bar sits when the capture was given it. +// A tilted or continent-wide view has no scale by design, so a capture +// without one is not logged; the capture logs a failure to measure itself. +function resolveExportScaleBar( + screenshot: MapScreenshotResult, + settings: ScaleBarSettings | null, +): ExportScaleBar | null { + if (!settings) return null + const { metersPerPixel, scaleAnchor } = screenshot + if (typeof metersPerPixel !== 'number' || !(metersPerPixel > 0)) return null + return { + metersPerPixel, + units: settings.units, + ...(scaleAnchor ? { anchor: scaleAnchor } : {}), + } +} + +// The export must never fail because a decoration did — deliver what can be +// drawn and log the rest, rather than surfacing a broken download. +async function applyDecorations( screenshot: MapScreenshotResult, - deps: LegendDeps, + settings: ScaleBarSettings | null, + deps: DecorationDeps, ): Promise { const { includeLegend = true, getLegendModel = getExportLegendModel, compose = composeExportImage, } = deps - if (!includeLegend) return screenshot + const scaleBar = resolveExportScaleBar(screenshot, settings) + let model: ExportLegendModel | null = null + if (includeLegend) { + try { + model = await getLegendModel() + } catch (err) { + console.warn('Share export: legend omitted', err) + } + } + if (!model && !scaleBar) return screenshot try { - return await compose(screenshot, await getLegendModel()) + return await compose(screenshot, model, { scaleBar }) } catch (err) { - console.warn('Share export: legend omitted', err) + console.warn('Share export: legend and scale bar omitted', err) return screenshot } } -export type DownloadSharePngDeps = LegendDeps & { - getScreenshot?: () => Promise +// The ScaleBar plugin's settings are read before the capture, so the capture +// measures the scale where the on-screen bar sits. +async function captureDecorated( + getScreenshot: GetScreenshot, + deps: DecorationDeps, +): Promise { + const settings = await readScaleBarSettings( + deps.getScaleBarSettings ?? mmgisGetScaleBarSettings, + ) + const screenshot = await getScreenshot( + settings?.at ? { scaleAt: settings.at } : undefined, + ) + if (!screenshot) throw new Error('No screenshot available') + return applyDecorations(screenshot, settings, deps) +} + +export type DownloadSharePngDeps = DecorationDeps & { + getScreenshot?: GetScreenshot download?: (blob: Blob, filename: string) => void getViewState?: () => Promise filename?: string @@ -112,9 +180,7 @@ export async function downloadSharePng( download = downloadBlob, getViewState = mmgisGetViewState, } = deps - const screenshot = await getScreenshot() - if (!screenshot) throw new Error('No screenshot available') - const composed = await applyLegend(screenshot, deps) + const composed = await captureDecorated(getScreenshot, deps) const filename = deps.filename ?? buildExportFilename(composed.extension, await getViewState()) @@ -122,8 +188,8 @@ export async function downloadSharePng( return composed } -export type DownloadSharePdfDeps = LegendDeps & { - getScreenshot?: () => Promise +export type DownloadSharePdfDeps = DecorationDeps & { + getScreenshot?: GetScreenshot blobToDataUrl?: (blob: Blob) => Promise buildPdf?: ( dataUrl: string, @@ -148,9 +214,7 @@ export async function downloadSharePdf( buildPdf = buildSharePdf, getViewState = mmgisGetViewState, } = deps - const screenshot = await getScreenshot() - if (!screenshot) throw new Error('No screenshot available') - const composed = await applyLegend(screenshot, deps) + const composed = await captureDecorated(getScreenshot, deps) const dataUrl = await convertBlobToDataUrl(composed.blob) const doc = await buildPdf(dataUrl, composed.width, composed.height) const filename = diff --git a/src/essence/Tools/_shared/legend/composeExportImage.ts b/src/essence/Tools/_shared/legend/composeExportImage.ts index 17c54a62e3..8cd0ca1d53 100644 --- a/src/essence/Tools/_shared/legend/composeExportImage.ts +++ b/src/essence/Tools/_shared/legend/composeExportImage.ts @@ -2,25 +2,53 @@ import type { MapScreenshotResult } from '../adapters/mmgisAPI' import { measureLegendBand, drawLegendBand } from './renderLegendBand' import type { ExportLegendModel } from './getExportLegendModel' import { resolveBandTheme, loadBandFonts, type BandTheme } from './bandTheme' +import { + buildScaleBarModel, + SCALE_BAR_MAX_WIDTH_PX, + type ScaleUnits, +} from '../scale/scaleBarModel' +import { + drawScaleBar, + measureScaleBar, + SCALE_BAR_MARGIN, +} from '../scale/renderScaleBar' + +export type ExportScaleBar = { + metersPerPixel: number + units: ScaleUnits + /** Image pixel for the bar's bottom-left corner, matching where the + * on-screen bar sits; the map's bottom-left corner when absent. */ + anchor?: { x: number; y: number } | null +} export type ComposeDeps = { createBitmap?: (blob: Blob) => Promise createCanvas?: () => HTMLCanvasElement scale?: number theme?: BandTheme + scaleBar?: ExportScaleBar | null } +// Low wins when the range is empty, keeping the bar's top-left on the image. +const clamp = (v: number, lo: number, hi: number): number => + Math.max(lo, Math.min(v, hi)) + +// Keeps the bar from dominating a narrow capture. +const SCALE_BAR_MAX_IMAGE_SHARE = 1 / 3 + /** - * Appends the legend band below a captured map image. The screenshot blob is - * the plugin/core boundary — decode it, extend the canvas downward, re-encode - * in the same mime type. An empty model returns the screenshot untouched. + * Decorates a captured map image: a scale bar over the map, at its anchor or + * the bottom-left corner, and the legend band appended below the map. The + * screenshot blob is the plugin/core boundary — decode it, draw onto a canvas, + * re-encode in the same mime type. With neither a legend row nor a scale bar to draw, the + * screenshot is returned untouched. */ export async function composeExportImage( screenshot: MapScreenshotResult, model: ExportLegendModel | null, deps: ComposeDeps = {}, ): Promise { - if (!model || model.rows.length === 0) return screenshot + const hasLegend = !!model && model.rows.length > 0 const { createBitmap = (blob: Blob) => createImageBitmap(blob), createCanvas = () => document.createElement('canvas'), @@ -29,7 +57,19 @@ export async function composeExportImage( (typeof window !== 'undefined' && window.devicePixelRatio) || 1, ), theme = resolveBandTheme(), + scaleBar = null, } = deps + const barModel = scaleBar + ? buildScaleBarModel( + scaleBar.metersPerPixel, + scaleBar.units, + Math.min( + SCALE_BAR_MAX_WIDTH_PX * scale, + screenshot.width * SCALE_BAR_MAX_IMAGE_SHARE, + ), + ) + : null + if (!hasLegend && !barModel) return screenshot await loadBandFonts(theme) const bitmap = await createBitmap(screenshot.blob) @@ -37,7 +77,7 @@ export async function composeExportImage( const canvas = createCanvas() const ctx = canvas.getContext('2d') if (!ctx) { - throw new Error('Legend compositing needs a 2D canvas context') + throw new Error('Export compositing needs a 2D canvas context') } // Measure on this same context before resizing the canvas: setting // canvas.width/height resets all context state (font, fillStyle, @@ -45,29 +85,40 @@ export async function composeExportImage( // anyway, so nothing here depends on that state surviving the // resize. That makes a second, throwaway canvas just for measuring // unnecessary. - const bandHeight = measureLegendBand( - ctx, - model, - screenshot.width, - scale, - theme, - ) + const bandHeight = hasLegend + ? measureLegendBand(ctx, model!, screenshot.width, scale, theme) + : 0 canvas.width = screenshot.width canvas.height = screenshot.height + bandHeight ctx.drawImage(bitmap, 0, 0) - drawLegendBand( - ctx, - model, - screenshot.width, - screenshot.height, - bandHeight, - scale, - theme, - ) + if (barModel) { + const margin = SCALE_BAR_MARGIN * scale + const box = measureScaleBar(ctx, barModel, scale, theme) + const anchor = scaleBar!.anchor + // An anchor near an edge is pulled in so the whole bar shows. + const left = anchor + ? clamp(anchor.x, margin, screenshot.width - box.width - margin) + : margin + const bottom = anchor + ? clamp(anchor.y, box.height + margin, screenshot.height - margin) + : screenshot.height - margin + drawScaleBar(ctx, barModel, { left, bottom, scale, theme }) + } + if (hasLegend) { + drawLegendBand( + ctx, + model!, + screenshot.width, + screenshot.height, + bandHeight, + scale, + theme, + ) + } const blob = await new Promise((resolve) => canvas.toBlob(resolve, screenshot.mimeType), ) - if (!blob) throw new Error('Legend compositing produced no image') + if (!blob) throw new Error('Export compositing produced no image') return { ...screenshot, blob, height: canvas.height } } finally { bitmap.close?.() diff --git a/src/essence/Tools/_shared/scale/__tests__/renderScaleBar.spec.js b/src/essence/Tools/_shared/scale/__tests__/renderScaleBar.spec.js new file mode 100644 index 0000000000..62cbb55743 --- /dev/null +++ b/src/essence/Tools/_shared/scale/__tests__/renderScaleBar.spec.js @@ -0,0 +1,73 @@ +import { describe, test, expect } from 'vitest' +import { drawScaleBar, measureScaleBar } from '../renderScaleBar.ts' + +const makeCtx = () => { + const fillRects = [] + const fillTexts = [] + const ctx = { + fillStyle: null, + font: '', + textBaseline: 'alphabetic', + fillRect: (...args) => fillRects.push({ args, fillStyle: ctx.fillStyle }), + fillText: (...args) => fillTexts.push({ args, font: ctx.font }), + measureText: (t) => ({ width: t.length * 6 }), + } + return { ctx, fillRects, fillTexts } +} + +const fontPx = (font) => Number(font.match(/(\d+)px/)[1]) + +const model = { distance: 100, unit: 'm', label: '100 m', widthPx: 100 } + +describe('drawScaleBar', () => { + // The rule is the bar: its length is what the label promises, so it is + // asserted to the pixel. + test('draws a rule exactly widthPx long and the label', () => { + const { ctx, fillRects, fillTexts } = makeCtx() + drawScaleBar(ctx, model, { left: 10, bottom: 300, scale: 1 }) + const rule = fillRects.find( + ({ args, fillStyle }) => args[2] === 100 && fillStyle !== null && !String(fillStyle).startsWith('rgba'), + ) + expect(rule).toBeDefined() + expect(fillTexts.map(({ args }) => args[0])).toEqual(['100 m']) + }) + + // A hi-DPI export carries a widthPx already measured in image pixels, so + // the type grows with scale and the rule does not. + test('scale grows the type but not the rule', () => { + const { ctx, fillRects, fillTexts } = makeCtx() + drawScaleBar(ctx, model, { left: 10, bottom: 300, scale: 2 }) + expect(fontPx(fillTexts[0].font)).toBe(24) + expect(fillRects.some(({ args }) => args[2] === 100)).toBe(true) + expect(fillRects.some(({ args }) => args[2] === 200)).toBe(false) + }) + + test('everything lies within the measured box above the anchor', () => { + for (const scale of [1, 2, 3]) { + const { ctx, fillRects, fillTexts } = makeCtx() + const left = 10 + const bottom = 300 + const { width, height } = measureScaleBar(ctx, model, scale) + drawScaleBar(ctx, model, { left, bottom, scale }) + for (const { + args: [x, y, w, h], + } of fillRects) { + expect(x).toBeGreaterThanOrEqual(left) + expect(x + w).toBeLessThanOrEqual(left + width + 1e-9) + expect(y).toBeGreaterThanOrEqual(bottom - height - 1e-9) + expect(y + h).toBeLessThanOrEqual(bottom + 1e-9) + } + const [{ args: [, , baseline], font }] = fillTexts + expect(baseline - fontPx(font)).toBeGreaterThanOrEqual(bottom - height) + expect(baseline).toBeLessThanOrEqual(bottom) + } + }) + + test('a long label widens the backing past the rule', () => { + const { ctx } = makeCtx() + const short = { ...model, widthPx: 20, label: '5,000 km' } + expect(measureScaleBar(ctx, short, 1).width).toBeGreaterThanOrEqual( + '5,000 km'.length * 6, + ) + }) +}) diff --git a/src/essence/Tools/_shared/scale/__tests__/scaleBarModel.spec.ts b/src/essence/Tools/_shared/scale/__tests__/scaleBarModel.spec.ts new file mode 100644 index 0000000000..4f614fa8e3 --- /dev/null +++ b/src/essence/Tools/_shared/scale/__tests__/scaleBarModel.spec.ts @@ -0,0 +1,119 @@ +import { describe, test, expect } from 'vitest' +import { + buildScaleBarModel, + niceScaleDistance, + resolveScaleUnits, + type ScaleBarModel, +} from '../scaleBarModel' + +const FEET_PER_METER = 3.280839895 +const METERS_PER = { m: 1, km: 1000, ft: 1 / FEET_PER_METER, mi: 5280 / FEET_PER_METER } +const scaleBarDistanceMeters = (model: ScaleBarModel) => + model.distance * METERS_PER[model.unit] + +describe('niceScaleDistance', () => { + test.each([ + [7, 5], + [23, 20], + [150, 100], + [999, 500], + [1, 1], + [5, 5], + [10, 10], + [0.37, 0.2], + [0.0123, 0.01], + ])('%s rounds down to %s', (max, nice) => { + expect(niceScaleDistance(max)).toBe(nice) + }) +}) + +describe('buildScaleBarModel', () => { + test('metric stays in metres below a kilometre', () => { + expect(buildScaleBarModel(1, 'metric', 150)).toEqual({ + distance: 100, + unit: 'm', + label: '100 m', + widthPx: 100, + }) + expect(buildScaleBarModel(0.004, 'metric', 150)).toMatchObject({ + distance: 0.5, + unit: 'm', + label: '0.5 m', + }) + }) + + test('metric switches to kilometres at 1000 m', () => { + expect(buildScaleBarModel(10, 'metric', 150)).toEqual({ + distance: 1, + unit: 'km', + label: '1 km', + widthPx: 100, + }) + expect(buildScaleBarModel(1000 / 150, 'metric', 150)).toMatchObject({ + unit: 'km', + distance: 1, + }) + }) + + test('imperial uses feet below a mile', () => { + // 150 m is 492 ft, which rounds down to 200 ft. + expect(buildScaleBarModel(1, 'imperial', 150)).toMatchObject({ + distance: 200, + unit: 'ft', + label: '200 ft', + }) + }) + + test('imperial switches to miles at 5280 ft', () => { + // 3000 m is 9842.5 ft, 1.86 mi, which rounds down to 1 mi. + const model = buildScaleBarModel(20, 'imperial', 150)! + expect(model).toMatchObject({ distance: 1, unit: 'mi', label: '1 mi' }) + expect(model.widthPx).toBeCloseTo(80.467, 2) + const oneMileMeters = 5280 / 3.280839895 + expect( + buildScaleBarModel(oneMileMeters / 150, 'imperial', 150), + ).toMatchObject({ distance: 1, unit: 'mi' }) + }) + + test('continental distances get thousands separators', () => { + expect(buildScaleBarModel(40000, 'metric', 150)!.label).toBe('5,000 km') + }) + + // The property every caller relies on: the drawn length, times the + // ground distance per pixel, is the distance the label names. + test.each(['metric', 'imperial'] as const)( + '%s bar length matches its label across zoom levels', + (units) => { + for (let exp = -3; exp <= 5; exp += 0.25) { + const mpp = Math.pow(10, exp) + const model = buildScaleBarModel(mpp, units, 150)! + expect(model.widthPx).toBeLessThanOrEqual(150 + 1e-9) + expect(model.widthPx).toBeGreaterThan(150 / 5 - 1e-9) + expect( + Math.abs(model.widthPx * mpp - scaleBarDistanceMeters(model)) / + scaleBarDistanceMeters(model), + ).toBeLessThan(1e-9) + } + }, + ) + + test.each([0, -1, NaN, Infinity, undefined, null])( + 'an unknown scale (%s) draws nothing', + (mpp) => { + expect(buildScaleBarModel(mpp as number, 'metric', 150)).toBeNull() + }, + ) + + test('a zero-width bar draws nothing', () => { + expect(buildScaleBarModel(1, 'metric', 0)).toBeNull() + }) +}) + +describe('resolveScaleUnits', () => { + test('only "imperial" selects imperial', () => { + expect(resolveScaleUnits('imperial')).toBe('imperial') + for (const v of [undefined, 'metric', 'Imperial', 0, '', null]) { + expect(resolveScaleUnits(v)).toBe('metric') + } + }) +}) diff --git a/src/essence/Tools/_shared/scale/renderScaleBar.ts b/src/essence/Tools/_shared/scale/renderScaleBar.ts new file mode 100644 index 0000000000..04745e5114 --- /dev/null +++ b/src/essence/Tools/_shared/scale/renderScaleBar.ts @@ -0,0 +1,68 @@ +import { DEFAULT_BAND_THEME, type BandTheme } from '../legend/bandTheme' +import type { ScaleBarModel } from './scaleBarModel' + +// Draws the export's scale bar. Metrics are logical pixels times `scale`, +// except `model.widthPx`, which is already in image pixels. + +export const SCALE_BAR_MARGIN = 12 +const PAD_X = 8 +const PAD_Y = 6 +const LABEL_TEXT = 12 +const LABEL_GAP = 4 +const LABEL_INDENT = 8 +const TICK = 8 +const BACKING = 'rgba(255, 255, 255, 0.8)' + +type Ctx2D = Pick & { + fillStyle: unknown + font: string + textBaseline: CanvasTextBaseline +} + +const ruleOf = (scale: number): number => Math.max(2, Math.round(2 * scale)) + +/** The backing box's size, in image pixels. */ +export const measureScaleBar = ( + ctx: Ctx2D, + model: ScaleBarModel, + scale: number, + theme: BandTheme = DEFAULT_BAND_THEME, +): { width: number; height: number } => { + ctx.font = `${theme.weights.semibold} ${Math.round(LABEL_TEXT * scale)}px ${theme.family}` + const labelWidth = ctx.measureText(model.label).width + LABEL_INDENT * scale + const rule = ruleOf(scale) + return { + width: Math.max(model.widthPx + rule, labelWidth) + 2 * PAD_X * scale, + height: + (2 * PAD_Y + LABEL_TEXT + LABEL_GAP + TICK) * scale + rule, + } +} + +/** + * Draws the bar with its backing's bottom-left corner at (`left`, `bottom`). + */ +export const drawScaleBar = ( + ctx: Ctx2D, + model: ScaleBarModel, + opts: { left: number; bottom: number; scale: number; theme?: BandTheme }, +): void => { + const { left, bottom, scale, theme = DEFAULT_BAND_THEME } = opts + const { width, height } = measureScaleBar(ctx, model, scale, theme) + const top = bottom - height + const rule = ruleOf(scale) + + ctx.fillStyle = BACKING + ctx.fillRect(left, top, width, height) + + const x = left + PAD_X * scale + const ruleTop = bottom - PAD_Y * scale - rule + const tick = TICK * scale + ctx.fillStyle = theme.ink + ctx.fillRect(x, ruleTop, model.widthPx, rule) + ctx.fillRect(x, ruleTop - tick, rule, tick + rule) + ctx.fillRect(x + model.widthPx - rule, ruleTop - tick, rule, tick + rule) + + ctx.font = `${theme.weights.semibold} ${Math.round(LABEL_TEXT * scale)}px ${theme.family}` + ctx.textBaseline = 'alphabetic' + ctx.fillText(model.label, x + LABEL_INDENT * scale, ruleTop - tick - LABEL_GAP * scale) +} diff --git a/src/essence/Tools/_shared/scale/scaleBarModel.ts b/src/essence/Tools/_shared/scale/scaleBarModel.ts new file mode 100644 index 0000000000..d028e88f43 --- /dev/null +++ b/src/essence/Tools/_shared/scale/scaleBarModel.ts @@ -0,0 +1,79 @@ +// Scale bar arithmetic, shared by the on-screen bar and the exported one so +// both show the same distance for the same view. + +export type ScaleUnits = 'metric' | 'imperial' + +export type ScaleUnitLabel = 'm' | 'km' | 'ft' | 'mi' + +export type ScaleBarModel = { + /** The round distance the bar spans, in `unit`. */ + distance: number + unit: ScaleUnitLabel + label: string + /** The bar's length, in the same pixels as the metres-per-pixel input. */ + widthPx: number +} + +/** The longest the on-screen bar grows, in CSS pixels. */ +export const SCALE_BAR_MAX_WIDTH_PX = 150 + +const FEET_PER_METER = 3.280839895 +const FEET_PER_MILE = 5280 +const METERS_PER_KILOMETER = 1000 + +/** Anything other than 'imperial' reads as metric. */ +export const resolveScaleUnits = (value: unknown): ScaleUnits => + value === 'imperial' ? 'imperial' : 'metric' + +/** The largest 1, 2 or 5 × 10^n not exceeding `max`; toPrecision strips + * float noise so 0.2 reads as 0.2. */ +export function niceScaleDistance(max: number): number { + const pow10 = Math.pow(10, Math.floor(Math.log10(max))) + const mantissa = max / pow10 + const nice = mantissa >= 5 ? 5 : mantissa >= 2 ? 2 : 1 + return parseFloat((nice * pow10).toPrecision(12)) +} + +/** What to draw for a bar at most `maxWidthPx` long; null for an unknown scale. */ +export function buildScaleBarModel( + metersPerPixel: number | null | undefined, + units: ScaleUnits, + maxWidthPx: number, +): ScaleBarModel | null { + if ( + typeof metersPerPixel !== 'number' || + !Number.isFinite(metersPerPixel) || + metersPerPixel <= 0 || + !(maxWidthPx > 0) + ) { + return null + } + const maxMeters = metersPerPixel * maxWidthPx + let maxInUnit: number + let unit: ScaleUnitLabel + if (units === 'imperial') { + const maxFeet = maxMeters * FEET_PER_METER + if (maxFeet >= FEET_PER_MILE) { + maxInUnit = maxFeet / FEET_PER_MILE + unit = 'mi' + } else { + maxInUnit = maxFeet + unit = 'ft' + } + } else if (maxMeters >= METERS_PER_KILOMETER) { + maxInUnit = maxMeters / METERS_PER_KILOMETER + unit = 'km' + } else { + maxInUnit = maxMeters + unit = 'm' + } + const distance = niceScaleDistance(maxInUnit) + return { + distance, + unit, + label: `${distance.toLocaleString('en-US', { + maximumFractionDigits: 10, + })} ${unit}`, + widthPx: (maxWidthPx * distance) / maxInUnit, + } +} diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index 6b9a575164..a64231334e 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -1,5 +1,6 @@ import L_ from '../Basics/Layers_/Layers_' import F_ from '../Basics/Formulae_/Formulae_' +import { captureScreenshotWithScale } from '../Basics/MapEngines/mapScale' import ToolController_ from '../Basics/ToolController_/ToolController_' import QueryURL from '../Ancillary/QueryURL' import TimeControl from '../Basics/TimeControl_/TimeControl' @@ -485,18 +486,13 @@ var mmgisAPI_ = { : null, } }, - getMapScreenshot: function () { + getMapScreenshot: function (options) { // Capture is engine-specific (Leaflet DOM rasterization vs deck.gl // canvas readback); delegate to the active IMapEngine adapter. A // missing engine means no map is loaded yet. const engine = L_.Map_ && L_.Map_.engine if (engine && typeof engine.captureScreenshot === 'function') { - // Convert a sync engine throw into a rejection. - try { - return Promise.resolve(engine.captureScreenshot()) - } catch (err) { - return Promise.reject(err) - } + return captureScreenshotWithScale(engine, options?.scaleAt) } return Promise.reject( new Error('getMapScreenshot: no active map engine to capture') @@ -821,7 +817,9 @@ var mmgisAPI = { * that the deck.gl capture is limited to the GL canvas and does not include * HTML overlays/markers layered on top. Asynchronous; requires no backend * call. Rejects if no map engine is active. - * @returns {Promise<{blob: Blob, mimeType: 'image/png', extension: 'png', width: number, height: number}>} - resolves to a PNG Blob plus image metadata. + * @param {object} [options] + * @param {{clientX: number, clientY: number}} [options.scaleAt] - page position to measure the scale at, instead of near the image's bottom-left corner + * @returns {Promise<{blob: Blob, mimeType: 'image/png', extension: 'png', width: number, height: number, metersPerPixel: number|null, scaleAnchor: {x: number, y: number}|null}>} - resolves to a PNG Blob plus image metadata. `metersPerPixel` is the ground distance one *image* pixel spans at `scaleAt`, or near the image's bottom-left corner without it, null when the map cannot measure it. `scaleAnchor` is `scaleAt` in image pixels, absent without it. * Plugins should prefer `mmgisAPI.request('map:getScreenshot')`. */ getMapScreenshot: mmgisAPI_.getMapScreenshot, @@ -1077,7 +1075,7 @@ mmgisAPI.provide('map:getViewState', () => mmgisAPI_.getViewState()) // the mission has built an engine — Layers_.fina() is where it becomes // readable, so a caller asking at load time gets null rather than an answer. mmgisAPI.provide('map:getEngineType', () => L_.Map_?.engine?.engineType ?? null) -mmgisAPI.provide('map:getScreenshot', () => mmgisAPI_.getMapScreenshot()) +mmgisAPI.provide('map:getScreenshot', (options) => mmgisAPI_.getMapScreenshot(options)) mmgisAPI.provide('app:copyText', (text) => // Bus payloads arrive from arbitrary plugins; refuse non-strings rather // than clobber the user's clipboard with a coerced 'undefined'. diff --git a/tests/unit/ShareExport/composeExportImage.spec.js b/tests/unit/ShareExport/composeExportImage.spec.js index 6352bb5a56..94862aff0f 100644 --- a/tests/unit/ShareExport/composeExportImage.spec.js +++ b/tests/unit/ShareExport/composeExportImage.spec.js @@ -95,4 +95,124 @@ describe('composeExportImage', () => { screenshot.width, ]) }) + + describe('scale bar', () => { + const compose = async (model, scaleBar, scale = 1) => { + const calls = [] + const canvas = makeCanvas(calls) + const result = await composeExportImage(screenshot, model, { + createBitmap: async () => ({ close: vi.fn() }), + createCanvas: () => canvas, + scale, + scaleBar, + }) + return { calls, canvas, result } + } + const labels = (calls) => + calls.filter((c) => c.op === 'fillText').map((c) => c.args[0]) + + // A scale bar alone still decorates the map, and adds no band: the + // image keeps the screenshot's size. + test('draws the bar with no legend, without growing the image', async () => { + const { calls, canvas, result } = await compose(null, { + metersPerPixel: 1, + units: 'metric', + }) + expect([canvas.width, canvas.height]).toEqual([640, 480]) + expect(result.height).toBe(480) + expect(calls[0].op).toBe('drawImage') + expect(labels(calls)).toEqual(['100 m']) + }) + + test('draws exactly one bar alongside the legend band', async () => { + const { calls } = await compose(bandModel, { + metersPerPixel: 20, + units: 'imperial', + }) + expect(labels(calls).filter((t) => t === '1 mi')).toHaveLength(1) + expect(labels(calls)).toContain('Displacement') + }) + + test('an unknown scale draws no bar', async () => { + const createBitmap = vi.fn() + expect( + await composeExportImage(screenshot, null, { + createBitmap, + scaleBar: { metersPerPixel: 0, units: 'metric' }, + }), + ).toBe(screenshot) + expect(createBitmap).not.toHaveBeenCalled() + }) + + // The bar's metres-per-pixel is already in image pixels, so its rule + // spans distance / metersPerPixel pixels at any DPR, and sits on the + // map rather than in the band below it. + test.each([1, 2])( + 'the rule spans the labelled distance at scale %s', + async (scale) => { + const metersPerPixel = 0.5 + const { calls } = await compose( + bandModel, + { metersPerPixel, units: 'metric' }, + scale, + ) + const label = labels(calls).find((t) => / m$/.test(t)) + const meters = Number(label.split(' ')[0]) + const rule = calls.find( + (c) => + c.op === 'fillRect' && + Math.abs(c.args[2] - meters / metersPerPixel) < 1e-9, + ) + expect(rule).toBeDefined() + expect(rule.args[1]).toBeLessThan(screenshot.height) + }, + ) + + test('the bar is never wider than a third of the image', async () => { + const narrow = { ...screenshot, width: 150 } + const calls = [] + await composeExportImage(narrow, null, { + createBitmap: async () => ({ close: vi.fn() }), + createCanvas: () => makeCanvas(calls), + scale: 2, + scaleBar: { metersPerPixel: 1, units: 'metric' }, + }) + // 50 px max -> 50 m. + expect(labels(calls)).toEqual(['50 m']) + }) + + // The bar's backing is the first rectangle drawn after the map. + const backing = (calls) => calls.find((c) => c.op === 'fillRect').args + + test('sits in the bottom-left corner without an anchor', async () => { + const { calls } = await compose(null, { metersPerPixel: 1, units: 'metric' }) + const [left, top, , height] = backing(calls) + expect(left).toBe(12) + expect(top + height).toBe(screenshot.height - 12) + }) + + // The anchor is where the on-screen bar sits, so the export puts its + // bar in the same spot, e.g. the top of the map. + test('puts its bottom-left corner on the anchor', async () => { + const { calls } = await compose(null, { + metersPerPixel: 1, + units: 'metric', + anchor: { x: 200, y: 90 }, + }) + const [left, top, , height] = backing(calls) + expect(left).toBe(200) + expect(top + height).toBe(90) + }) + + test('pulls an anchor near an edge in so the whole bar shows', async () => { + const { calls } = await compose(null, { + metersPerPixel: 1, + units: 'metric', + anchor: { x: screenshot.width - 5, y: 3 }, + }) + const [left, top, width] = backing(calls) + expect(left + width).toBe(screenshot.width - 12) + expect(top).toBe(12) + }) + }) }) diff --git a/tests/unit/ShareExport/shareActions.spec.js b/tests/unit/ShareExport/shareActions.spec.js index 104c00eac2..90bf240282 100644 --- a/tests/unit/ShareExport/shareActions.spec.js +++ b/tests/unit/ShareExport/shareActions.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from 'vitest' +import { test, expect, vi } from 'vitest' import { copyShareLink, downloadSharePng, @@ -386,3 +386,179 @@ test.describe('legend compositing in the downloads', () => { expect(buildArgs).toEqual([{ w: composed.width, h: composed.height }]) }) }) + +test.describe('scale bar in the downloads', () => { + const screenshot = { + blob: new Blob(['png'], { type: 'image/png' }), + mimeType: 'image/png', + extension: 'png', + width: 640, + height: 480, + metersPerPixel: 2.5, + } + const composed = { ...screenshot, blob: new Blob(['composed']) } + const recordCompose = (calls) => async (shot, model, deps) => { + calls.push({ shot, model, deps }) + return composed + } + + test('draws the bar in the units the plugin shows, at the capture scale', async () => { + const calls = [] + await downloadSharePng({ + getScreenshot: async () => screenshot, + download: () => {}, + includeLegend: false, + getScaleBarSettings: async () => ({ units: 'imperial' }), + compose: recordCompose(calls), + }) + expect(calls).toEqual([ + { + shot: screenshot, + model: null, + deps: { scaleBar: { metersPerPixel: 2.5, units: 'imperial' } }, + }, + ]) + }) + + // The capture measures where the on-screen bar sits, and the export + // draws its bar at that same spot. + test('captures at the on-screen bar and draws at the anchor it returns', async () => { + const calls = [] + const asked = [] + const at = { clientX: 40, clientY: 90 } + const anchor = { x: 80, y: 180 } + await downloadSharePng({ + getScreenshot: async (options) => { + asked.push(options) + return { ...screenshot, scaleAnchor: anchor } + }, + download: () => {}, + includeLegend: false, + getScaleBarSettings: async () => ({ units: 'metric', at }), + compose: recordCompose(calls), + }) + expect(asked).toEqual([{ scaleAt: at }]) + expect(calls[0].deps.scaleBar).toEqual({ + metersPerPixel: 2.5, + units: 'metric', + anchor, + }) + }) + + test('a failing plugin still exports the map', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const result = await downloadSharePng({ + getScreenshot: async () => screenshot, + download: () => {}, + includeLegend: false, + getScaleBarSettings: async () => { + throw new Error('plugin blew up') + }, + compose: () => { + throw new Error('should not be called') + }, + }) + expect(result).toBe(screenshot) + } finally { + warn.mockRestore() + } + }) + + // No plugin answering means no bar on the map, so none in the export. + test('no plugin, no bar', async () => { + const result = await downloadSharePng({ + getScreenshot: async () => screenshot, + download: () => {}, + includeLegend: false, + getScaleBarSettings: async () => null, + compose: () => { + throw new Error('should not be called') + }, + }) + expect(result).toBe(screenshot) + }) + + test('the bus wrapper asks whichever plugin draws the scale bar', async () => { + const { api, requests } = makeBusApi({ + 'export:getScaleBar': { units: 'metric' }, + 'map:getScreenshot': screenshot, + }) + window.mmgisAPI = api + try { + const calls = [] + await downloadSharePng({ + download: () => {}, + includeLegend: false, + getViewState: async () => null, + compose: recordCompose(calls), + }) + expect(requests.map((r) => r.name)).toContain( + 'export:getScaleBar', + ) + expect(calls[0].deps.scaleBar.units).toBe('metric') + } finally { + delete window.mmgisAPI + } + }) + + // A tilted or continent-wide view has no scale by design, not by failure. + test('a capture without a scale downloads without a bar, quietly', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { metersPerPixel, ...unscaled } = screenshot + const result = await downloadSharePng({ + getScreenshot: async () => unscaled, + download: () => {}, + includeLegend: false, + getScaleBarSettings: async () => ({ units: 'metric' }), + compose: () => { + throw new Error('should not be called') + }, + }) + expect(result).toBe(unscaled) + expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) + + test('a legend failure still leaves the scale bar', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const calls = [] + await downloadSharePng({ + getScreenshot: async () => screenshot, + download: () => {}, + getLegendModel: async () => { + throw new Error('legend model blew up') + }, + getScaleBarSettings: async () => ({ units: 'metric' }), + compose: recordCompose(calls), + }) + expect(calls).toHaveLength(1) + expect(calls[0].model).toBeNull() + expect(calls[0].deps.scaleBar).not.toBeNull() + } finally { + warn.mockRestore() + } + }) + + test('the PDF embeds the decorated image', async () => { + const calls = [] + const buildArgs = [] + await downloadSharePdf({ + getScreenshot: async () => screenshot, + blobToDataUrl: async () => 'data:image/png;base64,x', + buildPdf: (data, w, h) => { + buildArgs.push({ w, h }) + return { save: () => {} } + }, + includeLegend: false, + getScaleBarSettings: async () => ({ units: 'metric' }), + compose: recordCompose(calls), + }) + expect(calls).toHaveLength(1) + expect(buildArgs).toEqual([{ w: composed.width, h: composed.height }]) + }) +}) diff --git a/tests/unit/deckGLOverlayEvents.spec.js b/tests/unit/deckGLOverlayEvents.spec.js new file mode 100644 index 0000000000..015f7b198a --- /dev/null +++ b/tests/unit/deckGLOverlayEvents.spec.js @@ -0,0 +1,236 @@ +import { test, expect, vi, beforeEach } from 'vitest' + +// The deck.gl engine reports the 'move' stream while the camera moves, with +// or without a basemap. The GL constructors are mocked (jsdom has no WebGL); the +// basemap mock holds a settable camera. + +const basemaps = [] + +vi.mock('@deck.gl/core', async (importOriginal) => { + const actual = await importOriginal() + class MockDeck { + constructor(props) { + this.props = props + this.canvas = makeCanvas() + } + setProps() {} + redraw() {} + finalize() {} + getCanvas() { + return this.canvas + } + } + return { ...actual, Deck: MockDeck } +}) + +vi.mock('maplibre-gl', () => { + class MockMap { + constructor() { + this.handlers = {} + this.center = { lat: 0, lng: 0 } + this.zoom = 0 + this.canvas = makeCanvas() + basemaps.push(this) + } + addControl() {} + on(event, handler) { + ;(this.handlers[event] ||= []).push(handler) + } + once(event, handler) { + this.on(event, handler) + } + off() {} + fire(event) { + for (const h of this.handlers[event] || []) h() + } + triggerRepaint() { + const pending = this.handlers.render || [] + this.handlers.render = [] + for (const h of pending) h() + } + setMaxBounds() {} + remove() {} + getCenter() { + return this.center + } + getZoom() { + return this.zoom + } + getBearing() { + return 0 + } + getPitch() { + return 0 + } + getCanvas() { + return this.canvas + } + } + class MockPopup {} + return { + Map: MockMap, + Popup: MockPopup, + default: { Map: MockMap, Popup: MockPopup }, + } +}) + +function makeCanvas() { + return { + width: 800, + height: 400, + toBlob: (cb) => cb(new Blob(['png'], { type: 'image/png' })), + } +} + +const { DeckGLAdapter } = await import( + '../../src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts' +) + +function makeContainer() { + let el = document.getElementById('map') + if (!el) { + el = document.createElement('div') + el.id = 'map' + document.body.appendChild(el) + } + return el +} + +beforeEach(() => { + basemaps.length = 0 + makeContainer() +}) + +const overlay = () => { + const adapter = new DeckGLAdapter() + adapter.init({ + containerId: 'map', + zoom: 0, + center: { lat: 0, lng: 0 }, + basemap: { provider: 'maplibre', style: 'https://example/style.json' }, + }) + return { adapter, basemap: basemaps[0] } +} + +test.describe('DeckGLAdapter overlay move events', () => { + // A live scale bar follows the camera while it moves, not only when the + // gesture ends. + test("each basemap move reaches the engine's 'move' listeners", () => { + const { adapter, basemap } = overlay() + const moves = [] + adapter.on('move', (e) => moves.push(e)) + basemap.center = { lat: 10, lng: 20 } + basemap.zoom = 7 + basemap.fire('move') + expect(moves).toHaveLength(1) + expect(moves[0]).toMatchObject({ latitude: 10, longitude: 20, zoom: 7 }) + }) +}) + +test.describe('DeckGLAdapter standalone move events', () => { + const standalone = () => { + const adapter = new DeckGLAdapter() + adapter.init({ containerId: 'map', zoom: 0, center: { lat: 0, lng: 0 } }) + const events = [] + adapter.on('move', (e) => events.push(['move', e])) + adapter.on('moveend', (e) => events.push(['moveend', e])) + return { adapter, deck: adapter.getNativeMap(), events } + } + const view = (latitude, longitude, zoom) => ({ + latitude, + longitude, + zoom, + bearing: 0, + pitch: 0, + }) + + test("a camera change outside a gesture reaches 'move', then 'moveend'", () => { + const { deck, events } = standalone() + deck.props.onViewStateChange({ viewState: view(3, 4, 2) }) + expect(events.map(([name]) => name)).toEqual(['move', 'moveend']) + expect(events[0][1]).toMatchObject({ latitude: 3, longitude: 4, zoom: 2 }) + }) + + // Listeners on both would otherwise do their work twice a frame. + test("a gesture reports 'move' each frame and 'moveend' once it settles", () => { + const { deck, events } = standalone() + const dragging = { isDragging: true, isPanning: true } + deck.props.onInteractionStateChange(dragging) + deck.props.onViewStateChange({ viewState: view(1, 1, 2), interactionState: dragging }) + deck.props.onViewStateChange({ viewState: view(2, 2, 2), interactionState: dragging }) + const inertia = { isDragging: false, isPanning: false, inTransition: true } + deck.props.onInteractionStateChange(inertia) + deck.props.onViewStateChange({ viewState: view(3, 3, 2), interactionState: inertia }) + expect(events.map(([name]) => name)).toEqual(['move', 'move', 'move']) + deck.props.onInteractionStateChange({ inTransition: false }) + expect(events.map(([name]) => name)).toEqual(['move', 'move', 'move', 'moveend']) + expect(events[3][1]).toMatchObject({ latitude: 3, longitude: 3 }) + }) + + test("a gesture that never moved the camera raises no 'moveend'", () => { + const { deck, events } = standalone() + deck.props.onInteractionStateChange({ isDragging: true }) + deck.props.onInteractionStateChange({ isDragging: false }) + expect(events).toEqual([]) + }) + + // A Sites jump or a URL view moves the camera without animating it, and + // deck.gl reports no view state change for that. + test("a jump in code reaches 'move' and 'moveend'", () => { + const { adapter, events } = standalone() + adapter.setView({ lat: 5, lng: 6 }, 14) + expect(events.map(([name]) => name)).toEqual(['move', 'moveend']) + expect(events[1][1]).toMatchObject({ latitude: 5, longitude: 6, zoom: 14 }) + }) + + // deck.gl reports an animated move frame by frame as it plays. + test('an animated move in code leaves its reporting to deck.gl', () => { + const { adapter, events } = standalone() + adapter.flyTo({ center: { lat: 5, lng: 6 }, zoom: 14 }) + expect(events).toEqual([]) + }) +}) + +test.describe('DeckGLAdapter unprojection', () => { + const sized = (el, w, h) => { + Object.defineProperty(el, 'offsetWidth', { value: w, configurable: true }) + Object.defineProperty(el, 'offsetHeight', { value: h, configurable: true }) + } + + // Successive unprojections share one viewport, which has to follow the + // camera and the container size rather than answer for a stale view. + test('follows camera and container changes', () => { + const el = makeContainer() + sized(el, 800, 400) + const adapter = new DeckGLAdapter() + adapter.init({ containerId: 'map', zoom: 2, center: { lat: 0, lng: 0 } }) + const at = { x: 400, y: 200 } + expect(adapter.containerPointToLatLng(at).lng).toBeCloseTo(0, 6) + + adapter.getNativeMap().props.onViewStateChange({ + viewState: { latitude: 0, longitude: 30, zoom: 2, bearing: 0, pitch: 0 }, + }) + expect(adapter.containerPointToLatLng(at).lng).toBeCloseTo(30, 6) + + sized(el, 1200, 400) + expect(adapter.containerPointToLatLng(at).lng).toBeLessThan(30) + sized(el, 0, 0) + }) +}) + +test.describe('DeckGLAdapter pointer events', () => { + // A scale bar measuring under the pointer returns to the map centre once + // the pointer leaves, as it does on Leaflet's 'mouseout'. + test("the pointer leaving the map reaches 'mouseout' until destroy", () => { + const el = makeContainer() + const adapter = new DeckGLAdapter() + adapter.init({ containerId: 'map', zoom: 0, center: { lat: 0, lng: 0 } }) + const outs = vi.fn() + adapter.on('mouseout', outs) + el.dispatchEvent(new MouseEvent('mouseleave')) + expect(outs).toHaveBeenCalledTimes(1) + adapter.destroy() + el.dispatchEvent(new MouseEvent('mouseleave')) + expect(outs).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/unit/deckGLSideBySide.spec.js b/tests/unit/deckGLSideBySide.spec.js index 7dc7e60be1..db5567d1c7 100644 --- a/tests/unit/deckGLSideBySide.spec.js +++ b/tests/unit/deckGLSideBySide.spec.js @@ -296,6 +296,32 @@ test.describe('DeckGLAdapter side-by-side comparison', () => { expect(moves).toEqual([]) }) + // Live readouts such as the scale bar follow the camera during a drag. + test("frames mid-drag announce 'move' from the dragged pane", () => { + const adapter = makeAdapter() + sideBySide(adapter) + const [, right] = paneMaps(adapter) + + const frames = [] + adapter.on('move', () => frames.push(adapter.getCenter())) + + right.center = { lng: -90, lat: 35 } + right.fire('move') + + expect(frames).toEqual([{ lat: 35, lng: -90 }]) + }) + + test("re-clipping does not announce 'move'", () => { + const adapter = makeAdapter() + sideBySide(adapter) + + const frames = [] + adapter.on('move', () => frames.push(adapter.getCenter())) + adapter.setComparisonDivider(0.8) + + expect(frames).toEqual([]) + }) + test('a basemap swap follows into both panes', () => { const adapter = makeAdapter() sideBySide(adapter) diff --git a/tests/unit/mapScale.spec.js b/tests/unit/mapScale.spec.js new file mode 100644 index 0000000000..57adab9c46 --- /dev/null +++ b/tests/unit/mapScale.spec.js @@ -0,0 +1,295 @@ +import { test, expect, vi, beforeEach, afterEach } from 'vitest' + +import F_ from '../../src/essence/Basics/Formulae_/Formulae_' +import { + metersPerPixelAt, + captureScreenshotWithScale, + DEFAULT_SCALE_INSET_PX, + MAX_SCALE_PITCH_DEG, + MAX_SCALE_SPREAD, +} from '../../src/essence/Basics/MapEngines/mapScale' + +// The map scale is measured on screen: the engine places two nearby container +// points on the ground and their distance is taken on the mission's planet. +// The stub engine is a plate carrée of `DEG_PER_PX` degrees per pixel with the +// equator on container row 0, inside a container laid out at (100, 50). + +const EARTH_RADIUS = 6378137 +const DEG_PER_PX = 0.001 +const RECT = { left: 100, top: 50, width: 800, height: 600 } + +const degToMeters = (deg) => (deg * Math.PI * EARTH_RADIUS) / 180 + +function makeEngine({ + rect = RECT, + clientWidth = rect.width, + pitch = 0, + webMercator = true, + toLatLng, +} = {}) { + const container = { + clientWidth, + getBoundingClientRect: () => rect, + } + return { + getContainer: () => container, + getViewState: () => ({ pitch }), + isWebMercator: () => webMercator, + containerPointToLatLng: vi.fn( + toLatLng ?? (({ x, y }) => ({ lng: x * DEG_PER_PX, lat: -y * DEG_PER_PX })) + ), + } +} + +let savedRadius +beforeEach(() => { + savedRadius = F_.radiusOfPlanetMajor + F_.radiusOfPlanetMajor = EARTH_RADIUS +}) +afterEach(() => { + F_.radiusOfPlanetMajor = savedRadius +}) + +// Leaflet's Web Mercator: 256 px per world at zoom 0, centred on 0°, 0°. +const mercator = (zoom, extra = {}) => { + const world = 256 * 2 ** zoom + return makeEngine({ + ...extra, + toLatLng: ({ x, y }) => { + const my = 0.5 - (y - RECT.height / 2) / world + return { + lng: ((x - RECT.width / 2) / world) * 360, + lat: (Math.atan(Math.sinh(Math.PI * 2 * (my - 0.5))) * 180) / Math.PI, + } + }, + }) +} + +test.describe('metersPerPixelAt', () => { + test('measures at the map centre when given no point', () => { + const engine = makeEngine() + metersPerPixelAt(engine) + expect(engine.containerPointToLatLng).toHaveBeenNthCalledWith(1, { + x: RECT.width / 2, + y: RECT.height / 2, + }) + }) + + test('measures at a container point', () => { + const engine = makeEngine() + metersPerPixelAt(engine, { x: 30, y: 0 }) + expect(engine.containerPointToLatLng).toHaveBeenNthCalledWith(1, { x: 30, y: 0 }) + }) + + test('gives the ground metres one pixel spans there', () => { + // Row 0 is the equator, where the plate carrée is true to scale. + expect(metersPerPixelAt(makeEngine(), { x: 0, y: 0 })).toBeCloseTo( + degToMeters(DEG_PER_PX), + 6 + ) + }) + + // At 60° a degree of longitude is half as long as at the equator. + test('follows the latitude of the point measured', () => { + const engine = makeEngine({ + toLatLng: ({ x, y }) => ({ lng: x * DEG_PER_PX, lat: y < 300 ? 0 : 60 }), + }) + const equator = metersPerPixelAt(engine, { x: 0, y: 0 }) + const sixty = metersPerPixelAt(engine, { x: 0, y: 400 }) + expect(sixty / equator).toBeCloseTo(0.5, 6) + }) + + test("uses the mission's planet radius", () => { + const engine = makeEngine() + const earth = metersPerPixelAt(engine) + F_.radiusOfPlanetMajor = 3396190 + expect(metersPerPixelAt(engine) / earth).toBeCloseTo(3396190 / EARTH_RADIUS, 9) + }) + + test('ignores a point that is not finite', () => { + const engine = makeEngine() + metersPerPixelAt(engine, { x: 'left' }) + expect(engine.containerPointToLatLng).toHaveBeenNthCalledWith(1, { + x: RECT.width / 2, + y: RECT.height / 2, + }) + }) + + // The scale is local to the point, so it holds wherever a single bar + // could not describe the whole view. + test('measures at any zoom and on a tilted map', () => { + expect(metersPerPixelAt(mercator(0))).toBeCloseTo( + (2 * Math.PI * EARTH_RADIUS) / 256, + 0 + ) + expect(metersPerPixelAt(makeEngine({ pitch: 60 }))).not.toBeNull() + }) + + // Zoomed out, rows above and below the world unproject toward the poles. + test("is null for a point past a Mercator world's edge", () => { + expect(metersPerPixelAt(mercator(0), { x: 400, y: 5 })).toBeNull() + expect(metersPerPixelAt(mercator(0), { x: 400, y: 595 })).toBeNull() + }) + + // A polar projection has ground all the way to the pole. + test('measures near the pole on a projection other than Mercator', () => { + const engine = makeEngine({ + webMercator: false, + toLatLng: ({ x }) => ({ lng: x * DEG_PER_PX, lat: 89 }), + }) + expect(metersPerPixelAt(engine)).not.toBeNull() + }) + + test('is null for a point outside the map', () => { + expect(metersPerPixelAt(makeEngine(), { x: -1, y: 10 })).toBeNull() + expect(metersPerPixelAt(makeEngine(), { x: 10, y: RECT.height + 1 })).toBeNull() + }) + + test('is null for a map with no size', () => { + const engine = makeEngine({ rect: { left: 0, top: 0, width: 0, height: 0 } }) + expect(metersPerPixelAt(engine)).toBeNull() + }) + + test('is null where the engine cannot place the points', () => { + const engine = makeEngine({ toLatLng: () => ({ lat: NaN, lng: NaN }) }) + expect(metersPerPixelAt(engine)).toBeNull() + }) + + test('is null when the engine throws', () => { + const engine = makeEngine({ + toLatLng: () => { + throw new Error('no projection yet') + }, + }) + expect(metersPerPixelAt(engine)).toBeNull() + }) + + test('is null when the planet radius is not configured', () => { + F_.radiusOfPlanetMajor = NaN + expect(metersPerPixelAt(makeEngine())).toBeNull() + }) +}) + +test.describe('captureScreenshotWithScale', () => { + const corner = { x: DEFAULT_SCALE_INSET_PX, y: RECT.height - DEFAULT_SCALE_INSET_PX } + const shot = (width) => ({ + blob: new Blob(['png'], { type: 'image/png' }), + mimeType: 'image/png', + extension: 'png', + width, + height: width / 2, + }) + + // The image is twice the map's CSS width (devicePixelRatio 2), so each + // image pixel spans half the ground a CSS pixel does. + test('stamps the scale in image pixels', async () => { + const engine = makeEngine() + const cssScale = metersPerPixelAt(engine, corner) + engine.captureScreenshot = () => Promise.resolve(shot(1600)) + const result = await captureScreenshotWithScale(engine) + expect(result.width).toBe(1600) + expect(result.metersPerPixel).toBeCloseTo(cssScale / 2, 9) + }) + + test('reads the scale of the view the capture began with', async () => { + const engine = makeEngine() + const before = metersPerPixelAt(engine, corner) + engine.captureScreenshot = () => { + engine.containerPointToLatLng.mockImplementation(({ x, y }) => ({ + lng: x * DEG_PER_PX * 4, + lat: -y * DEG_PER_PX, + })) + return Promise.resolve(shot(800)) + } + expect((await captureScreenshotWithScale(engine)).metersPerPixel).toBeCloseTo(before, 9) + }) + + // An export draws its bar where the on-screen one sits, so it measures + // there and learns that spot in image pixels. + test('measures at a requested page position and gives it in image pixels', async () => { + const engine = makeEngine() + const at = { clientX: 130, clientY: 450 } + const cssScale = metersPerPixelAt(engine, { x: 30, y: 400 }) + engine.captureScreenshot = () => Promise.resolve(shot(1600)) + const result = await captureScreenshotWithScale(engine, at) + expect(result.metersPerPixel).toBeCloseTo(cssScale / 2, 9) + expect(result.scaleAnchor).toEqual({ x: 60, y: 800 }) + }) + + test('gives no anchor without a requested position', async () => { + const engine = makeEngine() + engine.captureScreenshot = () => Promise.resolve(shot(1600)) + expect((await captureScreenshotWithScale(engine)).scaleAnchor).toBeUndefined() + }) + + test('stamps no scale when the map cannot measure one', async () => { + const engine = makeEngine({ rect: { left: 0, top: 0, width: 0, height: 0 } }) + engine.captureScreenshot = () => Promise.resolve(shot(800)) + expect((await captureScreenshotWithScale(engine)).metersPerPixel).toBeNull() + }) + + // An export's bar must describe the whole image, so it has the view rules + // the live bar does without. + const exportScale = async (engine, at) => { + engine.captureScreenshot = () => Promise.resolve(shot(RECT.width)) + return (await captureScreenshotWithScale(engine, at)).metersPerPixel + } + + test("measures near the image's bottom-left corner when given no point", async () => { + const engine = makeEngine() + await exportScale(engine) + expect(engine.containerPointToLatLng).toHaveBeenNthCalledWith(1, corner) + }) + + // A tilted map's scale changes from bottom to top, so no bar fits it. + test('stamps no scale for a map tilted past the limit', async () => { + expect(await exportScale(makeEngine({ pitch: MAX_SCALE_PITCH_DEG }))).not.toBeNull() + expect(await exportScale(makeEngine({ pitch: MAX_SCALE_PITCH_DEG + 1 }))).toBeNull() + }) + + // A wide view spans latitudes whose scales differ several-fold, so a bar + // sized for one of them misdescribes the rest. + test('stamps no scale when it varies too much from top to bottom', async () => { + // The view's rows run from 0° at the top to `bottomLat` at the bottom. + const view = (bottomLat) => + makeEngine({ + toLatLng: ({ x, y }) => ({ + lng: x * DEG_PER_PX, + lat: (-bottomLat * y) / RECT.height, + }), + }) + const spreadTo = (lat) => 1 / Math.cos((lat * Math.PI) / 180) + expect(spreadTo(45)).toBeLessThan(MAX_SCALE_SPREAD) + expect(spreadTo(60)).toBeGreaterThan(MAX_SCALE_SPREAD) + expect(await exportScale(view(45))).not.toBeNull() + expect(await exportScale(view(60))).toBeNull() + }) + + test('judges the spread only from rows the engine can place', async () => { + const engine = makeEngine({ + toLatLng: ({ x, y }) => + y < 100 ? { lat: NaN, lng: NaN } : { lng: x * DEG_PER_PX, lat: 0 }, + }) + expect(await exportScale(engine)).toBeCloseTo(degToMeters(DEG_PER_PX), 6) + }) + + // Zooming out only widens the view, so once the scale is gone it stays + // gone, including at world views where rows run past the world's edges. + test('stays without a scale as a Mercator map zooms out past the limit', async () => { + const shown = [] + for (const zoom of [0, 1, 2, 3, 4, 5, 6, 8, 10]) { + shown.push((await exportScale(mercator(zoom))) != null) + } + const firstShown = shown.indexOf(true) + expect(firstShown).toBeGreaterThan(0) + expect(shown.slice(firstShown).every(Boolean)).toBe(true) + }) + + test("turns an engine's synchronous throw into a rejection", async () => { + const engine = makeEngine() + engine.captureScreenshot = () => { + throw new Error('boom before promise') + } + await expect(captureScreenshotWithScale(engine)).rejects.toThrow(/boom/) + }) +}) diff --git a/tests/unit/mmgisApiScreenshot.spec.js b/tests/unit/mmgisApiScreenshot.spec.js index 3a227bf530..1577b44446 100644 --- a/tests/unit/mmgisApiScreenshot.spec.js +++ b/tests/unit/mmgisApiScreenshot.spec.js @@ -39,7 +39,8 @@ test.describe('mmgisAPI.getMapScreenshot delegation (issue #143)', () => { const result = await mmgisAPI.getMapScreenshot() expect(capture).toHaveBeenCalledTimes(1) - expect(result).toBe(screenshot) + // This engine stub has no container to measure, so no scale. + expect(result).toEqual({ ...screenshot, metersPerPixel: null }) }) test('rejects when no engine is active', async () => {