PhxMaplibre

PubSub-first MapLibre GL JS integration for Phoenix LiveView. You render a map with a stateless function component. Whitelisted map interactions come back as %PhxMaplibre.Event{} structs on that map's own PubSub topic, and any BEAM process can drive the map by broadcasting a %PhxMaplibre.Command{} on its commands topic. The process that renders a map and the process that feeds it need not be the same one, or know about each other at all.

How it works

browser (MapLibre GL) ──"maplibre:event"──▶ attach_hook(:handle_event) ──▶ PubSub events topic ──▶ any subscriber
browser (MapLibre GL) ◀──push_event──────── attach_hook(:handle_info) ◀── PubSub commands topic ◀── any process

Both directions are ordinary PubSub topics, so the sender and the map's LiveView never have to be related. A supervised simulation can drive a map it never rendered, and a test, a second LiveView, or a LiveDashboard page can watch a map's interactions by calling PhxMaplibre.subscribe/2.

Installation

Inside this umbrella, or any umbrella that vendors the library as a sibling app, depend on it with in_umbrella:

{:phx_maplibre, in_umbrella: true}

Once it is published, the Hex form will be:

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

JavaScript

maplibre-gl is a peer dependency. Your app installs it and passes the module into the hook factory, so the library never pins a MapLibre GL version.

// assets/package.json
{
"dependencies": {
"maplibre-gl": "5.24.0"
}
}
// assets/js/app.js
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import maplibregl from "maplibre-gl"
import {createMapHook} from "phx_maplibre"
const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: {PhxMaplibreHook: createMapHook(maplibregl)}
})

createMapHook(maplibregl) returns the hook implementation. Register it under exactly the name PhxMaplibreHook; that is what PhxMaplibre.Components.map/1 puts in phx-hook.

esbuild resolves import "phx_maplibre" Node-style, walking NODE_PATH. Where to point it depends on how you consume the library:

CSS

Import the MapLibre GL stylesheet first, then this library's (popup and control theming), alongside your own CSS. Paths below are relative to assets/css/app.css.

For a Hex dependency:

@import "../node_modules/maplibre-gl/dist/maplibre-gl.css";
@import "../../deps/phx_maplibre/priv/css/phx_maplibre.css";

For an umbrella sibling (as demo_gsd_tracker does):

@import "../node_modules/maplibre-gl/dist/maplibre-gl.css";
@import "../../../phx_maplibre/priv/css/phx_maplibre.css";

Quick start

defmodule MyAppWeb.TrackerLive do
use MyAppWeb, :live_view
use PhxMaplibre.LiveView
@map_id "tracker-map"
def mount(_params, _session, socket) do
socket =
socket
|> assign(:map_id, @map_id)
|> PhxMaplibre.LiveView.attach_map(@map_id, pubsub: MyApp.PubSub)
{:ok, socket}
end
def render(assigns) do
~H"""
<PhxMaplibre.Components.map
id={@map_id}
center={%{lng: 13.405, lat: 52.52}}
zoom={11}
class="h-full w-full"
/>
"""
end
# No handle_event/3 clause needed — the hook installed by
# `use PhxMaplibre.LiveView` relays client events to PubSub for you.
def handle_info(%PhxMaplibre.Event{event: :ready, payload: %{bounds: _bounds}}, socket) do
features = %{
type: "FeatureCollection",
features: [
%{
type: "Feature",
id: "1",
geometry: %{type: "Point", coordinates: [13.405, 52.52]},
properties: %{id: "1", title: "Berlin"}
}
]
}
{:noreply, PhxMaplibre.set_features(socket, @map_id, features)}
end
def handle_info(%PhxMaplibre.Event{event: :feature_selected, payload: payload}, socket) do
IO.inspect(payload, label: "selected")
{:noreply, socket}
end
def handle_info(%PhxMaplibre.Event{}, socket), do: {:noreply, socket}
end

:ready is the obvious cue for the first push, but not a hard requirement. set_features and set_area_features that arrive before the initial style has loaded are held by the hook and applied as soon as the sources exist. The camera commands (fly_to, fit_bounds) have no such buffer.

