Rover

Maps for Phoenix LiveView, powered by OpenLayers.

Hex.pmDocs

Markers with emoji and labels, a parcel outline, a route, and IGN plan tiles, all rendered by the playground

OpenLayers is a serious mapping engine. It is also ten concepts deep before you can put three pins on a map: Map, View, Layer, Source, Feature, Geometry, Style, Overlay, Interaction, Control.

Rover keeps the engine and removes the ceremony.

<.map id="clients" center={{45.75, 4.85}} zoom={12} markers={@clients} />
assign(socket,
clients: [
%{id: 1, lat: 45.76, lon: 4.83, label: "Atelier"},
%{id: 2, lat: 45.74, lon: 4.86, label: "Dépôt"}
]
)

That is the whole thing. Assign a list of maps, get a map. Assign a different list, and Rover updates only the markers that actually changed.

Why not just write a hook?

Because you already can, and the first version is genuinely short. Three pins on a tile layer is a dozen lines of JavaScript and an afternoon.

The afternoon after that is the one to think about. A phx-hook has no opinion about what happens when the list changes, so you write the reconciliation — and if you write the obvious version, clearing the layer and redrawing it, you also get the flicker, the interrupted pan and the popup that closes itself. Then the framing, because someone will open a map with one marker and another with two hundred. Then updated(), or you discover that changing the element's id is the only way to get new data in. Then the coordinate order, once, in the wrong direction. Then the attribution, which is a licence condition rather than a detail.

None of that is hard. All of it is work you have done before, and Rover has done it here, with tests that assert the reconciliation by object identity so it stays done.

The other half of the bet is the engine underneath. OpenLayers carries projections, huge vector layers, WMS/WMTS and the rest of the serious GIS surface — so the day your three pins turn into a cadastral overlay, the ceiling is somewhere else entirely.

Installation

def deps do
[{:rover, "~> 0.2"}]
end

Rover ships a prebuilt JavaScript bundle with OpenLayers already inside it, so a stock mix phx.new application — no npm, no node_modules, no package.json — works as-is.

In assets/js/app.js:

import { RoverHooks } from "../../deps/rover/priv/static/rover.js"
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: { ...RoverHooks }
})

In assets/css/app.css:

@import "../../deps/rover/priv/static/rover.css";

And in the html_helpers block of your lib/my_app_web.ex:

import Rover.Components

Markers

A marker is anything with an id and a coordinate. Plain maps, structs, Ecto schemas:

%{id: 1, lat: 45.75, lon: 4.85, label: "Atelier"}
FieldMeaning
:idRequired. Stable identity used to diff the map.
:lat / :lonRequired. Also accepted: :latitude/:longitude, :lng.
:labelText drawn next to the marker. Falls back to :name or :title.
:colorColour of the default pin, e.g. "#e11d48".
:emojiAn emoji drawn in place of the pin, e.g. "🏠".
:iconURL of an image to use instead of the pin.
:scaleSize multiplier.
:tooltipShown on hover. Defaults to the label.
:draggableLets the user move it — see on_marker_drag_end.
:dataAny map; echoed back verbatim in events.

If your schema names things differently, say so once:

<.map
id="stores"
markers={@stores}
marker_fields={[lat: :latitude, lon: :longitude, label: :trade_name]}
/>

Events

<.map id="clients" markers={@clients} on_marker_click="select_client" />
def handle_event("select_client", %{"id" => id}, socket) do
{:noreply, assign(socket, selected: id)}
end
AttributePayload
on_marker_click%{"id" =>, "lat" =>, "lon" =>, "data" =>}
on_cluster_click%{"count" =>, "ids" => [id, …], "lat" =>, "lon" =>}
on_shape_click%{"id" =>, "lat" =>, "lon" =>, "data" =>}
on_map_click%{"lat" =>, "lon" =>}
on_move_end%{"center" => [lat, lon], "zoom" =>, "bbox" => %{"south" =>, "west" =>, "north" =>, "east" =>}}
on_marker_drag_end%{"id" =>, "lat" =>, "lon" =>}

Inside a Phoenix.LiveComponent, add target={@myself}.

Shapes

Outlines, routes and zones come in as GeoJSON:

<.map id="parcel" shapes={@parcels} tiles={:ign_ortho} />
assign(socket,
parcels: [
%{id: p.id, geometry: p.cadastral_outline, color: "#16a34a", fill_opacity: 0.2}
]
)

A bare geometry, a Feature or a FeatureCollection; atom or string keys; or an undecoded JSON string, so ST_AsGeoJSON output goes straight in. Fields: :color, :width, :fill_color, :fill_opacity, :label, :tooltip, :rev, :data.

Shapes are the one place Rover is not latitude-first — GeoJSON is defined as [longitude, latitude] and the standard wins, because geometry is never typed by hand. See Rover.Shape for why.

