Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 43 additions & 21 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ is no server other than the tile servers the map style points at.
## Data flow

```
settings (URL, bbox, mm, dpi)
settings (URL, bbox, mm, dpi, bearing)
│
▼
styles/ resolve URL → MapLibre style + attribution + transformRequest
│
▼
viewport/ bbox + px size → {center, zoom}; tile grid → per-tile centers
viewport/ bbox + px size + bearing → {center, zoom, bearing}; tile grid → per-tile centers
│
▼
map-renderer/ hidden MapLibre map, one tile at a time → Uint8Array RGB rows
Expand Down Expand Up @@ -77,20 +77,29 @@ latitude clamped to ±85.051129). No maplibre import.

```ts
type Bbox = [west: number, south: number, east: number, north: number];
type Viewport = { center: [lng, lat]; zoom: number; width: number; height: number }; // CSS px
type Viewport = { center: [lng, lat]; zoom: number; width: number; height: number; bearing?: number }; // CSS px, degrees

isValidBbox(b: unknown): b is Bbox
fitBounds(bbox: Bbox, width: number, height: number): Viewport // no padding
viewportBbox(v: Viewport): Bbox // what the viewport actually covers
fitBounds(bbox: Bbox, width: number, height: number, bearing = 0): Viewport // no padding
viewportCorners(v: Viewport): [LngLat, LngLat, LngLat, LngLat] // page corners, screen order from top-left
viewportBbox(v: Viewport): Bbox // envelope of the corners
fitsWorld(v: Viewport): boolean // every corner inside ±85.051129°
project(lngLat, zoom): [x, y] / unproject([x, y], zoom): [lng, lat] // world px at zoom
rotateOffset([x, y], bearing): [x, y] // screen-space offset → world px offset
normalizeBearing(deg): number // wrapped to (-180, 180], as MapLibre reports it
tileGrid(v: Viewport, tile: { width: number; height: number }): TileRect[]
// row-major list of { col, row, x, y, width, height, center: [lng, lat] }
// x/y/width/height in CSS px of the full viewport; edge tiles are smaller
mmToPx(mm: number, dpi: number): number
```

Tile centers are computed at the viewport zoom in world pixel space so tiles
abut exactly at integer pixel boundaries.
Tile rects live in screen space, and each rect's `center` is the world point
under its centre pixel — for a rotated page, its screen offset from the page
centre turned by the bearing. MapLibre rotates rigidly about the screen
centre, so a tile-sized map at the same zoom and bearing centred there shows
exactly that rect's pixels and tiles abut exactly. `fitBounds` keeps the
north-up zoom and centre; the bearing only turns the page about the bbox
centre, so parts of the bbox can fall off the page.

### `mosaic` (pure, unit-tested)

Expand Down Expand Up @@ -151,7 +160,7 @@ createMapRenderer(opts: {
}): Promise<MapRenderer>

interface MapRenderer {
render(tile: TileRect, zoom: number): Promise<Uint8Array>; // cropped to tile.width/height, top-down
render(tile: TileRect, zoom: number, bearing = 0): Promise<Uint8Array>; // cropped to tile.width/height, top-down
destroy(): void;
}
maxTileSize(pixelRatio: number): { width: number; height: number } // from a probe WebGL2 context
Expand All @@ -163,14 +172,24 @@ One hidden `maplibregl.Map`, created once and reused for every tile:
`display: none`, which makes MapLibre fall back to 400×300).
- options: `pixelRatio`, `canvasContextAttributes: { preserveDrawingBuffer: true }`,
`maxCanvasSize` from the probe's `MAX_TEXTURE_SIZE`, `fadeDuration: 0`,
`trackResize: false`, `interactive: false`, `attributionControl: false`.
`trackResize: false`, `interactive: false`, `attributionControl: false`,
and a no-op `transformConstrain`: MapLibre's default pans a camera whose
unrotated frame crosses ±85.051129°, which would misalign an edge tile of a
rotated page. `export` has already rejected a page that leaves the world.
- after construction, assert `canvas.width === floor(tileSize.width * pixelRatio)`
or throw: MapLibre silently lowers the pixel ratio when a canvas is too big.
- `render`: register `once("idle")` before `jumpTo({ center, zoom })`, race it
against `renderTimeoutMs` and against any `error` event whose source is a
tile or the style; then `gl.readPixels` into a reusable scratch buffer,
flip rows (WebGL is bottom-up), drop alpha when `channels === 3`, crop to
the tile rect, and return a fresh `Uint8Array`.
- `render`: register `once("idle")` before `jumpTo({ center, zoom, bearing })`,
race it against `renderTimeoutMs` and against any `error` event whose
source is a tile or the style; then `gl.readPixels` into a reusable scratch
buffer, flip rows (WebGL is bottom-up), drop alpha when `channels === 3`,
crop to the tile rect, and return a fresh `Uint8Array`. The shift that puts
an edge tile in the canvas's top-left corner is a screen-space offset, so
it is turned by the bearing before being applied in world px.
- raster, hillshade and colour-relief layers are drawn with MapLibre's
pixel-aligned matrix, which snaps the camera centre to whole world px. At
bearing 0 every tile centre shares one fractional part so the snap is the
same; a rotated page's tile centres do not, so raster imagery can jog by up
to a device pixel across a seam. Vector layers are unaffected.
- `render` takes the CSS-px rect from `tileGrid` and returns
`floor(tile.width · pixelRatio) × floor(tile.height · pixelRatio)` pixels, so
`export` hands `mosaic` (whose rects are output pixels) the rects scaled by
Expand Down Expand Up @@ -216,10 +235,11 @@ being flushed to disk.
exportMap(opts: {
style: string | StyleSpecification; transformRequest?;
bbox: Bbox; widthPx: number; heightPx: number; pixelRatio: number;
bearing?: number;
filename: string;
onProgress?: (fraction: number) => void;
signal?: AbortSignal;
}): Promise<{ bbox: Bbox; zoom: number }> // resolves when the download is fully written
}): Promise<{ bbox: Bbox; corners; zoom: number; bearing: number }> // resolves when the download is fully written
```

