From 727f00781e3d651f388100a768c91d4144725e6b Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Thu, 25 Jun 2026 11:30:18 -0500 Subject: [PATCH 01/23] [143] Expose share-link and map-screenshot via plugin API Extract the inline html2canvas screenshot logic from the BottomBar camera button into a reusable ScreenshotUtils.getMapScreenshot() that resolves to a PNG data URL, and expose it publicly as mmgisAPI.getMapScreenshot(). The onclone SVG/z-index fixups are preserved verbatim. The camera button shows its spinner, downloads the capture via an object URL (blob) to avoid base64 bloat on large captures, and hides the spinner on failure. Make mmgisAPI.writeCoordinateURL() the canonical share-link method by removing the frontend link-shortener dependency: drop the shortenURL branch/param from QueryURL.writeCoordinateURL so it returns the full URL synchronously with no backend call, update the BottomBar copy-link button accordingly, and remove the now-dead shortener_shorten frontend registry entries. The backend shortener endpoint/route/table and the shortener_expand path (for loading existing short links) are left untouched. Also fix a latent restore bug carried into the shared util (#mapToolBar bottom was reset to a string literal instead of its saved value) and add behavioral unit tests that drive getMapScreenshot() against injected jQuery/html2canvas fakes. --- src/essence/Ancillary/QueryURL.js | 32 +--- .../Basics/UserInterface_/BottomBar.js | 151 ++++++------------ .../Basics/UserInterface_/ScreenshotUtils.js | 113 +++++++++++++ src/essence/mmgisAPI/mmgisAPI.js | 14 +- src/pre/calls.js | 4 - src/pre/staticHandlers.js | 1 - tests/unit/shareScreenshotApi.spec.js | 147 +++++++++++++++++ 7 files changed, 322 insertions(+), 140 deletions(-) create mode 100644 src/essence/Basics/UserInterface_/ScreenshotUtils.js create mode 100644 tests/unit/shareScreenshotApi.spec.js diff --git a/src/essence/Ancillary/QueryURL.js b/src/essence/Ancillary/QueryURL.js index 39aea2df0..1ba86aad0 100644 --- a/src/essence/Ancillary/QueryURL.js +++ b/src/essence/Ancillary/QueryURL.js @@ -277,8 +277,10 @@ var QueryURL = { tools "tools=camp$1.3.4," */ + // Builds and returns the full, self-contained view URL synchronously. This + // is the canonical share-link method (exposed publicly via + // mmgisAPI.writeCoordinateURL). It makes no backend call. writeCoordinateURL: function ( - shortenURL = true, mapLon, mapLat, mapZoom, @@ -288,12 +290,6 @@ var QueryURL = { ) { L_.Viewer_.getLocation() - var callback - if (typeof mapLon === 'function') { - callback = mapLon - mapLon = undefined - } - //Defaults if (mapLon == undefined) mapLon = L_.Map_.map.getCenter().lng if (mapLat == undefined) mapLat = L_.Map_.map.getCenter().lat @@ -427,28 +423,6 @@ var QueryURL = { var url = encodeURI(urlAppendage) - if (shortenURL) { - calls.api( - 'shortener_shorten', - { - url: url, - }, - function (s) { - //Set and update the short url - L_.url = - window.location.href.split('?')[0] + '?s=' + s.body.url - window.history.replaceState('', '', L_.url) - if (typeof callback === 'function') callback() - }, - function (e) { - //Set and update the full url - L_.url = window.location.href.split('?')[0] + url - window.history.replaceState('', '', L_.url) - if (typeof callback === 'function') callback() - } - ) - } - return window.location.href.split('?')[0] + url }, writeSearchURL: function (searchStrs, searchFile) { diff --git a/src/essence/Basics/UserInterface_/BottomBar.js b/src/essence/Basics/UserInterface_/BottomBar.js index f4bc7d2a3..bda7ac5c6 100644 --- a/src/essence/Basics/UserInterface_/BottomBar.js +++ b/src/essence/Basics/UserInterface_/BottomBar.js @@ -6,7 +6,7 @@ import L_ from '../Layers_/Layers_' import QueryURL from '../../Ancillary/QueryURL' import Modal from '../../Ancillary/Modal' -import HTML2Canvas from 'html2canvas' +import { getMapScreenshot } from './ScreenshotUtils' import tippy from 'tippy.js' import './BottomBar.css' @@ -32,18 +32,18 @@ let BottomBar = { }) .on('click', function () { const linkButton = $(this) - QueryURL.writeCoordinateURL(true, function () { - F_.copyToClipboard(L_.url) + L_.url = QueryURL.writeCoordinateURL() + window.history.replaceState('', '', L_.url) + F_.copyToClipboard(L_.url) - linkButton.removeClass('mdi-open-in-new') - linkButton.addClass('mdi-check-bold') - linkButton.css('color', 'var(--color-green)') - setTimeout(() => { - linkButton.removeClass('mdi-check-bold') - linkButton.css('color', '') - linkButton.addClass('mdi-open-in-new') - }, 3000) - }) + linkButton.removeClass('mdi-open-in-new') + linkButton.addClass('mdi-check-bold') + linkButton.css('color', 'var(--color-green)') + setTimeout(() => { + linkButton.removeClass('mdi-check-bold') + linkButton.css('color', '') + linkButton.addClass('mdi-open-in-new') + }, 3000) }) bottomBar.append(topBarLink) @@ -68,104 +68,45 @@ let BottomBar = { 'opacity': '0.8' }) .on('click', function () { - //We need to manually order leaflet z-indices for this to work - let zIndices = [] - $('#mapScreen #map .leaflet-tile-pane') - .children() - .each(function (i, elm) { - zIndices.push($(elm).css('z-index')) - $(elm).css('z-index', i + 1) - }) - $('.leaflet-control-scalefactor').css('display', 'none') - $('#mmgis-map-compass').css('display', 'none') - $('.leaflet-control-zoom').css('display', 'none') + // Screenshot capture (DOM prep, html2canvas, and UI restore) + // lives in ScreenshotUtils so it can be reused by the public + // mmgisAPI.getMapScreenshot(). Here we just show the loading + // spinner, then download the resulting PNG data URL. $('#topBarScreenshotLoading').css('display', 'block') - $('#scaleBar').css('margin-top', '0px') - const savedMapToolBarBottom = - $('#mapToolBar').css('bottom') || '0px' - $('#mapToolBar').css('bottom', '0px') - $(`#toggleTimeUI.active`).trigger('click') - - const documentElm = document.getElementById('mapScreen') - HTML2Canvas(documentElm, { - allowTaint: true, - useCORS: true, - logging: false, - scrollX: -window.scrollX, - scrollY: -window.scrollY, - windowWidth: documentElm.offsetWidth, - windowHeight: documentElm.offsetHeight, - onclone: function (e) { - // Fix svg layer shift - const originalSVG = document.body.querySelectorAll( - 'svg.leaflet-zoom-animated' - ) - const copySVG = e.body.querySelectorAll( - 'svg.leaflet-zoom-animated' - ) - copySVG.forEach((copyEle, i) => { - const attribute = originalSVG - .item(i) - .getAttribute('style') - const parentElement = copyEle.parentElement - parentElement.removeChild(copyEle) - const temp = document.createElement('div') - temp.appendChild(copyEle) - parentElement.appendChild(temp) - temp.setAttribute('style', attribute) - copyEle.removeAttribute('style') - }) - // Fix tile layer z-indices - const originalZ = document.body.querySelectorAll( - '.leaflet-tile-pane > div.leaflet-layer' - ) - const copyZ = e.body.querySelectorAll( - '.leaflet-tile-pane > div.leaflet-layer' - ) - copyZ.forEach((copyEle, i) => { - const attribute = originalZ - .item(i) - .getAttribute('style') - copyEle.setAttribute('style', attribute) - }) - }, - }).then(function (canvas) { - canvas.id = 'mmgisScreenshot' - document.body.appendChild(canvas) + getMapScreenshot() + .then(async function (dataURL) { + const mission = L_.configData?.msv?.mission + const time = L_.TimeControl_?.currentTime + const mapCenter = L_.Map_.map.getCenter() + const lng = mapCenter.lng.toFixed(4) + const lat = mapCenter.lat.toFixed(4) + const name = `mmgis-${mission}_${ + time ? `${time.replaceAll(':', '-')}_` : '' + }${lat}_${lng}.png` - const mission = L_.configData?.msv?.mission - const time = L_.TimeControl_?.currentTime - const mapCenter = L_.Map_.map.getCenter() - const lng = mapCenter.lng.toFixed(4) - const lat = mapCenter.lat.toFixed(4) + // Download via a blob URL rather than the base64 data + // URL directly: large/hi-DPI captures inflate ~33% as + // base64 and large data-URL anchor downloads are less + // reliable across browsers. + const blob = await (await fetch(dataURL)).blob() + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.setAttribute('download', name) + link.setAttribute('href', url) + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) - F_.downloadCanvas( - canvas.id, - `mmgis-${mission}_${ - time ? `${time.replaceAll(':', '-')}_` : '' - }${lat}_${lng}`, - function () { - canvas.remove() - setTimeout(function () { - $('#topBarScreenshotLoading').css( - 'display', - 'none' - ) - }, 2000) - } - ) - }) - $('#mapScreen #map .leaflet-tile-pane') - .children() - .each(function (i, elm) { - $(elm).css('z-index', zIndices[i]) + setTimeout(function () { + $('#topBarScreenshotLoading').css('display', 'none') + }, 2000) + }) + .catch(function (err) { + console.error('Screenshot failed:', err) + $('#topBarScreenshotLoading').css('display', 'none') }) - $('.leaflet-control-scalefactor').css('display', 'flex') - $('#mmgis-map-compass').css('display', 'block') - $('.leaflet-control-zoom').css('display', 'block') - $('#scaleBar').css('margin-top', '5px') - $('#mapToolBar').css('bottom', 'savedMapToolBarBottom') }) bottomBar.append(topBarScreenshot) diff --git a/src/essence/Basics/UserInterface_/ScreenshotUtils.js b/src/essence/Basics/UserInterface_/ScreenshotUtils.js new file mode 100644 index 000000000..0900ec6ad --- /dev/null +++ b/src/essence/Basics/UserInterface_/ScreenshotUtils.js @@ -0,0 +1,113 @@ +import $ from 'jquery' +import HTML2Canvas from 'html2canvas' + +/** + * Captures a PNG screenshot of the current 2D map (#mapScreen). + * + * Temporarily hides UI chrome (zoom controls, compass, scale factor) and + * normalizes the Leaflet pane z-indices so html2canvas rasterizes the layers + * in the correct order, then restores that UI afterwards. The `onclone` + * callback performs non-obvious SVG re-parenting and tile-pane z-index fixups + * that the cloned document needs in order to render identically to the live + * map; it must not be altered. + * + * This is the reusable core behind both the BottomBar camera button and the + * public `mmgisAPI.getMapScreenshot()` method. + * + * @param {object} [deps] - Injectable dependencies (intended for testing). In + * production these default to the imported jQuery and html2canvas. + * @param {function} [deps.html2canvas] - html2canvas implementation. + * @param {function} [deps.jquery] - jQuery implementation. + * @returns {Promise} Resolves to a PNG image as a data URL string + * (e.g. 'data:image/png;base64,...'). The data URL form is convenient for + * both triggering a download and embedding the image (e.g. into a PDF). + */ +function getMapScreenshot(deps = {}) { + const html2canvas = deps.html2canvas || HTML2Canvas + const jquery = deps.jquery || $ + + //We need to manually order leaflet z-indices for this to work + let zIndices = [] + jquery('#mapScreen #map .leaflet-tile-pane') + .children() + .each(function (i, elm) { + zIndices.push(jquery(elm).css('z-index')) + jquery(elm).css('z-index', i + 1) + }) + jquery('.leaflet-control-scalefactor').css('display', 'none') + jquery('#mmgis-map-compass').css('display', 'none') + jquery('.leaflet-control-zoom').css('display', 'none') + jquery('#scaleBar').css('margin-top', '0px') + const savedMapToolBarBottom = jquery('#mapToolBar').css('bottom') || '0px' + jquery('#mapToolBar').css('bottom', '0px') + jquery(`#toggleTimeUI.active`).trigger('click') + + const documentElm = document.getElementById('mapScreen') + const capture = html2canvas(documentElm, { + allowTaint: true, + useCORS: true, + logging: false, + scrollX: -window.scrollX, + scrollY: -window.scrollY, + windowWidth: documentElm.offsetWidth, + windowHeight: documentElm.offsetHeight, + onclone: function (e) { + // Fix svg layer shift + const originalSVG = document.body.querySelectorAll( + 'svg.leaflet-zoom-animated' + ) + const copySVG = e.body.querySelectorAll( + 'svg.leaflet-zoom-animated' + ) + copySVG.forEach((copyEle, i) => { + const attribute = originalSVG + .item(i) + .getAttribute('style') + const parentElement = copyEle.parentElement + parentElement.removeChild(copyEle) + const temp = document.createElement('div') + temp.appendChild(copyEle) + parentElement.appendChild(temp) + temp.setAttribute('style', attribute) + copyEle.removeAttribute('style') + }) + + // Fix tile layer z-indices + const originalZ = document.body.querySelectorAll( + '.leaflet-tile-pane > div.leaflet-layer' + ) + const copyZ = e.body.querySelectorAll( + '.leaflet-tile-pane > div.leaflet-layer' + ) + copyZ.forEach((copyEle, i) => { + const attribute = originalZ + .item(i) + .getAttribute('style') + copyEle.setAttribute('style', attribute) + }) + }, + }).then(function (canvas) { + return canvas.toDataURL('image/png') + }) + + // Restore the UI chrome we hid for the capture. This runs immediately + // (not in .then) because html2canvas clones the DOM synchronously within + // the call above, before its first internal await, so the capture already + // holds the hidden-chrome state and restoring the live UI now does not + // affect it. + jquery('#mapScreen #map .leaflet-tile-pane') + .children() + .each(function (i, elm) { + jquery(elm).css('z-index', zIndices[i]) + }) + jquery('.leaflet-control-scalefactor').css('display', 'flex') + jquery('#mmgis-map-compass').css('display', 'block') + jquery('.leaflet-control-zoom').css('display', 'block') + jquery('#scaleBar').css('margin-top', '5px') + jquery('#mapToolBar').css('bottom', savedMapToolBarBottom) + + return capture +} + +export { getMapScreenshot } +export default getMapScreenshot diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index cc4c05e86..eb6d89702 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -5,6 +5,7 @@ import QueryURL from '../Ancillary/QueryURL' import TimeControl from '../Basics/TimeControl_/TimeControl' import Login from '../Ancillary/Login/Login' import LegendTool from '../Tools/Legend/LegendTool.js' +import { getMapScreenshot } from '../Basics/UserInterface_/ScreenshotUtils' import mitt from 'mitt' import $ from 'jquery' @@ -438,7 +439,10 @@ var mmgisAPI_ = { return validEvents.includes(eventName) }, writeCoordinateURL: function () { - return QueryURL.writeCoordinateURL(false) + return QueryURL.writeCoordinateURL() + }, + getMapScreenshot: function () { + return getMapScreenshot() }, onLoadCallback: null, onLoaded: function (onLoadCallback) { @@ -723,6 +727,14 @@ var mmgisAPI = { */ writeCoordinateURL: mmgisAPI_.writeCoordinateURL, + /** getMapScreenshot - captures a PNG screenshot of the current 2D map. + * Hides UI chrome (zoom controls, compass, scale factor) during capture and + * restores it afterwards. The capture is rasterized with html2canvas, so this + * is asynchronous and requires no backend call. + * @returns {Promise} - resolves to a PNG image as a data URL string (e.g. 'data:image/png;base64,...'). The data URL form can be used to trigger a download or to embed the image (e.g. into a PDF). + */ + getMapScreenshot: mmgisAPI_.getMapScreenshot, + /** onLoaded - calls onLoadCallback as a function once MMGIS has finished loading. * @param {function} - onLoadCallback - function reference to function that is called when MMGIS is finished loading */ diff --git a/src/pre/calls.js b/src/pre/calls.js index 1cfe8cf0a..1f92175cb 100644 --- a/src/pre/calls.js +++ b/src/pre/calls.js @@ -117,10 +117,6 @@ const c = { type: 'POST', url: 'api/files/gethistory', }, - shortener_shorten: { - type: 'POST', - url: 'api/shortener/shorten', - }, shortener_expand: { type: 'POST', url: 'api/shortener/expand', diff --git a/src/pre/staticHandlers.js b/src/pre/staticHandlers.js index 88fc1036c..ee18da283 100644 --- a/src/pre/staticHandlers.js +++ b/src/pre/staticHandlers.js @@ -97,7 +97,6 @@ const STATIC_HANDLERS = { files_publish: drop(), files_gethistory: drop(), // Drop — modules not deployed alongside dashboards - shortener_shorten: drop(), shortener_expand: drop(), clear_test: drop(), tactical_targets: drop(), diff --git a/tests/unit/shareScreenshotApi.spec.js b/tests/unit/shareScreenshotApi.spec.js new file mode 100644 index 000000000..65a08945a --- /dev/null +++ b/tests/unit/shareScreenshotApi.spec.js @@ -0,0 +1,147 @@ +import { test, expect } from '@playwright/test' + +import getMapScreenshot, { + getMapScreenshot as namedGetMapScreenshot, +} from '../../src/essence/Basics/UserInterface_/ScreenshotUtils.js' + +// Issue #143 - expose share-link and map-screenshot as first-class plugin API. +// +// ScreenshotUtils.getMapScreenshot() drives the live DOM (jQuery) and rasterizes +// with html2canvas, neither of which exists in this Node test context. The +// function therefore accepts injectable `jquery`/`html2canvas` deps so the +// behavior can be exercised against lightweight fakes. + +// A fake jQuery that records every .css(prop, value) setter call (keyed by the +// selector it was invoked on) and answers .css(prop) getters with a known value. +function makeMockJQuery(getterValue) { + const cssSets = [] // { selector, prop, value } + const triggered = [] // { selector, event } + + function makeNode(selector) { + return { + css(prop, value) { + if (value === undefined) return getterValue // getter + cssSets.push({ selector, prop, value }) + return this + }, + children() { + // No children in the fake DOM; the z-index reorder loop no-ops. + return { each() { return this } } + }, + trigger(event) { + triggered.push({ selector, event }) + return this + }, + } + } + + function jquery(selector) { + return makeNode(selector) + } + jquery._cssSets = cssSets + jquery._triggered = triggered + return jquery +} + +function makeMockHtml2canvas(dataURL) { + const calls = [] // { element, options } + function html2canvas(element, options) { + calls.push({ element, options }) + return Promise.resolve({ + toDataURL: () => dataURL, + }) + } + html2canvas._calls = calls + return html2canvas +} + +function setupGlobalDom() { + global.window = global.window || {} + global.window.scrollX = 0 + global.window.scrollY = 0 + global.document = { + getElementById: (id) => + id === 'mapScreen' ? { offsetWidth: 1024, offsetHeight: 768 } : null, + body: { querySelectorAll: () => [] }, + createElement: () => ({ + appendChild() {}, + removeChild() {}, + setAttribute() {}, + removeAttribute() {}, + }), + } +} + +test.describe('ScreenshotUtils.getMapScreenshot - export surface', () => { + test('is exported as both a default and named function', () => { + expect(typeof getMapScreenshot).toBe('function') + expect(typeof namedGetMapScreenshot).toBe('function') + expect(getMapScreenshot).toBe(namedGetMapScreenshot) + }) +}) + +test.describe('ScreenshotUtils.getMapScreenshot - behavior', () => { + test.beforeEach(() => { + setupGlobalDom() + }) + + test('resolves to the PNG data URL produced by html2canvas', async () => { + const jquery = makeMockJQuery('5px') + const html2canvas = makeMockHtml2canvas('data:image/png;base64,FAKEPNG') + + const result = await getMapScreenshot({ jquery, html2canvas }) + + expect(result).toBe('data:image/png;base64,FAKEPNG') + }) + + test('invokes html2canvas once on #mapScreen with an onclone option', async () => { + const jquery = makeMockJQuery('5px') + const html2canvas = makeMockHtml2canvas('data:image/png;base64,X') + + await getMapScreenshot({ jquery, html2canvas }) + + expect(html2canvas._calls.length).toBe(1) + const { element, options } = html2canvas._calls[0] + expect(element.offsetWidth).toBe(1024) + expect(options.windowWidth).toBe(1024) + expect(options.windowHeight).toBe(768) + // The onclone SVG/z-index fixups are mandatory for a correct capture. + expect(typeof options.onclone).toBe('function') + }) + + test('hides UI chrome for the capture and restores it afterwards', async () => { + const jquery = makeMockJQuery('5px') + const html2canvas = makeMockHtml2canvas('data:image/png;base64,X') + + await getMapScreenshot({ jquery, html2canvas }) + + const displayFor = (selector) => + jquery._cssSets + .filter((c) => c.selector === selector && c.prop === 'display') + .map((c) => c.value) + + // Each chrome element is hidden, then restored (visible) again. + expect(displayFor('.leaflet-control-zoom')).toEqual(['none', 'block']) + expect(displayFor('.leaflet-control-scalefactor')).toEqual([ + 'none', + 'flex', + ]) + expect(displayFor('#mmgis-map-compass')).toEqual(['none', 'block']) + }) + + test('restores #mapToolBar bottom to its saved value, not a string literal', async () => { + // Regression guard: the restore must pass the captured variable, not the + // literal 'savedMapToolBarBottom'. Saved value here is '5px'. + const jquery = makeMockJQuery('5px') + const html2canvas = makeMockHtml2canvas('data:image/png;base64,X') + + await getMapScreenshot({ jquery, html2canvas }) + + const bottomValues = jquery._cssSets + .filter((c) => c.selector === '#mapToolBar' && c.prop === 'bottom') + .map((c) => c.value) + + expect(bottomValues).toEqual(['0px', '5px']) + expect(bottomValues).not.toContain('savedMapToolBarBottom') + }) +}) From a329c596f9c2be2b0626f82f0ce57c74d1eb77c0 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Mon, 29 Jun 2026 19:16:31 -0500 Subject: [PATCH 02/23] [143] Fix share-link and screenshot for the modern layout writeCoordinateURL() called UserInterface_.getPanelPercents(), which only exists on the classic/mobile UI controllers, throwing in the modern layout. getMapScreenshot() targeted #mapScreen, absent in the modern layout. Guard the panel-percents call (fall back to a map-only split) and fall back to the #map container so both capabilities work in the modern layout that plugins run in. --- src/essence/Ancillary/QueryURL.js | 8 +++++++- src/essence/Basics/UserInterface_/ScreenshotUtils.js | 9 ++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/essence/Ancillary/QueryURL.js b/src/essence/Ancillary/QueryURL.js index 1ba86aad0..b3363327c 100644 --- a/src/essence/Ancillary/QueryURL.js +++ b/src/essence/Ancillary/QueryURL.js @@ -354,7 +354,13 @@ var QueryURL = { } //panePercents - var pP = L_.UserInterface_.getPanelPercents() + // getPanelPercents only exists on the classic/mobile UI controllers; the + // modern layout has no such method, so fall back to a map-only split. + var pP = + L_.UserInterface_ && + typeof L_.UserInterface_.getPanelPercents === 'function' + ? L_.UserInterface_.getPanelPercents() + : { viewer: 0, map: 100, globe: 0 } var panePercents = pP.viewer + ',' + pP.map + ',' + pP.globe urlAppendage += '&panePercents=' + panePercents diff --git a/src/essence/Basics/UserInterface_/ScreenshotUtils.js b/src/essence/Basics/UserInterface_/ScreenshotUtils.js index 0900ec6ad..66571394f 100644 --- a/src/essence/Basics/UserInterface_/ScreenshotUtils.js +++ b/src/essence/Basics/UserInterface_/ScreenshotUtils.js @@ -28,7 +28,7 @@ function getMapScreenshot(deps = {}) { //We need to manually order leaflet z-indices for this to work let zIndices = [] - jquery('#mapScreen #map .leaflet-tile-pane') + jquery('#map .leaflet-tile-pane') .children() .each(function (i, elm) { zIndices.push(jquery(elm).css('z-index')) @@ -42,7 +42,10 @@ function getMapScreenshot(deps = {}) { jquery('#mapToolBar').css('bottom', '0px') jquery(`#toggleTimeUI.active`).trigger('click') - const documentElm = document.getElementById('mapScreen') + // The classic UI wraps the map in #mapScreen; the modern layout has no such + // wrapper, so fall back to the #map container (present in both layouts). + const documentElm = + document.getElementById('mapScreen') || document.getElementById('map') const capture = html2canvas(documentElm, { allowTaint: true, useCORS: true, @@ -95,7 +98,7 @@ function getMapScreenshot(deps = {}) { // the call above, before its first internal await, so the capture already // holds the hidden-chrome state and restoring the live UI now does not // affect it. - jquery('#mapScreen #map .leaflet-tile-pane') + jquery('#map .leaflet-tile-pane') .children() .each(function (i, elm) { jquery(elm).css('z-index', zIndices[i]) From 0f6eef5fca312bc398c31e5902b5e2ed5f13c42c Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Mon, 29 Jun 2026 20:50:29 -0500 Subject: [PATCH 03/23] [143] Engine-aware map screenshot (deck.gl/GL canvas capture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public mmgisAPI.getMapScreenshot() returned a blank image on the modern deck.gl/GL map because it always went through html2canvas, which cannot rasterize a WebGL canvas. Make screenshot capture a responsibility of the active map engine. - IMapEngine: add captureScreenshot(): Promise to the contract. - LeafletAdapter: implement it by delegating to the existing html2canvas helper (getMapScreenshot in ScreenshotUtils) — unchanged Leaflet logic. - DeckGLAdapter: set preserveDrawingBuffer: true at GL-map creation (both mapbox-gl and maplibre-gl paths) so the canvas can be read back, and implement captureScreenshot() that forces a repaint then reads the GL canvas via toDataURL. Overlay mode is interleaved, so the base map's single canvas already holds basemap + deck layers. - mmgisAPI.getMapScreenshot(): delegate to the active engine's captureScreenshot(), falling back to the html2canvas helper. - BottomBar camera button: route through mmgisAPI.getMapScreenshot() so it shares the engine-aware path. Tests: add deck.gl capture coverage (overlay redraw, triggerRepaint+rAF fallback, standalone, no-map rejection) and a LeafletAdapter contract check. Import MAP_ENGINE in the deck.gl spec from types/engine to avoid a leaflet transitive import that breaks module load in the Node test env. --- .../MapEngines/Adapters/DeckGLAdapter.ts | 83 +++++++++++++++++++ .../MapEngines/Adapters/LeafletAdapter.ts | 14 ++++ src/essence/Basics/MapEngines/IMapEngine.ts | 13 +++ .../Basics/UserInterface_/BottomBar.js | 13 +-- src/essence/mmgisAPI/mmgisAPI.js | 10 +++ tests/unit/LeafletAdapter.spec.js | 9 ++ tests/unit/deckGLAdapter.spec.js | 67 ++++++++++++++- 7 files changed, 202 insertions(+), 7 deletions(-) diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 3ba161004..98e64ac02 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -121,10 +121,20 @@ interface BasemapInstance { setMaxBounds(bounds: [[number, number], [number, number]] | null): unknown /** Register a map event listener. */ on(type: string, handler: (...args: unknown[]) => void): unknown + /** Register a one-shot map event listener that auto-removes after firing once. */ + once(type: string, handler: (...args: unknown[]) => void): unknown /** Remove a previously registered map event listener. */ off(type: string, handler: (...args: unknown[]) => void): unknown /** Recalculate the map size from its container element. */ resize(): void + /** Return the WebGL canvas element the base map renders into. */ + getCanvas(): HTMLCanvasElement + /** Force a synchronous re-render of the map (maplibre-gl). */ + redraw?(): void + /** Schedule a re-render on the next animation frame (mapbox-gl + maplibre-gl). */ + triggerRepaint?(): void + /** Whether the map's style and sources are fully loaded and idle. */ + loaded?(): boolean } /** @@ -364,6 +374,72 @@ export class DeckGLAdapter implements IMapEngine { return this._container } + /** + * Capture the current map view as a PNG data URL. + * + * WebGL clears its drawing buffer after every composite, so a canvas + * `toDataURL()` returns blank unless the GL context was created with + * `preserveDrawingBuffer: true` (set in {@link _setupOverlay}) AND the read + * happens in the same frame as a render. We therefore force a repaint and + * read the canvas on the next animation frame. + * + * - **Overlay mode** (the modern map): the `MapboxOverlay` runs in + * `interleaved: true` mode, so deck.gl draws into the base map's GL + * context — there is a single canvas holding basemap + deck layers. + * We repaint the base map and read `basemap.getCanvas().toDataURL()`. + * - **Standalone mode**: deck.gl owns the only canvas; we redraw the + * `Deck` instance and read its canvas. + */ + captureScreenshot(): Promise { + return new Promise((resolve, reject) => { + try { + if (this._isOverlayMode && this._basemap) { + const basemap = this._basemap + // Force a synchronous repaint, then read back in the same + // frame. redraw() (maplibre-gl) paints synchronously; + // triggerRepaint() (both libs) schedules a frame, so we + // fall back to a rAF read for that path. + const read = () => { + const canvas = basemap.getCanvas() + resolve(canvas.toDataURL('image/png')) + } + if (typeof basemap.redraw === 'function') { + basemap.redraw() + read() + } else { + basemap.triggerRepaint?.() + requestAnimationFrame(() => { + try { + read() + } catch (err) { + reject(err as Error) + } + }) + } + return + } + + const deck = this._deck + if (!deck) { + reject(new Error('[DeckGLAdapter] captureScreenshot: no active map to capture')) + return + } + deck.redraw('screenshot') + const canvas = (deck as unknown as { getCanvas?: () => HTMLCanvasElement }) + .getCanvas?.() + if (!canvas) { + reject( + new Error('[DeckGLAdapter] captureScreenshot: deck canvas unavailable') + ) + return + } + resolve(canvas.toDataURL('image/png')) + } catch (err) { + reject(err as Error) + } + }) + } + /** * Anchored HTML overlay. deck.gl renders to canvas and has no native * overlay system, so we own the DOM node directly: append to the @@ -1107,6 +1183,13 @@ export class DeckGLAdapter implements IMapEngine { minZoom: this._minZoom, maxZoom: this._maxZoom, projection: 'mercator', + // Required so the GL canvas can be read back via toDataURL() in + // captureScreenshot(). WebGL clears its drawing buffer after each + // composite unless this is set, so without it the export is blank. + // Must be set at map-creation time — it cannot be toggled later. + // Slight perf/memory cost (an extra buffer copy per frame), which + // is acceptable for a basemap-backed deck.gl view. + preserveDrawingBuffer: true, } if (basemap.provider === 'mapbox' && basemap.accessToken) { diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index 836aa0be1..ec000c382 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -46,6 +46,7 @@ import { } from 'terra-draw' import { TerraDrawLeafletAdapter } from 'terra-draw-leaflet-adapter' import { extractVerticesFromGeometry } from './DrawingHelpers' +import { getMapScreenshot } from '../../UserInterface_/ScreenshotUtils' import { MapEventHandler, MapEventOptions, @@ -331,6 +332,19 @@ export default class LeafletAdapter implements IMapEngine, IMapEn return this._container! } + /** + * Capture the current Leaflet view as a PNG data URL. + * + * Delegates to the shared {@link getMapScreenshot} helper, which performs + * the html2canvas rasterization plus the Leaflet-specific DOM prep + * (pane z-index normalization, SVG re-parenting, UI-chrome hide/restore). + * That logic is correct for Leaflet's DOM/SVG/tile rendering and is left + * unchanged here. + */ + captureScreenshot(): Promise { + return getMapScreenshot() + } + // ======================================== // VIEW CONTROL METHODS // ======================================== diff --git a/src/essence/Basics/MapEngines/IMapEngine.ts b/src/essence/Basics/MapEngines/IMapEngine.ts index 9493c40fe..81116e027 100644 --- a/src/essence/Basics/MapEngines/IMapEngine.ts +++ b/src/essence/Basics/MapEngines/IMapEngine.ts @@ -65,6 +65,19 @@ export interface IMapEngine< */ getContainer(): HTMLElement + /** + * Capture the current map view as a PNG image. + * + * Each engine owns the capture strategy for its rendering technology: + * - Leaflet rasterizes its DOM/SVG/tile panes with html2canvas. + * - deck.gl reads the WebGL canvas directly (the base map's GL context + * when running in interleaved overlay mode), which html2canvas cannot do. + * + * @returns Resolves to a PNG image as a data URL string + * (e.g. `'data:image/png;base64,...'`). + */ + captureScreenshot(): Promise + /** * Jump to a center and zoom without animation. */ diff --git a/src/essence/Basics/UserInterface_/BottomBar.js b/src/essence/Basics/UserInterface_/BottomBar.js index bda7ac5c6..a8b73b407 100644 --- a/src/essence/Basics/UserInterface_/BottomBar.js +++ b/src/essence/Basics/UserInterface_/BottomBar.js @@ -6,7 +6,6 @@ import L_ from '../Layers_/Layers_' import QueryURL from '../../Ancillary/QueryURL' import Modal from '../../Ancillary/Modal' -import { getMapScreenshot } from './ScreenshotUtils' import tippy from 'tippy.js' import './BottomBar.css' @@ -68,13 +67,15 @@ let BottomBar = { 'opacity': '0.8' }) .on('click', function () { - // Screenshot capture (DOM prep, html2canvas, and UI restore) - // lives in ScreenshotUtils so it can be reused by the public - // mmgisAPI.getMapScreenshot(). Here we just show the loading - // spinner, then download the resulting PNG data URL. + // Screenshot capture is engine-aware: it flows through + // mmgisAPI.getMapScreenshot(), which delegates to the active + // map engine (Leaflet html2canvas vs deck.gl GL-canvas + // readback). Here we just show the loading spinner, then + // download the resulting PNG data URL. $('#topBarScreenshotLoading').css('display', 'block') - getMapScreenshot() + window.mmgisAPI + .getMapScreenshot() .then(async function (dataURL) { const mission = L_.configData?.msv?.mission const time = L_.TimeControl_?.currentTime diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index eb6d89702..a79359b29 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -442,6 +442,16 @@ var mmgisAPI_ = { return QueryURL.writeCoordinateURL() }, getMapScreenshot: function () { + // Screenshot capture is engine-specific: Leaflet rasterizes its DOM + // with html2canvas, while the deck.gl/GL map must read its WebGL + // canvas directly (html2canvas cannot capture a WebGL canvas). Delegate + // to the active IMapEngine adapter, which owns the right strategy. + const engine = L_.Map_ && L_.Map_.engine + if (engine && typeof engine.captureScreenshot === 'function') { + return engine.captureScreenshot() + } + // Fallback for environments where the engine facade is unavailable + // (preserves the legacy Leaflet behaviour). return getMapScreenshot() }, onLoadCallback: null, diff --git a/tests/unit/LeafletAdapter.spec.js b/tests/unit/LeafletAdapter.spec.js index 3be97466e..de8bed89f 100644 --- a/tests/unit/LeafletAdapter.spec.js +++ b/tests/unit/LeafletAdapter.spec.js @@ -143,6 +143,15 @@ test.describe('LeafletAdapter - Lifecycle', () => { expect(adapter.getNativeMap()).toBeNull() }) + test('exposes captureScreenshot() as part of the IMapEngine contract', () => { + // The engine-aware screenshot path (issue #143) requires every adapter + // to implement captureScreenshot(). LeafletAdapter delegates to the + // shared html2canvas helper; here we assert the method is present so + // mmgisAPI.getMapScreenshot() can call it uniformly. + const adapter = new LeafletAdapter() + expect(typeof adapter.captureScreenshot).toBe('function') + }) + test('destroy() is safe to call when map is not initialized', () => { setup() const adapter = new LeafletAdapter() diff --git a/tests/unit/deckGLAdapter.spec.js b/tests/unit/deckGLAdapter.spec.js index 644d7d26d..61e471607 100644 --- a/tests/unit/deckGLAdapter.spec.js +++ b/tests/unit/deckGLAdapter.spec.js @@ -1,6 +1,9 @@ import { test, expect } from '@playwright/test' import { DeckGLAdapter } from '../../src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts' -import { MAP_ENGINE } from '../../src/essence/Basics/MapEngines/index.ts' +// Import MAP_ENGINE from the lightweight types module rather than MapEngines/index.ts. +// index.ts transitively imports LeafletAdapter -> leaflet, which references a global +// `window` at module-eval time and fails in this Node test context. +import { MAP_ENGINE } from '../../src/essence/Basics/MapEngines/types/engine.ts' function makeAdapter({ longitude = -120, latitude = 40, zoom = 5 } = {}) { const adapter = new DeckGLAdapter() @@ -215,6 +218,68 @@ test.describe('DeckGLAdapter', () => { }) }) + test.describe('captureScreenshot', () => { + test('overlay mode reads the basemap GL canvas after a redraw', async () => { + const adapter = makeAdapter() + let redrawn = false + const canvas = { + toDataURL: (type) => { + expect(type).toBe('image/png') + // Only valid once a render has occurred this frame. + return redrawn + ? 'data:image/png;base64,DECKGL' + : 'data:image/png;base64,BLANK' + }, + } + adapter._isOverlayMode = true + adapter._basemap = { + redraw: () => { redrawn = true }, + getCanvas: () => canvas, + } + + const result = await adapter.captureScreenshot() + expect(redrawn).toBe(true) + expect(result).toBe('data:image/png;base64,DECKGL') + }) + + test('overlay mode without redraw() falls back to triggerRepaint + rAF', async () => { + global.requestAnimationFrame = + global.requestAnimationFrame || ((cb) => setTimeout(() => cb(0), 0)) + const adapter = makeAdapter() + let repainted = false + adapter._isOverlayMode = true + adapter._basemap = { + triggerRepaint: () => { repainted = true }, + getCanvas: () => ({ toDataURL: () => 'data:image/png;base64,RAF' }), + } + + const result = await adapter.captureScreenshot() + expect(repainted).toBe(true) + expect(result).toBe('data:image/png;base64,RAF') + }) + + test('standalone mode redraws deck and reads its canvas', async () => { + const adapter = makeAdapter() + let redrawArg = null + adapter._isOverlayMode = false + adapter._deck = { + redraw: (reason) => { redrawArg = reason }, + getCanvas: () => ({ toDataURL: () => 'data:image/png;base64,STANDALONE' }), + } + + const result = await adapter.captureScreenshot() + expect(redrawArg).toBe('screenshot') + expect(result).toBe('data:image/png;base64,STANDALONE') + }) + + test('rejects when there is no active map to capture', async () => { + const adapter = makeAdapter() + adapter._isOverlayMode = false + adapter._deck = null + await expect(adapter.captureScreenshot()).rejects.toThrow(/no active map/) + }) + }) + test.describe('destroy', () => { test('clears all layers', () => { const adapter = makeAdapter() From 1e55e43f280bfaa8b8840862457e0f1f9e04a3b5 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Mon, 29 Jun 2026 22:01:52 -0500 Subject: [PATCH 04/23] [143] Set preserveDrawingBuffer for standalone deck.gl mode Standalone Deck (no basemap) omitted preserveDrawingBuffer, so captureScreenshot() would read a cleared buffer and return a blank image there. Match the overlay path so capture works in standalone mode too. --- src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 98e64ac02..1dbd6e0c6 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -1115,6 +1115,9 @@ export class DeckGLAdapter implements IMapEngine { private _initStandaloneMode(): void { this._deck = new Deck({ parent: this._container, + // Preserve the drawing buffer so captureScreenshot() can read the + // rendered frame (parity with the overlay path in _setupOverlay). + glOptions: { preserveDrawingBuffer: true }, width: '100%', height: '100%', controller: true, From fa686574cbed5bf210f64ffd45325245fcf0326b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 30 Jun 2026 03:05:29 +0000 Subject: [PATCH 05/23] chore: bump version to 4.2.12-20260630 [version bump] --- configure/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/configure/package.json b/configure/package.json index b920c8496..3104b0ae1 100644 --- a/configure/package.json +++ b/configure/package.json @@ -1,6 +1,6 @@ { "name": "configure", - "version": "4.2.11-20260611", + "version": "4.2.12-20260630", "homepage": "./configure/build", "private": true, "dependencies": { diff --git a/package.json b/package.json index b55026871..387ba3825 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mmgis", - "version": "4.2.11-20260611", + "version": "4.2.12-20260630", "description": "A web-based mapping and localization solution for science operation on planetary missions.", "homepage": "build", "repository": { From ca68abece775bcfa3dfe367c5ffa27086b4baacb Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 1 Jul 2026 10:39:13 -0500 Subject: [PATCH 06/23] [143] fix(tests): unblock unit suite (vitest import + registry count) shareScreenshotApi.spec.js imported test/expect from @playwright/test but lives under tests/unit, which Vitest globs; Playwright's test.describe() threw at collection and failed the whole Vitest run, so the screenshot regression guards never executed. Import from 'vitest' like the sibling specs. staticHandlers.spec.js hardcoded a 40-entry count that dropped to 39 when shortener_shorten was removed from calls.js/staticHandlers.js. Update to 39; the parity tests already covered the symmetric removal. --- tests/unit/shareScreenshotApi.spec.js | 2 +- tests/unit/staticHandlers.spec.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/shareScreenshotApi.spec.js b/tests/unit/shareScreenshotApi.spec.js index 65a08945a..475eb44b2 100644 --- a/tests/unit/shareScreenshotApi.spec.js +++ b/tests/unit/shareScreenshotApi.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test' +import { test, expect } from 'vitest' import getMapScreenshot, { getMapScreenshot as namedGetMapScreenshot, diff --git a/tests/unit/staticHandlers.spec.js b/tests/unit/staticHandlers.spec.js index b63c1814b..e876dddf1 100644 --- a/tests/unit/staticHandlers.spec.js +++ b/tests/unit/staticHandlers.spec.js @@ -26,8 +26,8 @@ const getHandlerNames = () => { } test.describe('staticHandlers parity with calls.js', () => { - test('calls.js registry has the expected 40 entries', () => { - expect(getCallNames()).toHaveLength(40) + test('calls.js registry has the expected 39 entries', () => { + expect(getCallNames()).toHaveLength(39) }) test('every calls.js entry has a STATIC_HANDLERS handler', () => { From 705f041cbdcc7588aa11beaa20f18c3371049864 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 1 Jul 2026 10:41:43 -0500 Subject: [PATCH 07/23] [143] fix(deckgl): set standalone preserveDrawingBuffer via deviceProps (v9) _initStandaloneMode set glOptions: { preserveDrawingBuffer: true }, but glOptions is a deck.gl v8 prop and does not exist in v9 (we run 9.2.10); the surrounding 'as any' cast hid that it was silently dropped. Standalone capture only worked because v9 defaults preserveDrawingBuffer to true. Set it explicitly via the correct v9 shape, deviceProps.webgl, so the guarantee no longer rides on that default. Add a spec that drives the real init() path with the Deck/maplibre constructors mocked and asserts the setting reaches both the standalone (Deck) and overlay (maplibre Map) paths; the existing captureScreenshot tests bypass init() and never covered it. --- .../MapEngines/Adapters/DeckGLAdapter.ts | 5 +- tests/unit/deckGLScreenshotConfig.spec.js | 97 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/unit/deckGLScreenshotConfig.spec.js diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 1dbd6e0c6..c6facbb7a 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -1117,7 +1117,10 @@ export class DeckGLAdapter implements IMapEngine { parent: this._container, // Preserve the drawing buffer so captureScreenshot() can read the // rendered frame (parity with the overlay path in _setupOverlay). - glOptions: { preserveDrawingBuffer: true }, + // deck.gl v9 defaults this to true, but we set it explicitly via + // deviceProps.webgl so the guarantee is not dependent on that + // default. (The v8-era `glOptions` prop no longer exists in v9.) + deviceProps: { webgl: { preserveDrawingBuffer: true } }, width: '100%', height: '100%', controller: true, diff --git a/tests/unit/deckGLScreenshotConfig.spec.js b/tests/unit/deckGLScreenshotConfig.spec.js new file mode 100644 index 000000000..c62fd589c --- /dev/null +++ b/tests/unit/deckGLScreenshotConfig.spec.js @@ -0,0 +1,97 @@ +import { test, expect, vi, beforeEach } from 'vitest' + +// Issue #143 - the deck.gl screenshot path depends on the GL context being +// created with `preserveDrawingBuffer: true`; without it, canvas.toDataURL() +// returns a blank image. The captureScreenshot() tests inject fake +// _basemap/_deck and never call init(), so nothing guards that init() actually +// requests that setting. These tests drive the real init() path with the GL +// constructors mocked, and assert the setting is passed through on both the +// standalone (Deck) and overlay (maplibre Map) paths. +// +// Regression guard specifically for the deck.gl v9 shape: the setting lives at +// deviceProps.webgl.preserveDrawingBuffer on Deck (the v8 `glOptions` prop no +// longer exists in v9), and preserveDrawingBuffer at the top level of the +// maplibre/mapbox Map constructor options. + +const deckCtorArgs = [] +const maplibreCtorArgs = [] + +vi.mock('@deck.gl/core', async (importOriginal) => { + // Keep the real interpolators etc.; only the Deck constructor is replaced so + // no real WebGL context is created (jsdom has none). + const actual = await importOriginal() + class MockDeck { + constructor(props) { + deckCtorArgs.push(props) + } + setProps() {} + redraw() {} + finalize() {} + getCanvas() { + return { toDataURL: () => 'data:image/png;base64,MOCK' } + } + } + return { ...actual, Deck: MockDeck } +}) + +vi.mock('maplibre-gl', () => { + class MockMap { + constructor(opts) { + maplibreCtorArgs.push(opts) + } + addControl() {} + on() {} + once() {} + off() {} + setMaxBounds() {} + remove() {} + getCanvas() { + return { toDataURL: () => 'data:image/png;base64,MOCK' } + } + } + return { Map: MockMap, default: { Map: MockMap } } +}) + +// Imported after the mocks are declared (vi.mock is hoisted regardless). +const { DeckGLAdapter } = await import( + '../../src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts' +) + +function makeContainer(id = 'map') { + let el = document.getElementById(id) + if (!el) { + el = document.createElement('div') + el.id = id + document.body.appendChild(el) + } + return el +} + +test.describe('DeckGLAdapter init sets preserveDrawingBuffer (issue #143)', () => { + beforeEach(() => { + deckCtorArgs.length = 0 + maplibreCtorArgs.length = 0 + makeContainer('map') + }) + + test('standalone mode creates the Deck with deviceProps.webgl.preserveDrawingBuffer', () => { + const adapter = new DeckGLAdapter() + adapter.init({ containerId: 'map', zoom: 4, center: { lat: 0, lng: 0 } }) + + expect(deckCtorArgs).toHaveLength(1) + expect(deckCtorArgs[0].deviceProps.webgl.preserveDrawingBuffer).toBe(true) + }) + + test('maplibre overlay mode creates the Map with preserveDrawingBuffer', () => { + const adapter = new DeckGLAdapter() + adapter.init({ + containerId: 'map', + zoom: 4, + center: { lat: 0, lng: 0 }, + basemap: { provider: 'maplibre', style: 'https://example/style.json' }, + }) + + expect(maplibreCtorArgs).toHaveLength(1) + expect(maplibreCtorArgs[0].preserveDrawingBuffer).toBe(true) + }) +}) From 8010171bcc5cd227b3157c2f79907769b4904067 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 1 Jul 2026 10:45:08 -0500 Subject: [PATCH 08/23] [143] refactor(engine): own Leaflet screenshot in the engine layer The engine adapter was reaching up into the UI-shell layer: LeafletAdapter and the core mmgisAPI both imported getMapScreenshot from Basics/UserInterface_/ScreenshotUtils, inverting the intended dependency direction (engines sit below the UI and stay portable). - Move ScreenshotUtils.js -> MapEngines/Adapters/LeafletScreenshot.js, next to the adapter that owns it (it IS the Leaflet capture strategy). Chrome hide/restore stays with it, since those fixups exist only to make html2canvas rasterize the Leaflet DOM correctly. - LeafletAdapter imports it from the sibling file (same layer, no inversion). - mmgisAPI.getMapScreenshot() delegates only to the active engine and rejects when none is present, dropping the hardcoded-Leaflet fallback. Map_ assigns its engine synchronously at init, so the fallback was only reachable before any map loaded. - Fix a restore asymmetry (C2): the time UI was collapsed for the capture but never reopened; remember its state and toggle it back, with a regression test. - Reword the public getMapScreenshot() JSDoc to be engine-agnostic and note the deck.gl capture excludes HTML overlays (E1/E2). --- .../MapEngines/Adapters/DeckGLAdapter.ts | 3 ++ .../MapEngines/Adapters/LeafletAdapter.ts | 2 +- .../Adapters/LeafletScreenshot.js} | 28 ++++++++++------- src/essence/mmgisAPI/mmgisAPI.js | 26 +++++++++------- tests/unit/shareScreenshotApi.spec.js | 30 ++++++++++++++++--- 5 files changed, 62 insertions(+), 27 deletions(-) rename src/essence/Basics/{UserInterface_/ScreenshotUtils.js => MapEngines/Adapters/LeafletScreenshot.js} (77%) diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index c6facbb7a..7131d6b90 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -389,6 +389,9 @@ export class DeckGLAdapter implements IMapEngine { * We repaint the base map and read `basemap.getCanvas().toDataURL()`. * - **Standalone mode**: deck.gl owns the only canvas; we redraw the * `Deck` instance and read its canvas. + * + * Note: this captures only the GL canvas. Anchored HTML overlays/markers + * added via {@link addOverlay} are separate DOM nodes and are not included. */ captureScreenshot(): Promise { return new Promise((resolve, reject) => { diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index ec000c382..4980a059d 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -46,7 +46,7 @@ import { } from 'terra-draw' import { TerraDrawLeafletAdapter } from 'terra-draw-leaflet-adapter' import { extractVerticesFromGeometry } from './DrawingHelpers' -import { getMapScreenshot } from '../../UserInterface_/ScreenshotUtils' +import { getMapScreenshot } from './LeafletScreenshot' import { MapEventHandler, MapEventOptions, diff --git a/src/essence/Basics/UserInterface_/ScreenshotUtils.js b/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js similarity index 77% rename from src/essence/Basics/UserInterface_/ScreenshotUtils.js rename to src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js index 66571394f..4f4e07b9a 100644 --- a/src/essence/Basics/UserInterface_/ScreenshotUtils.js +++ b/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js @@ -2,17 +2,18 @@ import $ from 'jquery' import HTML2Canvas from 'html2canvas' /** - * Captures a PNG screenshot of the current 2D map (#mapScreen). + * The Leaflet map engine's screenshot strategy: captures a PNG of the current + * 2D Leaflet map. Invoked via `LeafletAdapter.captureScreenshot()`, which is + * what `mmgisAPI.getMapScreenshot()` delegates to when Leaflet is the active + * engine. (The deck.gl engine reads its WebGL canvas instead — see + * `DeckGLAdapter.captureScreenshot()`.) * - * Temporarily hides UI chrome (zoom controls, compass, scale factor) and - * normalizes the Leaflet pane z-indices so html2canvas rasterizes the layers - * in the correct order, then restores that UI afterwards. The `onclone` - * callback performs non-obvious SVG re-parenting and tile-pane z-index fixups - * that the cloned document needs in order to render identically to the live - * map; it must not be altered. - * - * This is the reusable core behind both the BottomBar camera button and the - * public `mmgisAPI.getMapScreenshot()` method. + * Temporarily hides UI chrome (zoom controls, compass, scale factor, time UI) + * and normalizes the Leaflet pane z-indices so html2canvas rasterizes the + * layers in the correct order, then restores that chrome afterwards. The + * `onclone` callback performs non-obvious SVG re-parenting and tile-pane + * z-index fixups that the cloned document needs in order to render identically + * to the live map; it must not be altered. * * @param {object} [deps] - Injectable dependencies (intended for testing). In * production these default to the imported jQuery and html2canvas. @@ -40,7 +41,11 @@ function getMapScreenshot(deps = {}) { jquery('#scaleBar').css('margin-top', '0px') const savedMapToolBarBottom = jquery('#mapToolBar').css('bottom') || '0px' jquery('#mapToolBar').css('bottom', '0px') - jquery(`#toggleTimeUI.active`).trigger('click') + // Collapse the time UI so it stays out of the capture; remember whether it + // was open so we can restore it afterwards (the click removes the .active + // class, so the restore re-triggers the plain #toggleTimeUI selector). + const timeUIWasActive = jquery('#toggleTimeUI.active').length > 0 + if (timeUIWasActive) jquery('#toggleTimeUI.active').trigger('click') // The classic UI wraps the map in #mapScreen; the modern layout has no such // wrapper, so fall back to the #map container (present in both layouts). @@ -108,6 +113,7 @@ function getMapScreenshot(deps = {}) { jquery('.leaflet-control-zoom').css('display', 'block') jquery('#scaleBar').css('margin-top', '5px') jquery('#mapToolBar').css('bottom', savedMapToolBarBottom) + if (timeUIWasActive) jquery('#toggleTimeUI').trigger('click') return capture } diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index a79359b29..2cc15bbdf 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -5,7 +5,6 @@ import QueryURL from '../Ancillary/QueryURL' import TimeControl from '../Basics/TimeControl_/TimeControl' import Login from '../Ancillary/Login/Login' import LegendTool from '../Tools/Legend/LegendTool.js' -import { getMapScreenshot } from '../Basics/UserInterface_/ScreenshotUtils' import mitt from 'mitt' import $ from 'jquery' @@ -443,16 +442,18 @@ var mmgisAPI_ = { }, getMapScreenshot: function () { // Screenshot capture is engine-specific: Leaflet rasterizes its DOM - // with html2canvas, while the deck.gl/GL map must read its WebGL - // canvas directly (html2canvas cannot capture a WebGL canvas). Delegate - // to the active IMapEngine adapter, which owns the right strategy. + // with html2canvas, while the deck.gl/GL map reads its WebGL canvas + // directly (html2canvas cannot capture a WebGL canvas). Delegate to the + // active IMapEngine adapter, which owns the right strategy. Map_ assigns + // its engine synchronously at init, so a missing engine means no map is + // loaded yet — reject rather than reach into a specific engine's DOM. const engine = L_.Map_ && L_.Map_.engine if (engine && typeof engine.captureScreenshot === 'function') { return engine.captureScreenshot() } - // Fallback for environments where the engine facade is unavailable - // (preserves the legacy Leaflet behaviour). - return getMapScreenshot() + return Promise.reject( + new Error('getMapScreenshot: no active map engine to capture') + ) }, onLoadCallback: null, onLoaded: function (onLoadCallback) { @@ -737,10 +738,13 @@ var mmgisAPI = { */ writeCoordinateURL: mmgisAPI_.writeCoordinateURL, - /** getMapScreenshot - captures a PNG screenshot of the current 2D map. - * Hides UI chrome (zoom controls, compass, scale factor) during capture and - * restores it afterwards. The capture is rasterized with html2canvas, so this - * is asynchronous and requires no backend call. + /** getMapScreenshot - captures a PNG screenshot of the current map view. + * Delegates to the active map engine, so the capture strategy is + * engine-specific: the Leaflet engine rasterizes its DOM (hiding UI chrome + * for the shot), while the deck.gl/GL engine reads its WebGL canvas. Note + * 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} - resolves to a PNG image as a data URL string (e.g. 'data:image/png;base64,...'). The data URL form can be used to trigger a download or to embed the image (e.g. into a PDF). */ getMapScreenshot: mmgisAPI_.getMapScreenshot, diff --git a/tests/unit/shareScreenshotApi.spec.js b/tests/unit/shareScreenshotApi.spec.js index 475eb44b2..119dc8b09 100644 --- a/tests/unit/shareScreenshotApi.spec.js +++ b/tests/unit/shareScreenshotApi.spec.js @@ -2,11 +2,11 @@ import { test, expect } from 'vitest' import getMapScreenshot, { getMapScreenshot as namedGetMapScreenshot, -} from '../../src/essence/Basics/UserInterface_/ScreenshotUtils.js' +} from '../../src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js' // Issue #143 - expose share-link and map-screenshot as first-class plugin API. // -// ScreenshotUtils.getMapScreenshot() drives the live DOM (jQuery) and rasterizes +// LeafletScreenshot.getMapScreenshot() drives the live DOM (jQuery) and rasterizes // with html2canvas, neither of which exists in this Node test context. The // function therefore accepts injectable `jquery`/`html2canvas` deps so the // behavior can be exercised against lightweight fakes. @@ -19,6 +19,9 @@ function makeMockJQuery(getterValue) { function makeNode(selector) { return { + // A matched selector reports one element; the code uses .length to + // detect whether the time UI was active before the capture. + length: 1, css(prop, value) { if (value === undefined) return getterValue // getter cssSets.push({ selector, prop, value }) @@ -72,7 +75,7 @@ function setupGlobalDom() { } } -test.describe('ScreenshotUtils.getMapScreenshot - export surface', () => { +test.describe('LeafletScreenshot.getMapScreenshot - export surface', () => { test('is exported as both a default and named function', () => { expect(typeof getMapScreenshot).toBe('function') expect(typeof namedGetMapScreenshot).toBe('function') @@ -80,7 +83,7 @@ test.describe('ScreenshotUtils.getMapScreenshot - export surface', () => { }) }) -test.describe('ScreenshotUtils.getMapScreenshot - behavior', () => { +test.describe('LeafletScreenshot.getMapScreenshot - behavior', () => { test.beforeEach(() => { setupGlobalDom() }) @@ -144,4 +147,23 @@ test.describe('ScreenshotUtils.getMapScreenshot - behavior', () => { expect(bottomValues).toEqual(['0px', '5px']) expect(bottomValues).not.toContain('savedMapToolBarBottom') }) + + test('collapses the time UI for the capture and reopens it afterwards', async () => { + // Regression guard: the time UI is toggled off (via the .active + // selector) before the shot and must be toggled back on (via the plain + // selector, since the click cleared .active) afterwards. Previously the + // restore was missing, leaving the time UI collapsed. + const jquery = makeMockJQuery('5px') + const html2canvas = makeMockHtml2canvas('data:image/png;base64,X') + + await getMapScreenshot({ jquery, html2canvas }) + + const timeToggleEvents = jquery._triggered.filter((t) => + t.selector.startsWith('#toggleTimeUI') + ) + expect(timeToggleEvents).toEqual([ + { selector: '#toggleTimeUI.active', event: 'click' }, + { selector: '#toggleTimeUI', event: 'click' }, + ]) + }) }) From 4d0933ce03dce9273261d5ac2911b6240de7ce16 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 1 Jul 2026 10:50:33 -0500 Subject: [PATCH 09/23] [143] test: cover engine delegation, share-link guard, and Leaflet capture Add behavioral coverage for the claims the PR previously only asserted: - mmgisAPI.getMapScreenshot() delegates to the active engine's captureScreenshot() and rejects when no engine is loaded (drives the real facade via the L_ singleton). - QueryURL.writeCoordinateURL() returns the full view URL synchronously as a string and, in the modern layout (no UserInterface_.getPanelPercents), falls back to a map-only pane split without throwing. - LeafletAdapter.captureScreenshot() actually delegates to the Leaflet screenshot strategy and returns its promise (was existence-only). The mmgisAPI/QueryURL specs stub the Viewer_ aggregator, whose JSX-in-.js viewers can't be parsed by vite's import-analysis in the jsdom env. --- tests/unit/LeafletAdapter.spec.js | 22 +++++-- tests/unit/mmgisApiScreenshot.spec.js | 51 +++++++++++++++ tests/unit/writeCoordinateURL.spec.js | 90 +++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 tests/unit/mmgisApiScreenshot.spec.js create mode 100644 tests/unit/writeCoordinateURL.spec.js diff --git a/tests/unit/LeafletAdapter.spec.js b/tests/unit/LeafletAdapter.spec.js index 08800651b..366840217 100644 --- a/tests/unit/LeafletAdapter.spec.js +++ b/tests/unit/LeafletAdapter.spec.js @@ -1,6 +1,14 @@ -import { test, expect } from 'vitest' +import { test, expect, vi } from 'vitest' import LeafletAdapter from '../../src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts' import { MAP_ENGINE } from '../../src/essence/Basics/MapEngines/types/engine.ts' +import { getMapScreenshot as mockedLeafletCapture } from '../../src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js' + +// Mock the Leaflet screenshot strategy so we can assert LeafletAdapter delegates +// to it (issue #143) without driving the real html2canvas/DOM path. +vi.mock('../../src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js', () => { + const fn = vi.fn(() => Promise.resolve('data:image/png;base64,LEAFLET')) + return { getMapScreenshot: fn, default: fn } +}) /** @@ -143,13 +151,19 @@ test.describe('LeafletAdapter - Lifecycle', () => { expect(adapter.getNativeMap()).toBeNull() }) - test('exposes captureScreenshot() as part of the IMapEngine contract', () => { + test('captureScreenshot() delegates to the Leaflet screenshot strategy', async () => { // The engine-aware screenshot path (issue #143) requires every adapter // to implement captureScreenshot(). LeafletAdapter delegates to the - // shared html2canvas helper; here we assert the method is present so - // mmgisAPI.getMapScreenshot() can call it uniformly. + // Leaflet screenshot helper; assert it actually calls it and returns the + // helper's promise (not just that the method exists). const adapter = new LeafletAdapter() expect(typeof adapter.captureScreenshot).toBe('function') + + mockedLeafletCapture.mockClear() + const result = await adapter.captureScreenshot() + + expect(mockedLeafletCapture).toHaveBeenCalledTimes(1) + expect(result).toBe('data:image/png;base64,LEAFLET') }) test('destroy() is safe to call when map is not initialized', () => { diff --git a/tests/unit/mmgisApiScreenshot.spec.js b/tests/unit/mmgisApiScreenshot.spec.js new file mode 100644 index 000000000..ecacc8177 --- /dev/null +++ b/tests/unit/mmgisApiScreenshot.spec.js @@ -0,0 +1,51 @@ +import { test, expect, vi, beforeEach, afterEach } from 'vitest' + +// Viewer_ pulls in Photosphere/ModelViewer/PDFViewer, which are JSX written in +// .js files that vite's import-analysis can't parse. Nothing in this test needs +// the real viewers, so stub the aggregator to keep the mmgisAPI import chain +// parseable in the jsdom test env. +vi.mock('../../src/essence/Basics/Viewer_/Viewer_', () => ({ default: {} })) + +import { mmgisAPI } from '../../src/essence/mmgisAPI/mmgisAPI' +import L_ from '../../src/essence/Basics/Layers_/Layers_' + +// Issue #143 - mmgisAPI.getMapScreenshot() is the public, engine-aware entry +// point. It must delegate to the active map engine's captureScreenshot() and +// reject when no engine is loaded (rather than reach into a specific engine's +// DOM). L_ is a shared singleton, so we drive L_.Map_.engine directly. + +test.describe('mmgisAPI.getMapScreenshot delegation (issue #143)', () => { + let savedMap + beforeEach(() => { + savedMap = L_.Map_ + }) + afterEach(() => { + L_.Map_ = savedMap + }) + + test('delegates to the active engine and returns its result', async () => { + const capture = vi.fn(() => + Promise.resolve('data:image/png;base64,ENGINE') + ) + L_.Map_ = { engine: { captureScreenshot: capture } } + + const result = await mmgisAPI.getMapScreenshot() + + expect(capture).toHaveBeenCalledTimes(1) + expect(result).toBe('data:image/png;base64,ENGINE') + }) + + test('rejects when no engine is active', async () => { + L_.Map_ = { engine: null } + await expect(mmgisAPI.getMapScreenshot()).rejects.toThrow( + /no active map engine/ + ) + }) + + test('rejects when the engine has no captureScreenshot method', async () => { + L_.Map_ = { engine: {} } + await expect(mmgisAPI.getMapScreenshot()).rejects.toThrow( + /no active map engine/ + ) + }) +}) diff --git a/tests/unit/writeCoordinateURL.spec.js b/tests/unit/writeCoordinateURL.spec.js new file mode 100644 index 000000000..0ead8822e --- /dev/null +++ b/tests/unit/writeCoordinateURL.spec.js @@ -0,0 +1,90 @@ +import { test, expect, vi, beforeEach, afterEach } from 'vitest' + +// Viewer_ pulls in Photosphere/ModelViewer/PDFViewer, which are JSX written in +// .js files that vite's import-analysis can't parse. Nothing here needs the real +// viewers, so stub the aggregator to keep the QueryURL import chain parseable in +// the jsdom test env. +vi.mock('../../src/essence/Basics/Viewer_/Viewer_', () => ({ default: {} })) + +import QueryURL from '../../src/essence/Ancillary/QueryURL' +import L_ from '../../src/essence/Basics/Layers_/Layers_' +import T_ from '../../src/essence/Basics/ToolController_/ToolController_' + +// Issue #143 - writeCoordinateURL() is the canonical share-link method: it must +// return the full view URL synchronously (a string, no backend call), and it +// must not throw in the modern layout, where UserInterface_.getPanelPercents() +// does not exist (classic/mobile only). The guard falls back to a map-only +// pane split there. + +// The set of L_ / T_ properties writeCoordinateURL touches. We stub the minimal +// surface so every branch reaches the return, then snapshot/restore around each +// test since L_ and T_ are shared singletons. +const TOUCHED = [ + 'Viewer_', + 'Map_', + 'Globe_', + 'mission', + 'site', + 'UserInterface_', + 'layers', + 'lastActiveFeature', + 'configData', +] + +let saved +let savedGetToolsUrl + +beforeEach(() => { + saved = {} + for (const key of TOUCHED) saved[key] = L_[key] + savedGetToolsUrl = T_.getToolsUrl + + L_.Viewer_ = { getLocation: () => false, getLastImageId: () => false } + L_.Map_ = { + map: { + getCenter: () => ({ lng: -122.5, lat: 37.8 }), + getZoom: () => 6, + }, + } + L_.Globe_ = { litho: { getCenter: () => null, getCameras: () => null } } + L_.mission = 'TestMission' + L_.site = '' + L_.layers = { on: {}, data: {}, opacity: {} } + L_.lastActiveFeature = { layerName: null } + L_.configData = { time: { enabled: false } } + T_.getToolsUrl = () => false +}) + +afterEach(() => { + for (const key of TOUCHED) L_[key] = saved[key] + T_.getToolsUrl = savedGetToolsUrl +}) + +test.describe('QueryURL.writeCoordinateURL (issue #143)', () => { + test('returns the full view URL synchronously as a string', () => { + L_.UserInterface_ = { + getPanelPercents: () => ({ viewer: 10, map: 70, globe: 20 }), + } + + const url = QueryURL.writeCoordinateURL() + + // Synchronous string, not a Promise. + expect(typeof url).toBe('string') + expect(url).toContain('mission=TestMission') + expect(url).toContain('mapLon=-122.5') + expect(url).toContain('mapLat=37.8') + expect(url).toContain('mapZoom=6') + expect(url).toContain('panePercents=10,70,20') + }) + + test('does not throw in the modern layout and falls back to a map-only split', () => { + // Modern layout: no getPanelPercents on UserInterface_. + L_.UserInterface_ = {} + + let url + expect(() => { + url = QueryURL.writeCoordinateURL() + }).not.toThrow() + expect(url).toContain('panePercents=0,100,0') + }) +}) From 112298dd3244ffa0892364593c431eb2e7b485b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Jul 2026 00:42:36 +0000 Subject: [PATCH 10/23] chore: bump version to 4.2.14-20260702 [version bump] --- configure/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/configure/package.json b/configure/package.json index 854d0d6d3..928106a84 100644 --- a/configure/package.json +++ b/configure/package.json @@ -1,6 +1,6 @@ { "name": "configure", - "version": "4.2.13-20260701", + "version": "4.2.14-20260702", "homepage": "./configure/build", "private": true, "dependencies": { diff --git a/package.json b/package.json index a3fd07e42..219d438e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mmgis", - "version": "4.2.13-20260701", + "version": "4.2.14-20260702", "description": "A web-based mapping and localization solution for science operation on planetary missions.", "homepage": "build", "repository": { From 8df273851638ef9fd6270661b12e1a90a9abf33e Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 1 Jul 2026 21:17:12 -0500 Subject: [PATCH 11/23] [143] fix: download screenshots via Blob decode, not fetch(data:) The shipped helmet CSP's connect-src does not allow the data: scheme, so the fetch(dataURL) round-trip failed to download on stock deployments. Decode base64 to a Blob directly in a new F_.downloadDataUrl helper (replacing the now-orphaned F_.downloadCanvas) and defer the object-URL revoke so browsers that dereference it asynchronously can't abort the download. --- src/essence/Basics/Formulae_/Formulae_.js | 36 ++++++++++++------- .../Basics/UserInterface_/BottomBar.js | 16 ++------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/essence/Basics/Formulae_/Formulae_.js b/src/essence/Basics/Formulae_/Formulae_.js index 0bcf8e5d1..18062fa36 100644 --- a/src/essence/Basics/Formulae_/Formulae_.js +++ b/src/essence/Basics/Formulae_/Formulae_.js @@ -1803,18 +1803,30 @@ var Formulae_ = { downloadAnchorNode.click() downloadAnchorNode.remove() }, - downloadCanvas(canvasId, name, callback) { - var link = document.createElement('a') - name = name ? name + '.png' : 'mmgis.png' - link.setAttribute('download', name) - document.getElementById(canvasId).toBlob(function (blob) { - var objUrl = URL.createObjectURL(blob) - link.setAttribute('href', objUrl) - document.body.appendChild(link) - link.click() - link.remove() - if (typeof callback === 'function') callback() - }) + // Downloads a data-URL image (e.g. canvas.toDataURL output) as a file. + // Decodes to a Blob and downloads via an object URL: large/hi-DPI captures + // inflate ~33% as base64 and data-URL anchor downloads are capped/unreliable + // across browsers (Chromium caps them around 2MB). fetch(dataUrl) is avoided + // deliberately — the shipped CSP's connect-src does not allow the data: + // scheme, so fetch-based conversion fails on stock deployments. + downloadDataUrl(dataUrl, filename) { + const [header, base64] = dataUrl.split(',') + const mimeMatch = header.match(/data:([^;,]+)/) + const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream' + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + const url = URL.createObjectURL(new Blob([bytes], { type: mime })) + const link = document.createElement('a') + link.setAttribute('download', filename) + link.setAttribute('href', url) + document.body.appendChild(link) // required for firefox + link.click() + link.remove() + // Revoke on a delay: browsers may dereference the blob URL + // asynchronously after the click, and an immediate revoke can abort + // the download. + setTimeout(() => URL.revokeObjectURL(url), 10000) }, getMinMaxOfArray(arrayOfNumbers) { return { diff --git a/src/essence/Basics/UserInterface_/BottomBar.js b/src/essence/Basics/UserInterface_/BottomBar.js index a8b73b407..77efd260a 100644 --- a/src/essence/Basics/UserInterface_/BottomBar.js +++ b/src/essence/Basics/UserInterface_/BottomBar.js @@ -76,7 +76,7 @@ let BottomBar = { window.mmgisAPI .getMapScreenshot() - .then(async function (dataURL) { + .then(function (dataURL) { const mission = L_.configData?.msv?.mission const time = L_.TimeControl_?.currentTime const mapCenter = L_.Map_.map.getCenter() @@ -86,19 +86,7 @@ let BottomBar = { time ? `${time.replaceAll(':', '-')}_` : '' }${lat}_${lng}.png` - // Download via a blob URL rather than the base64 data - // URL directly: large/hi-DPI captures inflate ~33% as - // base64 and large data-URL anchor downloads are less - // reliable across browsers. - const blob = await (await fetch(dataURL)).blob() - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.setAttribute('download', name) - link.setAttribute('href', url) - document.body.appendChild(link) - link.click() - link.remove() - URL.revokeObjectURL(url) + F_.downloadDataUrl(dataURL, name) setTimeout(function () { $('#topBarScreenshotLoading').css('display', 'none') From d11fd77ed7e1cc163672ca1591dff66a1db73e2c Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 1 Jul 2026 21:17:12 -0500 Subject: [PATCH 12/23] [143] fix: harden share-link and screenshot APIs against early/failed calls writeCoordinateURL returns null until mission finalization instead of letting QueryURL TypeError on unassigned L_.Viewer_/L_.Map_ (a plugin's always-visible Copy Link button is clickable during load). getMapScreenshot converts a synchronous engine throw into a rejection, and LeafletScreenshot restores the hidden UI chrome on the error path. --- .../MapEngines/Adapters/LeafletScreenshot.js | 142 ++++++++++-------- src/essence/mmgisAPI/mmgisAPI.js | 41 ++++- tests/unit/mmgisApiScreenshot.spec.js | 16 ++ 3 files changed, 137 insertions(+), 62 deletions(-) diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js b/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js index 4f4e07b9a..c1d97b44f 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js +++ b/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js @@ -47,73 +47,95 @@ function getMapScreenshot(deps = {}) { const timeUIWasActive = jquery('#toggleTimeUI.active').length > 0 if (timeUIWasActive) jquery('#toggleTimeUI.active').trigger('click') + // Restore the UI chrome hidden above. Split out so the error path below + // can restore too — without it, a synchronous throw between the hide and + // the capture would leave controls hidden and the time UI collapsed. + const restoreChrome = function () { + jquery('#map .leaflet-tile-pane') + .children() + .each(function (i, elm) { + jquery(elm).css('z-index', zIndices[i]) + }) + jquery('.leaflet-control-scalefactor').css('display', 'flex') + jquery('#mmgis-map-compass').css('display', 'block') + jquery('.leaflet-control-zoom').css('display', 'block') + jquery('#scaleBar').css('margin-top', '5px') + jquery('#mapToolBar').css('bottom', savedMapToolBarBottom) + if (timeUIWasActive) jquery('#toggleTimeUI').trigger('click') + } + // The classic UI wraps the map in #mapScreen; the modern layout has no such // wrapper, so fall back to the #map container (present in both layouts). const documentElm = document.getElementById('mapScreen') || document.getElementById('map') - const capture = html2canvas(documentElm, { - allowTaint: true, - useCORS: true, - logging: false, - scrollX: -window.scrollX, - scrollY: -window.scrollY, - windowWidth: documentElm.offsetWidth, - windowHeight: documentElm.offsetHeight, - onclone: function (e) { - // Fix svg layer shift - const originalSVG = document.body.querySelectorAll( - 'svg.leaflet-zoom-animated' - ) - const copySVG = e.body.querySelectorAll( - 'svg.leaflet-zoom-animated' - ) - copySVG.forEach((copyEle, i) => { - const attribute = originalSVG - .item(i) - .getAttribute('style') - const parentElement = copyEle.parentElement - parentElement.removeChild(copyEle) - const temp = document.createElement('div') - temp.appendChild(copyEle) - parentElement.appendChild(temp) - temp.setAttribute('style', attribute) - copyEle.removeAttribute('style') - }) + if (!documentElm) { + restoreChrome() + return Promise.reject( + new Error('getMapScreenshot: no #mapScreen/#map container to capture') + ) + } - // Fix tile layer z-indices - const originalZ = document.body.querySelectorAll( - '.leaflet-tile-pane > div.leaflet-layer' - ) - const copyZ = e.body.querySelectorAll( - '.leaflet-tile-pane > div.leaflet-layer' - ) - copyZ.forEach((copyEle, i) => { - const attribute = originalZ - .item(i) - .getAttribute('style') - copyEle.setAttribute('style', attribute) - }) - }, - }).then(function (canvas) { - return canvas.toDataURL('image/png') - }) + let capture + try { + capture = html2canvas(documentElm, { + allowTaint: true, + useCORS: true, + logging: false, + scrollX: -window.scrollX, + scrollY: -window.scrollY, + windowWidth: documentElm.offsetWidth, + windowHeight: documentElm.offsetHeight, + onclone: function (e) { + // Fix svg layer shift + const originalSVG = document.body.querySelectorAll( + 'svg.leaflet-zoom-animated' + ) + const copySVG = e.body.querySelectorAll( + 'svg.leaflet-zoom-animated' + ) + copySVG.forEach((copyEle, i) => { + const attribute = originalSVG + .item(i) + .getAttribute('style') + const parentElement = copyEle.parentElement + parentElement.removeChild(copyEle) + const temp = document.createElement('div') + temp.appendChild(copyEle) + parentElement.appendChild(temp) + temp.setAttribute('style', attribute) + copyEle.removeAttribute('style') + }) - // Restore the UI chrome we hid for the capture. This runs immediately - // (not in .then) because html2canvas clones the DOM synchronously within - // the call above, before its first internal await, so the capture already - // holds the hidden-chrome state and restoring the live UI now does not - // affect it. - jquery('#map .leaflet-tile-pane') - .children() - .each(function (i, elm) { - jquery(elm).css('z-index', zIndices[i]) + // Fix tile layer z-indices + const originalZ = document.body.querySelectorAll( + '.leaflet-tile-pane > div.leaflet-layer' + ) + const copyZ = e.body.querySelectorAll( + '.leaflet-tile-pane > div.leaflet-layer' + ) + copyZ.forEach((copyEle, i) => { + const attribute = originalZ + .item(i) + .getAttribute('style') + copyEle.setAttribute('style', attribute) + }) + }, + }).then(function (canvas) { + return canvas.toDataURL('image/png') }) - jquery('.leaflet-control-scalefactor').css('display', 'flex') - jquery('#mmgis-map-compass').css('display', 'block') - jquery('.leaflet-control-zoom').css('display', 'block') - jquery('#scaleBar').css('margin-top', '5px') - jquery('#mapToolBar').css('bottom', savedMapToolBarBottom) - if (timeUIWasActive) jquery('#toggleTimeUI').trigger('click') + } catch (err) { + restoreChrome() + throw err + } + + // Restore the UI chrome we hid for the capture. This runs immediately + // (not in .then) so the live UI isn't visibly degraded for the duration of + // the capture; html2canvas's initial DOM clone happens synchronously within + // the call above. (Caveat: html2canvas's `onclone` fires later and copies + // some styles from the live document, so restored values can leak into the + // clone's tile z-indices — a pre-existing quirk inherited from the old + // BottomBar implementation.) + restoreChrome() return capture } diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index 2cc15bbdf..de8475dbb 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -438,8 +438,31 @@ var mmgisAPI_ = { return validEvents.includes(eventName) }, writeCoordinateURL: function () { + // The URL builder dereferences L_.Viewer_ / L_.Map_.map / TimeControl, + // which only exist after mission finalization (fina). Until then return + // null — the documented "no link available yet" signal — so an early + // caller (e.g. a plugin's always-visible Copy Link button clicked while + // layers are still loading) doesn't hit a TypeError. + if (mmgisAPI_.map == null) return null return QueryURL.writeCoordinateURL() }, + getViewState: function () { + // View metadata for plugins (e.g. provenance-rich export filenames) + // without reaching into core internals. Fields are null until the + // mission has loaded far enough to answer them. + const map = L_.Map_ && L_.Map_.map + const center = + map && typeof map.getCenter === 'function' ? map.getCenter() : null + return { + missionName: L_.configData?.msv?.mission ?? null, + time: L_.TimeControl_?.currentTime ?? null, + center: center ? { lat: center.lat, lng: center.lng } : null, + zoom: + map && typeof map.getZoom === 'function' + ? map.getZoom() + : null, + } + }, getMapScreenshot: function () { // Screenshot capture is engine-specific: Leaflet rasterizes its DOM // with html2canvas, while the deck.gl/GL map reads its WebGL canvas @@ -449,7 +472,14 @@ var mmgisAPI_ = { // loaded yet — reject rather than reach into a specific engine's DOM. const engine = L_.Map_ && L_.Map_.engine if (engine && typeof engine.captureScreenshot === 'function') { - return engine.captureScreenshot() + // The engine does synchronous DOM work before its promise exists; + // catch a sync throw so callers always get a rejection, never an + // exception escaping what is documented as a promise-returning API. + try { + return Promise.resolve(engine.captureScreenshot()) + } catch (err) { + return Promise.reject(err) + } } return Promise.reject( new Error('getMapScreenshot: no active map engine to capture') @@ -734,10 +764,17 @@ var mmgisAPI = { /** writeCoordinateURL - writes out the current view as a url. This returns the long form of * the 'Copy Link' feature and does not save a short url to the database. - * @returns {string} - a string containing the current view as a url + * @returns {string|null} - a string containing the current view as a url, or null if the mission has not finished loading yet */ writeCoordinateURL: mmgisAPI_.writeCoordinateURL, + /** getViewState - returns metadata about the current view (for example to + * build provenance-rich export filenames). Fields are null until the + * mission has loaded far enough to answer them. + * @returns {object} {missionName: string|null, time: string|null, center: {lat, lng}|null, zoom: number|null} + */ + getViewState: mmgisAPI_.getViewState, + /** getMapScreenshot - captures a PNG screenshot of the current map view. * Delegates to the active map engine, so the capture strategy is * engine-specific: the Leaflet engine rasterizes its DOM (hiding UI chrome diff --git a/tests/unit/mmgisApiScreenshot.spec.js b/tests/unit/mmgisApiScreenshot.spec.js index ecacc8177..4eb8dba69 100644 --- a/tests/unit/mmgisApiScreenshot.spec.js +++ b/tests/unit/mmgisApiScreenshot.spec.js @@ -48,4 +48,20 @@ test.describe('mmgisAPI.getMapScreenshot delegation (issue #143)', () => { /no active map engine/ ) }) + + // The engine does synchronous DOM work before its promise exists (e.g. + // Leaflet's chrome hiding); a sync throw must surface as a rejection, not + // an exception escaping the promise-returning API. + test('converts a synchronous engine throw into a rejection', async () => { + L_.Map_ = { + engine: { + captureScreenshot: () => { + throw new Error('boom before promise') + }, + }, + } + await expect(mmgisAPI.getMapScreenshot()).rejects.toThrow( + /boom before promise/ + ) + }) }) From 7ff1b2203f8642f35b49edcd30bb868874c57052 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 1 Jul 2026 21:17:25 -0500 Subject: [PATCH 13/23] [143] perf(deckgl): capture on demand, drop always-on preserveDrawingBuffer preserveDrawingBuffer:true imposed a permanent per-frame buffer-copy and an extra full-DPR framebuffer on every deck.gl dashboard to support occasional screenshots. Capture instead via basemap.once('render') + triggerRepaint() (same-frame readback, 3s safety timeout); the standalone path keeps its synchronous redraw('screenshot') + toDataURL, which deck.gl v9 draws in the same call stack. Makes the previously dead BasemapInstance.once() member load-bearing and removes the unused loaded()/redraw() members. --- .../MapEngines/Adapters/DeckGLAdapter.ts | 94 +++++++++---------- tests/unit/deckGLAdapter.spec.js | 65 +++++++++---- tests/unit/deckGLScreenshotConfig.spec.js | 33 ++++--- 3 files changed, 112 insertions(+), 80 deletions(-) diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 7131d6b90..899dce5cb 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -129,14 +129,17 @@ interface BasemapInstance { resize(): void /** Return the WebGL canvas element the base map renders into. */ getCanvas(): HTMLCanvasElement - /** Force a synchronous re-render of the map (maplibre-gl). */ - redraw?(): void /** Schedule a re-render on the next animation frame (mapbox-gl + maplibre-gl). */ - triggerRepaint?(): void - /** Whether the map's style and sources are fully loaded and idle. */ - loaded?(): boolean + triggerRepaint(): void } +/** + * How long {@link DeckGLAdapter.captureScreenshot} waits for the basemap's + * `render` event before rejecting. Generous enough for a slow first frame, + * short enough that a dead map fails fast. + */ +const SCREENSHOT_RENDER_TIMEOUT_MS = 3000 + /** * DeckGL map engine adapter. * @@ -377,18 +380,25 @@ export class DeckGLAdapter implements IMapEngine { /** * Capture the current map view as a PNG data URL. * - * WebGL clears its drawing buffer after every composite, so a canvas - * `toDataURL()` returns blank unless the GL context was created with - * `preserveDrawingBuffer: true` (set in {@link _setupOverlay}) AND the read - * happens in the same frame as a render. We therefore force a repaint and - * read the canvas on the next animation frame. + * WebGL clears its drawing buffer once the browser presents a frame, so + * `canvas.toDataURL()` only returns pixels if the read happens before + * that clear. Rather than paying the per-frame cost of creating the GL + * context with `preserveDrawingBuffer: true`, we capture on demand: * * - **Overlay mode** (the modern map): the `MapboxOverlay` runs in * `interleaved: true` mode, so deck.gl draws into the base map's GL * context — there is a single canvas holding basemap + deck layers. - * We repaint the base map and read `basemap.getCanvas().toDataURL()`. - * - **Standalone mode**: deck.gl owns the only canvas; we redraw the - * `Deck` instance and read its canvas. + * We subscribe `once('render')` and call `triggerRepaint()`: the + * `render` event fires after the frame's draw but before the browser + * presents (and clears) the buffer — the documented maplibre/mapbox + * pattern for readback without `preserveDrawingBuffer`. A timeout + * rejects if the render event never fires (e.g. destroyed map). + * - **Standalone mode**: deck.gl owns the only canvas. In deck.gl v9, + * `deck.redraw(reason)` draws synchronously (`redraw` -> `_drawLayers` + * -> `DeckRenderer.renderLayers` all in the same call stack), so + * reading the canvas immediately afterwards in the same task is safe: + * the drawing buffer is only cleared when the browser presents, after + * the current task completes. * * Note: this captures only the GL canvas. Anchored HTML overlays/markers * added via {@link addOverlay} are separate DOM nodes and are not included. @@ -398,27 +408,22 @@ export class DeckGLAdapter implements IMapEngine { try { if (this._isOverlayMode && this._basemap) { const basemap = this._basemap - // Force a synchronous repaint, then read back in the same - // frame. redraw() (maplibre-gl) paints synchronously; - // triggerRepaint() (both libs) schedules a frame, so we - // fall back to a rAF read for that path. - const read = () => { - const canvas = basemap.getCanvas() - resolve(canvas.toDataURL('image/png')) - } - if (typeof basemap.redraw === 'function') { - basemap.redraw() - read() - } else { - basemap.triggerRepaint?.() - requestAnimationFrame(() => { - try { - read() - } catch (err) { - reject(err as Error) - } - }) - } + const timeout = setTimeout(() => { + reject( + new Error( + '[DeckGLAdapter] captureScreenshot: timed out waiting for the basemap render event' + ) + ) + }, SCREENSHOT_RENDER_TIMEOUT_MS) + basemap.once('render', () => { + clearTimeout(timeout) + try { + resolve(basemap.getCanvas().toDataURL('image/png')) + } catch (err) { + reject(err as Error) + } + }) + basemap.triggerRepaint() return } @@ -427,6 +432,8 @@ export class DeckGLAdapter implements IMapEngine { reject(new Error('[DeckGLAdapter] captureScreenshot: no active map to capture')) return } + // Synchronous in deck.gl v9 — pixels are guaranteed present + // for the toDataURL() read below (same task, pre-present). deck.redraw('screenshot') const canvas = (deck as unknown as { getCanvas?: () => HTMLCanvasElement }) .getCanvas?.() @@ -1118,12 +1125,9 @@ export class DeckGLAdapter implements IMapEngine { private _initStandaloneMode(): void { this._deck = new Deck({ parent: this._container, - // Preserve the drawing buffer so captureScreenshot() can read the - // rendered frame (parity with the overlay path in _setupOverlay). - // deck.gl v9 defaults this to true, but we set it explicitly via - // deviceProps.webgl so the guarantee is not dependent on that - // default. (The v8-era `glOptions` prop no longer exists in v9.) - deviceProps: { webgl: { preserveDrawingBuffer: true } }, + // No preserveDrawingBuffer needed: captureScreenshot() reads the + // canvas synchronously after deck.redraw(), before the browser + // presents (and clears) the drawing buffer. width: '100%', height: '100%', controller: true, @@ -1192,13 +1196,9 @@ export class DeckGLAdapter implements IMapEngine { minZoom: this._minZoom, maxZoom: this._maxZoom, projection: 'mercator', - // Required so the GL canvas can be read back via toDataURL() in - // captureScreenshot(). WebGL clears its drawing buffer after each - // composite unless this is set, so without it the export is blank. - // Must be set at map-creation time — it cannot be toggled later. - // Slight perf/memory cost (an extra buffer copy per frame), which - // is acceptable for a basemap-backed deck.gl view. - preserveDrawingBuffer: true, + // No preserveDrawingBuffer needed: captureScreenshot() reads the + // canvas inside a once('render') handler, in the same frame the + // map draws — before the drawing buffer is presented and cleared. } if (basemap.provider === 'mapbox' && basemap.accessToken) { diff --git a/tests/unit/deckGLAdapter.spec.js b/tests/unit/deckGLAdapter.spec.js index 0bf734d32..7b7c78faf 100644 --- a/tests/unit/deckGLAdapter.spec.js +++ b/tests/unit/deckGLAdapter.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from 'vitest' +import { test, expect, vi } from 'vitest' import { DeckGLAdapter } from '../../src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts' // Import MAP_ENGINE from the lightweight types module rather than MapEngines/index.ts. // index.ts transitively imports LeafletAdapter -> leaflet, which references a global @@ -219,43 +219,76 @@ test.describe('DeckGLAdapter', () => { }) test.describe('captureScreenshot', () => { - test('overlay mode reads the basemap GL canvas after a redraw', async () => { + test('overlay mode reads the canvas inside the render event after triggerRepaint', async () => { const adapter = makeAdapter() - let redrawn = false + let inRenderFrame = false + let renderHandler = null const canvas = { toDataURL: (type) => { expect(type).toBe('image/png') - // Only valid once a render has occurred this frame. - return redrawn + // Only valid during the render event, before the browser + // presents (and clears) the drawing buffer. + return inRenderFrame ? 'data:image/png;base64,DECKGL' : 'data:image/png;base64,BLANK' }, } adapter._isOverlayMode = true adapter._basemap = { - redraw: () => { redrawn = true }, + once: (type, handler) => { + expect(type).toBe('render') + renderHandler = handler + }, + triggerRepaint: () => { + // Simulate the frame the repaint schedules: the map draws, + // fires 'render' while the buffer still holds pixels, then + // the buffer is cleared on present. + inRenderFrame = true + renderHandler() + inRenderFrame = false + }, getCanvas: () => canvas, } const result = await adapter.captureScreenshot() - expect(redrawn).toBe(true) expect(result).toBe('data:image/png;base64,DECKGL') }) - test('overlay mode without redraw() falls back to triggerRepaint + rAF', async () => { - global.requestAnimationFrame = - global.requestAnimationFrame || ((cb) => setTimeout(() => cb(0), 0)) + test('overlay mode rejects when toDataURL throws during the render event', async () => { const adapter = makeAdapter() - let repainted = false + let renderHandler = null adapter._isOverlayMode = true adapter._basemap = { - triggerRepaint: () => { repainted = true }, - getCanvas: () => ({ toDataURL: () => 'data:image/png;base64,RAF' }), + once: (_type, handler) => { renderHandler = handler }, + triggerRepaint: () => renderHandler(), + getCanvas: () => ({ + toDataURL: () => { throw new Error('tainted canvas') }, + }), } - const result = await adapter.captureScreenshot() - expect(repainted).toBe(true) - expect(result).toBe('data:image/png;base64,RAF') + await expect(adapter.captureScreenshot()).rejects.toThrow(/tainted canvas/) + }) + + test('overlay mode rejects after the timeout if the render event never fires', async () => { + vi.useFakeTimers() + try { + const adapter = makeAdapter() + let repainted = false + adapter._isOverlayMode = true + adapter._basemap = { + once: () => {}, + triggerRepaint: () => { repainted = true }, + getCanvas: () => ({ toDataURL: () => 'data:image/png;base64,NEVER' }), + } + + const capture = adapter.captureScreenshot() + const assertion = expect(capture).rejects.toThrow(/timed out/) + vi.advanceTimersByTime(3000) + await assertion + expect(repainted).toBe(true) + } finally { + vi.useRealTimers() + } }) test('standalone mode redraws deck and reads its canvas', async () => { diff --git a/tests/unit/deckGLScreenshotConfig.spec.js b/tests/unit/deckGLScreenshotConfig.spec.js index c62fd589c..9a8e781d1 100644 --- a/tests/unit/deckGLScreenshotConfig.spec.js +++ b/tests/unit/deckGLScreenshotConfig.spec.js @@ -1,17 +1,16 @@ import { test, expect, vi, beforeEach } from 'vitest' -// Issue #143 - the deck.gl screenshot path depends on the GL context being -// created with `preserveDrawingBuffer: true`; without it, canvas.toDataURL() -// returns a blank image. The captureScreenshot() tests inject fake -// _basemap/_deck and never call init(), so nothing guards that init() actually -// requests that setting. These tests drive the real init() path with the GL -// constructors mocked, and assert the setting is passed through on both the -// standalone (Deck) and overlay (maplibre Map) paths. -// -// Regression guard specifically for the deck.gl v9 shape: the setting lives at -// deviceProps.webgl.preserveDrawingBuffer on Deck (the v8 `glOptions` prop no -// longer exists in v9), and preserveDrawingBuffer at the top level of the -// maplibre/mapbox Map constructor options. +// Issue #143 - the deck.gl screenshot path captures on demand instead of +// paying the per-frame cost of `preserveDrawingBuffer: true`: +// - overlay mode reads the canvas inside a once('render') handler after +// triggerRepaint(), in the same frame the map draws (before the browser +// presents and clears the buffer); +// - standalone mode reads immediately after deck.redraw('screenshot'), which +// draws synchronously in deck.gl v9. +// Nothing should request preserveDrawingBuffer at init time any more. These +// tests drive the real init() path with the GL constructors mocked and assert +// the flag is absent on both the standalone (Deck) and overlay (maplibre Map) +// paths — a regression guard against reintroducing the always-on buffer copy. const deckCtorArgs = [] const maplibreCtorArgs = [] @@ -67,22 +66,22 @@ function makeContainer(id = 'map') { return el } -test.describe('DeckGLAdapter init sets preserveDrawingBuffer (issue #143)', () => { +test.describe('DeckGLAdapter init does not set preserveDrawingBuffer (issue #143)', () => { beforeEach(() => { deckCtorArgs.length = 0 maplibreCtorArgs.length = 0 makeContainer('map') }) - test('standalone mode creates the Deck with deviceProps.webgl.preserveDrawingBuffer', () => { + test('standalone mode creates the Deck without preserveDrawingBuffer deviceProps', () => { const adapter = new DeckGLAdapter() adapter.init({ containerId: 'map', zoom: 4, center: { lat: 0, lng: 0 } }) expect(deckCtorArgs).toHaveLength(1) - expect(deckCtorArgs[0].deviceProps.webgl.preserveDrawingBuffer).toBe(true) + expect(deckCtorArgs[0].deviceProps?.webgl?.preserveDrawingBuffer).toBeUndefined() }) - test('maplibre overlay mode creates the Map with preserveDrawingBuffer', () => { + test('maplibre overlay mode creates the Map without preserveDrawingBuffer', () => { const adapter = new DeckGLAdapter() adapter.init({ containerId: 'map', @@ -92,6 +91,6 @@ test.describe('DeckGLAdapter init sets preserveDrawingBuffer (issue #143)', () = }) expect(maplibreCtorArgs).toHaveLength(1) - expect(maplibreCtorArgs[0].preserveDrawingBuffer).toBe(true) + expect(maplibreCtorArgs[0].preserveDrawingBuffer).toBeUndefined() }) }) From 973ba1794b675c6339ac40d26451d7391f6d820b Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 1 Jul 2026 21:17:41 -0500 Subject: [PATCH 14/23] [143] test: cover getViewState and the writeCoordinateURL readiness guard --- tests/unit/mmgisApiViewState.spec.js | 75 ++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/unit/mmgisApiViewState.spec.js diff --git a/tests/unit/mmgisApiViewState.spec.js b/tests/unit/mmgisApiViewState.spec.js new file mode 100644 index 000000000..f143408cb --- /dev/null +++ b/tests/unit/mmgisApiViewState.spec.js @@ -0,0 +1,75 @@ +import { test, expect, vi, beforeEach, afterEach } from 'vitest' + +// Viewer_ pulls in Photosphere/ModelViewer/PDFViewer, which are JSX written in +// .js files that vite's import-analysis can't parse. Nothing in this test needs +// the real viewers, so stub the aggregator to keep the mmgisAPI import chain +// parseable in the jsdom test env. +vi.mock('../../src/essence/Basics/Viewer_/Viewer_', () => ({ default: {} })) + +import { mmgisAPI } from '../../src/essence/mmgisAPI/mmgisAPI' +import L_ from '../../src/essence/Basics/Layers_/Layers_' + +// Issue #143 - readiness guards on the public share-link surface, and the +// getViewState() metadata plugins use for provenance-rich export filenames. +// L_ is a shared singleton, so we drive its fields directly and restore. + +const TOUCHED = ['Map_', 'configData', 'TimeControl_'] + +test.describe('mmgisAPI.writeCoordinateURL readiness guard (issue #143)', () => { + let saved + beforeEach(() => { + saved = {} + TOUCHED.forEach((k) => (saved[k] = L_[k])) + }) + afterEach(() => { + TOUCHED.forEach((k) => (L_[k] = saved[k])) + mmgisAPI.map = null + }) + + test('returns null before mission finalization instead of throwing', () => { + // Before fina() runs, mmgisAPI.map is unset and QueryURL's + // dereferences (L_.Viewer_, L_.Map_.map, ...) would TypeError. + mmgisAPI.map = null + expect(mmgisAPI.writeCoordinateURL()).toBeNull() + }) +}) + +test.describe('mmgisAPI.getViewState (issue #143)', () => { + let saved + beforeEach(() => { + saved = {} + TOUCHED.forEach((k) => (saved[k] = L_[k])) + }) + afterEach(() => { + TOUCHED.forEach((k) => (L_[k] = saved[k])) + }) + + test('returns all-null fields when nothing has loaded', () => { + L_.Map_ = null + L_.configData = null + L_.TimeControl_ = null + expect(mmgisAPI.getViewState()).toEqual({ + missionName: null, + time: null, + center: null, + zoom: null, + }) + }) + + test('reports mission, time, center, and zoom once loaded', () => { + L_.Map_ = { + map: { + getCenter: () => ({ lat: 4.5, lng: 137.4 }), + getZoom: () => 7, + }, + } + L_.configData = { msv: { mission: 'MSL' } } + L_.TimeControl_ = { currentTime: '2026-07-01T12:00:00Z' } + expect(mmgisAPI.getViewState()).toEqual({ + missionName: 'MSL', + time: '2026-07-01T12:00:00Z', + center: { lat: 4.5, lng: 137.4 }, + zoom: 7, + }) + }) +}) From e6e40416e27bbee04654f33275d92efbe7079d49 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 1 Jul 2026 23:39:21 -0500 Subject: [PATCH 15/23] Restore full-mode URL shortening fallback --- src/essence/Ancillary/QueryURL.js | 28 ++++++++ .../Basics/UserInterface_/BottomBar.js | 26 ++++---- src/pre/calls.js | 4 ++ src/pre/staticHandlers.js | 1 + tests/unit/staticHandlers.spec.js | 4 +- tests/unit/writeCoordinateURL.spec.js | 65 +++++++++++++++++++ 6 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/essence/Ancillary/QueryURL.js b/src/essence/Ancillary/QueryURL.js index b3363327c..42d237cda 100644 --- a/src/essence/Ancillary/QueryURL.js +++ b/src/essence/Ancillary/QueryURL.js @@ -4,6 +4,7 @@ import F_ from '../Basics/Formulae_/Formulae_' import L_ from '../Basics/Layers_/Layers_' import T_ from '../Basics/ToolController_/ToolController_' import calls from '../../pre/calls' +import { isStaticBuild } from '../../pre/capabilities' import TimeControl from '../Basics/TimeControl_/TimeControl' import TimeUI from '../Basics/TimeControl_/TimeUI' @@ -431,6 +432,33 @@ var QueryURL = { return window.location.href.split('?')[0] + url }, + getShareURL: function (callback) { + var fullUrl = this.writeCoordinateURL() + + if (isStaticBuild()) { + if (typeof callback === 'function') callback(fullUrl) + return + } + + var baseUrl = window.location.href.split('?')[0] + var urlAppendage = fullUrl.startsWith(baseUrl) + ? fullUrl.substring(baseUrl.length) + : fullUrl.substring(fullUrl.indexOf('?')) + + calls.api( + 'shortener_shorten', + { + url: urlAppendage, + }, + function (s) { + var shortUrl = baseUrl + '?s=' + s.body.url + if (typeof callback === 'function') callback(shortUrl) + }, + function () { + if (typeof callback === 'function') callback(fullUrl) + } + ) + }, writeSearchURL: function (searchStrs, searchFile) { return //!!!!!!!!!!!!!!!! /* diff --git a/src/essence/Basics/UserInterface_/BottomBar.js b/src/essence/Basics/UserInterface_/BottomBar.js index 77efd260a..33a8b6e6e 100644 --- a/src/essence/Basics/UserInterface_/BottomBar.js +++ b/src/essence/Basics/UserInterface_/BottomBar.js @@ -31,18 +31,20 @@ let BottomBar = { }) .on('click', function () { const linkButton = $(this) - L_.url = QueryURL.writeCoordinateURL() - window.history.replaceState('', '', L_.url) - F_.copyToClipboard(L_.url) - - linkButton.removeClass('mdi-open-in-new') - linkButton.addClass('mdi-check-bold') - linkButton.css('color', 'var(--color-green)') - setTimeout(() => { - linkButton.removeClass('mdi-check-bold') - linkButton.css('color', '') - linkButton.addClass('mdi-open-in-new') - }, 3000) + QueryURL.getShareURL(function (url) { + L_.url = url + window.history.replaceState('', '', L_.url) + F_.copyToClipboard(L_.url) + + linkButton.removeClass('mdi-open-in-new') + linkButton.addClass('mdi-check-bold') + linkButton.css('color', 'var(--color-green)') + setTimeout(() => { + linkButton.removeClass('mdi-check-bold') + linkButton.css('color', '') + linkButton.addClass('mdi-open-in-new') + }, 3000) + }) }) bottomBar.append(topBarLink) diff --git a/src/pre/calls.js b/src/pre/calls.js index 1f92175cb..dcef97e5a 100644 --- a/src/pre/calls.js +++ b/src/pre/calls.js @@ -117,6 +117,10 @@ const c = { type: 'POST', url: 'api/files/gethistory', }, + shortener_shorten: { + type: 'POST', + url: 'api/shortener/shorten', + }, shortener_expand: { type: 'POST', url: 'api/shortener/expand', diff --git a/src/pre/staticHandlers.js b/src/pre/staticHandlers.js index ee18da283..88fc1036c 100644 --- a/src/pre/staticHandlers.js +++ b/src/pre/staticHandlers.js @@ -97,6 +97,7 @@ const STATIC_HANDLERS = { files_publish: drop(), files_gethistory: drop(), // Drop — modules not deployed alongside dashboards + shortener_shorten: drop(), shortener_expand: drop(), clear_test: drop(), tactical_targets: drop(), diff --git a/tests/unit/staticHandlers.spec.js b/tests/unit/staticHandlers.spec.js index e876dddf1..b63c1814b 100644 --- a/tests/unit/staticHandlers.spec.js +++ b/tests/unit/staticHandlers.spec.js @@ -26,8 +26,8 @@ const getHandlerNames = () => { } test.describe('staticHandlers parity with calls.js', () => { - test('calls.js registry has the expected 39 entries', () => { - expect(getCallNames()).toHaveLength(39) + test('calls.js registry has the expected 40 entries', () => { + expect(getCallNames()).toHaveLength(40) }) test('every calls.js entry has a STATIC_HANDLERS handler', () => { diff --git a/tests/unit/writeCoordinateURL.spec.js b/tests/unit/writeCoordinateURL.spec.js index 0ead8822e..335bbbcd4 100644 --- a/tests/unit/writeCoordinateURL.spec.js +++ b/tests/unit/writeCoordinateURL.spec.js @@ -5,10 +5,20 @@ import { test, expect, vi, beforeEach, afterEach } from 'vitest' // viewers, so stub the aggregator to keep the QueryURL import chain parseable in // the jsdom test env. vi.mock('../../src/essence/Basics/Viewer_/Viewer_', () => ({ default: {} })) +vi.mock('../../src/pre/calls', () => ({ + default: { + api: vi.fn(), + }, +})) +vi.mock('../../src/pre/capabilities', () => ({ + isStaticBuild: vi.fn(() => false), +})) import QueryURL from '../../src/essence/Ancillary/QueryURL' import L_ from '../../src/essence/Basics/Layers_/Layers_' import T_ from '../../src/essence/Basics/ToolController_/ToolController_' +import calls from '../../src/pre/calls' +import { isStaticBuild } from '../../src/pre/capabilities' // Issue #143 - writeCoordinateURL() is the canonical share-link method: it must // return the full view URL synchronously (a string, no backend call), and it @@ -35,6 +45,9 @@ let saved let savedGetToolsUrl beforeEach(() => { + vi.mocked(calls.api).mockReset() + vi.mocked(isStaticBuild).mockReturnValue(false) + saved = {} for (const key of TOUCHED) saved[key] = L_[key] savedGetToolsUrl = T_.getToolsUrl @@ -88,3 +101,55 @@ test.describe('QueryURL.writeCoordinateURL (issue #143)', () => { expect(url).toContain('panePercents=0,100,0') }) }) + +test.describe('QueryURL.getShareURL (issue #143)', () => { + test('returns the long URL without shortening in static builds', () => { + vi.mocked(isStaticBuild).mockReturnValue(true) + L_.UserInterface_ = {} + + const callback = vi.fn() + + QueryURL.getShareURL(callback) + + expect(calls.api).not.toHaveBeenCalled() + expect(callback).toHaveBeenCalledWith( + expect.stringContaining('mission=TestMission') + ) + expect(callback.mock.calls[0][0]).toContain('panePercents=0,100,0') + }) + + test('returns a short URL when the full-mode shortener succeeds', () => { + L_.UserInterface_ = {} + vi.mocked(calls.api).mockImplementation((call, data, success) => { + success({ body: { url: 'abc12' } }) + }) + const callback = vi.fn() + + QueryURL.getShareURL(callback) + + expect(calls.api).toHaveBeenCalledWith( + 'shortener_shorten', + { url: expect.stringContaining('?mission=TestMission') }, + expect.any(Function), + expect.any(Function) + ) + expect(callback).toHaveBeenCalledWith( + 'http://localhost:3000/?s=abc12' + ) + }) + + test('falls back to the long URL when shortening fails', () => { + L_.UserInterface_ = {} + vi.mocked(calls.api).mockImplementation((call, data, success, error) => { + error() + }) + const callback = vi.fn() + + QueryURL.getShareURL(callback) + + expect(callback).toHaveBeenCalledWith( + expect.stringContaining('mission=TestMission') + ) + expect(callback.mock.calls[0][0]).not.toContain('?s=') + }) +}) From 35037a371e9176e83c2279d6b0e161f5c3131a4b Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 1 Jul 2026 23:43:53 -0500 Subject: [PATCH 16/23] Return map screenshots as Blob results --- src/essence/Basics/Formulae_/Formulae_.js | 5 +- .../MapEngines/Adapters/DeckGLAdapter.ts | 39 ++++-- .../MapEngines/Adapters/LeafletAdapter.ts | 7 +- .../MapEngines/Adapters/LeafletScreenshot.js | 43 ++++-- src/essence/Basics/MapEngines/IMapEngine.ts | 13 +- .../Basics/UserInterface_/BottomBar.js | 10 +- src/essence/mmgisAPI/mmgisAPI.js | 20 +-- tests/unit/LeafletAdapter.spec.js | 15 +- tests/unit/deckGLAdapter.spec.js | 131 +++++++++++------- tests/unit/mmgisApiScreenshot.spec.js | 11 +- tests/unit/shareScreenshotApi.spec.js | 52 +++++-- 11 files changed, 237 insertions(+), 109 deletions(-) diff --git a/src/essence/Basics/Formulae_/Formulae_.js b/src/essence/Basics/Formulae_/Formulae_.js index 18062fa36..5b35db030 100644 --- a/src/essence/Basics/Formulae_/Formulae_.js +++ b/src/essence/Basics/Formulae_/Formulae_.js @@ -1816,7 +1816,10 @@ var Formulae_ = { const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) - const url = URL.createObjectURL(new Blob([bytes], { type: mime })) + this.downloadBlob(new Blob([bytes], { type: mime }), filename) + }, + downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob) const link = document.createElement('a') link.setAttribute('download', filename) link.setAttribute('href', url) diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 899dce5cb..56c9ad9eb 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -29,7 +29,7 @@ import { MapboxOverlay } from '@deck.gl/mapbox' import { Map as MaplibreGLMap } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' -import type { IMapEngine } from '../IMapEngine' +import type { IMapEngine, MapScreenshotResult } from '../IMapEngine' import { MAP_ENGINE } from '../types/engine' import type { MapEngineType } from '../types/engine' import type { LatLng, LatLngLike, BoundsLike, PointLike } from '../types/geometry' @@ -140,6 +140,28 @@ interface BasemapInstance { */ const SCREENSHOT_RENDER_TIMEOUT_MS = 3000 +function canvasToPngScreenshot(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + if (typeof canvas.toBlob !== 'function') { + reject(new Error('[DeckGLAdapter] captureScreenshot: canvas.toBlob is unavailable')) + return + } + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error('[DeckGLAdapter] captureScreenshot: canvas.toBlob returned null')) + return + } + resolve({ + blob, + mimeType: 'image/png', + extension: 'png', + width: canvas.width, + height: canvas.height, + }) + }, 'image/png') + }) +} + /** * DeckGL map engine adapter. * @@ -403,8 +425,8 @@ export class DeckGLAdapter implements IMapEngine { * Note: this captures only the GL canvas. Anchored HTML overlays/markers * added via {@link addOverlay} are separate DOM nodes and are not included. */ - captureScreenshot(): Promise { - return new Promise((resolve, reject) => { + captureScreenshot(): Promise { + return new Promise((resolve, reject) => { try { if (this._isOverlayMode && this._basemap) { const basemap = this._basemap @@ -417,11 +439,10 @@ export class DeckGLAdapter implements IMapEngine { }, SCREENSHOT_RENDER_TIMEOUT_MS) basemap.once('render', () => { clearTimeout(timeout) - try { - resolve(basemap.getCanvas().toDataURL('image/png')) - } catch (err) { - reject(err as Error) - } + canvasToPngScreenshot(basemap.getCanvas()).then( + resolve, + reject + ) }) basemap.triggerRepaint() return @@ -443,7 +464,7 @@ export class DeckGLAdapter implements IMapEngine { ) return } - resolve(canvas.toDataURL('image/png')) + canvasToPngScreenshot(canvas).then(resolve, reject) } catch (err) { reject(err as Error) } diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index 4980a059d..81f038db8 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -7,7 +7,7 @@ * */ -import { IMapEngine } from '../IMapEngine' +import { IMapEngine, MapScreenshotResult } from '../IMapEngine' import { LatLng, LatLngLike, @@ -333,7 +333,7 @@ export default class LeafletAdapter implements IMapEngine, IMapEn } /** - * Capture the current Leaflet view as a PNG data URL. + * Capture the current Leaflet view as a PNG Blob result. * * Delegates to the shared {@link getMapScreenshot} helper, which performs * the html2canvas rasterization plus the Leaflet-specific DOM prep @@ -341,7 +341,7 @@ export default class LeafletAdapter implements IMapEngine, IMapEn * That logic is correct for Leaflet's DOM/SVG/tile rendering and is left * unchanged here. */ - captureScreenshot(): Promise { + captureScreenshot(): Promise { return getMapScreenshot() } @@ -1268,4 +1268,3 @@ export default class LeafletAdapter implements IMapEngine, IMapEn return null } } - diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js b/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js index c1d97b44f..d8ae89b37 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js +++ b/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js @@ -19,11 +19,10 @@ import HTML2Canvas from 'html2canvas' * production these default to the imported jQuery and html2canvas. * @param {function} [deps.html2canvas] - html2canvas implementation. * @param {function} [deps.jquery] - jQuery implementation. - * @returns {Promise} Resolves to a PNG image as a data URL string - * (e.g. 'data:image/png;base64,...'). The data URL form is convenient for - * both triggering a download and embedding the image (e.g. into a PDF). - */ -function getMapScreenshot(deps = {}) { + * @returns {Promise} Resolves to a PNG Blob plus metadata: + * `{ blob, mimeType, extension, width, height }`. + */ +function getMapScreenshot(deps = {}) { const html2canvas = deps.html2canvas || HTML2Canvas const jquery = deps.jquery || $ @@ -120,9 +119,7 @@ function getMapScreenshot(deps = {}) { copyEle.setAttribute('style', attribute) }) }, - }).then(function (canvas) { - return canvas.toDataURL('image/png') - }) + }).then(canvasToPngScreenshot) } catch (err) { restoreChrome() throw err @@ -138,7 +135,29 @@ function getMapScreenshot(deps = {}) { restoreChrome() return capture -} - -export { getMapScreenshot } -export default getMapScreenshot +} + +function canvasToPngScreenshot(canvas) { + return new Promise(function (resolve, reject) { + if (typeof canvas.toBlob !== 'function') { + reject(new Error('getMapScreenshot: canvas.toBlob is unavailable')) + return + } + canvas.toBlob(function (blob) { + if (!blob) { + reject(new Error('getMapScreenshot: canvas.toBlob returned null')) + return + } + resolve({ + blob, + mimeType: 'image/png', + extension: 'png', + width: canvas.width, + height: canvas.height, + }) + }, 'image/png') + }) +} + +export { getMapScreenshot } +export default getMapScreenshot diff --git a/src/essence/Basics/MapEngines/IMapEngine.ts b/src/essence/Basics/MapEngines/IMapEngine.ts index 81116e027..780807963 100644 --- a/src/essence/Basics/MapEngines/IMapEngine.ts +++ b/src/essence/Basics/MapEngines/IMapEngine.ts @@ -18,6 +18,14 @@ import { } from './types/events' import { MapEngineType } from './types/engine' +export interface MapScreenshotResult { + blob: Blob + mimeType: 'image/png' + extension: 'png' + width: number + height: number +} + /** * Core map engine contract. * @@ -73,10 +81,9 @@ export interface IMapEngine< * - deck.gl reads the WebGL canvas directly (the base map's GL context * when running in interleaved overlay mode), which html2canvas cannot do. * - * @returns Resolves to a PNG image as a data URL string - * (e.g. `'data:image/png;base64,...'`). + * @returns Resolves to a PNG image Blob plus metadata. */ - captureScreenshot(): Promise + captureScreenshot(): Promise /** * Jump to a center and zoom without animation. diff --git a/src/essence/Basics/UserInterface_/BottomBar.js b/src/essence/Basics/UserInterface_/BottomBar.js index 33a8b6e6e..35925c286 100644 --- a/src/essence/Basics/UserInterface_/BottomBar.js +++ b/src/essence/Basics/UserInterface_/BottomBar.js @@ -73,14 +73,14 @@ let BottomBar = { // mmgisAPI.getMapScreenshot(), which delegates to the active // map engine (Leaflet html2canvas vs deck.gl GL-canvas // readback). Here we just show the loading spinner, then - // download the resulting PNG data URL. + // download the resulting PNG Blob. $('#topBarScreenshotLoading').css('display', 'block') window.mmgisAPI .getMapScreenshot() - .then(function (dataURL) { - const mission = L_.configData?.msv?.mission - const time = L_.TimeControl_?.currentTime + .then(function (screenshot) { + const mission = L_.configData?.msv?.mission + const time = L_.TimeControl_?.currentTime const mapCenter = L_.Map_.map.getCenter() const lng = mapCenter.lng.toFixed(4) const lat = mapCenter.lat.toFixed(4) @@ -88,7 +88,7 @@ let BottomBar = { time ? `${time.replaceAll(':', '-')}_` : '' }${lat}_${lng}.png` - F_.downloadDataUrl(dataURL, name) + F_.downloadBlob(screenshot.blob, name) setTimeout(function () { $('#topBarScreenshotLoading').css('display', 'none') diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index de8475dbb..bc8d4ec35 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -775,16 +775,16 @@ var mmgisAPI = { */ getViewState: mmgisAPI_.getViewState, - /** getMapScreenshot - captures a PNG screenshot of the current map view. - * Delegates to the active map engine, so the capture strategy is - * engine-specific: the Leaflet engine rasterizes its DOM (hiding UI chrome - * for the shot), while the deck.gl/GL engine reads its WebGL canvas. Note - * 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} - resolves to a PNG image as a data URL string (e.g. 'data:image/png;base64,...'). The data URL form can be used to trigger a download or to embed the image (e.g. into a PDF). - */ - getMapScreenshot: mmgisAPI_.getMapScreenshot, + /** getMapScreenshot - captures a PNG screenshot of the current map view. + * Delegates to the active map engine, so the capture strategy is + * engine-specific: the Leaflet engine rasterizes its DOM (hiding UI chrome + * for the shot), while the deck.gl/GL engine reads its WebGL canvas. Note + * 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. + */ + getMapScreenshot: mmgisAPI_.getMapScreenshot, /** onLoaded - calls onLoadCallback as a function once MMGIS has finished loading. * @param {function} - onLoadCallback - function reference to function that is called when MMGIS is finished loading diff --git a/tests/unit/LeafletAdapter.spec.js b/tests/unit/LeafletAdapter.spec.js index 366840217..95018467b 100644 --- a/tests/unit/LeafletAdapter.spec.js +++ b/tests/unit/LeafletAdapter.spec.js @@ -6,7 +6,14 @@ import { getMapScreenshot as mockedLeafletCapture } from '../../src/essence/Basi // Mock the Leaflet screenshot strategy so we can assert LeafletAdapter delegates // to it (issue #143) without driving the real html2canvas/DOM path. vi.mock('../../src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js', () => { - const fn = vi.fn(() => Promise.resolve('data:image/png;base64,LEAFLET')) + const result = { + blob: new Blob(['leaflet'], { type: 'image/png' }), + mimeType: 'image/png', + extension: 'png', + width: 640, + height: 480, + } + const fn = vi.fn(() => Promise.resolve(result)) return { getMapScreenshot: fn, default: fn } }) @@ -163,7 +170,11 @@ test.describe('LeafletAdapter - Lifecycle', () => { const result = await adapter.captureScreenshot() expect(mockedLeafletCapture).toHaveBeenCalledTimes(1) - expect(result).toBe('data:image/png;base64,LEAFLET') + expect(result.mimeType).toBe('image/png') + expect(result.extension).toBe('png') + expect(result.width).toBe(640) + expect(result.height).toBe(480) + expect(result.blob).toBeInstanceOf(Blob) }) test('destroy() is safe to call when map is not initialized', () => { diff --git a/tests/unit/deckGLAdapter.spec.js b/tests/unit/deckGLAdapter.spec.js index 7b7c78faf..564de5d96 100644 --- a/tests/unit/deckGLAdapter.spec.js +++ b/tests/unit/deckGLAdapter.spec.js @@ -218,21 +218,22 @@ test.describe('DeckGLAdapter', () => { }) }) - test.describe('captureScreenshot', () => { - test('overlay mode reads the canvas inside the render event after triggerRepaint', async () => { - const adapter = makeAdapter() - let inRenderFrame = false - let renderHandler = null - const canvas = { - toDataURL: (type) => { - expect(type).toBe('image/png') - // Only valid during the render event, before the browser - // presents (and clears) the drawing buffer. - return inRenderFrame - ? 'data:image/png;base64,DECKGL' - : 'data:image/png;base64,BLANK' - }, - } + test.describe('captureScreenshot', () => { + test('overlay mode reads the canvas inside the render event after triggerRepaint', async () => { + const adapter = makeAdapter() + let inRenderFrame = false + let renderHandler = null + const blob = new Blob(['deckgl'], { type: 'image/png' }) + const canvas = { + width: 256, + height: 128, + toBlob: (callback, type) => { + expect(type).toBe('image/png') + // Only valid during the render event, before the browser + // presents (and clears) the drawing buffer. + callback(inRenderFrame ? blob : null) + }, + } adapter._isOverlayMode = true adapter._basemap = { once: (type, handler) => { @@ -249,25 +250,33 @@ test.describe('DeckGLAdapter', () => { }, getCanvas: () => canvas, } - - const result = await adapter.captureScreenshot() - expect(result).toBe('data:image/png;base64,DECKGL') - }) - - test('overlay mode rejects when toDataURL throws during the render event', async () => { - const adapter = makeAdapter() - let renderHandler = null - adapter._isOverlayMode = true - adapter._basemap = { - once: (_type, handler) => { renderHandler = handler }, - triggerRepaint: () => renderHandler(), - getCanvas: () => ({ - toDataURL: () => { throw new Error('tainted canvas') }, - }), - } - - await expect(adapter.captureScreenshot()).rejects.toThrow(/tainted canvas/) - }) + + const result = await adapter.captureScreenshot() + expect(result).toEqual({ + blob, + mimeType: 'image/png', + extension: 'png', + width: 256, + height: 128, + }) + }) + + test('overlay mode rejects when toBlob returns null during the render event', async () => { + const adapter = makeAdapter() + let renderHandler = null + adapter._isOverlayMode = true + adapter._basemap = { + once: (_type, handler) => { renderHandler = handler }, + triggerRepaint: () => renderHandler(), + getCanvas: () => ({ + width: 256, + height: 128, + toBlob: (callback) => callback(null), + }), + } + + await expect(adapter.captureScreenshot()).rejects.toThrow(/toBlob returned null/) + }) test('overlay mode rejects after the timeout if the render event never fires', async () => { vi.useFakeTimers() @@ -275,11 +284,15 @@ test.describe('DeckGLAdapter', () => { const adapter = makeAdapter() let repainted = false adapter._isOverlayMode = true - adapter._basemap = { - once: () => {}, - triggerRepaint: () => { repainted = true }, - getCanvas: () => ({ toDataURL: () => 'data:image/png;base64,NEVER' }), - } + adapter._basemap = { + once: () => {}, + triggerRepaint: () => { repainted = true }, + getCanvas: () => ({ + width: 256, + height: 128, + toBlob: () => {}, + }), + } const capture = adapter.captureScreenshot() const assertion = expect(capture).rejects.toThrow(/timed out/) @@ -291,19 +304,33 @@ test.describe('DeckGLAdapter', () => { } }) - test('standalone mode redraws deck and reads its canvas', async () => { - const adapter = makeAdapter() - let redrawArg = null - adapter._isOverlayMode = false - adapter._deck = { - redraw: (reason) => { redrawArg = reason }, - getCanvas: () => ({ toDataURL: () => 'data:image/png;base64,STANDALONE' }), - } - - const result = await adapter.captureScreenshot() - expect(redrawArg).toBe('screenshot') - expect(result).toBe('data:image/png;base64,STANDALONE') - }) + test('standalone mode redraws deck and reads its canvas', async () => { + const adapter = makeAdapter() + let redrawArg = null + const blob = new Blob(['standalone'], { type: 'image/png' }) + adapter._isOverlayMode = false + adapter._deck = { + redraw: (reason) => { redrawArg = reason }, + getCanvas: () => ({ + width: 300, + height: 200, + toBlob: (callback, type) => { + expect(type).toBe('image/png') + callback(blob) + }, + }), + } + + const result = await adapter.captureScreenshot() + expect(redrawArg).toBe('screenshot') + expect(result).toEqual({ + blob, + mimeType: 'image/png', + extension: 'png', + width: 300, + height: 200, + }) + }) test('rejects when there is no active map to capture', async () => { const adapter = makeAdapter() diff --git a/tests/unit/mmgisApiScreenshot.spec.js b/tests/unit/mmgisApiScreenshot.spec.js index 4eb8dba69..026c16095 100644 --- a/tests/unit/mmgisApiScreenshot.spec.js +++ b/tests/unit/mmgisApiScreenshot.spec.js @@ -24,15 +24,22 @@ test.describe('mmgisAPI.getMapScreenshot delegation (issue #143)', () => { }) test('delegates to the active engine and returns its result', async () => { + const screenshot = { + blob: new Blob(['engine'], { type: 'image/png' }), + mimeType: 'image/png', + extension: 'png', + width: 640, + height: 480, + } const capture = vi.fn(() => - Promise.resolve('data:image/png;base64,ENGINE') + Promise.resolve(screenshot) ) L_.Map_ = { engine: { captureScreenshot: capture } } const result = await mmgisAPI.getMapScreenshot() expect(capture).toHaveBeenCalledTimes(1) - expect(result).toBe('data:image/png;base64,ENGINE') + expect(result).toBe(screenshot) }) test('rejects when no engine is active', async () => { diff --git a/tests/unit/shareScreenshotApi.spec.js b/tests/unit/shareScreenshotApi.spec.js index 119dc8b09..4d3f959b6 100644 --- a/tests/unit/shareScreenshotApi.spec.js +++ b/tests/unit/shareScreenshotApi.spec.js @@ -46,12 +46,21 @@ function makeMockJQuery(getterValue) { return jquery } -function makeMockHtml2canvas(dataURL) { +function makePngBlob(content = 'png') { + return new Blob([content], { type: 'image/png' }) +} + +function makeMockHtml2canvas(blob = makePngBlob()) { const calls = [] // { element, options } function html2canvas(element, options) { calls.push({ element, options }) return Promise.resolve({ - toDataURL: () => dataURL, + width: element.offsetWidth, + height: element.offsetHeight, + toBlob: (callback, type) => { + expect(type).toBe('image/png') + callback(blob) + }, }) } html2canvas._calls = calls @@ -88,18 +97,43 @@ test.describe('LeafletScreenshot.getMapScreenshot - behavior', () => { setupGlobalDom() }) - test('resolves to the PNG data URL produced by html2canvas', async () => { + test('resolves to a PNG Blob screenshot result produced by html2canvas', async () => { const jquery = makeMockJQuery('5px') - const html2canvas = makeMockHtml2canvas('data:image/png;base64,FAKEPNG') + const blob = makePngBlob('leaflet') + const html2canvas = makeMockHtml2canvas(blob) const result = await getMapScreenshot({ jquery, html2canvas }) - expect(result).toBe('data:image/png;base64,FAKEPNG') + expect(result).toEqual({ + blob, + mimeType: 'image/png', + extension: 'png', + width: 1024, + height: 768, + }) + }) + + test('rejects when canvas.toBlob returns null', async () => { + const jquery = makeMockJQuery('5px') + const html2canvas = makeMockHtml2canvas() + html2canvas._calls = [] + const nullBlobHtml2canvas = (element, options) => { + html2canvas._calls.push({ element, options }) + return Promise.resolve({ + width: element.offsetWidth, + height: element.offsetHeight, + toBlob: (callback) => callback(null), + }) + } + + await expect( + getMapScreenshot({ jquery, html2canvas: nullBlobHtml2canvas }) + ).rejects.toThrow(/toBlob returned null/) }) test('invokes html2canvas once on #mapScreen with an onclone option', async () => { const jquery = makeMockJQuery('5px') - const html2canvas = makeMockHtml2canvas('data:image/png;base64,X') + const html2canvas = makeMockHtml2canvas() await getMapScreenshot({ jquery, html2canvas }) @@ -114,7 +148,7 @@ test.describe('LeafletScreenshot.getMapScreenshot - behavior', () => { test('hides UI chrome for the capture and restores it afterwards', async () => { const jquery = makeMockJQuery('5px') - const html2canvas = makeMockHtml2canvas('data:image/png;base64,X') + const html2canvas = makeMockHtml2canvas() await getMapScreenshot({ jquery, html2canvas }) @@ -136,7 +170,7 @@ test.describe('LeafletScreenshot.getMapScreenshot - behavior', () => { // Regression guard: the restore must pass the captured variable, not the // literal 'savedMapToolBarBottom'. Saved value here is '5px'. const jquery = makeMockJQuery('5px') - const html2canvas = makeMockHtml2canvas('data:image/png;base64,X') + const html2canvas = makeMockHtml2canvas() await getMapScreenshot({ jquery, html2canvas }) @@ -154,7 +188,7 @@ test.describe('LeafletScreenshot.getMapScreenshot - behavior', () => { // selector, since the click cleared .active) afterwards. Previously the // restore was missing, leaving the time UI collapsed. const jquery = makeMockJQuery('5px') - const html2canvas = makeMockHtml2canvas('data:image/png;base64,X') + const html2canvas = makeMockHtml2canvas() await getMapScreenshot({ jquery, html2canvas }) From fd8bc88260b53e46e45b1f235772fc114992112c Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Thu, 2 Jul 2026 01:00:16 -0500 Subject: [PATCH 17/23] [143] fix: pin clone fixups to kickoff-time styles; share them with Animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit html2canvas fires onclone after this module has already restored the live UI, so re-reading tile z-indices there leaked the restored values into the clone and undid the pre-capture normalization (inherited from the old BottomBar implementation). The fixups now apply a snapshot taken at capture kickoff. The identical SVG re-parent + tile z-index workaround in the Animation tool's OffscreenMapManager now uses the same shared LeafletCloneFixups module instead of its own copy. Also drops the test-only jquery/html2canvas injection params and the redundant default export — specs use vi.mock instead. --- .../MapEngines/Adapters/LeafletCloneFixups.js | 74 ++++++++++ .../MapEngines/Adapters/LeafletScreenshot.js | 133 +++++++----------- .../Tools/Animation/OffscreenMapManager.js | 50 ++----- tests/unit/shareScreenshotApi.spec.js | 110 +++++++++------ 4 files changed, 209 insertions(+), 158 deletions(-) create mode 100644 src/essence/Basics/MapEngines/Adapters/LeafletCloneFixups.js diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletCloneFixups.js b/src/essence/Basics/MapEngines/Adapters/LeafletCloneFixups.js new file mode 100644 index 000000000..e93762b7e --- /dev/null +++ b/src/essence/Basics/MapEngines/Adapters/LeafletCloneFixups.js @@ -0,0 +1,74 @@ +/** + * Shared html2canvas clone fixups for Leaflet maps. + * + * html2canvas clones the DOM and rasterizes the clone, but two Leaflet quirks + * make a naive clone render wrong: + * - Overlay SVGs carry Leaflet's pan/zoom transform in their inline style, + * and html2canvas mispositions transformed SVGs — so each SVG is + * re-parented into a wrapper
that carries the transform instead. + * - Tile layers stack by inline z-index, which the clone can lose. + * + * Both the screenshot capture (LeafletScreenshot) and the Animation tool's + * offscreen frame capture (OffscreenMapManager) need the identical fixups; + * they differ only in the selectors that scope which map is being captured. + * + * The styles are snapshotted from the live document up front (not read inside + * `onclone`): html2canvas fires `onclone` asynchronously, so by then the + * caller may have restored/mutated the live styles — the snapshot pins the + * capture to the styles exactly as they were at kickoff. + */ + +/** + * Reads the inline style attributes the clone fixups will need, from the live + * document, at call time. Call this at capture kickoff, before any UI restore. + * + * @param {object} selectors + * @param {string} selectors.svgSelector - matches the map's overlay SVGs + * @param {string} selectors.tileSelector - matches the map's tile layer divs + * @returns {object} snapshot to pass to {@link applyLeafletCloneFixups} + */ +export function snapshotLeafletPaneStyles({ svgSelector, tileSelector }) { + return { + svgSelector, + tileSelector, + svgStyles: Array.from( + document.body.querySelectorAll(svgSelector), + (el) => el.getAttribute('style') + ), + tileStyles: Array.from( + document.body.querySelectorAll(tileSelector), + (el) => el.getAttribute('style') + ), + } +} + +/** + * Applies the fixups to an html2canvas clone (the `onclone` callback's body). + * + * @param {HTMLElement} cloneBody - `e.body` from html2canvas's onclone + * @param {object} snapshot - from {@link snapshotLeafletPaneStyles} + */ +export function applyLeafletCloneFixups(cloneBody, snapshot) { + // Fix svg layer shift: move each overlay SVG's inline style (Leaflet's + // pan/zoom transform) onto a wrapper div, which html2canvas renders + // correctly. + const copySVG = cloneBody.querySelectorAll(snapshot.svgSelector) + copySVG.forEach((copyEle, i) => { + const attribute = snapshot.svgStyles[i] + const parentElement = copyEle.parentElement + parentElement.removeChild(copyEle) + const temp = document.createElement('div') + temp.appendChild(copyEle) + parentElement.appendChild(temp) + if (attribute != null) temp.setAttribute('style', attribute) + copyEle.removeAttribute('style') + }) + + // Fix tile layer stacking: copy the snapshotted inline styles (z-index) + // onto the clone's tile layers. + const copyZ = cloneBody.querySelectorAll(snapshot.tileSelector) + copyZ.forEach((copyEle, i) => { + if (snapshot.tileStyles[i] != null) + copyEle.setAttribute('style', snapshot.tileStyles[i]) + }) +} diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js b/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js index d8ae89b37..b138e25b1 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js +++ b/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js @@ -1,5 +1,10 @@ -import $ from 'jquery' -import HTML2Canvas from 'html2canvas' +import jquery from 'jquery' +import html2canvas from 'html2canvas' + +import { + snapshotLeafletPaneStyles, + applyLeafletCloneFixups, +} from './LeafletCloneFixups' /** * The Leaflet map engine's screenshot strategy: captures a PNG of the current @@ -11,21 +16,13 @@ import HTML2Canvas from 'html2canvas' * Temporarily hides UI chrome (zoom controls, compass, scale factor, time UI) * and normalizes the Leaflet pane z-indices so html2canvas rasterizes the * layers in the correct order, then restores that chrome afterwards. The - * `onclone` callback performs non-obvious SVG re-parenting and tile-pane - * z-index fixups that the cloned document needs in order to render identically - * to the live map; it must not be altered. + * clone fixups the capture needs (SVG re-parenting, tile z-index copy) live in + * the shared LeafletCloneFixups module. * - * @param {object} [deps] - Injectable dependencies (intended for testing). In - * production these default to the imported jQuery and html2canvas. - * @param {function} [deps.html2canvas] - html2canvas implementation. - * @param {function} [deps.jquery] - jQuery implementation. - * @returns {Promise} Resolves to a PNG Blob plus metadata: - * `{ blob, mimeType, extension, width, height }`. - */ -function getMapScreenshot(deps = {}) { - const html2canvas = deps.html2canvas || HTML2Canvas - const jquery = deps.jquery || $ - + * @returns {Promise} Resolves to a PNG Blob plus metadata: + * `{ blob, mimeType, extension, width, height }`. + */ +function getMapScreenshot() { //We need to manually order leaflet z-indices for this to work let zIndices = [] jquery('#map .leaflet-tile-pane') @@ -46,6 +43,15 @@ function getMapScreenshot(deps = {}) { const timeUIWasActive = jquery('#toggleTimeUI.active').length > 0 if (timeUIWasActive) jquery('#toggleTimeUI.active').trigger('click') + // Snapshot the pane styles NOW — after the z-index normalization above, + // before the restore below. The onclone fixups apply these saved values + // instead of re-reading the live DOM, which will already be restored by + // the time html2canvas fires onclone. + const paneStyles = snapshotLeafletPaneStyles({ + svgSelector: 'svg.leaflet-zoom-animated', + tileSelector: '.leaflet-tile-pane > div.leaflet-layer', + }) + // Restore the UI chrome hidden above. Split out so the error path below // can restore too — without it, a synchronous throw between the hide and // the capture would leave controls hidden and the time UI collapsed. @@ -85,41 +91,9 @@ function getMapScreenshot(deps = {}) { windowWidth: documentElm.offsetWidth, windowHeight: documentElm.offsetHeight, onclone: function (e) { - // Fix svg layer shift - const originalSVG = document.body.querySelectorAll( - 'svg.leaflet-zoom-animated' - ) - const copySVG = e.body.querySelectorAll( - 'svg.leaflet-zoom-animated' - ) - copySVG.forEach((copyEle, i) => { - const attribute = originalSVG - .item(i) - .getAttribute('style') - const parentElement = copyEle.parentElement - parentElement.removeChild(copyEle) - const temp = document.createElement('div') - temp.appendChild(copyEle) - parentElement.appendChild(temp) - temp.setAttribute('style', attribute) - copyEle.removeAttribute('style') - }) - - // Fix tile layer z-indices - const originalZ = document.body.querySelectorAll( - '.leaflet-tile-pane > div.leaflet-layer' - ) - const copyZ = e.body.querySelectorAll( - '.leaflet-tile-pane > div.leaflet-layer' - ) - copyZ.forEach((copyEle, i) => { - const attribute = originalZ - .item(i) - .getAttribute('style') - copyEle.setAttribute('style', attribute) - }) + applyLeafletCloneFixups(e.body, paneStyles) }, - }).then(canvasToPngScreenshot) + }).then(canvasToPngScreenshot) } catch (err) { restoreChrome() throw err @@ -127,37 +101,34 @@ function getMapScreenshot(deps = {}) { // Restore the UI chrome we hid for the capture. This runs immediately // (not in .then) so the live UI isn't visibly degraded for the duration of - // the capture; html2canvas's initial DOM clone happens synchronously within - // the call above. (Caveat: html2canvas's `onclone` fires later and copies - // some styles from the live document, so restored values can leak into the - // clone's tile z-indices — a pre-existing quirk inherited from the old - // BottomBar implementation.) + // the capture: html2canvas's initial DOM clone happens synchronously within + // the call above, and the later-firing onclone fixups apply the snapshot + // taken before this restore, so restored values can't leak into the clone. restoreChrome() return capture -} - -function canvasToPngScreenshot(canvas) { - return new Promise(function (resolve, reject) { - if (typeof canvas.toBlob !== 'function') { - reject(new Error('getMapScreenshot: canvas.toBlob is unavailable')) - return - } - canvas.toBlob(function (blob) { - if (!blob) { - reject(new Error('getMapScreenshot: canvas.toBlob returned null')) - return - } - resolve({ - blob, - mimeType: 'image/png', - extension: 'png', - width: canvas.width, - height: canvas.height, - }) - }, 'image/png') - }) -} - -export { getMapScreenshot } -export default getMapScreenshot +} + +function canvasToPngScreenshot(canvas) { + return new Promise(function (resolve, reject) { + if (typeof canvas.toBlob !== 'function') { + reject(new Error('getMapScreenshot: canvas.toBlob is unavailable')) + return + } + canvas.toBlob(function (blob) { + if (!blob) { + reject(new Error('getMapScreenshot: canvas.toBlob returned null')) + return + } + resolve({ + blob, + mimeType: 'image/png', + extension: 'png', + width: canvas.width, + height: canvas.height, + }) + }, 'image/png') + }) +} + +export { getMapScreenshot } diff --git a/src/essence/Tools/Animation/OffscreenMapManager.js b/src/essence/Tools/Animation/OffscreenMapManager.js index c1224e4f4..6a4a8987a 100644 --- a/src/essence/Tools/Animation/OffscreenMapManager.js +++ b/src/essence/Tools/Animation/OffscreenMapManager.js @@ -20,6 +20,10 @@ import L_ from '../../Basics/Layers_/Layers_' import Map_ from '../../Basics/Map_/Map_' import F_ from '../../Basics/Formulae_/Formulae_' import HTML2Canvas from 'html2canvas' +import { + snapshotLeafletPaneStyles, + applyLeafletCloneFixups, +} from '../../Basics/MapEngines/Adapters/LeafletCloneFixups' // Access Leaflet from global window object const L = window.L @@ -620,6 +624,16 @@ class OffscreenMapManager { // Wait a bit for Leaflet to render (asynchronous rendering) await new Promise((resolve) => setTimeout(resolve, 100)) + // Snapshot the pane styles for the shared Leaflet/html2canvas + // clone fixups (SVG re-parenting, tile z-index copy); the + // selectors scope them to the offscreen map only. + const paneStyles = snapshotLeafletPaneStyles({ + svgSelector: + '#offscreen-map-container .leaflet-overlay-pane > svg', + tileSelector: + '#offscreen-map-container .leaflet-tile-pane > div.leaflet-layer', + }) + // Capture using HTML2Canvas const canvas = await HTML2Canvas(this.container, { allowTaint: true, @@ -635,41 +649,7 @@ class OffscreenMapManager { windowWidth: this.container.offsetWidth, windowHeight: this.container.offsetHeight, - onclone: function (e) { - // Fix svg layer shift - const originalSVG = document.body.querySelectorAll( - '#offscreen-map-container .leaflet-overlay-pane > svg' - ) - const copySVG = e.body.querySelectorAll( - '#offscreen-map-container .leaflet-overlay-pane > svg' - ) - copySVG.forEach((copyEle, i) => { - const attribute = originalSVG - .item(i) - .getAttribute('style') - const parentElement = copyEle.parentElement - parentElement.removeChild(copyEle) - const temp = document.createElement('div') - temp.appendChild(copyEle) - parentElement.appendChild(temp) - temp.setAttribute('style', attribute) - copyEle.removeAttribute('style') - }) - - // Fix tile layer z-indices - const originalZ = document.body.querySelectorAll( - '#offscreen-map-container .leaflet-tile-pane > div.leaflet-layer' - ) - const copyZ = e.body.querySelectorAll( - '#offscreen-map-container .leaflet-tile-pane > div.leaflet-layer' - ) - copyZ.forEach((copyEle, i) => { - const attribute = originalZ - .item(i) - .getAttribute('style') - copyEle.setAttribute('style', attribute) - }) - }, + onclone: (e) => applyLeafletCloneFixups(e.body, paneStyles), }) // Since the offscreen container was sized to match the drawn bbox dimensions diff --git a/tests/unit/shareScreenshotApi.spec.js b/tests/unit/shareScreenshotApi.spec.js index 4d3f959b6..fd478153e 100644 --- a/tests/unit/shareScreenshotApi.spec.js +++ b/tests/unit/shareScreenshotApi.spec.js @@ -1,15 +1,20 @@ -import { test, expect } from 'vitest' - -import getMapScreenshot, { - getMapScreenshot as namedGetMapScreenshot, -} from '../../src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js' +import { test, expect, vi } from 'vitest' // Issue #143 - expose share-link and map-screenshot as first-class plugin API. // -// LeafletScreenshot.getMapScreenshot() drives the live DOM (jQuery) and rasterizes -// with html2canvas, neither of which exists in this Node test context. The -// function therefore accepts injectable `jquery`/`html2canvas` deps so the -// behavior can be exercised against lightweight fakes. +// LeafletScreenshot.getMapScreenshot() drives the live DOM (jQuery) and +// rasterizes with html2canvas, neither of which exists in this Node test +// context — so both module imports are replaced with configurable fakes via +// vi.mock (no production injection seam needed). Each test assigns the fake +// implementations through the hoisted `mocks` holder before calling. + +const mocks = vi.hoisted(() => ({ jquery: null, html2canvas: null })) +vi.mock('jquery', () => ({ default: (...args) => mocks.jquery(...args) })) +vi.mock('html2canvas', () => ({ + default: (...args) => mocks.html2canvas(...args), +})) + +import { getMapScreenshot } from '../../src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js' // A fake jQuery that records every .css(prop, value) setter call (keyed by the // selector it was invoked on) and answers .css(prop) getters with a known value. @@ -84,25 +89,18 @@ function setupGlobalDom() { } } -test.describe('LeafletScreenshot.getMapScreenshot - export surface', () => { - test('is exported as both a default and named function', () => { - expect(typeof getMapScreenshot).toBe('function') - expect(typeof namedGetMapScreenshot).toBe('function') - expect(getMapScreenshot).toBe(namedGetMapScreenshot) - }) -}) - test.describe('LeafletScreenshot.getMapScreenshot - behavior', () => { test.beforeEach(() => { setupGlobalDom() + mocks.jquery = makeMockJQuery('5px') + mocks.html2canvas = makeMockHtml2canvas() }) test('resolves to a PNG Blob screenshot result produced by html2canvas', async () => { - const jquery = makeMockJQuery('5px') const blob = makePngBlob('leaflet') - const html2canvas = makeMockHtml2canvas(blob) + mocks.html2canvas = makeMockHtml2canvas(blob) - const result = await getMapScreenshot({ jquery, html2canvas }) + const result = await getMapScreenshot() expect(result).toEqual({ blob, @@ -114,28 +112,20 @@ test.describe('LeafletScreenshot.getMapScreenshot - behavior', () => { }) test('rejects when canvas.toBlob returns null', async () => { - const jquery = makeMockJQuery('5px') - const html2canvas = makeMockHtml2canvas() - html2canvas._calls = [] - const nullBlobHtml2canvas = (element, options) => { - html2canvas._calls.push({ element, options }) - return Promise.resolve({ + mocks.html2canvas = (element, options) => + Promise.resolve({ width: element.offsetWidth, height: element.offsetHeight, toBlob: (callback) => callback(null), }) - } - await expect( - getMapScreenshot({ jquery, html2canvas: nullBlobHtml2canvas }) - ).rejects.toThrow(/toBlob returned null/) + await expect(getMapScreenshot()).rejects.toThrow(/toBlob returned null/) }) test('invokes html2canvas once on #mapScreen with an onclone option', async () => { - const jquery = makeMockJQuery('5px') - const html2canvas = makeMockHtml2canvas() + const html2canvas = mocks.html2canvas - await getMapScreenshot({ jquery, html2canvas }) + await getMapScreenshot() expect(html2canvas._calls.length).toBe(1) const { element, options } = html2canvas._calls[0] @@ -147,10 +137,9 @@ test.describe('LeafletScreenshot.getMapScreenshot - behavior', () => { }) test('hides UI chrome for the capture and restores it afterwards', async () => { - const jquery = makeMockJQuery('5px') - const html2canvas = makeMockHtml2canvas() + const jquery = mocks.jquery - await getMapScreenshot({ jquery, html2canvas }) + await getMapScreenshot() const displayFor = (selector) => jquery._cssSets @@ -169,10 +158,9 @@ test.describe('LeafletScreenshot.getMapScreenshot - behavior', () => { test('restores #mapToolBar bottom to its saved value, not a string literal', async () => { // Regression guard: the restore must pass the captured variable, not the // literal 'savedMapToolBarBottom'. Saved value here is '5px'. - const jquery = makeMockJQuery('5px') - const html2canvas = makeMockHtml2canvas() + const jquery = mocks.jquery - await getMapScreenshot({ jquery, html2canvas }) + await getMapScreenshot() const bottomValues = jquery._cssSets .filter((c) => c.selector === '#mapToolBar' && c.prop === 'bottom') @@ -187,10 +175,9 @@ test.describe('LeafletScreenshot.getMapScreenshot - behavior', () => { // selector) before the shot and must be toggled back on (via the plain // selector, since the click cleared .active) afterwards. Previously the // restore was missing, leaving the time UI collapsed. - const jquery = makeMockJQuery('5px') - const html2canvas = makeMockHtml2canvas() + const jquery = mocks.jquery - await getMapScreenshot({ jquery, html2canvas }) + await getMapScreenshot() const timeToggleEvents = jquery._triggered.filter((t) => t.selector.startsWith('#toggleTimeUI') @@ -200,4 +187,43 @@ test.describe('LeafletScreenshot.getMapScreenshot - behavior', () => { { selector: '#toggleTimeUI', event: 'click' }, ]) }) + + test('onclone applies the styles snapshotted before the UI restore', async () => { + // Regression guard for the restore/onclone race: the clone fixups must + // use the tile styles captured at kickoff (post-normalization), not + // re-read the live DOM (already restored when onclone fires). We + // simulate: live tiles report style A at kickoff, then mutate to style + // B before onclone runs — the clone must receive A. + const liveTile = { + getAttribute: () => 'z-index: 1', + setAttribute() {}, + } + global.document.body.querySelectorAll = (sel) => + sel.includes('tile') ? [liveTile] : [] + + let cloneApplied = [] + const cloneTile = { + setAttribute: (name, value) => cloneApplied.push(value), + getAttribute: () => null, + } + mocks.html2canvas = (element, options) => { + // Live DOM "restores" (mutates) before onclone fires. + liveTile.getAttribute = () => 'z-index: 999' + options.onclone({ + body: { + querySelectorAll: (sel) => + sel.includes('tile') ? [cloneTile] : [], + }, + }) + return Promise.resolve({ + width: element.offsetWidth, + height: element.offsetHeight, + toBlob: (callback) => callback(makePngBlob()), + }) + } + + await getMapScreenshot() + + expect(cloneApplied).toEqual(['z-index: 1']) + }) }) From 5327ef346ce2a77059fc9128651184c3a77a79fc Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Thu, 2 Jul 2026 08:46:00 -0500 Subject: [PATCH 18/23] =?UTF-8?q?[143]=20fix:=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20CRLF=20hygiene,=20timeout=20unhook,=20dead=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize the LF lines the fix pass introduced into three pure-CRLF files (QueryURL.js, BottomBar.js, mmgisAPI.js). Unhook the pending once('render') listener when a deck.gl capture times out — a backgrounded tab's timed-out capture otherwise leaves it armed to fire a wasted capture on a later render (regression-tested). Delete F_.downloadDataUrl, dead since the Blob screenshot contract landed. --- src/essence/Ancillary/QueryURL.js | 56 +++--- src/essence/Basics/Formulae_/Formulae_.js | 19 +- .../MapEngines/Adapters/DeckGLAdapter.ts | 20 ++- .../Basics/UserInterface_/BottomBar.js | 38 ++-- src/essence/mmgisAPI/mmgisAPI.js | 20 +-- tests/unit/deckGLAdapter.spec.js | 166 +++++++++--------- 6 files changed, 161 insertions(+), 158 deletions(-) diff --git a/src/essence/Ancillary/QueryURL.js b/src/essence/Ancillary/QueryURL.js index 42d237cda..c142e8684 100644 --- a/src/essence/Ancillary/QueryURL.js +++ b/src/essence/Ancillary/QueryURL.js @@ -4,7 +4,7 @@ import F_ from '../Basics/Formulae_/Formulae_' import L_ from '../Basics/Layers_/Layers_' import T_ from '../Basics/ToolController_/ToolController_' import calls from '../../pre/calls' -import { isStaticBuild } from '../../pre/capabilities' +import { isStaticBuild } from '../../pre/capabilities' import TimeControl from '../Basics/TimeControl_/TimeControl' import TimeUI from '../Basics/TimeControl_/TimeUI' @@ -432,33 +432,33 @@ var QueryURL = { return window.location.href.split('?')[0] + url }, - getShareURL: function (callback) { - var fullUrl = this.writeCoordinateURL() - - if (isStaticBuild()) { - if (typeof callback === 'function') callback(fullUrl) - return - } - - var baseUrl = window.location.href.split('?')[0] - var urlAppendage = fullUrl.startsWith(baseUrl) - ? fullUrl.substring(baseUrl.length) - : fullUrl.substring(fullUrl.indexOf('?')) - - calls.api( - 'shortener_shorten', - { - url: urlAppendage, - }, - function (s) { - var shortUrl = baseUrl + '?s=' + s.body.url - if (typeof callback === 'function') callback(shortUrl) - }, - function () { - if (typeof callback === 'function') callback(fullUrl) - } - ) - }, + getShareURL: function (callback) { + var fullUrl = this.writeCoordinateURL() + + if (isStaticBuild()) { + if (typeof callback === 'function') callback(fullUrl) + return + } + + var baseUrl = window.location.href.split('?')[0] + var urlAppendage = fullUrl.startsWith(baseUrl) + ? fullUrl.substring(baseUrl.length) + : fullUrl.substring(fullUrl.indexOf('?')) + + calls.api( + 'shortener_shorten', + { + url: urlAppendage, + }, + function (s) { + var shortUrl = baseUrl + '?s=' + s.body.url + if (typeof callback === 'function') callback(shortUrl) + }, + function () { + if (typeof callback === 'function') callback(fullUrl) + } + ) + }, writeSearchURL: function (searchStrs, searchFile) { return //!!!!!!!!!!!!!!!! /* diff --git a/src/essence/Basics/Formulae_/Formulae_.js b/src/essence/Basics/Formulae_/Formulae_.js index 5b35db030..ec2ebf8a7 100644 --- a/src/essence/Basics/Formulae_/Formulae_.js +++ b/src/essence/Basics/Formulae_/Formulae_.js @@ -1803,21 +1803,10 @@ var Formulae_ = { downloadAnchorNode.click() downloadAnchorNode.remove() }, - // Downloads a data-URL image (e.g. canvas.toDataURL output) as a file. - // Decodes to a Blob and downloads via an object URL: large/hi-DPI captures - // inflate ~33% as base64 and data-URL anchor downloads are capped/unreliable - // across browsers (Chromium caps them around 2MB). fetch(dataUrl) is avoided - // deliberately — the shipped CSP's connect-src does not allow the data: - // scheme, so fetch-based conversion fails on stock deployments. - downloadDataUrl(dataUrl, filename) { - const [header, base64] = dataUrl.split(',') - const mimeMatch = header.match(/data:([^;,]+)/) - const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream' - const binary = atob(base64) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) - this.downloadBlob(new Blob([bytes], { type: mime }), filename) - }, + // Downloads a Blob as a file via a transient object URL. Preferred over + // data-URL anchor downloads (capped/unreliable for large payloads across + // browsers) and over fetch(dataUrl) conversion (the shipped CSP's + // connect-src does not allow the data: scheme). downloadBlob(blob, filename) { const url = URL.createObjectURL(blob) const link = document.createElement('a') diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 56c9ad9eb..c03c8667c 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -430,20 +430,26 @@ export class DeckGLAdapter implements IMapEngine { try { if (this._isOverlayMode && this._basemap) { const basemap = this._basemap + const onRender = () => { + clearTimeout(timeout) + canvasToPngScreenshot(basemap.getCanvas()).then( + resolve, + reject + ) + } const timeout = setTimeout(() => { + // Unhook the pending listener, otherwise a timed-out + // capture (e.g. a backgrounded tab whose repaint frame + // never ran) leaves it armed to fire — and do a wasted + // capture — on some later render. + basemap.off('render', onRender) reject( new Error( '[DeckGLAdapter] captureScreenshot: timed out waiting for the basemap render event' ) ) }, SCREENSHOT_RENDER_TIMEOUT_MS) - basemap.once('render', () => { - clearTimeout(timeout) - canvasToPngScreenshot(basemap.getCanvas()).then( - resolve, - reject - ) - }) + basemap.once('render', onRender) basemap.triggerRepaint() return } diff --git a/src/essence/Basics/UserInterface_/BottomBar.js b/src/essence/Basics/UserInterface_/BottomBar.js index 35925c286..bf64b5842 100644 --- a/src/essence/Basics/UserInterface_/BottomBar.js +++ b/src/essence/Basics/UserInterface_/BottomBar.js @@ -31,20 +31,20 @@ let BottomBar = { }) .on('click', function () { const linkButton = $(this) - QueryURL.getShareURL(function (url) { - L_.url = url - window.history.replaceState('', '', L_.url) - F_.copyToClipboard(L_.url) - - linkButton.removeClass('mdi-open-in-new') - linkButton.addClass('mdi-check-bold') - linkButton.css('color', 'var(--color-green)') - setTimeout(() => { - linkButton.removeClass('mdi-check-bold') - linkButton.css('color', '') - linkButton.addClass('mdi-open-in-new') - }, 3000) - }) + QueryURL.getShareURL(function (url) { + L_.url = url + window.history.replaceState('', '', L_.url) + F_.copyToClipboard(L_.url) + + linkButton.removeClass('mdi-open-in-new') + linkButton.addClass('mdi-check-bold') + linkButton.css('color', 'var(--color-green)') + setTimeout(() => { + linkButton.removeClass('mdi-check-bold') + linkButton.css('color', '') + linkButton.addClass('mdi-open-in-new') + }, 3000) + }) }) bottomBar.append(topBarLink) @@ -73,14 +73,14 @@ let BottomBar = { // mmgisAPI.getMapScreenshot(), which delegates to the active // map engine (Leaflet html2canvas vs deck.gl GL-canvas // readback). Here we just show the loading spinner, then - // download the resulting PNG Blob. + // download the resulting PNG Blob. $('#topBarScreenshotLoading').css('display', 'block') window.mmgisAPI .getMapScreenshot() - .then(function (screenshot) { - const mission = L_.configData?.msv?.mission - const time = L_.TimeControl_?.currentTime + .then(function (screenshot) { + const mission = L_.configData?.msv?.mission + const time = L_.TimeControl_?.currentTime const mapCenter = L_.Map_.map.getCenter() const lng = mapCenter.lng.toFixed(4) const lat = mapCenter.lat.toFixed(4) @@ -88,7 +88,7 @@ let BottomBar = { time ? `${time.replaceAll(':', '-')}_` : '' }${lat}_${lng}.png` - F_.downloadBlob(screenshot.blob, name) + F_.downloadBlob(screenshot.blob, name) setTimeout(function () { $('#topBarScreenshotLoading').css('display', 'none') diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index bc8d4ec35..f77f4f7e7 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -775,16 +775,16 @@ var mmgisAPI = { */ getViewState: mmgisAPI_.getViewState, - /** getMapScreenshot - captures a PNG screenshot of the current map view. - * Delegates to the active map engine, so the capture strategy is - * engine-specific: the Leaflet engine rasterizes its DOM (hiding UI chrome - * for the shot), while the deck.gl/GL engine reads its WebGL canvas. Note - * 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. - */ - getMapScreenshot: mmgisAPI_.getMapScreenshot, + /** getMapScreenshot - captures a PNG screenshot of the current map view. + * Delegates to the active map engine, so the capture strategy is + * engine-specific: the Leaflet engine rasterizes its DOM (hiding UI chrome + * for the shot), while the deck.gl/GL engine reads its WebGL canvas. Note + * 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. + */ + getMapScreenshot: mmgisAPI_.getMapScreenshot, /** onLoaded - calls onLoadCallback as a function once MMGIS has finished loading. * @param {function} - onLoadCallback - function reference to function that is called when MMGIS is finished loading diff --git a/tests/unit/deckGLAdapter.spec.js b/tests/unit/deckGLAdapter.spec.js index 564de5d96..7301658f0 100644 --- a/tests/unit/deckGLAdapter.spec.js +++ b/tests/unit/deckGLAdapter.spec.js @@ -218,22 +218,22 @@ test.describe('DeckGLAdapter', () => { }) }) - test.describe('captureScreenshot', () => { - test('overlay mode reads the canvas inside the render event after triggerRepaint', async () => { - const adapter = makeAdapter() - let inRenderFrame = false - let renderHandler = null - const blob = new Blob(['deckgl'], { type: 'image/png' }) - const canvas = { - width: 256, - height: 128, - toBlob: (callback, type) => { - expect(type).toBe('image/png') - // Only valid during the render event, before the browser - // presents (and clears) the drawing buffer. - callback(inRenderFrame ? blob : null) - }, - } + test.describe('captureScreenshot', () => { + test('overlay mode reads the canvas inside the render event after triggerRepaint', async () => { + const adapter = makeAdapter() + let inRenderFrame = false + let renderHandler = null + const blob = new Blob(['deckgl'], { type: 'image/png' }) + const canvas = { + width: 256, + height: 128, + toBlob: (callback, type) => { + expect(type).toBe('image/png') + // Only valid during the render event, before the browser + // presents (and clears) the drawing buffer. + callback(inRenderFrame ? blob : null) + }, + } adapter._isOverlayMode = true adapter._basemap = { once: (type, handler) => { @@ -250,87 +250,95 @@ test.describe('DeckGLAdapter', () => { }, getCanvas: () => canvas, } - - const result = await adapter.captureScreenshot() - expect(result).toEqual({ - blob, - mimeType: 'image/png', - extension: 'png', - width: 256, - height: 128, - }) - }) - - test('overlay mode rejects when toBlob returns null during the render event', async () => { - const adapter = makeAdapter() - let renderHandler = null - adapter._isOverlayMode = true - adapter._basemap = { - once: (_type, handler) => { renderHandler = handler }, - triggerRepaint: () => renderHandler(), - getCanvas: () => ({ - width: 256, - height: 128, - toBlob: (callback) => callback(null), - }), - } - - await expect(adapter.captureScreenshot()).rejects.toThrow(/toBlob returned null/) - }) + + const result = await adapter.captureScreenshot() + expect(result).toEqual({ + blob, + mimeType: 'image/png', + extension: 'png', + width: 256, + height: 128, + }) + }) + + test('overlay mode rejects when toBlob returns null during the render event', async () => { + const adapter = makeAdapter() + let renderHandler = null + adapter._isOverlayMode = true + adapter._basemap = { + once: (_type, handler) => { renderHandler = handler }, + triggerRepaint: () => renderHandler(), + getCanvas: () => ({ + width: 256, + height: 128, + toBlob: (callback) => callback(null), + }), + } + + await expect(adapter.captureScreenshot()).rejects.toThrow(/toBlob returned null/) + }) test('overlay mode rejects after the timeout if the render event never fires', async () => { vi.useFakeTimers() try { const adapter = makeAdapter() let repainted = false + let armedHandler = null + const removed = [] adapter._isOverlayMode = true - adapter._basemap = { - once: () => {}, - triggerRepaint: () => { repainted = true }, - getCanvas: () => ({ - width: 256, - height: 128, - toBlob: () => {}, - }), - } + adapter._basemap = { + once: (type, handler) => { armedHandler = handler }, + off: (type, handler) => { removed.push({ type, handler }) }, + triggerRepaint: () => { repainted = true }, + getCanvas: () => ({ + width: 256, + height: 128, + toBlob: () => {}, + }), + } const capture = adapter.captureScreenshot() const assertion = expect(capture).rejects.toThrow(/timed out/) vi.advanceTimersByTime(3000) await assertion expect(repainted).toBe(true) + // The timeout must unhook the pending render listener, or a + // timed-out capture leaves it armed to fire on a later render. + expect(removed).toEqual([ + { type: 'render', handler: armedHandler }, + ]) } finally { vi.useRealTimers() } }) - test('standalone mode redraws deck and reads its canvas', async () => { - const adapter = makeAdapter() - let redrawArg = null - const blob = new Blob(['standalone'], { type: 'image/png' }) - adapter._isOverlayMode = false - adapter._deck = { - redraw: (reason) => { redrawArg = reason }, - getCanvas: () => ({ - width: 300, - height: 200, - toBlob: (callback, type) => { - expect(type).toBe('image/png') - callback(blob) - }, - }), - } - - const result = await adapter.captureScreenshot() - expect(redrawArg).toBe('screenshot') - expect(result).toEqual({ - blob, - mimeType: 'image/png', - extension: 'png', - width: 300, - height: 200, - }) - }) + test('standalone mode redraws deck and reads its canvas', async () => { + const adapter = makeAdapter() + let redrawArg = null + const blob = new Blob(['standalone'], { type: 'image/png' }) + adapter._isOverlayMode = false + adapter._deck = { + redraw: (reason) => { redrawArg = reason }, + getCanvas: () => ({ + width: 300, + height: 200, + toBlob: (callback, type) => { + expect(type).toBe('image/png') + callback(blob) + }, + }), + } + + const result = await adapter.captureScreenshot() + expect(redrawArg).toBe('screenshot') + expect(result).toEqual({ + blob, + mimeType: 'image/png', + extension: 'png', + width: 300, + height: 200, + }) + }) test('rejects when there is no active map to capture', async () => { const adapter = makeAdapter() From f8091b4fac486ac5fe5fc67849fde92e959cac47 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Thu, 2 Jul 2026 08:59:11 -0500 Subject: [PATCH 19/23] [143] docs: trim comments to repo-normal density; correct Leaflet claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix-pass files carried 2-6x the repo's comment density; cut narration and keep only the constraints the code can't show. Also corrects the clone fixup rationale: cloned nodes keep their inline styles — the guard is against onclone reading live styles after the UI restore, not against the clone 'losing' z-index; and scopes the transform claim to zoom-animated overlay SVG containers. --- src/essence/Basics/Formulae_/Formulae_.js | 10 ++-- .../MapEngines/Adapters/DeckGLAdapter.ts | 32 ++++--------- .../MapEngines/Adapters/LeafletCloneFixups.js | 48 +++++-------------- .../MapEngines/Adapters/LeafletScreenshot.js | 32 ++++--------- src/essence/mmgisAPI/mmgisAPI.js | 24 +++------- 5 files changed, 41 insertions(+), 105 deletions(-) diff --git a/src/essence/Basics/Formulae_/Formulae_.js b/src/essence/Basics/Formulae_/Formulae_.js index ec2ebf8a7..4203f3139 100644 --- a/src/essence/Basics/Formulae_/Formulae_.js +++ b/src/essence/Basics/Formulae_/Formulae_.js @@ -1803,10 +1803,8 @@ var Formulae_ = { downloadAnchorNode.click() downloadAnchorNode.remove() }, - // Downloads a Blob as a file via a transient object URL. Preferred over - // data-URL anchor downloads (capped/unreliable for large payloads across - // browsers) and over fetch(dataUrl) conversion (the shipped CSP's - // connect-src does not allow the data: scheme). + // Downloads a Blob as a file via a transient object URL. Don't convert + // via fetch(dataUrl): the shipped CSP's connect-src blocks the data: scheme. downloadBlob(blob, filename) { const url = URL.createObjectURL(blob) const link = document.createElement('a') @@ -1815,9 +1813,7 @@ var Formulae_ = { document.body.appendChild(link) // required for firefox link.click() link.remove() - // Revoke on a delay: browsers may dereference the blob URL - // asynchronously after the click, and an immediate revoke can abort - // the download. + // Deferred: an immediate revoke can abort the download. setTimeout(() => URL.revokeObjectURL(url), 10000) }, getMinMaxOfArray(arrayOfNumbers) { diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index c03c8667c..8db6f0d9c 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -406,24 +406,14 @@ export class DeckGLAdapter implements IMapEngine { * `canvas.toDataURL()` only returns pixels if the read happens before * that clear. Rather than paying the per-frame cost of creating the GL * context with `preserveDrawingBuffer: true`, we capture on demand: + * overlay mode reads the shared (interleaved) canvas inside a + * `once('render')` handler after `triggerRepaint()` — the render event + * fires before the browser presents/clears the buffer; standalone mode + * reads right after `deck.redraw(reason)`, which draws synchronously in + * deck.gl v9, so the buffer is still valid within the same task. * - * - **Overlay mode** (the modern map): the `MapboxOverlay` runs in - * `interleaved: true` mode, so deck.gl draws into the base map's GL - * context — there is a single canvas holding basemap + deck layers. - * We subscribe `once('render')` and call `triggerRepaint()`: the - * `render` event fires after the frame's draw but before the browser - * presents (and clears) the buffer — the documented maplibre/mapbox - * pattern for readback without `preserveDrawingBuffer`. A timeout - * rejects if the render event never fires (e.g. destroyed map). - * - **Standalone mode**: deck.gl owns the only canvas. In deck.gl v9, - * `deck.redraw(reason)` draws synchronously (`redraw` -> `_drawLayers` - * -> `DeckRenderer.renderLayers` all in the same call stack), so - * reading the canvas immediately afterwards in the same task is safe: - * the drawing buffer is only cleared when the browser presents, after - * the current task completes. - * - * Note: this captures only the GL canvas. Anchored HTML overlays/markers - * added via {@link addOverlay} are separate DOM nodes and are not included. + * Captures only the GL canvas: HTML overlays/markers added via + * {@link addOverlay} are separate DOM nodes and are not included. */ captureScreenshot(): Promise { return new Promise((resolve, reject) => { @@ -438,10 +428,8 @@ export class DeckGLAdapter implements IMapEngine { ) } const timeout = setTimeout(() => { - // Unhook the pending listener, otherwise a timed-out - // capture (e.g. a backgrounded tab whose repaint frame - // never ran) leaves it armed to fire — and do a wasted - // capture — on some later render. + // Unhook, or the listener stays armed to fire a wasted + // capture on some later render (e.g. backgrounded tab). basemap.off('render', onRender) reject( new Error( @@ -459,8 +447,6 @@ export class DeckGLAdapter implements IMapEngine { reject(new Error('[DeckGLAdapter] captureScreenshot: no active map to capture')) return } - // Synchronous in deck.gl v9 — pixels are guaranteed present - // for the toDataURL() read below (same task, pre-present). deck.redraw('screenshot') const canvas = (deck as unknown as { getCanvas?: () => HTMLCanvasElement }) .getCanvas?.() diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletCloneFixups.js b/src/essence/Basics/MapEngines/Adapters/LeafletCloneFixups.js index e93762b7e..4eecfca3d 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletCloneFixups.js +++ b/src/essence/Basics/MapEngines/Adapters/LeafletCloneFixups.js @@ -1,32 +1,20 @@ /** - * Shared html2canvas clone fixups for Leaflet maps. + * Shared html2canvas clone fixups for Leaflet maps, used by the screenshot + * capture (LeafletScreenshot) and the Animation tool's offscreen frame capture + * (OffscreenMapManager); only the scoping selectors differ. * - * html2canvas clones the DOM and rasterizes the clone, but two Leaflet quirks - * make a naive clone render wrong: - * - Overlay SVGs carry Leaflet's pan/zoom transform in their inline style, - * and html2canvas mispositions transformed SVGs — so each SVG is - * re-parented into a wrapper
that carries the transform instead. - * - Tile layers stack by inline z-index, which the clone can lose. + * - Leaflet positions zoom-animated overlay SVG containers with inline CSS + * transforms. html2canvas can render those transformed SVGs shifted, so each + * SVG is re-parented into a wrapper
that carries the transform. + * - Leaflet tile layers use inline z-index values on their containers. The + * fixup reapplies the capture-time values so screenshot ordering matches the + * map state at capture kickoff. * - * Both the screenshot capture (LeafletScreenshot) and the Animation tool's - * offscreen frame capture (OffscreenMapManager) need the identical fixups; - * they differ only in the selectors that scope which map is being captured. - * - * The styles are snapshotted from the live document up front (not read inside - * `onclone`): html2canvas fires `onclone` asynchronously, so by then the - * caller may have restored/mutated the live styles — the snapshot pins the - * capture to the styles exactly as they were at kickoff. + * Styles are snapshotted up front rather than read inside `onclone`, which + * fires asynchronously — after the caller may have restored the live styles. */ -/** - * Reads the inline style attributes the clone fixups will need, from the live - * document, at call time. Call this at capture kickoff, before any UI restore. - * - * @param {object} selectors - * @param {string} selectors.svgSelector - matches the map's overlay SVGs - * @param {string} selectors.tileSelector - matches the map's tile layer divs - * @returns {object} snapshot to pass to {@link applyLeafletCloneFixups} - */ +/** Reads the inline styles the fixups need from the live document. */ export function snapshotLeafletPaneStyles({ svgSelector, tileSelector }) { return { svgSelector, @@ -42,16 +30,8 @@ export function snapshotLeafletPaneStyles({ svgSelector, tileSelector }) { } } -/** - * Applies the fixups to an html2canvas clone (the `onclone` callback's body). - * - * @param {HTMLElement} cloneBody - `e.body` from html2canvas's onclone - * @param {object} snapshot - from {@link snapshotLeafletPaneStyles} - */ +/** Applies the fixups to an html2canvas clone (`e.body` from onclone). */ export function applyLeafletCloneFixups(cloneBody, snapshot) { - // Fix svg layer shift: move each overlay SVG's inline style (Leaflet's - // pan/zoom transform) onto a wrapper div, which html2canvas renders - // correctly. const copySVG = cloneBody.querySelectorAll(snapshot.svgSelector) copySVG.forEach((copyEle, i) => { const attribute = snapshot.svgStyles[i] @@ -64,8 +44,6 @@ export function applyLeafletCloneFixups(cloneBody, snapshot) { copyEle.removeAttribute('style') }) - // Fix tile layer stacking: copy the snapshotted inline styles (z-index) - // onto the clone's tile layers. const copyZ = cloneBody.querySelectorAll(snapshot.tileSelector) copyZ.forEach((copyEle, i) => { if (snapshot.tileStyles[i] != null) diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js b/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js index b138e25b1..df40556d5 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js +++ b/src/essence/Basics/MapEngines/Adapters/LeafletScreenshot.js @@ -7,17 +7,9 @@ import { } from './LeafletCloneFixups' /** - * The Leaflet map engine's screenshot strategy: captures a PNG of the current - * 2D Leaflet map. Invoked via `LeafletAdapter.captureScreenshot()`, which is - * what `mmgisAPI.getMapScreenshot()` delegates to when Leaflet is the active - * engine. (The deck.gl engine reads its WebGL canvas instead — see - * `DeckGLAdapter.captureScreenshot()`.) - * - * Temporarily hides UI chrome (zoom controls, compass, scale factor, time UI) - * and normalizes the Leaflet pane z-indices so html2canvas rasterizes the - * layers in the correct order, then restores that chrome afterwards. The - * clone fixups the capture needs (SVG re-parenting, tile z-index copy) live in - * the shared LeafletCloneFixups module. + * The Leaflet map engine's screenshot strategy: rasterizes the map container + * with html2canvas, temporarily hiding some UI (zoom controls, compass, scale + * factor, time UI) and normalizing the Leaflet pane z-indices for the shot. * * @returns {Promise} Resolves to a PNG Blob plus metadata: * `{ blob, mimeType, extension, width, height }`. @@ -43,18 +35,14 @@ function getMapScreenshot() { const timeUIWasActive = jquery('#toggleTimeUI.active').length > 0 if (timeUIWasActive) jquery('#toggleTimeUI.active').trigger('click') - // Snapshot the pane styles NOW — after the z-index normalization above, - // before the restore below. The onclone fixups apply these saved values - // instead of re-reading the live DOM, which will already be restored by - // the time html2canvas fires onclone. + // Snapshot now — after the z-index normalization, before the restore — + // because onclone fires after the restore has already run. const paneStyles = snapshotLeafletPaneStyles({ svgSelector: 'svg.leaflet-zoom-animated', tileSelector: '.leaflet-tile-pane > div.leaflet-layer', }) - // Restore the UI chrome hidden above. Split out so the error path below - // can restore too — without it, a synchronous throw between the hide and - // the capture would leave controls hidden and the time UI collapsed. + // Split out so the error paths below can restore the hidden UI too. const restoreChrome = function () { jquery('#map .leaflet-tile-pane') .children() @@ -99,11 +87,9 @@ function getMapScreenshot() { throw err } - // Restore the UI chrome we hid for the capture. This runs immediately - // (not in .then) so the live UI isn't visibly degraded for the duration of - // the capture: html2canvas's initial DOM clone happens synchronously within - // the call above, and the later-firing onclone fixups apply the snapshot - // taken before this restore, so restored values can't leak into the clone. + // Restore immediately (not in .then) so the UI isn't visibly degraded + // during the capture: html2canvas clones the DOM synchronously above, and + // the onclone fixups use the pre-restore snapshot. restoreChrome() return capture diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index f77f4f7e7..0af6d00ab 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -438,18 +438,13 @@ var mmgisAPI_ = { return validEvents.includes(eventName) }, writeCoordinateURL: function () { - // The URL builder dereferences L_.Viewer_ / L_.Map_.map / TimeControl, - // which only exist after mission finalization (fina). Until then return - // null — the documented "no link available yet" signal — so an early - // caller (e.g. a plugin's always-visible Copy Link button clicked while - // layers are still loading) doesn't hit a TypeError. + // The URL builder dereferences objects that only exist after mission + // finalization; return null (the "no link yet" signal) until then. if (mmgisAPI_.map == null) return null return QueryURL.writeCoordinateURL() }, getViewState: function () { - // View metadata for plugins (e.g. provenance-rich export filenames) - // without reaching into core internals. Fields are null until the - // mission has loaded far enough to answer them. + // View metadata for plugins; fields are null until loaded. const map = L_.Map_ && L_.Map_.map const center = map && typeof map.getCenter === 'function' ? map.getCenter() : null @@ -464,17 +459,12 @@ var mmgisAPI_ = { } }, getMapScreenshot: function () { - // Screenshot capture is engine-specific: Leaflet rasterizes its DOM - // with html2canvas, while the deck.gl/GL map reads its WebGL canvas - // directly (html2canvas cannot capture a WebGL canvas). Delegate to the - // active IMapEngine adapter, which owns the right strategy. Map_ assigns - // its engine synchronously at init, so a missing engine means no map is - // loaded yet — reject rather than reach into a specific engine's DOM. + // 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') { - // The engine does synchronous DOM work before its promise exists; - // catch a sync throw so callers always get a rejection, never an - // exception escaping what is documented as a promise-returning API. + // Convert a sync engine throw into a rejection. try { return Promise.resolve(engine.captureScreenshot()) } catch (err) { From 3d2c1fab25459967c19d10b51316d2a38a942980 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Thu, 2 Jul 2026 09:05:38 -0500 Subject: [PATCH 20/23] [143] docs: captureScreenshot returns a Blob result, not a data URL --- src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 8db6f0d9c..5dc68bfa0 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -400,7 +400,7 @@ export class DeckGLAdapter implements IMapEngine { } /** - * Capture the current map view as a PNG data URL. + * Capture the current map view as a PNG Blob screenshot result. * * WebGL clears its drawing buffer once the browser presents a frame, so * `canvas.toDataURL()` only returns pixels if the read happens before From b1f040a9e1a9b4e739ce37f21914dba464bdecd6 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Thu, 2 Jul 2026 09:27:47 -0500 Subject: [PATCH 21/23] [143] feat: expose mmgisAPI.copyText(); upgrade the clipboard helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F_.copyToClipboard now tries the async Clipboard API first and falls back to the legacy textarea path when it is absent (insecure origins) OR rejects (embedding iframe without allow=clipboard-write, expired user gesture) — resolving true/false, never rejecting. mmgisAPI.copyText delegates to it so plugins get the same single implementation instead of re-vendoring the fallback. The four core call sites now gate their 'Copied!' feedback on the result instead of showing success unconditionally. --- src/essence/Ancillary/ContextMenu.js | 26 ++++--- src/essence/Basics/Formulae_/Formulae_.js | 59 +++++++++----- .../Basics/UserInterface_/BottomBar.js | 21 ++--- src/essence/Tools/Info/InfoTool.js | 23 +++--- src/essence/mmgisAPI/mmgisAPI.js | 12 +++ tests/unit/copyText.spec.js | 78 +++++++++++++++++++ 6 files changed, 170 insertions(+), 49 deletions(-) create mode 100644 tests/unit/copyText.spec.js diff --git a/src/essence/Ancillary/ContextMenu.js b/src/essence/Ancillary/ContextMenu.js index c4e802ec0..a58ea0476 100644 --- a/src/essence/Ancillary/ContextMenu.js +++ b/src/essence/Ancillary/ContextMenu.js @@ -97,22 +97,28 @@ function showContextMenuMap(e) { $('#contextMenuMapCopyCoords').on('click', function () { F_.copyToClipboard( JSON.stringify(Coordinates.getAllCoordinates(), null, 2) - ) - $('#contextMenuMapCopyCoords').text('Copied!') - setTimeout(function () { - $('#contextMenuMapCopyCoords').text('Copy Coordinates') - }, 2000) + ).then((copied) => { + if (!copied) return + $('#contextMenuMapCopyCoords').text('Copied!') + setTimeout(function () { + $('#contextMenuMapCopyCoords').text('Copy Coordinates') + }, 2000) + }) }) $('#contextMenuCopyable').on('click', function () { const that = this const key = $(that).attr('key') const copyable = L_._toolCopyables[key] - F_.copyToClipboard(JSON.stringify(copyable.copyable, null, 2)) - $(that).text('Copied!') - setTimeout(function () { - $(that).text(copyable.title) - }, 2000) + F_.copyToClipboard(JSON.stringify(copyable.copyable, null, 2)).then( + (copied) => { + if (!copied) return + $(that).text('Copied!') + setTimeout(function () { + $(that).text(copyable.title) + }, 2000) + } + ) }) contextMenuActionsFull.forEach((c) => { diff --git a/src/essence/Basics/Formulae_/Formulae_.js b/src/essence/Basics/Formulae_/Formulae_.js index 4203f3139..2a0337ebd 100644 --- a/src/essence/Basics/Formulae_/Formulae_.js +++ b/src/essence/Basics/Formulae_/Formulae_.js @@ -2043,28 +2043,49 @@ var Formulae_ = { return arr1.filter((e) => arr2.indexOf(e) !== -1) }, /** - * Copies input to user's clipboard + * Copies text to the user's clipboard. Resolves true on success, false on + * failure — never rejects. Tries the async Clipboard API first, falling + * back to a hidden textarea + execCommand('copy') when it is absent + * (insecure origins) OR when it rejects (e.g. an embedding iframe without + * allow="clipboard-write", or a call outside the user-gesture window). * @param {string} text - text to copy to clipboard - * @credit https://hackernoon.com/copying-text-to-clipboard-with-javascript-df4d4988697f + * @returns {Promise} */ - copyToClipboard(text) { - const el = document.createElement('textarea') // Create a