From f7bedb5a983c88da4aa1886572faada193ada64f Mon Sep 17 00:00:00 2001 From: Brian McLeer Date: Fri, 12 Jun 2026 17:25:24 -0600 Subject: [PATCH 01/12] Some UI and settings updates --- config.json | 11 +- icon.svg | 2 +- manifest.json | 34 +- src/config.ts | 17 +- src/runtime/translations/default.ts | 85 ++ src/runtime/widget.tsx | 1824 ++++++++++++--------------- src/setting/setting.tsx | 136 +- src/setting/translations/default.ts | 19 + 8 files changed, 1024 insertions(+), 1104 deletions(-) create mode 100644 src/runtime/translations/default.ts create mode 100644 src/setting/translations/default.ts diff --git a/config.json b/config.json index d3887ee..a86e81f 100644 --- a/config.json +++ b/config.json @@ -1,3 +1,10 @@ { - "savedInstancesToString":"" -} \ No newline at end of file + "captureViewpoint": true, + "captureLayers": true, + "captureFilters": true, + "captureBasemap": true, + "captureTime": false, + "captureGraphics": true, + "maxInstances": 0, + "defaultInstanceName": "" +} diff --git a/icon.svg b/icon.svg index 7851772..e819e89 100644 --- a/icon.svg +++ b/icon.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/manifest.json b/manifest.json index be8db52..2105b4c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,18 +1,18 @@ { - "name": "saveInstance", - "label": "Save Instance", - "type": "widget", - "version": "1.0.5", - "exbVersion": "1.17.0", - "author": "Sven Jensen", - "description": "Allow user to save the extent of the map, layer(s) status, graphcis.. etc.", - "copyright": "", - "properties": {}, - "translatedLocales": [ - "en" - ], - "defaultSize": { - "width": 250, - "height": 450 - } - } \ No newline at end of file + "name": "saveInstance", + "label": "Save Instance", + "type": "widget", + "version": "2.0.0", + "exbVersion": "1.20.0", + "author": "Sven Jensen", + "description": "Save, load and share the state of your map, extent/viewpoint, layer visibility & opacity, definition expressions, basemap, time and graphics, stored locally in the browser. Rebuilt for accessibility (WCAG 2.1 AA).", + "copyright": "", + "properties": {}, + "translatedLocales": [ + "en" + ], + "defaultSize": { + "width": 320, + "height": 520 + } +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 6b12c73..80755fa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,22 @@ import { type ImmutableObject } from 'seamless-immutable' export interface Config { - savedInstancesToString: string, + /** Save the current viewpoint (center, scale, rotation, and 3D camera). */ + captureViewpoint: boolean + /** Save each layer's visibility and opacity. */ + captureLayers: boolean + /** Save feature-layer definition expressions and label visibility. */ + captureFilters: boolean + /** Save the active basemap. */ + captureBasemap: boolean + /** Save the map's time extent (time slider position). */ + captureTime: boolean + /** Save view graphics (including Draw/Sketch graphics). */ + captureGraphics: boolean + /** Maximum number of stored instances. 0 means unlimited. */ + maxInstances: number + /** Name of an instance to load automatically when the app opens. Empty means none. */ + defaultInstanceName: string } export type IMConfig = ImmutableObject diff --git a/src/runtime/translations/default.ts b/src/runtime/translations/default.ts new file mode 100644 index 0000000..a0c8023 --- /dev/null +++ b/src/runtime/translations/default.ts @@ -0,0 +1,85 @@ +export default { + _widgetLabel: 'Save Instance', + + // Save panel + saveHeading: 'Save the current map', + instanceNameLabel: 'Instance name', + instanceNameHint: 'Give this saved view a short, recognizable name.', + saveInstance: 'Save instance', + saved: 'Saved instance “{name}”.', + + // Saved list + savedHeading: 'Saved instances', + emptyState: 'No saved instances yet. Enter a name above and choose Save instance to store the current map.', + searchLabel: 'Filter saved instances', + searchPlaceholder: 'Filter by name', + sortLabel: 'Sort instances', + sortNameAsc: 'Name (A–Z)', + sortNameDesc: 'Name (Z–A)', + sortNewest: 'Newest first', + sortOldest: 'Oldest first', + noMatches: 'No instances match “{query}”.', + + // Table + tableCaption: 'Saved map instances and the actions available for each.', + colName: 'Name', + colSaved: 'Saved', + colActions: 'Actions', + showLegend: 'Show what each action does', + legendHeading: 'Action reference', + legendIcon: 'Icon', + legendAction: 'Action', + closeLegend: 'Close action reference', + + // Row actions (used as accessible names) + loadAction: 'Load “{name}” onto the map', + renameAction: 'Rename “{name}”', + downloadAction: 'Download “{name}”', + clearGraphicsAction: 'Clear graphics for “{name}” from the map', + deleteAction: 'Delete “{name}”', + loadingInstance: 'Loading “{name}”…', + loaded: 'Loaded instance “{name}”.', + + // Rename modal + renameTitle: 'Rename instance', + renameLabel: 'New name for “{name}”', + rename: 'Rename', + renamed: 'Renamed to “{name}”.', + + // Delete modal + deleteTitle: 'Delete instance', + deleteConfirm: 'Delete the instance “{name}”? This cannot be undone.', + delete: 'Delete', + deleted: 'Deleted instance “{name}”.', + graphicsCleared: 'Cleared graphics for “{name}”.', + + // Upload / download footer + uploadInstances: 'Upload instances', + downloadInstances: 'Download all', + uploadHint: 'Import instances from a .txt file you have downloaded.', + imported: 'Imported {count, plural, one {# instance} other {# instances}}.', + + // Replace-on-import modal + replaceTitle: 'Instance already exists', + replaceConfirm: 'An instance named “{name}” already exists. Replace it with the imported one?', + replace: 'Replace', + keepExisting: 'Keep existing', + + // Shared modal buttons + cancel: 'Cancel', + confirm: 'Confirm', + + // Errors / status + errNoMap: 'Connect this widget to a map before saving.', + errNoName: 'Enter an instance name first.', + errDuplicate: 'An instance named “{name}” already exists. Choose a different name.', + errMaxInstances: 'You have reached the limit of {max} saved instances. Delete one before saving another.', + errStorageQuota: 'The browser storage limit was reached, so the instance was not saved. Delete some instances or reduce saved graphics.', + errStorage: 'The instance could not be saved to browser storage.', + errStorageUnavailable: 'Browser storage is unavailable, so instances cannot be saved on this device.', + errInvalidFile: 'Choose a valid .txt file exported by this widget.', + errInvalidContent: 'That file is not a valid instance export.', + errNothingToDownload: 'There are no saved instances to download.', + errWrongMap: 'This instance was saved from a different map. Some settings may not apply.', + errLoadFailed: 'The instance could not be fully loaded.' +} diff --git a/src/runtime/widget.tsx b/src/runtime/widget.tsx index 9f36ac1..1be9581 100644 --- a/src/runtime/widget.tsx +++ b/src/runtime/widget.tsx @@ -1,1114 +1,878 @@ -import { React, type AllWidgetProps } from 'jimu-core' -import { type IMConfig } from '../config' // Ensure that the '../config' module exists and is correctly named +/** @jsx jsx */ +import { + React, + jsx, + css, + hooks, + type AllWidgetProps +} from 'jimu-core' +import { type IMConfig } from '../config' import { JimuMapView, JimuMapViewComponent } from 'jimu-arcgis' -import { useEffect, useState } from 'react'; -import Extent from 'esri/geometry/Extent'; -import Graphic from "@arcgis/core/Graphic.js"; -import Basemap from "@arcgis/core/Basemap.js"; -import { Loading } from 'jimu-ui' -import * as reactiveUtils from "@arcgis/core/core/reactiveUtils.js"; -import Collection from "@arcgis/core/core/Collection.js"; -import Layer from "@arcgis/core/layers/Layer.js"; +import { + Button, + TextInput, + Label, + Loading, + LoadingType, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Alert, + Tooltip, + Select, + Option +} from 'jimu-ui' +import Extent from '@arcgis/core/geometry/Extent.js' +import Viewpoint from '@arcgis/core/Viewpoint.js' +import Graphic from '@arcgis/core/Graphic.js' +import Basemap from '@arcgis/core/Basemap.js' +import TimeExtent from '@arcgis/core/time/TimeExtent.js' +import Collection from '@arcgis/core/core/Collection.js' +import defaultMessages from './translations/default' /** - * @author Sven Jensen - * @version 1.0.1 - * - * The Save Instance widget developed by Sven Jensen, 2025. + * Save Instance, rebuilt for accessibility (WCAG 2.1 AA) and a wider set of + * captured map state. Instances are stored in the browser's localStorage. + * + * @author Sven Jensen, 2025 + * @version 2.0.0 */ -const Widget = (props: AllWidgetProps) => { - - const [jimuMapView, setJimuMapView] = useState(null); - const [currentInstanceName, setCurrentInstanceName] = useState(""); - - const [showFunctionLegend, setShowFunctionLegend] = useState(false); - const [savedInstances, setSavedInstances] = useState([]); - - const [currentlyLoadingIndex, setCurrentlyLoadingIndex] = useState(-1); - - - // Esri color ramps - Blue 19 - // #00497cff,#0062a8ff,#007cd3ff,#00b7ffff - const blue19Colors = ["#00497cff", "#0062a8ff", "#007cd3ff", "#00b7ffff"]; - const storageKey = "saveInstanceWidgetInstances"; - const jimuDrawGroupLayerIdIdentifier = "jimu-draw"; - - - - useEffect(() => { - loadSavedInstancesFromStorage(); - },[]) - - /** - * Description: Handles the change of the active view. - * @param {type} jmv - The new active view - * @return {void} - */ - const activeViewChangeHandler = (jmv: JimuMapView) => { - if (jmv) { - - // Basic example of watching for changes on a boolean property - reactiveUtils.watch( - // getValue function - () => jmv.view.updating, - // callback - (updating) => { - if(updating){ - }else{ - setCurrentlyLoadingIndex(-1) - } - }); - - setJimuMapView(jmv); - } - }; - - - //Get the name,webmapId,extent,layers,graphics - //getMapInstanceData() - /** - * returns the instance object for the current map - * @returns {Object} map settings for instance - */ - const getSettingsForCurrentMap = async () => { - // if(isLoading){ - // const shouldSave = window.confirm("The map is currently updating. Are you sure you want to save the current instance?"); - // if(!shouldSave){ - // return null - // } - // } - if (!jimuMapView) return null; - - - if(currentInstanceName === ""){ - alert("Please enter an instance name") - return null - } - - //check if there is an instance with the same name as currentInstanceName - const duplicateInstance = savedInstances.filter(s => s.name === currentInstanceName); - if(duplicateInstance.length > 0){ - alert(`The instance "${currentInstanceName}" already exists, please choose another name`) - return null - } - - - const settings = { - name: currentInstanceName, - webmapId: jimuMapView.view.map.portalItem.id, - extent: jimuMapView.view.extent.toJSON(), - layers: [], - graphics: jimuMapView.view.graphics.toArray(), - basemap: jimuMapView.view.map.basemap.toJSON() - }; - - //let settingsGraphics; - - try { - const layerSettings = await getLayerSettingsForCurrentMap(); - settings.layers = layerSettings; - // settingsGraphics = layerSettings[1]; - } catch (err) { - console.error("An error occurred while getting the layers from the current map.", err); - } - - settings.graphics = getGraphicsForCurrentMap(); - - - const updatedSavedInstances = [...savedInstances, settings]; - setSavedInstances(updatedSavedInstances); - - - storeInstances(updatedSavedInstances); - return settings; - }; - - - /** - * Collects all graphics from the current map view and the graphics layers. - * @param {Array} graphicsLayersGraphics Graphics from the graphics layers. - * @returns {Array} An array of graphics in JSON format. - */ - function getGraphicsForCurrentMap(){ - const graphicsList = [] - - //Collect graphics from view.graphics - const viewGraphics = jimuMapView.view.graphics; - //store each graphic.toJSON() in the gralphicsList - viewGraphics.forEach(graphic => { - graphicsList.push(graphic.toJSON()); - }); - - return graphicsList - } - - -/** - * Represents a layer or sublayer node in the hierarchy tree. - */ -interface LayerHierarchyNode { - layerSettings: {}; - subLayers: LayerHierarchyNode[]; // subLayers is an array of nodes - subLayersType: string; +const STORAGE_KEY = 'saveInstanceWidgetInstances' +const SCHEMA_VERSION = 2 +const DRAW_GROUP_LAYER_ID = 'jimu-draw' + +// Calcite web component, registered globally by Experience Builder. Typed as a +// dynamic tag so it is valid JSX under the emotion (jimu-core) jsx pragma. +const CalciteIcon: any = 'calcite-icon' + +type SortKey = 'name-asc' | 'name-desc' | 'date-desc' | 'date-asc' +type StatusKind = 'success' | 'warning' | 'error' + +interface StatusMessage { kind: StatusKind, text: string } + +type ActiveModal = + | { kind: 'rename', name: string, value: string } + | { kind: 'delete', name: string } + | { kind: 'replace', dupes: string[], incoming: any[] } + | null + +// ------------------------------------------------------------------------- +// Unicode-safe base64 (btoa/atob only handle Latin-1 and throw on emoji etc.) +// ------------------------------------------------------------------------- +function toBase64 (str: string): string { + const bytes = new TextEncoder().encode(str) + let binary = '' + const chunk = 0x8000 + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk) as unknown as number[]) + } + return btoa(binary) } -/** -* Recursively builds a hierarchical array structure of layers and sublayers. -* -* @param {Collection} layersToProcess - The collection of layers to process at the current level. -* @returns {LayerHierarchyNode[]} An array representing the hierarchical structure -* of the layers provided in layersToProcess. -*/ -function buildLayerHierarchyRecursive(layersToProcess: Collection): LayerHierarchyNode[] { - const hierarchy: LayerHierarchyNode[] = []; - - // Base case: If there are no layers in the current collection, return an empty array. - if (!layersToProcess || layersToProcess.length === 0) { - return hierarchy; +function fromBase64 (b64: string): string { + const binary = atob(b64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + try { + // v2 writes UTF-8; this also decodes plain-ASCII v1 data correctly. + return new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch (e) { + // v1 used btoa() directly (Latin-1). Fall back to the raw byte string, + // which is exactly what v1's atob() produced. + return binary } - - - let graphicsLayersGraphics = []; - - // Iterate through the layers at the current level. - layersToProcess.forEach((item) => { - let subLayersType = 'null'; - let immediateSublayers = new Collection(); - - // Determine immediate sublayers for the current item. - // This logic works for various layer types including MapImageLayer, GroupLayer, etc. - // Use .toArray() because allSublayers/sublayers/layers return Collections. - // We add them to a new Collection to pass to the recursive call. - if (item.type !== "map-notes") { - if (item.allSublayers && item.allSublayers.length > 0) { - immediateSublayers.addMany(item.allSublayers); - subLayersType = 'allSublayers' - } else if (item.sublayers && item.sublayers.length > 0) { - immediateSublayers.addMany(item.sublayers); - subLayersType = 'sublayers' - } else if (item.layers && item.layers.length > 0) { // Specifically for GroupLayer type - immediateSublayers.addMany(item.layers); - subLayersType = 'layers' - } else if(item.subLayers && item.subLayers.length > 0){ - immediateSublayers.addMany(item.subLayers); - subLayersType = 'subLayers' - } - } - - // --- Recursive Step --- - // Recursively call the function to build the hierarchy for the immediate sublayers. - const nestedSublayerHierarchy = buildLayerHierarchyRecursive(immediateSublayers); - - const layerSettings = getSettingsForLayer(item); - - - // Create the node structure for the current item. - // The 'subLayers' property holds the array structure returned by the recursive call. - const currentNode: LayerHierarchyNode = { - 'layerSettings': layerSettings, - 'subLayers': nestedSublayerHierarchy, // This is the array of child nodes - 'subLayersType': subLayersType - }; - - // Add the current node to the hierarchy array for this level. - hierarchy.push(currentNode); - }); - - // Return the array structure built for this level of the hierarchy. - return hierarchy; } -/** -* Initiates the building of the layer hierarchy tree starting from the map's top-level layers. -* -* @returns {LayerHierarchyNode[]} The complete hierarchical array structure of map layers. -*/ -function getLayerHierarchyTree(): LayerHierarchyNode[] { //LayerHierarchyNode[] { - // Get the top-level layers from the map. - const topLevelLayers = jimuMapView.view.map.layers; - - // Start the recursive process with the top-level layers. - const hierarchy = buildLayerHierarchyRecursive(topLevelLayers); - - - return hierarchy; +function storageAvailable (): boolean { + try { + const x = '__si_test__' + window.localStorage.setItem(x, x) + window.localStorage.removeItem(x) + return true + } catch (e) { + return false + } } - /** - * Retrieves the settings for all layers in the current map view. - * @returns {Promise} A promise that resolves to an array of layer settings. - */ - const getLayerSettingsForCurrentMap = async () => { - if (!jimuMapView) return []; - - //let graphicsLayersGraphics = []; - try { - const settings = getLayerHierarchyTree(); - // const settings = Object.values(layerObjects).map((layer, idx) => { +/** Normalize/upgrade an instance object to the current schema. */ +function migrateInstance (raw: any): any { + if (!raw || typeof raw !== 'object' || typeof raw.name !== 'string') return null + // v1 did not tag top-level view graphics with the instance name, so clear and + // dedupe by instance would miss them. Backfill the attribute on migration. + const graphics = (Array.isArray(raw.graphics) ? raw.graphics : []).map((g: any) => + (g && typeof g === 'object') + ? { ...g, attributes: { ...(g.attributes || {}), instance: g?.attributes?.instance ?? raw.name } } + : g) + return { + schemaVersion: SCHEMA_VERSION, + name: raw.name, + createdAt: raw.createdAt || new Date().toISOString(), + webmapId: raw.webmapId ?? null, + viewpoint: raw.viewpoint ?? null, + extent: raw.extent ?? null, + timeExtent: raw.timeExtent ?? null, + basemap: raw.basemap ?? null, + layers: Array.isArray(raw.layers) ? raw.layers : [], + graphics + } +} - // const layerSettings = getSettingsForLayer(layer); - - // const layerSettingsObject = layerSettings[0]; - // const isGraphicsLayer = layerSettings[1] as Boolean; - // const layerGraphicsJSON = layerSettings[2]; - - // if(isGraphicsLayer === true){ //If it is a graphics layer - // graphicsLayersGraphics.push(...layerGraphicsJSON); //add it's graphics to the list - // } - // return layerSettingsObject; - // }); +const Widget = (props: AllWidgetProps): React.ReactElement => { + const translate = hooks.useTranslation(defaultMessages) + const config = props.config + + const [jimuMapView, setJimuMapView] = React.useState(null) + const [savedInstances, setSavedInstances] = React.useState([]) + const [nameInput, setNameInput] = React.useState('') + const [search, setSearch] = React.useState('') + const [sort, setSort] = React.useState('date-desc') + const [loadingName, setLoadingName] = React.useState(null) + const [showLegend, setShowLegend] = React.useState(false) + const [status, setStatus] = React.useState(null) + const [modal, setModal] = React.useState(null) + + const statusTimer = React.useRef(null) + const defaultLoadedRef = React.useRef(false) + const fileInputRef = React.useRef(null) + const nameInputId = React.useRef(`si-name-${props.id}`).current + const searchInputId = React.useRef(`si-search-${props.id}`).current + + // --------------------------------------------------------------------- + // Status banner helper (announced to screen readers via the Alert region) + // --------------------------------------------------------------------- + const announce = React.useCallback((kind: StatusKind, text: string) => { + setStatus({ kind, text }) + if (statusTimer.current) window.clearTimeout(statusTimer.current) + if (kind === 'success') { + statusTimer.current = window.setTimeout(() => { setStatus(null) }, 5000) + } + }, []) - //return [settings, graphicsLayersGraphics]; + React.useEffect(() => () => { if (statusTimer.current) window.clearTimeout(statusTimer.current) }, []) - return settings - } catch (err) { - console.error('SaveInstance error in getLayerSettingsForCurrentMap, error getting layersObjects = ', err); - return []; - } - }; - -/** - * Retrieves settings for a specified layer. - * @param {Object} layer - The layer object for which settings are to be retrieved. - * @returns {Array} An array containing the layer settings object, a boolean indicating if the layer is a graphics layer, and an array of graphics in JSON format if applicable. - */ - const getSettingsForLayer = (layer) => { - - let layerGraphicsJSON; - //let isGraphicsLayer = false; + // --------------------------------------------------------------------- + // Storage + // --------------------------------------------------------------------- + const loadFromStorage = React.useCallback(() => { + if (!storageAvailable()) { + announce('warning', translate('errStorageUnavailable')) + return + } + const stored = window.localStorage.getItem(STORAGE_KEY) + if (!stored) return + try { + const parsed = JSON.parse(fromBase64(stored)) + const migrated = (Array.isArray(parsed) ? parsed : []).map(migrateInstance).filter(Boolean) + setSavedInstances(migrated) + } catch (e) { + console.error('SaveInstance: could not read stored instances.', e) + } + }, [announce, translate]) - const layerSettings = { - id: layer.id, - name: layer.title, - type: getLayerType(layer), - isVisible: layer.visible, - options: null, - graphics: null - }; + /** Persist instances. Returns true on success. */ + const persist = React.useCallback((instances: any[]): boolean => { + if (!storageAvailable()) { + announce('error', translate('errStorageUnavailable')) + return false + } + try { + window.localStorage.setItem(STORAGE_KEY, toBase64(JSON.stringify(instances))) + return true + } catch (e) { + const quota = e instanceof DOMException && + (e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED') + announce('error', translate(quota ? 'errStorageQuota' : 'errStorage')) + return false + } + }, [announce, translate]) - switch (layerSettings.type) { - case "MapImageLayer": //used to be ArcGISDynamicMapServiceLayer - layerSettings.options = getOptionsForDynamicLayer(layer); - break; - case "FeatureLayer": - layerSettings.options = getOptionsForFeatureLayer(layer); - break; - case "TileLayer": - layerSettings.options = getOptionsForTiledLayer(layer); - break; - case "GroupLayer": - layerSettings.options = getOptionsForGroupLayer(layer) - break; - case "GraphicsLayer": - layerSettings.graphics = getGraphicsFromGraphicsLayer(layer) - break; - case "UnknownLayerType": - layerSettings.options = getOptionsForUnknownLayer(layer); - break; - default: - break; - } + React.useEffect(() => { loadFromStorage() }, [loadFromStorage]) - return layerSettings - }; + // --------------------------------------------------------------------- + // Map view + // --------------------------------------------------------------------- + const activeViewChangeHandler = (jmv: JimuMapView): void => { + if (jmv) setJimuMapView(jmv) + } - + // Optional: load a default instance once on startup + React.useEffect(() => { + if (defaultLoadedRef.current) return + const target = config?.defaultInstanceName + if (!jimuMapView || !target || savedInstances.length === 0) return + const found = savedInstances.find(i => i.name === target) + if (found) { + defaultLoadedRef.current = true + void loadInstance(found) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [jimuMapView, savedInstances, config?.defaultInstanceName]) + + // --------------------------------------------------------------------- + // Capture map state + // --------------------------------------------------------------------- + const getLayerType = (layer: any): string => { + switch (layer.type) { + case 'feature': return 'FeatureLayer' + case 'tile': return 'TileLayer' + case 'map-image': return 'MapImageLayer' + case 'group': return 'GroupLayer' + case 'graphics': return 'GraphicsLayer' + default: return 'UnknownLayerType' + } + } - /** - * Determines the type of the layer. - * @param {Object} layer - the layer whose type is to be determined - * @returns {string} the type of the layer - */ - const getLayerType = (layer) => { - if (layer.type === 'feature') return 'FeatureLayer'; - if (layer.type === 'tile') return 'TileLayer'; - if (layer.type === 'map-image') return 'MapImageLayer'; - if(layer.type === "group") return "GroupLayer"; - if(layer.type === "graphics") return "GraphicsLayer"; - return 'UnknownLayerType'; - }; + const getGraphicsFromGraphicsLayer = (layer: any, instanceName: string): any[] => { + return layer.graphics.toArray().map((graphic: any) => { + graphic.attributes = { ...graphic.attributes, instance: instanceName } + return graphic.toJSON() + }) + } - - /** - * Extracts all graphics from the given GraphicsLayer, and adds the current - * instance name to the graphics' attributes. - * @param {Object} layer - the GraphicsLayer from which to extract the graphics - * @returns {Array} an array of graphics in JSON format - */ - const getGraphicsFromGraphicsLayer = (layer) => { - let layerGraphicsList = []; - const graphics = layer.graphics.toArray(); - graphics.forEach(graphic => { - graphic.attributes = { - ...graphic.attributes, - "instance": currentInstanceName - } - layerGraphicsList.push(graphic.toJSON()); - }); - return layerGraphicsList - } + const getSettingsForLayer = (layer: any, instanceName: string): any => { + const settings: any = { + id: layer.id, + name: layer.title, + type: getLayerType(layer), + isVisible: layer.visible, + options: {}, + graphics: null + } + if (config.captureLayers) { + settings.options.opacity = layer.opacity + if (typeof layer.refreshInterval === 'number') settings.options.refreshInterval = layer.refreshInterval + } + if (config.captureFilters && settings.type === 'FeatureLayer') { + if (layer.definitionExpression != null) settings.options.definitionExpression = layer.definitionExpression + if (typeof layer.labelsVisible === 'boolean') settings.options.labelsVisible = layer.labelsVisible + } + if (config.captureGraphics && settings.type === 'GraphicsLayer') { + settings.graphics = getGraphicsFromGraphicsLayer(layer, instanceName) + } + return settings + } - /** - * Returns the options for an unknown layer type. - * @param {Object} layer - the layer for which to generate the options - * @returns {Object} the options for the layer - */ - function getOptionsForUnknownLayer(layer) { - const options = { - opacity: layer.opacity, - visibleTimeExtent: layer.visibleTimeExtent, - }; - return options; + const buildLayerHierarchy = (layers: any, instanceName: string): any[] => { + const hierarchy: any[] = [] + if (!layers || layers.length === 0) return hierarchy + layers.forEach((item: any) => { + let subLayersType = 'null' + const immediate = new Collection() + if (item.type !== 'map-notes') { + if (item.allSublayers && item.allSublayers.length > 0) { immediate.addMany(item.allSublayers); subLayersType = 'allSublayers' } else if (item.sublayers && item.sublayers.length > 0) { immediate.addMany(item.sublayers); subLayersType = 'sublayers' } else if (item.layers && item.layers.length > 0) { immediate.addMany(item.layers); subLayersType = 'layers' } else if (item.subLayers && item.subLayers.length > 0) { immediate.addMany(item.subLayers); subLayersType = 'subLayers' } } + hierarchy.push({ + layerSettings: getSettingsForLayer(item, instanceName), + subLayers: buildLayerHierarchy(immediate, instanceName), + subLayersType + }) + }) + return hierarchy + } - /** - * Returns the options for the given dynamic layer. - * @param {Object} layer - the dynamic layer for which to generate the options - * @returns {Object} the options for the dynamic layer - */ - const getOptionsForDynamicLayer = (layer) => { - const options = { - opacity: layer.opacity, - refreshInterval: layer.refreshInterval, - }; - return options; - }; - - /** - * Returns the options for the given feature layer. - * @param {Object} layer - the feature layer for which to generate the options - * @returns {Object} the options for the feature layer - */ - const getOptionsForFeatureLayer = (layer) => { - const options = { - mode: layer.mode || 'on-demand', - outFields: ["*"], - opacity: layer.opacity, - refreshInterval: layer.refreshInterval, - }; - - return options; - }; - - /** - * Returns the options for the given tiled layer. - * @param {Object} layer - the tiled layer for which to generate the options - * @returns {Object} the options for the tiled layer - */ - - const getOptionsForTiledLayer = (layer) => { - const options = { - opacity: layer.opacity, - refreshInterval: layer.refreshInterval, - }; - - return options; - }; - - - /** - * Returns the options for the given group layer. - * @param {Object} layer - the group layer for which to generate the options - * @returns {Object} the options for the group layer - * - * The options for the group layer are gathered from the layer's properties - * and from the settings of the layers in it, which are gathered by calling - * getSettingsForLayer() on each of them. - */ - const getOptionsForGroupLayer = (layer) => { - const options = { - opacity: layer.opacity, - refreshInterval: layer.refreshInterval, - }; - - return options; - }; - - - - - - // Define interfaces for clarity based on your settings structure - interface SingleLayerSettings { - id: string | number; // Use number for sublayers of MapImageLayer if applicable - name: string; - type: string; // Or a more specific type/enum if known - isVisible: boolean; - options: Record; // Dictionary of other options like opacity - graphics: any[]; + const buildInstance = (name: string): any => { + const view = jimuMapView.view + const instance: any = { + schemaVersion: SCHEMA_VERSION, + name, + createdAt: new Date().toISOString(), + webmapId: view.map?.portalItem?.id ?? null, + viewpoint: null, + extent: null, + timeExtent: null, + basemap: null, + layers: [], + graphics: [] } - - interface LayerSettingsNode { - layerSettings: SingleLayerSettings; - subLayers: LayerSettingsNode[]; // Nested array of sub-settings nodes - subLayersType: "allSublayers" | "sublayers" | "subLayers" | "layers" | "null"; // How to access live sublayers + if (config.captureViewpoint) { + instance.viewpoint = view.viewpoint?.toJSON() ?? null + instance.extent = view.extent?.toJSON() ?? null } - - - /** - * Recursively applies settings from a settings structure to the corresponding layers on the map. - * - * @param {LayerSettingsNode[]} settingsNodes - Array of settings nodes for the current level of recursion. - * @param {Collection} liveLayersCollection - The collection of live layers from the map that corresponds - * to the settingsNodes array at this level. - */ - function applyLayerSettingsRecursive(settingsNodes: LayerSettingsNode[], liveLayersCollection: Collection, instanceName:string): void { - // Base case: If there are no settings nodes to process or no live layers to apply settings to, stop recursion. - if (!settingsNodes || settingsNodes.length === 0 || !liveLayersCollection) { - return; + if (config.captureTime && view.timeExtent) { + instance.timeExtent = { + start: view.timeExtent.start ? view.timeExtent.start.toISOString() : null, + end: view.timeExtent.end ? view.timeExtent.end.toISOString() : null } - - // Loop through each layer/sublayer's settings node at the current level - settingsNodes.forEach((settingNode) => { - const setting = settingNode.layerSettings; - const settingsSubLayers = settingNode.subLayers; - const subLayersType = settingNode.subLayersType; - - - // If the layer from the settings is not found on the map (e.g., it was removed), skip it. - - if(setting.id.toString().includes(jimuDrawGroupLayerIdIdentifier) && setting.type === "GroupLayer"){ - //this is the jimu draw widget layer. - //add all the graphics to the map. - //grab the settingsSubLayers.subLayers - settingsSubLayers.forEach((subLayer) => { - if(subLayer.layerSettings.type === "GraphicsLayer"){ - setGraphicsOnMap(subLayer.layerSettings.graphics, instanceName); - } - }) - } - - // --- Find the corresponding live layer on the map --- - // Use the ID from the settings to find the actual layer object in the live collection. - const liveLayer = liveLayersCollection.find(layer => layer.id === setting.id); - - if (!liveLayer) { - console.warn(`Layer with ID "${setting.id}" not found on the map. Settings cannot be applied for this layer.`); - return; // Move to the next setting node - } - - // --- Apply settings to the found live layer --- - - // Apply visibility - try { - liveLayer.visible = setting.isVisible; - } catch (error) { - console.warn(`Could not set visibility for layer "${liveLayer.id}":`, error); - } - - - // Apply other options (like opacity) - if (setting.options) { - for (const optionName in setting.options) { - // Check if the layer object actually has this property before attempting to set it. - // Some properties might only exist on specific layer types. - // For simplicity, we'll attempt dynamic assignment, but be aware of potential errors. - try { - // Using 'as any' to bypass strict type checking for dynamic property assignment - (liveLayer as any)[optionName] = setting.options[optionName]; - } catch (error) { - console.warn(`Could not apply option "${optionName}" to layer "${liveLayer.id}":`, error); - } - } - } - - // --- Recurse for sublayers if they exist in the settings --- - if (settingsSubLayers && settingsSubLayers.length > 0) { - let liveSublayersCollection: Collection | undefined; - - // Determine the correct property name on the live layer to access its immediate children, - // based on the 'subLayersType' saved in the settings. - // Need to cast the live layer to potentially different types as these properties - // might exist on specific Layer subclasses (MapImageLayer, GroupLayer). - switch (subLayersType) { - case "allSublayers": - // 'allSublayers' is typically on MapImageLayer - liveSublayersCollection = liveLayer.allSublayers; - break; - case "sublayers": - // 'sublayers' can be on MapImageLayer, ElevationLayer, etc. - liveSublayersCollection = liveLayer.sublayers || liveLayer.sublayers; // Add GroupLayer as sometimes sublayers is there too - break; - case "layers": - // 'layers' is typically on GroupLayer - liveSublayersCollection = liveLayer.layers.items; - break; - case "subLayers": - // 'layers' is typically on GroupLayer - liveSublayersCollection = liveLayer.subLayers; - break; - - default: - // If subLayersType is 'null' or unexpected, there are no sublayers to process via property access - console.warn(`Unknown or null subLayersType "${subLayersType}" for layer "${liveLayer.id}". Cannot recurse into live sublayers.`); - liveSublayersCollection = undefined; // Ensure it's undefined - break; - } - - - // If we successfully found the live collection of sublayers for the current layer, - // make the recursive call with the sub-settings and the live sub-collection. - if (liveSublayersCollection) { - applyLayerSettingsRecursive(settingsSubLayers, liveSublayersCollection, instanceName); - } else { - // This might happen if the layer type in the map doesn't match what was saved - // or if the property didn't exist for some other reason. - console.warn(`Could not find the live sublayer collection for layer "${liveLayer.id}" using type "${subLayersType}". Cannot apply settings to its children.`); - } - } - }); } - - - /** - * Entry point function to apply saved layer settings to the map. - * - * @param {LayerSettingsNode[]} settings - The hierarchical array structure of layer settings to apply. - */ - function setLayersOnMap(settings: LayerSettingsNode[], instanceName:string): void { - if (!settings || settings.length === 0) { - console.log("No layer settings provided to apply."); - return; + if (config.captureBasemap && view.map?.basemap) { + instance.basemap = view.map.basemap.toJSON() + } + if (config.captureLayers || config.captureFilters || config.captureGraphics) { + try { + instance.layers = buildLayerHierarchy(view.map.layers, name) + } catch (e) { + console.error('SaveInstance: error reading layers.', e) } - - // Start the recursive process with the top-level settings array - // and the map's top-level layers collection. - const topLevelLiveLayers = jimuMapView.view.map.layers; - applyLayerSettingsRecursive(settings, topLevelLiveLayers, instanceName); - - console.log("Finished applying layer settings."); } + if (config.captureGraphics) { + instance.graphics = view.graphics.toArray().map((g: any) => g.toJSON()) + } + return instance + } + const handleSave = (): void => { + if (!jimuMapView) { announce('error', translate('errNoMap')); return } + const name = nameInput.trim() + if (!name) { announce('error', translate('errNoName')); return } + if (savedInstances.some(i => i.name === name)) { + announce('error', translate('errDuplicate', { name })) + return + } + if (config.maxInstances > 0 && savedInstances.length >= config.maxInstances) { + announce('error', translate('errMaxInstances', { max: config.maxInstances })) + return + } + const instance = buildInstance(name) + const updated = [...savedInstances, instance] + if (persist(updated)) { + setSavedInstances(updated) + setNameInput('') + announce('success', translate('saved', { name })) + } + } + // --------------------------------------------------------------------- + // Apply / load map state + // --------------------------------------------------------------------- + const setGraphicsOnMap = (graphics: any[], instanceName: string): void => { + if (!graphics || graphics.length === 0) return + const existing = jimuMapView.view.graphics.filter((g: any) => g?.attributes?.instance === instanceName) + if (existing.length > 0) jimuMapView.view.graphics.removeMany(existing) + graphics.forEach((g: any) => { jimuMapView.view.graphics.add(Graphic.fromJSON(g)) }) + } - - - - - - - - - - - - /** - * Apply graphics to the map. - * @param {Array} graphics - An array of graphics in JSON format. - * @param {string} instanceName - The name of the instance to which the graphics belong. - * - * This function first removes any existing graphics with the same instance name. - * Then it adds all the graphics in the graphics array. - */ - function setGraphicsOnMap(graphics, instanceName){ - //find any graphics with the same instance name - if(graphics.length === 0){ - console.warn("No graphics provided to apply.") - return - } - let existingGraphics = jimuMapView.view.graphics.filter(function(graphic){ - return graphic?.attributes?.instance === instanceName - }) - - - //remove all graphics with same instance name - if(existingGraphics.length > 0){ - jimuMapView.view.graphics.removeMany(existingGraphics) - } - - //add all the graphics - graphics.forEach(function(graphic){ - let graphicFromJSON = Graphic.fromJSON(graphic); - jimuMapView.view.graphics.add(graphicFromJSON); - }) - - } - - - - /** - * Apply the settings from the given instance to the current map - * @param {Object} sessionToLoad a instance - */ - function loadInstance(instanceToLoad, index) { - setCurrentlyLoadingIndex(index) - - //set the current loading instance index to the one that is currently loading - - - - // toggle layers - if (instanceToLoad.layers) { - setLayersOnMap(instanceToLoad.layers, instanceToLoad.name); - } - - - setGraphicsOnMap(instanceToLoad.graphics, instanceToLoad.name); - - - // } - let extentToLoad; - - //set basemap - if (instanceToLoad.basemap) { - let newBaseMap = Basemap.fromJSON(instanceToLoad.basemap); - jimuMapView.view.map.basemap = newBaseMap; - } - - // zoom the map - if (instanceToLoad.extent) { - extentToLoad = Extent.fromJSON(instanceToLoad.extent); - jimuMapView.view.goTo(extentToLoad); - } - } - - -/** - * Checks if the given type of web storage is available and functional. - * Attempts to set and remove an item to verify storage capabilities. - * - * @param {string} type - The type of storage to check, e.g., "localStorage" or "sessionStorage". - * @returns {boolean} - True if storage is available and can be used, false otherwise. - */ - - function storageAvailable(type) { - let storage; - try { - storage = window[type]; - const x = "__storage_test__"; - storage.setItem(x, x); - storage.removeItem(x); - return true; - } catch (e) { - return ( - e instanceof DOMException && - e.name === "QuotaExceededError" && - // acknowledge QuotaExceededError only if there's something already stored - storage && - storage.length !== 0 - ); - } - } - - - -/** - * Loads saved instances from local storage. - * If local storage is available, retrieves, decodes, and parses the saved instances JSON string. - * Updates the current instances with the retrieved saved instances. - * Logs a message if no saved instances are found. - * - * Requires the 'storageAvailable' function to check local storage availability - * and the 'setSavedInstances' function to update the current instances. - */ - function loadSavedInstancesFromStorage() { - if (storageAvailable("localStorage")) { - // Yippee! We can use localStorage awesomeness - var storedString = "", - storedInstances = null; - - storedString = localStorage.getItem(storageKey); - if (!storedString) { - console.log("No saved instances found."); - return; + const applyLayerSettings = (nodes: any[], liveLayers: any, instanceName: string): void => { + if (!nodes || nodes.length === 0 || !liveLayers) return + nodes.forEach((node) => { + const setting = node.layerSettings + const subNodes = node.subLayers + + // Restore Draw/Sketch graphics that were nested under the jimu-draw group + if (setting.id?.toString().includes(DRAW_GROUP_LAYER_ID) && setting.type === 'GroupLayer') { + subNodes?.forEach((sub: any) => { + if (sub.layerSettings.type === 'GraphicsLayer' && config.captureGraphics) { + setGraphicsOnMap(sub.layerSettings.graphics ?? [], instanceName) } + }) + } - const decodedStoredInstances = atob(storedString); - storedInstances = JSON.parse(decodedStoredInstances); - - // replace to current instances - setSavedInstances(storedInstances); - } else { - // Too bad, no localStorage for us + const live = liveLayers.find((l: any) => l.id === setting.id) + if (!live) { + console.warn(`SaveInstance: layer "${setting.id}" not found on the map.`) + return + } + if (config.captureLayers) { + try { live.visible = setting.isVisible } catch (e) { /* not settable */ } + } + if (setting.options) { + Object.keys(setting.options).forEach((key) => { + const captureOk = + (key === 'definitionExpression' || key === 'labelsVisible') ? config.captureFilters : config.captureLayers + if (!captureOk) return + try { (live as any)[key] = setting.options[key] } catch (e) { /* not settable */ } + }) + } + if (subNodes && subNodes.length > 0) { + let liveSub: any + switch (node.subLayersType) { + case 'allSublayers': liveSub = live.allSublayers; break + case 'sublayers': liveSub = live.sublayers; break + case 'layers': liveSub = live.layers; break + case 'subLayers': liveSub = live.subLayers; break + default: liveSub = undefined } + if (liveSub) applyLayerSettings(subNodes, liveSub, instanceName) } + }) + } - /** - * save the current instances to local storage - */ - function storeInstances(savedInstances) { - if (storageAvailable("localStorage")) { - // Yippee! We can use localStorage awesomeness - const savedInstancesJSON = JSON.stringify(savedInstances); - const savedInstancesEncoded = btoa(savedInstancesJSON); - localStorage.setItem(storageKey, savedInstancesEncoded); + const loadInstance = async (instance: any): Promise => { + if (!jimuMapView) { announce('error', translate('errNoMap')); return } + setLoadingName(instance.name) + const view = jimuMapView.view + let hadIssue = false - } else { - // Too bad, no localStorage for us - } + const currentMapId = view.map?.portalItem?.id ?? null + if (instance.webmapId && currentMapId && instance.webmapId !== currentMapId) { + announce('warning', translate('errWrongMap')) } - /** - * Edit the name of an instance - * @param {string} instanceName - the name of the instance to be edited - */ - function editInstanceName(instanceName){ - //open a prompt window asking what they want to change {instanceName} to, and also have a cancel button - const newName = window.prompt("Enter a new name for the instance " + instanceName + ":"); - if(newName){ - //check that the new name is not already in use - const duplicateInstances = savedInstances.filter(s => s.name === newName); - if(duplicateInstances.length > 0){ - alert(`The instance "${newName}" already exists, please choose another name`) - return - } + try { + if (config.captureLayers || config.captureFilters || config.captureGraphics) { + if (instance.layers) applyLayerSettings(instance.layers, view.map.layers, instance.name) + } + if (config.captureGraphics) setGraphicsOnMap(instance.graphics, instance.name) - const savedInstancesCopy = [...savedInstances]; - savedInstancesCopy.forEach(instance => { - if(instance.name === instanceName){ - instance.name = newName; - instance.graphics.forEach(graphic => { - graphic.attributes.instance = newName - }) - } + if (config.captureBasemap && instance.basemap) { + view.map.basemap = Basemap.fromJSON(instance.basemap) + } + if (config.captureTime && instance.timeExtent) { + view.timeExtent = new TimeExtent({ + start: instance.timeExtent.start ? new Date(instance.timeExtent.start) : null, + end: instance.timeExtent.end ? new Date(instance.timeExtent.end) : null }) - setSavedInstances(savedInstancesCopy); - storeInstances(savedInstancesCopy); } - } - - - /** - * Validate the uploaded file. If the uploaded file is valid JSON, it will - * be parsed and each instance will be added to the saved instances list. - * If an instance with the same name already exists, the user will be - * prompted to replace it. - * @param {string} uploadedString The uploaded file as a string. - */ - function validateUploadedString(uploadedString:string){ - //validate the uploaded file - try { - let decodedJSON = atob(uploadedString); - const parsed = JSON.parse(decodedJSON); - - const savedInstancesMap = new Map(savedInstances.map(inst => [inst.name, inst])); - - parsed.forEach(newInstance => { - if (savedInstancesMap.has(newInstance.name)) { - const replace = window.confirm(`The instance "${newInstance.name}" already exists. Do you want to replace it?`); - if (replace) { - savedInstancesMap.set(newInstance.name, newInstance); - } else { - console.log(`User cancelled replacing "${newInstance.name}"`); - } - } else { - savedInstancesMap.set(newInstance.name, newInstance); - } - }); - - - const updatedSavedInstances = Array.from(savedInstancesMap.values()); - setSavedInstances(updatedSavedInstances); - storeInstances(updatedSavedInstances); - - } catch (err) { - console.error("Invalid JSON input"); + if (config.captureViewpoint) { + if (instance.viewpoint) { + await view.goTo(Viewpoint.fromJSON(instance.viewpoint)) + } else if (instance.extent) { + await view.goTo(Extent.fromJSON(instance.extent)) + } + } + } catch (e: any) { + // goTo rejects when interrupted by another navigation, that is benign + if (e?.name !== 'AbortError') { + hadIssue = true + console.error('SaveInstance: error loading instance.', e) } + } finally { + setLoadingName(null) } - -/** - * Handles the file input change event. If a valid text file is uploaded, - * it reads the file content and validates it. If the file is not a valid - * text file, an alert is shown to the user. - * - * @param {Event} event - The file input change event. - */ + announce(hadIssue ? 'warning' : 'success', + translate(hadIssue ? 'errLoadFailed' : 'loaded', { name: instance.name })) + } - const handleFileChange = (event) => { - const file = event.target.files?.[0]; - if (file && file.type === "text/plain") { - const reader = new FileReader(); - reader.onload = (e) => { - const text = e.target?.result; - if (typeof text === 'string') { - validateUploadedString(text) - } - }; - reader.readAsText(file); - } else { - alert("Please upload a valid .txt file."); + // --------------------------------------------------------------------- + // Rename / delete / clear + // --------------------------------------------------------------------- + const commitRename = (oldName: string, newName: string): void => { + const trimmed = newName.trim() + if (!trimmed) return + if (savedInstances.some(i => i.name === trimmed)) { + announce('error', translate('errDuplicate', { name: trimmed })) + return + } + const updated = savedInstances.map(inst => { + if (inst.name !== oldName) return inst + return { + ...inst, + name: trimmed, + graphics: (inst.graphics || []).map((g: any) => + g?.attributes ? { ...g, attributes: { ...g.attributes, instance: trimmed } } : g) } - }; + }) + if (persist(updated)) { + setSavedInstances(updated) + announce('success', translate('renamed', { name: trimmed })) + } + setModal(null) + } -/** - * Initiates a download of the saved instances as a text file. - * The file is named using the provided instance name and the current date and time. - * @param {string} instanceName - The name to prefix the downloaded file with. - */ + const clearGraphics = (name: string): void => { + if (!jimuMapView) return + const existing = jimuMapView.view.graphics.filter((g: any) => g?.attributes?.instance === name) + jimuMapView.view.graphics.removeMany(existing) + announce('success', translate('graphicsCleared', { name })) + } - const handleDownload = (instanceName, isAllInstances) => { + const commitDelete = (name: string): void => { + if (jimuMapView) { + const existing = jimuMapView.view.graphics.filter((g: any) => g?.attributes?.instance === name) + jimuMapView.view.graphics.removeMany(existing) + } + const updated = savedInstances.filter(i => i.name !== name) + if (persist(updated)) { + setSavedInstances(updated) + announce('success', translate('deleted', { name })) + } + setModal(null) + } - if(savedInstances.length === 0){ - alert("There are no saved instances to download.") + // --------------------------------------------------------------------- + // Import / export + // --------------------------------------------------------------------- + const isValidInstanceList = (data: any): boolean => + Array.isArray(data) && data.every(d => d && typeof d === 'object' && typeof d.name === 'string') + + const handleFileChange = (event: React.ChangeEvent): void => { + const file = event.target.files?.[0] + if (event.target) event.target.value = '' + if (!file || !(file.type === 'text/plain' || file.name.toLowerCase().endsWith('.txt'))) { + announce('error', translate('errInvalidFile')) + return + } + const reader = new FileReader() + reader.onload = (e) => { + const text = e.target?.result + if (typeof text !== 'string') { announce('error', translate('errInvalidContent')); return } + let parsed: any + try { + parsed = JSON.parse(fromBase64(text)) + } catch (err) { + announce('error', translate('errInvalidContent')) return } - - let savedInstancesToDownload; - if(isAllInstances){ - savedInstancesToDownload = savedInstances; + if (!isValidInstanceList(parsed)) { announce('error', translate('errInvalidContent')); return } + const incoming = parsed.map(migrateInstance).filter(Boolean) + const existingNames = new Set(savedInstances.map(i => i.name)) + const dupes = incoming.filter(i => existingNames.has(i.name)).map(i => i.name) + if (dupes.length > 0) { + setModal({ kind: 'replace', dupes, incoming }) } else { - savedInstancesToDownload = [savedInstances.find(instance => instance.name === instanceName)]; + applyImport(incoming, false) } - const savedInstancesJSON = JSON.stringify(savedInstancesToDownload); - const savedInstancesEncoded = btoa(savedInstancesJSON); - const date = new Date(); - const formattedDate = `${date.toLocaleString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit' })}-${date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`; - const filename = `${instanceName}-${formattedDate}.txt`; - - const blob = new Blob([savedInstancesEncoded], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - - URL.revokeObjectURL(url); // Clean up after download - }; - - - + } + reader.readAsText(file) + } - /** - * Removes all graphics from the map that have the specified instance name. - * @param {string} instanceName - The name of the instance whose graphics are to be removed. - */ - function removeInstanceGraphicsFromMap(instanceName){ - let existingGraphics = jimuMapView.view.graphics.filter(function(graphic){ - return graphic.attributes.instance === instanceName - }) - jimuMapView.view.graphics.removeMany(existingGraphics) + const applyImport = (incoming: any[], replaceDupes: boolean): void => { + const map = new Map(savedInstances.map(i => [i.name, i])) + let count = 0 + incoming.forEach((inst) => { + const exists = map.has(inst.name) + if (!exists || replaceDupes) { map.set(inst.name, inst); count++ } + }) + const updated = Array.from(map.values()) + if (persist(updated)) { + setSavedInstances(updated) + announce('success', translate('imported', { count })) } + setModal(null) + } -/** - * Removes an instance and its associated graphics from the map. - * Updates the saved instances list by filtering out the specified instance - * and stores the updated list in local storage. - * - * @param {string} instanceName - The name of the instance to be removed. - */ - function removeInstance(instanceName){ - //prompt user if they are sure they want to remove the instance - const shouldRemove = window.confirm(`Are you sure you want to remove the instance "${instanceName}"?`); - if (!shouldRemove) { - return + const handleDownload = (name: string, all: boolean): void => { + if (savedInstances.length === 0) { announce('error', translate('errNothingToDownload')); return } + const toDownload = all ? savedInstances : [savedInstances.find(i => i.name === name)] + const encoded = toBase64(JSON.stringify(toDownload)) + const now = new Date() + const stamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}` + const filename = `${all ? 'all-instances' : name}-${stamp}.txt` + const blob = new Blob([encoded], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + } + + // --------------------------------------------------------------------- + // Derived list (filter + sort) + // --------------------------------------------------------------------- + const visibleInstances = React.useMemo(() => { + const q = search.trim().toLowerCase() + const filtered = q ? savedInstances.filter(i => i.name.toLowerCase().includes(q)) : savedInstances.slice() + filtered.sort((a, b) => { + switch (sort) { + case 'name-asc': return a.name.localeCompare(b.name) + case 'name-desc': return b.name.localeCompare(a.name) + case 'date-asc': return (a.createdAt || '').localeCompare(b.createdAt || '') + default: return (b.createdAt || '').localeCompare(a.createdAt || '') } + }) + return filtered + }, [savedInstances, search, sort]) + + const formatDate = (iso: string): string => { + if (!iso) return '' + const d = new Date(iso) + return isNaN(d.getTime()) ? '' : d.toLocaleDateString() + } - removeInstanceGraphicsFromMap(instanceName) - const updatedSavedInstances = savedInstances.filter(instance => instance.name !== instanceName); - setSavedInstances(updatedSavedInstances) - storeInstances(updatedSavedInstances) + // --------------------------------------------------------------------- + // Styles (theme-driven via EXB CSS vars, with safe light-mode fallbacks) + // --------------------------------------------------------------------- + const styles = css` + padding: 0.75rem; + color: var(--sys-color-surface-paper-text, inherit); + .si-sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } + h3.si-heading { font-size: 0.95rem; margin: 0.25rem 0 0.5rem; } + .si-field { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.5rem; } + .si-hint { font-size: 0.75rem; opacity: 0.75; } + .si-toolbar { display: flex; gap: 0.5rem; align-items: flex-end; margin: 0.5rem 0; flex-wrap: wrap; } + .si-toolbar > * { flex: 1 1 8rem; min-width: 7rem; } + hr.si-rule { border: none; border-top: 1px solid var(--sys-color-divider-primary, rgba(110,110,110,0.35)); margin: 0.75rem 0; } + table.si-table { border-collapse: collapse; width: 100%; font-size: 0.8rem; } + table.si-table caption { text-align: left; } + table.si-table th, table.si-table td { + border: 1px solid var(--sys-color-divider-primary, rgba(110,110,110,0.35)); + padding: 0.25rem; text-align: center; vertical-align: middle; + } + table.si-table thead th { + background: var(--sys-color-primary-main, #076fe5); + color: var(--sys-color-primary-text, #ffffff); + font-weight: 600; + } + table.si-table th[scope="row"] { text-align: left; font-weight: 600; } + table.si-table tbody tr:nth-of-type(even) { + background: var(--sys-color-surface-background-hint, rgba(110,110,110,0.08)); + } + .si-cell-btn { min-width: 1.75rem; } + .si-action-cell { padding: 0; } + .si-footer { display: flex; gap: 0.5rem; margin-top: 1rem; flex-wrap: wrap; } + .si-footer > * { flex: 1 1 auto; } + .si-empty { + font-size: 0.8rem; padding: 0.75rem; + border: 1px dashed var(--sys-color-divider-primary, rgba(110,110,110,0.4)); + border-radius: 4px; + } + .si-legend td:first-of-type { width: 2.25rem; } + ` + + const iconBtn = (icon: string, label: string, onClick: () => void, busy = false): React.ReactElement => ( + + + + ) - - + // --------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------- + const hasMap = props.useMapWidgetIds && props.useMapWidgetIds.length === 1 - /** - * Render the list of saved instances as a table. - * Each instance is rendered as a table row with buttons to: - * - load the instance to the map - * - edit the instance name - * - download the instance - * - clear the instance graphics from the map - * - delete the instance - * - * If there are no saved instances, nothing is rendered. - */ - function renderSavedInstances(){ - if (savedInstances.length > 0) { + return ( +
+ {hasMap && ( + + )} + + {/* Status / errors, Alert announces to assistive tech */} + {status && ( + { setStatus(null) }} + /> + )} + + {/* Save */} +