Computes the viewport (CSS size = px / pixelRatio), chooses the tile size
Expand All @@ -236,18 +256,20 @@ and cleans up the download in `finally`.
light DOM (so Tailwind applies): `<settings-form>` and `<preview-map>`. The
layout and fields match the previous app: style/tile URL, Mapbox token
(shown only for Mapbox URLs), width and height in mm, bbox `W,S,E,N`,
preview-bbox toggle, DPI select (96/192/288/384), the "exports at zoom Z,
W×H px" line, the attribution text with one "I will include this
attribution" checkbox, Export / Cancel and a progress bar.
preview-bbox toggle, rotation in degrees, DPI select (96/192/288/384), the
"exports at zoom Z, W×H px" line, the attribution text with one "I will
include this attribution" checkbox, Export / Cancel and a progress bar.

Settings persist to `localStorage` under `map-printer-settings`, parsed
defensively (defaults merged, invalid values replaced). Validation is
inline: invalid fields are marked and Export is disabled until they pass.

The preview map is a normal interactive MapLibre map sized to the paper
aspect ratio with a dashed bbox overlay, plus MapLibre's attribution
control. Style errors (bad URL, bad token) mark the field instead of
tearing down the map.
aspect ratio with a dashed bbox overlay and a solid outline of the page as it
will print, plus MapLibre's navigation and attribution controls. The map is
shown at the export bearing; rotating it (right-drag or the compass) writes
the bearing back to the form, and the form's field turns the map. Style
errors (bad URL, bad token) mark the field instead of tearing down the map.

Unsupported browsers (no `CompressionStream`, `ReadableStream`, service
worker or WebGL2) get a message instead of a form. Safari 16.4+, Chrome
Expand Down
76 changes: 75 additions & 1 deletion e2e/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,72 @@ describeEngines((engine) => {
await page.waitForSelector('[data-message="success"]');
});

