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
PhxMaplibre.Components.map/1is a plain function component, not aPhoenix.LiveComponent. It renders onedivwithphx-update="ignore"and aphx-hook. The map itself lives in the browser and the component never re-renders it.use PhxMaplibre.LiveViewin the parent LiveView adds anon_mountcallback that attaches twoPhoenix.LiveView.attach_hook/4hooks, one on:handle_eventand one on:handle_info. They relay map traffic in both directions and leave your ownhandle_event/handle_infoclauses alone.PhxMaplibre.LiveView.attach_map/3, called inmount/3, registers a map id and subscribes the LiveView to that map's events and commands topics.- A client interaction reaches the browser hook and is pushed to the server as
a single multiplexed
"maplibre:event"LiveView event carrying the map id, event name, and payload. The:handle_eventhook turns it into a%PhxMaplibre.Event{}and broadcasts it onphx_maplibre:{map_id}:events, where every subscriber, the owning LiveView included, picks it up in an ordinaryhandle_info/2clause. Nohandle_event/3clause is ever needed for map interactions. - A call to
PhxMaplibre.fly_to/3(orset_features/3,fit_bounds/3, …) validates the params, wraps them in a%PhxMaplibre.Command{}, and broadcasts onphx_maplibre:{map_id}:commands. The:handle_infohook on the owning LiveView turns that back into apush_event/3to the browser hook, which applies it to the MapLibre instance.
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:
-
Hex dependency. The package is fetched into the app's
depsdirectory, soNODE_PATHneedsdeps(thepackage.jsonmainfield points atpriv/js/phx_maplibre.js, resolved relative to that real path):# config/config.exs (config/ and deps/ are both direct children of the app root)config :esbuild,my_app: [args: ~w(js/app.js --bundle --outdir=../priv/static/assets/js),cd: Path.expand("../assets", __DIR__),env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__)]}] -
Umbrella sibling, as in this repo, where
demo_gsd_trackerconsumesphx_maplibrewithin_umbrella: true. Herephx_maplibreis not indepsat all but a sibling directory underapps/, soNODE_PATHneeds theappsdirectory itself:# apps/demo_gsd_tracker/config/config.exsconfig :esbuild,version: "0.25.4",demo_gsd_tracker: [args: ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js),cd: Path.expand("../assets", __DIR__),env: %{"NODE_PATH" => [Path.expand("../assets", __DIR__), # local node_modules (maplibre-gl)Path.expand("../../../deps", __DIR__), # shared umbrella depsPath.expand("../..", __DIR__), # apps/ — resolves `phx_maplibre` directlyMix.Project.build_path()]}]
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
:pubsub— the PubSub server. Falls back toconfig :phx_maplibre, pubsub: MyApp.PubSub. One of the two is required, or the call raisesArgumentError.:topic_prefix— overrides the"phx_maplibre"topic prefix.config :phx_maplibre, topic_prefix: "..."sets it globally.:subscribe— subscribe this LiveView to the map's events topic. Defaulttrue. Setfalsewhen another process handles the events.:commands— subscribe to the commands topic and relay commands to the browser. Defaulttrue.:events— the events this map may relay, as atoms. Default: all of them (PhxMaplibre.Event.event_names/0). Pass the same list you gave the component — see below for why the component's list is not enough.:max_event_payload_bytes— drop client event payloads above this size (default524_288, i.e. 512 KB; positive integer or:infinity). The cap guards against fabricated payloads from hostile clients, not your own data — if your features carry large properties (a base64-encoded image, say) that round-trip through:feature_selected, raise it or pass:infinity. On a single node the broadcast shares large binaries rather than copying them per subscriber, so a higher limit costs less than it sounds.
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:
command(map_id, ..., opts)validates the params, broadcasts a%PhxMaplibre.Command{}over PubSub, and returns:ok | {:error, reason}. Callable from any process. Needs:pubsubinoptsorconfig :phx_maplibre, pubsub: MyApp.PubSub.command(socket, map_id, ..., opts)validates the params and callsPhoenix.LiveView.push_event/3on the socket, skipping PubSub. Only the LiveView that owns the map can use it. It saves a PubSub round trip, which is worth having for big or frequent payloads such asset_features/3with thousands of features on a timer.
| 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:
- Features are matched by
idacross updates. A first-seen id appears in place (no fly-in); an id missing from an update disappears immediately. - Properties (status, paint overrides, …) always apply immediately — only the position tweens.
- The measured interval is clamped to 250 ms – 15 s (first update: 5 s), so a burst or a stall cannot produce absurd motion.
- Interaction parity is complete: hover/select feature-state, popups (which
ride their moving feature), and
:feature_selectedwith the position at click time all work identically on the animated source. - The animation loop idles whenever every feature has reached its target and wakes on the next update; above ~1,500 animated features it drops from 30 to 10 frames per second — keep pushing viewport-filtered subsets rather than the world.
- Two safety bounds: an update with more than 10,000 features renders as a
plain snapshot (no animation, one console warning) until a smaller update
arrives, and only features whose
idis a string of at most 128 characters or a finite number participate in tweening — everything else still renders via the snapshot path. - Pass
animate_min_zoom={false}(ornil) on the component to disable the animated path entirely and render every update as an instant snapshot.
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:
- The server chooses map ids. For per-user maps, generate one per
session —
"tracker-" <> Base.encode16(:crypto.strong_rand_bytes(16))— as the demo apps do. It is unguessable and only ever rendered into that user's DOM. - Gate shared maps with your normal LiveView auth. A deliberately shared
id (
"fleet-overview") is fine when the attach is conditional:live_session+on_mountassigns the current user, and you callattach_map/3only aftercan_view_map?(user, map_id)passes. Not attaching is the denial. - Namespace structural ids by tenant (
"org:#{org.id}:fleet") and verify membership inmount/3; backend broadcasters derive topics the same way, so no client-nameable id crosses a tenant boundary. - Sign ids that round-trip through the client (deep links, params) with
Phoenix.Token, and verify before attaching.
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):
properties.title→<h3 class="phx-maplibre-popup-title">properties.description→<p class="phx-maplibre-popup-desc">- every other property (excluding
title,description, keys starting with_, and theSTYLE_PROPSabove) → a.phx-maplibre-popup-rowwith a.phx-maplibre-popup-label/.phx-maplibre-popup-valuepair, in property order
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:
data-theme="dark"→dark_styledata-theme="light"→light_style- no
data-themeattribute → theprefers-color-scheme: darkmedia query decides
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.
[:phx_maplibre, :event]fires when a client interaction is relayed to PubSub. Metadata:%{map_id: map_id, event: event_name}.[:phx_maplibre, :command]fires when a command is sent, from both the PubSub form and the socket fast path. Metadata:%{map_id: map_id, command: command_name}.[:phx_maplibre, :event_dropped]fires when untrusted client input is rejected before relay. Metadata:%{map_id: map_id | nil, reason: reason};reasonis:malformed,:unattached,:unknown_event,:invalid_payload,:disallowed_event, or:oversized.
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.