diff --git a/API/Backend/Config/routes/configs.js b/API/Backend/Config/routes/configs.js index 5706a5a0b..7de41ea10 100644 --- a/API/Backend/Config/routes/configs.js +++ b/API/Backend/Config/routes/configs.js @@ -596,7 +596,7 @@ router.get("/missions", function (req, res, next) { let allMissions = []; for (let i = 0; i < missions.length; i++) allMissions.push(missions[i].DISTINCT); - allMissions.sort(); + allMissions.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); res.send({ status: "success", missions: allMissions }); return null; }) diff --git a/configure/src/core/Configure.js b/configure/src/core/Configure.js index 8d9a97dbc..aab4e4817 100644 --- a/configure/src/core/Configure.js +++ b/configure/src/core/Configure.js @@ -36,7 +36,10 @@ export default function Configure() { "missions", null, (res) => { - dispatch(setMissions(res.missions)); + const missions = (res?.missions || []) + .slice() + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + dispatch(setMissions(missions)); }, (res) => { dispatch( diff --git a/configure/src/core/Maker.js b/configure/src/core/Maker.js index 05188e747..6dea942ba 100644 --- a/configure/src/core/Maker.js +++ b/configure/src/core/Maker.js @@ -249,11 +249,26 @@ const getComponent = ( inlineHelp, value, forceField, - dispatch + dispatch, + fieldDefaults ) => { const directConf = layer == null ? (tool == null ? configuration : tool) : layer; let inner; + let disabled = false; + if (com.disableSwitch) { + let switchVal = getIn(configuration, com.disableSwitch, null); + if (switchVal == null) { + // fall back to defaultChecked of the referenced switch if available + const def = fieldDefaults?.[com.disableSwitch]; + if (def != null && typeof def.defaultChecked === "boolean") { + switchVal = def.defaultChecked; + } else { + switchVal = false; + } + } + disabled = !switchVal; + } switch (com.type) { case "gap": return ( @@ -270,6 +285,7 @@ const getComponent = ( label={com.name} variant="filled" size="small" + disabled={disabled} inputProps={{ autoComplete: "off", }} @@ -309,6 +325,7 @@ const getComponent = ( label={com.name} variant="filled" size="small" + disabled={disabled} inputProps={{ autoComplete: "off", }} @@ -341,6 +358,7 @@ const getComponent = ( className={c.button} variant="outlined" startIcon={} + disabled={disabled} onClick={() => { if (com.action === "tile-populate-from-x") { tilePopulateFromX( @@ -395,6 +413,75 @@ const getComponent = ( ); } ); + } else if (com.action === "projection-populate-from-x") { + const inputPath = getIn(configuration, "projection.xmlpath", ""); + if (inputPath == null || inputPath === "") { + dispatch( + setSnackBarText({ + text: "Please provide a path or URL to tilemapresource.xml or an ArcGIS MapServer (?f=pjson).", + severity: "error", + }) + ); + return; + } + + const missionPath = `Missions/${configuration.msv.mission}/`; + projectionPopulateFromX(inputPath, missionPath) + .then((vals) => { + const { bounds, origin, reszoomlevel, resunitsperpixel } = vals; + + let conf = updateConfiguration( + "projection.bounds", + bounds || getIn(configuration, "projection.bounds", null), + null, + true + ); + if (origin != null) { + conf = updateConfiguration( + "projection.origin", + origin, + null, + true, + conf + ); + } + if (reszoomlevel != null) { + conf = updateConfiguration( + "projection.reszoomlevel", + reszoomlevel, + null, + true, + conf + ); + } + if (resunitsperpixel != null) { + conf = updateConfiguration( + "projection.resunitsperpixel", + resunitsperpixel, + null, + true, + conf + ); + } + + // Final dispatch + updateConfiguration("projection.bounds", bounds, null, false, conf); + + dispatch( + setSnackBarText({ + text: "Projection fields populated.", + severity: "success", + }) + ); + }) + .catch((err) => { + dispatch( + setSnackBarText({ + text: `Failed to populate projection fields: ${err?.message || err}`, + severity: "error", + }) + ); + }); } }} > @@ -428,6 +515,7 @@ const getComponent = ( label={com.name} variant="filled" size="small" + disabled={disabled} inputProps={{ autoComplete: "off", }} @@ -517,6 +605,7 @@ const getComponent = ( label={com.name} variant="filled" size="small" + disabled={disabled} inputProps={{ autoComplete: "off", }} @@ -559,6 +648,7 @@ const getComponent = ( {com.name} { updateConfiguration( @@ -869,6 +963,7 @@ const getComponent = ( { if (color) { let colorStr = color.hex; @@ -1147,20 +1242,32 @@ const makeConfig = ( } if (row.components) { made.push( - - - {row.components.map((com, idx2) => { + (() => { + // Build a lookup of defaultChecked for switches to support disableSwitch fallbacks + const fieldDefaults = {}; + row.components.forEach((com) => { + if (com.type === "switch" && typeof com.field === "string") { + fieldDefaults[com.field] = { + defaultChecked: com.defaultChecked, + }; + } + }); + + return ( + + + {row.components.map((com, idx2) => { return ( ); - })} - - + })} + + + ); + })() ); } }); @@ -1359,3 +1469,127 @@ function tilePopulateFromX( }); } } + +function projectionPopulateFromX(pathOrUrl, missionPath) { + return new Promise((resolve, reject) => { + const isAbsolute = isUrlAbsolute(pathOrUrl); + const isXmlLike = /tilemapresource\.xml$/i.test(pathOrUrl) || /\.xml$/i.test(pathOrUrl); + const isEsri = /mapserver/i.test(pathOrUrl); + + let fullUrl = pathOrUrl; + if (!isAbsolute && !isEsri) { + fullUrl = missionPath.replace("config.json", "") + fullUrl; + if (window.mmgisglobal.IS_DOCKER !== "true") { + fullUrl = `../../${fullUrl}`; + } + } + + if (isXmlLike && !isEsri) { + fetch(fullUrl) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.text(); + }) + .then((str) => new window.DOMParser().parseFromString(str, "text/xml")) + .then((xml) => { + try { + const bboxEl = xml.getElementsByTagName("BoundingBox")[0]; + const bounds = bboxEl + ? [ + parseFloat(bboxEl.getAttribute("minx")), + parseFloat(bboxEl.getAttribute("miny")), + parseFloat(bboxEl.getAttribute("maxx")), + parseFloat(bboxEl.getAttribute("maxy")), + ] + : null; + + let origin = null; + const originEl = xml.getElementsByTagName("Origin")[0]; + if (originEl) { + origin = [ + parseFloat(originEl.getAttribute("x")), + parseFloat(originEl.getAttribute("y")), + ]; + } + + const tileSets = xml.getElementsByTagName("TileSet"); + let reszoomlevel = null; + let resunitsperpixel = null; + if (tileSets && tileSets.length > 0) { + // Prefer order 0 if present; otherwise first + let chosen = tileSets[0]; + for (let i = 0; i < tileSets.length; i++) { + const o = parseInt(tileSets[i].getAttribute("order")); + if (!isNaN(o) && o === 0) { + chosen = tileSets[i]; + break; + } + } + const o = chosen.getAttribute("order"); + const upp = chosen.getAttribute("units-per-pixel"); + if (o != null) reszoomlevel = parseInt(o); + if (upp != null) resunitsperpixel = parseFloat(upp); + } + + resolve({ bounds, origin, reszoomlevel, resunitsperpixel }); + } catch (err) { + reject(err); + } + }) + .catch((err) => reject(err)); + } else { + // Treat as ArcGIS MapServer JSON + try { + let url = fullUrl; + if (!/\?/.test(url)) url += "?f=pjson"; + else if (!/[?&]f=pjson/i.test(url)) url += "&f=pjson"; + + fetch(url) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }) + .then((json) => { + try { + const extent = json.fullExtent || json.initialExtent; + const bounds = extent + ? [ + parseFloat(extent.xmin), + parseFloat(extent.ymin), + parseFloat(extent.xmax), + parseFloat(extent.ymax), + ] + : null; + + let origin = null; + if (json.tileInfo && json.tileInfo.origin) { + origin = [ + parseFloat(json.tileInfo.origin.x), + parseFloat(json.tileInfo.origin.y), + ]; + } + + let reszoomlevel = null; + let resunitsperpixel = null; + const lods = json.tileInfo && json.tileInfo.lods; + if (lods && lods.length > 0) { + let chosen = lods.find((l) => l.level === 0); + if (!chosen) { + chosen = lods.slice().sort((a, b) => a.level - b.level)[0]; + } + reszoomlevel = chosen.level; + resunitsperpixel = chosen.resolution; + } + + resolve({ bounds, origin, reszoomlevel, resunitsperpixel }); + } catch (err) { + reject(err); + } + }) + .catch((err) => reject(err)); + } catch (err) { + reject(err); + } + } + }); +} diff --git a/configure/src/metaconfigs/tab-coordinates-config.json b/configure/src/metaconfigs/tab-coordinates-config.json index f48d3a273..3a6a981c0 100644 --- a/configure/src/metaconfigs/tab-coordinates-config.json +++ b/configure/src/metaconfigs/tab-coordinates-config.json @@ -21,6 +21,7 @@ "name": "EPSG Code", "description": "An EPSG (or similar) code representing the spatial reference system. See https://spatialreference.org/ref/epsg/ for examples. For instance: 'IAU2000:30120'", "type": "text", + "disableSwitch": "projection.custom", "width": 2 }, @@ -29,21 +30,11 @@ "name": "Proj4 (v2.3.14) String", "description": "A Proj4 String that defines the projection. See https://proj.org/en/9.4/operations/projections/index.html for examples. Here is an example value of a lunar south-pole stereographic proj4 string: '+proj=stere +lat_0=-90 +lon_0=0 +k=1 +x_0=0 +y_0=0 +a=1737400 +b=1737400 +units=m +no_defs'", "type": "text", + "disableSwitch": "projection.custom", "width": 10 } ] }, - { - "components": [ - { - "field": "projection.xmlpath", - "name": "Path to Basemap tilemapresource.xml", - "description": "Set the path to the tilemapresource.xml that was created with the base (and global) tileset. Used only to help auto-populate the remaining projection values below.", - "type": "text", - "width": 12 - } - ] - }, { "components": [ { @@ -51,6 +42,7 @@ "name": "Bounds Min X", "description": "Minimum easting value of the projection's spatial extent as stated by the base global tilemapresource.xml.", "type": "number", + "disableSwitch": "projection.custom", "width": 3 }, { @@ -58,6 +50,7 @@ "name": "Bounds Min Y", "description": "Minimum northing value of the projection's spatial extent as stated by the base global tilemapresource.xml.", "type": "number", + "disableSwitch": "projection.custom", "width": 3 }, { @@ -65,6 +58,7 @@ "name": "Bounds Max X", "description": "Maximum easting value of the projection's spatial extent as stated by the base global tilemapresource.xml.", "type": "number", + "disableSwitch": "projection.custom", "width": 3 }, { @@ -72,6 +66,7 @@ "name": "Bounds Max Y", "description": "Maximum northing value of the projection's spatial extent as stated by the base global tilemapresource.xml.", "type": "number", + "disableSwitch": "projection.custom", "width": 3 } ] @@ -83,6 +78,7 @@ "name": "Origin X", "description": "Origin easting of the projection", "type": "number", + "disableSwitch": "projection.custom", "width": 3 }, { @@ -90,6 +86,7 @@ "name": "Origin Y", "description": "Origin northing of the projection", "type": "number", + "disableSwitch": "projection.custom", "width": 3 }, { @@ -98,6 +95,7 @@ "description": "A zoom level from the tilemapresource.xml to combine with the following units-per-pixel. Most often this can be set to a zoom level of '0'.", "type": "number", "min": 0, + "disableSwitch": "projection.custom", "width": 3 }, { @@ -105,10 +103,32 @@ "name": "... the units per pixel are", "description": "Based on the zoom level defined before and the tilemapresource.xml, the respective units-per-pixel to set the scaling of the projection.", "type": "number", + "disableSwitch": "projection.custom", "width": 3 } ] }, + { + "section": "Actions", + "components": [ + { + "field": "projection.xmlpath", + "name": "Path to Basemap tilemapresource.xml or ArcGIS MapServer", + "description": "Set the path to the tilemapresource.xml that was created with the base (and global) tileset or ArcGIS MapServer endpoint with ?f=pjson (e.g., services.arcgisonline.com/arcgis/rest/services/.../MapServer?f=pjson).. Used only to help auto-populate some projection values above.", + "type": "text", + "disableSwitch": "projection.custom", + "width": 8 + }, + { + "name": "Auto-Populate Projection Fields From tilemapresource.xml or ArcGIS MapServer", + "description": "Reads the file set above and auto-fills Bounds, Origin, and Resolution fields. Supports a local/relative tilemapresource.xml, or an ArcGIS MapServer endpoint with ?f=pjson (e.g., services.arcgisonline.com/arcgis/rest/services/.../MapServer?f=pjson).", + "type": "button", + "action": "projection-populate-from-x", + "disableSwitch": "projection.custom", + "width": 4 + } + ] + }, { "name": "Displayed Coordinates", "description": "Configure which coordinates are made visible to users when mousing around and exporting vectors.", @@ -139,6 +159,7 @@ "name": "Longitude Display Offset", "description": "", "type": "number", + "disableSwitch": "coordinates.coordll", "width": 3 }, { @@ -146,6 +167,7 @@ "name": "Latitude Display Offset", "description": "", "type": "number", + "disableSwitch": "coordinates.coordll", "width": 3 } ] @@ -166,6 +188,7 @@ "name": "Easting Display Offset", "description": "", "type": "number", + "disableSwitch": "coordinates.coorden", "width": 3 }, { @@ -173,6 +196,7 @@ "name": "Northing Display Offset", "description": "", "type": "number", + "disableSwitch": "coordinates.coorden", "width": 3 }, { @@ -180,6 +204,7 @@ "name": "Easting Display Multiplier", "description": "", "type": "number", + "disableSwitch": "coordinates.coorden", "width": 2 }, { @@ -187,6 +212,7 @@ "name": "Northing Display Multiplier", "description": "", "type": "number", + "disableSwitch": "coordinates.coorden", "width": 2 } ] @@ -207,6 +233,7 @@ "name": "Display Name", "description": "Optionally set a Display Name to aid users in identifying the projection.", "type": "text", + "disableSwitch": "coordinates.coordcustomproj", "width": 4 }, { @@ -214,6 +241,7 @@ "name": "X Coordinate Label", "description": "", "type": "text", + "disableSwitch": "coordinates.coordcustomproj", "width": 2 }, { @@ -221,6 +249,7 @@ "name": "Y Coordinate Label", "description": "", "type": "text", + "disableSwitch": "coordinates.coordcustomproj", "width": 2 }, { @@ -228,6 +257,7 @@ "name": "Z Coordinate Label", "description": "", "type": "text", + "disableSwitch": "coordinates.coordcustomproj", "width": 2 } ] @@ -248,6 +278,7 @@ "name": "Display Name", "description": "Optionally set a Display Name to aid users in identifying the projection.", "type": "text", + "disableSwitch": "coordinates.coordsecondaryproj", "width": 4 }, { @@ -255,6 +286,7 @@ "name": "X Coordinate Label", "description": "", "type": "text", + "disableSwitch": "coordinates.coordsecondaryproj", "width": 2 }, { @@ -262,6 +294,7 @@ "name": "Y Coordinate Label", "description": "", "type": "text", + "disableSwitch": "coordinates.coordsecondaryproj", "width": 2 }, { @@ -269,6 +302,7 @@ "name": "Z Coordinate Label", "description": "", "type": "text", + "disableSwitch": "coordinates.coordsecondaryproj", "width": 2 } ] @@ -327,6 +361,7 @@ "name": "DEM URL", "description": "The path to the mission's base DEM to query elevation values off of.", "type": "text", + "disableSwitch": "coordinates.coordelev", "width": 10 } ] diff --git a/configure/src/metaconfigs/tab-time-config.json b/configure/src/metaconfigs/tab-time-config.json index ebce3bbb3..f6d4b16bb 100644 --- a/configure/src/metaconfigs/tab-time-config.json +++ b/configure/src/metaconfigs/tab-time-config.json @@ -22,6 +22,7 @@ "description": "Whether or not the Time user interface should be visible. This allows time to be enabled while restricting users from using its UI.", "type": "checkbox", "width": 3, + "disableSwitch": "time.enabled", "defaultChecked": true }, { @@ -30,6 +31,7 @@ "description": "If enabled and visible, the Time UI will be initially open on the bottom of the screen.", "type": "checkbox", "width": 3, + "disableSwitch": "time.enabled", "defaultChecked": false } ] @@ -42,6 +44,7 @@ "name": "Time Format", "description": "The time format to be displayed on the Time UI. Uses D3 time format specifiers: https://github.com/d3/d3-time-format. Default: %Y-%m-%dT%H:%M:%SZ", "type": "text", + "disableSwitch": "time.enabled", "width": 6 }, { @@ -50,6 +53,7 @@ "description": "If enabled, the Time UI will start in Live (Present) mode.", "type": "checkbox", "width": 3, + "disableSwitch": "time.enabled", "defaultChecked": false }, { @@ -58,6 +62,7 @@ "description": "The Time UI begins in the Range Mode and allows users to bound by start and end times. Point Mode has users only control the end time and has start time implied by negative infinity.", "type": "checkbox", "width": 3, + "disableSwitch": "time.enabled", "defaultChecked": false } ] @@ -69,6 +74,7 @@ "name": "Initial Start Time", "description": "The initial start time. It should be before the Initial End Time and it can be made relative by appending ' {+/-} {seconds}' - for instance: '2024-03-04T14:05:00Z + 864000'. Default: 1 month before Initial End Time", "type": "text", + "disableSwitch": "time.enabled", "width": 6 }, { @@ -76,6 +82,7 @@ "name": "Initial End Time", "description": "The initial end time. It should be after the Initial Start Time. You can use 'now' to have the end time be the present and you can make it relative by appending ' {+/-} {seconds}' - for instance: '2024-03-04T14:05:00Z + 864000'. Default: now", "type": "text", + "disableSwitch": "time.enabled", "width": 6 } ] @@ -87,6 +94,7 @@ "name": "Initial Timeline Window Start Time", "description": "This does not control the time range for queries. This only allows the initial time window of the time line to differ from just being the Start Time to the End Time. A use-case for this would be to set the window times to fit the full extent of the temporal data but only set the Initial Start and End Times as a subset of that so as not to query everything on load. Can be made relative by appending ' {+/-} {seconds}' - for instance: '2024-03-04T14:05:00Z + 864000'. Default: Initial Start Time", "type": "text", + "disableSwitch": "time.enabled", "width": 6 }, { @@ -94,6 +102,7 @@ "name": "Initial Timeline Window End Time", "description": "This does not control the time range for queries. This only allows the initial time window of the time line to differ from just being the Start Time to the End Time. Should be after Initial Window End Time Use now to have the end time be the present. Can be made relative by appending ' {+/-} {seconds}' - for instance: '2024-03-04T14:05:00Z + 864000'. Default: Initial End Time", "type": "text", + "disableSwitch": "time.enabled", "width": 6 } ] diff --git a/configure/src/metaconfigs/tab-ui-config.json b/configure/src/metaconfigs/tab-ui-config.json index 2b99182cc..d147132d1 100644 --- a/configure/src/metaconfigs/tab-ui-config.json +++ b/configure/src/metaconfigs/tab-ui-config.json @@ -125,6 +125,7 @@ "name": "DEM Fallback URL", "description": "A URL to an underlying and ever-present DEM terrain tileset to fill in any missing elevation tiles of active layers.", "type": "text", + "disableSwitch": "panels.globe", "width": 8 }, { @@ -132,6 +133,7 @@ "name": "DEM Fallback Format", "description": "", "type": "dropdown", + "disableSwitch": "panels.globe", "width": 2, "options": ["tms", "wmts", "wms"] }, diff --git a/configure/src/pages/Users/Modals/UpdateUserModal/UpdateUserModal.js b/configure/src/pages/Users/Modals/UpdateUserModal/UpdateUserModal.js index f0b145ddd..dc57b35cd 100644 --- a/configure/src/pages/Users/Modals/UpdateUserModal/UpdateUserModal.js +++ b/configure/src/pages/Users/Modals/UpdateUserModal/UpdateUserModal.js @@ -191,7 +191,10 @@ const UpdateUserModal = (props) => { {}, (res) => { if (res?.missions) { - setAvailableMissions(res.missions); + const missions = res.missions + .slice() + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + setAvailableMissions(missions); } }, (res) => { diff --git a/src/App.js b/src/App.js index d89bdd214..c06634963 100644 --- a/src/App.js +++ b/src/App.js @@ -67,7 +67,10 @@ function initApp() { 'missions', {}, function (s) { - continueOn(s.missions) + const missions = (s.missions || []) + .slice() + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + continueOn(missions) }, function (e) { continueOn([])