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.jsonand 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.
- 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.
- Minimal viable config is tiny. Only
version+ one basemap is required. Everything else has documented defaults. - Schema-first. A published JSON Schema (
$schemaURL) gives CMS editors autocomplete & validation in any modern editor; map0 validates at runtime and renders helpful errors ("layers[2].urlis missing — awmslayer needs aurl") instead of a blank map. - Declarative first, imperative escape hatch. Whatever config can do, the JS API can do too (and more), but config never requires code.
- Forward compatible.
versionfield; unknown keys warn (console), never crash. Migrations documented per major. - 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.
<!-- 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.)
| 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 |
{
"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.
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).
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 legacycrsmember in the answer becomes the layer'scrs. 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 onegeojsonlayer 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 legacycrsmember 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).
- 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
hovertemplate 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,fieldsorcontenttemplate, 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: falsesuppresses a layer's feature info in both.
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
$schemakey 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
validateConfigright in the page: paste a config, read the errors. - CI / save hooks —
npx ajv-cli validate -s schema/v1.json -d config.map0.json, orcheck-jsonschemaon the Python side. - Runtime —
validateConfigstays authoritative: the https-URL policy (N6), id uniqueness across the layer tree, and cross-field rules (colorvshillshade,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).
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.
{ "$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" } } } }