What gets built, in what order, and where each piece stands. This is the only document that tracks status. README.md describes the API, and ARCHITECTURE.md describes how it works.

The order follows the pipeline, pure stages first. Every phase ends with something testable without a network, and nothing touches a real tile server until the phase that needs one.

PhaseStatus
0Design: README, architecture, prior-art auditdone
1Projection and tile griddone
2Canvas and a synthetic sourcedone
3HTTP sources and fetchingdone
4Tile cachedone
5Fallback sources and mirrorsdone
6Fitting the viewportdone
7Markers and attributiondone
8StaticMaps.Plugdone
9StaticMaps.Component for Phoenix LiveViewnot started
10Source.PMTilesnot started
11Tile cache shared across a clusternot started
12StaticMaps.TilePlug: serving tiles you may redistributenot started

Phase 0 — Design

Done. README and architecture written, and five existing implementations (StephanGeorg/staticmaps, komoot/staticmap, flopp/py-staticmaps, Luzifer/staticmap, mapbox/quilt) audited from source. What the audit changed is recorded in ARCHITECTURE.md § Lessons from prior art.

Phase 1 — Projection and tile grid

Done. Pure maths, no dependencies.

  • StaticMaps.Projection: lat/lng ↔ world pixels, latitude clamp.
  • StaticMaps.Viewport: the resolved view and the one world-to-output rounding rule. Fitting joins it in Phase 6.
  • StaticMaps.TileGrid: tile keys and placements for a centre, zoom, size, scale and source tile size, with antimeridian wrap by placement, pole rows dropped and keys deduplicated.
  • Property tests for round trips and full coverage; explicit cases for zoom 0, the antimeridian, the poles and 512-px sources.

Done when the grid for any valid viewport covers every output pixel exactly once with in-range keys.

Phase 2 — Canvas and a synthetic source

Done. The first image out.

  • StaticMaps.Canvas over vix: background, tile insertion with clipping, PNG and JPEG encoding.
  • StaticMaps.Source behaviour with fetch/2 and info/1.
  • A test-only synthetic source drawing labelled, coloured tiles.
  • The pixel-tolerance image comparison helper used by every later phase.
  • render/1 end to end with :center, :zoom, :size, :scale, :format and :background, and {:error, _} for invalid options.

Done when golden images for the Phase 1 edge cases match.

Phase 3 — HTTP sources and fetching

Done.

  • StaticMaps.Source.HTTP: User-Agent with the configured contact, timeouts, retries on transport errors, 429 and 5xx with Retry-After, response validation.
  • StaticMaps.Fetcher: per-source global concurrency limit, joining in-flight fetches.
  • Source.OSM and Source.Template.
  • on_tile_error: :fail | :fill and %StaticMaps.TileError{}.

  • Tested against a local Bandit server that can be told to fail, stall or lie about content type.

Done when a real OSM render works by hand, and every failure mode of the local server produces the documented result.

Phase 4 — Tile cache

Done. Expired tiles are also served when revalidation fails, so a tile server outage does not fail renders it has already served.

  • StaticMaps.TileCache behaviour and the ETS implementation.
  • Freshness from Cache-Control, Expires or the 7-day default; conditional revalidation; byte limit with LRU eviction.

Done when a second render of the same view makes no requests, and an expired tile is revalidated with a conditional request.

Phase 5 — Fallback sources and mirrors

Done. Attribution is not drawn yet, so that a fallback's image carries the fallback's attribution is checked in Phase 7. Checking providers' terms for the guide showed that several forbid server-side caching, so sources can opt out of the tile cache with cache: false.

Two ways to keep rendering when a tile server is slow or down: fallback between different sources, and load spread across mirrors of the same source.

  • source: [primary, fallback, …]. When a render fails with a TileError, the whole map is rendered again from the next source. Tiles are never mixed between sources: providers draw different maps, so a mixed image is a visible patchwork, and the attribution would credit only one of them.
  • The attribution drawn is always that of the source the image came from.
  • A circuit breaker per source: after repeated failures that show it is unhealthy, not merely missing tiles, a source is skipped for a cooling-off period instead of every render waiting for it to fail again.
  • A guide listing Source.Template configurations for common providers, each with a link to its terms, rather than a built-in module per provider. Provider URLs change, and built-in presets go stale: tile-providers.md.
  • cache: false per source, for providers whose terms forbid server-side caching.

Mirrors are several servers serving identical tiles: your own tile servers, or one provider's hosts.

  • Source.Template accepts a list of URL templates as :url. Each tile goes to a mirror chosen from its position, so a tile always has the same URL and HTTP caches along the way keep working, and the load spreads evenly across mirrors.
  • When a tile fails on its mirror, it is retried on the next one. Unlike fallback, this is per tile: the tiles are identical, so the image stays seamless.
  • One concurrency limit per source, shared by its mirrors. Per-mirror limits were the original plan, but they cannot hold during failover: a down mirror's tiles land on the others on top of their own.

Fallback and mirrors exist for availability and capacity. Neither is a way to spread load across several different free services to stay under each one's limits; the library does not rotate between different providers.

Done when a render whose primary source fails comes back from the fallback with the fallback's attribution, a failing source is skipped until its cooling-off ends, tiles are spread across mirrors by position, and a tile that fails on one mirror is served by another.

Phase 6 — Fitting the viewport

