diff --git a/README.md b/README.md index cc806ee..c097f01 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,277 @@ lare-viewer/ └── package.json # Project dependencies and scripts ``` +## Configuring `navigation.json` + +The workflow and the processes requests are configurable from the JSON file at `src/config/navigation.json`. + +### Overall structure + +```json +{ + "logo": "/desirmed_logo.png", + "menus": [ + { + "id": "regionSelection", + "title": "Region Selection", + "drawerTitle": "Select a base map", + "icon": "mdi-map-marker-radius", + "requiredSteps": [], + "completionEvent": null, + "components": [ /* UI configuration */ ], + "wps": { /* optional WPS configuration */ } + } + ] +} +``` + +- **`logo`**: Path (relative to `public/`) to the logo shown at the top of the main navigation drawer. +- **`menus`**: Ordered list of workflow steps. Each menu item: + - **`id`**: Unique identifier used internally (e.g. for dependencies and WPS results). + - **`title`**: Label shown in the main navigation drawer. + - **`drawerTitle`**: Title shown at the top of the step drawer. + - **`icon`**: Vuetify Material Design Icon name (e.g. `mdi-map-marker-radius`). + - **`requiredSteps`** (optional): Array of menu `id`s that must be completed before this step is enabled. + - **`completionEvent`** (optional): How the step is marked complete. `null` (default) means completion is driven by child components; `"auto"` means the step completes as soon as its drawer opens. + - **`components`**: List of UI components rendered inside the step drawer. + - **`wps`** (optional): Configuration for a WPS Execute call associated with this step (see below). + +### Components inside a menu + +Each entry in `components` has the shape: + +```json +{ + "component": "LayerList", + "componentProps": { + "layers": [ + { + "id": "layer-id", + "name": "Human readable name", + "active": true, + "clickable": true, + "propertiesBox": "region" + } + ] + } +} +``` + +- **`component`**: Name of a Vue component in `src/components` (without `.vue`), e.g. `LayerList`, `SelectionList`, `NumberInput`. +- **`componentProps`**: Arbitrary props passed straight through to the component. + +Different components expect different props: + +- **`LayerList`**: + - **`layers`**: Array of layer configs with: + - `id`: Map layer identifier. + - `name`: Display name in the UI. + - `active`: Whether the layer is initially visible. + - `clickable`: Whether clicking the map interacts with this layer. + - `propertiesBox` (optional): Name of a properties box configuration (used for showing feature attributes). +- **`SelectionList`**: + - **`label`**: Label shown above the list. + - **`options`**: Array of `{ "id": "value", "name": "Label" }` entries. +- **`NumberInput`**: + - Typical props include `label`, `suffix`, `min`, `max`, `step`, `defaultValue`, and `defaultValueSource` (see below under WPS input sources). + +### Wiring menus together with `requiredSteps` + +Menus can depend on the completion of earlier steps. For example: + +```json +{ + "id": "hazard", + "title": "Hazard mitigation selection", + "requiredSteps": ["regionSelection"], + "components": [ /* ... */ ] +} +``` + +The `hazard` step becomes clickable only after the `regionSelection` step has been completed. + +### WPS configuration per step + +Each menu can optionally define a `wps` object describing a WPS Execute call to run when the step is completed or when the user interacts with the map. + +Example from `navigation.json`: + +```json +{ + "id": "regionSelection", + "title": "Region Selection", + "drawerTitle": "Select a base map", + "icon": "mdi-map-marker-radius", + "components": [ + { + "component": "LayerList", + "componentProps": { + "layers": [ + { + "id": "landuse:U2018_CLC2018_V2020_20u1_cog", + "name": "European Land Use Cover", + "active": true, + "clickable": false + }, + { + "id": "region:nuts_2021_level_3", + "name": "NUTS 3 Regions", + "active": true, + "clickable": true, + "propertiesBox": "region" + } + ] + } + } + ], + "wps": { + "identifier": "lare_region", + "trigger": "mapClick", + "inputs": [ + { + "id": "nutsname", + "type": "LiteralData", + "source": "store:map.activeRegion.properties.nuts_name" + } + ], + "storeResultAs": "regionSelection" + } +} +``` + +The WPS configuration supports the following fields: + +- **`identifier`**: The WPS process identifier as exposed by the backend (e.g. `"lare_region"`, `"lare_hazard"`, `"lare_uom"`). This value is passed directly to the WPS `Execute` request. +- **`trigger`**: When to execute the WPS call for this step: + - `"mapClick"`: Execute whenever the user selects a region on the map (using `mapStore.activeRegion`) while this step is open. + - `"stepComplete"`: Execute when the step signals completion (for example when the user picks an option or enters a number and the component emits a `step-complete` event). +- **`inputs`**: Array of WPS input definitions: + - `id`: Input identifier expected by the WPS process. + - `type`: WPS data type, typically `"LiteralData"` (default) or `"ComplexData"`. + - `source`: Where the value comes from, using the pattern `:`. +- **`storeResultAs`** (optional): Key under which the full WPS response is stored in the app store (`app.wpsResults[storeResultAs]`). +- **`outputActions`** (optional): Array of post-processing instructions for the response (see below). + +The WPS calls are sent to the base URL configured via the environment variable: + +```env +VITE_WPS_BASE_URL=https://your-wps-endpoint.example.com/wps +``` + +### WPS input `source` syntax + +The `source` field describes where to read the value for a given WPS input. Supported forms: + +- **`store:.`**: + - Reads from a Pinia store by name, e.g. `"store:map.activeRegion.properties.nuts_name"`. + - `storeName` is the key used in the WPS context (`app` or `map`). +- **`payload:`**: + - Reads from the payload passed by the component that completes the step. + - Example: in the `hazard` step: + ```json + { "id": "hazard", "type": "LiteralData", "source": "payload:value" } + ``` +- **`wpsResult:`**: + - Reads from previously stored WPS results (`app.wpsResults`), allowing chaining between steps. + - Example: in the `uom` step: + ```json + "defaultValueSource": "wpsResult:regionSelection.suggested_uom" + ``` +- **`static:`**: + - Use a literal constant value. + +### Storing and reusing WPS results + +When `storeResultAs` is set, the full parsed WPS response is stored under: + +- `app.wpsResults[storeResultAs]` + +You can then reference this data in later steps via `source: "wpsResult:..."` or via component props like `defaultValueSource` (for `NumberInput`). + +Example: + +```json +{ + "id": "uom", + "title": "Calculate UOM", + "requiredSteps": ["hazard"], + "wps": { + "identifier": "lare_uom", + "trigger": "stepComplete", + "inputs": [ + { "id": "nutsname", "type": "LiteralData", "source": "store:map.activeRegion.properties.nuts_name" }, + { "id": "uomsize", "type": "LiteralData", "source": "payload:value" } + ], + "storeResultAs": "uom" + }, + "components": [ + { + "component": "NumberInput", + "componentProps": { + "label": "Hexagon size (in m²)", + "suffix": "m²", + "min": 100, + "max": 50000000, + "step": 100000, + "defaultValue": 1000, + "defaultValueSource": "wpsResult:regionSelection.suggested_uom" + } + } + ] +} +``` + +### Post-processing with `outputActions` + +For more advanced cases, you can define `outputActions` inside the `wps` block to automatically store parts of the response or to add dynamic layers to the map. + +Supported actions: + +- **`storeValue`**: Store a (sub-)value into `app.wpsResults` under a nested key. +- **`addLayer`**: Add one or more dynamic layers to the map (via `mapStore.addDynamicLayer`). + +Example: + +```json +{ + "wps": { + "identifier": "lare_example", + "trigger": "stepComplete", + "inputs": [ /* ... */ ], + "outputActions": [ + { + "action": "storeValue", + "path": "response.statistics", + "storeAs": "example.statistics" + }, + { + "action": "addLayer", + "path": "response.layers" + } + ] + } +} +``` + +- **`path`**: + - Dot-notated path into the WPS response object. + - If omitted or set to `"response"`, the entire response is used. + - If it starts with `"response."`, that prefix is ignored (so `"response.layers"` and `"layers"` are equivalent). +- **`storeAs`** (for `storeValue`): + - Dot-notated path where the value will be stored under `app.wpsResults`, e.g. `"example.statistics"`. +- **`layerConfig`** (optional, for `addLayer`): + - Extra fields merged into each dynamic layer configuration (e.g. default opacity, visibility flags). + +The structure expected by `addLayer` is: + +- Either an array under the given `path`, or an object with a `contents` array. +- Each entry in `contents` (or the array itself) should contain: + - `layer`: Layer identifier. + - `url`: WMS/WFS/WMS-T endpoint for the layer. + - `name` (optional): Human-readable name. + +This matches the output format produced by the backend WPS processes and is what the viewer uses to add layers dynamically. + ## License diff --git a/components.d.ts b/components.d.ts index 9493f7a..481364a 100644 --- a/components.d.ts +++ b/components.d.ts @@ -12,12 +12,7 @@ export {} declare module 'vue' { export interface GlobalComponents { ActiveFeatureProperties: typeof import('./src/components/ActiveFeatureProperties.vue')['default'] - AppFooter: typeof import('./src/components/AppFooter.vue')['default'] AreaMenu: typeof import('./src/components/AreaMenu.vue')['default'] - copy: typeof import('./src/components/HazardMenu.vue')['default'] - HazardMenu: typeof import('./src/components/HazardMenu.vue')['default'] - HazardSelect: typeof import('./src/components/HazardSelect.vue')['default'] - HelloWorld: typeof import('./src/components/HelloWorld.vue')['default'] LayerLegend: typeof import('./src/components/LayerLegend.vue')['default'] LayerList: typeof import('./src/components/LayerList.vue')['default'] MapComponent: typeof import('./src/components/MapComponent.vue')['default'] diff --git a/src/components/HazardMenu.vue b/src/components/HazardMenu.vue deleted file mode 100644 index fb3e4c4..0000000 --- a/src/components/HazardMenu.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - - - diff --git a/src/components/NumberInput.vue b/src/components/NumberInput.vue index 0cf8441..ec29e69 100644 --- a/src/components/NumberInput.vue +++ b/src/components/NumberInput.vue @@ -26,7 +26,9 @@ diff --git a/src/config/navigation.json b/src/config/navigation.json index 0edafda..cd04f9c 100644 --- a/src/config/navigation.json +++ b/src/config/navigation.json @@ -2,11 +2,10 @@ "logo": "/desirmed_logo.png", "menus": [ { - "id": "baseMaps", - "title": "Base Maps", + "id": "regionSelection", + "title": "Region Selection", "drawerTitle": "Select a base map", "icon": "mdi-map-marker-radius", - "completionEvent": "auto", "components": [ { "component": "LayerList", @@ -28,19 +27,14 @@ ] } } - ] - }, - { - "id": "regionSelection", - "title": "Region Selection", - "icon": "mdi-hexagon-outline", - "drawerTitle": "Select a region in the map", - "requiredSteps": [ - "baseMaps" ], - "completionEvent": "auto", "wps": { - "identifier": "test" + "identifier": "lare_region", + "trigger": "mapClick", + "inputs": [ + { "id": "nutsname", "type": "LiteralData", "source": "store:map.activeRegion.properties.nuts_name" } + ], + "storeResultAs": "regionSelection" } }, { @@ -52,7 +46,13 @@ "regionSelection" ], "wps": { - "identifier": "hazard" + "identifier": "lare_hazard", + "trigger": "stepComplete", + "inputs": [ + { "id": "nutsname", "type": "LiteralData", "source": "store:map.activeRegion.properties.nuts_name" }, + { "id": "hazard", "type": "LiteralData", "source": "payload:value" } + ], + "storeResultAs": "hazard" }, "components": [ { @@ -93,16 +93,26 @@ "requiredSteps": [ "hazard" ], + "wps": { + "identifier": "lare_uom", + "trigger": "stepComplete", + "inputs": [ + { "id": "nutsname", "type": "LiteralData", "source": "store:map.activeRegion.properties.nuts_name" }, + { "id": "uomsize", "type": "LiteralData", "source": "payload:value" } + ], + "storeResultAs": "uom" + }, "components": [ { "component": "NumberInput", "componentProps": { - "label": "Hexagon size (in m)", - "suffix": "m", + "label": "Hexagon size (in m²)", + "suffix": "m²", "min": 100, - "max": 10000, - "step": 100, - "defaultValue": 1000 + "max": 50000000, + "step": 100000, + "defaultValue": 1000, + "defaultValueSource": "wpsResult:regionSelection.suggested_uom" } } ] @@ -157,4 +167,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/src/lib/wps/handle-output.js b/src/lib/wps/handle-output.js new file mode 100644 index 0000000..e624235 --- /dev/null +++ b/src/lib/wps/handle-output.js @@ -0,0 +1,77 @@ +/** + * Processes WPS response data according to output action definitions + * from navigation.json. + * + * Supported actions: + * - storeValue: saves a value (or sub-path of response) into appStore.wpsResults + * - addLayer: adds a dynamic layer to the map via mapStore + * + * @param {Array} actions - Output action definitions from config + * @param {Object} response - The parsed WPS response + * @param {Object} stores - { app: appStore, map: mapStore } + * + * @typedef {Object} OutputAction + * @property {'storeValue'|'addLayer'} action + * @property {string} [path] - Dot-notated path into the response (omit or "response" for full response) + * @property {string} [storeAs] - For storeValue: key under wpsResults to store into + * @property {Object} [layerConfig] - For addLayer: layer configuration + */ +export function handleOutputActions (actions, response, stores) { + if (!actions?.length || !response) return + + for (const action of actions) { + const value = resolvePath(response, action.path) + + switch (action.action) { + case 'storeValue': + if (action.storeAs && stores.app) { + setNestedValue(stores.app.wpsResults, action.storeAs, value) + } + break + + case 'addLayer': { + if (!value || !stores.map?.addDynamicLayer) break + const folders = Array.isArray(value) ? value : [value] + for (const folder of folders) { + const entries = folder?.contents ?? [folder] + for (const entry of entries) { + if (entry?.layer && entry?.url) { + stores.map.addDynamicLayer({ + id: entry.layer, + name: entry.name || entry.layer, + layer: entry.layer, + url: entry.url, + ...(action.layerConfig || {}), + }) + } + } + } + break + } + + default: + console.warn(`[handleOutputActions] Unknown action: "${ action.action }"`) + } + } +} + +function resolvePath (obj, path) { + if (!path || path === 'response') return obj + + const cleanPath = path.startsWith('response.') ? path.slice(9) : path + return cleanPath.split('.').reduce((o, key) => o?.[key], obj) +} + +function setNestedValue (obj, path, value) { + const parts = path.split('.') + let current = obj + + for (let i = 0; i < parts.length - 1; i++) { + if (current[parts[i]] == null) { + current[parts[i]] = {} + } + current = current[parts[i]] + } + + current[parts[parts.length - 1]] = value +} diff --git a/src/lib/wps/resolve-input.js b/src/lib/wps/resolve-input.js new file mode 100644 index 0000000..8a0f09c --- /dev/null +++ b/src/lib/wps/resolve-input.js @@ -0,0 +1,50 @@ +/** + * Resolve a single value from a source string. + * Source format: "store:path" | "payload:path" | "wpsResult:path" | "static:value" + */ +export function resolveInputValue (source, context) { + const i = source.indexOf(':') + if (i === -1) return undefined + const type = source.slice(0, i) + const path = source.slice(i + 1) + const pathParts = path.split('.') + + if (type === 'store') { + const store = context.stores?.[pathParts[0]] + return store ? get(pathParts.slice(1), store) : undefined + } + if (type === 'payload') return get(pathParts, context.payload) + if (type === 'wpsResult') { + const results = context.stores?.app?.wpsResults + return results ? get(pathParts, results) : undefined + } + if (type === 'static') return path + return undefined +} + +/** + * Resolve WPS inputs from config. + * @param {Array} inputs - [{ id, type, source }, ...] + * @param {Object} context - { payload, stores } + * @returns {Array} [{ id, type, value }, ...] for sendWpsRequest + */ +export function resolveInputs (inputs, context) { + if (!Array.isArray(inputs)) return [] + const out = [] + for (const { id, type = 'LiteralData', source } of inputs) { + if (!id || !source) continue + const value = resolveInputValue(source, context) + if (value === undefined || value === null) continue + out.push({ id, type, value }) + } + return out +} + +function get (pathParts, obj) { + let cur = obj + for (const key of pathParts) { + if (cur == null) return undefined + cur = cur[key] + } + return cur +} diff --git a/src/stores/app.js b/src/stores/app.js index 7550807..f820c90 100644 --- a/src/stores/app.js +++ b/src/stores/app.js @@ -5,6 +5,7 @@ export const useAppStore = defineStore('app', { state: () => ({ activeMenu: null, completedSteps: [], + wpsResults: {}, }), getters: { @@ -37,6 +38,10 @@ export const useAppStore = defineStore('app', { } }, + setWpsResult (key, result) { + this.wpsResults[key] = result + }, + resetStepsFrom (stepId) { const menus = config.menus const index = menus.findIndex(m => m.id === stepId) @@ -45,6 +50,9 @@ export const useAppStore = defineStore('app', { this.completedSteps = this.completedSteps.filter( id => !toRemove.includes(id), ) + for (const id of toRemove) { + delete this.wpsResults[id] + } } }, }, diff --git a/src/stores/map.js b/src/stores/map.js index e44a944..c4806ff 100644 --- a/src/stores/map.js +++ b/src/stores/map.js @@ -1,4 +1,3 @@ -// stores/map.js import { defineStore } from 'pinia' import layersConfig from '@/data/base-layers-config.json' import buildMapboxLayer from '@/lib/build-mapbox-layer' @@ -139,5 +138,24 @@ export const useMapStore = defineStore('map', { clearActiveRegion () { this.activeRegion = null }, + + addDynamicLayer (layerConfig) { + const existing = this.mapboxLayers.find(l => l.id === layerConfig.id) + if (existing) return + + const built = buildMapboxLayer({ + ...layerConfig, + format: 'image/png', + }) + if (built) { + this.mapboxLayers.push(built) + this.layerVisibility[layerConfig.id] = true + } + }, + + removeDynamicLayer (layerId) { + this.mapboxLayers = this.mapboxLayers.filter(l => l.id !== layerId) + delete this.layerVisibility[layerId] + }, }, })