attach_map/3 options

Subscribing needs a connected socket, so a dead mount only records the registration. Call attach_map/3 unconditionally in mount/3; the connected mount does the subscribing. Calling it again for an id that is already registered replaces the registration rather than stacking a second set of subscriptions. PhxMaplibre.LiveView.detach_map/2 unsubscribes and forgets an id, which you need when a live_patch swaps the map out but the process survives.

Component attributes

PhxMaplibre.Components.map/1:

attribute type default notes
id :string required DOM id; also the map id used for topics and events — must match attach_map/3's id
center :map %{lng: 13.405, lat: 52.52} initial center
zoom :any 11 initial zoom level
light_style :string Carto Positron style URL style used when the document has no dark theme
dark_style :string Carto Dark Matter style URL style used when <html data-theme="dark">, or when there is no data-theme and the OS prefers dark
cluster :boolean true cluster point features (MapLibre GL clustering on the points source)
cluster_color :string nil single color for cluster bubbles, with dark count text; unset keeps the built-in size-stepped palette
animate_min_zoom :any 12 zoom at/above which point features render with animated position transitions between set_features updates (see Animated updates); false (or nil) disables animation
navigation :boolean true show the NavigationControl (zoom/rotate)
geolocation :boolean false show the GeolocateControl
fly_on_geolocate :boolean true fly the map to the user's position on geolocation success
events :list [:ready, :feature_selected, :feature_deselected, :cluster_selected, :move_end, :geolocation_success, :geolocation_error] opt-in whitelist — only listed event names ever leave the browser
move_end_throttle_ms :integer 1000 minimum interval between :move_end events
class :any nil extra classes merged onto the phx-maplibre container class
rest :global passed through to the container div

The whitelist is enforced client-side: pushMapEvent in priv/js/events.js checks membership before calling pushEvent, so an event you leave out never reaches the server. :feature_hovered and :feature_unhovered are absent from the default list on purpose, since hover fires on every pointer transition. Opt in explicitly when you want it:

events={[:ready, :move_end, :feature_selected, :feature_deselected, :feature_hovered, :feature_unhovered]}

Client-side is the operative word: the list keeps your own map from being chatty, but a connected client can push any event name it likes over the channel. Give the same list to attach_map/3's :events option to gate the server side, where it is enforced before anything is relayed to PubSub:

@events [:ready, :move_end, :feature_selected, :feature_deselected]
PhxMaplibre.LiveView.attach_map(socket, "tracker-map", pubsub: MyApp.PubSub, events: @events)

Payloads over 512 KB are dropped with a warning, whatever the event.

Events reference

Every event is a %PhxMaplibre.Event{map_id: map_id, event: event_name, payload: payload, meta: %{pid: pid, at: datetime}}. meta records which LiveView process relayed the event and when. Payload keys the library knows about become atoms; unrecognized keys and everything inside :properties (your own feature data) keep their string keys.

event fires when payload
:ready the map's initial style has finished loading and its sources/layers exist %{bounds: bounds, center: %{lng:, lat:}, zoom: zoom}
:feature_selected a point or area feature is clicked %{id: id, kind: "point" | "area", lng: lng, lat: lat, feature: geojson_feature} (for areas, lng/lat are the click position, not a centroid)
:feature_deselected a selected feature is replaced by a new selection of the same kind, an already-selected area is clicked again (toggle off), or a click lands on empty map space (fires once per kind — point and/or area — that was selected) %{id: id, kind: "point" | "area"}
:feature_hovered the pointer enters a point or area feature — opt-in, not in the default events list %{id: id, kind: "point" | "area", title: title | nil}
:feature_unhovered the pointer leaves a feature, or moves directly onto another feature (fires for the old feature before :feature_hovered fires for the new one) — opt-in %{id: id, kind: "point" | "area", title: title | nil}
:cluster_selected a cluster circle is clicked (only when cluster={true}); the map also eases to the cluster's expansion zoom %{cluster_id: id, point_count: n, center: %{lng:, lat:}}
:move_end the map stops moving (pan/zoom/fly), throttled to at most one per move_end_throttle_ms %{bounds: %{west:, south:, east:, north:}, center: %{lng:, lat:}, zoom: zoom}
:geolocation_success the browser's GeolocateControl resolves a position %{lng: lng, lat: lat, accuracy: accuracy}
:geolocation_error the browser denies or fails geolocation %{code: code, message: message}

