Skip to content

Lesson 5 — Depots as points

The map currently shows service area boundaries — large polygons covering the country. Real dispatch software has more than just boundaries on its maps; it has the operational hubs that coordinate the work. Depots are the regional dispatch centres — the buildings where dispatchers run their shifts, coordinate with field officers across their region, and route work to the right people. There’s typically one per major regional area, and a dispatcher’s job is to manage the work flowing through their own depot.

In this lesson we put depots on the map. By the end, the Lab page will show the same SA3 polygons we already had, with nine depot pins overlaid — one for each major Australian city. Click a depot and a popup tells you which one it is.

The mechanics are familiar. We’ve already done one source/layer pair for service areas; this lesson adds a second source/layer pair for depots. The pattern of “multiple sources per map” is fundamental to building richer maps, and this lesson establishes it.

Seeding the depots

Module 3 Lesson 2 had us add Sydney Central and Brisbane North manually as we learned the basics of inserting PostGIS values. Two depots was enough for that lesson’s purposes; for a dispatch app it’s nowhere near enough. We need depots in every major capital.

Rather than insert seven more by hand, we’ll create a rake task that seeds them. This introduces a pattern that’s distinct from Module 3’s import task: where the SA3 import reads from a vendored data file, this seed reads from inline literal data in the rake task itself.

The distinction matters. SA3 boundaries come from an external authoritative source (the ABS) — they belong in a vendored file with proper provenance documentation. Depot locations are chassis-curated tutorial data — a plausible scattering of dispatch hubs we’ve made up for teaching purposes. Inlining them in the seed task is honest about what they are, and avoids adding a download to the appendix for what’s effectively just nine rows.

Create lib/tasks/seed.rake:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
namespace :vera do
  desc "Seed the dispatch deck with a starter set of depots"
  task seed_depots: :environment do
    depots = [
      { code: "SYD-01", name: "Sydney Central",       lat: -33.8688, lng: 151.2093 },
      { code: "MEL-01", name: "Melbourne West",       lat: -37.8136, lng: 144.9631 },
      { code: "BNE-01", name: "Brisbane North",       lat: -27.4698, lng: 153.0251 },
      { code: "PER-01", name: "Perth East",           lat: -31.9523, lng: 115.8575 },
      { code: "ADE-01", name: "Adelaide Central",     lat: -34.9285, lng: 138.6007 },
      { code: "HOB-01", name: "Hobart Waterfront",    lat: -42.8826, lng: 147.3257 },
      { code: "DAR-01", name: "Darwin Port",          lat: -12.4634, lng: 130.8456 },
      { code: "NEW-01", name: "Newcastle Industrial", lat: -32.9283, lng: 151.7817 },
      { code: "CNS-01", name: "Cairns Tropical",      lat: -16.9203, lng: 145.7710 }
    ]

    Depot.transaction do
      depots.each do |d|
        Depot.upsert(
          {
            code:       d[:code],
            name:       d[:name],
            location:   "POINT(#{d[:lng]} #{d[:lat]})",
            created_at: Time.current,
            updated_at: Time.current
          },
          unique_by: :code
        )
      end
    end

    puts "Seeded #{Depot.count} depots."
  end
end

Worth understanding a few details:

upsert with unique_by: :code — re-running the task is safe. Each upsert either inserts the row or updates the existing one matched by code. If your manually-inserted “Brisbane North” from Module 3 was identical to the seed value, nothing changes; if you typed a slightly different name, the seed brings it back to canonical.

The location: string"POINT(#{d[:lng]} #{d[:lat]})" is WKT (Well-Known Text), the format we met in Module 3. The ActiveRecord postgis adapter parses WKT strings on assignment, converts them to PostGIS geometry, and stores them in the column. Note the order: longitude first, latitude second — WKT’s coordinate convention.

No SRID prefix needed. The column constraint already forces SRID 4326, so the WKT doesn’t need to declare it. The adapter handles the SRID stamping on the way in.

Run the task:

1
bin/rails vera:seed_depots

Output:

1
Seeded 9 depots.

Serving depots as GeoJSON

Same pattern as Lesson 1. We need a service object that turns depots into a GeoJSON FeatureCollection, plus a controller that serves it.

Create app/services/serialize_depots.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
module SerializeDepots
  extend self

  def call
    {
      type:     "FeatureCollection",
      features: build_features
    }
  end

  private

  def build_features
    depots.map do |depot|
      {
        type:       "Feature",
        id:         depot.id,
        geometry:   JSON.parse(depot.geojson),
        properties: {
          id:   depot.id,
          code: depot.code,
          name: depot.name
        }
      }
    end
  end

  def depots
    Depot
      .select("id, code, name, ST_AsGeoJSON(location) AS geojson")
      .order(:code)
  end
end

Same shape as SerializeServiceAreas from Lesson 1. The only differences are which model and which columns. The pattern generalises nicely — for any new spatial model the dispatch deck adds, the serializer follows this shape.

Note the top-level id: on each feature, just like SerializeServiceAreas. Even though we don’t yet have hover or selection on depots, the convention is consistent: every GeoJSON feature in the dispatch deck has a top-level id, ready to be addressed by feature-state if any layer needs it later.

Generate the controller:

1
bin/rails generate controller Depots

Add the route:

1
2
3
# config/routes.rb
resources :depots,         only: [:index]
resources :service_areas,  only: [:index]

