From bf05c0285d3ddf968c1bfb8f1b6e76f864dfa334 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 8 Jun 2026 11:34:59 -0500 Subject: [PATCH 1/8] scaffold new tool --- .../AddTempLayer/AddTempLayerComponent.scss | 6 ++ .../AddTempLayer/AddTempLayerComponent.tsx | 19 +++++++ .../Tools/AddTempLayer/AddTempLayerTool.js | 56 +++++++++++++++++++ .../Tools/AddTempLayer/addTempLayerHelpers.ts | 29 ++++++++++ src/essence/Tools/AddTempLayer/config.json | 14 +++++ 5 files changed, 124 insertions(+) create mode 100644 src/essence/Tools/AddTempLayer/AddTempLayerComponent.scss create mode 100644 src/essence/Tools/AddTempLayer/AddTempLayerComponent.tsx create mode 100644 src/essence/Tools/AddTempLayer/AddTempLayerTool.js create mode 100644 src/essence/Tools/AddTempLayer/addTempLayerHelpers.ts create mode 100644 src/essence/Tools/AddTempLayer/config.json diff --git a/src/essence/Tools/AddTempLayer/AddTempLayerComponent.scss b/src/essence/Tools/AddTempLayer/AddTempLayerComponent.scss new file mode 100644 index 000000000..958888319 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/AddTempLayerComponent.scss @@ -0,0 +1,6 @@ +// AddTempLayer — theme-token styling (scaffold). +// Real styles (modal, fields, error state) come in a later step. + +.add-temp-layer { + font-family: var(--theme-font-ui); +} diff --git a/src/essence/Tools/AddTempLayer/AddTempLayerComponent.tsx b/src/essence/Tools/AddTempLayer/AddTempLayerComponent.tsx new file mode 100644 index 000000000..9fcd41df9 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/AddTempLayerComponent.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { AddTempLayerInput } from './addTempLayerHelpers' +import './AddTempLayerComponent.scss' + +export interface AddTempLayerComponentProps { + /** Submit a layer to add. Wired to mmgisAPI.addLayer by the wrapper. */ + onAddLayer?: (input: AddTempLayerInput) => void +} + +// Scaffold only — renders a placeholder. Button + modal UI come in a later step. +export function AddTempLayerComponent(_props: AddTempLayerComponentProps) { + return ( +
+ {/* TODO: trigger button + "Add layer from URL" modal */} +
+ ) +} + +export default AddTempLayerComponent diff --git a/src/essence/Tools/AddTempLayer/AddTempLayerTool.js b/src/essence/Tools/AddTempLayer/AddTempLayerTool.js new file mode 100644 index 000000000..675d146bb --- /dev/null +++ b/src/essence/Tools/AddTempLayer/AddTempLayerTool.js @@ -0,0 +1,56 @@ +/** + * AddTempLayerTool — MMGIS plugin wrapper for AddTempLayerComponent. + * + * Scaffold only. The wrapper will later translate the component's onAddLayer + * callback into mmgisAPI.addLayer(...) (session-only, lost on reload). + */ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { AddTempLayerComponent } from './AddTempLayerComponent' + +// Bridges the standalone component to MMGIS. No wiring yet. +function ConnectedAddTempLayer() { + return +} + +// Mounts the React tree into the panel target MMGIS assigns via make(targetId). +function InterfaceWithMMGIS(targetId) { + const target = document.getElementById(targetId) + if (!target) { + this.separateFromMMGIS = () => {} + return + } + + const root = createRoot(target) + root.render() + + this.separateFromMMGIS = () => { + root.unmount() + } +} + +const AddTempLayerTool = { + height: 0, + width: 300, + targetId: null, + MMGISInterface: null, + made: false, + + make(targetId) { + this.targetId = targetId + this.MMGISInterface = new InterfaceWithMMGIS(targetId) + this.made = true + }, + + destroy() { + if (this.MMGISInterface) this.MMGISInterface.separateFromMMGIS() + this.MMGISInterface = null + this.made = false + }, + + getUrlString() { + return '' + }, +} + +export default AddTempLayerTool diff --git a/src/essence/Tools/AddTempLayer/addTempLayerHelpers.ts b/src/essence/Tools/AddTempLayer/addTempLayerHelpers.ts new file mode 100644 index 000000000..dfa1c4a05 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/addTempLayerHelpers.ts @@ -0,0 +1,29 @@ +// Pure helpers for AddTempLayer — no MMGIS imports. +// Scaffold only: types + stub signatures. Real logic comes in a later step. + +export type TempLayerType = 'xyz' | 'wms' | 'wmts' | 'geojson' + +/** What the modal collects from the user. */ +export interface AddTempLayerInput { + url: string + displayName?: string + type?: TempLayerType +} + +/** Validate that a string is a usable layer URL. (stub) */ +export function validateUrl(url: string): boolean { + return typeof url === 'string' && url.trim().length > 0 +} + +/** Best-effort detect the layer type from a URL. (stub) */ +export function detectLayerType(_url: string): TempLayerType | null { + return null +} + +/** + * Build an MMGIS layerObj from user input, ready for mmgisAPI.addLayer. (stub) + * The per-type mapping (xyz / wms / wmts / geojson) is filled in later. + */ +export function buildLayerObj(_input: AddTempLayerInput): object | null { + return null +} diff --git a/src/essence/Tools/AddTempLayer/config.json b/src/essence/Tools/AddTempLayer/config.json new file mode 100644 index 000000000..90af83c5e --- /dev/null +++ b/src/essence/Tools/AddTempLayer/config.json @@ -0,0 +1,14 @@ +{ + "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" + } +} From cd09523b6a63960b2d2340112a1b5f6953245a93 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Mon, 8 Jun 2026 15:49:47 -0500 Subject: [PATCH 2/8] addtemp layer wip --- src/essence/Basics/Layers_/Layers_.js | 304 +++++++++--------- .../AddTempLayer/AddTempLayerComponent.scss | 207 +++++++++++- .../AddTempLayer/AddTempLayerComponent.tsx | 195 ++++++++++- .../Tools/AddTempLayer/AddTempLayerTool.js | 25 +- .../Tools/AddTempLayer/addTempLayerHelpers.ts | 97 +++++- .../LayerManager/MMGISLayerManagerAdapter.tsx | 1 + src/essence/mmgisAPI/mmgisAPI.js | 10 +- 7 files changed, 667 insertions(+), 172 deletions(-) diff --git a/src/essence/Basics/Layers_/Layers_.js b/src/essence/Basics/Layers_/Layers_.js index 077a14c1b..62cdbb33a 100644 --- a/src/essence/Basics/Layers_/Layers_.js +++ b/src/essence/Basics/Layers_/Layers_.js @@ -217,6 +217,15 @@ const L_ = { const uuid = L_.asLayerUUID(layerUUID) return L_.layers.on?.[uuid] === true }), + // Generic "add a managed layer" channel (in-memory only — not + // persisted to the backend, so it's 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), @@ -359,17 +368,16 @@ const L_ = { // Generate different endpoints based on type if (type === 'tile') { // Tile endpoint for raster tiles - return `${baseUrl}/collections/${collectionName}/tiles/${ - (layerData && layerData.tileMatrixSet) || 'WebMercatorQuad' - }/{z}/{x}/{y}?assets=asset${bandsParam}${resamplingParam}` + return `${baseUrl}/collections/${collectionName}/tiles/${(layerData && layerData.tileMatrixSet) || 'WebMercatorQuad' + }/{z}/{x}/{y}?assets=asset${bandsParam}${resamplingParam}` } else { // For images, we use preview endpoint // Note: STAC collections are typically designed for tile serving if (layerData && layerData.name) { console.warn( `STAC layer "${layerData.name}" is configured as an image layer. ` + - `STAC collections work best with tile layer type. ` + - `Attempting to use preview endpoint.` + `STAC collections work best with tile layer type. ` + + `Attempting to use preview endpoint.` ) } return `${baseUrl}/collections/${collectionName}/preview?assets=asset${bandsParam}${resamplingParam}` @@ -416,7 +424,10 @@ 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 || '' @@ -506,7 +517,7 @@ const L_ = { if (!ignoreToggleStateChange) { try { $('.drawToolContextMenuHeaderClose').click() - } catch (err) {} + } catch (err) { } } if ( L_.Map_.engine && @@ -569,7 +580,7 @@ const L_ = { for (let sub in L_.layers.attachments[s.name]) { if (L_.layers.attachments[s.name][sub].on) { switch ( - L_.layers.attachments[s.name][sub].type + L_.layers.attachments[s.name][sub].type ) { case 'model': L_.Globe_.litho.addLayer( @@ -603,10 +614,10 @@ const L_ = { ].layer ), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf( - s.name - ) + 1 - + L_._layersOrdered.indexOf( + s.name + ) ) break case 'labels': @@ -639,10 +650,10 @@ const L_ = { ].layer ), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf( - s.name - ) + 1 - + L_._layersOrdered.indexOf( + s.name + ) ) break } @@ -661,8 +672,8 @@ const L_ = { L_.Map_.engine.setLayerZIndex( L_.Map_.nativeLayer(L_.layers.layer[s.name]), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(s.name) + 1 - + L_._layersOrdered.indexOf(s.name) ) } @@ -731,8 +742,8 @@ const L_ = { L_.Map_.engine.setLayerZIndex( L_.Map_.nativeLayer(L_.layers.layer[s.name]), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(s.name) + 1 - + L_._layersOrdered.indexOf(s.name) ) } else { let hadToMake = false @@ -777,8 +788,8 @@ const L_ = { L_.Map_.engine.setLayerZIndex( L_.Map_.nativeLayer(L_.layers.layer[s.name]), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(s.name) + 1 - + L_._layersOrdered.indexOf(s.name) ) } @@ -832,11 +843,11 @@ const L_ = { ?.bearing && s.variables?.markerAttachments ?.bearing.enabled == - null) || - s.variables?.markerAttachments - ?.bearing?.enabled === true + null) || + s.variables?.markerAttachments + ?.bearing?.enabled === true ? s.variables.markerAttachments - .bearing + .bearing : null, }, opacity: L_.layers.opacity[s.name], @@ -964,8 +975,8 @@ const L_ = { L_.Map_.engine.setLayerZIndex( L_.Map_.nativeLayer(sublayer.layer), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(layerName) + 1 - + L_._layersOrdered.indexOf(layerName) ) break case 'labels': @@ -979,8 +990,8 @@ const L_ = { L_.Map_.engine.setLayerZIndex( L_.Map_.nativeLayer(sublayer.layer), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(layerName) + 1 - + L_._layersOrdered.indexOf(layerName) ) L_.setSublayerOpacity(layerName, sublayerName) break @@ -1056,7 +1067,7 @@ const L_ = { ]) { const sublayer = L_.layers.attachments[ - L_.layers.dataFlat[i].name + L_.layers.dataFlat[i].name ][s] if (sublayer.on) { switch (sublayer.type) { @@ -1132,7 +1143,7 @@ const L_ = { console.log(e) console.warn( 'Warning: Failed to add layer to map: ' + - L_.layers.dataFlat[i].name + L_.layers.dataFlat[i].name ) } } @@ -1151,8 +1162,8 @@ const L_ = { engine.setLayerZIndex( L_.Map_.nativeLayer(L_.layers.layer[s.name]), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(s.name) + 1 - + L_._layersOrdered.indexOf(s.name) ) let demUrl = s.demtileurl @@ -1234,10 +1245,10 @@ const L_ = { ?.bearing && s.variables?.markerAttachments ?.bearing.enabled == null) || - s.variables?.markerAttachments?.bearing - ?.enabled === true + s.variables?.markerAttachments?.bearing + ?.enabled === true ? s.variables.markerAttachments - .bearing + .bearing : null, }, opacity: L_.layers.opacity[s.name], @@ -1269,8 +1280,8 @@ const L_ = { geojson.features ? geojson.features : geojson.length > 0 && geojson[0].type === 'Feature' - ? geojson - : null + ? geojson + : null ) if (keepLastN && keepLastN > 0) { layer._sourceGeoJSON.features = @@ -1336,7 +1347,7 @@ const L_ = { setStyle(layer, newStyle) { try { layer.setStyle(newStyle) - } catch (err) {} + } catch (err) { } }, setActiveFeature(layer) { if (layer && layer.feature && layer.options?.layerName) @@ -1433,7 +1444,7 @@ const L_ = { } try { //layer.bringToFront() - } catch (err) {} + } catch (err) { } }, toggleFeature(layer, on) { const display = on ? 'inherit' : 'none' @@ -1781,15 +1792,15 @@ const L_ = { const styleString = (s.color != null ? 'text-shadow: ' + - F_.getTextShadowString(s.color, s.strokeOpacity, s.weight) + - '; ' + F_.getTextShadowString(s.color, s.strokeOpacity, s.weight) + + '; ' : '') + (s.fillColor != null ? 'color: ' + s.fillColor + '; ' : '') + (s.fontSize != null ? 'font-size: ' + s.fontSize + '; ' : '') + (s.rotation != null ? 'transform: rotateZ(' + - parseInt(!isNaN(s.rotation) ? s.rotation : 0) * -1 + - 'deg); ' + parseInt(!isNaN(s.rotation) ? s.rotation : 0) * -1 + + 'deg); ' : '') const id = className + '_' + id1 + '_' + id2 @@ -1813,14 +1824,14 @@ const L_ = { ) .setContent( "
" + - `
" + - `${feature.properties.name.replace(/[<>;{}]/g, '')}`, - '
' + + `
" + + `${feature.properties.name.replace(/[<>;{}]/g, '')}`, + '
' + '
' ) @@ -2073,9 +2084,9 @@ const L_ = { 'color', layer.feature?.properties?.style ?.fillColor || - layer.options?.fillColor || - fillColor || - 'white' + layer.options?.fillColor || + fillColor || + 'white' ) } else if (layer._isArrow) { // Arrow @@ -2191,7 +2202,7 @@ const L_ = { sublayerName ].layer._layers[sll].feature._style ) - } catch (err) {} + } catch (err) { } } } } @@ -2548,8 +2559,8 @@ const L_ = { layer._latlng.lat, layer._latlng.lng, activePoint.zoom || - L_.Map_.mapScaleZoom || - L_.Map_.map.getZoom(), + L_.Map_.mapScaleZoom || + L_.Map_.map.getZoom(), ] } else if (layer._latlngs) { let lat = 0, @@ -2564,8 +2575,8 @@ const L_ = { lng / llflat.length, parseInt( activePoint.zoom || - L_.Map_.mapScaleZoom || - L_.Map_.map.getZoom() + L_.Map_.mapScaleZoom || + L_.Map_.map.getZoom() ), ] } @@ -2665,9 +2676,9 @@ const L_ = { if (!keepTime) { console.warn( 'Warning: The input for keep' + - trimType.capitalizeFirstLetter() + - 'Time is invalid: ' + - keepTime + trimType.capitalizeFirstLetter() + + 'Time is invalid: ' + + keepTime ) return } @@ -2675,7 +2686,7 @@ const L_ = { if (!timePropPath) { console.warn( 'Warning: The input for timePropPath is invalid: ' + - timePropPath + timePropPath ) return } @@ -2685,9 +2696,9 @@ const L_ = { if (isNaN(keepAfterAsDate.getTime())) { console.warn( 'Warning: The input for keep' + - trimType.capitalizeFirstLetter() + - 'Time is invalid: ' + - keepTime + trimType.capitalizeFirstLetter() + + 'Time is invalid: ' + + keepTime ) return } @@ -2714,7 +2725,7 @@ const L_ = { if (isNaN(layerDate.getTime())) { console.warn( 'Warning: The time for the layer is invalid: ' + - layer.feature.properties[timePropPath] + layer.feature.properties[timePropPath] ) continue } @@ -2738,7 +2749,7 @@ const L_ = { } else { console.warn( 'Warning: Unable to trim vector layer as it does not exist: ' + - layerName + layerName ) } }, @@ -2755,12 +2766,12 @@ const L_ = { if (Number.isNaN(Number(keepNum))) { console.warn( 'Warning: Unable to trim vector layer `' + - layerName + - '` as keep' + - keepType.capitalizeFirstLetter() + - 'N == ' + - keepN + - ' and is not a valid integer' + layerName + + '` as keep' + + keepType.capitalizeFirstLetter() + + 'N == ' + + keepN + + ' and is not a valid integer' ) return } @@ -2819,7 +2830,7 @@ const L_ = { } else { console.warn( 'Warning: Unable to trim vector layer as it does not exist: ' + - layerName + layerName ) } }, @@ -2830,10 +2841,10 @@ const L_ = { if (!time) { console.warn( 'Warning: Unable to trim the LineString in vector layer `' + - layerName + - '` as time === ' + - time + - ' and is invalid' + layerName + + '` as time === ' + + time + + ' and is invalid' ) return } @@ -2847,10 +2858,10 @@ const L_ = { if (!timeProp) { console.warn( 'Warning: Unable to trim the LineString in vector layer `' + - layerName + - '` as timeProp === ' + - timeProp + - ' and is invalid' + layerName + + '` as timeProp === ' + + timeProp + + ' and is invalid' ) return } @@ -2859,10 +2870,10 @@ const L_ = { if (Number.isNaN(Number(trimNum))) { console.warn( 'Warning: Unable to trim the LineString in vector layer `' + - layerName + - '` as trimN == ' + - trimN + - ' and is not a valid integer' + layerName + + '` as trimN == ' + + trimN + + ' and is not a valid integer' ) return } @@ -2871,10 +2882,10 @@ const L_ = { if (!TRIM_DIRECTION.includes(startOrEnd)) { console.warn( 'Warning: Unable to trim the LineString in vector layer `' + - layerName + - '` as startOrEnd == ' + - startOrEnd + - ' and is not a valid input value' + layerName + + '` as startOrEnd == ' + + startOrEnd + + ' and is not a valid input value' ) return } @@ -2882,10 +2893,10 @@ const L_ = { if (!time) { console.warn( 'Warning: Unable to trim the LineString in vector layer `' + - layerName + - '` as startOrEnd == ' + - startOrEnd + - ' and is not a valid input value' + layerName + + '` as startOrEnd == ' + + startOrEnd + + ' and is not a valid input value' ) return } @@ -2904,8 +2915,8 @@ const L_ = { if (findNonLineString.length > 0) { console.warn( 'Warning: Unable to trim the vector layer `' + - layerName + - '` as the features contain geometry that is not LineString' + layerName + + '` as the features contain geometry that is not LineString' ) return } @@ -2936,10 +2947,10 @@ const L_ = { if (!feature.properties.hasOwnProperty(timeProp)) { console.warn( 'Warning: Unable to trim the vector layer `' + - layerName + - "` as the the feature's properties object is missing the `" + - timeProp + - '` key' + layerName + + "` as the the feature's properties object is missing the `" + + timeProp + + '` key' ) return } @@ -2984,10 +2995,10 @@ const L_ = { if (!feature.properties.hasOwnProperty(timeProp)) { console.warn( 'Warning: Unable to trim the vector layer `' + - layerName + - "` as the the feature's properties object is missing the key `" + - timeProp + - '` for the end time' + layerName + + "` as the the feature's properties object is missing the key `" + + timeProp + + '` for the end time' ) return } @@ -3027,15 +3038,15 @@ const L_ = { } else { console.warn( 'Warning: Unable to trim the vector layer `' + - layerName + - '` as the layer contains no features' + layerName + + '` as the layer contains no features' ) return } } else { console.warn( 'Warning: Unable to trim vector layer as it does not exist: ' + - layerName + layerName ) } }, @@ -3046,9 +3057,9 @@ const L_ = { if (!inputData) { console.warn( 'Warning: Unable to append to vector layer `' + - layerName + - '` as inputData is invalid: ' + - JSON.stringify(inputData, null, 4) + layerName + + '` as inputData is invalid: ' + + JSON.stringify(inputData, null, 4) ) return false } @@ -3057,11 +3068,11 @@ const L_ = { if (!inputData.properties.hasOwnProperty(timeProp)) { console.warn( 'Warning: Unable to append to the vector layer `' + - layerName + - '` as timeProp === ' + - timeProp + - ' and does not exist as a property in inputData: ' + - JSON.stringify(lastFeature, null, 4) + layerName + + '` as timeProp === ' + + timeProp + + ' and does not exist as a property in inputData: ' + + JSON.stringify(lastFeature, null, 4) ) return false } @@ -3085,9 +3096,9 @@ const L_ = { if (lastFeature.geometry.type !== 'LineString') { console.warn( 'Warning: Unable to append to the vector layer `' + - layerName + - '` as the feature is not a LineStringfeature: ' + - JSON.stringify(lastFeature, null, 4) + layerName + + '` as the feature is not a LineStringfeature: ' + + JSON.stringify(lastFeature, null, 4) ) return false } @@ -3096,11 +3107,11 @@ const L_ = { if (!lastFeature.properties.hasOwnProperty(timeProp)) { console.warn( 'Warning: Unable to append to the vector layer `' + - layerName + - '` as timeProp === ' + - timeProp + - ' and does not exist as a property in the feature: ' + - JSON.stringify(lastFeature, null, 4) + layerName + + '` as timeProp === ' + + timeProp + + ' and does not exist as a property in the feature: ' + + JSON.stringify(lastFeature, null, 4) ) return } @@ -3109,9 +3120,9 @@ const L_ = { if (inputData.geometry.type !== 'LineString') { console.warn( 'Warning: Unable to append to vector layer `' + - layerName + - "` as inputData has the wrong geometry type (must be of type 'LineString'): " + - JSON.stringify(inputData, null, 4) + layerName + + "` as inputData has the wrong geometry type (must be of type 'LineString'): " + + JSON.stringify(inputData, null, 4) ) return false } @@ -3128,9 +3139,9 @@ const L_ = { } else { console.warn( 'Warning: Unable to append to vector layer `' + - layerName + - "` as inputData has the wrong type (must be of type 'Feature'): " + - JSON.stringify(inputData, null, 4) + layerName + + "` as inputData has the wrong type (must be of type 'Feature'): " + + JSON.stringify(inputData, null, 4) ) return false } @@ -3148,7 +3159,7 @@ const L_ = { console.log(e) console.warn( 'Warning: Unable to append LineString to layer as the layer or input data is invalid: ' + - layerName + layerName ) return false } @@ -3165,15 +3176,15 @@ const L_ = { } else { console.warn( 'Warning: Unable to append to the vector layer `' + - layerName + - '` as the layer contains no features' + layerName + + '` as the layer contains no features' ) return false } } else { console.warn( 'Warning: Unable to append to vector layer as it does not exist: ' + - layerName + layerName ) return false } @@ -3199,7 +3210,7 @@ const L_ = { console.log(e) console.warn( 'Warning: Unable to update vector layer as the layer or input data is invalid: ' + - layerName + layerName ) return false } @@ -3209,7 +3220,7 @@ const L_ = { } else { console.warn( 'Warning: Unable to update vector layer as it does not exist: ' + - layerName + layerName ) return false } @@ -3273,8 +3284,8 @@ const L_ = { if (sub === 'image_overlays') { subUpdateLayers[sub].layer.setZIndex( L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(layerName) + 1 - + L_._layersOrdered.indexOf(layerName) ) } } @@ -3351,12 +3362,11 @@ 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 listeners that the layer list changed so they can rebuild + // (e.g. the Layers panel). Decoupled — works in both classic and modern + // layouts and lets any subscriber react. + if (window.mmgisAPI) { + window.mmgisAPI.emit('layers:listChanged') } }, addLayerToLayersData: async function (layerName) { @@ -3827,7 +3837,7 @@ const L_ = { if ( typeof props[p] === 'string' && props[p].toLowerCase().match(/\.(jpeg|jpg|gif|png|xml)$/) != - null + null ) { let url = props[p] const isGif = url.toLowerCase().match(/\.gif$/) != null diff --git a/src/essence/Tools/AddTempLayer/AddTempLayerComponent.scss b/src/essence/Tools/AddTempLayer/AddTempLayerComponent.scss index 958888319..13f9b42b2 100644 --- a/src/essence/Tools/AddTempLayer/AddTempLayerComponent.scss +++ b/src/essence/Tools/AddTempLayer/AddTempLayerComponent.scss @@ -1,6 +1,209 @@ -// AddTempLayer — theme-token styling (scaffold). -// Real styles (modal, fields, error state) come in a later step. +// AddTempLayer — theme-token styling (USWDS). +// Trigger button + "Add layer from URL" modal. No submit wiring yet. .add-temp-layer { font-family: var(--theme-font-ui); + padding: var(--theme-spacing-1); + box-sizing: border-box; + width: 100%; +} + +// ─── Trigger button (dashed, full-width) ────────────────────────────────── + +.add-temp-layer__trigger { + display: flex; + align-items: center; + justify-content: center; + gap: var(--theme-spacing-1); + width: 100%; + box-sizing: border-box; + padding: var(--theme-spacing-1) var(--theme-spacing-2); + background: transparent; + border: 1px dashed var(--theme-color-base-light); + border-radius: var(--theme-radius-md); + color: var(--theme-color-primary); + font-family: var(--theme-font-ui); + font-size: var(--theme-font-size-2xs); + font-weight: var(--theme-font-weight-semibold); + cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease; + + &:hover { + border-color: var(--theme-color-primary-dark); + color: var(--theme-color-primary-dark); + background: var(--theme-color-base-lightest); + } + + svg { + flex-shrink: 0; + } +} + +.add-temp-layer__trigger-label { + white-space: nowrap; +} + +// ─── Modal (portaled to document.body) ──────────────────────────────────── + +.add-temp-layer__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; + padding-top: var(--theme-spacing-8); + padding-right: var(--theme-spacing-2); + box-sizing: border-box; +} + +.add-temp-layer__modal { + width: 372px; + max-width: 100%; + background: var(--theme-color-white); + border-radius: var(--theme-radius-lg); + box-shadow: 0 8px 32px var(--theme-color-shadow); + display: flex; + flex-direction: column; + overflow: hidden; + font-family: var(--theme-font-ui); +} + +.add-temp-layer__modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--theme-spacing-2); + border-bottom: 1px solid var(--theme-color-base-lighter); +} + +.add-temp-layer__modal-title { + display: flex; + align-items: center; + gap: var(--theme-spacing-1); + font-size: var(--theme-font-size-sm); + font-weight: var(--theme-font-weight-semibold); + color: var(--theme-color-ink); +} + +.add-temp-layer__close { + display: flex; + align-items: center; + justify-content: center; + padding: var(--theme-spacing-05); + border: none; + background: transparent; + color: var(--theme-color-base); + cursor: pointer; + border-radius: var(--theme-radius-md); + + &:hover { + background: var(--theme-color-base-lightest); + color: var(--theme-color-ink); + } +} + +.add-temp-layer__modal-body { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-2); + padding: var(--theme-spacing-2); +} + +.add-temp-layer__help { + margin: 0; + font-size: var(--theme-font-size-2xs); + color: var(--theme-color-base-dark); +} + +.add-temp-layer__field { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-05); +} + +.add-temp-layer__label { + font-size: var(--theme-font-size-3xs); + font-weight: var(--theme-font-weight-semibold); + color: var(--theme-color-base-dark); +} + +.add-temp-layer__input { + width: 100%; + box-sizing: border-box; + padding: var(--theme-spacing-1); + border: 1px solid var(--theme-color-base-light); + border-radius: var(--theme-radius-md); + background: var(--theme-color-white); + color: var(--theme-color-ink); + font-family: var(--theme-font-ui); + font-size: var(--theme-font-size-2xs); + outline: none; + + &:focus { + border-color: var(--theme-color-primary); + box-shadow: 0 0 0 2px var(--theme-color-primary-light); + } + + &--error { + border-color: var(--theme-color-secondary); + + &:focus { + border-color: var(--theme-color-secondary); + box-shadow: 0 0 0 2px var(--theme-color-secondary-light); + } + } +} + +.add-temp-layer__error { + margin-top: var(--theme-spacing-05); + font-size: var(--theme-font-size-3xs); + color: var(--theme-color-secondary); +} + +.add-temp-layer__behaviour { + background: var(--theme-color-base-lightest); + border-radius: var(--theme-radius-md); + padding: var(--theme-spacing-1) var(--theme-spacing-2); +} + +.add-temp-layer__behaviour-list { + margin: 0; + padding-left: var(--theme-spacing-2); + font-size: var(--theme-font-size-3xs); + color: var(--theme-color-base-dark); + + li { + margin: var(--theme-spacing-05) 0; + } +} + +.add-temp-layer__modal-footer { + display: flex; + justify-content: flex-end; + padding: var(--theme-spacing-2); + border-top: 1px solid var(--theme-color-base-lighter); +} + +.add-temp-layer__submit { + padding: var(--theme-spacing-1) var(--theme-spacing-3); + border: none; + border-radius: var(--theme-radius-md); + background: var(--theme-color-primary); + color: var(--theme-color-white); + font-family: var(--theme-font-ui); + font-size: var(--theme-font-size-2xs); + font-weight: var(--theme-font-weight-semibold); + cursor: pointer; + transition: background 0.12s ease; + + &:hover { + background: var(--theme-color-primary-dark); + } + + &:disabled { + opacity: 0.6; + cursor: default; + } } diff --git a/src/essence/Tools/AddTempLayer/AddTempLayerComponent.tsx b/src/essence/Tools/AddTempLayer/AddTempLayerComponent.tsx index 9fcd41df9..0f61ca065 100644 --- a/src/essence/Tools/AddTempLayer/AddTempLayerComponent.tsx +++ b/src/essence/Tools/AddTempLayer/AddTempLayerComponent.tsx @@ -1,19 +1,202 @@ -import React from 'react' -import { AddTempLayerInput } from './addTempLayerHelpers' +import React, { useState } from 'react' +import { createPortal } from 'react-dom' +import { AddTempLayerInput, validateUrl, detectLayerType } from './addTempLayerHelpers' import './AddTempLayerComponent.scss' export interface AddTempLayerComponentProps { - /** Submit a layer to add. Wired to mmgisAPI.addLayer by the wrapper. */ + /** Submit a layer to add. Wired to mmgisAPI.addLayer by the wrapper (later). */ onAddLayer?: (input: AddTempLayerInput) => void } -// Scaffold only — renders a placeholder. Button + modal UI come in a later step. -export function AddTempLayerComponent(_props: AddTempLayerComponentProps) { +export function AddTempLayerComponent({ onAddLayer }: AddTempLayerComponentProps) { + 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() + if (!validateUrl(trimmedUrl)) { + setError('Please enter a valid http(s) URL.') + return + } + if (!detectLayerType(trimmedUrl)) { + setError( + 'This link is not supported. Please paste a valid WMS, WMTS, or GeoJSON URL.' + ) + return + } + + setError(null) + setSubmitting(true) + Promise.resolve( + onAddLayer?.({ + url: trimmedUrl, + 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 (
- {/* TODO: trigger button + "Add layer from URL" modal */} + + + {open && + createPortal( +
+
+
+ + + Add layer from URL + + +
+ +
+

+ Provide a WMS, WMTS, or GeoJSON link to add 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 + )}
) } +// ─── Inline Material/USWDS glyphs (portable; no sprite/MDI-font coupling) ─── + +function Icon({ size = 18, children }: { size?: number; children: React.ReactNode }) { + return ( + + ) +} + +function PlusIcon() { + return ( + + + + ) +} + +function LinkIcon() { + return ( + + + + ) +} + +function CloseIcon() { + return ( + + + + ) +} + export default AddTempLayerComponent diff --git a/src/essence/Tools/AddTempLayer/AddTempLayerTool.js b/src/essence/Tools/AddTempLayer/AddTempLayerTool.js index 675d146bb..21179edd6 100644 --- a/src/essence/Tools/AddTempLayer/AddTempLayerTool.js +++ b/src/essence/Tools/AddTempLayer/AddTempLayerTool.js @@ -1,16 +1,31 @@ /** * AddTempLayerTool — MMGIS plugin wrapper for AddTempLayerComponent. * - * Scaffold only. The wrapper will later translate the component's onAddLayer - * callback into mmgisAPI.addLayer(...) (session-only, lost on reload). + * Bus channels consumed (request): + * layers:addLayer (session-only layer add; lost on reload) + * + * Translates the component's onAddLayer callback into an event-bus request. + * No direct core access — the plugin only talks to window.mmgisAPI. */ -import React from 'react' +import React, { useCallback } from 'react' import { createRoot } from 'react-dom/client' import { AddTempLayerComponent } from './AddTempLayerComponent' +import { buildLayerObj } from './addTempLayerHelpers' -// Bridges the standalone component to MMGIS. No wiring yet. +// Bridges the standalone component to MMGIS via the event bus. function ConnectedAddTempLayer() { - return + const onAddLayer = useCallback((input) => { + const layerObj = buildLayerObj(input) + if (!layerObj) { + return Promise.reject(new Error('Invalid or unrecognized layer URL')) + } + const api = window.mmgisAPI + if (!api?.request) return Promise.reject(new Error('mmgisAPI unavailable')) + // Generic layers:addLayer — shows in the Layers panel, lost on reload. + return api.request('layers:addLayer', layerObj) + }, []) + + return } // Mounts the React tree into the panel target MMGIS assigns via make(targetId). diff --git a/src/essence/Tools/AddTempLayer/addTempLayerHelpers.ts b/src/essence/Tools/AddTempLayer/addTempLayerHelpers.ts index dfa1c4a05..df8bde8ef 100644 --- a/src/essence/Tools/AddTempLayer/addTempLayerHelpers.ts +++ b/src/essence/Tools/AddTempLayer/addTempLayerHelpers.ts @@ -1,5 +1,4 @@ // Pure helpers for AddTempLayer — no MMGIS imports. -// Scaffold only: types + stub signatures. Real logic comes in a later step. export type TempLayerType = 'xyz' | 'wms' | 'wmts' | 'geojson' @@ -10,20 +9,100 @@ export interface AddTempLayerInput { type?: TempLayerType } -/** Validate that a string is a usable layer URL. (stub) */ +/** True if the string is a well-formed http(s) URL. */ export function validateUrl(url: string): boolean { - return typeof url === 'string' && url.trim().length > 0 + const u = (url || '').trim() + if (u.length === 0) return false + try { + const parsed = new URL(u) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } } -/** Best-effort detect the layer type from a URL. (stub) */ -export function detectLayerType(_url: string): TempLayerType | null { +/** + * Best-effort detect the layer type from a URL. Returns null if unrecognised. + * Checked most-specific first: templated XYZ, then OGC services, then GeoJSON. + */ +export function detectLayerType(url: string): TempLayerType | null { + const u = (url || '').trim().toLowerCase() + if (u.length === 0) return null + + // Templated raster tiles: must contain {z}/{x}/{y} + if (u.includes('{z}') && u.includes('{x}') && u.includes('{y}')) return 'xyz' + + // OGC services, detected by query params or path + if (u.includes('service=wmts') || u.includes('request=gettile') || /\bwmts\b/.test(u)) { + return 'wmts' + } + if (u.includes('service=wms') || u.includes('request=getmap')) { + return 'wms' + } + + // 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' + } + return null } +// 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 from user input, ready for mmgisAPI.addLayer. (stub) - * The per-type mapping (xyz / wms / wmts / geojson) is filled in later. + * Build an MMGIS layerObj for the generic layers:addLayer channel. + * In-memory only (not persisted), so it shows in the Layers panel but is lost on reload. + * Returns null if the URL is invalid or the type can't be determined. + * + * Type → MMGIS mapping: + * geojson → { type: 'vector' } + * wms → { type: 'tile', tileformat: 'wms' } + * wmts/xyz → { type: 'tile', tileformat: 'wmts' } (tms:false; standard z/x/y) */ -export function buildLayerObj(_input: AddTempLayerInput): object | null { - return null +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 } + } + + // deck.gl's TileLayer has no {s} subdomain concept, so collapse {s} to a + // concrete subdomain ('a' is valid on OSM/Carto and harmless on Leaflet). + const tileUrl = url.replace(/\{s\}/g, 'a') + // MMGIS sets Leaflet tms:true only when tileformat === 'tms'; standard web + // XYZ needs tms:false, so xyz maps to 'wmts'. + const tileformat = type === 'wms' ? 'wms' : 'wmts' + return { + ...base, + type: 'tile', + url: tileUrl, + tileformat, + minZoom: 0, + maxNativeZoom: 18, + maxZoom: 22, + } } diff --git a/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx b/src/essence/Tools/LayerManager/MMGISLayerManagerAdapter.tsx index 75b2aa397..cfc3c1fe5 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 51d56e4c6..bc4a763e4 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 } From 631b8558b55c2ea13da6748e9cd1bd68d99f2d68 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 9 Jun 2026 13:39:31 -0500 Subject: [PATCH 3/8] refractor to monorepo pattern --- .../AddTempLayer/AddTempLayerComponent.scss | 209 ------------------ .../AddTempLayer/AddTempLayerComponent.tsx | 202 ----------------- .../Tools/AddTempLayer/AddTempLayerTool.js | 71 ------ .../Tools/AddTempLayer/AddTempLayerTool.tsx | 45 ++++ .../AddTempLayer/MMGISAddTempLayerAdapter.tsx | 22 ++ .../AddTempLayer/adapters/buildLayerObj.ts | 60 +++++ .../Tools/AddTempLayer/addTempLayerHelpers.ts | 108 --------- src/essence/Tools/AddTempLayer/config.json | 9 + .../lib/geo/AddLayerModal/AddLayerModal.tsx | 111 ++++++++++ .../AddTempLayerPanel/AddTempLayerPanel.tsx | 90 ++++++++ src/essence/Tools/AddTempLayer/lib/index.ts | 9 + .../components-geo/add-layer-modal.scss | 166 ++++++++++++++ .../styles/components-geo/add-temp-layer.scss | 41 ++++ .../lib/styles/components-geo/index.scss | 3 + .../Tools/AddTempLayer/lib/styles/index.scss | 4 + .../AddTempLayer/lib/styles/scss-imports.d.ts | 1 + src/essence/Tools/AddTempLayer/lib/types.ts | 8 + .../Tools/AddTempLayer/lib/utils/icons.tsx | 41 ++++ .../Tools/AddTempLayer/lib/utils/url.ts | 46 ++++ 19 files changed, 656 insertions(+), 590 deletions(-) delete mode 100644 src/essence/Tools/AddTempLayer/AddTempLayerComponent.scss delete mode 100644 src/essence/Tools/AddTempLayer/AddTempLayerComponent.tsx delete mode 100644 src/essence/Tools/AddTempLayer/AddTempLayerTool.js create mode 100644 src/essence/Tools/AddTempLayer/AddTempLayerTool.tsx create mode 100644 src/essence/Tools/AddTempLayer/MMGISAddTempLayerAdapter.tsx create mode 100644 src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts delete mode 100644 src/essence/Tools/AddTempLayer/addTempLayerHelpers.ts create mode 100644 src/essence/Tools/AddTempLayer/lib/geo/AddLayerModal/AddLayerModal.tsx create mode 100644 src/essence/Tools/AddTempLayer/lib/geo/AddTempLayerPanel/AddTempLayerPanel.tsx create mode 100644 src/essence/Tools/AddTempLayer/lib/index.ts create mode 100644 src/essence/Tools/AddTempLayer/lib/styles/components-geo/add-layer-modal.scss create mode 100644 src/essence/Tools/AddTempLayer/lib/styles/components-geo/add-temp-layer.scss create mode 100644 src/essence/Tools/AddTempLayer/lib/styles/components-geo/index.scss create mode 100644 src/essence/Tools/AddTempLayer/lib/styles/index.scss create mode 100644 src/essence/Tools/AddTempLayer/lib/styles/scss-imports.d.ts create mode 100644 src/essence/Tools/AddTempLayer/lib/types.ts create mode 100644 src/essence/Tools/AddTempLayer/lib/utils/icons.tsx create mode 100644 src/essence/Tools/AddTempLayer/lib/utils/url.ts diff --git a/src/essence/Tools/AddTempLayer/AddTempLayerComponent.scss b/src/essence/Tools/AddTempLayer/AddTempLayerComponent.scss deleted file mode 100644 index 13f9b42b2..000000000 --- a/src/essence/Tools/AddTempLayer/AddTempLayerComponent.scss +++ /dev/null @@ -1,209 +0,0 @@ -// AddTempLayer — theme-token styling (USWDS). -// Trigger button + "Add layer from URL" modal. No submit wiring yet. - -.add-temp-layer { - font-family: var(--theme-font-ui); - padding: var(--theme-spacing-1); - box-sizing: border-box; - width: 100%; -} - -// ─── Trigger button (dashed, full-width) ────────────────────────────────── - -.add-temp-layer__trigger { - display: flex; - align-items: center; - justify-content: center; - gap: var(--theme-spacing-1); - width: 100%; - box-sizing: border-box; - padding: var(--theme-spacing-1) var(--theme-spacing-2); - background: transparent; - border: 1px dashed var(--theme-color-base-light); - border-radius: var(--theme-radius-md); - color: var(--theme-color-primary); - font-family: var(--theme-font-ui); - font-size: var(--theme-font-size-2xs); - font-weight: var(--theme-font-weight-semibold); - cursor: pointer; - transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease; - - &:hover { - border-color: var(--theme-color-primary-dark); - color: var(--theme-color-primary-dark); - background: var(--theme-color-base-lightest); - } - - svg { - flex-shrink: 0; - } -} - -.add-temp-layer__trigger-label { - white-space: nowrap; -} - -// ─── Modal (portaled to document.body) ──────────────────────────────────── - -.add-temp-layer__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; - padding-top: var(--theme-spacing-8); - padding-right: var(--theme-spacing-2); - box-sizing: border-box; -} - -.add-temp-layer__modal { - width: 372px; - max-width: 100%; - background: var(--theme-color-white); - border-radius: var(--theme-radius-lg); - box-shadow: 0 8px 32px var(--theme-color-shadow); - display: flex; - flex-direction: column; - overflow: hidden; - font-family: var(--theme-font-ui); -} - -.add-temp-layer__modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--theme-spacing-2); - border-bottom: 1px solid var(--theme-color-base-lighter); -} - -.add-temp-layer__modal-title { - display: flex; - align-items: center; - gap: var(--theme-spacing-1); - font-size: var(--theme-font-size-sm); - font-weight: var(--theme-font-weight-semibold); - color: var(--theme-color-ink); -} - -.add-temp-layer__close { - display: flex; - align-items: center; - justify-content: center; - padding: var(--theme-spacing-05); - border: none; - background: transparent; - color: var(--theme-color-base); - cursor: pointer; - border-radius: var(--theme-radius-md); - - &:hover { - background: var(--theme-color-base-lightest); - color: var(--theme-color-ink); - } -} - -.add-temp-layer__modal-body { - display: flex; - flex-direction: column; - gap: var(--theme-spacing-2); - padding: var(--theme-spacing-2); -} - -.add-temp-layer__help { - margin: 0; - font-size: var(--theme-font-size-2xs); - color: var(--theme-color-base-dark); -} - -.add-temp-layer__field { - display: flex; - flex-direction: column; - gap: var(--theme-spacing-05); -} - -.add-temp-layer__label { - font-size: var(--theme-font-size-3xs); - font-weight: var(--theme-font-weight-semibold); - color: var(--theme-color-base-dark); -} - -.add-temp-layer__input { - width: 100%; - box-sizing: border-box; - padding: var(--theme-spacing-1); - border: 1px solid var(--theme-color-base-light); - border-radius: var(--theme-radius-md); - background: var(--theme-color-white); - color: var(--theme-color-ink); - font-family: var(--theme-font-ui); - font-size: var(--theme-font-size-2xs); - outline: none; - - &:focus { - border-color: var(--theme-color-primary); - box-shadow: 0 0 0 2px var(--theme-color-primary-light); - } - - &--error { - border-color: var(--theme-color-secondary); - - &:focus { - border-color: var(--theme-color-secondary); - box-shadow: 0 0 0 2px var(--theme-color-secondary-light); - } - } -} - -.add-temp-layer__error { - margin-top: var(--theme-spacing-05); - font-size: var(--theme-font-size-3xs); - color: var(--theme-color-secondary); -} - -.add-temp-layer__behaviour { - background: var(--theme-color-base-lightest); - border-radius: var(--theme-radius-md); - padding: var(--theme-spacing-1) var(--theme-spacing-2); -} - -.add-temp-layer__behaviour-list { - margin: 0; - padding-left: var(--theme-spacing-2); - font-size: var(--theme-font-size-3xs); - color: var(--theme-color-base-dark); - - li { - margin: var(--theme-spacing-05) 0; - } -} - -.add-temp-layer__modal-footer { - display: flex; - justify-content: flex-end; - padding: var(--theme-spacing-2); - border-top: 1px solid var(--theme-color-base-lighter); -} - -.add-temp-layer__submit { - padding: var(--theme-spacing-1) var(--theme-spacing-3); - border: none; - border-radius: var(--theme-radius-md); - background: var(--theme-color-primary); - color: var(--theme-color-white); - font-family: var(--theme-font-ui); - font-size: var(--theme-font-size-2xs); - font-weight: var(--theme-font-weight-semibold); - cursor: pointer; - transition: background 0.12s ease; - - &:hover { - background: var(--theme-color-primary-dark); - } - - &:disabled { - opacity: 0.6; - cursor: default; - } -} diff --git a/src/essence/Tools/AddTempLayer/AddTempLayerComponent.tsx b/src/essence/Tools/AddTempLayer/AddTempLayerComponent.tsx deleted file mode 100644 index 0f61ca065..000000000 --- a/src/essence/Tools/AddTempLayer/AddTempLayerComponent.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import React, { useState } from 'react' -import { createPortal } from 'react-dom' -import { AddTempLayerInput, validateUrl, detectLayerType } from './addTempLayerHelpers' -import './AddTempLayerComponent.scss' - -export interface AddTempLayerComponentProps { - /** Submit a layer to add. Wired to mmgisAPI.addLayer by the wrapper (later). */ - onAddLayer?: (input: AddTempLayerInput) => void -} - -export function AddTempLayerComponent({ onAddLayer }: AddTempLayerComponentProps) { - 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() - if (!validateUrl(trimmedUrl)) { - setError('Please enter a valid http(s) URL.') - return - } - if (!detectLayerType(trimmedUrl)) { - setError( - 'This link is not supported. Please paste a valid WMS, WMTS, or GeoJSON URL.' - ) - return - } - - setError(null) - setSubmitting(true) - Promise.resolve( - onAddLayer?.({ - url: trimmedUrl, - 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 && - createPortal( -
-
-
- - - Add layer from URL - - -
- -
-

- Provide a WMS, WMTS, or GeoJSON link to add 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 - )} -
- ) -} - -// ─── Inline Material/USWDS glyphs (portable; no sprite/MDI-font coupling) ─── - -function Icon({ size = 18, children }: { size?: number; children: React.ReactNode }) { - return ( - - ) -} - -function PlusIcon() { - return ( - - - - ) -} - -function LinkIcon() { - return ( - - - - ) -} - -function CloseIcon() { - return ( - - - - ) -} - -export default AddTempLayerComponent diff --git a/src/essence/Tools/AddTempLayer/AddTempLayerTool.js b/src/essence/Tools/AddTempLayer/AddTempLayerTool.js deleted file mode 100644 index 21179edd6..000000000 --- a/src/essence/Tools/AddTempLayer/AddTempLayerTool.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * AddTempLayerTool — MMGIS plugin wrapper for AddTempLayerComponent. - * - * Bus channels consumed (request): - * layers:addLayer (session-only layer add; lost on reload) - * - * Translates the component's onAddLayer callback into an event-bus request. - * No direct core access — the plugin only talks to window.mmgisAPI. - */ -import React, { useCallback } from 'react' -import { createRoot } from 'react-dom/client' -import { AddTempLayerComponent } from './AddTempLayerComponent' -import { buildLayerObj } from './addTempLayerHelpers' - -// Bridges the standalone component to MMGIS via the event bus. -function ConnectedAddTempLayer() { - const onAddLayer = useCallback((input) => { - const layerObj = buildLayerObj(input) - if (!layerObj) { - return Promise.reject(new Error('Invalid or unrecognized layer URL')) - } - const api = window.mmgisAPI - if (!api?.request) return Promise.reject(new Error('mmgisAPI unavailable')) - // Generic layers:addLayer — shows in the Layers panel, lost on reload. - return api.request('layers:addLayer', layerObj) - }, []) - - return -} - -// Mounts the React tree into the panel target MMGIS assigns via make(targetId). -function InterfaceWithMMGIS(targetId) { - const target = document.getElementById(targetId) - if (!target) { - this.separateFromMMGIS = () => {} - return - } - - const root = createRoot(target) - root.render() - - this.separateFromMMGIS = () => { - root.unmount() - } -} - -const AddTempLayerTool = { - height: 0, - width: 300, - targetId: null, - MMGISInterface: null, - made: false, - - make(targetId) { - this.targetId = targetId - this.MMGISInterface = new InterfaceWithMMGIS(targetId) - this.made = true - }, - - destroy() { - if (this.MMGISInterface) this.MMGISInterface.separateFromMMGIS() - this.MMGISInterface = null - this.made = false - }, - - getUrlString() { - return '' - }, -} - -export default AddTempLayerTool 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..b54a66f0d --- /dev/null +++ b/src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts @@ -0,0 +1,60 @@ +// 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 determined. + * + * Type → MMGIS mapping: + * geojson → { type: 'vector' } + * wms → { type: 'tile', tileformat: 'wms' } + * wmts/xyz → { type: 'tile', tileformat: 'wmts' } (tms:false; standard z/x/y) + */ +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 } + } + + // deck.gl's TileLayer has no {s} subdomain concept, so collapse {s} to a + // concrete subdomain ('a' is valid on OSM/Carto and harmless on Leaflet). + const tileUrl = url.replace(/\{s\}/g, 'a') + // MMGIS sets Leaflet tms:true only when tileformat === 'tms'; standard web + // XYZ needs tms:false, so xyz maps to 'wmts'. + const tileformat = type === 'wms' ? 'wms' : 'wmts' + return { + ...base, + type: 'tile', + url: tileUrl, + tileformat, + minZoom: 0, + maxNativeZoom: 18, + maxZoom: 22, + } +} diff --git a/src/essence/Tools/AddTempLayer/addTempLayerHelpers.ts b/src/essence/Tools/AddTempLayer/addTempLayerHelpers.ts deleted file mode 100644 index df8bde8ef..000000000 --- a/src/essence/Tools/AddTempLayer/addTempLayerHelpers.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Pure helpers for AddTempLayer — no MMGIS imports. - -export type TempLayerType = 'xyz' | 'wms' | 'wmts' | 'geojson' - -/** What the modal collects from the user. */ -export interface AddTempLayerInput { - url: string - displayName?: string - type?: TempLayerType -} - -/** 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 - try { - const parsed = new URL(u) - return parsed.protocol === 'http:' || parsed.protocol === 'https:' - } catch { - return false - } -} - -/** - * Best-effort detect the layer type from a URL. Returns null if unrecognised. - * Checked most-specific first: templated XYZ, then OGC services, then GeoJSON. - */ -export function detectLayerType(url: string): TempLayerType | null { - const u = (url || '').trim().toLowerCase() - if (u.length === 0) return null - - // Templated raster tiles: must contain {z}/{x}/{y} - if (u.includes('{z}') && u.includes('{x}') && u.includes('{y}')) return 'xyz' - - // OGC services, detected by query params or path - if (u.includes('service=wmts') || u.includes('request=gettile') || /\bwmts\b/.test(u)) { - return 'wmts' - } - if (u.includes('service=wms') || u.includes('request=getmap')) { - return 'wms' - } - - // 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' - } - - return null -} - -// 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 for the generic layers:addLayer channel. - * In-memory only (not persisted), so it shows in the Layers panel but is lost on reload. - * Returns null if the URL is invalid or the type can't be determined. - * - * Type → MMGIS mapping: - * geojson → { type: 'vector' } - * wms → { type: 'tile', tileformat: 'wms' } - * wmts/xyz → { type: 'tile', tileformat: 'wmts' } (tms:false; standard z/x/y) - */ -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 } - } - - // deck.gl's TileLayer has no {s} subdomain concept, so collapse {s} to a - // concrete subdomain ('a' is valid on OSM/Carto and harmless on Leaflet). - const tileUrl = url.replace(/\{s\}/g, 'a') - // MMGIS sets Leaflet tms:true only when tileformat === 'tms'; standard web - // XYZ needs tms:false, so xyz maps to 'wmts'. - const tileformat = type === 'wms' ? 'wms' : 'wmts' - return { - ...base, - type: 'tile', - url: tileUrl, - tileformat, - minZoom: 0, - maxNativeZoom: 18, - maxZoom: 22, - } -} diff --git a/src/essence/Tools/AddTempLayer/config.json b/src/essence/Tools/AddTempLayer/config.json index 90af83c5e..2c671cf82 100644 --- a/src/essence/Tools/AddTempLayer/config.json +++ b/src/essence/Tools/AddTempLayer/config.json @@ -10,5 +10,14 @@ "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..d2cf730b9 --- /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 + + +
+ +
+

+ Provide a WMS, WMTS, or GeoJSON link to add 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..0c171cb5c --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/geo/AddTempLayerPanel/AddTempLayerPanel.tsx @@ -0,0 +1,90 @@ +import React, { useState } from 'react' +import type { AddTempLayerInput } from '../../types' +import { validateUrl, detectLayerType } 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() + if (!validateUrl(trimmedUrl)) { + setError('Please enter a valid http(s) URL.') + return + } + if (!detectLayerType(trimmedUrl)) { + setError( + 'This link is not supported. Please paste a valid WMS, WMTS, or GeoJSON URL.', + ) + return + } + + setError(null) + setSubmitting(true) + Promise.resolve( + onAddLayer?.({ + url: trimmedUrl, + 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..0da8d7cc6 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/styles/components-geo/add-layer-modal.scss @@ -0,0 +1,166 @@ +// "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); + } + + &__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..14b335b34 --- /dev/null +++ b/src/essence/Tools/AddTempLayer/lib/utils/url.ts @@ -0,0 +1,46 @@ +// 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 + try { + const parsed = new URL(u) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + +/** + * Best-effort detect the layer type from a URL. Returns null if unrecognised. + * Checked most-specific first: templated XYZ, then OGC services, then GeoJSON. + */ +export function detectLayerType(url: string): TempLayerType | null { + const u = (url || '').trim().toLowerCase() + if (u.length === 0) return null + + // Templated raster tiles: must contain {z}/{x}/{y} + if (u.includes('{z}') && u.includes('{x}') && u.includes('{y}')) return 'xyz' + + // OGC services, detected by query params or path + if (u.includes('service=wmts') || u.includes('request=gettile') || /\bwmts\b/.test(u)) { + return 'wmts' + } + if (u.includes('service=wms') || u.includes('request=getmap')) { + return 'wms' + } + + // 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' + } + + return null +} From 6480f2ba0291c8b84636bbdbea959877cbeccadc Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 9 Jun 2026 13:51:54 -0500 Subject: [PATCH 4/8] wms support --- .../MapEngines/Adapters/DeckGLHelpers.ts | 43 +++++++++++++++++-- src/essence/Basics/MapEngines/types/layers.ts | 30 +++++++------ src/essence/Basics/Map_/Map_.js | 1 + 3 files changed, 59 insertions(+), 15 deletions(-) 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..63cc0c28b 100644 --- a/src/essence/Basics/MapEngines/types/layers.ts +++ b/src/essence/Basics/MapEngines/types/layers.ts @@ -36,12 +36,18 @@ export interface TileLayerOptions extends LayerOptions { tms?: boolean subdomains?: string | string[] attribution?: string - time?: string - maxNativeZoom?: number - tileSize?: number - tileElevation?: number - nativeOptions?: Record -} + time?: string + maxNativeZoom?: number + tileSize?: number + tileElevation?: number + /** + * Tile request scheme. 'wms' routes to deck.gl's WMSLayer (the GetMap BBOX + * is computed by deck per view). Anything else is treated as a + * {z}/{x}/{y} URL template (xyz / wmts-rest / tms). + */ + tileformat?: string + nativeOptions?: Record +} /** * Options for GeoJSON vector layers. @@ -92,12 +98,12 @@ export interface ImageOverlayOptions extends LayerOptions { /** * Options for vector tile (MVT/protobuf) layers. */ -export interface VectorTileLayerOptions extends LayerOptions { - vectorTileLayerStyles?: Record - maxNativeZoom?: number - attribution?: string - nativeOptions?: Record -} +export interface VectorTileLayerOptions extends LayerOptions { + vectorTileLayerStyles?: Record + maxNativeZoom?: number + attribution?: string + nativeOptions?: Record +} /** * Options for point cloud layers. 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), From a2fb1430205d71747c40ed1bcb0585ad9eab8352 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 9 Jun 2026 14:03:11 -0500 Subject: [PATCH 5/8] build url for wms --- .../AddTempLayer/adapters/buildLayerObj.ts | 93 ++++++++++++++++--- .../Tools/AddTempLayer/lib/utils/url.ts | 4 + 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts b/src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts index b54a66f0d..b95efaff7 100644 --- a/src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts +++ b/src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts @@ -16,10 +16,14 @@ export function uniqueLayerName(): string { * Build an MMGIS layerObj. Returns null if the URL is invalid or the type * can't be determined. * - * Type → MMGIS mapping: - * geojson → { type: 'vector' } - * wms → { type: 'tile', tileformat: 'wms' } - * wmts/xyz → { type: 'tile', tileformat: 'wmts' } (tms:false; standard z/x/y) + * Type → MMGIS mapping (consumed by the merged core WMS support): + * geojson → { type: 'vector', url } + * wms → { type: 'tile', tileformat: 'wms', url } — full WMS url passed + * through; Leaflet's WMSColorFilter and deck.gl's WMSLayer both + * parse LAYERS/FORMAT/… out of it. + * wmts → { type: 'tile', tileformat: 'wmts', url } — KVP rewritten to a + * {z}/{y}/{x} template so it renders via the template-tile path. + * xyz → { type: 'tile', tileformat: 'wmts', url } — {z}/{x}/{y} template. */ export function buildLayerObj( input: AddTempLayerInput, @@ -42,19 +46,84 @@ export function buildLayerObj( return { ...base, type: 'vector', url } } - // deck.gl's TileLayer has no {s} subdomain concept, so collapse {s} to a - // concrete subdomain ('a' is valid on OSM/Carto and harmless on Leaflet). - const tileUrl = url.replace(/\{s\}/g, 'a') - // MMGIS sets Leaflet tms:true only when tileformat === 'tms'; standard web - // XYZ needs tms:false, so xyz maps to 'wmts'. - const tileformat = type === 'wms' ? 'wms' : 'wmts' + if (type === 'wms') { + // Pass the full WMS url through unchanged: both engines parse the WMS + // params (LAYERS, FORMAT, …) out of it. WMS renders at any zoom (no + // fixed native tile grid), so no maxNativeZoom. + return { + ...base, + type: 'tile', + tileformat: 'wms', + url, + minZoom: 0, + maxZoom: 22, + } + } + + if (type === 'wmts') { + // Rewrite a WMTS KVP GetTile endpoint into a {z}/{y}/{x} url template so + // it renders via the standard template-tile path in both engines. + const templated = buildWmtsTemplate(url) + if (!templated) return null + return { + ...base, + type: 'tile', + tileformat: 'wmts', + url: templated, + minZoom: 0, + maxNativeZoom: 18, + maxZoom: 22, + } + } + + // xyz — templated raster tiles. deck.gl's TileLayer has no {s} subdomain + // concept, so collapse {s} to a concrete subdomain ('a' is valid on + // OSM/Carto and harmless on Leaflet). tileformat 'wmts' => tms:false. return { ...base, type: 'tile', - url: tileUrl, - tileformat, + tileformat: 'wmts', + url: url.replace(/\{s\}/g, 'a'), minZoom: 0, maxNativeZoom: 18, maxZoom: 22, } } + +/** + * Rewrite a WMTS KVP GetTile url into a {z}/{y}/{x} template. Returns null when + * no LAYER param is present (WMTS can't request a tile without one). Best-effort: + * assumes a zoom-indexed TileMatrix (web-mercator GoogleMapsCompatible), which + * covers the common Earth case; named matrix sets won't map cleanly. + */ +function buildWmtsTemplate(rawUrl: string): string | null { + const qIdx = rawUrl.indexOf('?') + const baseUrl = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx) + const search = qIdx === -1 ? '' : rawUrl.slice(qIdx + 1) + const params = new URLSearchParams(search) + const get = (k: string): string | null => { + for (const [key, val] of params) { + if (key.toLowerCase() === k) return val + } + return null + } + + const layer = get('layer') + if (!layer) return null + + // Build the literal template by hand — URLSearchParams would percent-encode + // the {z}/{y}/{x} braces and break the placeholders. + const qs = [ + 'SERVICE=WMTS', + 'REQUEST=GetTile', + `VERSION=${get('version') || '1.0.0'}`, + `LAYER=${encodeURIComponent(layer)}`, + `STYLE=${encodeURIComponent(get('style') || 'default')}`, + `FORMAT=${encodeURIComponent(get('format') || 'image/png')}`, + `TILEMATRIXSET=${encodeURIComponent(get('tilematrixset') || 'GoogleMapsCompatible')}`, + 'TILEMATRIX={z}', + 'TILEROW={y}', + 'TILECOL={x}', + ].join('&') + return `${baseUrl}?${qs}` +} diff --git a/src/essence/Tools/AddTempLayer/lib/utils/url.ts b/src/essence/Tools/AddTempLayer/lib/utils/url.ts index 14b335b34..2abf859d2 100644 --- a/src/essence/Tools/AddTempLayer/lib/utils/url.ts +++ b/src/essence/Tools/AddTempLayer/lib/utils/url.ts @@ -5,6 +5,10 @@ import type { TempLayerType } from '../types' 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:' From ea24af2e39c1b1844e945cf43f455d303903d6d2 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Tue, 9 Jun 2026 14:21:35 -0500 Subject: [PATCH 6/8] clean up --- src/essence/Basics/Layers_/Layers_.js | 292 +++++++++--------- src/essence/Basics/MapEngines/types/layers.ts | 32 +- 2 files changed, 160 insertions(+), 164 deletions(-) diff --git a/src/essence/Basics/Layers_/Layers_.js b/src/essence/Basics/Layers_/Layers_.js index 62cdbb33a..3645f06eb 100644 --- a/src/essence/Basics/Layers_/Layers_.js +++ b/src/essence/Basics/Layers_/Layers_.js @@ -217,9 +217,8 @@ const L_ = { const uuid = L_.asLayerUUID(layerUUID) return L_.layers.on?.[uuid] === true }), - // Generic "add a managed layer" channel (in-memory only — not - // persisted to the backend, so it's lost on reload). layerObj - // requires { name, type, ... }. See mmgisAPI.addLayer. + // 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) ), @@ -368,16 +367,17 @@ const L_ = { // Generate different endpoints based on type if (type === 'tile') { // Tile endpoint for raster tiles - return `${baseUrl}/collections/${collectionName}/tiles/${(layerData && layerData.tileMatrixSet) || 'WebMercatorQuad' - }/{z}/{x}/{y}?assets=asset${bandsParam}${resamplingParam}` + return `${baseUrl}/collections/${collectionName}/tiles/${ + (layerData && layerData.tileMatrixSet) || 'WebMercatorQuad' + }/{z}/{x}/{y}?assets=asset${bandsParam}${resamplingParam}` } else { // For images, we use preview endpoint // Note: STAC collections are typically designed for tile serving if (layerData && layerData.name) { console.warn( `STAC layer "${layerData.name}" is configured as an image layer. ` + - `STAC collections work best with tile layer type. ` + - `Attempting to use preview endpoint.` + `STAC collections work best with tile layer type. ` + + `Attempting to use preview endpoint.` ) } return `${baseUrl}/collections/${collectionName}/preview?assets=asset${bandsParam}${resamplingParam}` @@ -427,7 +427,8 @@ const L_ = { if ( process.env.NODE_ENV === 'development' && process.env.ENABLE_CORS_PROXY === 'true' && - F_.isUrlAbsolute(nextUrl)) { + F_.isUrlAbsolute(nextUrl) + ) { try { if (new URL(nextUrl).origin !== window.location.origin) { const rootPath = window?.mmgisglobal?.ROOT_PATH || '' @@ -517,7 +518,7 @@ const L_ = { if (!ignoreToggleStateChange) { try { $('.drawToolContextMenuHeaderClose').click() - } catch (err) { } + } catch (err) {} } if ( L_.Map_.engine && @@ -580,7 +581,7 @@ const L_ = { for (let sub in L_.layers.attachments[s.name]) { if (L_.layers.attachments[s.name][sub].on) { switch ( - L_.layers.attachments[s.name][sub].type + L_.layers.attachments[s.name][sub].type ) { case 'model': L_.Globe_.litho.addLayer( @@ -614,10 +615,10 @@ const L_ = { ].layer ), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf( - s.name - ) + 1 - + L_._layersOrdered.indexOf( + s.name + ) ) break case 'labels': @@ -650,10 +651,10 @@ const L_ = { ].layer ), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf( - s.name - ) + 1 - + L_._layersOrdered.indexOf( + s.name + ) ) break } @@ -672,8 +673,8 @@ const L_ = { L_.Map_.engine.setLayerZIndex( L_.Map_.nativeLayer(L_.layers.layer[s.name]), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(s.name) + 1 - + L_._layersOrdered.indexOf(s.name) ) } @@ -742,8 +743,8 @@ const L_ = { L_.Map_.engine.setLayerZIndex( L_.Map_.nativeLayer(L_.layers.layer[s.name]), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(s.name) + 1 - + L_._layersOrdered.indexOf(s.name) ) } else { let hadToMake = false @@ -788,8 +789,8 @@ const L_ = { L_.Map_.engine.setLayerZIndex( L_.Map_.nativeLayer(L_.layers.layer[s.name]), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(s.name) + 1 - + L_._layersOrdered.indexOf(s.name) ) } @@ -843,11 +844,11 @@ const L_ = { ?.bearing && s.variables?.markerAttachments ?.bearing.enabled == - null) || - s.variables?.markerAttachments - ?.bearing?.enabled === true + null) || + s.variables?.markerAttachments + ?.bearing?.enabled === true ? s.variables.markerAttachments - .bearing + .bearing : null, }, opacity: L_.layers.opacity[s.name], @@ -975,8 +976,8 @@ const L_ = { L_.Map_.engine.setLayerZIndex( L_.Map_.nativeLayer(sublayer.layer), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(layerName) + 1 - + L_._layersOrdered.indexOf(layerName) ) break case 'labels': @@ -990,8 +991,8 @@ const L_ = { L_.Map_.engine.setLayerZIndex( L_.Map_.nativeLayer(sublayer.layer), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(layerName) + 1 - + L_._layersOrdered.indexOf(layerName) ) L_.setSublayerOpacity(layerName, sublayerName) break @@ -1067,7 +1068,7 @@ const L_ = { ]) { const sublayer = L_.layers.attachments[ - L_.layers.dataFlat[i].name + L_.layers.dataFlat[i].name ][s] if (sublayer.on) { switch (sublayer.type) { @@ -1143,7 +1144,7 @@ const L_ = { console.log(e) console.warn( 'Warning: Failed to add layer to map: ' + - L_.layers.dataFlat[i].name + L_.layers.dataFlat[i].name ) } } @@ -1162,8 +1163,8 @@ const L_ = { engine.setLayerZIndex( L_.Map_.nativeLayer(L_.layers.layer[s.name]), L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(s.name) + 1 - + L_._layersOrdered.indexOf(s.name) ) let demUrl = s.demtileurl @@ -1245,10 +1246,10 @@ const L_ = { ?.bearing && s.variables?.markerAttachments ?.bearing.enabled == null) || - s.variables?.markerAttachments?.bearing - ?.enabled === true + s.variables?.markerAttachments?.bearing + ?.enabled === true ? s.variables.markerAttachments - .bearing + .bearing : null, }, opacity: L_.layers.opacity[s.name], @@ -1280,8 +1281,8 @@ const L_ = { geojson.features ? geojson.features : geojson.length > 0 && geojson[0].type === 'Feature' - ? geojson - : null + ? geojson + : null ) if (keepLastN && keepLastN > 0) { layer._sourceGeoJSON.features = @@ -1347,7 +1348,7 @@ const L_ = { setStyle(layer, newStyle) { try { layer.setStyle(newStyle) - } catch (err) { } + } catch (err) {} }, setActiveFeature(layer) { if (layer && layer.feature && layer.options?.layerName) @@ -1444,7 +1445,7 @@ const L_ = { } try { //layer.bringToFront() - } catch (err) { } + } catch (err) {} }, toggleFeature(layer, on) { const display = on ? 'inherit' : 'none' @@ -1792,15 +1793,15 @@ const L_ = { const styleString = (s.color != null ? 'text-shadow: ' + - F_.getTextShadowString(s.color, s.strokeOpacity, s.weight) + - '; ' + F_.getTextShadowString(s.color, s.strokeOpacity, s.weight) + + '; ' : '') + (s.fillColor != null ? 'color: ' + s.fillColor + '; ' : '') + (s.fontSize != null ? 'font-size: ' + s.fontSize + '; ' : '') + (s.rotation != null ? 'transform: rotateZ(' + - parseInt(!isNaN(s.rotation) ? s.rotation : 0) * -1 + - 'deg); ' + parseInt(!isNaN(s.rotation) ? s.rotation : 0) * -1 + + 'deg); ' : '') const id = className + '_' + id1 + '_' + id2 @@ -1824,14 +1825,14 @@ const L_ = { ) .setContent( "
" + - `
" + - `${feature.properties.name.replace(/[<>;{}]/g, '')}`, - '
' + + `
" + + `${feature.properties.name.replace(/[<>;{}]/g, '')}`, + '
' + '
' ) @@ -2084,9 +2085,9 @@ const L_ = { 'color', layer.feature?.properties?.style ?.fillColor || - layer.options?.fillColor || - fillColor || - 'white' + layer.options?.fillColor || + fillColor || + 'white' ) } else if (layer._isArrow) { // Arrow @@ -2202,7 +2203,7 @@ const L_ = { sublayerName ].layer._layers[sll].feature._style ) - } catch (err) { } + } catch (err) {} } } } @@ -2559,8 +2560,8 @@ const L_ = { layer._latlng.lat, layer._latlng.lng, activePoint.zoom || - L_.Map_.mapScaleZoom || - L_.Map_.map.getZoom(), + L_.Map_.mapScaleZoom || + L_.Map_.map.getZoom(), ] } else if (layer._latlngs) { let lat = 0, @@ -2575,8 +2576,8 @@ const L_ = { lng / llflat.length, parseInt( activePoint.zoom || - L_.Map_.mapScaleZoom || - L_.Map_.map.getZoom() + L_.Map_.mapScaleZoom || + L_.Map_.map.getZoom() ), ] } @@ -2676,9 +2677,9 @@ const L_ = { if (!keepTime) { console.warn( 'Warning: The input for keep' + - trimType.capitalizeFirstLetter() + - 'Time is invalid: ' + - keepTime + trimType.capitalizeFirstLetter() + + 'Time is invalid: ' + + keepTime ) return } @@ -2686,7 +2687,7 @@ const L_ = { if (!timePropPath) { console.warn( 'Warning: The input for timePropPath is invalid: ' + - timePropPath + timePropPath ) return } @@ -2696,9 +2697,9 @@ const L_ = { if (isNaN(keepAfterAsDate.getTime())) { console.warn( 'Warning: The input for keep' + - trimType.capitalizeFirstLetter() + - 'Time is invalid: ' + - keepTime + trimType.capitalizeFirstLetter() + + 'Time is invalid: ' + + keepTime ) return } @@ -2725,7 +2726,7 @@ const L_ = { if (isNaN(layerDate.getTime())) { console.warn( 'Warning: The time for the layer is invalid: ' + - layer.feature.properties[timePropPath] + layer.feature.properties[timePropPath] ) continue } @@ -2749,7 +2750,7 @@ const L_ = { } else { console.warn( 'Warning: Unable to trim vector layer as it does not exist: ' + - layerName + layerName ) } }, @@ -2766,12 +2767,12 @@ const L_ = { if (Number.isNaN(Number(keepNum))) { console.warn( 'Warning: Unable to trim vector layer `' + - layerName + - '` as keep' + - keepType.capitalizeFirstLetter() + - 'N == ' + - keepN + - ' and is not a valid integer' + layerName + + '` as keep' + + keepType.capitalizeFirstLetter() + + 'N == ' + + keepN + + ' and is not a valid integer' ) return } @@ -2830,7 +2831,7 @@ const L_ = { } else { console.warn( 'Warning: Unable to trim vector layer as it does not exist: ' + - layerName + layerName ) } }, @@ -2841,10 +2842,10 @@ const L_ = { if (!time) { console.warn( 'Warning: Unable to trim the LineString in vector layer `' + - layerName + - '` as time === ' + - time + - ' and is invalid' + layerName + + '` as time === ' + + time + + ' and is invalid' ) return } @@ -2858,10 +2859,10 @@ const L_ = { if (!timeProp) { console.warn( 'Warning: Unable to trim the LineString in vector layer `' + - layerName + - '` as timeProp === ' + - timeProp + - ' and is invalid' + layerName + + '` as timeProp === ' + + timeProp + + ' and is invalid' ) return } @@ -2870,10 +2871,10 @@ const L_ = { if (Number.isNaN(Number(trimNum))) { console.warn( 'Warning: Unable to trim the LineString in vector layer `' + - layerName + - '` as trimN == ' + - trimN + - ' and is not a valid integer' + layerName + + '` as trimN == ' + + trimN + + ' and is not a valid integer' ) return } @@ -2882,10 +2883,10 @@ const L_ = { if (!TRIM_DIRECTION.includes(startOrEnd)) { console.warn( 'Warning: Unable to trim the LineString in vector layer `' + - layerName + - '` as startOrEnd == ' + - startOrEnd + - ' and is not a valid input value' + layerName + + '` as startOrEnd == ' + + startOrEnd + + ' and is not a valid input value' ) return } @@ -2893,10 +2894,10 @@ const L_ = { if (!time) { console.warn( 'Warning: Unable to trim the LineString in vector layer `' + - layerName + - '` as startOrEnd == ' + - startOrEnd + - ' and is not a valid input value' + layerName + + '` as startOrEnd == ' + + startOrEnd + + ' and is not a valid input value' ) return } @@ -2915,8 +2916,8 @@ const L_ = { if (findNonLineString.length > 0) { console.warn( 'Warning: Unable to trim the vector layer `' + - layerName + - '` as the features contain geometry that is not LineString' + layerName + + '` as the features contain geometry that is not LineString' ) return } @@ -2947,10 +2948,10 @@ const L_ = { if (!feature.properties.hasOwnProperty(timeProp)) { console.warn( 'Warning: Unable to trim the vector layer `' + - layerName + - "` as the the feature's properties object is missing the `" + - timeProp + - '` key' + layerName + + "` as the the feature's properties object is missing the `" + + timeProp + + '` key' ) return } @@ -2995,10 +2996,10 @@ const L_ = { if (!feature.properties.hasOwnProperty(timeProp)) { console.warn( 'Warning: Unable to trim the vector layer `' + - layerName + - "` as the the feature's properties object is missing the key `" + - timeProp + - '` for the end time' + layerName + + "` as the the feature's properties object is missing the key `" + + timeProp + + '` for the end time' ) return } @@ -3038,15 +3039,15 @@ const L_ = { } else { console.warn( 'Warning: Unable to trim the vector layer `' + - layerName + - '` as the layer contains no features' + layerName + + '` as the layer contains no features' ) return } } else { console.warn( 'Warning: Unable to trim vector layer as it does not exist: ' + - layerName + layerName ) } }, @@ -3057,9 +3058,9 @@ const L_ = { if (!inputData) { console.warn( 'Warning: Unable to append to vector layer `' + - layerName + - '` as inputData is invalid: ' + - JSON.stringify(inputData, null, 4) + layerName + + '` as inputData is invalid: ' + + JSON.stringify(inputData, null, 4) ) return false } @@ -3068,11 +3069,11 @@ const L_ = { if (!inputData.properties.hasOwnProperty(timeProp)) { console.warn( 'Warning: Unable to append to the vector layer `' + - layerName + - '` as timeProp === ' + - timeProp + - ' and does not exist as a property in inputData: ' + - JSON.stringify(lastFeature, null, 4) + layerName + + '` as timeProp === ' + + timeProp + + ' and does not exist as a property in inputData: ' + + JSON.stringify(lastFeature, null, 4) ) return false } @@ -3096,9 +3097,9 @@ const L_ = { if (lastFeature.geometry.type !== 'LineString') { console.warn( 'Warning: Unable to append to the vector layer `' + - layerName + - '` as the feature is not a LineStringfeature: ' + - JSON.stringify(lastFeature, null, 4) + layerName + + '` as the feature is not a LineStringfeature: ' + + JSON.stringify(lastFeature, null, 4) ) return false } @@ -3107,11 +3108,11 @@ const L_ = { if (!lastFeature.properties.hasOwnProperty(timeProp)) { console.warn( 'Warning: Unable to append to the vector layer `' + - layerName + - '` as timeProp === ' + - timeProp + - ' and does not exist as a property in the feature: ' + - JSON.stringify(lastFeature, null, 4) + layerName + + '` as timeProp === ' + + timeProp + + ' and does not exist as a property in the feature: ' + + JSON.stringify(lastFeature, null, 4) ) return } @@ -3120,9 +3121,9 @@ const L_ = { if (inputData.geometry.type !== 'LineString') { console.warn( 'Warning: Unable to append to vector layer `' + - layerName + - "` as inputData has the wrong geometry type (must be of type 'LineString'): " + - JSON.stringify(inputData, null, 4) + layerName + + "` as inputData has the wrong geometry type (must be of type 'LineString'): " + + JSON.stringify(inputData, null, 4) ) return false } @@ -3139,9 +3140,9 @@ const L_ = { } else { console.warn( 'Warning: Unable to append to vector layer `' + - layerName + - "` as inputData has the wrong type (must be of type 'Feature'): " + - JSON.stringify(inputData, null, 4) + layerName + + "` as inputData has the wrong type (must be of type 'Feature'): " + + JSON.stringify(inputData, null, 4) ) return false } @@ -3159,7 +3160,7 @@ const L_ = { console.log(e) console.warn( 'Warning: Unable to append LineString to layer as the layer or input data is invalid: ' + - layerName + layerName ) return false } @@ -3176,15 +3177,15 @@ const L_ = { } else { console.warn( 'Warning: Unable to append to the vector layer `' + - layerName + - '` as the layer contains no features' + layerName + + '` as the layer contains no features' ) return false } } else { console.warn( 'Warning: Unable to append to vector layer as it does not exist: ' + - layerName + layerName ) return false } @@ -3210,7 +3211,7 @@ const L_ = { console.log(e) console.warn( 'Warning: Unable to update vector layer as the layer or input data is invalid: ' + - layerName + layerName ) return false } @@ -3220,7 +3221,7 @@ const L_ = { } else { console.warn( 'Warning: Unable to update vector layer as it does not exist: ' + - layerName + layerName ) return false } @@ -3284,8 +3285,8 @@ const L_ = { if (sub === 'image_overlays') { subUpdateLayers[sub].layer.setZIndex( L_._layersOrdered.length + - 1 - - L_._layersOrdered.indexOf(layerName) + 1 - + L_._layersOrdered.indexOf(layerName) ) } } @@ -3362,9 +3363,8 @@ const L_ = { await L_.removeLayerFromLayersData(layerName) } - // Notify listeners that the layer list changed so they can rebuild - // (e.g. the Layers panel). Decoupled — works in both classic and modern - // layouts and lets any subscriber react. + // 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') } @@ -3837,7 +3837,7 @@ const L_ = { if ( typeof props[p] === 'string' && props[p].toLowerCase().match(/\.(jpeg|jpg|gif|png|xml)$/) != - null + null ) { let url = props[p] const isGif = url.toLowerCase().match(/\.gif$/) != null diff --git a/src/essence/Basics/MapEngines/types/layers.ts b/src/essence/Basics/MapEngines/types/layers.ts index 63cc0c28b..5c0642dec 100644 --- a/src/essence/Basics/MapEngines/types/layers.ts +++ b/src/essence/Basics/MapEngines/types/layers.ts @@ -36,18 +36,14 @@ export interface TileLayerOptions extends LayerOptions { tms?: boolean subdomains?: string | string[] attribution?: string - time?: string - maxNativeZoom?: number - tileSize?: number - tileElevation?: number - /** - * Tile request scheme. 'wms' routes to deck.gl's WMSLayer (the GetMap BBOX - * is computed by deck per view). Anything else is treated as a - * {z}/{x}/{y} URL template (xyz / wmts-rest / tms). - */ - tileformat?: string - nativeOptions?: Record -} + time?: string + maxNativeZoom?: number + tileSize?: number + tileElevation?: number + /** 'wms' => deck.gl WMSLayer; else a {z}/{x}/{y} url template. */ + tileformat?: string + nativeOptions?: Record +} /** * Options for GeoJSON vector layers. @@ -98,12 +94,12 @@ export interface ImageOverlayOptions extends LayerOptions { /** * Options for vector tile (MVT/protobuf) layers. */ -export interface VectorTileLayerOptions extends LayerOptions { - vectorTileLayerStyles?: Record - maxNativeZoom?: number - attribution?: string - nativeOptions?: Record -} +export interface VectorTileLayerOptions extends LayerOptions { + vectorTileLayerStyles?: Record + maxNativeZoom?: number + attribution?: string + nativeOptions?: Record +} /** * Options for point cloud layers. From b8e735cdffb74193189b7b03c8026d2018595312 Mon Sep 17 00:00:00 2001 From: Sijan Bhattarai Date: Wed, 17 Jun 2026 13:25:37 -0500 Subject: [PATCH 7/8] refractor remove buildwmtstemplate and add validation --- .../AddTempLayer/adapters/buildLayerObj.ts | 85 +++--------- .../lib/geo/AddLayerModal/AddLayerModal.tsx | 2 +- .../AddTempLayerPanel/AddTempLayerPanel.tsx | 16 ++- .../Tools/AddTempLayer/lib/utils/url.ts | 79 +++++++++-- tests/unit/addTempLayerUrl.spec.js | 131 ++++++++++++++++++ 5 files changed, 226 insertions(+), 87 deletions(-) create mode 100644 tests/unit/addTempLayerUrl.spec.js diff --git a/src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts b/src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts index b95efaff7..59c09078d 100644 --- a/src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts +++ b/src/essence/Tools/AddTempLayer/adapters/buildLayerObj.ts @@ -14,16 +14,19 @@ export function uniqueLayerName(): string { /** * Build an MMGIS layerObj. Returns null if the URL is invalid or the type - * can't be determined. + * can't be detected. * - * Type → MMGIS mapping (consumed by the merged core WMS support): - * geojson → { type: 'vector', url } - * wms → { type: 'tile', tileformat: 'wms', url } — full WMS url passed - * through; Leaflet's WMSColorFilter and deck.gl's WMSLayer both - * parse LAYERS/FORMAT/… out of it. - * wmts → { type: 'tile', tileformat: 'wmts', url } — KVP rewritten to a - * {z}/{y}/{x} template so it renders via the template-tile path. - * xyz → { type: 'tile', tileformat: 'wmts', url } — {z}/{x}/{y} template. + * 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, @@ -47,9 +50,7 @@ export function buildLayerObj( } if (type === 'wms') { - // Pass the full WMS url through unchanged: both engines parse the WMS - // params (LAYERS, FORMAT, …) out of it. WMS renders at any zoom (no - // fixed native tile grid), so no maxNativeZoom. + // WMS renders at any zoom (no fixed native tile grid), so no maxNativeZoom. return { ...base, type: 'tile', @@ -60,70 +61,16 @@ export function buildLayerObj( } } - if (type === 'wmts') { - // Rewrite a WMTS KVP GetTile endpoint into a {z}/{y}/{x} url template so - // it renders via the standard template-tile path in both engines. - const templated = buildWmtsTemplate(url) - if (!templated) return null - return { - ...base, - type: 'tile', - tileformat: 'wmts', - url: templated, - minZoom: 0, - maxNativeZoom: 18, - maxZoom: 22, - } - } - - // xyz — templated raster tiles. deck.gl's TileLayer has no {s} subdomain - // concept, so collapse {s} to a concrete subdomain ('a' is valid on - // OSM/Carto and harmless on Leaflet). tileformat 'wmts' => tms:false. + // wmts / xyz — a {z}/{x}/{y} tile template the engine fills per tile, + // passed through unchanged. return { ...base, type: 'tile', tileformat: 'wmts', - url: url.replace(/\{s\}/g, 'a'), + url, minZoom: 0, maxNativeZoom: 18, maxZoom: 22, } -} - -/** - * Rewrite a WMTS KVP GetTile url into a {z}/{y}/{x} template. Returns null when - * no LAYER param is present (WMTS can't request a tile without one). Best-effort: - * assumes a zoom-indexed TileMatrix (web-mercator GoogleMapsCompatible), which - * covers the common Earth case; named matrix sets won't map cleanly. - */ -function buildWmtsTemplate(rawUrl: string): string | null { - const qIdx = rawUrl.indexOf('?') - const baseUrl = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx) - const search = qIdx === -1 ? '' : rawUrl.slice(qIdx + 1) - const params = new URLSearchParams(search) - const get = (k: string): string | null => { - for (const [key, val] of params) { - if (key.toLowerCase() === k) return val - } - return null - } - - const layer = get('layer') - if (!layer) return null - // Build the literal template by hand — URLSearchParams would percent-encode - // the {z}/{y}/{x} braces and break the placeholders. - const qs = [ - 'SERVICE=WMTS', - 'REQUEST=GetTile', - `VERSION=${get('version') || '1.0.0'}`, - `LAYER=${encodeURIComponent(layer)}`, - `STYLE=${encodeURIComponent(get('style') || 'default')}`, - `FORMAT=${encodeURIComponent(get('format') || 'image/png')}`, - `TILEMATRIXSET=${encodeURIComponent(get('tilematrixset') || 'GoogleMapsCompatible')}`, - 'TILEMATRIX={z}', - 'TILEROW={y}', - 'TILECOL={x}', - ].join('&') - return `${baseUrl}?${qs}` } diff --git a/src/essence/Tools/AddTempLayer/lib/geo/AddLayerModal/AddLayerModal.tsx b/src/essence/Tools/AddTempLayer/lib/geo/AddLayerModal/AddLayerModal.tsx index d2cf730b9..e0c551eef 100644 --- a/src/essence/Tools/AddTempLayer/lib/geo/AddLayerModal/AddLayerModal.tsx +++ b/src/essence/Tools/AddTempLayer/lib/geo/AddLayerModal/AddLayerModal.tsx @@ -52,7 +52,7 @@ export function AddLayerModal({

- Provide a WMS, WMTS, or GeoJSON link to add to your layer gallery. + Paste a layer URL to add it to your layer gallery.