For people working on map0, not with it. Everything here was learned by building the thing: the rules that keep the architecture intact, the traps in MapLibre and in real SDI services, and how to verify a change. User-facing behaviour belongs in 04-configuration.md.
pnpm install
pnpm dev # demo site on :5173 — /demos is where the work shows up
pnpm typecheck # tsc --build across all packages, strict
pnpm test # Vitest (pure logic only, no DOM/network)
pnpm build && pnpm smoke # browser smoke test on the BUILT bundle, network-free (CI runs it)
pnpm build && pnpm size # library bundle + the size budget
pnpm demo:standalone # build + copy dist next to the standalone demo page
pnpm build:npm # assemble the publishable `map0` package (§release)
pnpm release # the whole release: version, changelog, verify, publish (§release)
node e2e/verify-demos.mjs # every demo page, headless, with screenshots
node e2e/verify-demos.mjs wms # …or oneThe loop that catches the most: change something → pnpm typecheck → look at it in the browser →
node e2e/verify-demos.mjs. Unit tests cover URL building, templates, merging, projections — the
parts that are pure functions. Everything involving MapLibre, the DOM or a live service is verified
in a real browser instead, because that is where it actually breaks.
Three of the worst bugs in this repo's history looked fine in the dev server and were broken in the
built bundle, or vice versa (§4.3, §4.4). When touching the build, the element lifecycle or a
service adapter, check both paths: the dev server (/demos/*) and the built bundle
(/demos/standalone.html after pnpm demo:standalone).
packages/map0 is the only publishable package: map0-viewer on npm, containing the prebuilt
bundle (no dependencies, MapLibre included). The folder keeps the product name, the package cannot:
npm's typosquatting heuristic rejects the unscoped map0 as too similar to mcp1, hapi, tap
and tape — short names are normalised (0 reads as o) and compared by edit distance, and the
check is server-side only, so npm pack/--dry-run will not warn you. The name is unregistered but
unavailable; only npm support can release it. Scoped names skip the check entirely, which is why the
map0 org is reserved for the day @map0/core and @map0/react become separate installs. The
workspace packages stay private until then, so a stray pnpm publish -r cannot leak them.
The release is one command, run from a clean main (release-it + @release-it/conventional-changelog,
configured in packages/map0/.release-it.json; scripts/release.mjs runs it in packages/map0 —
that package.json carries the published version, the root one is a private 0.0.0 — and feeds it a
GITHUB_TOKEN from the gh CLI's keyring login):
pnpm release # add --dry-run to watch without touching anythingWhat it does, in order — any failing step aborts before anything is public:
- gate:
pnpm typecheck+pnpm test; - proposes the bump from the conventional commits since the last tag (
fix:→ patch,feat:→ minor,BREAKING CHANGE→ major — confirm or override interactively) and writes the new section intoCHANGELOG.md; scripts/bump-version-refs.mjsmoves the CDN/prose version references in both READMEs and the standalone demo page — reference counts are asserted, a reworded sentence fails the release;pnpm build:npm(bundle without sourcemaps + regenerated notices) andnpm pack;e2e/verify-tarball.mjs— the folder-is-the-unit gate, see below;npm publish(the account has 2FA at auth-and-writes — release-it prompts for the OTP; npm versions are immutable, an unpublish is only possible within 72 h and burns the name for 24 h);- release commit + annotated tag
v<version>, push, and a GitHub release with the changelog section as notes and the verified.tgzattached as asset.
Changelog fine print: issue references in commit messages only count as GH-<n> — with the default
#<n> prefix the parser turned a #333 grey and the #dem URL fragment in commit bodies into
dead issue links. The initial CHANGELOG.md was backfilled from the tag history with the same preset
(2026-08-19), as were the GitHub releases for v0.0.1–v0.0.5.
Two things a release has to get right, both of which fail silently if it does not:
- The folder is the unit.
dist/shipsmap0.js, its lazy chunks and MapLibre's three files side by side (§4.4). Verify the packed tarball, notpackages/ui/dist— automated ine2e/verify-tarball.mjs(hook step 5, or by hand:node e2e/verify-tarball.mjs <tgz>): it unpacks the tarball oversite/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. - Third-party notices are an obligation.
scripts/build-npm.mjsreads the build's sourcemaps to find every package compiled into the bundle and reproduces each licence in full. A new bundled dependency whose npm tarball ships no licence text (pmtiles, today) fails the build until its text is added underscripts/third-party/and registered inFALLBACK_LICENSES. Do not silence this check.
Ownership: an unscoped package belongs to whoever publishes it first — the personal account that
ran npm publish, not the org. Add a second owner (npm owner add <user> map0-viewer) so the name
does not depend on one person's account.
npm publish is the CDN release: jsDelivr and unpkg mirror the tarball at
…/npm/map0-viewer@<version>/dist/map0.js — versioned, immutable, Access-Control-Allow-Origin: *.
Verified cross-origin against 0.0.1 from a foreign page: the entry, every lazy chunk and
maplibre-gl-worker.mjs load from the CDN, 12,849 vector-tile features on screen, no console errors.
What makes that work is MapLibre v6 handling the cross-origin worker itself: a same-origin Worker
is a hard browser rule, so it wraps the absolute worker URL in a one-line blob module
(import "<url>") and starts that. The docs read as if WORKER_URL had to be configured for a
CDN — it does not, as long as the files answer with CORS. Do not "fix" this with a self-hosted worker
copy; it only recreates the folder-unit problem the bundle already solves.
The docs name jsDelivr first and unpkg as the alternative: unpkg is the better-known name from a decade of tutorials, but jsDelivr is multi-CDN with documented failover, a purge API and mainland China coverage. Both are verified, so the choice is reversible for anyone copying a snippet.
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 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
/v<x.y.z>/ path here would mean materialising every supported version at site-build time, because
an Azure SWA deploy replaces the whole site — which is exactly the service jsDelivr performs for
free.
Break one of these and something breaks quietly, usually only in production builds.
- The element must not import values from
@map0/core. Onlyimport type(erased at build time) plus value imports from@map0/schema. One value import pulls MapLibre into the entry chunk and undoes the 31 KB page tier. The engine arrives throughloadEngine(). - 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.tsis the only place that loads ogc-client; proj4 goes throughloadProj4(). - Everything rendered as HTML is sanitised. Popup templates come from CMS fields, GetFeatureInfo
HTML comes from third-party services.
popup.tsowns DOMPurify; property values are escaped when the template is rendered and the result is sanitised before it touches the DOM. - The config is data, never code. No
eval, no expression language. The template syntax is a closed mustache subset on purpose. - Config order is z-order, top of the list first (F2.1). The layer tree is the single source of
truth; groups exist only in the TOC. MapLibre appends every new layer on top, so core mounts the
tree bottom-up (
drawOrder()) — and sequentially, never in parallel, or the stack is a race. Everything that reads the stack (TOC, legend, hit test) usesstackOrder(): runtime-added layers first, then the configured tree. - Overlays survive style changes.
map.setStyle()drops runtime sources and layers, so the basemap manager re-injects them viatransformStyle— including the highlight source and the current projection. Anything added to the map outside an adapter needs to be in that set. - Services that cannot serve EPSG:3857 are out of scope (D-02), and the client should say so rather than render an empty layer.
MapLibre v6 ships ESM split into maplibre-gl.mjs (main thread), maplibre-gl-shared.mjs and
maplibre-gl-worker.mjs; the worker is spawned from a URL relative to the bundle and imports the
shared half itself. Consequences:
- Bundling MapLibre ships the shared half twice (once inside our bundle, once as the file the worker
needs). We keep
maplibre-glexternal and copy all three files in verbatim — next to the entry in the librarydist, next to the hashed chunks in the site'sassets/(scripts/maplibre-dist.mjs). - In Vite dev,
optimizeDeps.exclude: ["maplibre-gl"]is required. Pre-bundling rewrites the worker URL into.vite/depswhere it 404s, and the worker then dies silently: raster layers still render on the main thread while vector tiles and GeoJSON never appear. - The same failure in a static build, and better hidden: the worker URL is assembled at runtime,
so nothing emits the file for you. If it is missing and the host answers unknown paths with
index.html— Vite's defaultappType: "spa", or an SPA rewrite on the CDN — the worker is handed HTML with status 200, so there is not even a 404 in the network panel. HenceappType: "mpa"for the demo site and nonavigationFallbackinstaticwebapp.config.json. - Diagnostic:
page.workers()in Playwright, or the Sources → Threads panel in DevTools. Cheapest check after a deploy: request a path that cannot exist and confirm it answers 404, not 200.
It stays false while anything is pending, and load waits for the first tile batch, which a slow WMS
can stretch to many seconds. The UI dismisses its loading veil on the first rendered frame after
style.load, with a 12 s fallback.
Real style documents contain relative URLs that MapLibre v6 rejects or ignores:
sprite: "../sprites/sprite"→ rejected outright ("must be absolute").- TileJSON with
tiles: ["tile/{z}/{y}/{x}.pbf"]→ not resolved, no tile requests, no error.
core/src/basemaps.ts absolutises sprite, glyphs and tiles against the style URL without
the URL constructor, because that would percent-encode the {z} placeholders. TileJSON sources are
fetched and inlined, which also brings the zoom range along and prevents overzoom 404 storms. The
vector overlay adapter uses the same helper — the bug was fixed twice because it appeared in two
places.
Rendering is Web Mercator plus globe, full stop. The coordinate readout reprojects numbers for humans with proj4 (display only) — that is a different problem from reprojecting tiles for a GPU, and conflating them is how you end up promising something the engine cannot do.
basemap.at advertises five tile hosts, four of which (maps1–maps4.wien.gv.at) no longer resolve
in DNS. The WMTS adapter probes one real tile per candidate host in parallel and takes the first that
answers — any HTTP response counts, including 404, since that still proves the host is reachable. The
winning origin is cached per capabilities URL so further layers skip the probe.
A silent empty map is the worst possible failure mode; prefer a wasted request over that.
The same probe taught a second lesson: it decides which host is alive, so it must race one candidate per origin. Racing every resource link of one host lets the network settle which format the layer uses — a coin flip between runs.
GeoServer/GWC lists application/vnd.mapbox-vector-tile first for every vector-backed layer,
before image/png. Taking resourceLinks[0] therefore fed protobuf into a MapLibre raster
source: tiles arrive with HTTP 200, image decoding fails, the layer reports an error and shows
nothing. A raster source needs an explicit image-format filter (isRasterTileFormat) —
InfoFormat/UTFGrid/TileJSON links are equally tempting and equally undecodable.
Vector tiles from such a service are a vector layer, not a wmts one. GWC even publishes a ready
TileJSON per style (…/wmts/rest/<layer>/<style>/tilejson/pbf) whose tiles template is already in
XYZ order, so it drops straight into a vector layer with sourceLayer from vector_layers[].id.
GWC restricts a regional layer to per-level row/column ranges (TileMatrixSetLimits) and answers
anything outside with 400 TileOutOfRange — a whole-Austria view of an Austria-sized layer
requested column 32 at z6 where the range starts at 33, and every viewport tile outside the extent
was an error. QGIS is unaffected because it honours the limits; MapLibre has no per-level concept,
but doesn't need one: for a quadtree pyramid the footprint of the deepest level's limits lies
within every coarser level's footprint, so one source bounds derived from it keeps the tile cover
equal to the server's range at every level (verified against all 25 published levels). Two traps
en route:
- The coverage edges sit exactly on tile boundaries, and capabilities coordinates are print-rounded
(GeoServer emits
2.003750834E7, ~3 mm off). Without an inset, MapLibre's floor/ceil cover tips over into a neighbouring out-of-range tile at the deepest level;coverageFromLimitspulls the edges in by 1 % of a tile (min 2 cm). TopLeftCorneraxis order is a coin flip. One and the same GeoServer document publishesEPSG:900913as "x y" andWebMercatorQuadas "y x" (URN axis rules). Read as-is, the clip degenerates to a corner of the world and the layer silently disappears — found live, not in review. A real top-left keeps the matrix inside the world square, so the adapter tries both readings and keeps the one that fits; anything still degenerate is discarded in favour of the WGS84 bbox, because a wrong clip (empty map) is far worse than no clip (wasted 400s).
The zoom range rides along for free: levels missing from the limits are TileOutOfRange territory
too (GWC zoomStart/zoomStop), so they bound the source's minzoom/maxzoom.
Verified while building the demos, useful as a reference for what "typical" looks like:
data.wien.gv.at/daten/geosendsAccess-Control-Allow-Origin: *(WMS, WFS).- GetFeatureInfo offers
application/json— prefer it, fall back to HTML/GML. - WFS with
outputFormat=json&srsName=EPSG:4326gives GeoJSON ageojsonlayer can use directly. - 375 layers in one capabilities document, so the add-layer dialog needs a filter box.
@camptocamp/ogc-client is the only maintained capabilities parser for this stack, and it works well — but:
- Never call
enableFallbackWithoutWorker(). It looks like a defensive fallback; in practice its handler module can be dropped by dep optimizers and requests then queue forever with no error. The library's default worker is base64-inlined and bundler-safe. - Its optional
ol/proj4peers are only needed bygetOpenLayersTileGrid(), which map0 never calls. They are aliased tosrc/stubs/ol-stub.tsso the build does not pull in OpenLayers. - CRS inheritance is not resolved for nested layers, so a child layer can appear to offer no EPSG:3857 when its parent declares it. The add-layer dialog therefore warns instead of blocking, and treats an empty CRS list as "probably fine".
- In Vite dev it belongs in
optimizeDeps.include; otherwise the first dialog open triggers dep discovery and reloads the page mid-flow.
The basemap pipeline inlines TileJSON sources (see 05 §field notes: relative tiles templates).
The first version copied every TileJSON key into the source object — and a TileJSON legally
carries metadata (tilejson, name, center, description, …) that the MapLibre style spec does
not allow on a source. MapLibre v6 then refuses the entire style ("unknown property"), the map
never fires style.load, and the viewer sits behind its loading veil forever. basemap.at's
TileJSONs happened to carry nothing offensive, so this slept until the ICGC contextmaps styles
(COG demo) hit it. Fix in inlineTileJsonSources: copy only keys valid for the source type —
minzoom/maxzoom/bounds/attribution plus scheme (vector/raster) or encoding
(raster-dem, where scheme itself is illegal).
- The adapter calls
getCogMetadata()beforeaddSource: it fails fast with a clear message (unreachable file, or "projection EPSG:xxxxx is not supported" — the protocol never reprojects), and itsbboxfeeds "zoom to layer". The result is cached inside the library, so the protocol handler does not fetch the header twice. - The protocol answers the source
urlwith a TileJSON (tiles/bounds/maxzoom from the file header); alwaystileSize: 256. - 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'ssetColorFunction(), which is keyed by the plain COG URL, globally — and overrides any#color/#demfragment 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/offsetare 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 exactvaluematches 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@3ships no licence text in its tarball → fallback entry inscripts/build-npm.mjs(same mechanism as pmtiles). - Hillshade has no opacity paint property.
hillshade: …renders#dem(Terrain-RGB) into araster-demsource with ahillshadelayer — and the TOC opacity slider maps tohillshade-exaggeration(base = the configured exaggeration) because that is the only usable intensity knob. - 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 originaldata.bev.gv.atfile is EPSG:31287 and its server sends noAccess-Control-Allow-Origin, so it cannot be used in place. Recipe:gdalwarp -of COG -t_srs EPSG:3857 -te <mercator bounds> -tr 36 36 -r bilinear -ot Int16 -dstnodata -32768 -co COMPRESS=DEFLATE -co PREDICTOR=2 /vsicurl/<url> out.tif. Pick a nodata outside the data range — a COG with no declared nodata treats 0 as transparent (protocol behaviour), which on a DEM would punch holes at sea level.
The proj4 → stub alias (added for ogc-client's optional peer) predated the coordinate readout. In
the dev server the alias does not apply, so coordinates worked; in the built bundle proj4 resolved to
a no-op stub and the readout silently produced nothing. Two lessons: package-level build config is
invisible to the dev server, and an alias is a blunt instrument — scope it to the module that needs
it, or drop it when a real dependency arrives.
- Rollup writes
output.pathsverbatim into every chunk that imports the external module, so chunks must sit in the same directory as the entry. Achunks/subfolder made./maplibre-gl.mjsresolve one level too deep — and the standalone demo looked fine because it silently fell back to the dev sources. Demo pages that test the bundle must not have a fallback. - The entry is a facade. Because the lazy chunks import our shared code, Rollup emits
map0.jsas a small re-export shim in front of the real chunk. That is normal for a code-split library; it costs one extra request, which amodulepreloadhint can remove if it ever matters. - Chunk names come from module names, which is why
popup-*.jsandadd-layer-dialog-*.jsare readable.scripts/check-size.mjsuses that plus the sourcemaps to classify tiers. - Fat optional dependencies are worth stubbing. jsPDF dynamically imports html2canvas and canvg
for
doc.html()and SVG rasterisation, which map0 never calls — ~110 KB gzip that shipped in the folder and would only ever be dead weight. Aliased tosrc/stubs/pdf-stub.ts, same trick as ogc-client's OpenLayers peers. Note what is not stubbed: jsPDF also pulls dompurify, and our popup renderer uses that one for real. - A detached
<a download>is unreliable for large blobs. The PNG export had worked that way for months; the multi-megabyte PDF silently did nothing until the anchor was appended to the document before clicking. Append, click, remove. - Two copies of Lit produce a console warning and subtle breakage. It happens when a page loads
both the built bundle and the sources — see
data-client="bundle"in the standalone demo. - Never re-export an external binding from a non-entry chunk.
export { Popup } from "maplibre-gl"incore/index.tslooked harmless and worked in dev; in the build Rollup emitted the re-export but dropped the corresponding import, so the chunk referenced an 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.
Everything the site serves is either content-hashed (/assets/*, cached for a year and
immutable) or no-cache HTML — except the standalone demo's bundle. /standalone/map0.js keeps
its name across releases and imports content-hashed chunks that a deploy replaces, deleting the
old ones. That combination breaks on its own: a browser holding the previous map0.js asks for
chunk names that are now 404, and the demo shows no map at all. Its own note ("run pnpm demo:standalone") then points at the wrong cause.
staticwebapp.config.json routes /standalone/map0.js and map0-ssr.js to no-cache and leaves
the hashed chunks around them on a short max-age. Deliberately not immutable for the chunks,
even though hashed names would allow it: Azure's route patterns are matched top-down, and an entry
that slipped past its exact-path rule would inherit a year of caching — a permanently stale bundle
for returning visitors, fixable only by renaming the file. Short everywhere fails soft; the
bandwidth is one demo page's worth.
That header does not reach the browser, though, and the reason is worth knowing before debugging cache behaviour on this site: Cloudflare's Browser Cache TTL (4 h at the time of writing) acts as a floor on everything Cloudflare itself caches, which it decides by file extension. Measured on one deploy:
| URL | origin sends | client receives | cf-cache-status |
|---|---|---|---|
/assets/<hash>.js |
1 year, immutable |
unchanged | HIT |
/standalone/map0.js |
no-cache |
max-age=14400 |
REVALIDATED |
| a page (HTML) | no-cache |
unchanged | DYNAMIC |
/standalone/*.mjs, /schema/v1.json |
max-age=3600 / no-cache |
unchanged | DYNAMIC |
So TTLs below four hours get raised (3600 and no-cache alike), longer ones pass, and anything
Cloudflare does not cache — HTML, .mjs, .json — passes untouched. cf-cache-status: REVALIDATED is the useful signal that the origin's no-cache did arrive: the edge revalidates on
every request, so it never serves a stale bundle. Only the browser-facing header is rewritten.
Resolved outside this repo: Cloudflare's Browser Cache TTL is now set to Respect Existing
Headers (Caching → Configuration), so the origin is authoritative everywhere — /assets/* keeps
its year, the entry keeps its no-cache. Changing that setting does not reach objects Cloudflare
has already stored: the old copy kept serving its old header until it was purged (MISS, then
REVALIDATED — the steady state to expect here). If the setting is ever put back to a fixed TTL,
this breaks again silently, and the repo-side answer would be a versioned path
(/standalone/<version>/map0.js), immutable by construction and independent of any dashboard.
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 <html lang="de">, 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.
- Focus lives in the shadow root.
document.activeElementreturns the host element, never what is focused inside. Read(node.getRootNode() as ShadowRoot).activeElement. - Constructable stylesheets carry MapLibre's CSS, adopted at runtime so it stays out of the entry chunk. Order matters: it must be prepended, or our component styles no longer override it.
- Container queries, not media queries. A viewer can sit in a narrow CMS column on a wide screen; layout decisions must come from the element's own width.
IntersectionObservernever fires fordisplay: none, which is exactly right: a map in a hidden tab stays unloaded until shown, avoiding MapLibre initialising into a zero-size container.- Legends must not invent symbols. A GeoJSON layer without a style gets fill + line + circle renderers speculatively (the geometry is unknown until the data arrives), so swatches are derived only from a style the author actually wrote.
- Map text needs glyphs, DOM markers do not. A symbol layer can only draw text if the basemap
style provides a glyph source — over a raster basemap it silently renders nothing. Measurement
labels are therefore
Markerelements styled with the component's CSS tokens. - A modal map interaction must claim the pointer. While measuring, a click sets a vertex and
must not also open a feature-info popup underneath it:
core.setInteractionLocked(true)suspends feature info and hover for as long as the interaction owns the map. - Window-level keyboard handlers need
composedPath(). Inside a shadow rootevent.targetis retargeted to the host, so a handler cannot tell whether the user is typing in the search box. Without that check, Backspace while searching deletes a measurement vertex.
| Layer | Tool | Covers |
|---|---|---|
| Pure logic | Vitest (packages/**/*.test.ts) |
URL building, templates, merge rules, projections, permalink codec |
| Whole demos | e2e/verify-demos.mjs (Playwright) |
every demo page renders, key interactions, console must stay clean |
| Bundle | scripts/check-size.mjs |
tier sizes, page-tier budget |
Notes on the E2E script:
- It launches system Chrome (
channel: "chrome"), falling back to the bundled shell. The bundled headless shell software-renders WebGL, which is too slow for a ~780-layer vector style — symptoms look like "the map never finishes loading". - Console errors fail a demo. Known-broken services get an explicit allow-list (
IGNORED_CONSOLE), so accepting noise is a visible decision rather than a habit. - Screenshots land in
e2e/shots/(git-ignored). They are for looking at, not yet compared automatically — visual regression is still open (N9). - The demos run against live services, so a failing run is worth repeating once before believing it.
packages/schema types, validation (JSON-path errors), defaults, extends merging
→ no dependencies, no DOM, no MapLibre
packages/core the engine: map creation, basemap manager, source adapters, feature info,
highlight, print composition, permalink codec, coordinates, i18n
→ imports MapLibre; never imports the UI
packages/ui <map0-viewer>, panels, dialogs, popup rendering, focus trap, styles
→ imports core lazily (see invariant 1)
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)
Adapters implement one interface (core/src/adapters/types.ts): mount, visibility, opacity, bounds,
legend, optional featureInfo. Adding a source type means adding an adapter and registering it —
that registry is the intended extension point.
| Symptom | Look here first |
|---|---|
| Raster renders, vector tiles and GeoJSON do not | MapLibre worker died — dep pre-bundling in dev, missing worker file or an SPA fallback in a build (§3.1) |
| Layer added, no tile requests at all | relative tile templates in TileJSON (§3.3) |
Works in pnpm dev, broken in the bundle |
package-level build config: aliases, externals (§4.4) |
| Works in the bundle, broken in dev | optimizeDeps include/exclude (§3.1, §4.3) |
| Capabilities request hangs forever, no error | enableFallbackWithoutWorker (§4.3) |
| Map stays empty, tiles 404 or DNS-fail | dead mirror host in capabilities (§4.1) |
| Overlays vanish after switching basemap | something added outside an adapter, missing from transformStyle (invariant 6) |
| Console: "Multiple versions of Lit loaded" | page loads both bundle and sources (§5) |
| Page tier over budget in CI | a value import from @map0/core crept into the element (invariant 1) |