diff --git a/configure/src/components/Panel/Modals/NewMissionModal/NewMissionModal.js b/configure/src/components/Panel/Modals/NewMissionModal/NewMissionModal.js index 312f25f49..860d79bda 100644 --- a/configure/src/components/Panel/Modals/NewMissionModal/NewMissionModal.js +++ b/configure/src/components/Panel/Modals/NewMissionModal/NewMissionModal.js @@ -156,6 +156,7 @@ const NewMissionModal = (props) => { const config = { msv: { + view: [39, -98, 4], radius: { major: planetRadius.major, minor: planetRadius.minor, @@ -165,7 +166,7 @@ const NewMissionModal = (props) => { }, }; - if (selectedEngine === "deckgl" && basemapProvider !== "none" && basemapStyle) { + if (basemapProvider !== "none" && basemapStyle) { config.msv.basemap = { provider: basemapProvider, style: basemapStyle, @@ -341,13 +342,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 50d9d4fa2..36b77df22 100644 --- a/configure/src/metaconfigs/tab-ui-config.json +++ b/configure/src/metaconfigs/tab-ui-config.json @@ -263,7 +263,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/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLAdapter.ts index 961c97b71..02f434b95 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 } @@ -823,9 +831,11 @@ export class DeckGLAdapter implements IMapEngine { }, onClick: (info: PickingInfo) => { this._featureClickHandler?.(pickInfoToResult(info)) + this._emitClick(info) }, onHover: (info: PickingInfo) => { this._featureHoverHandler?.(pickInfoToResult(info)) + this._emitMouseMove(info) }, } as any) } @@ -890,9 +900,11 @@ export class DeckGLAdapter implements IMapEngine { layers: [], onClick: (info: PickingInfo) => { this._featureClickHandler?.(pickInfoToResult(info)) + this._emitClick(info) }, onHover: (info: PickingInfo) => { this._featureHoverHandler?.(pickInfoToResult(info)) + this._emitMouseMove(info) }, }) @@ -988,6 +1000,34 @@ export class DeckGLAdapter implements IMapEngine { private _emitEvent(name: string, data?: unknown): void { this._eventListeners.get(name)?.forEach((h) => h(data as PickingInfo)) } + + private _emitClick(info: PickingInfo): void { + if (!info?.coordinate) return + this._eventListeners.get('click')?.forEach( + (h) => h(this._buildNormalizedPointerEvent(info) as unknown as PickingInfo) + ) + } + + private _emitMouseMove(info: PickingInfo): void { + if (!info?.coordinate) return + this._eventListeners.get('mousemove')?.forEach( + (h) => h(this._buildNormalizedPointerEvent(info) as unknown as PickingInfo) + ) + } + + 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, + } + } } export default DeckGLAdapter diff --git a/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts b/src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts index 0d631078b..3d8b4dbe1 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' @@ -79,6 +80,9 @@ export default class LeafletAdapter implements IMapEngine, IMapEn */ private _initOptions: MapInitOptions | null = null + private _basemapLayer: any = null + private _basemapAccessToken: string | undefined + /** * Initialize the Leaflet map instance */ @@ -154,6 +158,10 @@ export default class LeafletAdapter implements IMapEngine, IMapEn if (attributionControl) { attributionControl.remove() } + + if (options.basemap && options.basemap.provider && options.basemap.provider !== 'none') { + this._initBasemapTileLayer(options.basemap) + } } /** @@ -247,6 +255,8 @@ export default class LeafletAdapter implements IMapEngine, IMapEn destroy(): void { if (!this._map) return + this._removeBasemapLayer() + this._eventHandlers.forEach((handler, eventName) => { this._map.off(eventName, handler) }) @@ -268,6 +278,10 @@ export default class LeafletAdapter implements IMapEngine, IMapEn return this._map } + getBasemap(): any { + return this._basemapLayer + } + /** * Get the container element */ @@ -939,4 +953,80 @@ export default class LeafletAdapter implements IMapEngine, IMapEn } return null } + + // ======================================== + // BASEMAP TILE LAYER METHODS + // ======================================== + + 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() + + const specMinZoom = (spec.options as { minZoom?: number }).minZoom + if (typeof specMinZoom === 'number' && specMinZoom > this._map.getMinZoom()) { + this._map.setMinZoom(specMinZoom) + } + } + + 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() + } + + private _removeBasemapLayer(): void { + if (this._basemapLayer && this._map) { + this._map.removeLayer(this._basemapLayer) + } + this._basemapLayer = 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, + minZoom: 1, + attribution: '© Mapbox © OpenStreetMap', + }, + } + } + + if (style.includes('{z}') && style.includes('{x}') && style.includes('{y}')) { + return { url: style, options: {} } + } + + return { + url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + options: { + subdomains: 'abc', + attribution: '© OpenStreetMap contributors', + }, + } + } + + private _inferProvider(styleUrl: string): BasemapOptions['provider'] { + if (styleUrl.startsWith('mapbox://')) return 'mapbox' + return 'maplibre' + } } diff --git a/src/essence/Basics/MapEngines/types/view.ts b/src/essence/Basics/MapEngines/types/view.ts index b0ff0f765..789e91e36 100644 --- a/src/essence/Basics/MapEngines/types/view.ts +++ b/src/essence/Basics/MapEngines/types/view.ts @@ -45,18 +45,38 @@ 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}). */ -export type BasemapProvider = 'mapbox' | 'maplibre' +export type BasemapProvider = 'mapbox' | 'maplibre' | 'none' /** - * 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 b342739c0..1674ff58a 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -51,6 +51,47 @@ const IMAGE_DEFAULT_COLOR_RAMP = 'binary' // Provider cleanup functions for re-initialization let _providerCleanups = [] +let _basemapStyles = [] +let _basemapActiveIndex = 0 + +let _mapClickHandler = null +let _mapMouseMoveHandler = null + +function _resolveBasemapStyles(basemapConfig, engineType) { + const isLeaflet = engineType === MAP_ENGINE.LEAFLET + + 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_DECKGL = [ + { name: 'Streets', style: 'https://tiles.openfreemap.org/styles/liberty' }, + { name: 'Light', style: 'https://tiles.openfreemap.org/styles/positron' }, + { 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' }, + { 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 maplibreDefaults = isLeaflet ? MAPLIBRE_DEFAULTS_LEAFLET : MAPLIBRE_DEFAULTS_DECKGL + + const styles = + basemapConfig.styles && basemapConfig.styles.length > 0 + ? [...basemapConfig.styles] + : basemapConfig.provider === 'mapbox' + ? [...MAPBOX_DEFAULTS] + : [...maplibreDefaults] + + 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, @@ -245,7 +286,77 @@ 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] + }), + 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 + }), + 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') { + 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) + + 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) + } } //Make our layers @@ -308,6 +419,15 @@ let Map_ = { buildToolBar() + const basemapConfig = L_.configData?.msv?.basemap + if (basemapConfig && basemapConfig.provider && basemapConfig.provider !== 'none') { + _basemapStyles = _resolveBasemapStyles(basemapConfig, engineType) + _basemapActiveIndex = 0 + _basemapStyles.forEach(function (s, i) { + if (s.style === basemapConfig.style) _basemapActiveIndex = i + }) + } + TimeControl.updateLayersTime() }, /** 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/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 new file mode 100644 index 000000000..62ee4000b --- /dev/null +++ b/src/essence/Tools/MapControl/config.json @@ -0,0 +1,58 @@ +{ + "defaultIcon": "layers", + "description": "Floating map control bar: basemap switcher, search, measure, zoom.", + "descriptionFull": { + "title": "A floating horizontal toolbar rendered over the map. Each of the four features can be enabled or disabled independently." + }, + "hasVars": true, + "name": "MapControl", + "toolbarPriority": 5, + "separatedTool": true, + "paths": { + "MapControlTool": "essence/Tools/MapControl/MapControlTool" + }, + "metadata": { + "icon": "layers", + "modernLayoutSupport": true + }, + "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/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/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index 51d56e4c6..9d907c441 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -750,6 +750,41 @@ 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'), + + /** 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'), + + /** 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. */