A Vue 3 map application shell: interactive Mapbox GL map + optional step-by-step sidebar, driven almost entirely by JSON configuration.
The same shared codebase powers different products (for example LARE Viewer and NL2120 Viewer). What changes between deployments is mainly:
src/config/workflow.json— UI steps, components, processes, dialogssrc/config/base-layers-config.json— layer sources and stylingpublic/assets (logo, favicon).env/ Mapbox & service URLssrc/lib/constant.js— default map center and zoom
Design idea: enable or disable features by editing config (and optionally dropping in a new Vue component), not by rewriting the app shell.
- Interactive Mapbox GL map (WMS / WMTS raster and vector tiles)
- Config-driven wizard steps with Confirm / Continue flows
- Optional OGC API Processes (JSON execute) with dynamic layer add/remove
- Layer toggles, legends (WMS image or category swatches), feature info, related geometry, attribute filters
- Optional markdown info dialog
- Vuetify 3 UI, Pinia state, Vite tooling
| Piece | Role |
|---|---|
workflow.json |
Steps, components, confirmations, processes, info dialog, legend layout |
base-layers-config.json |
Layer URLs, paint, legends, category styles |
Pinia app / map |
Selections, process results, visibility, filters, clicked feature |
src/lib/ogc-process/ |
Resolve inputs → execute → output actions |
src/components/ |
Pluggable UI blocks loaded by filename |
- Node.js 20+ recommended (see
package.jsonenginesif present) and npm - A Mapbox access token (Mapbox account)
- GeoServer (or compatible WMS/WMTS/WFS) for map layers you configure
- An OGC API Processes server only if your workflow runs processes (e.g. LARE / pygeoapi)
git clone <this-repository-url>
cd <repository-folder>
npm installCreate a .env in the project root. Vite only exposes variables prefixed with VITE_.
| Variable | Required | Description |
|---|---|---|
VITE_MAPBOX_TOKEN |
Yes | Mapbox public token |
VITE_OGC_API_URL |
If you use processes | OGC API base URL (no trailing slash), e.g. http://localhost:5000 |
VITE_WPS_BASE_URL |
Fallback | Used only if VITE_OGC_API_URL is unset |
VITE_GEOSERVER_PUBLIC_BASE_URL |
Optional | Browser-reachable GeoServer base when JSON URLs use Docker/internal hosts |
VITE_MAPBOX_TOKEN=pk.ey...
VITE_OGC_API_URL=http://localhost:5000
# VITE_GEOSERVER_PUBLIC_BASE_URL=http://localhost:8080/geoservernpm run dev # usually http://localhost:3000
npm run build # output in dist/
npm run preview
npm run lintApp.vueloadsworkflow.json. OptionalinitialSetup.processwithtrigger: "onStart"runs once. First step opens automatically ifstepsis non-empty. Global overlays (FeatureInfoPanel,LayerLegend,InfoDialog) mount always but only activate when config enables them.NavigationDrawer.vuelists each step and mounts oneSubMenuper step.SubMenu.vueloads components by name viaimport.meta.glob('@/components/*.vue'), handles Confirm, and runs processes according toprocess.trigger.views/Home.vue→MapComponent.vuehosts the map; the map store builds layers frombase-layers-config.jsonand tracks selection / filters / dynamic layers.
src/
├── config/
│ ├── workflow.json # Product UI + processes
│ ├── base-layers-config.json # Layer service definitions
│ └── info-dialog.md # Optional; used if infoDialog.contentFile points here
├── components/ # Wizard + map UI (name = workflow component id)
├── stores/
│ ├── app.js # Steps, selections, processResults, info dialog
│ └── map.js # Layers, visibility, filters, region, hover
├── lib/
│ ├── ogc-process/ # Execute + resolve inputs + output actions
│ ├── constant.js # MAP_CENTER, MAP_ZOOM, basemap styles
│ └── … # Layer builders, legend, category-style, helpers
├── views/Home.vue
└── App.vue
- Copy / fork the repo.
- Replace logo under
public/and setworkflow.json→logo(and optionallogoWidth,logoAlt). - Set
MAP_CENTER/MAP_ZOOMinsrc/lib/constant.js. - Fill
base-layers-config.jsonwith your layers. - Rewrite
workflow.jsonsteps (or start empty — see below). - Set
.envfor Mapbox (and OGC/GeoServer as needed). - Only add a new
.vueundercomponents/when an existing block cannot do the job; then reference it by filename inworkflow.json.
Minimal valid workflow (shell only — no steps):
{
"logo": "/your-logo.png",
"initialSetup": {},
"steps": []
}With steps: [], the drawer has no wizard items and nothing auto-opens. Useful to verify the map shell before wiring UX.
| Field | Purpose |
|---|---|
logo |
Path under public/ |
logoWidth |
Optional CSS width (e.g. "140px"). Default "80px" |
logoAlt |
Optional image alt text |
initialSetup |
Restart button and/or bootstrap process |
legendStack |
"vertical" (default) or "horizontal" for the floating legend |
infoDialog |
Optional markdown dialog (see below) |
steps |
Ordered wizard steps |
"initialSetup": {
"restartButton": true,
"process": {
"identifier": "my-start",
"trigger": "onStart",
"inputs": [],
"storeResultAs": "initialSetup"
}
}restartButton: shows Restart in the drawer after the first step is completed; resets app + map state and re-runsonStartif present.
Only active when enabled: true. Markdown file must live under src/config/ (loaded via Vite glob).
| Field | Meaning |
|---|---|
enabled |
Master switch |
showOnStart |
Open once on load (unless “remembered”) |
showButton |
Info icon in the drawer footer |
remember |
"local" | "session" | "always" (always = never persist “seen”) |
storageKey |
Optional; default viewer:info-dialog-seen |
contentFile |
e.g. "info-dialog.md" |
title, closeLabel, width, height |
Dialog chrome |
| Field | Meaning |
|---|---|
id |
Stable id (requiredSteps, store keys) |
title |
Drawer label |
drawerTitle |
Submenu heading |
icon |
Optional MDI icon name |
explanation |
Help text above footer |
requiredSteps |
Step ids that must be completed first |
disabledOnContinue |
After complete, step stays locked unless reopened while active |
requiresConfirmation |
Footer Confirm required to advance |
confirmationSource |
"component" | "mapClick" | "process" |
requiredSelections |
Selection keys that must be set before Confirm enables |
confirmFlashWhenEnabled / explanationFlashWhenAvailable |
Attention flashes |
completionEvent |
"auto" → complete when submenu opens |
components |
[{ "component": "Name", "componentProps": { … } }] |
process |
Optional OGC process block |
| Value | Ready when… |
|---|---|
component |
Child signals ready / selections satisfied |
mapClick |
A map feature is selected (mapStore.activeRegion) |
process |
Process ran successfully (for trigger: "component", Confirm waits for a result) |
"process": {
"identifier": "my-process",
"trigger": "component",
"inputs": [
{ "id": "session_id", "source": "store:app.processResults.initialSetup.session_id" },
{ "id": "uom_size", "source": "payload:value" },
{ "id": "id", "source": "store:map.activeRegionId" }
],
"storeResultAs": "uom",
"outputActions": [
{ "action": "removeLayer", "fromResultKey": "uom", "path": "response" },
{ "action": "addLayer", "path": "response" }
]
}trigger
| Trigger | When it runs |
|---|---|
onStart |
App mount / Restart (App.vue) |
component |
Child emits run-process (NumberInput, ProcessRunButton, …) |
stepOpen |
Submenu opens |
mapClick |
Region selected while step is open |
stepComplete |
Step completes (payload from children) |
Input source prefixes (resolve-input.js):
| Prefix | Resolves from |
|---|---|
store:app.… / store:map.… |
Pinia stores |
payload:… |
Event payload (e.g. calculator value) |
processResult:… |
app.processResults |
static:… |
Literal after the first : |
Selection objects shaped like { id, … } are sent to the server as the scalar id.
outputActions
action |
Effect |
|---|---|
storeValue |
Save a path from the response into processResults (storeAs) |
addLayer |
Add dynamic WMS layers from response entries with layer + url |
removeLayer |
Remove layers from a previous result (fromResultKey); uses a snapshot when storeResultAs overwrites the same key |
Request path: {baseUrl}/processes/{identifier}/execution?f=json.
Reference by file name without .vue. Unknown names are skipped.
| Component | Typical use |
|---|---|
SelectionList |
Dropdown → app.selections[selectionKey]. Options may carry extra fields for processes/layers. Supports condition / conditionSource, disabledUntilCondition, confirmSelection, flashWhenEnabled |
LayerList |
Layer switches. Per layer: id, name, active, clickable, condition, propertiesBox, flashWhenEnabled, plus optional attributeFilter, featureInfo, relatedGeometry (see below) |
NumberInput |
Number field; optional Calculate → run-process. defaultValueSource uses the same store: / processResult: syntax as processes |
ProcessRunButton |
Explicit run button; requiredSelections, flashWhenEnabled; emits run-process |
ActiveFeatureProperties |
Usually via LayerList propertiesBox — compact selected-feature card |
Shell / map pieces (not listed in components[]; always available as needed):
| Piece | Role |
|---|---|
MapComponent / MapLayer / MapZoomControl |
Map host, per-layer interaction, fit-bounds |
RelatedGeometry |
Driven by LayerList relatedGeometry config |
FeatureInfoPanel |
Driven by LayerList featureInfo |
LayerLegend |
Floating legend for visible layers |
InfoDialog |
Driven by top-level infoDialog |
FlashHighlight |
Used internally for attention pulses |
attributeFilter — mounts LayerAttributeFilter when the layer is visible:
| Prop | Notes |
|---|---|
attributeKey |
Required primary attribute |
secondaryAttributeKey |
Optional → hierarchical groups |
delimiter |
Default ";" for multi-value attributes |
dimOnDeselect |
false (hide via filter) | "primary" | "all" (dim paint) |
showCategoryColors, wfsUrl, defaultCollapse, emptySecondaryLabel, title |
UX / data loading |
featureInfo — right-hand panel fields for the selected feature:
"featureInfo": {
"title": "Details",
"emptyValue": "—",
"fields": [{ "attribute": "naam", "title": "Name" }]
}relatedGeometry — show/filter companion fill/outline layers on select/hover:
| Field | Notes |
|---|---|
layerId / outlineLayerId |
Ids from base-layers-config |
sourceAttribute / targetAttribute |
Join keys (default fid) |
fitBounds |
Zoom to related geometry (suppresses default point zoom when true) |
showOnHover / showOnHoverWithSelection |
Hover behaviour |
Array of layer service definitions. Workflow LayerList entries should use the same id.
| Field | Purpose |
|---|---|
id, name, layer |
App id, label, GeoServer layer name |
url |
WMS or WMTS endpoint |
format |
e.g. image/png or application/vnd.mapbox-vector-tile |
paint / layout |
Mapbox style properties |
vectorType |
fill | line | circle (vector tiles) |
promoteId |
Feature id property (needed for click / feature-state) |
bbox, minZoom, maxZoom |
Optional tile bounds / zoom |
mapServiceVersion |
WMS version if needed |
Two entries with the same id (one raster, one MVT) → app builds a visible raster (id_raster) plus a clickable vector layer.
| Field | Purpose |
|---|---|
showInLegend |
false hides from floating legend |
legendMode |
"categories" → swatch legend from categoryStyle / WFS |
legendLayout |
"dense" for wide GetLegendGraphic images |
legendCardMaxWidth, legendBodyMaxHeight, legendExpanded |
Card UX |
legendOptions |
Passed into GeoServer legend_options (fontSize, columns, dpi, …) |
categoryStyle |
Colors, radius, stroke, dimmed styles for circle categories |
If the browser cannot reach hostnames in JSON (Docker), set VITE_GEOSERVER_PUBLIC_BASE_URL.
| Export | Role |
|---|---|
MAP_CENTER |
[longitude, latitude] |
MAP_ZOOM |
Initial zoom |
MAP_BASELAYERS / MAP_BASELAYER_DEFAULT |
Mapbox style list |
Change these per deployment (not via workflow.json today).
- Two configs, one id —
base-layers-config= how to draw;workflowLayerList = when visible / clickable / filters / info. Sameidlinks them. - Clickable only for the active step —
clickable: trueis registered per step. Clicks work when that step’s submenu is open. - Component name = filename —
"SelectionList"loadsSelectionList.vue. Typos fail silently (component missing). - Confirm stays disabled — Check
requiredSelections,confirmationSource, map click, or whether acomponent-triggered process returned a result. removeLayer+ samestoreResultAskey — Previous result is snapshotted before overwrite so old dynamic layers can be removed. Prefer explicitfromResultKeyover relying on “clear everything”.- Global panels vs step components —
FeatureInfoPanel/InfoDialog/RelatedGeometryare not added tocomponents[]; they react to config on layers / top-level workflow. - Selection objects vs process scalars — UI may store
{ id, name, … }; process inputs coerce toid. - Env at build time —
VITE_*values are baked in atnpm run build; changing the server env later without rebuilding does nothing.
- Set
VITE_*in the environment used for build. - Ensure GeoServer / tile URLs are reachable from end-user browsers (or use
VITE_GEOSERVER_PUBLIC_BASE_URL). - Host
dist/as an SPA (fallback toindex.html).
| Issue | Check |
|---|---|
| Blank map | VITE_MAPBOX_TOKEN; browser console |
| Processes fail | VITE_OGC_API_URL; process id; CORS; network tab |
| Layers missing | Browser-reachable GeoServer URL; layer name; id match between configs |
| Legend empty | showInLegend; WMS GetLegendGraphic; or legendMode: "categories" + WFS |
| Filter / dim not working | attributeFilter + vector layer; promoteId; categoryStyle where needed |
| Feature info never opens | Layer has featureInfo and is clickable for the active step |
| Info dialog never shows | infoDialog.enabled; showOnStart / button; remember + storageKey |
Issues and pull requests are welcome. Run npm run lint (or at least eslint on src/) before submitting.
When adding a shared feature, prefer opt-in config so existing products keep working without JSON changes.
- Vue.js, Vite, Vuetify, Mapbox GL JS
- Studio Meta —
@studiometa/vue-mapbox-gl - Sibling deployments share this viewer core: lare-viewer, NL2120-viewer
- LARE — example OGC API Processes backend