test("exports a raster tile template in the fixture palette", async () => {
test("an invalid rotation is reported inline", async () => {
await page.fill("#bearing", "");
await page.waitForSelector('[data-error="bearing"]');
expect(await page.isDisabled("#export")).toBe(true);

await page.fill("#bearing", "0");
await page.waitForSelector('[data-error="bearing"]', { state: "detached" });
await page.waitForSelector("#export:not([disabled])");
});

test("the compass resets the rotation field", async () => {
await page.fill("#bearing", "90");
await page.waitForSelector('[data-zoom-line]:has-text("rotated 90°")');

await page.click(".maplibregl-ctrl-compass");
await page.waitForFunction(
() => (document.querySelector("#bearing") as HTMLInputElement).value === "0",
);
await page.waitForSelector('[data-zoom-line]:not(:has-text("rotated"))');
});

test("exports the bbox turned by the rotation field", async () => {
await page.fill("#bearing", "30");
await page.waitForSelector('[data-zoom-line]:has-text("rotated 30°")');
const [download] = await Promise.all([
page.waitForEvent("download", { timeout: 60_000 }),
page.click("#export"),
]);
const file = path.join(browser.downloadDir, download.suggestedFilename());
await download.saveAs(file);
const png = decode(fs.readFileSync(file));

expect(png.width).toBe(mmToPx(WIDTH_MM, DPI, 1));
expect(png.height).toBe(mmToPx(HEIGHT_MM, DPI, 1));
// The 6° bbox height fills the page, so the 2° square's half-side is a
// sixth of it. The square turns 30° about the page centre.
const cx = png.width / 2;
const cy = png.height / 2;
const half = png.height / 6;
const cos = Math.cos(Math.PI / 6);
const sin = Math.sin(Math.PI / 6);
expect(isRed(pixelAt(png, Math.round(cx), Math.round(cy)))).toBe(true);
// Just inside the north-up square's corner: outside the turned one.
expect(
isRed(pixelAt(png, Math.round(cx + 0.9 * half), Math.round(cy - 0.9 * half))),
).toBe(false);
// Most of the way to the turned square's south-east corner: inside it,
// but beyond the north-up square's east edge.
const cornerX = half * cos + half * sin;
const cornerY = -half * sin + half * cos;
expect(cornerX * 0.85).toBeGreaterThan(half + 3);
expect(
isRed(
pixelAt(png, Math.round(cx + 0.85 * cornerX), Math.round(cy + 0.85 * cornerY)),
),
).toBe(true);
await page.waitForSelector('[data-message="success"]');
await page.fill("#bearing", "0");
});

/** Exports the raster fixture and checks every flat patch is a tile colour. */
async function exportRaster(bearing: string): Promise<void> {
await page.fill("#bbox", RASTER_BBOX);
await page.fill("#width", String(RASTER_WIDTH_MM));
await page.fill("#height", String(RASTER_HEIGHT_MM));
await page.fill("#bearing", bearing);
// Last: the debounced re-resolve would otherwise re-enable Export before
// waitForStyle sees it go disabled.
await page.fill("#style", rasterTemplate);
Expand All @@ -171,6 +233,18 @@ describeEngines((engine) => {
}
expect(sampled).toBeGreaterThan(100);
expect(seen.size).toBeGreaterThan(1);
}

test("exports a raster tile template in the fixture palette", async () => {
await exportRaster("0");
});

test("exports a turned raster tile template in the fixture palette", async () => {
// The style is already the raster one, so waitForStyle would hang on a
// field that does not change; re-select the vector fixture first.
await page.fill("#style", fixtureStyle);
await waitForStyle(page);
await exportRaster("30");
});

// Last: Playwright's Firefox stops reporting downloads once one has been
Expand Down
8 changes: 7 additions & 1 deletion e2e/browsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,13 @@ export function usePage(engine: Engine): EnginePage {
headless: true,
...engine.launchOptions,
});
holder.page = await browser.newPage({ acceptDownloads: true });
// Tall enough that the whole settings form fits without scrolling: at the
// default 720 px WebKit failed to tick the attribution checkbox once it
// had to be scrolled into view inside the form's pane.
holder.page = await browser.newPage({
acceptDownloads: true,
viewport: { width: 1280, height: 1000 },
});
holder.page.on("pageerror", (error) =>
console.error(`[${engine.name}]`, error),
);
Expand Down
111 changes: 111 additions & 0 deletions e2e/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { describeEngines, harnessUrl, usePage } from "./browsers.ts";
import {
fitBounds,
project,
rotateOffset,
type Bbox,
type LngLat,
type Viewport,
} from "../src/lib/viewport/index.ts";

Expand Down Expand Up @@ -39,6 +41,14 @@ function projectedRect(bbox: Bbox, v: Viewport, pixelRatio: number): Rect {
};
}

/** Where a lng/lat lands in the exported image's pixels, at any bearing. */
function toScreen(v: Viewport, point: LngLat, pixelRatio: number): [number, number] {
const [cx, cy] = project(v.center, v.zoom);
const [x, y] = project(point, v.zoom);
const [sx, sy] = rotateOffset([x - cx, y - cy], -(v.bearing ?? 0));
return [(sx + v.width / 2) * pixelRatio, (sy + v.height / 2) * pixelRatio];
}

function pixelAt(png: DecodedPng, x: number, y: number): number[] {
const i = (Math.round(y) * png.width + Math.round(x)) * png.channels;
return [png.data[i], png.data[i + 1], png.data[i + 2]];
Expand Down Expand Up @@ -68,6 +78,7 @@ interface ExportRequest {
widthPx: number;
heightPx: number;
pixelRatio: number;
bearing?: number;
filename: string;
tileSize?: { width: number; height: number };
}
Expand All @@ -94,6 +105,7 @@ describeEngines((engine) => {
widthPx: number;
heightPx: number;
pixelRatio: number;
bearing?: number;
filename: string;
tileSize?: { width: number; height: number };
};
Expand Down Expand Up @@ -241,6 +253,105 @@ describeEngines((engine) => {
}
});

test("exports a rotated 3×3 tile grid without seams", async () => {
const widthPx = 900;
const heightPx = 700;
const pixelRatio = 2;
const bearing = 30;
const tileSize = { width: 150, height: 120 };
const { result, png } = await exportPng({
style: fixtureStyle,
bbox: TIGHT_BBOX,
widthPx,
heightPx,
pixelRatio,
bearing,
filename: "rotated.png",
tileSize,
});

const v = fitBounds(
TIGHT_BBOX,
widthPx / pixelRatio,
heightPx / pixelRatio,
bearing,
);
expect(png.width).toBe(widthPx);
expect(png.height).toBe(heightPx);
expect(result.bearing).toBe(bearing);
expect(result.zoom).toBeCloseTo(fitBounds(TIGHT_BBOX, 450, 350).zoom, 9);
for (const [i, corner] of result.corners.entries()) {
const [x, y] = toScreen(v, corner, pixelRatio);
const expected = [
[0, 0],
[widthPx, 0],
[widthPx, heightPx],
[0, heightPx],
][i];
expect(x, `corner ${i} x`).toBeCloseTo(expected[0], 3);
expect(y, `corner ${i} y`).toBeCloseTo(expected[1], 3);
}

const corners: [number, number][] = (
[
[SQUARE[0], SQUARE[3]],
[SQUARE[2], SQUARE[3]],
[SQUARE[2], SQUARE[1]],
[SQUARE[0], SQUARE[1]],
] as LngLat[]
).map((c) => toScreen(v, c, pixelRatio));
const centre = toScreen(v, [0, 0], pixelRatio);
const margin = 2 * pixelRatio;

// The square still straddles every seam of the grid; the seams are where
// a tile rendered at the wrong centre would show.
for (const x of [tileSize.width, tileSize.width * 2].map(
(x) => x * pixelRatio,
)) {
for (const dx of [-1, 0, 1]) {
expectColor(pixelAt(png, x + dx, centre[1]), RED, `seam x ${x + dx}`);
}
}
for (const y of [tileSize.height, tileSize.height * 2].map(
(y) => y * pixelRatio,
)) {
for (const dy of [-1, 0, 1]) {
expectColor(pixelAt(png, centre[0], y + dy), RED, `seam y ${y + dy}`);
}
}

// Walk each edge of the turned square: red a margin inside it, white a
// margin outside.
for (let i = 0; i < 4; i++) {
const a = corners[i];
const b = corners[(i + 1) % 4];
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
// Inward normal: the edge turned a quarter turn towards the centre.
let nx = -(b[1] - a[1]) / length;
let ny = (b[0] - a[0]) / length;
const midX = (a[0] + b[0]) / 2;
const midY = (a[1] + b[1]) / 2;
if ((centre[0] - midX) * nx + (centre[1] - midY) * ny < 0) {
nx = -nx;
ny = -ny;
}
for (const t of sampleAlong(0, 1, margin / length)) {
const px = a[0] + (b[0] - a[0]) * t;
const py = a[1] + (b[1] - a[1]) * t;
expectColor(
pixelAt(png, px + nx * margin, py + ny * margin),
RED,
`edge ${i} in at ${t.toFixed(2)}`,
);
expectColor(
pixelAt(png, px - nx * margin, py - ny * margin),
WHITE,
`edge ${i} out at ${t.toFixed(2)}`,
);
}
}
});

const testFailedDownload = engine.reportsFailedDownloads ? test : test.skip;

testFailedDownload("aborting mid-export fails the download", async () => {
Expand Down
Loading