Skip to content

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
connect() {
  this.map = L.map(this.element, this.#mapOptions())
  this.#addTileLayer()
  this.#addMarkers()
  this.#addLayers()
  this.#setupGroupEvents()
  this.#setupActionCable()
  this.resizeObserver = new ResizeObserver(() => this.map.invalidateSize())
  this.resizeObserver.observe(this.element)
}

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

1
2
3
4
5
6
disconnect() {
  this.resizeObserver?.disconnect()
  this.subscription?.unsubscribe()
  this.map?.remove()
  this.map = null
}

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.

1
2
this.resizeObserver = new ResizeObserver(() => this.map.invalidateSize())
this.resizeObserver.observe(this.element)

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.

1
2
3
this.map.on('click', (e) => {
  this.dispatch('click', { detail: { lat: e.latlng.lat, lng: e.latlng.lng } })
})

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:

1
Map::Options.new(tile_layer: :cartodb_positron)

Not like this:

1
2
3
4
Map::Options.new(
  tile_url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
  tile_attribution: "© OpenStreetMap contributors © CARTO"
)

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