A Phoenix LiveView Component for displaying an interactive map with dynamic data.
The library requires Elixir 1.16+, Phoenix 1.8+, and Phoenix LiveView 1.1+.
By rendering the map on the server, it avoids the client-side map libraries for simple mapping needs. Utilizing LiveView, we can also update map data on the server, and let the browser do what it does best—rendering markup.
The map is rendered as an SVG. Raster and standalone SVG sources are emitted as
<image> tiles, while Shortbread-compatible vector sources are fetched and
decoded on the server into nested SVG tiles. LiveMap progressively injects
standalone SVG tiles so page-level CSS can style them; the external image
remains the no-JavaScript fallback.
Please consult and follow usage policies of the tile servers.
A LiveMap can be added to a LiveView by:
<.live_component
module={LiveMap} id="live-map"
title="Example Live Map"
width="800" height="600"
center="10.4197639,107.1070841" zoom="11"
rendering-type="vector"
>
<%# Styles slot %>
<:style>
/* CSS custom variables or class selectors to customize map colors */
:root {
--live-map-water-fill: #38bdf8;
--live-map-land-fill: #fef08a;
}
.live-map-shortbread-role-building {
fill: #cbd5e1;
}
</:style>
<%# Optional custom HTML map controls %>
<:map_control action="pan-up">
<span class="inline-flex h-6 w-6 items-center justify-center rounded bg-white text-slate-900">↑</span>
</:map_control>
<:map_control action="zoom-in">
<span class="inline-flex h-6 w-6 items-center justify-center rounded bg-white text-slate-900">+</span>
</:map_control>
<:map_control action="zoom-out">
<span class="inline-flex h-6 w-6 items-center justify-center rounded bg-white text-slate-900">-</span>
</:map_control>
<:map_control action="fullscreen">
<span class="inline-flex h-6 w-6 items-center justify-center text-slate-900">⛶</span>
</:map_control>
<%# Optional SVG overlays projected from map coordinates %>
<:polygon
id="district"
label="Sample district"
points={[
%{latitude: 10.34, longitude: 107.07},
%{latitude: 10.35, longitude: 107.09},
%{latitude: 10.33, longitude: 107.11}
]}
/>
<:polyline
id="route"
label="Sample route"
points={[
%{latitude: 10.34, longitude: 107.07},
%{latitude: 10.36, longitude: 107.10},
%{latitude: 10.38, longitude: 107.13}
]}
/>
<%# A single explicit marker %>
<:marker
id="harbor"
position="10.411379,107.136224"
title="Harbor"
/>
<%# Add a slot body to <:marker> when you want custom HTML marker UI. %>
<%# Multiple markers via :for %>
<:marker
:for={marker <- @visible_markers}
id={marker.id}
position={{marker.latitude, marker.longitude}}
title={marker.label}
/>
</.live_component>
Run examples/live_maps.exs for a single-file LiveView example powered by Mix.install/1.
Map controls are opt-in: LiveMap renders no navigation buttons by default. Add
repeated :map_control slots with an action and HTML content to enable any
combination of zoom-in, zoom-out, pan-up, pan-right, pan-down,
pan-left, and fullscreen. Pan controls initially render as a compact D-pad
launcher. Activating it reveals the directional controls, with zoom in above
the right control and zoom out below it. The optional positive step defaults
to 1. A zoom step changes one zoom level; a pan step moves half the map width
or height at the current zoom.
The fullscreen action toggles a server-rendered, CSS viewport-filling mode and
does not require a custom JavaScript hook. Browser-native fullscreen, which also
hides browser chrome, requires the client-side Fullscreen API and is therefore
outside LiveMap's server-rendered controls.
Use on_bounds_changed when the parent LiveView needs to retain or persist
control-driven bounds changes. The server callback receives the map id, triggering
action, new center, and new zoom; it can send that value back to the parent
process for URL patching or other state updates.
Each :marker slot entry must provide position and title. The
optional id is used to generate a stable DOM id. LiveMap only projects and renders
the markers it receives; deciding which markers to pass remains the responsibility of
the parent LiveView. When the :marker slot body is omitted, LiveMap renders a
default SVG marker pin with the marker title exposed through the SVG title. If a body is provided, it must be HTML content;
LiveMap wraps it in a <foreignObject> automatically. This keeps the public API
decoupled from the internal SVG rendering details while still allowing rich HTML
marker UIs. No :let or projected slot assigns are required. You can pass a single
marker directly, or emit multiple marker slots with :for.
Like Google's <gmp-map>
and <gmp-advanced-marker>
elements, center and position accept a "latitude,longitude" string.
Elixir callers may also pass a
{latitude, longitude} tuple or %{lat: latitude, lng: longitude} map.
The map-level latitude and longitude attributes and the marker-level
latitude, longitude, and label attributes are deprecated compatibility
fallbacks. When both forms are supplied, center, position, and title win.
Custom map controls use HTML content only. LiveMap wraps that content for
display inside the SVG control chrome. The old :zoom_in and :zoom_out slots
remain as deprecated one-step aliases for the corresponding map-control actions.
Polygon and polyline overlays are projected in map coordinates and rendered as
SVG shapes on their own layer. Each :polygon or :polyline slot accepts a
points list of %{latitude: ..., longitude: ...} maps, with optional id
and label attributes. LiveMap renders default SVG <polygon> and <polyline>
elements for these overlays.
HTML marker example:
<:marker id="harbor" position="10.411379,107.136224" title="Harbor">
<button class="rounded-full bg-emerald-700 px-3 py-1 text-xs font-semibold text-white">
Harbor
</button>
</:marker>
Set rendering-type to the raster|vector enum to select LiveMap's built-in
OpenStreetMap raster or Shortbread vector source. LiveMap keeps raster as the
default for backward compatibility:
<.live_component
module={LiveMap}
id="live-map"
center="10.4197639,107.1070841"
zoom={11}
rendering-type="raster"
/>Use rendering-type="vector" to switch to the built-in OSM vector source
without configuring tile_source. The optional Req dependency is required for
vector rendering.
tile_source remains supported for backward compatibility and custom tile
servers. If both attributes are supplied, rendering-type selects the built-in
source.
Tile source type is inferred from the URL by default: .mvt and .pbf URLs are
treated as MVT sources, .svg URLs are treated as standalone SVG sources, and
everything else is treated as raster.
Raster and Shortbread MVT sources support overzoom above their max_zoom by
cropping the appropriate parent tile. Shortbread MVT sources are
server-rendered:
<.live_component
module={LiveMap}
id="live-map"
center="10.4197639,107.1070841"
zoom={15}
tile_source={%{
url: "https://vector.openstreetmap.org/$VERSION/{zoom}/{x}/{y}.mvt",
version: "shortbread_v1",
max_zoom: 14,
headers: [{"x-example-header", "demo"}]
}}
/>Point tile_source at a display-ready SVG tile endpoint to keep tile bytes out
of LiveView diffs:
<.live_component
module={LiveMap}
id="svg-map"
center="10.4197639,107.1070841"
zoom={11}
base-style="detailed"
styles={@map_styles}
tile_source={%{
url: "/vector/{zoom}/{x}/{y}.svg",
attribution: "© OpenStreetMap contributors"
}}
/>Do not also set rendering-type: when present, it intentionally selects one of
LiveMap's built-in sources instead of tile_source. Root-relative URLs are
accepted for raster and SVG browser-loaded tiles. Server-fetched MVT sources
must remain absolute HTTP(S) URLs.
The external SVG is always rendered through <image> first. LiveMap's built-in
runtime colocated hook fetches the same URL, uses the browser Cache API according
to its HTTP freshness headers, scopes SVG IDs, and injects it into the page. No
asset import or custom hook setup is required. Disabled JavaScript, blocked
scripts, failed requests, and invalid SVG retain the image fallback.
Security: Injected SVG is trusted application code. LiveMap deliberately does not sanitize it; elements, attributes, scripts, styles, and external references are preserved apart from collision-safe fragment rewriting. Never point an SVG tile source at content an untrusted party can control.
Strict script-src Content Security Policies must supply the same per-response
nonce used by the application:
<.live_component
module={LiveMap}
id="svg-map"
tile_source={%{url: "/vector/{z}/{x}/{y}.svg"}}
script_csp_nonce={@script_csp_nonce}
/>Cross-origin SVGs need CORS permission for injection even when the browser can
display them as an image. A CORS failure simply preserves that image. Browser
image requests cannot apply tile_source.headers; use a same-origin proxy such
as LiveMap.VectorTile.Plug when upstream credentials or headers are required.
Applications without an existing SVG tile service can mount the included Plug:
# router.ex
forward "/vector", LiveMap.VectorTile.Plug,
source: LiveMap.Tile.default_vector_source(),
base_style: "detailed",
styles: [],
max_display_zoom: 22,
max_age: 86_400,
compress: trueThe route accepts GET and HEAD at /{zoom}/{x}/{y}.svg, validates tile
coordinates, fetches only the MVT source fixed in the router configuration,
and returns standalone SVG with Cache-Control and ETag validators. Gzip
content negotiation is enabled by default, with Vary: Accept-Encoding and a
representation-specific ETag; set compress: false when a reverse proxy should
own compression instead. Above the source's max_zoom, the Plug fetches and
crops the appropriate parent while styling labels for the requested display
zoom. The optional Req dependency is required.
The Plug does not retain rendered tiles in process. Applications can place it
behind their preferred reverse-proxy, CDN, or Plug cache. A wrapper Plug can
also initialize LiveMap.VectorTile.Plug once and call it after checking its
own cache.
The Plug's base_style and styles produce the complete no-JavaScript tile.
Pass the same choices to LiveMap when the injected result should have matching
page-level overrides. Use versioned endpoint URLs whenever a long-lived style
or source profile changes.
Vector maps use an SVG adaptation of the open source
VersaTiles Colorful style
for Shortbread tiles out of the box (base-style="colorful"). The defaults
include its land, water, road, building, boundary, and label palette plus a
conservative label policy: country labels appear first, followed by capitals,
cities, towns, and smaller places as the map zooms in. State/region labels are
held back until zoom 7, and dense address and point-of-interest layers are
hidden. Shortbread's English name is preferred when one is available.
Use base-style="detailed" for a denser overview closer to a conventional
Google-style basemap. It strengthens the road and administrative-boundary
hierarchy, distinguishes more land-cover classes, shows state/region labels at
their earliest useful source zoom, and promotes larger regions plus
high-population capitals, cities, and towns. Overview and regional zooms use
progressive label-density tiers based on Shortbread area and population
metadata. The preset is still server-rendered SVG, so it cannot add details
that are absent from the source tile or perform MapLibre/Google-style global
label collision detection.
Use base-style="physical" with vector rendering for a Google-like physical
overview. This hybrid preset renders an NPS Natural Earth physical raster
underlay beneath the detailed Shortbread roads, boundaries, labels, and LiveMap
overlays:
<.live_component
module={LiveMap}
id="physical-map"
center="29.7604,-95.3698"
zoom={5}
rendering-type="vector"
base-style="physical"
/>The built-in underlay uses Esri's World Physical Map, whose source is the U.S. National Park Service. It stays fully visible through zoom 6, fades across levels 7–9, crops its maximum zoom-8 parent tiles at level 9, and is removed at zoom 10 so the normal detailed vector fills take over. Source attribution is rendered in the lower-left corner.
To use another physical or terrain service, set background-tile-source to an
absolute raster tile template. Attribution metadata may live on the source or
be overridden with the component's attribution and attribution-url
attributes:
<.live_component
module={LiveMap}
id="custom-physical-map"
rendering-type="vector"
base-style="physical"
background-tile-source={%{
url: "https://tiles.example.com/physical/{z}/{x}/{y}.jpg",
max_zoom: 8,
attribution: "Example physical tiles",
attribution_url: "https://tiles.example.com/terms"
}}
/>The selected base-style rules are applied before styles, so a Google Maps
style JSON from a service such as Snazzy Maps can recolor the map or explicitly
show and hide features:
styles = "priv/map_style.json" |> File.read!() |> Jason.decode!()
<.live_component
module={LiveMap}
id="styled-map"
center="10.4197639,107.1070841"
zoom={11}
rendering-type="vector"
base-style="detailed"
styles={styles}
/>LiveMap supports the common Google style fields used for feature visibility, color, and stroke weight. The CSS custom properties shown in the main usage example remain available for smaller overrides without a style JSON.
The tile source URL may use {zoom} or {z}, plus {x}, {y}, {version},
and $VERSION placeholders. version is only required when the URL contains a
version placeholder. Absolute HTTP(S) URLs are accepted for every source;
root-relative URLs are also accepted for browser-loaded raster and SVG sources.
If you use vector sources from another application, include the optional Req dependency there as well:
def deps do
[
{:live_map, "~> 0.0.1"},
{:req, "~> 0.6.2"}
]
endFor server-side tile fetches, configure an identifying default User-Agent:
config :live_map, :tile_user_agent,
"MyApp/1.0 (contact@example.com)"Per-source headers are optional and are merged with that default.
Live components publish each decoded vector source tile as soon as it is ready. Concurrent fetch/decode work defaults to the smaller of eight tasks or the number of online schedulers, and can be tuned for the tile service and host:
config :live_map, :vector_tile_concurrency, 4Decoded vector tiles are retained in a per-map LRU cache, so panning back to a
recent area does not fetch or decode those tiles again. While zooming in, the
nearest cached parent tile is cropped and scaled as a placeholder until the
requested child tile finishes loading. The cache defaults to 64 display tiles;
set it to 0 to disable retention or tune it for the memory available to each
LiveView process:
config :live_map, :vector_tile_cache_size, 96The escript still renders raster output by default:
./live_map --latitude 10.4197639 --longitude 107.1070841 --zoom 11 --width 640 --height 360 > map.svgTo emit self-contained vector SVG, point the CLI at an MVT source:
./live_map \
--latitude 10.4197639 \
--longitude 107.1070841 \
--zoom 15 \
--width 640 \
--height 360 \
--tile-url 'https://vector.openstreetmap.org/$VERSION/{zoom}/{x}/{y}.mvt' \
--tile-version shortbread_v1 \
--tile-user-agent 'MyApp/1.0 (contact@example.com)' \
> map.svg- Vector tile rendering moves tile fetching and decoding onto your server. Treat
tile_sourceas trusted application configuration, not as untrusted request input. - Injected SVG becomes active page DOM. LiveMap intentionally does not sanitize it and preserves embedded elements, styles, scripts, and references while rewriting local IDs. Only inject SVG endpoints you trust as application code; an untrusted source can create an XSS vulnerability.
LiveMap.VectorTile.Plugnever accepts an upstream source from request parameters. Keep its configured source URL and headers trusted, version styles in the endpoint URL, and place a CDN or reverse proxy in front when appropriate.- LiveMap fetches vector tiles through Req and enables Req's HTTP cache for repeated requests.
- Remote tile sources can increase server load and can expose SSRF risks if you allow untrusted users to control URLs or headers.
- Continue to display proper OpenStreetMap attribution and follow the upstream tile usage policies for whatever raster or vector service you configure.
- The OpenStreetMap vector service at
vector.openstreetmap.orgrequires a valid identifying User-Agent, local caching, and nono-cacherequest headers. Review the current policy before shipping against it.
Manual SVG enhancement smoke checks:
- Load an SVG map normally and confirm the nested tile SVGs receive page-level style overrides.
- Disable JavaScript and confirm the endpoint-styled
<image>tiles remain visible. - Pan and zoom rapidly and confirm obsolete requests are aborted without replacing newer tiles.
- Pan across the antimeridian and confirm repeated wrapped tiles have unique scoped IDs.
- Test strict CSP with a matching
script_csp_nonce, then without it to verify fallback behavior. - Test a CORS-enabled cross-origin source and a blocked source; the latter must retain
<image>.
If available in Hex, the package can be installed
by adding live_map to your list of dependencies in mix.exs:
def deps do
[
{:live_map, "~> 0.0.1"}
]
endDocumentation is generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/live_map.