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

A Data

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.

B Projection

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.

C Renderer

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.

D Export

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

01 Vector tiles, not Overpass

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.

02 SVG as the product

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.

03 Two documents, not one

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

Any placeSearch by name or drop a pin, then set the width of the frame in kilometres rather than by fighting a zoom slider.
Paper sizesStandard print formats with the aspect ratio locked to the paper, so what is composed on screen is what gets cut.
StylingThemes, road weighting by class, separate treatment for open sea and inland water, optional frame and coordinates.
ExportVector SVG, or PNG at a chosen DPI. No watermark, no account, no export limit.

Attribution to OpenStreetMap is baked into the poster itself rather than the page, because the file is what leaves the site.

05 / Try it

Make one and print it

Pick a place that means something, set the frame, download the SVG. Nothing to sign up for.