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 sourceThis 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 explains how it works inside, and 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 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
def deps do
[
{:staticmaps, "~> 0.1"},
# only if you serve maps over HTTP with StaticMaps.Plug
{:plug, "~> 1.16"}
]
endStaticMaps sends an identifying User-Agent with every tile request, as OSM's policy
requires. Tell it how the tile provider can reach you:
# 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
{: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 |
: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 |
:on_tile_error | :fail | :fail or :fill; see 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
:centerand:zoom, and you get exactly that view. - Give
:bounds, and StaticMaps picks the highest zoom at which the bounds, the markers and:paddingall 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
:centerwithout: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
:zoomwithout: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:
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.
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 forTile 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:
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
endStaticMaps.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_sizeis 256 or 512 pixels. Getting it wrong puts every tile in the wrong place.:zoomis the range the source serves.render/1rejects a zoom outside it rather than fetching tiles that do not exist.:scaleslists the pixel densities the source has tiles for. Withscale: 2, a source that lists2provides 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 has configurations for common providers, and what each one's terms allow.
A source for PMTiles archives, one file of tiles on your own storage with no tile server, is planned; see the roadmap.
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:
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-ControlorExpiresheader allows, and for 7 days when the source sends neither, as OSM's policy requires; - revalidates an expired tile with
If-None-MatchorIf-Modified-Sinceinstead 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:
config :staticmaps, :tile_cache,
module: StaticMaps.TileCache.ETS, # nil turns caching off
max_bytes: 64 * 1024 * 1024Tiles 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:
source: [
{StaticMaps.Source.Template, url: "https://…", attribution: "© Provider", cache: false},
StaticMaps.Source.OSM
]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
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.
# 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]# 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 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 (Node)
- komoot/staticmap (Python)
- flopp/py-staticmaps (Python)
- Luzifer/staticmap (Go)
- 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 says why.
Within Elixir, spherical_mercator
provides projection maths and ex_pmtiles reads
PMTiles archives; this library does the rest, from choosing tiles to the finished image.
License
MIT; see LICENSE.