Tutorial: Geocoding with Rails 7 + Leaflet.js + Stimulus.js

Luis PorrasLuis Porras
5 min read

In this tutorial we are going to add geocoding (address lookup, a.k.a. geoseaching) to a Rails application. The idea is to be able to write an address and get its geo coordinates, latitude and longitude, display that address in a map and save that information in our database.

There are a couple of Map Javascript libraries and one of my favorite is Leaflet.js. Leaflet is the leading open-source Javascript library for mobile-friendly interactive maps.

Getting Started

Let's create a new Rails 7 application:

rails new leaflet-demo
cd leaflet-demo
rails server

We are going to create a model called Place and generate a CRUD using scaffold.

bin/rails generate scaffold Place name:string address:string latitude:float longitude:float
rake db:migrate

Let's setup the root page to redirect to places index page.

# modify ./leaflet-demo/config/routes.rb

Rails.application.routes.draw do
  resources :places
  root "places#index"
end

If we go to /pages/new page we will see a form to create a new Place:

Screen Shot 2022-08-12 at 3.51.57 PM.png

The idea is to replace the address field with a Leaflet Map that is going to have a textfield to lookup for an address.

The address, latitude, and longitude fields are going to be hidden because we are going to populate that information when we find an address and position a marker in leaflet map.

So let's edit our form:

# modify ./leaflet-demo/app/views/places/_form.html.erb

<div data-controller="map">
  <%= form_with(model: place) do |form| %>
    <% if place.errors.any? %>
      <div style="color: red">
        <h2><%= pluralize(place.errors.count, "error") %> prohibited this place from being saved:</h2>

        <ul>
          <% place.errors.each do |error| %>
            <li><%= error.full_message %></li>
          <% end %>
        </ul>
      </div>
    <% end %>

    <div>
      <%= form.label :name, style: "display: block" %>
      <%= form.text_field :name %>
    </div>

    <div data-map-target="container" style="min-height: 300px"></div>

    <%= form.hidden_field :address, data: {"map-target": "address"} %>
    <%= form.hidden_field :latitude, data: {"map-target": "latitude"} %>
    <%= form.hidden_field :longitude, data: {"map-target": "longitude"} %>

    <div>
      <%= form.submit %>
    </div>
  <% end %>
</div>

You can notice here that we wrapped our form in a div with a data attribute data-controller with value map. This is the DOM element that is going to be linked with our Stimulus Controller.

The hidden fields address, latitude and logitude have also data attributes, called data-map-target, the map word is because of the name of the controller. These fields are going to be accesible in our Map Controller using the variables this.latitudeTarget, this.longitudeTarget and this.addressTarget

We also added a div element with data attribute data-map-target="container" it is the map container that we are going to link with Leaflet.

Now let's add leaflet to our Rails application. Since we are using Rails 7, it uses importmaps by default so we have to add leaflet using the importmap command like this:

bin/importmap pin leaflet
bin/importmap pin leaflet-geosearch

In this point I recommend you to restart the rails server.

NOTE: If you get an error from jspm you can use jsdeliver with bin/importmap pin leaflet --from jsdelivr

Notice we also added leaflet-geosearch, it is a plugin that adds support for geocoding to Leaflet, It comes with controls to be embedded in your Leaflet map.

When we'll create our map it will looks like this:

Screen Shot 2022-08-12 at 4.04.31 PM.png

With Importmaps we only added the javascript libraries we need, but leaftlet and leaflet-geosearch have also css files, we are going to add those css references to our application.html.erb layout using the CDNs

<!DOCTYPE html>
<html>
  <head>
    <title>LeafletDemo</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>

    <link
      rel="stylesheet"
      href="https://unpkg.com/leaflet@1.8.0/dist/leaflet.css"
      integrity="sha512-hoalWLoI8r4UszCkZ5kL8vayOGVae1oxXe/2A4AO6J9+580uKHDO3JdHb7NzwwzK5xr/Fs0W40kiNHxM9vyTtQ=="
      crossorigin=""
    />
    <link rel="stylesheet" href="https://unpkg.com/leaflet-geosearch@3.0.0/dist/geosearch.css"/>

    <%= javascript_importmap_tags %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

Now we need to add our Map to the Form, we are going to use Stimulus.js, so we have to create a map_controller.js file inside app/javascript/controllers and we are going to paste this code inside:

# modify ./leaflet-demo/app/javascript/controllers/map_controller.js

import { Controller } from "@hotwired/stimulus"
import L from "leaflet"
import * as GeoSearch from "leaflet-geosearch"

export default class extends Controller {
  static targets = ["container", "address", "latitude", "longitude"]

  connect() {
    let defaultLocation = [51.505, -0.09]

    if (this.latitudeTarget.value.length > 0 && this.longitudeTarget.value.length > 0) {
      defaultLocation = [this.latitudeTarget.value, this.longitudeTarget.value]
    }

    this.map = L.map(this.containerTarget).setView(defaultLocation, 18);
    const provider = new GeoSearch.OpenStreetMapProvider();

    L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(this.map);

    const search = new GeoSearch.GeoSearchControl({
      style: 'bar',
      provider: provider,
      marker: {
        draggable: true,
      },
    });

    this.map.addControl(search);

    this.map.on('geosearch/showlocation', (event) => {
      let latLng = event.marker.getLatLng()

      this.addressTarget.value = event.location.label
      this.latitudeTarget.value = latLng.lat
      this.longitudeTarget.value = latLng.lng
    });

    this.map.on('geosearch/marker/dragend', (event) => {
      this.latitudeTarget.value = event.location.lat
      this.longitudeTarget.value = event.location.lng
    })

    if (this.addressTarget.value.length > 0) {
      let query = { query: this.addressTarget.value }

      provider.search(query).then((result) => {
        search.showResult(result[0], query)
      });

      search.searchElement.input.value = this.addressTarget.value
    }
  }

  disconnect(){
    this.map.remove()
  }
}

You should see something like this:

Screen Shot 2022-08-17 at 9.44.26 AM.png

Now if you type an address in the textfield inside the leaflet map it will autocomplete and list addresses, if you select one of them then a marker will appear in the map.

If you save the form it will store correctly the address, latitude and longitude. This happens because we are listening to the event "geosearch/showlocation" on the Leaflet map, and we are modifying the values of the hidden fields.

We are also looking for a given address in the case we want to edit an already created place, to get this working we are using the search method that the provider uses to look for the stored address and find the first one and locate a marker in the map.

Finally

This is all for this article. Hope you found it helpful :)

You can find a step by step video for this tutorial here

Source code in github

Thanks

4
Subscribe to my newsletter

Read articles from Luis Porras directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Luis Porras
Luis Porras

I'm a Barranquillero Developer. Expert in Ruby, Node.js, Rails React.js working currently in BairesDev