All notable changes to this project are documented here. The format follows Keep a Changelog and this project adheres to Semantic Versioning.

0.9.0 - 2026-09-11

Added

  • shape_source: geometry the browser fetches from a URL, rather than carried in an attribute.

    <.map id="parcels" shape_source={{:url, ~p"/api/parcels.geojson", rev: @parcels_rev}} />

    The case shapes is wrong for, and the one the README has called the honest limit of the transport since 0.1.0: an HTML attribute is a single dynamic slot, so touching any shape re-serialises every one of them. Right for a parcel outline, wrong for hundreds of kilobytes of cadastre that never changes and is the same for everybody. :rev rides as a query parameter, so changing it asks a question no cache has an answer to; :style restyles what is loaded rather than fetching again.

    A layer of its own, not a variant of the shape layer. That one knows every feature it holds, keyed by the id it was told; features read out of a file are in no such map, and letting them into that source would put untracked features beside tracked ones — the problem the sketch layer already exists to avoid on the drawing path.

    What it costs is the server's knowledge of what it sent. These features are drawn under the shapes it does send, and there are no popups, no keyboard entries and no :editable for them, because all three need a shape the server can name.

    The document is fetched here rather than through OpenLayers' own loader, which has no handle to cancel one. A rev bumped while a large document is still arriving leaves two responses racing, and features are indexed by id: a stale response landing first takes the ids and the fresh ones are dropped as duplicates, leaving the old document on the map under the new URL until somebody bumps again. Only the current request is accepted; a failed one empties the layer and says which URL it was, rather than leaving the last document that did load on the map — clickable, framed, and answering to a URL nobody is asking for — with one console line to say it is stale.

    A file's own id is kept beside its feature rather than on it: ol/source/Vector indexes by that and silently refuses a second feature whose id is taken, so a document repeating one — a parcel split into several Features — would lose every repeat, with nothing logged.

    They take part in the framing, but claim no fit of their own for having arrived late. A map with nothing to frame at mount has not spent the one fit every map without a center gets, so the first document claims that one; a map that already framed its markers, or that was flown somewhere, keeps the view it has.

  • on_source_shape_click: clicks on shape_source geometry, carrying whatever id and properties the GeoJSON declares.

    A handler of its own rather than on_shape_click with a flag in the payload, because without one this geometry has to be scenery. A cadastral backdrop covers the whole viewport, so a click claimed on the strength of on_shape_click would leave the map with no on_map_click anywhere — the rule 0.7.0 established for decorative shapes, for geometry the server cannot name. A <:shape_popup> is no reason to claim one either: there is nothing to open for a feature the server has never seen, and a file's id colliding with a shape's would open that shape's popup over geometry with nothing to do with it.

    UrlShapeLayer joins the escape hatch's exports.

Changed

  • Rover.fly_to/4 and Rover.fit_to/4 now count as a map's initial framing.

    A map with no center is framed around its content once, and until now that fit was still owed after a flight: on a map whose content arrives asynchronously — markers from a slow query, or a shape_source document — it ran when the content landed and pulled the view back off wherever the application had just sent it. Both commands are decisions about the view, and the client's own contract for them said so: "the next update must not undo this and must not think a fit is owed."

    Under Changed rather than Fixed, because an application can be relying on the old behaviour: one that flies to a placeholder region at mount while its markers load used to be reframed around them when they arrived, and now stays where it flew. If that is what you wanted, fit={true} refits on every change, and framing on arrival is what leaving the view alone at mount already does.

0.8.0 - 2026-09-08

