From 2718d3212005f2c9fe7596b154d8f7a5944ee9a2 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 14 Apr 2026 12:14:56 -0500 Subject: [PATCH 01/21] leaflet basemap rendering --- .../Modals/NewMissionModal/NewMissionModal.js | 11 +- configure/src/metaconfigs/tab-ui-config.json | 2 +- src/css/mmgis.css | 99 ++++++++ src/essence/Ancillary/BasemapSwitcher.js | 221 ++++++++++++++++++ .../MapEngines/Adapters/LeafletAdapter.ts | 207 ++++++++++++++++ src/essence/Basics/MapEngines/types/view.ts | 46 +++- src/essence/Basics/Map_/Map_.js | 4 + 7 files changed, 576 insertions(+), 14 deletions(-) create mode 100644 src/essence/Ancillary/BasemapSwitcher.js diff --git a/configure/src/components/Panel/Modals/NewMissionModal/NewMissionModal.js b/configure/src/components/Panel/Modals/NewMissionModal/NewMissionModal.js index 47c2177b6..1a672f40e 100644 --- a/configure/src/components/Panel/Modals/NewMissionModal/NewMissionModal.js +++ b/configure/src/components/Panel/Modals/NewMissionModal/NewMissionModal.js @@ -118,7 +118,7 @@ const MAP_ENGINES = [ const MODAL_NAME = "newMission"; const NewMissionModal = (props) => { - const {} = props; + const { } = props; const c = useStyles(); const modal = useSelector((state) => state.core.modal[MODAL_NAME]); @@ -155,6 +155,7 @@ const NewMissionModal = (props) => { const config = { msv: { + view: [39, -98, 4], radius: { major: planetRadius.major, minor: planetRadius.minor, @@ -163,7 +164,7 @@ const NewMissionModal = (props) => { }, }; - if (selectedEngine === "deckgl" && basemapProvider !== "none" && basemapStyle) { + if (basemapProvider !== "none" && basemapStyle) { config.msv.basemap = { provider: basemapProvider, style: basemapStyle, @@ -313,7 +314,7 @@ const NewMissionModal = (props) => { {`Choose the rendering engine for this mission's 2D map. This cannot be changed after the mission is created.`} - {selectedEngine === "deckgl" && ( + {selectedEngine !== "" && ( <> Basemap @@ -322,13 +323,13 @@ const NewMissionModal = (props) => { onChange={(e) => setBasemapProvider(e.target.value)} label="Basemap" > - None (transparent background) + None (no basemap) MapLibre GL (open-source) Mapbox GL (requires access token) - {`Optional vector-tile basemap rendered beneath deck.gl layers. Can be changed later.`} + {`Optional vector-tile basemap rendered beneath map layers. Can be changed later.`} {basemapProvider !== "none" && ( <> diff --git a/configure/src/metaconfigs/tab-ui-config.json b/configure/src/metaconfigs/tab-ui-config.json index d5d7850a3..1c47e8a16 100644 --- a/configure/src/metaconfigs/tab-ui-config.json +++ b/configure/src/metaconfigs/tab-ui-config.json @@ -76,7 +76,7 @@ { "field": "msv.basemap.provider", "name": "Provider", - "description": "Renders a vector-tile basemap beneath deck.gl layers using MapboxOverlay. Has no effect on Leaflet-engine missions. 'maplibre' is open-source and requires no token. 'mapbox' requires an access token.", + "description": "Renders a vector-tile basemap beneath map layers using MapLibre or Mapbox GL. Works with both Leaflet and deck.gl engine missions. 'maplibre' is open-source and requires no token. 'mapbox' requires an access token.", "type": "dropdown", "options": ["none", "maplibre", "mapbox"], "default": "none", diff --git a/src/css/mmgis.css b/src/css/mmgis.css index e1de5fa5b..c1dc47798 100644 --- a/src/css/mmgis.css +++ b/src/css/mmgis.css @@ -398,6 +398,105 @@ body { .leaflet-container { background: black; } +/* When a basemap overlay is active, the Leaflet container must be transparent + so the MapLibre/Mapbox GL map behind it is visible. */ +.mmgis-has-basemap.leaflet-container, +.mmgis-has-basemap .leaflet-container { + background: transparent !important; +} +/* Basemap underlay container — sits behind the Leaflet #map div */ +.mmgis-basemap-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; +} +/* Ensure the Leaflet map div sits above the basemap container */ +#map.mmgis-has-basemap, +.mmgis-has-basemap #map { + position: relative; + z-index: 1; +} +/* ---- Basemap Switcher Control ---- */ +.mmgis-basemap-switcher { + position: absolute; + bottom: 72px; + left: 12px; + z-index: 1002; + display: flex; + flex-direction: column; + gap: 4px; + pointer-events: auto; +} +.mmgis-basemap-switcher-toggle { + width: 52px; + height: 52px; + border-radius: 4px; + background: var(--color-a); + border: 2px solid var(--color-a-5); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + transition: border-color 0.2s ease-out; + overflow: hidden; + user-select: none; +} +.mmgis-basemap-switcher-toggle:hover { + border-color: var(--color-mmgis); +} +.mmgis-basemap-switcher-toggle i { + font-size: 22px; + color: var(--color-a5); +} +.mmgis-basemap-switcher-toggle span { + font-size: 9px; + color: var(--color-a5); + margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 48px; + text-align: center; +} +.mmgis-basemap-switcher-options { + display: none; + flex-direction: column; + gap: 3px; + background: var(--color-a); + border: 1px solid var(--color-a-5); + border-radius: 4px; + padding: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); + max-height: 240px; + overflow-y: auto; +} +.mmgis-basemap-switcher.open .mmgis-basemap-switcher-options { + display: flex; +} +.mmgis-basemap-switcher-option { + padding: 6px 10px; + cursor: pointer; + border-radius: 3px; + font-size: 12px; + color: var(--color-a5); + white-space: nowrap; + transition: background 0.15s ease-out, color 0.15s ease-out; +} +.mmgis-basemap-switcher-option:hover { + background: var(--color-a1); + color: #fff; +} +.mmgis-basemap-switcher-option.active { + background: var(--color-o); + color: #fff; +} .leaflet-popup-content-wrapper { background: var(--color-a); color: #e1e1e1; diff --git a/src/essence/Ancillary/BasemapSwitcher.js b/src/essence/Ancillary/BasemapSwitcher.js new file mode 100644 index 000000000..ffb5e534f --- /dev/null +++ b/src/essence/Ancillary/BasemapSwitcher.js @@ -0,0 +1,221 @@ +/** + * BasemapSwitcher.js + * + * A floating UI control that lets the user switch between configured + * basemap styles (e.g. Streets, Satellite, Terrain) at runtime. + * + * Reads style presets from `L_.configData.msv.basemap.styles[]` and + * calls `Map_.engine.setBasemapStyle(url)` to apply the selected style + * to the underlying MapLibre/Mapbox GL basemap in both Leaflet and + * deck.gl adapter modes. + * + * If no styles[] array is configured, sensible defaults are generated + * based on the basemap provider. + */ + +import $ from 'jquery' +import L_ from '../Basics/Layers_/Layers_' + +let Map_ = null +let _container = null +let _activeIndex = 0 + +/** + * Default style presets for Mapbox provider. + */ +const MAPBOX_DEFAULTS = [ + { name: 'Streets', style: 'mapbox://styles/mapbox/streets-v12' }, + { name: 'Satellite', style: 'mapbox://styles/mapbox/satellite-streets-v12' }, + { name: 'Outdoors', style: 'mapbox://styles/mapbox/outdoors-v12' }, + { name: 'Light', style: 'mapbox://styles/mapbox/light-v11' }, + { name: 'Dark', style: 'mapbox://styles/mapbox/dark-v11' }, +] + +/** + * Default style presets for MapLibre provider. + * Uses OpenFreeMap styles which are free and require no token. + */ +const MAPLIBRE_DEFAULTS = [ + { name: 'Liberty', style: 'https://tiles.openfreemap.org/styles/liberty' }, + { name: 'Bright', style: 'https://tiles.openfreemap.org/styles/bright' }, + { name: 'Positron', style: 'https://tiles.openfreemap.org/styles/positron' }, +] + +const BasemapSwitcher = { + /** + * Initialise and mount the basemap switcher control. + * + * @param {object} mapRef - Reference to the Map_ module. + */ + init(mapRef) { + Map_ = mapRef + + const basemapConfig = L_.configData?.msv?.basemap + if (!basemapConfig || !basemapConfig.provider || basemapConfig.provider === 'none') { + return + } + + // Use configured styles, or auto-generate defaults based on provider + let styles = basemapConfig.styles + if (!styles || styles.length === 0) { + styles = this._getDefaultStyles(basemapConfig) + } + + if (!styles || styles.length === 0) { + return + } + + // Determine which style is initially active (match against basemap.style) + _activeIndex = 0 + styles.forEach((s, i) => { + if (s.style === basemapConfig.style) { + _activeIndex = i + } + }) + + // Build the DOM + this._buildUI(styles) + }, + + /** + * Remove the switcher from the DOM. + */ + destroy() { + $(document).off('click.basemapSwitcher') + if (_container) { + _container.remove() + _container = null + } + }, + + /** + * Generate default style presets based on the basemap provider. + * If the configured style URL is not in the defaults, prepend it as "Current". + * + * @param {object} basemapConfig + * @returns {Array<{name: string, style: string}>} + */ + _getDefaultStyles(basemapConfig) { + let defaults + if (basemapConfig.provider === 'mapbox') { + defaults = [...MAPBOX_DEFAULTS] + } else { + defaults = [...MAPLIBRE_DEFAULTS] + } + + // If the configured style is already in defaults, we're good. + const currentInDefaults = defaults.some( + (d) => d.style === basemapConfig.style + ) + + // If not, prepend the current style so the user can always get back to it. + if (!currentInDefaults && basemapConfig.style) { + defaults.unshift({ + name: 'Current', + style: basemapConfig.style, + }) + } + + return defaults + }, + + /** + * Build and append the switcher UI. + * @param {Array<{name: string, style: string}>} styles + */ + _buildUI(styles) { + // Remove previous instance if any + this.destroy() + + const activeName = styles[_activeIndex]?.name || 'Map' + + // Container + _container = $('
') + .addClass('mmgis-basemap-switcher') + .attr('id', 'mmgis-basemap-switcher') + + // Toggle button + const toggle = $('
') + .addClass('mmgis-basemap-switcher-toggle') + .attr('title', 'Switch basemap style') + .html( + `${activeName}` + ) + .on('click', function (e) { + e.stopPropagation() + _container.toggleClass('open') + }) + + _container.append(toggle) + + // Options panel + const options = $('
').addClass('mmgis-basemap-switcher-options') + + styles.forEach((styleEntry, index) => { + const option = $('
') + .addClass('mmgis-basemap-switcher-option') + .addClass(index === _activeIndex ? 'active' : '') + .text(styleEntry.name) + .on('click', function (e) { + e.stopPropagation() + BasemapSwitcher._selectStyle(index, styles) + }) + options.append(option) + }) + + _container.append(options) + + // Mount into mapToolBar (bottom of map area) + $('#mapToolBar').append(_container) + + // Close on outside click + $(document).on('click.basemapSwitcher', function () { + if (_container) _container.removeClass('open') + }) + }, + + /** + * Handle a style selection. + * + * @param {number} index + * @param {Array<{name: string, style: string}>} styles + */ + _selectStyle(index, styles) { + if (index === _activeIndex) { + _container.removeClass('open') + return + } + + _activeIndex = index + const selectedStyle = styles[index] + + // Apply the style to the basemap + if (Map_ && Map_.engine) { + if (typeof Map_.engine.setBasemapStyle === 'function') { + // Leaflet adapter + Map_.engine.setBasemapStyle(selectedStyle.style) + } else if (typeof Map_.engine.getBasemap === 'function') { + // deck.gl adapter — setStyle on the underlying basemap + const basemap = Map_.engine.getBasemap() + if (basemap && typeof basemap.setStyle === 'function') { + basemap.setStyle(selectedStyle.style) + } + } + } + + // Update UI + _container + .find('.mmgis-basemap-switcher-option') + .removeClass('active') + .eq(index) + .addClass('active') + + _container.find('.mmgis-basemap-switcher-toggle span').text( + selectedStyle.name + ) + + _container.removeClass('open') + }, +} + +export default BasemapSwitcher diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index 0d631078b..41054e5f0 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -22,6 +22,7 @@ import { FitBoundsOptions, MapInitOptions, ProjectionOptions, + BasemapOptions, } from '../types/view' import { LayerOptions, TileLayerOptions, MarkerOptions } from '../types/layers' import { IMapEngineMarkers } from '../IMapEngineMarkers' @@ -40,6 +41,9 @@ import { } from '../types/events' import { MapEngineType } from '../types/engine' +import { Map as MaplibreGLMap } from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' + // Leaflet is loaded globally via window.L declare const L: any @@ -79,6 +83,27 @@ export default class LeafletAdapter implements IMapEngine, IMapEn */ private _initOptions: MapInitOptions | null = null + /** + * The MapLibre/Mapbox GL basemap instance (when basemap overlay is active). + * Renders behind the transparent Leaflet canvas. + */ + private _basemapMap: any = null + + /** + * The DOM element hosting the basemap map (sibling of the Leaflet #map div). + */ + private _basemapContainer: HTMLDivElement | null = null + + /** + * The basemap configuration passed at init time. + */ + private _basemapOptions: BasemapOptions | null = null + + /** + * Bound handler for synchronising Leaflet camera → basemap camera. + */ + private _syncHandler: (() => void) | null = null + /** * Initialize the Leaflet map instance */ @@ -154,6 +179,11 @@ export default class LeafletAdapter implements IMapEngine, IMapEn if (attributionControl) { attributionControl.remove() } + + // --- Basemap overlay setup --- + if (options.basemap && options.basemap.provider && options.basemap.provider !== 'none' as any) { + this._initBasemapOverlay(options.basemap, center, zoom) + } } /** @@ -247,6 +277,9 @@ export default class LeafletAdapter implements IMapEngine, IMapEn destroy(): void { if (!this._map) return + // Clean up basemap overlay first + this._destroyBasemapOverlay() + this._eventHandlers.forEach((handler, eventName) => { this._map.off(eventName, handler) }) @@ -268,6 +301,14 @@ export default class LeafletAdapter implements IMapEngine, IMapEn return this._map } + /** + * Get the underlying MapLibre/Mapbox GL basemap instance. + * Returns null if no basemap is configured. + */ + getBasemap(): any { + return this._basemapMap + } + /** * Get the container element */ @@ -473,6 +514,7 @@ export default class LeafletAdapter implements IMapEngine, IMapEn */ invalidateSize(): void { this._map.invalidateSize() + this._basemapMap?.resize() } /** @@ -939,4 +981,169 @@ export default class LeafletAdapter implements IMapEngine, IMapEn } return null } + + // ======================================== + // BASEMAP OVERLAY METHODS + // ======================================== + + /** + * Initialise a MapLibre/Mapbox GL basemap behind the Leaflet canvas. + * + * Creates a sibling div before the Leaflet container, instantiates a + * MapLibre (or Mapbox) GL map inside it, makes the Leaflet container + * background transparent, and wires up pan/zoom sync events. + */ + private _initBasemapOverlay( + basemap: BasemapOptions, + center: LatLng, + zoom: number + ): void { + this._basemapOptions = basemap + + this._basemapContainer = document.createElement('div') + this._basemapContainer.id = 'mmgis-basemap' + this._basemapContainer.className = 'mmgis-basemap-container' + this._basemapContainer.style.position = 'absolute' + this._basemapContainer.style.top = '0' + this._basemapContainer.style.left = '0' + this._basemapContainer.style.width = '100%' + this._basemapContainer.style.height = '100%' + this._basemapContainer.style.zIndex = '0' + + // Insert before the Leaflet container so it renders behind + this._container!.parentNode!.insertBefore( + this._basemapContainer, + this._container + ) + + // Make the Leaflet container transparent so the basemap shows through + this._container!.style.background = 'transparent' + this._container!.classList.add('mmgis-has-basemap') + + // Ensure leaflet panes have transparent background + const tilePane = this._container!.querySelector('.leaflet-tile-pane') as HTMLElement + if (tilePane) { + tilePane.style.background = 'transparent' + } + + // Create the basemap map instance + if (basemap.provider === 'mapbox') { + this._initBasemapMapbox(basemap, center, zoom) + } else { + // MapLibre (default) + this._initBasemapMaplibre(basemap, center, zoom) + } + + // Wire up Leaflet → basemap sync + this._syncHandler = () => this._syncBasemap() + this._map.on('move', this._syncHandler) + this._map.on('zoomend', this._syncHandler) + this._map.on('resize', () => this._basemapMap?.resize()) + } + + /** + * Initialise a MapLibre GL basemap. + */ + private _initBasemapMaplibre( + basemap: BasemapOptions, + center: LatLng, + zoom: number + ): void { + this._basemapMap = new MaplibreGLMap({ + container: this._basemapContainer!, + style: basemap.style, + center: [center.lng, center.lat], + zoom: zoom, + attributionControl: false, + interactive: false, // Leaflet handles all interaction + }) + } + + /** + * Initialise a Mapbox GL basemap via dynamic import. + */ + private async _initBasemapMapbox( + basemap: BasemapOptions, + center: LatLng, + zoom: number + ): Promise { + try { + const lib = (await import('mapbox-gl')) as any + const MapboxMap = (lib.default ?? lib).Map + if (!MapboxMap) { + throw new Error('Map not found in mapbox-gl module') + } + + this._basemapMap = new MapboxMap({ + container: this._basemapContainer!, + style: basemap.style, + center: [center.lng, center.lat], + zoom: zoom, + accessToken: basemap.accessToken, + attributionControl: false, + interactive: false, + }) + } catch { + console.error( + 'LeafletAdapter: mapbox-gl is not installed. ' + + "Run `npm install mapbox-gl` or use provider: 'maplibre' instead." + ) + } + } + + /** + * Synchronise the basemap camera to match the current Leaflet view. + * Called on Leaflet `move` and `zoomend` events. + */ + private _syncBasemap(): void { + if (!this._basemapMap) return + + const center = this._map.getCenter() + const zoom = this._map.getZoom() + + this._basemapMap.jumpTo({ + center: [center.lng, center.lat], + zoom: zoom, + }) + } + + /** + * Switch the basemap to a different style at runtime. + * This is used by the BasemapSwitcher UI control. + * + * @param styleUrl - A MapLibre/Mapbox style URL. + */ + setBasemapStyle(styleUrl: string): void { + if (!this._basemapMap) return + this._basemapMap.setStyle(styleUrl) + } + + /** + * Clean up the basemap overlay: remove the GL map, its container, + * and restore the Leaflet container's opaque background. + */ + private _destroyBasemapOverlay(): void { + if (this._syncHandler && this._map) { + this._map.off('move', this._syncHandler) + this._map.off('zoomend', this._syncHandler) + this._syncHandler = null + } + + if (this._basemapMap) { + this._basemapMap.remove() + this._basemapMap = null + } + + if (this._basemapContainer) { + this._basemapContainer.remove() + this._basemapContainer = null + } + + if (this._container) { + this._container.style.background = '' + this._container.classList.remove('mmgis-has-basemap') + } + + this._basemapOptions = null + } } diff --git a/src/essence/Basics/MapEngines/types/view.ts b/src/essence/Basics/MapEngines/types/view.ts index b0ff0f765..6af24d7cd 100644 --- a/src/essence/Basics/MapEngines/types/view.ts +++ b/src/essence/Basics/MapEngines/types/view.ts @@ -45,7 +45,7 @@ export interface FitBoundsOptions extends ViewOptions { } /** - * Supported basemap providers for deck.gl overlay mode. + * Supported basemap providers for deck.gl and Leaflet overlay mode. * * `'maplibre'` uses MapLibre GL JS (open-source, no access token required). * `'mapbox'` uses Mapbox GL JS (requires a valid {@link BasemapOptions.accessToken}). @@ -53,10 +53,30 @@ export interface FitBoundsOptions extends ViewOptions { export type BasemapProvider = 'mapbox' | 'maplibre' /** - * Configuration for an optional vector-tile basemap rendered beneath deck.gl layers - * via `@deck.gl/mapbox`'s `MapboxOverlay`. When present, the adapter runs in overlay - * mode; when absent it falls back to a standalone `Deck` instance with a transparent - * background. + * A named basemap style preset for the in-map style switcher control. + * Each entry maps a user-friendly display name to a MapLibre/Mapbox style URL. + * + * @example + * ```ts + * { name: 'Streets', style: 'https://demotiles.maplibre.org/style.json' } + * ``` + */ +export interface BasemapStyleEntry { + /** Display name shown in the style switcher. */ + name: string + /** MapLibre/Mapbox style URL for this entry. */ + style: string +} + +/** + * Configuration for an optional vector-tile basemap rendered beneath map layers + * for both the deck.gl and Leaflet adapters. + * + * **deck.gl**: Uses `@deck.gl/mapbox`'s `MapboxOverlay` to composite deck.gl layers + * on top of a MapLibre/Mapbox GL basemap. + * + * **Leaflet**: Creates a MapLibre/Mapbox GL map behind a transparent Leaflet canvas + * and synchronises pan/zoom events so the basemap and Leaflet layers stay aligned. * * The chosen provider's stylesheet must be imported in the application entry point: * `import 'maplibre-gl/dist/maplibre-gl.css'` or `import 'mapbox-gl/dist/mapbox-gl.css'`. @@ -69,6 +89,7 @@ export interface BasemapOptions { provider: BasemapProvider /** * Map style URL or a Mapbox/MapLibre style JSON object URL. + * This is the default/initial style that the basemap loads with. * @example 'https://demotiles.maplibre.org/style.json' * @example 'mapbox://styles/mapbox/streets-v12' */ @@ -78,6 +99,12 @@ export interface BasemapOptions { * Ignored for `'maplibre'`. */ accessToken?: string + /** + * Optional array of named style presets for the in-map style switcher control. + * When provided, a floating UI selector lets the user switch between basemap + * styles (e.g. Streets, Satellite, Terrain) at runtime. + */ + styles?: BasemapStyleEntry[] } /** @@ -102,9 +129,12 @@ export interface MapInitOptions { editable?: boolean projection?: ProjectionOptions /** - * Optional vector-tile basemap to render beneath deck.gl layers via `MapboxOverlay`. - * When absent the DeckGL adapter operates in standalone mode with a transparent background. - * Has no effect on the Leaflet adapter. + * Optional vector-tile basemap to render beneath map layers. + * + * - **deck.gl**: renders via `MapboxOverlay` (overlay mode). + * - **Leaflet**: renders a MapLibre/Mapbox GL map behind a transparent Leaflet canvas. + * + * When absent, both adapters fall back to their default (no basemap) behaviour. */ basemap?: BasemapOptions } diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 4a3549723..a48eaea39 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -18,6 +18,7 @@ import { Kinds } from '../../../pre/tools' import DataShaders from '../../Ancillary/DataShaders' import calls from '../../../pre/calls' import TimeControl from '../TimeControl_/TimeControl' +import BasemapSwitcher from '../../Ancillary/BasemapSwitcher' import gjv from 'geojson-validation' import { @@ -306,6 +307,9 @@ let Map_ = { buildToolBar() + // Mount basemap style switcher if styles are configured + BasemapSwitcher.init(Map_) + TimeControl.updateLayersTime() }, /** From 524ff460d744f3c5cb5494804edafbfe2d75ded6 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 14 Apr 2026 13:21:50 -0500 Subject: [PATCH 02/21] basemap style switcher fixed --- src/essence/Ancillary/BasemapSwitcher.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/essence/Ancillary/BasemapSwitcher.js b/src/essence/Ancillary/BasemapSwitcher.js index ffb5e534f..54e5212b8 100644 --- a/src/essence/Ancillary/BasemapSwitcher.js +++ b/src/essence/Ancillary/BasemapSwitcher.js @@ -165,8 +165,8 @@ const BasemapSwitcher = { _container.append(options) - // Mount into mapToolBar (bottom of map area) - $('#mapToolBar').append(_container) + // Mount into mapScreen (the map's parent container) + $('#mapScreen').append(_container) // Close on outside click $(document).on('click.basemapSwitcher', function () { From 4616a20442c72cf726fece6de1ed920fabc8f4e5 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 20 Apr 2026 13:36:30 -0500 Subject: [PATCH 03/21] basemap switcher APIs setup --- src/css/mmgis.css | 78 ------- src/essence/Ancillary/BasemapSwitcher.js | 221 ------------------ .../MapEngines/Adapters/DeckGLAdapter.ts | 8 + .../MapEngines/Adapters/LeafletAdapter.ts | 52 ++--- src/essence/Basics/MapEngines/types/view.ts | 2 +- src/essence/Basics/Map_/Map_.js | 65 +++++- src/essence/mmgisAPI/mmgisAPI.js | 18 ++ 7 files changed, 113 insertions(+), 331 deletions(-) delete mode 100644 src/essence/Ancillary/BasemapSwitcher.js diff --git a/src/css/mmgis.css b/src/css/mmgis.css index c1dc47798..ac0ce8494 100644 --- a/src/css/mmgis.css +++ b/src/css/mmgis.css @@ -419,84 +419,6 @@ body { position: relative; z-index: 1; } -/* ---- Basemap Switcher Control ---- */ -.mmgis-basemap-switcher { - position: absolute; - bottom: 72px; - left: 12px; - z-index: 1002; - display: flex; - flex-direction: column; - gap: 4px; - pointer-events: auto; -} -.mmgis-basemap-switcher-toggle { - width: 52px; - height: 52px; - border-radius: 4px; - background: var(--color-a); - border: 2px solid var(--color-a-5); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); - cursor: pointer; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - transition: border-color 0.2s ease-out; - overflow: hidden; - user-select: none; -} -.mmgis-basemap-switcher-toggle:hover { - border-color: var(--color-mmgis); -} -.mmgis-basemap-switcher-toggle i { - font-size: 22px; - color: var(--color-a5); -} -.mmgis-basemap-switcher-toggle span { - font-size: 9px; - color: var(--color-a5); - margin-top: 2px; - text-transform: uppercase; - letter-spacing: 0.5px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 48px; - text-align: center; -} -.mmgis-basemap-switcher-options { - display: none; - flex-direction: column; - gap: 3px; - background: var(--color-a); - border: 1px solid var(--color-a-5); - border-radius: 4px; - padding: 4px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); - max-height: 240px; - overflow-y: auto; -} -.mmgis-basemap-switcher.open .mmgis-basemap-switcher-options { - display: flex; -} -.mmgis-basemap-switcher-option { - padding: 6px 10px; - cursor: pointer; - border-radius: 3px; - font-size: 12px; - color: var(--color-a5); - white-space: nowrap; - transition: background 0.15s ease-out, color 0.15s ease-out; -} -.mmgis-basemap-switcher-option:hover { - background: var(--color-a1); - color: #fff; -} -.mmgis-basemap-switcher-option.active { - background: var(--color-o); - color: #fff; -} .leaflet-popup-content-wrapper { background: var(--color-a); color: #e1e1e1; diff --git a/src/essence/Ancillary/BasemapSwitcher.js b/src/essence/Ancillary/BasemapSwitcher.js deleted file mode 100644 index 54e5212b8..000000000 --- a/src/essence/Ancillary/BasemapSwitcher.js +++ /dev/null @@ -1,221 +0,0 @@ -/** - * BasemapSwitcher.js - * - * A floating UI control that lets the user switch between configured - * basemap styles (e.g. Streets, Satellite, Terrain) at runtime. - * - * Reads style presets from `L_.configData.msv.basemap.styles[]` and - * calls `Map_.engine.setBasemapStyle(url)` to apply the selected style - * to the underlying MapLibre/Mapbox GL basemap in both Leaflet and - * deck.gl adapter modes. - * - * If no styles[] array is configured, sensible defaults are generated - * based on the basemap provider. - */ - -import $ from 'jquery' -import L_ from '../Basics/Layers_/Layers_' - -let Map_ = null -let _container = null -let _activeIndex = 0 - -/** - * Default style presets for Mapbox provider. - */ -const MAPBOX_DEFAULTS = [ - { name: 'Streets', style: 'mapbox://styles/mapbox/streets-v12' }, - { name: 'Satellite', style: 'mapbox://styles/mapbox/satellite-streets-v12' }, - { name: 'Outdoors', style: 'mapbox://styles/mapbox/outdoors-v12' }, - { name: 'Light', style: 'mapbox://styles/mapbox/light-v11' }, - { name: 'Dark', style: 'mapbox://styles/mapbox/dark-v11' }, -] - -/** - * Default style presets for MapLibre provider. - * Uses OpenFreeMap styles which are free and require no token. - */ -const MAPLIBRE_DEFAULTS = [ - { name: 'Liberty', style: 'https://tiles.openfreemap.org/styles/liberty' }, - { name: 'Bright', style: 'https://tiles.openfreemap.org/styles/bright' }, - { name: 'Positron', style: 'https://tiles.openfreemap.org/styles/positron' }, -] - -const BasemapSwitcher = { - /** - * Initialise and mount the basemap switcher control. - * - * @param {object} mapRef - Reference to the Map_ module. - */ - init(mapRef) { - Map_ = mapRef - - const basemapConfig = L_.configData?.msv?.basemap - if (!basemapConfig || !basemapConfig.provider || basemapConfig.provider === 'none') { - return - } - - // Use configured styles, or auto-generate defaults based on provider - let styles = basemapConfig.styles - if (!styles || styles.length === 0) { - styles = this._getDefaultStyles(basemapConfig) - } - - if (!styles || styles.length === 0) { - return - } - - // Determine which style is initially active (match against basemap.style) - _activeIndex = 0 - styles.forEach((s, i) => { - if (s.style === basemapConfig.style) { - _activeIndex = i - } - }) - - // Build the DOM - this._buildUI(styles) - }, - - /** - * Remove the switcher from the DOM. - */ - destroy() { - $(document).off('click.basemapSwitcher') - if (_container) { - _container.remove() - _container = null - } - }, - - /** - * Generate default style presets based on the basemap provider. - * If the configured style URL is not in the defaults, prepend it as "Current". - * - * @param {object} basemapConfig - * @returns {Array<{name: string, style: string}>} - */ - _getDefaultStyles(basemapConfig) { - let defaults - if (basemapConfig.provider === 'mapbox') { - defaults = [...MAPBOX_DEFAULTS] - } else { - defaults = [...MAPLIBRE_DEFAULTS] - } - - // If the configured style is already in defaults, we're good. - const currentInDefaults = defaults.some( - (d) => d.style === basemapConfig.style - ) - - // If not, prepend the current style so the user can always get back to it. - if (!currentInDefaults && basemapConfig.style) { - defaults.unshift({ - name: 'Current', - style: basemapConfig.style, - }) - } - - return defaults - }, - - /** - * Build and append the switcher UI. - * @param {Array<{name: string, style: string}>} styles - */ - _buildUI(styles) { - // Remove previous instance if any - this.destroy() - - const activeName = styles[_activeIndex]?.name || 'Map' - - // Container - _container = $('
') - .addClass('mmgis-basemap-switcher') - .attr('id', 'mmgis-basemap-switcher') - - // Toggle button - const toggle = $('
') - .addClass('mmgis-basemap-switcher-toggle') - .attr('title', 'Switch basemap style') - .html( - `${activeName}` - ) - .on('click', function (e) { - e.stopPropagation() - _container.toggleClass('open') - }) - - _container.append(toggle) - - // Options panel - const options = $('
').addClass('mmgis-basemap-switcher-options') - - styles.forEach((styleEntry, index) => { - const option = $('
') - .addClass('mmgis-basemap-switcher-option') - .addClass(index === _activeIndex ? 'active' : '') - .text(styleEntry.name) - .on('click', function (e) { - e.stopPropagation() - BasemapSwitcher._selectStyle(index, styles) - }) - options.append(option) - }) - - _container.append(options) - - // Mount into mapScreen (the map's parent container) - $('#mapScreen').append(_container) - - // Close on outside click - $(document).on('click.basemapSwitcher', function () { - if (_container) _container.removeClass('open') - }) - }, - - /** - * Handle a style selection. - * - * @param {number} index - * @param {Array<{name: string, style: string}>} styles - */ - _selectStyle(index, styles) { - if (index === _activeIndex) { - _container.removeClass('open') - return - } - - _activeIndex = index - const selectedStyle = styles[index] - - // Apply the style to the basemap - if (Map_ && Map_.engine) { - if (typeof Map_.engine.setBasemapStyle === 'function') { - // Leaflet adapter - Map_.engine.setBasemapStyle(selectedStyle.style) - } else if (typeof Map_.engine.getBasemap === 'function') { - // deck.gl adapter — setStyle on the underlying basemap - const basemap = Map_.engine.getBasemap() - if (basemap && typeof basemap.setStyle === 'function') { - basemap.setStyle(selectedStyle.style) - } - } - } - - // Update UI - _container - .find('.mmgis-basemap-switcher-option') - .removeClass('active') - .eq(index) - .addClass('active') - - _container.find('.mmgis-basemap-switcher-toggle span').text( - selectedStyle.name - ) - - _container.removeClass('open') - }, -} - -export default BasemapSwitcher diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 961c97b71..ca16130a3 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -113,6 +113,8 @@ interface BasemapInstance { off(type: string, handler: (...args: unknown[]) => void): unknown /** Recalculate the map size from its container element. */ resize(): void + /** Switch the map to a different style URL at runtime. */ + setStyle(styleUrl: string): unknown } /** @@ -324,6 +326,12 @@ export class DeckGLAdapter implements IMapEngine { return this._basemap } + setBasemapStyle(styleUrl: string): void { + if (this._basemap) { + this._basemap.setStyle(styleUrl) + } + } + getContainer(): HTMLElement { return this._container } diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index 41054e5f0..8bc2e8276 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -95,14 +95,14 @@ export default class LeafletAdapter implements IMapEngine, IMapEn private _basemapContainer: HTMLDivElement | null = null /** - * The basemap configuration passed at init time. + * Bound handler for synchronising Leaflet camera → basemap camera. */ - private _basemapOptions: BasemapOptions | null = null + private _syncHandler: (() => void) | null = null /** - * Bound handler for synchronising Leaflet camera → basemap camera. + * Bound handler for resizing the basemap on map resize events. */ - private _syncHandler: (() => void) | null = null + private _resizeHandler: (() => void) | null = null /** * Initialize the Leaflet map instance @@ -181,7 +181,7 @@ export default class LeafletAdapter implements IMapEngine, IMapEn } // --- Basemap overlay setup --- - if (options.basemap && options.basemap.provider && options.basemap.provider !== 'none' as any) { + if (options.basemap && options.basemap.provider && options.basemap.provider !== 'none') { this._initBasemapOverlay(options.basemap, center, zoom) } } @@ -998,17 +998,9 @@ export default class LeafletAdapter implements IMapEngine, IMapEn center: LatLng, zoom: number ): void { - this._basemapOptions = basemap - this._basemapContainer = document.createElement('div') this._basemapContainer.id = 'mmgis-basemap' this._basemapContainer.className = 'mmgis-basemap-container' - this._basemapContainer.style.position = 'absolute' - this._basemapContainer.style.top = '0' - this._basemapContainer.style.left = '0' - this._basemapContainer.style.width = '100%' - this._basemapContainer.style.height = '100%' - this._basemapContainer.style.zIndex = '0' // Insert before the Leaflet container so it renders behind this._container!.parentNode!.insertBefore( @@ -1026,19 +1018,22 @@ export default class LeafletAdapter implements IMapEngine, IMapEn tilePane.style.background = 'transparent' } - // Create the basemap map instance + this._resizeHandler = () => this._basemapMap?.resize() + if (basemap.provider === 'mapbox') { - this._initBasemapMapbox(basemap, center, zoom) + this._initBasemapMapbox(basemap, center, zoom).then(() => { + this._syncHandler = () => this._syncBasemap() + this._map.on('move', this._syncHandler) + this._map.on('zoomend', this._syncHandler) + this._map.on('resize', this._resizeHandler!) + }) } else { - // MapLibre (default) this._initBasemapMaplibre(basemap, center, zoom) + this._syncHandler = () => this._syncBasemap() + this._map.on('move', this._syncHandler) + this._map.on('zoomend', this._syncHandler) + this._map.on('resize', this._resizeHandler) } - - // Wire up Leaflet → basemap sync - this._syncHandler = () => this._syncBasemap() - this._map.on('move', this._syncHandler) - this._map.on('zoomend', this._syncHandler) - this._map.on('resize', () => this._basemapMap?.resize()) } /** @@ -1083,10 +1078,11 @@ export default class LeafletAdapter implements IMapEngine, IMapEn attributionControl: false, interactive: false, }) - } catch { + } catch (err) { console.error( 'LeafletAdapter: mapbox-gl is not installed. ' + - "Run `npm install mapbox-gl` or use provider: 'maplibre' instead." + "Run `npm install mapbox-gl` or use provider: 'maplibre' instead.", + err ) } } @@ -1109,7 +1105,6 @@ export default class LeafletAdapter implements IMapEngine, IMapEn /** * Switch the basemap to a different style at runtime. - * This is used by the BasemapSwitcher UI control. * * @param styleUrl - A MapLibre/Mapbox style URL. */ @@ -1129,6 +1124,11 @@ export default class LeafletAdapter implements IMapEngine, IMapEn this._syncHandler = null } + if (this._resizeHandler && this._map) { + this._map.off('resize', this._resizeHandler) + this._resizeHandler = null + } + if (this._basemapMap) { this._basemapMap.remove() this._basemapMap = null @@ -1143,7 +1143,5 @@ export default class LeafletAdapter implements IMapEngine, IMapEn this._container.style.background = '' this._container.classList.remove('mmgis-has-basemap') } - - this._basemapOptions = null } } diff --git a/src/essence/Basics/MapEngines/types/view.ts b/src/essence/Basics/MapEngines/types/view.ts index 6af24d7cd..789e91e36 100644 --- a/src/essence/Basics/MapEngines/types/view.ts +++ b/src/essence/Basics/MapEngines/types/view.ts @@ -50,7 +50,7 @@ export interface FitBoundsOptions extends ViewOptions { * `'maplibre'` uses MapLibre GL JS (open-source, no access token required). * `'mapbox'` uses Mapbox GL JS (requires a valid {@link BasemapOptions.accessToken}). */ -export type BasemapProvider = 'mapbox' | 'maplibre' +export type BasemapProvider = 'mapbox' | 'maplibre' | 'none' /** * A named basemap style preset for the in-map style switcher control. diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index a48eaea39..10928cc99 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -18,8 +18,6 @@ import { Kinds } from '../../../pre/tools' import DataShaders from '../../Ancillary/DataShaders' import calls from '../../../pre/calls' import TimeControl from '../TimeControl_/TimeControl' -import BasemapSwitcher from '../../Ancillary/BasemapSwitcher' - import gjv from 'geojson-validation' import { evaluate_cmap, @@ -50,6 +48,39 @@ const IMAGE_DEFAULT_COLOR_RAMP = 'binary' // Provider cleanup functions for re-initialization let _providerCleanups = [] +// Basemap state +let _basemapStyles = [] +let _basemapActiveIndex = 0 + +function _resolveBasemapStyles(basemapConfig) { + const MAPBOX_DEFAULTS = [ + { name: 'Streets', style: 'mapbox://styles/mapbox/streets-v12' }, + { name: 'Satellite', style: 'mapbox://styles/mapbox/satellite-streets-v12' }, + { name: 'Outdoors', style: 'mapbox://styles/mapbox/outdoors-v12' }, + { name: 'Light', style: 'mapbox://styles/mapbox/light-v11' }, + { name: 'Dark', style: 'mapbox://styles/mapbox/dark-v11' }, + ] + const MAPLIBRE_DEFAULTS = [ + { name: 'Liberty', style: 'https://tiles.openfreemap.org/styles/liberty' }, + { name: 'Bright', style: 'https://tiles.openfreemap.org/styles/bright' }, + { name: 'Positron', style: 'https://tiles.openfreemap.org/styles/positron' }, + ] + + let styles = + basemapConfig.styles && basemapConfig.styles.length > 0 + ? [...basemapConfig.styles] + : basemapConfig.provider === 'mapbox' + ? [...MAPBOX_DEFAULTS] + : [...MAPLIBRE_DEFAULTS] + + const currentInList = styles.some((s) => s.style === basemapConfig.style) + if (!currentInList && basemapConfig.style) { + styles.unshift({ name: 'Current', style: basemapConfig.style }) + } + + return styles +} + let Map_ = { /** The native map object (L.Map for Leaflet, Deck for deck.gl). Kept for backward compatibility with existing callers. */ map: null, @@ -244,6 +275,26 @@ let Map_ = { Map_.map.panTo(latlng) return true }), + window.mmgisAPI.provide('map:setBasemap', (styleName) => { + const index = _basemapStyles.findIndex((s) => s.name === styleName) + if (index === -1) { + console.warn(`[map:setBasemap] No basemap style found with name: "${styleName}"`) + return false + } + const selectedStyle = _basemapStyles[index] + if (Map_.engine && typeof Map_.engine.setBasemapStyle === 'function') { + Map_.engine.setBasemapStyle(selectedStyle.style) + } + _basemapActiveIndex = index + return true + }), + window.mmgisAPI.provide('map:getBasemap', () => { + if (_basemapStyles.length === 0) return null + return { ..._basemapStyles[_basemapActiveIndex] } + }), + window.mmgisAPI.provide('map:getBasemapStyles', () => { + return [..._basemapStyles] + }), ] } @@ -307,8 +358,14 @@ let Map_ = { buildToolBar() - // Mount basemap style switcher if styles are configured - BasemapSwitcher.init(Map_) + const basemapConfig = L_.configData?.msv?.basemap + if (basemapConfig && basemapConfig.provider && basemapConfig.provider !== 'none') { + _basemapStyles = _resolveBasemapStyles(basemapConfig) + _basemapActiveIndex = 0 + _basemapStyles.forEach(function (s, i) { + if (s.style === basemapConfig.style) _basemapActiveIndex = i + }) + } TimeControl.updateLayersTime() }, diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index 51d56e4c6..2950526d7 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -750,6 +750,24 @@ var mmgisAPI = { */ toggleLayer: mmgisAPI_.toggleLayer, + /** setBasemap - switches the active basemap style by name. + * Style names come from msv.basemap.styles[] in the mission config, + * or the provider defaults if no styles are configured. + * @param {string} styleName - display name of the style (e.g. 'Streets', 'Liberty') + * @returns {Promise} - true if found and applied, false if not found + */ + setBasemap: (styleName) => mmgisAPI.request('map:setBasemap', styleName), + + /** getBasemap - returns the currently active basemap style. + * @returns {Promise<{name: string, style: string} | null>} + */ + getBasemap: () => mmgisAPI.request('map:getBasemap'), + + /** getBasemapStyles - returns all available basemap style options. + * @returns {Promise>} + */ + getBasemapStyles: () => mmgisAPI.request('map:getBasemapStyles'), + /** overwriteLegends - overwrite the contents displayed in the LegendTool; useful when used with `toggleSeparatedTool` event listener in mmgisAPI * @param {array} - legends - an array of objects, where each object must contain the following keys: legend, layerUUID, display_name, opacity. The value for the legend key should be in the same format as what is stored in the layers data under the `_legend` key (i.e. `L_.layers.data[layerName]._legend`). layerUUID and display_name should be strings and opacity should be a number between 0 and 1. */ From b6b2837a2d0c45c12383cd157e29ad7e26beb985 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 21 Apr 2026 11:28:16 -0500 Subject: [PATCH 04/21] zoom apis added --- src/essence/Basics/Map_/Map_.js | 22 ++++++++++++++++++++++ src/essence/mmgisAPI/mmgisAPI.js | 10 ++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 10928cc99..fa0522809 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -295,6 +295,28 @@ let Map_ = { window.mmgisAPI.provide('map:getBasemapStyles', () => { return [..._basemapStyles] }), + window.mmgisAPI.provide('map:zoomIn', () => { + if (!Map_.engine || typeof Map_.engine.getZoom !== 'function') return false + const current = Map_.engine.getZoom() + const max = typeof Map_.engine.getMaxZoom === 'function' + ? Map_.engine.getMaxZoom() + : Infinity + const next = Math.min(current + 1, max) + if (next === current) return false + Map_.engine.setZoom(next) + return true + }), + window.mmgisAPI.provide('map:zoomOut', () => { + if (!Map_.engine || typeof Map_.engine.getZoom !== 'function') return false + const current = Map_.engine.getZoom() + const min = typeof Map_.engine.getMinZoom === 'function' + ? Map_.engine.getMinZoom() + : -Infinity + const next = Math.max(current - 1, min) + if (next === current) return false + Map_.engine.setZoom(next) + return true + }), ] } diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index 2950526d7..1b146e856 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -768,6 +768,16 @@ var mmgisAPI = { */ getBasemapStyles: () => mmgisAPI.request('map:getBasemapStyles'), + /** zoomIn - increments the map zoom by 1 level, clamped to the max zoom. + * @returns {Promise} - true if zoom changed, false if already at max + */ + zoomIn: () => mmgisAPI.request('map:zoomIn'), + + /** zoomOut - decrements the map zoom by 1 level, clamped to the min zoom. + * @returns {Promise} - true if zoom changed, false if already at min + */ + zoomOut: () => mmgisAPI.request('map:zoomOut'), + /** overwriteLegends - overwrite the contents displayed in the LegendTool; useful when used with `toggleSeparatedTool` event listener in mmgisAPI * @param {array} - legends - an array of objects, where each object must contain the following keys: legend, layerUUID, display_name, opacity. The value for the legend key should be in the same format as what is stored in the layers data under the `_legend` key (i.e. `L_.layers.data[layerName]._legend`). layerUUID and display_name should be strings and opacity should be a number between 0 and 1. */ From 069487572413e6946dbf5d694a8b61831b0a6a01 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 22 Apr 2026 10:13:12 -0500 Subject: [PATCH 05/21] APIs for measure tool --- src/essence/Basics/Map_/Map_.js | 60 ++++++++++++++++++++++++++++++++ src/essence/mmgisAPI/mmgisAPI.js | 22 ++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index fa0522809..d9750bb5f 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -52,6 +52,22 @@ let _providerCleanups = [] let _basemapStyles = [] let _basemapActiveIndex = 0 +// Ephemeral overlay layers keyed by plugin-provided id +const _overlays = new Map() + +// Whitelisted Leaflet path style options accepted by map:addOverlay +const _OVERLAY_STYLE_KEYS = [ + 'color', 'weight', 'opacity', + 'fillColor', 'fillOpacity', + 'radius', 'dashArray', 'lineCap', 'lineJoin', +] +function _pickStyle(style) { + if (!style || typeof style !== 'object') return {} + const out = {} + for (const k of _OVERLAY_STYLE_KEYS) if (style[k] != null) out[k] = style[k] + return out +} + function _resolveBasemapStyles(basemapConfig) { const MAPBOX_DEFAULTS = [ { name: 'Streets', style: 'mapbox://styles/mapbox/streets-v12' }, @@ -317,7 +333,51 @@ let Map_ = { Map_.engine.setZoom(next) return true }), + window.mmgisAPI.provide('map:addOverlay', ({ id, geojson, style } = {}) => { + if (!Map_.map || !L || !id || !geojson) return false + const existing = _overlays.get(id) + if (existing) { + Map_.map.removeLayer(existing) + _overlays.delete(id) + } + const pathStyle = _pickStyle(style) + const layer = L.geoJSON(geojson, { + interactive: false, + style: pathStyle, + pointToLayer: (_f, latlng) => L.circleMarker(latlng, { + interactive: false, + ...pathStyle, + }), + }) + layer.addTo(Map_.map) + _overlays.set(id, layer) + return true + }), + window.mmgisAPI.provide('map:removeOverlay', (id) => { + const layer = _overlays.get(id) + if (!layer) return false + if (Map_.map) Map_.map.removeLayer(layer) + _overlays.delete(id) + return true + }), + window.mmgisAPI.provide('map:clearOverlays', () => { + _overlays.forEach((layer) => { + if (Map_.map) Map_.map.removeLayer(layer) + }) + _overlays.clear() + return true + }), ] + + if (Map_.map && typeof Map_.map.on === 'function') { + Map_.map.on('click', (e) => { + if (!e || !e.latlng) return + window.mmgisAPI.emit('map:click', { + lat: e.latlng.lat, + lng: e.latlng.lng, + }) + }) + } } //Make our layers diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index 1b146e856..dc545959f 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -778,6 +778,28 @@ var mmgisAPI = { */ zoomOut: () => mmgisAPI.request('map:zoomOut'), + /** addOverlay - draws an ephemeral GeoJSON overlay on the map, keyed by id. + * Calling again with the same id replaces the previous overlay. These overlays + * are not persisted and do not appear in the Layers tool. + * @param {object} options + * @param {string} options.id - unique id, convention: 'plugin::' + * @param {object} options.geojson - a GeoJSON Feature or FeatureCollection + * @param {object} [options.style] - Leaflet path style (color, weight, opacity, fillColor, fillOpacity, radius, dashArray, lineCap, lineJoin) + * @returns {Promise} + */ + addOverlay: (options) => mmgisAPI.request('map:addOverlay', options), + + /** removeOverlay - removes an overlay previously added with addOverlay. + * @param {string} id + * @returns {Promise} - true if removed, false if no overlay with that id + */ + removeOverlay: (id) => mmgisAPI.request('map:removeOverlay', id), + + /** clearOverlays - removes all overlays added with addOverlay. + * @returns {Promise} + */ + clearOverlays: () => mmgisAPI.request('map:clearOverlays'), + /** overwriteLegends - overwrite the contents displayed in the LegendTool; useful when used with `toggleSeparatedTool` event listener in mmgisAPI * @param {array} - legends - an array of objects, where each object must contain the following keys: legend, layerUUID, display_name, opacity. The value for the legend key should be in the same format as what is stored in the layers data under the `_legend` key (i.e. `L_.layers.data[layerName]._legend`). layerUUID and display_name should be strings and opacity should be a number between 0 and 1. */ From 54bb007933ff61d14cc86e22e8339f4582dcf027 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 22 Apr 2026 10:49:07 -0500 Subject: [PATCH 06/21] route overlay + click APIs through engine adapter --- .../MapEngines/Adapters/DeckGLAdapter.ts | 20 ++++++ src/essence/Basics/Map_/Map_.js | 71 ++++++++++--------- 2 files changed, 57 insertions(+), 34 deletions(-) diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index ca16130a3..d4cdf7583 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -831,6 +831,7 @@ export class DeckGLAdapter implements IMapEngine { }, onClick: (info: PickingInfo) => { this._featureClickHandler?.(pickInfoToResult(info)) + this._emitClick(info) }, onHover: (info: PickingInfo) => { this._featureHoverHandler?.(pickInfoToResult(info)) @@ -898,6 +899,7 @@ export class DeckGLAdapter implements IMapEngine { layers: [], onClick: (info: PickingInfo) => { this._featureClickHandler?.(pickInfoToResult(info)) + this._emitClick(info) }, onHover: (info: PickingInfo) => { this._featureHoverHandler?.(pickInfoToResult(info)) @@ -996,6 +998,24 @@ export class DeckGLAdapter implements IMapEngine { private _emitEvent(name: string, data?: unknown): void { this._eventListeners.get(name)?.forEach((h) => h(data as PickingInfo)) } + + /** + * Bridge deck.gl's onClick PickingInfo to the normalized {lat, lng} + * shape that the LeafletAdapter also emits, so consumers of `on('click', ...)` + * see the same event shape regardless of engine. + */ + private _emitClick(info: PickingInfo): void { + if (!info?.coordinate) return + const normalized = { + lat: info.coordinate[1], + lng: info.coordinate[0], + containerPoint: + info.x != null && info.y != null + ? { x: info.x, y: info.y } + : undefined, + } + this._eventListeners.get('click')?.forEach((h) => h(normalized as unknown as PickingInfo)) + } } export default DeckGLAdapter diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index d9750bb5f..1dfceb44d 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -52,10 +52,14 @@ let _providerCleanups = [] let _basemapStyles = [] let _basemapActiveIndex = 0 -// Ephemeral overlay layers keyed by plugin-provided id -const _overlays = new Map() - -// Whitelisted Leaflet path style options accepted by map:addOverlay +// Ephemeral overlay ids currently registered with the active engine. +// The actual layer objects are owned by the adapter (IMapEngine.createLayer), +// we just track the ids so clearOverlays can iterate them. +const _overlayIds = new Set() + +// Whitelisted style keys accepted by map:addOverlay. Keeps the surface narrow +// so marketplace plugins can't pass callbacks or arbitrary engine-internal +// options. The adapter maps these to native Leaflet / deck.gl style props. const _OVERLAY_STYLE_KEYS = [ 'color', 'weight', 'opacity', 'fillColor', 'fillOpacity', @@ -68,6 +72,10 @@ function _pickStyle(style) { return out } +// Handler reference for the engine-level click → emit('map:click') bridge. +// Retained so re-init can detach the previous one before re-attaching. +let _mapClickHandler = null + function _resolveBasemapStyles(basemapConfig) { const MAPBOX_DEFAULTS = [ { name: 'Streets', style: 'mapbox://styles/mapbox/streets-v12' }, @@ -334,49 +342,44 @@ let Map_ = { return true }), window.mmgisAPI.provide('map:addOverlay', ({ id, geojson, style } = {}) => { - if (!Map_.map || !L || !id || !geojson) return false - const existing = _overlays.get(id) - if (existing) { - Map_.map.removeLayer(existing) - _overlays.delete(id) + if (!Map_.engine || !id || !geojson) return false + if (_overlayIds.has(id)) { + Map_.engine.removeLayer(id) + _overlayIds.delete(id) } - const pathStyle = _pickStyle(style) - const layer = L.geoJSON(geojson, { + Map_.engine.createLayer({ + id, + type: 'vector', + geojson, + style: _pickStyle(style), interactive: false, - style: pathStyle, - pointToLayer: (_f, latlng) => L.circleMarker(latlng, { - interactive: false, - ...pathStyle, - }), }) - layer.addTo(Map_.map) - _overlays.set(id, layer) + _overlayIds.add(id) return true }), window.mmgisAPI.provide('map:removeOverlay', (id) => { - const layer = _overlays.get(id) - if (!layer) return false - if (Map_.map) Map_.map.removeLayer(layer) - _overlays.delete(id) + if (!Map_.engine || !_overlayIds.has(id)) return false + Map_.engine.removeLayer(id) + _overlayIds.delete(id) return true }), window.mmgisAPI.provide('map:clearOverlays', () => { - _overlays.forEach((layer) => { - if (Map_.map) Map_.map.removeLayer(layer) - }) - _overlays.clear() + if (!Map_.engine) return false + _overlayIds.forEach((id) => Map_.engine.removeLayer(id)) + _overlayIds.clear() return true }), ] - if (Map_.map && typeof Map_.map.on === 'function') { - Map_.map.on('click', (e) => { - if (!e || !e.latlng) return - window.mmgisAPI.emit('map:click', { - lat: e.latlng.lat, - lng: e.latlng.lng, - }) - }) + if (Map_.engine && typeof Map_.engine.on === 'function') { + if (_mapClickHandler && typeof Map_.engine.off === 'function') { + Map_.engine.off('click', _mapClickHandler) + } + _mapClickHandler = (e) => { + if (!e || e.lat == null || e.lng == null) return + window.mmgisAPI.emit('map:click', { lat: e.lat, lng: e.lng }) + } + Map_.engine.on('click', _mapClickHandler) } } From b7f2934d9dcd320edb3ca5a0c9e267f066afb5f1 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 22 Apr 2026 15:24:02 -0500 Subject: [PATCH 07/21] basemap as tilelayer --- .../MapEngines/Adapters/LeafletAdapter.ts | 246 +++++++----------- 1 file changed, 87 insertions(+), 159 deletions(-) diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index 8bc2e8276..b026b47ab 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -41,9 +41,6 @@ import { } from '../types/events' import { MapEngineType } from '../types/engine' -import { Map as MaplibreGLMap } from 'maplibre-gl' -import 'maplibre-gl/dist/maplibre-gl.css' - // Leaflet is loaded globally via window.L declare const L: any @@ -84,25 +81,17 @@ export default class LeafletAdapter implements IMapEngine, IMapEn private _initOptions: MapInitOptions | null = null /** - * The MapLibre/Mapbox GL basemap instance (when basemap overlay is active). - * Renders behind the transparent Leaflet canvas. - */ - private _basemapMap: any = null - - /** - * The DOM element hosting the basemap map (sibling of the Leaflet #map div). + * The Leaflet tile layer used as the basemap (when basemap is configured). + * Rendered natively by Leaflet, so it shares the same projection, camera, + * and render loop as all other Leaflet layers — no sync required. */ - private _basemapContainer: HTMLDivElement | null = null + private _basemapLayer: any = null /** - * Bound handler for synchronising Leaflet camera → basemap camera. + * Access token forwarded into URL templates for providers that require one + * (e.g. Mapbox Static Tiles). Stored at init, reused on style swaps. */ - private _syncHandler: (() => void) | null = null - - /** - * Bound handler for resizing the basemap on map resize events. - */ - private _resizeHandler: (() => void) | null = null + private _basemapAccessToken: string | undefined /** * Initialize the Leaflet map instance @@ -180,9 +169,9 @@ export default class LeafletAdapter implements IMapEngine, IMapEn attributionControl.remove() } - // --- Basemap overlay setup --- + // --- Basemap tile layer setup --- if (options.basemap && options.basemap.provider && options.basemap.provider !== 'none') { - this._initBasemapOverlay(options.basemap, center, zoom) + this._initBasemapTileLayer(options.basemap) } } @@ -277,8 +266,8 @@ export default class LeafletAdapter implements IMapEngine, IMapEn destroy(): void { if (!this._map) return - // Clean up basemap overlay first - this._destroyBasemapOverlay() + // Clean up basemap layer first + this._removeBasemapLayer() this._eventHandlers.forEach((handler, eventName) => { this._map.off(eventName, handler) @@ -302,11 +291,11 @@ export default class LeafletAdapter implements IMapEngine, IMapEn } /** - * Get the underlying MapLibre/Mapbox GL basemap instance. + * Get the Leaflet tile layer used as the basemap. * Returns null if no basemap is configured. */ getBasemap(): any { - return this._basemapMap + return this._basemapLayer } /** @@ -514,7 +503,6 @@ export default class LeafletAdapter implements IMapEngine, IMapEn */ invalidateSize(): void { this._map.invalidateSize() - this._basemapMap?.resize() } /** @@ -983,165 +971,105 @@ export default class LeafletAdapter implements IMapEngine, IMapEn } // ======================================== - // BASEMAP OVERLAY METHODS + // BASEMAP TILE LAYER METHODS // ======================================== /** - * Initialise a MapLibre/Mapbox GL basemap behind the Leaflet canvas. + * Add a Leaflet tile layer as the basemap. Uses Leaflet's native rendering + * so the basemap shares the same projection, camera, and render loop as + * all other Leaflet layers — no sync, no drift, no scale mismatch. * - * Creates a sibling div before the Leaflet container, instantiates a - * MapLibre (or Mapbox) GL map inside it, makes the Leaflet container - * background transparent, and wires up pan/zoom sync events. + * The GL-specific style URL from the mission config is translated to a + * raster tile URL template by {@link _resolveBasemapTileSpec}. */ - private _initBasemapOverlay( - basemap: BasemapOptions, - center: LatLng, - zoom: number - ): void { - this._basemapContainer = document.createElement('div') - this._basemapContainer.id = 'mmgis-basemap' - this._basemapContainer.className = 'mmgis-basemap-container' - - // Insert before the Leaflet container so it renders behind - this._container!.parentNode!.insertBefore( - this._basemapContainer, - this._container - ) - - // Make the Leaflet container transparent so the basemap shows through - this._container!.style.background = 'transparent' - this._container!.classList.add('mmgis-has-basemap') - - // Ensure leaflet panes have transparent background - const tilePane = this._container!.querySelector('.leaflet-tile-pane') as HTMLElement - if (tilePane) { - tilePane.style.background = 'transparent' - } - - this._resizeHandler = () => this._basemapMap?.resize() - - if (basemap.provider === 'mapbox') { - this._initBasemapMapbox(basemap, center, zoom).then(() => { - this._syncHandler = () => this._syncBasemap() - this._map.on('move', this._syncHandler) - this._map.on('zoomend', this._syncHandler) - this._map.on('resize', this._resizeHandler!) - }) - } else { - this._initBasemapMaplibre(basemap, center, zoom) - this._syncHandler = () => this._syncBasemap() - this._map.on('move', this._syncHandler) - this._map.on('zoomend', this._syncHandler) - this._map.on('resize', this._resizeHandler) - } + private _initBasemapTileLayer(basemap: BasemapOptions): void { + this._basemapAccessToken = basemap.accessToken + const spec = this._resolveBasemapTileSpec(basemap) + this._basemapLayer = L.tileLayer(spec.url, spec.options) + this._basemapLayer.addTo(this._map) + this._basemapLayer.bringToBack() } /** - * Initialise a MapLibre GL basemap. + * Swap the basemap to a different style at runtime. + * Removes the current tile layer and adds a new one with the resolved URL. + * + * @param styleUrl - a Mapbox style URL (`mapbox://styles/...`), a raw tile + * URL template containing `{z}/{x}/{y}`, or any other URL (falls back to OSM). */ - private _initBasemapMaplibre( - basemap: BasemapOptions, - center: LatLng, - zoom: number - ): void { - this._basemapMap = new MaplibreGLMap({ - container: this._basemapContainer!, - style: basemap.style, - center: [center.lng, center.lat], - zoom: zoom, - attributionControl: false, - interactive: false, // Leaflet handles all interaction + setBasemapStyle(styleUrl: string): void { + if (!this._map) return + const spec = this._resolveBasemapTileSpec({ + provider: this._inferProvider(styleUrl), + style: styleUrl, + accessToken: this._basemapAccessToken, }) + this._removeBasemapLayer() + this._basemapLayer = L.tileLayer(spec.url, spec.options) + this._basemapLayer.addTo(this._map) + this._basemapLayer.bringToBack() } /** - * Initialise a Mapbox GL basemap via dynamic import. + * Remove the basemap tile layer (if any) from the map. */ - private async _initBasemapMapbox( - basemap: BasemapOptions, - center: LatLng, - zoom: number - ): Promise { - try { - const lib = (await import('mapbox-gl')) as any - const MapboxMap = (lib.default ?? lib).Map - if (!MapboxMap) { - throw new Error('Map not found in mapbox-gl module') - } - - this._basemapMap = new MapboxMap({ - container: this._basemapContainer!, - style: basemap.style, - center: [center.lng, center.lat], - zoom: zoom, - accessToken: basemap.accessToken, - attributionControl: false, - interactive: false, - }) - } catch (err) { - console.error( - 'LeafletAdapter: mapbox-gl is not installed. ' + - "Run `npm install mapbox-gl` or use provider: 'maplibre' instead.", - err - ) + private _removeBasemapLayer(): void { + if (this._basemapLayer && this._map) { + this._map.removeLayer(this._basemapLayer) } + this._basemapLayer = null } /** - * Synchronise the basemap camera to match the current Leaflet view. - * Called on Leaflet `move` and `zoomend` events. - */ - private _syncBasemap(): void { - if (!this._basemapMap) return - - const center = this._map.getCenter() - const zoom = this._map.getZoom() - - this._basemapMap.jumpTo({ - center: [center.lng, center.lat], - zoom: zoom, - }) - } - - /** - * Switch the basemap to a different style at runtime. + * Translate a mission's basemap config into a raster tile URL template + + * L.tileLayer options suitable for Leaflet's native renderer. * - * @param styleUrl - A MapLibre/Mapbox style URL. - */ - setBasemapStyle(styleUrl: string): void { - if (!this._basemapMap) return - this._basemapMap.setStyle(styleUrl) - } - - /** - * Clean up the basemap overlay: remove the GL map, its container, - * and restore the Leaflet container's opaque background. + * Accepted inputs: + * - `mapbox://styles/{user}/{style}` → Mapbox Static Tiles API (needs token) + * - Any URL containing `{z}`, `{x}`, `{y}` → used as-is + * - Anything else (e.g. MapLibre style.json URLs) → OSM fallback, since + * vector style JSONs can't be rendered by Leaflet natively. */ - private _destroyBasemapOverlay(): void { - if (this._syncHandler && this._map) { - this._map.off('move', this._syncHandler) - this._map.off('zoomend', this._syncHandler) - this._syncHandler = null - } - - if (this._resizeHandler && this._map) { - this._map.off('resize', this._resizeHandler) - this._resizeHandler = null + private _resolveBasemapTileSpec(basemap: BasemapOptions): { + url: string + options: Record + } { + const style = basemap.style || '' + + const mapboxMatch = style.match(/^mapbox:\/\/styles\/([^/]+)\/(.+)$/) + if (mapboxMatch) { + const [, user, styleId] = mapboxMatch + const token = basemap.accessToken || this._basemapAccessToken || '' + return { + url: `https://api.mapbox.com/styles/v1/${user}/${styleId}/tiles/{z}/{x}/{y}?access_token=${token}`, + options: { + tileSize: 512, + zoomOffset: -1, + attribution: '© Mapbox © OpenStreetMap', + }, + } } - if (this._basemapMap) { - this._basemapMap.remove() - this._basemapMap = null + if (style.includes('{z}') && style.includes('{x}') && style.includes('{y}')) { + return { url: style, options: {} } } - if (this._basemapContainer) { - this._basemapContainer.remove() - this._basemapContainer = null + return { + url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + options: { + subdomains: 'abc', + attribution: '© OpenStreetMap contributors', + }, } + } - if (this._container) { - this._container.style.background = '' - this._container.classList.remove('mmgis-has-basemap') - } + /** + * Best-effort inference of the basemap provider from a style URL. + * Used only by runtime `setBasemapStyle` calls that receive a bare URL + * without an explicit provider field. + */ + private _inferProvider(styleUrl: string): BasemapOptions['provider'] { + if (styleUrl.startsWith('mapbox://')) return 'mapbox' + return 'maplibre' } } From 319533bb9bee111c55d02124e4007cd856f158f3 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 22 Apr 2026 15:31:48 -0500 Subject: [PATCH 08/21] fix:style urls in maplibre --- src/essence/Basics/Map_/Map_.js | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 1dfceb44d..8367c176a 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -76,7 +76,12 @@ function _pickStyle(style) { // Retained so re-init can detach the previous one before re-attaching. let _mapClickHandler = null -function _resolveBasemapStyles(basemapConfig) { +function _resolveBasemapStyles(basemapConfig, engineType) { + const isLeaflet = engineType === MAP_ENGINE.LEAFLET + + // Mapbox defaults use mapbox:// style URLs. Both engines accept them — + // DeckGL via Mapbox GL's setStyle, Leaflet via the adapter's translation + // to the Static Tiles API. const MAPBOX_DEFAULTS = [ { name: 'Streets', style: 'mapbox://styles/mapbox/streets-v12' }, { name: 'Satellite', style: 'mapbox://styles/mapbox/satellite-streets-v12' }, @@ -84,18 +89,33 @@ function _resolveBasemapStyles(basemapConfig) { { name: 'Light', style: 'mapbox://styles/mapbox/light-v11' }, { name: 'Dark', style: 'mapbox://styles/mapbox/dark-v11' }, ] - const MAPLIBRE_DEFAULTS = [ + + // For DeckGL, MapLibre defaults are vector style.json URLs rendered by + // MapLibre GL. For Leaflet, MapLibre styles aren't renderable directly, + // so we substitute distinct open raster tile providers — each style + // switch resolves to a different URL so switching is meaningful. + const MAPLIBRE_DEFAULTS_DECKGL = [ { name: 'Liberty', style: 'https://tiles.openfreemap.org/styles/liberty' }, { name: 'Bright', style: 'https://tiles.openfreemap.org/styles/bright' }, { name: 'Positron', style: 'https://tiles.openfreemap.org/styles/positron' }, ] + const MAPLIBRE_DEFAULTS_LEAFLET = [ + { name: 'Standard', style: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, + { name: 'Positron', style: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png' }, + { name: 'Dark Matter', style: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png' }, + { name: 'Voyager', style: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png' }, + { name: 'Topo', style: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png' }, + ] + + const mapboxDefaults = MAPBOX_DEFAULTS + const maplibreDefaults = isLeaflet ? MAPLIBRE_DEFAULTS_LEAFLET : MAPLIBRE_DEFAULTS_DECKGL let styles = basemapConfig.styles && basemapConfig.styles.length > 0 ? [...basemapConfig.styles] : basemapConfig.provider === 'mapbox' - ? [...MAPBOX_DEFAULTS] - : [...MAPLIBRE_DEFAULTS] + ? [...mapboxDefaults] + : [...maplibreDefaults] const currentInList = styles.some((s) => s.style === basemapConfig.style) if (!currentInList && basemapConfig.style) { @@ -445,7 +465,7 @@ let Map_ = { const basemapConfig = L_.configData?.msv?.basemap if (basemapConfig && basemapConfig.provider && basemapConfig.provider !== 'none') { - _basemapStyles = _resolveBasemapStyles(basemapConfig) + _basemapStyles = _resolveBasemapStyles(basemapConfig, engineType) _basemapActiveIndex = 0 _basemapStyles.forEach(function (s, i) { if (s.style === basemapConfig.style) _basemapActiveIndex = i From 7ffc66fdb441b98711cdb7098cba17fde3b0b9bf Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 22 Apr 2026 15:39:04 -0500 Subject: [PATCH 09/21] changing style urls --- src/essence/Basics/Map_/Map_.js | 34 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 8367c176a..6509a47e6 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -90,38 +90,36 @@ function _resolveBasemapStyles(basemapConfig, engineType) { { name: 'Dark', style: 'mapbox://styles/mapbox/dark-v11' }, ] - // For DeckGL, MapLibre defaults are vector style.json URLs rendered by - // MapLibre GL. For Leaflet, MapLibre styles aren't renderable directly, - // so we substitute distinct open raster tile providers — each style - // switch resolves to a different URL so switching is meaningful. + // Shared "Streets / Light / Dark" names for both engines. URLs differ + // because DeckGL renders MapLibre vector style.json via MapLibre GL, + // and Leaflet needs raster {z}/{x}/{y} templates. Names match the + // MapControl gradient keys so dropdown thumbnails are correct on both + // engines with no plugin-side change. + // + // Leaflet gets an extra "Terrain" (OpenTopoMap) since no free MapLibre + // vector terrain style is available to keep strict parity. const MAPLIBRE_DEFAULTS_DECKGL = [ - { name: 'Liberty', style: 'https://tiles.openfreemap.org/styles/liberty' }, - { name: 'Bright', style: 'https://tiles.openfreemap.org/styles/bright' }, - { name: 'Positron', style: 'https://tiles.openfreemap.org/styles/positron' }, + { name: 'Streets', style: 'https://tiles.openfreemap.org/styles/liberty' }, + { name: 'Light', style: 'https://tiles.openfreemap.org/styles/positron' }, + { name: 'Dark', style: 'https://tiles.openfreemap.org/styles/dark-matter' }, ] const MAPLIBRE_DEFAULTS_LEAFLET = [ - { name: 'Standard', style: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, - { name: 'Positron', style: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png' }, - { name: 'Dark Matter', style: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png' }, - { name: 'Voyager', style: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png' }, - { name: 'Topo', style: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png' }, + { name: 'Streets', style: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, + { name: 'Light', style: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png' }, + { name: 'Dark', style: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png' }, + { name: 'Terrain', style: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png' }, ] const mapboxDefaults = MAPBOX_DEFAULTS const maplibreDefaults = isLeaflet ? MAPLIBRE_DEFAULTS_LEAFLET : MAPLIBRE_DEFAULTS_DECKGL - let styles = + const styles = basemapConfig.styles && basemapConfig.styles.length > 0 ? [...basemapConfig.styles] : basemapConfig.provider === 'mapbox' ? [...mapboxDefaults] : [...maplibreDefaults] - const currentInList = styles.some((s) => s.style === basemapConfig.style) - if (!currentInList && basemapConfig.style) { - styles.unshift({ name: 'Current', style: basemapConfig.style }) - } - return styles } From cd67ceb388e88d7dd667eb6ee1f22612e3d7e4c3 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 22 Apr 2026 15:43:44 -0500 Subject: [PATCH 10/21] maplibre dark style change --- src/essence/Basics/Map_/Map_.js | 60 ++++++++++++++++----------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 6509a47e6..03972c503 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -34,7 +34,7 @@ import { buildDeckLayer } from '../MapEngines/Adapters/DeckGLHelpers' let L = window.L -let essenceFina = function () {} +let essenceFina = function () { } mapEngineRegistry.register(MAP_ENGINE.LEAFLET, LeafletAdapter) mapEngineRegistry.register(MAP_ENGINE.DECKGL, DeckGLAdapter) @@ -101,7 +101,7 @@ function _resolveBasemapStyles(basemapConfig, engineType) { const MAPLIBRE_DEFAULTS_DECKGL = [ { name: 'Streets', style: 'https://tiles.openfreemap.org/styles/liberty' }, { name: 'Light', style: 'https://tiles.openfreemap.org/styles/positron' }, - { name: 'Dark', style: 'https://tiles.openfreemap.org/styles/dark-matter' }, + { name: 'Dark', style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json' }, ] const MAPLIBRE_DEFAULTS_LEAFLET = [ { name: 'Streets', style: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, @@ -117,8 +117,8 @@ function _resolveBasemapStyles(basemapConfig, engineType) { basemapConfig.styles && basemapConfig.styles.length > 0 ? [...basemapConfig.styles] : basemapConfig.provider === 'mapbox' - ? [...mapboxDefaults] - : [...maplibreDefaults] + ? [...mapboxDefaults] + : [...maplibreDefaults] return styles } @@ -673,10 +673,10 @@ let Map_ = { ) { L_.layers.layer[L_._layersOrdered[hasIndex[i]]].setZIndex( L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf( - L_._layersOrdered[hasIndex[i]] - ) + 1 - + L_._layersOrdered.indexOf( + L_._layersOrdered[hasIndex[i]] + ) ) L_.layers.layer[L_._layersOrdered[hasIndex[i]]].clearCache() L_.layers.layer[L_._layersOrdered[hasIndex[i]]].redraw() @@ -690,10 +690,10 @@ let Map_ = { for (let i = 0; i < hasIndexRaster.length; i++) { L_.layers.layer[L_._layersOrdered[hasIndexRaster[i]]].setZIndex( L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf( - L_._layersOrdered[hasIndexRaster[i]] - ) + 1 - + L_._layersOrdered.indexOf( + L_._layersOrdered[hasIndexRaster[i]] + ) ) } @@ -706,7 +706,7 @@ let Map_ = { L_.layers.layer[key].forEach((l) => { try { l.bringToFront() - } catch (err) {} + } catch (err) { } }) } }) @@ -720,7 +720,7 @@ let Map_ = { // If it's a dynamic extent layer, just re-call its function if ( L_._onSpecificLayerToggleSubscriptions[ - `dynamicextent_${layerObj.name}` + `dynamicextent_${layerObj.name}` ] != null ) { if (L_.layers.on[layerObj.name]) @@ -875,9 +875,9 @@ let Map_ = { const zoom = Map_.map.getZoom() const min = Map_.map - .project(bounds.getNorthWest(), zoom) - .divideBy(256) - .floor(), + .project(bounds.getNorthWest(), zoom) + .divideBy(256) + .floor(), max = Map_.map .project(bounds.getSouthEast(), zoom) .divideBy(256) @@ -1213,7 +1213,7 @@ async function makeVectorLayer( if (existingLayer != null && existingLayer !== false) { console.warn( `[${new Date().toISOString()}] Refresh failed for ${layerObj.display_name}, ` + - `keeping existing layer. Next refresh in ${layerObj.time?.refreshIntervalAmount || 60}s` + `keeping existing layer. Next refresh in ${layerObj.time?.refreshIntervalAmount || 60}s` ) // Mark layer as having a failed refresh ctx.layerRegistry.refreshFailed[layerObj.name] = true @@ -1436,7 +1436,7 @@ async function makeVelocityLayer( position: layerObj.variables?.streamlines ?.displayPosition ? layerObj.variables?.streamlines - ?.displayPosition + ?.displayPosition : 'bottomleft', emptyString: '', }, @@ -1469,7 +1469,7 @@ async function makeVelocityLayer( : 15, colorScale: colorScale, }) - velocityLayer.setZIndex = function () {} + velocityLayer.setZIndex = function () { } L_.layers.layer[layerObj.name] = velocityLayer } else if (layerObj.kind == 'particles') { let points = [] @@ -1505,7 +1505,7 @@ async function makeVelocityLayer( : 'Oxa6b3e9', } let rainLayer = L.rain(points, options) - rainLayer.setZIndex = function () {} + rainLayer.setZIndex = function () { } L_.layers.layer[layerObj.name] = rainLayer } L_._layersLoaded[L_._layersOrdered.indexOf(layerObj.name)] = @@ -1575,9 +1575,8 @@ async function makeTileLayer(layerObj, mapContext = null) { layerUrl = `${window.location.origin}${( window.location.pathname || '' - ).replace(/\/$/g, '')}/titiler/cog/tiles/${ - layerObj.tileMatrixSet || 'WebMercatorQuad' - }/{z}/{x}/{y}.webp?url=${layerUrl}${bandsParam}${resamplingParam}` + ).replace(/\/$/g, '')}/titiler/cog/tiles/${layerObj.tileMatrixSet || 'WebMercatorQuad' + }/{z}/{x}/{y}.webp?url=${layerUrl}${bandsParam}${resamplingParam}` default: break @@ -1725,8 +1724,7 @@ function makeVectorTileLayer(layerObj, mapContext = null) { if (urlSplit[0].toLowerCase() === 'geodatasets' && urlSplit[1] != null) { layerUrl = - `${window.mmgisglobal.ROOT_PATH || ''}/api/geodatasets/get?layer=${ - urlSplit[1] + `${window.mmgisglobal.ROOT_PATH || ''}/api/geodatasets/get?layer=${urlSplit[1] }` + '&type=mvt&x={x}&y={y}&z={z}' } @@ -1852,7 +1850,7 @@ function makeVectorTileLayer(layerObj, mapContext = null) { e.layer._renderer._features[i].feature._pxBounds.max .y >= p.y && e.layer._renderer._features[i].feature.properties[ - vtId + vtId ] != e.layer.properties[vtId] ) { L_.layers.layer[layerName].activeFeatures.push({ @@ -2126,8 +2124,8 @@ function makeImageLayer(layerObj, mapContext = null) { L_.layers.layer[layerObj.name].setZIndex( L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(layerObj.name) + 1 - + L_._layersOrdered.indexOf(layerObj.name) ) L_.setLayerOpacity(layerObj.name, L_.layers.opacity[layerObj.name]) @@ -2226,8 +2224,8 @@ function makeVideoLayer(layerObj, mapContext = null) { L_.layers.layer[layerObj.name].setZIndex( L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(layerObj.name) + 1 - + L_._layersOrdered.indexOf(layerObj.name) ) L_.setLayerOpacity(layerObj.name, L_.layers.opacity[layerObj.name]) From 036be3dcca8df9b3e100f80616b463de460a9e79 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 22 Apr 2026 16:01:50 -0500 Subject: [PATCH 11/21] fix:min zoom set in mapbox leaflet --- .../Basics/MapEngines/Adapters/LeafletAdapter.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index b026b47ab..e2c2cf414 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -988,6 +988,14 @@ export default class LeafletAdapter implements IMapEngine, IMapEn this._basemapLayer = L.tileLayer(spec.url, spec.options) this._basemapLayer.addTo(this._map) this._basemapLayer.bringToBack() + + // If the provider has a natural floor (e.g. Mapbox 512 tiles at + // Leaflet zoom 1+), raise the map's minZoom so the user can never + // pan/zoom to a level where the basemap renders blank. + const specMinZoom = (spec.options as { minZoom?: number }).minZoom + if (typeof specMinZoom === 'number' && specMinZoom > this._map.getMinZoom()) { + this._map.setMinZoom(specMinZoom) + } } /** @@ -1045,6 +1053,11 @@ export default class LeafletAdapter implements IMapEngine, IMapEn options: { tileSize: 512, zoomOffset: -1, + // Mapbox Static Tiles API serves 512-sized tiles indexed at + // standard Web Mercator zoom. Paired with zoomOffset -1, that + // means Leaflet zoom 0 resolves to URL zoom -1, which Mapbox + // doesn't serve. Floor the tile layer at Leaflet zoom 1. + minZoom: 1, attribution: '© Mapbox © OpenStreetMap', }, } From dda2fe9c74b48e259c1d195da9cbb5df0c8ab163 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 22 Apr 2026 16:11:43 -0500 Subject: [PATCH 12/21] mouseover event emit, for draw --- .../MapEngines/Adapters/DeckGLAdapter.ts | 19 ++++++++++++++++++ .../MapEngines/Adapters/LeafletHelpers.ts | 19 +++++++++++++++++- src/essence/Basics/Map_/Map_.js | 20 ++++++++++++++++++- src/essence/mmgisAPI/mmgisAPI.js | 7 +++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index d4cdf7583..c82d3d127 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -835,6 +835,7 @@ export class DeckGLAdapter implements IMapEngine { }, onHover: (info: PickingInfo) => { this._featureHoverHandler?.(pickInfoToResult(info)) + this._emitMouseMove(info) }, } as any) } @@ -903,6 +904,7 @@ export class DeckGLAdapter implements IMapEngine { }, onHover: (info: PickingInfo) => { this._featureHoverHandler?.(pickInfoToResult(info)) + this._emitMouseMove(info) }, }) @@ -1016,6 +1018,23 @@ export class DeckGLAdapter implements IMapEngine { } this._eventListeners.get('click')?.forEach((h) => h(normalized as unknown as PickingInfo)) } + + /** + * Bridge deck.gl's onHover PickingInfo to the normalized {lat, lng} + * shape for `on('mousemove', ...)` subscribers, mirroring LeafletAdapter. + */ + private _emitMouseMove(info: PickingInfo): void { + if (!info?.coordinate) return + const normalized = { + lat: info.coordinate[1], + lng: info.coordinate[0], + containerPoint: + info.x != null && info.y != null + ? { x: info.x, y: info.y } + : undefined, + } + this._eventListeners.get('mousemove')?.forEach((h) => h(normalized as unknown as PickingInfo)) + } } export default DeckGLAdapter diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletHelpers.ts b/src/essence/Basics/MapEngines/Adapters/LeafletHelpers.ts index 153d9d333..efe21e1d1 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletHelpers.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletHelpers.ts @@ -125,16 +125,33 @@ function _buildTileLayer(id: string, options: TileLayerOptions): any { /** * Build an L.geoJSON layer from {@link GeoJSONLayerOptions}. * Callback props (style, onEachFeature, pointToLayer, filter) are forwarded as-is. + * + * When no `pointToLayer` is supplied, Point features are rendered as + * `L.circleMarker` using the `style` options (radius, color, weight, fillColor, + * fillOpacity). This avoids Leaflet's default `L.marker` fallback — which loads + * the default marker PNG + shadow — for callers that just want styled dots. */ function _buildGeoJSONLayer(id: string, options: GeoJSONLayerOptions): any { if (!options.geojson) { throw new Error('buildLeafletLayer (vector): options.geojson is required') } + const style = (options.style as Record) || {} + const defaultPointToLayer = (_feature: any, latlng: any) => + L.circleMarker(latlng, { + radius: style.radius ?? 5, + color: style.color ?? '#3388ff', + weight: style.weight ?? 2, + opacity: style.opacity ?? 1, + fillColor: style.fillColor ?? style.color ?? '#3388ff', + fillOpacity: style.fillOpacity ?? 0.6, + interactive: options.interactive ?? true, + }) + const leafletOptions: Record = { ...(options.style !== undefined ? { style: options.style } : {}), ...(options.onEachFeature ? { onEachFeature: options.onEachFeature } : {}), - ...(options.pointToLayer ? { pointToLayer: options.pointToLayer } : {}), + pointToLayer: options.pointToLayer ?? defaultPointToLayer, ...(options.filter ? { filter: options.filter } : {}), ...(options.nativeOptions ?? {}), } diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 03972c503..458ed38d7 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -72,9 +72,10 @@ function _pickStyle(style) { return out } -// Handler reference for the engine-level click → emit('map:click') bridge. +// Handler references for engine-level event bridges to the plugin bus. // Retained so re-init can detach the previous one before re-attaching. let _mapClickHandler = null +let _mapMouseMoveHandler = null function _resolveBasemapStyles(basemapConfig, engineType) { const isLeaflet = engineType === MAP_ENGINE.LEAFLET @@ -387,6 +388,14 @@ let Map_ = { _overlayIds.clear() return true }), + window.mmgisAPI.provide('map:latLngToContainerPoint', (latlng) => { + if (!Map_.engine || typeof Map_.engine.latLngToContainerPoint !== 'function') { + return null + } + if (!latlng || latlng.lat == null || latlng.lng == null) return null + const p = Map_.engine.latLngToContainerPoint(latlng) + return p ? { x: p.x, y: p.y } : null + }), ] if (Map_.engine && typeof Map_.engine.on === 'function') { @@ -398,6 +407,15 @@ let Map_ = { window.mmgisAPI.emit('map:click', { lat: e.lat, lng: e.lng }) } Map_.engine.on('click', _mapClickHandler) + + if (_mapMouseMoveHandler && typeof Map_.engine.off === 'function') { + Map_.engine.off('mousemove', _mapMouseMoveHandler) + } + _mapMouseMoveHandler = (e) => { + if (!e || e.lat == null || e.lng == null) return + window.mmgisAPI.emit('map:mousemove', { lat: e.lat, lng: e.lng }) + } + Map_.engine.on('mousemove', _mapMouseMoveHandler) } } diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index dc545959f..7a64a3238 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -800,6 +800,13 @@ var mmgisAPI = { */ clearOverlays: () => mmgisAPI.request('map:clearOverlays'), + /** latLngToContainerPoint - project a {lat, lng} to pixel coordinates + * relative to the map container. Useful for positioning DOM overlays. + * @param {{lat: number, lng: number}} latlng + * @returns {Promise<{x: number, y: number} | null>} + */ + latLngToContainerPoint: (latlng) => mmgisAPI.request('map:latLngToContainerPoint', latlng), + /** overwriteLegends - overwrite the contents displayed in the LegendTool; useful when used with `toggleSeparatedTool` event listener in mmgisAPI * @param {array} - legends - an array of objects, where each object must contain the following keys: legend, layerUUID, display_name, opacity. The value for the legend key should be in the same format as what is stored in the layers data under the `_legend` key (i.e. `L_.layers.data[layerName]._legend`). layerUUID and display_name should be strings and opacity should be a number between 0 and 1. */ From a3b1a3dd5ad7fcba1094cc61bb59fc84036db09e Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 22 Apr 2026 16:41:29 -0500 Subject: [PATCH 13/21] fix(deck.gl): normalize Leaflet-style keys in vector layers --- .../MapEngines/Adapters/DeckGLHelpers.ts | 62 ++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts index bfcab5fbb..01a1f40e9 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts @@ -122,6 +122,40 @@ export function pickInfoToResult(info: PickingInfo): FeaturePickResult { * * @throws {Error} If `options.type` is not a supported layer type. */ +/** + * Coerce a color input into deck.gl's [r, g, b, a] (0–255) format. + * + * Accepts: + * - already-RGBA arrays like [26, 115, 232, 255] — passed through, alpha defaults to 255 + * - CSS hex strings: `#rgb`, `#rrggbb`, `#rrggbbaa` + * - anything else → `fallback` + * + * This lets callers pass Leaflet-flavoured style (`color: '#1a73e8'`) without + * having to know deck.gl's native RGBA expectation. + */ +function _toRgba( + input: unknown, + fallback: [number, number, number, number] +): [number, number, number, number] { + if (Array.isArray(input) && input.length >= 3) { + const [r, g, b, a] = input as number[] + return [r, g, b, a ?? 255] + } + if (typeof input === 'string') { + let s = input.trim() + if (s.startsWith('#')) s = s.slice(1) + if (s.length === 3) s = s.split('').map((c) => c + c).join('') + if (s.length === 6 || s.length === 8) { + const r = parseInt(s.slice(0, 2), 16) + const g = parseInt(s.slice(2, 4), 16) + const b = parseInt(s.slice(4, 6), 16) + const a = s.length === 8 ? parseInt(s.slice(6, 8), 16) : 255 + if (!isNaN(r) && !isNaN(g) && !isNaN(b)) return [r, g, b, a] + } + } + return fallback +} + export function buildDeckLayer(id: string, options: LayerOptions): Layer { switch (options.type) { case 'tile': { @@ -153,6 +187,23 @@ export function buildDeckLayer(id: string, options: LayerOptions): Layer { o.style && typeof o.style === 'object' && !Array.isArray(o.style) ? (o.style as Record) : {} + + // Accept either deck.gl-native style keys (strokeColor, strokeWidth) + // or Leaflet-style keys (color, weight, fillColor, fillOpacity, + // radius). Deck.gl-native wins when both are provided so existing + // callers aren't broken. + const lineColor = _toRgba(style.strokeColor ?? style.color, [0, 0, 0, 255]) + const lineWidth = (style.strokeWidth as number) ?? (style.weight as number) ?? 1 + + const baseFill = _toRgba(style.fillColor, [0, 0, 255, 128]) + const fillAlpha = + typeof style.fillOpacity === 'number' + ? Math.round(Math.max(0, Math.min(1, style.fillOpacity as number)) * 255) + : baseFill[3] + const fillColor: [number, number, number, number] = [ + baseFill[0], baseFill[1], baseFill[2], fillAlpha, + ] + return new GeoJsonLayer({ id, data: o.geojson as unknown as ConstructorParameters[0]['data'], @@ -160,9 +211,14 @@ export function buildDeckLayer(id: string, options: LayerOptions): Layer { filled: o.filled ?? true, stroked: o.stroked ?? true, extruded: o.extruded ?? false, - getFillColor: (style.fillColor ?? [0, 0, 255, 128]) as [number, number, number, number], - getLineColor: (style.strokeColor ?? [0, 0, 0, 255]) as [number, number, number, number], - getLineWidth: (style.strokeWidth ?? 1) as number, + getFillColor: fillColor, + getLineColor: lineColor, + getLineWidth: lineWidth, + // Point features need an explicit radius accessor. Without it, + // deck.gl's default renders circles in meters (sub-pixel at most + // real-world zooms — effectively invisible). + getPointRadius: (style.radius as number) ?? 5, + pointRadiusUnits: 'pixels', pointType: o.pointType ?? 'circle', lineWidthUnits: o.lineWidthUnits ?? 'pixels', pickable: o.interactive ?? true, From cbc4536b49c36197e7ca3ddd899bf6327c6610b0 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 22 Apr 2026 16:44:26 -0500 Subject: [PATCH 14/21] fix(deck.gl): include latlng in normalized pointer events --- .../MapEngines/Adapters/DeckGLAdapter.ts | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index c82d3d127..866efb1d5 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -1008,15 +1008,9 @@ export class DeckGLAdapter implements IMapEngine { */ private _emitClick(info: PickingInfo): void { if (!info?.coordinate) return - const normalized = { - lat: info.coordinate[1], - lng: info.coordinate[0], - containerPoint: - info.x != null && info.y != null - ? { x: info.x, y: info.y } - : undefined, - } - this._eventListeners.get('click')?.forEach((h) => h(normalized as unknown as PickingInfo)) + this._eventListeners.get('click')?.forEach( + (h) => h(this._buildNormalizedPointerEvent(info) as unknown as PickingInfo) + ) } /** @@ -1025,15 +1019,29 @@ export class DeckGLAdapter implements IMapEngine { */ private _emitMouseMove(info: PickingInfo): void { if (!info?.coordinate) return - const normalized = { - lat: info.coordinate[1], - lng: info.coordinate[0], + this._eventListeners.get('mousemove')?.forEach( + (h) => h(this._buildNormalizedPointerEvent(info) as unknown as PickingInfo) + ) + } + + /** + * Build the normalized pointer-event shape shared between click and + * mousemove. Matches the LeafletAdapter's `_normalizeEvent` output so + * legacy consumers reading `e.latlng.lng` (e.g. Ancillary/Coordinates) + * keep working regardless of engine. + */ + private _buildNormalizedPointerEvent(info: PickingInfo): Record { + const lat = info.coordinate![1] + const lng = info.coordinate![0] + return { + lat, + lng, + latlng: { lat, lng }, containerPoint: info.x != null && info.y != null ? { x: info.x, y: info.y } : undefined, } - this._eventListeners.get('mousemove')?.forEach((h) => h(normalized as unknown as PickingInfo)) } } From ae72021dd73095c4ec217347af6a97758b3977a0 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Thu, 23 Apr 2026 09:41:40 -0500 Subject: [PATCH 15/21] clean up --- src/css/mmgis.css | 21 ------- .../MapEngines/Adapters/DeckGLAdapter.ts | 15 ----- .../MapEngines/Adapters/DeckGLHelpers.ts | 18 ------ .../MapEngines/Adapters/LeafletAdapter.ts | 56 ------------------- .../MapEngines/Adapters/LeafletHelpers.ts | 5 -- src/essence/Basics/Map_/Map_.js | 20 ------- 6 files changed, 135 deletions(-) diff --git a/src/css/mmgis.css b/src/css/mmgis.css index ac0ce8494..e1de5fa5b 100644 --- a/src/css/mmgis.css +++ b/src/css/mmgis.css @@ -398,27 +398,6 @@ body { .leaflet-container { background: black; } -/* When a basemap overlay is active, the Leaflet container must be transparent - so the MapLibre/Mapbox GL map behind it is visible. */ -.mmgis-has-basemap.leaflet-container, -.mmgis-has-basemap .leaflet-container { - background: transparent !important; -} -/* Basemap underlay container — sits behind the Leaflet #map div */ -.mmgis-basemap-container { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; -} -/* Ensure the Leaflet map div sits above the basemap container */ -#map.mmgis-has-basemap, -.mmgis-has-basemap #map { - position: relative; - z-index: 1; -} .leaflet-popup-content-wrapper { background: var(--color-a); color: #e1e1e1; diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 866efb1d5..02f434b95 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts @@ -1001,11 +1001,6 @@ export class DeckGLAdapter implements IMapEngine { this._eventListeners.get(name)?.forEach((h) => h(data as PickingInfo)) } - /** - * Bridge deck.gl's onClick PickingInfo to the normalized {lat, lng} - * shape that the LeafletAdapter also emits, so consumers of `on('click', ...)` - * see the same event shape regardless of engine. - */ private _emitClick(info: PickingInfo): void { if (!info?.coordinate) return this._eventListeners.get('click')?.forEach( @@ -1013,10 +1008,6 @@ export class DeckGLAdapter implements IMapEngine { ) } - /** - * Bridge deck.gl's onHover PickingInfo to the normalized {lat, lng} - * shape for `on('mousemove', ...)` subscribers, mirroring LeafletAdapter. - */ private _emitMouseMove(info: PickingInfo): void { if (!info?.coordinate) return this._eventListeners.get('mousemove')?.forEach( @@ -1024,12 +1015,6 @@ export class DeckGLAdapter implements IMapEngine { ) } - /** - * Build the normalized pointer-event shape shared between click and - * mousemove. Matches the LeafletAdapter's `_normalizeEvent` output so - * legacy consumers reading `e.latlng.lng` (e.g. Ancillary/Coordinates) - * keep working regardless of engine. - */ private _buildNormalizedPointerEvent(info: PickingInfo): Record { const lat = info.coordinate![1] const lng = info.coordinate![0] diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts index 01a1f40e9..7a1f88c3c 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts @@ -122,17 +122,6 @@ export function pickInfoToResult(info: PickingInfo): FeaturePickResult { * * @throws {Error} If `options.type` is not a supported layer type. */ -/** - * Coerce a color input into deck.gl's [r, g, b, a] (0–255) format. - * - * Accepts: - * - already-RGBA arrays like [26, 115, 232, 255] — passed through, alpha defaults to 255 - * - CSS hex strings: `#rgb`, `#rrggbb`, `#rrggbbaa` - * - anything else → `fallback` - * - * This lets callers pass Leaflet-flavoured style (`color: '#1a73e8'`) without - * having to know deck.gl's native RGBA expectation. - */ function _toRgba( input: unknown, fallback: [number, number, number, number] @@ -188,10 +177,6 @@ export function buildDeckLayer(id: string, options: LayerOptions): Layer { ? (o.style as Record) : {} - // Accept either deck.gl-native style keys (strokeColor, strokeWidth) - // or Leaflet-style keys (color, weight, fillColor, fillOpacity, - // radius). Deck.gl-native wins when both are provided so existing - // callers aren't broken. const lineColor = _toRgba(style.strokeColor ?? style.color, [0, 0, 0, 255]) const lineWidth = (style.strokeWidth as number) ?? (style.weight as number) ?? 1 @@ -214,9 +199,6 @@ export function buildDeckLayer(id: string, options: LayerOptions): Layer { getFillColor: fillColor, getLineColor: lineColor, getLineWidth: lineWidth, - // Point features need an explicit radius accessor. Without it, - // deck.gl's default renders circles in meters (sub-pixel at most - // real-world zooms — effectively invisible). getPointRadius: (style.radius as number) ?? 5, pointRadiusUnits: 'pixels', pointType: o.pointType ?? 'circle', diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index e2c2cf414..3d8b4dbe1 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts @@ -80,17 +80,7 @@ export default class LeafletAdapter implements IMapEngine, IMapEn */ private _initOptions: MapInitOptions | null = null - /** - * The Leaflet tile layer used as the basemap (when basemap is configured). - * Rendered natively by Leaflet, so it shares the same projection, camera, - * and render loop as all other Leaflet layers — no sync required. - */ private _basemapLayer: any = null - - /** - * Access token forwarded into URL templates for providers that require one - * (e.g. Mapbox Static Tiles). Stored at init, reused on style swaps. - */ private _basemapAccessToken: string | undefined /** @@ -169,7 +159,6 @@ export default class LeafletAdapter implements IMapEngine, IMapEn attributionControl.remove() } - // --- Basemap tile layer setup --- if (options.basemap && options.basemap.provider && options.basemap.provider !== 'none') { this._initBasemapTileLayer(options.basemap) } @@ -266,7 +255,6 @@ export default class LeafletAdapter implements IMapEngine, IMapEn destroy(): void { if (!this._map) return - // Clean up basemap layer first this._removeBasemapLayer() this._eventHandlers.forEach((handler, eventName) => { @@ -290,10 +278,6 @@ export default class LeafletAdapter implements IMapEngine, IMapEn return this._map } - /** - * Get the Leaflet tile layer used as the basemap. - * Returns null if no basemap is configured. - */ getBasemap(): any { return this._basemapLayer } @@ -974,14 +958,6 @@ export default class LeafletAdapter implements IMapEngine, IMapEn // BASEMAP TILE LAYER METHODS // ======================================== - /** - * Add a Leaflet tile layer as the basemap. Uses Leaflet's native rendering - * so the basemap shares the same projection, camera, and render loop as - * all other Leaflet layers — no sync, no drift, no scale mismatch. - * - * The GL-specific style URL from the mission config is translated to a - * raster tile URL template by {@link _resolveBasemapTileSpec}. - */ private _initBasemapTileLayer(basemap: BasemapOptions): void { this._basemapAccessToken = basemap.accessToken const spec = this._resolveBasemapTileSpec(basemap) @@ -989,22 +965,12 @@ export default class LeafletAdapter implements IMapEngine, IMapEn this._basemapLayer.addTo(this._map) this._basemapLayer.bringToBack() - // If the provider has a natural floor (e.g. Mapbox 512 tiles at - // Leaflet zoom 1+), raise the map's minZoom so the user can never - // pan/zoom to a level where the basemap renders blank. const specMinZoom = (spec.options as { minZoom?: number }).minZoom if (typeof specMinZoom === 'number' && specMinZoom > this._map.getMinZoom()) { this._map.setMinZoom(specMinZoom) } } - /** - * Swap the basemap to a different style at runtime. - * Removes the current tile layer and adds a new one with the resolved URL. - * - * @param styleUrl - a Mapbox style URL (`mapbox://styles/...`), a raw tile - * URL template containing `{z}/{x}/{y}`, or any other URL (falls back to OSM). - */ setBasemapStyle(styleUrl: string): void { if (!this._map) return const spec = this._resolveBasemapTileSpec({ @@ -1018,9 +984,6 @@ export default class LeafletAdapter implements IMapEngine, IMapEn this._basemapLayer.bringToBack() } - /** - * Remove the basemap tile layer (if any) from the map. - */ private _removeBasemapLayer(): void { if (this._basemapLayer && this._map) { this._map.removeLayer(this._basemapLayer) @@ -1028,16 +991,6 @@ export default class LeafletAdapter implements IMapEngine, IMapEn this._basemapLayer = null } - /** - * Translate a mission's basemap config into a raster tile URL template + - * L.tileLayer options suitable for Leaflet's native renderer. - * - * Accepted inputs: - * - `mapbox://styles/{user}/{style}` → Mapbox Static Tiles API (needs token) - * - Any URL containing `{z}`, `{x}`, `{y}` → used as-is - * - Anything else (e.g. MapLibre style.json URLs) → OSM fallback, since - * vector style JSONs can't be rendered by Leaflet natively. - */ private _resolveBasemapTileSpec(basemap: BasemapOptions): { url: string options: Record @@ -1053,10 +1006,6 @@ export default class LeafletAdapter implements IMapEngine, IMapEn options: { tileSize: 512, zoomOffset: -1, - // Mapbox Static Tiles API serves 512-sized tiles indexed at - // standard Web Mercator zoom. Paired with zoomOffset -1, that - // means Leaflet zoom 0 resolves to URL zoom -1, which Mapbox - // doesn't serve. Floor the tile layer at Leaflet zoom 1. minZoom: 1, attribution: '© Mapbox © OpenStreetMap', }, @@ -1076,11 +1025,6 @@ export default class LeafletAdapter implements IMapEngine, IMapEn } } - /** - * Best-effort inference of the basemap provider from a style URL. - * Used only by runtime `setBasemapStyle` calls that receive a bare URL - * without an explicit provider field. - */ private _inferProvider(styleUrl: string): BasemapOptions['provider'] { if (styleUrl.startsWith('mapbox://')) return 'mapbox' return 'maplibre' diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletHelpers.ts b/src/essence/Basics/MapEngines/Adapters/LeafletHelpers.ts index efe21e1d1..9c8725822 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletHelpers.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletHelpers.ts @@ -125,11 +125,6 @@ function _buildTileLayer(id: string, options: TileLayerOptions): any { /** * Build an L.geoJSON layer from {@link GeoJSONLayerOptions}. * Callback props (style, onEachFeature, pointToLayer, filter) are forwarded as-is. - * - * When no `pointToLayer` is supplied, Point features are rendered as - * `L.circleMarker` using the `style` options (radius, color, weight, fillColor, - * fillOpacity). This avoids Leaflet's default `L.marker` fallback — which loads - * the default marker PNG + shadow — for callers that just want styled dots. */ function _buildGeoJSONLayer(id: string, options: GeoJSONLayerOptions): any { if (!options.geojson) { diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 458ed38d7..36f59dfb4 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -48,18 +48,11 @@ const IMAGE_DEFAULT_COLOR_RAMP = 'binary' // Provider cleanup functions for re-initialization let _providerCleanups = [] -// Basemap state let _basemapStyles = [] let _basemapActiveIndex = 0 -// Ephemeral overlay ids currently registered with the active engine. -// The actual layer objects are owned by the adapter (IMapEngine.createLayer), -// we just track the ids so clearOverlays can iterate them. const _overlayIds = new Set() -// Whitelisted style keys accepted by map:addOverlay. Keeps the surface narrow -// so marketplace plugins can't pass callbacks or arbitrary engine-internal -// options. The adapter maps these to native Leaflet / deck.gl style props. const _OVERLAY_STYLE_KEYS = [ 'color', 'weight', 'opacity', 'fillColor', 'fillOpacity', @@ -72,17 +65,12 @@ function _pickStyle(style) { return out } -// Handler references for engine-level event bridges to the plugin bus. -// Retained so re-init can detach the previous one before re-attaching. let _mapClickHandler = null let _mapMouseMoveHandler = null function _resolveBasemapStyles(basemapConfig, engineType) { const isLeaflet = engineType === MAP_ENGINE.LEAFLET - // Mapbox defaults use mapbox:// style URLs. Both engines accept them — - // DeckGL via Mapbox GL's setStyle, Leaflet via the adapter's translation - // to the Static Tiles API. const MAPBOX_DEFAULTS = [ { name: 'Streets', style: 'mapbox://styles/mapbox/streets-v12' }, { name: 'Satellite', style: 'mapbox://styles/mapbox/satellite-streets-v12' }, @@ -91,14 +79,6 @@ function _resolveBasemapStyles(basemapConfig, engineType) { { name: 'Dark', style: 'mapbox://styles/mapbox/dark-v11' }, ] - // Shared "Streets / Light / Dark" names for both engines. URLs differ - // because DeckGL renders MapLibre vector style.json via MapLibre GL, - // and Leaflet needs raster {z}/{x}/{y} templates. Names match the - // MapControl gradient keys so dropdown thumbnails are correct on both - // engines with no plugin-side change. - // - // Leaflet gets an extra "Terrain" (OpenTopoMap) since no free MapLibre - // vector terrain style is available to keep strict parity. const MAPLIBRE_DEFAULTS_DECKGL = [ { name: 'Streets', style: 'https://tiles.openfreemap.org/styles/liberty' }, { name: 'Light', style: 'https://tiles.openfreemap.org/styles/positron' }, From 2eb8c4e8c2f59699544b86a350fa932830bca824 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Thu, 23 Apr 2026 09:49:29 -0500 Subject: [PATCH 16/21] revert: drop default pointToLayer from vector layer builder --- .../Basics/MapEngines/Adapters/LeafletHelpers.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletHelpers.ts b/src/essence/Basics/MapEngines/Adapters/LeafletHelpers.ts index 9c8725822..153d9d333 100644 --- a/src/essence/Basics/MapEngines/Adapters/LeafletHelpers.ts +++ b/src/essence/Basics/MapEngines/Adapters/LeafletHelpers.ts @@ -131,22 +131,10 @@ function _buildGeoJSONLayer(id: string, options: GeoJSONLayerOptions): any { throw new Error('buildLeafletLayer (vector): options.geojson is required') } - const style = (options.style as Record) || {} - const defaultPointToLayer = (_feature: any, latlng: any) => - L.circleMarker(latlng, { - radius: style.radius ?? 5, - color: style.color ?? '#3388ff', - weight: style.weight ?? 2, - opacity: style.opacity ?? 1, - fillColor: style.fillColor ?? style.color ?? '#3388ff', - fillOpacity: style.fillOpacity ?? 0.6, - interactive: options.interactive ?? true, - }) - const leafletOptions: Record = { ...(options.style !== undefined ? { style: options.style } : {}), ...(options.onEachFeature ? { onEachFeature: options.onEachFeature } : {}), - pointToLayer: options.pointToLayer ?? defaultPointToLayer, + ...(options.pointToLayer ? { pointToLayer: options.pointToLayer } : {}), ...(options.filter ? { filter: options.filter } : {}), ...(options.nativeOptions ?? {}), } From 9627997aea33bc9b4dc93b1f96c15739330732d3 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Fri, 24 Apr 2026 09:58:11 -0500 Subject: [PATCH 17/21] mapcontrol tool WIP --- .../Tools/MapControl/MapControl.helpers.js | 9 + src/essence/Tools/MapControl/MapControl.js | 292 ++++++++++++++++++ .../Tools/MapControl/MapControl.styles.js | 216 +++++++++++++ .../Tools/MapControl/MapControlTool.js | 151 +++++++++ src/essence/Tools/MapControl/config.json | 14 + 5 files changed, 682 insertions(+) create mode 100644 src/essence/Tools/MapControl/MapControl.helpers.js create mode 100644 src/essence/Tools/MapControl/MapControl.js create mode 100644 src/essence/Tools/MapControl/MapControl.styles.js create mode 100644 src/essence/Tools/MapControl/MapControlTool.js create mode 100644 src/essence/Tools/MapControl/config.json diff --git a/src/essence/Tools/MapControl/MapControl.helpers.js b/src/essence/Tools/MapControl/MapControl.helpers.js new file mode 100644 index 000000000..addf349e3 --- /dev/null +++ b/src/essence/Tools/MapControl/MapControl.helpers.js @@ -0,0 +1,9 @@ +export function formatDistance(m) { + if (m < 1000) { + const ft = m * 3.28084 + return `${ft.toFixed(0)} ft (${m.toFixed(1)} m)` + } + const mi = m * 0.000621371 + const km = m / 1000 + return `${mi.toFixed(2)} mi (${km.toFixed(2)} km)` +} diff --git a/src/essence/Tools/MapControl/MapControl.js b/src/essence/Tools/MapControl/MapControl.js new file mode 100644 index 000000000..cdcc158ac --- /dev/null +++ b/src/essence/Tools/MapControl/MapControl.js @@ -0,0 +1,292 @@ +// Plugin dependency: `@turf/turf` is imported below for great-circle distance +// in the measure tool. MMGIS already bundles it. To port this component to +// another project, install `@turf/turf` or swap the `distance` call for an +// equivalent implementation. +import React, { useState, useRef, useEffect, useMemo } from 'react' +import { distance, point } from '@turf/turf' +import { formatDistance } from './MapControl.helpers' +import { + bg, + createStyles, + resolveTheme, + MEASURE_STYLE, + BasemapIcon, + PlusIcon, + MinusIcon, + RulerIcon, +} from './MapControl.styles' + +const MEASURE_OVERLAY_ID = 'plugin:mapcontrol:measure' + +function MapControl({ + styles = [], + active = null, + rightOffset = 12, + theme, + onSelectBasemap, + onZoomIn, + onZoomOut, + subscribeToMap, + drawOverlay, + removeOverlay, + projectLatLng, + setMapCursor, +}) { + const resolvedTheme = useMemo(() => resolveTheme(theme), [theme]) + const S = useMemo(() => createStyles(resolvedTheme), [resolvedTheme]) + + const [open, setOpen] = useState(false) + const [measuring, setMeasuring] = useState(false) + const [points, setPoints] = useState([]) + const [mouseLatLng, setMouseLatLng] = useState(null) + const [labelPixel, setLabelPixel] = useState(null) + const rootRef = useRef(null) + + const measureSupported = Boolean( + subscribeToMap && drawOverlay && removeOverlay + ) + + let segment = null + if (measuring) { + if (points.length === 2) segment = [points[0], points[1]] + else if (points.length === 1 && mouseLatLng) + segment = [points[0], mouseLatLng] + } + + function toggleMeasure() { + if (measuring) { + setMeasuring(false) + setPoints([]) + setMouseLatLng(null) + setLabelPixel(null) + } else { + setMeasuring(true) + setPoints([]) + setMouseLatLng(null) + setLabelPixel(null) + setOpen(false) + } + } + + useEffect(() => { + function onDown(e) { + if (rootRef.current && !rootRef.current.contains(e.target)) + setOpen(false) + } + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, []) + + useEffect(() => { + function onKey(e) { + if (e.key !== 'Escape') return + setOpen(false) + if (measuring) { + setMeasuring(false) + setPoints([]) + } + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [measuring]) + + useEffect(() => { + if (!measuring || !subscribeToMap) return undefined + + const unsubscribe = subscribeToMap({ + onClick: (e) => { + if (!e || e.lat == null || e.lng == null) return + setPoints((prev) => { + if (prev.length >= 2) return [{ lat: e.lat, lng: e.lng }] + return prev.concat([{ lat: e.lat, lng: e.lng }]) + }) + }, + onMouseMove: (e) => { + if (!e || e.lat == null || e.lng == null) return + setMouseLatLng({ lat: e.lat, lng: e.lng }) + }, + }) + + if (setMapCursor) setMapCursor('crosshair') + + return () => { + if (typeof unsubscribe === 'function') unsubscribe() + if (setMapCursor) setMapCursor('') + } + }, [measuring, subscribeToMap, setMapCursor]) + + useEffect(() => { + if (!drawOverlay || !removeOverlay) return + + if (!measuring || points.length === 0) { + removeOverlay(MEASURE_OVERLAY_ID) + return + } + + const features = points.map((p) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, + })) + + if (segment) { + features.push({ + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [segment[0].lng, segment[0].lat], + [segment[1].lng, segment[1].lat], + ], + }, + }) + } + + drawOverlay({ + id: MEASURE_OVERLAY_ID, + geojson: { type: 'FeatureCollection', features }, + style: MEASURE_STYLE, + }) + }, [measuring, points, mouseLatLng, drawOverlay, removeOverlay]) + + useEffect(() => { + return () => { + if (removeOverlay) removeOverlay(MEASURE_OVERLAY_ID) + } + }, [removeOverlay]) + + useEffect(() => { + if (!segment || !projectLatLng) { + setLabelPixel(null) + return undefined + } + const mid = { + lat: (segment[0].lat + segment[1].lat) / 2, + lng: (segment[0].lng + segment[1].lng) / 2, + } + let cancelled = false + Promise.resolve(projectLatLng(mid)).then((pt) => { + if (!cancelled && pt && pt.x != null && pt.y != null) { + setLabelPixel({ x: pt.x, y: pt.y }) + } + }) + return () => { + cancelled = true + } + }, [ + segment ? segment[0].lat : null, + segment ? segment[0].lng : null, + segment ? segment[1].lat : null, + segment ? segment[1].lng : null, + projectLatLng, + ]) + + const current = active || styles[0] + const hasStyles = styles.length > 0 + const hasZoom = Boolean(onZoomIn || onZoomOut) + + return ( + <> +
+
+ {hasStyles && ( + <> + + + + )} + {measureSupported && ( + <> + + {hasZoom && } + + )} + {onZoomOut && ( + + )} + {onZoomIn && onZoomOut && } + {onZoomIn && ( + + )} +
+ {measuring && points.length === 0 && ( +
Click points on the map
+ )} + {open && hasStyles && ( +
+
Style
+ {styles.map((entry) => { + const isActive = + current && entry.name === current.name + return ( + + ) + })} +
+ )} +
+ {measuring && segment && labelPixel && ( +
+ {formatDistance( + distance( + point([segment[0].lng, segment[0].lat]), + point([segment[1].lng, segment[1].lat]), + { units: 'meters' } + ) + )} +
+ )} + + ) +} + +export default MapControl diff --git a/src/essence/Tools/MapControl/MapControl.styles.js b/src/essence/Tools/MapControl/MapControl.styles.js new file mode 100644 index 000000000..8785a1926 --- /dev/null +++ b/src/essence/Tools/MapControl/MapControl.styles.js @@ -0,0 +1,216 @@ +import React from 'react' + +const FONT_FAMILY = + "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif" + +export const defaultTheme = { + surface: '#fff', + primary: '#1a73e8', + text: '#202124', + textMuted: '#5f6368', + textHint: '#9aa0a6', + divider: 'rgba(0,0,0,0.12)', + surfaceHighlight: '#e8f0fe', + shadow: '0 1px 6px rgba(0,0,0,0.25)', + shadowLg: '0 2px 12px rgba(0,0,0,0.2)', + fontFamily: FONT_FAMILY, + radius: 8, + radiusSm: 4, +} + +export function resolveTheme(overrides) { + if (!overrides) return defaultTheme + return { ...defaultTheme, ...overrides } +} + +export const MEASURE_STYLE = { + color: '#202124', + weight: 2, + opacity: 1, + dashArray: '2 6', + lineCap: 'round', + radius: 4, + fillColor: '#202124', + fillOpacity: 1, +} + +const Icon = ({ size = 18, children }) => ( + + {children} + +) + +export const BasemapIcon = (props) => ( + + + +) + +export const PlusIcon = (props) => ( + + + +) + +export const MinusIcon = (props) => ( + + + +) + +export const RulerIcon = (props) => ( + + + +) + +const GRADIENTS = { + streets: 'linear-gradient(135deg,#f0ebe0,#ddd0b8,#bca888)', + satellite: 'linear-gradient(135deg,#0c1f2e,#163b20,#0a2e0a)', + outdoors: 'linear-gradient(135deg,#e0f5c8,#88c458,#4a8030)', + light: 'linear-gradient(135deg,#fff,#ebebeb,#d4d4d4)', + dark: 'linear-gradient(135deg,#2c2c3a,#1a1a28,#0c0c18)', + terrain: 'linear-gradient(135deg,#d4edbc,#6ab040,#3d6e28)', + liberty: 'linear-gradient(135deg,#f5e6ca,#d4a853,#8b7355)', + bright: 'linear-gradient(135deg,#dff0fb,#93cce8,#4a9ec4)', + positron: 'linear-gradient(135deg,#f8f8f8,#e0e0e0,#b8b8b8)', +} +const DEFAULT_BG = 'linear-gradient(135deg,#4a90d9,#2a6db8,#1450a0)' + +export function bg(name) { + const k = (name || '').toLowerCase() + const m = Object.entries(GRADIENTS).find(([key]) => k.includes(key)) + return m ? m[1] : DEFAULT_BG +} + +export function createStyles(theme) { + return { + root: (rightOffset) => ({ + position: 'absolute', + top: 12, + right: rightOffset, + zIndex: 1002, + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + pointerEvents: 'auto', + fontFamily: theme.fontFamily, + }), + bar: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + background: theme.surface, + borderRadius: theme.radius, + overflow: 'hidden', + boxShadow: theme.shadow, + }, + barBtn: (active, hasBg) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 36, + height: 36, + padding: 0, + border: 'none', + cursor: 'pointer', + background: hasBg || 'transparent', + position: 'relative', + color: hasBg ? theme.surface : theme.text, + outline: active ? `2px solid ${theme.primary}` : 'none', + outlineOffset: -2, + }), + divider: { + width: 1, + height: 24, + background: theme.divider, + alignSelf: 'center', + }, + panel: { + marginTop: 6, + background: theme.surface, + borderRadius: theme.radius, + overflow: 'hidden', + boxShadow: theme.shadowLg, + minWidth: 220, + display: 'flex', + flexDirection: 'column', + }, + panelHeader: { + padding: '10px 12px 6px', + fontSize: 10, + fontWeight: 700, + letterSpacing: 0.8, + textTransform: 'uppercase', + color: theme.textHint, + }, + row: (active) => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + padding: '8px 12px', + background: active ? theme.surfaceHighlight : 'transparent', + border: 'none', + cursor: 'pointer', + width: '100%', + textAlign: 'left', + }), + rowLabel: (active) => ({ + fontSize: 14, + fontWeight: active ? 600 : 500, + color: active ? theme.primary : theme.text, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }), + rowThumb: (gradient, active) => ({ + width: 44, + height: 28, + borderRadius: theme.radiusSm, + flexShrink: 0, + background: gradient, + border: active + ? `2px solid ${theme.primary}` + : '2px solid transparent', + boxShadow: active + ? `0 0 0 1px ${theme.primary}` + : '0 1px 2px rgba(0,0,0,0.15)', + }), + readoutHint: { + marginTop: 6, + alignSelf: 'flex-end', + background: theme.surface, + borderRadius: theme.radius, + boxShadow: theme.shadow, + padding: '8px 14px', + fontSize: 12, + fontWeight: 500, + color: theme.textMuted, + }, + measureLabel: (pixel) => ({ + position: 'absolute', + left: pixel.x, + top: pixel.y, + transform: 'translate(-50%, -50%)', + background: theme.surface, + borderRadius: theme.radius, + boxShadow: theme.shadow, + padding: '6px 10px', + fontSize: 12, + fontWeight: 600, + color: theme.primary, + whiteSpace: 'nowrap', + pointerEvents: 'none', + zIndex: 1003, + fontFamily: theme.fontFamily, + }), + } +} diff --git a/src/essence/Tools/MapControl/MapControlTool.js b/src/essence/Tools/MapControl/MapControlTool.js new file mode 100644 index 000000000..a00c91533 --- /dev/null +++ b/src/essence/Tools/MapControl/MapControlTool.js @@ -0,0 +1,151 @@ +import React, { useState, useEffect, useCallback } from 'react' +import ReactDOM from 'react-dom' +import MapControl from './MapControl' + +const ROOT_ID = 'mmgis-map-control-root' +const ROOT_STYLE = + 'position:absolute;inset:0;pointer-events:none;z-index:1002;' +const TOP_BAR_ID = 'topBarRight' +const MAP_SCREEN_ID = 'mapScreen' + +function useTopBarOffset() { + const [offset, setOffset] = useState(12) + + useEffect(() => { + function measure() { + const el = document.getElementById(TOP_BAR_ID) + if (!el) { + setOffset(12) + return + } + const w = el.getBoundingClientRect().width + setOffset(w > 0 ? Math.ceil(w) + 16 : 12) + } + measure() + const t = setTimeout(measure, 300) + window.addEventListener('resize', measure) + return () => { + clearTimeout(t) + window.removeEventListener('resize', measure) + } + }, []) + + return offset +} + +function ConnectedMapControl() { + const [styles, setStyles] = useState([]) + const [active, setActive] = useState(null) + const rightOffset = useTopBarOffset() + + useEffect(() => { + const api = window.mmgisAPI + if (!api) return + Promise.all([api.getBasemapStyles(), api.getBasemap()]).then( + ([s, a]) => { + setStyles(s || []) + setActive(a || (s && s[0]) || null) + } + ) + }, []) + + const onSelectBasemap = useCallback((entry) => { + setActive(entry) + const api = window.mmgisAPI + if (api) api.setBasemap(entry.name) + }, []) + + const onZoomIn = useCallback(() => { + const api = window.mmgisAPI + if (api) api.zoomIn() + }, []) + + const onZoomOut = useCallback(() => { + const api = window.mmgisAPI + if (api) api.zoomOut() + }, []) + + const subscribeToMap = useCallback(({ onClick, onMouseMove }) => { + const api = window.mmgisAPI + if (!api || !api.on) return () => {} + const offClick = api.on('map:click', onClick) + const offMove = api.on('map:mousemove', onMouseMove) + return () => { + if (typeof offClick === 'function') offClick() + if (typeof offMove === 'function') offMove() + } + }, []) + + const drawOverlay = useCallback((opts) => { + const api = window.mmgisAPI + if (api) api.addOverlay(opts) + }, []) + + const removeOverlay = useCallback((id) => { + const api = window.mmgisAPI + if (api) api.removeOverlay(id) + }, []) + + const projectLatLng = useCallback((latlng) => { + const api = window.mmgisAPI + if (!api) return null + return api.latLngToContainerPoint(latlng) + }, []) + + const setMapCursor = useCallback((cursor) => { + const screen = document.getElementById(MAP_SCREEN_ID) + if (screen) screen.style.cursor = cursor + }, []) + + return ( + + ) +} + +function InterfaceWithMMGIS() { + const mapScreen = document.getElementById(MAP_SCREEN_ID) + if (!mapScreen) { + this.separateFromMMGIS = () => {} + return + } + + const container = document.createElement('div') + container.id = ROOT_ID + container.style.cssText = ROOT_STYLE + mapScreen.appendChild(container) + + ReactDOM.render(, container) + + this.separateFromMMGIS = () => { + ReactDOM.unmountComponentAtNode(container) + container.remove() + } +} + +const MapControlTool = { + height: 0, + width: 0, + MMGISInterface: null, + make() { + if (this.MMGISInterface) return + this.MMGISInterface = new InterfaceWithMMGIS() + }, + destroy() { + if (this.MMGISInterface) this.MMGISInterface.separateFromMMGIS() + this.MMGISInterface = null + }, +} + +export default MapControlTool diff --git a/src/essence/Tools/MapControl/config.json b/src/essence/Tools/MapControl/config.json new file mode 100644 index 000000000..4eb776f2d --- /dev/null +++ b/src/essence/Tools/MapControl/config.json @@ -0,0 +1,14 @@ +{ + "defaultIcon": "layers", + "description": "Map controls: basemap style, zoom in, zoom out.", + "descriptionFull": { + "title": "A horizontal map overlay control for switching between basemap styles and zooming in/out. Add this tool to a mission to show a floating map control bar." + }, + "hasVars": false, + "name": "MapControl", + "toolbarPriority": 5, + "separatedTool": true, + "paths": { + "MapControlTool": "essence/Tools/MapControl/MapControlTool" + } +} From e6b98c9b98b0e246d4c08e911e631381b867c5a3 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Fri, 5 Jun 2026 09:44:56 -0500 Subject: [PATCH 18/21] refactor(MapControl): decouple component from MMGIS wrapper Standalone React component + pure helpers + themeable SCSS, mounted by a bus-only wrapper. Adds per-feature configure toggles. --- .../Tools/MapControl/MapControl.helpers.js | 9 - src/essence/Tools/MapControl/MapControl.js | 292 -------------- .../Tools/MapControl/MapControl.styles.js | 216 ----------- .../Tools/MapControl/MapControlComponent.scss | 214 +++++++++++ .../Tools/MapControl/MapControlComponent.tsx | 360 ++++++++++++++++++ .../Tools/MapControl/MapControlTool.js | 293 ++++++++++---- src/essence/Tools/MapControl/config.json | 46 ++- .../Tools/MapControl/mapControlHelpers.ts | 95 +++++ 8 files changed, 929 insertions(+), 596 deletions(-) delete mode 100644 src/essence/Tools/MapControl/MapControl.helpers.js delete mode 100644 src/essence/Tools/MapControl/MapControl.js delete mode 100644 src/essence/Tools/MapControl/MapControl.styles.js create mode 100644 src/essence/Tools/MapControl/MapControlComponent.scss create mode 100644 src/essence/Tools/MapControl/MapControlComponent.tsx create mode 100644 src/essence/Tools/MapControl/mapControlHelpers.ts diff --git a/src/essence/Tools/MapControl/MapControl.helpers.js b/src/essence/Tools/MapControl/MapControl.helpers.js deleted file mode 100644 index addf349e3..000000000 --- a/src/essence/Tools/MapControl/MapControl.helpers.js +++ /dev/null @@ -1,9 +0,0 @@ -export function formatDistance(m) { - if (m < 1000) { - const ft = m * 3.28084 - return `${ft.toFixed(0)} ft (${m.toFixed(1)} m)` - } - const mi = m * 0.000621371 - const km = m / 1000 - return `${mi.toFixed(2)} mi (${km.toFixed(2)} km)` -} diff --git a/src/essence/Tools/MapControl/MapControl.js b/src/essence/Tools/MapControl/MapControl.js deleted file mode 100644 index cdcc158ac..000000000 --- a/src/essence/Tools/MapControl/MapControl.js +++ /dev/null @@ -1,292 +0,0 @@ -// Plugin dependency: `@turf/turf` is imported below for great-circle distance -// in the measure tool. MMGIS already bundles it. To port this component to -// another project, install `@turf/turf` or swap the `distance` call for an -// equivalent implementation. -import React, { useState, useRef, useEffect, useMemo } from 'react' -import { distance, point } from '@turf/turf' -import { formatDistance } from './MapControl.helpers' -import { - bg, - createStyles, - resolveTheme, - MEASURE_STYLE, - BasemapIcon, - PlusIcon, - MinusIcon, - RulerIcon, -} from './MapControl.styles' - -const MEASURE_OVERLAY_ID = 'plugin:mapcontrol:measure' - -function MapControl({ - styles = [], - active = null, - rightOffset = 12, - theme, - onSelectBasemap, - onZoomIn, - onZoomOut, - subscribeToMap, - drawOverlay, - removeOverlay, - projectLatLng, - setMapCursor, -}) { - const resolvedTheme = useMemo(() => resolveTheme(theme), [theme]) - const S = useMemo(() => createStyles(resolvedTheme), [resolvedTheme]) - - const [open, setOpen] = useState(false) - const [measuring, setMeasuring] = useState(false) - const [points, setPoints] = useState([]) - const [mouseLatLng, setMouseLatLng] = useState(null) - const [labelPixel, setLabelPixel] = useState(null) - const rootRef = useRef(null) - - const measureSupported = Boolean( - subscribeToMap && drawOverlay && removeOverlay - ) - - let segment = null - if (measuring) { - if (points.length === 2) segment = [points[0], points[1]] - else if (points.length === 1 && mouseLatLng) - segment = [points[0], mouseLatLng] - } - - function toggleMeasure() { - if (measuring) { - setMeasuring(false) - setPoints([]) - setMouseLatLng(null) - setLabelPixel(null) - } else { - setMeasuring(true) - setPoints([]) - setMouseLatLng(null) - setLabelPixel(null) - setOpen(false) - } - } - - useEffect(() => { - function onDown(e) { - if (rootRef.current && !rootRef.current.contains(e.target)) - setOpen(false) - } - document.addEventListener('mousedown', onDown) - return () => document.removeEventListener('mousedown', onDown) - }, []) - - useEffect(() => { - function onKey(e) { - if (e.key !== 'Escape') return - setOpen(false) - if (measuring) { - setMeasuring(false) - setPoints([]) - } - } - document.addEventListener('keydown', onKey) - return () => document.removeEventListener('keydown', onKey) - }, [measuring]) - - useEffect(() => { - if (!measuring || !subscribeToMap) return undefined - - const unsubscribe = subscribeToMap({ - onClick: (e) => { - if (!e || e.lat == null || e.lng == null) return - setPoints((prev) => { - if (prev.length >= 2) return [{ lat: e.lat, lng: e.lng }] - return prev.concat([{ lat: e.lat, lng: e.lng }]) - }) - }, - onMouseMove: (e) => { - if (!e || e.lat == null || e.lng == null) return - setMouseLatLng({ lat: e.lat, lng: e.lng }) - }, - }) - - if (setMapCursor) setMapCursor('crosshair') - - return () => { - if (typeof unsubscribe === 'function') unsubscribe() - if (setMapCursor) setMapCursor('') - } - }, [measuring, subscribeToMap, setMapCursor]) - - useEffect(() => { - if (!drawOverlay || !removeOverlay) return - - if (!measuring || points.length === 0) { - removeOverlay(MEASURE_OVERLAY_ID) - return - } - - const features = points.map((p) => ({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, - })) - - if (segment) { - features.push({ - type: 'Feature', - geometry: { - type: 'LineString', - coordinates: [ - [segment[0].lng, segment[0].lat], - [segment[1].lng, segment[1].lat], - ], - }, - }) - } - - drawOverlay({ - id: MEASURE_OVERLAY_ID, - geojson: { type: 'FeatureCollection', features }, - style: MEASURE_STYLE, - }) - }, [measuring, points, mouseLatLng, drawOverlay, removeOverlay]) - - useEffect(() => { - return () => { - if (removeOverlay) removeOverlay(MEASURE_OVERLAY_ID) - } - }, [removeOverlay]) - - useEffect(() => { - if (!segment || !projectLatLng) { - setLabelPixel(null) - return undefined - } - const mid = { - lat: (segment[0].lat + segment[1].lat) / 2, - lng: (segment[0].lng + segment[1].lng) / 2, - } - let cancelled = false - Promise.resolve(projectLatLng(mid)).then((pt) => { - if (!cancelled && pt && pt.x != null && pt.y != null) { - setLabelPixel({ x: pt.x, y: pt.y }) - } - }) - return () => { - cancelled = true - } - }, [ - segment ? segment[0].lat : null, - segment ? segment[0].lng : null, - segment ? segment[1].lat : null, - segment ? segment[1].lng : null, - projectLatLng, - ]) - - const current = active || styles[0] - const hasStyles = styles.length > 0 - const hasZoom = Boolean(onZoomIn || onZoomOut) - - return ( - <> -
-
- {hasStyles && ( - <> - - - - )} - {measureSupported && ( - <> - - {hasZoom && } - - )} - {onZoomOut && ( - - )} - {onZoomIn && onZoomOut && } - {onZoomIn && ( - - )} -
- {measuring && points.length === 0 && ( -
Click points on the map
- )} - {open && hasStyles && ( -
-
Style
- {styles.map((entry) => { - const isActive = - current && entry.name === current.name - return ( - - ) - })} -
- )} -
- {measuring && segment && labelPixel && ( -
- {formatDistance( - distance( - point([segment[0].lng, segment[0].lat]), - point([segment[1].lng, segment[1].lat]), - { units: 'meters' } - ) - )} -
- )} - - ) -} - -export default MapControl diff --git a/src/essence/Tools/MapControl/MapControl.styles.js b/src/essence/Tools/MapControl/MapControl.styles.js deleted file mode 100644 index 8785a1926..000000000 --- a/src/essence/Tools/MapControl/MapControl.styles.js +++ /dev/null @@ -1,216 +0,0 @@ -import React from 'react' - -const FONT_FAMILY = - "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif" - -export const defaultTheme = { - surface: '#fff', - primary: '#1a73e8', - text: '#202124', - textMuted: '#5f6368', - textHint: '#9aa0a6', - divider: 'rgba(0,0,0,0.12)', - surfaceHighlight: '#e8f0fe', - shadow: '0 1px 6px rgba(0,0,0,0.25)', - shadowLg: '0 2px 12px rgba(0,0,0,0.2)', - fontFamily: FONT_FAMILY, - radius: 8, - radiusSm: 4, -} - -export function resolveTheme(overrides) { - if (!overrides) return defaultTheme - return { ...defaultTheme, ...overrides } -} - -export const MEASURE_STYLE = { - color: '#202124', - weight: 2, - opacity: 1, - dashArray: '2 6', - lineCap: 'round', - radius: 4, - fillColor: '#202124', - fillOpacity: 1, -} - -const Icon = ({ size = 18, children }) => ( - - {children} - -) - -export const BasemapIcon = (props) => ( - - - -) - -export const PlusIcon = (props) => ( - - - -) - -export const MinusIcon = (props) => ( - - - -) - -export const RulerIcon = (props) => ( - - - -) - -const GRADIENTS = { - streets: 'linear-gradient(135deg,#f0ebe0,#ddd0b8,#bca888)', - satellite: 'linear-gradient(135deg,#0c1f2e,#163b20,#0a2e0a)', - outdoors: 'linear-gradient(135deg,#e0f5c8,#88c458,#4a8030)', - light: 'linear-gradient(135deg,#fff,#ebebeb,#d4d4d4)', - dark: 'linear-gradient(135deg,#2c2c3a,#1a1a28,#0c0c18)', - terrain: 'linear-gradient(135deg,#d4edbc,#6ab040,#3d6e28)', - liberty: 'linear-gradient(135deg,#f5e6ca,#d4a853,#8b7355)', - bright: 'linear-gradient(135deg,#dff0fb,#93cce8,#4a9ec4)', - positron: 'linear-gradient(135deg,#f8f8f8,#e0e0e0,#b8b8b8)', -} -const DEFAULT_BG = 'linear-gradient(135deg,#4a90d9,#2a6db8,#1450a0)' - -export function bg(name) { - const k = (name || '').toLowerCase() - const m = Object.entries(GRADIENTS).find(([key]) => k.includes(key)) - return m ? m[1] : DEFAULT_BG -} - -export function createStyles(theme) { - return { - root: (rightOffset) => ({ - position: 'absolute', - top: 12, - right: rightOffset, - zIndex: 1002, - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-end', - pointerEvents: 'auto', - fontFamily: theme.fontFamily, - }), - bar: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - background: theme.surface, - borderRadius: theme.radius, - overflow: 'hidden', - boxShadow: theme.shadow, - }, - barBtn: (active, hasBg) => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 36, - height: 36, - padding: 0, - border: 'none', - cursor: 'pointer', - background: hasBg || 'transparent', - position: 'relative', - color: hasBg ? theme.surface : theme.text, - outline: active ? `2px solid ${theme.primary}` : 'none', - outlineOffset: -2, - }), - divider: { - width: 1, - height: 24, - background: theme.divider, - alignSelf: 'center', - }, - panel: { - marginTop: 6, - background: theme.surface, - borderRadius: theme.radius, - overflow: 'hidden', - boxShadow: theme.shadowLg, - minWidth: 220, - display: 'flex', - flexDirection: 'column', - }, - panelHeader: { - padding: '10px 12px 6px', - fontSize: 10, - fontWeight: 700, - letterSpacing: 0.8, - textTransform: 'uppercase', - color: theme.textHint, - }, - row: (active) => ({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: 8, - padding: '8px 12px', - background: active ? theme.surfaceHighlight : 'transparent', - border: 'none', - cursor: 'pointer', - width: '100%', - textAlign: 'left', - }), - rowLabel: (active) => ({ - fontSize: 14, - fontWeight: active ? 600 : 500, - color: active ? theme.primary : theme.text, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }), - rowThumb: (gradient, active) => ({ - width: 44, - height: 28, - borderRadius: theme.radiusSm, - flexShrink: 0, - background: gradient, - border: active - ? `2px solid ${theme.primary}` - : '2px solid transparent', - boxShadow: active - ? `0 0 0 1px ${theme.primary}` - : '0 1px 2px rgba(0,0,0,0.15)', - }), - readoutHint: { - marginTop: 6, - alignSelf: 'flex-end', - background: theme.surface, - borderRadius: theme.radius, - boxShadow: theme.shadow, - padding: '8px 14px', - fontSize: 12, - fontWeight: 500, - color: theme.textMuted, - }, - measureLabel: (pixel) => ({ - position: 'absolute', - left: pixel.x, - top: pixel.y, - transform: 'translate(-50%, -50%)', - background: theme.surface, - borderRadius: theme.radius, - boxShadow: theme.shadow, - padding: '6px 10px', - fontSize: 12, - fontWeight: 600, - color: theme.primary, - whiteSpace: 'nowrap', - pointerEvents: 'none', - zIndex: 1003, - fontFamily: theme.fontFamily, - }), - } -} diff --git a/src/essence/Tools/MapControl/MapControlComponent.scss b/src/essence/Tools/MapControl/MapControlComponent.scss new file mode 100644 index 000000000..d98b57769 --- /dev/null +++ b/src/essence/Tools/MapControl/MapControlComponent.scss @@ -0,0 +1,214 @@ +// MapControl — styled with USWDS disaster theme tokens (CSS custom properties). +// All --theme-* vars are defined in dist/disasters.css and available globally. + +.map-control { + position: absolute; + top: 18px; + // right is set via inline style — dynamic offset from top-bar chrome width + display: flex; + flex-direction: column; + align-items: flex-end; + pointer-events: auto; + font-family: var(--theme-font-ui); + z-index: 1002; +} + +// ─── Toolbar (transparent wrapper, each feature is its own card) ────────── + +.map-control__bar { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + white-space: nowrap; +} + +.map-control__group { + display: flex; + flex-direction: row; + align-items: center; + background: var(--theme-color-white); + border: 1px solid var(--theme-color-base-lighter); + border-radius: var(--theme-radius-md); + box-shadow: 0 1px 3px var(--theme-color-shadow); + overflow: hidden; + height: 36px; +} + +.map-control__btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 100%; + padding: 0; + border: none; + cursor: pointer; + background: transparent; + color: var(--theme-color-ink); + flex-shrink: 0; + transition: background 0.12s ease, color 0.12s ease; + + &:hover { + background: var(--theme-color-base-lightest); + } + + &--active { + color: var(--theme-color-primary); + // primary tint — no lightest primary token in the theme + background: rgba(14, 116, 130, 0.1); + + &:hover { + background: rgba(14, 116, 130, 0.16); + } + } +} + +.map-control__divider { + width: 1px; + height: 24px; + background: var(--theme-color-base-lighter); + flex-shrink: 0; +} + +// ─── Dropdown panel (basemap + search) ──────────────────────────────────── + +.map-control__panel { + margin-top: var(--theme-spacing-05); + background: var(--theme-color-white); + border-radius: var(--theme-radius-lg); + box-shadow: 0 4px 16px var(--theme-color-shadow); + min-width: 220px; + display: flex; + flex-direction: column; + max-height: 320px; + overflow-y: auto; +} + +.map-control__panel-header { + padding: var(--theme-spacing-1) var(--theme-spacing-105) var(--theme-spacing-05); + font-size: var(--theme-font-size-3xs); + font-weight: var(--theme-font-weight-bold); + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--theme-color-base); + flex-shrink: 0; +} + +// ─── Basemap / search rows ───────────────────────────────────────────────── + +.map-control__row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: var(--theme-spacing-1); + padding: var(--theme-spacing-1) var(--theme-spacing-105); + background: transparent; + border: none; + cursor: pointer; + width: 100%; + text-align: left; + transition: background 0.1s ease; + flex-shrink: 0; + + &:hover { + background: var(--theme-color-base-lightest); + } + + &--active { + background: rgba(14, 116, 130, 0.06); + + .map-control__row-label { + font-weight: var(--theme-font-weight-semibold); + color: var(--theme-color-primary); + } + + .map-control__row-thumb { + border-color: var(--theme-color-primary); + } + } +} + +.map-control__row-label { + font-size: var(--theme-font-size-2xs); + font-weight: var(--theme-font-weight-normal); + color: var(--theme-color-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.map-control__row-thumb { + width: 44px; + height: 28px; + border-radius: var(--theme-radius-sm); + flex-shrink: 0; + border: 2px solid transparent; + box-shadow: 0 1px 3px var(--theme-color-shadow); +} + +// ─── Search input ────────────────────────────────────────────────────────── + +.map-control__search-row { + padding: var(--theme-spacing-1) var(--theme-spacing-105); + flex-shrink: 0; +} + +.map-control__search-input { + width: 100%; + box-sizing: border-box; + padding: var(--theme-spacing-05) var(--theme-spacing-1); + border: 1px solid var(--theme-color-base-light); + border-radius: var(--theme-radius-md); + background: var(--theme-color-white); + color: var(--theme-color-ink); + font-family: var(--theme-font-ui); + font-size: var(--theme-font-size-2xs); + outline: none; + + &:focus { + border-color: var(--theme-color-primary); + box-shadow: 0 0 0 2px rgba(14, 116, 130, 0.15); + } +} + +.map-control__search-hint { + padding: var(--theme-spacing-1) var(--theme-spacing-105); + font-size: var(--theme-font-size-3xs); + color: var(--theme-color-base); + flex-shrink: 0; +} + +// ─── Measure hint (below bar when active) ───────────────────────────────── + +.map-control__hint { + margin-top: var(--theme-spacing-05); + align-self: flex-end; + background: var(--theme-color-white); + border-radius: var(--theme-radius-lg); + box-shadow: 0 2px 8px var(--theme-color-shadow); + padding: var(--theme-spacing-1) var(--theme-spacing-105); + font-size: var(--theme-font-size-3xs); + font-weight: var(--theme-font-weight-semibold); + color: var(--theme-color-base-dark); +} + +// ─── Distance label (absolutely positioned over map canvas) ─────────────── + +.map-control__measure-label { + position: absolute; + transform: translate(-50%, -50%); + background: var(--theme-color-white); + border-radius: var(--theme-radius-lg); + box-shadow: 0 2px 8px var(--theme-color-shadow); + padding: var(--theme-spacing-05) var(--theme-spacing-1); + font-size: var(--theme-font-size-3xs); + font-weight: var(--theme-font-weight-semibold); + color: var(--theme-color-primary); + white-space: nowrap; + pointer-events: none; + z-index: 1003; + font-family: var(--theme-font-ui); +} diff --git a/src/essence/Tools/MapControl/MapControlComponent.tsx b/src/essence/Tools/MapControl/MapControlComponent.tsx new file mode 100644 index 000000000..80c8371c6 --- /dev/null +++ b/src/essence/Tools/MapControl/MapControlComponent.tsx @@ -0,0 +1,360 @@ +import React, { useState, useRef, useEffect } from 'react' +import { + BasemapStyle, + GeocodeResult, + LatLng, + MapOverlayOpts, + MapSubscribeHandlers, + MEASURE_STYLE, + basemapGradient, + formatDistance, + measureDistance, + geocodeSearch, +} from './mapControlHelpers' +import './MapControlComponent.scss' + +const MEASURE_OVERLAY_ID = 'plugin:mapcontrol:measure' +const SEARCH_DEBOUNCE_MS = 300 + +export interface MapControlComponentProps { + /** Distance from the right edge of the map canvas (px). Dynamic — supplied by wrapper. */ + rightOffset?: number + + // Basemap + basemapStyles?: BasemapStyle[] + activeBasemap?: BasemapStyle | null + onSelectBasemap?: (style: BasemapStyle) => void + + // Zoom + onZoomIn?: () => void + onZoomOut?: () => void + + // Measure — all three must be provided to enable the feature + subscribeToMap?: (handlers: MapSubscribeHandlers) => () => void + onDrawOverlay?: (opts: MapOverlayOpts) => void + onRemoveOverlay?: (id: string) => void + onProjectLatLng?: (latlng: LatLng) => Promise<{ x: number; y: number } | null> + onSetCursor?: (cursor: string) => void + + // Geocode search + onSearchSelect?: (result: GeocodeResult) => void +} + +export function MapControlComponent({ + rightOffset = 12, + basemapStyles = [], + activeBasemap = null, + onSelectBasemap, + onZoomIn, + onZoomOut, + subscribeToMap, + onDrawOverlay, + onRemoveOverlay, + onProjectLatLng, + onSetCursor, + onSearchSelect, +}: MapControlComponentProps) { + const rootRef = useRef(null) + + const [basemapOpen, setBasemapOpen] = useState(false) + const [searchOpen, setSearchOpen] = useState(false) + const [measuring, setMeasuring] = useState(false) + const [points, setPoints] = useState([]) + const [mouseLatLng, setMouseLatLng] = useState(null) + const [labelPixel, setLabelPixel] = useState<{ x: number; y: number } | null>(null) + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [searchLoading, setSearchLoading] = useState(false) + + const searchInputRef = useRef(null) + const searchTimerRef = useRef | null>(null) + + const measureSupported = Boolean(subscribeToMap && onDrawOverlay && onRemoveOverlay) + + // Derived live segment for measure preview + let segment: [LatLng, LatLng] | null = null + if (measuring) { + if (points.length === 2) segment = [points[0], points[1]] + else if (points.length === 1 && mouseLatLng) segment = [points[0], mouseLatLng] + } + + // ── Close panels on outside click ──────────────────────────────────────── + useEffect(() => { + function onDown(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setBasemapOpen(false) + setSearchOpen(false) + } + } + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, []) + + // ── Escape key ──────────────────────────────────────────────────────────── + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key !== 'Escape') return + setBasemapOpen(false) + setSearchOpen(false) + if (measuring) { + setMeasuring(false) + setPoints([]) + setMouseLatLng(null) + setLabelPixel(null) + } + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [measuring]) + + // ── Auto-focus search input ─────────────────────────────────────────────── + useEffect(() => { + if (searchOpen) searchInputRef.current?.focus() + else { setSearchQuery(''); setSearchResults([]) } + }, [searchOpen]) + + // ── Debounced geocode search ─────────────────────────────────────────────── + useEffect(() => { + if (searchTimerRef.current) clearTimeout(searchTimerRef.current) + if (searchQuery.length < 2) { setSearchResults([]); setSearchLoading(false); return } + setSearchLoading(true) + searchTimerRef.current = setTimeout(async () => { + const results = await geocodeSearch(searchQuery) + setSearchResults(results) + setSearchLoading(false) + }, SEARCH_DEBOUNCE_MS) + return () => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current) } + }, [searchQuery]) + + // ── Map click/move subscription for measure ─────────────────────────────── + useEffect(() => { + if (!measuring || !subscribeToMap) return + const unsub = subscribeToMap({ + onClick: (e) => { + if (e?.lat == null || e?.lng == null) return + setPoints((prev) => + prev.length >= 2 ? [{ lat: e.lat, lng: e.lng }] : [...prev, { lat: e.lat, lng: e.lng }] + ) + }, + onMouseMove: (e) => { + if (e?.lat != null && e?.lng != null) setMouseLatLng({ lat: e.lat, lng: e.lng }) + }, + }) + if (onSetCursor) onSetCursor('crosshair') + return () => { + unsub() + if (onSetCursor) onSetCursor('') + } + }, [measuring, subscribeToMap, onSetCursor]) + + // ── Draw measure overlay ────────────────────────────────────────────────── + useEffect(() => { + if (!onDrawOverlay || !onRemoveOverlay) return + if (!measuring || points.length === 0) { onRemoveOverlay(MEASURE_OVERLAY_ID); return } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const features: any[] = points.map((p) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, + properties: {}, + })) + if (segment) { + features.push({ + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [[segment[0].lng, segment[0].lat], [segment[1].lng, segment[1].lat]], + }, + properties: {}, + }) + } + onDrawOverlay({ id: MEASURE_OVERLAY_ID, geojson: { type: 'FeatureCollection', features }, style: MEASURE_STYLE }) + // mouseLatLng intentionally in deps — redraws preview line on every mouse move + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [measuring, points, mouseLatLng, onDrawOverlay, onRemoveOverlay]) + + // ── Cleanup overlay on unmount ───────────────────────────────────────────── + useEffect(() => () => { onRemoveOverlay?.(MEASURE_OVERLAY_ID) }, [onRemoveOverlay]) + + // ── Project segment midpoint → pixel for distance label ─────────────────── + useEffect(() => { + if (!segment || !onProjectLatLng) { setLabelPixel(null); return } + const mid = { lat: (segment[0].lat + segment[1].lat) / 2, lng: (segment[0].lng + segment[1].lng) / 2 } + let cancelled = false + Promise.resolve(onProjectLatLng(mid)).then((pt) => { + if (!cancelled && pt?.x != null && pt?.y != null) setLabelPixel({ x: pt.x, y: pt.y }) + }) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [segment?.[0]?.lat, segment?.[0]?.lng, segment?.[1]?.lat, segment?.[1]?.lng, onProjectLatLng]) + + // ── Handlers ────────────────────────────────────────────────────────────── + + function toggleBasemap() { setBasemapOpen((v) => !v); setSearchOpen(false) } + + function toggleSearch() { setSearchOpen((v) => !v); setBasemapOpen(false) } + + function toggleMeasure() { + if (measuring) { + setMeasuring(false); setPoints([]); setMouseLatLng(null); setLabelPixel(null) + } else { + setMeasuring(true); setPoints([]); setBasemapOpen(false); setSearchOpen(false) + } + } + + function handleSearchSelect(result: GeocodeResult) { + setSearchOpen(false) + onSearchSelect?.(result) + } + + // ── Render ──────────────────────────────────────────────────────────────── + + const current = activeBasemap ?? (basemapStyles[0] ?? null) + const hasStyles = basemapStyles.length > 0 + const hasZoom = Boolean(onZoomIn && onZoomOut) + + return ( + <> +
+ {/* ── Toolbar (each feature is its own card) ── */} +
+ {onSearchSelect && ( +
+ +
+ )} + {hasStyles && ( +
+ +
+ )} + {measureSupported && ( +
+ +
+ )} + {hasZoom && ( +
+ + + +
+ )} +
+ + {/* ── Basemap panel ── */} + {basemapOpen && hasStyles && ( +
+
Basemap style
+ {basemapStyles.map((entry) => { + const isActive = current?.name === entry.name + return ( + + ) + })} +
+ )} + + {/* ── Search panel ── */} + {searchOpen && ( +
+
+ setSearchQuery(e.target.value)} + /> +
+ {searchLoading &&
Searching…
} + {!searchLoading && searchQuery.length >= 2 && searchResults.length === 0 && ( +
No results
+ )} + {searchResults.map((r) => ( + + ))} +
+ )} + + {/* ── Measure hint ── */} + {measuring && points.length === 0 && ( +
Click two points on the map
+ )} +
+ + {/* ── Distance label (positioned over map canvas) ── */} + {measuring && segment && labelPixel && ( +
+ {formatDistance(measureDistance(segment[0], segment[1]))} +
+ )} + + ) +} + +// ─── Inline SVG icons (no icon-font dependency) ──────────────────────────── + +function Icon({ children }: { children: React.ReactNode }) { + return ( + + ) +} +function BasemapIcon() { return } +function SearchIcon() { return } +function RulerIcon() { return } +function PlusIcon() { return } +function MinusIcon() { return } + +export default MapControlComponent diff --git a/src/essence/Tools/MapControl/MapControlTool.js b/src/essence/Tools/MapControl/MapControlTool.js index a00c91533..252653ed8 100644 --- a/src/essence/Tools/MapControl/MapControlTool.js +++ b/src/essence/Tools/MapControl/MapControlTool.js @@ -1,151 +1,292 @@ -import React, { useState, useEffect, useCallback } from 'react' -import ReactDOM from 'react-dom' -import MapControl from './MapControl' +/** + * MapControlTool — MMGIS plugin wrapper for MapControlComponent. + * + * Bus channels consumed (request): + * map:getBasemapStyles map:getBasemap map:setBasemap + * map:zoomIn map:zoomOut + * map:addOverlay map:removeOverlay + * map:fitBounds map:latLngToContainerPoint + * + * Bus channels consumed (on / subscribe): + * map:click map:mousemove + */ +import React, { useState, useEffect, useRef, useCallback } from 'react' +import { createRoot } from 'react-dom/client' +import { MapControlComponent } from './MapControlComponent' -const ROOT_ID = 'mmgis-map-control-root' -const ROOT_STYLE = - 'position:absolute;inset:0;pointer-events:none;z-index:1002;' +const ROOT_ID = 'mmgis-map-control-root' +// position:fixed anchors to the viewport regardless of which UI layout is +// active (Modern UI's #modern-content is emptied on re-render; Default UI uses +// #mapScreen). Mounting into document.body is the simplest, safest anchor. +const ROOT_STYLE = 'position:fixed;inset:0;pointer-events:none;z-index:1002;' const TOP_BAR_ID = 'topBarRight' -const MAP_SCREEN_ID = 'mapScreen' +const SEARCH_OVERLAY_ID = 'plugin:mapcontrol:search' + +// Set cursor on the actual Leaflet map canvas. +const MAP_CURSOR_IDS = ['mapScreen', 'map'] +function _getMapCursorEl() { + for (const id of MAP_CURSOR_IDS) { + const el = document.getElementById(id) + if (el) return el + } + return null +} + +// Poll for app readiness — any of these elements means the map is alive. +const APP_READY_IDS = ['mapScreen', 'modern-content', 'map'] +function _isAppReady() { + return APP_READY_IDS.some((id) => document.getElementById(id) !== null) +} + +// ─── Top-bar offset hook ────────────────────────────────────────────────────── +// Measures #topBarRight so the bar never overlaps the top-right chrome. function useTopBarOffset() { const [offset, setOffset] = useState(12) - useEffect(() => { function measure() { const el = document.getElementById(TOP_BAR_ID) - if (!el) { - setOffset(12) - return - } - const w = el.getBoundingClientRect().width + const w = el ? el.getBoundingClientRect().width : 0 setOffset(w > 0 ? Math.ceil(w) + 16 : 12) } measure() const t = setTimeout(measure, 300) window.addEventListener('resize', measure) - return () => { - clearTimeout(t) - window.removeEventListener('resize', measure) - } + return () => { clearTimeout(t); window.removeEventListener('resize', measure) } }, []) - return offset } +// ─── ConnectedMapControl ────────────────────────────────────────────────────── +// Translates MMGIS event-bus calls into plain props for MapControlComponent. + +// Default feature flags — all on. Overridden by mission tool variables. +const DEFAULT_FEATURES = { + showBasemapSwitcher: true, + showSearch: true, + showMeasure: true, + showZoom: true, +} + +function isFalsy(v) { + return v === false || v === 'false' || v === 0 || v === '0' +} + +function useToolFeatures() { + const [features, setFeatures] = useState(DEFAULT_FEATURES) + useEffect(() => { + let cancelled = false + let attempts = 0 + const MAX = 20 + + function tryFetch() { + if (cancelled || attempts >= MAX) return + attempts++ + const api = window.mmgisAPI + if (!api) { setTimeout(tryFetch, 300); return } + // getToolVars lowercases the name internally + api.request('tool:getVars', 'mapcontrol') + .then((vars) => { + if (cancelled) return + // __noVars means the tool has no saved variables — keep defaults + if (!vars || vars.__noVars) return + setFeatures({ + showBasemapSwitcher: !isFalsy(vars.showBasemapSwitcher), + showSearch: !isFalsy(vars.showSearch), + showMeasure: !isFalsy(vars.showMeasure), + showZoom: !isFalsy(vars.showZoom), + }) + }) + .catch(() => { + if (!cancelled) setTimeout(tryFetch, 300) + }) + } + + tryFetch() + return () => { cancelled = true } + }, []) + return features +} + function ConnectedMapControl() { - const [styles, setStyles] = useState([]) - const [active, setActive] = useState(null) + const [basemapStyles, setBasemapStyles] = useState([]) + const [activeBasemap, setActiveBasemap] = useState(null) const rightOffset = useTopBarOffset() + const features = useToolFeatures() useEffect(() => { - const api = window.mmgisAPI - if (!api) return - Promise.all([api.getBasemapStyles(), api.getBasemap()]).then( - ([s, a]) => { - setStyles(s || []) - setActive(a || (s && s[0]) || null) - } - ) - }, []) + let cancelled = false + let attempts = 0 + const MAX_ATTEMPTS = 20 // 20 × 300 ms = 6 s max - const onSelectBasemap = useCallback((entry) => { - setActive(entry) - const api = window.mmgisAPI - if (api) api.setBasemap(entry.name) - }, []) + function tryFetch() { + if (cancelled || attempts >= MAX_ATTEMPTS) return + attempts++ + const api = window.mmgisAPI + if (!api) { setTimeout(tryFetch, 300); return } + Promise.all([ + api.request('map:getBasemapStyles'), + api.request('map:getBasemap'), + ]).then(([styles, active]) => { + if (cancelled) return + const s = styles || [] + // Handler not yet registered or map not ready — retry + if (s.length === 0) { setTimeout(tryFetch, 300); return } + setBasemapStyles(s) + setActiveBasemap(active || s[0] || null) + }).catch(() => { + if (!cancelled) setTimeout(tryFetch, 300) + }) + } - const onZoomIn = useCallback(() => { - const api = window.mmgisAPI - if (api) api.zoomIn() + tryFetch() + return () => { cancelled = true } }, []) - const onZoomOut = useCallback(() => { - const api = window.mmgisAPI - if (api) api.zoomOut() + const onSelectBasemap = useCallback((style) => { + setActiveBasemap(style) + window.mmgisAPI?.request('map:setBasemap', style.name) }, []) + const onZoomIn = useCallback(() => { window.mmgisAPI?.request('map:zoomIn') }, []) + const onZoomOut = useCallback(() => { window.mmgisAPI?.request('map:zoomOut') }, []) + const subscribeToMap = useCallback(({ onClick, onMouseMove }) => { const api = window.mmgisAPI - if (!api || !api.on) return () => {} - const offClick = api.on('map:click', onClick) - const offMove = api.on('map:mousemove', onMouseMove) + if (!api?.on) return () => {} + const offClick = api.on('map:click', onClick) + const offMove = api.on('map:mousemove', onMouseMove) return () => { if (typeof offClick === 'function') offClick() - if (typeof offMove === 'function') offMove() + if (typeof offMove === 'function') offMove() } }, []) - const drawOverlay = useCallback((opts) => { - const api = window.mmgisAPI - if (api) api.addOverlay(opts) + // Guard: only call map:removeOverlay if the overlay was actually drawn. + const overlayExistsRef = useRef(false) + const onDrawOverlay = useCallback((opts) => { + overlayExistsRef.current = true + window.mmgisAPI?.request('map:addOverlay', opts) + }, []) + const onRemoveOverlay = useCallback((id) => { + if (!overlayExistsRef.current) return + overlayExistsRef.current = false + window.mmgisAPI?.request('map:removeOverlay', id).catch(() => {}) }, []) - const removeOverlay = useCallback((id) => { - const api = window.mmgisAPI - if (api) api.removeOverlay(id) + const onProjectLatLng = useCallback((latlng) => { + if (!window.mmgisAPI) return Promise.resolve(null) + return window.mmgisAPI.request('map:latLngToContainerPoint', latlng).catch(() => null) }, []) - const projectLatLng = useCallback((latlng) => { - const api = window.mmgisAPI - if (!api) return null - return api.latLngToContainerPoint(latlng) + const onSetCursor = useCallback((cursor) => { + const el = _getMapCursorEl() + if (el) el.style.cursor = cursor }, []) - const setMapCursor = useCallback((cursor) => { - const screen = document.getElementById(MAP_SCREEN_ID) - if (screen) screen.style.cursor = cursor + const onSearchSelect = useCallback((result) => { + const api = window.mmgisAPI + if (!api) return + // Nominatim bbox: [min_lat, max_lat, min_lon, max_lon] + // map:fitBounds expects [[south, west], [north, east]] + api.request('map:fitBounds', [ + [result.bbox[0], result.bbox[2]], + [result.bbox[1], result.bbox[3]], + ]) + api.request('map:addOverlay', { + id: SEARCH_OVERLAY_ID, + geojson: { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { type: 'Point', coordinates: [result.lng, result.lat] }, + properties: {}, + }], + }, + style: { color: '#005ea2', fillColor: '#005ea2', fillOpacity: 0.85, radius: 7, weight: 2 }, + }) }, []) return ( - ) } +// ─── InterfaceWithMMGIS ─────────────────────────────────────────────────────── +// Mounts the React overlay into document.body with position:fixed so it is +// immune to any parent container being emptied or repositioned. + function InterfaceWithMMGIS() { - const mapScreen = document.getElementById(MAP_SCREEN_ID) - if (!mapScreen) { - this.separateFromMMGIS = () => {} - return - } + document.getElementById(ROOT_ID)?.remove() const container = document.createElement('div') container.id = ROOT_ID container.style.cssText = ROOT_STYLE - mapScreen.appendChild(container) + document.body.appendChild(container) - ReactDOM.render(, container) + const root = createRoot(container) + root.render() this.separateFromMMGIS = () => { - ReactDOM.unmountComponentAtNode(container) + root.unmount() container.remove() } } +// ─── MapControlTool (MMGIS plugin contract) ─────────────────────────────────── + const MapControlTool = { height: 0, width: 0, MMGISInterface: null, + made: false, + make() { if (this.MMGISInterface) return this.MMGISInterface = new InterfaceWithMMGIS() + this.made = true }, + destroy() { if (this.MMGISInterface) this.MMGISInterface.separateFromMMGIS() this.MMGISInterface = null + this.made = false }, + + getUrlString() { return '' }, +} + +// ─── Module-level auto-mount ────────────────────────────────────────────────── +// src/pre/tools.js eagerly imports every tool at app start (before any DOM +// exists). Poll until the map is alive (#mapScreen / #modern-content / #map), +// then mount. The MMGISInterface guard prevents double-mounting if make() is +// later called by the panel system. + +function _tryAutoMount() { + if (MapControlTool.MMGISInterface) return true + if (!_isAppReady()) return false + MapControlTool.make() + return true +} + +if (typeof window !== 'undefined') { + if (!_tryAutoMount()) { + const _id = setInterval(() => { if (_tryAutoMount()) clearInterval(_id) }, 100) + setTimeout(() => clearInterval(_id), 15000) + } } export default MapControlTool diff --git a/src/essence/Tools/MapControl/config.json b/src/essence/Tools/MapControl/config.json index 4eb776f2d..80fe61133 100644 --- a/src/essence/Tools/MapControl/config.json +++ b/src/essence/Tools/MapControl/config.json @@ -1,14 +1,54 @@ { "defaultIcon": "layers", - "description": "Map controls: basemap style, zoom in, zoom out.", + "description": "Floating map control bar: basemap switcher, search, measure, zoom.", "descriptionFull": { - "title": "A horizontal map overlay control for switching between basemap styles and zooming in/out. Add this tool to a mission to show a floating map control bar." + "title": "A floating horizontal toolbar rendered over the map. Each of the four features can be enabled or disabled independently." }, - "hasVars": false, + "hasVars": true, "name": "MapControl", "toolbarPriority": 5, "separatedTool": true, "paths": { "MapControlTool": "essence/Tools/MapControl/MapControlTool" + }, + "config": { + "rows": [ + { + "components": [ + { + "field": "variables.showBasemapSwitcher", + "name": "Basemap switcher", + "description": "Show the basemap style selector button.", + "type": "checkbox", + "width": 3, + "defaultChecked": true + }, + { + "field": "variables.showSearch", + "name": "Search", + "description": "Show the geocode search button.", + "type": "checkbox", + "width": 3, + "defaultChecked": true + }, + { + "field": "variables.showMeasure", + "name": "Measure", + "description": "Show the distance measure button.", + "type": "checkbox", + "width": 3, + "defaultChecked": true + }, + { + "field": "variables.showZoom", + "name": "Zoom", + "description": "Show the zoom in / zoom out buttons.", + "type": "checkbox", + "width": 3, + "defaultChecked": true + } + ] + } + ] } } diff --git a/src/essence/Tools/MapControl/mapControlHelpers.ts b/src/essence/Tools/MapControl/mapControlHelpers.ts new file mode 100644 index 000000000..2ab386c74 --- /dev/null +++ b/src/essence/Tools/MapControl/mapControlHelpers.ts @@ -0,0 +1,95 @@ +import { distance, point } from '@turf/turf' + +export type LatLng = { lat: number; lng: number } +export type BasemapStyle = { name: string } +export type GeocodeResult = { + id: string + displayName: string + lat: number + lng: number + // Nominatim boundingbox: [min_lat, max_lat, min_lon, max_lon] + bbox: [number, number, number, number] +} +export type MapOverlayOpts = { + id: string + geojson: object + style: object +} +export type MapSubscribeHandlers = { + onClick: (e: LatLng) => void + onMouseMove: (e: LatLng) => void +} + +export const MEASURE_STYLE = { + color: '#1b1b1b', + weight: 2, + opacity: 1, + dashArray: '2 6', + lineCap: 'round', + radius: 4, + fillColor: '#1b1b1b', + fillOpacity: 1, +} + +const BASEMAP_GRADIENTS: Record = { + streets: 'linear-gradient(135deg,#f0ebe0,#ddd0b8,#bca888)', + satellite:'linear-gradient(135deg,#0c1f2e,#163b20,#0a2e0a)', + outdoors: 'linear-gradient(135deg,#e0f5c8,#88c458,#4a8030)', + light: 'linear-gradient(135deg,#fff,#ebebeb,#d4d4d4)', + dark: 'linear-gradient(135deg,#2c2c3a,#1a1a28,#0c0c18)', + terrain: 'linear-gradient(135deg,#d4edbc,#6ab040,#3d6e28)', + liberty: 'linear-gradient(135deg,#f5e6ca,#d4a853,#8b7355)', + bright: 'linear-gradient(135deg,#dff0fb,#93cce8,#4a9ec4)', + positron: 'linear-gradient(135deg,#f8f8f8,#e0e0e0,#b8b8b8)', +} +const DEFAULT_BG = 'linear-gradient(135deg,#4a90d9,#2a6db8,#1450a0)' + +export function basemapGradient(name: string): string { + const k = (name || '').toLowerCase() + const entry = Object.entries(BASEMAP_GRADIENTS).find(([key]) => k.includes(key)) + return entry ? entry[1] : DEFAULT_BG +} + +export function measureDistance(a: LatLng, b: LatLng): number { + return distance( + point([a.lng, a.lat]), + point([b.lng, b.lat]), + { units: 'meters' } + ) +} + +export function formatDistance(meters: number): string { + if (meters < 1000) { + const ft = meters * 3.28084 + return `${ft.toFixed(0)} ft (${meters.toFixed(1)} m)` + } + const mi = meters * 0.000621371 + const km = meters / 1000 + return `${mi.toFixed(2)} mi (${km.toFixed(2)} km)` +} + +export async function geocodeSearch(query: string): Promise { + const q = query.trim() + if (q.length < 2) return [] + const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q)}&limit=5` + try { + const res = await fetch(url, { headers: { 'Accept-Language': 'en' } }) + if (!res.ok) return [] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: any[] = await res.json() + return data.map((item) => ({ + id: String(item.place_id), + displayName: item.display_name as string, + lat: parseFloat(item.lat), + lng: parseFloat(item.lon), + bbox: [ + parseFloat(item.boundingbox[0]), + parseFloat(item.boundingbox[1]), + parseFloat(item.boundingbox[2]), + parseFloat(item.boundingbox[3]), + ] as [number, number, number, number], + })) + } catch { + return [] + } +} From 589012e26b4ac2183f28ba3dc85848fbcb39a6fa Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 8 Jun 2026 10:45:06 -0500 Subject: [PATCH 19/21] removed duplicate addoverlay methods --- src/essence/Basics/Map_/Map_.js | 42 ------------------- .../Tools/MapControl/MapControlTool.js | 24 ++++++++--- src/essence/mmgisAPI/mmgisAPI.js | 22 ---------- 3 files changed, 18 insertions(+), 70 deletions(-) diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 7204e6f89..4b46c7d6f 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -53,20 +53,6 @@ let _providerCleanups = [] let _basemapStyles = [] let _basemapActiveIndex = 0 -const _overlayIds = new Set() - -const _OVERLAY_STYLE_KEYS = [ - 'color', 'weight', 'opacity', - 'fillColor', 'fillOpacity', - 'radius', 'dashArray', 'lineCap', 'lineJoin', -] -function _pickStyle(style) { - if (!style || typeof style !== 'object') return {} - const out = {} - for (const k of _OVERLAY_STYLE_KEYS) if (style[k] != null) out[k] = style[k] - return out -} - let _mapClickHandler = null let _mapMouseMoveHandler = null @@ -342,34 +328,6 @@ let Map_ = { Map_.engine.setZoom(next) return true }), - window.mmgisAPI.provide('map:addOverlay', ({ id, geojson, style } = {}) => { - if (!Map_.engine || !id || !geojson) return false - if (_overlayIds.has(id)) { - Map_.engine.removeLayer(id) - _overlayIds.delete(id) - } - Map_.engine.createLayer({ - id, - type: 'vector', - geojson, - style: _pickStyle(style), - interactive: false, - }) - _overlayIds.add(id) - return true - }), - window.mmgisAPI.provide('map:removeOverlay', (id) => { - if (!Map_.engine || !_overlayIds.has(id)) return false - Map_.engine.removeLayer(id) - _overlayIds.delete(id) - return true - }), - window.mmgisAPI.provide('map:clearOverlays', () => { - if (!Map_.engine) return false - _overlayIds.forEach((id) => Map_.engine.removeLayer(id)) - _overlayIds.clear() - return true - }), window.mmgisAPI.provide('map:latLngToContainerPoint', (latlng) => { if (!Map_.engine || typeof Map_.engine.latLngToContainerPoint !== 'function') { return null diff --git a/src/essence/Tools/MapControl/MapControlTool.js b/src/essence/Tools/MapControl/MapControlTool.js index 252653ed8..f4ff35558 100644 --- a/src/essence/Tools/MapControl/MapControlTool.js +++ b/src/essence/Tools/MapControl/MapControlTool.js @@ -4,8 +4,8 @@ * Bus channels consumed (request): * map:getBasemapStyles map:getBasemap map:setBasemap * map:zoomIn map:zoomOut - * map:addOverlay map:removeOverlay * map:fitBounds map:latLngToContainerPoint + * map:createLayer map:removeLayer (provided by PR #90 imapengine-drawing) * * Bus channels consumed (on / subscribe): * map:click map:mousemove @@ -162,16 +162,26 @@ function ConnectedMapControl() { } }, []) - // Guard: only call map:removeOverlay if the overlay was actually drawn. + // Vector overlays go through map:createLayer / map:removeLayer (provided by + // the imapengine-drawing PR #90). We render ephemeral vector geometry, so we + // use the generic layer CRUD rather than #90's map:addOverlay, which is a + // DOM-element-at-a-point overlay with a different contract. + // Guard: only call removeLayer if the layer was actually drawn. const overlayExistsRef = useRef(false) - const onDrawOverlay = useCallback((opts) => { + const onDrawOverlay = useCallback(({ id, geojson, style }) => { overlayExistsRef.current = true - window.mmgisAPI?.request('map:addOverlay', opts) + window.mmgisAPI?.request('map:createLayer', { + id, + type: 'vector', + geojson, + style, + interactive: false, + }) }, []) const onRemoveOverlay = useCallback((id) => { if (!overlayExistsRef.current) return overlayExistsRef.current = false - window.mmgisAPI?.request('map:removeOverlay', id).catch(() => {}) + window.mmgisAPI?.request('map:removeLayer', { id }).catch(() => {}) }, []) const onProjectLatLng = useCallback((latlng) => { @@ -193,8 +203,10 @@ function ConnectedMapControl() { [result.bbox[0], result.bbox[2]], [result.bbox[1], result.bbox[3]], ]) - api.request('map:addOverlay', { + api.request('map:createLayer', { id: SEARCH_OVERLAY_ID, + type: 'vector', + interactive: false, geojson: { type: 'FeatureCollection', features: [{ diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index 7a64a3238..9d907c441 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -778,28 +778,6 @@ var mmgisAPI = { */ zoomOut: () => mmgisAPI.request('map:zoomOut'), - /** addOverlay - draws an ephemeral GeoJSON overlay on the map, keyed by id. - * Calling again with the same id replaces the previous overlay. These overlays - * are not persisted and do not appear in the Layers tool. - * @param {object} options - * @param {string} options.id - unique id, convention: 'plugin::' - * @param {object} options.geojson - a GeoJSON Feature or FeatureCollection - * @param {object} [options.style] - Leaflet path style (color, weight, opacity, fillColor, fillOpacity, radius, dashArray, lineCap, lineJoin) - * @returns {Promise} - */ - addOverlay: (options) => mmgisAPI.request('map:addOverlay', options), - - /** removeOverlay - removes an overlay previously added with addOverlay. - * @param {string} id - * @returns {Promise} - true if removed, false if no overlay with that id - */ - removeOverlay: (id) => mmgisAPI.request('map:removeOverlay', id), - - /** clearOverlays - removes all overlays added with addOverlay. - * @returns {Promise} - */ - clearOverlays: () => mmgisAPI.request('map:clearOverlays'), - /** latLngToContainerPoint - project a {lat, lng} to pixel coordinates * relative to the map container. Useful for positioning DOM overlays. * @param {{lat: number, lng: number}} latlng From 0d1a8c911856525eecb75c91b4c6e4c0222260d6 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 8 Jun 2026 10:59:13 -0500 Subject: [PATCH 20/21] clean up --- .../Modals/NewMissionModal/NewMissionModal.js | 2 +- .../MapEngines/Adapters/DeckGLHelpers.ts | 141 ++++++++---------- src/essence/Basics/Map_/Map_.js | 53 +++---- .../Tools/MapControl/MapControlTool.js | 16 +- 4 files changed, 92 insertions(+), 120 deletions(-) diff --git a/configure/src/components/Panel/Modals/NewMissionModal/NewMissionModal.js b/configure/src/components/Panel/Modals/NewMissionModal/NewMissionModal.js index 360c14c6c..860d79bda 100644 --- a/configure/src/components/Panel/Modals/NewMissionModal/NewMissionModal.js +++ b/configure/src/components/Panel/Modals/NewMissionModal/NewMissionModal.js @@ -118,7 +118,7 @@ const MAP_ENGINES = [ const MODAL_NAME = "newMission"; const NewMissionModal = (props) => { - const { } = props; + const {} = props; const c = useStyles(); const modal = useSelector((state) => state.core.modal[MODAL_NAME]); diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts index d09d2b455..c3f61ea6b 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts @@ -191,29 +191,6 @@ function getPropValue(object: unknown, path: string | undefined): unknown { * * @throws {Error} If `options.type` is not a supported layer type. */ -function _toRgba( - input: unknown, - fallback: [number, number, number, number] -): [number, number, number, number] { - if (Array.isArray(input) && input.length >= 3) { - const [r, g, b, a] = input as number[] - return [r, g, b, a ?? 255] - } - if (typeof input === 'string') { - let s = input.trim() - if (s.startsWith('#')) s = s.slice(1) - if (s.length === 3) s = s.split('').map((c) => c + c).join('') - if (s.length === 6 || s.length === 8) { - const r = parseInt(s.slice(0, 2), 16) - const g = parseInt(s.slice(2, 4), 16) - const b = parseInt(s.slice(4, 6), 16) - const a = s.length === 8 ? parseInt(s.slice(6, 8), 16) : 255 - if (!isNaN(r) && !isNaN(g) && !isNaN(b)) return [r, g, b, a] - } - } - return fallback -} - export function buildDeckLayer(id: string, options: LayerOptions): Layer { const resolvedType = DECKGL_TYPE_ALIAS[options.type ?? ''] ?? options.type switch (resolvedType) { @@ -231,11 +208,11 @@ export function buildDeckLayer(id: string, options: LayerOptions): Layer { const bbox = (props.tile as { bbox: { west: number; south: number; east: number; north: number } }).bbox const bounds = Number.isFinite(tileElevation) ? [ - [bbox.west, bbox.south, tileElevation], - [bbox.west, bbox.north, tileElevation], - [bbox.east, bbox.north, tileElevation], - [bbox.east, bbox.south, tileElevation], - ] + [bbox.west, bbox.south, tileElevation], + [bbox.west, bbox.north, tileElevation], + [bbox.east, bbox.north, tileElevation], + [bbox.east, bbox.south, tileElevation], + ] : [bbox.west, bbox.south, bbox.east, bbox.north] return new BitmapLayer({ ...(props as object), @@ -278,59 +255,59 @@ export function buildDeckLayer(id: string, options: LayerOptions): Layer { const getFillColor = fillColorProp || fillOpacityProp ? (feature: Record) => { - const hexVal = fillColorProp - ? (getPropValue(feature, fillColorProp) as string | undefined) - : undefined - const alphaVal = fillOpacityProp - ? getPropValue(feature, fillOpacityProp) - : undefined - if (hexVal === undefined && alphaVal === undefined) return staticFillColor - return hexToRgba( - hexVal ?? (style.fillColor as string | undefined), - alphaVal !== undefined - ? Number(alphaVal) - : style.fillOpacity !== undefined + const hexVal = fillColorProp + ? (getPropValue(feature, fillColorProp) as string | undefined) + : undefined + const alphaVal = fillOpacityProp + ? getPropValue(feature, fillOpacityProp) + : undefined + if (hexVal === undefined && alphaVal === undefined) return staticFillColor + return hexToRgba( + hexVal ?? (style.fillColor as string | undefined), + alphaVal !== undefined + ? Number(alphaVal) + : style.fillOpacity !== undefined ? Number(style.fillOpacity) : 0.8, - staticFillColor - ) - } + staticFillColor + ) + } : staticFillColor const getLineColor = colorProp || opacityProp ? (feature: Record) => { - const hexVal = colorProp - ? (getPropValue(feature, colorProp) as string | undefined) - : undefined - const alphaVal = opacityProp - ? getPropValue(feature, opacityProp) - : undefined - if (hexVal === undefined && alphaVal === undefined) return staticLineColor - return hexToRgba( - hexVal ?? (style.color as string | undefined), - alphaVal !== undefined - ? Number(alphaVal) - : style.opacity !== undefined + const hexVal = colorProp + ? (getPropValue(feature, colorProp) as string | undefined) + : undefined + const alphaVal = opacityProp + ? getPropValue(feature, opacityProp) + : undefined + if (hexVal === undefined && alphaVal === undefined) return staticLineColor + return hexToRgba( + hexVal ?? (style.color as string | undefined), + alphaVal !== undefined + ? Number(alphaVal) + : style.opacity !== undefined ? Number(style.opacity) : 1, - staticLineColor - ) - } + staticLineColor + ) + } : staticLineColor const getLineWidth = weightProp ? (feature: Record) => { - const v = getPropValue(feature, weightProp) - return v !== undefined ? Number(v) : staticLineWidth - } + const v = getPropValue(feature, weightProp) + return v !== undefined ? Number(v) : staticLineWidth + } : staticLineWidth const getPointRadius = radiusProp ? (feature: Record) => { - const v = getPropValue(feature, radiusProp) - return v !== undefined ? Number(v) : staticPointRadius - } + const v = getPropValue(feature, radiusProp) + return v !== undefined ? Number(v) : staticPointRadius + } : staticPointRadius const markerIcon = o.variables?.markerIcon @@ -356,16 +333,16 @@ export function buildDeckLayer(id: string, options: LayerOptions): Layer { pickable: o.interactive ?? true, ...(iconUrl ? { - getIcon: () => ({ - url: iconUrl, - width: iconW, - height: iconH, - anchorX, - anchorY, - }), - getIconSize: iconH, - iconSizeUnits: 'pixels' as const, - } + getIcon: () => ({ + url: iconUrl, + width: iconW, + height: iconH, + anchorX, + anchorY, + }), + getIconSize: iconH, + iconSizeUnits: 'pixels' as const, + } : {}), ...(o.nativeOptions ?? {}), } as ConstructorParameters[0]) as unknown as Layer @@ -411,9 +388,9 @@ export function buildDeckLayer(id: string, options: LayerOptions): Layer { : {} const data = o.data && - typeof o.data === 'object' && - !Array.isArray(o.data) && - (o.data as { type?: unknown }).type === 'FeatureCollection' + typeof o.data === 'object' && + !Array.isArray(o.data) && + (o.data as { type?: unknown }).type === 'FeatureCollection' ? (o.data as { features?: unknown[] }).features : o.data const getPosition = (object: unknown): [number, number] | [number, number, number] => { @@ -443,9 +420,9 @@ export function buildDeckLayer(id: string, options: LayerOptions): Layer { getPosition, getRadius: radiusProp ? (object: unknown) => { - const value = getPropValue(object, radiusProp) - return value !== undefined ? Number(value) : staticRadius - } + const value = getPropValue(object, radiusProp) + return value !== undefined ? Number(value) : staticRadius + } : staticRadius, getFillColor: hexToRgba( style.fillColor as string | undefined, @@ -497,8 +474,8 @@ export function buildDeckLayer(id: string, options: LayerOptions): Layer { default: throw new Error( `buildDeckLayer: unsupported layer type "${options.type}"` + - (resolvedType !== options.type ? ` (resolved to "${resolvedType}")` : '') + - `. Supported types: 'tile', 'vector', 'vectortile', 'scatterplot', 'tile3d', 'pointcloud'.` + (resolvedType !== options.type ? ` (resolved to "${resolvedType}")` : '') + + `. Supported types: 'tile', 'vector', 'vectortile', 'scatterplot', 'tile3d', 'pointcloud'.` ) } } diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 4b46c7d6f..1674ff58a 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -20,6 +20,7 @@ import { Kinds } from '../../../pre/tools' import DataShaders from '../../Ancillary/DataShaders' import calls from '../../../pre/calls' import TimeControl from '../TimeControl_/TimeControl' + import gjv from 'geojson-validation' import { evaluate_cmap, @@ -36,7 +37,7 @@ import { buildDeckLayer } from '../MapEngines/Adapters/DeckGLHelpers' let L = window.L -let essenceFina = function () { } +let essenceFina = function () {} mapEngineRegistry.register(MAP_ENGINE.LEAFLET, LeafletAdapter) mapEngineRegistry.register(MAP_ENGINE.DECKGL, DeckGLAdapter) @@ -79,14 +80,13 @@ function _resolveBasemapStyles(basemapConfig, engineType) { { name: 'Terrain', style: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png' }, ] - const mapboxDefaults = MAPBOX_DEFAULTS const maplibreDefaults = isLeaflet ? MAPLIBRE_DEFAULTS_LEAFLET : MAPLIBRE_DEFAULTS_DECKGL const styles = basemapConfig.styles && basemapConfig.styles.length > 0 ? [...basemapConfig.styles] : basemapConfig.provider === 'mapbox' - ? [...mapboxDefaults] + ? [...MAPBOX_DEFAULTS] : [...maplibreDefaults] return styles @@ -632,10 +632,10 @@ let Map_ = { ) { L_.layers.layer[L_._layersOrdered[hasIndex[i]]].setZIndex( L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf( - L_._layersOrdered[hasIndex[i]] - ) + 1 - + L_._layersOrdered.indexOf( + L_._layersOrdered[hasIndex[i]] + ) ) L_.layers.layer[L_._layersOrdered[hasIndex[i]]].clearCache() L_.layers.layer[L_._layersOrdered[hasIndex[i]]].redraw() @@ -649,10 +649,10 @@ let Map_ = { for (let i = 0; i < hasIndexRaster.length; i++) { L_.layers.layer[L_._layersOrdered[hasIndexRaster[i]]].setZIndex( L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf( - L_._layersOrdered[hasIndexRaster[i]] - ) + 1 - + L_._layersOrdered.indexOf( + L_._layersOrdered[hasIndexRaster[i]] + ) ) } @@ -665,7 +665,7 @@ let Map_ = { L_.layers.layer[key].forEach((l) => { try { l.bringToFront() - } catch (err) { } + } catch (err) {} }) } }) @@ -679,7 +679,7 @@ let Map_ = { // If it's a dynamic extent layer, just re-call its function if ( L_._onSpecificLayerToggleSubscriptions[ - `dynamicextent_${layerObj.name}` + `dynamicextent_${layerObj.name}` ] != null ) { if (L_.layers.on[layerObj.name]) @@ -834,9 +834,9 @@ let Map_ = { const zoom = Map_.map.getZoom() const min = Map_.map - .project(bounds.getNorthWest(), zoom) - .divideBy(256) - .floor(), + .project(bounds.getNorthWest(), zoom) + .divideBy(256) + .floor(), max = Map_.map .project(bounds.getSouthEast(), zoom) .divideBy(256) @@ -1245,7 +1245,7 @@ async function makeVectorLayer( if (existingLayer != null && existingLayer !== false) { console.warn( `[${new Date().toISOString()}] Refresh failed for ${layerObj.display_name}, ` + - `keeping existing layer. Next refresh in ${layerObj.time?.refreshIntervalAmount || 60}s` + `keeping existing layer. Next refresh in ${layerObj.time?.refreshIntervalAmount || 60}s` ) // Mark layer as having a failed refresh ctx.layerRegistry.refreshFailed[layerObj.name] = true @@ -1474,7 +1474,7 @@ async function makeVelocityLayer( position: layerObj.variables?.streamlines ?.displayPosition ? layerObj.variables?.streamlines - ?.displayPosition + ?.displayPosition : 'bottomleft', emptyString: '', }, @@ -1507,7 +1507,7 @@ async function makeVelocityLayer( : 15, colorScale: colorScale, }) - velocityLayer.setZIndex = function () { } + velocityLayer.setZIndex = function () {} L_.layers.layer[layerObj.name] = velocityLayer } else if (layerObj.kind == 'particles') { let points = [] @@ -1543,7 +1543,7 @@ async function makeVelocityLayer( : 'Oxa6b3e9', } let rainLayer = L.rain(points, options) - rainLayer.setZIndex = function () { } + rainLayer.setZIndex = function () {} L_.layers.layer[layerObj.name] = rainLayer } L_._layersLoaded[L_._layersOrdered.indexOf(layerObj.name)] = @@ -1814,7 +1814,8 @@ function makeVectorTileLayer(layerObj, mapContext = null) { if (urlSplit[0].toLowerCase() === 'geodatasets' && urlSplit[1] != null) { layerUrl = - `${window.mmgisglobal.ROOT_PATH || ''}/api/geodatasets/get?layer=${urlSplit[1] + `${window.mmgisglobal.ROOT_PATH || ''}/api/geodatasets/get?layer=${ + urlSplit[1] }` + '&type=mvt&x={x}&y={y}&z={z}' } @@ -1974,7 +1975,7 @@ function makeVectorTileLayer(layerObj, mapContext = null) { e.layer._renderer._features[i].feature._pxBounds.max .y >= p.y && e.layer._renderer._features[i].feature.properties[ - vtId + vtId ] != e.layer.properties[vtId] ) { L_.layers.layer[layerName].activeFeatures.push({ @@ -2246,8 +2247,8 @@ function makeImageLayer(layerObj, mapContext = null) { L_.layers.layer[layerObj.name].setZIndex( L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(layerObj.name) + 1 - + L_._layersOrdered.indexOf(layerObj.name) ) L_.setLayerOpacity(layerObj.name, L_.layers.opacity[layerObj.name]) @@ -2344,8 +2345,8 @@ function makeVideoLayer(layerObj, mapContext = null) { L_.layers.layer[layerObj.name].setZIndex( L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(layerObj.name) + 1 - + L_._layersOrdered.indexOf(layerObj.name) ) L_.setLayerOpacity(layerObj.name, L_.layers.opacity[layerObj.name]) diff --git a/src/essence/Tools/MapControl/MapControlTool.js b/src/essence/Tools/MapControl/MapControlTool.js index f4ff35558..177ddc70f 100644 --- a/src/essence/Tools/MapControl/MapControlTool.js +++ b/src/essence/Tools/MapControl/MapControlTool.js @@ -5,7 +5,7 @@ * map:getBasemapStyles map:getBasemap map:setBasemap * map:zoomIn map:zoomOut * map:fitBounds map:latLngToContainerPoint - * map:createLayer map:removeLayer (provided by PR #90 imapengine-drawing) + * map:createLayer map:removeLayer * * Bus channels consumed (on / subscribe): * map:click map:mousemove @@ -15,9 +15,7 @@ import { createRoot } from 'react-dom/client' import { MapControlComponent } from './MapControlComponent' const ROOT_ID = 'mmgis-map-control-root' -// position:fixed anchors to the viewport regardless of which UI layout is -// active (Modern UI's #modern-content is emptied on re-render; Default UI uses -// #mapScreen). Mounting into document.body is the simplest, safest anchor. +// Viewport-fixed, click-through overlay layer mounted on document.body. const ROOT_STYLE = 'position:fixed;inset:0;pointer-events:none;z-index:1002;' const TOP_BAR_ID = 'topBarRight' const SEARCH_OVERLAY_ID = 'plugin:mapcontrol:search' @@ -162,11 +160,8 @@ function ConnectedMapControl() { } }, []) - // Vector overlays go through map:createLayer / map:removeLayer (provided by - // the imapengine-drawing PR #90). We render ephemeral vector geometry, so we - // use the generic layer CRUD rather than #90's map:addOverlay, which is a - // DOM-element-at-a-point overlay with a different contract. - // Guard: only call removeLayer if the layer was actually drawn. + // Ephemeral vector overlays via map:createLayer / map:removeLayer. + // overlayExistsRef guards removeLayer so it only fires after a draw. const overlayExistsRef = useRef(false) const onDrawOverlay = useCallback(({ id, geojson, style }) => { overlayExistsRef.current = true @@ -238,8 +233,7 @@ function ConnectedMapControl() { } // ─── InterfaceWithMMGIS ─────────────────────────────────────────────────────── -// Mounts the React overlay into document.body with position:fixed so it is -// immune to any parent container being emptied or repositioned. +// Mounts the React tree into the overlay layer; separateFromMMGIS tears it down. function InterfaceWithMMGIS() { document.getElementById(ROOT_ID)?.remove() From 40ada65626693c2ffbf65f3494169e8a64a70d4b Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 9 Jun 2026 10:00:52 -0500 Subject: [PATCH 21/21] refractor:monorepo pattern --- .../MapControl/MMGISMapControlAdapter.tsx | 109 ++++++ .../Tools/MapControl/MapControlComponent.scss | 214 ----------- .../Tools/MapControl/MapControlComponent.tsx | 360 ------------------ .../Tools/MapControl/MapControlTool.js | 298 --------------- .../Tools/MapControl/MapControlTool.tsx | 74 ++++ .../Tools/MapControl/adapters/getBasemaps.ts | 15 + .../Tools/MapControl/adapters/handlers.ts | 92 +++++ .../Tools/MapControl/adapters/mmgisAPI.ts | 39 ++ .../MapControl/adapters/useMMGISEvent.ts | 12 + .../MapControl/adapters/useMMGISToolVars.ts | 42 ++ src/essence/Tools/MapControl/config.json | 4 + .../lib/geo/BasemapPanel/BasemapPanel.tsx | 37 ++ .../lib/geo/MapControlBar/MapControlBar.tsx | 200 ++++++++++ .../lib/geo/MeasureLabel/MeasureLabel.tsx | 18 + .../lib/geo/SearchPanel/SearchPanel.tsx | 48 +++ .../Tools/MapControl/lib/geo/icons.tsx | 54 +++ .../lib/hooks/useDebouncedSearch.ts | 35 ++ .../Tools/MapControl/lib/hooks/useMeasure.ts | 166 ++++++++ .../MapControl/lib/hooks/useOutsideClick.ts | 16 + src/essence/Tools/MapControl/lib/index.ts | 18 + .../styles/components-geo/basemap-panel.scss | 72 ++++ .../lib/styles/components-geo/index.scss | 5 + .../components-geo/map-control-bar.scss | 74 ++++ .../styles/components-geo/measure-label.scss | 15 + .../styles/components-geo/search-panel.scss | 68 ++++ .../Tools/MapControl/lib/styles/index.scss | 4 + .../MapControl/lib/styles/scss-imports.d.ts | 1 + src/essence/Tools/MapControl/lib/types.ts | 28 ++ .../Tools/MapControl/lib/utils/basemap.ts | 20 + .../Tools/MapControl/lib/utils/geocode.ts | 31 ++ .../Tools/MapControl/lib/utils/measure.ts | 28 ++ .../Tools/MapControl/mapControlHelpers.ts | 95 ----- 32 files changed, 1325 insertions(+), 967 deletions(-) create mode 100644 src/essence/Tools/MapControl/MMGISMapControlAdapter.tsx delete mode 100644 src/essence/Tools/MapControl/MapControlComponent.scss delete mode 100644 src/essence/Tools/MapControl/MapControlComponent.tsx delete mode 100644 src/essence/Tools/MapControl/MapControlTool.js create mode 100644 src/essence/Tools/MapControl/MapControlTool.tsx create mode 100644 src/essence/Tools/MapControl/adapters/getBasemaps.ts create mode 100644 src/essence/Tools/MapControl/adapters/handlers.ts create mode 100644 src/essence/Tools/MapControl/adapters/mmgisAPI.ts create mode 100644 src/essence/Tools/MapControl/adapters/useMMGISEvent.ts create mode 100644 src/essence/Tools/MapControl/adapters/useMMGISToolVars.ts create mode 100644 src/essence/Tools/MapControl/lib/geo/BasemapPanel/BasemapPanel.tsx create mode 100644 src/essence/Tools/MapControl/lib/geo/MapControlBar/MapControlBar.tsx create mode 100644 src/essence/Tools/MapControl/lib/geo/MeasureLabel/MeasureLabel.tsx create mode 100644 src/essence/Tools/MapControl/lib/geo/SearchPanel/SearchPanel.tsx create mode 100644 src/essence/Tools/MapControl/lib/geo/icons.tsx create mode 100644 src/essence/Tools/MapControl/lib/hooks/useDebouncedSearch.ts create mode 100644 src/essence/Tools/MapControl/lib/hooks/useMeasure.ts create mode 100644 src/essence/Tools/MapControl/lib/hooks/useOutsideClick.ts create mode 100644 src/essence/Tools/MapControl/lib/index.ts create mode 100644 src/essence/Tools/MapControl/lib/styles/components-geo/basemap-panel.scss create mode 100644 src/essence/Tools/MapControl/lib/styles/components-geo/index.scss create mode 100644 src/essence/Tools/MapControl/lib/styles/components-geo/map-control-bar.scss create mode 100644 src/essence/Tools/MapControl/lib/styles/components-geo/measure-label.scss create mode 100644 src/essence/Tools/MapControl/lib/styles/components-geo/search-panel.scss create mode 100644 src/essence/Tools/MapControl/lib/styles/index.scss create mode 100644 src/essence/Tools/MapControl/lib/styles/scss-imports.d.ts create mode 100644 src/essence/Tools/MapControl/lib/types.ts create mode 100644 src/essence/Tools/MapControl/lib/utils/basemap.ts create mode 100644 src/essence/Tools/MapControl/lib/utils/geocode.ts create mode 100644 src/essence/Tools/MapControl/lib/utils/measure.ts delete mode 100644 src/essence/Tools/MapControl/mapControlHelpers.ts diff --git a/src/essence/Tools/MapControl/MMGISMapControlAdapter.tsx b/src/essence/Tools/MapControl/MMGISMapControlAdapter.tsx new file mode 100644 index 000000000..58d3ac00c --- /dev/null +++ b/src/essence/Tools/MapControl/MMGISMapControlAdapter.tsx @@ -0,0 +1,109 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { MapControlBar } from './lib' +import type { BasemapStyle } from './lib' +import { useMMGISToolVars } from './adapters/useMMGISToolVars' +import { getBasemaps } from './adapters/getBasemaps' +import { + selectBasemap, + zoomIn, + zoomOut, + subscribeToMap, + drawOverlay, + removeOverlay, + projectLatLng, + setCursor, + flyToResult, +} from './adapters/handlers' + +type ToolVars = { + showBasemapSwitcher?: unknown + showSearch?: unknown + showMeasure?: unknown + showZoom?: unknown +} + +const isFalsy = (v: unknown) => + v === false || v === 'false' || v === 0 || v === '0' + +// Measures #topBarRight so the bar tucks beside the existing top-right chrome. +function useTopBarOffset(): number { + const [offset, setOffset] = useState(12) + useEffect(() => { + const measure = () => { + const el = document.getElementById('topBarRight') + const w = el ? el.getBoundingClientRect().width : 0 + setOffset(w > 0 ? Math.ceil(w) + 16 : 12) + } + measure() + const t = setTimeout(measure, 300) + window.addEventListener('resize', measure) + return () => { + clearTimeout(t) + window.removeEventListener('resize', measure) + } + }, []) + return offset +} + +export function MMGISMapControlAdapter() { + const [basemapStyles, setBasemapStyles] = useState([]) + const [activeBasemap, setActiveBasemap] = useState(null) + const rightOffset = useTopBarOffset() + const vars = useMMGISToolVars('mapcontrol') + + // Default ON; a saved false/0 disables the feature. + const showBasemapSwitcher = !isFalsy(vars.showBasemapSwitcher) + const showSearch = !isFalsy(vars.showSearch) + const showMeasure = !isFalsy(vars.showMeasure) + const showZoom = !isFalsy(vars.showZoom) + + // Retry until the map registers its basemap handlers (fixes the + // "switcher appears only sometimes" race). + useEffect(() => { + let cancelled = false + let attempts = 0 + const tick = () => { + if (cancelled || attempts >= 20) return + attempts++ + getBasemaps() + .then(({ styles, active }) => { + if (cancelled) return + if (styles.length === 0) { + setTimeout(tick, 300) + return + } + setBasemapStyles(styles) + setActiveBasemap(active) + }) + .catch(() => { + if (!cancelled) setTimeout(tick, 300) + }) + } + tick() + return () => { + cancelled = true + } + }, []) + + const onSelectBasemap = useCallback((style: BasemapStyle) => { + setActiveBasemap(style) + selectBasemap(style) + }, []) + + return ( + + ) +} diff --git a/src/essence/Tools/MapControl/MapControlComponent.scss b/src/essence/Tools/MapControl/MapControlComponent.scss deleted file mode 100644 index d98b57769..000000000 --- a/src/essence/Tools/MapControl/MapControlComponent.scss +++ /dev/null @@ -1,214 +0,0 @@ -// MapControl — styled with USWDS disaster theme tokens (CSS custom properties). -// All --theme-* vars are defined in dist/disasters.css and available globally. - -.map-control { - position: absolute; - top: 18px; - // right is set via inline style — dynamic offset from top-bar chrome width - display: flex; - flex-direction: column; - align-items: flex-end; - pointer-events: auto; - font-family: var(--theme-font-ui); - z-index: 1002; -} - -// ─── Toolbar (transparent wrapper, each feature is its own card) ────────── - -.map-control__bar { - display: flex; - flex-direction: row; - align-items: center; - gap: 10px; - white-space: nowrap; -} - -.map-control__group { - display: flex; - flex-direction: row; - align-items: center; - background: var(--theme-color-white); - border: 1px solid var(--theme-color-base-lighter); - border-radius: var(--theme-radius-md); - box-shadow: 0 1px 3px var(--theme-color-shadow); - overflow: hidden; - height: 36px; -} - -.map-control__btn { - display: flex; - align-items: center; - justify-content: center; - width: 36px; - height: 100%; - padding: 0; - border: none; - cursor: pointer; - background: transparent; - color: var(--theme-color-ink); - flex-shrink: 0; - transition: background 0.12s ease, color 0.12s ease; - - &:hover { - background: var(--theme-color-base-lightest); - } - - &--active { - color: var(--theme-color-primary); - // primary tint — no lightest primary token in the theme - background: rgba(14, 116, 130, 0.1); - - &:hover { - background: rgba(14, 116, 130, 0.16); - } - } -} - -.map-control__divider { - width: 1px; - height: 24px; - background: var(--theme-color-base-lighter); - flex-shrink: 0; -} - -// ─── Dropdown panel (basemap + search) ──────────────────────────────────── - -.map-control__panel { - margin-top: var(--theme-spacing-05); - background: var(--theme-color-white); - border-radius: var(--theme-radius-lg); - box-shadow: 0 4px 16px var(--theme-color-shadow); - min-width: 220px; - display: flex; - flex-direction: column; - max-height: 320px; - overflow-y: auto; -} - -.map-control__panel-header { - padding: var(--theme-spacing-1) var(--theme-spacing-105) var(--theme-spacing-05); - font-size: var(--theme-font-size-3xs); - font-weight: var(--theme-font-weight-bold); - letter-spacing: 0.8px; - text-transform: uppercase; - color: var(--theme-color-base); - flex-shrink: 0; -} - -// ─── Basemap / search rows ───────────────────────────────────────────────── - -.map-control__row { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: var(--theme-spacing-1); - padding: var(--theme-spacing-1) var(--theme-spacing-105); - background: transparent; - border: none; - cursor: pointer; - width: 100%; - text-align: left; - transition: background 0.1s ease; - flex-shrink: 0; - - &:hover { - background: var(--theme-color-base-lightest); - } - - &--active { - background: rgba(14, 116, 130, 0.06); - - .map-control__row-label { - font-weight: var(--theme-font-weight-semibold); - color: var(--theme-color-primary); - } - - .map-control__row-thumb { - border-color: var(--theme-color-primary); - } - } -} - -.map-control__row-label { - font-size: var(--theme-font-size-2xs); - font-weight: var(--theme-font-weight-normal); - color: var(--theme-color-ink); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 1; -} - -.map-control__row-thumb { - width: 44px; - height: 28px; - border-radius: var(--theme-radius-sm); - flex-shrink: 0; - border: 2px solid transparent; - box-shadow: 0 1px 3px var(--theme-color-shadow); -} - -// ─── Search input ────────────────────────────────────────────────────────── - -.map-control__search-row { - padding: var(--theme-spacing-1) var(--theme-spacing-105); - flex-shrink: 0; -} - -.map-control__search-input { - width: 100%; - box-sizing: border-box; - padding: var(--theme-spacing-05) var(--theme-spacing-1); - border: 1px solid var(--theme-color-base-light); - border-radius: var(--theme-radius-md); - background: var(--theme-color-white); - color: var(--theme-color-ink); - font-family: var(--theme-font-ui); - font-size: var(--theme-font-size-2xs); - outline: none; - - &:focus { - border-color: var(--theme-color-primary); - box-shadow: 0 0 0 2px rgba(14, 116, 130, 0.15); - } -} - -.map-control__search-hint { - padding: var(--theme-spacing-1) var(--theme-spacing-105); - font-size: var(--theme-font-size-3xs); - color: var(--theme-color-base); - flex-shrink: 0; -} - -// ─── Measure hint (below bar when active) ───────────────────────────────── - -.map-control__hint { - margin-top: var(--theme-spacing-05); - align-self: flex-end; - background: var(--theme-color-white); - border-radius: var(--theme-radius-lg); - box-shadow: 0 2px 8px var(--theme-color-shadow); - padding: var(--theme-spacing-1) var(--theme-spacing-105); - font-size: var(--theme-font-size-3xs); - font-weight: var(--theme-font-weight-semibold); - color: var(--theme-color-base-dark); -} - -// ─── Distance label (absolutely positioned over map canvas) ─────────────── - -.map-control__measure-label { - position: absolute; - transform: translate(-50%, -50%); - background: var(--theme-color-white); - border-radius: var(--theme-radius-lg); - box-shadow: 0 2px 8px var(--theme-color-shadow); - padding: var(--theme-spacing-05) var(--theme-spacing-1); - font-size: var(--theme-font-size-3xs); - font-weight: var(--theme-font-weight-semibold); - color: var(--theme-color-primary); - white-space: nowrap; - pointer-events: none; - z-index: 1003; - font-family: var(--theme-font-ui); -} diff --git a/src/essence/Tools/MapControl/MapControlComponent.tsx b/src/essence/Tools/MapControl/MapControlComponent.tsx deleted file mode 100644 index 80c8371c6..000000000 --- a/src/essence/Tools/MapControl/MapControlComponent.tsx +++ /dev/null @@ -1,360 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react' -import { - BasemapStyle, - GeocodeResult, - LatLng, - MapOverlayOpts, - MapSubscribeHandlers, - MEASURE_STYLE, - basemapGradient, - formatDistance, - measureDistance, - geocodeSearch, -} from './mapControlHelpers' -import './MapControlComponent.scss' - -const MEASURE_OVERLAY_ID = 'plugin:mapcontrol:measure' -const SEARCH_DEBOUNCE_MS = 300 - -export interface MapControlComponentProps { - /** Distance from the right edge of the map canvas (px). Dynamic — supplied by wrapper. */ - rightOffset?: number - - // Basemap - basemapStyles?: BasemapStyle[] - activeBasemap?: BasemapStyle | null - onSelectBasemap?: (style: BasemapStyle) => void - - // Zoom - onZoomIn?: () => void - onZoomOut?: () => void - - // Measure — all three must be provided to enable the feature - subscribeToMap?: (handlers: MapSubscribeHandlers) => () => void - onDrawOverlay?: (opts: MapOverlayOpts) => void - onRemoveOverlay?: (id: string) => void - onProjectLatLng?: (latlng: LatLng) => Promise<{ x: number; y: number } | null> - onSetCursor?: (cursor: string) => void - - // Geocode search - onSearchSelect?: (result: GeocodeResult) => void -} - -export function MapControlComponent({ - rightOffset = 12, - basemapStyles = [], - activeBasemap = null, - onSelectBasemap, - onZoomIn, - onZoomOut, - subscribeToMap, - onDrawOverlay, - onRemoveOverlay, - onProjectLatLng, - onSetCursor, - onSearchSelect, -}: MapControlComponentProps) { - const rootRef = useRef(null) - - const [basemapOpen, setBasemapOpen] = useState(false) - const [searchOpen, setSearchOpen] = useState(false) - const [measuring, setMeasuring] = useState(false) - const [points, setPoints] = useState([]) - const [mouseLatLng, setMouseLatLng] = useState(null) - const [labelPixel, setLabelPixel] = useState<{ x: number; y: number } | null>(null) - const [searchQuery, setSearchQuery] = useState('') - const [searchResults, setSearchResults] = useState([]) - const [searchLoading, setSearchLoading] = useState(false) - - const searchInputRef = useRef(null) - const searchTimerRef = useRef | null>(null) - - const measureSupported = Boolean(subscribeToMap && onDrawOverlay && onRemoveOverlay) - - // Derived live segment for measure preview - let segment: [LatLng, LatLng] | null = null - if (measuring) { - if (points.length === 2) segment = [points[0], points[1]] - else if (points.length === 1 && mouseLatLng) segment = [points[0], mouseLatLng] - } - - // ── Close panels on outside click ──────────────────────────────────────── - useEffect(() => { - function onDown(e: MouseEvent) { - if (rootRef.current && !rootRef.current.contains(e.target as Node)) { - setBasemapOpen(false) - setSearchOpen(false) - } - } - document.addEventListener('mousedown', onDown) - return () => document.removeEventListener('mousedown', onDown) - }, []) - - // ── Escape key ──────────────────────────────────────────────────────────── - useEffect(() => { - function onKey(e: KeyboardEvent) { - if (e.key !== 'Escape') return - setBasemapOpen(false) - setSearchOpen(false) - if (measuring) { - setMeasuring(false) - setPoints([]) - setMouseLatLng(null) - setLabelPixel(null) - } - } - document.addEventListener('keydown', onKey) - return () => document.removeEventListener('keydown', onKey) - }, [measuring]) - - // ── Auto-focus search input ─────────────────────────────────────────────── - useEffect(() => { - if (searchOpen) searchInputRef.current?.focus() - else { setSearchQuery(''); setSearchResults([]) } - }, [searchOpen]) - - // ── Debounced geocode search ─────────────────────────────────────────────── - useEffect(() => { - if (searchTimerRef.current) clearTimeout(searchTimerRef.current) - if (searchQuery.length < 2) { setSearchResults([]); setSearchLoading(false); return } - setSearchLoading(true) - searchTimerRef.current = setTimeout(async () => { - const results = await geocodeSearch(searchQuery) - setSearchResults(results) - setSearchLoading(false) - }, SEARCH_DEBOUNCE_MS) - return () => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current) } - }, [searchQuery]) - - // ── Map click/move subscription for measure ─────────────────────────────── - useEffect(() => { - if (!measuring || !subscribeToMap) return - const unsub = subscribeToMap({ - onClick: (e) => { - if (e?.lat == null || e?.lng == null) return - setPoints((prev) => - prev.length >= 2 ? [{ lat: e.lat, lng: e.lng }] : [...prev, { lat: e.lat, lng: e.lng }] - ) - }, - onMouseMove: (e) => { - if (e?.lat != null && e?.lng != null) setMouseLatLng({ lat: e.lat, lng: e.lng }) - }, - }) - if (onSetCursor) onSetCursor('crosshair') - return () => { - unsub() - if (onSetCursor) onSetCursor('') - } - }, [measuring, subscribeToMap, onSetCursor]) - - // ── Draw measure overlay ────────────────────────────────────────────────── - useEffect(() => { - if (!onDrawOverlay || !onRemoveOverlay) return - if (!measuring || points.length === 0) { onRemoveOverlay(MEASURE_OVERLAY_ID); return } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const features: any[] = points.map((p) => ({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, - properties: {}, - })) - if (segment) { - features.push({ - type: 'Feature', - geometry: { - type: 'LineString', - coordinates: [[segment[0].lng, segment[0].lat], [segment[1].lng, segment[1].lat]], - }, - properties: {}, - }) - } - onDrawOverlay({ id: MEASURE_OVERLAY_ID, geojson: { type: 'FeatureCollection', features }, style: MEASURE_STYLE }) - // mouseLatLng intentionally in deps — redraws preview line on every mouse move - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [measuring, points, mouseLatLng, onDrawOverlay, onRemoveOverlay]) - - // ── Cleanup overlay on unmount ───────────────────────────────────────────── - useEffect(() => () => { onRemoveOverlay?.(MEASURE_OVERLAY_ID) }, [onRemoveOverlay]) - - // ── Project segment midpoint → pixel for distance label ─────────────────── - useEffect(() => { - if (!segment || !onProjectLatLng) { setLabelPixel(null); return } - const mid = { lat: (segment[0].lat + segment[1].lat) / 2, lng: (segment[0].lng + segment[1].lng) / 2 } - let cancelled = false - Promise.resolve(onProjectLatLng(mid)).then((pt) => { - if (!cancelled && pt?.x != null && pt?.y != null) setLabelPixel({ x: pt.x, y: pt.y }) - }) - return () => { cancelled = true } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [segment?.[0]?.lat, segment?.[0]?.lng, segment?.[1]?.lat, segment?.[1]?.lng, onProjectLatLng]) - - // ── Handlers ────────────────────────────────────────────────────────────── - - function toggleBasemap() { setBasemapOpen((v) => !v); setSearchOpen(false) } - - function toggleSearch() { setSearchOpen((v) => !v); setBasemapOpen(false) } - - function toggleMeasure() { - if (measuring) { - setMeasuring(false); setPoints([]); setMouseLatLng(null); setLabelPixel(null) - } else { - setMeasuring(true); setPoints([]); setBasemapOpen(false); setSearchOpen(false) - } - } - - function handleSearchSelect(result: GeocodeResult) { - setSearchOpen(false) - onSearchSelect?.(result) - } - - // ── Render ──────────────────────────────────────────────────────────────── - - const current = activeBasemap ?? (basemapStyles[0] ?? null) - const hasStyles = basemapStyles.length > 0 - const hasZoom = Boolean(onZoomIn && onZoomOut) - - return ( - <> -
- {/* ── Toolbar (each feature is its own card) ── */} -
- {onSearchSelect && ( -
- -
- )} - {hasStyles && ( -
- -
- )} - {measureSupported && ( -
- -
- )} - {hasZoom && ( -
- - - -
- )} -
- - {/* ── Basemap panel ── */} - {basemapOpen && hasStyles && ( -
-
Basemap style
- {basemapStyles.map((entry) => { - const isActive = current?.name === entry.name - return ( - - ) - })} -
- )} - - {/* ── Search panel ── */} - {searchOpen && ( -
-
- setSearchQuery(e.target.value)} - /> -
- {searchLoading &&
Searching…
} - {!searchLoading && searchQuery.length >= 2 && searchResults.length === 0 && ( -
No results
- )} - {searchResults.map((r) => ( - - ))} -
- )} - - {/* ── Measure hint ── */} - {measuring && points.length === 0 && ( -
Click two points on the map
- )} -
- - {/* ── Distance label (positioned over map canvas) ── */} - {measuring && segment && labelPixel && ( -
- {formatDistance(measureDistance(segment[0], segment[1]))} -
- )} - - ) -} - -// ─── Inline SVG icons (no icon-font dependency) ──────────────────────────── - -function Icon({ children }: { children: React.ReactNode }) { - return ( - - ) -} -function BasemapIcon() { return } -function SearchIcon() { return } -function RulerIcon() { return } -function PlusIcon() { return } -function MinusIcon() { return } - -export default MapControlComponent diff --git a/src/essence/Tools/MapControl/MapControlTool.js b/src/essence/Tools/MapControl/MapControlTool.js deleted file mode 100644 index 177ddc70f..000000000 --- a/src/essence/Tools/MapControl/MapControlTool.js +++ /dev/null @@ -1,298 +0,0 @@ -/** - * MapControlTool — MMGIS plugin wrapper for MapControlComponent. - * - * Bus channels consumed (request): - * map:getBasemapStyles map:getBasemap map:setBasemap - * map:zoomIn map:zoomOut - * map:fitBounds map:latLngToContainerPoint - * map:createLayer map:removeLayer - * - * Bus channels consumed (on / subscribe): - * map:click map:mousemove - */ -import React, { useState, useEffect, useRef, useCallback } from 'react' -import { createRoot } from 'react-dom/client' -import { MapControlComponent } from './MapControlComponent' - -const ROOT_ID = 'mmgis-map-control-root' -// Viewport-fixed, click-through overlay layer mounted on document.body. -const ROOT_STYLE = 'position:fixed;inset:0;pointer-events:none;z-index:1002;' -const TOP_BAR_ID = 'topBarRight' -const SEARCH_OVERLAY_ID = 'plugin:mapcontrol:search' - -// Set cursor on the actual Leaflet map canvas. -const MAP_CURSOR_IDS = ['mapScreen', 'map'] -function _getMapCursorEl() { - for (const id of MAP_CURSOR_IDS) { - const el = document.getElementById(id) - if (el) return el - } - return null -} - -// Poll for app readiness — any of these elements means the map is alive. -const APP_READY_IDS = ['mapScreen', 'modern-content', 'map'] -function _isAppReady() { - return APP_READY_IDS.some((id) => document.getElementById(id) !== null) -} - -// ─── Top-bar offset hook ────────────────────────────────────────────────────── -// Measures #topBarRight so the bar never overlaps the top-right chrome. - -function useTopBarOffset() { - const [offset, setOffset] = useState(12) - useEffect(() => { - function measure() { - const el = document.getElementById(TOP_BAR_ID) - const w = el ? el.getBoundingClientRect().width : 0 - setOffset(w > 0 ? Math.ceil(w) + 16 : 12) - } - measure() - const t = setTimeout(measure, 300) - window.addEventListener('resize', measure) - return () => { clearTimeout(t); window.removeEventListener('resize', measure) } - }, []) - return offset -} - -// ─── ConnectedMapControl ────────────────────────────────────────────────────── -// Translates MMGIS event-bus calls into plain props for MapControlComponent. - -// Default feature flags — all on. Overridden by mission tool variables. -const DEFAULT_FEATURES = { - showBasemapSwitcher: true, - showSearch: true, - showMeasure: true, - showZoom: true, -} - -function isFalsy(v) { - return v === false || v === 'false' || v === 0 || v === '0' -} - -function useToolFeatures() { - const [features, setFeatures] = useState(DEFAULT_FEATURES) - useEffect(() => { - let cancelled = false - let attempts = 0 - const MAX = 20 - - function tryFetch() { - if (cancelled || attempts >= MAX) return - attempts++ - const api = window.mmgisAPI - if (!api) { setTimeout(tryFetch, 300); return } - // getToolVars lowercases the name internally - api.request('tool:getVars', 'mapcontrol') - .then((vars) => { - if (cancelled) return - // __noVars means the tool has no saved variables — keep defaults - if (!vars || vars.__noVars) return - setFeatures({ - showBasemapSwitcher: !isFalsy(vars.showBasemapSwitcher), - showSearch: !isFalsy(vars.showSearch), - showMeasure: !isFalsy(vars.showMeasure), - showZoom: !isFalsy(vars.showZoom), - }) - }) - .catch(() => { - if (!cancelled) setTimeout(tryFetch, 300) - }) - } - - tryFetch() - return () => { cancelled = true } - }, []) - return features -} - -function ConnectedMapControl() { - const [basemapStyles, setBasemapStyles] = useState([]) - const [activeBasemap, setActiveBasemap] = useState(null) - const rightOffset = useTopBarOffset() - const features = useToolFeatures() - - useEffect(() => { - let cancelled = false - let attempts = 0 - const MAX_ATTEMPTS = 20 // 20 × 300 ms = 6 s max - - function tryFetch() { - if (cancelled || attempts >= MAX_ATTEMPTS) return - attempts++ - const api = window.mmgisAPI - if (!api) { setTimeout(tryFetch, 300); return } - Promise.all([ - api.request('map:getBasemapStyles'), - api.request('map:getBasemap'), - ]).then(([styles, active]) => { - if (cancelled) return - const s = styles || [] - // Handler not yet registered or map not ready — retry - if (s.length === 0) { setTimeout(tryFetch, 300); return } - setBasemapStyles(s) - setActiveBasemap(active || s[0] || null) - }).catch(() => { - if (!cancelled) setTimeout(tryFetch, 300) - }) - } - - tryFetch() - return () => { cancelled = true } - }, []) - - const onSelectBasemap = useCallback((style) => { - setActiveBasemap(style) - window.mmgisAPI?.request('map:setBasemap', style.name) - }, []) - - const onZoomIn = useCallback(() => { window.mmgisAPI?.request('map:zoomIn') }, []) - const onZoomOut = useCallback(() => { window.mmgisAPI?.request('map:zoomOut') }, []) - - const subscribeToMap = useCallback(({ onClick, onMouseMove }) => { - const api = window.mmgisAPI - if (!api?.on) return () => {} - const offClick = api.on('map:click', onClick) - const offMove = api.on('map:mousemove', onMouseMove) - return () => { - if (typeof offClick === 'function') offClick() - if (typeof offMove === 'function') offMove() - } - }, []) - - // Ephemeral vector overlays via map:createLayer / map:removeLayer. - // overlayExistsRef guards removeLayer so it only fires after a draw. - const overlayExistsRef = useRef(false) - const onDrawOverlay = useCallback(({ id, geojson, style }) => { - overlayExistsRef.current = true - window.mmgisAPI?.request('map:createLayer', { - id, - type: 'vector', - geojson, - style, - interactive: false, - }) - }, []) - const onRemoveOverlay = useCallback((id) => { - if (!overlayExistsRef.current) return - overlayExistsRef.current = false - window.mmgisAPI?.request('map:removeLayer', { id }).catch(() => {}) - }, []) - - const onProjectLatLng = useCallback((latlng) => { - if (!window.mmgisAPI) return Promise.resolve(null) - return window.mmgisAPI.request('map:latLngToContainerPoint', latlng).catch(() => null) - }, []) - - const onSetCursor = useCallback((cursor) => { - const el = _getMapCursorEl() - if (el) el.style.cursor = cursor - }, []) - - const onSearchSelect = useCallback((result) => { - const api = window.mmgisAPI - if (!api) return - // Nominatim bbox: [min_lat, max_lat, min_lon, max_lon] - // map:fitBounds expects [[south, west], [north, east]] - api.request('map:fitBounds', [ - [result.bbox[0], result.bbox[2]], - [result.bbox[1], result.bbox[3]], - ]) - api.request('map:createLayer', { - id: SEARCH_OVERLAY_ID, - type: 'vector', - interactive: false, - geojson: { - type: 'FeatureCollection', - features: [{ - type: 'Feature', - geometry: { type: 'Point', coordinates: [result.lng, result.lat] }, - properties: {}, - }], - }, - style: { color: '#005ea2', fillColor: '#005ea2', fillOpacity: 0.85, radius: 7, weight: 2 }, - }) - }, []) - - return ( - - ) -} - -// ─── InterfaceWithMMGIS ─────────────────────────────────────────────────────── -// Mounts the React tree into the overlay layer; separateFromMMGIS tears it down. - -function InterfaceWithMMGIS() { - document.getElementById(ROOT_ID)?.remove() - - const container = document.createElement('div') - container.id = ROOT_ID - container.style.cssText = ROOT_STYLE - document.body.appendChild(container) - - const root = createRoot(container) - root.render() - - this.separateFromMMGIS = () => { - root.unmount() - container.remove() - } -} - -// ─── MapControlTool (MMGIS plugin contract) ─────────────────────────────────── - -const MapControlTool = { - height: 0, - width: 0, - MMGISInterface: null, - made: false, - - make() { - if (this.MMGISInterface) return - this.MMGISInterface = new InterfaceWithMMGIS() - this.made = true - }, - - destroy() { - if (this.MMGISInterface) this.MMGISInterface.separateFromMMGIS() - this.MMGISInterface = null - this.made = false - }, - - getUrlString() { return '' }, -} - -// ─── Module-level auto-mount ────────────────────────────────────────────────── -// src/pre/tools.js eagerly imports every tool at app start (before any DOM -// exists). Poll until the map is alive (#mapScreen / #modern-content / #map), -// then mount. The MMGISInterface guard prevents double-mounting if make() is -// later called by the panel system. - -function _tryAutoMount() { - if (MapControlTool.MMGISInterface) return true - if (!_isAppReady()) return false - MapControlTool.make() - return true -} - -if (typeof window !== 'undefined') { - if (!_tryAutoMount()) { - const _id = setInterval(() => { if (_tryAutoMount()) clearInterval(_id) }, 100) - setTimeout(() => clearInterval(_id), 15000) - } -} - -export default MapControlTool diff --git a/src/essence/Tools/MapControl/MapControlTool.tsx b/src/essence/Tools/MapControl/MapControlTool.tsx new file mode 100644 index 000000000..07ddb2bbb --- /dev/null +++ b/src/essence/Tools/MapControl/MapControlTool.tsx @@ -0,0 +1,74 @@ +/** + * MapControlTool — MMGIS tool wrapper for the MapControl floating overlay. + * + * Unlike a docked panel tool, MapControl is a floating overlay: it self-mounts + * into a viewport-fixed, click-through layer on document.body. src/pre/tools.js + * eagerly imports every tool at app start, so the module-level auto-mount polls + * until the map is alive, then mounts. The _root guard prevents double-mounting + * if the panel system also calls make(). + * + * All MMGIS coupling lives in MMGISMapControlAdapter + adapters/. The lib/ tree + * is portable. + */ +import React from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { MMGISMapControlAdapter } from './MMGISMapControlAdapter' + +const ROOT_ID = 'mmgis-map-control-root' +const ROOT_STYLE = 'position:fixed;inset:0;pointer-events:none;z-index:1002;' +const APP_READY_IDS = ['mapScreen', 'modern-content', 'map'] + +function isAppReady(): boolean { + return APP_READY_IDS.some((id) => document.getElementById(id) !== null) +} + +let _root: Root | null = null + +const MapControlTool = { + height: 0, + width: 0, + made: false, + + make() { + if (_root) return + document.getElementById(ROOT_ID)?.remove() + const container = document.createElement('div') + container.id = ROOT_ID + container.style.cssText = ROOT_STYLE + document.body.appendChild(container) + _root = createRoot(container) + _root.render() + this.made = true + }, + + destroy() { + if (_root) { + _root.unmount() + _root = null + } + document.getElementById(ROOT_ID)?.remove() + this.made = false + }, + + getUrlString() { + return '' + }, +} + +function tryAutoMount(): boolean { + if (_root) return true + if (!isAppReady()) return false + MapControlTool.make() + return true +} + +if (typeof window !== 'undefined') { + if (!tryAutoMount()) { + const id = setInterval(() => { + if (tryAutoMount()) clearInterval(id) + }, 100) + setTimeout(() => clearInterval(id), 15000) + } +} + +export default MapControlTool diff --git a/src/essence/Tools/MapControl/adapters/getBasemaps.ts b/src/essence/Tools/MapControl/adapters/getBasemaps.ts new file mode 100644 index 000000000..4282c9b86 --- /dev/null +++ b/src/essence/Tools/MapControl/adapters/getBasemaps.ts @@ -0,0 +1,15 @@ +import { mmgisRequest } from './mmgisAPI' +import type { BasemapStyle } from '../lib' + +/** Pull the basemap style list + active style from the map engine via the bus. */ +export async function getBasemaps(): Promise<{ + styles: BasemapStyle[] + active: BasemapStyle | null +}> { + const [styles, active] = await Promise.all([ + mmgisRequest('map:getBasemapStyles'), + mmgisRequest('map:getBasemap'), + ]) + const s = styles || [] + return { styles: s, active: active || s[0] || null } +} diff --git a/src/essence/Tools/MapControl/adapters/handlers.ts b/src/essence/Tools/MapControl/adapters/handlers.ts new file mode 100644 index 000000000..32258723b --- /dev/null +++ b/src/essence/Tools/MapControl/adapters/handlers.ts @@ -0,0 +1,92 @@ +// MapControl actions, each translating a lib callback into event-bus calls. +// MMGIS-coupled — stays in MMGIS. +import { mmgisOn, mmgisRequest } from './mmgisAPI' +import type { + BasemapStyle, + ContainerPoint, + GeocodeResult, + LatLng, + MapOverlayOpts, + MapSubscribeHandlers, +} from '../lib' + +const SEARCH_OVERLAY_ID = 'plugin:mapcontrol:search' +const MAP_CURSOR_IDS = ['mapScreen', 'map'] + +export const selectBasemap = (style: BasemapStyle): void => { + void mmgisRequest('map:setBasemap', style.name) +} + +export const zoomIn = (): void => { + void mmgisRequest('map:zoomIn') +} + +export const zoomOut = (): void => { + void mmgisRequest('map:zoomOut') +} + +/** Subscribe to map click/move; returns an unsubscribe fn. */ +export const subscribeToMap = (handlers: MapSubscribeHandlers): (() => void) => { + const offClick = mmgisOn('map:click', (e) => handlers.onClick(e as LatLng)) + const offMove = mmgisOn('map:mousemove', (e) => handlers.onMouseMove(e as LatLng)) + return () => { + offClick() + offMove() + } +} + +/** Draw an ephemeral vector overlay (measure line). map:createLayer is provided by #90. */ +export const drawOverlay = (opts: MapOverlayOpts): void => { + void mmgisRequest('map:createLayer', { + id: opts.id, + type: 'vector', + geojson: opts.geojson, + style: opts.style, + interactive: false, + }) +} + +export const removeOverlay = (id: string): void => { + void mmgisRequest('map:removeLayer', { id }) +} + +export const projectLatLng = async (latlng: LatLng): Promise => { + return (await mmgisRequest('map:latLngToContainerPoint', latlng)) ?? null +} + +/** Set the cursor on the live map canvas (DOM, not bus). */ +export const setCursor = (cursor: string): void => { + for (const id of MAP_CURSOR_IDS) { + const el = document.getElementById(id) + if (el) { + el.style.cursor = cursor + return + } + } +} + +/** Fly to a geocode result and drop a pin overlay. */ +export const flyToResult = (result: GeocodeResult): void => { + // Nominatim bbox: [min_lat, max_lat, min_lon, max_lon] + // map:fitBounds expects [[south, west], [north, east]] + void mmgisRequest('map:fitBounds', [ + [result.bbox[0], result.bbox[2]], + [result.bbox[1], result.bbox[3]], + ]) + void mmgisRequest('map:createLayer', { + id: SEARCH_OVERLAY_ID, + type: 'vector', + interactive: false, + geojson: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [result.lng, result.lat] }, + properties: {}, + }, + ], + }, + style: { color: '#005ea2', fillColor: '#005ea2', fillOpacity: 0.85, radius: 7, weight: 2 }, + }) +} diff --git a/src/essence/Tools/MapControl/adapters/mmgisAPI.ts b/src/essence/Tools/MapControl/adapters/mmgisAPI.ts new file mode 100644 index 000000000..a5cb15416 --- /dev/null +++ b/src/essence/Tools/MapControl/adapters/mmgisAPI.ts @@ -0,0 +1,39 @@ +// Typed wrappers over window.mmgisAPI. MMGIS-coupled — stays in MMGIS. + +type EventCleanup = () => void + +// Must stay structurally identical to other tools' Window.mmgisAPI augmentation +// (e.g. LayerManager's) — TS merges global declarations only if they match. +type MMGISAPI = { + request: (name: string, params?: unknown) => Promise + on: (event: string, handler: (payload?: unknown) => void) => EventCleanup + emit: (event: string, payload?: unknown) => void + provide?: (name: string, handler: (...args: unknown[]) => unknown) => EventCleanup + hasHandler?: (name: string) => boolean +} + +declare global { + interface Window { + mmgisAPI?: MMGISAPI + } +} + +export const mmgisRequest = async ( + name: string, + params?: unknown, +): Promise => { + if (window.mmgisAPI?.request) return (await window.mmgisAPI.request(name, params)) as T + return null +} + +export const mmgisOn = ( + event: string, + handler: (payload?: unknown) => void, +): EventCleanup => { + if (!window.mmgisAPI?.on) return () => {} + return window.mmgisAPI.on(event, handler) +} + +export const mmgisEmit = (event: string, payload?: unknown): void => { + window.mmgisAPI?.emit?.(event, payload) +} diff --git a/src/essence/Tools/MapControl/adapters/useMMGISEvent.ts b/src/essence/Tools/MapControl/adapters/useMMGISEvent.ts new file mode 100644 index 000000000..941b7c1a6 --- /dev/null +++ b/src/essence/Tools/MapControl/adapters/useMMGISEvent.ts @@ -0,0 +1,12 @@ +import { useEffect } from 'react' +import { mmgisOn } from './mmgisAPI' + +export const useMMGISEvent = ( + eventName: string, + handler: (payload?: unknown) => void, +): void => { + useEffect(() => { + const cleanup = mmgisOn(eventName, handler) + return cleanup + }, [eventName, handler]) +} diff --git a/src/essence/Tools/MapControl/adapters/useMMGISToolVars.ts b/src/essence/Tools/MapControl/adapters/useMMGISToolVars.ts new file mode 100644 index 000000000..c72eb8faa --- /dev/null +++ b/src/essence/Tools/MapControl/adapters/useMMGISToolVars.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react' +import { mmgisRequest } from './mmgisAPI' + +export const useMMGISToolVars = < + T extends Record = Record, +>( + toolName: string, +): T => { + const [vars, setVars] = useState({} as T) + useEffect(() => { + let cancelled = false + let attempts = 0 + const MAX = 20 // retry while Layers_.fina() registers the handler (~6s) + + const tryFetch = () => { + if (cancelled || attempts >= MAX) return + attempts++ + mmgisRequest('tool:getVars', toolName) + .then((result) => { + if (cancelled) return + // __noVars (or null) → handler not ready / no saved vars; retry. + if (!result || (result as Record).__noVars) { + setTimeout(tryFetch, 300) + return + } + setVars(result) + }) + .catch((err) => { + if (!cancelled) { + console.warn(`[useMMGISToolVars] '${toolName}' vars unavailable:`, err) + setTimeout(tryFetch, 300) + } + }) + } + + tryFetch() + return () => { + cancelled = true + } + }, [toolName]) + return vars +} diff --git a/src/essence/Tools/MapControl/config.json b/src/essence/Tools/MapControl/config.json index 80fe61133..62ee4000b 100644 --- a/src/essence/Tools/MapControl/config.json +++ b/src/essence/Tools/MapControl/config.json @@ -11,6 +11,10 @@ "paths": { "MapControlTool": "essence/Tools/MapControl/MapControlTool" }, + "metadata": { + "icon": "layers", + "modernLayoutSupport": true + }, "config": { "rows": [ { diff --git a/src/essence/Tools/MapControl/lib/geo/BasemapPanel/BasemapPanel.tsx b/src/essence/Tools/MapControl/lib/geo/BasemapPanel/BasemapPanel.tsx new file mode 100644 index 000000000..4a0a97ebb --- /dev/null +++ b/src/essence/Tools/MapControl/lib/geo/BasemapPanel/BasemapPanel.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import type { BasemapStyle } from '../../types' +import { basemapGradient } from '../../utils/basemap' + +export type BasemapPanelProps = { + /** All selectable basemap styles. */ + styles: BasemapStyle[] + /** Currently-active style, used to mark the selected row. */ + active: BasemapStyle | null + /** Called when a style row is chosen. */ + onSelect: (style: BasemapStyle) => void +} + +export function BasemapPanel({ styles, active, onSelect }: BasemapPanelProps) { + return ( +
+
Basemap style
+ {styles.map((entry) => { + const isActive = active?.name === entry.name + return ( + + ) + })} +
+ ) +} diff --git a/src/essence/Tools/MapControl/lib/geo/MapControlBar/MapControlBar.tsx b/src/essence/Tools/MapControl/lib/geo/MapControlBar/MapControlBar.tsx new file mode 100644 index 000000000..4ce92613c --- /dev/null +++ b/src/essence/Tools/MapControl/lib/geo/MapControlBar/MapControlBar.tsx @@ -0,0 +1,200 @@ +import React, { useEffect, useRef, useState } from 'react' +import type { + BasemapStyle, + ContainerPoint, + GeocodeResult, + LatLng, + MapOverlayOpts, + MapSubscribeHandlers, +} from '../../types' +import { useOutsideClick } from '../../hooks/useOutsideClick' +import { useDebouncedSearch } from '../../hooks/useDebouncedSearch' +import { useMeasure } from '../../hooks/useMeasure' +import { BasemapPanel } from '../BasemapPanel/BasemapPanel' +import { SearchPanel } from '../SearchPanel/SearchPanel' +import { MeasureLabel } from '../MeasureLabel/MeasureLabel' +import { BasemapIcon, MinusIcon, PlusIcon, RulerIcon, SearchIcon } from '../icons' + +export type MapControlBarProps = { + /** Distance from the right edge of the viewport (px) — dynamic, from the wrapper. */ + rightOffset?: number + + // Basemap + basemapStyles?: BasemapStyle[] + activeBasemap?: BasemapStyle | null + onSelectBasemap?: (style: BasemapStyle) => void + + // Zoom + onZoomIn?: () => void + onZoomOut?: () => void + + // Measure — all three (subscribe/draw/remove) needed to enable the feature + subscribeToMap?: (handlers: MapSubscribeHandlers) => () => void + onDrawOverlay?: (opts: MapOverlayOpts) => void + onRemoveOverlay?: (id: string) => void + onProjectLatLng?: (latlng: LatLng) => Promise + onSetCursor?: (cursor: string) => void + + // Geocode search + onSearchSelect?: (result: GeocodeResult) => void +} + +export function MapControlBar({ + rightOffset = 12, + basemapStyles = [], + activeBasemap = null, + onSelectBasemap, + onZoomIn, + onZoomOut, + subscribeToMap, + onDrawOverlay, + onRemoveOverlay, + onProjectLatLng, + onSetCursor, + onSearchSelect, +}: MapControlBarProps) { + const rootRef = useRef(null) + const [basemapOpen, setBasemapOpen] = useState(false) + const [searchOpen, setSearchOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + + const measure = useMeasure({ + subscribeToMap, + onDrawOverlay, + onRemoveOverlay, + onProjectLatLng, + onSetCursor, + }) + const { results, loading } = useDebouncedSearch(searchOpen ? searchQuery : '') + + useOutsideClick(rootRef, () => { + setBasemapOpen(false) + setSearchOpen(false) + }) + + // Escape closes panels and exits measure mode + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key !== 'Escape') return + setBasemapOpen(false) + setSearchOpen(false) + measure.stop() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [measure.stop]) + + // Clear the query when the search panel closes + useEffect(() => { + if (!searchOpen) setSearchQuery('') + }, [searchOpen]) + + const current = activeBasemap ?? (basemapStyles[0] ?? null) + const hasStyles = basemapStyles.length > 0 + const hasZoom = Boolean(onZoomIn && onZoomOut) + + function toggleBasemap() { + setBasemapOpen((v) => !v) + setSearchOpen(false) + } + function toggleSearch() { + setSearchOpen((v) => !v) + setBasemapOpen(false) + } + function toggleMeasure() { + setBasemapOpen(false) + setSearchOpen(false) + measure.toggle() + } + function handleSearchSelect(r: GeocodeResult) { + setSearchOpen(false) + onSearchSelect?.(r) + } + + return ( + <> +
+
+ {onSearchSelect && ( +
+ +
+ )} + {hasStyles && ( +
+ +
+ )} + {measure.supported && ( +
+ +
+ )} + {hasZoom && ( +
+ + + +
+ )} +
+ + {basemapOpen && hasStyles && ( + { + onSelectBasemap?.(entry) + setBasemapOpen(false) + }} + /> + )} + + {searchOpen && ( + + )} + + {measure.awaitingFirst && ( +
Click two points on the map
+ )} +
+ + {measure.measuring && measure.segment && measure.labelPixel && ( + + )} + + ) +} diff --git a/src/essence/Tools/MapControl/lib/geo/MeasureLabel/MeasureLabel.tsx b/src/essence/Tools/MapControl/lib/geo/MeasureLabel/MeasureLabel.tsx new file mode 100644 index 000000000..7352d57ae --- /dev/null +++ b/src/essence/Tools/MapControl/lib/geo/MeasureLabel/MeasureLabel.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import type { ContainerPoint, LatLng } from '../../types' +import { formatDistance, measureDistance } from '../../utils/measure' + +export type MeasureLabelProps = { + /** The two-point segment being measured. */ + segment: [LatLng, LatLng] + /** Pixel position (map-container-relative) to anchor the label. */ + pixel: ContainerPoint +} + +export function MeasureLabel({ segment, pixel }: MeasureLabelProps) { + return ( +
+ {formatDistance(measureDistance(segment[0], segment[1]))} +
+ ) +} diff --git a/src/essence/Tools/MapControl/lib/geo/SearchPanel/SearchPanel.tsx b/src/essence/Tools/MapControl/lib/geo/SearchPanel/SearchPanel.tsx new file mode 100644 index 000000000..e4c0b6b4f --- /dev/null +++ b/src/essence/Tools/MapControl/lib/geo/SearchPanel/SearchPanel.tsx @@ -0,0 +1,48 @@ +import React, { useEffect, useRef } from 'react' +import type { GeocodeResult } from '../../types' + +export type SearchPanelProps = { + query: string + results: GeocodeResult[] + loading: boolean + onQueryChange: (query: string) => void + onSelect: (result: GeocodeResult) => void +} + +export function SearchPanel({ query, results, loading, onQueryChange, onSelect }: SearchPanelProps) { + const inputRef = useRef(null) + + // Auto-focus the input when the panel mounts (i.e. when search opens). + useEffect(() => { + inputRef.current?.focus() + }, []) + + return ( +
+
+ onQueryChange(e.target.value)} + /> +
+ {loading &&
Searching…
} + {!loading && query.length >= 2 && results.length === 0 && ( +
No results
+ )} + {results.map((r) => ( + + ))} +
+ ) +} diff --git a/src/essence/Tools/MapControl/lib/geo/icons.tsx b/src/essence/Tools/MapControl/lib/geo/icons.tsx new file mode 100644 index 000000000..dd5427056 --- /dev/null +++ b/src/essence/Tools/MapControl/lib/geo/icons.tsx @@ -0,0 +1,54 @@ +import React from 'react' + +// Inline Material/USWDS glyphs — portable, no icon-font/sprite dependency. + +function Icon({ children }: { children: React.ReactNode }) { + return ( + + ) +} + +export function BasemapIcon() { + return ( + + + + ) +} +export function SearchIcon() { + return ( + + + + ) +} +export function RulerIcon() { + return ( + + + + ) +} +export function PlusIcon() { + return ( + + + + ) +} +export function MinusIcon() { + return ( + + + + ) +} diff --git a/src/essence/Tools/MapControl/lib/hooks/useDebouncedSearch.ts b/src/essence/Tools/MapControl/lib/hooks/useDebouncedSearch.ts new file mode 100644 index 000000000..d5bc2e343 --- /dev/null +++ b/src/essence/Tools/MapControl/lib/hooks/useDebouncedSearch.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef, useState } from 'react' +import type { GeocodeResult } from '../types' +import { geocodeSearch } from '../utils/geocode' + +const DEBOUNCE_MS = 300 + +/** Debounced Nominatim search. Returns live results + loading for a query string. */ +export function useDebouncedSearch(query: string): { + results: GeocodeResult[] + loading: boolean +} { + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const timer = useRef | null>(null) + + useEffect(() => { + if (timer.current) clearTimeout(timer.current) + if (query.length < 2) { + setResults([]) + setLoading(false) + return + } + setLoading(true) + timer.current = setTimeout(async () => { + const r = await geocodeSearch(query) + setResults(r) + setLoading(false) + }, DEBOUNCE_MS) + return () => { + if (timer.current) clearTimeout(timer.current) + } + }, [query]) + + return { results, loading } +} diff --git a/src/essence/Tools/MapControl/lib/hooks/useMeasure.ts b/src/essence/Tools/MapControl/lib/hooks/useMeasure.ts new file mode 100644 index 000000000..f5b534ed6 --- /dev/null +++ b/src/essence/Tools/MapControl/lib/hooks/useMeasure.ts @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useState } from 'react' +import type { + ContainerPoint, + LatLng, + MapOverlayOpts, + MapSubscribeHandlers, +} from '../types' +import { MEASURE_STYLE } from '../utils/measure' + +const MEASURE_OVERLAY_ID = 'plugin:mapcontrol:measure' + +export type MeasureCallbacks = { + subscribeToMap?: (handlers: MapSubscribeHandlers) => () => void + onDrawOverlay?: (opts: MapOverlayOpts) => void + onRemoveOverlay?: (id: string) => void + onProjectLatLng?: (latlng: LatLng) => Promise + onSetCursor?: (cursor: string) => void +} + +export type MeasureState = { + /** True only if the map callbacks needed for measuring were provided. */ + supported: boolean + measuring: boolean + /** Two-point segment being measured (or live preview), else null. */ + segment: [LatLng, LatLng] | null + /** Pixel position for the distance label, or null. */ + labelPixel: ContainerPoint | null + /** No points collected yet (show the hint). */ + awaitingFirst: boolean + toggle: () => void + stop: () => void +} + +/** + * Owns measure-mode state: subscribes to map click/move, collects two points, + * draws the line overlay, and projects the midpoint to a pixel for the label. + * All map interaction is via injected callbacks — no MMGIS coupling. + */ +export function useMeasure({ + subscribeToMap, + onDrawOverlay, + onRemoveOverlay, + onProjectLatLng, + onSetCursor, +}: MeasureCallbacks): MeasureState { + const [measuring, setMeasuring] = useState(false) + const [points, setPoints] = useState([]) + const [mouseLatLng, setMouseLatLng] = useState(null) + const [labelPixel, setLabelPixel] = useState(null) + + const supported = Boolean(subscribeToMap && onDrawOverlay && onRemoveOverlay) + + let segment: [LatLng, LatLng] | null = null + if (measuring) { + if (points.length === 2) segment = [points[0], points[1]] + else if (points.length === 1 && mouseLatLng) segment = [points[0], mouseLatLng] + } + + // Subscribe to map click/move while measuring + useEffect(() => { + if (!measuring || !subscribeToMap) return + const unsub = subscribeToMap({ + onClick: (e) => { + if (e?.lat == null || e?.lng == null) return + setPoints((prev) => + prev.length >= 2 + ? [{ lat: e.lat, lng: e.lng }] + : [...prev, { lat: e.lat, lng: e.lng }] + ) + }, + onMouseMove: (e) => { + if (e?.lat != null && e?.lng != null) setMouseLatLng({ lat: e.lat, lng: e.lng }) + }, + }) + if (onSetCursor) onSetCursor('crosshair') + return () => { + unsub() + if (onSetCursor) onSetCursor('') + } + }, [measuring, subscribeToMap, onSetCursor]) + + // Draw / update the measure overlay + useEffect(() => { + if (!onDrawOverlay || !onRemoveOverlay) return + if (!measuring || points.length === 0) { + onRemoveOverlay(MEASURE_OVERLAY_ID) + return + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const features: any[] = points.map((p) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, + properties: {}, + })) + if (segment) { + features.push({ + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [segment[0].lng, segment[0].lat], + [segment[1].lng, segment[1].lat], + ], + }, + properties: {}, + }) + } + onDrawOverlay({ + id: MEASURE_OVERLAY_ID, + geojson: { type: 'FeatureCollection', features }, + style: MEASURE_STYLE, + }) + // mouseLatLng intentionally in deps — redraws the preview line on move. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [measuring, points, mouseLatLng, onDrawOverlay, onRemoveOverlay]) + + // Cleanup overlay on unmount + useEffect(() => () => onRemoveOverlay?.(MEASURE_OVERLAY_ID), [onRemoveOverlay]) + + // Project segment midpoint → pixel for the distance label + useEffect(() => { + if (!segment || !onProjectLatLng) { + setLabelPixel(null) + return + } + const mid = { + lat: (segment[0].lat + segment[1].lat) / 2, + lng: (segment[0].lng + segment[1].lng) / 2, + } + let cancelled = false + Promise.resolve(onProjectLatLng(mid)).then((pt) => { + if (!cancelled && pt?.x != null && pt?.y != null) setLabelPixel({ x: pt.x, y: pt.y }) + }) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [segment?.[0]?.lat, segment?.[0]?.lng, segment?.[1]?.lat, segment?.[1]?.lng, onProjectLatLng]) + + const stop = useCallback(() => { + setMeasuring(false) + setPoints([]) + setMouseLatLng(null) + setLabelPixel(null) + }, []) + const toggle = useCallback(() => { + setMeasuring((prev) => { + setPoints([]) + if (prev) { + setMouseLatLng(null) + setLabelPixel(null) + } + return !prev + }) + }, []) + + return { + supported, + measuring, + segment, + labelPixel, + awaitingFirst: measuring && points.length === 0, + toggle, + stop, + } +} diff --git a/src/essence/Tools/MapControl/lib/hooks/useOutsideClick.ts b/src/essence/Tools/MapControl/lib/hooks/useOutsideClick.ts new file mode 100644 index 000000000..9439e4deb --- /dev/null +++ b/src/essence/Tools/MapControl/lib/hooks/useOutsideClick.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react' +import type { RefObject } from 'react' + +/** Calls onOutside when a mousedown lands outside the ref'd element. */ +export function useOutsideClick( + ref: RefObject, + onOutside: () => void, +): void { + useEffect(() => { + function onDown(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) onOutside() + } + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, [ref, onOutside]) +} diff --git a/src/essence/Tools/MapControl/lib/index.ts b/src/essence/Tools/MapControl/lib/index.ts new file mode 100644 index 000000000..ca9dd84ac --- /dev/null +++ b/src/essence/Tools/MapControl/lib/index.ts @@ -0,0 +1,18 @@ +// Components +export { MapControlBar, type MapControlBarProps } from './geo/MapControlBar/MapControlBar' +export { BasemapPanel, type BasemapPanelProps } from './geo/BasemapPanel/BasemapPanel' +export { SearchPanel, type SearchPanelProps } from './geo/SearchPanel/SearchPanel' +export { MeasureLabel, type MeasureLabelProps } from './geo/MeasureLabel/MeasureLabel' + +// Shared domain types +export type { + LatLng, + BasemapStyle, + GeocodeResult, + MapOverlayOpts, + MapSubscribeHandlers, + ContainerPoint, +} from './types' + +// Side-effect import of compiled styles +import './styles/index.scss' diff --git a/src/essence/Tools/MapControl/lib/styles/components-geo/basemap-panel.scss b/src/essence/Tools/MapControl/lib/styles/components-geo/basemap-panel.scss new file mode 100644 index 000000000..6af3c2b44 --- /dev/null +++ b/src/essence/Tools/MapControl/lib/styles/components-geo/basemap-panel.scss @@ -0,0 +1,72 @@ +.blocks-basemap-panel { + margin-top: var(--theme-spacing-05, 0.25rem); + min-width: 220px; + max-height: 320px; + overflow-y: auto; + display: flex; + flex-direction: column; + background: var(--theme-color-white, #ffffff); + border-radius: var(--theme-radius-lg, 0.5rem); + box-shadow: 0 4px 16px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); + font-family: var(--theme-font-ui); + + &__header { + flex-shrink: 0; + padding: var(--theme-spacing-1, 0.5rem) var(--theme-spacing-105, 0.75rem) var(--theme-spacing-05, 0.25rem); + font-size: var(--theme-font-size-3xs, 0.75rem); + font-weight: var(--theme-font-weight-bold, 700); + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--theme-color-base, #71767a); + } + + &__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--theme-spacing-1, 0.5rem); + width: 100%; + flex-shrink: 0; + padding: var(--theme-spacing-1, 0.5rem) var(--theme-spacing-105, 0.75rem); + background: transparent; + border: none; + cursor: pointer; + text-align: left; + transition: background 0.1s ease; + + &:hover { + background: var(--theme-color-base-lightest, #f1f3f6); + } + + &--active { + background: var(--theme-color-base-lighter, #dfe1e2); + + .blocks-basemap-panel__label { + color: var(--theme-color-primary, #0e7482); + font-weight: var(--theme-font-weight-semibold, 600); + } + + .blocks-basemap-panel__thumb { + border-color: var(--theme-color-primary, #0e7482); + } + } + } + + &__label { + flex: 1; + font-size: var(--theme-font-size-2xs, 0.88rem); + color: var(--theme-color-ink, #1b1b1b); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__thumb { + width: 44px; + height: 28px; + flex-shrink: 0; + border-radius: var(--theme-radius-sm, 2px); + border: 2px solid transparent; + box-shadow: 0 1px 3px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); + } +} diff --git a/src/essence/Tools/MapControl/lib/styles/components-geo/index.scss b/src/essence/Tools/MapControl/lib/styles/components-geo/index.scss new file mode 100644 index 000000000..44019d2d2 --- /dev/null +++ b/src/essence/Tools/MapControl/lib/styles/components-geo/index.scss @@ -0,0 +1,5 @@ +// Aggregator — one @use per component partial in this directory. +@use 'map-control-bar'; +@use 'basemap-panel'; +@use 'search-panel'; +@use 'measure-label'; diff --git a/src/essence/Tools/MapControl/lib/styles/components-geo/map-control-bar.scss b/src/essence/Tools/MapControl/lib/styles/components-geo/map-control-bar.scss new file mode 100644 index 000000000..e64de31af --- /dev/null +++ b/src/essence/Tools/MapControl/lib/styles/components-geo/map-control-bar.scss @@ -0,0 +1,74 @@ +.blocks-map-control { + position: absolute; + top: 18px; + // right is set via inline style — dynamic offset from the top-bar chrome. + display: flex; + flex-direction: column; + align-items: flex-end; + pointer-events: auto; + font-family: var(--theme-font-ui); + z-index: 1002; + + &__bar { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--theme-spacing-1, 0.5rem); + white-space: nowrap; + } + + &__group { + display: flex; + flex-direction: row; + align-items: center; + height: 36px; + overflow: hidden; + background: var(--theme-color-white, #ffffff); + border: 1px solid var(--theme-color-base-lighter, #dfe1e2); + border-radius: var(--theme-radius-md, 0.25rem); + box-shadow: 0 1px 3px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); + } + + &__btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 100%; + padding: 0; + border: none; + cursor: pointer; + flex-shrink: 0; + background: transparent; + color: var(--theme-color-ink, #1b1b1b); + transition: background 0.12s ease, color 0.12s ease; + + &:hover { + background: var(--theme-color-base-lightest, #f1f3f6); + } + + &--active { + color: var(--theme-color-primary, #0e7482); + background: var(--theme-color-base-lighter, #dfe1e2); + } + } + + &__divider { + width: 1px; + height: 24px; + flex-shrink: 0; + background: var(--theme-color-base-lighter, #dfe1e2); + } + + &__hint { + margin-top: var(--theme-spacing-05, 0.25rem); + align-self: flex-end; + padding: var(--theme-spacing-1, 0.5rem) var(--theme-spacing-105, 0.75rem); + background: var(--theme-color-white, #ffffff); + border-radius: var(--theme-radius-md, 0.25rem); + box-shadow: 0 2px 8px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); + font-size: var(--theme-font-size-3xs, 0.75rem); + font-weight: var(--theme-font-weight-semibold, 600); + color: var(--theme-color-base-dark, #565c65); + } +} diff --git a/src/essence/Tools/MapControl/lib/styles/components-geo/measure-label.scss b/src/essence/Tools/MapControl/lib/styles/components-geo/measure-label.scss new file mode 100644 index 000000000..6f2f8120f --- /dev/null +++ b/src/essence/Tools/MapControl/lib/styles/components-geo/measure-label.scss @@ -0,0 +1,15 @@ +.blocks-measure-label { + position: absolute; + transform: translate(-50%, -50%); + padding: var(--theme-spacing-05, 0.25rem) var(--theme-spacing-1, 0.5rem); + background: var(--theme-color-white, #ffffff); + border-radius: var(--theme-radius-lg, 0.5rem); + box-shadow: 0 2px 8px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); + font-family: var(--theme-font-ui); + font-size: var(--theme-font-size-3xs, 0.75rem); + font-weight: var(--theme-font-weight-semibold, 600); + color: var(--theme-color-primary, #0e7482); + white-space: nowrap; + pointer-events: none; + z-index: 1003; +} diff --git a/src/essence/Tools/MapControl/lib/styles/components-geo/search-panel.scss b/src/essence/Tools/MapControl/lib/styles/components-geo/search-panel.scss new file mode 100644 index 000000000..88d7a2e54 --- /dev/null +++ b/src/essence/Tools/MapControl/lib/styles/components-geo/search-panel.scss @@ -0,0 +1,68 @@ +.blocks-search-panel { + margin-top: var(--theme-spacing-05, 0.25rem); + min-width: 220px; + max-height: 320px; + overflow-y: auto; + display: flex; + flex-direction: column; + background: var(--theme-color-white, #ffffff); + border-radius: var(--theme-radius-lg, 0.5rem); + box-shadow: 0 4px 16px var(--theme-color-shadow, rgba(0, 0, 0, 0.15)); + font-family: var(--theme-font-ui); + + &__row { + flex-shrink: 0; + padding: var(--theme-spacing-1, 0.5rem) var(--theme-spacing-105, 0.75rem); + } + + &__input { + width: 100%; + box-sizing: border-box; + padding: var(--theme-spacing-05, 0.25rem) var(--theme-spacing-1, 0.5rem); + border: 1px solid var(--theme-color-base-light, #a9aeb1); + border-radius: var(--theme-radius-md, 0.25rem); + background: var(--theme-color-white, #ffffff); + color: var(--theme-color-ink, #1b1b1b); + font-family: var(--theme-font-ui); + font-size: var(--theme-font-size-2xs, 0.88rem); + outline: none; + + &:focus { + border-color: var(--theme-color-primary, #0e7482); + box-shadow: 0 0 0 2px var(--theme-color-primary-light, #87bac1); + } + } + + &__hint { + flex-shrink: 0; + padding: var(--theme-spacing-1, 0.5rem) var(--theme-spacing-105, 0.75rem); + font-size: var(--theme-font-size-3xs, 0.75rem); + color: var(--theme-color-base, #71767a); + } + + &__result { + display: flex; + align-items: center; + width: 100%; + flex-shrink: 0; + padding: var(--theme-spacing-1, 0.5rem) var(--theme-spacing-105, 0.75rem); + background: transparent; + border: none; + cursor: pointer; + text-align: left; + transition: background 0.1s ease; + + &:hover { + background: var(--theme-color-base-lightest, #f1f3f6); + } + } + + &__label { + flex: 1; + font-size: var(--theme-font-size-2xs, 0.88rem); + color: var(--theme-color-ink, #1b1b1b); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/src/essence/Tools/MapControl/lib/styles/index.scss b/src/essence/Tools/MapControl/lib/styles/index.scss new file mode 100644 index 000000000..998b166ff --- /dev/null +++ b/src/essence/Tools/MapControl/lib/styles/index.scss @@ -0,0 +1,4 @@ +// Theming (USWDS framework + theme tokens + :root --theme-* custom properties) +// is provided by MMGIS's per-theme bundles at dist/.css, loaded at +// runtime. Component partials reference --theme-* directly (Path A). +@forward 'components-geo'; diff --git a/src/essence/Tools/MapControl/lib/styles/scss-imports.d.ts b/src/essence/Tools/MapControl/lib/styles/scss-imports.d.ts new file mode 100644 index 000000000..99508d563 --- /dev/null +++ b/src/essence/Tools/MapControl/lib/styles/scss-imports.d.ts @@ -0,0 +1 @@ +declare module '*.scss' diff --git a/src/essence/Tools/MapControl/lib/types.ts b/src/essence/Tools/MapControl/lib/types.ts new file mode 100644 index 000000000..c115f8033 --- /dev/null +++ b/src/essence/Tools/MapControl/lib/types.ts @@ -0,0 +1,28 @@ +// Shared domain types for the MapControl library. Framework-agnostic — no MMGIS. + +export type LatLng = { lat: number; lng: number } + +export type BasemapStyle = { name: string } + +export type GeocodeResult = { + id: string + displayName: string + lat: number + lng: number + // Nominatim boundingbox: [min_lat, max_lat, min_lon, max_lon] + bbox: [number, number, number, number] +} + +export type MapOverlayOpts = { + id: string + geojson: object + style: object +} + +export type MapSubscribeHandlers = { + onClick: (e: LatLng) => void + onMouseMove: (e: LatLng) => void +} + +/** Pixel point relative to the map container. */ +export type ContainerPoint = { x: number; y: number } diff --git a/src/essence/Tools/MapControl/lib/utils/basemap.ts b/src/essence/Tools/MapControl/lib/utils/basemap.ts new file mode 100644 index 000000000..bb2412ea5 --- /dev/null +++ b/src/essence/Tools/MapControl/lib/utils/basemap.ts @@ -0,0 +1,20 @@ +// Cosmetic gradient swatch for a basemap row, guessed from the style name. + +const BASEMAP_GRADIENTS: Record = { + streets: 'linear-gradient(135deg,#f0ebe0,#ddd0b8,#bca888)', + satellite: 'linear-gradient(135deg,#0c1f2e,#163b20,#0a2e0a)', + outdoors: 'linear-gradient(135deg,#e0f5c8,#88c458,#4a8030)', + light: 'linear-gradient(135deg,#fff,#ebebeb,#d4d4d4)', + dark: 'linear-gradient(135deg,#2c2c3a,#1a1a28,#0c0c18)', + terrain: 'linear-gradient(135deg,#d4edbc,#6ab040,#3d6e28)', + liberty: 'linear-gradient(135deg,#f5e6ca,#d4a853,#8b7355)', + bright: 'linear-gradient(135deg,#dff0fb,#93cce8,#4a9ec4)', + positron: 'linear-gradient(135deg,#f8f8f8,#e0e0e0,#b8b8b8)', +} +const DEFAULT_BG = 'linear-gradient(135deg,#4a90d9,#2a6db8,#1450a0)' + +export function basemapGradient(name: string): string { + const k = (name || '').toLowerCase() + const entry = Object.entries(BASEMAP_GRADIENTS).find(([key]) => k.includes(key)) + return entry ? entry[1] : DEFAULT_BG +} diff --git a/src/essence/Tools/MapControl/lib/utils/geocode.ts b/src/essence/Tools/MapControl/lib/utils/geocode.ts new file mode 100644 index 000000000..14897d83a --- /dev/null +++ b/src/essence/Tools/MapControl/lib/utils/geocode.ts @@ -0,0 +1,31 @@ +import type { GeocodeResult } from '../types' + +/** + * Geocode a place name via Nominatim (OpenStreetMap). No API key required. + * Returns [] on empty/short queries or any failure (never throws). + */ +export async function geocodeSearch(query: string): Promise { + const q = query.trim() + if (q.length < 2) return [] + const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q)}&limit=5` + try { + const res = await fetch(url, { headers: { 'Accept-Language': 'en' } }) + if (!res.ok) return [] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: any[] = await res.json() + return data.map((item) => ({ + id: String(item.place_id), + displayName: item.display_name as string, + lat: parseFloat(item.lat), + lng: parseFloat(item.lon), + bbox: [ + parseFloat(item.boundingbox[0]), + parseFloat(item.boundingbox[1]), + parseFloat(item.boundingbox[2]), + parseFloat(item.boundingbox[3]), + ] as [number, number, number, number], + })) + } catch { + return [] + } +} diff --git a/src/essence/Tools/MapControl/lib/utils/measure.ts b/src/essence/Tools/MapControl/lib/utils/measure.ts new file mode 100644 index 000000000..e2d14cf44 --- /dev/null +++ b/src/essence/Tools/MapControl/lib/utils/measure.ts @@ -0,0 +1,28 @@ +import { distance, point } from '@turf/turf' +import type { LatLng } from '../types' + +/** Leaflet-path style for the measure line + endpoints. */ +export const MEASURE_STYLE = { + color: '#1b1b1b', + weight: 2, + opacity: 1, + dashArray: '2 6', + lineCap: 'round', + radius: 4, + fillColor: '#1b1b1b', + fillOpacity: 1, +} + +export function measureDistance(a: LatLng, b: LatLng): number { + return distance(point([a.lng, a.lat]), point([b.lng, b.lat]), { units: 'meters' }) +} + +export function formatDistance(meters: number): string { + if (meters < 1000) { + const ft = meters * 3.28084 + return `${ft.toFixed(0)} ft (${meters.toFixed(1)} m)` + } + const mi = meters * 0.000621371 + const km = meters / 1000 + return `${mi.toFixed(2)} mi (${km.toFixed(2)} km)` +} diff --git a/src/essence/Tools/MapControl/mapControlHelpers.ts b/src/essence/Tools/MapControl/mapControlHelpers.ts deleted file mode 100644 index 2ab386c74..000000000 --- a/src/essence/Tools/MapControl/mapControlHelpers.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { distance, point } from '@turf/turf' - -export type LatLng = { lat: number; lng: number } -export type BasemapStyle = { name: string } -export type GeocodeResult = { - id: string - displayName: string - lat: number - lng: number - // Nominatim boundingbox: [min_lat, max_lat, min_lon, max_lon] - bbox: [number, number, number, number] -} -export type MapOverlayOpts = { - id: string - geojson: object - style: object -} -export type MapSubscribeHandlers = { - onClick: (e: LatLng) => void - onMouseMove: (e: LatLng) => void -} - -export const MEASURE_STYLE = { - color: '#1b1b1b', - weight: 2, - opacity: 1, - dashArray: '2 6', - lineCap: 'round', - radius: 4, - fillColor: '#1b1b1b', - fillOpacity: 1, -} - -const BASEMAP_GRADIENTS: Record = { - streets: 'linear-gradient(135deg,#f0ebe0,#ddd0b8,#bca888)', - satellite:'linear-gradient(135deg,#0c1f2e,#163b20,#0a2e0a)', - outdoors: 'linear-gradient(135deg,#e0f5c8,#88c458,#4a8030)', - light: 'linear-gradient(135deg,#fff,#ebebeb,#d4d4d4)', - dark: 'linear-gradient(135deg,#2c2c3a,#1a1a28,#0c0c18)', - terrain: 'linear-gradient(135deg,#d4edbc,#6ab040,#3d6e28)', - liberty: 'linear-gradient(135deg,#f5e6ca,#d4a853,#8b7355)', - bright: 'linear-gradient(135deg,#dff0fb,#93cce8,#4a9ec4)', - positron: 'linear-gradient(135deg,#f8f8f8,#e0e0e0,#b8b8b8)', -} -const DEFAULT_BG = 'linear-gradient(135deg,#4a90d9,#2a6db8,#1450a0)' - -export function basemapGradient(name: string): string { - const k = (name || '').toLowerCase() - const entry = Object.entries(BASEMAP_GRADIENTS).find(([key]) => k.includes(key)) - return entry ? entry[1] : DEFAULT_BG -} - -export function measureDistance(a: LatLng, b: LatLng): number { - return distance( - point([a.lng, a.lat]), - point([b.lng, b.lat]), - { units: 'meters' } - ) -} - -export function formatDistance(meters: number): string { - if (meters < 1000) { - const ft = meters * 3.28084 - return `${ft.toFixed(0)} ft (${meters.toFixed(1)} m)` - } - const mi = meters * 0.000621371 - const km = meters / 1000 - return `${mi.toFixed(2)} mi (${km.toFixed(2)} km)` -} - -export async function geocodeSearch(query: string): Promise { - const q = query.trim() - if (q.length < 2) return [] - const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q)}&limit=5` - try { - const res = await fetch(url, { headers: { 'Accept-Language': 'en' } }) - if (!res.ok) return [] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data: any[] = await res.json() - return data.map((item) => ({ - id: String(item.place_id), - displayName: item.display_name as string, - lat: parseFloat(item.lat), - lng: parseFloat(item.lon), - bbox: [ - parseFloat(item.boundingbox[0]), - parseFloat(item.boundingbox[1]), - parseFloat(item.boundingbox[2]), - parseFloat(item.boundingbox[3]), - ] as [number, number, number, number], - })) - } catch { - return [] - } -}