{translate('saveHeading')}

+
+ + { setNameInput(e.target.value) }} + onAcceptValue={() => { if (nameInput.trim()) handleSave() }} + /> + {translate('instanceNameHint')} +
+ + +
+ + {/* Saved list */} +

{translate('savedHeading')}

+ + {savedInstances.length === 0 + ? ( +

{translate('emptyState')}

+ ) + : ( + +
+
+ + { setSearch(e.target.value) }} + /> +
+
+ + +
+
- return ( -
- - + {visibleInstances.length === 0 + ?

{translate('noMatches', { query: search.trim() })}

+ : ( +
+ + - - + + + - {savedInstances.map((instance, index) => ( - - - + + {visibleInstances.map((instance) => ( + + + + - - - - ))}
{translate('tableCaption')}
NameFunctions {translate('colName')}{translate('colSaved')} + {translate('colActions')}{' '} + +
{instance.name} - +
{instance.name}{formatDate(instance.createdAt)} + {iconBtn('overwrite-features', translate('loadAction', { name: instance.name }), + () => { void loadInstance(instance) }, loadingName === instance.name)} - + + {iconBtn('edit-attributes', translate('renameAction', { name: instance.name }), + () => { setModal({ kind: 'rename', name: instance.name, value: instance.name }) })} - + + {iconBtn('download', translate('downloadAction', { name: instance.name }), + () => { handleDownload(instance.name, false) })} - + + {iconBtn('x-circle', translate('clearGraphicsAction', { name: instance.name }), + () => { clearGraphics(instance.name) })} - + + {iconBtn('trash', translate('deleteAction', { name: instance.name }), + () => { setModal({ kind: 'delete', name: instance.name }) })}
-
- ) - } - } - + )} - - return ( -
- {/* Loading Overlay */} - {/** isLoading && ( -
- -
- )*/} - {props.hasOwnProperty("useMapWidgetIds") && - props.useMapWidgetIds && - props.useMapWidgetIds.length == 1 && ( - - )} -

Save your current map instance:

-
- - setCurrentInstanceName(e.target.value)} style={{alignSelf: 'center', border: '1px solid', padding: '2px', borderColor: blue19Colors[3]}}/> -
- - - -
- -

Saved Instances:

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
SymbolFunction Description -
Load instance to map
Edit instance name
Download instance
Clear instance graphics from map
Delete instance
-
- -
- {renderSavedInstances()} -
- -
- - + {translate('uploadInstances')} + +
+ + + {/* Rename modal */} + { setModal(null) }} onClosed={() => { setModal(null) }} aria-labelledby='si-rename-title'> + {modal?.kind === 'rename' && ( + + { setModal(null) }}>{translate('renameTitle')} + + + { setModal({ ...modal, value: e.target.value }) }} + onAcceptValue={() => { commitRename(modal.name, modal.value) }} + /> + + + + + + + )} + + + {/* Delete modal */} + { setModal(null) }} onClosed={() => { setModal(null) }} aria-labelledby='si-delete-title'> + {modal?.kind === 'delete' && ( + + { setModal(null) }}>{translate('deleteTitle')} + {translate('deleteConfirm', { name: modal.name })} + + + + + + )} + + + {/* Replace-on-import modal */} + { setModal(null) }} onClosed={() => { setModal(null) }} aria-labelledby='si-replace-title'> + {modal?.kind === 'replace' && ( + + { setModal(null) }}>{translate('replaceTitle')} + {translate('replaceConfirm', { name: modal.dupes.join('”, “') })} + + + + + + )} +
) } export default Widget - - diff --git a/src/setting/setting.tsx b/src/setting/setting.tsx index 42dae79..1ca91b3 100644 --- a/src/setting/setting.tsx +++ b/src/setting/setting.tsx @@ -1,65 +1,95 @@ - - +/** @jsx jsx */ +import { React, jsx, css, hooks } from 'jimu-core' +import { type AllWidgetSettingProps } from 'jimu-for-builder' import { - AllDataSourceTypes, - DataSourceComponent, - Immutable, - React, - UseDataSource, -} from "jimu-core"; -import { IMConfig } from "../config"; + MapWidgetSelector, + SettingRow, + SettingSection +} from 'jimu-ui/advanced/setting-components' +import { Switch, NumericInput, TextInput } from 'jimu-ui' +import { type IMConfig } from '../config' +import defaultMessages from './translations/default' -import { AllWidgetSettingProps } from "jimu-for-builder"; -import { MapWidgetSelector, SettingRow, SettingSection } from "jimu-ui/advanced/setting-components"; -import { useState } from "react"; -import { DataSourceSelector } from "jimu-ui/advanced/data-source-selector"; +function Setting (props: AllWidgetSettingProps): React.ReactElement { + const translate = hooks.useTranslation(defaultMessages) + const { config, id, onSettingChange } = props + const onMapWidgetSelected = (useMapWidgetIds: string[]): void => { + onSettingChange({ id, useMapWidgetIds }) + } -function Setting(props: AllWidgetSettingProps) { - -const onMapWidgetSelected = (useMapWidgetIds: string[]) => { - props.onSettingChange({ - id: props.id, - useMapWidgetIds: useMapWidgetIds, - }); -}; + const setConfig = (key: keyof IMConfig, value: any): void => { + onSettingChange({ id, config: (config as any).set(key, value) }) + } -/** - * Description: Updates the feature layer datasource in the config props - * @param useDataSources - Datasource for the feature layer - */ -const onDataSourceChange = (useDataSources: UseDataSource[]) => { - props.onSettingChange({ - id: props.id, - useDataSources: useDataSources, - }); -}; -/** - * Description: Toggles the use of the datasource in the config props - * @param useDataSourcesEnabled - boolean that determines if the datasource is enabled - */ -const onToggleUseDataEnabled = (useDataSourcesEnabled: boolean) => { - props.onSettingChange({ - id: props.id, - useDataSourcesEnabled, - }); -}; + const hint = css`font-size: 0.75rem; opacity: 0.75; line-height: 1.3;` + const fullWidth = css`width: 100%;` + + const toggle = (key: keyof IMConfig, labelKey: string): React.ReactElement => ( + + { setConfig(key, e.target.checked) }} + /> + + ) return (
- - - - - - + + + + + + {translate('selectMapHint')} + -
- ) + + {toggle('captureViewpoint', 'captureViewpoint')} + {toggle('captureLayers', 'captureLayers')} + {toggle('captureFilters', 'captureFilters')} + {toggle('captureBasemap', 'captureBasemap')} + {toggle('captureTime', 'captureTime')} + {toggle('captureGraphics', 'captureGraphics')} + + + + + { setConfig('maxInstances', Math.max(0, Math.floor(value || 0))) }} + /> + + + {translate('maxInstancesHint')} + + + + { setConfig('defaultInstanceName', e.target.value) }} + /> + + + {translate('defaultInstanceHint')} + + +
+ ) } -export default Setting; \ No newline at end of file +export default Setting diff --git a/src/setting/translations/default.ts b/src/setting/translations/default.ts new file mode 100644 index 0000000..4be1e10 --- /dev/null +++ b/src/setting/translations/default.ts @@ -0,0 +1,19 @@ +export default { + selectMap: 'Select a map', + selectMapHint: 'Choose the map widget this tool will save and restore.', + + captureSection: 'What to save', + captureViewpoint: 'Viewpoint (center, scale, rotation, 3D camera)', + captureLayers: 'Layer visibility and opacity', + captureFilters: 'Definition expressions and labels', + captureBasemap: 'Basemap', + captureTime: 'Time extent (time slider position)', + captureGraphics: 'Graphics (including Draw/Sketch)', + + limitsSection: 'Limits and startup', + maxInstances: 'Maximum saved instances', + maxInstancesHint: 'Set to 0 for no limit.', + defaultInstance: 'Load this instance on startup', + defaultInstanceHint: 'Type the exact name of a saved instance, or leave blank.', + defaultInstanceNone: 'None' +} From 9e0abbfb671ec20d9352eb63e49d9af749ee69ee Mon Sep 17 00:00:00 2001 From: Brian McLeer Date: Tue, 16 Jun 2026 15:16:15 -0600 Subject: [PATCH 02/12] Some UI and settings updates --- .npmignore | 7 + CHANGES.md | 73 ++ LICENSE | 6 +- README.md | 147 ++- config.json | 11 +- icon.svg | 2 +- manifest.json | 34 +- package-lock.json | 13 + package.json | 31 + src/config.ts | 17 +- src/runtime/translations/default.ts | 85 ++ src/runtime/widget.tsx | 1824 ++++++++++++--------------- src/setting/setting.tsx | 136 +- src/setting/translations/default.ts | 19 + 14 files changed, 1225 insertions(+), 1180 deletions(-) create mode 100644 .npmignore create mode 100644 CHANGES.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/runtime/translations/default.ts create mode 100644 src/setting/translations/default.ts diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..8ccf39d --- /dev/null +++ b/.npmignore @@ -0,0 +1,7 @@ +node_modules/ +.vs/ +*.user +*.suo +.DS_Store +Thumbs.db +CHANGES.md diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..1490a39 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,73 @@ +# Save Instance 2.0.0: what changed + +Storage is still **local only** (browser `localStorage`). Everything else from the +review was implemented, and the UI was rebuilt against jimu-ui's themed, +accessible components to meet **WCAG 2.1 AA**. + +## Accessibility (WCAG 2.1 AA) +- **Color & contrast**: dropped the hardcoded `blue19` ramp (several values, e.g. + `#007cd3` white text ≈ 3.9:1, failed AA) in favor of EXB theme tokens via CSS + variables (`--sys-color-primary-main` / `--sys-color-primary-text`), which EXB + pairs for AA. Light-mode fallbacks are included, and the widget now adapts to + dark themes. +- **Names for icon controls**: every icon button has a translated `aria-label` + (e.g. *"Load 'Downtown' onto the map"*); the icon itself is `aria-hidden`. No + reliance on `title` alone. +- **Forms**: inputs are associated with real `