Appendix B — map_controller.js: For the Curious
Every map in this series is rendered by Leaflet. But you have not written a
single line of Leaflet code. This appendix explains how that is possible —
what map_controller.js is actually doing, and why it is designed the way it
is.
You do not need to read this to use the library. It is here for the moment when something in a module made you wonder: but how does that actually work?
What the Controller Is
map_controller.js is a Stimulus controller. Its job is narrow and deliberate:
it owns the Leaflet map instance for the duration of a page visit. It creates
the instance when the component appears in the DOM, configures it from the JSON
the Ruby DSL produced, and destroys it cleanly when the component leaves.
That is all it does. It does not know about your data. It does not know about your routes. It does not know which module you are working through. It reads a JSON blob and hands the result to Leaflet.
The JSON blob is the boundary. On one side of it is Ruby. On the other side is Leaflet. The controller sits exactly at that boundary and does the translation.
The Lifecycle
A Stimulus controller has two moments that matter: connect() and
disconnect(). Everything the controller does happens in one of those two
places, or in a private method called from one of them.
connect() — building the map
When the <div data-controller="map"> element appears in the DOM, Stimulus
calls connect(). This is where the Leaflet map instance is created.
|
|
The order matters. The map instance must exist before anything can be added to it. The tile layer should be added before markers and data layers, so the background renders first. The resize observer is set up last — it is infrastructure, not content.
Each #add* method reads from the values Stimulus has already parsed from the
data-map-*-value attributes — the JSON that Components::Map rendered.
By the time connect() runs, all of that data is available as plain JavaScript
objects.
disconnect() — tearing it down
When the element leaves the DOM — because of a Turbo navigation, a Turbo Frame
update, or the page closing — Stimulus calls disconnect().
|
|
The map.remove() call is the one most people do not expect to need. Unlike a
chart library that simply stops rendering when its canvas is removed, Leaflet
attaches event listeners to the window and document objects during
initialisation — listeners for keyboard events, scroll events, and resize
events. If you remove the map’s DOM element without calling map.remove(),
those listeners remain attached. In a Turbo application, where the same
JavaScript context persists across many page visits, this produces a slow
accumulation of ghost event listeners that eventually causes subtle and
confusing bugs.
map.remove() cleans all of that up. Setting this.map = null afterwards
ensures the garbage collector can reclaim the memory.
The resize observer and ActionCable subscription get the same treatment — both attach to external objects that outlive the map element, so both must be explicitly cleaned up.
Three Things That Surprised Us
1. Why does Leaflet need to be told when its container resizes?
Most DOM elements reflow automatically when their container changes size. Leaflet maps do not, because Leaflet measures the container’s dimensions once at initialisation and uses those measurements to calculate tile positions and marker coordinates. If the container later changes size — because a sidebar collapsed, a panel opened, or the window was resized — Leaflet’s internal measurements are wrong. Tiles appear in the wrong places. Markers drift.
map.invalidateSize() tells Leaflet to re-measure its container and
recalculate everything. The ResizeObserver calls it automatically whenever
the container’s size changes.
|
|
This is the kind of thing that works perfectly during development — when you are not resizing panels — and fails mysteriously in production when a user opens a sidebar.
2. Why do Leaflet events come from the instance, not the DOM?
In standard web development, events bubble up through the DOM and you listen for them on elements. Leaflet has its own event system that sits alongside the DOM event system. A click on a marker, a zoom change, a layer being added — these are Leaflet events, fired on the map instance or on individual layer objects, not on DOM elements.
This means you cannot use standard data-action Stimulus syntax to listen for
Leaflet events. The controller bridges this gap explicitly, translating Leaflet
events into either Stimulus dispatched events (for cross-component
communication) or direct DOM updates.
|
|
This is why the select_url: and group: features are implemented in the
controller rather than in HTML — there is no other place for them to live.
3. Why is the tile layer a named string rather than a URL?
You specify a tile layer in Ruby like this:
|
|
Not like this:
|
|
The named string approach exists for two reasons. First, tile provider URLs include subdomains, attribution strings, and sometimes API keys — details that do not belong scattered through your view layer. Second, attribution is a licence requirement, not optional. If you pass a raw URL, you have to remember the attribution separately. If you pass a name, the registry supplies the attribution automatically. You cannot forget it.
The controller resolves the name to a full tile configuration from a registry object before passing anything to Leaflet. The Ruby DSL and the JavaScript registry use the same names, so the contract between them is just a string.
The Full Source
The complete annotated map_controller.js — including plugin registration,
layer management, ActionCable integration, and the group event system — is
maintained in the companion GitHub repository:
github.com/your-org/leaflet-phlex
The repository includes installation instructions, a changelog tracking Leaflet version compatibility, and notes on extending the controller for cases the library does not cover out of the box.
See also: Appendix C — Components::Map Reference