From 4b61d64f20bf6e7e4c64579cd623c8975fdba95f Mon Sep 17 00:00:00 2001 From: mcronin Date: Tue, 15 Sep 2026 22:00:47 -0400 Subject: [PATCH] feat: render and time a real basemap in the browser gate (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #63's browser gate proved the routing to a basemap archive and not the picture, because there was no archive to render: #53 publishes one and is blocked on #52, which is Phase 3. This builds a PMTiles v3 archive from arithmetic instead, emits it into the harness build, and renders from it. What that immediately found is a defect in shipping code. MapLibre v6 parses every vector tile in a Web Worker and locates it with `new URL('./maplibre-gl-worker.mjs', import.meta.url)`, which under a bundler resolves against the hashed chunk MapLibre landed in — a file no bundler emits, because the expression is built from a variable. The script 404s, the Worker is constructed anyway, every tile-parse message goes into it unanswered, and a production map fetches all its tiles and draws none of them with no error anywhere. `maplibre.ts` now calls `setWorkerUrl` with a `?worker&url` import; `?url` alone would not do, because the dist worker imports its sibling `maplibre-gl-shared.mjs`. The cold-load measurement #63's eighth criterion asks for is taken and printed on every run, and is explicitly only the client-side floor: served from the loopback interface, it carries none of the hosting latency that criterion is pointed at. That half still belongs to #53. - apps/web/browser/pmtiles-fixture.ts: the archive, written from the v3 spec and MVT 2.1; no OpenStreetMap data, so no attribution attaches to it - apps/web/browser/harness.ts: reads the drawing buffer each frame and times the first painted tile, with `preserveDrawingBuffer` forced by the harness - apps/web/browser/map.browser.spec.ts: the render, its control, and the number - apps/web/vite.browser.config.ts: emits the archive at build time; never committed, and at a path that leaves the "#53 has published nothing" tripwire asserting its 404 - apps/web/vitest.config.ts: includes `browser/**/*.test.ts`, so the fixture's own unit test is run by something Refs #63 Refs #53 Signed-off-by: Matthew Cronin Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016X4EwacF1KuXDPhXzunifS Signed-off-by: mcronin --- CLAUDE.md | 53 ++- apps/web/browser/harness.ts | 363 ++++++++++++++- apps/web/browser/map.browser.spec.ts | 232 +++++++++- apps/web/browser/pmtiles-fixture.test.ts | 368 +++++++++++++++ apps/web/browser/pmtiles-fixture.ts | 552 +++++++++++++++++++++++ apps/web/src/map/maplibre.ts | 38 ++ apps/web/src/map/port.ts | 39 +- apps/web/vite.browser.config.ts | 33 +- apps/web/vitest.config.ts | 16 +- docs/architecture.md | 23 +- 10 files changed, 1679 insertions(+), 38 deletions(-) create mode 100644 apps/web/browser/pmtiles-fixture.test.ts create mode 100644 apps/web/browser/pmtiles-fixture.ts diff --git a/CLAUDE.md b/CLAUDE.md index d50f311e..4b973621 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,10 @@ apps/ AGPL-3.0-or-later, without exception web/ browser client — the Phase 1 product (#48-#51) browser/ the browser gate (#63) — the one place a real browser runs; a harness page driving the map adapter, and its Playwright - spec. See §4a and §4f. Since #111 it also holds + spec, and since #63's cold-load criterion the PMTiles + archive that page renders: built from arithmetic, emitted + at build time, never committed, and carrying no + OpenStreetMap data. See §4a and §4f. Since #111 it also holds capture.html, which is NOT a gate: it is the tool a person opens with a trainer in front of them, built by the same second Vite config so it can never ship. Since @@ -1210,8 +1213,9 @@ browser runs**. | File | What it is | |---|---| -| `index.html`, `harness.ts` | a page that builds a map through the **real** `map/maplibre.ts` and the **real** `basemapStyle`, and publishes what happened on `window.__oylHarness` | +| `index.html`, `harness.ts` | a page that builds a map through the **real** `map/maplibre.ts` and the **real** `basemapStyle`, and publishes what happened on `window.__oylHarness` — and, since the fixture archive, what reached the drawing buffer and when, on `window.__oylMapLoad` | | `map.browser.spec.ts` | the Playwright spec that drives it | +| `pmtiles-fixture.ts` | a PMTiles v3 archive written from arithmetic, so the gate has a basemap to render. Emitted into `browser/dist` by `vite.browser.config.ts`, never committed, and carrying no OpenStreetMap data. The **only** file in this directory with a Vitest test beside it (`pmtiles-fixture.test.ts`), which is why `apps/web/vitest.config.ts` includes `browser/**/*.test.ts` | | `game.html`, `game-harness.ts` | since #91, the same idea for the renderer: a page that builds a scene through the **real** `game/three-renderer.ts` and the **real** `terrain.ts` | | `game.browser.spec.ts` | its spec — the renderer constructs against a live context, the geometry is one a driver accepts, and a frame reaches the drawing buffer (read back with `readPixels`, because `render` not throwing is a weaker claim) | | `hud.html`, `hud-harness.tsx` | since #266, the ride HUD: the **real** `game/hud/HudPanel.tsx` under the **real** `design/theme.css`, in the **real** `oyl-shell` → `oyl-main` → `oyl-game` chain `AppShell` and `GameView` give it, with every field populated and all three of #259's settled outcome words. A **`.tsx`**, and the only React in this directory | @@ -1332,9 +1336,45 @@ builds the bind address and the polled URL from one `HOST` constant so they cann shape happens. A green browser gate on a developer's machine says nothing about which addresses that machine has. -**What it does not prove: that tiles render.** There is no archive to render from. The spec asserts -the 404 rather than tolerating it, so the day #53 lands that test goes red — which is the right -moment for somebody to come back and replace it with one that asserts tiles actually drew. +⚠️ **This section used to end "What it does not prove: that tiles render — there is no archive to +render from", and that is no longer true.** A reviewer who remembers it is reading the old file. The +gate now builds its own archive: `apps/web/browser/pmtiles-fixture.ts` writes a **PMTiles v3** +archive from arithmetic — a 127-byte header, a one-entry root directory whose run length spans every +tile id from z0 to z14, and one Mapbox Vector Tile of three rectangles and a line — and +`vite.browser.config.ts` emits it into `browser/dist` at build time. It is **not committed**: +`browser/dist` is already gitignored and already pruned by `scripts/check-repo-rules.sh`, and a +binary in the tree would need an `.spdx-exempt` entry §3a would refuse it. It contains **no +OpenStreetMap data**, so no ODbL attribution obligation attaches to a build artefact. + +⚠️ **It is served at `/basemap-fixture.pmtiles`, NOT at `/basemap.pmtiles`.** The "there is still no +published archive" test asserts the 404 at the latter, and it stays: moving the fixture there would +make a tripwire permanently green against a file of our own, which is the shape of a test that +cannot fail. The day #53 publishes an archive, that test still goes red. + +**What this bought, immediately, was a defect no gate here could see.** `maplibre.ts` could not load +MapLibre's **tile-parsing worker** under a bundler at all — `defaultWorkerUrl()` resolves +`./maplibre-gl-worker.mjs` against the hashed chunk and nothing emits that file, so a production map +fetched every tile and drew none of them, silently. Every symptom was an absence: no throw, a live GL +context, the right origin, the right range request, `registrations === 1`. The fix is +`setWorkerUrl(…'?worker&url')` and `docs/architecture.md` §"The map dependencies" records why +`?worker&url` rather than `?url`. Reverting it turns **exactly** the two fixture tests red and leaves +every pre-existing browser test green — which is the measurement, not the argument. + +⚠️ **What the fixture still does NOT prove**, and criterion 8 is only **partly** discharged by it. +The archive is served from the loopback interface, so the cold-load figure carries **none** of the +hosting latency the criterion is pointed at — ADR 0010 D-1 quotes Protomaps' warning that R2 is +*"known to have higher latency (500 ms or higher)"*, and that term is removed here by construction. +The tile is uncompressed and 98 bytes; a real one is gzipped and thousands of times larger. What is +measured is the **client-side floor**, printed by the spec on every run, and the harness that will +take the hosted measurement the day #53 lands. Read `pmtiles-fixture.ts`'s header before quoting the +number. + +⚠️ **The paint is read back with `preserveDrawingBuffer` forced on by the harness**, by patching +`HTMLCanvasElement.prototype.getContext` before the map is built — `maplibre.ts` must not set it, and +an earlier attempt to borrow MapLibre's own animation frame instead **did not work**: v6 reaches its +renderer through `browser.frameAsync`, which resolves a promise, so anything chained synchronously +onto the frame callback reads a buffer that has already been presented and cleared. Every sample came +back `#000000`. The cost is in the measured number and is identical on both pages. ### 4g. The dependency-licence gate @@ -2207,6 +2247,9 @@ top of an issue **supersedes its body**. | Which origins the map is allowed to reach, and why the style is built rather than fetched | `apps/web/src/map/basemap.ts` §`styleOrigins`, [ADR 0010](docs/adr/0010-map-tiles-and-routing.md) D-1 | | Why MapLibre is behind a seam, and what that seam does not prove | `apps/web/src/map/port.ts` | | What a real browser checks that jsdom cannot, and why it shares one CI job | §4f, `apps/web/browser/`, `.github/workflows/rules.yml` | +| Why a built map fetches every tile and draws none of them without one extra line | `apps/web/src/map/maplibre.ts` §`setWorkerUrl`, [`docs/architecture.md`](docs/architecture.md) §"The map dependencies" | +| Where the basemap the browser gate renders comes from, and what it deliberately does not contain | `apps/web/browser/pmtiles-fixture.ts`, §4f | +| What a cold load costs the client, what that number leaves out, and who owns the rest | `apps/web/browser/map.browser.spec.ts` §"a real archive, rendered and timed", [#53](https://github.com/openzigs/onyourleft/issues/53) | | Whether a dependency's licence is allowed where it lands, and which licences are ruled on | §4g, [ADR 0015](docs/adr/0015-dependency-licences.md), `scripts/check-dependency-licences.mjs` §`POLICY` | | Where the basemap URL is configured, and why nothing is configured today | `.env.example` §`VITE_BASEMAP_PMTILES_URL`, [#53](https://github.com/openzigs/onyourleft/issues/53) | | Which segment-matching approach was chosen, what it measured, and which of its numbers no longer describe the shipped code | [`docs/spikes/0001-segment-matching.md`](docs/spikes/0001-segment-matching.md) and its 2026-09-08 retirement note | diff --git a/apps/web/browser/harness.ts b/apps/web/browser/harness.ts index ab5f6ec6..fe7ff90f 100644 --- a/apps/web/browser/harness.ts +++ b/apps/web/browser/harness.ts @@ -35,15 +35,33 @@ * question. Said out loud because a reader who spots the missing call should * know it was a decision. * - * ## What this page deliberately does not prove + * ## What this page proves about tiles, and what it still does not * - * That tiles render. There is no published archive (#53), so the request for - * one returns 404 and the map reports a source error. That is expected, it is - * asserted, and it is the honest limit of what can be checked before #53 lands - * — see `map.browser.spec.ts`. + * ⚠️ This heading used to read *"what this page deliberately does not prove: + * that tiles render"*, and that is no longer the whole story — a reviewer who + * remembers it is reading the old file. With `?archive=` pointed at the + * fixture archive `vite.browser.config.ts` emits, tiles **do** render here, and + * {@link watchForPaint} reads them off the drawing buffer and times the first + * one. Finding a defect is what motivated it: MapLibre could not load its + * tile-parsing worker under a bundler at all, and no gate in this repository + * could see that until something asked it to parse a tile. + * + * With no `?archive=` the default is still `/basemap.pmtiles`, which 404s + * because #53 has published nothing — and the spec asserts that 404 rather than + * tolerating it, so the day an archive exists the gate says so. + * + * What is still unproven is anything about a **hosted** archive: latency, + * compression, CDN behaviour, a tile that is not 98 bytes of synthetic + * geometry. `pmtiles-fixture.ts` sets out that limit in full. */ -import { basemapStyle, OSM_ATTRIBUTION, type BasemapConfig } from '../src/map/basemap'; +import { + basemapStyle, + BASEMAP_SOURCE_ID, + OSM_ATTRIBUTION, + type BasemapConfig, + type BasemapStyle, +} from '../src/map/basemap'; import { mapLibrePort } from '../src/map/maplibre'; import { trackBounds, trackFeature, type TrackGeometry } from '../src/map/track'; @@ -74,10 +92,331 @@ export interface HarnessResult { readonly errors: readonly string[]; } +/** One archive range request, as the browser's own clock recorded it. */ +export interface ArchiveRequestTiming { + /** How far into the page's life the request started, in milliseconds. */ + readonly startedMs: number; + /** How long it took, request to last byte. */ + readonly durationMs: number; + /** Bytes over the wire, `0` when the browser served it from cache. */ + readonly transferredBytes: number; +} + +/** + * What the page observed about the basemap reaching the screen. + * + * Published **after** {@link HarnessResult}, and separately, because none of it + * is true when `create` returns: a tile has to be fetched, decoded and drawn + * first. The spec waits on this object rather than on a duration. + */ +export interface MapLoadResult { + /** A colour that can only have come from a tile reached the drawing buffer. */ + readonly painted: boolean; + /** + * From immediately before `renderer.create` to the frame that painted. + * + * The **client's** share of a cold load: a range request for the header and + * root directory, a range request for the tile, the MVT decode, and the first + * draw. `undefined` when nothing painted. + */ + readonly firstPaintMs: number | undefined; + /** + * The same instant, measured from the start of navigation. + * + * Larger than {@link firstPaintMs} by whatever it cost to fetch and evaluate + * the page and the map engine — which is part of a real cold load and is the + * half a lazy `import()` of `maplibre.ts` is meant to keep off most visits. + */ + readonly firstPaintSinceNavigationMs: number | undefined; + /** Frames the probe ran on, so a run that never rendered is distinguishable. */ + readonly frames: number; + /** The drawing buffer's size, so "all black" and "no canvas" are told apart. */ + readonly canvasSize: string; + /** How long the page waited before giving up on a paint. */ + readonly deadlineMs: number; + /** Colours only a tile can produce, read out of the real style. */ + readonly basemapColours: readonly string[]; + /** The style's background colour, which needs no tile at all. */ + readonly backgroundColour: string | undefined; + /** What the probe points actually read, `#rrggbb`, most recent frame. */ + readonly samples: readonly string[]; + /** Range requests for the archive, from the Resource Timing buffer. */ + readonly archiveRequests: readonly ArchiveRequestTiming[]; +} + declare global { interface Window { __oylHarness?: HarnessResult; + __oylMapLoad?: MapLoadResult; + } +} + +/** `#rrggbb` to a triple. Returns `undefined` for anything else in the style. */ +function parseHex(colour: unknown): readonly [number, number, number] | undefined { + if (typeof colour !== 'string') { + return undefined; } + const match = /^#([0-9a-f]{6})$/i.exec(colour.trim()); + if (match?.[1] === undefined) { + return undefined; + } + const value = Number.parseInt(match[1], 16); + return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; +} + +/** + * The colours only a tile can put on screen, read out of the style we ship. + * + * Derived rather than written down. A hard-coded `#eeece7` here would be a + * second copy of `basemapStyle`'s cartography, and the failure mode of a second + * copy is the worst one available to this gate: change the style's earth colour + * and the probe looks for a colour nothing paints, `painted` goes false, and + * the gate reports "no tile reached the screen" about a map that is working + * perfectly. + * + * The **background** layer is excluded on purpose and reported separately: it + * has no `source`, so it paints before any network request and its presence + * says nothing about tiles. That distinction is the whole of the control case. + */ +function paletteOf(style: BasemapStyle): { + basemap: readonly (readonly [number, number, number])[]; + background: string | undefined; + basemapNames: readonly string[]; +} { + const basemap: (readonly [number, number, number])[] = []; + const basemapNames: string[] = []; + let background: string | undefined; + for (const layer of style.layers) { + const paint = layer.paint ?? {}; + if (layer.source === BASEMAP_SOURCE_ID) { + for (const key of ['fill-color', 'line-color']) { + const parsed = parseHex(paint[key]); + if (parsed !== undefined) { + basemap.push(parsed); + basemapNames.push(String(paint[key])); + } + } + } else if (typeof paint['background-color'] === 'string') { + background = paint['background-color']; + } + } + return { basemap, background, basemapNames }; +} + +/** `#rrggbb`, so a sample reads the same way the style writes a colour. */ +function hex(channels: readonly [number, number, number]): string { + return `#${channels.map((c) => c.toString(16).padStart(2, '0')).join('')}`; +} + +/** + * How far a sampled channel may sit from a declared one and still count. + * + * Small, because these are opaque flat fills and the canvas is 8-bit sRGB — + * there is no blending to allow for at the probe points. It is not zero because + * a rasteriser is entitled to round, and a gate that fails on a rounding + * difference is a gate that gets deleted. + */ +const COLOUR_TOLERANCE = 4; + +function matches( + sample: readonly [number, number, number], + target: readonly [number, number, number], +): boolean { + return sample.every( + (channel, index) => Math.abs(channel - (target[index] ?? 0)) <= COLOUR_TOLERANCE, + ); +} + +/** Where along each strip the probe reads. Clear of the centre, where the track is. */ +const PROBE_COLUMNS = [0.05, 0.25, 0.5, 0.75, 0.95] as const; +/** Two rows rather than one, so a strip that lands on the track is not the only evidence. */ +const PROBE_ROWS = [0.05, 0.75] as const; + +/** How long the page waits for a tile before publishing "nothing painted". */ +function paintDeadlineMs(): number { + const configured = new URL(window.location.href).searchParams.get('paintDeadline'); + const parsed = configured === null ? Number.NaN : Number(configured); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 5000; +} + +/** Archive range requests, from the browser's own Resource Timing buffer. */ +function archiveTimings(archive: string): ArchiveRequestTiming[] { + const wanted = archive.split('/').pop() ?? archive; + return performance + .getEntriesByType('resource') + .filter((entry) => entry.name.includes(wanted)) + .map((entry) => { + const resource = entry as PerformanceResourceTiming; + return { + startedMs: resource.startTime, + durationMs: resource.duration, + transferredBytes: resource.transferSize, + }; + }); +} + +/** + * Force `preserveDrawingBuffer` on, for this page only. + * + * ⚠️ **The first version of this harness did not do this, and it did not + * work** — recorded here because the mechanism that failed is a plausible one + * that a reader would otherwise try again. Without the flag a WebGL drawing + * buffer is readable only between the engine drawing it and the compositor + * taking it, which is a window inside one animation frame. `game-harness.ts` + * hits that window by owning the render loop; nothing here can, because + * `MapView` deliberately does not expose the map object. The attempt was to + * borrow MapLibre's own frame by wrapping `window.requestAnimationFrame` + * before the map existed, so the probe ran immediately after its callback. + * Every sample came back `#000000`: MapLibre v6 reaches its renderer through + * `browser.frameAsync`, which resolves a **promise** from the frame callback, + * so the draw happens in a microtask *after* anything chained onto that + * callback synchronously. The probe was reading a buffer that had already been + * presented and cleared. + * + * `maplibre.ts` must not set this: it is a real per-frame cost, paid by every + * rider so that a test can read a pixel, and a shipping option that exists for + * a harness is the shape CLAUDE.md calls the test dictating shipping code. So + * the harness forces it into the context attributes instead, by intercepting + * the one call MapLibre makes to get a context. + * + * ⚠️ **It is in the measured number.** Keeping the buffer costs frame time. It + * costs it identically on the fixture page and the control page, so the two + * remain comparable, and it makes the cold-load figure a slight over-estimate + * rather than an under-estimate — which is the right direction for a number + * whose job is to be a floor. + */ +function preserveTheDrawingBuffer(): void { + // Unbound on purpose, and re-bound with `.call` below — the same prototype + // patch, and the same disable with the same reason, as + // `game-harness.ts`'s `countingGpuResources`: the original has to be held + // separately from any instance, because every canvas in the page shares it + // and the one we care about does not exist yet. + // eslint-disable-next-line @typescript-eslint/unbound-method + const original = HTMLCanvasElement.prototype.getContext; + // One cast at the boundary, and only one. `getContext` is overloaded five + // ways; re-declaring those overloads faithfully here would be far more + // surface than the one-line behaviour being wrapped, and every one of them + // would have to be kept in step with the DOM lib. + const patched = function (this: HTMLCanvasElement, type: string, attributes?: unknown): unknown { + const call = original as ( + this: HTMLCanvasElement, + type: string, + attributes?: unknown, + ) => unknown; + if (type === 'webgl' || type === 'webgl2') { + return call.call(this, type, { + ...(attributes as Record | undefined), + preserveDrawingBuffer: true, + }); + } + return call.call(this, type, attributes); + }; + HTMLCanvasElement.prototype.getContext = patched as typeof HTMLCanvasElement.prototype.getContext; +} + +/** + * Watch the drawing buffer until a tile paints, and time it. + * + * With the buffer preserved (see above) the probe no longer has to be inside + * the engine's frame: it runs on its own animation-frame loop and reads the + * most recently rendered picture, whenever that was rendered. + * + * ⚠️ **Which sets the resolution of the number.** The paint is detected on the + * first of *this* loop's frames at which tile colour is already present, so the + * figure is late by up to one frame — about 16 ms at 60 Hz, and more on a + * loaded runner. That is stated rather than hidden: it is comfortably below the + * hundreds of milliseconds ADR 0010 D-1 warns a hosted archive can cost, which + * is what the measurement is for, and it is far too coarse to support any + * claim finer than that. + * + * ## What it costs the number it measures + * + * Two `readPixels` of a one-pixel-tall strip per frame — a few kilobytes, and a + * pipeline flush each. That is real and it is **in** the reported figure. It is + * also identical on both pages, so the fixture and the control are comparable + * even though neither is a clean-room frame time. + */ +function watchForPaint(container: HTMLDivElement, style: BasemapStyle, archive: string): void { + const palette = paletteOf(style); + const deadline = paintDeadlineMs(); + const createdAt = performance.now(); + let frames = 0; + let samples: string[] = []; + let canvasSize = '0x0'; + let done = false; + + const publish = (painted: boolean, at: number | undefined): void => { + if (done) { + return; + } + done = true; + window.__oylMapLoad = { + painted, + firstPaintMs: at === undefined ? undefined : at - createdAt, + firstPaintSinceNavigationMs: at, + frames, + canvasSize, + deadlineMs: deadline, + basemapColours: palette.basemapNames, + backgroundColour: palette.background, + samples, + archiveRequests: archiveTimings(archive), + }; + }; + + /** Read the probe points. Returns whether a tile colour was among them. */ + const probe = (): boolean => { + const canvas = container.querySelector('canvas'); + const gl = canvas?.getContext('webgl2') ?? canvas?.getContext('webgl') ?? null; + if (canvas === null || gl === null || canvas.width === 0 || canvas.height === 0) { + return false; + } + frames += 1; + canvasSize = `${String(canvas.width)}x${String(canvas.height)}`; + // MapLibre binds its own framebuffers during a render and does not + // guarantee what is bound when one ends. Reading the default one is the + // only thing that answers "what would the rider see"; MapLibre re-binds + // whatever it needs at the start of its next frame, so leaving this bound + // costs nothing. + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + const read: string[] = []; + // `readPixels` has its origin at the bottom left, where CSS has it at the + // top left. Nothing here depends on which row is which — two rows exist so + // that one of them landing on the ride trace cannot be the whole evidence. + for (const row of PROBE_ROWS) { + const y = Math.min(canvas.height - 1, Math.max(0, Math.round(canvas.height * row))); + const strip = new Uint8Array(canvas.width * 4); + gl.readPixels(0, y, canvas.width, 1, gl.RGBA, gl.UNSIGNED_BYTE, strip); + for (const column of PROBE_COLUMNS) { + const x = Math.min(canvas.width - 1, Math.max(0, Math.round(canvas.width * column))); + read.push(hex([strip[x * 4] ?? 0, strip[x * 4 + 1] ?? 0, strip[x * 4 + 2] ?? 0])); + } + } + samples = read; + return read.some((sample) => { + const channels = parseHex(sample); + return channels !== undefined && palette.basemap.some((target) => matches(channels, target)); + }); + }; + + const tick = (): void => { + if (done) { + return; + } + if (probe()) { + publish(true, performance.now()); + return; + } + window.requestAnimationFrame(tick); + }; + window.requestAnimationFrame(tick); + + window.setTimeout(() => { + // One last read before giving up, so the control case reports what was + // actually on screen rather than whatever the loop happened to see last. + probe(); + publish(false, undefined); + }, deadline); } /** A short two-point line, so the map has geometry to fit itself to. */ @@ -120,10 +459,18 @@ function run(): void { throw new Error('the harness page is missing its #map container'); } - const config: BasemapConfig = { archiveUrl: archiveUrl(), attribution: OSM_ATTRIBUTION }; + const archive = archiveUrl(); + const config: BasemapConfig = { archiveUrl: archive, attribution: OSM_ATTRIBUTION }; + const style = basemapStyle(config); const errors: string[] = []; let created = false; + // Both before the map exists, and that ordering is load-bearing for the + // first of them: MapLibre asks for its context inside the constructor, and a + // context's attributes cannot be changed afterwards. + preserveTheDrawingBuffer(); + watchForPaint(container, style, archive); + try { // Registered before the map is created, exactly as `MapPanel.tsx` does it. // ⚠️ Not optional and not a formality: `renderer.create` does **not** @@ -135,7 +482,7 @@ function run(): void { if (registerProtocol()) { mapLibrePort.protocol.ensure(); } - const view = mapLibrePort.renderer.create(container, { style: basemapStyle(config) }); + const view = mapLibrePort.renderer.create(container, { style }); created = true; view.setTrack(trackFeature(TRACK), trackBounds(TRACK)); } catch (error: unknown) { diff --git a/apps/web/browser/map.browser.spec.ts b/apps/web/browser/map.browser.spec.ts index 69601597..894ac36e 100644 --- a/apps/web/browser/map.browser.spec.ts +++ b/apps/web/browser/map.browser.spec.ts @@ -30,20 +30,58 @@ * nowhere. Neither is the other's superset, both are load-bearing, and * deleting either leaves a real hole. * - * ⚠️ **This does not prove tiles render.** There is no published archive (#53), - * so the archive request 404s. What is asserted is that the request was made, - * to the right host, through the protocol handler — the routing, not the - * picture. The limit is named here rather than left for a reader to infer. + * ⚠️ **This file used to say it did not prove tiles render, and that is no + * longer true of the whole of it** — a reviewer who remembers the paragraph + * *"there is no published archive, so the archive request 404s… the routing, + * not the picture"* is reading the old file. It still describes the first + * `describe` block exactly: those tests drive `/basemap.pmtiles`, which does + * 404, and the 404 is asserted rather than tolerated so that the day #53 + * publishes an archive the gate goes red and somebody comes back. + * + * The **second** block is what changed. `pmtiles-fixture.ts` builds a PMTiles + * v3 archive from arithmetic, `vite.browser.config.ts` emits it into the + * harness build at a **different** path, and the gate renders from it — so + * "a tile decoded and reached the drawing buffer" is now checked, and the + * client's share of a cold load is measured. What is still not checked is + * anything about a *hosted* archive: see that block's own note, and #53. */ import { expect, test } from '@playwright/test'; import { HARNESS_ORIGIN } from '../playwright.config'; -import type { HarnessResult } from './harness'; +import type { HarnessResult, MapLoadResult } from './harness'; +import { FIXTURE_ARCHIVE_FILE } from './pmtiles-fixture'; /** Where the harness asks for its archive. Same origin as the page. */ const ARCHIVE_PATH = '/basemap.pmtiles'; +/** + * The archive `vite.browser.config.ts` emits, on the same origin as the page. + * + * ⚠️ A **different path** from {@link ARCHIVE_PATH}, deliberately, and this is + * the decision most worth reading before changing anything below. The tests + * above assert that `/basemap.pmtiles` **404s**, because #53 has not published + * one, and they exist so the day it does the gate goes red and somebody comes + * back. Serving the fixture at that path would discharge that tripwire by + * making it permanently green against a file of our own — which is the same + * shape as a test that cannot fail. So the fixture is additive: it proves what + * a real engine does with a real archive, and it leaves the "there is still no + * published basemap" assertion exactly as it was. + */ +const FIXTURE_PATH = `/${FIXTURE_ARCHIVE_FILE}`; +const FIXTURE_URL = `${HARNESS_ORIGIN}${FIXTURE_PATH}`; + +/** + * How long the control page waits before reporting that nothing painted. + * + * A negative assertion needs a time box, and this one is not picked by feel: + * the fixture test below requires its own first paint to land inside **half** + * of it. So a machine slow enough to make this box too short fails the positive + * test first, with a number in the message, rather than turning the control + * into a quiet false pass. + */ +const CONTROL_DEADLINE_MS = 3000; + /** * Hosts a request is permitted to reach. * @@ -78,6 +116,22 @@ async function harnessResult( return page.evaluate(() => window.__oylHarness); } +/** + * What the page saw reach the drawing buffer, once it has stopped watching. + * + * Published either on the frame a tile painted or at the page's own deadline, + * so waiting on it is waiting on an event in both directions — including the + * one where the answer is "nothing". + */ +async function mapLoad(page: import('@playwright/test').Page): Promise { + await page.waitForFunction(() => window.__oylMapLoad !== undefined); + const result = await page.evaluate(() => window.__oylMapLoad); + if (result === undefined) { + throw new Error('the harness published no map-load result'); + } + return result; +} + /** * Wait until the archive has actually been asked for. * @@ -198,3 +252,171 @@ test.describe('the map engine in a real browser', () => { } }); }); + +/** + * What a real engine does with a real archive — and what it costs. + * + * Everything above this line was written against an archive that does not + * exist, and says so. Two of #63's criteria could not be reached from there: + * + * - **Criterion 1**, whose first half is *"a recorded activity with GPS renders + * its trace over the basemap"*. The trace is asserted in three places already; + * *over the basemap* had no basemap to be over. + * - **Criterion 8**, *"Cold-load behaviour is measured and recorded in the PR: + * time to first painted tile on a cold cache"* — which needs a tile to paint. + * + * `pmtiles-fixture.ts` supplies one: a PMTiles v3 archive built from arithmetic, + * emitted into the harness build, carrying no OpenStreetMap data. It is served + * from the harness origin, so it is **localhost latency**, and that is the whole + * of what separates this from the measurement #63 actually asks for. + * + * ⚠️ **What this measurement is NOT.** ADR 0010 D-1 and #53 both quote + * Protomaps' own warning that R2 is *"known to have higher latency (500 ms or + * higher)"*, and criterion 8's last sentence points straight at it. A loopback + * server removes that term by construction. What is left is the **floor** — the + * part of the number that would remain if the host were infinitely fast — and a + * decomposition: the archive's own range requests are reported beside the total, + * so the hosted figure, when there is a host, can be read as latency rather than + * as an unexplained sum. Criterion 8 is therefore **partly** discharged and the + * remainder still belongs to #53. + * + * ⚠️ And it is a headless Chromium on a software rasteriser in CI, against an + * **uncompressed** 98-byte tile. A real basemap tile is gzipped and thousands of + * times larger. Nothing here says what a phone on mobile data will see. + */ +test.describe('a real archive, rendered and timed', () => { + test('paints the archive’s own tiles under the ride trace — criterion 1, and 7', async ({ + page, + }) => { + // The archive is named in the URL rather than in the code, which is + // criterion 7 — *"the basemap URL is configuration, and a test proves the + // map renders against a second archive URL without a code change"* — + // executed by a real engine rather than by a style comparison. Nothing in + // `apps/web/src` mentions this file. + const seen = watch(page); + await page.goto(`/?archive=${encodeURIComponent(FIXTURE_URL)}`); + const load = await mapLoad(page); + + expect( + load.painted, + `no basemap colour reached the drawing buffer. Looked for ${load.basemapColours.join(', ')}; ` + + `read ${load.samples.join(', ')} over ${String(load.frames)} frames`, + ).toBe(true); + // The probe ran on real frames rather than reporting a paint from a single + // lucky read. Nought frames with `painted` true would mean the mechanism is + // reporting something other than what it claims. + expect(load.frames).toBeGreaterThan(0); + + // The colours found are the style's own, not "something other than the + // background". A fill that drew in the wrong colour is a tile that decoded + // into the wrong layer, and that is worth telling apart from no tile. + const basemapSamples = load.samples.filter((sample) => load.basemapColours.includes(sample)); + expect(basemapSamples.length).toBeGreaterThan(0); + + // It came off the wire, from the configured archive, through the protocol + // handler — and nowhere else. Criterion 3 again, this time on a page where + // tiles actually flowed rather than one where the source 404d. + expect(load.archiveRequests.length).toBeGreaterThan(0); + expect(seen.requests.filter((url) => url.startsWith(FIXTURE_URL)).length).toBeGreaterThan(0); + const foreign = seen.requests.filter( + (url) => + !url.startsWith('blob:') && + !url.startsWith('data:') && + new URL(url).origin !== HARNESS_ORIGIN, + ); + expect(foreign, `requests left the configured origin: ${foreign.join(', ')}`).toEqual([]); + + // And the box the control case below rests on. @see CONTROL_DEADLINE_MS + expect(load.firstPaintMs).toBeDefined(); + expect(load.firstPaintMs ?? Number.POSITIVE_INFINITY).toBeLessThan(CONTROL_DEADLINE_MS / 2); + }); + + test('paints no tile colour at all when the archive is missing — the control', async ({ + page, + }) => { + // ⚠️ **The half that makes the test above mean something.** Without it, a + // probe reading a cleared buffer, a canvas that never existed, a palette + // derived from the wrong layers and a genuinely working map would all be + // indistinguishable from each other — three of those four would report + // "painted" as false, and nobody would be looking. Here the archive 404s, + // so the ONLY thing that may be on screen is the style's background and the + // ride's own trace, and the assertion is that the tile colours are absent + // rather than that anything in particular is present. + await page.goto(`/?paintDeadline=${String(CONTROL_DEADLINE_MS)}`); + const load = await mapLoad(page); + + expect(load.painted).toBe(false); + expect(load.firstPaintMs).toBeUndefined(); + // The probe did run: a page that rendered no frames would report no paint + // for a reason that has nothing to do with the archive. + expect(load.frames).toBeGreaterThan(0); + // Something was on screen, and it was the background the style declares. + // Without this the "no tile colour" assertion is satisfied by a blank + // canvas, an unloaded stylesheet, or a readback of all zeros. + expect(load.samples).toContain(load.backgroundColour); + for (const sample of load.samples) { + expect( + load.basemapColours, + `a tile colour appeared with no archive: ${sample}`, + ).not.toContain(sample); + } + }); + + test('records what a cold load costs the client — criterion 8, as far as it goes', async ({ + page, + }, testInfo) => { + // A fresh Playwright context per test means an empty HTTP cache, which is + // what "cold" means here. `transferredBytes` is reported for each request + // so that a run where the browser served the archive from cache after all + // is visible in the number rather than hidden inside it. + await page.goto(`/?archive=${encodeURIComponent(FIXTURE_URL)}`); + const load = await mapLoad(page); + + expect(load.painted).toBe(true); + const clientMs = load.firstPaintMs ?? Number.NaN; + const pageMs = load.firstPaintSinceNavigationMs ?? Number.NaN; + expect(Number.isFinite(clientMs)).toBe(true); + expect(Number.isFinite(pageMs)).toBe(true); + expect(clientMs).toBeGreaterThan(0); + // The page cannot have painted before it started loading, and the client's + // share cannot exceed the whole. Two sanity bounds rather than a budget: + // ⚠️ **no wall-clock threshold is asserted and one must not be added.** An + // absolute millisecond gate on a shared runner is flaky, a flaky gate gets + // disabled, and a disabled gate is how a performance claim survives with + // nobody re-running it — the same reasoning `game.browser.spec.ts` records + // for the shading cost. + expect(pageMs).toBeGreaterThanOrEqual(clientMs); + + // ⚠️ **More than two, and the reason is worth knowing before anybody reads + // the hosted number.** The header and the root directory arrive together in + // one prefetch of the first 16 KB — which is *why* the PMTiles spec requires + // the root directory to live there — and both are cached for the life of the + // page. The **tiles** are not: `SharedPromiseCache` caches headers and + // directories only, so every visible tile is its own range request even when + // several of them resolve, as they do here, to the same bytes. A first draft + // of this comment asserted "two round trips, no more"; the run reported + // three, because three tiles covered the viewport. On a high-latency store + // that multiplier is the cold-load cost, and it belongs in #53's reading of + // whatever number it measures. + expect(load.archiveRequests.length).toBeGreaterThanOrEqual(1); + const wire = load.archiveRequests.reduce((total, request) => total + request.durationMs, 0); + + const measured = + `first painted tile ${clientMs.toFixed(1)} ms after the map was created, ` + + `${pageMs.toFixed(1)} ms after navigation started; ` + + `${String(load.archiveRequests.length)} archive range request(s) costing ` + + `${wire.toFixed(1)} ms in total; ` + + `${String(load.frames)} frames probed; ` + + 'served from the loopback interface, so this is the client-side floor and ' + + 'carries none of the hosting latency #53 owns'; + testInfo.annotations.push({ + type: 'cold load — time to first painted tile', + description: measured, + }); + // Printed as well as annotated: an annotation reaches the JSON and HTML + // reports and not the log, and the log is the only artefact anybody reads on + // a green run. #63's criterion 8 says "recorded in the PR", and a number + // nobody can see is not recorded. + console.log(`cold load — ${measured}`); + }); +}); diff --git a/apps/web/browser/pmtiles-fixture.test.ts b/apps/web/browser/pmtiles-fixture.test.ts new file mode 100644 index 00000000..d03a5e87 --- /dev/null +++ b/apps/web/browser/pmtiles-fixture.test.ts @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * The fixture archive, read back through the decoder that will actually read it. + * + * ## Why this is not asserted against expected bytes + * + * A byte-for-byte expectation over a generated binary is the cheapest test to + * write here and the least useful. It goes red for a reordering that changes + * nothing, it goes green for a header field that is well-formed and wrong, and + * it never once exercises the question that matters — **can the consumer read + * it?** CLAUDE.md §5's defect shape is *a write that reports success while the + * read cannot see it*, and its instruction is to *"assert by reading back + * through the same path a real consumer uses"*. So the container is read back + * through `pmtiles`' own `PMTiles` class, which is the class `maplibre.ts` + * hands to `addProtocol`. + * + * ⚠️ **What this substitutes, and what it therefore cannot see.** The + * {@link MemorySource} below replaces `FetchSource`, so everything about the + * **transport** is outside this file: whether the harness server answers a + * `Range` request at all, whether it returns `206` or a whole-file `200` that + * `FetchSource` rejects, whether the `.pmtiles` extension is served. Those are + * real failure modes and `map.browser.spec.ts` is where they are covered, over + * a real HTTP server and a real engine. Neither file is the other's superset. + * + * ## And why there is a second decoder in here + * + * `PMTiles` hands back a tile body and has no opinion about what is in it — it + * is a container reader. Nothing in this repository decodes MVT, so + * {@link readTile} does, in about forty lines of protobuf, written from the + * specification rather than from the encoder it checks. That independence is + * the point: a reader derived from the writer agrees with it by construction, + * including where both are wrong. The same reason `identity-verifier.test.ts` + * calls `crypto.subtle.verify` directly instead of routing through + * `web-crypto.ts`. + */ + +import { PMTiles, type RangeResponse, type Source, zxyToTileId } from 'pmtiles'; +import { describe, expect, it } from 'vitest'; + +import { + basemapStyle, + BASEMAP_SOURCE_ID, + OSM_ATTRIBUTION, + type BasemapStyle, +} from '../src/map/basemap'; +import { + buildFixtureArchive, + FIXTURE_MAX_ZOOM, + FIXTURE_SOURCE_LAYERS, + fixtureTile, + pyramidTileCount, +} from './pmtiles-fixture'; + +/** The archive in memory, so a range request is a slice. @see the module note. */ +class MemorySource implements Source { + constructor(private readonly bytes: Uint8Array) {} + + getKey(): string { + return 'memory://fixture'; + } + + getBytes(offset: number, length: number): Promise { + // Clamped the way a well-behaved HTTP server clamps a range that runs past + // the end of the file — which is what the first request does, since it asks + // for 16,384 bytes of an archive far smaller than that. + const end = Math.min(offset + length, this.bytes.length); + const slice = this.bytes.slice(offset, end); + return Promise.resolve({ data: slice.buffer }); + } +} + +interface ProtobufCursor { + readonly bytes: Uint8Array; + position: number; +} + +function readVarint(cursor: ProtobufCursor): number { + let value = 0; + let shift = 1; + for (;;) { + const byte = cursor.bytes[cursor.position]; + if (byte === undefined) { + throw new RangeError('a varint ran off the end of the buffer'); + } + cursor.position += 1; + value += (byte & 0x7f) * shift; + if ((byte & 0x80) === 0) { + return value; + } + shift *= 0x80; + } +} + +/** Every `(field, bytes)` pair in a protobuf message, wire types 0 and 2 only. */ +function readFields(bytes: Uint8Array): { field: number; varint?: number; bytes?: Uint8Array }[] { + const cursor: ProtobufCursor = { bytes, position: 0 }; + const fields: { field: number; varint?: number; bytes?: Uint8Array }[] = []; + while (cursor.position < bytes.length) { + const key = readVarint(cursor); + const field = Math.floor(key / 8); + const wireType = key % 8; + if (wireType === 0) { + fields.push({ field, varint: readVarint(cursor) }); + } else if (wireType === 2) { + const length = readVarint(cursor); + fields.push({ field, bytes: bytes.subarray(cursor.position, cursor.position + length) }); + cursor.position += length; + } else { + throw new RangeError(`the fixture should emit no wire type ${String(wireType)}`); + } + } + return fields; +} + +interface DecodedFeature { + readonly type: number; + readonly commands: readonly number[]; +} + +interface DecodedLayer { + readonly name: string; + readonly extent: number; + readonly version: number; + readonly features: readonly DecodedFeature[]; +} + +/** A vector tile, as far as this fixture uses the format. */ +function readTile(bytes: Uint8Array): DecodedLayer[] { + const layers: DecodedLayer[] = []; + for (const entry of readFields(bytes)) { + if (entry.field !== 3 || entry.bytes === undefined) { + continue; + } + let name = ''; + let extent = 0; + let version = 0; + const features: DecodedFeature[] = []; + for (const field of readFields(entry.bytes)) { + if (field.field === 1 && field.bytes !== undefined) { + name = new TextDecoder().decode(field.bytes); + } else if (field.field === 2 && field.bytes !== undefined) { + let type = 0; + const commands: number[] = []; + for (const part of readFields(field.bytes)) { + if (part.field === 3 && part.varint !== undefined) { + type = part.varint; + } else if (part.field === 4 && part.bytes !== undefined) { + const cursor: ProtobufCursor = { bytes: part.bytes, position: 0 }; + while (cursor.position < part.bytes.length) { + commands.push(readVarint(cursor)); + } + } + } + features.push({ type, commands }); + } else if (field.field === 5 && field.varint !== undefined) { + extent = field.varint; + } else if (field.field === 15 && field.varint !== undefined) { + version = field.varint; + } + } + layers.push({ name, extent, version, features }); + } + return layers; +} + +/** Undo zigzag, so a parameter integer reads as the signed delta it encodes. */ +function unzigzag(value: number): number { + return (value >>> 1) ^ -(value & 1); +} + +/** The vertices a ring or line walks, from its command integers. */ +function walk(commands: readonly number[]): { x: number; y: number }[] { + const points: { x: number; y: number }[] = []; + let x = 0; + let y = 0; + let index = 0; + while (index < commands.length) { + const command = commands[index] ?? 0; + index += 1; + const id = command % 8; + const count = Math.floor(command / 8); + if (id === 7) { + continue; + } + for (let step = 0; step < count; step += 1) { + x += unzigzag(commands[index] ?? 0); + y += unzigzag(commands[index + 1] ?? 0); + index += 2; + points.push({ x, y }); + } + } + return points; +} + +/** The signed area of a ring, MVT 2.1 §4.3.3.3's surveyor's formula. */ +function signedArea(points: readonly { x: number; y: number }[]): number { + let total = 0; + for (let index = 0; index < points.length; index += 1) { + const here = points[index]; + const next = points[(index + 1) % points.length]; + if (here === undefined || next === undefined) { + continue; + } + total += here.x * next.y - next.x * here.y; + } + return total / 2; +} + +/** Every `source-layer` the real style reads from the basemap vector source. */ +function styleSourceLayers(style: BasemapStyle): string[] { + return style.layers + .filter((layer) => layer.source === BASEMAP_SOURCE_ID) + .map((layer) => layer['source-layer']) + .filter((name): name is string => name !== undefined) + .sort(); +} + +const FIXTURE_URL = 'https://tiles.example.test/basemap.pmtiles'; + +describe('the fixture archive', () => { + it('is readable by the decoder the client actually ships', async () => { + const archive = new PMTiles(new MemorySource(buildFixtureArchive())); + const header = await archive.getHeader(); + + expect(header.specVersion).toBe(3); + // `TileType.Mvt`. A wrong value here is the difference between MapLibre + // parsing the body and MapLibre handing it to an image decoder. + expect(header.tileType).toBe(1); + expect(header.minZoom).toBe(0); + expect(header.maxZoom).toBe(FIXTURE_MAX_ZOOM); + expect(header.numTileEntries).toBe(1); + expect(header.numTileContents).toBe(1); + expect(header.numAddressedTiles).toBe(pyramidTileCount(FIXTURE_MAX_ZOOM)); + }); + + it('declares the bounds Web Mercator actually has', async () => { + const archive = new PMTiles(new MemorySource(buildFixtureArchive())); + const header = await archive.getHeader(); + + // ⚠️ Asserted because `pmtiles`' own MapLibre adapter logs + // `Bounds of PMTiles archive … are not valid` and carries on when + // `minLon >= maxLon`, which is a defect that reaches the browser as a + // console line nobody reads and a map that clips everything away. + expect(header.minLon).toBeLessThan(header.maxLon); + expect(header.minLat).toBeLessThan(header.maxLat); + expect(header.minLon).toBeCloseTo(-180, 5); + expect(header.maxLon).toBeCloseTo(180, 5); + expect(header.maxLat).toBeCloseTo(85.0511287, 5); + }); + + it('carries its metadata section, and claims no OpenStreetMap data in it', async () => { + const archive = new PMTiles(new MemorySource(buildFixtureArchive())); + const metadata = (await archive.getMetadata()) as { + vector_layers: { id: string }[]; + attribution?: string; + }; + + expect(metadata.vector_layers.map((layer) => layer.id)).toEqual([...FIXTURE_SOURCE_LAYERS]); + // The fixture is three rectangles and a line. An attribution field here + // would be a false licence notice inside a build artefact, and the + // product's own OSM credit — asserted in `MapPanel.test.tsx` — is a + // separate thing that this must not be mistaken for. + expect(metadata.attribution).toBeUndefined(); + expect(OSM_ATTRIBUTION).not.toContain('fixture'); + }); + + it('answers for every tile from zoom zero to its declared maximum', async () => { + const archive = new PMTiles(new MemorySource(buildFixtureArchive())); + const expected = fixtureTile(); + + // The corners of the pyramid, not a sample of the middle: the first tile, + // the last tile at the deepest zoom, and one in between. A run length that + // is short by any amount fails on the last of these, and a run length that + // is one too long is invisible — which is why `numAddressedTiles` is + // asserted arithmetically above rather than only probed here. + const probes: [number, number, number][] = [ + [0, 0, 0], + [1, 1, 0], + [7, 63, 42], + [FIXTURE_MAX_ZOOM, 2 ** FIXTURE_MAX_ZOOM - 1, 2 ** FIXTURE_MAX_ZOOM - 1], + ]; + for (const [z, x, y] of probes) { + const response = await archive.getZxy(z, x, y); + expect(response, `no tile at ${String(z)}/${String(x)}/${String(y)}`).toBeDefined(); + expect(new Uint8Array(response?.data ?? new ArrayBuffer(0))).toEqual(expected); + } + + // And the entry does not run past the zoom the header claims. A tile the + // header says is absent must actually be absent, or `maxZoom` is a lie the + // renderer will act on. + expect(await archive.getZxy(FIXTURE_MAX_ZOOM + 1, 0, 0)).toBeUndefined(); + }); + + it('spans exactly the pyramid, with no tile id left over', () => { + // The arithmetic behind the run length, checked against the library's own + // id function rather than against itself: the id of the last tile at the + // deepest zoom is one less than the count. + const last = zxyToTileId(FIXTURE_MAX_ZOOM, 2 ** FIXTURE_MAX_ZOOM - 1, 0); + expect(last).toBe(pyramidTileCount(FIXTURE_MAX_ZOOM) - 1); + expect(pyramidTileCount(0)).toBe(1); + expect(pyramidTileCount(1)).toBe(5); + }); +}); + +describe('the fixture tile', () => { + it('fills every source layer the real style reads, and no other', () => { + const style = basemapStyle({ archiveUrl: FIXTURE_URL, attribution: OSM_ATTRIBUTION }); + + // The drift guard. A `source-layer` added to `basemapStyle` that the + // fixture does not carry is a style layer the browser gate can never see + // paint — the map still renders, the pixel assertion still passes on some + // other layer's colour, and the new one is covered by nothing at all. + expect([...FIXTURE_SOURCE_LAYERS].sort()).toEqual(styleSourceLayers(style)); + }); + + it('decodes as a version 2 vector tile with the extent it declares', () => { + const layers = readTile(fixtureTile()); + + expect(layers.map((layer) => layer.name)).toEqual([...FIXTURE_SOURCE_LAYERS]); + for (const layer of layers) { + expect(layer.version, `${layer.name} version`).toBe(2); + expect(layer.extent, `${layer.name} extent`).toBe(4096); + expect(layer.features.length).toBe(1); + } + }); + + it('draws its polygons the way round that makes them exterior rings', () => { + const layers = readTile(fixtureTile()); + const polygons = layers.filter((layer) => layer.features[0]?.type === 3); + + expect(polygons.map((layer) => layer.name)).toEqual(['earth', 'water']); + for (const layer of polygons) { + const points = walk(layer.features[0]?.commands ?? []); + expect(points.length, `${layer.name} vertices`).toBe(4); + // ⚠️ The whole reason this assertion exists. MVT 2.1 §4.3.3.3 reads the + // sign of this number as the ring's role: positive is an exterior ring, + // negative is a hole. Reverse the vertex order and the archive is still + // well-formed, `PMTiles` still hands the tile over, and MapLibre draws + // nothing at all — a failure whose only symptom is a blank map. + expect(signedArea(points), `${layer.name} winding`).toBeGreaterThan(0); + } + }); + + it('covers the whole tile with earth, past the edge, so tiles do not seam', () => { + const earth = readTile(fixtureTile()).find((layer) => layer.name === 'earth'); + const points = walk(earth?.features[0]?.commands ?? []); + + // Every corner strictly outside `[0, 4096]`, which is what makes adjacent + // tiles overlap instead of meeting on an antialiased shared edge. + for (const point of points) { + expect(point.x < 0 || point.x > 4096).toBe(true); + expect(point.y < 0 || point.y > 4096).toBe(true); + } + }); + + it('carries one feature that is not a polygon', () => { + const roads = readTile(fixtureTile()).find((layer) => layer.name === 'roads'); + + // The encoder is otherwise proved by rectangles alone, which would leave + // `LineTo` without `ClosePath` — a different command sequence — untested. + expect(roads?.features[0]?.type).toBe(2); + expect(walk(roads?.features[0]?.commands ?? []).length).toBe(2); + // And it is not closed: a `ClosePath` here would make MapLibre read a + // degenerate ring rather than a line. + expect(roads?.features[0]?.commands).not.toContain(15); + }); +}); diff --git a/apps/web/browser/pmtiles-fixture.ts b/apps/web/browser/pmtiles-fixture.ts new file mode 100644 index 00000000..cf6ff6c7 --- /dev/null +++ b/apps/web/browser/pmtiles-fixture.ts @@ -0,0 +1,552 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * A PMTiles v3 archive, built from nothing, so the browser gate has a basemap. + * + * ## Why this exists + * + * #63's eighth acceptance criterion is *"Cold-load behaviour is measured and + * recorded in the PR: time to first painted tile on a cold cache."* Two of that + * sentence's words could not be reached before this file: there was **no + * archive**, so no tile was ever painted and there was nothing to time. + * `map.browser.spec.ts` said so in its own words — the archive request 404s, so + * the gate asserts the routing rather than the picture, and *"the day #53 lands, + * that test goes red — which is the right moment for somebody to come back and + * replace it with one that asserts tiles actually drew."* + * + * This is that moment arriving from the other direction. #53 publishes a + * **real** archive of the whole planet to a **real** host, and it is blocked on + * #52, which is Phase 3. What #53 does *not* own is the client's own cost: + * fetching a 127-byte header, a root directory and a tile over a range request, + * decoding an MVT, and handing it to a GL pipeline. That half is ours, it is + * measurable today, and measuring it is what lets the hosted number — when it + * exists — be read as *latency* rather than as an unexplained total. + * + * ⚠️ **So this does NOT discharge criterion 8, and must not be reported as + * doing so.** Serving from the loopback interface removes the one term the + * criterion is pointed at: ADR 0010 D-1 and #53 both quote Protomaps' warning + * that R2 is *"known to have higher latency (500 ms or higher)"*, and a + * localhost measurement is silent about that by construction. What it gives is + * the **floor** — the part of the number that would remain if the host were + * infinitely fast — and a harness that is already in place to take the hosted + * measurement the day there is a host. + * + * ## Why an encoder rather than a committed binary + * + * `packages/fit`'s corpus is the precedent in this repository, and it is + * committed **and** generated from a deterministic generator, so that "the + * fixture" and "what the generator writes" cannot drift. Here the archive is + * not committed at all: it is emitted into `browser/dist` at build time by + * `vite.browser.config.ts`, which is gitignored and pruned by + * `scripts/check-repo-rules.sh` already. A binary in the tree would need an + * `.spdx-exempt` entry it does not qualify for (§3a: the one reason is + * third-party generator output) and would be a file nobody can read in review. + * + * ## What is in the archive, and what is deliberately not + * + * **One tile.** Every tile id from zoom 0 to {@link FIXTURE_MAX_ZOOM} resolves + * to the same bytes, through a single directory entry whose run length spans + * the lot — which is PMTiles' own deduplication doing exactly what it is for. + * That is what makes the fixture independent of where the map happens to be + * looking: `fitBounds` on the harness track picks a zoom, MapLibre asks for + * whichever tiles cover the viewport, and every one of them exists. + * + * **No OpenStreetMap data of any kind.** Three rectangles and a line, drawn + * from arithmetic in this file. That matters beyond tidiness: ODbL attribution + * attaches to a Produced Work derived from OSM, and a fixture that carried a + * scrap of real coastline would quietly make this repository's build output a + * derivative. It carries none, so no attribution obligation attaches to the + * archive. (The **product's** attribution is a separate criterion, asserted in + * `MapPanel.test.tsx` and `map.a11y.test.tsx`, and nothing here touches it.) + * + * **No compression.** `Compression.None` for both the internal directories and + * the tile bodies, which the spec admits and the decoder takes as a pass-through + * — so this file needs no gzip implementation and the gate needs no + * `DecompressionStream`. ⚠️ It also means the measurement below is of an + * **uncompressed** tile. A real basemap tile is gzipped, so the hosted number + * will carry a decompression term this one does not; said out loud because a + * reader comparing the two otherwise has no way to know. + * + * ## Provenance + * + * Every field offset, enum value and varint column below comes from the PMTiles + * v3 specification (`protomaps/PMTiles:spec/v3/spec.md`), which is **public + * domain / CC0** — #63's own context table records that, and it is the reason + * this can be written from the document rather than from anyone's source. The + * vector-tile encoding comes from the Mapbox Vector Tile specification 2.1, + * which is CC-BY-3.0 for the prose and whose wire format is a protobuf schema. + * Nothing was copied from an implementation; §6's rule about prior art applies + * here as everywhere. + */ + +/** The file the harness build emits, and the path the gate asks for. */ +export const FIXTURE_ARCHIVE_FILE = 'basemap-fixture.pmtiles'; + +/** + * The deepest zoom the archive claims to hold. + * + * It claims *every* tile to this depth and holds one, so the number's only job + * is to be at least as deep as any view the gate opens. MapLibre clamps a + * request to a source's `maxzoom` and overzooms beyond it, so a view zoomed + * further in than this still draws — it draws a stretched copy of the same + * tile, which is the correct behaviour for a source that has run out of detail + * and is exactly what a real archive does past z15. + */ +export const FIXTURE_MAX_ZOOM = 14; + +/** + * The source layers the fixture fills. + * + * ⚠️ **Asserted against the real style, in `pmtiles-fixture.test.ts`, rather + * than kept in step by hand.** A `source-layer` in `basemapStyle` that this + * archive does not carry is a layer the browser gate can never see paint — the + * map would render, the assertion would pass on some *other* layer's pixels, + * and the new one would be covered by nothing. The test requires the two sets + * to be equal, so adding a layer to the style fails here until the fixture + * carries it. + * + * Written here rather than imported from `basemap.ts` because this module is + * bundled into `vite.browser.config.ts`, which Vite loads in Node — and + * `basemap.ts` names `import.meta.env`. Keeping this file dependency-free is + * what lets one module serve the build, the unit suite and the gate. + */ +export const FIXTURE_SOURCE_LAYERS = ['earth', 'water', 'roads'] as const; + +/** + * The MVT coordinate extent, in tile units. + * + * 4096 is the value every published basemap uses and the one MapLibre assumes + * when a layer omits the field. It is written into the tile regardless: a + * default that is relied upon rather than stated is a defect waiting for the + * default to change. + */ +const EXTENT = 4096; + +/** + * How far each polygon is drawn past the tile edge, in tile units. + * + * ⚠️ **Not decoration.** A fill that stops exactly on the tile boundary meets + * its neighbour's fill on a shared edge, and the rasteriser antialiases both + * sides of it — which leaves a one-pixel seam of whatever is underneath, at + * every tile join, on a page whose whole assertion is "what colour is this + * pixel". Drawing past the edge makes the two overlap. Real vector tiles carry + * a buffer for the same reason. + */ +const EDGE_BUFFER = 64; + +/** + * MVT geometry types, from the Mapbox Vector Tile specification 2.1 §4.3.4. + * + * `POINT` is 1 and is absent here because the fixture has no point feature to + * declare it for — an unused constant is a compile error under `noUnusedLocals`, + * and a name that documents a format this file does not write is a worse thing + * to keep than a gap in a numbering. + */ +const GEOMETRY_LINESTRING = 2; +const GEOMETRY_POLYGON = 3; + +/** PMTiles v3 header, §"Header". Fixed size, so every offset below is absolute. */ +const HEADER_BYTES = 127; + +/** `Compression.None`, PMTiles v3 §"Compression". */ +const COMPRESSION_NONE = 1; + +/** `TileType.Mvt`, PMTiles v3 §"Tile Type". */ +const TILE_TYPE_MVT = 1; + +/** Coordinates are stored as signed 32-bit integers scaled by 1e7. */ +const COORDINATE_SCALE = 10_000_000; + +/** + * The latitude Web Mercator stops at. + * + * `atan(sinh(π))` in degrees. Written to the precision the header's `int32` + * can carry rather than rounded to 85.05, so the archive's declared bounds are + * the projection's own rather than an approximation of them — MapLibre reads + * these into the source's `bounds` and clips against them. + */ +const MERCATOR_LATITUDE_LIMIT = 85.0511287; + +/** A point in a tile's own coordinate space. */ +interface TilePoint { + readonly x: number; + readonly y: number; +} + +/** One feature, already reduced to its command/parameter integers. */ +interface EncodedFeature { + readonly type: number; + readonly geometry: readonly number[]; +} + +/** + * Zigzag encoding, MVT 2.1 §4.3.2. + * + * `(n << 1) ^ (n >> 31)` — the arithmetic shift is what maps a negative to an + * odd positive, so a parameter integer is always a plain varint. + */ +function zigzag(value: number): number { + return (value << 1) ^ (value >> 31); +} + +/** + * A base-128 varint. + * + * ⚠️ Arithmetic rather than bitwise, deliberately. JavaScript's bitwise + * operators coerce to **signed 32 bits**, and the run length this file writes + * for a full zoom-14 pyramid is 357,913,941 — which fits, today, and would stop + * fitting the moment {@link FIXTURE_MAX_ZOOM} reached 16. A varint writer that + * is correct only for the values it currently sees is the kind of latent defect + * that surfaces as a corrupt archive rather than as an error. + */ +function writeVarint(out: number[], value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`a varint must be a non-negative safe integer, not ${String(value)}`); + } + let remaining = value; + while (remaining >= 0x80) { + out.push((remaining % 0x80) + 0x80); + remaining = Math.floor(remaining / 0x80); + } + out.push(remaining); +} + +/** A protobuf field key: the field number and the wire type, as one varint. */ +function writeKey(out: number[], field: number, wireType: number): void { + writeVarint(out, field * 8 + wireType); +} + +/** A length-delimited field — wire type 2 — carrying already-encoded bytes. */ +function writeBytesField(out: number[], field: number, bytes: readonly number[]): void { + writeKey(out, field, 2); + writeVarint(out, bytes.length); + // A loop rather than `push(...bytes)`: spreading a large array into a call + // is how a fixture generator meets the argument-count limit, and a tile that + // grows is the least interesting way to discover it. + for (const byte of bytes) { + out.push(byte); + } +} + +/** A varint field — wire type 0. */ +function writeVarintField(out: number[], field: number, value: number): void { + writeKey(out, field, 0); + writeVarint(out, value); +} + +/** + * A run of varints, as bytes — MVT's packed `geometry` column. + * + * ⚠️ **The one conversion it is easy to leave out, and leaving it out is + * silent.** A command integer is a *number*, not a byte: `LineTo` for three + * vertices is 26, but the parameter beside it is 8,448. Handing the command + * list straight to {@link writeBytesField} stores each one modulo 256 — 8,448 + * becomes 0 and 8,447 becomes 255 — and the result is still a well-formed + * protobuf, still a well-formed PMTiles archive, and still parses into the + * right *number* of vertices. It is the winding assertion in + * `pmtiles-fixture.test.ts` that catches it, because the vertices are all at + * the wrong place and the ring's area collapses to zero. This function existing + * separately is what makes that mistake unavailable rather than merely tested + * for. + */ +function packVarints(values: readonly number[]): readonly number[] { + const packed: number[] = []; + for (const value of values) { + writeVarint(packed, value); + } + return packed; +} + +/** A UTF-8 string field. ASCII only here, which every layer name below is. */ +function writeStringField(out: number[], field: number, value: string): void { + const bytes: number[] = []; + for (const unit of new TextEncoder().encode(value)) { + bytes.push(unit); + } + writeBytesField(out, field, bytes); +} + +/** + * A closed ring, as MVT command and parameter integers. + * + * MVT 2.1 §4.3.3.3: a ring is `MoveTo(1)` to its first vertex, `LineTo(n-1)` + * for the rest, then `ClosePath` — and the closing vertex is **not** repeated, + * because `ClosePath` supplies it. The winding decides the ring's role: the + * surveyor's formula over the vertices below is positive, which the + * specification defines as an **exterior** ring. Reverse it and MapLibre reads + * a hole, draws nothing, and the gate reports "no tile painted" for a tile that + * arrived perfectly. + */ +function ring(points: readonly TilePoint[]): readonly number[] { + const commands: number[] = []; + let cursorX = 0; + let cursorY = 0; + const first = points[0]; + if (first === undefined) { + throw new RangeError('a ring needs at least one vertex'); + } + // MoveTo, one vertex. `(1 & 0x7) | (1 << 3)`. + commands.push(9, zigzag(first.x - cursorX), zigzag(first.y - cursorY)); + cursorX = first.x; + cursorY = first.y; + const rest = points.slice(1); + // LineTo, the remaining vertices. `(2 & 0x7) | (count << 3)`. + commands.push(2 + rest.length * 8); + for (const point of rest) { + commands.push(zigzag(point.x - cursorX), zigzag(point.y - cursorY)); + cursorX = point.x; + cursorY = point.y; + } + // ClosePath, one. `(7 & 0x7) | (1 << 3)`. + commands.push(15); + return commands; +} + +/** An axis-aligned rectangle as an exterior ring, in tile units. */ +function rectangle(west: number, north: number, east: number, south: number): readonly number[] { + return ring([ + { x: west, y: north }, + { x: east, y: north }, + { x: east, y: south }, + { x: west, y: south }, + ]); +} + +/** An open line, as MVT command and parameter integers. */ +function line(points: readonly TilePoint[]): readonly number[] { + const commands: number[] = []; + const first = points[0]; + if (first === undefined) { + throw new RangeError('a line needs at least one vertex'); + } + commands.push(9, zigzag(first.x), zigzag(first.y)); + let cursorX = first.x; + let cursorY = first.y; + const rest = points.slice(1); + commands.push(2 + rest.length * 8); + for (const point of rest) { + commands.push(zigzag(point.x - cursorX), zigzag(point.y - cursorY)); + cursorX = point.x; + cursorY = point.y; + } + return commands; +} + +/** + * One MVT layer. + * + * No `keys` and no `values`: the style has no filter and no data-driven paint, + * so a feature in this fixture has no properties to carry. A tag column that + * nothing reads would be bytes in a measurement that is partly about bytes. + */ +function encodeLayer(name: string, features: readonly EncodedFeature[]): readonly number[] { + const layer: number[] = []; + writeStringField(layer, 1, name); + for (const feature of features) { + const body: number[] = []; + writeVarintField(body, 3, feature.type); + writeBytesField(body, 4, packVarints(feature.geometry)); + writeBytesField(layer, 2, body); + } + writeVarintField(layer, 5, EXTENT); + // Field 15. Stated rather than defaulted — see {@link EXTENT}. + writeVarintField(layer, 15, 2); + return layer; +} + +/** + * The one tile every id in the archive resolves to. + * + * Three layers, matching {@link FIXTURE_SOURCE_LAYERS} and therefore the three + * the style reads: + * + * - **`earth`** — the whole tile, so *some* basemap colour is on screen at any + * zoom, at any crop, however the harness track happens to have framed itself. + * A fixture whose visible colour depended on where the viewport landed would + * be a gate that goes red for a reason no one can act on. + * - **`water`** — the middle quarter, drawn over the earth. It exists so that + * "a tile drew" is not one colour's word: two layers from the same tile, + * painted in the style's own order, is a much harder thing to produce by + * accident than one flat fill. + * - **`roads`** — a line across the middle, which is the only feature here that + * is not a polygon. It is what stops the encoder being proved by rectangles + * alone. + */ +export function fixtureTile(): Uint8Array { + const outer = EXTENT + EDGE_BUFFER; + const inner = EXTENT / 4; + const layers: Record = { + earth: [ + { + type: GEOMETRY_POLYGON, + geometry: rectangle(-EDGE_BUFFER, -EDGE_BUFFER, outer, outer), + }, + ], + water: [ + { + type: GEOMETRY_POLYGON, + geometry: rectangle(inner, inner, EXTENT - inner, EXTENT - inner), + }, + ], + roads: [ + { + type: GEOMETRY_LINESTRING, + geometry: line([ + { x: -EDGE_BUFFER, y: EXTENT / 2 }, + { x: outer, y: EXTENT / 2 }, + ]), + }, + ], + }; + + const tile: number[] = []; + for (const name of FIXTURE_SOURCE_LAYERS) { + const features = layers[name]; + if (features === undefined) { + throw new RangeError(`no geometry was built for the declared source layer ${name}`); + } + writeBytesField(tile, 3, encodeLayer(name, features)); + } + return Uint8Array.from(tile); +} + +/** + * How many tiles a full pyramid holds from zoom 0 to `maxZoom` inclusive. + * + * `(4^(z+1) - 1) / 3`. This is the run length of the archive's single entry — + * PMTiles orders tile ids by zoom and then along a Hilbert curve, so ids + * `0 … count-1` are precisely every tile at every zoom up to `maxZoom`, and one + * entry covering all of them is one tile serving all of them. + */ +export function pyramidTileCount(maxZoom: number): number { + return (4 ** (maxZoom + 1) - 1) / 3; +} + +/** + * The root directory: one entry, four varint columns. + * + * PMTiles v3 §"Directory": the entries are written column-wise — ids as deltas + * from the previous id, then run lengths, then lengths, then offsets. ⚠️ The + * offset column stores **offset + 1**, because a stored `0` means "immediately + * after the previous entry" for every entry but the first. Storing a literal + * zero here reads back as offset `-1`, which is a range request for a byte + * before the file and an archive that fails with no clue why. + */ +function rootDirectory(tileLength: number, runLength: number): readonly number[] { + const directory: number[] = []; + writeVarint(directory, 1); + // Tile id column: the first entry's delta from zero. + writeVarint(directory, 0); + writeVarint(directory, runLength); + writeVarint(directory, tileLength); + writeVarint(directory, 1); + return directory; +} + +/** + * The archive's JSON metadata. + * + * ⚠️ **MapLibre never reads this on the path the client uses.** `maplibre.ts` + * builds `new Protocol()` with no options, whose `metadata` flag is therefore + * off, so the TileJSON it synthesises comes from the header alone and this + * section is never fetched. It is written because the format has a section for + * it and an archive without one is malformed — and because + * `pmtiles-fixture.test.ts` reads it back, which is the only assertion that + * would notice the offset or length being wrong. + * + * It carries **no attribution field**, deliberately: there is no OSM data in + * this archive to attribute, and a fixture that claimed otherwise would put a + * false licence notice into a build artefact. + */ +function metadataJson(): string { + return JSON.stringify({ + name: 'on-your-left browser-gate fixture', + description: 'Synthetic geometry. Contains no OpenStreetMap data.', + vector_layers: FIXTURE_SOURCE_LAYERS.map((id) => ({ + id, + fields: {}, + minzoom: 0, + maxzoom: FIXTURE_MAX_ZOOM, + })), + }); +} + +/** A signed coordinate, scaled and rounded the way the header stores it. */ +function scaled(degrees: number): number { + return Math.round(degrees * COORDINATE_SCALE); +} + +/** + * The whole archive, as bytes. + * + * The layout is header → root directory → JSON metadata → tile data, with no + * leaf directories: one entry fits in the root many times over, and the spec's + * requirement that the root lie inside the first 16,384 bytes — so a client can + * prefetch header and directory in one range request — is met by a very wide + * margin. That prefetch is the reason the cold-load number below is two round + * trips rather than three. + */ +export function buildFixtureArchive(): Uint8Array { + const tile = fixtureTile(); + const metadata = new TextEncoder().encode(metadataJson()); + const runLength = pyramidTileCount(FIXTURE_MAX_ZOOM); + const directory = rootDirectory(tile.length, runLength); + + const rootOffset = HEADER_BYTES; + const metadataOffset = rootOffset + directory.length; + const tileDataOffset = metadataOffset + metadata.length; + const total = tileDataOffset + tile.length; + + const bytes = new Uint8Array(total); + const view = new DataView(bytes.buffer); + + // Magic. PMTiles v3 §"Header": the seven ASCII bytes `PMTiles`, then the + // spec version. The decoder checks only the first two of them, which is why + // the other five are worth getting right from the document rather than from + // what a reader happens to accept. + bytes.set(new TextEncoder().encode('PMTiles'), 0); + view.setUint8(7, 3); + + const setUint64 = (offset: number, value: number): void => { + // Little-endian, split rather than `setBigUint64`, so nothing in this file + // needs a `BigInt` for numbers that are all comfortably safe integers. + view.setUint32(offset, value % 0x1_0000_0000, true); + view.setUint32(offset + 4, Math.floor(value / 0x1_0000_0000), true); + }; + + setUint64(8, rootOffset); + setUint64(16, directory.length); + setUint64(24, metadataOffset); + setUint64(32, metadata.length); + // No leaf directories. The offset still points somewhere sane rather than at + // zero: a client that range-requests a zero-length region at the start of the + // file is asking for the header back. + setUint64(40, tileDataOffset); + setUint64(48, 0); + setUint64(56, tileDataOffset); + setUint64(64, tile.length); + // Addressed tiles: every id the archive answers for. Entries and contents: + // one each, which is the deduplication ratio written down. + setUint64(72, runLength); + setUint64(80, 1); + setUint64(88, 1); + + view.setUint8(96, 1); + view.setUint8(97, COMPRESSION_NONE); + view.setUint8(98, COMPRESSION_NONE); + view.setUint8(99, TILE_TYPE_MVT); + view.setUint8(100, 0); + view.setUint8(101, FIXTURE_MAX_ZOOM); + view.setInt32(102, scaled(-180), true); + view.setInt32(106, scaled(-MERCATOR_LATITUDE_LIMIT), true); + view.setInt32(110, scaled(180), true); + view.setInt32(114, scaled(MERCATOR_LATITUDE_LIMIT), true); + view.setUint8(118, 0); + view.setInt32(119, 0, true); + view.setInt32(123, 0, true); + + bytes.set(Uint8Array.from(directory), rootOffset); + bytes.set(metadata, metadataOffset); + bytes.set(tile, tileDataOffset); + return bytes; +} diff --git a/apps/web/src/map/maplibre.ts b/apps/web/src/map/maplibre.ts index 38d7c7dd..fa5f7e82 100644 --- a/apps/web/src/map/maplibre.ts +++ b/apps/web/src/map/maplibre.ts @@ -42,10 +42,13 @@ import { addProtocol, Map as MapLibreMap, removeProtocol, + setWorkerUrl, type AddProtocolAction, type GeoJSONSource, type StyleSpecification, } from 'maplibre-gl'; +// The worker, bundled by Vite and addressed by URL. @see the setWorkerUrl note +import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'; import { Protocol } from 'pmtiles'; import { TRACK_LAYER_ID, TRACK_SOURCE_ID } from './basemap'; @@ -53,6 +56,41 @@ import type { MapPort, MapRenderer, MapView, MapViewOptions } from './port'; import { createProtocolRegistry } from './protocol'; import type { TrackBounds, TrackFeature } from './track'; +/** + * Where the tile-parsing worker is, because MapLibre cannot work it out here. + * + * ⚠️ **Without this the map fetches its tiles and draws none of them, in a + * production build, silently.** MapLibre v6 parses every vector tile in a Web + * Worker, and it locates that worker with + * `new URL('./maplibre-gl-worker.mjs', import.meta.url)`. Under a bundler + * `import.meta.url` is the **hashed chunk** MapLibre was bundled into, so the + * request goes to `/assets/maplibre-gl-worker.mjs` — a file no bundler emits, + * because that expression is built from a variable and is not statically + * analysable. The worker's script 404s, the `Worker` object is created anyway, + * every `loadTile` message is sent into it and never answered, and the tile sits + * in `loading` for ever. + * + * **Every symptom of that is an absence.** No exception is thrown, `create` + * returns normally, the canvas has a live GL context, the archive is fetched + * from the right origin over a correct range request, and the map reports no + * error. #63's browser gate was green through all of it, because until the + * fixture archive (#63, `browser/pmtiles-fixture.ts`) there was no tile for the + * worker to fail to parse — the gate asserted routing, and routing was fine. + * Building the archive is what surfaced it, which is the ordinary way an + * endpoint's first real consumer finds its defects. + * + * `?worker&url` and **not** `?url`: `maplibre-gl-worker.mjs` imports its sibling + * `maplibre-gl-shared.mjs`, so a verbatim copy of the one file fails on its + * first import and nothing loads — the same blank map by a different route. + * `?worker&url` makes Vite bundle the worker with what it imports and hands back + * the emitted URL. + * + * Set at module scope rather than per map: it is process-wide configuration, and + * this module is only evaluated when a map is actually wanted — `main.tsx` + * reaches it through `import()`. + */ +setWorkerUrl(workerUrl); + /** An empty geometry, for a map created before its track is known. */ const EMPTY_TRACK = { type: 'FeatureCollection' as const, diff --git a/apps/web/src/map/port.ts b/apps/web/src/map/port.ts index d6df89f3..b4f38486 100644 --- a/apps/web/src/map/port.ts +++ b/apps/web/src/map/port.ts @@ -15,24 +15,31 @@ * jsdom (CLAUDE.md §4e says so of the accessibility gate, and the rest follows * it), so `new maplibregl.Map(...)` cannot be constructed in this suite at all. * - * The alternative would be a headless-browser job. That is a real option and it - * was not taken here, for a reason worth stating rather than assuming: CI runs - * **exactly** the §4a commands, `main` requires a status check whose context is - * the string `Repository rules`, and CLAUDE.md §4c records that a *second* job - * reports under a different context and its failure would therefore not block a - * merge. Adding browser tests is a change to the gate, and it is a decision - * rather than a detail. + * The alternative would be a headless-browser job. ⚠️ **It was taken, after + * this paragraph was written** — a reviewer who remembers this file saying the + * option *"was not taken here"* is reading the old one. #176 added + * `apps/web/browser/`, inside the existing `Repository rules` job rather than + * beside it, for exactly the reason the old paragraph gave: CLAUDE.md §4c + * records that a second job reports under a different context and could not + * block a merge. §4f is what that gate is and is not. * - * What the seam buys is not a workaround. Every one of #63's criteria except - * the cold-load measurement is about **what this client does** — how many times - * it registers a protocol, which origins its style reaches, what coordinate - * array it hands the map, what it renders when there is no GPS. All of those - * are decided on this side of the boundary, and asserting them here is stronger - * than reading pixels: a screenshot cannot tell you the array behind the line - * had the front door still in it. + * What the seam buys is not a workaround. Most of #63's criteria are about + * **what this client does** — how many times it registers a protocol, which + * origins its style reaches, what coordinate array it hands the map, what it + * renders when there is no GPS. All of those are decided on this side of the + * boundary, and asserting them here is stronger than reading pixels: a + * screenshot cannot tell you the array behind the line had the front door still + * in it. * - * What it does **not** buy is a check that MapLibre draws what we asked. That - * is real, and it is named in the pull request rather than papered over. + * ⚠️ What it does **not** buy is a check that MapLibre draws what we asked — + * and that gap was not theoretical. `maplibre.ts` shipped for months unable to + * load its own tile-parsing worker under a bundler, so a production map would + * have fetched every tile and drawn none of them. Nothing on this side of the + * seam could see it: the port was satisfied, the style was right, the origins + * were right, the protocol registered once. It took a real archive rendered in + * a real engine (`browser/pmtiles-fixture.ts`) to find it. Read + * `maplibre.ts`'s `setWorkerUrl` note before trusting a green suite here to + * mean the map works. */ import type { BasemapStyle } from './basemap'; diff --git a/apps/web/vite.browser.config.ts b/apps/web/vite.browser.config.ts index 46a6cea7..6d0b39b6 100644 --- a/apps/web/vite.browser.config.ts +++ b/apps/web/vite.browser.config.ts @@ -25,9 +25,40 @@ * keep in step with `vite.config.ts` for no behaviour the gate can observe. */ -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; + +import { buildFixtureArchive, FIXTURE_ARCHIVE_FILE } from './browser/pmtiles-fixture'; + +/** + * Emit the PMTiles archive the gate renders from (#63). + * + * Built rather than committed. `browser/dist` is already gitignored and already + * pruned by `scripts/check-repo-rules.sh`, so the archive needs no new ignore + * line, no `.spdx-exempt` entry — which §3a would refuse it anyway, since it is + * not third-party generator output — and leaves nothing in the tree that a + * reviewer cannot read. `pmtiles-fixture.ts` says what is in it and why it + * carries no OpenStreetMap data. + * + * ⚠️ **`generateBundle` rather than a `public/` file**, because a file in + * `browser/public/` would be a binary in the repository, which is the thing + * above. The cost is that the archive exists only after a build: `vite preview` + * serves `browser/dist`, and `test:browser` builds before it previews. + */ +function pmtilesFixture(): Plugin { + return { + name: 'oyl-pmtiles-fixture', + generateBundle() { + this.emitFile({ + type: 'asset', + fileName: FIXTURE_ARCHIVE_FILE, + source: buildFixtureArchive(), + }); + }, + }; +} export default defineConfig({ + plugins: [pmtilesFixture()], root: 'browser', // ⚠️ A **multi-page** app, which here means "a static file server". Vite's // default single-page mode rewrites every unknown path to `index.html`, so a diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index cbd7ce8a..809eb406 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -18,6 +18,20 @@ export default defineConfig({ // accidental `document` reference in a recorder still fails rather than // being hidden behind a working global. setupFiles: ['fake-indexeddb/auto'], - include: ['src/**/*.test.{ts,tsx}'], + // ⚠️ `browser/` as well as `src/`, since #63's fixture archive. The browser + // gate's directory is not all Playwright specs: `pmtiles-fixture.ts` is a + // pure encoder that `vite.browser.config.ts` calls at build time, and its + // own test is a Vitest one. Without this line that file matches neither + // runner's selector — Playwright takes `**/*.browser.spec.ts` and this took + // `src/` — so it would be a test nobody runs, which is worse than none. + // `packages/fit/vitest.config.ts` includes `tools/**/*.test.ts` for the + // same reason and about the same kind of file: an authoring-time generator + // whose output is a committed or built artefact. + // + // It does not widen the **coverage** report, which is `apps/*/src/**` in + // the root config, and deliberately: a fixture generator's coverage mixed + // into a client's denominator is the thing #110 decided against for + // `packages/fit/tools`. + include: ['src/**/*.test.{ts,tsx}', 'browser/**/*.test.{ts,tsx}'], }, }); diff --git a/docs/architecture.md b/docs/architecture.md index 5d295cfb..14cc8fa9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,7 +82,9 @@ apps/ AGPL-3.0-or-later, without exception src/library/ the activity library's row model and its port (#62) src/map/ the ride map (#63): the basemap configuration and its origin proof, the GeoJSON conversion, the once-per-app - protocol registration, and the MapLibre adapter + protocol registration, and the MapLibre adapter — which + is also the one place the tile-parsing worker's URL is + set, without which a built map draws no tile at all src/recording/ the composition root: engine + checkpoints + recovery (#46) src/routes/ saved routes (#73): the store port, the edit decision and its concurrency token, the shared-route payload, the @@ -708,7 +710,7 @@ alternatives are there. | Coverage gate | **no percentage** — every new code path covered by a test proven to fail without the change | | Linter / formatter | ESLint 10 + typescript-eslint + Prettier 3 | | Map rendering | **MapLibre GL JS 6.7.0** + **`pmtiles` 4.5.0**, both BSD-3-Clause — installed by #63, in `apps/web` (ADR 0010 D-1) | -| Basemap | Protomaps basemap as a PMTiles archive on storage this project controls. **Not published yet — #53** | +| Basemap | Protomaps basemap as a PMTiles archive on storage this project controls. **Not published yet — #53.** The browser gate renders from a synthetic archive built by `apps/web/browser/pmtiles-fixture.ts`, which contains no OpenStreetMap data | | Real-time transport | deferred to [#16](https://github.com/openzigs/onyourleft/issues/16) | Installed as of #23: the toolchain above, React 19.2.8, React DOM 19.2.8 and Vite 8.2.2. Everything @@ -733,6 +735,23 @@ nothing non-OSI. `maplibre-gl` is **977 kB minified**, which is why `apps/web/sr reached through a dynamic `import()` and lands in its own chunk: a rider who only opens indoor rides never downloads it. +⚠️ **MapLibre v6 needs a second file emitted beside that chunk, and no bundler emits it by +itself.** Every vector tile is parsed in a Web Worker, and MapLibre finds that worker with +`new URL('./maplibre-gl-worker.mjs', import.meta.url)` — under a bundler `import.meta.url` is the +hashed chunk MapLibre was bundled into, and the expression is built from a variable, so it is not +statically analysable and the file is never emitted. The request 404s, the `Worker` is constructed +anyway, every tile-parse message is sent into it and never answered, and the map fetches all its +tiles and draws none of them, with no error anywhere. + +`maplibre.ts` therefore calls `setWorkerUrl` with a URL imported as +`maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url`. **`?worker&url` and not `?url`**: the dist +worker imports its sibling `maplibre-gl-shared.mjs`, so a verbatim copy of one file fails on its +first import and produces the same blank map by a different route. `pnpm run build` emits +`assets/maplibre-gl-worker-*.js` (~486 kB, referenced only from the lazy map chunk, so the code +split is unaffected). + +This was found by #63's browser gate only once it had a real archive to render — see below. + ### Segment matching: every tolerance, and that each is ours [#66](https://github.com/openzigs/onyourleft/issues/66)'s definition of done asks for the chosen