Added

  • layers: tile layers drawn over the basemap and under everything else — a cadastral overlay on a plan, orthophotography under the roads and labels of a map. Each entry is {:tiles, spec} or {:tiles, spec, opts}, where spec is anything tiles itself accepts, presets and vector styles included, with :opacity, :visible, :min_zoom, :max_zoom and :id.

    Reconciled the way markers and shapes are: changing an opacity or hiding a layer keeps every tile the browser has already fetched, and only a layer whose spec changed is rebuilt. :id is what carries a layer across a change of position in the list; without one, position is the identity, since two entries of the same tiles are otherwise indistinguishable.

    Deliberately beneath the heatmap, the shapes and the markers, and in list order. A layer here is part of the backdrop being assembled — tiles drawn over the caller's own data would hide it — and that keeps one z-index for the whole stack rather than a per-layer ordering to reason about.

    They live in a single ol/layer/Group rather than N layers in the map's own collection, so adding one is ordinary work on that group instead of more surgery on the array the basemap swap already reaches into. The construction a basemap and an overlay share — including the vector path, which is a group ol-mapbox-style populates rather than a source that can be set — moved into assets/js/tiles.js, so there is one place for the licence handling to live.

  • declutter: hides labels that would overlap instead of drawing them over one another. Markers and shapes share one declutter group, because two layers decluttering separately resolve only their own collisions and a shape's label would still be drawn across a marker's.

    Every marker image is an obstacle rather than a candidate — pins, icons, emoji, and a cluster circle with its count. OpenLayers declutters images as readily as text, so without that a dense map loses whole markers to make room for labels, which is backwards. Measured on the playground's crowd, by asking the map at each marker's own position whether a marker is there: 228 of them answer with the images marked as obstacles, 200 without. Hit-testing, popups and the keyboard index are unaffected either way.

    Off by default: a label that loses a collision is not drawn at all, so on a dense map labels appear and disappear as the view moves. Clustering remains the better answer to the same problem.

  • {:wmts, capabilities_url, layer: "..."}: a real ol/source/WMTS, for a service whose tile grid is its own rather than the Web Mercator one every other basemap here shares. Rover fetches the capabilities document, reads the grid out of it and builds the source from what it says; :matrix_set, :format, :attributions and :max_zoom are optional, :layer is not, since a capabilities document describes several.

    The map renders before the document has been read and gains its tiles when it lands, the shape the vector basemap already had — which is also why :max_zoom has a value from the start: the initial framing needs a ceiling before anything has been fetched. Both asynchronous paths now check that the layer they were handed has not been disposed in the meantime, so a basemap swapped mid-flight cannot come back to life and go on fetching tiles.

    Of the four ways reading a document can go wrong, OpenLayers reports one: an unknown layer. A matrix set the layer does not offer is silently swapped for its first one, a format it does not serve is taken at face value and every tile request then fails quietly, and a matrix set in a CRS OpenLayers does not know — anything but Web Mercator and WGS 84, absent proj4 — makes it dereference a null projection and throw where a reader would blame the network. All four are checked before the document is handed over, and each says which of them it was.

    The :ign_* presets deliberately keep their XYZ shortcut: the Géoportail's KVP endpoint carries the tile coordinates in the query string and its layers are on the grid the map already uses, so nothing is bought by the round-trip there.

Changed

  • The attribution control is rendered for a map with layers but no basemap. tiles={:none} was the one case that dropped it; a map that draws no basemap and two overlays still renders somebody's tiles.

0.7.0 - 2026-09-07

Added

  • interactions, an allow-list of the gestures a map answers to, mirror of controls: :drag_pan, :mouse_wheel_zoom, :double_click_zoom, :pinch_zoom, :keyboard_pan, :keyboard_zoom, :drag_rotate, :pinch_rotate, :drag_zoom. All of them by default, so nothing changes for a map that says nothing. The two reasons to trim it: a map in the flow of a page should leave the wheel to the page, and a shift-drag should not be able to leave the map crooked with no way back. interactions={[]} is not interactive={false} — tooltips, clicks and the cursor stay — and Rover's own dragging, editing and drawing interactions are not on the list.

  • :anchor, :rotation and :opacity on markers, for the pin or an :icon. :anchor is [x, y] as fractions of the image — [0.5, 1], the default, is a pin's tip; [0.5, 0.5] the middle of a dot or a badge. :rotation is in degrees, clockwise, for an icon with a heading. A marker's popup now clears the image by the image's own height above the coordinate, read off the icon once it has loaded, rather than the built-in pin's 36px — a taller icon used to be overlapped, a centred one floated above nothing.

  • Theming through CSS custom properties. Every colour in rover.css is now a --rover-* property on .rover-map, so an application restyles a map by setting a variable rather than overriding a rule. Dark mode answers to data-theme="dark" or a .dark class on an ancestor as well as to prefers-color-scheme, and data-theme="light" / .light holds a map light on a dark OS — the two things a page with its own theme toggle could not do before. The basemap is deliberately not coupled to any of this: tiles are content and already an attribute.

