Skip to content

Latest commit

 

History

History
438 lines (389 loc) · 27.8 KB

04-configuration.md

File metadata and controls

438 lines (389 loc) · 27.8 KB

04 — Configuration Concept

Status: draft · 2026-08-20 · The config is the product. This document defines its philosophy, shape, and a full annotated example. The published JSON Schema lives at packages/schema/v1.json and is served at https://map0.net/schema/v1.json — see Schema & validation.

Looking for a specific key? The key-by-key reference generated from the schema is config-reference.md: every key with its type, default and description. This document explains the concept and shows a full example.

Principles

  1. One JSON document per map. Everything — basemaps, layer tree, controls, popups, print, theming, i18n — in a single file/field. No companion files, no service registry.
  2. Minimal viable config is tiny. Only version + one basemap is required. Everything else has documented defaults.
  3. Schema-first. A published JSON Schema ($schema URL) gives CMS editors autocomplete & validation in any modern editor; map0 validates at runtime and renders helpful errors ("layers[2].url is missing — a wms layer needs a url") instead of a blank map.
  4. Declarative first, imperative escape hatch. Whatever config can do, the JS API can do too (and more), but config never requires code.
  5. Forward compatible. version field; unknown keys warn (console), never crash. Migrations documented per major.
  6. No secrets in config. Configs live in public HTML/CMS fields. Auth for protected services is provided at runtime via the JS API (headers/token callback), never as config values.

Embedding

<!-- 1. once per page -->
<script type="module" src="https://cdn.example.com/map0/map0.js"></script>

<!-- 2a. config by URL -->
<map0-viewer config-src="/configs/city-env.map0.json" style="height:520px"></map0-viewer>

<!-- 2b. config inline (CMS-friendly: one blob pasted into the page) -->
<map0-viewer style="height:520px">
  <script type="application/json">
    { "version": 1, "basemaps": [ ... ], "layers": [ ... ] }
  </script>
</map0-viewer>
// 2c. programmatic (SPAs, wrappers)
import { createMap } from "map0";
const app = createMap(document.querySelector("#map"), config);
app.on("featureclick", (e) => ...);
app.setLayerVisibility("noise-2024", false);

loading is an attribute, not a config key. The element waits until it is near the viewport before fetching anything — including the config — so the switch that controls that cannot live inside the config it would gate. It mirrors <img loading>:

<map0-viewer config-src="…"></map0-viewer>                  <!-- lazy (default) -->
<map0-viewer config-src="…" loading="eager"></map0-viewer>  <!-- above the fold -->

theme is an attribute for the same reason. theme.mode in the config suits a map that owns the light/dark decision, but a host page with its own dark-mode toggle knows better than the config can. theme="dark" / theme="light" on the element overrides theme.mode, and flipping the attribute restyles a running map — map0.net's own topbar toggle drives its demo maps this way. Unset, the config decides as usual.

(Element/API names indicative; final naming in 06-architecture.md, pending D-01.)

Top-level shape

Key Purpose Required
$schema editor tooling no
version config schema major version (1) yes
extends URL of a base config to inherit (org-wide basemaps/theme); local keys win, arrays replace no
meta title/description of the map (used in print header, aria-label) no
map initial view & constraints no
basemaps[] exclusive background layers yes (≥1)
layers[] overlay tree (groups + layers); top of the list = top of the map, and runtime-added layers go above all of them no
controls which UI elements exist, where no
print what the print dialog offers: formats, paper sizes, resolutions, sheet elements no
search geocoder config no
theme design tokens, light/dark no
i18n locale, string overrides (overrides: { "<locale>": { "<key>": "<text>" } }) no
permalink shareable state in the URL hash (F10.1): view, basemap, layer states, user-added layers; true or { "param": "map0" } — opt-in, coexists with host hash routing. User-added layers with inline data (dropped files) stay out of the link — a link is a reference, and a local file has no address; layers added by URL travel. Two viewers on one page need different param values; a second viewer claiming the same one falls back to map0-2 and says so in the console no

Full annotated example

