Skip to content

Leaflet maps

Leaflet.js is a lightweight mapping library that runs beautifully inside a WebView. This lesson builds an interactive map with city navigation and click-to-place markers, demonstrating how map events flow to Ruby and how Ruby controls the map via JsBridge.

map01.png

1
ruby main.rb

Requires internet access for Leaflet CDN and OpenStreetMap tiles.

Download leaflet_map_app.zip

File structure

leaflet_map_app/
├── main.rb
├── assets/
│   └── js/
│       └── js_bridge_client.js
└── lib/
    ├── map_frame.rb
    ├── js_bridge.rb
    ├── html/
    │   └── map_page.rb
    ├── models/
    │   └── cities.rb
    └── panels/
        ├── map_panel.rb
        └── sidebar_panel.rb

Loading Leaflet

lib/html/map_page.rb shows both approaches clearly.

Approach A — CDN (used in this app):

1
2
3
<link rel="stylesheet"
      href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

Simple, always up to date, requires internet. Good for development and apps that are always online.

Approach B — Local files as base64 data URIs (for offline use):

1
2
curl -o assets/js/leaflet.js  https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
curl -o assets/js/leaflet.css https://unpkg.com/leaflet@1.9.4/dist/leaflet.css
1
2
LEAFLET_JS  = Base64.strict_encode64(File.read('assets/js/leaflet.js'))
LEAFLET_CSS = Base64.strict_encode64(File.read('assets/js/leaflet.css'))
1
2
<link rel="stylesheet" href="data:text/css;base64,#{LEAFLET_CSS}">
<script src="data:text/javascript;base64,#{LEAFLET_JS}"></script>

The Module 6 apps use Approach B since they need to work offline. The tile caching pattern for offline map tiles is covered there.

Map setup

A minimal Leaflet map needs a div, a tile layer, and an initial view:

1
2
3
4
5
6
var map = L.map('map').setView([lat, lng], zoom);

L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  attribution: '&copy; OpenStreetMap contributors',
  maxZoom: 19
}).addTo(map);

The html, body { height: 100% } and #map { width: 100%; height: 100% } CSS is essential — without it the map renders at zero height and appears blank.

Map events → Ruby

Map events are forwarded to Ruby via RubyBridge.emit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
map.on('click', function(e) {
  RubyBridge.emit('mapClick', {
    lat: e.latlng.lat,
    lng: e.latlng.lng
  });
});

map.on('moveend', function() {
  var centre = map.getCenter();
  RubyBridge.emit('mapMoved', {
    lat:  centre.lat,
    lng:  centre.lng,
    zoom: map.getZoom()
  });
});

Ruby handles them in @bridge.on handlers inside MapPanel:

1
2
3
4
5
6
7
@bridge.on('mapClick') do |payload|
  @on_click&.call(payload)
end

@bridge.on('mapMoved') do |payload|
  @on_moved&.call(payload)
end

The frame receives these via callbacks and updates the sidebar — following the lesson 3.2 inter-panel communication pattern.

Ruby → map

Ruby controls the map by calling registered JS methods:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
RubyBridge.register('flyTo', function(p) {
  map.flyTo([p.lat, p.lng], p.zoom || 12);
  return { ok: true };
});

RubyBridge.register('addMarker', function(p) {
  var marker = L.marker([p.lat, p.lng]);
  if (p.label) marker.bindPopup(p.label);
  marker.addTo(map);
  markers[p.id] = marker;
  return { ok: true };
});

RubyBridge.register('clearMarkers', function() {
  Object.keys(markers).forEach(function(id) {
    map.removeLayer(markers[id]);
  });
  markers = {};
  return { ok: true };
});

The MapPanel exposes these as clean Ruby methods:

1
2
3
4
5
6
7
8
9
def fly_to(lat, lng, zoom: 12)
  return unless @ready
  @bridge.call('flyTo', { lat: lat, lng: lng, zoom: zoom })
end

def clear_markers
  return unless @ready
  @bridge.call('clearMarkers', {})
end

The frame never calls @bridge directly — it calls @map_panel.fly_to(...). This keeps the WebView/JsBridge details inside the panel where they belong.

Marker management

Markers placed by JS clicks are tracked with IDs on both sides. When the user clicks the map, JS places the marker and emits mapClick with an ID. Ruby adds it to the sidebar list. When the user removes a marker from the sidebar, Ruby calls remove_marker(id) which calls the JS removeMarker method.

This is the bidirectional state management pattern that all the Module 6 map apps use — JS owns the visual markers, Ruby owns the list, IDs keep them in sync.

What comes next

The Module 6 route planner extends this app significantly — polylines, GeoJSON export, offline tile caching with a thread pool, context menus, and a more sophisticated state model. The architecture is the same: MapPanel wraps all WebView/Leaflet details, the frame coordinates, and everything talks through clean Ruby interfaces.


Previous: Live data with Chart.js | Next: Project: Markdown editor with WebView