Changed

  • The attribution control is on whenever the map has a basemap, whether or not controls lists it. controls={[:scale_line]} used to drop it silently, and every preset's provider requires the credit as a condition of use — a list that happened to leave it out was never a decision to stop honouring that. Forced rather than raised on: an existing list keeps rendering, and the control shows nothing for a source with no attribution text, so adding it is never visually wrong. tiles={:none} still renders without it, there being nothing to credit.

Fixed

  • A shape with no handler and no popup swallowed on_map_click. The rule that such a shape is scenery — that it must not claim the click, or a filled polygon eats every map click across its interior — was in the code and in 0.3.0's notes, and had never once fired. The popup layer subscribes to shape clicks on every map, to dismiss whatever is open, and the client counted that subscription as interest. Every shape on every map was therefore a click target, with the pointer cursor to prove it, and a click inside a decorative zone produced nothing at all. The server now says whether a <:shape_popup> was rendered, and Rover's own subscribers no longer count. An application subscribing through the JavaScript escape hatch still does.
  • Point shapes rendered as nothing. A shape style had a stroke and a fill, which paint lines and areas; a Point has neither, so it drew as nothing — including a point drawn with start_drawing/3 and echoed back by the server, which vanished the moment it was accepted. Point and MultiPoint shapes are now drawn as a dot in the shape's colour.
  • A typo in marker_fields, shape_fields or heatmap_fields was silently ignored. A field that was never mapped falls back to its default keys, so marker_fields={[latitude: :lat]} read :lat as if nothing had been said, and marker_fields={[:latitude]} — the mapping written backwards — did the same. Both now raise, naming the fields Rover knows, the way cluster and controls already did for theirs.
  • heatmap_fields maps the coordinate too. :lat and :lon were accepted by no one: unmapped they are read by Rover.Geo, which knows :latitude, :longitude and :lng but not a schema's own names, and a mapping naming them was ignored. They now work as they do for markers — including one axis at a time, and by function.
  • A nil tiles option no longer overwrites a preset's own value. {:osm, attributions: Application.get_env(:my_app, :credit)} with the config unset would have blanked the OSM credit this release goes out of its way to keep. Only options actually given are merged.
  • Rover.bbox/1 and fit_to/4 accept the box on_move_end delivers. The event hands you "bbox" => %{"south" => …}; the functions wanted a {south, west, north, east} tuple, so the one value you had could not be handed back without repacking it. Both key styles are read now.
  • {preset, opts} dropped every option but :key. {:osm, max_zoom: 18} resolved as plain :osm, with nothing to say about the option it lost, while {:xyz, url, opts} honoured :max_zoom and :attributions all along. A preset now takes both, and every tiles form rejects an option it does not read rather than ignoring it.
  • Tearing a map down left its interactions, controls and tooltip alive. Detaching the map from its element does not dispose it; the editing and dragging interactions kept their listeners on the shape and marker sources, and the full-screen control kept one on the document, for as long as the page lived. The hook now disposes the map itself.

0.6.0 - 2026-09-04

Added

  • Vector basemaps: :carto_light_vector, :carto_dark_vector, and :carto_voyager_vector, rendered through ol-mapbox-style as native OpenLayers vector tile layers alongside the existing raster presets, which keep working unchanged. A generic {:vector, style_url} / {:vector, style_url, opts} hatch, symmetric with the existing {:xyz, ...} one, covers any other MapLibre-compatible style — Mapbox, MapTiler, or self-hosted. carto_api_key config and the per-call key: opt apply to the three vector presets exactly as they already do to their raster counterparts.

