Rover (Rover v0.9.0)

Copy Markdown View Source

Maps for Phoenix LiveView, powered by OpenLayers.

OpenLayers is a serious mapping engine — projections, tile pyramids, vector layers, styling, interactions. It is also a lot to learn before you can put three pins on a map. Rover keeps the engine and hides 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"}
  ]
)

No Feature, no VectorSource, no Style. Assign a list, get a map. Assign a different list, and only the markers that changed are touched.

Geometries work the same way, from GeoJSON:

<.map id="parcel" shapes={@parcels} tiles={:ign_ortho} />

Installation

Add the dependency:

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

Register the hook in assets/js/app.js. Rover ships a prebuilt bundle with OpenLayers already inside, so there is nothing to install with npm:

import { RoverHooks } from "../../deps/rover/priv/static/rover.js"

const liveSocket = new LiveSocket("/live", Socket, {
  params: { _csrf_token: csrfToken },
  hooks: { ...RoverHooks }
})

Import the stylesheet in assets/css/app.css:

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

And import the component where you need it — typically once, in the html_helpers block of your *_web.ex:

import Rover.Components

Where to go next

  • Rover.Components — the <.map> component, its attributes, its events and the <:popup> slot.
  • Rover.Marker — what counts as a marker, and how to map your own schemas.
  • Rover.Shape — GeoJSON outlines, routes and zones.
  • Rover.Heatmap — density as a heat field rather than as pins.
  • Rover.Tiles — basemaps, including the French Géoportail, and the attribution you are required to keep.
  • Rover.Geo — coordinates, bounding boxes, distances.

Bring your own OpenLayers

If your app already builds JavaScript with npm and you want to control the OpenLayers version, import the peer build instead and add ol yourself:

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

This one needs a build change, and without it esbuild stops with Could not resolve "ol/Map.js" — once for each of the twenty-seven ol specifiers the peer build leaves bare. esbuild resolves a bare import by walking up from the file that wrote it, which here is deps/rover/priv/static/, where there is no node_modules and never will be. Phoenix's generated NODE_PATH points at deps alone, so your ol is never on the search path.

In your existing config :esbuild block in config/config.exs, replace the env: key — leave args: and cd: exactly as they are:

env: %{
  "NODE_PATH" =>
    Enum.join(
      [Path.expand("../deps", __DIR__), Path.expand("../assets/node_modules", __DIR__)],
      if(match?({:win32, _}, :os.type()), do: ";", else: ":")
    )
}

The default build needs none of this — OpenLayers is already inside rover.js.

Rover is tested against the version it bundles; the peer build is offered for applications that need to share a single OpenLayers instance with their own code.

Summary

Functions

The bounding box of anything fit_to/4 accepts, as {south, west, north, east}.

Frames a map's view around some content, without making the view part of your state.

Moves a map's view, without making the view part of your state.

Arms the map for drawing, so the user can put a new shape on it.

Disarms a map armed by start_drawing/3, and discards any unfinished sketch.

Functions

bbox(content)

@spec bbox(term()) :: Rover.Geo.bbox() | nil

The bounding box of anything fit_to/4 accepts, as {south, west, north, east}.

Markers, shapes, plain coordinates, a mixed list, or a box passed through unchanged. nil when there is nothing to enclose.

Examples

iex> Rover.bbox([%{id: 1, lat: 45.0, lon: 4.0}, %{id: 2, lat: 46.0, lon: 5.0}])
{45.0, 4.0, 46.0, 5.0}

iex> Rover.bbox({45.0, 4.0, 46.0, 5.0})
{45.0, 4.0, 46.0, 5.0}

iex> Rover.bbox(%{"south" => 45.0, "west" => 4.0, "north" => 46.0, "east" => 5.0})
{45.0, 4.0, 46.0, 5.0}

iex> Rover.bbox([])
nil

fit_to(socket, id, content, opts \\ [])

Frames a map's view around some content, without making the view part of your state.

The counterpart to fly_to/4 for "show me these": pass markers, shapes, coordinates, or a {south, west, north, east} bounding box, and the client fits the view to it — using the viewport size, which only the client knows.

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

Like fly_to/4, this counts as the map's initial framing: content arriving afterwards will not pull the view back off what you framed.

Options

  • :padding — pixels kept clear around the content. Defaults to 48.
  • :max_zoom — how far in the fit may go. Defaults to 16, which stops a single point from filling the screen.
  • :duration — animation length in milliseconds. Defaults to 500.

Returns the socket untouched when there is nothing to frame, so fit_to(socket, "fleet", []) is a no-op rather than an error.

fly_to(socket, id, center, opts \\ [])

Moves a map's view, without making the view part of your state.

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 — fit falls back to false and the centre stops being derived — so you trade the default behaviour for one gesture, and you have to keep the view in assigns from then on.

This is a one-shot command instead. Nothing is assigned, no attribute changes, and the map keeps its declarative framing for everything else.

It does count as the map's initial framing, though: a map with no center is framed around its content once, and a flight is a decision about the view, so content arriving afterwards will not pull it back. fit={true} still refits on every change.

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

Options

  • :zoom — where to end up. Omit to keep the current zoom.
  • :duration — animation length in milliseconds. Defaults to 500. 0 jumps.

The first argument to identify is the map's DOM id, because a LiveView can hold several maps and an event reaches all of them.

start_drawing(socket, id, opts \\ [])

Arms the map for drawing, so the user can put a new shape on it.

:editable lets a user reshape a geometry that already exists. This is the other half: there is nothing to attach a per-item flag to yet, so drawing is a one-shot command like fly_to/4 rather than an attribute — a mode the server turns on for a gesture and turns off again.

def handle_event("draw_parcel", _params, socket) do
  {:noreply, Rover.start_drawing(socket, "parcels", type: :polygon)}
end

The finished geometry arrives on on_draw_end, with no :id — the shape does not exist yet, and identity is yours to assign:

def handle_event("drew", %{"geometry" => geometry}, socket) do
  parcel = %{id: System.unique_integer([:positive]), geometry: geometry}

  {:noreply,
   socket
   |> assign(parcels: socket.assigns.parcels ++ [parcel])
   |> Rover.stop_drawing("parcels")}
end

The mode stays armed until stop_drawing/2, so a user asked to trace four parcels traces four without touching the toolbar again. Escape abandons the sketch in progress and leaves the mode armed, the way it does in every drawing tool.

While it is armed the map claims every click — on_marker_click, on_shape_click, on_cluster_click and on_map_click all stop firing, and popups stop opening. A click that places a vertex is not a click on whatever sits under it. Because the mode persists, leaving it armed leaves the map's other click behaviour off with it.

Options

  • :type:polygon (the default), :line or :point.

There is no :circle. OpenLayers can draw one, GeoJSON has no way to represent one, and a shape that cannot round-trip through Rover.Shape would be a geometry the server could never store or send back.

A map rendered with interactive={false} refuses to arm: it is a picture, and drawing is the largest interaction there is.

stop_drawing(socket, id)

Disarms a map armed by start_drawing/3, and discards any unfinished sketch.

Calling it on a map that is not drawing does nothing.