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/public/configs/cog.map0.json b/examples/public/configs/cog.map0.json deleted file mode 100644 index 04d8f88..0000000 --- a/examples/public/configs/cog.map0.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "$schema": "https://map0.net/schema/v1.json", - "version": 1, - "meta": { - "title": "Cloud Optimized GeoTIFF", - "description": "COG overlays read directly from static storage — no tile server." - }, - "map": { "bounds": [12.45, 46.95, 13.05, 47.3] }, - "basemaps": [ - { - "id": "grau", - "title": "basemap.at Grey", - "type": "raster", - "url": "https://mapsneu.wien.gv.at/basemap/bmapgrau/normal/google3857/{z}/{y}/{x}.png", - "attribution": "© basemap.at", - "default": true - }, - { - "id": "gris", - "title": "ICGC Grey", - "type": "style", - "url": "https://geoserveis.icgc.cat/contextmaps/icgc_mapa_base_gris.json", - "attribution": "© Institut Cartogràfic i Geològic de Catalunya" - }, - { "id": "none", "title": "None", "type": "empty" } - ], - "layers": [ - { - "id": "dgm", - "type": "cog", - "title": "Hillshade Großglockner (BEV DGM)", - "url": "/data/bev-dgm25-grossglockner-3857.tif", - "hillshade": { "exaggeration": 0.6 }, - "attribution": "Geländemodell © BEV, data.bev.gv.at (CC BY 4.0)", - "metadata": { - "url": "https://geoportal.inspire.gv.at/metadatensuche/inspire/ger/catalog.search#/metadata/6a853c17-8960-44a4-81fb-18e0549a1c80", - "title": "Digitales Geländehöhenmodell – Höhenraster 25m, Stichtag 31.12.2022 (BEV)" - } - }, - { - "id": "kriging", - "type": "cog", - "title": "Interpolated surface (single band)", - "url": "https://labs.geomatico.es/maplibre-cog-protocol/data/kriging.tif", - "color": { - "scheme": "BrewerSpectral9", - "min": 1.7084054885838, - "max": 1.7919403772937, - "continuous": true, - "reverse": true - }, - "opacity": 0.85, - "attribution": "Demo data © Geomatico", - "metadata": { - "url": "https://github.com/geomatico/maplibre-cog-protocol", - "title": "maplibre-cog-protocol sample data" - } - }, - { - "id": "ortho-cog", - "type": "cog", - "title": "Orthophoto (RGB COG)", - "url": "https://labs.geomatico.es/maplibre-cog-protocol/data/image.tif", - "attribution": "Orthophoto © ICGC · demo data © Geomatico", - "metadata": { - "url": "https://github.com/geomatico/maplibre-cog-protocol", - "title": "maplibre-cog-protocol sample data" - } - } - ], - "controls": { - "legend": { "open": true }, - "home": true - }, - "i18n": { "locale": "en" } -} diff --git a/package.json b/package.json index f01ece8..c7c9395 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,12 @@ }, "packageManager": "pnpm@11.21.0", "scripts": { - "dev": "vite --config examples/vite.config.ts", + "dev": "vite --config site/vite.config.ts", "build": "pnpm --filter @map0/ui build", "build:npm": "pnpm build && node scripts/build-npm.mjs", - "build:site": "pnpm demo:standalone && vite build --config examples/vite.config.ts", - "serve": "vite preview --config examples/vite.config.ts", - "demo:standalone": "pnpm build && node -e \"const fs=require('node:fs');fs.rmSync('examples/public/standalone',{recursive:true,force:true});fs.cpSync('packages/ui/dist','examples/public/standalone',{recursive:true})\"", + "build:site": "pnpm demo:standalone && vite build --config site/vite.config.ts", + "serve": "vite preview --config site/vite.config.ts", + "demo:standalone": "pnpm build && node -e \"const fs=require('node:fs');fs.rmSync('site/public/standalone',{recursive:true,force:true});fs.cpSync('packages/ui/dist','site/public/standalone',{recursive:true})\"", "size": "node scripts/check-size.mjs", "release": "node scripts/release.mjs", "typecheck": "tsc --build", @@ -24,6 +24,7 @@ }, "devDependencies": { "@release-it/conventional-changelog": "^12.0.0", + "node-html-parser": "^9.0.1", "playwright": "^1.62.1", "release-it": "^21.0.2", "typescript": "^5.6.0", diff --git a/packages/core/src/adapters/cog.ts b/packages/core/src/adapters/cog.ts index a7c7465..0e7f08b 100644 --- a/packages/core/src/adapters/cog.ts +++ b/packages/core/src/adapters/cog.ts @@ -1,10 +1,19 @@ -import type { CogColorDef, CogHillshadeDef, CogLayerDef, NormalizedLayer } from "@map0/schema"; +import type { + CogColorClassDef, + CogColorRampDef, + CogHillshadeDef, + CogLayerDef, + NormalizedLayer, +} from "@map0/schema"; import { SourceAdapter, type LegendEntry, type LegendSpec } from "./types.js"; type NormalizedCog = CogLayerDef & NormalizedLayer & { type: "cog" }; type CogModule = typeof import("@geomatico/maplibre-cog-protocol"); +/** the per-pixel callback of setColorFunction() — the type itself is not re-exported */ +type ColorFunction = NonNullable[1]>; + let cogRegistered = false; /** Register the cog:// protocol once, lazily (geotiff.js is only loaded when a config has a cog layer). */ @@ -22,9 +31,10 @@ async function loadCogProtocol(): Promise { /** * cog:// source URL, with the protocol's #color fragment for single-band ramps - * or #dem for Terrain-RGB encoding (hillshade). + * or #dem for Terrain-RGB encoding (hillshade). Explicit classes carry no + * fragment — they render through a per-URL color function instead. */ -export function buildCogUrl(url: string, opts?: { color?: CogColorDef; dem?: boolean }): string { +export function buildCogUrl(url: string, opts?: { color?: CogColorRampDef; dem?: boolean }): string { if (opts?.dem) return `cog://${url}#dem`; const color = opts?.color; if (!color) return `cog://${url}`; @@ -54,7 +64,7 @@ function formatValue(value: number, step: number): string { */ export function cogLegendEntries( scale: RgbScale, - color: Pick, + color: Pick, ): LegendEntry[] { const { min, max } = color; const span = max - min; @@ -97,10 +107,118 @@ export function cogLegendEntries( return entries; } +/* ---------------------------- explicit classes ---------------------------- */ + +/** "#rgb", "#rrggbb", "#rrggbbaa" → RGBA bytes (format enforced by the validator) */ +function parseHexColor(hex: string): Uint8ClampedArray { + let h = hex.slice(1); + if (h.length === 3) h = [...h].map((c) => c + c).join(""); + const bytes = [0, 2, 4, 6].map((i) => + i < h.length ? parseInt(h.slice(i, i + 2), 16) : 255, + ); + return new Uint8ClampedArray(bytes); +} + +const TRANSPARENT = new Uint8ClampedArray([0, 0, 0, 0]); + +/** + * Per-pixel renderer for explicit classes: exact values win over ranges; + * ranges are [from, to) with the highest "to" inclusive (the validator's + * contract), so the data maximum never falls off the top class. noData/NaN, + * fill pixels (Infinity) and unmatched values are transparent. + * Exact values compare with a relative epsilon because the value is + * scale/offset-adjusted first and e.g. 3 × 0.01 is not exactly 0.03. + */ +export function classColorFunction(classes: CogColorClassDef[]): ColorFunction { + const exacts: Array<{ value: number; rgba: Uint8ClampedArray }> = []; + const ranges: Array<{ from: number; to: number; rgba: Uint8ClampedArray }> = []; + for (const cls of classes) { + const rgba = parseHexColor(cls.color); + if (cls.value !== undefined) exacts.push({ value: cls.value, rgba }); + else if (cls.from !== undefined && cls.to !== undefined) + ranges.push({ from: cls.from, to: cls.to, rgba }); + } + ranges.sort((a, b) => a.from - b.from); + const maxTo = ranges.reduce((m, r) => Math.max(m, r.to), -Infinity); + + return (pixel, color, metadata) => { + const raw = pixel[0]; + if (raw === undefined || raw === metadata.noData || !Number.isFinite(raw)) { + color.set(TRANSPARENT); + return; + } + const value = raw * metadata.scale + metadata.offset; + for (const e of exacts) { + if (Math.abs(value - e.value) <= 1e-9 * Math.max(1, Math.abs(e.value))) { + color.set(e.rgba); + return; + } + } + for (const r of ranges) { + if (value < r.from) break; // sorted — no later range can match + if (value < r.to || (r.to === maxTo && value <= r.to)) { + color.set(r.rgba); + return; + } + } + color.set(TRANSPARENT); + }; +} + +/** Legend entries for explicit classes: one swatch per class, in config order. */ +export function classLegendEntries(classes: CogColorClassDef[]): LegendEntry[] { + return classes.map((cls) => ({ + label: cls.label ?? (cls.value !== undefined ? String(cls.value) : `${cls.from} – ${cls.to}`), + color: cls.color, + shape: "square" as const, + })); +} + +/** + * setColorFunction() is keyed by the plain COG URL and global to the protocol + * (it also overrides #color/#dem fragments on the same URL). Refcount per URL + * so removing one of two layers sharing a file keeps the other rendered, and + * the function is cleared when the last one unmounts. + */ +const activeClassFunctions = new Map(); + +function registerClassColorFunction( + cog: CogModule, + url: string, + classes: CogColorClassDef[], +): () => void { + const key = JSON.stringify(classes); + const entry = activeClassFunctions.get(url); + if (entry) { + entry.count++; + if (entry.key !== key) { + console.warn( + `[map0] multiple cog layers style ${url} with different "classes" — ` + + `the protocol keys color functions by URL, so the last definition wins for all of them`, + ); + entry.key = key; + cog.setColorFunction(url, classColorFunction(classes)); + } + } else { + activeClassFunctions.set(url, { count: 1, key }); + cog.setColorFunction(url, classColorFunction(classes)); + } + let released = false; + return () => { + if (released) return; + released = true; + const e = activeClassFunctions.get(url); + if (!e || --e.count > 0) return; + activeClassFunctions.delete(url); + cog.setColorFunction(url, undefined); + }; +} + export class CogAdapter extends SourceAdapter { private srcId = `m0s-${this.def.id}`; private lyrId = `m0l-${this.def.id}`; private rampLegend: LegendEntry[] | null = null; + private releaseColorFunction?: () => void; get sourceIds(): string[] { return [this.srcId]; @@ -122,16 +240,22 @@ export class CogAdapter extends SourceAdapter { this.def.bounds = meta.bbox as [number, number, number, number]; } - if (this.def.color) { + const color = this.def.color; + const ramp = color && "scheme" in color ? color : undefined; + if (ramp) { /* throws on an unknown scheme name — before the source is added */ const scale = cog.colorScale({ - colorScheme: this.def.color.scheme, - min: this.def.color.min, - max: this.def.color.max, - isContinuous: this.def.color.continuous ?? false, - isReverse: this.def.color.reverse ?? false, + colorScheme: ramp.scheme, + min: ramp.min, + max: ramp.max, + isContinuous: ramp.continuous ?? false, + isReverse: ramp.reverse ?? false, }) as RgbScale; - this.rampLegend = cogLegendEntries(scale, this.def.color); + this.rampLegend = cogLegendEntries(scale, ramp); + } else if (color && "classes" in color) { + /* explicit classes render through the protocol's per-URL color function */ + this.releaseColorFunction = registerClassColorFunction(cog, this.def.url, color.classes); + this.rampLegend = classLegendEntries(color.classes); } const hs: CogHillshadeDef | null = this.def.hillshade @@ -143,7 +267,7 @@ export class CogAdapter extends SourceAdapter { map.addSource(this.srcId, { type: hs ? "raster-dem" : "raster", /* the protocol answers this URL with a TileJSON (tiles, bounds, maxzoom) */ - url: buildCogUrl(this.def.url, { color: this.def.color, dem: !!hs }), + url: buildCogUrl(this.def.url, { color: ramp, dem: !!hs }), tileSize: 256, // the protocol always renders 256-px tiles ...(hs ? { encoding: "mapbox" } : {}), // #dem emits the Mapbox Terrain-RGB scheme ...(this.def.attribution ? { attribution: this.def.attribution } : {}), @@ -183,6 +307,12 @@ export class CogAdapter extends SourceAdapter { this.opacityEntries = [[this.lyrId, "raster-opacity", 1]]; } + override unmount(): void { + super.unmount(); + this.releaseColorFunction?.(); + this.releaseColorFunction = undefined; + } + protected override autoLegend(): LegendSpec | null { return this.rampLegend && this.rampLegend.length > 0 ? { kind: "entries", entries: this.rampLegend } diff --git a/packages/core/src/core.test.ts b/packages/core/src/core.test.ts index 270f4d6..bb27d63 100644 --- a/packages/core/src/core.test.ts +++ b/packages/core/src/core.test.ts @@ -6,7 +6,12 @@ import { normalizeLegendUrl, WmsAdapter, } from "./adapters/wms.js"; -import { buildCogUrl, cogLegendEntries } from "./adapters/cog.js"; +import { + buildCogUrl, + classColorFunction, + classLegendEntries, + cogLegendEntries, +} from "./adapters/cog.js"; import { expandSimpleStyle } from "./adapters/geojson.js"; import { deriveFromStyleLayers, entryFromPaint } from "./adapters/legend-derive.js"; import { escapeHtml, renderFields, renderTemplate } from "./template.js"; @@ -141,6 +146,75 @@ describe("cog", () => { it("returns no legend for a degenerate range", () => { expect(cogLegendEntries(() => [0, 0, 0], { min: 5, max: 5 })).toEqual([]); }); + + /* run the per-pixel function the way the protocol does: one pixel at a time */ + const paint = ( + fn: ReturnType, + value: number, + metadata: { offset: number; scale: number; noData?: number }, + ): number[] => { + const rgba = new Uint8ClampedArray(4); + fn(new Float64Array([value]), rgba, { ...metadata, images: [] }); + return [...rgba]; + }; + const identity = { offset: 0, scale: 1 }; + + it("classes: colors exact values, leaves everything else transparent", () => { + const fn = classColorFunction([ + { value: 0, color: "#67a9cf" }, + { value: 1, color: "#ef8a62" }, + ]); + expect(paint(fn, 0, identity)).toEqual([0x67, 0xa9, 0xcf, 255]); + expect(paint(fn, 1, identity)).toEqual([0xef, 0x8a, 0x62, 255]); + expect(paint(fn, 2, identity)).toEqual([0, 0, 0, 0]); + expect(paint(fn, 0.5, identity)).toEqual([0, 0, 0, 0]); + }); + + it("classes: ranges are [from, to) and the highest to is inclusive", () => { + const fn = classColorFunction([ + { from: 0, to: 10, color: "#111" }, + { from: 10, to: 20, color: "#222" }, + ]); + expect(paint(fn, 0, identity)).toEqual([0x11, 0x11, 0x11, 255]); + expect(paint(fn, 9.99, identity)).toEqual([0x11, 0x11, 0x11, 255]); + expect(paint(fn, 10, identity)).toEqual([0x22, 0x22, 0x22, 255]); // boundary → upper class + expect(paint(fn, 20, identity)).toEqual([0x22, 0x22, 0x22, 255]); // data maximum stays styled + expect(paint(fn, 20.01, identity)).toEqual([0, 0, 0, 0]); + expect(paint(fn, -1, identity)).toEqual([0, 0, 0, 0]); + }); + + it("classes: exact values win over ranges; gaps stay transparent", () => { + const fn = classColorFunction([ + { from: 0, to: 10, color: "#111" }, + { value: 5, color: "#fff" }, + { from: 20, to: 30, color: "#333" }, + ]); + expect(paint(fn, 5, identity)).toEqual([255, 255, 255, 255]); + expect(paint(fn, 15, identity)).toEqual([0, 0, 0, 0]); // gap between ranges + }); + + it("classes: noData, NaN and fill pixels are transparent; scale/offset apply", () => { + const fn = classColorFunction([{ value: 0.03, color: "#ff0000cc" }]); + /* raw 3 with scale 0.01 → 0.03 despite float rounding; alpha from #…cc */ + expect(paint(fn, 3, { offset: 0, scale: 0.01 })).toEqual([255, 0, 0, 0xcc]); + expect(paint(fn, 255, { ...identity, noData: 255 })).toEqual([0, 0, 0, 0]); + expect(paint(fn, NaN, identity)).toEqual([0, 0, 0, 0]); + expect(paint(fn, Infinity, identity)).toEqual([0, 0, 0, 0]); + }); + + it("classes: derives one legend entry per class, labels defaulting to the values", () => { + expect( + classLegendEntries([ + { value: 1, color: "#ef8a62", label: "versiegelt" }, + { value: 0, color: "#67a9cf" }, + { from: 2, to: 5, color: "#999999" }, + ]), + ).toEqual([ + { label: "versiegelt", color: "#ef8a62", shape: "square" }, + { label: "0", color: "#67a9cf", shape: "square" }, + { label: "2 – 5", color: "#999999", shape: "square" }, + ]); + }); }); describe("templates", () => { diff --git a/packages/map0/.release-it.json b/packages/map0/.release-it.json index cb6c3dc..0ca24e3 100644 --- a/packages/map0/.release-it.json +++ b/packages/map0/.release-it.json @@ -34,7 +34,7 @@ "node ../../e2e/verify-tarball.mjs map0-viewer-${version}.tgz" ], "before:git:release": [ - "git add ../../CHANGELOG.md ../../README.md ../../examples/demos/standalone.html" + "git add ../../CHANGELOG.md ../../README.md ../../site/demos/standalone.html" ] } } diff --git a/packages/map0/README.md b/packages/map0/README.md index b8371a5..a7b2eac 100644 --- a/packages/map0/README.md +++ b/packages/map0/README.md @@ -5,7 +5,7 @@ > One script tag plus one JSON config = a full-featured map on any web page. MapLibre made cartography declarative — the style spec is JSON. map0 extends the same idea to the -*map client*: basemaps, layer tree, legends, feature popups, search, measuring, print, globe, +*web map client*: basemaps, layer tree, legends, feature popups, search, measuring, print, globe, theming and languages are all declared in a single JSON document that can live in a CMS field. It renders straight into the page as a web component — no iframe, no backend, no build step required. @@ -77,8 +77,10 @@ change the map without touching the page: ``` The element also takes `loading="eager"` (the default `"lazy"` initialises the map when it comes -near the viewport), accepts a config object via its `config` property, and talks to the page -without imports: +near the viewport) and `theme="dark"` / `theme="light"` — a host-page override for the colour +scheme that beats the config's `theme.mode` and restyles a running map when flipped, so a page +with its own dark-mode toggle just sets the attribute. It accepts a config object via its +`config` property, and talks to the page without imports: ```js const viewer = document.querySelector("map0-viewer"); @@ -137,7 +139,8 @@ silently does nothing. ## What is in the config Layer sources: WMS, WMTS, XYZ/raster, vector tiles, PMTiles, GeoJSON (with clustering), and COG — -Cloud Optimized GeoTIFF as RGB imagery, single-band color ramps or DEM hillshade. Plus a +Cloud Optimized GeoTIFF as RGB imagery, single-band color ramps, explicit value/range classes, or +DEM hillshade. Plus a layer tree with groups, legends (`"auto"` derives them from the service), feature info with HTML templates, hover, search, measuring, coordinate readout in projected CRS, print/PDF export, permalinks, `extends` for shared base configs, CSS-variable theming, and per-language label @@ -178,7 +181,7 @@ change from release to release. ## Weight -A page pays ~23 KB gzip for the element itself. The engine, MapLibre and its stylesheet (~304 KB +A page pays ~31 KB gzip for the element itself. The engine, MapLibre and its stylesheet (~305 KB gzip) load when a map actually initialises — never for a map nobody scrolls to. Capabilities parsing, proj4, PMTiles, the COG decoder, measuring and the dialogs load on first use. diff --git a/packages/schema/src/schema.test.ts b/packages/schema/src/schema.test.ts index 1c35692..d06aca0 100644 --- a/packages/schema/src/schema.test.ts +++ b/packages/schema/src/schema.test.ts @@ -109,6 +109,94 @@ describe("validateConfig", () => { expect(paths).toContain("$.layers[0].color.continuous"); }); + it("accepts a cog layer classified by exact values and ranges", () => { + const r = validateConfig({ + ...minimal, + layers: [ + { + type: "cog", + url: "https://example.org/binary.tif", + color: { + classes: [ + { value: 0, color: "#67a9cf", label: "unversiegelt" }, + { value: 1, color: "#ef8a62" }, + { from: 2, to: 5, color: "#999" }, + { from: 5, to: 10, color: "#33333380", label: "5 – 9" }, + ], + }, + }, + ], + }); + expect(r.errors).toEqual([]); + }); + + it("flags broken cog classes with precise paths", () => { + const r = validateConfig({ + ...minimal, + layers: [ + { + type: "cog", + url: "https://example.org/binary.tif", + color: { + scheme: "BrewerSpectral9", // cannot combine a ramp with classes + classes: [ + { value: 0, color: "red" }, // not hex + { value: 0, color: "#fff" }, // duplicate value + { value: 1, from: 2, to: 3, color: "#fff" }, // value and range together + { from: 3, to: 3, color: "#fff" }, // empty range + { color: "#fff" }, // neither value nor range + ], + }, + }, + ], + }); + const paths = r.errors.map((e) => e.path); + expect(paths).toContain("$.layers[0].color.scheme"); + expect(paths).toContain("$.layers[0].color.classes[0].color"); + expect(paths).toContain("$.layers[0].color.classes[1].value"); + expect(paths).toContain("$.layers[0].color.classes[2]"); + expect(paths).toContain("$.layers[0].color.classes[3].to"); + expect(paths).toContain("$.layers[0].color.classes[4]"); + }); + + it("rejects overlapping class ranges but lets them touch", () => { + const touching = validateConfig({ + ...minimal, + layers: [ + { + type: "cog", + url: "https://example.org/data.tif", + color: { + classes: [ + { from: 0, to: 10, color: "#fff" }, + { from: 10, to: 20, color: "#000" }, + ], + }, + }, + ], + }); + expect(touching.errors).toEqual([]); + + const overlapping = validateConfig({ + ...minimal, + layers: [ + { + type: "cog", + url: "https://example.org/data.tif", + color: { + classes: [ + { from: 0, to: 10, color: "#fff" }, + { from: 9, to: 20, color: "#000" }, + ], + }, + }, + ], + }); + expect( + overlapping.errors.some((e) => e.path === "$.layers[0].color.classes[1]"), + ).toBe(true); + }); + it("rejects unknown layer types", () => { const r = validateConfig({ ...minimal, layers: [{ type: "wfs", url: "x" }] }); expect(r.errors.some((e) => e.path === "$.layers[0].type")).toBe(true); diff --git a/packages/schema/src/types.ts b/packages/schema/src/types.ts index fedbb9a..a94a178 100644 --- a/packages/schema/src/types.ts +++ b/packages/schema/src/types.ts @@ -166,8 +166,11 @@ export interface RasterLayerDef extends LayerCommon { tileSize?: number; } -/** single-band value → color mapping for a "cog" layer */ -export interface CogColorDef { +/** single-band value → color mapping for a "cog" layer: a built-in ramp or explicit classes */ +export type CogColorDef = CogColorRampDef | CogColorClassesDef; + +/** built-in color ramp stretched over [min, max] */ +export interface CogColorRampDef { /** built-in ramp name (ColorBrewer/CARTOColors), e.g. "BrewerSpectral7" or "CartoEarth" */ scheme: string; /** data value mapped to the first ramp color */ @@ -180,6 +183,31 @@ export interface CogColorDef { reverse?: boolean; } +/** explicit classification: each pixel gets the color of the class it falls into */ +export interface CogColorClassesDef { + classes: CogColorClassDef[]; +} + +/** + * One class: either an exact `value` (categorical rasters) or a `from`/`to` + * range. Ranges include `from` and exclude `to` — except the class with the + * highest `to`, which includes it, so the data maximum is never unstyled. + * Exact values win over ranges; pixels matching no class are transparent + * (as are noData pixels). + */ +export interface CogColorClassDef { + /** exact data value (compared after the COG's scale/offset are applied) */ + value?: number; + /** range start, inclusive */ + from?: number; + /** range end, exclusive (inclusive for the class with the highest "to") */ + to?: number; + /** hex color, e.g. "#ef8a62" — 3, 6, or 8 digits (8 = with alpha) */ + color: string; + /** legend text; default: the value, or "from – to" */ + label?: string; +} + /** hillshade rendering options for a "cog" DEM layer (MapLibre hillshade paint) */ export interface CogHillshadeDef { /** shading intensity 0..1; default 0.5 (also scaled by the layer's opacity) */ diff --git a/packages/schema/src/v1-schema.test.ts b/packages/schema/src/v1-schema.test.ts index fc9d14a..22a0ee2 100644 --- a/packages/schema/src/v1-schema.test.ts +++ b/packages/schema/src/v1-schema.test.ts @@ -19,6 +19,7 @@ import { BASEMAP_SWITCHER_KEYS, BASEMAP_TYPES, CLUSTER_KEYS, + COG_COLOR_CLASS_KEYS, COG_COLOR_KEYS, CONFIG_KEYS, CONTROL_KEYS, @@ -96,6 +97,7 @@ const KEY_TABLES: Array<[definition: string, keys: readonly string[]]> = [ ["metadata", METADATA_KEYS], ["legendEntry", LEGEND_ENTRY_KEYS], ["cogColor", COG_COLOR_KEYS], + ["cogColorClass", COG_COLOR_CLASS_KEYS], ["hillshadeOptions", HILLSHADE_KEYS], ["clusterOptions", CLUSTER_KEYS], ["controls", CONTROL_KEYS], @@ -239,7 +241,7 @@ describe("v1.json validates configs", () => { /* every demo config on map0.net carries a $schema line — they must all pass, and (extends-fragments aside) the runtime validator must agree */ - const configsDir = new URL("../../../examples/public/configs/", import.meta.url); + const configsDir = new URL("../../../site/public/configs/", import.meta.url); const demoConfigs = readdirSync(configsDir).filter( (f) => f.endsWith(".map0.json") && !f.startsWith("_"), // _base is a fragment, not a config ); diff --git a/packages/schema/src/validate.ts b/packages/schema/src/validate.ts index 681eaa9..cdafbd8 100644 --- a/packages/schema/src/validate.ts +++ b/packages/schema/src/validate.ts @@ -133,7 +133,10 @@ export const HILLSHADE_KEYS = [ "highlightColor", "accentColor", ]; -export const COG_COLOR_KEYS = ["scheme", "min", "max", "continuous", "reverse"]; +export const COG_COLOR_KEYS = ["scheme", "min", "max", "continuous", "reverse", "classes"]; +export const COG_COLOR_CLASS_KEYS = ["value", "from", "to", "color", "label"]; +/** classes are rendered per pixel, so the color must be machine-readable: hex only */ +export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i; export const CONTROL_KEYS = [ "navigation", "scale", @@ -572,20 +575,26 @@ function validateLayers( ); if (layer.color !== undefined) { if (!isObject(layer.color)) { - err(`${p}.color`, 'color must be { "scheme", "min", "max", "continuous"?, "reverse"? }'); + err( + `${p}.color`, + 'color must be a ramp { "scheme", "min", "max" } or a classification { "classes": […] }', + ); } else { const c = layer.color; checkKeys(c, COG_COLOR_KEYS, `${p}.color`, err); - if (typeof c.scheme !== "string" || !c.scheme) - err(`${p}.color.scheme`, 'color needs a "scheme" (ramp name, e.g. "BrewerSpectral7")'); - if (typeof c.min !== "number" || !Number.isFinite(c.min)) - err(`${p}.color.min`, 'color needs a numeric "min" (value of the first ramp color)'); - if (typeof c.max !== "number" || !Number.isFinite(c.max)) - err(`${p}.color.max`, 'color needs a numeric "max" (value of the last ramp color)'); - if (typeof c.min === "number" && typeof c.max === "number" && !(c.min < c.max)) - err(`${p}.color.max`, '"max" must be greater than "min"'); - expectBoolean(c.continuous, `${p}.color.continuous`, err); - expectBoolean(c.reverse, `${p}.color.reverse`, err); + if (c.classes !== undefined) validateCogClasses(c, `${p}.color`, err); + else { + if (typeof c.scheme !== "string" || !c.scheme) + err(`${p}.color.scheme`, 'color needs a "scheme" (ramp name, e.g. "BrewerSpectral7") or "classes"'); + if (typeof c.min !== "number" || !Number.isFinite(c.min)) + err(`${p}.color.min`, 'color needs a numeric "min" (value of the first ramp color)'); + if (typeof c.max !== "number" || !Number.isFinite(c.max)) + err(`${p}.color.max`, 'color needs a numeric "max" (value of the last ramp color)'); + if (typeof c.min === "number" && typeof c.max === "number" && !(c.min < c.max)) + err(`${p}.color.max`, '"max" must be greater than "min"'); + expectBoolean(c.continuous, `${p}.color.continuous`, err); + expectBoolean(c.reverse, `${p}.color.reverse`, err); + } } } break; @@ -632,6 +641,63 @@ function validateLayers( }); } +/** + * "classes": explicit value/range → color classification for a cog layer. + * Ranges are [from, to) with the highest "to" inclusive — validated here so a + * value can never fall into two classes (overlap) and boundary values are + * predictable without an inclusive/exclusive knob per class. + */ +function validateCogClasses(c: Record, path: string, err: Err): void { + for (const key of ["scheme", "min", "max", "continuous", "reverse"]) { + if (c[key] !== undefined) + err(`${path}.${key}`, `"${key}" cannot be combined with "classes" — use a ramp or a classification, not both`); + } + const classes = c.classes; + if (!Array.isArray(classes) || classes.length === 0) { + return err( + `${path}.classes`, + 'classes must be a non-empty array of { "value" | "from"+"to", "color", "label"? }', + ); + } + const values: number[] = []; + const ranges: Array<{ from: number; to: number; path: string }> = []; + classes.forEach((cls, j) => { + const cp = `${path}.classes[${j}]`; + if (!isObject(cls)) return err(cp, "a class must be an object"); + checkKeys(cls, COG_COLOR_CLASS_KEYS, cp, err); + if (typeof cls.color !== "string" || !HEX_COLOR.test(cls.color)) + err(`${cp}.color`, 'a class needs a hex "color" like "#ef8a62" (3, 6, or 8 digits)'); + expectString(cls.label, `${cp}.label`, err); + const hasValue = cls.value !== undefined; + const hasRange = cls.from !== undefined || cls.to !== undefined; + if (hasValue && hasRange) return err(cp, 'use either "value" or "from"/"to", not both'); + if (hasValue) { + if (typeof cls.value !== "number" || !Number.isFinite(cls.value)) + return err(`${cp}.value`, '"value" must be a number'); + if (values.includes(cls.value)) return err(`${cp}.value`, `duplicate value ${cls.value}`); + values.push(cls.value); + return; + } + if (!hasRange) return err(cp, 'a class needs a "value" or a "from"/"to" range'); + if (typeof cls.from !== "number" || !Number.isFinite(cls.from)) + return err(`${cp}.from`, 'a range class needs a numeric "from" (inclusive)'); + if (typeof cls.to !== "number" || !Number.isFinite(cls.to)) + return err(`${cp}.to`, 'a range class needs a numeric "to" (exclusive; the highest "to" is inclusive)'); + if (!(cls.from < cls.to)) return err(`${cp}.to`, '"to" must be greater than "from"'); + ranges.push({ from: cls.from, to: cls.to, path: cp }); + }); + ranges.sort((a, b) => a.from - b.from); + for (let i = 1; i < ranges.length; i++) { + if (ranges[i]!.from < ranges[i - 1]!.to) { + err( + `${ranges[i]!.path}`, + `range ${ranges[i]!.from} – ${ranges[i]!.to} overlaps ${ranges[i - 1]!.from} – ${ranges[i - 1]!.to} — ` + + `classes may touch ("to" is exclusive) but not overlap`, + ); + } + } +} + function validateLegend(legend: unknown, p: string, err: Err): void { if (legend === undefined || legend === false || legend === "auto") return; if (typeof legend === "string") return checkUrl(legend, `${p}.legend`, err); diff --git a/packages/schema/v1.json b/packages/schema/v1.json index 882f627..d141282 100644 --- a/packages/schema/v1.json +++ b/packages/schema/v1.json @@ -452,8 +452,7 @@ }, "cogColor": { "type": "object", - "description": "single-band value → color mapping; the legend derives its swatches from this ramp. Omit for RGB/grayscale imagery. Mutually exclusive with \"hillshade\".", - "required": ["scheme", "min", "max"], + "description": "single-band value → color mapping: a built-in ramp (\"scheme\"/\"min\"/\"max\") or an explicit classification (\"classes\"). The legend derives its swatches from it. Omit for RGB/grayscale imagery. Mutually exclusive with \"hillshade\".", "additionalProperties": false, "properties": { "scheme": { @@ -463,8 +462,48 @@ "min": { "type": "number", "description": "data value mapped to the first ramp color" }, "max": { "type": "number", "description": "data value mapped to the last ramp color — must be greater than \"min\"" }, "continuous": { "type": "boolean", "default": false, "description": "interpolate between the ramp colors instead of discrete classes" }, - "reverse": { "type": "boolean", "default": false, "description": "reverse the ramp" } - } + "reverse": { "type": "boolean", "default": false, "description": "reverse the ramp" }, + "classes": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/cogColorClass" }, + "description": "explicit classification instead of a ramp: exact values and/or [from, to) ranges, each with its own color. Pixels matching no class (and noData pixels) are transparent. Cannot be combined with \"scheme\"/\"min\"/\"max\"." + } + }, + "if": { "required": ["classes"] }, + "then": { + "not": { + "anyOf": [ + { "required": ["scheme"] }, + { "required": ["min"] }, + { "required": ["max"] }, + { "required": ["continuous"] }, + { "required": ["reverse"] } + ] + } + }, + "else": { "required": ["scheme", "min", "max"] } + }, + "cogColorClass": { + "type": "object", + "description": "one class: an exact \"value\" (categorical rasters) or a \"from\"/\"to\" range. Ranges include \"from\" and exclude \"to\" — except the class with the highest \"to\", which includes it. Exact values win over ranges.", + "required": ["color"], + "additionalProperties": false, + "properties": { + "value": { "type": "number", "description": "exact data value (compared after the COG's scale/offset are applied)" }, + "from": { "type": "number", "description": "range start, inclusive" }, + "to": { "type": "number", "description": "range end, exclusive (inclusive for the class with the highest \"to\") — must be greater than \"from\"" }, + "color": { + "type": "string", + "pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$", + "description": "hex color, e.g. \"#ef8a62\" — 3, 6, or 8 digits (8 = with alpha)" + }, + "label": { "type": "string", "description": "legend text; default: the value, or \"from – to\"" } + }, + "oneOf": [ + { "required": ["value"], "not": { "anyOf": [{ "required": ["from"] }, { "required": ["to"] }] } }, + { "required": ["from", "to"], "not": { "required": ["value"] } } + ] }, "hillshadeOptions": { "type": "object", diff --git a/packages/ui/src/map0-viewer.ts b/packages/ui/src/map0-viewer.ts index 9c63906..00a9261 100644 --- a/packages/ui/src/map0-viewer.ts +++ b/packages/ui/src/map0-viewer.ts @@ -105,6 +105,13 @@ export class Map0Viewer extends LitElement { * whether the config is fetched at all. */ @property({ attribute: "loading" }) loading: "lazy" | "eager" = "lazy"; + /** + * Host-page override for the colour scheme. Unset, the config's `theme.mode` + * decides; `theme="dark"` / `theme="light"` wins over it, and flipping the + * attribute restyles a running map. This is the hook for a host page with its + * own dark-mode toggle — the config cannot know what the page around it did. + */ + @property({ attribute: "theme" }) theme?: "light" | "dark"; @state() private _errors: ValidationError[] | null = null; @state() private _fatal: string | null = null; @@ -228,6 +235,8 @@ export class Map0Viewer extends LitElement { } protected override updated(changed: PropertyValues): void { + /* the theme attribute restyles the running map in place — no reload */ + if (changed.has("theme") && this.normalized) this.applyTheme(this.normalized); /* while init() is still running too — the generation token retires it */ if (!this.initialized || !this.initStarted || !this.activeSource) return; if (!changed.has("config") && !changed.has("configSrc")) return; @@ -585,18 +594,26 @@ export class Map0Viewer extends LitElement { } } + /** removes the previous scheme listener when applyTheme runs again (attribute flip) */ + private themeUnsub?: () => void; + private applyTheme(cfg: NormalizedConfig): void { this.style.setProperty("--map0-primary", cfg.theme.primary); this.style.setProperty("--map0-radius", RADII[cfg.theme.radius]); this.style.setProperty("--map0-radius-sm", RADII_SM[cfg.theme.radius]); if (cfg.theme.font) this.style.setProperty("--map0-font", cfg.theme.font); + this.themeUnsub?.(); + this.themeUnsub = undefined; const apply = (dark: boolean) => this.setAttribute("data-theme", dark ? "dark" : "light"); - if (cfg.theme.mode === "auto" && typeof matchMedia !== "undefined") { + if (this.theme === "dark" || this.theme === "light") { + apply(this.theme === "dark"); + } else if (cfg.theme.mode === "auto" && typeof matchMedia !== "undefined") { const mq = matchMedia("(prefers-color-scheme: dark)"); const listener = (e: MediaQueryListEvent) => apply(e.matches); mq.addEventListener("change", listener); - this.unsubs.push(() => mq.removeEventListener("change", listener)); + this.themeUnsub = () => mq.removeEventListener("change", listener); + this.unsubs.push(this.themeUnsub); apply(mq.matches); } else { apply(cfg.theme.mode === "dark"); diff --git a/packages/ui/src/styles.ts b/packages/ui/src/styles.ts index 30b2d38..38095f5 100644 --- a/packages/ui/src/styles.ts +++ b/packages/ui/src/styles.ts @@ -501,7 +501,12 @@ export const componentStyles = css` width: 14px; height: 14px; border-radius: 3px; - background: var(--swatch); + /* composite the color over white, like a legend on paper — a semi-transparent + color must not mix with the panel background (dark theme!) but read like it + does over a light basemap */ + background: + linear-gradient(var(--swatch), var(--swatch)), + #fff; border: 1px solid rgba(0, 0, 0, 0.18); } .swatch[data-shape="line"] { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9acf4a..c7bf709 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@release-it/conventional-changelog': specifier: ^12.0.0 version: 12.0.0(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)(release-it@21.0.2) + node-html-parser: + specifier: ^9.0.1 + version: 9.0.1 playwright: specifier: ^1.62.1 version: 1.62.1 @@ -794,6 +797,9 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -905,6 +911,13 @@ packages: css-line-break@2.1.0: resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} @@ -978,9 +991,22 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dompurify@3.4.13: resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -988,6 +1014,14 @@ packages: earcut@3.2.3: resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1291,6 +1325,12 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-html-parser@9.0.1: + resolution: {integrity: sha512-QrdiYYm1NnLRXsMXThUgVcF/syWfWIgHFmy8hylWMGFbHFtnRuXLBxxGQvIm3xaIJMUDJ0ayOmT/FGJYT3pZIw==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + ohash@2.0.12: resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} @@ -2252,6 +2292,8 @@ snapshots: before-after-hook@4.0.0: {} + boolbase@1.0.0: {} + buffer-from@1.1.2: {} bundle-name@4.1.0: @@ -2379,6 +2421,16 @@ snapshots: utrie: 1.0.2 optional: true + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + d3-array@3.2.4: dependencies: internmap: 2.0.3 @@ -2437,14 +2489,36 @@ snapshots: destr@2.0.5: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + dompurify@3.4.13: optionalDependencies: '@types/trusted-types': 2.0.7 + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@17.4.2: {} earcut@3.2.3: {} + entities@4.5.0: {} + + entities@8.0.0: {} + es-module-lexer@1.7.0: {} esbuild@0.25.12: @@ -2765,6 +2839,15 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-html-parser@9.0.1: + dependencies: + css-select: 5.2.2 + entities: 8.0.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + ohash@2.0.12: {} onetime@7.0.0: diff --git a/scripts/bump-version-refs.mjs b/scripts/bump-version-refs.mjs index 9fd03ad..bf3896b 100644 --- a/scripts/bump-version-refs.mjs +++ b/scripts/bump-version-refs.mjs @@ -23,8 +23,8 @@ const root = fileURLToPath(new URL("..", import.meta.url)); const FILES = { "README.md": 2, // status line + jsDelivr snippet "packages/map0/README.md": 4, // preview banner + jsDelivr + unpkg + schema URL - "examples/demos/standalone.html": 2, // jsDelivr snippet + unpkg mention - "examples/demos/validate.html": 1, // pinned schema URL + "site/demos/standalone.html": 2, // jsDelivr snippet + unpkg mention + "site/demos/validate.html": 1, // pinned schema URL }; const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); diff --git a/scripts/check-size.mjs b/scripts/check-size.mjs index 715820e..4fcb49e 100644 --- a/scripts/check-size.mjs +++ b/scripts/check-size.mjs @@ -8,107 +8,138 @@ * deferred — per-feature chunks: capabilities parsing, proj4, PMTiles, dialogs * * Only the page tier is capped; the rest is reported so regressions stay visible. + * `measure()` is also what fills the numbers on the website (site/i18n/plugin.ts), + * so the copy cannot claim a size the build does not produce. * Usage: node scripts/check-size.mjs [--json] */ import { gzipSync } from "node:zlib"; import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { join } from "node:path"; -const DIST = "packages/ui/dist"; -const PAGE_BUDGET_KB = 40; +/* resolved from this file, not from the working directory: the site build + imports measure() and runs with its own cwd */ +const DIST = fileURLToPath(new URL("../packages/ui/dist", import.meta.url)); +export const PAGE_BUDGET_KB = 40; -const jsFiles = readdirSync(DIST).filter((f) => f.endsWith(".js") || f.endsWith(".mjs")); -const isVendorMaplibre = (f) => f.startsWith("maplibre-gl"); +/** + * @typedef {{ file: string, kb: number, tier: "page" | "map" | "deferred", contents: string }} Chunk + * @typedef {{ page: number, map: number, deferred: number, mapInView: number, rows: Chunk[] }} Sizes + */ -/** static (non-`import()`) chunk-to-chunk edges */ -const staticImports = new Map(); -const contents = new Map(); -for (const file of jsFiles) { - const src = readFileSync(join(DIST, file), "utf8"); - contents.set(file, src); - const dynamic = new Set([...src.matchAll(/import\(\s*["']\.\/([^"']+)["']/g)].map((m) => m[1])); - const all = [...src.matchAll(/(?:from|import)\s*["']\.\/([^"']+)["']/g)].map((m) => m[1]); - staticImports.set( - file, - all.filter((f) => !dynamic.has(f)), - ); -} +/** + * Weigh the built bundle. Throws if it was never built. + * @returns {Sizes} every tier in KB gzip, and one row per chunk + */ +export function measure() { + const jsFiles = readdirSync(DIST).filter((f) => f.endsWith(".js") || f.endsWith(".mjs")); + const isVendorMaplibre = (f) => f.startsWith("maplibre-gl"); -const closure = (roots) => { - const seen = new Set(); - const stack = [...roots]; - while (stack.length) { - const file = stack.pop(); - if (!file || seen.has(file)) continue; - seen.add(file); - stack.push(...(staticImports.get(file) ?? [])); + /** static (non-`import()`) chunk-to-chunk edges */ + const staticImports = new Map(); + for (const file of jsFiles) { + const src = readFileSync(join(DIST, file), "utf8"); + const dynamic = new Set([...src.matchAll(/import\(\s*["']\.\/([^"']+)["']/g)].map((m) => m[1])); + const all = [...src.matchAll(/(?:from|import)\s*["']\.\/([^"']+)["']/g)].map((m) => m[1]); + staticImports.set( + file, + all.filter((f) => !dynamic.has(f)), + ); } - return seen; -}; -const page = closure(["map0.js"]); + const closure = (roots) => { + const seen = new Set(); + const stack = [...roots]; + while (stack.length) { + const file = stack.pop(); + if (!file || seen.has(file)) continue; + seen.add(file); + stack.push(...(staticImports.get(file) ?? [])); + } + return seen; + }; -/** what a chunk is made of, read from its sourcemap */ -const describe = (file) => { - try { - const map = JSON.parse(readFileSync(join(DIST, `${file}.map`), "utf8")); - const names = new Set( - map.sources.map((s) => { - const i = s.lastIndexOf("node_modules/"); - if (i >= 0) { - const rest = s.slice(i + 13); - return rest.split("/").slice(0, rest.startsWith("@") ? 2 : 1).join("/"); - } - return s.split("/").pop().replace(/\.[jt]s$/, ""); - }), - ); - return [...names].slice(0, 3).join(", "); - } catch { - return ""; + const page = closure(["map0.js"]); + + /** what a chunk is made of, read from its sourcemap */ + const describe = (file) => { + try { + const map = JSON.parse(readFileSync(join(DIST, `${file}.map`), "utf8")); + const names = new Set( + map.sources.map((s) => { + const i = s.lastIndexOf("node_modules/"); + if (i >= 0) { + const rest = s.slice(i + 13); + return rest.split("/").slice(0, rest.startsWith("@") ? 2 : 1).join("/"); + } + return s.split("/").pop().replace(/\.[jt]s$/, ""); + }), + ); + return [...names].slice(0, 3).join(", "); + } catch { + return ""; + } + }; + + /* The map tier is MapLibre plus everything that statically needs it — except the + modules a user opens by hand. Those reference MapLibre for their own map work + but are not part of showing a map, so they are listed as on-demand. */ + const ON_DEMAND = /(-dialog|^measure)$/; + const isOnDemandChunk = (file) => { + const parts = describe(file).split(", ").filter(Boolean); + return parts.length > 0 && parts.every((name) => ON_DEMAND.test(name)); + }; + const mapTier = new Set(jsFiles.filter(isVendorMaplibre)); + for (const file of jsFiles) { + if (page.has(file) || isVendorMaplibre(file) || isOnDemandChunk(file)) continue; + if ([...closure([file])].some((f) => isVendorMaplibre(f) || f.startsWith("maplibre-css"))) { + mapTier.add(file); + } } -}; -/* The map tier is MapLibre plus everything that statically needs it — except the - modules a user opens by hand. Those reference MapLibre for their own map work - but are not part of showing a map, so they are listed as on-demand. */ -const ON_DEMAND = /(-dialog|^measure)$/; -const isOnDemandChunk = (file) => { - const parts = describe(file).split(", ").filter(Boolean); - return parts.length > 0 && parts.every((name) => ON_DEMAND.test(name)); -}; -const mapTier = new Set(jsFiles.filter(isVendorMaplibre)); -for (const file of jsFiles) { - if (page.has(file) || isVendorMaplibre(file) || isOnDemandChunk(file)) continue; - if ([...closure([file])].some((f) => isVendorMaplibre(f) || f.startsWith("maplibre-css"))) { - mapTier.add(file); + const rows = []; + const totals = { page: 0, map: 0, deferred: 0 }; + for (const file of jsFiles) { + const gz = gzipSync(readFileSync(join(DIST, file))).length; + const tier = page.has(file) ? "page" : mapTier.has(file) ? "map" : "deferred"; + totals[tier] += gz; + rows.push({ file, kb: +(gz / 1024).toFixed(1), tier, contents: describe(file) }); } -} -const rows = []; -const totals = { page: 0, map: 0, deferred: 0 }; -for (const file of jsFiles) { - const gz = gzipSync(readFileSync(join(DIST, file))).length; - const tier = page.has(file) ? "page" : mapTier.has(file) ? "map" : "deferred"; - totals[tier] += gz; - rows.push({ file, kb: +(gz / 1024).toFixed(1), tier, contents: describe(file) }); + const kb = (bytes) => +(bytes / 1024).toFixed(1); + return { + page: kb(totals.page), + map: kb(totals.map), + deferred: kb(totals.deferred), + mapInView: kb(totals.page + totals.map), + rows, + }; } -const kb = (bytes) => (bytes / 1024).toFixed(1); -if (process.argv.includes("--json")) { - console.log(JSON.stringify({ ...Object.fromEntries(Object.entries(totals).map(([k, v]) => [k, +kb(v)])), rows }, null, 1)); -} else { - rows.sort((a, b) => (a.tier === b.tier ? b.kb - a.kb : a.tier.localeCompare(b.tier))); - for (const r of rows) { - console.log(`${r.tier.padEnd(9)}${String(r.kb).padStart(7)} KB ${r.file.padEnd(28)} ${r.contents}`); +function report() { + const sizes = measure(); + const pad = (n) => String(n.toFixed(1)).padStart(7); + if (process.argv.includes("--json")) { + console.log(JSON.stringify(sizes, null, 1)); + } else { + const rows = [...sizes.rows].sort((a, b) => + a.tier === b.tier ? b.kb - a.kb : a.tier.localeCompare(b.tier), + ); + for (const r of rows) { + console.log(`${r.tier.padEnd(9)}${String(r.kb).padStart(7)} KB ${r.file.padEnd(28)} ${r.contents}`); + } + console.log("─".repeat(78)); + console.log(`page load ${pad(sizes.page)} KB gz budget ${PAGE_BUDGET_KB} KB`); + console.log(`+ first map ${pad(sizes.map)} KB gz engine, MapLibre, stylesheet`); + console.log(`+ on demand ${pad(sizes.deferred)} KB gz capabilities, proj4, PMTiles, dialogs`); + console.log(`map in view ${pad(sizes.mapInView)} KB gz`); } - console.log("─".repeat(78)); - console.log(`page load ${kb(totals.page).padStart(7)} KB gz budget ${PAGE_BUDGET_KB} KB`); - console.log(`+ first map ${kb(totals.map).padStart(7)} KB gz engine, MapLibre, stylesheet`); - console.log(`+ on demand ${kb(totals.deferred).padStart(7)} KB gz capabilities, proj4, PMTiles, dialogs`); - console.log(`map in view ${kb(totals.page + totals.map).padStart(7)} KB gz`); -} -if (totals.page / 1024 > PAGE_BUDGET_KB) { - console.error(`\n✗ page-load bundle ${kb(totals.page)} KB exceeds the ${PAGE_BUDGET_KB} KB budget`); - process.exit(1); + if (sizes.page > PAGE_BUDGET_KB) { + console.error(`\n✗ page-load bundle ${sizes.page} KB exceeds the ${PAGE_BUDGET_KB} KB budget`); + process.exit(1); + } } + +/* CLI only when run directly — the site build imports measure() instead */ +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) report(); diff --git a/scripts/stub-aliases.mjs b/scripts/stub-aliases.mjs index 3aa628d..c92b0ff 100644 --- a/scripts/stub-aliases.mjs +++ b/scripts/stub-aliases.mjs @@ -1,6 +1,6 @@ /** * Optional peer dependencies that map0 never uses, stubbed for both builds: - * the library bundle (packages/ui) and the demo site (examples). + * the library bundle (packages/ui) and the demo site (site/). * * ogc-client can hand its WMTS capabilities to OpenLayers, and jsPDF can render * HTML/SVG — neither path exists in map0. Without these aliases a production diff --git a/examples/404.html b/site/404.html similarity index 69% rename from examples/404.html rename to site/404.html index a73fa82..7bfbd43 100644 --- a/examples/404.html +++ b/site/404.html @@ -3,7 +3,7 @@ - Not found — map0 + Not found — map0 @@ -15,16 +15,16 @@
-

404 · page not found

+

404 · page not found

404

-

This one is off the map.

-

+

This one is off the map.

+

The address you followed does not exist here — or it did, before the demo site moved on. The two places worth going instead:

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.

-

The same thing from code

+

The same thing from code

           const api = document.querySelector('map0-viewer').api;
 
diff --git a/site/demos/cog.html b/site/demos/cog.html
new file mode 100644
index 0000000..ff09a1e
--- /dev/null
+++ b/site/demos/cog.html
@@ -0,0 +1,135 @@
+
+
+  
+    
+    
+    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. 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. +

+
+
+ + diff --git a/examples/demos/coordinates.html b/site/demos/coordinates.html similarity index 67% rename from examples/demos/coordinates.html rename to site/demos/coordinates.html index 792e770..5c710bb 100644 --- a/examples/demos/coordinates.html +++ b/site/demos/coordinates.html @@ -3,7 +3,7 @@ - Coordinates — map0 demos + Coordinates — map0 demos @@ -11,34 +11,34 @@
-

Map features

-

Coordinates

-

+

Map features

+

Coordinates

+

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 `
     `;
 }
 
@@ -29,14 +35,15 @@ function gallery(): string {
     const cards = DEMOS.filter((d) => d.group === group)
       .map(
         (d) => `
-        
+        
           
${d.icon}
-

${d.title}

-

${d.blurb}

+

${title(d)}

+

${blurb(d)}

`, ) .join(""); - return `

${group}

${cards}
`; + const label = LANG === "de" ? GROUP_LABELS_DE[group] : group; + return `

${label}

${cards}
`; }).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 @@ - Globe — map0 demos + Globe — map0 demos @@ -11,37 +11,37 @@

-

Map features

-

Globe

-

+

Map features

+

Globe

+

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 @@ - Lazy loading — map0 demos + Lazy loading — map0 demos