Rover.Components (Rover v0.8.0)

Copy Markdown View Source

The <.map> component.

import Rover.Components

<.map id="clients" center={{45.75, 4.85}} zoom={12} markers={@clients} />

That is the whole API for the common case. Everything below is about the less common ones.

How updates reach the map

Rover renders your markers into a data-rover-markers attribute. When you assign/3 a new list, LiveView diffs the attribute and sends only that attribute down the wire; the JavaScript runtime then diffs the list by marker id and touches only the OpenLayers features that actually changed. Adding one marker to a list of five hundred adds one feature — it does not rebuild the layer, and it does not interrupt a pan, a zoom or an open popup.

This is why Rover.Marker insists on a stable :id.

Events

Each on_* attribute takes the name of an event your LiveView handles:

<.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" => id, "lat" => lat, "lon" => lon, "data" => data}
on_cluster_click%{"count" => n, "ids" => [id, …], "lat" => lat, "lon" => lon}
on_shape_click%{"id" => id, "lat" => lat, "lon" => lon, "data" => data}
on_map_click%{"lat" => lat, "lon" => lon}
on_move_end%{"center" => [lat, lon], "zoom" => zoom, "bbox" => %{"south" =>, "west" =>, "north" =>, "east" =>}}
on_marker_drag_end%{"id" => id, "lat" => lat, "lon" => lon}
on_shape_edit_end%{"id" => id, "geometry" => geojson_geometry, "properties" => geojson_properties, "data" => data}
on_draw_end%{"type" => type, "geometry" => geojson_geometry}

Inside a Phoenix.LiveComponent, route the events to yourself with target={@myself}.

on_draw_end is the one with no :id, because the shape it describes does not exist yet — see Rover.start_drawing/3, which is what arms the map to send it.

Viewports can straddle the antimeridian

Longitudes are wrapped into -180..180, so a user looking at Fiji or New Zealand gets a bbox where west is greater than east. When that happens the map adds "crosses_antimeridian" => true, because the obvious query — where: m.lon >= ^west and m.lon <= ^east — matches nothing for those users. Split the range in two when you see the flag.

Controlled view, uncontrolled panning

center and zoom are applied when they change on the server. A user panning the map does not push new values back unless you ask for them with on_move_end, and a re-render triggered by something unrelated will not yank the view back to where it started. Assign a new center and the map animates to it.

When you give no center at all, Rover derives a starting frame from the markers. That derived value is explicitly not treated as an instruction — otherwise moving one marker would shift the centroid and drag the view along with it on every update.

Framing versus refitting

Two separate things:

  • The first frame. With no center, "put my markers on screen" is the whole instruction, so the map always fits the markers once when it appears. The client does it, because only the client knows the viewport size.
  • Refitting. fit governs what happens afterwards. false leaves the view alone, :once does nothing more, true refits on every change.

Summary

Functions

Renders an interactive map.

Functions

map(assigns)

@spec map(map()) :: Phoenix.LiveView.Rendered.t()

Renders an interactive map.

Examples

Three markers around Lyon, clickable:

<.map
  id="clients"
  center={{45.75, 4.85}}
  zoom={12}
  markers={@clients}
  on_marker_click="select_client"
/>

Fit the view to whatever is on the map instead of choosing a center:

<.map id="fleet" markers={@vehicles} fit={true} tiles={:carto_dark} height="60vh" />

Read markers out of an Ecto schema that names its fields differently:

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

