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 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"}
  ]
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:

# 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.

OptionDefaultMeaning
:centerfitted{lat, lng}
:zoomfittedinteger, within the source's zoom range
:bounds—{{south, west}, {north, east}}; the view is fitted to it
:padding20pixels kept clear around :bounds and markers when fitting
:size{600, 400}output pixels, before :scale; at most {2048, 2048}
:scale12 renders at twice the pixel density for retina
:markers[]{lat, lng} or {lat, lng, opts}; see Markers
:sourceStaticMaps.Source.OSMa 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
:attributiontruesee 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 :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:

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}}
]
OptionDefaultMeaning
:color"#d63e2a"the pin's colour: "#rgb", "#rrggbb" or {r, g, b}
:labelnoneup to three characters written in the pin's head, in black or white, whichever reads better on the colour
:iconthe pinSVG or image bytes to draw instead of the pin
:anchorbottom 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 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:

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 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-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:

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:

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 optionDefaultHeader it sets
:max_age86_400 (1 day)max-age, how long browsers keep it
:s_maxage2_592_000 (30 days)s-maxage, how long shared caches (CDNs) keep it
:stale_while_revalidate86_400stale-while-revalidate, so an expired entry is served while the CDN refetches
:immutablefalseimmutable, 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 optionDefault
:max_size{1280, 1280}largest :size it will render
:max_scale2
:max_markers100
:max_concurrencySystem.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

SituationStatusCache-Control
Rendered200your :cache settings
If-None-Match matches304your :cache settings
Bad or missing signature403no-store
Signed, but over a limit or invalid400no-store
A tile failed503no-store
Not GET or HEAD405no-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:

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.