0.5.0 - 2026-08-31

Added

  • Drawing new shapes: Rover.start_drawing/3, Rover.stop_drawing/2 and on_draw_end. The other half of 0.4.0's :editable, which could only reshape a geometry that already existed. Deliberately a mode rather than an attribute — there is no shape yet to hang a per-item flag on, and an always-armed ol/interaction/Draw would swallow every ordinary map and shape click — so it is a one-shot command in the shape of fly_to/4. on_draw_end is the only event with no "id": the shape does not exist until the server makes one, so identity is the server's to assign.

    The sketch is drawn into a separate scratch source, never into ShapeLayer's own. A feature Draw put there would be absent from the entries map reconcile/1 tracks, so the server's echo of the new shape would render a second feature beside it and the polygon would appear twice. The scratch layer is cleared by the same call that applies a new :shapes list, so the handover is invisible on the accepted path and is the cleanup on the abandoned one.

    :type is :polygon, :line or :point. There is no :circle: OpenLayers draws one, GeoJSON cannot represent one, and it could never round-trip through Rover.Shape. Escape abandons the sketch in progress and leaves the mode armed; new points snap to the shapes already on the map; a map with interactive={false} refuses to arm, and locking one mid-draw cancels the mode rather than remembering it.

    While the mode is armed the map does not report clicks or tooltips of its own: a click that places a vertex is not a click on the map, and reporting it would fire on_map_click once per corner — dismissing any open popup each time, since the popup layer listens for exactly that.

  • A keyboard and screen-reader pass. A map was a canvas with nothing focusable in it and no accessible name, so everything on one was unreachable without a mouse.

    The map itself now carries tabindex="0" and role="application", and OpenLayers' keyboardEventTarget is pointed at the element the component gives that tabindex to. KeyboardPan and KeyboardZoom were in defaultInteractions() from the start — present, and unreachable, because nothing ever focused the viewport they listened on.

    Every marker and shape a click can do something with is rendered as a visually-hidden button, which is the only DOM a keyboard or a screen reader can reach a painted feature through. Pressing one takes the same path as a pointer click: the same payload to the server, the same popup opened by the same subscriber. Features that are pure scenery — no handler, no popup — get no button, because a tab stop that does nothing when pressed is worse than none.

    Popups became role="dialog", named after their feature. One opened from the keyboard takes focus and hands it back to the button on close; one opened by a pointer does neither, since a mouse user's attention is already where they clicked.

    New label attribute for the accessible name, defaulting to "Map". The canvas's aria-label and tabindex are re-applied by the client on every config change, because the element is phx-update="ignore" and LiveView merges only data-* attributes onto one of those — a map that locked itself would otherwise stay a focusable role="application" with nothing behind it.

  • Carto API key support on :carto_light, :carto_dark, and :carto_voyager, which Carto's basemaps now need. Configure a default with config :rover, Rover.Tiles, carto_api_key: "...", or pass tiles={{:carto_dark, key: "..."}} per call to override it. A preset with no key configured or passed keeps the URL it had.

    Worth knowing what an unkeyed map looks like, because it is not what anyone goes looking for: the tiles still load, with API KEY REQUIRED stamped diagonally across every one. Not a blank map, not a 401 — a legible map wearing a watermark. The key is free, issued by return email with no queue and no Carto account, and covers 5 million tile requests a month.

    Carto also say the raster service is being retired in favour of vector tiles and that they are considering freezing its data updates, with no date published for either. Rover's basemap layer is raster-only today; the key covers both services, so nothing is wasted when that changes.

    Thanks to @rockneurotiko, who found this and wrote the implementation.

  • Dialyzer in CI, and mix dialyzer locally. The public API is annotated with @spec end to end and nothing ever checked those annotations against the code. The PLT is written to _build/plts, so the _build cache CI already keeps carries it; :missing_return and :extra_return are on, because a @spec that has drifted from its function is the thing worth catching.

  • A coverage floor. mix test --cover now fails below the threshold declared in mix.exs, and the lint row of the CI matrix runs it. The floor is a ratchet: raise it when a release lands above it, never lower it to make a red run green.