Hover is exact-once per transition. Moving around inside one feature emits nothing further, and going from feature A straight onto feature B emits :feature_unhovered for A strictly before :feature_hovered for B. Cluster circles highlight on hover but emit no event; only point and area features do.

GeoJSON is the exchange format in both directions. Features go to the map as GeoJSON through set_features/set_area_features, and :feature_selected hands the clicked feature back under :feature as a GeoJSON Feature — geometry and properties exactly as your data provided them (string-keyed, paint overrides included). Read application data from payload.feature["properties"].

Commands reference

Every command has two forms:

PubSub form socket fast-path form params notes
set_features(map_id, geojson, opts \\ []) set_features(socket, map_id, geojson) geojson: a FeatureCollection map or a list of Feature maps replaces all point features; points should carry an id property (used for hover/select setFeatureState)
set_area_features(map_id, geojson, opts \\ []) set_area_features(socket, map_id, geojson) same shape as set_features replaces all area (polygon) features
fly_to(map_id, center, opts \\ []) fly_to(socket, map_id, center, opts \\ []) center: %{lng:, lat:}; opts: :zoom (default 14), :duration ms (default 1500) animated flight to a point
fit_bounds(map_id, bounds_or_geojson, opts \\ []) fit_bounds(socket, map_id, bounds_or_geojson, opts \\ []) explicit %{west:, south:, east:, north:}, or any GeoJSON whose bbox is computed via PhxMaplibre.Geo.bounds/1; opts: :padding px (default 40), :max_zoom (default 15) the PubSub form returns {:error, :no_coordinates} for GeoJSON with no coordinates; the socket form raises ArgumentError in that case
set_style(map_id, style, opts \\ []) set_style(socket, map_id, style) style: a MapLibre style URL swaps the base style; library sources/layers/data/feature-state are re-added automatically after the style loads
request_geolocation(map_id, opts \\ []) request_geolocation(socket, map_id) triggers the browser's geolocate control; result arrives as :geolocation_success/:geolocation_error. No-op on a map rendered without geolocation={true}, which has no control to trigger

A bare list of Feature maps is wrapped into a FeatureCollection for you by PhxMaplibre.Geo.feature_collection/1.

PhxMaplibre.Command.new/3 validates params before anything is broadcast, so a malformed call fails at the call site with {:error, {:invalid_params, command}} instead of quietly doing nothing in the browser. The socket fast path goes through the same constructor and raises ArgumentError.

set_features/set_area_features are checked the same way: the geojson has to be a FeatureCollection map (type of "FeatureCollection", string or atom key, and a list under features) whose entries are Feature maps, or a bare list of Feature maps. Anything else — %{} most notably, which would fail silently inside setData — is {:error, :invalid_geojson}, or an ArgumentError from the socket form. The check is structural; geometries are yours.

Cookbook: drive a map from anywhere

Any process that knows the map id and the PubSub server can move the map, not only the LiveView that renders it. From iex:

iex> PhxMaplibre.fly_to("tracker-map", %{lng: 13.405, lat: 52.52}, zoom: 13, pubsub: MyApp.PubSub)
:ok

Or from a supervised process, say a ticker that recenters the map on the busiest cluster every half minute:

defmodule MyApp.MapDirector do
use GenServer
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@impl true
def init(opts) do
:timer.send_interval(30_000, :recenter)
{:ok, %{map_id: Keyword.fetch!(opts, :map_id), pubsub: Keyword.fetch!(opts, :pubsub)}}
end
@impl true
def handle_info(:recenter, state) do
center = MyApp.Tracking.busiest_area_center()
PhxMaplibre.fly_to(state.map_id, center, zoom: 13, pubsub: state.pubsub)
{:noreply, state}
end
end

