From d4c2a9ec329cef57f44a494f4ed2793637026264 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Thu, 15 Oct 2026 14:07:42 -0500 Subject: [PATCH 1/6] Map: scale bar on the 2D map and in PNG/PDF exports Adds a ScaleBar tool that shows a metric or imperial scale bar over the map, and draws a matching bar, measured in image pixels, onto Share exports while the tool is in the layout. - IMapEngine gains getMetersPerPixel() and getChromeInsets(); the deck.gl adapter derives the scale from Web Mercator at the view centre, and the Leaflet adapter reports none. - Screenshots carry metersPerPixel in image pixels, so exports scale correctly on high-DPI displays. - Bottom float zones sit above the basemap's logo and attribution. --- .../APIs/JavaScript/Main/Event-Bus-API.md | 2 + .../MapEngines/Adapters/DeckGLAdapter.ts | 87 +++++- .../MapEngines/Adapters/DeckGLHelpers.ts | 13 + .../MapEngines/Adapters/LeafletAdapter.ts | 12 +- src/essence/Basics/MapEngines/IMapEngine.ts | 19 ++ src/essence/Basics/Map_/Map_.js | 3 + .../UserInterface_/UserInterfaceModern_.css | 13 +- .../UserInterface_/UserInterfaceModern_.js | 55 +++- src/essence/Tools/MapControl/config.json | 2 +- .../Tools/ScaleBar/MMGISScaleBarAdapter.tsx | 86 ++++++ src/essence/Tools/ScaleBar/ScaleBarTool.tsx | 71 +++++ .../__tests__/MMGISScaleBarAdapter.spec.tsx | 166 +++++++++++ src/essence/Tools/ScaleBar/config.json | 46 +++ src/essence/Tools/ScaleBar/lib/ScaleBar.tsx | 23 ++ src/essence/Tools/ScaleBar/lib/index.ts | 4 + .../Tools/ScaleBar/lib/styles/index.scss | 29 ++ src/essence/Tools/ShareExport/config.json | 2 +- .../Tools/_shared/adapters/mmgisAPI.ts | 17 ++ .../Tools/_shared/adapters/shareActions.ts | 58 +++- .../_shared/legend/composeExportImage.ts | 79 +++-- .../scale/__tests__/renderScaleBar.spec.js | 73 +++++ .../scale/__tests__/scaleBarModel.spec.ts | 119 ++++++++ .../Tools/_shared/scale/renderScaleBar.ts | 68 +++++ .../Tools/_shared/scale/scaleBarModel.ts | 79 +++++ src/essence/mmgisAPI/mmgisAPI.js | 2 +- .../ShareExport/composeExportImage.spec.js | 86 ++++++ tests/unit/ShareExport/shareActions.spec.js | 132 ++++++++- tests/unit/deckGLAdapter.spec.js | 4 + tests/unit/deckGLMetersPerPixel.spec.js | 278 ++++++++++++++++++ .../userInterfaceModern.mapChrome.spec.js | 52 ++++ 30 files changed, 1630 insertions(+), 50 deletions(-) create mode 100644 src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx create mode 100644 src/essence/Tools/ScaleBar/ScaleBarTool.tsx create mode 100644 src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx create mode 100644 src/essence/Tools/ScaleBar/config.json create mode 100644 src/essence/Tools/ScaleBar/lib/ScaleBar.tsx create mode 100644 src/essence/Tools/ScaleBar/lib/index.ts create mode 100644 src/essence/Tools/ScaleBar/lib/styles/index.scss create mode 100644 src/essence/Tools/_shared/scale/__tests__/renderScaleBar.spec.js create mode 100644 src/essence/Tools/_shared/scale/__tests__/scaleBarModel.spec.ts create mode 100644 src/essence/Tools/_shared/scale/renderScaleBar.ts create mode 100644 src/essence/Tools/_shared/scale/scaleBarModel.ts create mode 100644 tests/unit/deckGLMetersPerPixel.spec.js create mode 100644 tests/unit/userInterfaceModern.mapChrome.spec.js diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index 148ac01d35..38bf4527a2 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -390,6 +390,8 @@ has already delivered — the request can resolve after a later event lands. | `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` | none | `number \| null` | Ground metres per CSS pixel at the view centre. Exact at the centre only when the view is tilted. Null before the map exists, or when the engine cannot measure distance | +| `map:getChromeInsets` | none | `{ bottomLeft, bottomRight }` | How far, in CSS pixels, the basemap's own logo and attribution reach up from the bottom edge of the map on each side. Zero where there is none. `map:chromeInsetsChange` carries the same shape whenever it changes | | `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 | diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 22fb775c81..b69353a8b2 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -34,6 +34,7 @@ import type { IMapEngine, ComparisonConfig, ComparisonLayout, + MapChromeInsets, MapScreenshotResult } from '../IMapEngine' import { MAP_ENGINE } from '../types/engine' @@ -67,6 +68,7 @@ import { resolveLayerId, pickInfoToResult, buildDeckLayer, + webMercatorMetersPerPixel, } from './DeckGLHelpers' import { TerraDraw, @@ -271,7 +273,21 @@ const isTerraDrawLayer = (id: string): boolean => id.startsWith(`${TERRA_DRAW_PR */ const UNRANKED_Z_INDEX = Number.MAX_SAFE_INTEGER -function canvasToPngScreenshot(canvas: HTMLCanvasElement): Promise { +/** The on-screen scale converted to the canvas's drawing-buffer pixels, + * which outnumber CSS pixels by the device pixel ratio. */ +function imageMetersPerPixel( + canvas: HTMLCanvasElement, + cssMetersPerPixel: number | null +): number | null { + const cssWidth = canvas.clientWidth + if (cssMetersPerPixel == null || !(cssWidth > 0) || !(canvas.width > 0)) return null + return (cssMetersPerPixel * cssWidth) / canvas.width +} + +function canvasToPngScreenshot( + canvas: HTMLCanvasElement, + cssMetersPerPixel: number | null = null +): Promise { return new Promise((resolve, reject) => { if (typeof canvas.toBlob !== 'function') { reject(new Error('[DeckGLAdapter] captureScreenshot: canvas.toBlob is unavailable')) @@ -288,6 +304,7 @@ function canvasToPngScreenshot(canvas: HTMLCanvasElement): Promise { */ private _comparisonResizeObserver: ResizeObserver | null = null + /** Watches the basemap's bottom control corners (logo, attribution). */ + private _chromeObserver: ResizeObserver | null = null + private _chromeInsets: MapChromeInsets = { bottomLeft: 0, bottomRight: 0 } + // ── Side-by-side comparison state ───────────────────────────────────────── // The other way of splitting the viewport: rather than one camera drawn // twice and wiped between, each half gets a map of its own — basemap @@ -510,9 +531,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 +547,7 @@ export class DeckGLAdapter implements IMapEngine { pitch: this._basemap!.getPitch(), } if (this._comparisonEnabled) this._syncComparisonCamera() + this._emitEvent('move', this._viewState) } /** @@ -625,6 +647,10 @@ export class DeckGLAdapter implements IMapEngine { this._comparisonEnabled = false this._destroyComparisonSurfaces() + this._chromeObserver?.disconnect() + this._chromeObserver = null + this._chromeInsets = { bottomLeft: 0, bottomRight: 0 } + if (this._isOverlayMode) { if (this._basemap) { this._basemap.off('move', this._onBasemapMove) @@ -780,10 +806,10 @@ export class DeckGLAdapter implements IMapEngine { const basemap = this._basemap const onRender = () => { clearTimeout(timeout) - canvasToPngScreenshot(basemap.getCanvas()).then( - resolve, - reject - ) + canvasToPngScreenshot( + basemap.getCanvas(), + this.getMetersPerPixel() + ).then(resolve, reject) } const timeout = setTimeout(() => { // Unhook, or the listener stays armed to fire a wasted @@ -814,7 +840,7 @@ export class DeckGLAdapter implements IMapEngine { ) return } - canvasToPngScreenshot(canvas).then(resolve, reject) + canvasToPngScreenshot(canvas, this.getMetersPerPixel()).then(resolve, reject) } catch (err) { reject(err as Error) } @@ -1049,6 +1075,16 @@ export class DeckGLAdapter implements IMapEngine { } } + getMetersPerPixel(): number | null { + if (this._isOverlayMode ? !this._basemap : !this._deck) return null + // A screenshot asks for this; a failure here must not fail the capture. + try { + return webMercatorMetersPerPixel(this.getCenter().lat, this.getZoom()) + } catch { + return null + } + } + getViewState(): ViewState { if (this._isOverlayMode) { const c = this._basemap!.getCenter() @@ -2429,6 +2465,41 @@ export class DeckGLAdapter implements IMapEngine { // switch re-anchors the layers on the new style's labels. this._basemap.on('style.load', this._onBasemapLoad) this._basemap.on('error', this._onBasemapError) + this._watchBasemapChrome() + } + + /** + * MapLibre and Mapbox create their corner containers with the map and + * fill them later (logo, attribution), so the corners are measured each + * time they resize. + */ + private _watchBasemapChrome(): void { + this._chromeObserver?.disconnect() + this._chromeObserver = null + if (typeof ResizeObserver === 'undefined') return + const corner = (side: string) => + this._container.querySelector( + `.maplibregl-ctrl-bottom-${side}, .mapboxgl-ctrl-bottom-${side}` + ) + const left = corner('left') + const right = corner('right') + // A corner's box includes its controls' margins; an empty one has no height. + const reach = (el: HTMLElement | null): number => { + if (!el) return 0 + const rect = el.getBoundingClientRect() + if (rect.height === 0) return 0 + return Math.max(0, Math.ceil(this._container.getBoundingClientRect().bottom - rect.top)) + } + this._chromeObserver = new ResizeObserver(() => { + this._chromeInsets = { bottomLeft: reach(left), bottomRight: reach(right) } + this._emitEvent('chromeinsetschange', { ...this._chromeInsets }) + }) + if (left) this._chromeObserver.observe(left) + if (right) this._chromeObserver.observe(right) + } + + getChromeInsets(): MapChromeInsets { + return { ...this._chromeInsets } } /** diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts index 344a8fe893..5660cb2eca 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts @@ -84,6 +84,19 @@ export function resolvePadding( return padding } +const EARTH_CIRCUMFERENCE_M = 40075016.686 +const MAX_MERCATOR_LATITUDE = 85.051129 + +/** + * Ground metres per pixel at `latDeg` and `zoom` on deck.gl/MapLibre's Web + * Mercator, which lays the world out on 512-pixel tiles. Always Earth, + * whatever the mission's planet radius. + */ +export function webMercatorMetersPerPixel(latDeg: number, zoom: number): number { + const lat = Math.max(-MAX_MERCATOR_LATITUDE, Math.min(MAX_MERCATOR_LATITUDE, latDeg)) + return (EARTH_CIRCUMFERENCE_M * Math.cos((lat * Math.PI) / 180)) / (512 * Math.pow(2, zoom)) +} + /** * Builds a WebMercatorViewport from the given state and container size. * Pass `zoom` to override `state.zoom`. diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index 429cabd265..1ae6389aca 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -7,7 +7,7 @@ * */ -import { IMapEngine, MapScreenshotResult } from '../IMapEngine' +import { IMapEngine, MapChromeInsets, MapScreenshotResult } from '../IMapEngine' import { LatLng, LatLngLike, @@ -511,6 +511,16 @@ export default class LeafletAdapter implements IMapEngine, IMapEn } } + /** The scale bar supports the deck.gl engine only. */ + getMetersPerPixel(): number | null { + return null + } + + /** The attribution control is removed at init; nothing sits at the bottom. */ + getChromeInsets(): MapChromeInsets { + return { bottomLeft: 0, bottomRight: 0 } + } + /** * Get the current view state */ diff --git a/src/essence/Basics/MapEngines/IMapEngine.ts b/src/essence/Basics/MapEngines/IMapEngine.ts index 9b093e9f49..f78a3cb08e 100644 --- a/src/essence/Basics/MapEngines/IMapEngine.ts +++ b/src/essence/Basics/MapEngines/IMapEngine.ts @@ -23,6 +23,16 @@ export interface MapScreenshotResult { extension: 'png' width: number height: number + /** Ground metres per *image* pixel at the centre, at capture time. + * Null or absent when the engine cannot say. */ + metersPerPixel?: number | null +} + +/** How far, in CSS pixels, the basemap's logo and attribution reach up from + * the map's bottom edge on each side. */ +export interface MapChromeInsets { + bottomLeft: number + bottomRight: number } /** @@ -129,6 +139,15 @@ export interface IMapEngine< */ getViewState(): ViewState + /** + * Ground metres per CSS pixel at the view centre (exact there only when + * tilted). Null before the map exists or when the engine cannot say. + */ + getMetersPerPixel(): number | null + + /** Bottom corner chrome; `'chromeinsetschange'` is emitted when it changes. */ + getChromeInsets(): MapChromeInsets + /** * Set the full camera state. Leaflet adapter ignores bearing and pitch. */ diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 30e9ecade7..e7cdadb001 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -260,6 +260,8 @@ 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', () => engine.getMetersPerPixel()), + window.mmgisAPI.provide('map:getChromeInsets', () => engine.getChromeInsets()), window.mmgisAPI.provide('map:setView', ({ center, zoom } = {}) => { if (!center) return false engine.setView(center, zoom) @@ -407,6 +409,7 @@ let Map_ = { reEmit('drawcancel', 'map:drawcancel') reEmit('move', 'map:move') reEmit('moveend', 'map:moveend') + reEmit('chromeinsetschange', 'map:chromeInsetsChange') // Pointer streams consumed by plugins (e.g. measure tools, overlays). reEmit('click', 'map:click') reEmit('mousemove', 'map:mousemove') diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css index 3806531cf6..83afc6c633 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css @@ -555,13 +555,18 @@ body.ui-is-dragging .ui-modern-grid .ui-panel { padding: var(--ui-float-outer-gap); } -/* Zone positions */ +/* Zone positions. The bottom zones sit above the basemap's logo and + attribution (--ui-map-chrome-*, set by the layout). */ .ui-float-zone-top-left { top: 0; left: 0; } .ui-float-zone-top-center { top: 0; left: 50%; transform: translateX(-50%); } .ui-float-zone-top-right { top: 0; right: 0; } -.ui-float-zone-bottom-left { bottom: 0; left: 0; } -.ui-float-zone-bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); } -.ui-float-zone-bottom-right { bottom: 0; right: 0; } +.ui-float-zone-bottom-left { bottom: var(--ui-map-chrome-bottom-left, 0px); left: 0; } +.ui-float-zone-bottom-center { + bottom: max(var(--ui-map-chrome-bottom-left, 0px), var(--ui-map-chrome-bottom-right, 0px)); + left: 50%; + transform: translateX(-50%); +} +.ui-float-zone-bottom-right { bottom: var(--ui-map-chrome-bottom-right, 0px); right: 0; } /* Float panels re-enable pointer events */ .ui-float-panel { diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js index cd8595a56c..3e55a88744 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js @@ -19,6 +19,35 @@ let layoutStyle = '' let toolLoadQueue = [] let cleanupLayoutListener = null let _resizeObserver = null +let cleanupChromeListener = null +let mapChromeInsets = { bottomLeft: 0, bottomRight: 0 } + +const CHROME_INSET_VARS = { + bottomLeft: '--ui-map-chrome-bottom-left', + bottomRight: '--ui-map-chrome-bottom-right', +} + +// Lifts the bottom float zones above the basemap's logo and attribution. The +// chrome is measured from the map's bottom edge; when a panel overlays the +// bottom of the map, the part it covers needs no clearing. +const _applyMapChromeInsets = () => { + const center = document.getElementById('ui-modern-center') + const map = document.getElementById('map') + const covered = + center && map + ? Math.max(0, map.getBoundingClientRect().bottom - center.getBoundingClientRect().bottom) + : 0 + Object.entries(CHROME_INSET_VARS).forEach(([side, name]) => { + const lift = Math.max(0, mapChromeInsets[side] - covered) + document.documentElement.style.setProperty(name, `${lift}px`) + }) +} + +const _setMapChromeInsets = (insets) => { + if (!insets) return + mapChromeInsets = insets + _applyMapChromeInsets() +} // --- DOM Builder Helpers --- const _createPanelIconTray = (panel) => { @@ -313,11 +342,27 @@ const UserInterfaceModern_ = { cleanupLayoutListener = mmgisAPI.on('panels:changed', this.syncDOMState.bind(this)) } + if (!cleanupChromeListener) { + cleanupChromeListener = mmgisAPI.on( + 'map:chromeInsetsChange', + _setMapChromeInsets + ) + } + // The map may have reported its chrome before this listener existed. + if (mmgisAPI.hasHandler('map:getChromeInsets')) { + mmgisAPI + .request('map:getChromeInsets') + .then(_setMapChromeInsets) + .catch(() => {}) + } + _applyMapChromeInsets() + // Set up ResizeObserver to dispatch resize events when the center map area changes size if (_resizeObserver) { _resizeObserver.disconnect() } _resizeObserver = new ResizeObserver(() => { + _applyMapChromeInsets() window.dispatchEvent(new Event('resize')) }) const centerElement = document.getElementById('ui-modern-center') @@ -340,6 +385,14 @@ const UserInterfaceModern_ = { _resizeObserver.disconnect() _resizeObserver = null } + if (cleanupChromeListener) { + cleanupChromeListener() + cleanupChromeListener = null + } + mapChromeInsets = { bottomLeft: 0, bottomRight: 0 } + Object.values(CHROME_INSET_VARS).forEach((name) => + document.documentElement.style.removeProperty(name) + ) $('#modern-content').empty() panels = [] mmgisAPI_._pluginController = null @@ -870,4 +923,4 @@ const UserInterfaceModern_ = { } export default UserInterfaceModern_ -export { _createPanelHeader, _createPanelIconTray, _renderRegion } +export { _createPanelHeader, _createPanelIconTray, _renderRegion, _setMapChromeInsets } 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..1ebdb16aaa --- /dev/null +++ b/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx @@ -0,0 +1,86 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { ScaleBar } from './lib' +import { + buildScaleBarModel, + resolveScaleUnits, + SCALE_BAR_MAX_WIDTH_PX, + type ScaleUnits, +} from '../_shared/scale/scaleBarModel' +import { useMMGISToolVars } from '../_shared/adapters/useMMGISToolVars' +import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady' +import { useMMGISEvent } from '../_shared/adapters/useMMGISEvent' +import { + mmgisGetMetersPerPixel, + mmgisProvide, + SCALE_BAR_SETTINGS_REQUEST, + type ScaleBarSettings, +} from '../_shared/adapters/mmgisAPI' + +type ToolVars = { + units?: unknown +} + +const SCALE_RETRY_MS = 250 +const SCALE_RETRY_LIMIT = 80 + +// False under a display:none ancestor (a hidden plugin or collapsed panel). +const isOnScreen = (el: HTMLElement | null): boolean => + !!el && el.getClientRects().length > 0 + +export function MMGISScaleBarAdapter() { + const vars = useMMGISToolVars('scalebar') + const units = resolveScaleUnits(vars.units) + const [metersPerPixel, setMetersPerPixel] = useState(null) + const hostRef = useRef(null) + + // An engine that loads its basemap asynchronously answers null at first, + // and nothing moves to prompt another read, so a null answer is retried. + const retryTimer = useRef(null) + const retries = useRef(0) + const unmounted = useRef(false) + const refresh = useCallback(async () => { + let next: number | null = null + try { + next = await mmgisGetMetersPerPixel() + } catch (err) { + console.warn('ScaleBar: reading the map scale failed', err) + } + if (unmounted.current) return + setMetersPerPixel(next) + if (next == null && retryTimer.current == null && retries.current++ < SCALE_RETRY_LIMIT) { + retryTimer.current = window.setTimeout(() => { + retryTimer.current = null + void refresh() + }, SCALE_RETRY_MS) + } + }, []) + useEffect( + () => () => { + unmounted.current = true + if (retryTimer.current != null) window.clearTimeout(retryTimer.current) + }, + [], + ) + useMMGISHandlerReady('map:getMetersPerPixel', refresh) + + useMMGISEvent('map:move', refresh) + useMMGISEvent('map:moveend', refresh) + + // Exports draw a bar exactly when the map shows one, in the same units. + const unitsRef = useRef(units) + unitsRef.current = units + useEffect( + () => + mmgisProvide(SCALE_BAR_SETTINGS_REQUEST, (): ScaleBarSettings | null => + isOnScreen(hostRef.current) ? { units: unitsRef.current } : null, + ), + [], + ) + + const model = buildScaleBarModel(metersPerPixel, units, SCALE_BAR_MAX_WIDTH_PX) + return ( +
+ +
+ ) +} diff --git a/src/essence/Tools/ScaleBar/ScaleBarTool.tsx b/src/essence/Tools/ScaleBar/ScaleBarTool.tsx new file mode 100644 index 0000000000..cc5b1db7fd --- /dev/null +++ b/src/essence/Tools/ScaleBar/ScaleBarTool.tsx @@ -0,0 +1,71 @@ +/** + * 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 coupling lives in MMGISScaleBarAdapter. The lib/ tree is + * portable. + */ +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..84eafaf299 --- /dev/null +++ b/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx @@ -0,0 +1,166 @@ +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 metersPerPixel: number | null +let listeners: Map void>> +let provided: Map unknown> + +const fire = async (event: string) => { + await act(async () => { + for (const handler of listeners.get(event) ?? []) handler() + }) +} + +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 host one. +const showHost = (container: HTMLElement) => { + const host = container.querySelector('.blocks-scale-bar-host') as HTMLElement + host.getClientRects = () => [{}] as unknown as DOMRectList +} + +beforeEach(() => { + vars = {} + metersPerPixel = 1 + listeners = new Map() + provided = new Map() + ;(window as { mmgisAPI?: unknown }).mmgisAPI = { + request: vi.fn(async (name: string) => { + if (name === 'tool:getVars') return vars + if (name === 'map:getMetersPerPixel') return 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() + }, + ) + + // A map whose basemap loads asynchronously has no scale when the plugin + // first asks; the bar appears once it does, without a zoom or pan. + test('keeps asking until the map has a scale', async () => { + vi.useFakeTimers() + try { + metersPerPixel = null + const { container, unmount } = await mount() + expect(container.querySelector('.blocks-scale-bar')).toBeNull() + metersPerPixel = 1 + await act(async () => { + await vi.advanceTimersByTimeAsync(300) + }) + expect(label(container)).toBe('100 m') + await unmount() + } finally { + vi.useRealTimers() + } + }) + + test('stops asking once unmounted', async () => { + vi.useFakeTimers() + try { + metersPerPixel = null + const { unmount } = await mount() + await unmount() + const request = (window as unknown as { + mmgisAPI: { request: ReturnType } + }).mmgisAPI.request + const calls = request.mock.calls.length + await vi.advanceTimersByTimeAsync(2000) + expect(request.mock.calls.length).toBe(calls) + } finally { + vi.useRealTimers() + } + }) + + 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('plugin:scalebar:getSettings') + 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() + showHost(container) + expect(settings()).toEqual({ units: 'imperial' }) + 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() + }) + + test('stop answering once the plugin is gone', async () => { + const { unmount } = await mount() + expect(provided.has('plugin:scalebar:getSettings')).toBe(true) + await unmount() + expect(provided.has('plugin:scalebar:getSettings')).toBe(false) + }) + }) +}) diff --git a/src/essence/Tools/ScaleBar/config.json b/src/essence/Tools/ScaleBar/config.json new file mode 100644 index 0000000000..8be1120db9 --- /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, ...) and follows the map as it zooms and pans, switching from metres to kilometres or from feet to miles as the view widens. While the bar is shown, PNG and PDF exports from the Share button draw a matching bar, measured for the exported image, over the map's bottom-left corner. Leave the tool out of the layout to show no bar on the map or in exports. When the map is tilted, the bar is exact at the centre of 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..7517ea1092 --- /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: 4px; + 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..b307c7121f 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 over the map's bottom-left corner. 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..2f4fe3079f 100644 --- a/src/essence/Tools/_shared/adapters/mmgisAPI.ts +++ b/src/essence/Tools/_shared/adapters/mmgisAPI.ts @@ -26,6 +26,8 @@ export type MapScreenshotResult = { extension: 'png' width: number height: number + /** Ground metres per image pixel at the centre, at capture time. */ + metersPerPixel?: number | null } /** The subset of a mission layer config that plugins read off the bus. */ @@ -156,6 +158,21 @@ export const mmgisGetMapScreenshot = (): Promise< return mmgisRequestIfProvided('map:getScreenshot') } +/** Ground metres per CSS pixel at the view centre; null before the map exists. */ +export const mmgisGetMetersPerPixel = (): Promise => { + return mmgisRequestIfProvided('map:getMetersPerPixel') +} + +export type ScaleBarSettings = { units: 'metric' | 'imperial' } + +export const SCALE_BAR_SETTINGS_REQUEST = 'plugin:scalebar:getSettings' + +/** The ScaleBar plugin's units while its bar is on screen, otherwise null, + * so exports carry a bar exactly when the map shows one. */ +export const mmgisGetScaleBarSettings = (): Promise => { + return mmgisRequestIfProvided(SCALE_BAR_SETTINGS_REQUEST) +} + /** View metadata (mission, time, center, zoom); fields null until loaded. */ export const mmgisGetViewState = (): Promise => { return mmgisRequestIfProvided('map:getViewState') diff --git a/src/essence/Tools/_shared/adapters/shareActions.ts b/src/essence/Tools/_shared/adapters/shareActions.ts index b14d0a356f..5c76808207 100644 --- a/src/essence/Tools/_shared/adapters/shareActions.ts +++ b/src/essence/Tools/_shared/adapters/shareActions.ts @@ -3,12 +3,18 @@ import { mmgisGetMapScreenshot, mmgisGetViewState, mmgisCopyText, + mmgisGetScaleBarSettings, 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, @@ -68,28 +74,60 @@ export async function copyShareLink( export type LegendDeps = { 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( +// Uses the capture's own scale, which is in image pixels and of that exact view. +async function resolveExportScaleBar( + screenshot: MapScreenshotResult, + getScaleBarSettings: () => Promise, +): Promise { + try { + const settings = await getScaleBarSettings() + if (!settings) return null + const { metersPerPixel } = screenshot + if (typeof metersPerPixel !== 'number' || !(metersPerPixel > 0)) { + console.warn('Share export: scale bar omitted, the map gave no scale') + return null + } + return { metersPerPixel, units: settings.units } + } catch (err) { + console.warn('Share export: scale bar omitted', err) + return null + } +} + +// 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, ): Promise { const { includeLegend = true, getLegendModel = getExportLegendModel, + getScaleBarSettings = mmgisGetScaleBarSettings, compose = composeExportImage, } = deps - if (!includeLegend) return screenshot + const scaleBar = await resolveExportScaleBar(screenshot, getScaleBarSettings) + 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 } } @@ -114,7 +152,7 @@ export async function downloadSharePng( } = deps const screenshot = await getScreenshot() if (!screenshot) throw new Error('No screenshot available') - const composed = await applyLegend(screenshot, deps) + const composed = await applyDecorations(screenshot, deps) const filename = deps.filename ?? buildExportFilename(composed.extension, await getViewState()) @@ -150,7 +188,7 @@ export async function downloadSharePdf( } = deps const screenshot = await getScreenshot() if (!screenshot) throw new Error('No screenshot available') - const composed = await applyLegend(screenshot, deps) + const composed = await applyDecorations(screenshot, 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..c627227e98 100644 --- a/src/essence/Tools/_shared/legend/composeExportImage.ts +++ b/src/essence/Tools/_shared/legend/composeExportImage.ts @@ -2,25 +2,42 @@ 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, SCALE_BAR_MARGIN } from '../scale/renderScaleBar' + +export type ExportScaleBar = { + metersPerPixel: number + units: ScaleUnits +} export type ComposeDeps = { createBitmap?: (blob: Blob) => Promise createCanvas?: () => HTMLCanvasElement scale?: number theme?: BandTheme + scaleBar?: ExportScaleBar | null } +// 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's 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 +46,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 +66,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 +74,35 @@ 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) { + drawScaleBar(ctx, barModel, { + left: SCALE_BAR_MARGIN * scale, + bottom: screenshot.height - SCALE_BAR_MARGIN * scale, + 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..58557dc24e --- /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 = 4 +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..7395ec8eed 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -821,7 +821,7 @@ 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. + * @returns {Promise<{blob: Blob, mimeType: 'image/png', extension: 'png', width: number, height: number, metersPerPixel: number|null}>} - resolves to a PNG Blob plus image metadata. `metersPerPixel` is the ground distance one *image* pixel spans at the image centre, null when the engine cannot say. * Plugins should prefer `mmgisAPI.request('map:getScreenshot')`. */ getMapScreenshot: mmgisAPI_.getMapScreenshot, diff --git a/tests/unit/ShareExport/composeExportImage.spec.js b/tests/unit/ShareExport/composeExportImage.spec.js index 6352bb5a56..0fd5e7851f 100644 --- a/tests/unit/ShareExport/composeExportImage.spec.js +++ b/tests/unit/ShareExport/composeExportImage.spec.js @@ -95,4 +95,90 @@ 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']) + }) + }) }) diff --git a/tests/unit/ShareExport/shareActions.spec.js b/tests/unit/ShareExport/shareActions.spec.js index 104c00eac2..40564d308d 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,133 @@ 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' } }, + }, + ]) + }) + + // 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 the ScaleBar plugin and reads null without it', async () => { + const { api, requests } = makeBusApi({ + 'plugin:scalebar:getSettings': { 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( + 'plugin:scalebar:getSettings', + ) + expect(calls[0].deps.scaleBar.units).toBe('metric') + } finally { + delete window.mmgisAPI + } + }) + + test('a capture without a scale downloads without a bar', 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).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/deckGLAdapter.spec.js b/tests/unit/deckGLAdapter.spec.js index 0c5fa4f5a9..410aeb038b 100644 --- a/tests/unit/deckGLAdapter.spec.js +++ b/tests/unit/deckGLAdapter.spec.js @@ -946,6 +946,7 @@ test.describe('DeckGLAdapter', () => { extension: 'png', width: 256, height: 128, + metersPerPixel: null, }) }) @@ -1025,6 +1026,9 @@ test.describe('DeckGLAdapter', () => { extension: 'png', width: 300, height: 200, + // The fake canvas never laid out (no clientWidth), so the + // image scale is unknown. + metersPerPixel: null, }) }) diff --git a/tests/unit/deckGLMetersPerPixel.spec.js b/tests/unit/deckGLMetersPerPixel.spec.js new file mode 100644 index 0000000000..2d3ca4c701 --- /dev/null +++ b/tests/unit/deckGLMetersPerPixel.spec.js @@ -0,0 +1,278 @@ +import { test, expect, vi, beforeEach } from 'vitest' + +// The deck.gl engine's scale: metres per CSS pixel at the view centre, the +// same figure converted to image pixels on every screenshot, and the 'move' +// stream a live scale bar follows. The GL constructors are mocked (jsdom has +// no WebGL); the basemap mock holds a settable camera and canvas. + +const basemaps = [] + +vi.mock('@deck.gl/core', async (importOriginal) => { + const actual = await importOriginal() + class MockDeck { + constructor(props) { + this.props = props + this.canvas = makeCanvas(400, 800) + } + setProps() {} + redraw() {} + finalize() {} + getCanvas() { + return this.canvas + } + } + return { ...actual, Deck: MockDeck } +}) + +vi.mock('maplibre-gl', () => { + class MockMap { + constructor(opts) { + this.handlers = {} + // MapLibre builds its control corners with the map. + for (const side of ['left', 'right']) { + const corner = document.createElement('div') + corner.className = `maplibregl-ctrl-bottom-${side}` + opts.container.appendChild(corner) + } + this.center = { lat: 0, lng: 0 } + this.zoom = 0 + this.canvas = makeCanvas(400, 800) + 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 }, + } +}) + +// A canvas whose drawing buffer is `bufferWidth` wide while it lays out +// `cssWidth` wide, as on a display with devicePixelRatio bufferWidth/cssWidth. +function makeCanvas(cssWidth, bufferWidth) { + return { + clientWidth: cssWidth, + width: bufferWidth, + height: bufferWidth / 2, + toBlob: (cb) => cb(new Blob(['png'], { type: 'image/png' })), + } +} + +const { DeckGLAdapter } = await import( + '../../src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts' +) +const { webMercatorMetersPerPixel } = await import( + '../../src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.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 standalone = (center, zoom) => { + const adapter = new DeckGLAdapter() + adapter.init({ containerId: 'map', zoom, center }) + return adapter +} + +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('webMercatorMetersPerPixel', () => { + test('zoom 0 at the equator spans the world in 512 pixels', () => { + expect(webMercatorMetersPerPixel(0, 0)).toBeCloseTo(78271.517, 3) + }) + + test('shrinks with the cosine of latitude, clamped at the Mercator limit', () => { + expect(webMercatorMetersPerPixel(60, 3)).toBeCloseTo( + webMercatorMetersPerPixel(0, 3) / 2, + 6, + ) + expect(webMercatorMetersPerPixel(90, 3)).toBe( + webMercatorMetersPerPixel(85.051129, 3), + ) + expect(webMercatorMetersPerPixel(90, 3)).toBeGreaterThan(0) + }) +}) + +test.describe('DeckGLAdapter.getMetersPerPixel', () => { + test('standalone mode reads the camera', () => { + expect(standalone({ lat: 0, lng: 0 }, 0).getMetersPerPixel()).toBeCloseTo( + 78271.517, + 3, + ) + expect( + standalone({ lat: 45, lng: 10 }, 10).getMetersPerPixel(), + ).toBeCloseTo(54.05, 2) + }) + + test('overlay mode reads the basemap camera', () => { + const { adapter, basemap } = overlay() + basemap.center = { lat: 45, lng: 0 } + basemap.zoom = 10 + expect(adapter.getMetersPerPixel()).toBeCloseTo(54.05, 2) + }) + + test('reads null before the map exists', () => { + expect(new DeckGLAdapter().getMetersPerPixel()).toBeNull() + }) +}) + +test.describe('DeckGLAdapter.captureScreenshot scale', () => { + // The image is twice the CSS width (devicePixelRatio 2), so each image + // pixel spans half the ground a CSS pixel does. + test('standalone mode stamps the scale in image pixels', async () => { + const adapter = standalone({ lat: 0, lng: 0 }, 4) + const shot = await adapter.captureScreenshot() + expect(shot.width).toBe(800) + expect(shot.metersPerPixel).toBeCloseTo(adapter.getMetersPerPixel() / 2, 9) + }) + + test('overlay mode stamps the scale in image pixels', async () => { + const { adapter, basemap } = overlay() + basemap.center = { lat: 60, lng: 0 } + basemap.zoom = 12 + const shot = await adapter.captureScreenshot() + expect(shot.metersPerPixel).toBeCloseTo( + webMercatorMetersPerPixel(60, 12) / 2, + 9, + ) + }) + + test('a canvas that has not laid out stamps no scale', async () => { + const adapter = standalone({ lat: 0, lng: 0 }, 4) + adapter._deck.canvas.clientWidth = 0 + expect((await adapter.captureScreenshot()).metersPerPixel).toBeNull() + }) +}) + +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 basemap chrome', () => { + // A ResizeObserver that runs its callback on demand, standing in for the + // browser's (jsdom has none). + let observers + beforeEach(() => { + observers = [] + globalThis.ResizeObserver = class { + constructor(cb) { + this.cb = cb + this.targets = [] + observers.push(this) + } + observe(el) { + this.targets.push(el) + } + disconnect() { + this.targets = [] + } + } + }) + + const rect = (el, top, bottom) => { + el.getBoundingClientRect = () => ({ top, bottom, height: bottom - top }) + } + + test('reports how far the logo and attribution reach up from the bottom', () => { + const container = makeContainer() + container.innerHTML = '' + const { adapter } = overlay() + rect(container, 0, 600) + const left = container.querySelector('.maplibregl-ctrl-bottom-left') + const right = container.querySelector('.maplibregl-ctrl-bottom-right') + expect(observers[0].targets).toEqual([left, right]) + + const changes = [] + adapter.on('chromeinsetschange', (e) => changes.push(e)) + rect(left, 569, 600) + rect(right, 578.5, 600) + observers[0].cb() + expect(adapter.getChromeInsets()).toEqual({ bottomLeft: 31, bottomRight: 22 }) + expect(changes).toEqual([{ bottomLeft: 31, bottomRight: 22 }]) + }) + + test('an empty corner reaches nowhere', () => { + const container = makeContainer() + container.innerHTML = '' + const { adapter } = overlay() + rect(container, 0, 600) + rect(container.querySelector('.maplibregl-ctrl-bottom-left'), 600, 600) + rect(container.querySelector('.maplibregl-ctrl-bottom-right'), 600, 600) + observers[0].cb() + expect(adapter.getChromeInsets()).toEqual({ bottomLeft: 0, bottomRight: 0 }) + }) + + test('standalone mode has no basemap chrome', () => { + expect(standalone({ lat: 0, lng: 0 }, 2).getChromeInsets()).toEqual({ + bottomLeft: 0, + bottomRight: 0, + }) + expect(observers).toHaveLength(0) + }) +}) diff --git a/tests/unit/userInterfaceModern.mapChrome.spec.js b/tests/unit/userInterfaceModern.mapChrome.spec.js new file mode 100644 index 0000000000..1b42bf2aed --- /dev/null +++ b/tests/unit/userInterfaceModern.mapChrome.spec.js @@ -0,0 +1,52 @@ +import { test, expect, vi, afterEach } from 'vitest' +vi.mock('../../src/essence/Basics/Viewer_/Viewer_', () => ({ default: {} })) + +// The bottom float zones are lifted by CSS variables the layout sets from the +// map's reported chrome. jsdom does no layout, so the map and centre region +// are given the rectangles a real page would have. + +const { _setMapChromeInsets } = await import( + '../../src/essence/Basics/UserInterface_/UserInterfaceModern_.js' +) + +const box = (id, bottom) => { + const el = document.createElement('div') + el.id = id + el.getBoundingClientRect = () => ({ top: 0, bottom, left: 0, right: 800, height: bottom, width: 800 }) + document.body.appendChild(el) + return el +} + +const lift = (side) => + document.documentElement.style.getPropertyValue(`--ui-map-chrome-bottom-${side}`) + +afterEach(() => { + document.body.innerHTML = '' + document.documentElement.removeAttribute('style') +}) + +test('zones rise by the full chrome when the map and centre share a bottom edge', () => { + box('map', 600) + box('ui-modern-center', 600) + _setMapChromeInsets({ bottomLeft: 31, bottomRight: 18 }) + expect(lift('left')).toBe('31px') + expect(lift('right')).toBe('18px') +}) + +// A bottom panel laid over the map already covers the lower part of the +// chrome; only what reaches above the centre region needs clearing. +test('chrome hidden under a panel overlaying the map is not cleared again', () => { + box('map', 600) + box('ui-modern-center', 580) + _setMapChromeInsets({ bottomLeft: 31, bottomRight: 12 }) + expect(lift('left')).toBe('11px') + expect(lift('right')).toBe('0px') +}) + +test('a map with no bottom chrome leaves the zones on the edge', () => { + box('map', 600) + box('ui-modern-center', 600) + _setMapChromeInsets({ bottomLeft: 0, bottomRight: 0 }) + expect(lift('left')).toBe('0px') + expect(lift('right')).toBe('0px') +}) From 3f2e4e154098410a88bda184aaafcd64f7fa1292 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Thu, 15 Oct 2026 14:27:57 -0500 Subject: [PATCH 2/6] Scale bar: measure on screen for any engine, hide when tilted The map scale is the ground distance between two nearby screen points, measured on the mission's planet, so it works for deck.gl and Leaflet alike and holds at the bar's own position rather than the view centre. - mapScale.ts measures metres per CSS pixel at a page position (or near the bottom-left corner) and stamps screenshots with the scale in image pixels, read before the capture starts. - map:getMetersPerPixel takes an optional { clientX, clientY }; the ScaleBar asks at its own position. - Remove the Web Mercator formula and getMetersPerPixel from IMapEngine and the adapters. - No scale while the map is tilted more than 10 degrees, where no single bar fits the view. - Indent the bar's label 8px on screen and in exports. --- .../APIs/JavaScript/Main/Event-Bus-API.md | 2 +- .../MapEngines/Adapters/DeckGLAdapter.ts | 38 +--- .../MapEngines/Adapters/DeckGLHelpers.ts | 13 -- .../MapEngines/Adapters/LeafletAdapter.ts | 5 - src/essence/Basics/MapEngines/IMapEngine.ts | 11 +- src/essence/Basics/MapEngines/mapScale.ts | 80 ++++++++ src/essence/Basics/Map_/Map_.js | 3 +- .../Tools/ScaleBar/MMGISScaleBarAdapter.tsx | 10 +- .../__tests__/MMGISScaleBarAdapter.spec.tsx | 18 ++ src/essence/Tools/ScaleBar/config.json | 2 +- .../Tools/ScaleBar/lib/styles/index.scss | 2 +- .../Tools/_shared/adapters/mmgisAPI.ts | 16 +- .../Tools/_shared/scale/renderScaleBar.ts | 2 +- src/essence/mmgisAPI/mmgisAPI.js | 15 +- tests/unit/deckGLAdapter.spec.js | 4 - ...el.spec.js => deckGLOverlayChrome.spec.js} | 92 +-------- tests/unit/mapScale.spec.js | 175 ++++++++++++++++++ tests/unit/mmgisApiScreenshot.spec.js | 3 +- 18 files changed, 327 insertions(+), 164 deletions(-) create mode 100644 src/essence/Basics/MapEngines/mapScale.ts rename tests/unit/{deckGLMetersPerPixel.spec.js => deckGLOverlayChrome.spec.js} (63%) create mode 100644 tests/unit/mapScale.spec.js diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index 38bf4527a2..017fdd09b4 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -390,7 +390,7 @@ has already delivered — the request can resolve after a later event lands. | `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` | none | `number \| null` | Ground metres per CSS pixel at the view centre. Exact at the centre only when the view is tilted. Null before the map exists, or when the engine cannot measure distance | +| `map:getMetersPerPixel` | `{ clientX, clientY }` (optional) | `number \| null` | Ground metres per CSS pixel at that page position, measured on the mission's planet. Without a position, near the map's bottom-left corner. Map scale varies across the screen, so a scale bar asks at its own position. Null before the map exists, while the map is tilted more than 10°, or when the planet radius is not configured | | `map:getChromeInsets` | none | `{ bottomLeft, bottomRight }` | How far, in CSS pixels, the basemap's own logo and attribution reach up from the bottom edge of the map on each side. Zero where there is none. `map:chromeInsetsChange` carries the same shape whenever it changes | | `map:setView` | `{ center, zoom }` | `true` | Set map view | | `map:fitBounds` | `bounds` | `true` | Fit map to bounds | diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index b69353a8b2..4d3bd092ef 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -68,7 +68,6 @@ import { resolveLayerId, pickInfoToResult, buildDeckLayer, - webMercatorMetersPerPixel, } from './DeckGLHelpers' import { TerraDraw, @@ -273,21 +272,7 @@ const isTerraDrawLayer = (id: string): boolean => id.startsWith(`${TERRA_DRAW_PR */ const UNRANKED_Z_INDEX = Number.MAX_SAFE_INTEGER -/** The on-screen scale converted to the canvas's drawing-buffer pixels, - * which outnumber CSS pixels by the device pixel ratio. */ -function imageMetersPerPixel( - canvas: HTMLCanvasElement, - cssMetersPerPixel: number | null -): number | null { - const cssWidth = canvas.clientWidth - if (cssMetersPerPixel == null || !(cssWidth > 0) || !(canvas.width > 0)) return null - return (cssMetersPerPixel * cssWidth) / canvas.width -} - -function canvasToPngScreenshot( - canvas: HTMLCanvasElement, - cssMetersPerPixel: number | null = null -): Promise { +function canvasToPngScreenshot(canvas: HTMLCanvasElement): Promise { return new Promise((resolve, reject) => { if (typeof canvas.toBlob !== 'function') { reject(new Error('[DeckGLAdapter] captureScreenshot: canvas.toBlob is unavailable')) @@ -304,7 +289,6 @@ function canvasToPngScreenshot( extension: 'png', width: canvas.width, height: canvas.height, - metersPerPixel: imageMetersPerPixel(canvas, cssMetersPerPixel), }) }, 'image/png') }) @@ -806,10 +790,10 @@ export class DeckGLAdapter implements IMapEngine { const basemap = this._basemap const onRender = () => { clearTimeout(timeout) - canvasToPngScreenshot( - basemap.getCanvas(), - this.getMetersPerPixel() - ).then(resolve, reject) + canvasToPngScreenshot(basemap.getCanvas()).then( + resolve, + reject + ) } const timeout = setTimeout(() => { // Unhook, or the listener stays armed to fire a wasted @@ -840,7 +824,7 @@ export class DeckGLAdapter implements IMapEngine { ) return } - canvasToPngScreenshot(canvas, this.getMetersPerPixel()).then(resolve, reject) + canvasToPngScreenshot(canvas).then(resolve, reject) } catch (err) { reject(err as Error) } @@ -1075,16 +1059,6 @@ export class DeckGLAdapter implements IMapEngine { } } - getMetersPerPixel(): number | null { - if (this._isOverlayMode ? !this._basemap : !this._deck) return null - // A screenshot asks for this; a failure here must not fail the capture. - try { - return webMercatorMetersPerPixel(this.getCenter().lat, this.getZoom()) - } catch { - return null - } - } - getViewState(): ViewState { if (this._isOverlayMode) { const c = this._basemap!.getCenter() diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts index 5660cb2eca..344a8fe893 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts @@ -84,19 +84,6 @@ export function resolvePadding( return padding } -const EARTH_CIRCUMFERENCE_M = 40075016.686 -const MAX_MERCATOR_LATITUDE = 85.051129 - -/** - * Ground metres per pixel at `latDeg` and `zoom` on deck.gl/MapLibre's Web - * Mercator, which lays the world out on 512-pixel tiles. Always Earth, - * whatever the mission's planet radius. - */ -export function webMercatorMetersPerPixel(latDeg: number, zoom: number): number { - const lat = Math.max(-MAX_MERCATOR_LATITUDE, Math.min(MAX_MERCATOR_LATITUDE, latDeg)) - return (EARTH_CIRCUMFERENCE_M * Math.cos((lat * Math.PI) / 180)) / (512 * Math.pow(2, zoom)) -} - /** * Builds a WebMercatorViewport from the given state and container size. * Pass `zoom` to override `state.zoom`. diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index 1ae6389aca..bf7f403d45 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -511,11 +511,6 @@ export default class LeafletAdapter implements IMapEngine, IMapEn } } - /** The scale bar supports the deck.gl engine only. */ - getMetersPerPixel(): number | null { - return null - } - /** The attribution control is removed at init; nothing sits at the bottom. */ getChromeInsets(): MapChromeInsets { return { bottomLeft: 0, bottomRight: 0 } diff --git a/src/essence/Basics/MapEngines/IMapEngine.ts b/src/essence/Basics/MapEngines/IMapEngine.ts index f78a3cb08e..47aadc19fc 100644 --- a/src/essence/Basics/MapEngines/IMapEngine.ts +++ b/src/essence/Basics/MapEngines/IMapEngine.ts @@ -23,8 +23,9 @@ export interface MapScreenshotResult { extension: 'png' width: number height: number - /** Ground metres per *image* pixel at the centre, at capture time. - * Null or absent when the engine cannot say. */ + /** Ground metres per *image* pixel near the image's bottom-left corner, + * at capture time. Stamped by mmgisAPI, not by the engine; null or + * absent when the map cannot measure it. */ metersPerPixel?: number | null } @@ -139,12 +140,6 @@ export interface IMapEngine< */ getViewState(): ViewState - /** - * Ground metres per CSS pixel at the view centre (exact there only when - * tilted). Null before the map exists or when the engine cannot say. - */ - getMetersPerPixel(): number | null - /** Bottom corner chrome; `'chromeinsetschange'` is emitted when it changes. */ getChromeInsets(): MapChromeInsets diff --git a/src/essence/Basics/MapEngines/mapScale.ts b/src/essence/Basics/MapEngines/mapScale.ts new file mode 100644 index 0000000000..5d3e35d0b4 --- /dev/null +++ b/src/essence/Basics/MapEngines/mapScale.ts @@ -0,0 +1,80 @@ +import F_ from '../Formulae_/Formulae_' +import type { IMapEngine, MapScreenshotResult } from './IMapEngine' +import type { LatLng, LatLngLike } from './types/geometry' + +type ScaleEngine = Pick + +/** A position on the page, in CSS pixels, as a DOM rectangle or event gives it. */ +export type ClientPoint = { clientX: number; clientY: 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 + +// Both adapters answer with the object form; a tuple is in Leaflet order. +const toLatLng = (p: LatLngLike): LatLng => + Array.isArray(p) ? { lat: p[0], lng: p[1] } : (p as LatLng) + +/** Tilt, in degrees, beyond which the map has 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 map's bottom-left corner the scale is measured when no + * point is given: about where an export draws its scale bar. */ +export const DEFAULT_SCALE_INSET_PX = 16 + +/** + * Ground metres per CSS pixel at `at`, or near the map's bottom-left corner + * when `at` is omitted. + * + * The engine places two points a few pixels apart on the ground and the + * distance between them is measured on the mission's planet, so the result + * holds for any engine or projection — at that point. Mercator's scale changes + * with latitude, so a bar measured elsewhere on the screen would be off at + * wide zooms. + * + * Null when the map has no size, is tilted beyond MAX_SCALE_PITCH_DEG, the + * engine cannot place the points, or the planet radius is not configured. + */ +export function metersPerPixelAt(engine: ScaleEngine, at?: ClientPoint): number | 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 + // `at` arrives over the bus, so anything but a finite point is ignored. + const given = !!at && Number.isFinite(at.clientX) && Number.isFinite(at.clientY) + 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 a = toLatLng(engine.containerPointToLatLng({ x, y })) + const b = toLatLng(engine.containerPointToLatLng({ x: x + PROBE_PX, y })) + const meters = F_.lngLatDistBetween(a.lng, a.lat, b.lng, b.lat) + return Number.isFinite(meters) && meters > 0 ? meters / PROBE_PX : null + } catch { + return null + } +} + +/** + * Captures the map and stamps the screenshot with the ground metres one + * *image* pixel spans near its bottom-left corner, where an export draws its + * scale bar. + * + * The scale is read before the capture starts, so a slow capture still + * carries the scale of the view it began with. Image pixels outnumber CSS + * pixels by whatever ratio the engine rendered at, which the image's width + * against the map's gives directly. + */ +export async function captureScreenshotWithScale( + engine: ScaleEngine & Pick +): Promise { + const cssMetersPerPixel = metersPerPixelAt(engine) + const cssWidth = cssMetersPerPixel == null ? 0 : engine.getContainer().clientWidth + const screenshot = await engine.captureScreenshot() + const metersPerPixel = + cssMetersPerPixel != null && cssWidth > 0 && screenshot.width > 0 + ? (cssMetersPerPixel * cssWidth) / screenshot.width + : null + return { ...screenshot, metersPerPixel } +} diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index e7cdadb001..924255d47f 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,7 +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', () => engine.getMetersPerPixel()), + window.mmgisAPI.provide('map:getMetersPerPixel', (at) => metersPerPixelAt(engine, at)), window.mmgisAPI.provide('map:getChromeInsets', () => engine.getChromeInsets()), window.mmgisAPI.provide('map:setView', ({ center, zoom } = {}) => { if (!center) return false diff --git a/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx b/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx index 1ebdb16aaa..4e8d75261e 100644 --- a/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx +++ b/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx @@ -27,6 +27,14 @@ const SCALE_RETRY_LIMIT = 80 const isOnScreen = (el: HTMLElement | null): boolean => !!el && el.getClientRects().length > 0 +// The bar's rule starts at the host's bottom-left corner. Map scale varies +// across the screen, so it is measured there rather than at the centre. +const ruleStart = (el: HTMLElement | null) => { + if (!isOnScreen(el)) return undefined + const rect = el!.getBoundingClientRect() + return { clientX: rect.left, clientY: rect.bottom } +} + export function MMGISScaleBarAdapter() { const vars = useMMGISToolVars('scalebar') const units = resolveScaleUnits(vars.units) @@ -41,7 +49,7 @@ export function MMGISScaleBarAdapter() { const refresh = useCallback(async () => { let next: number | null = null try { - next = await mmgisGetMetersPerPixel() + next = await mmgisGetMetersPerPixel(ruleStart(hostRef.current)) } catch (err) { console.warn('ScaleBar: reading the map scale failed', err) } diff --git a/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx b/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx index 84eafaf299..28786973d8 100644 --- a/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx +++ b/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx @@ -90,6 +90,24 @@ describe('MMGISScaleBarAdapter', () => { }, ) + // Map scale varies across the screen, so the bar asks for it where its + // rule starts: the host's bottom-left corner. + test('asks for the scale at its own position on the page', async () => { + const { container, unmount } = await mount() + showHost(container) + const host = container.querySelector('.blocks-scale-bar-host') as HTMLElement + host.getBoundingClientRect = () => ({ left: 24, bottom: 580 }) as DOMRect + await fire('map:move') + const request = (window as unknown as { + mmgisAPI: { request: ReturnType } + }).mmgisAPI.request + expect(request).toHaveBeenLastCalledWith('map:getMetersPerPixel', { + clientX: 24, + clientY: 580, + }) + await unmount() + }) + // A map whose basemap loads asynchronously has no scale when the plugin // first asks; the bar appears once it does, without a zoom or pan. test('keeps asking until the map has a scale', async () => { diff --git a/src/essence/Tools/ScaleBar/config.json b/src/essence/Tools/ScaleBar/config.json index 8be1120db9..3a2d76e8e9 100644 --- a/src/essence/Tools/ScaleBar/config.json +++ b/src/essence/Tools/ScaleBar/config.json @@ -2,7 +2,7 @@ "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, ...) and follows the map as it zooms and pans, switching from metres to kilometres or from feet to miles as the view widens. While the bar is shown, PNG and PDF exports from the Share button draw a matching bar, measured for the exported image, over the map's bottom-left corner. Leave the tool out of the layout to show no bar on the map or in exports. When the map is tilted, the bar is exact at the centre of the view.", + "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, ...) and follows the map as it zooms and pans, switching from metres to kilometres or from feet to miles as the view widens. While the bar is shown, PNG and PDF exports from the Share button draw a matching bar, measured for the exported image, over the map's bottom-left corner. Leave the tool out of the layout to show no bar on the map or in exports. The scale is measured where the bar sits, on the mission's planet, so it holds at wide zooms. The bar hides while the map is tilted more than 10°, where no single scale fits the view, and exports of a tilted view carry no bar.", "example": { "units": "metric" } diff --git a/src/essence/Tools/ScaleBar/lib/styles/index.scss b/src/essence/Tools/ScaleBar/lib/styles/index.scss index 7517ea1092..058f939469 100644 --- a/src/essence/Tools/ScaleBar/lib/styles/index.scss +++ b/src/essence/Tools/ScaleBar/lib/styles/index.scss @@ -16,7 +16,7 @@ user-select: none; &__label { - padding-left: 4px; + padding-left: 8px; white-space: nowrap; } diff --git a/src/essence/Tools/_shared/adapters/mmgisAPI.ts b/src/essence/Tools/_shared/adapters/mmgisAPI.ts index 2f4fe3079f..a34df473ce 100644 --- a/src/essence/Tools/_shared/adapters/mmgisAPI.ts +++ b/src/essence/Tools/_shared/adapters/mmgisAPI.ts @@ -26,7 +26,8 @@ export type MapScreenshotResult = { extension: 'png' width: number height: number - /** Ground metres per image pixel at the centre, at capture time. */ + /** Ground metres per image pixel near the image's bottom-left corner, at + * capture time. */ metersPerPixel?: number | null } @@ -158,9 +159,16 @@ export const mmgisGetMapScreenshot = (): Promise< return mmgisRequestIfProvided('map:getScreenshot') } -/** Ground metres per CSS pixel at the view centre; null before the map exists. */ -export const mmgisGetMetersPerPixel = (): Promise => { - return mmgisRequestIfProvided('map:getMetersPerPixel') +/** + * Ground metres per CSS pixel at a page position (e.g. a DOM rect's corner), + * or near the map's bottom-left corner when `at` is omitted. Null before the + * map exists or where it cannot measure. + */ +export const mmgisGetMetersPerPixel = (at?: { + clientX: number + clientY: number +}): Promise => { + return mmgisRequestIfProvided('map:getMetersPerPixel', at) } export type ScaleBarSettings = { units: 'metric' | 'imperial' } diff --git a/src/essence/Tools/_shared/scale/renderScaleBar.ts b/src/essence/Tools/_shared/scale/renderScaleBar.ts index 58557dc24e..04745e5114 100644 --- a/src/essence/Tools/_shared/scale/renderScaleBar.ts +++ b/src/essence/Tools/_shared/scale/renderScaleBar.ts @@ -9,7 +9,7 @@ const PAD_X = 8 const PAD_Y = 6 const LABEL_TEXT = 12 const LABEL_GAP = 4 -const LABEL_INDENT = 4 +const LABEL_INDENT = 8 const TICK = 8 const BACKING = 'rgba(255, 255, 255, 0.8)' diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index 7395ec8eed..8af1d39686 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' @@ -488,15 +489,13 @@ var mmgisAPI_ = { getMapScreenshot: function () { // 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. + // missing engine means no map is loaded yet. The scale stamped on the + // result is measured the same way for every engine. Being async, + // captureScreenshotWithScale turns a sync engine throw into a + // rejection. 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) } return Promise.reject( new Error('getMapScreenshot: no active map engine to capture') @@ -821,7 +820,7 @@ 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, metersPerPixel: number|null}>} - resolves to a PNG Blob plus image metadata. `metersPerPixel` is the ground distance one *image* pixel spans at the image centre, null when the engine cannot say. + * @returns {Promise<{blob: Blob, mimeType: 'image/png', extension: 'png', width: number, height: number, metersPerPixel: number|null}>} - resolves to a PNG Blob plus image metadata. `metersPerPixel` is the ground distance one *image* pixel spans near the image's bottom-left corner, null when the map cannot measure it. * Plugins should prefer `mmgisAPI.request('map:getScreenshot')`. */ getMapScreenshot: mmgisAPI_.getMapScreenshot, diff --git a/tests/unit/deckGLAdapter.spec.js b/tests/unit/deckGLAdapter.spec.js index 410aeb038b..0c5fa4f5a9 100644 --- a/tests/unit/deckGLAdapter.spec.js +++ b/tests/unit/deckGLAdapter.spec.js @@ -946,7 +946,6 @@ test.describe('DeckGLAdapter', () => { extension: 'png', width: 256, height: 128, - metersPerPixel: null, }) }) @@ -1026,9 +1025,6 @@ test.describe('DeckGLAdapter', () => { extension: 'png', width: 300, height: 200, - // The fake canvas never laid out (no clientWidth), so the - // image scale is unknown. - metersPerPixel: null, }) }) diff --git a/tests/unit/deckGLMetersPerPixel.spec.js b/tests/unit/deckGLOverlayChrome.spec.js similarity index 63% rename from tests/unit/deckGLMetersPerPixel.spec.js rename to tests/unit/deckGLOverlayChrome.spec.js index 2d3ca4c701..4ac2a77447 100644 --- a/tests/unit/deckGLMetersPerPixel.spec.js +++ b/tests/unit/deckGLOverlayChrome.spec.js @@ -1,9 +1,9 @@ import { test, expect, vi, beforeEach } from 'vitest' -// The deck.gl engine's scale: metres per CSS pixel at the view centre, the -// same figure converted to image pixels on every screenshot, and the 'move' -// stream a live scale bar follows. The GL constructors are mocked (jsdom has -// no WebGL); the basemap mock holds a settable camera and canvas. +// What the deck.gl engine's overlay mode tells the layout and a live scale +// bar: the 'move' stream while the camera moves, and how far the basemap's +// logo and attribution reach up from the bottom. The GL constructors are +// mocked (jsdom has no WebGL); the basemap mock holds a settable camera. const basemaps = [] @@ -12,7 +12,7 @@ vi.mock('@deck.gl/core', async (importOriginal) => { class MockDeck { constructor(props) { this.props = props - this.canvas = makeCanvas(400, 800) + this.canvas = makeCanvas() } setProps() {} redraw() {} @@ -36,7 +36,7 @@ vi.mock('maplibre-gl', () => { } this.center = { lat: 0, lng: 0 } this.zoom = 0 - this.canvas = makeCanvas(400, 800) + this.canvas = makeCanvas() basemaps.push(this) } addControl() {} @@ -81,13 +81,10 @@ vi.mock('maplibre-gl', () => { } }) -// A canvas whose drawing buffer is `bufferWidth` wide while it lays out -// `cssWidth` wide, as on a display with devicePixelRatio bufferWidth/cssWidth. -function makeCanvas(cssWidth, bufferWidth) { +function makeCanvas() { return { - clientWidth: cssWidth, - width: bufferWidth, - height: bufferWidth / 2, + width: 800, + height: 400, toBlob: (cb) => cb(new Blob(['png'], { type: 'image/png' })), } } @@ -95,9 +92,6 @@ function makeCanvas(cssWidth, bufferWidth) { const { DeckGLAdapter } = await import( '../../src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts' ) -const { webMercatorMetersPerPixel } = await import( - '../../src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts' -) function makeContainer() { let el = document.getElementById('map') @@ -131,74 +125,6 @@ const overlay = () => { return { adapter, basemap: basemaps[0] } } -test.describe('webMercatorMetersPerPixel', () => { - test('zoom 0 at the equator spans the world in 512 pixels', () => { - expect(webMercatorMetersPerPixel(0, 0)).toBeCloseTo(78271.517, 3) - }) - - test('shrinks with the cosine of latitude, clamped at the Mercator limit', () => { - expect(webMercatorMetersPerPixel(60, 3)).toBeCloseTo( - webMercatorMetersPerPixel(0, 3) / 2, - 6, - ) - expect(webMercatorMetersPerPixel(90, 3)).toBe( - webMercatorMetersPerPixel(85.051129, 3), - ) - expect(webMercatorMetersPerPixel(90, 3)).toBeGreaterThan(0) - }) -}) - -test.describe('DeckGLAdapter.getMetersPerPixel', () => { - test('standalone mode reads the camera', () => { - expect(standalone({ lat: 0, lng: 0 }, 0).getMetersPerPixel()).toBeCloseTo( - 78271.517, - 3, - ) - expect( - standalone({ lat: 45, lng: 10 }, 10).getMetersPerPixel(), - ).toBeCloseTo(54.05, 2) - }) - - test('overlay mode reads the basemap camera', () => { - const { adapter, basemap } = overlay() - basemap.center = { lat: 45, lng: 0 } - basemap.zoom = 10 - expect(adapter.getMetersPerPixel()).toBeCloseTo(54.05, 2) - }) - - test('reads null before the map exists', () => { - expect(new DeckGLAdapter().getMetersPerPixel()).toBeNull() - }) -}) - -test.describe('DeckGLAdapter.captureScreenshot scale', () => { - // The image is twice the CSS width (devicePixelRatio 2), so each image - // pixel spans half the ground a CSS pixel does. - test('standalone mode stamps the scale in image pixels', async () => { - const adapter = standalone({ lat: 0, lng: 0 }, 4) - const shot = await adapter.captureScreenshot() - expect(shot.width).toBe(800) - expect(shot.metersPerPixel).toBeCloseTo(adapter.getMetersPerPixel() / 2, 9) - }) - - test('overlay mode stamps the scale in image pixels', async () => { - const { adapter, basemap } = overlay() - basemap.center = { lat: 60, lng: 0 } - basemap.zoom = 12 - const shot = await adapter.captureScreenshot() - expect(shot.metersPerPixel).toBeCloseTo( - webMercatorMetersPerPixel(60, 12) / 2, - 9, - ) - }) - - test('a canvas that has not laid out stamps no scale', async () => { - const adapter = standalone({ lat: 0, lng: 0 }, 4) - adapter._deck.canvas.clientWidth = 0 - expect((await adapter.captureScreenshot()).metersPerPixel).toBeNull() - }) -}) - test.describe('DeckGLAdapter overlay move events', () => { // A live scale bar follows the camera while it moves, not only when the // gesture ends. diff --git a/tests/unit/mapScale.spec.js b/tests/unit/mapScale.spec.js new file mode 100644 index 0000000000..0a317264fa --- /dev/null +++ b/tests/unit/mapScale.spec.js @@ -0,0 +1,175 @@ +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, +} 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, toLatLng } = {}) { + const container = { + clientWidth, + getBoundingClientRect: () => rect, + } + return { + getContainer: () => container, + getViewState: () => ({ pitch }), + 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 +}) + +test.describe('metersPerPixelAt', () => { + test('measures near the bottom-left corner when given no point', () => { + const engine = makeEngine() + metersPerPixelAt(engine) + expect(engine.containerPointToLatLng).toHaveBeenNthCalledWith(1, { + x: DEFAULT_SCALE_INSET_PX, + y: RECT.height - DEFAULT_SCALE_INSET_PX, + }) + }) + + test('takes a page position relative to the map container', () => { + const engine = makeEngine() + metersPerPixelAt(engine, { clientX: 130, clientY: 50 }) + expect(engine.containerPointToLatLng).toHaveBeenNthCalledWith(1, { x: 30, y: 0 }) + }) + + test('gives the ground metres one pixel spans there', () => { + const engine = makeEngine() + // Row 0 is the equator, where the plate carrée is true to scale. + expect(metersPerPixelAt(engine, { clientX: 100, clientY: 50 })).toBeCloseTo( + degToMeters(DEG_PER_PX), + 6 + ) + }) + + // At 60° a degree of longitude is half as long as at the equator, so a + // bar measured at the centre would be wrong at the bottom of the map. + test('follows the latitude of the point measured', () => { + const engine = makeEngine({ + toLatLng: ({ x, y }) => ({ lng: x * DEG_PER_PX, lat: y === 0 ? 0 : 60 }), + }) + const equator = metersPerPixelAt(engine, { clientX: 100, clientY: 50 }) + const sixty = metersPerPixelAt(engine, { clientX: 100, clientY: 60 }) + expect(sixty / equator).toBeCloseTo(0.5, 6) + }) + + test("uses the mission's planet radius", () => { + const engine = makeEngine() + const at = { clientX: 100, clientY: 50 } + const earth = metersPerPixelAt(engine, at) + F_.radiusOfPlanetMajor = 3396190 + expect(metersPerPixelAt(engine, at) / earth).toBeCloseTo(3396190 / EARTH_RADIUS, 9) + }) + + test('ignores a position that is not a finite point', () => { + const engine = makeEngine() + metersPerPixelAt(engine, { clientX: 'left' }) + expect(engine.containerPointToLatLng).toHaveBeenNthCalledWith(1, { + x: DEFAULT_SCALE_INSET_PX, + y: RECT.height - DEFAULT_SCALE_INSET_PX, + }) + }) + + 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() + }) + + // A tilted map's scale changes from bottom to top, so no bar fits it. + test('is null for a map tilted past the limit', () => { + expect(metersPerPixelAt(makeEngine({ pitch: MAX_SCALE_PITCH_DEG }))).not.toBeNull() + expect(metersPerPixelAt(makeEngine({ pitch: MAX_SCALE_PITCH_DEG + 1 }))).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 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) + 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) + 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) + }) + + 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() + }) + + 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 () => { From 05ecc0799475517cd405adb73503b2adf58ebe43 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Thu, 15 Oct 2026 14:43:53 -0500 Subject: [PATCH 3/6] Scale bar: leave basemap chrome clearance to its own change The bottom float zones' clearance above the basemap logo and attribution is a layout change for every bottom panel, so it moves to a separate branch. --- .../APIs/JavaScript/Main/Event-Bus-API.md | 1 - .../MapEngines/Adapters/DeckGLAdapter.ts | 44 ---------- .../MapEngines/Adapters/LeafletAdapter.ts | 7 +- src/essence/Basics/MapEngines/IMapEngine.ts | 10 --- src/essence/Basics/Map_/Map_.js | 2 - .../UserInterface_/UserInterfaceModern_.css | 13 +-- .../UserInterface_/UserInterfaceModern_.js | 55 +----------- ...me.spec.js => deckGLOverlayEvents.spec.js} | 84 +------------------ .../userInterfaceModern.mapChrome.spec.js | 52 ------------ 9 files changed, 10 insertions(+), 258 deletions(-) rename tests/unit/{deckGLOverlayChrome.spec.js => deckGLOverlayEvents.spec.js} (50%) delete mode 100644 tests/unit/userInterfaceModern.mapChrome.spec.js diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index 017fdd09b4..61344d774d 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -391,7 +391,6 @@ has already delivered — the request can resolve after a later event lands. | `map:getBounds` | none | `LatLngBounds` | Get map bounds | | `map:getZoom` | none | `number` | Get current zoom level | | `map:getMetersPerPixel` | `{ clientX, clientY }` (optional) | `number \| null` | Ground metres per CSS pixel at that page position, measured on the mission's planet. Without a position, near the map's bottom-left corner. Map scale varies across the screen, so a scale bar asks at its own position. Null before the map exists, while the map is tilted more than 10°, or when the planet radius is not configured | -| `map:getChromeInsets` | none | `{ bottomLeft, bottomRight }` | How far, in CSS pixels, the basemap's own logo and attribution reach up from the bottom edge of the map on each side. Zero where there is none. `map:chromeInsetsChange` carries the same shape whenever it changes | | `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 | diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 4d3bd092ef..1d92d2d641 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -34,7 +34,6 @@ import type { IMapEngine, ComparisonConfig, ComparisonLayout, - MapChromeInsets, MapScreenshotResult } from '../IMapEngine' import { MAP_ENGINE } from '../types/engine' @@ -454,10 +453,6 @@ export class DeckGLAdapter implements IMapEngine { */ private _comparisonResizeObserver: ResizeObserver | null = null - /** Watches the basemap's bottom control corners (logo, attribution). */ - private _chromeObserver: ResizeObserver | null = null - private _chromeInsets: MapChromeInsets = { bottomLeft: 0, bottomRight: 0 } - // ── Side-by-side comparison state ───────────────────────────────────────── // The other way of splitting the viewport: rather than one camera drawn // twice and wiped between, each half gets a map of its own — basemap @@ -631,10 +626,6 @@ export class DeckGLAdapter implements IMapEngine { this._comparisonEnabled = false this._destroyComparisonSurfaces() - this._chromeObserver?.disconnect() - this._chromeObserver = null - this._chromeInsets = { bottomLeft: 0, bottomRight: 0 } - if (this._isOverlayMode) { if (this._basemap) { this._basemap.off('move', this._onBasemapMove) @@ -2439,41 +2430,6 @@ export class DeckGLAdapter implements IMapEngine { // switch re-anchors the layers on the new style's labels. this._basemap.on('style.load', this._onBasemapLoad) this._basemap.on('error', this._onBasemapError) - this._watchBasemapChrome() - } - - /** - * MapLibre and Mapbox create their corner containers with the map and - * fill them later (logo, attribution), so the corners are measured each - * time they resize. - */ - private _watchBasemapChrome(): void { - this._chromeObserver?.disconnect() - this._chromeObserver = null - if (typeof ResizeObserver === 'undefined') return - const corner = (side: string) => - this._container.querySelector( - `.maplibregl-ctrl-bottom-${side}, .mapboxgl-ctrl-bottom-${side}` - ) - const left = corner('left') - const right = corner('right') - // A corner's box includes its controls' margins; an empty one has no height. - const reach = (el: HTMLElement | null): number => { - if (!el) return 0 - const rect = el.getBoundingClientRect() - if (rect.height === 0) return 0 - return Math.max(0, Math.ceil(this._container.getBoundingClientRect().bottom - rect.top)) - } - this._chromeObserver = new ResizeObserver(() => { - this._chromeInsets = { bottomLeft: reach(left), bottomRight: reach(right) } - this._emitEvent('chromeinsetschange', { ...this._chromeInsets }) - }) - if (left) this._chromeObserver.observe(left) - if (right) this._chromeObserver.observe(right) - } - - getChromeInsets(): MapChromeInsets { - return { ...this._chromeInsets } } /** diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index bf7f403d45..429cabd265 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -7,7 +7,7 @@ * */ -import { IMapEngine, MapChromeInsets, MapScreenshotResult } from '../IMapEngine' +import { IMapEngine, MapScreenshotResult } from '../IMapEngine' import { LatLng, LatLngLike, @@ -511,11 +511,6 @@ export default class LeafletAdapter implements IMapEngine, IMapEn } } - /** The attribution control is removed at init; nothing sits at the bottom. */ - getChromeInsets(): MapChromeInsets { - return { bottomLeft: 0, bottomRight: 0 } - } - /** * Get the current view state */ diff --git a/src/essence/Basics/MapEngines/IMapEngine.ts b/src/essence/Basics/MapEngines/IMapEngine.ts index 47aadc19fc..2ef6c0b7b5 100644 --- a/src/essence/Basics/MapEngines/IMapEngine.ts +++ b/src/essence/Basics/MapEngines/IMapEngine.ts @@ -29,13 +29,6 @@ export interface MapScreenshotResult { metersPerPixel?: number | null } -/** How far, in CSS pixels, the basemap's logo and attribution reach up from - * the map's bottom edge on each side. */ -export interface MapChromeInsets { - bottomLeft: number - bottomRight: number -} - /** * Core map engine contract. * @@ -140,9 +133,6 @@ export interface IMapEngine< */ getViewState(): ViewState - /** Bottom corner chrome; `'chromeinsetschange'` is emitted when it changes. */ - getChromeInsets(): MapChromeInsets - /** * Set the full camera state. Leaflet adapter ignores bearing and pitch. */ diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 924255d47f..7e0362d9ce 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -262,7 +262,6 @@ let Map_ = { 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:getChromeInsets', () => engine.getChromeInsets()), window.mmgisAPI.provide('map:setView', ({ center, zoom } = {}) => { if (!center) return false engine.setView(center, zoom) @@ -410,7 +409,6 @@ let Map_ = { reEmit('drawcancel', 'map:drawcancel') reEmit('move', 'map:move') reEmit('moveend', 'map:moveend') - reEmit('chromeinsetschange', 'map:chromeInsetsChange') // Pointer streams consumed by plugins (e.g. measure tools, overlays). reEmit('click', 'map:click') reEmit('mousemove', 'map:mousemove') diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css index 83afc6c633..3806531cf6 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css @@ -555,18 +555,13 @@ body.ui-is-dragging .ui-modern-grid .ui-panel { padding: var(--ui-float-outer-gap); } -/* Zone positions. The bottom zones sit above the basemap's logo and - attribution (--ui-map-chrome-*, set by the layout). */ +/* Zone positions */ .ui-float-zone-top-left { top: 0; left: 0; } .ui-float-zone-top-center { top: 0; left: 50%; transform: translateX(-50%); } .ui-float-zone-top-right { top: 0; right: 0; } -.ui-float-zone-bottom-left { bottom: var(--ui-map-chrome-bottom-left, 0px); left: 0; } -.ui-float-zone-bottom-center { - bottom: max(var(--ui-map-chrome-bottom-left, 0px), var(--ui-map-chrome-bottom-right, 0px)); - left: 50%; - transform: translateX(-50%); -} -.ui-float-zone-bottom-right { bottom: var(--ui-map-chrome-bottom-right, 0px); right: 0; } +.ui-float-zone-bottom-left { bottom: 0; left: 0; } +.ui-float-zone-bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); } +.ui-float-zone-bottom-right { bottom: 0; right: 0; } /* Float panels re-enable pointer events */ .ui-float-panel { diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js index 3e55a88744..cd8595a56c 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js @@ -19,35 +19,6 @@ let layoutStyle = '' let toolLoadQueue = [] let cleanupLayoutListener = null let _resizeObserver = null -let cleanupChromeListener = null -let mapChromeInsets = { bottomLeft: 0, bottomRight: 0 } - -const CHROME_INSET_VARS = { - bottomLeft: '--ui-map-chrome-bottom-left', - bottomRight: '--ui-map-chrome-bottom-right', -} - -// Lifts the bottom float zones above the basemap's logo and attribution. The -// chrome is measured from the map's bottom edge; when a panel overlays the -// bottom of the map, the part it covers needs no clearing. -const _applyMapChromeInsets = () => { - const center = document.getElementById('ui-modern-center') - const map = document.getElementById('map') - const covered = - center && map - ? Math.max(0, map.getBoundingClientRect().bottom - center.getBoundingClientRect().bottom) - : 0 - Object.entries(CHROME_INSET_VARS).forEach(([side, name]) => { - const lift = Math.max(0, mapChromeInsets[side] - covered) - document.documentElement.style.setProperty(name, `${lift}px`) - }) -} - -const _setMapChromeInsets = (insets) => { - if (!insets) return - mapChromeInsets = insets - _applyMapChromeInsets() -} // --- DOM Builder Helpers --- const _createPanelIconTray = (panel) => { @@ -342,27 +313,11 @@ const UserInterfaceModern_ = { cleanupLayoutListener = mmgisAPI.on('panels:changed', this.syncDOMState.bind(this)) } - if (!cleanupChromeListener) { - cleanupChromeListener = mmgisAPI.on( - 'map:chromeInsetsChange', - _setMapChromeInsets - ) - } - // The map may have reported its chrome before this listener existed. - if (mmgisAPI.hasHandler('map:getChromeInsets')) { - mmgisAPI - .request('map:getChromeInsets') - .then(_setMapChromeInsets) - .catch(() => {}) - } - _applyMapChromeInsets() - // Set up ResizeObserver to dispatch resize events when the center map area changes size if (_resizeObserver) { _resizeObserver.disconnect() } _resizeObserver = new ResizeObserver(() => { - _applyMapChromeInsets() window.dispatchEvent(new Event('resize')) }) const centerElement = document.getElementById('ui-modern-center') @@ -385,14 +340,6 @@ const UserInterfaceModern_ = { _resizeObserver.disconnect() _resizeObserver = null } - if (cleanupChromeListener) { - cleanupChromeListener() - cleanupChromeListener = null - } - mapChromeInsets = { bottomLeft: 0, bottomRight: 0 } - Object.values(CHROME_INSET_VARS).forEach((name) => - document.documentElement.style.removeProperty(name) - ) $('#modern-content').empty() panels = [] mmgisAPI_._pluginController = null @@ -923,4 +870,4 @@ const UserInterfaceModern_ = { } export default UserInterfaceModern_ -export { _createPanelHeader, _createPanelIconTray, _renderRegion, _setMapChromeInsets } +export { _createPanelHeader, _createPanelIconTray, _renderRegion } diff --git a/tests/unit/deckGLOverlayChrome.spec.js b/tests/unit/deckGLOverlayEvents.spec.js similarity index 50% rename from tests/unit/deckGLOverlayChrome.spec.js rename to tests/unit/deckGLOverlayEvents.spec.js index 4ac2a77447..003517624d 100644 --- a/tests/unit/deckGLOverlayChrome.spec.js +++ b/tests/unit/deckGLOverlayEvents.spec.js @@ -1,9 +1,8 @@ import { test, expect, vi, beforeEach } from 'vitest' -// What the deck.gl engine's overlay mode tells the layout and a live scale -// bar: the 'move' stream while the camera moves, and how far the basemap's -// logo and attribution reach up from the bottom. The GL constructors are -// mocked (jsdom has no WebGL); the basemap mock holds a settable camera. +// The deck.gl engine's overlay mode reports the 'move' stream while the +// camera moves. The GL constructors are mocked (jsdom has no WebGL); the +// basemap mock holds a settable camera. const basemaps = [] @@ -26,14 +25,8 @@ vi.mock('@deck.gl/core', async (importOriginal) => { vi.mock('maplibre-gl', () => { class MockMap { - constructor(opts) { + constructor() { this.handlers = {} - // MapLibre builds its control corners with the map. - for (const side of ['left', 'right']) { - const corner = document.createElement('div') - corner.className = `maplibregl-ctrl-bottom-${side}` - opts.container.appendChild(corner) - } this.center = { lat: 0, lng: 0 } this.zoom = 0 this.canvas = makeCanvas() @@ -108,12 +101,6 @@ beforeEach(() => { makeContainer() }) -const standalone = (center, zoom) => { - const adapter = new DeckGLAdapter() - adapter.init({ containerId: 'map', zoom, center }) - return adapter -} - const overlay = () => { const adapter = new DeckGLAdapter() adapter.init({ @@ -139,66 +126,3 @@ test.describe('DeckGLAdapter overlay move events', () => { expect(moves[0]).toMatchObject({ latitude: 10, longitude: 20, zoom: 7 }) }) }) - -test.describe('DeckGLAdapter basemap chrome', () => { - // A ResizeObserver that runs its callback on demand, standing in for the - // browser's (jsdom has none). - let observers - beforeEach(() => { - observers = [] - globalThis.ResizeObserver = class { - constructor(cb) { - this.cb = cb - this.targets = [] - observers.push(this) - } - observe(el) { - this.targets.push(el) - } - disconnect() { - this.targets = [] - } - } - }) - - const rect = (el, top, bottom) => { - el.getBoundingClientRect = () => ({ top, bottom, height: bottom - top }) - } - - test('reports how far the logo and attribution reach up from the bottom', () => { - const container = makeContainer() - container.innerHTML = '' - const { adapter } = overlay() - rect(container, 0, 600) - const left = container.querySelector('.maplibregl-ctrl-bottom-left') - const right = container.querySelector('.maplibregl-ctrl-bottom-right') - expect(observers[0].targets).toEqual([left, right]) - - const changes = [] - adapter.on('chromeinsetschange', (e) => changes.push(e)) - rect(left, 569, 600) - rect(right, 578.5, 600) - observers[0].cb() - expect(adapter.getChromeInsets()).toEqual({ bottomLeft: 31, bottomRight: 22 }) - expect(changes).toEqual([{ bottomLeft: 31, bottomRight: 22 }]) - }) - - test('an empty corner reaches nowhere', () => { - const container = makeContainer() - container.innerHTML = '' - const { adapter } = overlay() - rect(container, 0, 600) - rect(container.querySelector('.maplibregl-ctrl-bottom-left'), 600, 600) - rect(container.querySelector('.maplibregl-ctrl-bottom-right'), 600, 600) - observers[0].cb() - expect(adapter.getChromeInsets()).toEqual({ bottomLeft: 0, bottomRight: 0 }) - }) - - test('standalone mode has no basemap chrome', () => { - expect(standalone({ lat: 0, lng: 0 }, 2).getChromeInsets()).toEqual({ - bottomLeft: 0, - bottomRight: 0, - }) - expect(observers).toHaveLength(0) - }) -}) diff --git a/tests/unit/userInterfaceModern.mapChrome.spec.js b/tests/unit/userInterfaceModern.mapChrome.spec.js deleted file mode 100644 index 1b42bf2aed..0000000000 --- a/tests/unit/userInterfaceModern.mapChrome.spec.js +++ /dev/null @@ -1,52 +0,0 @@ -import { test, expect, vi, afterEach } from 'vitest' -vi.mock('../../src/essence/Basics/Viewer_/Viewer_', () => ({ default: {} })) - -// The bottom float zones are lifted by CSS variables the layout sets from the -// map's reported chrome. jsdom does no layout, so the map and centre region -// are given the rectangles a real page would have. - -const { _setMapChromeInsets } = await import( - '../../src/essence/Basics/UserInterface_/UserInterfaceModern_.js' -) - -const box = (id, bottom) => { - const el = document.createElement('div') - el.id = id - el.getBoundingClientRect = () => ({ top: 0, bottom, left: 0, right: 800, height: bottom, width: 800 }) - document.body.appendChild(el) - return el -} - -const lift = (side) => - document.documentElement.style.getPropertyValue(`--ui-map-chrome-bottom-${side}`) - -afterEach(() => { - document.body.innerHTML = '' - document.documentElement.removeAttribute('style') -}) - -test('zones rise by the full chrome when the map and centre share a bottom edge', () => { - box('map', 600) - box('ui-modern-center', 600) - _setMapChromeInsets({ bottomLeft: 31, bottomRight: 18 }) - expect(lift('left')).toBe('31px') - expect(lift('right')).toBe('18px') -}) - -// A bottom panel laid over the map already covers the lower part of the -// chrome; only what reaches above the centre region needs clearing. -test('chrome hidden under a panel overlaying the map is not cleared again', () => { - box('map', 600) - box('ui-modern-center', 580) - _setMapChromeInsets({ bottomLeft: 31, bottomRight: 12 }) - expect(lift('left')).toBe('11px') - expect(lift('right')).toBe('0px') -}) - -test('a map with no bottom chrome leaves the zones on the edge', () => { - box('map', 600) - box('ui-modern-center', 600) - _setMapChromeInsets({ bottomLeft: 0, bottomRight: 0 }) - expect(lift('left')).toBe('0px') - expect(lift('right')).toBe('0px') -}) From 1220a20936d51043b64846e6976eaa014ec7d6a4 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Thu, 15 Oct 2026 14:47:28 -0500 Subject: [PATCH 4/6] Scale bar: refresh on map events instead of polling, trim comments - Read the scale on handler-ready, map:moveend and window resize, and at most once per frame on map:move, instead of retrying null reads on a timer. - Provide the export settings with units as an effect dependency. - ScaleBarSettings reuses ScaleUnits; LegendDeps becomes DecorationDeps. - Drop the unused tuple branch in metersPerPixelAt. - Shorten the mapScale docstrings, the bus API row and the tool's Configure description. --- .../APIs/JavaScript/Main/Event-Bus-API.md | 2 +- src/essence/Basics/MapEngines/mapScale.ts | 31 +++---- .../Tools/ScaleBar/MMGISScaleBarAdapter.tsx | 58 ++++++------- src/essence/Tools/ScaleBar/ScaleBarTool.tsx | 3 +- .../__tests__/MMGISScaleBarAdapter.spec.tsx | 82 +++++++++---------- src/essence/Tools/ScaleBar/config.json | 2 +- .../Tools/_shared/adapters/mmgisAPI.ts | 3 +- .../Tools/_shared/adapters/shareActions.ts | 8 +- src/essence/mmgisAPI/mmgisAPI.js | 5 +- 9 files changed, 86 insertions(+), 108 deletions(-) diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index 61344d774d..d12606b35e 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -390,7 +390,7 @@ has already delivered — the request can resolve after a later event lands. | `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` | `{ clientX, clientY }` (optional) | `number \| null` | Ground metres per CSS pixel at that page position, measured on the mission's planet. Without a position, near the map's bottom-left corner. Map scale varies across the screen, so a scale bar asks at its own position. Null before the map exists, while the map is tilted more than 10°, or when the planet radius is not configured | +| `map:getMetersPerPixel` | `{ clientX, clientY }` (optional) | `number \| null` | Ground metres per CSS pixel at that page position, or near the map's bottom-left corner when omitted. Null before the map exists, while it is tilted more than 10°, 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 | diff --git a/src/essence/Basics/MapEngines/mapScale.ts b/src/essence/Basics/MapEngines/mapScale.ts index 5d3e35d0b4..11c0326772 100644 --- a/src/essence/Basics/MapEngines/mapScale.ts +++ b/src/essence/Basics/MapEngines/mapScale.ts @@ -1,6 +1,6 @@ import F_ from '../Formulae_/Formulae_' import type { IMapEngine, MapScreenshotResult } from './IMapEngine' -import type { LatLng, LatLngLike } from './types/geometry' +import type { LatLng } from './types/geometry' type ScaleEngine = Pick @@ -12,10 +12,6 @@ export type ClientPoint = { clientX: number; clientY: number } // great-circle chord rather than the distance along the screen row. const PROBE_PX = 10 -// Both adapters answer with the object form; a tuple is in Leaflet order. -const toLatLng = (p: LatLngLike): LatLng => - Array.isArray(p) ? { lat: p[0], lng: p[1] } : (p as LatLng) - /** Tilt, in degrees, beyond which the map has 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. */ @@ -27,13 +23,9 @@ export const DEFAULT_SCALE_INSET_PX = 16 /** * Ground metres per CSS pixel at `at`, or near the map's bottom-left corner - * when `at` is omitted. - * - * The engine places two points a few pixels apart on the ground and the - * distance between them is measured on the mission's planet, so the result - * holds for any engine or projection — at that point. Mercator's scale changes - * with latitude, so a bar measured elsewhere on the screen would be off at - * wide zooms. + * 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. * * Null when the map has no size, is tilted beyond MAX_SCALE_PITCH_DEG, the * engine cannot place the points, or the planet radius is not configured. @@ -47,8 +39,9 @@ export function metersPerPixelAt(engine: ScaleEngine, at?: ClientPoint): number const given = !!at && Number.isFinite(at.clientX) && Number.isFinite(at.clientY) 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 a = toLatLng(engine.containerPointToLatLng({ x, y })) - const b = toLatLng(engine.containerPointToLatLng({ x: x + PROBE_PX, y })) + // 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 } catch { @@ -58,13 +51,9 @@ export function metersPerPixelAt(engine: ScaleEngine, at?: ClientPoint): number /** * Captures the map and stamps the screenshot with the ground metres one - * *image* pixel spans near its bottom-left corner, where an export draws its - * scale bar. - * - * The scale is read before the capture starts, so a slow capture still - * carries the scale of the view it began with. Image pixels outnumber CSS - * pixels by whatever ratio the engine rendered at, which the image's width - * against the map's gives directly. + * *image* pixel spans near its bottom-left corner. 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 diff --git a/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx b/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx index 4e8d75261e..8b7fb8b8ff 100644 --- a/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx +++ b/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx @@ -4,7 +4,6 @@ import { buildScaleBarModel, resolveScaleUnits, SCALE_BAR_MAX_WIDTH_PX, - type ScaleUnits, } from '../_shared/scale/scaleBarModel' import { useMMGISToolVars } from '../_shared/adapters/useMMGISToolVars' import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady' @@ -20,9 +19,6 @@ type ToolVars = { units?: unknown } -const SCALE_RETRY_MS = 250 -const SCALE_RETRY_LIMIT = 80 - // False under a display:none ancestor (a hidden plugin or collapsed panel). const isOnScreen = (el: HTMLElement | null): boolean => !!el && el.getClientRects().length > 0 @@ -41,48 +37,44 @@ export function MMGISScaleBarAdapter() { const [metersPerPixel, setMetersPerPixel] = useState(null) const hostRef = useRef(null) - // An engine that loads its basemap asynchronously answers null at first, - // and nothing moves to prompt another read, so a null answer is retried. - const retryTimer = useRef(null) - const retries = useRef(0) - const unmounted = useRef(false) const refresh = useCallback(async () => { - let next: number | null = null try { - next = await mmgisGetMetersPerPixel(ruleStart(hostRef.current)) + setMetersPerPixel(await mmgisGetMetersPerPixel(ruleStart(hostRef.current))) } catch (err) { console.warn('ScaleBar: reading the map scale failed', err) - } - if (unmounted.current) return - setMetersPerPixel(next) - if (next == null && retryTimer.current == null && retries.current++ < SCALE_RETRY_LIMIT) { - retryTimer.current = window.setTimeout(() => { - retryTimer.current = null - void refresh() - }, SCALE_RETRY_MS) + setMetersPerPixel(null) } }, []) - useEffect( - () => () => { - unmounted.current = true - if (retryTimer.current != null) window.clearTimeout(retryTimer.current) - }, - [], - ) useMMGISHandlerReady('map:getMetersPerPixel', refresh) - - useMMGISEvent('map:move', refresh) useMMGISEvent('map:moveend', refresh) - // Exports draw a bar exactly when the map shows one, in the same units. - const unitsRef = useRef(units) - unitsRef.current = units + // 'map:move' fires every frame of a gesture; read at most once per frame. + const frame = useRef(null) + const refreshNextFrame = useCallback(() => { + if (frame.current != null) return + frame.current = requestAnimationFrame(() => { + frame.current = null + void refresh() + }) + }, [refresh]) + useMMGISEvent('map:move', refreshNextFrame) + + // A map with no size yet has no scale; the layout dispatches 'resize' + // whenever the map area changes size. + useEffect(() => { + window.addEventListener('resize', refreshNextFrame) + return () => { + window.removeEventListener('resize', refreshNextFrame) + if (frame.current != null) cancelAnimationFrame(frame.current) + } + }, [refreshNextFrame]) + useEffect( () => mmgisProvide(SCALE_BAR_SETTINGS_REQUEST, (): ScaleBarSettings | null => - isOnScreen(hostRef.current) ? { units: unitsRef.current } : null, + isOnScreen(hostRef.current) ? { units } : null, ), - [], + [units], ) const model = buildScaleBarModel(metersPerPixel, units, SCALE_BAR_MAX_WIDTH_PX) diff --git a/src/essence/Tools/ScaleBar/ScaleBarTool.tsx b/src/essence/Tools/ScaleBar/ScaleBarTool.tsx index cc5b1db7fd..3a2b4f71f1 100644 --- a/src/essence/Tools/ScaleBar/ScaleBarTool.tsx +++ b/src/essence/Tools/ScaleBar/ScaleBarTool.tsx @@ -5,8 +5,7 @@ * `float-bottom-left` floating panel over the map) and calls make(targetId); * the React root mounts into that container. * - * All MMGIS coupling lives in MMGISScaleBarAdapter. The lib/ tree is - * portable. + * All MMGIS bus access lives in MMGISScaleBarAdapter. */ import React from 'react' import { createRoot, type Root } from 'react-dom/client' diff --git a/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx b/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx index 28786973d8..6fefeb7dcd 100644 --- a/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx +++ b/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx @@ -22,6 +22,21 @@ const fire = async (event: string) => { }) } +// 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 @@ -85,11 +100,35 @@ describe('MMGISScaleBarAdapter', () => { const { container, unmount } = await mount() metersPerPixel = 10 await fire(event) + await nextFrame() expect(label(container)).toBe('1 km') await unmount() }, ) + test('reads the scale at most once per frame while the map moves', async () => { + const { unmount } = await mount() + const before = scaleReads() + await fire('map:move') + await fire('map:move') + await fire('map:move') + 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() + }) + // Map scale varies across the screen, so the bar asks for it where its // rule starts: the host's bottom-left corner. test('asks for the scale at its own position on the page', async () => { @@ -97,53 +136,14 @@ describe('MMGISScaleBarAdapter', () => { showHost(container) const host = container.querySelector('.blocks-scale-bar-host') as HTMLElement host.getBoundingClientRect = () => ({ left: 24, bottom: 580 }) as DOMRect - await fire('map:move') - const request = (window as unknown as { - mmgisAPI: { request: ReturnType } - }).mmgisAPI.request - expect(request).toHaveBeenLastCalledWith('map:getMetersPerPixel', { + await fire('map:moveend') + expect(requestMock()).toHaveBeenLastCalledWith('map:getMetersPerPixel', { clientX: 24, clientY: 580, }) await unmount() }) - // A map whose basemap loads asynchronously has no scale when the plugin - // first asks; the bar appears once it does, without a zoom or pan. - test('keeps asking until the map has a scale', async () => { - vi.useFakeTimers() - try { - metersPerPixel = null - const { container, unmount } = await mount() - expect(container.querySelector('.blocks-scale-bar')).toBeNull() - metersPerPixel = 1 - await act(async () => { - await vi.advanceTimersByTimeAsync(300) - }) - expect(label(container)).toBe('100 m') - await unmount() - } finally { - vi.useRealTimers() - } - }) - - test('stops asking once unmounted', async () => { - vi.useFakeTimers() - try { - metersPerPixel = null - const { unmount } = await mount() - await unmount() - const request = (window as unknown as { - mmgisAPI: { request: ReturnType } - }).mmgisAPI.request - const calls = request.mock.calls.length - await vi.advanceTimersByTimeAsync(2000) - expect(request.mock.calls.length).toBe(calls) - } finally { - vi.useRealTimers() - } - }) - test('an unknown scale shows no bar', async () => { metersPerPixel = null const { container, unmount } = await mount() diff --git a/src/essence/Tools/ScaleBar/config.json b/src/essence/Tools/ScaleBar/config.json index 3a2d76e8e9..cc163cad14 100644 --- a/src/essence/Tools/ScaleBar/config.json +++ b/src/essence/Tools/ScaleBar/config.json @@ -2,7 +2,7 @@ "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, ...) and follows the map as it zooms and pans, switching from metres to kilometres or from feet to miles as the view widens. While the bar is shown, PNG and PDF exports from the Share button draw a matching bar, measured for the exported image, over the map's bottom-left corner. Leave the tool out of the layout to show no bar on the map or in exports. The scale is measured where the bar sits, on the mission's planet, so it holds at wide zooms. The bar hides while the map is tilted more than 10°, where no single scale fits the view, and exports of a tilted view carry no bar.", + "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, ...) and follows the map as it zooms and pans, in metres and kilometres or feet and miles. While the bar is shown, PNG and PDF exports from the Share button draw a matching bar over the map's bottom-left corner; leave the tool out of the layout for no bar on the map or in exports. The bar hides while the map is tilted more than 10°, and exports of a tilted view carry no bar.", "example": { "units": "metric" } diff --git a/src/essence/Tools/_shared/adapters/mmgisAPI.ts b/src/essence/Tools/_shared/adapters/mmgisAPI.ts index a34df473ce..073d477fc9 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 @@ -171,7 +172,7 @@ export const mmgisGetMetersPerPixel = (at?: { return mmgisRequestIfProvided('map:getMetersPerPixel', at) } -export type ScaleBarSettings = { units: 'metric' | 'imperial' } +export type ScaleBarSettings = { units: ScaleUnits } export const SCALE_BAR_SETTINGS_REQUEST = 'plugin:scalebar:getSettings' diff --git a/src/essence/Tools/_shared/adapters/shareActions.ts b/src/essence/Tools/_shared/adapters/shareActions.ts index 5c76808207..600d135240 100644 --- a/src/essence/Tools/_shared/adapters/shareActions.ts +++ b/src/essence/Tools/_shared/adapters/shareActions.ts @@ -71,7 +71,7 @@ export async function copyShareLink( return url } -export type LegendDeps = { +export type DecorationDeps = { includeLegend?: boolean getLegendModel?: () => Promise getScaleBarSettings?: () => Promise @@ -106,7 +106,7 @@ async function resolveExportScaleBar( // drawn and log the rest, rather than surfacing a broken download. async function applyDecorations( screenshot: MapScreenshotResult, - deps: LegendDeps, + deps: DecorationDeps, ): Promise { const { includeLegend = true, @@ -132,7 +132,7 @@ async function applyDecorations( } } -export type DownloadSharePngDeps = LegendDeps & { +export type DownloadSharePngDeps = DecorationDeps & { getScreenshot?: () => Promise download?: (blob: Blob, filename: string) => void getViewState?: () => Promise @@ -160,7 +160,7 @@ export async function downloadSharePng( return composed } -export type DownloadSharePdfDeps = LegendDeps & { +export type DownloadSharePdfDeps = DecorationDeps & { getScreenshot?: () => Promise blobToDataUrl?: (blob: Blob) => Promise buildPdf?: ( diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index 8af1d39686..f239285705 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -489,10 +489,7 @@ var mmgisAPI_ = { getMapScreenshot: function () { // 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. The scale stamped on the - // result is measured the same way for every engine. Being async, - // captureScreenshotWithScale turns a sync engine throw into a - // rejection. + // missing engine means no map is loaded yet. const engine = L_.Map_ && L_.Map_.engine if (engine && typeof engine.captureScreenshot === 'function') { return captureScreenshotWithScale(engine) From aeb86c065e3e85f20d809684b113cd24d1bb78c6 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Thu, 15 Oct 2026 16:00:59 -0500 Subject: [PATCH 5/6] Scale bar: read the scale under the pointer, keep view rules for exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The on-screen bar gives the scale under the pointer, or at the map's centre while the pointer is off the map or off a Web Mercator world. It shows at any zoom and tilt, since the scale at one point holds there. - map:getMetersPerPixel takes a container point { x, y }, centre by default. Engines report the pointer leaving the map as 'mouseout' (deck.gl on container mouseleave), re-emitted as map:mouseout. - Exports measure where the on-screen bar sits and carry no bar when the map is tilted past 10° or the scale varies more than 1.5x down the view, sampled on five rows so a world view never passes. - The bar re-reads on every map:move, skips renders that would look the same, and ignores reads that settle out of order. - deck.gl emits 'move' during side-by-side pane drags and standalone camera changes, and shares one viewport per camera for projections. --- .../APIs/JavaScript/Main/Event-Bus-API.md | 2 +- .../MapEngines/Adapters/DeckGLAdapter.ts | 45 ++++- src/essence/Basics/MapEngines/IMapEngine.ts | 9 +- src/essence/Basics/MapEngines/mapScale.ts | 148 ++++++++++++--- src/essence/Basics/Map_/Map_.js | 1 + .../Tools/ScaleBar/MMGISScaleBarAdapter.tsx | 80 ++++++-- .../__tests__/MMGISScaleBarAdapter.spec.tsx | 111 ++++++++--- src/essence/Tools/ScaleBar/config.json | 2 +- src/essence/Tools/ShareExport/config.json | 2 +- .../Tools/_shared/adapters/mmgisAPI.ts | 42 +++-- .../Tools/_shared/adapters/shareActions.ts | 71 ++++--- .../_shared/legend/composeExportImage.ts | 38 ++-- src/essence/mmgisAPI/mmgisAPI.js | 10 +- .../ShareExport/composeExportImage.spec.js | 34 ++++ tests/unit/ShareExport/shareActions.spec.js | 45 +++++ tests/unit/deckGLOverlayEvents.spec.js | 64 ++++++- tests/unit/deckGLSideBySide.spec.js | 26 +++ tests/unit/mapScale.spec.js | 178 +++++++++++++++--- 18 files changed, 752 insertions(+), 156 deletions(-) diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index d12606b35e..3824216ab5 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -390,7 +390,7 @@ has already delivered — the request can resolve after a later event lands. | `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` | `{ clientX, clientY }` (optional) | `number \| null` | Ground metres per CSS pixel at that page position, or near the map's bottom-left corner when omitted. Null before the map exists, while it is tilted more than 10°, or when the planet radius is not configured | +| `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 | diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 1d92d2d641..ea774cef2a 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -466,6 +466,11 @@ export class DeckGLAdapter implements IMapEngine { * `move` those copies raise is ignored rather than echoed back. */ private _sbsSyncing = 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 @@ -569,6 +574,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 @@ -615,6 +621,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 */ } }) @@ -1716,13 +1723,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] @@ -1731,7 +1759,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] @@ -2172,6 +2200,7 @@ export class DeckGLAdapter implements IMapEngine { this._viewState = this._clampToMaxBounds(viewState) pane.deck?.setProps({ viewState: this._viewState } as any) this._syncComparisonCamera(pane) + this._emitEvent('move', this._viewState) this._emitEvent('moveend', this._viewState) }, } as any) @@ -2232,9 +2261,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 @@ -2250,6 +2278,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. */ @@ -2293,6 +2322,7 @@ export class DeckGLAdapter implements IMapEngine { this._viewState = clamped this._deckSetProps({ viewState: clamped }) if (this._comparisonEnabled) this._syncComparisonCamera() + this._emitEvent('move', clamped) this._emitEvent('moveend', clamped) }, onClick: this._onPointerClick, @@ -2331,6 +2361,11 @@ export class DeckGLAdapter implements IMapEngine { isHovering: boolean }): string => (isDragging ? 'grabbing' : isHovering ? 'pointer' : 'grab') + /** 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)) diff --git a/src/essence/Basics/MapEngines/IMapEngine.ts b/src/essence/Basics/MapEngines/IMapEngine.ts index 2ef6c0b7b5..b111c35cb4 100644 --- a/src/essence/Basics/MapEngines/IMapEngine.ts +++ b/src/essence/Basics/MapEngines/IMapEngine.ts @@ -23,10 +23,13 @@ export interface MapScreenshotResult { extension: 'png' width: number height: number - /** Ground metres per *image* pixel near the image's bottom-left corner, - * at capture time. Stamped by mmgisAPI, not by the engine; null or - * absent when the map cannot measure it. */ + /** 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 } /** diff --git a/src/essence/Basics/MapEngines/mapScale.ts b/src/essence/Basics/MapEngines/mapScale.ts index 11c0326772..f891761cb7 100644 --- a/src/essence/Basics/MapEngines/mapScale.ts +++ b/src/essence/Basics/MapEngines/mapScale.ts @@ -2,48 +2,127 @@ import F_ from '../Formulae_/Formulae_' import type { IMapEngine, MapScreenshotResult } from './IMapEngine' import type { LatLng } from './types/geometry' -type ScaleEngine = Pick +type ScaleEngine = Pick< + IMapEngine, + 'containerPointToLatLng' | 'getContainer' | 'getViewState' +> /** 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 -/** Tilt, in degrees, beyond which the map has no scale. A tilted map's scale +// 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 + +// Whether the map is in Web Mercator: the default projection, or a custom one +// built from a `+proj=merc` definition. +const isMercator = (): boolean => { + const projString = (window as any).mmgisglobal?.customCRS?.projString + return typeof projString !== 'string' || /\+proj=merc\b/.test(projString) +} + +/** 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 map's bottom-left corner the scale is measured when no - * point is given: about where an export draws its scale bar. */ +/** 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 { + return null + } +} + /** - * Ground metres per CSS pixel at `at`, or near the map's bottom-left corner + * 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. + * 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, is tilted beyond MAX_SCALE_PITCH_DEG, the - * engine cannot place the points, or the planet radius is not configured. + * 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?: ClientPoint): number | null { +export function metersPerPixelAt(engine: ScaleEngine, at?: ContainerPoint): number | 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 - // `at` arrives over the bus, so anything but a finite point is ignored. - const given = !!at && Number.isFinite(at.clientX) && Number.isFinite(at.clientY) - const x = given ? at!.clientX - rect.left : DEFAULT_SCALE_INSET_PX - const y = given ? at!.clientY - rect.top : rect.height - DEFAULT_SCALE_INSET_PX - // 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 + 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 (isMercator() && !(Math.abs(ground.lat) <= MERCATOR_MAX_LAT)) return null + return scaleAt(engine, x, y) } catch { return null } @@ -51,19 +130,30 @@ export function metersPerPixelAt(engine: ScaleEngine, at?: ClientPoint): number /** * Captures the map and stamps the screenshot with the ground metres one - * *image* pixel spans near its bottom-left corner. 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. + * *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 + engine: ScaleEngine & Pick, + scaleAt?: ClientPoint ): Promise { - const cssMetersPerPixel = metersPerPixelAt(engine) - const cssWidth = cssMetersPerPixel == null ? 0 : engine.getContainer().clientWidth + const reading = readScale(engine, scaleAt) + const cssWidth = reading == null ? 0 : engine.getContainer().clientWidth const screenshot = await engine.captureScreenshot() - const metersPerPixel = - cssMetersPerPixel != null && cssWidth > 0 && screenshot.width > 0 - ? (cssMetersPerPixel * cssWidth) / screenshot.width + const ratio = + reading != null && cssWidth > 0 && screenshot.width > 0 + ? screenshot.width / cssWidth : null - return { ...screenshot, metersPerPixel } + 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 7e0362d9ce..eec2375163 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -412,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/ScaleBar/MMGISScaleBarAdapter.tsx b/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx index 8b7fb8b8ff..3b35175d19 100644 --- a/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx +++ b/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx @@ -4,6 +4,7 @@ import { buildScaleBarModel, resolveScaleUnits, SCALE_BAR_MAX_WIDTH_PX, + type ScaleUnits, } from '../_shared/scale/scaleBarModel' import { useMMGISToolVars } from '../_shared/adapters/useMMGISToolVars' import { useMMGISHandlerReady } from '../_shared/adapters/useMMGISHandlerReady' @@ -23,32 +24,88 @@ type ToolVars = { const isOnScreen = (el: HTMLElement | null): boolean => !!el && el.getClientRects().length > 0 -// The bar's rule starts at the host's bottom-left corner. Map scale varies -// across the screen, so it is measured there rather than at the centre. +// The bar's rule starts at the host's bottom-left corner, where an export +// draws and measures its own bar. const ruleStart = (el: HTMLElement | null) => { if (!isOnScreen(el)) return undefined const rect = el!.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 scales draw the same bar, so a frame that changes neither the +// label nor the visible length skips the render. +const drawsSameBar = ( + a: number | null, + b: number | null, + units: ScaleUnits, +): boolean => { + const ma = buildScaleBarModel(a, units, SCALE_BAR_MAX_WIDTH_PX) + const mb = buildScaleBarModel(b, units, SCALE_BAR_MAX_WIDTH_PX) + if (!ma || !mb) return ma === mb + return ma.label === mb.label && Math.abs(ma.widthPx - mb.widthPx) < WIDTH_TOLERANCE_PX +} + export function MMGISScaleBarAdapter() { const vars = useMMGISToolVars('scalebar') const units = resolveScaleUnits(vars.units) const [metersPerPixel, setMetersPerPixel] = 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. + 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 { - setMetersPerPixel(await mmgisGetMetersPerPixel(ruleStart(hostRef.current))) + 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) - setMetersPerPixel(null) } - }, []) + if (read !== latestRead.current) return + setMetersPerPixel((prev) => (drawsSameBar(prev, next, units) ? prev : next)) + }, [units]) useMMGISHandlerReady('map:getMetersPerPixel', refresh) + // 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) - // 'map:move' fires every frame of a gesture; read at most once per frame. + const onPointerMove = useCallback( + (event?: unknown) => { + pointer.current = containerPointOf(event) + void refresh() + }, + [refresh], + ) + const onPointerOut = useCallback(() => { + 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 @@ -57,10 +114,6 @@ export function MMGISScaleBarAdapter() { void refresh() }) }, [refresh]) - useMMGISEvent('map:move', refreshNextFrame) - - // A map with no size yet has no scale; the layout dispatches 'resize' - // whenever the map area changes size. useEffect(() => { window.addEventListener('resize', refreshNextFrame) return () => { @@ -71,9 +124,10 @@ export function MMGISScaleBarAdapter() { useEffect( () => - mmgisProvide(SCALE_BAR_SETTINGS_REQUEST, (): ScaleBarSettings | null => - isOnScreen(hostRef.current) ? { units } : null, - ), + mmgisProvide(SCALE_BAR_SETTINGS_REQUEST, (): ScaleBarSettings | null => { + const at = ruleStart(hostRef.current) + return at ? { units, at } : null + }), [units], ) diff --git a/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx b/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx index 6fefeb7dcd..fcc76a3a6f 100644 --- a/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx +++ b/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx @@ -12,13 +12,14 @@ import { mount } from '../../_shared/__tests__/reactHarness' */ let vars: Record -let metersPerPixel: number | 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) => { +const fire = async (event: string, payload?: unknown) => { await act(async () => { - for (const handler of listeners.get(event) ?? []) handler() + for (const handler of listeners.get(event) ?? []) handler(payload) }) } @@ -53,9 +54,13 @@ beforeEach(() => { listeners = new Map() provided = new Map() ;(window as { mmgisAPI?: unknown }).mmgisAPI = { - request: vi.fn(async (name: string) => { + request: vi.fn(async (name: string, arg?: unknown) => { if (name === 'tool:getVars') return vars - if (name === 'map:getMetersPerPixel') return metersPerPixel + if (name === 'map:getMetersPerPixel') { + return typeof metersPerPixel === 'function' + ? metersPerPixel(arg as { x: number; y: number } | undefined) + : metersPerPixel + } return null }), hasHandler: () => true, @@ -100,18 +105,17 @@ describe('MMGISScaleBarAdapter', () => { const { container, unmount } = await mount() metersPerPixel = 10 await fire(event) - await nextFrame() expect(label(container)).toBe('1 km') await unmount() }, ) - test('reads the scale at most once per frame while the map moves', async () => { + test('reads the scale once per resize burst', async () => { const { unmount } = await mount() const before = scaleReads() - await fire('map:move') - await fire('map:move') - await fire('map:move') + window.dispatchEvent(new Event('resize')) + window.dispatchEvent(new Event('resize')) + window.dispatchEvent(new Event('resize')) await nextFrame() expect(scaleReads()).toBe(before + 1) await unmount() @@ -129,18 +133,77 @@ describe('MMGISScaleBarAdapter', () => { await unmount() }) - // Map scale varies across the screen, so the bar asks for it where its - // rule starts: the host's bottom-left corner. - test('asks for the scale at its own position on the page', async () => { - const { container, unmount } = await mount() - showHost(container) - const host = container.querySelector('.blocks-scale-bar-host') as HTMLElement - host.getBoundingClientRect = () => ({ left: 24, bottom: 580 }) as DOMRect + 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', { - clientX: 24, - clientY: 580, + 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() + }) + + // 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() }) @@ -162,7 +225,13 @@ describe('MMGISScaleBarAdapter', () => { vars = { units: 'imperial' } const { container, unmount } = await mount() showHost(container) - expect(settings()).toEqual({ units: 'imperial' }) + const host = container.querySelector('.blocks-scale-bar-host') as HTMLElement + host.getBoundingClientRect = () => ({ left: 24, bottom: 580 }) as DOMRect + // The position lets the export draw and measure its bar there too. + expect(settings()).toEqual({ + units: 'imperial', + at: { clientX: 24, clientY: 580 }, + }) await unmount() }) diff --git a/src/essence/Tools/ScaleBar/config.json b/src/essence/Tools/ScaleBar/config.json index cc163cad14..8cbc9079db 100644 --- a/src/essence/Tools/ScaleBar/config.json +++ b/src/essence/Tools/ScaleBar/config.json @@ -2,7 +2,7 @@ "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, ...) and follows the map as it zooms and pans, in metres and kilometres or feet and miles. While the bar is shown, PNG and PDF exports from the Share button draw a matching bar over the map's bottom-left corner; leave the tool out of the layout for no bar on the map or in exports. The bar hides while the map is tilted more than 10°, and exports of a tilted view carry no bar.", + "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" } diff --git a/src/essence/Tools/ShareExport/config.json b/src/essence/Tools/ShareExport/config.json index b307c7121f..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. While the ScaleBar tool shows a scale bar on the map, both exports draw a matching bar over the map's bottom-left corner. 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 073d477fc9..c0d3907e89 100644 --- a/src/essence/Tools/_shared/adapters/mmgisAPI.ts +++ b/src/essence/Tools/_shared/adapters/mmgisAPI.ts @@ -27,11 +27,16 @@ export type MapScreenshotResult = { extension: 'png' width: number height: number - /** Ground metres per image pixel near the image's bottom-left corner, at - * capture time. */ + /** 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 @@ -153,26 +158,33 @@ 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 page position (e.g. a DOM rect's corner), - * or near the map's bottom-left corner when `at` is omitted. Null before the - * map exists or where it cannot measure. + * 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?: { - clientX: number - clientY: number -}): Promise => { +export const mmgisGetMetersPerPixel = ( + at?: { x: number; y: number }, +): Promise => { return mmgisRequestIfProvided('map:getMetersPerPixel', at) } -export type ScaleBarSettings = { units: ScaleUnits } +export type ScaleBarSettings = { + units: ScaleUnits + /** Where the on-screen bar's bottom-left corner sits on the page, so an + * export can draw and measure its bar at the same spot. */ + at?: ClientPoint +} export const SCALE_BAR_SETTINGS_REQUEST = 'plugin:scalebar:getSettings' diff --git a/src/essence/Tools/_shared/adapters/shareActions.ts b/src/essence/Tools/_shared/adapters/shareActions.ts index 600d135240..d68e750f98 100644 --- a/src/essence/Tools/_shared/adapters/shareActions.ts +++ b/src/essence/Tools/_shared/adapters/shareActions.ts @@ -4,6 +4,7 @@ import { mmgisGetViewState, mmgisCopyText, mmgisGetScaleBarSettings, + type ClientPoint, type MapScreenshotResult, type ScaleBarSettings, type ViewState, @@ -82,39 +83,53 @@ export type DecorationDeps = { ) => Promise } -// Uses the capture's own scale, which is in image pixels and of that exact view. -async function resolveExportScaleBar( - screenshot: MapScreenshotResult, +type GetScreenshot = (options?: { + scaleAt?: ClientPoint +}) => Promise + +async function readScaleBarSettings( getScaleBarSettings: () => Promise, -): Promise { +): Promise { try { - const settings = await getScaleBarSettings() - if (!settings) return null - const { metersPerPixel } = screenshot - if (typeof metersPerPixel !== 'number' || !(metersPerPixel > 0)) { - console.warn('Share export: scale bar omitted, the map gave no scale') - return null - } - return { metersPerPixel, units: settings.units } + 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. +function resolveExportScaleBar( + screenshot: MapScreenshotResult, + settings: ScaleBarSettings | null, +): ExportScaleBar | null { + if (!settings) return null + const { metersPerPixel, scaleAnchor } = screenshot + if (typeof metersPerPixel !== 'number' || !(metersPerPixel > 0)) { + console.warn('Share export: scale bar omitted, the map gave no scale') + 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, + settings: ScaleBarSettings | null, deps: DecorationDeps, ): Promise { const { includeLegend = true, getLegendModel = getExportLegendModel, - getScaleBarSettings = mmgisGetScaleBarSettings, compose = composeExportImage, } = deps - const scaleBar = await resolveExportScaleBar(screenshot, getScaleBarSettings) + const scaleBar = resolveExportScaleBar(screenshot, settings) let model: ExportLegendModel | null = null if (includeLegend) { try { @@ -132,8 +147,24 @@ async function applyDecorations( } } +// 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?: () => Promise + getScreenshot?: GetScreenshot download?: (blob: Blob, filename: string) => void getViewState?: () => Promise filename?: string @@ -150,9 +181,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 applyDecorations(screenshot, deps) + const composed = await captureDecorated(getScreenshot, deps) const filename = deps.filename ?? buildExportFilename(composed.extension, await getViewState()) @@ -161,7 +190,7 @@ export async function downloadSharePng( } export type DownloadSharePdfDeps = DecorationDeps & { - getScreenshot?: () => Promise + getScreenshot?: GetScreenshot blobToDataUrl?: (blob: Blob) => Promise buildPdf?: ( dataUrl: string, @@ -186,9 +215,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 applyDecorations(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 c627227e98..8cd0ca1d53 100644 --- a/src/essence/Tools/_shared/legend/composeExportImage.ts +++ b/src/essence/Tools/_shared/legend/composeExportImage.ts @@ -7,11 +7,18 @@ import { SCALE_BAR_MAX_WIDTH_PX, type ScaleUnits, } from '../scale/scaleBarModel' -import { drawScaleBar, SCALE_BAR_MARGIN } from '../scale/renderScaleBar' +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 = { @@ -22,14 +29,18 @@ export type ComposeDeps = { 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 /** - * Decorates a captured map image: a scale bar over the map's 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 + * 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( @@ -81,12 +92,17 @@ export async function composeExportImage( canvas.height = screenshot.height + bandHeight ctx.drawImage(bitmap, 0, 0) if (barModel) { - drawScaleBar(ctx, barModel, { - left: SCALE_BAR_MARGIN * scale, - bottom: screenshot.height - SCALE_BAR_MARGIN * scale, - scale, - theme, - }) + 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( diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index f239285705..a64231334e 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -486,13 +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') { - return captureScreenshotWithScale(engine) + return captureScreenshotWithScale(engine, options?.scaleAt) } return Promise.reject( new Error('getMapScreenshot: no active map engine to capture') @@ -817,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, metersPerPixel: number|null}>} - resolves to a PNG Blob plus image metadata. `metersPerPixel` is the ground distance one *image* pixel spans near the image's bottom-left corner, null when the map cannot measure it. + * @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, @@ -1073,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 0fd5e7851f..94862aff0f 100644 --- a/tests/unit/ShareExport/composeExportImage.spec.js +++ b/tests/unit/ShareExport/composeExportImage.spec.js @@ -180,5 +180,39 @@ describe('composeExportImage', () => { // 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 40564d308d..80ca6adf89 100644 --- a/tests/unit/ShareExport/shareActions.spec.js +++ b/tests/unit/ShareExport/shareActions.spec.js @@ -420,6 +420,51 @@ test.describe('scale bar in the downloads', () => { ]) }) + // 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({ diff --git a/tests/unit/deckGLOverlayEvents.spec.js b/tests/unit/deckGLOverlayEvents.spec.js index 003517624d..d3f716755b 100644 --- a/tests/unit/deckGLOverlayEvents.spec.js +++ b/tests/unit/deckGLOverlayEvents.spec.js @@ -1,7 +1,7 @@ import { test, expect, vi, beforeEach } from 'vitest' -// The deck.gl engine's overlay mode reports the 'move' stream while the -// camera moves. The GL constructors are mocked (jsdom has no WebGL); the +// 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 = [] @@ -126,3 +126,63 @@ test.describe('DeckGLAdapter overlay move events', () => { expect(moves[0]).toMatchObject({ latitude: 10, longitude: 20, zoom: 7 }) }) }) + +test.describe('DeckGLAdapter standalone move events', () => { + test("each camera change reaches 'move' as well as 'moveend'", () => { + const adapter = new DeckGLAdapter() + adapter.init({ containerId: 'map', zoom: 0, center: { lat: 0, lng: 0 } }) + const moves = [] + adapter.on('move', (e) => moves.push(e)) + adapter + .getNativeMap() + .props.onViewStateChange({ + viewState: { latitude: 3, longitude: 4, zoom: 2, bearing: 0, pitch: 0 }, + }) + expect(moves).toHaveLength(1) + expect(moves[0]).toMatchObject({ latitude: 3, longitude: 4, zoom: 2 }) + }) +}) + +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 index 0a317264fa..50822ca18f 100644 --- a/tests/unit/mapScale.spec.js +++ b/tests/unit/mapScale.spec.js @@ -6,6 +6,7 @@ import { 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 @@ -19,7 +20,12 @@ 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, toLatLng } = {}) { +function makeEngine({ + rect = RECT, + clientWidth = rect.width, + pitch = 0, + toLatLng, +} = {}) { const container = { clientWidth, getBoundingClientRect: () => rect, @@ -42,70 +48,110 @@ 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 near the bottom-left corner when given no point', () => { + test('measures at the map centre when given no point', () => { const engine = makeEngine() metersPerPixelAt(engine) expect(engine.containerPointToLatLng).toHaveBeenNthCalledWith(1, { - x: DEFAULT_SCALE_INSET_PX, - y: RECT.height - DEFAULT_SCALE_INSET_PX, + x: RECT.width / 2, + y: RECT.height / 2, }) }) - test('takes a page position relative to the map container', () => { + test('measures at a container point', () => { const engine = makeEngine() - metersPerPixelAt(engine, { clientX: 130, clientY: 50 }) + metersPerPixelAt(engine, { x: 30, y: 0 }) expect(engine.containerPointToLatLng).toHaveBeenNthCalledWith(1, { x: 30, y: 0 }) }) test('gives the ground metres one pixel spans there', () => { - const engine = makeEngine() // Row 0 is the equator, where the plate carrée is true to scale. - expect(metersPerPixelAt(engine, { clientX: 100, clientY: 50 })).toBeCloseTo( + 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, so a - // bar measured at the centre would be wrong at the bottom of the map. + // 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 === 0 ? 0 : 60 }), + toLatLng: ({ x, y }) => ({ lng: x * DEG_PER_PX, lat: y < 300 ? 0 : 60 }), }) - const equator = metersPerPixelAt(engine, { clientX: 100, clientY: 50 }) - const sixty = metersPerPixelAt(engine, { clientX: 100, clientY: 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 at = { clientX: 100, clientY: 50 } - const earth = metersPerPixelAt(engine, at) + const earth = metersPerPixelAt(engine) F_.radiusOfPlanetMajor = 3396190 - expect(metersPerPixelAt(engine, at) / earth).toBeCloseTo(3396190 / EARTH_RADIUS, 9) + expect(metersPerPixelAt(engine) / earth).toBeCloseTo(3396190 / EARTH_RADIUS, 9) }) - test('ignores a position that is not a finite point', () => { + test('ignores a point that is not finite', () => { const engine = makeEngine() - metersPerPixelAt(engine, { clientX: 'left' }) + metersPerPixelAt(engine, { x: 'left' }) expect(engine.containerPointToLatLng).toHaveBeenNthCalledWith(1, { - x: DEFAULT_SCALE_INSET_PX, - y: RECT.height - DEFAULT_SCALE_INSET_PX, + 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', () => { + window.mmgisglobal = { customCRS: { projString: '+proj=stere +lat_0=90' } } + try { + const engine = makeEngine({ + toLatLng: ({ x }) => ({ lng: x * DEG_PER_PX, lat: 89 }), + }) + expect(metersPerPixelAt(engine)).not.toBeNull() + } finally { + delete window.mmgisglobal + } + }) + + 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() }) - // A tilted map's scale changes from bottom to top, so no bar fits it. - test('is null for a map tilted past the limit', () => { - expect(metersPerPixelAt(makeEngine({ pitch: MAX_SCALE_PITCH_DEG }))).not.toBeNull() - expect(metersPerPixelAt(makeEngine({ pitch: MAX_SCALE_PITCH_DEG + 1 }))).toBeNull() - }) - test('is null where the engine cannot place the points', () => { const engine = makeEngine({ toLatLng: () => ({ lat: NaN, lng: NaN }) }) expect(metersPerPixelAt(engine)).toBeNull() @@ -127,6 +173,7 @@ test.describe('metersPerPixelAt', () => { }) 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', @@ -139,7 +186,7 @@ test.describe('captureScreenshotWithScale', () => { // 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) + const cssScale = metersPerPixelAt(engine, corner) engine.captureScreenshot = () => Promise.resolve(shot(1600)) const result = await captureScreenshotWithScale(engine) expect(result.width).toBe(1600) @@ -148,7 +195,7 @@ test.describe('captureScreenshotWithScale', () => { test('reads the scale of the view the capture began with', async () => { const engine = makeEngine() - const before = metersPerPixelAt(engine) + const before = metersPerPixelAt(engine, corner) engine.captureScreenshot = () => { engine.containerPointToLatLng.mockImplementation(({ x, y }) => ({ lng: x * DEG_PER_PX * 4, @@ -159,12 +206,87 @@ test.describe('captureScreenshotWithScale', () => { 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 = () => { From 52e3f6df375079287a431c1fc4c0869f0128eb61 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Thu, 15 Oct 2026 16:32:40 -0500 Subject: [PATCH 6/6] Scale bar: settle deck.gl moves once, centre on Leaflet, plugin-neutral export - deck.gl without a basemap reports jumps made in code, and raises 'moveend' once the camera settles instead of on every frame - On Leaflet the bar measures at the map centre, since Leaflet can miss the pointer leaving the map across a hoverable feature - Engines report Web Mercator through IMapEngine.isWebMercator() rather than a global another engine may have left behind - Exports ask 'export:getScaleBar', which answers only while a bar is drawn and anchors at the bar's backing box - The adapter keeps the built model in state; exports without a scale by design no longer log - Document the map pointer events, map:getScreenshot and export:getScaleBar --- .../APIs/JavaScript/Main/Event-Bus-API.md | 1662 +++++++++-------- .../MapEngines/Adapters/DeckGLAdapter.ts | 80 +- .../MapEngines/Adapters/LeafletAdapter.ts | 10 + src/essence/Basics/MapEngines/IMapEngine.ts | 7 + src/essence/Basics/MapEngines/mapScale.ts | 14 +- .../Tools/ScaleBar/MMGISScaleBarAdapter.tsx | 64 +- .../__tests__/MMGISScaleBarAdapter.spec.tsx | 52 +- .../Tools/_shared/adapters/mmgisAPI.ts | 19 +- .../Tools/_shared/adapters/shareActions.ts | 7 +- tests/unit/ShareExport/shareActions.spec.js | 11 +- tests/unit/deckGLOverlayEvents.spec.js | 68 +- tests/unit/mapScale.spec.js | 16 +- 12 files changed, 1100 insertions(+), 910 deletions(-) diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index 3824216ab5..f847de1772 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -1,819 +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: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: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 ea774cef2a..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,11 @@ 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 @@ -1770,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) } @@ -2195,14 +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('move', this._viewState) - this._emitEvent('moveend', this._viewState) + this._onDeckCameraChange(interactionState) }, + onInteractionStateChange: this._onDeckInteractionChange, } as any) } @@ -2317,14 +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('move', clamped) - this._emitEvent('moveend', clamped) + this._onDeckCameraChange(interactionState) }, + onInteractionStateChange: this._onDeckInteractionChange, onClick: this._onPointerClick, onHover: this._onPointerHover, getCursor: this._cursorFor, @@ -2361,6 +2401,27 @@ 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') @@ -2485,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 b111c35cb4..bc980b200c 100644 --- a/src/essence/Basics/MapEngines/IMapEngine.ts +++ b/src/essence/Basics/MapEngines/IMapEngine.ts @@ -371,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 index f891761cb7..b9e1d8b3da 100644 --- a/src/essence/Basics/MapEngines/mapScale.ts +++ b/src/essence/Basics/MapEngines/mapScale.ts @@ -4,7 +4,7 @@ import type { LatLng } from './types/geometry' type ScaleEngine = Pick< IMapEngine, - 'containerPointToLatLng' | 'getContainer' | 'getViewState' + 'containerPointToLatLng' | 'getContainer' | 'getViewState' | 'isWebMercator' > /** A position on the page, in CSS pixels, as a DOM rectangle or event gives it. */ @@ -23,13 +23,6 @@ const PROBE_PX = 10 // beyond it marks a point with no ground under it. const MERCATOR_MAX_LAT = 85.0511287798 -// Whether the map is in Web Mercator: the default projection, or a custom one -// built from a `+proj=merc` definition. -const isMercator = (): boolean => { - const projString = (window as any).mmgisglobal?.customCRS?.projString - return typeof projString !== 'string' || /\+proj=merc\b/.test(projString) -} - /** 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. */ @@ -98,7 +91,8 @@ function readScale(engine: ScaleEngine, at?: ClientPoint): ScaleReading | null { ].filter((s): s is number => s != null) if (Math.max(...scales) / Math.min(...scales) > MAX_SCALE_SPREAD) return null return { metersPerPixel, x, y } - } catch { + } catch (err) { + console.warn('Map screenshot: measuring the scale failed', err) return null } } @@ -121,7 +115,7 @@ export function metersPerPixelAt(engine: ScaleEngine, at?: ContainerPoint): numb 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 (isMercator() && !(Math.abs(ground.lat) <= MERCATOR_MAX_LAT)) return null + if (engine.isWebMercator() && !(Math.abs(ground.lat) <= MERCATOR_MAX_LAT)) return null return scaleAt(engine, x, y) } catch { return null diff --git a/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx b/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx index 3b35175d19..06032575f3 100644 --- a/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx +++ b/src/essence/Tools/ScaleBar/MMGISScaleBarAdapter.tsx @@ -4,15 +4,16 @@ import { buildScaleBarModel, resolveScaleUnits, SCALE_BAR_MAX_WIDTH_PX, - type ScaleUnits, + 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, - SCALE_BAR_SETTINGS_REQUEST, + EXPORT_SCALE_BAR_REQUEST, type ScaleBarSettings, } from '../_shared/adapters/mmgisAPI' @@ -20,15 +21,13 @@ type ToolVars = { units?: unknown } -// False under a display:none ancestor (a hidden plugin or collapsed panel). -const isOnScreen = (el: HTMLElement | null): boolean => - !!el && el.getClientRects().length > 0 - -// The bar's rule starts at the host's bottom-left corner, where an export -// draws and measures its own bar. -const ruleStart = (el: HTMLElement | null) => { - if (!isOnScreen(el)) return undefined - const rect = el!.getBoundingClientRect() +// 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 } } @@ -44,27 +43,24 @@ const containerPointOf = (event: unknown): { x: number; y: number } | null => { // Below this change in length, in CSS pixels, a redraw would look the same. const WIDTH_TOLERANCE_PX = 0.25 -// Whether two scales draw the same bar, so a frame that changes neither the +// 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: number | null, - b: number | null, - units: ScaleUnits, -): boolean => { - const ma = buildScaleBarModel(a, units, SCALE_BAR_MAX_WIDTH_PX) - const mb = buildScaleBarModel(b, units, SCALE_BAR_MAX_WIDTH_PX) - if (!ma || !mb) return ma === mb - return ma.label === mb.label && Math.abs(ma.widthPx - mb.widthPx) < WIDTH_TOLERANCE_PX +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 [metersPerPixel, setMetersPerPixel] = useState(null) + 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. + // 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) @@ -81,9 +77,20 @@ export function MMGISScaleBarAdapter() { console.warn('ScaleBar: reading the map scale failed', err) } if (read !== latestRead.current) return - setMetersPerPixel((prev) => (drawsSameBar(prev, next, units) ? prev : next)) + const nextModel = buildScaleBarModel(next, units, SCALE_BAR_MAX_WIDTH_PX) + setModel((prev) => (drawsSameBar(prev, nextModel) ? prev : nextModel)) }, [units]) - useMMGISHandlerReady('map:getMetersPerPixel', refresh) + // 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) @@ -91,12 +98,14 @@ export function MMGISScaleBarAdapter() { 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]) @@ -124,14 +133,13 @@ export function MMGISScaleBarAdapter() { useEffect( () => - mmgisProvide(SCALE_BAR_SETTINGS_REQUEST, (): ScaleBarSettings | null => { - const at = ruleStart(hostRef.current) + mmgisProvide(EXPORT_SCALE_BAR_REQUEST, (): ScaleBarSettings | null => { + const at = backingCorner(hostRef.current) return at ? { units, at } : null }), [units], ) - const model = buildScaleBarModel(metersPerPixel, units, SCALE_BAR_MAX_WIDTH_PX) return (
diff --git a/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx b/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx index fcc76a3a6f..0367c91a8c 100644 --- a/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx +++ b/src/essence/Tools/ScaleBar/__tests__/MMGISScaleBarAdapter.spec.tsx @@ -12,6 +12,7 @@ import { mount } from '../../_shared/__tests__/reactHarness' */ 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>> @@ -42,20 +43,23 @@ 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 host one. -const showHost = (container: HTMLElement) => { - const host = container.querySelector('.blocks-scale-bar-host') as HTMLElement - host.getClientRects = () => [{}] as unknown as DOMRectList +// 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) @@ -181,6 +185,21 @@ describe('MMGISScaleBarAdapter', () => { 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) @@ -216,7 +235,7 @@ describe('MMGISScaleBarAdapter', () => { describe('export settings', () => { const settings = () => { - const handler = provided.get('plugin:scalebar:getSettings') + const handler = provided.get('export:getScaleBar') if (!handler) throw new Error('getSettings was not provided') return handler() } @@ -224,10 +243,10 @@ describe('MMGISScaleBarAdapter', () => { test('answer with the units while the bar is on screen', async () => { vars = { units: 'imperial' } const { container, unmount } = await mount() - showHost(container) - const host = container.querySelector('.blocks-scale-bar-host') as HTMLElement - host.getBoundingClientRect = () => ({ left: 24, bottom: 580 }) as DOMRect - // The position lets the export draw and measure its bar there too. + 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 }, @@ -243,11 +262,22 @@ describe('MMGISScaleBarAdapter', () => { 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('plugin:scalebar:getSettings')).toBe(true) + expect(provided.has('export:getScaleBar')).toBe(true) await unmount() - expect(provided.has('plugin:scalebar:getSettings')).toBe(false) + expect(provided.has('export:getScaleBar')).toBe(false) }) }) }) diff --git a/src/essence/Tools/_shared/adapters/mmgisAPI.ts b/src/essence/Tools/_shared/adapters/mmgisAPI.ts index c0d3907e89..4345f21391 100644 --- a/src/essence/Tools/_shared/adapters/mmgisAPI.ts +++ b/src/essence/Tools/_shared/adapters/mmgisAPI.ts @@ -179,19 +179,26 @@ export const mmgisGetMetersPerPixel = ( 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 bottom-left corner sits on the page, so an - * export can draw and measure its bar at the same spot. */ + /** 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 } -export const SCALE_BAR_SETTINGS_REQUEST = 'plugin:scalebar:getSettings' +/** 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 ScaleBar plugin's units while its bar is on screen, otherwise null, - * so exports carry a bar exactly when the map shows one. */ +/** 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(SCALE_BAR_SETTINGS_REQUEST) + 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 d68e750f98..39c5e3c822 100644 --- a/src/essence/Tools/_shared/adapters/shareActions.ts +++ b/src/essence/Tools/_shared/adapters/shareActions.ts @@ -100,16 +100,15 @@ async function readScaleBarSettings( // 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)) { - console.warn('Share export: scale bar omitted, the map gave no scale') - return null - } + if (typeof metersPerPixel !== 'number' || !(metersPerPixel > 0)) return null return { metersPerPixel, units: settings.units, diff --git a/tests/unit/ShareExport/shareActions.spec.js b/tests/unit/ShareExport/shareActions.spec.js index 80ca6adf89..90bf240282 100644 --- a/tests/unit/ShareExport/shareActions.spec.js +++ b/tests/unit/ShareExport/shareActions.spec.js @@ -479,9 +479,9 @@ test.describe('scale bar in the downloads', () => { expect(result).toBe(screenshot) }) - test('the bus wrapper asks the ScaleBar plugin and reads null without it', async () => { + test('the bus wrapper asks whichever plugin draws the scale bar', async () => { const { api, requests } = makeBusApi({ - 'plugin:scalebar:getSettings': { units: 'metric' }, + 'export:getScaleBar': { units: 'metric' }, 'map:getScreenshot': screenshot, }) window.mmgisAPI = api @@ -494,7 +494,7 @@ test.describe('scale bar in the downloads', () => { compose: recordCompose(calls), }) expect(requests.map((r) => r.name)).toContain( - 'plugin:scalebar:getSettings', + 'export:getScaleBar', ) expect(calls[0].deps.scaleBar.units).toBe('metric') } finally { @@ -502,7 +502,8 @@ test.describe('scale bar in the downloads', () => { } }) - test('a capture without a scale downloads without a bar', async () => { + // 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 @@ -516,7 +517,7 @@ test.describe('scale bar in the downloads', () => { }, }) expect(result).toBe(unscaled) - expect(warn).toHaveBeenCalled() + expect(warn).not.toHaveBeenCalled() } finally { warn.mockRestore() } diff --git a/tests/unit/deckGLOverlayEvents.spec.js b/tests/unit/deckGLOverlayEvents.spec.js index d3f716755b..015f7b198a 100644 --- a/tests/unit/deckGLOverlayEvents.spec.js +++ b/tests/unit/deckGLOverlayEvents.spec.js @@ -128,18 +128,66 @@ test.describe('DeckGLAdapter overlay move events', () => { }) test.describe('DeckGLAdapter standalone move events', () => { - test("each camera change reaches 'move' as well as 'moveend'", () => { + const standalone = () => { const adapter = new DeckGLAdapter() adapter.init({ containerId: 'map', zoom: 0, center: { lat: 0, lng: 0 } }) - const moves = [] - adapter.on('move', (e) => moves.push(e)) - adapter - .getNativeMap() - .props.onViewStateChange({ - viewState: { latitude: 3, longitude: 4, zoom: 2, bearing: 0, pitch: 0 }, - }) - expect(moves).toHaveLength(1) - expect(moves[0]).toMatchObject({ latitude: 3, longitude: 4, zoom: 2 }) + 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([]) }) }) diff --git a/tests/unit/mapScale.spec.js b/tests/unit/mapScale.spec.js index 50822ca18f..57adab9c46 100644 --- a/tests/unit/mapScale.spec.js +++ b/tests/unit/mapScale.spec.js @@ -24,6 +24,7 @@ function makeEngine({ rect = RECT, clientWidth = rect.width, pitch = 0, + webMercator = true, toLatLng, } = {}) { const container = { @@ -33,6 +34,7 @@ function makeEngine({ return { getContainer: () => container, getViewState: () => ({ pitch }), + isWebMercator: () => webMercator, containerPointToLatLng: vi.fn( toLatLng ?? (({ x, y }) => ({ lng: x * DEG_PER_PX, lat: -y * DEG_PER_PX })) ), @@ -131,15 +133,11 @@ test.describe('metersPerPixelAt', () => { // A polar projection has ground all the way to the pole. test('measures near the pole on a projection other than Mercator', () => { - window.mmgisglobal = { customCRS: { projString: '+proj=stere +lat_0=90' } } - try { - const engine = makeEngine({ - toLatLng: ({ x }) => ({ lng: x * DEG_PER_PX, lat: 89 }), - }) - expect(metersPerPixelAt(engine)).not.toBeNull() - } finally { - delete window.mmgisglobal - } + 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', () => {