Attributes

  • id (:string) (required) - DOM id. Required — the map is a stateful hook and LiveView needs to track it.

  • center (:any) - The {lat, lon} the view is centred on. Defaults to the centre of markers when they are given, and to {0.0, 0.0} otherwise.

    Defaults to nil.

  • zoom (:any) - Zoom level, roughly 0 (world) to 20 (building). Defaults to nil.

  • min_zoom (:any) - Lowest zoom the user can reach. Defaults to nil.

  • max_zoom (:any) - Highest zoom the user can reach. Defaults to nil.

  • markers (:list) - Anything Rover.Marker.new!/2 accepts: maps, structs, Rover.Markers. Defaults to [].

  • marker_fields (:list) - Field mapping passed to Rover.Marker.new!/2, e.g. [lat: :latitude]. Defaults to [].

  • shapes (:list) - Anything Rover.Shape.new!/2 accepts. GeoJSON geometries — see Rover.Shape. Defaults to [].

  • shape_fields (:list) - Field mapping passed to Rover.Shape.new!/2, e.g. [geometry: :outline]. Defaults to [].

  • cluster (:any) - Groups nearby markers into counted circles, which is the answer to hundreds of them. true for the defaults, or a keyword list:

    • :distance — how close, in pixels, two markers must be to group. Default 40.
    • :min_distance — minimum gap between two groups, in pixels. Default 20.
    • :zoom_on_click — zoom into a group when it is clicked. Default true.

    Clicking a group also sends on_cluster_click. A group of one is drawn as its own marker, so nothing looks clustered until it actually is.

    Two consequences worth knowing: a marker that has been grouped has no popup — the popup would point at the group's centre rather than at the marker — and :draggable markers cannot be dragged at all while :cluster is set, even standing alone. Every marker is wrapped by a cluster feature once clustering is on, a lone one included, and dragging that would move the wrapper rather than the marker.

    Defaults to false.

  • on_cluster_click (:string) - Receives %{"count" => n, "ids" => [id, …], "lat" => lat, "lon" => lon} when a group is clicked. Note "ids" and "count" rather than a single "id" — a group is not a marker.

    Defaults to nil.

  • heatmap (:list) - Points for a density field — see Rover.Heatmap. No :id needed: a heatmap is an aggregate, so it is diffed by revision rather than feature by feature.

    Defaults to [].

  • heatmap_fields (:list) - Field mapping for the heatmap: :weight, and :lat / :lon when the rows name their coordinate something Rover.Geo does not already read — e.g. [weight: fn r -> r.orders / 40 end] or [lat: :y, lon: :x].

    Defaults to [].

  • heatmap_style (:list) - Any of :radius, :blur, :opacity, :gradient. See Rover.Heatmap.style!/1. Defaults to [].

  • tiles (:any) - A Rover.Tiles preset, {:xyz, url}, {:vector, style_url}, {:wmts, capabilities_url, layer: "..."}, or :none.

    Defaults to :osm.

  • layers (:list) - Tile layers drawn on top of the basemap and under everything else — a cadastral overlay on a plan, orthophotography over a map, a weather field over both. Each entry is {:tiles, spec} or {:tiles, spec, opts}, where spec is anything tiles itself accepts:

    <.map
      id="parcels"
      tiles={:ign_plan}
      layers={[
        {:tiles, {:xyz, @cadastre_url, attributions: "© DGFiP"}, opacity: 0.6, min_zoom: 12},
        {:tiles, :ign_ortho, visible: @ortho?}
      ]}
    />
    • :opacity — 0 to 1. Default 1.
    • :visible — draw it at all. Default true, and the way to toggle a layer without paying for its tiles again when it comes back.
    • :min_zoom / :max_zoom — the zoom range it is drawn over.
    • :id — a stable name for the layer. Given one, a layer keeps its tiles across a change of position in the list; without one, position is the identity, and reordering rebuilds.

    Layers are drawn in list order, all of them beneath the heatmap, the shapes and the markers: this is a basemap you are building up, not a way to put tiles over your own data. Each layer's attribution is collected by the attribution control alongside the basemap's, which is why that control is rendered whenever a map has any layer at all.

    Defaults to [].

  • declutter (:boolean) - Hides labels that would overlap, rather than drawing them on top of one another. Markers and shapes declutter together, so a shape's label yields to a marker's and neither is drawn twice over.

    Off by default, because it is a trade rather than an improvement: a label that loses is not drawn at all, and on a dense map that means labels appearing and disappearing as the view moves. Pins, icons, emoji and cluster circles are never hidden — they are what the labels move out of the way of.

    Defaults to false.

  • fit (:any) - Controls refitting as markers change: true (or :always) refits on every change, :once or false do not. Defaults to :once when no center is given, false otherwise. Note that a map given no center always fits once when it first appears, whatever fit says — see "Framing versus refitting".

    Defaults to nil.

  • fit_padding (:integer) - Pixels kept clear around a fitted view. Defaults to 48.

  • controls (:list) - Any of :zoom, :attribution, :scale_line, :full_screen, :rotate. :attribution is added whenever the map has a basemap, listed or not: every preset's provider requires it, and a list that happened to leave it out is not a decision to drop a licence condition. Only tiles={:none} — nothing to credit — renders without it.

    Defaults to [:zoom, :attribution].

  • interactions (:list) - The gestures the map answers to, from :drag_pan, :mouse_wheel_zoom, :double_click_zoom, :pinch_zoom, :keyboard_pan, :keyboard_zoom, :drag_rotate, :pinch_rotate and :drag_zoom (shift-drag a box). All of them by default. A map in the flow of a page usually wants interactions={[:drag_pan, :pinch_zoom, :double_click_zoom, :keyboard_pan, :keyboard_zoom]} — without :mouse_wheel_zoom the wheel scrolls the page instead of the map, and without the two rotations a shift-drag cannot leave the map crooked. Note that a view already rotated stays so; :rotate in controls is the way back.

    interactions={[]} is not interactive={false}: it removes the gestures and keeps the tooltips, the clicks and the cursor. Dragging a :draggable marker, reshaping an :editable shape and drawing are not in this list and cannot be removed through it.

    Defaults to [:drag_rotate, :double_click_zoom, :drag_pan, :pinch_rotate, :pinch_zoom, :keyboard_pan, :keyboard_zoom, :mouse_wheel_zoom, :drag_zoom].

  • interactive (:boolean) - When false the map becomes a picture: no panning, zooming, dragging, tooltips, cursor changes or click events, and the zoom, fullscreen and rotate controls are withheld. The attribution stays — it is a licence obligation, not an interaction — and so does the scale line if you asked for one.

    Defaults to true.

  • on_marker_click (:string) - Defaults to nil.

  • on_shape_click (:string) - Defaults to nil.

  • on_map_click (:string) - Defaults to nil.

  • on_move_end (:string) - Defaults to nil.

  • on_marker_drag_end (:string) - Defaults to nil.

  • on_shape_edit_end (:string) - Defaults to nil.

  • on_draw_end (:string) - Receives %{"type" => type, "geometry" => geometry} when the user finishes drawing a new shape, which they can only do while Rover.start_drawing/3 has armed this map. No :id: identity is yours to assign when you turn the geometry into a shape.

    Defaults to nil.

  • target (:any) - @myself to route events to the enclosing Phoenix.LiveComponent. Defaults to nil.

  • height (:any) - CSS height, applied as an inline style. Pass nil to emit no style at all and size the map from your own CSS — a Tailwind class, a flex parent, a container query. Note that an inline style beats a class, so class="h-96" needs height={nil} to take effect.

    Defaults to "24rem".

  • label (:string) - The map's accessible name, announced by a screen reader and read out when the map takes keyboard focus. The default says only that this is a map; say which map — label="Delivery points" — when there is more than one on a page, or when the map is the page.

    Defaults to "Map".

  • class (:any) - Extra classes on the map container. Defaults to nil.

  • Global attributes are accepted.

Slots

  • popup - Rendered once per marker and shown when that marker is clicked, with no server round-trip. Receives the Rover.Marker via :let.

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

    :data is nil unless you set it, hence the guard.

    Any element carrying data-rover-popup-close closes it; so do a click on the map and the Escape key. Because every marker's popup is rendered up front, this costs one DOM node per marker — fine for dozens, which is why clustering rather than popups is the answer to hundreds.

  • shape_popup - The same, for shapes. Receives the Rover.Shape via :let, and opens where the geometry was clicked rather than at its centroid — pointing at the middle of a long route or a large parcel would point at nothing the user did.

    <.map id="parcels" shapes={@parcels}>
      <:shape_popup :let={shape}>
        <h3>{shape.label}</h3>
        <p>{shape.data && shape.data.area} ha</p>
      </:shape_popup>
    </.map>

    Works with or without on_shape_click: the click is claimed when either the server or a popup wants it, and by neither when the shape is scenery.