From e13ce6d49d67ac7b76df9c41cb90568c38bffa79 Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Thu, 15 Oct 2026 14:46:54 -0500 Subject: [PATCH 1/2] Layout: bottom float zones clear the basemap logo and attribution The deck.gl engine measures how far the basemap's bottom control corners reach up from the map's edge and reports it as map:getChromeInsets and map:chromeInsetsChange. The modern layout lifts the bottom float zones by that much, less any part a panel already covers. --- .../APIs/JavaScript/Main/Event-Bus-API.md | 1 + .../MapEngines/Adapters/DeckGLAdapter.ts | 44 ++++++++++ .../MapEngines/Adapters/LeafletAdapter.ts | 7 +- src/essence/Basics/MapEngines/IMapEngine.ts | 10 +++ src/essence/Basics/Map_/Map_.js | 2 + .../UserInterface_/UserInterfaceModern_.css | 13 ++- .../UserInterface_/UserInterfaceModern_.js | 55 +++++++++++- ...ts.spec.js => deckGLOverlayChrome.spec.js} | 84 ++++++++++++++++++- .../userInterfaceModern.mapChrome.spec.js | 52 ++++++++++++ 9 files changed, 258 insertions(+), 10 deletions(-) rename tests/unit/{deckGLOverlayEvents.spec.js => deckGLOverlayChrome.spec.js} (50%) create mode 100644 tests/unit/userInterfaceModern.mapChrome.spec.js diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index d12606b35e..68f2de1e2e 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -391,6 +391,7 @@ has already delivered — the request can resolve after a later event lands. | `map:getBounds` | none | `LatLngBounds` | Get map bounds | | `map:getZoom` | none | `number` | Get current zoom level | | `map:getMetersPerPixel` | `{ clientX, clientY }` (optional) | `number \| null` | Ground metres per CSS pixel at that page position, or near the map's bottom-left corner when omitted. Null before the map exists, while it is tilted more than 10°, or when the planet radius is not configured | +| `map:getChromeInsets` | none | `{ bottomLeft, bottomRight }` | How far, in CSS pixels, the basemap's own logo and attribution reach up from the bottom edge of the map on each side. Zero where there is none. `map:chromeInsetsChange` carries the same shape whenever it changes | | `map:setView` | `{ center, zoom }` | `true` | Set map view | | `map:fitBounds` | `bounds` | `true` | Fit map to bounds | | `map:panTo` | `{ lat, lng }` | `true` | Pan map to coordinates | diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 1d92d2d641..4d3bd092ef 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -34,6 +34,7 @@ import type { IMapEngine, ComparisonConfig, ComparisonLayout, + MapChromeInsets, MapScreenshotResult } from '../IMapEngine' import { MAP_ENGINE } from '../types/engine' @@ -453,6 +454,10 @@ export class DeckGLAdapter implements IMapEngine { */ private _comparisonResizeObserver: ResizeObserver | null = null + /** Watches the basemap's bottom control corners (logo, attribution). */ + private _chromeObserver: ResizeObserver | null = null + private _chromeInsets: MapChromeInsets = { bottomLeft: 0, bottomRight: 0 } + // ── Side-by-side comparison state ───────────────────────────────────────── // The other way of splitting the viewport: rather than one camera drawn // twice and wiped between, each half gets a map of its own — basemap @@ -626,6 +631,10 @@ export class DeckGLAdapter implements IMapEngine { this._comparisonEnabled = false this._destroyComparisonSurfaces() + this._chromeObserver?.disconnect() + this._chromeObserver = null + this._chromeInsets = { bottomLeft: 0, bottomRight: 0 } + if (this._isOverlayMode) { if (this._basemap) { this._basemap.off('move', this._onBasemapMove) @@ -2430,6 +2439,41 @@ export class DeckGLAdapter implements IMapEngine { // switch re-anchors the layers on the new style's labels. this._basemap.on('style.load', this._onBasemapLoad) this._basemap.on('error', this._onBasemapError) + this._watchBasemapChrome() + } + + /** + * MapLibre and Mapbox create their corner containers with the map and + * fill them later (logo, attribution), so the corners are measured each + * time they resize. + */ + private _watchBasemapChrome(): void { + this._chromeObserver?.disconnect() + this._chromeObserver = null + if (typeof ResizeObserver === 'undefined') return + const corner = (side: string) => + this._container.querySelector( + `.maplibregl-ctrl-bottom-${side}, .mapboxgl-ctrl-bottom-${side}` + ) + const left = corner('left') + const right = corner('right') + // A corner's box includes its controls' margins; an empty one has no height. + const reach = (el: HTMLElement | null): number => { + if (!el) return 0 + const rect = el.getBoundingClientRect() + if (rect.height === 0) return 0 + return Math.max(0, Math.ceil(this._container.getBoundingClientRect().bottom - rect.top)) + } + this._chromeObserver = new ResizeObserver(() => { + this._chromeInsets = { bottomLeft: reach(left), bottomRight: reach(right) } + this._emitEvent('chromeinsetschange', { ...this._chromeInsets }) + }) + if (left) this._chromeObserver.observe(left) + if (right) this._chromeObserver.observe(right) + } + + getChromeInsets(): MapChromeInsets { + return { ...this._chromeInsets } } /** diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index 429cabd265..bf7f403d45 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -7,7 +7,7 @@ * */ -import { IMapEngine, MapScreenshotResult } from '../IMapEngine' +import { IMapEngine, MapChromeInsets, MapScreenshotResult } from '../IMapEngine' import { LatLng, LatLngLike, @@ -511,6 +511,11 @@ export default class LeafletAdapter implements IMapEngine, IMapEn } } + /** The attribution control is removed at init; nothing sits at the bottom. */ + getChromeInsets(): MapChromeInsets { + return { bottomLeft: 0, bottomRight: 0 } + } + /** * Get the current view state */ diff --git a/src/essence/Basics/MapEngines/IMapEngine.ts b/src/essence/Basics/MapEngines/IMapEngine.ts index 2ef6c0b7b5..47aadc19fc 100644 --- a/src/essence/Basics/MapEngines/IMapEngine.ts +++ b/src/essence/Basics/MapEngines/IMapEngine.ts @@ -29,6 +29,13 @@ export interface MapScreenshotResult { metersPerPixel?: number | null } +/** How far, in CSS pixels, the basemap's logo and attribution reach up from + * the map's bottom edge on each side. */ +export interface MapChromeInsets { + bottomLeft: number + bottomRight: number +} + /** * Core map engine contract. * @@ -133,6 +140,9 @@ export interface IMapEngine< */ getViewState(): ViewState + /** Bottom corner chrome; `'chromeinsetschange'` is emitted when it changes. */ + getChromeInsets(): MapChromeInsets + /** * Set the full camera state. Leaflet adapter ignores bearing and pitch. */ diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 7e0362d9ce..924255d47f 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -262,6 +262,7 @@ let Map_ = { window.mmgisAPI.provide('map:getBounds', () => engine.getBounds()), window.mmgisAPI.provide('map:getZoom', () => engine.getZoom()), window.mmgisAPI.provide('map:getMetersPerPixel', (at) => metersPerPixelAt(engine, at)), + window.mmgisAPI.provide('map:getChromeInsets', () => engine.getChromeInsets()), window.mmgisAPI.provide('map:setView', ({ center, zoom } = {}) => { if (!center) return false engine.setView(center, zoom) @@ -409,6 +410,7 @@ let Map_ = { reEmit('drawcancel', 'map:drawcancel') reEmit('move', 'map:move') reEmit('moveend', 'map:moveend') + reEmit('chromeinsetschange', 'map:chromeInsetsChange') // Pointer streams consumed by plugins (e.g. measure tools, overlays). reEmit('click', 'map:click') reEmit('mousemove', 'map:mousemove') diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css index 3806531cf6..83afc6c633 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.css +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.css @@ -555,13 +555,18 @@ body.ui-is-dragging .ui-modern-grid .ui-panel { padding: var(--ui-float-outer-gap); } -/* Zone positions */ +/* Zone positions. The bottom zones sit above the basemap's logo and + attribution (--ui-map-chrome-*, set by the layout). */ .ui-float-zone-top-left { top: 0; left: 0; } .ui-float-zone-top-center { top: 0; left: 50%; transform: translateX(-50%); } .ui-float-zone-top-right { top: 0; right: 0; } -.ui-float-zone-bottom-left { bottom: 0; left: 0; } -.ui-float-zone-bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); } -.ui-float-zone-bottom-right { bottom: 0; right: 0; } +.ui-float-zone-bottom-left { bottom: var(--ui-map-chrome-bottom-left, 0px); left: 0; } +.ui-float-zone-bottom-center { + bottom: max(var(--ui-map-chrome-bottom-left, 0px), var(--ui-map-chrome-bottom-right, 0px)); + left: 50%; + transform: translateX(-50%); +} +.ui-float-zone-bottom-right { bottom: var(--ui-map-chrome-bottom-right, 0px); right: 0; } /* Float panels re-enable pointer events */ .ui-float-panel { diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js index cd8595a56c..3e55a88744 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js @@ -19,6 +19,35 @@ let layoutStyle = '' let toolLoadQueue = [] let cleanupLayoutListener = null let _resizeObserver = null +let cleanupChromeListener = null +let mapChromeInsets = { bottomLeft: 0, bottomRight: 0 } + +const CHROME_INSET_VARS = { + bottomLeft: '--ui-map-chrome-bottom-left', + bottomRight: '--ui-map-chrome-bottom-right', +} + +// Lifts the bottom float zones above the basemap's logo and attribution. The +// chrome is measured from the map's bottom edge; when a panel overlays the +// bottom of the map, the part it covers needs no clearing. +const _applyMapChromeInsets = () => { + const center = document.getElementById('ui-modern-center') + const map = document.getElementById('map') + const covered = + center && map + ? Math.max(0, map.getBoundingClientRect().bottom - center.getBoundingClientRect().bottom) + : 0 + Object.entries(CHROME_INSET_VARS).forEach(([side, name]) => { + const lift = Math.max(0, mapChromeInsets[side] - covered) + document.documentElement.style.setProperty(name, `${lift}px`) + }) +} + +const _setMapChromeInsets = (insets) => { + if (!insets) return + mapChromeInsets = insets + _applyMapChromeInsets() +} // --- DOM Builder Helpers --- const _createPanelIconTray = (panel) => { @@ -313,11 +342,27 @@ const UserInterfaceModern_ = { cleanupLayoutListener = mmgisAPI.on('panels:changed', this.syncDOMState.bind(this)) } + if (!cleanupChromeListener) { + cleanupChromeListener = mmgisAPI.on( + 'map:chromeInsetsChange', + _setMapChromeInsets + ) + } + // The map may have reported its chrome before this listener existed. + if (mmgisAPI.hasHandler('map:getChromeInsets')) { + mmgisAPI + .request('map:getChromeInsets') + .then(_setMapChromeInsets) + .catch(() => {}) + } + _applyMapChromeInsets() + // Set up ResizeObserver to dispatch resize events when the center map area changes size if (_resizeObserver) { _resizeObserver.disconnect() } _resizeObserver = new ResizeObserver(() => { + _applyMapChromeInsets() window.dispatchEvent(new Event('resize')) }) const centerElement = document.getElementById('ui-modern-center') @@ -340,6 +385,14 @@ const UserInterfaceModern_ = { _resizeObserver.disconnect() _resizeObserver = null } + if (cleanupChromeListener) { + cleanupChromeListener() + cleanupChromeListener = null + } + mapChromeInsets = { bottomLeft: 0, bottomRight: 0 } + Object.values(CHROME_INSET_VARS).forEach((name) => + document.documentElement.style.removeProperty(name) + ) $('#modern-content').empty() panels = [] mmgisAPI_._pluginController = null @@ -870,4 +923,4 @@ const UserInterfaceModern_ = { } export default UserInterfaceModern_ -export { _createPanelHeader, _createPanelIconTray, _renderRegion } +export { _createPanelHeader, _createPanelIconTray, _renderRegion, _setMapChromeInsets } diff --git a/tests/unit/deckGLOverlayEvents.spec.js b/tests/unit/deckGLOverlayChrome.spec.js similarity index 50% rename from tests/unit/deckGLOverlayEvents.spec.js rename to tests/unit/deckGLOverlayChrome.spec.js index 003517624d..4ac2a77447 100644 --- a/tests/unit/deckGLOverlayEvents.spec.js +++ b/tests/unit/deckGLOverlayChrome.spec.js @@ -1,8 +1,9 @@ import { test, expect, vi, beforeEach } from 'vitest' -// The deck.gl engine's overlay mode reports the 'move' stream while the -// camera moves. The GL constructors are mocked (jsdom has no WebGL); the -// basemap mock holds a settable camera. +// What the deck.gl engine's overlay mode tells the layout and a live scale +// bar: the 'move' stream while the camera moves, and how far the basemap's +// logo and attribution reach up from the bottom. The GL constructors are +// mocked (jsdom has no WebGL); the basemap mock holds a settable camera. const basemaps = [] @@ -25,8 +26,14 @@ vi.mock('@deck.gl/core', async (importOriginal) => { vi.mock('maplibre-gl', () => { class MockMap { - constructor() { + constructor(opts) { this.handlers = {} + // MapLibre builds its control corners with the map. + for (const side of ['left', 'right']) { + const corner = document.createElement('div') + corner.className = `maplibregl-ctrl-bottom-${side}` + opts.container.appendChild(corner) + } this.center = { lat: 0, lng: 0 } this.zoom = 0 this.canvas = makeCanvas() @@ -101,6 +108,12 @@ beforeEach(() => { makeContainer() }) +const standalone = (center, zoom) => { + const adapter = new DeckGLAdapter() + adapter.init({ containerId: 'map', zoom, center }) + return adapter +} + const overlay = () => { const adapter = new DeckGLAdapter() adapter.init({ @@ -126,3 +139,66 @@ test.describe('DeckGLAdapter overlay move events', () => { expect(moves[0]).toMatchObject({ latitude: 10, longitude: 20, zoom: 7 }) }) }) + +test.describe('DeckGLAdapter basemap chrome', () => { + // A ResizeObserver that runs its callback on demand, standing in for the + // browser's (jsdom has none). + let observers + beforeEach(() => { + observers = [] + globalThis.ResizeObserver = class { + constructor(cb) { + this.cb = cb + this.targets = [] + observers.push(this) + } + observe(el) { + this.targets.push(el) + } + disconnect() { + this.targets = [] + } + } + }) + + const rect = (el, top, bottom) => { + el.getBoundingClientRect = () => ({ top, bottom, height: bottom - top }) + } + + test('reports how far the logo and attribution reach up from the bottom', () => { + const container = makeContainer() + container.innerHTML = '' + const { adapter } = overlay() + rect(container, 0, 600) + const left = container.querySelector('.maplibregl-ctrl-bottom-left') + const right = container.querySelector('.maplibregl-ctrl-bottom-right') + expect(observers[0].targets).toEqual([left, right]) + + const changes = [] + adapter.on('chromeinsetschange', (e) => changes.push(e)) + rect(left, 569, 600) + rect(right, 578.5, 600) + observers[0].cb() + expect(adapter.getChromeInsets()).toEqual({ bottomLeft: 31, bottomRight: 22 }) + expect(changes).toEqual([{ bottomLeft: 31, bottomRight: 22 }]) + }) + + test('an empty corner reaches nowhere', () => { + const container = makeContainer() + container.innerHTML = '' + const { adapter } = overlay() + rect(container, 0, 600) + rect(container.querySelector('.maplibregl-ctrl-bottom-left'), 600, 600) + rect(container.querySelector('.maplibregl-ctrl-bottom-right'), 600, 600) + observers[0].cb() + expect(adapter.getChromeInsets()).toEqual({ bottomLeft: 0, bottomRight: 0 }) + }) + + test('standalone mode has no basemap chrome', () => { + expect(standalone({ lat: 0, lng: 0 }, 2).getChromeInsets()).toEqual({ + bottomLeft: 0, + bottomRight: 0, + }) + expect(observers).toHaveLength(0) + }) +}) diff --git a/tests/unit/userInterfaceModern.mapChrome.spec.js b/tests/unit/userInterfaceModern.mapChrome.spec.js new file mode 100644 index 0000000000..1b42bf2aed --- /dev/null +++ b/tests/unit/userInterfaceModern.mapChrome.spec.js @@ -0,0 +1,52 @@ +import { test, expect, vi, afterEach } from 'vitest' +vi.mock('../../src/essence/Basics/Viewer_/Viewer_', () => ({ default: {} })) + +// The bottom float zones are lifted by CSS variables the layout sets from the +// map's reported chrome. jsdom does no layout, so the map and centre region +// are given the rectangles a real page would have. + +const { _setMapChromeInsets } = await import( + '../../src/essence/Basics/UserInterface_/UserInterfaceModern_.js' +) + +const box = (id, bottom) => { + const el = document.createElement('div') + el.id = id + el.getBoundingClientRect = () => ({ top: 0, bottom, left: 0, right: 800, height: bottom, width: 800 }) + document.body.appendChild(el) + return el +} + +const lift = (side) => + document.documentElement.style.getPropertyValue(`--ui-map-chrome-bottom-${side}`) + +afterEach(() => { + document.body.innerHTML = '' + document.documentElement.removeAttribute('style') +}) + +test('zones rise by the full chrome when the map and centre share a bottom edge', () => { + box('map', 600) + box('ui-modern-center', 600) + _setMapChromeInsets({ bottomLeft: 31, bottomRight: 18 }) + expect(lift('left')).toBe('31px') + expect(lift('right')).toBe('18px') +}) + +// A bottom panel laid over the map already covers the lower part of the +// chrome; only what reaches above the centre region needs clearing. +test('chrome hidden under a panel overlaying the map is not cleared again', () => { + box('map', 600) + box('ui-modern-center', 580) + _setMapChromeInsets({ bottomLeft: 31, bottomRight: 12 }) + expect(lift('left')).toBe('11px') + expect(lift('right')).toBe('0px') +}) + +test('a map with no bottom chrome leaves the zones on the edge', () => { + box('map', 600) + box('ui-modern-center', 600) + _setMapChromeInsets({ bottomLeft: 0, bottomRight: 0 }) + expect(lift('left')).toBe('0px') + expect(lift('right')).toBe('0px') +}) From d42000a6e56c6e6af673004e9e1a6dfc1822c7ca Mon Sep 17 00:00:00 2001 From: Sandesh Pandey Date: Thu, 15 Oct 2026 15:20:57 -0500 Subject: [PATCH 2/2] Layout: lift a bottom float zone only for chrome beneath it The engine also reports how far each corner's chrome reaches in from its side, so a zone beside the logo or attribution (in the overlay layout, where it sits under a side panel) stays on the edge. Inset changes are announced only when the rounded values change, and the zero insets are one shared constant. --- .../APIs/JavaScript/Main/Event-Bus-API.md | 2 +- .../MapEngines/Adapters/DeckGLAdapter.ts | 33 ++++++++++++++----- .../MapEngines/Adapters/LeafletAdapter.ts | 7 ++-- src/essence/Basics/MapEngines/IMapEngine.ts | 11 ++++++- .../UserInterface_/UserInterfaceModern_.js | 25 ++++++++------ tests/unit/deckGLOverlayChrome.spec.js | 26 ++++++++------- .../userInterfaceModern.mapChrome.spec.js | 20 ++++++++--- 7 files changed, 84 insertions(+), 40 deletions(-) diff --git a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md index 68f2de1e2e..dd0e81f7f5 100644 --- a/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md +++ b/docs/pages/APIs/JavaScript/Main/Event-Bus-API.md @@ -391,7 +391,7 @@ has already delivered — the request can resolve after a later event lands. | `map:getBounds` | none | `LatLngBounds` | Get map bounds | | `map:getZoom` | none | `number` | Get current zoom level | | `map:getMetersPerPixel` | `{ clientX, clientY }` (optional) | `number \| null` | Ground metres per CSS pixel at that page position, or near the map's bottom-left corner when omitted. Null before the map exists, while it is tilted more than 10°, or when the planet radius is not configured | -| `map:getChromeInsets` | none | `{ bottomLeft, bottomRight }` | How far, in CSS pixels, the basemap's own logo and attribution reach up from the bottom edge of the map on each side. Zero where there is none. `map:chromeInsetsChange` carries the same shape whenever it changes | +| `map:getChromeInsets` | none | `{ bottomLeft, bottomRight, bottomLeftWidth, bottomRightWidth }` | How far, in CSS pixels, the basemap's own logo and attribution reach up from the bottom edge of the map on each side (`bottomLeft`, `bottomRight`), and in from that side's edge (`…Width`). Zero where there is none. `map:chromeInsetsChange` carries the same shape whenever it changes | | `map:setView` | `{ center, zoom }` | `true` | Set map view | | `map:fitBounds` | `bounds` | `true` | Fit map to bounds | | `map:panTo` | `{ lat, lng }` | `true` | Pan map to coordinates | diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 4d3bd092ef..eeaf26ae53 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -37,6 +37,7 @@ import type { MapChromeInsets, MapScreenshotResult } from '../IMapEngine' +import { NO_CHROME_INSETS } from '../IMapEngine' import { MAP_ENGINE } from '../types/engine' import type { MapEngineType } from '../types/engine' import type { LatLng, LatLngLike, BoundsLike, PointLike } from '../types/geometry' @@ -456,7 +457,7 @@ export class DeckGLAdapter implements IMapEngine { /** Watches the basemap's bottom control corners (logo, attribution). */ private _chromeObserver: ResizeObserver | null = null - private _chromeInsets: MapChromeInsets = { bottomLeft: 0, bottomRight: 0 } + private _chromeInsets: MapChromeInsets = { ...NO_CHROME_INSETS } // ── Side-by-side comparison state ───────────────────────────────────────── // The other way of splitting the viewport: rather than one camera drawn @@ -633,7 +634,7 @@ export class DeckGLAdapter implements IMapEngine { this._chromeObserver?.disconnect() this._chromeObserver = null - this._chromeInsets = { bottomLeft: 0, bottomRight: 0 } + this._chromeInsets = { ...NO_CHROME_INSETS } if (this._isOverlayMode) { if (this._basemap) { @@ -2458,15 +2459,29 @@ export class DeckGLAdapter implements IMapEngine { const left = corner('left') const right = corner('right') // A corner's box includes its controls' margins; an empty one has no height. - const reach = (el: HTMLElement | null): number => { - if (!el) return 0 - const rect = el.getBoundingClientRect() - if (rect.height === 0) return 0 - return Math.max(0, Math.ceil(this._container.getBoundingClientRect().bottom - rect.top)) + const measure = (el: HTMLElement | null, map: DOMRect) => { + const rect = el?.getBoundingClientRect() + if (!rect || rect.height === 0) return { up: 0, left: 0, right: 0 } + return { + up: Math.max(0, Math.ceil(map.bottom - rect.top)), + left: Math.max(0, Math.ceil(rect.right - map.left)), + right: Math.max(0, Math.ceil(map.right - rect.left)), + } } this._chromeObserver = new ResizeObserver(() => { - this._chromeInsets = { bottomLeft: reach(left), bottomRight: reach(right) } - this._emitEvent('chromeinsetschange', { ...this._chromeInsets }) + const map = this._container.getBoundingClientRect() + const l = measure(left, map) + const r = measure(right, map) + const next: MapChromeInsets = { + bottomLeft: l.up, + bottomRight: r.up, + bottomLeftWidth: l.left, + bottomRightWidth: r.right, + } + const prev = this._chromeInsets + if ((Object.keys(next) as (keyof MapChromeInsets)[]).every((k) => next[k] === prev[k])) return + this._chromeInsets = next + this._emitEvent('chromeinsetschange', { ...next }) }) if (left) this._chromeObserver.observe(left) if (right) this._chromeObserver.observe(right) diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index bf7f403d45..0f2553b12a 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -7,7 +7,7 @@ * */ -import { IMapEngine, MapChromeInsets, MapScreenshotResult } from '../IMapEngine' +import { IMapEngine, MapChromeInsets, MapScreenshotResult, NO_CHROME_INSETS } from '../IMapEngine' import { LatLng, LatLngLike, @@ -511,9 +511,10 @@ export default class LeafletAdapter implements IMapEngine, IMapEn } } - /** The attribution control is removed at init; nothing sits at the bottom. */ + /** The attribution control is removed at init. The scale factor control + * Map_ adds when `mapscale` is configured is not measured. */ getChromeInsets(): MapChromeInsets { - return { bottomLeft: 0, bottomRight: 0 } + return { ...NO_CHROME_INSETS } } /** diff --git a/src/essence/Basics/MapEngines/IMapEngine.ts b/src/essence/Basics/MapEngines/IMapEngine.ts index 47aadc19fc..1f84ed79e9 100644 --- a/src/essence/Basics/MapEngines/IMapEngine.ts +++ b/src/essence/Basics/MapEngines/IMapEngine.ts @@ -30,12 +30,21 @@ export interface MapScreenshotResult { } /** How far, in CSS pixels, the basemap's logo and attribution reach up from - * the map's bottom edge on each side. */ + * the map's bottom edge on each side, and in from that side's edge. */ export interface MapChromeInsets { bottomLeft: number bottomRight: number + bottomLeftWidth: number + bottomRightWidth: number } +export const NO_CHROME_INSETS: Readonly = Object.freeze({ + bottomLeft: 0, + bottomRight: 0, + bottomLeftWidth: 0, + bottomRightWidth: 0, +}) + /** * Core map engine contract. * diff --git a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js index 3e55a88744..ac65f001c6 100644 --- a/src/essence/Basics/UserInterface_/UserInterfaceModern_.js +++ b/src/essence/Basics/UserInterface_/UserInterfaceModern_.js @@ -20,7 +20,7 @@ let toolLoadQueue = [] let cleanupLayoutListener = null let _resizeObserver = null let cleanupChromeListener = null -let mapChromeInsets = { bottomLeft: 0, bottomRight: 0 } +let mapChromeInsets = null const CHROME_INSET_VARS = { bottomLeft: '--ui-map-chrome-bottom-left', @@ -28,18 +28,23 @@ const CHROME_INSET_VARS = { } // Lifts the bottom float zones above the basemap's logo and attribution. The -// chrome is measured from the map's bottom edge; when a panel overlays the -// bottom of the map, the part it covers needs no clearing. +// chrome is measured from the map's edges. A zone needs no lift for chrome +// that sits beside it, under a side panel, or the part under a bottom panel. const _applyMapChromeInsets = () => { const center = document.getElementById('ui-modern-center') const map = document.getElementById('map') - const covered = - center && map - ? Math.max(0, map.getBoundingClientRect().bottom - center.getBoundingClientRect().bottom) - : 0 + const lifts = { bottomLeft: 0, bottomRight: 0 } + if (center && map && mapChromeInsets) { + const m = map.getBoundingClientRect() + const c = center.getBoundingClientRect() + const covered = Math.max(0, m.bottom - c.bottom) + if (c.left < m.left + (mapChromeInsets.bottomLeftWidth || 0)) + lifts.bottomLeft = Math.max(0, mapChromeInsets.bottomLeft - covered) + if (c.right > m.right - (mapChromeInsets.bottomRightWidth || 0)) + lifts.bottomRight = Math.max(0, mapChromeInsets.bottomRight - covered) + } Object.entries(CHROME_INSET_VARS).forEach(([side, name]) => { - const lift = Math.max(0, mapChromeInsets[side] - covered) - document.documentElement.style.setProperty(name, `${lift}px`) + document.documentElement.style.setProperty(name, `${lifts[side]}px`) }) } @@ -389,7 +394,7 @@ const UserInterfaceModern_ = { cleanupChromeListener() cleanupChromeListener = null } - mapChromeInsets = { bottomLeft: 0, bottomRight: 0 } + mapChromeInsets = null Object.values(CHROME_INSET_VARS).forEach((name) => document.documentElement.style.removeProperty(name) ) diff --git a/tests/unit/deckGLOverlayChrome.spec.js b/tests/unit/deckGLOverlayChrome.spec.js index 4ac2a77447..ee7251c4a2 100644 --- a/tests/unit/deckGLOverlayChrome.spec.js +++ b/tests/unit/deckGLOverlayChrome.spec.js @@ -92,6 +92,7 @@ function makeCanvas() { const { DeckGLAdapter } = await import( '../../src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts' ) +const { NO_CHROME_INSETS } = await import('../../src/essence/Basics/MapEngines/IMapEngine.ts') function makeContainer() { let el = document.getElementById('map') @@ -161,8 +162,8 @@ test.describe('DeckGLAdapter basemap chrome', () => { } }) - const rect = (el, top, bottom) => { - el.getBoundingClientRect = () => ({ top, bottom, height: bottom - top }) + const rect = (el, top, bottom, left = 0, right = 800) => { + el.getBoundingClientRect = () => ({ top, bottom, left, right, height: bottom - top }) } test('reports how far the logo and attribution reach up from the bottom', () => { @@ -176,11 +177,17 @@ test.describe('DeckGLAdapter basemap chrome', () => { const changes = [] adapter.on('chromeinsetschange', (e) => changes.push(e)) - rect(left, 569, 600) - rect(right, 578.5, 600) + rect(left, 569, 600, 0, 97.5) + rect(right, 578.5, 600, 580, 800) observers[0].cb() - expect(adapter.getChromeInsets()).toEqual({ bottomLeft: 31, bottomRight: 22 }) - expect(changes).toEqual([{ bottomLeft: 31, bottomRight: 22 }]) + const insets = { bottomLeft: 31, bottomRight: 22, bottomLeftWidth: 98, bottomRightWidth: 220 } + expect(adapter.getChromeInsets()).toEqual(insets) + expect(changes).toEqual([insets]) + + // A resize that leaves the rounded reach unchanged is not announced. + rect(left, 569.2, 600, 0, 97.6) + observers[0].cb() + expect(changes).toHaveLength(1) }) test('an empty corner reaches nowhere', () => { @@ -191,14 +198,11 @@ test.describe('DeckGLAdapter basemap chrome', () => { rect(container.querySelector('.maplibregl-ctrl-bottom-left'), 600, 600) rect(container.querySelector('.maplibregl-ctrl-bottom-right'), 600, 600) observers[0].cb() - expect(adapter.getChromeInsets()).toEqual({ bottomLeft: 0, bottomRight: 0 }) + expect(adapter.getChromeInsets()).toEqual(NO_CHROME_INSETS) }) test('standalone mode has no basemap chrome', () => { - expect(standalone({ lat: 0, lng: 0 }, 2).getChromeInsets()).toEqual({ - bottomLeft: 0, - bottomRight: 0, - }) + expect(standalone({ lat: 0, lng: 0 }, 2).getChromeInsets()).toEqual(NO_CHROME_INSETS) expect(observers).toHaveLength(0) }) }) diff --git a/tests/unit/userInterfaceModern.mapChrome.spec.js b/tests/unit/userInterfaceModern.mapChrome.spec.js index 1b42bf2aed..a16d354560 100644 --- a/tests/unit/userInterfaceModern.mapChrome.spec.js +++ b/tests/unit/userInterfaceModern.mapChrome.spec.js @@ -9,10 +9,10 @@ const { _setMapChromeInsets } = await import( '../../src/essence/Basics/UserInterface_/UserInterfaceModern_.js' ) -const box = (id, bottom) => { +const box = (id, bottom, left = 0, right = 800) => { const el = document.createElement('div') el.id = id - el.getBoundingClientRect = () => ({ top: 0, bottom, left: 0, right: 800, height: bottom, width: 800 }) + el.getBoundingClientRect = () => ({ top: 0, bottom, left, right, height: bottom, width: right - left }) document.body.appendChild(el) return el } @@ -28,7 +28,7 @@ afterEach(() => { test('zones rise by the full chrome when the map and centre share a bottom edge', () => { box('map', 600) box('ui-modern-center', 600) - _setMapChromeInsets({ bottomLeft: 31, bottomRight: 18 }) + _setMapChromeInsets({ bottomLeft: 31, bottomRight: 18, bottomLeftWidth: 98, bottomRightWidth: 40 }) expect(lift('left')).toBe('31px') expect(lift('right')).toBe('18px') }) @@ -38,7 +38,7 @@ test('zones rise by the full chrome when the map and centre share a bottom edge' test('chrome hidden under a panel overlaying the map is not cleared again', () => { box('map', 600) box('ui-modern-center', 580) - _setMapChromeInsets({ bottomLeft: 31, bottomRight: 12 }) + _setMapChromeInsets({ bottomLeft: 31, bottomRight: 12, bottomLeftWidth: 98, bottomRightWidth: 40 }) expect(lift('left')).toBe('11px') expect(lift('right')).toBe('0px') }) @@ -46,7 +46,17 @@ test('chrome hidden under a panel overlaying the map is not cleared again', () = test('a map with no bottom chrome leaves the zones on the edge', () => { box('map', 600) box('ui-modern-center', 600) - _setMapChromeInsets({ bottomLeft: 0, bottomRight: 0 }) + _setMapChromeInsets({ bottomLeft: 0, bottomRight: 0, bottomLeftWidth: 0, bottomRightWidth: 0 }) expect(lift('left')).toBe('0px') expect(lift('right')).toBe('0px') }) + +// In the overlay layout the map spans the page behind the side panels, so a +// corner's chrome can sit under a panel, beside the centre region. +test('chrome beside the centre region, under a side panel, is not cleared', () => { + box('map', 600) + box('ui-modern-center', 600, 300, 760) + _setMapChromeInsets({ bottomLeft: 31, bottomRight: 22, bottomLeftWidth: 98, bottomRightWidth: 220 }) + expect(lift('left')).toBe('0px') + expect(lift('right')).toBe('22px') +})