Edit app/controllers/depots_controller.rb:

1
2
3
4
5
class DepotsController < ApplicationController
  def index
    render json: SerializeDepots.call
  end
end

Verify it works by visiting http://localhost:3000/depots.json. You should see a FeatureCollection with 9 features. The geometry of each is a Point with [lng, lat] coordinates. The properties have id, code, and name.

Adding the source and layer to the component

Now the visual side. Open app/components/service_area_map.rb and add a depots source plus a circle layer to the map block. The component grows; the Lab page stays untouched.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Components::ServiceAreaMap < Components::Base
  prop :id,     String, default: -> { "service-area-map" }
  prop :height, String, default: -> { "600px" }

  def view_template
    render Vera::Map.new(id: @id, height: @height) do |m|
      m.source :service_areas, url: "/service_areas.json"
      m.source :depots,        url: "/depots.json"

      m.layer :service_areas_fill, source: :service_areas, type: :fill,
              paint: {
                fill_color:   "#1e3a5f",
                fill_opacity: 0.15
              },
              on_hover: {
                fill_opacity: 0.35
              }

      m.layer :service_areas_outline, source: :service_areas, type: :line,
              paint: {
                line_color:   "#1e3a5f",
                line_width:   1,
                line_opacity: 0.6
              },
              on_hover: {
                line_width:   2,
                line_opacity: 1.0
              }

      m.layer :depots_circle, source: :depots, type: :circle,
              paint: {
                circle_color:        "#dc2626",
                circle_radius:       6,
                circle_stroke_color: "#ffffff",
                circle_stroke_width: 2
              },
              on_hover: {
                 circle_radius: 8
              },
              popup: {
                template: "<div class='font-semibold'>{{name}}</div><div class='text-xs text-gray-500'>{{code}}</div>"
              }
    end
  end
end

Three new pieces.

Second source. m.source :depots, url: "/depots.json". The component now has two sources, fetched independently. MapLibre parallelises the fetches — the depots GeoJSON and service areas GeoJSON download in parallel.

Circle layer. type: :circle with circle-specific paint properties:

circle_color: "#dc2626" — Tailwind red-600. The depots stand out clearly against the muted blue polygon fills.

circle_radius: 6 — 6-pixel-radius circles. Visible from any zoom level without dominating the map.

circle_stroke_color: "#ffffff" and circle_stroke_width: 2 — white border around each circle. The stroke separates the depot pins from the polygon fills underneath, ensuring they’re visible even when the polygon is highlighted on hover.

A small on_hover: clause bumps circle_radius from 6 to 8 when the cursor hovers over a depot. This serves two purposes: the depot subtly grows under the cursor, communicating interactivity directly, and the gem’s hover wiring also changes the cursor to a pointer — the standard web-UI signal for “you can click this.” Both feed the same expectation: when you hover something interactive, it should respond.

Popup template. popup: { template: "..." } opens a popup when the user clicks a depot. The {{name}} and {{code}} placeholders interpolate with each feature’s properties at click time — no server round trip, no pre-rendered HTML in the GeoJSON payload.

The template is HTML; you can use Tailwind classes inside if you want — though the popup styling lives in MapLibre’s CSS contexts and Tailwind utilities don’t always behave exactly as they would in the rest of the app. For now, simple font- and text- utilities work fine.

Activity 1 — See the depots on the map

Refresh the Lab page. You should now see:

  • The same translucent blue SA3 polygons as before
  • Plus nine red circles, one in each major Australian capital
  • Hover over a polygon — it still highlights as before, the depot pins on top stay visible

Pan to each capital. Sydney’s in the south-east, Cairns up in the tropical north, Perth on the west coast, Hobart far south in Tasmania. The geographic spread shows Australia’s actual population distribution.

If something’s missing:

  • Check the Network tab for the /depots.json request — it should return 200 OK with 9 features
  • If the request 404s, check the resources :depots route
  • If the request returns but no circles appear, check the layer’s source: references the right name (:depots, matching the source’s first argument)

Activity 2 — Click a depot

Click any of the red circles. A popup should appear showing the depot’s name and code.

Click another. The first popup closes; the second opens.

Click outside the depot (on a polygon, or empty area). The popup closes.

This is the simplest popup pattern Vera offers — a per-feature template interpolated client-side. Other patterns exist: server-rendered HTML carried in feature properties, lazy turbo-frame popups that fetch content on demand. For a small amount of static info per feature (like name and code), the client-side template is the simplest approach.

Where this leaves us

The map now shows two layers of meaningful spatial data — service areas as polygons, depots as points — with proper visual treatment for each. The component has grown to hold both; the Lab page is unchanged.

The architectural shape that’s now in place:

  1. Each spatial model has a serializer in app/services/
  2. Each spatial model has a controller serving it as JSON
  3. Map components compose multiple sources/layers in their view_template

This pattern repeats from here. When Module 5 introduces technicians, they get SerializeTechnicians, a controller, and a layer added to whichever map needs them. When Module 6 introduces jobs, same pattern. The dispatch deck’s map components grow by adding layers; the data plumbing stays uniform.

Module 4 ends here. Module 5 takes the Lab page out of the spotlight: authentication and roles arrive, product pages appear (/dispatch, /jobs, /technicians), and the dispatcher’s view starts coming together. Module 6 lights up the first real product page with state-scoped service areas, click-to-drilldown, and the spatial joins that make it work.