How StaticMaps turns options into an image, and why each stage is built the way it is. This is written for people working on the library: it assumes you know Elixir and OTP and have used a slippy map, and explains the map maths from there. For the public API, see README.md. For what is planned next, see ROADMAP.md.

The pipeline

opts ──▶ Options ──▶ Viewport ──▶ TileGrid ──▶ Fetcher ──▶ Canvas ──▶ encode ──▶ binary
          validate    fit centre     which        cache,      stitch+crop,
          + normalise  and zoom      tiles,       source      markers,
                                      where                   attribution

Each stage is a pure function of the one before it, except the fetcher, which is the only stage that does I/O. That split is what makes everything else testable without a network.

ModuleJobPure?
StaticMaps.Optionsvalidate and normalise options, look up the source's info/1yes
StaticMaps.Projectionlat/lng ↔ world pixels, latitude clampyes
StaticMaps.Viewportthe resolved view, world-to-output rounding, and fitting centre and zoomyes
StaticMaps.TileGridwhich tiles cover the viewport and where each is placedyes
StaticMaps.Fetcherget the bytes for every tile: cache, dedupe, limit, retryno
StaticMaps.TileCachebehaviour, with an ETS implementationno
StaticMaps.CircuitBreakerskips a source that keeps failingno
StaticMaps.Markermarker options, how far each marker reaches, the pin's SVGyes
StaticMaps.Canvasthe only module that calls libvipsyes, given bytes
StaticMaps.Plugoptional HTTP layer over render/1no
StaticMaps.Plug.URLthe signed URL format, written by path/3 and read by the Plugyes
StaticMaps.Plug.Rendersjoins identical renders and limits each Plug's concurrencyno

Coordinates and projection

There are three coordinate spaces, and most bugs in prior art come from mixing them up.

  • Geographic: {lat, lng} in degrees. What the caller gives us.
  • World pixels: Web Mercator, where the whole world is a square 256 · 2^zoom pixels on a side, x growing east and y growing south. All geometry happens here, in floats.
  • Output pixels: the image, width · scale by height · scale. World pixels become output pixels by subtracting the top-left corner of the viewport and multiplying by scale.

StaticMaps.Projection owns the conversion. Latitude is clamped to ±85.05112878° before projecting, because Mercator y goes to infinity at the poles. It never wraps latitude: lat 100 is an invalid input, not lat −80.

The 256 in the world size is fixed. It defines what a zoom level means, the convention every slippy map uses, and is independent of the size of the tile images a source serves. Confusing the two is the most common tile-maths bug in prior art; see Tile size and scale.

Rounding happens once, at the end, when a position becomes an output pixel, and it always rounds halves up. Kernel.round/1 rounds halves away from zero, which puts −0.5 and 0.5 two pixels apart and opens a one-pixel seam between neighbouring tiles. Tiles and overlays go through the same function, Viewport.to_output/2, so a marker and the street under it can never disagree by a pixel.

Fitting the viewport

