diff --git a/.gitignore b/.gitignore
index fdb3bc0..4c0660b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,7 @@ build/
*.tsbuildinfo
# Generated demo bundle (created by `pnpm demo:standalone`)
-examples/public/standalone/
+site/public/standalone/
# JSON Schema copied into the package by `pnpm build:npm` (canonical: packages/schema/v1.json)
packages/map0/schema/
diff --git a/README.md b/README.md
index ce72451..62351ec 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ still drafts and will change without a deprecation path until 1.0. See
## The idea in one paragraph
MapLibre made cartography declarative (the style spec is JSON). map0 extends the same idea to the
-*map client*: basemaps, overlay tree/TOC, legends, feature popups, print, globe, GPS, theming — all
+*web map client*: basemaps, overlay tree/TOC, legends, feature popups, print, globe, GPS, theming — all
declared in a single, schema-validated JSON document that lives in a CMS field. It renders straight
into the page (web component / script tag — no iframe, no backend) and looks like 2026: pretty,
responsive, themable, accessible.
@@ -82,15 +82,16 @@ packages/schema config types, validation (JSON-path errors), defaults, publish
packages/core headless engine: basemap manager, source adapters (wms/wmts/raster/cog/geojson/
vector), feature info, i18n — no DOM UI
packages/ui the web component (Lit) + panels, popups, theming
-examples/ landing page, /demos gallery and demo pages
-examples/public/ demo configs and data served as-is
+site/ landing page, /demos gallery and demo pages (EN, with /de/ built from
+ per-page catalogues in site/i18n/)
+site/public/ demo configs and data served as-is
e2e/ headless smoke verification (grows into the Playwright suite in M1)
docs/ specification
```
Distribution note: `packages/ui/dist/` is a flat folder — `map0.js`, its chunks, and MapLibre's
three files shipped verbatim. Deploy the folder as a unit; the embed stays one script tag.
-A page pays **~23 KB gzip** for the element itself; the engine and MapLibre (~316 KB) load when the
+A page pays **~31 KB gzip** for the element itself; the engine and MapLibre (~305 KB) load when the
map approaches the viewport, and capabilities parsing, proj4, PMTiles, the COG decoder, measuring
and the dialogs only when those features are used. `pnpm size` prints the breakdown and fails when
the page tier grows.
diff --git a/docs/03-requirements.md b/docs/03-requirements.md
index 39cc9d4..f54bab5 100644
--- a/docs/03-requirements.md
+++ b/docs/03-requirements.md
@@ -38,7 +38,7 @@
| ID | Requirement | Prio |
|---|---|---|
| F3.1 | "Add layer" dialog: paste a service URL (WMS/WMTS), client parses capabilities, user picks layers | M → D-03 |
-| F3.2 | Add GeoJSON by URL; drag & drop a local GeoJSON/GPX file | S |
+| F3.2 | Add GeoJSON by URL; drag & drop a local GeoJSON/KML/GPX file | S |
| F3.3 | Remove/rename user-added layers; clearly distinguished from configured layers | M |
| F3.4 | Catalog search (CSW / OGC API Records) as source for adding layers | C → D-05 |
| F3.5 | User-added layers survive in the share/permalink state (not in the page config) | S |
diff --git a/docs/04-configuration.md b/docs/04-configuration.md
index 13a1f76..f84be67 100644
--- a/docs/04-configuration.md
+++ b/docs/04-configuration.md
@@ -55,6 +55,12 @@ inside the config it would gate. It mirrors ``:
```
+**`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](06-architecture.md), pending D-01.)*
## Top-level shape
@@ -197,6 +203,10 @@ inside the config it would gate. It mirrors ``:
"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)
@@ -310,6 +320,17 @@ and the palettes themselves are documented at [colorbrewer2.org](https://colorbr
[carto.com/carto-colors](https://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`) lets users paste a WMS URL;
diff --git a/docs/06-architecture.md b/docs/06-architecture.md
index 44b2271..25f5c12 100644
--- a/docs/06-architecture.md
+++ b/docs/06-architecture.md
@@ -31,9 +31,9 @@ map0/
│ │ # feature-info, legend resolution, i18n runtime — NO DOM UI
│ ├─ ui/ # Lit element + panels (TOC, legend, popup, print, …)
│ └─ react/ # (M2) thin wrapper
-├─ examples/ # demo configs incl. live Austrian SDI services + plain-HTML/CMS embeds
-├─ site/ # docs site (config reference generated from schema) + playground
-└─ e2e/ # Playwright + visual regression against examples/
+├─ site/ # map0.net: landing + /demos + demo configs (live Austrian SDI services,
+│ # plain-HTML/CMS embeds); grows into docs site + playground
+└─ e2e/ # Playwright + visual regression against site/
```
`core` is deliberately UI-free: it makes the web component thin, enables the React wrapper and
@@ -127,8 +127,8 @@ Measured with `pnpm size` (gzip, current). Three tiers, paid at different moment
| Tier | Size | Paid when |
|---|---|---|
-| **page** — custom element + Lit | ~20 KB | the page loads — budget 40 KB, enforced in CI |
-| **map** — engine, MapLibre (3 files), its stylesheet, popup renderer | ~314 KB | the element approaches the viewport |
+| **page** — custom element + Lit | ~31 KB | the page loads — budget 40 KB, enforced in CI |
+| **map** — engine, MapLibre (3 files), its stylesheet, popup renderer | ~305 KB | the element approaches the viewport |
| ogc-client (capabilities parsing) | ~62 KB | first add-layer dialog or WMTS layer |
| proj4 (+ wkt-parser, mgrs) | ~47 KB | first coordinate readout |
| PMTiles | ~8 KB | first `pmtiles://` layer |
@@ -136,7 +136,7 @@ Measured with `pnpm size` (gzip, current). Three tiers, paid at different moment
**Nothing but the element loads until the map is needed.** `` observes itself with an
IntersectionObserver (300 px root margin) and only then fetches the config, the engine and MapLibre
-— so an article with a map at the bottom pays 20 KB unless a reader scrolls there. `loading="eager"`
+— so an article with a map at the bottom pays 31 KB unless a reader scrolls there. `loading="eager"`
opts out, `load()` forces it, and elements inside a hidden tab stay unloaded until shown, which also
avoids MapLibre initialising into a zero-size container. This is an attribute rather than a config
key because it decides whether the config is fetched at all.
@@ -198,7 +198,7 @@ documented prominently (services must send CORS headers; no proxy in core, recip
- `schema`: golden tests for validation/defaults/migrations.
- `core`: adapter unit tests with mocked fetch (recorded capabilities/GFI fixtures from real
Austrian services).
-- `e2e`: Playwright against `examples/` — interaction flows + **visual regression screenshots**;
+- `e2e`: Playwright against `site/` — interaction flows + **visual regression screenshots**;
strict-CSP smoke test; a11y audit (axe) gate.
- CI budgets: bundle size check, Lighthouse on demo page.
diff --git a/docs/07-roadmap.md b/docs/07-roadmap.md
index c64ea45..987e1d2 100644
--- a/docs/07-roadmap.md
+++ b/docs/07-roadmap.md
@@ -13,7 +13,7 @@ What is left is mostly polish: a published JSON Schema and an accessibility pass
| Area | Status |
|---|---|
-| Data sources | ✅ WMS, WMTS, vector tiles/PMTiles, GeoJSON, COG (RGB, single-band ramps, hillshade), style & raster basemaps · ⬜ WFS, OGC API Features (deferred to v1.x per D-03) |
+| Data sources | ✅ WMS, WMTS, vector tiles/PMTiles, GeoJSON, COG (RGB, single-band ramps, explicit classes, hillshade), style & raster basemaps · ⬜ WFS, OGC API Features (deferred to v1.x per D-03) |
| Layer tree | ✅ groups, visibility, opacity, status, zoom hints, zoom-to-layer, metadata links, runtime add/remove · ⬜ drag reorder, filter box, radio groups |
| Feature info | ✅ GetFeatureInfo + vector query, templates, field tables, multi-hit, hover, highlight, coordinates · ⬜ mobile bottom sheet |
| Legend | ✅ service, style-derived, hand-written; in print |
@@ -22,7 +22,7 @@ What is left is mostly polish: a published JSON Schema and an accessibility pass
| Search | ✅ type-ahead geocoding, pluggable providers, coordinate input |
| Measuring | ✅ distance & area, geodesic, draggable vertices |
| Configuration | ✅ one document, validation with JSON-path errors (unknown keys, unique ids, https policy), `extends`, theming, i18n + overrides · 🟡 published JSON Schema |
-| Performance | ✅ 23 KB page tier, engine and features load on demand, CI budget |
+| Performance | ✅ 31 KB page tier, engine and features load on demand, CI budget |
| Accessibility | 🟡 keyboard operation, focus trap, reduced motion · ⬜ audit, DOM-mirrored results |
| Packaging | ✅ MIT licence, name, npm package `map0-viewer` published (prebuilt bundle + third-party notices), CDN via jsDelivr, demo site at map0.net · ⬜ TypeScript types |
@@ -53,7 +53,7 @@ What is left is mostly polish: a published JSON Schema and an accessibility pass
- ✅ **i18n** de/en + per-locale overrides (F11.1–F11.3)
- ✅ **Config inheritance** via `extends` (C6)
- ✅ **Error toasts** and TOC zoom-range hints (F2.5)
-- ✅ **Code splitting + lazy loading** — 23 KB page tier, MapLibre deferred until the map is in view, size budget in CI (N1)
+- ✅ **Code splitting + lazy loading** — 31 KB page tier, MapLibre deferred until the map is in view, size budget in CI (N1)
- ✅ **Dialog focus trap + Escape** (N4, partial)
- ✅ **Search** — type-ahead geocoding with Photon/Nominatim/custom providers, coordinate input (F8.1, F8.2)
- ✅ **Measure** — distance and area on the sphere, draggable vertices (F9.1)
@@ -82,15 +82,17 @@ What is left is mostly polish: a published JSON Schema and an accessibility pass
- ⬜ **WFS and OGC API Features** layer types (deferred from D-03)
- ⬜ **Drag-and-drop reorder** in the TOC (F2.6)
-- ⬜ **GeoJSON/GPX by URL and file drop** (F3.2)
+- ⬜ **GeoJSON/KML/GPX by URL and file drop** (F3.2)
- ⬜ **Terrain** (F1.8)
- ⬜ **Auth hooks** for protected services (C8)
-- ⬜ **React wrapper** (`@map0/react`)
+- ⬜ **React and Angular wrappers** (`@map0/react`, `@map0/angular`)
- ✅ **COG** layers (2026-08-19) — `type: "cog"`: RGB/grayscale imagery, single-band color
ramps with auto-derived legend, and DEM hillshade (`hillshade` key → raster-dem + hillshade
layer); bounds from the file header, decoder loaded on demand
(@geomatico/maplibre-cog-protocol, adopted at 0.9.x — see D-03 update) · 3D terrain itself
- stays with F1.8
+ stays with F1.8 · **explicit classes** added 2026-08-20 (`color.classes`: exact values and
+ [from, to) ranges with hand-picked colors and labels, for categorical/binary rasters a ramp
+ cannot express)
## M2 — Ecosystem
diff --git a/docs/08-decisions.md b/docs/08-decisions.md
index ddf07f1..9255dca 100644
--- a/docs/08-decisions.md
+++ b/docs/08-decisions.md
@@ -48,7 +48,8 @@ style-JSON basemaps · GeoJSON (incl. clustering) · PMTiles.
**Update 2026-08-19 — COG pulled forward.** `type: "cog"` shipped (RGB/grayscale imagery +
single-band color ramps) on @geomatico/maplibre-cog-protocol **before** its 1.0, pinned at ^0.9.2 —
a deliberate exception to the "wait for 1.0" gate in the roadmap: the API surface map0 touches
-(`cogProtocol`, `getCogMetadata`, `colorScale`) is small, the library is actively maintained
+(`cogProtocol`, `getCogMetadata`, `colorScale`, `setColorFunction` since `color.classes`) is
+small, the library is actively maintained
(0.9.2 released 2026-08-17), and the whole dependency loads as a lazy chunk only when a config
contains a cog layer. Consequence of the pin: review the changelog before any bump; `getCogMetadata`
is documented as unstable upstream. EPSG:3857-only fits D-02 (the protocol does not reproject; the
@@ -84,7 +85,7 @@ therefore targets GeoNetwork first: CSW 2.0.2 and, where available, OGC API Reco
| O-01 | License | **resolved 2026-08-17: MIT.** Supersedes the Apache-2.0 proposal — the shortest, most familiar licence for an embeddable client wins on adoption, and the engine below us (MapLibre, BSD-3-Clause) sets the same expectation. Accepted trade-off: no explicit patent grant. Root `LICENSE`; the published tarball carries it plus `THIRD-PARTY-NOTICES.md`. |
| O-02 | Product name + npm/domain availability | **resolved 2026-08-16:** the name is **map0** — "map" plus the zero code it takes to get one. npm (2026-08-17): the batteries-included bundle is published as **`map0-viewer`** — the registry rejects the unscoped `map0` under its typosquatting heuristic ("too similar to mcp1, hapi, tap, tape"; normalised, `map0` reads as *mapo*), even though the name is unregistered. Not appealable via the CLI; only npm support can release it, so the short name stays a wish, not a plan. The `map0` **org** is reserved for the later `@map0/*` split. Assembly: `pnpm build:npm`, see [09-engineering-notes.md](09-engineering-notes.md) §release. Domain: **map0.net** (secured 2026-08-17, serves the demo site — O-04). |
| O-03 | Which CMS(s) must be proven first? | **resolved 2026-08-14:** none for now — the reference embed is a **plain static HTML page** ("if it runs there, it runs in any CMS"). Org context: headless CMS (Squidex, Strapi) → map0 embeds into custom frontends built on top; raises the value of the M2 React wrapper. |
-| O-04 | Hosting of demo/docs site | **resolved 2026-08-16:** Azure Static Web Apps — `pnpm build:site` → `dist-site/`, uploaded by `.github/workflows/deploy-site.yml`; served at **map0.net** (O-02). Legal pages added 2026-08-19: `/imprint.html` + `/privacy.html` (company data mirrors the spatial-focus.net legal notice; privacy covers Azure hosting, self-hosted Umami and the third-party demo services), linked from every footer. |
+| O-04 | Hosting of demo/docs site | **resolved 2026-08-16:** Azure Static Web Apps — `pnpm build:site` → `dist-site/`, uploaded by `.github/workflows/deploy-site.yml`; served at **map0.net** (O-02). Legal pages added 2026-08-19: `/imprint.html` + `/privacy.html` (company data mirrors the spatial-focus.net legal notice; privacy covers Azure hosting, self-hosted Umami and the third-party demo services), linked from every footer. Bilingual since 2026-08-21: every page builds twice (EN at `/…`, DE at `/de/…`) from one English source plus a JSON catalogue in `site/i18n/de/` — a build-time Vite plugin, `hreflang` pairs, a browser-language redirect on `/` and a localStorage-persisted switcher, the same behaviour as spatial-focus.net without a framework. A topbar dark/light toggle (OS default, persisted) drives embedded maps via the viewer's `theme` attribute. The folder moved `examples/` → `site/` the same day. |
| O-05 | Browser floor | evergreen + WebGL2 (MapLibre v6 requirement) — confirm against portal analytics |
| O-06 | Geocoder default | **resolved 2026-08-15:** Photon (OSM, built for type-ahead, CORS-open) as the default, plus a provider interface — `"nominatim"` built in, and any gazetteer via a URL template. Public Photon instances carry fair-use expectations, so production installs self-host it or point at their own service. |
| O-07 | Repository home (GitHub org?) & governance | decide before M0 ends |
diff --git a/docs/09-engineering-notes.md b/docs/09-engineering-notes.md
index 5a1b489..bd8d202 100644
--- a/docs/09-engineering-notes.md
+++ b/docs/09-engineering-notes.md
@@ -77,7 +77,7 @@ Two things a release has to get right, both of which fail *silently* if it does
- **The folder is the unit.** `dist/` ships `map0.js`, its lazy chunks and MapLibre's three files
side by side (§4.4). Verify the **packed tarball**, not `packages/ui/dist` — automated in
`e2e/verify-tarball.mjs` (hook step 5, or by hand: `node e2e/verify-tarball.mjs `): it
- unpacks the tarball over `examples/public/standalone/`, loads `/demos/standalone.html` (which
+ unpacks the tarball over `site/public/standalone/`, loads `/demos/standalone.html` (which
then exercises exactly the files a consumer gets) and asserts rendered **vector-tile** features —
a missing worker file still paints raster layers — plus the COG config, because the COG decoder
is a lazy chunk and lazy-chunk resolution is what regresses between dev server and bundle.
@@ -110,7 +110,7 @@ China coverage. Both are verified, so the choice is reversible for anyone copyin
Two things `/standalone/` on map0.net is **not**: versioned, and immutable. It is the demo artefact,
rebuilt and overwritten by every `pnpm build:site`, `max-age=3600`. `access-control-allow-origin: *`
-in `examples/public/staticwebapp.config.json` makes it *loadable* from a foreign page, which is a
+in `site/public/staticwebapp.config.json` makes it *loadable* from a foreign page, which is a
convenience for anyone copying from the demo, not a distribution channel — a redeploy changes the
chunk hashes while a consumer's browser still holds the cached entry, and the entry then imports
chunks that no longer exist. Anyone embedding for real gets a pinned CDN URL. Building a versioned
@@ -124,7 +124,7 @@ Break one of these and something breaks quietly, usually only in production buil
1. **The element must not import values from `@map0/core`.** Only `import type` (erased at build
time) plus value imports from `@map0/schema`. One value import pulls MapLibre into the entry
- chunk and undoes the 20 KB page tier. The engine arrives through `loadEngine()`.
+ chunk and undoes the 31 KB page tier. The engine arrives through `loadEngine()`.
2. **One import site per heavy dependency.** Every `import()` statement becomes its own chunk, so a
library imported dynamically from two modules ships twice. `core/src/ogc.ts` is the only place
that loads ogc-client; proj4 goes through `loadProj4()`.
@@ -291,6 +291,16 @@ TileJSONs happened to carry nothing offensive, so this slept until the ICGC cont
- The ramp legend is derived from the library's `colorScale()` — for discrete ramps the class
count is recovered by sampling the scale (thresholds are uniform over min–max), so map0 does not
duplicate the palette tables.
+- `color.classes` (explicit values/ranges) renders through the protocol's `setColorFunction()`,
+ which is keyed by the **plain COG URL, globally** — and overrides any `#color`/`#dem` fragment on
+ that URL. Consequences the adapter handles: registration is refcounted per URL (removing one of
+ two layers sharing a file keeps the other rendered; the function is cleared when the last
+ unmounts), and two classes-layers on the same URL with different classes log a warning (last one
+ wins for both). Not handleable: a classes layer and a ramp/hillshade layer on the *same file*
+ — the color function silently wins; documented in 04-configuration instead. In the pixel
+ function, `scale`/`offset` are applied manually (the fragment paths do this inside the library;
+ the custom path does not), noData/NaN/`Infinity` (the reader's fill value) render transparent,
+ and exact `value` matches use a relative epsilon because scale/offset arithmetic is float.
- geotiff.js (plus lerc/pako decoders) rides in the lazy cog chunk — configs without a cog layer
never load it. `lerc@3` ships no licence text in its tarball → fallback entry in
`scripts/build-npm.mjs` (same mechanism as pmtiles).
@@ -298,7 +308,7 @@ TileJSONs happened to carry nothing offensive, so this slept until the ICGC cont
`raster-dem` source with a `hillshade` layer — and the TOC opacity slider maps to
`hillshade-exaggeration` (base = the configured exaggeration) because that is the only usable
intensity knob.
-- The demo DEM (`examples/public/data/bev-dgm25-grossglockner-3857.tif`, ~3 MB) is a cut of the
+- The demo DEM (`site/public/data/bev-dgm25-grossglockner-3857.tif`, ~3 MB) is a cut of the
BEV 25 m terrain model (CC BY 4.0): the original `data.bev.gv.at` file is EPSG:31287 **and** its
server sends no `Access-Control-Allow-Origin`, so it cannot be used in place. Recipe:
`gdalwarp -of COG -t_srs EPSG:3857 -te -tr 36 36 -r bilinear -ot Int16
@@ -341,6 +351,23 @@ it, or drop it when a real dependency arrives.
undefined name (`Popup is not defined`, only in the bundle). Wrap it in a function that *uses* the
binding instead — `createPopup()` — and the import survives.
+### 5.1 Site i18n: two languages from one source, at build time
+
+The site (map0.net) is bilingual without a framework. The English page is the only source:
+elements carry `data-i18n="key"` (inner HTML) or `data-i18n-attrs="attr:key"` (attribute values)
+and keep their English text inline; one JSON catalogue per page under `site/i18n/de/` supplies the
+German. `site/i18n/plugin.ts` emits every built page a second time under `/de/` (and serves `/de/…`
+on the fly in dev), sets ``, rewrites internal page links, injects `hreflang`
+pairs, and puts a one-time browser-language redirect on `/` (choice persisted in localStorage by
+the topbar switcher — same behaviour as spatial-focus.net's `redirectOn: "root"`). The build warns
+about **missing** keys (page stays English there) and **stale** keys (catalogue entries nothing
+asks for) — after editing English copy, touch the German catalogue or the build will say so.
+Strings that scripts render at runtime (gallery cards, pager, Copy buttons, validator status) key
+off `document.documentElement.lang` via `site/lang.ts` instead. The dark/light topbar toggle works
+the same way at the other end: an inline chrome script applies the stored choice before first
+paint, CSS carries the dark tokens twice (`@media` for the OS default, `:root.dark` for the
+explicit choice), and embedded maps follow through the viewer's `theme` attribute.
+
## 6. Field notes: component and browser
- **Focus lives in the shadow root.** `document.activeElement` returns the host element, never what
@@ -393,7 +420,8 @@ packages/core the engine: map creation, basemap manager, source adapters, fe
→ imports MapLibre; never imports the UI
packages/ui , panels, dialogs, popup rendering, focus trap, styles
→ imports core lazily (see invariant 1)
-examples/ landing page + /demos (one page per topic) + configs and data
+site/ landing page + /demos (one page per topic) + configs and data
+site/i18n/ German page catalogues + the build-time translation plugin (§5.1)
e2e/ headless verification
scripts/ build-adjacent tooling (size budget)
```
diff --git a/e2e/verify-tarball.mjs b/e2e/verify-tarball.mjs
index 16b1a21..f669453 100644
--- a/e2e/verify-tarball.mjs
+++ b/e2e/verify-tarball.mjs
@@ -1,6 +1,6 @@
/**
* The release gate from docs/09 §release, automated: verify the PACKED TARBALL,
- * never packages/ui/dist. Unpacks the tarball's dist over examples/public/standalone,
+ * never packages/ui/dist. Unpacks the tarball's dist over site/public/standalone,
* loads /demos/standalone.html (which uses exactly those files), and checks the two
* paths that only break in built bundles:
*
@@ -30,7 +30,7 @@ if (!process.argv[2] || !existsSync(tarball)) {
/* ---------------------------------------------- unpack over /standalone/ */
-const standalone = join(root, "examples", "public", "standalone");
+const standalone = join(root, "site", "public", "standalone");
rmSync(standalone, { recursive: true, force: true });
mkdirSync(standalone, { recursive: true });
/* relative paths only: a drive-letter argument makes GNU tar (MSYS) read
@@ -189,7 +189,7 @@ try {
/* -- 2: the COG path — same bundle, different config -- */
await page.evaluate(() => {
- document.querySelector("map0-viewer").setAttribute("config-src", "/configs/cog.map0.json");
+ document.querySelector("map0-viewer").setAttribute("config-src", "/configs/cog-terrain.map0.json");
});
await page.waitForFunction(
() => {
@@ -220,7 +220,7 @@ try {
demLayer: api.map.getLayer("m0l-dgm")?.type,
statuses: layers.map((l) => `${l.id}:${l.status}`).join(" "),
allReady: layers.every((l) => l.status === "ready"),
- rampEntries: byId("kriging")?.legend?.entries?.length ?? 0,
+ rampEntries: byId("dgm-elevation")?.legend?.entries?.length ?? 0,
};
});
record(
diff --git a/examples/demos/cog.html b/examples/demos/cog.html
deleted file mode 100644
index 73b4ba3..0000000
--- a/examples/demos/cog.html
+++ /dev/null
@@ -1,88 +0,0 @@
-
-
-
-
-
- Cloud Optimized GeoTIFF — map0 demos
-
-
-
-
-
-
-
-
Data sources
-
Cloud Optimized GeoTIFF
-
- A raster file on plain storage, read directly by the browser — no tile server, no
- server-side rendering. HTTP range requests fetch only the tiles in view. Three overlays
- here: a hillshade computed from the BEV elevation model of the Großglockner region, an
- interpolated single-band surface rendered through a color ramp, and an RGB orthophoto.
-
-
-
-
-
- Try it: the opening view shades the Hohe Tauern from a plain
- 3 MB GeoTIFF. A hillshade paints only shadows and highlights — flat terrain stays
- see-through so the basemap remains readable underneath; the opacity slider scales the
- shading intensity. The other two overlays sit in Catalonia: use zoom to layer on
- the orthophoto and watch the network tab — only small byte ranges of the 50 MB file
- are ever fetched. The legend shows the color ramp derived from the color
- config.
-
-
-
-
What this demo shows
-
-
A cog layer needs nothing but a URL — bounds and zoom range come from the file's own header
-
color maps a single band onto a named ramp (ColorBrewer/CARTOColors), discrete classes or continuous
-
hillshade renders a single-band DEM as relief shading (exaggeration, light direction, colors)
-
The legend panel derives its entries from the color ramp automatically
-
The decoder (geotiff.js, ~150 KB) loads on demand — configs without a COG layer never pay for it
-
-
- Ramp names follow the source palettes: Brewer<Name><classes>
- (e.g. BrewerSpectral9, 3–12 classes depending on the palette) and
- Carto<Name> (e.g. CartoEarth). The protocol library
- renders them all on one page:
- color scheme cheatsheet.
- A typo fails fast — the layer turns to error state with "… is not a supported color scheme".
-
-
- EPSG:3857 only.
- The protocol reads Web Mercator COGs and does not reproject — a file in a national CRS
- (Gauss-Krüger, Lambert, UTM) fails with a clear per-layer error. Prepare files with
- gdalwarp -t_srs EPSG:3857 +
- gdal_translate -of COG. Nodata pixels render transparent; a file without a
- declared nodata value treats 0 as transparent.
-
-
-
-
-
Configuration
-
-
-
-
-
When to reach for COG
-
- COG is the raster twin of PMTiles: publish one file on any static bucket that supports
- range requests and you have a zoomable overlay — orthophotos, elevation, model output,
- sensor rasters. For nationwide imagery with heavy traffic, a tiled service (WMTS) with a
- CDN in front still wins; for the long tail of project rasters that would otherwise need
- their own GeoServer, a COG on object storage is hard to beat.
-
-
- The protocol behind this layer type is
- @geomatico/maplibre-cog-protocol
- (pre-1.0, pinned): RGB and grayscale imagery, single-band color ramps and DEM hillshading.
- The hillshade above is the 25 m BEV terrain model, warped once to EPSG:3857 and cut
- down to this region (gdalwarp -of COG) — the original
- open-data file is
- EPSG:31287 and its server sends no CORS headers, so it cannot be read in place.
-
diff --git a/examples/code.ts b/site/code.ts
similarity index 93%
rename from examples/code.ts
rename to site/code.ts
index 8de05a3..3a27299 100644
--- a/examples/code.ts
+++ b/site/code.ts
@@ -3,6 +3,10 @@
* copy button, and the rule that a snippet is FETCHED from the very file the
* map loads wherever possible — so a snippet can never drift from what runs.
*/
+import { LANG } from "./lang.js";
+
+const STR =
+ LANG === "de" ? { copy: "Kopieren", copied: "Kopiert" } : { copy: "Copy", copied: "Copied" };
const escapeHtml = (s: string): string =>
s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
@@ -91,11 +95,11 @@ export function codeFigure(
const copy = document.createElement("button");
copy.className = "copy";
copy.type = "button";
- copy.textContent = "Copy";
+ copy.textContent = STR.copy;
copy.addEventListener("click", () => {
void navigator.clipboard.writeText(code).then(() => {
- copy.textContent = "Copied";
- setTimeout(() => (copy.textContent = "Copy"), 1600);
+ copy.textContent = STR.copied;
+ setTimeout(() => (copy.textContent = STR.copy), 1600);
});
});
caption.appendChild(copy);
@@ -141,7 +145,7 @@ export async function enhanceCode(): Promise {
const text = (await res.text()).trimEnd();
renderCode(pre, label, text, pre.dataset.lang ?? "json");
} catch {
- pre.textContent = `could not load ${src}`;
+ pre.textContent = LANG === "de" ? `${src} konnte nicht geladen werden` : `could not load ${src}`;
}
return;
}
diff --git a/examples/demos/add-layer.html b/site/demos/add-layer.html
similarity index 63%
rename from examples/demos/add-layer.html
rename to site/demos/add-layer.html
index 3c8ccaa..f9b8f70 100644
--- a/examples/demos/add-layer.html
+++ b/site/demos/add-layer.html
@@ -3,7 +3,7 @@
- Add layers — map0 demos
+ Add layers — map0 demos
@@ -11,16 +11,16 @@
-
Map features
-
Add layers
-
+
Map features
+
Add layers
+
This map starts empty on purpose. Users bring their own services — paste a URL, pick from
the capabilities, done.
-
+
Try it: the + button in the layer panel. Paste
https://data.wien.gv.at/daten/geo as WMS (375 layers, use the filter box), or
switch to WMTS and paste
@@ -28,20 +28,20 @@
Add layers
-
What this demo shows
+
What this demo shows
-
WMS and WMTS capabilities parsed in the browser — no server component involved
-
Filterable layer list with multi-select for services with hundreds of layers
-
Layers arrive fully wired: GetFeatureInfo, legend, metadata link, and a zoom range derived from the service's scale hints
-
Layers without a WebMercator offering are flagged before you add them (D-02)
-
Added layers appear in their own section of the panel and can be removed again
+
WMS and WMTS capabilities parsed in the browser — no server component involved
+
Filterable layer list with multi-select for services with hundreds of layers
+
Layers arrive fully wired: GetFeatureInfo, legend, metadata link, and a zoom range derived from the service's scale hints
+
Layers without a WebMercator offering are flagged before you add them (D-02)
+
Added layers appear in their own section of the panel and can be removed again
-
+
User-added layers never touch the page config. They live in session state — and, because
this demo also enables permalink, they travel in the share link, so a
colleague opens the map with your services already loaded.
-
+
CORS decides what works
The browser talks to the service directly. Services without CORS headers fail here — that
is a property of the service, not of the client, and the dialog says so instead of hanging.
@@ -49,13 +49,13 @@
What this demo shows
-
Configuration
+
Configuration
-
Set allowAdd: false for a locked-down map where only configured layers exist.
+
Set allowAdd: false for a locked-down map where only configured layers exist.
+ A raster file on plain storage, read directly by the browser — no tile server, no
+ server-side rendering. HTTP range requests fetch only the tiles in view. One map per
+ rendering mode below: RGB imagery, a DEM as terrain, and a classified single-band raster
+ — each with its complete config underneath.
+
+
+
+
+
Imagery — a URL is enough
+
+ An RGB orthophoto over Catalonia. The layer definition is nothing but a URL: bounds and
+ zoom range come from the file's own header.
+
+
+
+
+ Try it: open the network tab and pan around — only small byte ranges of
+ the 50 MB file are ever fetched, and tiles already seen never hit the network again.
+
+
+
+
+
+
+
Terrain — one DEM, two renderings
+
+ The same 3 MB elevation file (BEV 25 m terrain model, Großglockner region)
+ drives two layers: hillshade renders relief shading,
+ color maps the elevation band onto a hypsometric ramp. The legend derives
+ its class swatches from the ramp automatically.
+
+
+
+
+ Try it: toggle either layer off to see the other alone. A hillshade paints
+ only shadows and highlights — flat terrain stays see-through — and its opacity slider
+ scales the shading intensity.
+
+
+
+
+
+
+
Classification — your own values and colors
+
+ A ramp never fits categorical data. color.classes styles exact pixel values
+ (or from–to ranges) with hand-picked colors instead — here a binary sealed-surfaces
+ raster for all of Austria, 1.3 GB on a plain bucket. The legend takes one labelled
+ swatch per class; anything outside the classes renders transparent.
+
+
+
+
+ Try it: the unsealed class uses an 8-digit hex color — the last two digits
+ are alpha, so the basemap shows through. Drop that class from the config and unsealed
+ pixels disappear entirely.
+
+
+
+
+
+
+
What these demos show
+
+
A cog layer needs nothing but a URL — bounds and zoom range come from the file's own header
+
color maps a single band onto a named ramp (ColorBrewer/CARTOColors), discrete classes or continuous
+
color.classes instead styles exact values or from–to ranges directly, with hand-picked colors and labels
+
hillshade renders a single-band DEM as relief shading (exaggeration, light direction, colors)
+
The legend panel derives its entries from the color ramp or the classes automatically
+
The decoder (geotiff.js, ~150 KB) loads on demand — configs without a COG layer never pay for it
+
+
+ Ramp names follow the source palettes: Brewer<Name><classes>
+ (e.g. BrewerSpectral9, 3–12 classes depending on the palette) and
+ Carto<Name> (e.g. CartoEarth). The protocol library
+ renders them all on one page:
+ color scheme cheatsheet.
+ A typo fails fast — the layer turns to error state with "… is not a supported color scheme".
+
+
+ Class ranges follow one fixed rule instead of an inclusive/exclusive knob:
+ from is inclusive, to is exclusive — except the class with the
+ highest to, which includes it, so the data maximum never falls off the top
+ class. Exact values win over ranges. One constraint: the same file cannot mix
+ classes with a ramp or hillshade rendering on one page (the per-pixel color
+ function is keyed by the COG URL).
+
+
+ EPSG:3857 only.
+ The protocol reads Web Mercator COGs and does not reproject — a file in a national CRS
+ (Gauss-Krüger, Lambert, UTM) fails with a clear per-layer error. Prepare files with
+ gdalwarp -t_srs EPSG:3857 +
+ gdal_translate -of COG. Nodata pixels render transparent; a file without a
+ declared nodata value treats 0 as transparent.
+
+
+
+
+
When to reach for COG
+
+ COG is the raster twin of PMTiles: publish one file on any static bucket that supports
+ range requests and you have a zoomable overlay — orthophotos, elevation, model output,
+ sensor rasters. For nationwide imagery with heavy traffic, a tiled service (WMTS) with a
+ CDN in front still wins; for the long tail of project rasters that would otherwise need
+ their own GeoServer, a COG on object storage is hard to beat.
+
+
+ The protocol behind this layer type is
+ @geomatico/maplibre-cog-protocol
+ (pre-1.0, pinned): RGB and grayscale imagery, single-band color ramps and DEM hillshading.
+ The terrain demo uses the 25 m BEV terrain model, warped once to EPSG:3857 and cut
+ down to its region (gdalwarp -of COG) — the original
+ open-data file is
+ EPSG:31287 and its server sends no CORS headers, so it cannot be read in place.
+
The map renders in Web Mercator — but nobody in an Austrian office writes down Web
Mercator. Coordinates are displayed in whichever systems your users actually need.
-
+
Try it: right-click anywhere (or press and hold on a touch device). Each
row has a copy button.
-
What this demo shows
+
What this demo shows
-
Right-click and long-press both open the readout
-
Reprojection happens client-side with proj4 — no service call
-
Built-in definitions: MGI/Gauss-Krueger M28, M31, M34, Austria Lambert and all UTM zones
-
Any other CRS works by supplying a proj4 def in the config (EPSG:3416 below)
+
Right-click and long-press both open the readout
+
Reprojection happens client-side with proj4 — no service call
+
Built-in definitions: MGI/Gauss-Krueger M28, M31, M34, Austria Lambert and all UTM zones
+
Any other CRS works by supplying a proj4 def in the config (EPSG:3416 below)
-
+
Without a crs list map0 chooses sensibly by itself: WGS 84, the Gauss-Krueger
strip matching the clicked longitude, and the matching UTM zone. That default is what the
other demos on this site use.
-
+
Display, not rendering
This is a coordinate readout. MapLibre still renders in EPSG:3857 — services that
cannot deliver Web Mercator remain out of scope (decision D-02). Reprojecting numbers for
@@ -47,9 +47,9 @@
What this demo shows
-
Configuration
+
Configuration
-
Defaults, or off
+
Defaults, or off
"controls": {
"coordinates": true, // WGS 84 + matching GK strip + matching UTM zone
diff --git a/examples/demos/demo.css b/site/demos/demo.css
similarity index 84%
rename from examples/demos/demo.css
rename to site/demos/demo.css
index dca9a0f..faffeaf 100644
--- a/examples/demos/demo.css
+++ b/site/demos/demo.css
@@ -17,8 +17,13 @@
--wrap: 1180px;
}
+/* The dark tokens exist twice on purpose: the media query serves the OS
+ default (and no-JS visitors), the .dark class serves an explicit choice made
+ with the topbar toggle (persisted by the chrome script in vite.config.ts).
+ CSS cannot share one block between those two selectors. */
@media (prefers-color-scheme: dark) {
- :root {
+ :root:not(.light) {
+ color-scheme: dark;
--page-bg: #14181d;
--surface: #1c2127;
--fg: #e8eaee;
@@ -29,6 +34,20 @@
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 30px rgba(0, 0, 0, 0.35);
}
}
+:root.dark {
+ color-scheme: dark;
+ --page-bg: #14181d;
+ --surface: #1c2127;
+ --fg: #e8eaee;
+ --muted: #9aa4b1;
+ --border: rgba(255, 255, 255, 0.12);
+ --accent: #22b8cf;
+ --code-bg: #0b1220;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 30px rgba(0, 0, 0, 0.35);
+}
+:root.light {
+ color-scheme: light;
+}
* {
box-sizing: border-box;
@@ -110,6 +129,62 @@ a {
.topbar nav a.gh {
display: inline-flex;
}
+
+/* theme toggle — the visible icon follows the same cascade as the tokens:
+ moon in light mode, sun in dark mode (each shows what a click brings) */
+.theme-toggle {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px;
+ border: 0;
+ border-radius: 8px;
+ background: none;
+ color: var(--muted);
+ cursor: pointer;
+}
+.theme-toggle:hover {
+ color: var(--accent);
+}
+.theme-toggle svg {
+ display: block;
+}
+.theme-toggle .sun {
+ display: none;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not(.light) .theme-toggle .sun {
+ display: block;
+ }
+ :root:not(.light) .theme-toggle .moon {
+ display: none;
+ }
+}
+:root.dark .theme-toggle .sun {
+ display: block;
+}
+:root.dark .theme-toggle .moon {
+ display: none;
+}
+
+/* language switcher */
+.lang {
+ display: inline-flex;
+ gap: 2px;
+ padding: 2px;
+ border: 1px solid var(--border);
+ border-radius: 9px;
+}
+.topbar nav .lang a {
+ padding: 1px 7px;
+ border-radius: 6px;
+ font-size: 12.5px;
+ font-weight: 600;
+}
+.topbar nav .lang a[aria-current] {
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
+ color: var(--accent);
+}
+
.topbar nav a.gh svg {
display: block;
fill: currentColor;
@@ -534,6 +609,15 @@ footer.site a.footmark {
.topbar .wrap {
padding: 0 16px;
}
+ /* the toggle and switcher joined the topbar; the landing anchor links are
+ the ones a small screen can do without */
+ .topbar nav {
+ gap: 14px;
+ }
+ .topbar nav a[href^="/#"],
+ .topbar nav a[href^="/de/#"] {
+ display: none;
+ }
map0-viewer {
height: 66vh;
}
diff --git a/examples/demos/demo.ts b/site/demos/demo.ts
similarity index 63%
rename from examples/demos/demo.ts
rename to site/demos/demo.ts
index 4b9ff8a..de0c34c 100644
--- a/examples/demos/demo.ts
+++ b/site/demos/demo.ts
@@ -3,8 +3,9 @@
* and footer are injected by vite.config.ts on every page of the site).
* Code blocks come from ../code.ts, which the landing page uses as well.
*/
-import { DEMOS, GROUPS, type Demo } from "./demos.js";
+import { DEMOS, GROUPS, GROUP_LABELS_DE, type Demo } from "./demos.js";
import { enhanceCode } from "../code.js";
+import { LANG, LANG_PREFIX } from "../lang.js";
/* The standalone demo ships the built bundle and registers the element itself.
It must NOT fall back to the sources: a silent fallback would make a broken
@@ -13,14 +14,19 @@ if (document.body.dataset.client !== "bundle") await import("@map0/ui");
/* --------------------------------- chrome -------------------------------- */
+const title = (d: Demo): string => (LANG === "de" && d.titleDe) || d.title;
+const blurb = (d: Demo): string => (LANG === "de" ? d.blurbDe : d.blurb);
+const href = (d: Demo): string => `${LANG_PREFIX}/demos/${d.id}.html`;
+
function pager(current: Demo): string {
const i = DEMOS.findIndex((d) => d.id === current.id);
const prev = DEMOS[i - 1];
const next = DEMOS[i + 1];
+ const [prevLabel, nextLabel] = LANG === "de" ? ["Zurück", "Weiter"] : ["Previous", "Next"];
return `
`;
}).join("");
}
diff --git a/examples/demos/demos.ts b/site/demos/demos.ts
similarity index 58%
rename from examples/demos/demos.ts
rename to site/demos/demos.ts
index e6b4da8..848108e 100644
--- a/examples/demos/demos.ts
+++ b/site/demos/demos.ts
@@ -3,7 +3,10 @@
export interface Demo {
id: string;
title: string;
+ /** German title — omitted where the English one is a proper name anyway */
+ titleDe?: string;
blurb: string;
+ blurbDe: string;
group: "Data sources" | "Map features" | "Configuration" | "Integration";
icon: string;
}
@@ -13,6 +16,7 @@ export const DEMOS: Demo[] = [
id: "wms",
title: "WMS",
blurb: "Raster map services with GetFeatureInfo popups and GetLegendGraphic legends.",
+ blurbDe: "Raster-Kartendienste mit GetFeatureInfo-Popups und GetLegendGraphic-Legenden.",
group: "Data sources",
icon: "🗺️",
},
@@ -20,97 +24,123 @@ export const DEMOS: Demo[] = [
id: "wmts",
title: "WMTS",
blurb: "Tiled services resolved straight from GetCapabilities — matrix set, style and mirrors.",
+ blurbDe: "Kacheldienste direkt aus den GetCapabilities aufgelöst — Matrix-Set, Style und Mirrors.",
group: "Data sources",
icon: "🧱",
},
{
id: "vector-tiles",
title: "Vector tiles",
+ titleDe: "Vector Tiles",
blurb: "MVT overlays styled with the MapLibre style spec; PMTiles works the same way.",
+ blurbDe: "MVT-Overlays, gestylt mit der MapLibre-Style-Spezifikation; PMTiles funktioniert genauso.",
group: "Data sources",
icon: "🔺",
},
{
id: "geojson",
title: "GeoJSON & clustering",
+ titleDe: "GeoJSON & Clustering",
blurb: "Remote and inline GeoJSON, simplified styling and built-in point clustering.",
+ blurbDe: "Externes und eingebettetes GeoJSON, vereinfachtes Styling und eingebautes Punkt-Clustering.",
group: "Data sources",
icon: "💧",
},
{
id: "cog",
title: "Cloud Optimized GeoTIFF",
- blurb: "Rasters read straight from static storage — RGB imagery, color ramps and hillshade.",
+ blurb: "Rasters read straight from static storage — imagery, terrain and classified values.",
+ blurbDe: "Raster direkt aus statischem Speicher gelesen — Orthofoto, Gelände und klassifizierte Werte.",
group: "Data sources",
icon: "🛰️",
},
{
id: "popups",
title: "Popups & hover",
+ titleDe: "Popups & Hover",
blurb: "Templates, field tables, hover tooltips, selection highlight and multi-layer hits.",
+ blurbDe: "Templates, Attributtabellen, Hover-Tooltips, Hervorhebung und Treffer über mehrere Layer.",
group: "Map features",
icon: "💬",
},
{
id: "legend",
title: "Legend",
+ titleDe: "Legende",
blurb: "Service legends, swatches derived from the style, and hand-written legend entries.",
+ blurbDe: "Dienst-Legenden, aus dem Style abgeleitete Farbfelder und handgeschriebene Einträge.",
group: "Map features",
icon: "📊",
},
{
id: "search",
title: "Search",
+ titleDe: "Suche",
blurb: "Type-ahead place and address search, with any gazetteer behind it.",
+ blurbDe: "Orts- und Adresssuche mit Vorschlägen — dahinter ein Gazetteer Ihrer Wahl.",
group: "Map features",
icon: "🔍",
},
{
id: "coordinates",
title: "Coordinates",
+ titleDe: "Koordinaten",
blurb: "Right-click readout in WGS 84, Gauss-Krueger and UTM — with copy buttons.",
+ blurbDe: "Rechtsklick-Anzeige in WGS 84, Gauß-Krüger und UTM — mit Kopier-Buttons.",
group: "Map features",
icon: "📐",
},
{
id: "measure",
title: "Measure",
+ titleDe: "Messen",
blurb: "Distance and area on the sphere — click, drag a vertex, double-click.",
+ blurbDe: "Strecke und Fläche auf der Kugel — klicken, Stützpunkt ziehen, Doppelklick.",
group: "Map features",
icon: "📏",
},
{
id: "print",
title: "Print & export",
+ titleDe: "Drucken & Export",
blurb: "High-resolution PNG export and a print view with title, legend and scale bar.",
+ blurbDe: "Hochauflösender PNG-Export und eine Druckansicht mit Titel, Legende und Maßstab.",
group: "Map features",
icon: "🖨️",
},
{
id: "add-layer",
title: "Add layers",
+ titleDe: "Layer hinzufügen",
blurb: "Users paste a WMS or WMTS URL; capabilities are parsed in the browser.",
+ blurbDe: "Eine WMS- oder WMTS-URL genügt; die Capabilities werden im Browser geparst.",
group: "Map features",
icon: "➕",
},
{
id: "permalink",
title: "Share & permalink",
+ titleDe: "Teilen & Permalink",
blurb: "The full map state travels in the URL — view, basemap, layers, added services.",
+ blurbDe: "Der komplette Kartenzustand steckt in der URL — Ausschnitt, Hintergrund, Layer, Dienste.",
group: "Map features",
icon: "🔗",
},
{
id: "globe",
title: "Globe",
+ titleDe: "Globus",
blurb: "MapLibre's globe projection, one config key and a control away.",
+ blurbDe: "MapLibres Globus-Projektion — nur einen Config-Schlüssel und ein Control entfernt.",
group: "Map features",
icon: "🌍",
},
{
id: "minimal",
title: "Minimal config",
+ titleDe: "Minimale Config",
blurb: "The contract: a version and one basemap must already give you a usable map.",
+ blurbDe: "Die Garantie: Version plus eine Basemap ergeben bereits eine brauchbare Karte.",
group: "Configuration",
icon: "🧪",
},
@@ -118,41 +148,52 @@ export const DEMOS: Demo[] = [
id: "theming",
title: "Theming",
blurb: "Design tokens from config or host CSS, light/dark, and runtime restyling.",
+ blurbDe: "Design-Tokens aus Config oder Host-CSS, Hell/Dunkel und Umstylen zur Laufzeit.",
group: "Configuration",
icon: "🎨",
},
{
id: "i18n",
title: "Languages",
+ titleDe: "Sprachen",
blurb: "Built-in German and English UI plus per-locale string overrides from config.",
+ blurbDe: "Deutsche und englische UI eingebaut, plus String-Overrides je Sprache aus der Config.",
group: "Configuration",
icon: "🌐",
},
{
id: "extends",
title: "Config inheritance",
+ titleDe: "Config-Vererbung",
blurb: "One shared organisation base config, per-map deltas via extends.",
+ blurbDe: "Eine gemeinsame Basis-Config der Organisation, Abweichungen je Karte via extends.",
group: "Configuration",
icon: "🧬",
},
{
id: "validate",
title: "Validate a config",
+ titleDe: "Config validieren",
blurb: "Paste a config and run map0's validator online — plus every other place a config is checked.",
+ blurbDe: "Config einfügen und direkt online validieren — plus ein Überblick über alle anderen Prüfstellen.",
group: "Configuration",
icon: "✅",
},
{
id: "standalone",
title: "Script tag embed",
+ titleDe: "Script-Tag-Einbindung",
blurb: "The built bundle on a plain HTML page — no bundler, no framework.",
+ blurbDe: "Das gebaute Bundle auf einer einfachen HTML-Seite — ohne Bundler, ohne Framework.",
group: "Integration",
icon: "📦",
},
{
id: "lazy",
title: "Lazy loading",
+ titleDe: "Lazy Loading",
blurb: "A map below the fold costs 20 KB until someone scrolls to it.",
+ blurbDe: "Eine Karte unterhalb des sichtbaren Bereichs kostet 20 KB — bis jemand hinscrollt.",
group: "Integration",
icon: "🪶",
},
@@ -165,6 +206,13 @@ export const GROUPS: Array = [
"Integration",
];
+export const GROUP_LABELS_DE: Record = {
+ "Data sources": "Datenquellen",
+ "Map features": "Kartenfunktionen",
+ Configuration: "Konfiguration",
+ Integration: "Integration",
+};
+
export function demoById(id: string): Demo | undefined {
return DEMOS.find((d) => d.id === id);
}
diff --git a/examples/demos/extends.html b/site/demos/extends.html
similarity index 58%
rename from examples/demos/extends.html
rename to site/demos/extends.html
index e4eb357..1f90619 100644
--- a/examples/demos/extends.html
+++ b/site/demos/extends.html
@@ -3,7 +3,7 @@
- Config inheritance — map0 demos
+ Config inheritance — map0 demos
@@ -11,9 +11,9 @@
-
Configuration
-
Config inheritance
-
+
Configuration
+
Config inheritance
+
Fifty maps across an organisation should not repeat the same three basemaps fifty times.
One shared base config holds the house standard; each map only writes down what makes it
different.
@@ -21,30 +21,30 @@
Config inheritance
-
+
What you see: three basemaps, the home button, the legend position and the
share link all come from the organisation base file. This map itself only declares one
layer, a view and its own accent colour.
-
The map config
-
Short, because it inherits:
+
The map config
+
Short, because it inherits:
-
The shared base
-
Maintained once, centrally, and reused by every map in the organisation:
+
The shared base
+
Maintained once, centrally, and reused by every map in the organisation:
-
Merge rules
+
Merge rules
-
Objects merge recursively — the child's theme.primary wins, the base's theme.radius survives
-
Arrays replace as a whole — a layer list is an ordered statement, not a set to merge
-
Chains up to three levels deep (global → department → map), with cycle detection
-
Relative extends URLs resolve against the config file that declares them
+
Objects merge recursively — the child's theme.primary wins, the base's theme.radius survives
+
Arrays replace as a whole — a layer list is an ordered statement, not a set to merge
+
Chains up to three levels deep (global → department → map), with cycle detection
+
Relative extends URLs resolve against the config file that declares them
-
+
Why arrays replace
Merging layer arrays sounds convenient until you try to remove an inherited layer, reorder
two of them, or explain to an editor why layer three appeared out of nowhere. Replacing is
diff --git a/examples/demos/geojson.html b/site/demos/geojson.html
similarity index 69%
rename from examples/demos/geojson.html
rename to site/demos/geojson.html
index b34ea83..9c2e93d 100644
--- a/examples/demos/geojson.html
+++ b/site/demos/geojson.html
@@ -3,7 +3,7 @@
- GeoJSON & clustering — map0 demos
+ GeoJSON & clustering — map0 demos
@@ -11,30 +11,30 @@
-
Data sources
-
GeoJSON & clustering
-
+
Data sources
+
GeoJSON & clustering
+
Three ways to get features onto the map — a remote service, a file next to the page, and
a feature collection written straight into the config.
-
+
Try it: ~2400 drinking fountains arrive as one GeoJSON document and are
clustered on the GPU. Zoom in until the clusters break apart, and use the zoom-to-layer
button in the layer panel to jump to the local demo file.
-
What this demo shows
+
What this demo shows
-
data accepts a URL or an inline GeoJSON object — here a WFS response, a static file and a literal
-
Clustering is one config key; MapLibre does the grouping
-
The simplified style (circle-*, line-*, fill-*) expands into proper style layers per geometry type
-
Zoom-to-layer computes the bounding box from the data itself
+
data accepts a URL or an inline GeoJSON object — here a WFS response, a static file and a literal
+
Clustering is one config key; MapLibre does the grouping
+
The simplified style (circle-*, line-*, fill-*) expands into proper style layers per geometry type
+
Zoom-to-layer computes the bounding box from the data itself
-
+
For a WFS, the trick is simply asking for GeoJSON: append
outputFormat=json and srsName=EPSG:4326 to a GetFeature request
and the response is a source map0 can use directly. A dedicated wfs layer
@@ -43,13 +43,13 @@
What this demo shows
-
Configuration
+
Configuration
-
Full control over the style
-
+
Full control over the style
+
When the flat style object is not enough, pass an array of MapLibre style-spec layers
instead — expressions, filters, everything. map0 injects the source and keeps the layer
order:
diff --git a/examples/demos/globe.html b/site/demos/globe.html
similarity index 61%
rename from examples/demos/globe.html
rename to site/demos/globe.html
index 378d857..029b6f1 100644
--- a/examples/demos/globe.html
+++ b/site/demos/globe.html
@@ -3,7 +3,7 @@
A real globe projection, not a plugin and not a second rendering engine — this is the one
thing MapLibre gives us that no OpenLayers-based client has for free.
-
+
Try it: drag the globe, then use the globe button in the control stack to
switch between globe and flat Mercator. Zoom in far enough and the projection blends into
Mercator by itself.
-
What this demo shows
+
What this demo shows
-
map.projection: "globe" starts the map as a globe
-
The globe control toggles projections at runtime — overlays keep rendering
-
Overlays, popups and clustering behave exactly as in the flat map
-
Dark theme via theme.mode, so the globe sits on a dark backdrop
+
map.projection: "globe" starts the map as a globe
+
The globe control toggles projections at runtime — overlays keep rendering
+
Overlays, popups and clustering behave exactly as in the flat map
+
Dark theme via theme.mode, so the globe sits on a dark backdrop
-
+
The projection survives a basemap change, because map0 carries it through the style
transformation that re-injects overlays.
-
Configuration
+
Configuration
diff --git a/examples/demos/i18n.html b/site/demos/i18n.html
similarity index 64%
rename from examples/demos/i18n.html
rename to site/demos/i18n.html
index 1bbc591..ef98d72 100644
--- a/examples/demos/i18n.html
+++ b/site/demos/i18n.html
@@ -3,7 +3,7 @@
- Languages — map0 demos
+ Languages — map0 demos
@@ -11,30 +11,30 @@
-
Configuration
-
Languages
-
+
Configuration
+
Languages
+
German and English ship with the client. Every single string can be overridden per
locale from the config — including terms your organisation insists on.
-
+
Look closely: the panel is titled Kartenthemen, not the built-in
Kartenebenen, and the legend reads Zeichenerklärung. Both come from
i18n.overrides. Tooltips of the MapLibre controls follow the same locale.
-
How the locale is chosen
+
How the locale is chosen
-
locale: "auto" (the default) follows the visitor's browser language
-
A fixed locale pins the UI — useful when the surrounding page is single-language
-
fallback catches keys the chosen locale does not define
-
Overrides can even introduce a locale map0 does not ship, without a rebuild
+
locale: "auto" (the default) follows the visitor's browser language
+
A fixed locale pins the UI — useful when the surrounding page is single-language
+
fallback catches keys the chosen locale does not define
+
Overrides can even introduce a locale map0 does not ship, without a rebuild
-
+
Lookup order per key: your override for the locale, the built-in dictionary for the
locale, your override for the fallback, the built-in fallback. Nothing crashes on a
missing key — the key itself is shown, which makes gaps obvious in testing.
@@ -42,12 +42,12 @@
How the locale is chosen
-
Configuration
+
Configuration
-
A new language from config alone
+
A new language from config alone
"i18n": {
"locale": "it",
@@ -61,7 +61,7 @@
A new language from config alone
}
}
-
Anything not translated falls back to English rather than disappearing.
+
Anything not translated falls back to English rather than disappearing.
diff --git a/examples/demos/index.html b/site/demos/index.html
similarity index 89%
rename from examples/demos/index.html
rename to site/demos/index.html
index 18ce92f..8230847 100644
--- a/examples/demos/index.html
+++ b/site/demos/index.html
@@ -12,8 +12,8 @@
Demos
-
One topic per demo
-
+
One topic per demo
+
Every page below runs the real client against live services — mostly basemap.at and the
City of Vienna open data services. Each demo shows a map, explains what it demonstrates,
and prints the exact configuration that produced it.
diff --git a/examples/demos/lazy.html b/site/demos/lazy.html
similarity index 66%
rename from examples/demos/lazy.html
rename to site/demos/lazy.html
index 9dc1d41..8cd98f7 100644
--- a/examples/demos/lazy.html
+++ b/site/demos/lazy.html
@@ -3,7 +3,7 @@