From 7b902576e69720bd2467d7dbb2f0aa74060b2ded Mon Sep 17 00:00:00 2001
From: Gregor MacLennan
Date: Thu, 17 Sep 2026 15:24:04 +0100
Subject: [PATCH] feat: rotate the exported map by a bearing
Adds a rotation setting so north need not be at the top of the print.
Tiles were already screen-space rects rendered as small MapLibre maps, and
MapLibre turns rigidly about the screen centre, so a bearing only changes
how a screen offset becomes a world point: tileGrid and the renderer's
edge-tile alignment turn their offsets by the bearing, and everything after
the renderer is untouched. The bbox is fitted north-up as before and the
page turns about its centre.
The hidden renderer map now passes a no-op transformConstrain: MapLibre's
default pans a camera whose unrotated frame crosses the latitude limit,
which would misalign an edge tile of a rotated page. fitsWorld checks all
four page corners instead. Raster layers can jog by up to a device pixel at
seams of a rotated page because MapLibre snaps them to whole world pixels
per map; this is documented rather than fixed.
The form gains a Rotation field, the preview map drag-rotates with a
compass and the two stay in sync, and the preview draws the page outline
alongside the bbox since the bbox corners can fall off a rotated page. The
e2e pages use a taller viewport so the longer form needs no scrolling,
which WebKit's checkbox click did not survive.
Closes #5
---
docs/architecture.md | 64 ++++++++++-----
e2e/app.test.ts | 76 +++++++++++++++++-
e2e/browsers.ts | 8 +-
e2e/export.test.ts | 111 +++++++++++++++++++++++++
e2e/harness/main.ts | 35 ++++++++
e2e/map-renderer.test.ts | 91 ++++++++++++++++++++-
src/lib/export/README.md | 21 +++--
src/lib/export/index.ts | 28 +++++--
src/lib/map-renderer/README.md | 27 +++++--
src/lib/map-renderer/index.ts | 36 ++++++---
src/lib/viewport/README.md | 53 +++++++++---
src/lib/viewport/index.test.ts | 143 +++++++++++++++++++++++++++++++++
src/lib/viewport/index.ts | 94 ++++++++++++++++++----
src/main.ts | 9 ++-
src/preview-map.ts | 111 ++++++++++++++++++++-----
src/settings-form.ts | 85 +++++++++++++++++---
src/settings.test.ts | 24 ++++++
src/settings.ts | 20 ++++-
18 files changed, 925 insertions(+), 111 deletions(-)
diff --git a/docs/architecture.md b/docs/architecture.md
index 423bce8..d4e742e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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
@@ -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)
@@ -151,7 +160,7 @@ createMapRenderer(opts: {
}): Promise
interface MapRenderer {
- render(tile: TileRect, zoom: number): Promise; // cropped to tile.width/height, top-down
+ render(tile: TileRect, zoom: number, bearing = 0): Promise; // cropped to tile.width/height, top-down
destroy(): void;
}
maxTileSize(pixelRatio: number): { width: number; height: number } // from a probe WebGL2 context
@@ -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
@@ -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
@@ -236,18 +256,20 @@ and cleans up the download in `finally`.
light DOM (so Tailwind applies): `` and ``. 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
diff --git a/e2e/app.test.ts b/e2e/app.test.ts
index a36347d..934ce70 100644
--- a/e2e/app.test.ts
+++ b/e2e/app.test.ts
@@ -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 {
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);
@@ -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
diff --git a/e2e/browsers.ts b/e2e/browsers.ts
index cecd280..062724b 100644
--- a/e2e/browsers.ts
+++ b/e2e/browsers.ts
@@ -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),
);
diff --git a/e2e/export.test.ts b/e2e/export.test.ts
index f06e702..9b25d09 100644
--- a/e2e/export.test.ts
+++ b/e2e/export.test.ts
@@ -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";
@@ -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]];
@@ -68,6 +78,7 @@ interface ExportRequest {
widthPx: number;
heightPx: number;
pixelRatio: number;
+ bearing?: number;
filename: string;
tileSize?: { width: number; height: number };
}
@@ -94,6 +105,7 @@ describeEngines((engine) => {
widthPx: number;
heightPx: number;
pixelRatio: number;
+ bearing?: number;
filename: string;
tileSize?: { width: number; height: number };
};
@@ -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 () => {
diff --git a/e2e/harness/main.ts b/e2e/harness/main.ts
index 9611668..82ed0f5 100644
--- a/e2e/harness/main.ts
+++ b/e2e/harness/main.ts
@@ -1,15 +1,50 @@
+import { Map as MapLibreMap } from "maplibre-gl";
import * as download from "../../src/lib/download/index.ts";
import * as exporter from "../../src/lib/export/index.ts";
import * as mapRenderer from "../../src/lib/map-renderer/index.ts";
import * as styles from "../../src/lib/styles/index.ts";
import * as viewport from "../../src/lib/viewport/index.ts";
+/** Where MapLibre itself puts `points` on a map with this camera, so a test
+ * can pin the viewport module's rotation convention to the renderer's. */
+function mapLibreProject(
+ camera: viewport.Viewport,
+ points: viewport.LngLat[],
+): [number, number][] {
+ const container = document.createElement("div");
+ container.style.cssText =
+ "position:fixed;top:0;left:-100000px;" +
+ `width:${camera.width}px;height:${camera.height}px`;
+ document.body.append(container);
+ const map = new MapLibreMap({
+ container,
+ style: { version: 8, sources: {}, layers: [] },
+ center: camera.center,
+ zoom: camera.zoom,
+ bearing: camera.bearing ?? 0,
+ transformConstrain: (center, zoom) => ({ center, zoom }),
+ interactive: false,
+ attributionControl: false,
+ trackResize: false,
+ });
+ try {
+ return points.map((point) => {
+ const { x, y } = map.project(point);
+ return [x, y];
+ });
+ } finally {
+ map.remove();
+ container.remove();
+ }
+}
+
const mapPrinter = {
...mapRenderer,
...viewport,
...styles,
...download,
...exporter,
+ mapLibreProject,
};
declare global {
diff --git a/e2e/map-renderer.test.ts b/e2e/map-renderer.test.ts
index 6e32e49..c594a32 100644
--- a/e2e/map-renderer.test.ts
+++ b/e2e/map-renderer.test.ts
@@ -5,8 +5,10 @@ import { baseUrl, describeEngines, harnessUrl, usePage } from "./browsers.ts";
import {
fitBounds,
project,
+ rotateOffset,
tileGrid,
type Bbox,
+ type LngLat,
type TileRect,
type Viewport,
} from "../src/lib/viewport/index.ts";
@@ -57,6 +59,24 @@ function projectedRect(
};
}
+/** Where a lng/lat lands in the viewport's pixels, at any bearing. */
+function toScreen(v: Viewport, point: LngLat): [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, sy + v.height / 2];
+}
+
+/** `[a, b]` corners in screen order, from the square's NW clockwise. */
+function squareCorners([west, south, east, north]: Bbox): LngLat[] {
+ return [
+ [west, north],
+ [east, north],
+ [east, south],
+ [west, south],
+ ];
+}
+
function pixelAt(
pixels: Uint8Array,
width: number,
@@ -81,6 +101,7 @@ interface RenderOptions {
channels?: 3 | 4;
tiles: TileRect[];
zoom: number;
+ bearing?: number;
renderTimeoutMs?: number;
}
@@ -98,6 +119,7 @@ async function renderTiles(
channels?: 3 | 4;
tiles: import("../src/lib/viewport/index.ts").TileRect[];
zoom: number;
+ bearing?: number;
renderTimeoutMs?: number;
} = JSON.parse(json);
const renderer = await window.mapPrinter.createMapRenderer({
@@ -110,7 +132,7 @@ async function renderTiles(
try {
const out: Uint8Array[] = [];
for (const tile of opts.tiles) {
- out.push(await renderer.render(tile, opts.zoom));
+ out.push(await renderer.render(tile, opts.zoom, opts.bearing));
}
return out;
} finally {
@@ -267,6 +289,73 @@ describeEngines((engine) => {
}
});
+ test("viewport's rotation convention matches MapLibre's", async () => {
+ const v = fitBounds(VIEW_BBOX, 256, 200, 30);
+ const points = squareCorners(SQUARE);
+ const theirs = await page.evaluate(
+ (json: string) => {
+ const { camera, points } = JSON.parse(json) as {
+ camera: import("../src/lib/viewport/index.ts").Viewport;
+ points: import("../src/lib/viewport/index.ts").LngLat[];
+ };
+ return window.mapPrinter.mapLibreProject(camera, points);
+ },
+ JSON.stringify({ camera: v, points }),
+ );
+ for (const [i, point] of points.entries()) {
+ const [x, y] = toScreen(v, point);
+ expect(theirs[i][0], `x of corner ${i}`).toBeCloseTo(x, 3);
+ expect(theirs[i][1], `y of corner ${i}`).toBeCloseTo(y, 3);
+ }
+ });
+
+ test.each([
+ { bearing: 90, pixelRatio: 1 },
+ { bearing: 30, pixelRatio: 1 },
+ { bearing: -135, pixelRatio: 2 },
+ ])(
+ "renders the square turned by $bearing° at pixel ratio $pixelRatio",
+ async ({ bearing, pixelRatio }) => {
+ // Square: the tile is square too, so any bearing keeps it inside.
+ const v = fitBounds([-4, -4, 4, 4], 256, 256, bearing);
+ const [tile] = tileGrid(v, TILE_SIZE);
+ const width = tile.width * pixelRatio;
+ const pixels = await renderTile(page, {
+ style: fixtureStyle,
+ pixelRatio,
+ tile,
+ zoom: v.zoom,
+ bearing,
+ });
+
+ const centre = toScreen(v, [0, 0]);
+ const corners = squareCorners(SQUARE).map((c) => toScreen(v, c));
+ const toward = (
+ [x, y]: [number, number],
+ fraction: number,
+ ): [number, number] => [
+ (centre[0] + (x - centre[0]) * fraction) * pixelRatio,
+ (centre[1] + (y - centre[1]) * fraction) * pixelRatio,
+ ];
+ expectColor(
+ pixelAt(pixels, width, centre[0] * pixelRatio, centre[1] * pixelRatio),
+ RED,
+ );
+ for (const corner of corners) {
+ expectColor(pixelAt(pixels, width, ...toward(corner, 0.85)), RED);
+ expectColor(pixelAt(pixels, width, ...toward(corner, 1.15)), WHITE);
+ }
+ // Each edge midpoint, just inside and just outside.
+ for (let i = 0; i < 4; i++) {
+ const a = corners[i];
+ const b = corners[(i + 1) % 4];
+ const mid: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
+ expectColor(pixelAt(pixels, width, ...toward(mid, 0.9)), RED);
+ expectColor(pixelAt(pixels, width, ...toward(mid, 1.1)), WHITE);
+ }
+ },
+ );
+
test("renders RGBA with an opaque alpha channel", async () => {
const v = fitBounds(VIEW_BBOX, 256, 256);
const [tile] = tileGrid(v, TILE_SIZE);
diff --git a/src/lib/export/README.md b/src/lib/export/README.md
index 6a2c871..f4c88c8 100644
--- a/src/lib/export/README.md
+++ b/src/lib/export/README.md
@@ -10,27 +10,34 @@ exportMap(opts: {
bbox: Bbox;
widthPx: number; heightPx: number; // device px
pixelRatio: number; // positive integer
+ bearing?: number; // degrees clockwise from north up, default 0
filename: string;
onProgress?: (fraction: number) => void;
signal?: AbortSignal;
tileSize?: { width: number; height: number }; // test-only
-}): Promise<{ bbox: Bbox; zoom: number }>
+}): Promise<{ bbox: Bbox; corners: [LngLat, LngLat, LngLat, LngLat]; zoom: number; bearing: number }>
```
Resolves once the browser has read the last byte of the download out of the
service worker (or after 60 s if a pre-protocol-2 worker never says so), with
-the bbox the exported image actually covers (grown from `bbox` on the
-non-limiting axis) and the zoom it was rendered at. On iOS the file may still
-be flushing to disk for a moment after that.
+the corners of the page in screen order from the top-left, their envelope as
+`bbox` (north-up, that is `bbox` grown on the non-limiting axis), and the zoom
+and bearing it was rendered at. On iOS the file may still be flushing to disk
+for a moment after that.
+
+`bearing` turns the page about the centre of `bbox` without changing the zoom
+`bbox` fits at north-up, so at most angles the corners of `bbox` fall off the
+page; the UI draws the page outline on the preview for that reason.
## Sizes
`widthPx`/`heightPx` are the pixel dimensions of the PNG. The map is laid out
in CSS px, so both must be whole multiples of `pixelRatio` — which the UI
guarantees by taking its sizes from `viewport.mmToPx` — and the CSS viewport is
-`px / pixelRatio`. A viewport taller than the Mercator world at its zoom is
-rejected rather than rendered, because MapLibre would quietly shift the camera
-(see `viewport.fitsWorld`).
+`px / pixelRatio`. A page that leaves the Mercator world at its zoom — taller
+than it, or turned so a corner crosses the latitude limit — is rejected rather
+than rendered, because there is nothing to draw there (see
+`viewport.fitsWorld`).
Tiles are as wide as the GPU allows (`map-renderer.maxTileSize`) but never
wider than the viewport, so most exports are a single column. Their height is
diff --git a/src/lib/export/index.ts b/src/lib/export/index.ts
index ea8c9c6..50ccda3 100644
--- a/src/lib/export/index.ts
+++ b/src/lib/export/index.ts
@@ -15,7 +15,9 @@ import {
fitsWorld,
tileGrid,
viewportBbox,
+ viewportCorners,
type Bbox,
+ type LngLat,
type TileRect,
} from "../viewport/index.ts";
@@ -27,6 +29,8 @@ export interface ExportOptions {
widthPx: number;
heightPx: number;
pixelRatio: number;
+ /** Degrees clockwise from north up; the page turns about the bbox centre. */
+ bearing?: number;
filename: string;
onProgress?: (fraction: number) => void;
signal?: AbortSignal;
@@ -36,8 +40,12 @@ export interface ExportOptions {
}
export interface ExportResult {
+ /** Envelope of the page; equals `corners` only at bearing 0. */
bbox: Bbox;
+ /** Page corners in screen order: top-left, top-right, bottom-right, bottom-left. */
+ corners: [LngLat, LngLat, LngLat, LngLat];
zoom: number;
+ bearing: number;
}
const CHANNELS = 3;
@@ -73,11 +81,16 @@ export async function exportMap(opts: ExportOptions): Promise {
);
}
- const viewport = fitBounds(opts.bbox, widthPx / pixelRatio, heightPx / pixelRatio);
+ const viewport = fitBounds(
+ opts.bbox,
+ widthPx / pixelRatio,
+ heightPx / pixelRatio,
+ opts.bearing ?? 0,
+ );
if (!fitsWorld(viewport)) {
throw new RangeError(
- "This area is taller than the map at the zoom it would export at — " +
- "widen the area or reduce the height",
+ "This page reaches past the edge of the map at the zoom it would " +
+ "export at — widen the area, reduce the height or turn it less",
);
}
@@ -114,7 +127,7 @@ export async function exportMap(opts: ExportOptions): Promise {
height: heightPx,
channels: CHANNELS,
tiles,
- render: (tile) => map.render(tile.css, viewport.zoom),
+ render: (tile) => map.render(tile.css, viewport.zoom, viewport.bearing),
onProgress,
signal,
})
@@ -139,7 +152,12 @@ export async function exportMap(opts: ExportOptions): Promise {
renderer?.destroy();
}
- return { bbox: viewportBbox(viewport), zoom: viewport.zoom };
+ return {
+ bbox: viewportBbox(viewport),
+ corners: viewportCorners(viewport),
+ zoom: viewport.zoom,
+ bearing: viewport.bearing ?? 0,
+ };
}
/** The widest tile this GPU can render, and the tallest that keeps the buffers
diff --git a/src/lib/map-renderer/README.md b/src/lib/map-renderer/README.md
index 68433a2..261666f 100644
--- a/src/lib/map-renderer/README.md
+++ b/src/lib/map-renderer/README.md
@@ -21,7 +21,7 @@ createMapRenderer(opts: {
}): Promise
interface MapRenderer {
- render(tile: TileRect, zoom: number): Promise;
+ render(tile: TileRect, zoom: number, bearing?: number): Promise;
destroy(): void;
}
@@ -40,8 +40,12 @@ canvas means the tile is too large for this machine (see `maxTileSize`).
The map options are `pixelRatio`, `maxCanvasSize` set to the expected canvas
size (MapLibre's default is 4096×4096, which would clamp larger tiles),
`canvasContextAttributes: { preserveDrawingBuffer: true }`, `fadeDuration: 0`,
-`trackResize: false`, `interactive: false`, `attributionControl: false`. The
-container is `position: fixed; left: -100000px` with an explicit size — never
+`trackResize: false`, `interactive: false`, `attributionControl: false`, and
+a `transformConstrain` that returns the camera unchanged: MapLibre's default
+pans any camera whose unrotated frame crosses ±85.051129°, which would
+misalign an edge tile of a rotated page. The caller is expected to have
+rejected a page that leaves the world (`viewport.fitsWorld`). The container
+is `position: fixed; left: -100000px` with an explicit size — never
`display: none`, which makes MapLibre fall back to 400×300.
MapLibre's WebGL2 context is premultiplied, so every exported pixel has to be
@@ -51,12 +55,14 @@ styles so relative sprite, glyph and source references resolve) gets the same
`OPAQUE_BACKGROUND_LAYER` inserted below its first layer on `style.load` when
it has no background layer.
-### `render(tile, zoom)`
+### `render(tile, zoom, bearing = 0)`
`tile` is a `viewport.tileGrid` rect at most `tileSize` in each dimension. The
-map jumps to the center that puts the rect in the canvas's top-left corner
-(`tile.center` shifted by half the difference between `tileSize` and the rect,
-so an edge tile is cropped at the origin rather than at a fractional offset),
+map jumps to `zoom` and `bearing` at the center that puts the rect in the
+canvas's top-left corner (`tile.center` shifted by half the difference between
+`tileSize` and the rect — a screen-space shift, so it is turned by the bearing
+before being applied in world px — so an edge tile is cropped at the origin
+rather than at a fractional offset),
waits for MapLibre's `idle` event and reads the pixels inside that handler,
before any later frame can repaint the preserved drawing buffer. The result is
a fresh `Uint8Array` of `floor(tile.width × pixelRatio) ×
@@ -67,6 +73,13 @@ bottom-up), alpha dropped when `channels === 3`.
render frame as soon as nothing is dirty. Renders are sequential: calling
`render` while one is in flight rejects.
+Raster, hillshade and colour-relief layers are drawn with MapLibre's
+pixel-aligned matrix, which snaps the camera's world-pixel centre to a whole
+number. North-up, every tile centre of a grid shares one fractional part, so
+the snap is identical across tiles; a rotated grid's centres do not, so
+raster imagery can jog by up to one device pixel across a tile seam. Vector
+layers use the exact matrix and are unaffected.
+
The rect is CSS px while the result is device px, so a caller that also feeds
`mosaic` — whose rects are output pixels — passes `mosaic` the `tileGrid` rects
scaled by `pixelRatio` and `render` the unscaled ones.
diff --git a/src/lib/map-renderer/index.ts b/src/lib/map-renderer/index.ts
index 71806fd..20bb728 100644
--- a/src/lib/map-renderer/index.ts
+++ b/src/lib/map-renderer/index.ts
@@ -11,7 +11,12 @@ import {
ensureOpaqueBackground,
OPAQUE_BACKGROUND_LAYER,
} from "../styles/index.ts";
-import { project, unproject, type TileRect } from "../viewport/index.ts";
+import {
+ project,
+ rotateOffset,
+ unproject,
+ type TileRect,
+} from "../viewport/index.ts";
setWorkerUrl(maplibreWorkerUrl);
@@ -25,7 +30,7 @@ export interface MapRendererOptions {
}
export interface MapRenderer {
- render(tile: TileRect, zoom: number): Promise;
+ render(tile: TileRect, zoom: number, bearing?: number): Promise;
destroy(): void;
}
@@ -83,6 +88,10 @@ export async function createMapRenderer(
pixelRatio,
maxCanvasSize: [canvasWidth, canvasHeight],
canvasContextAttributes: { preserveDrawingBuffer: true },
+ // MapLibre's default constraint pans a camera whose unrotated frame leaves
+ // the ±85.051129° band, which would misalign an edge tile of a rotated
+ // page; the caller has already checked the whole page fits the world.
+ transformConstrain: (center, zoom) => ({ center, zoom }),
fadeDuration: 0,
trackResize: false,
interactive: false,
@@ -154,7 +163,11 @@ export async function createMapRenderer(
const scratch = new Uint8Array(canvasWidth * canvasHeight * 4);
let rendering = false;
- async function render(tile: TileRect, zoom: number): Promise {
+ async function render(
+ tile: TileRect,
+ zoom: number,
+ bearing = 0,
+ ): Promise {
if (rendering) {
throw new Error("render() called while a render is in progress");
}
@@ -207,7 +220,11 @@ export async function createMapRenderer(
};
rejectPending = fail;
map.once("idle", onIdle);
- map.jumpTo({ center: alignedCenter(tile, tileSize, zoom), zoom });
+ map.jumpTo({
+ center: alignedCenter(tile, tileSize, zoom, bearing),
+ zoom,
+ bearing,
+ });
});
} finally {
rendering = false;
@@ -229,15 +246,14 @@ function alignedCenter(
tile: TileRect,
tileSize: { width: number; height: number },
zoom: number,
+ bearing: number,
): [number, number] {
const [x, y] = project(tile.center, zoom);
- return unproject(
- [
- x + (tileSize.width - tile.width) / 2,
- y + (tileSize.height - tile.height) / 2,
- ],
- zoom,
+ const [dx, dy] = rotateOffset(
+ [(tileSize.width - tile.width) / 2, (tileSize.height - tile.height) / 2],
+ bearing,
);
+ return unproject([x + dx, y + dy], zoom);
}
/** readPixels rows are bottom-up; the result is top-down, RGB or RGBA. */
diff --git a/src/lib/viewport/README.md b/src/lib/viewport/README.md
index 465aec7..67da05d 100644
--- a/src/lib/viewport/README.md
+++ b/src/lib/viewport/README.md
@@ -26,7 +26,10 @@ Check `fitsWorld(v)` before rendering.
```ts
type LngLat = [lng: number, lat: number];
type Bbox = [west: number, south: number, east: number, north: number];
-type Viewport = { center: LngLat; zoom: number; width: number; height: number };
+type Viewport = {
+ center: LngLat; zoom: number; width: number; height: number;
+ bearing?: number; // degrees clockwise from north up, as MapLibre's; 0 when absent
+};
type TileRect = {
col: number; row: number;
x: number; y: number; width: number; height: number;
@@ -54,32 +57,56 @@ World pixels at `zoom`, origin at the north-west corner of the world. Latitude
is clamped to ±85.051129; longitude is not wrapped, so a longitude outside
±180 projects outside the world square.
-### `fitBounds(bbox, width, height) → Viewport`
+### `fitBounds(bbox, width, height, bearing = 0) → Viewport`
+
+The largest zoom at which `bbox` fits in `width × height` CSS px north-up,
+with no padding and no maximum zoom, centred on the bbox's Mercator midpoint.
+The zoom is fractional. `bearing` does not change the zoom or centre: the page
+turns about the bbox centre, so at most angles part of the bbox falls off it.
+Throws `TypeError` on an invalid bbox, a non-positive size or a non-finite
+bearing.
+
+### `normalizeBearing(deg) → number`
+
+Wrapped to `(-180, 180]`, the range MapLibre's `getBearing` reports.
+
+### `rotateOffset([x, y], bearing) → [x, y]`
+
+A screen-space offset (x right, y down, CSS px) as the world-pixel offset it
+covers on a map at `bearing`. MapLibre draws the world turned by `-bearing`,
+so the screen axes are the world axes turned by `+bearing`; passing
+`-bearing` goes the other way. The identity at bearing 0.
+
+### `viewportCorners(v) → [LngLat, LngLat, LngLat, LngLat]`
-The largest zoom at which `bbox` fits in `width × height` CSS px, with no
-padding and no maximum zoom, centred on the bbox's Mercator midpoint. The zoom
-is fractional. Throws `TypeError` on an invalid bbox or a non-positive size.
+The lng/lat under the viewport's corners in screen order: top-left,
+top-right, bottom-right, bottom-left. At bearing 0 these are the north-west,
+north-east, south-east and south-west corners of `viewportBbox(v)`.
### `viewportBbox(v) → Bbox`
-What the viewport actually covers — the bbox `fitBounds` was given, grown on
-the non-limiting axis. West/east may fall outside ±180, and north/south outside
+The envelope of `viewportCorners(v)`: at bearing 0 the bbox `fitBounds` was
+given, grown on the non-limiting axis; for a turned page, the axis-aligned
+box around it. West/east may fall outside ±180, and north/south outside
±85.051129, when the viewport is larger than the world at that zoom.
### `fitsWorld(v: Viewport): boolean`
-Whether the viewport's top and bottom edges lie within the ±85.051129 latitude
-band. When false, the export is taller than the world at that zoom and must be
-rejected or letterboxed by the caller.
+Whether every corner of the viewport lies within the ±85.051129 latitude
+band. When false, the page leaves the world at that zoom — taller than it
+north-up, or turned so that a corner crosses the limit — and must be rejected
+or letterboxed by the caller.
### `tileGrid(v, tile) → TileRect[]`
Row-major list of render rects covering the viewport exactly: no gaps, no
overlap. `x`, `y`, `width` and `height` are integer CSS px within the viewport;
right and bottom edge tiles are smaller than `tile` by the remainder. `center`
-is the lng/lat of the rect's own pixel centre at `v.zoom`, so rendering each
-rect as a map of `width × height` CSS px centred there makes the tiles abut
-exactly.
+is the lng/lat under the rect's own pixel centre at `v.zoom` and `v.bearing`,
+so rendering each rect as a map of `width × height` CSS px centred there, at
+the same zoom and bearing, makes the tiles abut exactly: MapLibre turns the
+map rigidly about the screen centre, so a smaller map centred on the point
+under a sub-rectangle's centre shows exactly that sub-rectangle.
The viewport and tile sizes must be positive integers — `TypeError` otherwise.
Rounding a fractional CSS size is the caller's decision, because the pixel
diff --git a/src/lib/viewport/index.test.ts b/src/lib/viewport/index.test.ts
index 550b89c..9de30a7 100644
--- a/src/lib/viewport/index.test.ts
+++ b/src/lib/viewport/index.test.ts
@@ -8,10 +8,13 @@ import {
fitsWorld,
isValidBbox,
mmToPx,
+ normalizeBearing,
project,
+ rotateOffset,
tileGrid,
unproject,
viewportBbox,
+ viewportCorners,
} from "./index.ts";
describe("project / unproject", () => {
@@ -383,3 +386,143 @@ describe("mmToPx", () => {
expect(() => mmToPx(210, 192, 1.5)).toThrow(TypeError);
});
});
+
+describe("normalizeBearing", () => {
+ it("wraps to (-180, 180]", () => {
+ expect(normalizeBearing(0)).toBe(0);
+ expect(normalizeBearing(90)).toBe(90);
+ expect(normalizeBearing(-90)).toBe(-90);
+ expect(normalizeBearing(180)).toBe(180);
+ expect(normalizeBearing(-180)).toBe(180);
+ expect(normalizeBearing(540)).toBe(180);
+ expect(normalizeBearing(270)).toBe(-90);
+ expect(normalizeBearing(360)).toBe(0);
+ expect(normalizeBearing(-450)).toBe(-90);
+ });
+
+ it("rejects non-finite input", () => {
+ expect(() => normalizeBearing(Number.NaN)).toThrow(TypeError);
+ expect(() => normalizeBearing(Number.POSITIVE_INFINITY)).toThrow(TypeError);
+ });
+});
+
+describe("rotateOffset", () => {
+ it("is the identity at bearing 0", () => {
+ expect(rotateOffset([3, -4], 0)).toEqual([3, -4]);
+ });
+
+ it("turns screen-up into world-east at bearing 90 (east up)", () => {
+ // With east up, a point east of the centre is above it on screen, so a
+ // screen offset pointing up (0, -d) must land east: world (+d, 0).
+ const [x, y] = rotateOffset([0, -10], 90);
+ expect(x).toBeCloseTo(10, 9);
+ expect(y).toBeCloseTo(0, 9);
+ const [x2, y2] = rotateOffset([10, 0], 90);
+ expect(x2).toBeCloseTo(0, 9);
+ expect(y2).toBeCloseTo(10, 9);
+ });
+
+ it("preserves length", () => {
+ for (const bearing of [-170, -45, 12.5, 33, 90, 179]) {
+ const [x, y] = rotateOffset([3, 4], bearing);
+ expect(Math.hypot(x, y)).toBeCloseTo(5, 9);
+ }
+ });
+});
+
+describe("bearing", () => {
+ const bbox: Bbox = [-10, -5, 10, 5];
+
+ it("fitBounds keeps the unrotated zoom and centre and records the bearing", () => {
+ const flat = fitBounds(bbox, 800, 400);
+ const turned = fitBounds(bbox, 800, 400, 30);
+ expect(flat.bearing).toBe(0);
+ expect(turned.bearing).toBe(30);
+ expect(turned.zoom).toBe(flat.zoom);
+ expect(turned.center).toEqual(flat.center);
+ expect(fitBounds(bbox, 800, 400, 450).bearing).toBe(90);
+ });
+
+ it("viewportCorners at bearing 0 are the unrotated bbox corners", () => {
+ const v = fitBounds(bbox, 800, 400);
+ const [nw, ne, se, sw] = viewportCorners(v);
+ const [west, south, east, north] = viewportBbox(v);
+ expect(nw[0]).toBeCloseTo(west, 9);
+ expect(nw[1]).toBeCloseTo(north, 9);
+ expect(ne[0]).toBeCloseTo(east, 9);
+ expect(ne[1]).toBeCloseTo(north, 9);
+ expect(se[0]).toBeCloseTo(east, 9);
+ expect(se[1]).toBeCloseTo(south, 9);
+ expect(sw[0]).toBeCloseTo(west, 9);
+ expect(sw[1]).toBeCloseTo(south, 9);
+ });
+
+ it("viewportCorners at bearing 90 put the top edge on the east", () => {
+ const v = fitBounds(bbox, 800, 400, 90);
+ const [tl, tr, br, bl] = viewportCorners(v);
+ // Screen top is east; screen right is south.
+ expect(tl[0]).toBeCloseTo(tr[0], 9);
+ expect(tl[0]).toBeGreaterThan(v.center[0]);
+ expect(bl[0]).toBeLessThan(v.center[0]);
+ expect(tr[1]).toBeLessThan(tl[1]);
+ expect(br[1]).toBeCloseTo(tr[1], 9);
+ // The page's world-space extent swapped axes: 400 px wide, 800 px tall.
+ const [x0, y0] = project(tl, v.zoom);
+ const [x1, y1] = project(br, v.zoom);
+ expect(Math.abs(x1 - x0)).toBeCloseTo(400, 6);
+ expect(Math.abs(y1 - y0)).toBeCloseTo(800, 6);
+ });
+
+ it("viewportBbox is the envelope of the rotated page", () => {
+ const flat = viewportBbox(fitBounds(bbox, 800, 400));
+ const turned = viewportBbox(fitBounds(bbox, 800, 400, 45));
+ expect(turned[0]).toBeLessThan(flat[0]);
+ expect(turned[2]).toBeGreaterThan(flat[2]);
+ expect(turned[1]).toBeLessThan(flat[1]);
+ expect(turned[3]).toBeGreaterThan(flat[3]);
+ expect(viewportBbox(fitBounds(bbox, 800, 400, 360))).toEqual(flat);
+ });
+
+ it("tileGrid keeps the screen rects and rotates the centres", () => {
+ const flat = fitBounds(bbox, 1000, 600);
+ const turned = { ...flat, bearing: 30 };
+ const tile = { width: 256, height: 256 };
+ const flatTiles = tileGrid(flat, tile);
+ const turnedTiles = tileGrid(turned, tile);
+ expect(turnedTiles.map(({ center: _, ...rect }) => rect)).toEqual(
+ flatTiles.map(({ center: _, ...rect }) => rect),
+ );
+ const [cx, cy] = project(turned.center, turned.zoom);
+ for (const t of turnedTiles) {
+ const [x, y] = project(t.center, turned.zoom);
+ const [ex, ey] = rotateOffset(
+ [t.x + t.width / 2 - 500, t.y + t.height / 2 - 300],
+ 30,
+ );
+ expect(x - cx).toBeCloseTo(ex, 6);
+ expect(y - cy).toBeCloseTo(ey, 6);
+ }
+ });
+
+ it("tileGrid at bearing 90 is the unrotated grid turned a quarter turn", () => {
+ const flat = fitBounds(bbox, 1000, 600);
+ const tile = { width: 200, height: 200 };
+ const turnedTiles = tileGrid({ ...flat, bearing: 90 }, tile);
+ const [cx, cy] = project(flat.center, flat.zoom);
+ for (const t of turnedTiles) {
+ const [x, y] = project(t.center, flat.zoom);
+ // A screen offset (sx, sy) from the centre lands at world (-sy, sx).
+ expect(x - cx).toBeCloseTo(-(t.y + t.height / 2 - 300), 6);
+ expect(y - cy).toBeCloseTo(t.x + t.width / 2 - 500, 6);
+ }
+ });
+
+ it("fitsWorld sees a rotated corner leave the world", () => {
+ // A wide strip near the pole fits north-up, but its corners cross the
+ // latitude limit once turned.
+ const v = fitBounds([-60, 70, 60, 84], 1200, 300);
+ expect(fitsWorld(v)).toBe(true);
+ expect(fitsWorld({ ...v, bearing: 30 })).toBe(false);
+ expect(fitsWorld({ ...v, bearing: 180 })).toBe(true);
+ });
+});
diff --git a/src/lib/viewport/index.ts b/src/lib/viewport/index.ts
index a985b4d..f9a4aa8 100644
--- a/src/lib/viewport/index.ts
+++ b/src/lib/viewport/index.ts
@@ -6,6 +6,8 @@ export interface Viewport {
zoom: number;
width: number;
height: number;
+ /** Degrees clockwise from north up, as MapLibre's `bearing`; 0 when absent. */
+ bearing?: number;
}
export interface TileRect {
@@ -91,7 +93,56 @@ export function unproject(point: [number, number], zoom: number): LngLat {
return [lngFromMercatorX(point[0] / size), latFromMercatorY(point[1] / size)];
}
-export function fitBounds(bbox: Bbox, width: number, height: number): Viewport {
+/** Wraps to `(-180, 180]`, the range MapLibre's `getBearing` reports. */
+export function normalizeBearing(bearing: number): number {
+ if (!isFiniteNumber(bearing)) {
+ throw new TypeError(`bearing must be a finite number, got ${bearing}`);
+ }
+ const wrapped = ((((bearing + 180) % 360) + 360) % 360) - 180;
+ return wrapped === -180 ? 180 : wrapped;
+}
+
+/** A screen-space offset (x right, y down) as a world-pixel offset at
+ * `bearing`: MapLibre draws the world turned by `-bearing`, so screen axes
+ * are the world axes turned by `+bearing`. */
+export function rotateOffset(
+ [x, y]: [number, number],
+ bearing: number,
+): [number, number] {
+ const radians = (bearing * Math.PI) / 180;
+ const cos = Math.cos(radians);
+ const sin = Math.sin(radians);
+ return [x * cos - y * sin, x * sin + y * cos];
+}
+
+/** World px at `v.zoom` under the viewport's screen point `[sx, sy]`. */
+function screenToWorld(
+ v: Viewport,
+ [sx, sy]: [number, number],
+): [number, number] {
+ const [cx, cy] = project(v.center, v.zoom);
+ const [dx, dy] = rotateOffset(
+ [sx - v.width / 2, sy - v.height / 2],
+ v.bearing ?? 0,
+ );
+ return [cx + dx, cy + dy];
+}
+
+function screenCorners(v: Viewport): [number, number][] {
+ return [
+ [0, 0],
+ [v.width, 0],
+ [v.width, v.height],
+ [0, v.height],
+ ];
+}
+
+export function fitBounds(
+ bbox: Bbox,
+ width: number,
+ height: number,
+ bearing = 0,
+): Viewport {
if (!isValidBbox(bbox)) {
throw new TypeError(`Invalid bbox: ${JSON.stringify(bbox)}`);
}
@@ -120,24 +171,41 @@ export function fitBounds(bbox: Bbox, width: number, height: number): Viewport {
zoom,
width,
height,
+ bearing: normalizeBearing(bearing),
};
}
+/** The viewport's corners as lng/lat in screen order: top-left, top-right,
+ * bottom-right, bottom-left. */
+export function viewportCorners(
+ v: Viewport,
+): [LngLat, LngLat, LngLat, LngLat] {
+ return screenCorners(v).map((corner) =>
+ unproject(screenToWorld(v, corner), v.zoom),
+ ) as [LngLat, LngLat, LngLat, LngLat];
+}
+
export function viewportBbox(v: Viewport): Bbox {
- const [cx, cy] = project(v.center, v.zoom);
- const [west, north] = unproject([cx - v.width / 2, cy - v.height / 2], v.zoom);
- const [east, south] = unproject([cx + v.width / 2, cy + v.height / 2], v.zoom);
- return [west, south, east, north];
+ const corners = viewportCorners(v);
+ const lngs = corners.map((c) => c[0]);
+ const lats = corners.map((c) => c[1]);
+ return [
+ Math.min(...lngs),
+ Math.min(...lats),
+ Math.max(...lngs),
+ Math.max(...lats),
+ ];
}
export function fitsWorld(v: Viewport): boolean {
const size = worldSize(v.zoom);
- const [, cy] = project(v.center, v.zoom);
const epsilon = size * 1e-9;
- return (
- cy - v.height / 2 >= mercatorY(MAX_LATITUDE) * size - epsilon &&
- cy + v.height / 2 <= mercatorY(-MAX_LATITUDE) * size + epsilon
- );
+ const top = mercatorY(MAX_LATITUDE) * size - epsilon;
+ const bottom = mercatorY(-MAX_LATITUDE) * size + epsilon;
+ return screenCorners(v).every((corner) => {
+ const [, y] = screenToWorld(v, corner);
+ return y >= top && y <= bottom;
+ });
}
export function tileGrid(
@@ -149,10 +217,6 @@ export function tileGrid(
assertPositiveInteger(tile.width, "tile width");
assertPositiveInteger(tile.height, "tile height");
- const [cx, cy] = project(v.center, v.zoom);
- const originX = cx - v.width / 2;
- const originY = cy - v.height / 2;
-
const cols = Math.ceil(v.width / tile.width);
const rows = Math.ceil(v.height / tile.height);
@@ -171,7 +235,7 @@ export function tileGrid(
width,
height,
center: unproject(
- [originX + x + width / 2, originY + y + height / 2],
+ screenToWorld(v, [x + width / 2, y + height / 2]),
v.zoom,
),
});
diff --git a/src/main.ts b/src/main.ts
index 7143fc0..ea31caf 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -33,6 +33,7 @@ function start(app: HTMLElement) {
form.settings = settings;
const preview = new PreviewMap();
preview.bbox = settings.bbox;
+ preview.bearing = settings.bearing;
preview.showBbox = settings.previewBbox;
preview.aspect = settings.width / settings.height;
@@ -69,6 +70,7 @@ function start(app: HTMLElement) {
settings = next;
saveSettings(settings);
preview.bbox = settings.bbox;
+ preview.bearing = settings.bearing;
preview.showBbox = settings.previewBbox;
preview.aspect = settings.width / settings.height;
if (styleChanged || tokenChanged) {
@@ -96,6 +98,10 @@ function start(app: HTMLElement) {
);
});
+ preview.addEventListener("bearing-change", (event) => {
+ form.setBearing((event as CustomEvent).detail);
+ });
+
preview.addEventListener("style-error", (event) => {
const { field, message } = (event as CustomEvent).detail;
if (field === "token") form.tokenError = message;
@@ -132,7 +138,7 @@ function start(app: HTMLElement) {
async function runExport() {
const input = form.styleInput;
if (!input) return;
- const { bbox, dpi, height, mapboxToken, width } = settings;
+ const { bbox, bearing, dpi, height, mapboxToken, width } = settings;
const pixelRatio = dpi / 96;
controller = new AbortController();
clearTimeout(savedTimer);
@@ -147,6 +153,7 @@ function start(app: HTMLElement) {
widthPx: mmToPx(width, dpi, pixelRatio),
heightPx: mmToPx(height, dpi, pixelRatio),
pixelRatio,
+ bearing,
filename: `map-${width}x${height}mm-${dpi}dpi.png`,
onProgress: (fraction) => {
form.progress = fraction;
diff --git a/src/preview-map.ts b/src/preview-map.ts
index 1809603..898bbb3 100644
--- a/src/preview-map.ts
+++ b/src/preview-map.ts
@@ -10,7 +10,12 @@ import {
} from "maplibre-gl";
import type { FeatureCollection } from "geojson";
import { LightElement } from "./lit-base.ts";
-import type { Bbox } from "./lib/viewport/index.ts";
+import {
+ fitBounds,
+ normalizeBearing,
+ viewportCorners,
+ type Bbox,
+} from "./lib/viewport/index.ts";
export interface StyleErrorDetail {
field: "style" | "token";
@@ -24,6 +29,7 @@ const EMPTY_STYLE: StyleSpecification = {
};
const BBOX_SOURCE = "map-printer-bbox";
+const PAGE_SOURCE = "map-printer-page";
const BBOX_LAYER: LayerSpecification = {
id: BBOX_SOURCE,
@@ -38,13 +44,33 @@ const BBOX_LAYER: LayerSpecification = {
},
};
+/** The page as it will print: the bbox grown to the paper's aspect ratio and
+ * turned by the bearing. */
+const PAGE_LAYER: LayerSpecification = {
+ id: PAGE_SOURCE,
+ type: "line",
+ source: PAGE_SOURCE,
+ layout: { "line-join": "miter" },
+ paint: {
+ "line-color": "rgb(0, 90, 255)",
+ "line-opacity": 0.8,
+ "line-width": 1.5,
+ },
+};
+
+/** Half the two-decimal step the form stores, so a map bearing that rounds
+ * to the stored value is neither reported nor snapped. */
+const BEARING_EPSILON = 0.005;
+
/** Right-hand pane: an interactive map at the paper's aspect ratio, with the
- * export bbox drawn on it. */
+ * export bbox and page outline drawn on it. Rotating the map (right-drag or
+ * the compass) fires `bearing-change`. */
export class PreviewMap extends LightElement {
static properties = {
mapStyle: { attribute: false },
transformRequest: { attribute: false },
bbox: { attribute: false },
+ bearing: { type: Number },
showBbox: { type: Boolean },
aspect: { type: Number },
usesToken: { type: Boolean },
@@ -53,6 +79,7 @@ export class PreviewMap extends LightElement {
declare mapStyle: string | StyleSpecification | null;
declare transformRequest: RequestTransformFunction | undefined;
declare bbox: Bbox;
+ declare bearing: number;
declare showBbox: boolean;
declare aspect: number;
/** Whether the current style is a Mapbox one, so a rejected request is the
@@ -72,6 +99,7 @@ export class PreviewMap extends LightElement {
this.mapStyle = null;
this.transformRequest = undefined;
this.bbox = [-180, -85, 180, 85];
+ this.bearing = 0;
this.showBbox = true;
this.aspect = 297 / 210;
this.usesToken = false;
@@ -108,15 +136,18 @@ export class PreviewMap extends LightElement {
container: this.container,
style: EMPTY_STYLE,
transformRequest: this.transformRequest,
- dragRotate: false,
+ bearing: this.bearing,
pitchWithRotate: false,
+ touchPitch: false,
+ maxPitch: 0,
});
this.map.addControl(
- new NavigationControl({ showCompass: false }),
+ new NavigationControl({ showCompass: true, visualizePitch: false }),
"top-left",
);
this.map.on("style.load", () => this.onStyleLoad());
this.map.on("error", (event) => this.onMapError(event));
+ this.map.on("rotateend", () => this.onRotateEnd());
this.zoomToBbox(0);
this.applyStyle();
this.resizeObserver = new ResizeObserver(() => this.fitContainer());
@@ -129,7 +160,15 @@ export class PreviewMap extends LightElement {
this.map?.setTransformRequest(this.transformRequest ?? null);
}
if (changed.has("mapStyle")) this.applyStyle();
- if (changed.has("bbox") || changed.has("showBbox")) this.drawBbox();
+ if (changed.has("bearing")) this.applyBearing();
+ if (
+ changed.has("bbox") ||
+ changed.has("bearing") ||
+ changed.has("aspect") ||
+ changed.has("showBbox")
+ ) {
+ this.drawBbox();
+ }
}
zoomToBbox(duration?: number) {
@@ -138,7 +177,24 @@ export class PreviewMap extends LightElement {
[this.bbox[0], this.bbox[1]],
[this.bbox[2], this.bbox[3]],
],
- duration === undefined ? {} : { duration },
+ { bearing: this.bearing, ...(duration === undefined ? {} : { duration }) },
+ );
+ }
+
+ private applyBearing() {
+ if (!this.map) return;
+ if (Math.abs(this.map.getBearing() - this.bearing) < BEARING_EPSILON) {
+ return;
+ }
+ this.map.setBearing(this.bearing);
+ }
+
+ private onRotateEnd() {
+ if (!this.map) return;
+ const bearing = normalizeBearing(this.map.getBearing());
+ if (Math.abs(bearing - this.bearing) < BEARING_EPSILON) return;
+ this.dispatchEvent(
+ new CustomEvent("bearing-change", { detail: bearing }),
);
}
@@ -170,6 +226,11 @@ export class PreviewMap extends LightElement {
data: emptyFeatures(),
});
this.map.addLayer(BBOX_LAYER);
+ this.map.addSource(PAGE_SOURCE, {
+ type: "geojson",
+ data: emptyFeatures(),
+ });
+ this.map.addLayer(PAGE_LAYER);
}
this.drawBbox();
this.dispatchEvent(
@@ -180,10 +241,23 @@ export class PreviewMap extends LightElement {
}
private drawBbox() {
- const source = this.map?.getSource(BBOX_SOURCE) as
+ const bboxSource = this.map?.getSource(BBOX_SOURCE) as
+ | GeoJSONSource
+ | undefined;
+ const pageSource = this.map?.getSource(PAGE_SOURCE) as
| GeoJSONSource
| undefined;
- source?.setData(this.showBbox ? bboxFeatures(this.bbox) : emptyFeatures());
+ if (!bboxSource || !pageSource) return;
+ if (!this.showBbox) {
+ bboxSource.setData(emptyFeatures());
+ pageSource.setData(emptyFeatures());
+ return;
+ }
+ bboxSource.setData(polygonFeatures(bboxRing(this.bbox)));
+ // The page's geographic outline depends only on the paper's aspect ratio,
+ // not its pixel size.
+ const page = fitBounds(this.bbox, this.aspect, 1, this.bearing);
+ pageSource.setData(polygonFeatures(viewportCorners(page)));
}
/** Marks the field that is wrong instead of tearing the map down, with
@@ -218,7 +292,16 @@ function emptyFeatures(): FeatureCollection {
return { type: "FeatureCollection", features: [] };
}
-function bboxFeatures([west, south, east, north]: Bbox): FeatureCollection {
+function bboxRing([west, south, east, north]: Bbox): [number, number][] {
+ return [
+ [west, south],
+ [east, south],
+ [east, north],
+ [west, north],
+ ];
+}
+
+function polygonFeatures(ring: readonly [number, number][]): FeatureCollection {
return {
type: "FeatureCollection",
features: [
@@ -227,15 +310,7 @@ function bboxFeatures([west, south, east, north]: Bbox): FeatureCollection {
properties: {},
geometry: {
type: "Polygon",
- coordinates: [
- [
- [west, south],
- [east, south],
- [east, north],
- [west, north],
- [west, south],
- ],
- ],
+ coordinates: [[...ring, ring[0]!]],
},
},
],
diff --git a/src/settings-form.ts b/src/settings-form.ts
index e18d137..f3dbcc1 100644
--- a/src/settings-form.ts
+++ b/src/settings-form.ts
@@ -12,6 +12,7 @@ import {
DPI_OPTIONS,
formatBbox,
parseBbox,
+ parseBearing,
type Dpi,
type Settings,
} from "./settings.ts";
@@ -57,7 +58,12 @@ export class SettingsForm extends LightElement {
declare message: ExportMessage | null;
/** Raw text of the fields that can be mid-edit and unparseable. */
- private declare draft: { bbox: string; width: string; height: string };
+ private declare draft: {
+ bbox: string;
+ width: string;
+ height: string;
+ bearing: string;
+ };
private declare accepted: boolean;
constructor() {
@@ -72,7 +78,7 @@ export class SettingsForm extends LightElement {
this.progress = 0;
this.message = null;
this.accepted = false;
- this.draft = { bbox: "", width: "", height: "" };
+ this.draft = { bbox: "", width: "", height: "", bearing: "" };
}
connectedCallback() {
@@ -81,13 +87,26 @@ export class SettingsForm extends LightElement {
bbox: formatBbox(this.settings.bbox),
width: String(this.settings.width),
height: String(this.settings.height),
+ bearing: formatBearing(this.settings.bearing),
};
}
+ /** For the preview map's compass: the field follows the map. */
+ setBearing(bearing: number) {
+ const value = parseBearing(formatBearing(bearing));
+ if (value === null) return;
+ this.draft = { ...this.draft, bearing: formatBearing(value) };
+ if (value !== this.settings.bearing) this.commit({ bearing: value });
+ }
+
private get bbox() {
return parseBbox(this.draft.bbox);
}
+ private get bearing() {
+ return parseBearing(this.draft.bearing);
+ }
+
private get widthMm() {
return positiveNumber(this.draft.width);
}
@@ -113,7 +132,12 @@ export class SettingsForm extends LightElement {
const { width, height } = this.pixelSize;
const ratio = this.pixelRatio;
return !fitsWorld(
- fitBounds(this.settings.bbox, width / ratio, height / ratio),
+ fitBounds(
+ this.settings.bbox,
+ width / ratio,
+ height / ratio,
+ this.settings.bearing,
+ ),
);
}
@@ -126,6 +150,7 @@ export class SettingsForm extends LightElement {
this.styleError === null &&
this.tokenError === null &&
this.bbox !== null &&
+ this.bearing !== null &&
this.widthMm !== null &&
this.heightMm !== null &&
!this.tooTall
@@ -155,6 +180,14 @@ export class SettingsForm extends LightElement {
else this.requestUpdate();
}
+ private onBearing(event: Event) {
+ const value = (event.target as HTMLInputElement).value;
+ this.draft = { ...this.draft, bearing: value };
+ const bearing = parseBearing(value);
+ if (bearing !== null) this.commit({ bearing });
+ else this.requestUpdate();
+ }
+
render() {
return html`
Map Printer
@@ -162,8 +195,8 @@ export class SettingsForm extends LightElement {
Export a print-resolution PNG of a map area, straight from your browser.
${this.styleField()} ${this.tokenField()} ${this.sizeFields()}
- ${this.bboxField()} ${this.dpiField()} ${this.zoomLine()}
- ${this.attributionBlock()} ${this.actions()}
+ ${this.bboxField()} ${this.bearingField()} ${this.dpiField()}
+ ${this.zoomLine()} ${this.attributionBlock()} ${this.actions()}
`;
}
@@ -268,11 +301,35 @@ export class SettingsForm extends LightElement {
previewBbox: (e.target as HTMLInputElement).checked,
})}
/>
- Preview bounding box on the map
+ Preview bounding box and page outline on the map
`;
}
+ private bearingField(): TemplateResult {
+ const invalid = this.bearing === null;
+ return html`
+
Rotation (degrees)
+
this.onBearing(e)}
+ />
+
+ 0 is north up, positive turns clockwise about the centre of the
+ bounding box. Or rotate the preview map: right-drag or the compass.
+
+ ${invalid
+ ? html`
+ Enter a rotation in degrees.
+
`
+ : nothing}
+
`;
+ }
+
private dpiField(): TemplateResult {
return html`
Target print DPI
@@ -300,20 +357,23 @@ export class SettingsForm extends LightElement {
}
if (this.tooTall) {
return html`
- This area is taller than the map at the zoom it would export at — widen
- the area or reduce the page height.
+ This page reaches past the edge of the map at the zoom it would export
+ at — widen the area, reduce the page height or turn it less.
`;
}
const { width, height } = this.pixelSize;
const ratio = this.pixelRatio;
- const { zoom } = fitBounds(
+ const { zoom, bearing = 0 } = fitBounds(
this.settings.bbox,
width / ratio,
height / ratio,
+ this.settings.bearing,
);
return html`
Map will export at zoom ${Math.round(zoom * 1000) / 1000} sized
- ${width}px x ${height}px
+ ${width}px x ${height}px${bearing
+ ? html`, rotated ${formatBearing(bearing)}°`
+ : nothing}
`;
}
@@ -412,6 +472,11 @@ function positiveNumber(text: string): number | null {
return text.trim() && Number.isFinite(value) && value > 0 ? value : null;
}
+/** Two decimals is finer than a compass drag resolves and keeps the field short. */
+function formatBearing(bearing: number): string {
+ return String(Math.round(bearing * 100) / 100);
+}
+
if (!customElements.get("settings-form")) {
customElements.define("settings-form", SettingsForm);
}
diff --git a/src/settings.test.ts b/src/settings.test.ts
index 0883ab5..c7aa468 100644
--- a/src/settings.test.ts
+++ b/src/settings.test.ts
@@ -4,6 +4,7 @@ import {
DEFAULT_SETTINGS,
loadSettings,
parseBbox,
+ parseBearing,
saveSettings,
} from "./settings.ts";
@@ -54,6 +55,23 @@ describe("parseBbox", () => {
});
});
+describe("parseBearing", () => {
+ test("accepts any finite number of degrees and wraps it", () => {
+ expect(parseBearing("0")).toBe(0);
+ expect(parseBearing(" -45.5 ")).toBe(-45.5);
+ expect(parseBearing("270")).toBe(-90);
+ expect(parseBearing("180")).toBe(180);
+ expect(parseBearing("-180")).toBe(180);
+ });
+
+ test("rejects blanks and non-numbers", () => {
+ expect(parseBearing("")).toBeNull();
+ expect(parseBearing(" ")).toBeNull();
+ expect(parseBearing("north")).toBeNull();
+ expect(parseBearing("Infinity")).toBeNull();
+ });
+});
+
describe("loadSettings", () => {
beforeEach(() => {
globalThis.localStorage = new MemoryStorage() as unknown as Storage;
@@ -78,6 +96,7 @@ describe("loadSettings", () => {
bbox: [1, 2, "three", 4],
dpi: 150,
previewBbox: "yes",
+ bearing: "45",
}),
);
expect(loadSettings()).toEqual({
@@ -87,6 +106,11 @@ describe("loadSettings", () => {
});
});
+ test("wraps a stored bearing", () => {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ bearing: 270 }));
+ expect(loadSettings().bearing).toBe(-90);
+ });
+
test("round-trips through saveSettings", () => {
const settings = { ...DEFAULT_SETTINGS, dpi: 288 as const, width: 420 };
saveSettings(settings);
diff --git a/src/settings.ts b/src/settings.ts
index c8770f7..9783a07 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,4 +1,9 @@
-import { clampBbox, isValidBbox, type Bbox } from "./lib/viewport/index.ts";
+import {
+ clampBbox,
+ isValidBbox,
+ normalizeBearing,
+ type Bbox,
+} from "./lib/viewport/index.ts";
export const DPI_OPTIONS = [96, 192, 288, 384] as const;
export type Dpi = (typeof DPI_OPTIONS)[number];
@@ -11,6 +16,8 @@ export interface Settings {
/** Page height in mm. */
height: number;
bbox: Bbox;
+ /** Degrees clockwise from north up, in `(-180, 180]`. */
+ bearing: number;
previewBbox: boolean;
dpi: Dpi;
}
@@ -22,6 +29,7 @@ export const DEFAULT_SETTINGS: Settings = {
width: 297,
height: 210,
bbox: [-7.1354, 57.9095, -6.1357, 58.516],
+ bearing: 0,
previewBbox: true,
dpi: 96,
};
@@ -41,6 +49,12 @@ export function formatBbox(bbox: Bbox): string {
return bbox.join(", ");
}
+/** Any finite number of degrees, wrapped to `(-180, 180]`. */
+export function parseBearing(text: string): number | null {
+ const value = Number(text.trim());
+ return text.trim() && Number.isFinite(value) ? normalizeBearing(value) : null;
+}
+
export function loadSettings(): Settings {
let raw: unknown = null;
try {
@@ -62,6 +76,10 @@ export function loadSettings(): Settings {
width: positiveNumber(stored.width) ?? DEFAULT_SETTINGS.width,
height: positiveNumber(stored.height) ?? DEFAULT_SETTINGS.height,
bbox: bbox && isValidBbox(bbox) ? bbox : DEFAULT_SETTINGS.bbox,
+ bearing:
+ typeof stored.bearing === "number" && Number.isFinite(stored.bearing)
+ ? normalizeBearing(stored.bearing)
+ : DEFAULT_SETTINGS.bearing,
previewBbox:
typeof stored.previewBbox === "boolean"
? stored.previewBbox