MyApp.MapDirector never renders the map and knows nothing about the LiveView that does beyond the map id. The command reaches the browser through the phx_maplibre:tracker-map:commands topic.

Animated updates

Point features move smoothly. Each set_features update is rendered twice: the clustered snapshot source updates instantly (that is what you see at low zoom), and — at zooms at or above animate_min_zoom (default 12, half a zoom level of hysteresis) — an unclustered animated source shows the same features tweening linearly from where they were displayed to their new positions. The tween duration is the measured interval between updates, so a server streaming viewport-filtered GeoJSON every few seconds produces continuous, constant-velocity motion with zero extra wire traffic and zero simulation logic in the browser.

The mechanics, so behavior is predictable:

Subscribing from other processes

Any process can watch a map's interactions without being its LiveView:

PhxMaplibre.subscribe("tracker-map", pubsub: MyApp.PubSub)
receive do
%PhxMaplibre.Event{event: :feature_selected, payload: payload} ->
IO.inspect(payload)
end

In a GenServer, another LiveView, or a test, match on %PhxMaplibre.Event{} in handle_info/2 the way the owning LiveView does. PhxMaplibre.unsubscribe/2 stops it. If you would rather subscribe through Phoenix.PubSub yourself, PhxMaplibre.events_topic/2 and PhxMaplibre.commands_topic/2 give you the raw topic names.

Security model: authorize at attach time

A browser client cannot subscribe to anything. Phoenix.PubSub.subscribe/2 is a BEAM-process primitive; the only process acting for a client is its own LiveView, and that LiveView subscribes to exactly the topics your server code passed to attach_map/3 in mount/3. No wire message triggers a subscription. The relay hook forwards "maplibre:event" only for map ids in that LiveView's own registry — an unregistered id is logged and dropped — and event names are matched against a compile-time allowlist, so a client cannot mint atoms or smuggle topic strings through a payload.

The consequence: the map id is a capability, and attach_map/3 is where authorization happens. The one way a hostile client reaches someone else's map is a server that attaches an id the client chose:

# Vulnerable: the map id becomes an unauthenticated capability. An attacker
# who knows (or guesses) another user's id now receives that map's commands
# and can forge events into its topic.
def mount(%{"map_id" => id}, _session, socket) do
{:ok, PhxMaplibre.LiveView.attach_map(socket, id, pubsub: MyApp.PubSub)}
end

Rules that keep the model sound:

One honest boundary: PubSub itself has no ACLs. Any server-side process may subscribe to any topic — that is trusted code by definition, and no library changes it. What this library guarantees is that the untrusted side of the wire never gets a subscription, never gets a relay for an unattached id, and never gets an event past the server-side :events allowlist and :max_event_payload_bytes cap.

Styling

Feature paint overrides

A feature's properties can carry flat MapLibre paint properties, which override the layer default for that one feature through a coalesce expression (["coalesce", ["get", "circle-color"], "#6366f1"]). The recognized set is STYLE_PROPS in priv/js/sources_layers.js:

fill-color, fill-opacity, fill-outline-color, fill-pattern,
circle-color, circle-radius, circle-opacity, circle-stroke-color, circle-stroke-width,
line-color, line-width, line-opacity, line-dasharray,
text-color, text-size, text-opacity, text-halo-color, text-halo-width,
icon-image, icon-size, icon-opacity,
background-color, background-opacity,
raster-opacity, hillshade-illumination-direction, hillshade-exaggeration

Only ten of those keys actually reach a layer today: fill-color and fill-opacity on the area fill, line-color/line-width/line-opacity on the area outline, and circle-color/circle-radius/circle-opacity/ circle-stroke-color/circle-stroke-width on unclustered points. The popup filter recognizes the whole set, but the rest has no layer to apply to. Selection and hover states win over these overrides: a selected point is always red, a hovered point always green. priv/js/sources_layers.js has the exact expressions.