StaticMaps.Viewport resolves :center and :zoom when the caller does not give both.

  1. Collect what must be visible: the :bounds corners and every marker. A :bounds with west > east crosses the antimeridian, so east gets + 360 before projecting.
  2. Unwrap longitudes onto the shortest arc that covers them all. Markers at 179° and −179° are 2° apart, not 358°, and a box around them has to be contiguous in world pixels. The shortest arc always starts where some marker or bounds edge starts, so trying each of those as the start and keeping the shortest is enough.
  3. For each candidate zoom, highest first within the first source's range, project everything to world pixels and grow the box by each marker's icon extent (a pin's tip is its coordinate; the rest of it sits above).
  4. Take the first zoom where that box, plus :padding on every side and the attribution strip at the bottom, fits in :size. If none does, take the lowest.
  5. The centre is the middle of that box in world pixels, then projected back. Because the box includes the pins, a single pin is centred with its whole icon in view, not with its tip at the centre and its top cut off in a short image.

Everything at one point has no size to fit, so it gets zoom 15 instead: close enough to see the street, far enough to see where it is. With a centre but no zoom, the centre stays put and the zoom is the highest at which everything fits around it; with a zoom but no centre, the zoom stays and the centre is the middle of the box at that zoom.

Taking the middle in degrees instead is a common mistake. Mercator stretches latitudes unevenly, so a centre halfway between two latitudes in degrees is not halfway between them on the map. For a box spanning many degrees of latitude, the view ends up off-centre and the padding is not respected.

The tile grid

StaticMaps.TileGrid turns a viewport into a list of placements: %{key: {z, x, y}, at: {px, py}}.

  • The tile columns and rows are those covering the viewport in world pixels, kept only if their rounded output position still lands on the image. When the view's origin sits on a half pixel, rounding can push a tile that just touches the view entirely off it, and fetching that tile would be wasted.
  • Placement uses the unwrapped column. A view across the antimeridian may need column -1 or 2^z. Its position on the canvas is computed from that unwrapped column. Only the fetch key wraps, with Integer.mod(x, 2^z), which is correct for negative numbers where rem/2 is not.
  • Rows outside 0..2^z - 1 are dropped, not wrapped. Past the poles there is no map; the canvas background shows through. Wrapping them fetches tiles from the opposite pole, and requesting them fetches tiles that do not exist.
  • Keys are deduplicated. At zoom 0 or 1 a wide image may need the same tile in several places. It is fetched once and placed many times.

Tile size and scale

:zoom always means the 256-pixel convention above. A source's info/1 says how its tiles relate to it, and the grid picks the tile zoom and how large each tile is drawn:

Source tile_size:scaleSource lists scale 2?Tiles fetchedDrawn at
2561—zoom z256 px
2562yeszoom z, high-density512 px, native
2562nozoom z512 px, scaled up
5121—zoom z − 1512 px
5122—zoom z − 11024 px, scaled up

A 512-pixel tile at zoom z − 1 covers the same ground as four 256-pixel tiles at zoom z, which is why a 512 source is asked for one zoom lower. Its minimum usable :zoom is therefore one higher than its own minimum.

Every fetched tile is checked against the size this table predicts. A mismatch is an error naming the source, because it means info/1 is wrong, and silently scaling would hide that.

Fetching

StaticMaps.Fetcher gets bytes for a list of tile keys. For each key it:

  1. looks in the tile cache;
  2. joins an in-flight fetch of the same key if another render already started one;
  3. otherwise waits for a slot under the source's concurrency limit and calls the source's fetch/2 in its own task, so a source that raises or hangs fails that tile and nothing else.

The concurrency limit is per source and shared across all renders, not per render. A per-render limit of 2 is no limit at all when fifty renders run at once, and OSM's policy is about the total load you put on them. A source here is its module together with its options, so two Source.Template servers do not throttle each other. Source.OSM allows two concurrent requests; other sources default to eight.

A Source.Template with several mirrors still has one limit, shared by all of them. Giving each mirror its own limit looks natural but cannot hold during failover: while one mirror is down, its tiles move to the others, which then carry their own share and the dead mirror's at once, twice their limit, just when they are the only servers left. One limit for the source means no mirror ever sees more than :max_concurrency requests.

A render that gives up waiting withdraws its requests: queued fetches nobody is waiting for are dropped, and late replies are discarded rather than left in the caller's mailbox. The same happens when a caller process dies mid-render.

StaticMaps.Source.HTTP implements fetch/2 for URL-based sources:

  • One User-Agent for everything: StaticMaps/<version> (+<contact>). Headers a source adds are merged with it, never replace it.
  • A connect timeout and a receive timeout on every request. There is no request without a deadline.
  • Retries, with exponential backoff, on transport errors, 408, 429, 500, 502, 503 and 504. Retry-After is honoured up to a limit, 10 seconds by default; a server asking for a longer wait fails the tile instead of stalling the render. A 404 is not retried; it means the tile does not exist.
  • The response must be 200 with an image content type, and must be a complete image of the expected size, before it counts as a tile. Anything else is an error and is not cached. Completeness is checked from each format's end marker, because libvips decodes a truncated PNG or JPEG without complaint and fills in the missing rows.
  • Subdomains, when a source uses them, are chosen from x + y, so a tile always has the same URL and HTTP caches along the way can do their job.

A render whose tiles all come back is complete. Otherwise the render fails with %StaticMaps.TileError{}, or, under on_tile_error: :fill, the missing placements are dropped and the background shows through. There is no third case where some errors are swallowed and others crash.

Fallback sources and the circuit breaker

:source may be a list. render/1 tries each source in order, building a separate tile grid for each, since sources can differ in tile size. The first source whose tiles all come back is drawn. Tiles are never mixed between sources: providers draw different maps, and a fallback tile in the middle of the primary's map is a visible patch with the wrong attribution. Mixing is only safe between mirrors of one source, which is why mirrors live inside Source.Template and fallback lives in render/1.

If every source fails, a render with one source returns its %TileError{}, and a render with several returns %StaticMaps.UnavailableError{} listing each attempt. Under on_tile_error: :fill, the first source that was tried is drawn with its gaps.

StaticMaps.CircuitBreaker counts consecutive failed renders per source, in an ETS table. Only failures that say the source is unhealthy count: connection errors, timeouts, 5xx, 408, 429, 401 and 403, responses that are not images, truncated images, and crashes. A 404 or a tile of the wrong size does not; otherwise one area of the map with no tiles would take a working source offline for every render. At the threshold, 5 by default, the source is open: renders skip it for the cooldown, 30 seconds by default. When the cooldown ends the next render tries it again. Success clears the count; another failure opens it for a further cooldown. Without the breaker, a source that is down costs every render a full round of timeouts and retries before the fallback is even tried.

The tile cache

StaticMaps.TileCache is a behaviour: get/1, put/2, and nothing else the pipeline depends on. Freshness, revalidation and validation live in StaticMaps.TileCache itself, so an implementation only stores and evicts. The default implementation is an ETS table owned by a process in the library's supervision tree.

Only sources that implement the optional fetch_cacheable/3 callback are cached. Every HTTP source does; a source that reads tiles from local storage gains nothing from a copy in memory. A source whose info/1 says cache: false is skipped entirely: its tiles are never stored, never answered from the cache, and never served stale. Several commercial providers forbid server-side caching in their terms, so this is per source; the rest of the sources in a render are still cached.

  • Key: {source_module, hash(source_opts), z, x, y, scale}. The options are hashed so an API key in them never sits in a cache key or a log line.
  • Freshness: Cache-Control: s-maxage, else max-age, less any Age a CDN in front of the server reports; else Expires; else 7 days. no-cache is stored but revalidated every time; no-store is not stored. An unreadable Expires, such as the common Expires: 0, counts as already expired.
  • Where it is checked: a fresh tile is answered in the rendering process before anything is queued, so a cache hit never waits for a slot behind slow fetches.
  • Revalidation: an expired entry keeps its ETag, Last-Modified and lifetime, so the next fetch is a conditional request, and a 304 renews it without a download. Many servers send a bare 304; the entry then keeps the lifetime it was stored with, rather than a no-cache tile turning into a week-long one. If the server cannot be reached, the expired entry is served rather than failing the render.
  • Failure: a cache that raises, such as the ETS table while its owner restarts, is a miss. Renders carry on fetching; they never fail because of the cache.
  • Validation: a response is stored only after it decodes as a complete image of the expected size, so a bad tile is never cached for a week.
  • Size: a byte limit, 64 MB by default. When a put goes over it, the least recently used entries are evicted down to 90% of the limit, so eviction does not run on every put. Reads go straight to the ETS table, but puts and eviction go through its owner process, so the byte count is only changed in one place and cannot drift. A cache that never evicts is an outage waiting for a busy week.

A disk-backed implementation writes to a temporary file and renames it into place, so a reader never sees half a tile.

Compositing

StaticMaps.Canvas is the only module that talks to libvips, through vix, which ships precompiled libvips binaries. Keeping it in one module means a change of imaging library touches one file.

  1. Create a canvas of the output size, filled with :background.
  2. Insert each tile at its placement. libvips clips anything outside the canvas, so stitching and cropping are one step, and the full mosaic of tiles is never built. A tile with transparency is flattened against :background first, and a tile fetched at scale 1 for a scale 2 render is scaled up to its drawn size.
  3. Draw markers, in order. The default pin is an SVG built from a template, and SVG icons are loaded as they are; libvips renders both at the output scale, so edges are anti-aliased and a scale: 2 pin is drawn at twice the size, not enlarged. A label and a colour are escaped before going into the SVG, so no marker option can inject markup. A marker is drawn once for every copy of the world it lands on.
  4. Draw the attribution strip: text on a translucent box, in a bottom corner, wrapping onto more lines when the image is too narrow. If libvips draws nothing, because no font is installed, the render fails: a map without its credit must not pass as finished.
  5. Encode. A JPEG has no transparency, so the background colour matters there.

Overlays are composited onto the tiles with an alpha channel added for the purpose, then the canvas goes back to three bands, so every image has the same shape whatever was drawn on it.

All drawing uses one draw order: tiles, then markers, then attribution. Attribution is always on top, so nothing a caller draws can cover it.

Fitting has to know the strip's height before anything is drawn, so StaticMaps.Options measures the strip of every source the render might use at the requested width, and passes the tallest to Viewport.fit/1 as a band along the bottom that nothing is fitted into. Measuring every source matters because a fallback's credit may wrap onto more lines than the primary's. The fitted box must fit above the band, and the centre moves down by half the band so the box sits in the middle of the space above it.

The Plug

StaticMaps.Plug is described from the outside in the README. Inside, a request goes:

GET /maps/<hash>.<ext>?<query>&sig=<hmac>
  │
  ├─ not GET or HEAD ──────────────────────────────────────────── 405, no-store
  ├─ HMAC over "<hash>.<ext>?<query>", hash == sha256(query) ── fail ─▶ 403, no-store
  ├─ If-None-Match == weak ETag of (query, Plug config, version) ─▶ 304   (no render, no fetch)
  ├─ decode, resolve named icons, enforce limits ──────────────── fail ─▶ 400, no-store
  ├─ join an in-flight render of the same map, or wait for this Plug's render slot
  ├─ render(opts ++ on_tile_error: :fail) ──────────── tiles fail ─▶ 503, no-store
  └─ 200, Cache-Control from :cache plus no-transform, ETag, Content-Type

The URL format lives in StaticMaps.Plug.URL, which path/3 writes and the Plug reads, so the two cannot drift apart. The query is canonical, with a fixed parameter order and number format, so one map has one URL. The signature covers the file name as well as the query, and it always comes last, so the Plug can split it off the raw query string and check exactly the bytes that were signed. The hash in the file name makes the path alone identify the map, for CDNs that ignore query strings.

The ETag is computed from the file name, the signed query and the Plug's own configuration, so the 304 path costs one HMAC and one comparison. The file name is in it because the PNG and JPEG of one map share a query but not their bytes; the ETag also keys the renders, so without it one format's request could receive the other's image. That is the point of a validator: a revalidating browser should cost nothing. The configuration is in it because the same URL renders differently from a different source, attribution or icon; the library version is in it so a release that draws differently replaces what caches hold.

StaticMaps.Plug.Renders runs the renders. It works like the fetcher: requests for the map already rendering wait for that render, and each mounted Plug has its own limit and queue, keyed by its configuration. A single limit shared by every mount would be set by whichever asked last, so one mount's :max_concurrency would override another's.

The Plug sends the whole image for HEAD as for GET; the server drops the body and keeps the Content-Length, so a HEAD reports the real size.

The :attribution and :source options come from the Plug's configuration, never from the query string. Anything a request can set, a stranger can set, signature or not, once they have one valid URL to copy.

Testing strategy

  • No network in the test suite. Tests use a synthetic source whose tiles are solid colours derived from {z, x, y}, each labelled with its key. A tile in the wrong place or from the wrong wrap is visible at a glance in a failing image.
  • Projection and grid maths are property-tested: round trips through the projection, every output pixel covered by exactly one placement, keys always inside 0..2^z - 1. Antimeridian, pole and zoom 0 cases are explicit examples as well.
  • Images are compared by decoded pixels with a tolerance, never by bytes. PNG bytes change with the zlib and libvips versions, and a byte comparison fails on an upgrade while telling you nothing about the map.
  • The Plug is tested with Plug.Test: signature rejection, the 304 path not calling the fetcher, error responses carrying no-store, and concurrent identical requests producing one render.

Lessons from prior art

Before this design was written, five existing implementations were read closely. Each decision above answers something they got wrong or right.

DecisionWhat it avoids
Clamp latitude, drop rows past the polesWrapping y to the opposite pole (komoot, StephanGeorg); wrapping latitude so 100° becomes −80° (komoot)
Place from the unwrapped columnTiles across the antimeridian landing off-canvas because the offset was computed after wrapping (quilt)
Centre in world pixels, not degreesOff-centre views and ignored padding on tall bounds (komoot, StephanGeorg)
tile_size and scales declared by the sourceOne tile size hard-coded (256 in four of them, 512 in quilt), or tile size confused with scale (komoot, StephanGeorg)
One tile-failure policy, :fail by defaultTiles silently dropped (StephanGeorg, Luzifer); some errors swallowed while others crash (flopp); one failure retried six times and the image lost (quilt)
Failures sent no-storeA map with a hole in it cached for a day as a success (Luzifer)
Retry 429/5xx, never 404Retrying the permanent error and giving up on the transient ones (quilt)
Timeouts on every requestRequests with no deadline at all (komoot, StephanGeorg, Luzifer)
Concurrency limited per source, globallyOne goroutine per tile (Luzifer); unbounded Promise.all (quilt)
Identifying User-Agent that callers cannot removeNo User-Agent at all (StephanGeorg); a generic one replaced by any custom headers (komoot)
Tile cache honours HTTP headers and has a size limitDisk caches that never expire and never evict (flopp, Luzifer)
Validate a tile before caching itAn HTML error page saved as a tile
Attribution drawn by default, not settable from a requestNo attribution at all (komoot, StephanGeorg, quilt); anyone can turn it off with a query parameter (Luzifer)
Sources are modulesAny caller making the server fetch any URL (Luzifer's overlays)
render/1 is a pure function of its optionsMutable builders holding render state, unsafe to share between processes (komoot, StephanGeorg, flopp)
Explicit 0 handled as a valuezoom: 0 treated as "not given" (komoot, StephanGeorg)
Pixel-tolerance image tests, no networkTests hitting live tile servers (StephanGeorg), byte-equal PNG assertions that never ran (quilt), a test file that cannot import (komoot), no tests (Luzifer)

Some things they got right, and this design keeps them:

  • Fitting that counts marker icon extents, not just marker points (all three fitting implementations).
  • Drawing overlays anti-aliased, whether through SVG (StephanGeorg) or supersampling (komoot).
  • Handing the renderer a plain tile → bytes function rather than an HTTP client (flopp). It is why Source has a fetch/2 callback, and why a source reading tiles from a file fits the same behaviour as a tile server.
  • Putting the renderer's version in the cache identity (Luzifer), which is why the Plug's ETag includes the library version.
  • Stitching and cropping in one operation on an output-sized canvas (quilt).