Removed

  • A Rover.Shape fallback clause that could not be reached — every caller of the private get/2 is already inside an is_map/1 guard. Dialyzer's first find.

0.4.0 - 2026-08-12

Added

  • :editable on shapes, and on_shape_edit_end. Lets the user drag a shape's vertices directly on the map — the geometry equivalent of :draggable on a marker, built the same way: an ol/interaction/Modify filtered to editable single-feature shapes, and the same "forget the cached identity so a same-valued server payload still reapplies" fix :draggable needed, ported from a geometry hash to the :rev shapes are actually diffed by. A FeatureCollection shape (more than one underlying feature) stays read-only — there is no single geometry a drag could write back to. on_shape_edit_end's geometry is always bare; a Feature-wrapped shape's properties travel in their own key on the payload instead, so merging the result back into :geometry does not silently lose them.

Fixed

  • The installation example still read {:rover, "~> 0.3"} in both the README and the Rover moduledoc.

0.3.2 - 2026-08-10

Added

  • A smoke test over the bundles in priv/static (assets/test/bundles.test.js). Nothing exercised what Hex actually ships: the playground and the browser suite both import assets/js/index.js, and the CI bundles job only runs git diff --quiet -- priv/static, which proves the artefacts match the source and not that they load. The new test loads rover.js and rover.min.js and checks the export surface, the hook's LiveView callbacks and a project / unproject round trip; rover.external.js cannot be imported from priv/static — it leaves ol as a peer import and there is no node_modules above it — so it is checked as text instead.
  • Elixir 1.16.3 in the CI matrix. It sits inside the ~> 1.15 requirement and was the one version in range that nothing tested.

Fixed

  • The bring-your-own-OpenLayers instructions did not build. Following them as written stops esbuild with Could not resolve "ol/Map.js", once per 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 — deps/rover/priv/static/, where no node_modules exists — and Phoenix's generated NODE_PATH covers deps only, never assets/node_modules. Both the README and the Rover moduledoc now carry the config/config.exs change that puts ol on the search path. Reproduced against esbuild 0.25.12 with Phoenix's own cd and NODE_PATH, and confirmed fixed the same way.
  • The installation example in the Rover moduledoc still read {:rover, "~> 0.2"}. 0.3.1 fixed the README copy and missed this one, which is the snippet HexDocs renders on the module page.
  • mix precommit ran assets.test before assets.build, so the new bundle smoke test would have checked the artefacts from the previous run and let the ones about to be committed through untested.
  • The changelog had no link definitions for [0.3.0] and [0.3.1], so both headings rendered as literal brackets.
  • The comment explaining why setContent fits once was attached to setHeatmap, the method above it.

0.3.1 - 2026-08-08

Fixed

  • The Livebook (notebooks/rover.livemd) failed at section 5 with assign/2 imported from both Kino.JS.Live.Context and Phoenix.Component, call is ambiguous. Section 4 imported Phoenix.Component at the top level of its cell to get ~H; Livebook threads a cell's top-level imports into every later cell, so it collided with Kino.JS.Live.Context's own assign/2 in RoverKino. The import now lives inside its own module, scoped to the cell that needs it.
  • The installation example in the README still read {:rover, "~> 0.2"}, one series behind what it was installing. The same copy in the Rover moduledoc was missed; see Unreleased.

0.3.0 - 2026-08-07