{
  "$schema": "https://map0.net/schema/v1.json",
  "version": 1,

  "meta": {
    "title": "Umweltkarte Musterstadt",
    "description": "Lärm, Luftgüte und Schutzgebiete der Stadt."
  },

  "map": {
    "center": [16.3725, 48.2083],          // lon, lat (WGS84)
    "zoom": 11,                            // alternatively: "bounds": [w, s, e, n]
    "minZoom": 6,
    "maxZoom": 20,
    "maxBounds": [9.4, 46.3, 17.3, 49.1],  // optional pan constraint (Austria)
    "projection": "mercator",              // "mercator" | "globe" (initial; toggle via control)
    "hash": false,                         // opt-in URL-hash view sync
    "cooperativeGestures": false           // true: Ctrl/⌘+scroll to zoom, two fingers to pan —
                                           // for maps embedded in a scrolling page
  },

  "basemaps": [
    {
      "id": "bmapv",
      "title": "basemap.at Standard",
      "type": "style",                     // MapLibre style JSON = vector basemap
      "url": "https://mapsneu.wien.gv.at/basemapv/bmapv/3857/resources/styles/root.json",
      "thumbnail": "auto",                 // "auto" renders a mini preview, or an image URL
      "default": true
    },
    {
      "id": "ortho",
      "title": "Orthofoto",
      "type": "raster",                    // XYZ / WMTS-REST template
      "url": "https://mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/normal/google3857/{z}/{y}/{x}.jpeg",
      "tileSize": 256,
      "maxZoom": 19,
      "attribution": "© basemap.at"
    },
    { "id": "none", "title": "Kein Hintergrund", "type": "empty" }
  ],

  "layers": [
    {
      "type": "group",
      "title": "Umwelt",
      "collapsed": false,
      "exclusive": false,                   // true = radio group: one child visible at a time (F2.3)
      "children": [
        {
          "id": "noise-2024",
          "type": "wms",
          "title": "Lärmkarte 2024",
          "url": "https://sdi.example.gv.at/ows",
          "layers": "laerm_strasse_2024",   // comma-separated WMS LAYERS
          "format": "image/png",
          "opacity": 0.7,
          "visible": true,
          "minZoom": 9,
          "legend": "auto",                 // "auto" = GetLegendGraphic (WMS) / style-derived swatches
                                            // (vector) | false | image URL |
                                            // [{ "label", "color"?, "shape"?: square|line|circle, "image"? }]
          "info": {                         // enables GetFeatureInfo on click
            "format": "application/json",
            "title": "Lärm {{db_class}} dB",
            "fields": [
              { "key": "db_class", "label": "Pegelklasse" },
              { "key": "quelle",   "label": "Quelle" }
            ]
          },
          "metadata": {
            "url": "https://catalog.example.gv.at/geonetwork/srv/ger/catalog.search#/metadata/abc-123",
            "title": "Datensatzbeschreibung im Katalog"
          },
          "bounds": [16.18, 48.12, 16.55, 48.32],  // optional; enables "zoom to layer" (F2.2);
                                                   // auto-filled from capabilities for dialog-added layers
          "attribution": "© Stadt Musterstadt"
        },
        {
          "id": "trees",
          "type": "geojson",
          "title": "Stadtbäume",
          "data": "https://data.example.gv.at/trees.geojson",   // URL or inline FeatureCollection
          // "crs": "EPSG:31256",           // document not in WGS84? reprojected on load (proj4 registry or { code, def })
          "cluster": { "enabled": true, "maxZoom": 15 },
          "style": {                        // simplified style; full MapLibre layers[] also allowed
            "circle-color": "#2e7d32",
            "circle-radius": 5,
            "circle-stroke-width": 1.5,
            "circle-stroke-color": "#ffffff"
          },
          "popup": {
            "title": "{{baumart_de}}",
            "content": "<b>Pflanzjahr:</b> {{pflanzjahr}}<br><b>Höhe:</b> {{hoehe_m}} m"
          },                                // templates are sanitized before rendering
          "hover": { "content": "{{baumart_de}}" }
        },
        {
          "id": "zoning",
          "type": "vector",                 // vector tiles overlay
          "title": "Flächenwidmung",
          "url": "pmtiles://https://tiles.example.gv.at/widmung.pmtiles",
          "sourceLayer": "widmung",
          "style": [                        // MapLibre style-spec layers; source/source-layer injected
            { "type": "fill", "paint": { "fill-color": ["get", "farbe"], "fill-opacity": 0.45 } },
            { "type": "line", "paint": { "line-color": "#555", "line-width": 0.5 } }
          ],
          "popup": { "title": "{{widmung_text}}" },
          "visible": false
        }
      ]
    },
    {
      "id": "dgm",
      "type": "cog",                        // Cloud Optimized GeoTIFF, fetched via range requests
      "title": "Geländehöhe",
      "url": "https://data.example.gv.at/dgm_3857.tif",  // must be EPSG:3857 (no reprojection)
      "color": {                            // omit for RGB imagery; single band → color ramp
        "scheme": "BrewerSpectral7",        // ColorBrewer/CARTOColors ramp name (see below)
        "min": 100, "max": 3000,            // data values at the ends of the ramp
        "continuous": true,                 // default false = discrete classes
        "reverse": true
      },                                    // legend swatches are derived from this ramp
                                            // — or classify explicitly instead of a ramp:
                                            // "color": { "classes": [
                                            //   { "value": 1, "color": "#c22f2f", "label": "sealed" },
                                            //   { "from": 2, "to": 5, "color": "#67a9cf" } ] }
                                            // DEM instead? "hillshade": true — or
                                            // { "exaggeration": 0.6, "illuminationDirection": 315,
                                            //   "shadowColor": "…" } (mutually exclusive w/ color)
      "opacity": 0.8
    },
    {
      "id": "districts",
      "type": "wfs",                        // GetFeature as GeoJSON, paged (count/startIndex)
      "title": "Bezirksgrenzen",
      "url": "https://data.example.gv.at/geo",
      "typeNames": "ogd:BEZIRKSGRENZEN",    // the WFS 2.0 GetFeature parameter
      "limit": 10000,                       // stop after this many features (default 10000)
      "pageSize": 5000,                     // features per request (default 5000)
      "params": { "cql_filter": "STAND >= 2024" },  // vendor parameters, appended verbatim
      "style": { "line-color": "#333333", "line-width": 1.2, "line-dasharray": [2, 2] },
      "visible": true
    }
  ],

  "controls": {
    "navigation": true,                     // zoom + compass
    "scale": true,
    "fullscreen": true,
    "geolocate": { "follow": false },
    "globe": true,
    "home": true,
    "layerSwitcher": {
      "position": "top-right",              // corner the panel docks to, like legend.position: panels on one
                                            // edge share its height; below 688px all share the right edge
      "open": "auto",                       // open on desktop, collapsed on mobile
      "allowAdd": true,                     // false disables the add-layer dialog and file drop (F3.1, F3.2)
      "filter": "auto"                      // filter box above the tree (F2.7): "auto" = from 10 layers on
    },
    "legend": { "position": "bottom-right", "open": false },
    "print": true,                          // print & export dialog (F7): PNG download + print view
    "measure": true,                        // distance & area, measured on the sphere (F9.1)
    "coordinates": true,                    // right-click/long-press readout (F5.6); default:
                                            // WGS 84 + Austrian GK strip + UTM zone (auto-selected);
                                            // or { "crs": [{ "code", "label"?, "def"? (proj4) }] }
    "attribution": { "compact": "auto" }
  },

  "search": {
    "provider": "photon",                   // "photon" | "nominatim" | { url, format, labelField }
    "url": "https://photon.example.org/api",// self-hosted instance (optional)
    "country": "AT",                        // restrict results
    "bias": true,                           // prefer hits near the current view
    "limit": 8,
    "minLength": 3,
    "coordinates": true,                    // accept "48.2083, 16.3725" (latitude first)
    "placeholder": "Adresse oder Ort suchen…"
  },

  "print": {                                // what the dialog offers; all keys optional
    "formats": ["png", "pdf"],              // download buttons (browser print is always there)
    "sizes": ["current", "A4-landscape", "A4-portrait", "A3-landscape", "A3-portrait"],
    "dpi": [96, 150, 300],
    "elements": ["title", "legend", "scalebar", "attribution", "date"]  // parts of the sheet
  },

  "theme": {
    "mode": "auto",                         // "light" | "dark" | "auto"
    "primary": "#0e7490",
    "radius": "md",                         // none | sm | md | lg
    "font": "system-ui",
    "density": "comfortable"                // "comfortable" | "compact"
  },

  "i18n": {
    "locale": "auto",                       // auto-detect, fallback chain
    "fallback": "en",
    "overrides": { "de": { "layerSwitcher.title": "Kartenthemen" } }
  }
}

