Rails 7, Leaflet and Import Maps with ESM and Stimulus
How to add Leaflet to Rails 7 with import maps.
I reach for Rails whenever I have a side project that I want to spin up quickly because it's just so magically productive. Hotwire is simply amazing – I ❤️ Turbo Frames.
I wanted to add some beautiful Leaflet maps to my Rails app using import maps rather than wrestling with the old asset pipeline. Here are the steps I took.
Pinning the libraries
Firstly, the Leaflet libraries need to be pinned and importmap
can work out the links to a CDN.
bin/importmap pin leaflet
bin/importmap pin leaflet-css
This adds these two lines to the importmap.rb
file (your version may vary, naturally):
# config/importmap.rb
# ...
pin "leaflet", to: "https://ga.jspm.io/npm:leaflet@1.9.4/dist/leaflet-src.js"
pin "leaflet-css", to: "https://ga.jspm.io/npm:leaflet-css@0.1.0/dist/leaflet.css.min.js"
Create a Stimulus controller
I'm going to wire up Leaflet using another part of Hotwire, Stimulus, because it makes sprinkling in JavaScript so much less gross. I'm using a Rails generator to scaffold a stimulus controller called map
.
rails generate stimulus map
Now I'm going to edit the controller so that it creates a Leaflet map on the connect
lifecycle callback. This is fired whenever the stimulus controller is connected to the DOM.
// app/javascript/controllers/map_controller.js
import { Controller } from "@hotwired/stimulus"
import "leaflet"
import "leaflet-css"
// Connects to data-controller="map"
export default class extends Controller {
connect(){
var map = L.map('map').setView([54, -4], 6);
var tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
}
disconnect(){
this.map.remove()
}
}
This imports both leaflet and leaflet-css modules, creates a map roughly centred on the UK and adds Open Street Map tiles (with attribution). The L.map('map')
will place the map in a div with the id of map
Update the view
Now I need a place in the HTML to attach the map to. I use Haml as my templating engine.
#map.leaflet-container{ style: 'min-height: 400px' }
%div{ data: { controller: 'map' } }
This creates a div with the id of map
which matches the id the JS is going to look for. It's styled with leaflet-container
class. The div has an explicit minimum height, without which the div will be zero height.
An inner div wires up the stimulus controller.
That's it. A lovely map, a JavaScript library and not a bit of the asset pipeline in sight and all with delicious separation of concerns.
...and a bit further
I actually want to add a bit of GeoJSON to draw a region on the map. This is how I tackled it.
My Rails controller has an action which renders the GeoJSON as a JSON object.
# app/controllers/places_controller.rb
class PlacesController < ApplicationController
def geometry
render json: geo_str(params[:id]) # some magic to get the bounds
end
end
and I have an entry in routes.rb
which gives me the geometry_place_path
helper.
I'm going to pass the URL for this into the stimulus controller and use JavaScript to fetch the contents. For this, I'll use a stimulus value attribute:
#map.leaflet-container{ style: 'min-height: 400px' }
%div{ data: { controller: 'map', 'map-geourl-value': geometry_plave_path(@place_id) } }
Finally, change the stimulus controller to use the value and fetch the data:
import { Controller } from "@hotwired/stimulus"
import "leaflet"
import "leaflet-css"
// Connects to data-controller="map"
export default class extends Controller {
static values = { geourl: String }
connect(){
var map = L.map('map').setView([54, -4], 6);
var tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
var geo = fetch(this.geourlValue)
.then( function (response) {
return response.json();
})
.then( function (json) {
var place = L.geoJson(json).addTo(map);
map.fitBounds(place.getBounds());
})
}
disconnect(){
this.map.remove()
}
}
Member discussion