A map with shapes and no markers frames the geometry, so a parcel page needs no center.

Geometry is diffed by revision, not by hashing

Markers hash their coordinate — two numbers. A route is thousands of points, so shapes carry a :rev computed once per render on the server (:erlang.phash2(geometry) by default). Pass your own if you have something better:

%{id: p.id, geometry: p.geom, rev: p.updated_at}

Same id and same :rev means the client leaves that feature alone.

Clustering

Hundreds of markers are a wall of overlapping icons. Grouping them is one attribute:

<.map id="clients" markers={@clients} cluster={true} />
<.map id="clients" markers={@clients} cluster={[distance: 60, zoom_on_click: false]} />

A group of one is drawn as its own marker, so nothing looks clustered until it actually is. Clicking a group zooms into it — without that a cluster is a dead end, showing you that twelve things are there with no way to reach them — and sends on_cluster_click with the member ids.

Reconciliation is untouched by any of this. ol/source/Clusterwraps the marker source rather than replacing it, so the markers are still diffed by id exactly as before; only what is drawn changes.

Two consequences worth knowing:

Heatmaps

Five hundred markers are a wall of overlapping icons. A heat field answers a different question — where is there a lot of this?

<.map id="deliveries" heatmap={@rows} heatmap_style={[radius: 12, blur: 20]} />

A point needs only a coordinate; :weight is relative, 0 to 1, and defaults to 1:

%{lat: 45.75, lon: 4.85}
%{lat: 45.75, lon: 4.85, weight: 0.4}

No :id here, unlike markers and shapes. A heatmap is an aggregate — no individual point is visible in the result — so per-point identity would be ceremony that buys nothing. It is diffed by revision instead, like shapes, which also means a style-only change restyles the layer without rebuilding the field.

Popups

A slot, rendered once per marker and shown on click with no server round-trip:

<.map id="clients" markers={@clients}>
<:popup :let={marker}>
<h3>{marker.label}</h3>
<p>{marker.data && marker.data.address}</p>
<button data-rover-popup-close>Close</button>
</:popup>
</.map>

Shapes get their own slot, and open where the geometry was clicked rather than at its centroid — pointing at the middle of a long route would point at nothing the user did:

<:shape_popup :let={shape}>
<h3>{shape.label}</h3>
<p>{shape.data && shape.data.area} ha</p>
</:shape_popup>

Both work with or without on_marker_click / on_shape_click: the click is claimed when either the server or a popup wants it, and by neither when the shape is scenery — a filled outline with no handler and no popup must not swallow on_map_click across its whole interior.

Closed by data-rover-popup-close, by clicking the map, or by Escape. Because the markup comes from HEEx it is escaped by construction — no interpolating customer names into popup HTML.

Deliberately not an ol/Overlay: an Overlay moves your node into the map viewport, which lives inside phx-update="ignore", and LiveView would then be patching markup it no longer owns. Rover leaves the nodes where HEEx put them and positions them itself. The cost is one DOM node per marker — fine for dozens, which is why clustering rather than popups is the answer to hundreds.

Moving the view without owning it

center and zoom are attributes, which is right when the view is a property of what you are rendering. It is the wrong tool for "the user clicked a row, take me there": passing center costs you the automatic framing, so you trade the default behaviour for one gesture and hold the view in assigns from then on.

For that, send a command instead:

def handle_event("select_client", %{"id" => id}, socket) do
client = Enum.find(socket.assigns.clients, &(&1.id == id))
{:noreply, Rover.fly_to(socket, "clients", {client.lat, client.lon}, zoom: 15)}
end

Nothing is assigned, no attribute changes, and the map keeps its declarative framing for everything else. Rover.fit_to/4 is the "show me these" counterpart and takes markers, shapes, coordinates or a {south, west, north, east} box:

{:noreply, Rover.fit_to(socket, "fleet", vehicles_on_shift, max_zoom: 15)}

Both name the map's DOM id, because a LiveView can hold several maps and an event reaches all of them.

Coordinates are always {lat, lon}

The order you say out loud. OpenLayers works in [x, y] — that is, [lon, lat] projected to Web Mercator — and Rover does that flip once, in JavaScript, where you never see it.

Rover.Geo is strict about it on purpose: a latitude of 145.75 raises rather than quietly drawing your marker in the middle of the Pacific.

Basemaps

<.map id="m" tiles={:carto_dark} ... />
<.map id="m" tiles={{:xyz, "https://tiles.example.com/{z}/{x}/{y}.png", attributions: "© Example"}} ... />
<.map id="m" tiles={:none} ... />

Presets: :osm, :osm_hot, :carto_light, :carto_dark, :carto_voyager, :opentopomap, :esri_world_imagery, :ign_plan, :ign_ortho.

The two IGN presets serve the French Géoportail — the reference plan and the aerial orthophotography. Unlike the demo endpoints below they are meant for production use.

