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`
+ + 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`
@@ -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