Poster / Engineering case study
A print shop file, made in a browser tab
Pick any place on earth, choose a paper size, download a file that a printer can put on a wall. The map is drawn from raw vector tiles rather than a map library, because the output is not a map you pan around — it is a document that has to survive being scaled to a metre wide.
My role: sole engineer — data pipeline, projection, renderer, export, interface. August 2026.
00 / Why
The same poster, without the checkout
Minimalist city map posters are a whole product category, and most of them sell a PNG for twenty to forty euros. The underlying data is OpenStreetMap, which is free and open. What is being charged for is the arrangement of it.
So this does the same job and gives it away, and it can afford to because nothing about it costs money to run: tiles come from a public CDN, every pixel is drawn on the visitor's machine, and the only hosting involved is a folder of static files. There is no watermark and no account, because there is nothing to protect or upsell.
01 / Architecture
Four steps, no map library
Vector tiles fetched straight from OpenFreeMap and decoded in the browser. Roads arrive already classified and already generalised for their zoom level, so a poster of a whole city does not drown in driveways.
Web Mercator by hand — four short functions. The bounding box is derived from a centre point and a width in kilometres, then a projector maps every coordinate into the poster's own pixel space.
The poster is emitted as an SVG string. It is split into a map body and the chrome around it, because one walks tens of thousands of coordinates and the other is a handful of elements — typing a title should not re-project a city.
SVG is the real deliverable — a print shop scales it to any size with no loss. PNG exists alongside it, rasterised at the pixel count the chosen paper size and DPI imply.
02 / How the map is built
From a place name to a drawn street
Four stages, and the whole chain runs client-side. The short version of each, in the code that actually does it.
1 — Pick a zoom the poster can carry
Detail is not a setting, it is a consequence. The widest zoom whose tile count stays under a ceiling wins, so a village gets fine streets and a metropolis gets arterials, without anyone choosing.
src/data/tiles.ts
// Highest zoom whose tile count stays reasonable.
export function zoomFor(bbox: Bbox): number {
for (let z = MAX_ZOOM; z >= 8; z--) {
const r = tileRange(bbox, z)
const count = (r.maxX - r.minX + 1) * (r.maxY - r.minY + 1)
if (count <= MAX_TILES) return z
}
return 8
}2 — Fetch the tiles in parallel
Every tile in range is requested at once rather than in sequence, and the whole batch carries an AbortSignal — dragging the map cancels the previous request set instead of racing it.
src/data/tiles.ts
const z = zoomFor(bbox)
const range = tileRange(bbox, z)
const jobs: Promise<VectorTile | null>[] = []
for (let x = range.minX; x <= range.maxX; x++)
for (let y = range.minY; y <= range.maxY; y++)
jobs.push(fetchTile(z, x, y, signal))
const tiles = await Promise.all(jobs)3 — Project coordinates into the page
Web Mercator written out rather than imported. The projector is built once per render and closes over the bounding box, so drawing a line is a pair of multiplications and nothing else.
src/data/geometry.ts
export function lonToX(lon: number) { return (lon + 180) / 360 }
export function latToY(lat: number) {
const s = Math.sin((lat * Math.PI) / 180)
return 0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)
}
// One closure per render; every point goes through this.
export function projector(bbox: Bbox, width: number, height: number) {
const x0 = lonToX(bbox.west), xSpan = lonToX(bbox.east) - x0
const y0 = latToY(bbox.north), ySpan = latToY(bbox.south) - y0
return (lat: number, lon: number): [number, number] => [
((lonToX(lon) - x0) / xSpan) * width,
((latToY(lat) - y0) / ySpan) * height,
]
}4 — Hand over a file, not a screenshot
SVG goes straight out as a blob. PNG is rasterised at the size the physical format implies, so a 50×70 cm poster at 300 DPI comes out at the pixel count a printer expects rather than whatever the screen happened to be.
src/poster/export.ts
// Physical size decides the pixels, not the viewport.
const pxPerMm = dpi / 25.4
const width = Math.round(widthMm * pxPerMm)
const height = Math.round(heightMm * pxPerMm)
const image = new Image()
image.src = URL.createObjectURL(
new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }),
)03 / Decisions
Decisions that mattered
The obvious source for OpenStreetMap geometry is an Overpass query. It was rejected for three reasons that all bite the same user: a shared free endpoint queues under load, the response is raw and ungeneralised, and it can rate-limit somebody in the middle of an edit. Tiles come off a CDN in milliseconds and arrive pre-thinned per zoom.
Why it matters: the cost is that detail is whatever the tile schema decided — which, for something meant to be looked at rather than navigated by, is the right trade.
Most tools in this category hand back a raster at a fixed size. Here the document is vector all the way through — the renderer emits an SVG string and the download is that string. Nothing is ever rasterised unless the visitor asks for a PNG.
Why it matters: the same file works as a phone wallpaper and as a metre-wide print, and a print shop can open it without asking for a bigger export.
The map body and the chrome around it — title, subtitle, coordinates, frame — are rendered as separate fragments with their own ids. Editing text swaps only the chrome.
Why it matters: typing in the title field stays instant on a large city, because the tens of thousands of projected points underneath are never touched.
04 / Shipped
What is in the product
Attribution to OpenStreetMap is baked into the poster itself rather than the page, because the file is what leaves the site.