Added

  • Clusteringcluster={true}, or a keyword list of :distance, :min_distance and :zoom_on_click. A group of one is drawn as its own marker; clicking a group zooms into it and sends on_cluster_click with the member ids. Reconciliation is untouched, because ol/source/Cluster wraps the marker source rather than replacing it — the markers are still diffed by id, only the drawing changes. A grouped marker has no popup (its pin sits at the group's centre, so a popup would point at empty space) and cannot be dragged.
  • Rover.Heatmap and the heatmap attribute — density as a heat field. No :id required, unlike markers and shapes: a heatmap is an aggregate, so per-point identity buys nothing. Diffed by revision instead, which also means a style-only change restyles the layer without rebuilding the field. Tunable with heatmap_style: :radius, :blur, :opacity, :gradient.
  • A <:shape_popup> slot, anchored where the geometry was clicked rather than at its centroid. Popup keys are now namespaced (marker:1, shape:1), because a marker and a shape may legitimately share an id and two nodes answering one selector means one of them silently wins.
  • :tooltip on shapes, shown at the pointer on hover, falling back to :label.
  • A shape claims a click when either the server or a popup wants it. Keying it on the configured event alone meant a <:shape_popup> could never open without an on_shape_click handler — while marker popups need no server at all. A shape with neither still claims nothing, so scenery does not swallow on_map_click.
  • Rover.fly_to/4 and Rover.fit_to/4 — imperative view control, for when the view is a gesture rather than state. center and zoom are attributes, so using them for "the user clicked a row, take me there" costs you the automatic framing and forces the view into your assigns. These are commands: nothing is assigned, no attribute changes, and the map keeps its declarative framing for everything else. Rover.bbox/1 is public alongside them, and takes markers, shapes, plain coordinates or a box.
  • A browser suite. Five Playwright scenarios against the mix dev playground, guarding the paths where every rendering bug this library has shipped actually lived: the tile URLs the browser requests, the popup DOM, and the view after an update. Runs in CI, and via mix assets.test.browser. Deliberately out of mix precommit — it needs a server and a browser.
  • The map instance is exposed on its own element as el._rover. The browser suite needs it (a marker is drawn in a canvas and has no DOM node, so a coordinate has to be turned into a pixel through the map itself), and it is the fastest way to answer "why is my marker not there?" from a console.
  • ?shapes=parcel|route|none on the playground picks the initial geometry, so the framing bug's conditions can be reproduced on a fresh mount.

Fixed

  • Clustering, from review, before it ever shipped:

    • Every discarded ol/source/Cluster stayed subscribed to the marker source, so each toggle of cluster left another live clusterer re-clustering the whole set on every update, in a source nothing draws.
    • Clicking a group above zoom 16 zoomed out. View#fit treats maxZoom as a resolution floor, so it clamps both ways: the drill-in now uses the basemap's own ceiling and refuses to move the view backwards at all.
    • A :draggable marker alone in a group was still draggable, and the drag moved the throwaway feature Cluster allocates — the marker's own geometry untouched, the event reporting coordinates for something that is not the marker, and the pin snapping back on the next recompute. Nothing is draggable while clustering, which is what the documentation already claimed.
    • The Cluster wrapper dropped wrapX: false, so groups repeated across world copies.
    • A cluster click did not dismiss an open popup, which mattered with zoom_on_click: false where nothing else moves.
    • A non-keyword list — cluster={[:distance]} — raised a match error instead of the friendly message every other option in the component produces.
    • on_cluster_click was in neither event table and had no docs, so the only place its payload was written down was the playground.
    • Reconciling a batch of moved or restyled markers reclustered the entire marker set once per feature touched rather than once for the batch — ol/source/Cluster recomputes on every change event from the source it wraps, and every setCoordinates/setStyle call fires one. A fleet of five hundred with fifty vehicles moving in one update paid for fifty full passes, not one, on exactly the workload clustering exists to make affordable.
  • Field accessors of the wrong arity now raise instead of being read as a map key and silently substituting the default. mix format rewrites &(&1.orders / 40) as & &1.orders/40, which Elixir parses as an arity-40 capture — so the mistake is easy to make and used to produce a quietly wrong map. Applies to Rover.Marker, Rover.Shape and Rover.Heatmap.

  • Live reload never worked in the playground. The endpoint declared plug Phoenix.LiveReloader but not the socket it connects to, so the browser retried a 404 forever while the esbuild watcher rebuilt bundles nobody loaded. Found by the browser suite on its first run, by refusing to tolerate a console error.

  • The playground had no PubSub, so the live-reload channel raised on every join.

  • height={nil} is now legal, and actually works. attr :height, :string rejected the nil its own documentation recommended — a compile warning, so an error in any project building with --warnings-as-errors. It is :any now, like :class. The attribute is also genuinely omitted rather than rendered as style="", because an empty inline style still beats a class in the cascade: a map sized by class="h-96" or by a flex parent could not be sized at all. A style you pass yourself now takes precedence over height.

Changed

  • The README's opening argument no longer rests on a comparison with another library. It answers the question a Phoenix developer actually faces — "why not just write a hook?" — and Leaflet's name has moved to a Coming from a Leaflet hook section, where it is a migration table rather than a benchmark. That section also names the three things that catch people: markers need a stable :id, height beats your class, and stroke opacity goes through rgba().

0.2.0 - 2026-08-06

Everything the README called "the obvious next steps", minus clustering.

Added

  • Rover.Shape and the shapes attribute — GeoJSON geometries: outlines, routes, zones. A bare geometry, a Feature or a FeatureCollection; atom or string keys; or an undecoded JSON string, so ST_AsGeoJSON output goes straight in. Styled with :color, :width, :fill_color, :fill_opacity and :label.
  • Shapes travel in their own data-rover-shapes attribute, so a marker that moved does not re-serialise a cadastral outline that did not.
  • Geometry is diffed by a server-computed :rev (:erlang.phash2/1 by default, or your own updated_at), never by hashing coordinates on the client. A route is thousands of points; hashing it per update is the cost the reconciler exists to avoid.
  • A map with shapes and no markers now frames the geometry. Previously it centred on {0.0, 0.0} — a parcel page showed the Gulf of Guinea.
  • :emoji on markers, drawn as canvas text rather than a DOM overlay, so it keeps the shared style cache, hit testing and reconciliation by identity that a pin has.
  • A <:popup> slot, rendered once per marker and shown on click with no server round-trip. Closed by data-rover-popup-close, a map click, or Escape. Deliberately not an ol/Overlay: an Overlay reparents its node into the map viewport, which lives inside phx-update="ignore", and LiveView would then be patching markup it no longer owns. Rover positions server-rendered nodes that never leave the outer element.
  • :ign_plan and :ign_ortho — the French Géoportail's reference plan and aerial orthophotography, both intended for production use rather than the demo endpoints the OSM and Carto presets point at.
  • on_shape_click, with markers winning ties: a pin inside its own parcel outline answers the click.

Fixed

  • Markers were excluded from the initial framing whenever shapes were present. The mount path loaded shapes, fitted, then loaded markers — and the second fit declined, because the first had already happened and fit defaults to :once. A map with both therefore framed the shapes alone, and any marker outside their bounding box was off-screen for good. Both layers are now loaded before a single fit. This was the release's headline combination, so it is worth being blunt: it was broken.
  • The zoom cap that keeps a lone marker from filling the screen had been removed for any non-degenerate extent, so two markers twenty metres apart zoomed past what the basemap can render. The cap now follows the tile source's own ceiling, and only marker-only extents stop earlier.
  • A click inside a shape no longer swallows on_map_click when on_shape_click was never wired. Shapes are filled by default, so their whole interior is hit-testable — a click-to-place-a-marker map with zone outlines silently stopped working anywhere inside a zone.
  • An open popup survives a LiveView patch. hidden is static in the template, so every re-render of the marker comprehension restored it and the popup vanished while the client still believed it was open.
  • A popup follows the pin during a drag, instead of hanging back at the coordinate the server last sent.
  • A popup near the top edge flips below its marker rather than being clipped away by the container's hidden overflow.
  • Geometry in the wrong projection — ST_AsGeoJSON on an EPSG:3857 column returns metres — no longer raises while deriving the map's centre. Framing is a convenience; taking a LiveView down at render time over it was the wrong trade.

Changed

  • mix dev takes PORT, and the playground now exercises shapes, emoji, popups and the IGN layers.
  • Fitting spans markers and shapes together, and the zoom cap that keeps a lone marker from filling the screen no longer applies to a polygon — capping a small parcel left it a speck in the middle of a region.

Bundle size

priv/static/rover.min.js grows from 333,765 to 360,062 bytes (98,910 → 104,868 gzipped): the ol/format/GeoJSON reader and the extent helpers. The peer build rover.external.js grows from 17,246 to 27,319 bytes (5,364 → 7,789 gzipped) — shapes and popups are Rover's own code, so leaving ol external does not exclude them.

0.1.0 - 2026-08-05

Added

  • <.map> function component: declarative map with center, zoom, markers.
  • Rover.Marker — normalises plain maps, structs and Ecto schemas into markers.
  • Rover.Geo — strict {lat, lon} handling, bounding boxes, distance.
  • Rover.Tiles — named tile presets (:osm, :carto_light, :carto_dark, …) plus arbitrary XYZ URLs.
  • JavaScript runtime bundling OpenLayers, exposed as the Rover LiveView hook, with keyed marker reconciliation (only changed features touch the map).
  • Events pushed back to LiveView: marker click, map click, move end, marker drag.
  • notebooks/rover.livemd — a Livebook that exercises each layer and renders a live map from Rover's own bundle.
  • GitHub Actions CI: Elixir 1.15–1.19, the Node test suite, and a check that the committed priv/static bundles match assets/js.

Fixed

  • mix dev now actually serves. Supervisor.start_link/2 links to the process that calls it — the one evaluating dev.exs. That process finished, the link took the endpoint down with it, and --no-halt kept the VM alive: the playground logged "Running ... at 127.0.0.1:4020" and then refused every connection.
  • listeners: [Phoenix.CodeReloader] added, which Phoenix 1.8 requires for code reloading; without it every request logged a warning and a stacktrace.
  • Any 404 in the playground — the browser asking for /favicon.ico was enough — raised in Phoenix's error handler, because no error view was configured. The playground now renders status pages, and the layout carries an inline favicon so the request is not made at all.
  • PORT=4021 mix dev runs the playground on another port.
  • The map no longer jumps to a world view when a marker moves. With no center, Rover derives one from the markers — a value that shifts whenever any marker does. The client read each shift as an instruction and animated to the derived centre at the derived zoom, landing on zoom 2 with no way back. The derived centre is now flagged and excluded from view-change detection.
  • A map given no center now always frames its markers once when it appears, even with fit={false} — previously that combination rendered the whole world.
  • setConfig re-applies controls and interactive. Toggling either after mount used to reach the client and do nothing.
  • interactive={false} now withholds the zoom, fullscreen and rotate controls and stops emitting click events, tooltips and cursor changes. Attribution and the scale line stay. Previously the +/- buttons still moved the view and clicks still pushed events.
  • on_move_end flags a bbox that straddles the antimeridian with "crosses_antimeridian" => true, instead of silently returning west > east to a viewport-query that then matches nothing.
  • A partial marker_fields mapping ([lat: :latitude]) no longer breaks reading the other axis from its usual key.
  • A marker dragged on the client is put back if the server does not accept the move; the stale geometry hash used to make the correcting payload look unchanged.
  • RoverMap supplies a default view instead of throwing when handed an incomplete config, which makes the malformed-payload fallback real.
  • The style cache is bounded, so volatile labels cannot accumulate styles for the lifetime of a long-lived LiveView session.
  • LICENSE is now the MIT text alone; the OpenLayers notice moved to NOTICE.md. Appending to LICENSE made licence scanners report "Other" instead of MIT.
  • <.map> no longer emits a trailing space in class when none was given.

Documented

  • Marker ids round-trip through JSON: integers and strings survive, atoms come back as strings and will not match.
  • fit governs refitting; the initial framing is separate.