# StaticMaps

Render a map to an image on the server, with no map library in the browser.

StaticMaps takes a centre, a zoom and a size, works out which tiles cover that view,
fetches them, stitches them into one picture, draws your markers on top and hands you the
bytes. Nothing ships to the client but an `<img>`.

```
{lat, lng} + zoom + size
        │
        ▼
   which tiles?  ──▶  fetch (cached)  ──▶  stitch + crop  ──▶  markers  ──▶  attribution  ──▶  PNG
        │                   │
   projection           tile source
```

This is for maps that do not move: a property listing, an office address, a photo's
location, an OG image, a PDF. If your users need to pan and zoom, you want Leaflet or
MapLibre and you want them in the browser — that is a genuinely different problem, and this
library does not pretend to solve it.

This README is for Elixir developers who want a map image from their application. It
assumes you know what a slippy map and its tiles are, and nothing about how to stitch
them. [ARCHITECTURE.md](guides/ARCHITECTURE.md) explains how it works inside, and
[ROADMAP.md](guides/ROADMAP.md) says what is planned next.

## Why this exists

Most "just use OpenStreetMap" maps are a client-side map library pointed at
`tile.openstreetmap.org`. That works until it doesn't: OSM's
[tile usage policy](https://operations.osmfoundation.org/policies/tiles/) is explicit that
their volunteer-funded servers are not for heavy or commercial use, and an interactive map
on every page of a commercial site is exactly the traffic they ask you to take elsewhere.

For a map that never moves, all that machinery buys nothing. One render produces one image
which is then cached forever, and a page view costs zero tile requests.

## Installation

```elixir
def deps do
  [
    {:staticmaps, "~> 0.1"},
    # only if you serve maps over HTTP with StaticMaps.Plug
    {:plug, "~> 1.16"}
  ]
end
```

StaticMaps sends an identifying `User-Agent` with every tile request, as OSM's policy
requires. Tell it how the tile provider can reach you:

```elixir
# config/config.exs
config :staticmaps, contact: "https://example.com/contact"
# User-Agent: StaticMaps/<version> (+https://example.com/contact)
```

The attribution strip is text, which libvips draws with the fonts installed through
fontconfig. Desktop systems have them; slim Docker images often do not, and there a
render fails with an error naming the missing font rather than producing a map without
its credit. Install one, for example `apt-get install fonts-dejavu-core` or
`apk add font-dejavu`.

## Usage

```elixir
{:ok, png} =
  StaticMaps.render(
    center: {14.5547, 121.0244},
    zoom: 15,
    size: {800, 400},
    markers: [{14.5547, 121.0244}]
  )

File.write!("makati.png", png)
```

That is the whole surface for the common case. Everything else is an option with a
defensible default.

Coordinates are always `{latitude, longitude}`. Several libraries in this space take
`[lng, lat]`, so check the order when porting code.

| Option | Default | Meaning |
|---|---|---|
| `:center` | fitted | `{lat, lng}` |
| `:zoom` | fitted | integer, within the source's zoom range |
| `:bounds` | — | `{{south, west}, {north, east}}`; the view is fitted to it |
| `:padding` | `20` | pixels kept clear around `:bounds` and markers when fitting |
| `:size` | `{600, 400}` | output pixels, before `:scale`; at most `{2048, 2048}` |
| `:scale` | `1` | `2` renders at twice the pixel density for retina |
| `:markers` | `[]` | `{lat, lng}` or `{lat, lng, opts}`; see [Markers](#markers) |
| `:source` | `StaticMaps.Source.OSM` | a module, or `{module, opts}`, implementing the behaviour; or a list of them, tried in order |
| `:format` | `:png` | `:png` or `:jpeg` |
| `:background` | `"#e0e0e0"` | colour for anything no tile covers, such as beyond the poles |
| `:attribution` | `true` | see [Attribution is not optional](#attribution-is-not-optional) |
| `:on_tile_error` | `:fail` | `:fail` or `:fill`; see [When a tile fails](#when-a-tile-fails) |

`render/1` returns `{:ok, binary}` or `{:error, exception}`. It never raises for bad input.
An unknown option, a zoom outside the source's range or a size over the limit is an
`{:error, %ArgumentError{}}`.

### Fitting the view

You do not have to pick a centre and zoom yourself.

- Give `:center` and `:zoom`, and you get exactly that view.
- Give `:bounds`, and StaticMaps picks the highest zoom at which the bounds, the markers
  and `:padding` all fit, and centres on them.
- Give only `:markers`, and it fits to the markers. A single marker, or several at the
  same spot, gets zoom 15, or the nearest zoom the source serves.
- Give `:center` without `:zoom`, and it keeps that centre and picks the highest zoom at
  which every marker is still in view; for a single marker, no higher than zoom 15.
- Give `:zoom` without `:center`, and it keeps that zoom and centres on the bounds and
  markers.

The zoom is chosen from the zooms the first source serves. When nothing fits even at its
lowest zoom, the lowest zoom is used.

Fitting counts each marker's icon size, not just its point, so a pin at the edge is never
half cut off. It also leaves room for the attribution strip.

A `:bounds` that crosses the antimeridian is written with `west > east`, for example
`{{-20, 170}, {-10, -170}}` for a box around Fiji. Markers need no such care: they are
fitted by the shortest span of longitude that covers them all, so markers either side of
the antimeridian frame the Pacific, not the whole world.

Latitudes are clamped to ±85.0511°, the limit of the Web Mercator projection. Anything
beyond that, and anything past the poles at low zoom, is painted `:background` rather than
filled with the wrong tiles.

Zoom is always an integer. Tiles only exist at integer zooms, and scaling them to fit a
fractional zoom blurs every label on the map.

### Markers

Each marker is a default pin, 24 × 36 pixels with its tip on the coordinate, unless its
options say otherwise:

```elixir
markers: [
  {14.5547, 121.0244},
  {14.5176, 121.0509, color: "#1565c0", label: "B"},
  {14.5995, 120.9842, icon: File.read!("priv/office.svg"), anchor: {16, 32}}
]
```

| Option | Default | Meaning |
|---|---|---|
| `:color` | `"#d63e2a"` | the pin's colour: `"#rgb"`, `"#rrggbb"` or `{r, g, b}` |
| `:label` | none | up to three characters written in the pin's head, in black or white, whichever reads better on the colour |
| `:icon` | the pin | SVG or image bytes to draw instead of the pin |
| `:anchor` | bottom centre | `{x, y}` in the icon's pixels that sits on the coordinate |

Pins and SVG icons are drawn at the output scale, so they stay sharp at `scale: 2`. An
image icon is enlarged for `scale: 2`, so give an SVG where you can. A label is drawn only
on the default pin.

Markers are drawn in the order given, later ones on top, and at low zoom a marker appears
on every copy of the world the image shows.

### Returning a binary, and nothing else

`render/1` gives you bytes. It does not write files, talk to S3, or know what a
`Phoenix.LiveView` is. Where the image lives is your application's business, and an image
library that knows about your bucket is one you cannot reuse in the next application.

The same reasoning applies to coordinates: it takes plain `{lat, lng}` tuples, not anyone's
geo struct. A three-line adapter on your side bridges whatever you already have.

### When a tile fails

A render needs every tile it covers. When one cannot be fetched after retries, the default
is `on_tile_error: :fail`, and `render/1` returns
`{:error, %StaticMaps.TileError{}}` naming the tiles that failed.

`on_tile_error: :fill` paints the missing tiles with `:background` and returns the image
anyway. Use it when a map with a grey square is better than no map, and do not cache the
result for long.

A map with a hole in it that looks like a success is the worst outcome, because it gets
cached and served for weeks. That is why `:fail` is the default.

A source that fails several renders in a row because it is down, unreachable or
erroring is skipped for a while, 30 seconds by default, rather than every render waiting
for it to fail again. Missing tiles, such as a `404`, do not count. The next source in the
list is used instead, or, if there is none, `render/1` returns at once with
`{:error, %StaticMaps.UnavailableError{}}`. The same error, listing each source's
failure, comes back when a render has backup sources and every one of them fails.

```elixir
config :staticmaps, :circuit_breaker,
  threshold: 5,      # failed renders in a row; nil turns the breaker off
  cooldown: 30_000   # milliseconds a failing source is skipped for
```

## Tile sources

A source is a module that turns a tile `{z, x, y}` into image bytes, and says what its
tiles look like and what credit their licence requires.

For the usual case, a server with a URL per tile, `use StaticMaps.Source.HTTP` and describe
the URL:

```elixir
defmodule MyApp.Tiles do
  use StaticMaps.Source.HTTP

  @impl true
  def url(%{z: z, x: x, y: y, scale: scale}, _opts) do
    suffix = if scale == 2, do: "@2x", else: ""
    "https://tiles.example.com/#{z}/#{x}/#{y}#{suffix}.png"
  end

  @impl true
  def info(_opts) do
    %{attribution: "© Example Maps", tile_size: 256, zoom: 0..19, scales: [1, 2]}
  end
end
```

`StaticMaps.Source.HTTP` supplies the fetching: the `User-Agent`, timeouts, retries, a
limit on concurrent requests and the tile cache. Implement `headers/2` to send an API key
or other headers a server needs. A source that does not use HTTP implements `fetch/2`
itself instead of `url/2`.

`info/1` matters more than it looks:

- `:tile_size` is 256 or 512 pixels. Getting it wrong puts every tile in the wrong place.
- `:zoom` is the range the source serves. `render/1` rejects a zoom outside it rather
  than fetching tiles that do not exist.
- `:scales` lists the pixel densities the source has tiles for. With `scale: 2`, a source
  that lists `2` provides sharp high-density tiles. One that does not has its tiles scaled
  up, so the map is soft but markers and attribution are still sharp.

Two come built in:

- `Source.OSM` — OpenStreetMap's standard style. Fine for development and light use. Read
  the policy note below before you point production at it.
- `Source.Template` — any `{z}/{x}/{y}` URL template, which covers most commercial
  providers with a key in the URL:
  `source: {StaticMaps.Source.Template, url: "https://…/{z}/{x}/{y}.png?key=…", attribution: "© Provider"}`.
  [guides/tile-providers.md](guides/tile-providers.md) has configurations for common
  providers, and what each one's terms allow.

A source for [PMTiles](https://protomaps.com/) archives, one file of tiles on your own
storage with no tile server, is planned; see the [roadmap](guides/ROADMAP.md).

Sources are modules in your code, never values taken from a request. A tile URL built from
user input turns your server into a proxy for anyone who asks.

### Backup sources and mirrors

Give `:source` a list, and when a render fails from the first source, the whole map is
rendered again from the next:

```elixir
source: [
  {StaticMaps.Source.Template, url: "https://…", attribution: "© Provider"},
  StaticMaps.Source.OSM
]
```

The fallback always renders the whole map. Different providers draw different maps, so
filling in one provider's missing tiles from another would give a visible patchwork, and
an attribution that credits only one of them. A fallback that does not serve the
requested zoom is skipped.

Servers that serve identical tiles, such as your own tile servers behind different
hosts, are mirrors of one source instead. `Source.Template` takes a list of URL
templates, sends each tile to a mirror chosen from its position, and fetches a tile from
the next mirror when its own fails. Since the tiles are identical, mixing mirrors within
one image is safe.

Neither feature rotates between different free services to stay under each one's usage
limits. That spreads heavy use across volunteers rather than reducing it.

## Attribution is not optional

Tiles carry licence terms. OSM's standard tiles are CC BY-SA 2.0 and the data is ODbL, both
of which require credit, and every commercial provider requires their own.

`render/1` therefore draws the source's attribution into the corner of the image by
default: dark text on a translucent white strip, drawn last so nothing covers it. On a
narrow image the text wraps rather than being cut off. When a fallback source rendered
the image, its credit is the one drawn.

You can move it with `attribution: [position: :bottom_left]`, and fitting keeps markers
clear of it wherever it is. You can render it yourself in surrounding markup, but turning
it off in the image takes `attribution: false`, which is a deliberate act and is your
licence violation to explain, not the library's.

`:attribution` is an option in your code only. `StaticMaps.Plug` never reads it from a
request.

## Caching

Two caches, doing different jobs:

**Tiles** are cached by `{source, z, x, y, scale}`, because adjacent subjects share almost
all of their tiles — a hundred properties in one city are not a hundred times the
fetches. The cache:

- keeps a tile for as long as the source's `Cache-Control` or `Expires` header allows,
  and for 7 days when the source sends neither, as OSM's policy requires;
- revalidates an expired tile with `If-None-Match` or `If-Modified-Since` instead of
  downloading it again, and serves the expired tile if the server cannot be reached;
- only stores a response that is a decodable image of the expected size, so a provider's
  HTML error page never becomes a tile;
- is limited in size, 64 MB in memory by default, and evicts the least recently used
  tiles when full.

The default cache lives in ETS and is lost on restart. Implement the
`StaticMaps.TileCache` behaviour to keep tiles somewhere else:

```elixir
config :staticmaps, :tile_cache,
  module: StaticMaps.TileCache.ETS,  # nil turns caching off
  max_bytes: 64 * 1024 * 1024
```

Tiles from HTTP sources are cached. A source that reads tiles from your own storage
does not implement the cache's callback, and its tiles are not copied into memory.

Several commercial providers forbid keeping their tiles on a server. Give such a source
`cache: false`, and its tiles are never stored or served from the cache, while other
sources, such as a fallback, are still cached:

```elixir
source: [
  {StaticMaps.Source.Template, url: "https://…", attribution: "© Provider", cache: false},
  StaticMaps.Source.OSM
]
```

[guides/tile-providers.md](guides/tile-providers.md) says which providers need it. Some
also restrict serving rendered images of their tiles at all, which `cache: false` does
not address.

**Rendered images are not cached** by the library, because you are better at it than this
library would be. You already know the identity of the thing being rendered and where its
image belongs.

If you would rather not store images at all, render them on request and let browsers and
a CDN hold them instead. See [Serving over HTTP](#serving-over-http).

## Serving over HTTP

`StaticMaps.Plug` is an optional layer over `render/1` for applications that serve maps
straight from a URL and let the HTTP caches in front of them do the storing. It is only
compiled when `:plug` is in your dependencies, and `render/1` does not depend on it.

```elixir
# router.ex — outside any pipeline that fetches the session
forward "/maps", StaticMaps.Plug,
  secret: {MyApp.Config, :map_secret, []},
  source: StaticMaps.Source.OSM,
  icons: %{office: File.read!("priv/office.svg")},
  cache: [max_age: 86_400, s_maxage: 2_592_000, stale_while_revalidate: 86_400]
```

```elixir
# anywhere you build a page
src = StaticMaps.Plug.path("/maps", [center: {14.5547, 121.0244}, zoom: 15], secret: secret)
# => "/maps/0eba24287dc2c888.png?c=14.5547%2C121.0244&z=15&sig=JGwV1b3F…"
```

The same options always produce the same URL, so the URL itself is the cache key. The
first request for a URL renders the image; after that the CDN answers every request until
the entry expires. The start of the path is a hash of the query, so even a CDN
configured to ignore query strings keys each map separately. `format: :jpeg` in the third
argument gives a `.jpg` URL.

`path/3` takes the options `render/1` does, except the ones only the Plug decides:
`:source`, `:attribution` and `:on_tile_error` come from the Plug's configuration, never
from a URL. A `:background` given to the Plug is a default, which a URL's `:background`
overrides. A marker's `:icon` is the name of one of the Plug's `:icons`, since a URL
cannot carry an image; `{lat, lng, icon: :office}` draws the icon registered as
`:office`.

| `:cache` option | Default | Header it sets |
|---|---|---|
| `:max_age` | `86_400` (1 day) | `max-age`, how long browsers keep it |
| `:s_maxage` | `2_592_000` (30 days) | `s-maxage`, how long shared caches (CDNs) keep it |
| `:stale_while_revalidate` | `86_400` | `stale-while-revalidate`, so an expired entry is served while the CDN refetches |
| `:immutable` | `false` | `immutable`, for when you never want a URL to change |

`cache: false` sends `Cache-Control: no-store` instead, which is useful in development.
Every cacheable response also says `no-transform`, so proxies, CDNs and the server's own
compression leave the image alone rather than spending time recompressing a PNG.

Each response also carries a weak `ETag` derived from the URL, the Plug's configuration
and the library version. A browser revalidating an expired image gets a `304 Not Modified`
straight after the signature check, without a render or a single tile fetch. It is weak on
purpose: when the tile provider updates its tiles, the pixels change but the map still
shows the same place, which is what a weak validator means. The library version is in it
so that a release that draws differently replaces old images.

The lifetimes are shorter than `immutable` by default because the tiles under a map do
change. Roads get built and labels get fixed, and a month-old map is fine but a
year-old one may not be.

### Signed URLs

The URL is signed with an HMAC of `:secret`, and the Plug rejects any request
whose signature does not match with `403`. Without that, anyone could use your server to
render arbitrary maps, and every one of those renders is tile traffic to your provider,
billed to you or, with `Source.OSM`, taken from the volunteers' servers. The signature
also stops people busting your CDN cache with endless variations of one URL.

`path/3` writes the parameters in a fixed order before signing, markers included, so the
same map always gets the same URL and the same signature.

Signing keeps strangers out; it does not protect you from your own bugs. The Plug still
enforces limits on every signed request:

| Plug option | Default | |
|---|---|---|
| `:max_size` | `{1280, 1280}` | largest `:size` it will render |
| `:max_scale` | `2` | |
| `:max_markers` | `100` | |
| `:max_concurrency` | `System.schedulers_online()` | renders in progress at once, for this mounted Plug |

Identical requests that arrive while a render is in progress wait for that render instead
of starting their own. This matters on a cold CDN: a popular page can send the same URL
many times before the first response is cached.

### Responses

| Situation | Status | `Cache-Control` |
|---|---|---|
| Rendered | `200` | your `:cache` settings |
| `If-None-Match` matches | `304` | your `:cache` settings |
| Bad or missing signature | `403` | `no-store` |
| Signed, but over a limit or invalid | `400` | `no-store` |
| A tile failed | `503` | `no-store` |
| Not `GET` or `HEAD` | `405` | `no-store` |

Every failure is `no-store`, so a CDN never keeps an error for a month. The Plug always
renders with `on_tile_error: :fail` for the same reason. Error bodies are a short generic
message; details go to your logs, not to the client. `HEAD` is answered like `GET`
without the body.

### Keeping the CDN able to cache

A CDN will not cache a response that sets a cookie, and most will not cache one marked
`private`. Mount the Plug outside your `:browser` pipeline so no session or CSRF plug
touches its responses. The Plug itself never sets cookies or `Vary`, because every response
for a URL is the same whoever asks for it. Your web server may add
`Vary: Accept-Encoding` on its own, as Bandit does; CDNs handle that without trouble.

## A note on OSM's servers

If you use `Source.OSM` in production, you are using infrastructure donated by volunteers.
The policy asks for an identifying `User-Agent`, forbids bulk downloading, and asks heavy
users to move to their own infrastructure. StaticMaps sends a proper `User-Agent`, makes
at most two concurrent requests to OSM, and caches as required. It will not pre-seed a
region for you, because that is bulk downloading no matter how it is dressed up.

Caching is also not a loophole. It reduces your load on their servers; it does not turn
heavy use into light use. If this library is carrying real traffic for you, a paid
provider or your own tiles are the honest answer, and switching is one line.
[guides/tile-providers.md](guides/tile-providers.md) lists providers and what their terms
allow.

## Prior art

The problem is well solved outside Elixir, and these are the implementations this library
learns from:

- [StephanGeorg/staticmaps](https://github.com/StephanGeorg/staticmaps) (Node)
- [komoot/staticmap](https://github.com/komoot/staticmap) (Python)
- [flopp/py-staticmaps](https://github.com/flopp/py-staticmaps) (Python)
- [Luzifer/staticmap](https://github.com/Luzifer/staticmap) (Go)
- [mapbox/quilt](https://github.com/mapbox/quilt) (Node)

Their APIs converged independently on roughly the same shape — centre, zoom, size,
markers — which is a good sign that it is the right one. Their shared weak spots are just
as useful. Where this design departs from them, [ARCHITECTURE.md](guides/ARCHITECTURE.md) says
why.

Within Elixir, [`spherical_mercator`](https://github.com/mspanc/elixir-spherical-mercator)
provides projection maths and [`ex_pmtiles`](https://github.com/admarrs/ex_pmtiles) reads
PMTiles archives; this library does the rest, from choosing tiles to the finished image.

## License

MIT; see [LICENSE](LICENSE).