Done. Markers are fitted but not drawn yet; drawing them, and reserving room for the attribution strip while fitting, come with Phase 7.

  • StaticMaps.Viewport: :bounds, markers, :padding, antimeridian bounds, centre in world pixels, zoom limited to the source's range.
  • :center without :zoom, and markers only.

Done when fitted views keep every marker and the padding inside the image across the edge cases, including a single marker and bounds across the antimeridian.

Phase 7 — Markers and attribution

Done. Markers also take a short :label, and the strip can move to the bottom left.

  • Default pin and {lat, lng, opts} styling; icon extents fed back to fitting.
  • Anti-aliased drawing at output scale.
  • Attribution strip with wrapping on narrow images, reserved during fitting, attribution: false.
  • The attribution drawn is that of the source the image came from, including a fallback.

Done when the README's usage example produces the image it describes.

Phase 8 — StaticMaps.Plug

Done. Custom icons are registered with the Plug by name, since a URL cannot carry them. The CDN check was made at the level of headers, with Plug.Test and a real Bandit server; a CDN in front of a real deployment is still to be tried.

  • path/3 with canonical parameter order and HMAC signing.
  • The request flow in ARCHITECTURE.md § The Plug: 403, 304 without rendering, 400 on limits, 503 on tile failure, no-store on every error, HEAD.
  • :cache headers and weak ETag including the library version.
  • Render slots and joining identical in-flight renders.

Done when a CDN in front of a sample app caches 200s and passes errors through uncached.

Phase 9 — StaticMaps.Component for Phoenix LiveView

An optional function component that puts a map in a page as an <img>, compiled only when phoenix_live_view is present, like StaticMaps.Plug.

  • <StaticMaps.Component.map center={…} zoom={…} size={…} markers={…} alt="…" /> renders an <img> whose src is a signed StaticMaps.Plug.path/3, so every map gets the Plug's browser and CDN caching.
  • A srcset with the scale: 2 version, so high-density screens get sharp maps.
  • width and height on the tag, so the page does not move when the image loads.
  • alt is required: a map with no text alternative says nothing to a screen reader.
  • The image is rendered when the browser asks for it, not in the LiveView process, so a slow tile server never blocks a LiveView.

It lives in this library rather than its own package because it and the Plug must agree on the signed URL format; one package keeps them in step. It moves to a separate package if it grows beyond one component, or if keeping up with LiveView releases starts forcing releases here.

Interactive maps stay out of scope; for panning and zooming, use Leaflet or MapLibre through a hook.

Done when a LiveView renders the component, and the browser loads both densities of the image through the Plug.

Phase 10 — Source.PMTiles

  • Raster PMTiles through ex_pmtiles, local and S3.
  • A clear error for vector archives.
  • A guide to producing a raster archive. The basemaps Protomaps publishes are vector tiles, so self-hosting means rendering raster tiles from them first; the guide has to cover that step, not just the download.

Phase 11 — Tile cache shared across a cluster

Nodes of one deployment share their tile caches, so a tile fetched on one node serves every node, and the cluster behaves as one client toward a tile server.

  • Nodes find each other through Erlang distribution, however the application already clusters (libcluster or otherwise); the library adds no discovery of its own.
  • A StaticMaps.TileCache implementation that looks in the local cache first, then asks peers in a :pg group before fetching. Peers answer only from their cache, never by fetching on another node's behalf.
  • Peers are the application's own nodes, so they are trusted and there is no licence question: it is one deployment, one client.
  • A slow or missing peer costs a short timeout, then the fetch goes ahead as usual.

Done when a tile fetched on one node of a test cluster is served to another without a second upstream request, and a peer that stops answering does not fail renders.

Phase 12 — StaticMaps.TilePlug: serving tiles you may redistribute

An opt-in endpoint, /{z}/{x}/{y}.png, that serves tiles to other applications, making a deployment a tile server in the ordinary, deliberate sense.

  • Serves only from sources whose info/1 says redistributable: true, such as a Source.PMTiles archive or tiles you render yourself. A source that does not say so is refused at startup, since most providers' terms forbid redistribution.
  • Never fetches upstream on a request's behalf. A tile not already in the archive or cache is a 404, so the endpoint cannot become an open proxy onto someone else's tile server.
  • Rate limits on by default, per client and in total, with Retry-After on 429.
  • The same HTTP caching as StaticMaps.Plug: Cache-Control, ETag and 304.
  • The attribution of the source, in a response header and in the documentation of the endpoint.

Done when the endpoint serves an archive's tiles within its limits, refuses a source not marked redistributable, and answers a missing tile with 404 without any upstream request.

Later

Asked for often enough in prior art to be likely, but not needed for the first release.

  • Lines and polygons as overlays, feeding fitting like markers do. Every prior implementation with overlays supports them.
  • WebP output.
  • A disk-backed TileCache.
  • Telemetry events for renders, fetches and cache hits.

Out of scope

  • Fractional zoom. Tiles exist at integer zooms, and scaling them blurs every label.
  • Rendering vector tiles. That is a map renderer, a different and much larger project.
  • Pre-seeding or bulk-downloading tiles for a region.
  • An internet-wide peer-to-peer tile network, where every deployment discovers others and shares tiles with strangers. Tiles from OpenStreetMap and commercial providers are not signed, so a peer could serve a wrong or malicious tile and nothing could tell; commercial terms forbid redistribution; and re-serving OSM tiles to strangers hides that load from OSM. For your own tiles, a PMTiles archive is one file with a known hash, which existing networks such as BitTorrent or IPFS already distribute and verify.
  • Interactive maps.