diff --git a/src/essence/Basics/Layers_/Layers_.js b/src/essence/Basics/Layers_/Layers_.js index 077a14c1b..3645f06eb 100644 --- a/src/essence/Basics/Layers_/Layers_.js +++ b/src/essence/Basics/Layers_/Layers_.js @@ -217,6 +217,14 @@ const L_ = { const uuid = L_.asLayerUUID(layerUUID) return L_.layers.on?.[uuid] === true }), + // In-memory layer add/remove (not persisted; lost on reload). + // layerObj requires { name, type, ... }. See mmgisAPI.addLayer. + window.mmgisAPI.provide('layers:addLayer', (layerObj) => + window.mmgisAPI.addLayer(layerObj) + ), + window.mmgisAPI.provide('layers:removeLayer', (layerUUID) => + window.mmgisAPI.removeLayer(layerUUID) + ), window.mmgisAPI.provide('tool:getVars', (toolName) => L_.getToolVars(toolName)), window.mmgisAPI.provide('app:isMobile', () => L_.UserInterface_?.isMobile === true), window.mmgisAPI.provide('app:getMissionPath', () => L_.missionPath), @@ -416,7 +424,11 @@ const L_ = { nextUrl = `/${nextUrl}` } } - if (process.env.NODE_ENV === 'development' && F_.isUrlAbsolute(nextUrl)) { + if ( + process.env.NODE_ENV === 'development' && + process.env.ENABLE_CORS_PROXY === 'true' && + F_.isUrlAbsolute(nextUrl) + ) { try { if (new URL(nextUrl).origin !== window.location.origin) { const rootPath = window?.mmgisglobal?.ROOT_PATH || '' @@ -3351,12 +3363,10 @@ const L_ = { await L_.removeLayerFromLayersData(layerName) } - if (ToolController_.activeToolName === 'LayersTool') { - const layersTool = ToolController_.getTool('LayersTool') - if (layersTool.destroy && layersTool.make) { - layersTool.destroy() - layersTool.make() - } + // Notify subscribers (e.g. the Layers panel) that the layer list + // changed, so they rebuild. Works in classic and modern layouts. + if (window.mmgisAPI) { + window.mmgisAPI.emit('layers:listChanged') } }, addLayerToLayersData: async function (layerName) { diff --git a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts index c3f61ea6b..242e7ea8b 100644 --- a/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts +++ b/src/essence/Basics/MapEngines/Adapters/DeckGLHelpers.ts @@ -10,7 +10,7 @@ import { type TransitionInterpolator, } from '@deck.gl/core' import { GeoJsonLayer, BitmapLayer, PointCloudLayer, ScatterplotLayer } from '@deck.gl/layers' -import { TileLayer, Tile3DLayer, MVTLayer } from '@deck.gl/geo-layers' +import { TileLayer, Tile3DLayer, MVTLayer, _WMSLayer as WMSLayer } from '@deck.gl/geo-layers' import { Tiles3DLoader } from '@loaders.gl/3d-tiles' import { color as parseColor } from 'd3' @@ -182,10 +182,30 @@ function getPropValue(object: unknown, path: string | undefined): unknown { ) } +/** + * Split a full WMS url into its service endpoint and LAYERS list. Mirrors the + * param parsing Leaflet's WMSColorFilter does, so a single layer url renders + * the same way in both engines. + */ +function parseWmsUrl(url: string): { base: string; layers: string[] } { + const qIdx = url.indexOf('?') + const base = qIdx === -1 ? url : url.slice(0, qIdx) + const search = qIdx === -1 ? '' : url.slice(qIdx + 1) + let layersVal = '' + for (const [key, val] of new URLSearchParams(search)) { + if (key.toLowerCase() === 'layers') { + layersVal = val + break + } + } + const layers = layersVal ? layersVal.split(',').filter(Boolean) : [] + return { base, layers } +} + /** * Construct a deck.gl layer from a {@link LayerOptions} spec. - * Supports `'tile'` (TileLayer + BitmapLayer), `'vector'` (GeoJsonLayer), - * and `'pointcloud'` (PointCloudLayer). + * Supports `'tile'` (TileLayer + BitmapLayer, or WMSLayer when tileformat is + * 'wms'), `'vector'` (GeoJsonLayer), and `'pointcloud'` (PointCloudLayer). * DeckGL class names (e.g. `'GeoJsonLayer'`) are automatically normalised * via {@link DECKGL_TYPE_ALIAS}. Use `nativeOptions` for deck.gl-specific props. * @@ -197,6 +217,23 @@ export function buildDeckLayer(id: string, options: LayerOptions): Layer { case 'tile': { const o = options as TileLayerOptions const tileElevation = Number(o.tileElevation) + + // WMS can't be a {z}/{x}/{y} template — the GetMap BBOX is computed + // per view. Route to deck.gl's WMSLayer, parsing the service URL and + // LAYERS out of the full WMS url (same params Leaflet reads). + if (o.tileformat === 'wms') { + const { base, layers } = parseWmsUrl(o.url) + return new WMSLayer({ + id, + data: base, + serviceType: 'wms', + layers, + srs: 'EPSG:3857', + opacity: o.opacity ?? 1, + ...(o.nativeOptions ?? {}), + }) as unknown as Layer + } + return new TileLayer({ id, data: o.url, diff --git a/src/essence/Basics/MapEngines/types/layers.ts b/src/essence/Basics/MapEngines/types/layers.ts index 641c4d1e1..5c0642dec 100644 --- a/src/essence/Basics/MapEngines/types/layers.ts +++ b/src/essence/Basics/MapEngines/types/layers.ts @@ -40,6 +40,8 @@ export interface TileLayerOptions extends LayerOptions { maxNativeZoom?: number tileSize?: number tileElevation?: number + /** 'wms' => deck.gl WMSLayer; else a {z}/{x}/{y} url template. */ + tileformat?: string nativeOptions?: Record } diff --git a/src/essence/Basics/Map_/Map_.js b/src/essence/Basics/Map_/Map_.js index 4703f7ecd..37967ed91 100644 --- a/src/essence/Basics/Map_/Map_.js +++ b/src/essence/Basics/Map_/Map_.js @@ -1599,6 +1599,7 @@ async function makeTileLayer(layerObj, mapContext = null) { ctx.layerRegistry.layer[layerObj.name] = buildDeckLayer(layerObj.name, { type: layerObj.type || 'tile', url: layerUrl, + tileformat: tileFormat, opacity: ctx.layerRegistry.opacity[layerObj.name] || 1, minZoom: parseInt(layerObj.minZoom), maxNativeZoom: parseInt(layerObj.maxNativeZoom), diff --git a/src/essence/Tools/AddTempLayer/AddTempLayerTool.tsx b/src/essence/Tools/AddTempLayer/AddTempLayerTool.tsx new file mode 100644 index 000000000..7712e08ea --- /dev/null +++ b/src/essence/Tools/AddTempLayer/AddTempLayerTool.tsx @@ -0,0 +1,45 @@ +/** + * AddTempLayerTool — MMGIS tool wrapper for the AddTempLayer plugin. + * + * Mounts the React tree into the panel target MMGIS assigns via make(targetId). + * All bus wiring lives in MMGISAddTempLayerAdapter; the lib/ tree is portable. + */ +import React from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { MMGISAddTempLayerAdapter } from './MMGISAddTempLayerAdapter' + +let _root: Root | null = null + +const AddTempLayerTool = { + height: 0, + width: 300 as number | 'full', + targetId: null as string | null, + made: false, + + make(targetId?: string) { + this.targetId = typeof targetId === 'string' ? targetId : 'toolPanel' + const container = document.getElementById(this.targetId) + if (!container) { + console.error(`AddTempLayerTool: container ${this.targetId} not found`) + return + } + _root = createRoot(container) + _root.render() + this.made = true + }, + + destroy() { + if (_root) { + _root.unmount() + _root = null + } + this.targetId = null + this.made = false + }, + + getUrlString() { + return '' + }, +} + +export default AddTempLayerTool diff --git a/src/essence/Tools/AddTempLayer/MMGISAddTempLayerAdapter.tsx b/src/essence/Tools/AddTempLayer/MMGISAddTempLayerAdapter.tsx new file mode 100644 index 000000000..ba421651c --- /dev/null +++ b/src/essence/Tools/AddTempLayer/MMGISAddTempLayerAdapter.tsx @@ -0,0 +1,22 @@ +import React, { useCallback } from 'react' +import { AddTempLayerPanel } from './lib' +import type { AddTempLayerInput } from './lib' +import { buildLayerObj } from './adapters/buildLayerObj' +import { mmgisRequest } from '../_shared/adapters/mmgisAPI' + +/** + * Bridges the portable AddTempLayerPanel to MMGIS via the event bus. The panel + * owns its own form state; this adapter only turns a submitted input into a + * `layers:addLayer` request (session-only add; lost on reload). + */ +export function MMGISAddTempLayerAdapter() { + const onAddLayer = useCallback((input: AddTempLayerInput) => { + const layerObj = buildLayerObj(input) + if (!layerObj) { + return Promise.reject(new Error('Invalid or unrecognized layer URL')) + } + return mmgisRequest('layers:addLayer', layerObj) + }, []) + + return +} diff --git a/src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts b/src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts new file mode 100644 index 000000000..59c09078d --- /dev/null +++ b/src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts @@ -0,0 +1,76 @@ +// MMGIS-coupled transform: AddTempLayerInput → an MMGIS layerObj for the +// generic `layers:addLayer` channel. In-memory only (not persisted), so the +// layer shows in the Layers panel but is lost on reload. +import type { AddTempLayerInput } from '../lib/types' +import { validateUrl, detectLayerType } from '../lib/utils/url' + +// Monotonic suffix so two adds in the same millisecond still get unique names. +let _tempLayerCounter = 0 + +/** Unique, colon-free layer name. addLayer rejects duplicates; colons break keying. */ +export function uniqueLayerName(): string { + return `temp-layer-${Date.now()}-${_tempLayerCounter++}` +} + +/** + * Build an MMGIS layerObj. Returns null if the URL is invalid or the type + * can't be detected. + * + * The URL is passed through VERBATIM — we never construct, rewrite, or + * substitute anything in it. We assume the user pasted a URL already in a + * supported, ready-to-use form (the modal documents those forms); if it isn't, + * it simply won't render, which is the user's responsibility, not ours. + * + * Type → MMGIS mapping: + * geojson → { type: 'vector', url } + * wms → { type: 'tile', tileformat: 'wms', url } — the engine appends + * BBOX/WIDTH/HEIGHT per tile and parses LAYERS/FORMAT from the url. + * wmts / xyz → { type: 'tile', tileformat: 'wmts', url } — a {z}/{x}/{y} + * template the engine fills per tile; must already be one. + */ +export function buildLayerObj( + input: AddTempLayerInput, +): ({ name: string } & Record) | null { + const url = (input.url || '').trim() + if (!validateUrl(url)) return null + + const type = input.type || detectLayerType(url) + if (!type) return null + + const base = { + name: uniqueLayerName(), + display_name: (input.displayName || '').trim() || 'Imported layer', + // visibility:true so the layer renders as soon as it's added. + visibility: true, + initialOpacity: 1, + } + + if (type === 'geojson') { + return { ...base, type: 'vector', url } + } + + if (type === 'wms') { + // WMS renders at any zoom (no fixed native tile grid), so no maxNativeZoom. + return { + ...base, + type: 'tile', + tileformat: 'wms', + url, + minZoom: 0, + maxZoom: 22, + } + } + + // wmts / xyz — a {z}/{x}/{y} tile template the engine fills per tile, + // passed through unchanged. + return { + ...base, + type: 'tile', + tileformat: 'wmts', + url, + minZoom: 0, + maxNativeZoom: 18, + maxZoom: 22, + } + +} diff --git a/src/essence/Tools/AddTempLayer/config.json b/src/essence/Tools/AddTempLayer/config.json new file mode 100644 index 000000000..2c671cf82 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/config.json @@ -0,0 +1,23 @@ +{ + "defaultIcon": "add", + "description": "Add an external layer (WMS/WMTS/XYZ/GeoJSON) to the map for the current session.", + "descriptionFull": { + "title": "Opens a modal to add a temporary external layer from a URL. The layer is session-only and is lost on reload." + }, + "hasVars": false, + "name": "AddTempLayer", + "toolbarPriority": 6, + "separatedTool": true, + "paths": { + "AddTempLayerTool": "essence/Tools/AddTempLayer/AddTempLayerTool" + }, + "metadata": { + "icon": "add", + "requiredOrientation": "vertical", + "compatiblePositions": ["left", "right"], + "preferredPosition": "left", + "modernLayoutSupport": true, + "width": 300, + "height": 0 + } +} diff --git a/src/essence/Tools/AddTempLayer/lib/geo/AddLayerModal/AddLayerModal.tsx b/src/essence/Tools/AddTempLayer/lib/geo/AddLayerModal/AddLayerModal.tsx new file mode 100644 index 000000000..e0c551eef --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/geo/AddLayerModal/AddLayerModal.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { createPortal } from 'react-dom' +import { LinkIcon, CloseIcon } from '../../utils/icons' + +export type AddLayerModalProps = { + /** Current URL field value. */ + url: string + /** Current display-name field value. */ + displayName: string + /** Validation / submission error to surface under the URL field. */ + error?: string | null + /** Disables the submit button while a request is in flight. */ + submitting?: boolean + onUrlChange: (next: string) => void + onDisplayNameChange: (next: string) => void + onSubmit: () => void + onClose: () => void +} + +export function AddLayerModal({ + url, + displayName, + error, + submitting, + onUrlChange, + onDisplayNameChange, + onSubmit, + onClose, +}: AddLayerModalProps) { + return createPortal( +
+
+
+ + + Add layer from URL + + +
+ +
+