Minimal config (must work!)

{
  "version": 1,
  "basemaps": [
    { "type": "style", "url": "https://mapsneu.wien.gv.at/basemapv/bmapv/3857/resources/styles/root.json" }
  ]
}

Result: full-quality map with default controls (zoom, attribution, fullscreen), default view (basemap's center or world), light/dark auto theme. Every demo starts from here.

Layer type matrix (per decision D-03, 2026-08-14)

type Source Notes
style MapLibre style URL basemaps only
raster XYZ/WMTS-REST template basemap or overlay
wms WMS 1.1.1/1.3.0 GetMap tiled raster source; info enables GetFeatureInfo; legend:"auto" → GetLegendGraphic
wmts capabilities URL + layer (+ optional matrixSet/style/format) REST & KVP; image tiles only — picks the first WebMercator matrix set (D-02) and the first advertised image format, bounds/legend from capabilities (legend URLs are re-issued with map0's standard icon size — GeoServer bakes misleading width/height into the advertised href). Requests are clipped to the layer's TileMatrixSetLimits (GWC answers outside tiles with 400 TileOutOfRange, not an empty tile). For a WMTS that also serves application/vnd.mapbox-vector-tile, use vector with the service's TileJSON to style it client-side.
cog Cloud Optimized GeoTIFF URL raster read straight from storage via HTTP range requests (protocol plugin, loaded on demand). RGB/grayscale by default; color renders a single band through a named ColorBrewer/CARTOColors ramp — { scheme, min, max, continuous?, reverse? } — and the legend panel derives its swatches from it; hillshade renders a single-band DEM as relief shading — true or { exaggeration?, illuminationDirection?, shadowColor?, highlightColor?, accentColor? } (mutually exclusive with color; a hillshade paints only shadows and highlights, so flat terrain stays transparent by design and the basemap shows through — layer opacity scales the exaggeration, hillshade has no opacity of its own). Bounds and zoom range come from the file header ("zoom to layer" needs no config). The file must be EPSG:3857 (no client-side reprojection — the layer errors with the offending EPSG code); nodata pixels render transparent, and a file with no declared nodata treats 0 as transparent.
wfs WFS endpoint + typeNames GetFeature as WGS84 GeoJSON, rendered through the geojson pipeline (same style/cluster/popup/hover/promoteId). WFS 2.0.0 (default) pages with count/startIndex — pageSize per request (default 5000) until the server's reported total or limit (default 10000) is reached; a server that silently caps a request is continued from where its answer ended. version: "1.1.0" = one maxFeatures request. outputFormat overrides the format name (default application/json; MapServer wants geojson); params appends vendor parameters (cql_filter, sortBy, …). OWS exception XML (HTTP 200) is unwrapped into the layer's error message. The service must send CORS headers.
geojson URL or inline clustering, simplified style or full style layers. crs names the CRS of a document that is not WGS84 — the features are reprojected once on load (proj4; the built-in registry covers Austrian GK M28/M31/M34 and Lambert, ETRS89 UTM 32/33N, LAEA Europe and Austria Lambert, WGS84 UTM zones — anything else via { "code", "def" }). Inline data carrying a legacy GeoJSON crs member is reprojected without the key. Rendering stays Web Mercator (D-02); this widens what can be loaded, not what can be drawn
geoparquet GeoParquet file URL fetched whole and decoded in the browser (hyparquet, loaded on demand — snappy/gzip/brotli/zstd/lz4 all work), then rendered through the geojson pipeline: same style/cluster/popup/hover/promoteId options. A fraction of the transfer size of the same data as GeoJSON; "zoom to layer" comes from the file's geo metadata bbox. Coordinates in another CRS are reprojected on load: the file's geo metadata (PROJJSON) names it — codes the built-in registry knows use map0's proj4 definitions (datum shift included), anything else is handed to proj4 as PROJJSON; crs on the layer overrides the metadata for files whose definition cannot be resolved (or lacks the datum shift). A CRS nobody can resolve fails with a per-layer error naming it and the key that fixes it. Plain parquet without geo metadata is rejected with a conversion hint.
vector TileJSON / {z}/{x}/{y} / pmtiles:// needs sourceLayer + style layers
ogcapi-features collection URL (…/collections/{id}) items fetched as GeoJSON (WGS84, the API default) and rendered through the geojson pipeline (same style/cluster/popup/hover/promoteId). Paging follows the response's next links — the spec's own mechanism, chosen because numberMatched is optional and missing in the wild; a server capping limit just produces more pages. pageSize per request (default 1000) until limit (default 10000); params appends query parameters (bbox, datetime, CQL2 filter, properties). JSON problem details surface as the layer error. CORS required.
group — nesting (children), collapsed, and exclusive for a radio group: one child visible at a time, a child group counting as one option. The first child configured visible starts on and the others start hidden (more than one written "visible": true is a warning); setVisibility() and share links obey the rule as well. The group header carries a box that switches all of the group's layers — one option of a radio group — and shows a dash while only some are on. While the filter box (controls.layerSwitcher.filter) narrows the tree, that box acts on the layers on show only

Simplified style: flat paint-property object (circle-*, line-*, fill-*, auto-derived per geometry). Full control: pass a MapLibre style: [ …layer objects… ] array instead. Both are valid; the simple form covers 80 % of admin needs, the full form keeps experts unblocked.

COG color-ramp names (color.scheme): the ramps are the standard ColorBrewer and CARTOColors palettes, named Brewer<Palette><classes> (BrewerSpectral9, BrewerYlGnBu5, … — 3 up to 9, 11 or 12 classes depending on the palette) and Carto<Palette> (CartoEarth, CartoTemps, … — always 7 colors). The protocol library renders every ramp on one page — the color scheme cheatsheet — and the palettes themselves are documented at colorbrewer2.org and carto.com/carto-colors. A name map0 does not recognise fails fast: the layer goes to error state with "… is not a supported color scheme".

COG explicit classes (color.classes, instead of a ramp): an array of classes, each an exact value (categorical/binary rasters) or a from/to range, with a hex color (8-digit = with alpha) and an optional legend label. Ranges include from and exclude to — except the class with the highest to, which includes it, so the data maximum never falls off the top class; there is deliberately no inclusive/exclusive knob. Exact values win over ranges; pixels matching no class (and noData pixels) are transparent, which doubles as a way to blank out irrelevant value ranges. Values are compared after the COG's scale/offset are applied — the numbers in the config are the real-world values. One caveat: the underlying color function is keyed by the COG URL, so two layers reading the same file cannot mix classes with a ramp/hillshade rendering (see engineering notes).

Runtime layer management (F3)

The add-layer dialog (TOC "+", controls.layerSwitcher.allowAdd) has three kinds:

  • WMS / WMTS (F3.1): paste a service URL; capabilities are parsed client-side (@camptocamp/ogc-client) and picked layers are added with GetFeatureInfo, legend, metadata link and a zoom range derived from the service's Max/MinScaleDenominator.
  • GeoJSON (F3.2): paste the URL of a GeoJSON file (same URL policy as the config: https, a relative URL, or http on localhost/http pages), optionally a title. The layer keeps the URL as its data, so it travels in a share link; the dialog fetches it once to report a typo, a CORS refusal or a non-GeoJSON answer in place. A legacy crs member in the answer becomes the layer's crs. The same kind offers a file picker for GeoJSON, KML and GPX files from the device.
  • Drag & drop: GeoJSON (.geojson/.json), KML and GPX files dropped anywhere on the viewer (a drop zone shows while dragging) become one geojson layer each, named after the file, with inline data, default styling — or the file's own simplestyle colours (stroke, fill, marker-color, what Google Earth styles turn into) per feature — and an automatic zoom to what arrived. KML/GPX are converted in the browser (@tmcw/togeojson, loaded with the first such file); a GeoJSON export in a projected CRS with a legacy crs member is reprojected like inline data. Files never leave the browser. Unreadable files are reported per file; the others still load.

allowAdd: false disables all of it. User-added layers live in session state only — they never mutate the page config — and are removable (✕ in the TOC). The same operations are available programmatically: api.addLayer(def) / api.removeLayer(id).

Templating & sanitization

  • Popup/tooltip templates use {{property}} placeholders plus a few helpers ({{#if}}, number/date formatting — final list TBD; deliberately not a full template language).
  • A cluster bubble has none of the features' properties, so the layer's hover template is not applied to it: the tooltip shows the number of features in the cluster instead, and a click on the bubble zooms in until the cluster breaks apart.
  • All rendered HTML (templates, WMS HTML GetFeatureInfo, service abstracts) is sanitized (allow-list: basic formatting, links rel="noopener", images) — configs come from CMS fields and services are third parties; XSS hygiene is non-negotiable.
  • Placement is automatic, not configured. On a viewer narrower than 640 px — the element's own width, so a map in a narrow CMS column counts as well as a phone — feature info opens as a bottom sheet docked to the lower edge of the map instead of an anchored popup: the same content (title, fields or content template, the per-layer sections of a multi-hit click), collapsed to about 45 % of the map height with the content scrolling inside, expandable to about 90 % via the grab handle or a drag. Dragging it down, its close button, Escape or a tap on empty map closes it and clears the selection highlight; the map above the sheet stays usable throughout. Above 640 px the anchored popup is used; a viewer that is resized across the threshold moves an open answer into the other container. popup: false suppresses a layer's feature info in both.

Schema & validation

The published JSON Schema (C3) is hand-written in packages/schema/v1.json — hand-written because its description texts are the editor documentation, which a generator cannot produce. It cannot drift from the validator: v1-schema.test.ts locks every key set and enum in the schema to the exported tables in validate.ts (both directions), and runs every demo config through the compiled schema. Adding a config key means touching both files, and the test says so.

The same descriptions are rendered as a key-by-key page, config-reference.md: pnpm docs:reference (scripts/config-reference.mjs) generates it from the schema, and config-reference.test.ts fails while the committed page is stale — so the reference cannot drift from the schema any more than the schema can drift from the validator. Improve a text in the schema's description, not in the generated file.

One canonical file, three places it is published:

URL Follows
https://map0.net/schema/v1.json latest release (copied into the site build)
https://cdn.jsdelivr.net/npm/map0-viewer@<version>/schema/v1.json that exact release (in the npm tarball)
https://cdn.jsdelivr.net/npm/map0-viewer/schema/v1.json latest release (floating)

The file is named after the config format version ("version": 1), not the package version — its draft status until 1.0 is carried by the 0.x package version and a $comment in the schema itself.

How it is used:

  • Editors — a $schema key in the config gives autocomplete, hover documentation and typo squiggles in VS Code, JetBrains, or Monaco embedded in a CMS. Every demo config carries the line.
  • Online — the validator demo runs validateConfig right in the page: paste a config, read the errors.
  • CI / save hooks — npx ajv-cli validate -s schema/v1.json -d config.map0.json, or check-jsonschema on the Python side.
  • Runtime — validateConfig stays authoritative: the https-URL policy (N6), id uniqueness across the layer tree, and cross-field rules (color vs hillshade, min < max) are not expressible in JSON Schema. Note one deliberate difference: the schema flags unknown keys (that squiggle is the point of editor support), while the runtime only warns about them (C5 — a config written for a newer map0 still opens).

Versioning policy

  • version = schema major. map0 v1.x reads config version 1.
  • Additive keys → minor releases, old configs untouched.
  • Breaking schema change → config version 2 + automated migrator (map0 migrate config.json) + migration notes. CMS content must survive client upgrades for years — treat configs as data, not code.