A feature can set properties["linked-id"] to another feature id. Selecting it then marks the linked feature with the secondary orange selection state. This is generic data-driven behavior: use it for paired assets, related records, or any other relationship without adding a new event contract.

The default popup renderer skips these keys so they don't show up as application data. Event payloads do not filter them: :feature_selected carries the GeoJSON Feature exactly as your data provided it, paint overrides included.

Popup HTML

Clicking a point or area feature opens a MapLibre popup, built by default from the feature's properties (buildPopupHTML in priv/js/popup.js):

All values are HTML-escaped, and feature data can never inject markup — there is deliberately no property that reaches the popup unescaped. Rich popups are code, not data: pass a popupContent renderer to createMapHook and return a DOM Node, which goes in via MapLibre's Popup#setDOMContent:

const hook = createMapHook(maplibregl, {
popupContent(feature) {
const el = document.createElement("div")
el.className = "phx-maplibre-popup"
el.textContent = feature.properties.title ?? "Unnamed"
return el // built with createElement/textContent — XSS-safe by construction
},
})

Return null (or nothing) to fall back to the default popup for that feature. If you assemble HTML strings inside the renderer via innerHTML, sanitizing them is on you — but that choice then lives visibly in your code, where a review can find it, not in whatever GeoJSON happens to flow in.

CSS classes to target for custom styling: .phx-maplibre (the container), .phx-maplibre-popup, .phx-maplibre-popup-title, .phx-maplibre-popup-desc, .phx-maplibre-popup-details, .phx-maplibre-popup-row, .phx-maplibre-popup-label, .phx-maplibre-popup-value, and .phx-maplibre-popup-type (styled but not emitted by the default renderer — it's there for custom popupContent nodes). priv/css/phx_maplibre.css ships light-mode styles for those plus MapLibre's own .maplibregl-popup-content and .maplibregl-popup-close-button, and dark variants scoped under [data-theme="dark"] — including .maplibregl-popup-tip and .maplibregl-ctrl-group, which are only restyled in dark mode.

Theme switching

light_style and dark_style are two independent MapLibre style URLs. The hook watches <html data-theme="..."> with a MutationObserver, debounced 300ms, and swaps styles when the resolved theme changes:

MapLibre discards everything the library added when the style changes, so once the new style has loaded the hook re-adds its sources and layers, pushes the current feature data back in, and restores hover and selection state. That happens on every theme flip and after an explicit set_style/3, with nothing required from the LiveView. Writing data-theme is your app's job; PhxMaplibre only reacts to it.

By default light_style and dark_style point at CARTO's public style CDN, which is fine for demos but worth reconsidering before production: the browser fetches that style JSON directly at runtime, so your map's appearance now depends on a third party's availability and on trusting the content they serve. Production consumers should self-host the style JSON (and its tile sources) or at least pin and review the specific style version they point to, rather than trusting a mutable public URL indefinitely.

Telemetry

Both events carry %{system_time: System.system_time()} as measurements.

Demo apps

apps/demo_gsd_tracker (sibling umbrella app, port 4002, live at gsd-tracker.weltenseglr.de) is the fuller example. A supervised OTP simulation flies GSDs ("surveillance pigeons") around Berlin — config :demo_gsd_tracker, :gsd_count sets the fleet size, 24,000 in the deployed demo — and pushes position updates with PhxMaplibre.set_features/3, reading viewport bounds back out of :move_end events to cull the feature set to what's on screen. Start at apps/demo_gsd_tracker/lib/gsd_tracker_web/live/map_live.ex, with assets/js/app.js and assets/css/app.css for the asset wiring.

apps/demo_berlin_districts (port 4001, live at phx-maplibre.demo.weltenseglr.de) is smaller: three maps on one page, covering clustering, area hover, geolocation, and theme switching.

License

EUPL-1.2, with an explicit clarification that commercial use — internal business use, powering commercial SaaS, and paid services or support — is permitted and encouraged. See LICENSE.