+ Paste a layer URL to add it to your layer gallery. +

+ + + + + +
+
    +
  • Color map is fixed (returned with the request)
  • +
  • Min & max values are fixed (if returned)
  • +
  • No date / time controls (WCS-time disabled)
  • +
  • Only the opacity slider is editable
  • +
+
+
+ +
+ +
+
+
, + document.body, + ) +} diff --git a/src/essence/Tools/AddTempLayer/lib/geo/AddTempLayerPanel/AddTempLayerPanel.tsx b/src/essence/Tools/AddTempLayer/lib/geo/AddTempLayerPanel/AddTempLayerPanel.tsx new file mode 100644 index 000000000..dda976a69 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/geo/AddTempLayerPanel/AddTempLayerPanel.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react' +import type { AddTempLayerInput } from '../../types' +import { validateUrl, detectLayerType, validateForType } from '../../utils/url' +import { PlusIcon } from '../../utils/icons' +import { AddLayerModal } from '../AddLayerModal/AddLayerModal' + +export type AddTempLayerPanelProps = { + /** Submit a layer to add. Resolves on success, rejects to show an error. */ + onAddLayer?: (input: AddTempLayerInput) => void | Promise +} + +export function AddTempLayerPanel({ onAddLayer }: AddTempLayerPanelProps) { + const [open, setOpen] = useState(false) + const [url, setUrl] = useState('') + const [displayName, setDisplayName] = useState('') + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + function close() { + setOpen(false) + setError(null) + setSubmitting(false) + } + + function onUrlChange(next: string) { + setUrl(next) + if (error) setError(null) + } + + function handleAdd() { + const trimmedUrl = url.trim() + // Basic URL sanity. + if (!validateUrl(trimmedUrl)) { + setError('Please enter a valid http(s) URL.') + return + } + // Stage 1 — detect the type, or reject as an unsupported kind of URL. + const type = detectLayerType(trimmedUrl) + if (!type) { + setError( + 'We can’t add that layer — this URL isn’t supported. We support XYZ, WMS, WMTS, and GeoJSON.', + ) + return + } + // Stage 2 — validate the URL is structurally usable for that type. + const result = validateForType(type, trimmedUrl) + if (!result.ok) { + setError(result.message || 'This URL isn’t valid for its type.') + return + } + + setError(null) + setSubmitting(true) + Promise.resolve( + onAddLayer?.({ + url: trimmedUrl, + type, + displayName: displayName.trim() || undefined, + }), + ) + .then(() => { + // Keep the modal open so multiple layers can be added; only the + // close (✕) button dismisses it. Clear fields for the next add. + setUrl('') + setDisplayName('') + setSubmitting(false) + setError(null) + }) + .catch((e) => { + setSubmitting(false) + setError(e?.message || 'Failed to add layer.') + }) + } + + return ( +
+ + + {open && ( + + )} +
+ ) +} diff --git a/src/essence/Tools/AddTempLayer/lib/index.ts b/src/essence/Tools/AddTempLayer/lib/index.ts new file mode 100644 index 000000000..abb4e996c --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/index.ts @@ -0,0 +1,9 @@ +// Components +export { AddTempLayerPanel, type AddTempLayerPanelProps } from './geo/AddTempLayerPanel/AddTempLayerPanel' +export { AddLayerModal, type AddLayerModalProps } from './geo/AddLayerModal/AddLayerModal' + +// Shared domain types +export type { TempLayerType, AddTempLayerInput } from './types' + +// Side-effect import of compiled styles +import './styles/index.scss' diff --git a/src/essence/Tools/AddTempLayer/lib/styles/components-geo/add-layer-modal.scss b/src/essence/Tools/AddTempLayer/lib/styles/components-geo/add-layer-modal.scss new file mode 100644 index 000000000..a7027f966 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/styles/components-geo/add-layer-modal.scss @@ -0,0 +1,169 @@ +// "Add layer from URL" modal — portaled to document.body, anchored top-right. + +.blocks-add-layer-modal { + &__overlay { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + // Anchor top-right, just below the top map toolbar (search / zoom / share). + align-items: flex-start; + justify-content: flex-end; + box-sizing: border-box; + padding-top: var(--theme-spacing-8, 64px); + padding-right: var(--theme-spacing-2, 16px); + } + + &__dialog { + display: flex; + flex-direction: column; + width: 372px; + max-width: 100%; + overflow: hidden; + background: var(--theme-color-white, #ffffff); + border-radius: var(--theme-radius-lg, 6px); + box-shadow: 0 8px 32px var(--theme-color-shadow, rgba(0, 0, 0, 0.18)); + font-family: var(--theme-font-ui); + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--theme-spacing-2, 16px); + border-bottom: 1px solid var(--theme-color-base-lighter, #dfe1e2); + } + + &__title { + display: flex; + align-items: center; + gap: var(--theme-spacing-1, 8px); + color: var(--theme-color-ink, #1b1b1b); + font-size: var(--theme-font-size-sm, 16px); + font-weight: var(--theme-font-weight-semibold, 600); + } + + &__close { + display: flex; + align-items: center; + justify-content: center; + padding: var(--theme-spacing-05, 4px); + background: transparent; + border: none; + border-radius: var(--theme-radius-md, 4px); + color: var(--theme-color-base, #71767a); + cursor: pointer; + + &:hover { + background: var(--theme-color-base-lightest, #f6f7f8); + color: var(--theme-color-ink, #1b1b1b); + } + } + + &__body { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-2, 16px); + padding: var(--theme-spacing-2, 16px); + } + + &__help { + margin: 0; + color: var(--theme-color-base-dark, #565c65); + font-size: var(--theme-font-size-2xs, 14px); + } + + &__field { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-05, 4px); + } + + &__label { + color: var(--theme-color-base-dark, #565c65); + font-size: var(--theme-font-size-3xs, 13px); + font-weight: var(--theme-font-weight-semibold, 600); + } + + &__input { + box-sizing: border-box; + width: 100%; + padding: var(--theme-spacing-1, 8px); + background: var(--theme-color-white, #ffffff); + border: 1px solid var(--theme-color-base-light, #a9aeb1); + border-radius: var(--theme-radius-md, 4px); + color: var(--theme-color-ink, #1b1b1b); + font-family: var(--theme-font-ui); + font-size: var(--theme-font-size-2xs, 14px); + outline: none; + + &:focus { + border-color: var(--theme-color-primary, #137480); + box-shadow: 0 0 0 2px var(--theme-color-primary-light, #97d4ea); + } + + &--error { + border-color: var(--theme-color-secondary, #b50909); + + &:focus { + border-color: var(--theme-color-secondary, #b50909); + box-shadow: 0 0 0 2px var(--theme-color-secondary-light, #f8dfe2); + } + } + } + + &__error { + margin-top: var(--theme-spacing-05, 4px); + color: var(--theme-color-secondary, #b50909); + font-size: var(--theme-font-size-3xs, 13px); + // The message can include a long example URL — wrap it instead of + // overflowing the dialog. + overflow-wrap: anywhere; + } + + &__behaviour { + padding: var(--theme-spacing-1, 8px) var(--theme-spacing-2, 16px); + background: var(--theme-color-base-lightest, #f6f7f8); + border-radius: var(--theme-radius-md, 4px); + } + + &__behaviour-list { + margin: 0; + padding-left: var(--theme-spacing-2, 16px); + color: var(--theme-color-base-dark, #565c65); + font-size: var(--theme-font-size-3xs, 13px); + + li { + margin: var(--theme-spacing-05, 4px) 0; + } + } + + &__footer { + display: flex; + justify-content: flex-end; + padding: var(--theme-spacing-2, 16px); + border-top: 1px solid var(--theme-color-base-lighter, #dfe1e2); + } + + &__submit { + padding: var(--theme-spacing-1, 8px) var(--theme-spacing-3, 24px); + background: var(--theme-color-primary, #137480); + border: none; + border-radius: var(--theme-radius-md, 4px); + color: var(--theme-color-white, #ffffff); + font-family: var(--theme-font-ui); + font-size: var(--theme-font-size-2xs, 14px); + font-weight: var(--theme-font-weight-semibold, 600); + cursor: pointer; + transition: background 0.12s ease; + + &:hover { + background: var(--theme-color-primary-dark, #0b4c54); + } + + &:disabled { + opacity: 0.6; + cursor: default; + } + } +} diff --git a/src/essence/Tools/AddTempLayer/lib/styles/components-geo/add-temp-layer.scss b/src/essence/Tools/AddTempLayer/lib/styles/components-geo/add-temp-layer.scss new file mode 100644 index 000000000..c49ac5d68 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/styles/components-geo/add-temp-layer.scss @@ -0,0 +1,41 @@ +// Trigger button (dashed, full-width) that opens the add-layer modal. + +.blocks-add-temp-layer { + box-sizing: border-box; + width: 100%; + padding: var(--theme-spacing-1, 8px); + font-family: var(--theme-font-ui); + + &__trigger { + display: flex; + align-items: center; + justify-content: center; + gap: var(--theme-spacing-1, 8px); + box-sizing: border-box; + width: 100%; + padding: var(--theme-spacing-1, 8px) var(--theme-spacing-2, 16px); + background: transparent; + border: 1px dashed var(--theme-color-base-light, #a9aeb1); + border-radius: var(--theme-radius-md, 4px); + color: var(--theme-color-primary, #137480); + font-family: var(--theme-font-ui); + font-size: var(--theme-font-size-2xs, 14px); + font-weight: var(--theme-font-weight-semibold, 600); + cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease; + + &:hover { + border-color: var(--theme-color-primary-dark, #0b4c54); + color: var(--theme-color-primary-dark, #0b4c54); + background: var(--theme-color-base-lightest, #f6f7f8); + } + + svg { + flex-shrink: 0; + } + } + + &__trigger-label { + white-space: nowrap; + } +} diff --git a/src/essence/Tools/AddTempLayer/lib/styles/components-geo/index.scss b/src/essence/Tools/AddTempLayer/lib/styles/components-geo/index.scss new file mode 100644 index 000000000..00d14fe96 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/styles/components-geo/index.scss @@ -0,0 +1,3 @@ +// Aggregator — one @use per component partial in this directory. +@use 'add-temp-layer'; +@use 'add-layer-modal'; diff --git a/src/essence/Tools/AddTempLayer/lib/styles/index.scss b/src/essence/Tools/AddTempLayer/lib/styles/index.scss new file mode 100644 index 000000000..4ef585cf1 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/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. +@forward 'components-geo'; diff --git a/src/essence/Tools/AddTempLayer/lib/styles/scss-imports.d.ts b/src/essence/Tools/AddTempLayer/lib/styles/scss-imports.d.ts new file mode 100644 index 000000000..99508d563 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/styles/scss-imports.d.ts @@ -0,0 +1 @@ +declare module '*.scss' diff --git a/src/essence/Tools/AddTempLayer/lib/types.ts b/src/essence/Tools/AddTempLayer/lib/types.ts new file mode 100644 index 000000000..a4ef052c6 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/types.ts @@ -0,0 +1,8 @@ +export type TempLayerType = 'xyz' | 'wms' | 'wmts' | 'geojson' + +/** What the modal collects from the user. */ +export interface AddTempLayerInput { + url: string + displayName?: string + type?: TempLayerType +} diff --git a/src/essence/Tools/AddTempLayer/lib/utils/icons.tsx b/src/essence/Tools/AddTempLayer/lib/utils/icons.tsx new file mode 100644 index 000000000..1e6f16df4 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/utils/icons.tsx @@ -0,0 +1,41 @@ +// Inline Material/USWDS glyphs — portable, no sprite/MDI-font coupling. +import React from 'react' + +export function Icon({ size = 18, children }: { size?: number; children: React.ReactNode }) { + return ( + + ) +} + +export function PlusIcon() { + return ( + + + + ) +} + +export function LinkIcon() { + return ( + + + + ) +} + +export function CloseIcon() { + return ( + + + + ) +} diff --git a/src/essence/Tools/AddTempLayer/lib/utils/url.ts b/src/essence/Tools/AddTempLayer/lib/utils/url.ts new file mode 100644 index 000000000..44d291326 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/utils/url.ts @@ -0,0 +1,114 @@ +// Pure URL helpers — no MMGIS imports, no DOM, no network. Inputs in, values out. +import type { TempLayerType } from '../types' + +/** True if the string is a well-formed http(s) URL. */ +export function validateUrl(url: string): boolean { + const u = (url || '').trim() + if (u.length === 0) return false + // Reject internal whitespace — a single URL has none after trimming, so this + // catches two URLs pasted into the field at once (which would otherwise be + // concatenated into one broken request). + if (/\s/.test(u)) return false + try { + const parsed = new URL(u) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + +/** + * Stage 1 — detect which supported type a URL is, so we can build the right + * layerObj. Returns null when it's none of our supported types, in which case + * the caller tells the user we only support XYZ / WMS / WMTS / GeoJSON. + * + * Detection only classifies; it does not check that the URL is well-formed for + * its type — that's `validateForType` (stage 2). Checked most-specific first. + */ +export function detectLayerType(url: string): TempLayerType | null { + const u = (url || '').trim().toLowerCase() + if (u.length === 0) return null + + // GeoJSON — by extension or output format (.json is a weaker fallback) + if ( + /\.geojson(\?|$)/.test(u) || + u.includes('f=geojson') || + u.includes('outputformat=geojson') || + /\.json(\?|$)/.test(u) + ) { + return 'geojson' + } + + // WMTS — OGC KVP markers + if (u.includes('service=wmts') || u.includes('request=gettile')) { + return 'wmts' + } + + // WMS — OGC KVP markers + if (u.includes('service=wms') || u.includes('request=getmap')) { + return 'wms' + } + + // XYZ — a templated raster-tile URL + if (u.includes('{z}') && u.includes('{x}') && u.includes('{y}')) { + return 'xyz' + } + + // None of the supported types. + return null +} + +/** Result of stage-2 structural validation. `message` is user-facing on failure. */ +export interface ValidationResult { + ok: boolean + message?: string +} + +/** A working example URL per type, shown in the error when validation fails. */ +const SAMPLE_URLS = { + xyz: 'https://host/tiles/{z}/{x}/{y}.png', + wms: 'https://host/wms?SERVICE=WMS&REQUEST=GetMap&LAYERS=name', + wmts: 'https://host/wmts?SERVICE=WMTS&REQUEST=GetTile&LAYER=name&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', +} + +/** + * Stage 2 — once the type is known, check the URL is structurally usable for + * that type (the URL is never modified; it must already be in a usable form). + * On failure the message names the problem and gives a working example for that + * specific type. + * + * xyz / wmts → must be a tile template containing {z}/{x}/{y} + * wms → must carry a LAYERS parameter + * geojson → nothing to check; identity is the response body, not the URL + */ +export function validateForType( + type: TempLayerType, + url: string, +): ValidationResult { + const u = (url || '').trim().toLowerCase() + + if (type === 'geojson') { + return { ok: true } + } + + if (type === 'wms') { + if (!/[?&]layers=[^&]+/.test(u)) { + return { + ok: false, + message: `We can’t add this layer — the WMS URL needs a LAYERS parameter. Example: ${SAMPLE_URLS.wms}`, + } + } + return { ok: true } + } + + // xyz / wmts — both render through the template-tile path, so the URL must + // already contain the {z}/{x}/{y} placeholders the engine fills per tile. + if (!(u.includes('{z}') && u.includes('{x}') && u.includes('{y}'))) { + const kind = type === 'wmts' ? 'WMTS' : 'XYZ' + return { + ok: false, + message: `We can’t add this layer — the ${kind} URL needs a {z}/{x}/{y} template. Example: ${SAMPLE_URLS[type]}`, + } + } + return { ok: true } +} diff --git a/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx b/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx index d891198f7..9fb19e6fc 100644 --- a/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx +++ b/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx @@ -37,6 +37,7 @@ export function MMGISLayerManagerAdapter() { useMMGISEvent('layer:visibilityChange', refresh) useMMGISEvent('layer:refreshStatusChange', refresh) useMMGISEvent('layer:opacityChange', refresh) + useMMGISEvent('layers:listChanged', refresh) // 'layers:getAll' is registered by Layers_.fina() during mission load. // Wait for it before doing the initial refresh, otherwise the adapter diff --git a/src/essence/mmgisAPI/mmgisAPI.js b/src/essence/mmgisAPI/mmgisAPI.js index cc4c05e86..d9894440c 100644 --- a/src/essence/mmgisAPI/mmgisAPI.js +++ b/src/essence/mmgisAPI/mmgisAPI.js @@ -121,10 +121,14 @@ var mmgisAPI_ = { true ) - // Then add - if (didSet) + // Then add. resetConfig re-parses configData into L_.layers.data so the + // new layer exists before modifyLayer renders it (matches the + // updateLayersHelper/WebSocket flow). In-memory only — not persisted to + // the backend, so added layers are lost on reload. + if (didSet) { + await L_.resetConfig(configData) await L_.modifyLayer(configData, layerObj.name, 'addLayer') - else { + } else { reject('Failed to add layer.') return } diff --git a/tests/unit/addTempLayerUrl.spec.js b/tests/unit/addTempLayerUrl.spec.js new file mode 100644 index 000000000..79b83bf4e --- /dev/null +++ b/tests/unit/addTempLayerUrl.spec.js @@ -0,0 +1,149 @@ +import { test, expect } from '@playwright/test' +import { + validateUrl, + detectLayerType, + validateForType, +} from '../../src/essence/Tools/AddTempLayer/lib/utils/url.ts' +import { buildLayerObj } from '../../src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts' + +test.describe('AddTempLayer url utils', () => { + test.describe('validateUrl', () => { + for (const url of [ + 'https://example.com/data.geojson', + 'http://example.com/wms?service=WMS', + ]) { + test(`accepts ${url}`, () => expect(validateUrl(url)).toBe(true)) + } + for (const [label, url] of [ + ['empty', ''], + ['two URLs pasted (whitespace)', 'https://a.com/x https://b.com/y'], + ['not a url', 'notaurl'], + ['non-http protocol', 'ftp://example.com/x'], + ]) { + test(`rejects ${label}`, () => expect(validateUrl(url)).toBe(false)) + } + }) + + test.describe('detectLayerType (stage 1)', () => { + // [url, expected type | null] + const cases = [ + // geojson — checked first + ['https://h/data.geojson', 'geojson'], + ['https://h/data.json', 'geojson'], + ['https://h/q?f=geojson', 'geojson'], + ['https://h/wfs?outputFormat=geojson', 'geojson'], + // ordering: a .geojson with "wmts" in the path is still geojson + ['https://h/wmts-archive/roads.geojson', 'geojson'], + // wmts — OGC KVP markers + ['https://h/wmts?service=WMTS&request=GetTile&layer=x', 'wmts'], + ['https://h/x?REQUEST=GetTile', 'wmts'], + // wmts wins over xyz when both markers present + ['https://h/x?service=WMTS&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', 'wmts'], + // wms + ['https://h/wms?service=WMS&request=GetMap&layers=a', 'wms'], + ['https://h/x?request=GetMap', 'wms'], + // xyz — templated tiles, no service markers + ['https://h/tiles/{z}/{x}/{y}.png', 'xyz'], + ['https://{s}.h/{z}/{x}/{y}.png', 'xyz'], + // unsupported → null + ['https://h/5/12/20.png', null], + ['https://h/index.html', null], + ['https://services.arcgisonline.com/arcgis/rest/services/World/MapServer', null], + ['', null], + ] + for (const [url, expected] of cases) { + test(`${url || '(empty)'} -> ${expected}`, () => { + expect(detectLayerType(url)).toBe(expected) + }) + } + }) + + test.describe('validateForType (stage 2)', () => { + const outcome = (r) => (r.ok ? 'ok' : 'fail') + + // [type, url, expected outcome] + const cases = [ + // geojson — nothing to check + ['geojson', 'https://h/data.geojson', 'ok'], + ['geojson', 'https://h/anything', 'ok'], + // wms — needs LAYERS + ['wms', 'https://h/wms?service=WMS&request=GetMap&layers=a', 'ok'], + ['wms', 'https://h/wms?SERVICE=WMS&LAYERS=a', 'ok'], // case-insensitive + ['wms', 'https://h/wms?service=WMS&request=GetMap', 'fail'], + ['wms', 'https://h/wms?service=WMS&layers=', 'fail'], // empty value + // xyz / wmts — need a {z}/{x}/{y} template + ['xyz', 'https://h/{z}/{x}/{y}.png', 'ok'], + ['xyz', 'https://h/5/12/20.png', 'fail'], + ['wmts', 'https://h/x?service=WMTS&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', 'ok'], + ['wmts', 'https://h/x?service=WMTS&request=GetTile&tilematrix=5&tilerow=12&tilecol=20', 'fail'], + ] + for (const [type, url, expected] of cases) { + test(`${type}: ${url} -> ${expected}`, () => { + expect(outcome(validateForType(type, url))).toBe(expected) + }) + } + + test('xyz failure message includes a working {z}/{x}/{y} example', () => { + const r = validateForType('xyz', 'https://h/5/12/20.png') + expect(r.ok).toBe(false) + expect(r.message).toContain('XYZ') + expect(r.message).toContain('Example:') + expect(r.message).toContain('{z}/{x}/{y}') + }) + + test('wmts failure message gives a WMTS example (not the XYZ one)', () => { + const r = validateForType( + 'wmts', + 'https://h/x?service=WMTS&request=GetTile&tilematrix=5', + ) + expect(r.ok).toBe(false) + expect(r.message).toContain('WMTS') + expect(r.message.toLowerCase()).toContain('service=wmts') + }) + + test('wms failure message gives a WMS example with LAYERS', () => { + const r = validateForType('wms', 'https://h/wms?service=WMS') + expect(r.ok).toBe(false) + expect(r.message).toContain('LAYERS') + expect(r.message.toLowerCase()).toContain('service=wms') + }) + }) + + test.describe('buildLayerObj passes the URL through verbatim', () => { + test('geojson -> vector, url unchanged', () => { + const url = 'https://h/data.geojson' + const obj = buildLayerObj({ url, type: 'geojson' }) + expect(obj).toMatchObject({ type: 'vector', url }) + }) + + test('wms -> tile/wms, url unchanged', () => { + const url = 'https://h/wms?service=WMS&request=GetMap&layers=a' + const obj = buildLayerObj({ url, type: 'wms' }) + expect(obj).toMatchObject({ type: 'tile', tileformat: 'wms', url }) + }) + + test('xyz -> tile/wmts, url unchanged (incl. {s})', () => { + const url = 'https://{s}.h/{z}/{x}/{y}.png' + const obj = buildLayerObj({ url, type: 'xyz' }) + // {s} is NOT substituted — passed through exactly as given. + expect(obj).toMatchObject({ type: 'tile', tileformat: 'wmts', url }) + }) + + test('wmts -> tile/wmts, url unchanged', () => { + const url = 'https://h/x?service=WMTS&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}' + const obj = buildLayerObj({ url, type: 'wmts' }) + expect(obj).toMatchObject({ type: 'tile', tileformat: 'wmts', url }) + }) + + test('invalid url -> null', () => { + expect(buildLayerObj({ url: 'notaurl', type: 'geojson' })).toBeNull() + }) + + test('gives a unique, colon-free layer name', () => { + const a = buildLayerObj({ url: 'https://h/a.geojson', type: 'geojson' }) + const b = buildLayerObj({ url: 'https://h/b.geojson', type: 'geojson' }) + expect(a.name).not.toBe(b.name) + expect(a.name).not.toContain(':') + }) + }) +})