Each one carries the attribution its provider requires, and Rover renders it. The OSM and Carto presets point at public demo servers with usage policies that forbid production traffic — for anything real, point {:xyz, …} at tiles you are entitled to use.

What "only update what changed" actually means

The map is rendered as three attributes: data-rover (the view), data-rover-markers and data-rover-shapes. LiveView already diffs attributes, so changing only your markers sends only your markers — a cadastral outline that did not move is not re-serialised because a delivery van did.

On the client, Rover diffs that list by marker id and splits the work in two: a marker that moved has its geometry mutated in place; a marker that was recoloured gets a new style and keeps its geometry. Everything else is left untouched — same Feature object, same Style object, shared between every marker that looks alike.

Adding one marker to a list of five hundred adds one feature. It does not rebuild the layer, interrupt a pan, or close an open tooltip. Shapes work the same way, keyed by id and compared by :rev. There are tests asserting exactly this, by object identity, in assets/test/markers.test.js and assets/test/shapes.test.js.

Reaching OpenLayers when you need it

<.map> is a floor, not a ceiling. The bundle also exports the pieces:

import { RoverMap, MarkerLayer, ShapeLayer, project, unproject } from "../../deps/rover/priv/static/rover.js"

The live instance is also on the element that owns it, which is the fastest way to answer "why is my marker not there?" from a console:

const map = document.getElementById("clients")._rover
map.map.getView().getZoom()
map.markerLayer.markerById(42)
map.contentExtent

Bring your own OpenLayers

If you already build with npm and want to own the ol version:

// package.json: "ol": "^10.0.0"
import { RoverHooks } from "../../deps/rover/priv/static/rover.external.js"

Try it without installing anything

Run in Livebook

notebooks/rover.livemd walks each layer separately — coordinates, markers, basemaps, and the exact JSON that crosses the wire — then feeds that real payload to Rover's own bundle to render a live map inside the notebook. It is the fastest way to see what a given <.map> actually sends.

Development

mix deps.get
mix assets.build # npm install + esbuild the bundles
mix dev # playground on http://localhost:4020
mix precommit # format, compile --warnings-as-errors, both test suites
mix assets.test.browser # the browser suite, in a real Chromium

The browser suite is small on purpose. Everything below the component — the canvas, the popup DOM, the tile URLs the browser actually requests — lives where ExUnit and node --test cannot look, and both rendering bugs this library has shipped were in there. It stands guard over those paths and nothing else. Each scenario has been watched to fail with its bug reintroduced.

The playground (dev/demo_live.ex) is the reference for the intended experience: a list of maps, buttons that add / move / recolour / remove markers, and a log of the events coming back. There is no OpenLayers in that file.

Status

Markers, GeoJSON shapes, emoji, popups, clustering, heatmaps, imperative view control and the French Géoportail are complete and tested. Still open: arbitrary HTML markers, drawing interactions, a keyboard and ARIA pass, real ol/source/WMTS sources, and loading geometry by URL rather than by attribute.

That last one is the honest limit of the current transport. An HTML attribute is a single dynamic slot, so any change re-serialises the whole payload. That is right for a cadastral outline or a delivery route; it is wrong for hundreds of kilobytes of static geometry. When it bites, the answer is an ol/source/Vector with a URL and a revision — not a bigger attribute.

Issues and PRs welcome.

Coming from a Leaflet hook

Most of the migration is deleting JavaScript. The mapping:

In your hookIn Rover
L.map + setView<.map center={{lat, lon}} zoom={12}>
L.tileLayer(url, …)tiles={:osm} or {:xyz, url, attributions: …}
L.marker + L.divIcon with an emojia marker's :emoji
L.marker + an image icona marker's :icon
bindPopup(html)the <:popup> slot — and HEEx escapes it for you
bindTooltip / titlea marker's :tooltip
L.geoJSON(geometry, style)shapes with :color, :width, :fill_opacity
featureGroup().getBounds() + fitBoundsautomatic, over markers and shapes together
handleEvent + push_event to feed the mapassign/3; the data rides on attributes
a versioned element id to force a remountnot needed — Rover has an updated()

Three things that catch people:

  1. Every marker needs a stable :id. Hook code usually builds anonymous marker maps, because Leaflet has no use for an identity. Rover diffs on it, so without one you get remove-and-add instead of an update — the flicker you were trying to leave behind. Ids are almost always right there in the record you are mapping over.
  2. height is an inline style, and beats your class. If you size maps with class="h-96" or a flex parent, pass height={nil}.
  3. Stroke opacity has no named field. Leaflet's opacity: 0.8 on a line becomes color: "rgba(37, 99, 235, 0.8)"; :fill_opacity covers the fill.

Licence

MIT — see LICENSE.

Rover redistributes OpenLayers (BSD 2-Clause) inside its JavaScript bundle; third-party notices are in NOTICE.md.