Skip to content

Lesson 4 — The dispatcher’s map

The manager’s map answered how is the operation going? across the whole country. The dispatcher’s question is different. Each dispatcher works one depot’s territory — roughly 38 service areas, a handful of field officers, however many jobs land today. Their map answers what’s happening in my region right now?

Three things go on the map: the SA3s in the dispatcher’s region, the live jobs in those SA3s, and the field officers working them. We’ll add each layer in turn so every step ends with something visible in the browser.

This lesson focuses entirely on the map. The dashboard panels around it — stats, pending jobs, today’s schedule — arrive in Lesson 6.

Part 1 — Service areas on a regional map

The first layer is the regional set of SA3s — roughly 38 polygons rather than the manager’s 340. Same red outlines, same hover behaviour, scoped to whichever depot the current dispatcher works for.

Three things need building together: the service object that prepares the GeoJSON, the controller action that exposes it, and the map component that renders it.

The service object. Create app/services/regional_service_areas.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
module RegionalServiceAreas
  extend self

  def call(user)
    sas = user.depot.service_areas

    {
      type: "FeatureCollection",
      features: sas.map { |sa|
        {
          type:       "Feature",
          id:         sa.id,
          geometry:   RGeo::GeoJSON.encode(sa.boundary),
          properties: { name: sa.name, code: sa.code }
        }
      }
    }
  end
end

A few things to unpack here.

user.depot.service_areas is the dispatcher’s region. Each ServiceArea belongs to a Depot (via the FK we added in Lesson 1); each Depot has_many :service_areas. A dispatcher’s region is therefore the set of SA3s their depot oversees. This is the first time the tutorial scopes spatial data per-user — “give me only the polygons this particular dispatcher is responsible for.” The scoping itself is relational (an FK lookup), not spatial; we’re asking the database “which rows belong to this depot?”, not “which polygons contain this point?” Module 6 will introduce the actual spatial predicates.

RGeo::GeoJSON.encode(sa.boundary) turns a PostGIS multipolygon (which is how the SA boundary lives in the database) into a plain Ruby hash matching the GeoJSON geometry spec. RGeo handles the conversion — coordinate extraction, ring ordering, the type: "MultiPolygon" wrapper. The result drops directly into our feature’s geometry: slot.

The feature shape — id, geometry, properties — is GeoJSON’s contract with MapLibre.

  • id makes each feature uniquely addressable. MapLibre’s feature-state machinery (which is what makes hover work) needs this; without it, hovering one polygon could light up several or none.
  • geometry is what gets drawn — the polygon, point, or line.
  • properties is the data MapLibre carries alongside the geometry, available to paint expressions, popups, and any layer-level styling. We’ll see this matter in Part 2 when job status drives circle colour, and again in popup templates.
Map sources are URLs MapLibre fetches. When a Vera map declares m.source :service_areas, url: "/.../regional.json", MapLibre’s source machinery hits that URL on map load, expects a GeoJSON FeatureCollection in response, and caches the result. The endpoint behind that URL just needs to return the right JSON shape — no view, no template, no HTML. We’ll add three such endpoints in this lesson, one per map source, each scoped to the current dispatcher.

The controller action. ServiceAreasController already exists from Module 4 with an #index action serving the national set. Add a regional action:

1
2
3
4
5
6
7
8
9
class ServiceAreasController < ApplicationController
  def index
    # ... existing national action from Module 4
  end

  def regional
    render json: RegionalServiceAreas.call(Current.user)
  end
end

The action receives the request, scopes by the current user, and returns the GeoJSON the map will fetch.

Add the route. In config/routes.rb:

1
get "/service_areas/regional", to: "service_areas#regional"

The URL hierarchy reads like a scope: /service_areas gives you the national set (Module 4), /service_areas/regional gives you one dispatcher’s region.

The map component. Create app/components/regional_map.rb — for now just the SA layer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Components::RegionalMap < Components::Base
  prop :id,     String, default: -> { "regional-map" }
  prop :height, String, default: -> { "100%" }

  def view_template
    render Vera::Map.new(id: @id, height: @height, style: :voyager) do |m|
      m.source :service_areas, url: "/service_areas/regional.json",
                               fit_bounds: { padding: 20 }

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

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

Two layers, both reading from the same source. This is a common Vera/MapLibre pattern: one source feeds many layers, each rendering a different visual aspect of the same features.

fit_bounds: { padding: 20 } on the SA source tells Vera to compute the bounding box of the loaded GeoJSON and fit the view to it once the features arrive. Without it the map opens at the gem’s default centre and zoom — Australia-wide, which is right for the national map but useless for one dispatcher’s region.

  • The fill layer paints the polygon interior. Near- invisible by default (fill_opacity: 0.05), lightening on hover (0.15). The fill exists mostly to give the polygon a hit-target for hover events; visually it’s the outline that carries weight.
  • The outline layer draws the polygon boundary. Red, thin, faded — the default state recedes. On hover the same line goes thicker and fully opaque, so the dispatcher can see exactly which SA they’re pointing at.

Both layers’ on_hover blocks are Vera’s affordance for “this property changes when the feature is hovered.” Underneath, Vera wires up the mousemove/mouseleave events and toggles MapLibre’s feature-state — the id we put on each feature is what makes that toggle target the right polygon.

The styling matches the national map from Module 4 — same red outline, same near-empty fill, same hover behaviour. The two maps share visual language; only the scope differs.

A page to host the map. For now we just need somewhere to render it. Create app/components/dashboards/dispatcher_dashboard.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Components::Dashboards::DispatcherDashboard < Components::Base
  prop :user, User

  def view_template
    div(class: "h-full flex flex-col bg-slate-50") do
      PageHeader(
        title:      "#{@user.depot.name} dispatch",
        subtitle:   "Today's operation in your region",
        breadcrumb: ["Dashboard"]
      )

      div(class: "flex-1 min-h-0 p-6") do
        div(class: "h-full rounded-lg border border-slate-200 bg-white overflow-hidden") do
          RegionalMap(height: "100%")
        end
      end
    end
  end
end

The title pulls from the depot — “Brisbane North dispatch”, “Sydney West dispatch” — instantly grounding the dispatcher in their territory. The map fills the body; Lesson 6 will add the stats panel and sidebars around it.

Wire up the controller. Update app/controllers/dashboard_controller.rb to add the dispatcher case:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class DashboardController < ApplicationController
  def index
    @page = page_component_for(Current.user.role)
  end

  private

  def page_component_for(role)
    case role
    when "manager"    then Components::Dashboards::ManagerDashboard
    when "dispatcher" then Components::Dashboards::DispatcherDashboard
    end
  end
end

Update the view to pass the user through. In app/views/dashboard/index.html.erb:

1
<%= render @page.new(user: Current.user) %>

Add prop :user, User to Components::Dashboards::ManagerDashboard so it accepts the new prop too — it’s unused on that page for now.

Look what we just did. Restart the Rails server. Sign in as a dispatcher (any of the seeded dispatcher accounts). Visit /dashboard.

You should see the page header naming your depot, and below it a regional map showing your depot’s SA3s — roughly 38 polygons outlined in red, hover to highlight. The map auto-fits to the region. Every dispatcher signs in and lands on a map of their own territory.

Part 2 — The jobs layer

Next layer: the jobs. Same three pieces — service, controller action, map source — added alongside what we just built.

The service. Create app/services/regional_jobs.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
34
35
36
37
38
39
40
41
42
43
44
45
module RegionalJobs
  extend self

  def call(user)
    sa_ids = user.depot.service_areas.select(:id)
    jobs   = Job.where(service_area_id: sa_ids,
                       status: %w[pending scheduled in_progress])
                .includes(:customer, :assigned_to)

    {
      type: "FeatureCollection",
      features: jobs.map { |job|
        {
          type:       "Feature",
          id:         job.id,
          geometry:   RGeo::GeoJSON.encode(job.location),
          properties: {
            status:       job.status,
            status_label: job.status.tr("_", " ").capitalize,
            customer:     job.customer.name,
            address:      job.customer.address,
            suburb:       job.customer.suburb,
            postcode:     job.customer.postcode,
            phone:        job.customer.phone,
            description:  job.description.presence || "Service call",
            fo_name:      job.assigned_to&.name,
            waiting:      waiting_label(job.created_at)
          }
        }
      }
    }
  end

  private

  def waiting_label(created_at)
    seconds = Time.current - created_at
    case seconds
    when 0..3600         then "#{(seconds / 60).to_i}m ago"
    when 3601..86_400    then "#{(seconds / 3600).to_i}h ago"
    when 86_401..604_800 then "#{(seconds / 86_400).to_i}d ago"
    else                      created_at.strftime("%-d %b")
    end
  end
end

Two things worth pausing on, both about how this differs from the SA service.

Each job is a point feature, not a polygon. A Job record carries a location column (PostGIS) holding the customer’s geocoded location. RGeo::GeoJSON.encode(job.location) gives back a { type: "Point", coordinates: [lng, lat] } hash — a single dot rather than a multipolygon. Same encoding mechanism, different geometry type. MapLibre will render these as circles in the layer below.

The properties carry data the layer will read at render time. Every property on a feature becomes available to MapLibre’s paint expressions and popup templates. We’re including status because the layer is going to colour each circle based on it; customer, suburb, and fo_name are there because they’ll appear in the popup when the dispatcher hovers a circle. Properties get serialised once on the server, sit on the feature in the browser, and get read at draw time without another roundtrip.

status: %w[pending scheduled in_progress] filters out complete and cancelled jobs. The map shows live work only — done is done, and there’s no value cluttering the dispatcher’s region with finished pins.

Spatial scoping via FK, not via geometry. We’re filtering jobs by service_area_id IN (...) — the same relational scoping idea as the SA service. The customer’s location is stored in the database already, so we don’t need to ask “which SA3 contains this point?” at request time. Module 6 introduces ST_Contains and the actual spatial predicates for queries that do need real-time geometric tests.

The controller action. Generate a new controller:

1
bin/rails g controller jobs

Replace its contents:

1
2
3
4
5
class JobsController < ApplicationController
  def regional
    render json: RegionalJobs.call(Current.user)
  end
end

Add the route:

1
get "/jobs/regional", to: "jobs#regional"

The map layer. Update RegionalMap to add the new source and layer (with popup):

 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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class Components::RegionalMap < Components::Base
  prop :id,     String, default: -> { "regional-map" }
  prop :height, String, default: -> { "100%" }

  JOB_STATUS_COLOURS = {
    "pending"     => "#f59e0b", # amber
    "scheduled"   => "#3b82f6", # blue
    "in_progress" => "#10b981"  # green
  }.freeze

  JOB_POPUP_TEMPLATE = <<~HTML.freeze
    <div class="vera-popup">
      <div class="vera-popup__head">
        <span class="vera-popup__pill vera-popup__pill--{{status}}">{{status_label}}</span>
        <span class="vera-popup__waiting">{{waiting}}</span>
      </div>

      <div class="vera-popup__customer">{{customer}}</div>
      <div class="vera-popup__address">{{address}}, {{suburb}} {{postcode}}</div>
      {{#if phone}}<div class="vera-popup__phone">{{phone}}</div>{{/if}}

      <div class="vera-popup__description">{{description}}</div>

      {{#if fo_name}}<div class="vera-popup__assignment"><span class="vera-popup__fo-label">FO:</span> {{fo_name}}</div>{{/if}}
    </div>
  HTML

  def view_template
    render Vera::Map.new(id: @id, height: @height, style: :voyager) do |m|
      m.source :service_areas, url: "/service_areas/regional.json",
                               fit_bounds: { padding: 20 }
      m.source :jobs,          url: "/jobs/regional.json"

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

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

      m.layer :jobs_circle, source: :jobs, type: :circle,
              paint: {
                circle_color: ["match", ["get", "status"],
                               "pending",     JOB_STATUS_COLOURS["pending"],
                               "scheduled",   JOB_STATUS_COLOURS["scheduled"],
                               "in_progress", JOB_STATUS_COLOURS["in_progress"],
                               "#64748b"], # slate fallback
                circle_radius:       5,
                circle_stroke_color: "#ffffff",
                circle_stroke_width: 1
              },
              on_hover: { circle_radius: 7 },
              popup:    { template: JOB_POPUP_TEMPLATE }
    end
  end
end

A few things to unpack.

The paint expression colours each circle by its status. That ["match", ["get", "status"], ...] array is a MapLibre expression — a small DSL evaluated by the renderer per feature, on the GPU, every frame. It reads “for each feature, get the value of its status property, match it against these branches, return the matching colour; if nothing matches, return slate.” 100 jobs render as 100 differently-coloured circles because the expression runs once per circle. We don’t loop in Ruby; MapLibre loops in C++.

The colour vocabulary. Amber for pending (needs action), blue for scheduled (queued), green for in progress (active). We’ll reuse this vocabulary in Part 3 for field officer state — green for engaged, blue for transit, amber for waiting. Once a reader sees one pin family, they can read the other.

The popup template is HTML with mustache-style placeholders, kept as a frozen constant at the top of the component. When a circle is hovered or clicked, Vera hands MapLibre’s popup machinery the feature’s properties hash; the template’s {{status}}, {{customer}}, {{waiting}} get substituted from those properties. This is why we put those values into properties on the server — they’re the data the popup will read.

The {{#if phone}}...{{/if}} blocks render only when the property is present. Some customers don’t have a phone on file; the template handles their absence cleanly without emitting an empty <div>.

Hover tweaks the radius. circle_radius: 5 becomes 7 on hover. Same feature-state mechanism as the SA hover; the id on each feature is what lets MapLibre toggle the right circle.

Look what we just did. Refresh /dashboard. The same regional map now has coloured circles scattered across it — amber for pending jobs, blue for scheduled, green for in progress. Hover any circle and the popup appears with the customer’s name, address, status, and assigned officer.

The map is starting to tell a story. At a glance the dispatcher can see “lots of amber in Toowong — pending jobs piling up there” or “green clusters in Indooroopilly — work happening”.

Part 3 — The field officers layer

Last layer: the field officers themselves. Same three pieces, plus one new wrinkle — FOs render as truck icons, not as circles, so the dispatcher can tell drivers from work at a glance.

The service. Create app/services/regional_field_officers.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
module RegionalFieldOfficers
  extend self

  STATES = %w[available en_route at_job].freeze

  def call(user)
    fos = User.where(role: "field_officer", depot_id: user.depot_id)
              .includes(:service_area)

    {
      type: "FeatureCollection",
      features: fos.map { |fo|
        centroid = fo.service_area.boundary.centroid
        state    = STATES.sample
        {
          type:       "Feature",
          id:         fo.id,
          geometry:   RGeo::GeoJSON.encode(centroid),
          properties: {
            name:        fo.name,
            state:       state,
            state_label: state.tr("_", " ").capitalize
          }
        }
      }
    }
  end
end

Two things distinguish this service from the previous two.

fo.service_area.boundary.centroid is a real spatial operation. RGeo computes the geometric centroid of the SA3 multipolygon — the point that, mathematically, is its “middle.” For an irregular polygon like a real-world SA3 boundary, the centroid sits roughly where you’d point if asked to put a finger on the area’s middle. It’s the same operation you’d reach for in PostGIS as ST_Centroid(boundary); RGeo offers it as a Ruby method on the geometry object, computed in-process rather than in the database.

We’re using it because field officers don’t yet have their own coordinates — there’s no users.location column. Each FO gets rendered at the geometric middle of their assigned SA3 as a stand-in. Module 9 introduces real-time position tracking; the centroid is a lesson-time fallback.

State is picked at random. STATES.sample returns one of available, en_route, at_job. In production this would be derived from real activity (does the FO have an in-progress job? are they between jobs?), which is Module 9 territory. For now the random pick gives the map something to colour, demonstrating how state would flow to the renderer without committing to what the state actually is.

The controller action. New controller:

1
bin/rails g controller field_officers
1
2
3
4
5
class FieldOfficersController < ApplicationController
  def regional
    render json: RegionalFieldOfficers.call(Current.user)
  end
end
1
get "/field_officers/regional", to: "field_officers#regional"

Symbol layers and tintable icons. This is where the FO layer diverges from the jobs layer. Jobs are circles — small, abstract, GPU-rendered dots that read as “work location.” Field officers are people in trucks; rendering them as larger circles would visually conflate them with jobs, even with a state-colour difference. They need a different shape to read as a different kind of thing.

MapLibre offers two paths for non-circle markers: DOM markers (m.marker) and symbol layers (m.layer type: :symbol). DOM markers are HTML elements positioned over the map — easy to style with Tailwind, but they don’t scale past a hundred or so before performance drops off. Symbol layers render icons through the GPU pipeline, like circles do, by rasterising an icon to a sprite once and drawing it many times. They scale to thousands of features but require the icon to be registered ahead of time as a map image.

For FOs we use a symbol layer. Even though we only have a handful per region today, the architecture is the same as it would be for ten thousand vehicles, and it teaches the sprite/symbol-layer mental model that any data-driven map will eventually need.

The icon comes from phlex-icons-hero. Add the gem to your Gemfile if it’s not already there:

1
gem "phlex-icons-hero"

Then bundle install. The gem provides Heroicons as Phlex components — PhlexIcons::Hero::Truck.new(class: "size-8") renders a truck SVG you can drop anywhere a Phlex component is accepted.

Registering the icon as a tintable map image. Add the m.image declaration before the FO layer in RegionalMap:

1
2
m.image :truck, svg: PhlexIcons::Hero::Truck.new(class: "size-8"),
                tintable: true

This registers a sprite called truck from the Heroicon SVG. The tintable: true flag tells Vera to register the sprite as a signed distance field — internally MapLibre stores the icon’s silhouette shape rather than its colours, so the sprite can be tinted at draw time by the layer’s icon_color paint expression. One icon registration; three state colours expressed in the layer.

The contrast: if we had passed fill: "#f59e0b" instead of tintable: true, the gem would have baked amber into the sprite at registration time, and we’d need three separate sprite registrations for three FO states (truck_amber, truck_blue, truck_green). For data-driven colouring, tintable: keeps the call site clean.

The FO layer. Add the source, the image registration, and the layer (with popup) to RegionalMap:

 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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class Components::RegionalMap < Components::Base
  prop :id,     String, default: -> { "regional-map" }
  prop :height, String, default: -> { "100%" }

  JOB_STATUS_COLOURS = {
    "pending"     => "#f59e0b",
    "scheduled"   => "#3b82f6",
    "in_progress" => "#10b981"
  }.freeze

  FO_STATE_COLOURS = {
    "available" => "#f59e0b",
    "en_route"  => "#3b82f6",
    "at_job"    => "#10b981"
  }.freeze

  JOB_POPUP_TEMPLATE = <<~HTML.freeze
    # ... unchanged ...
  HTML

  FO_POPUP_TEMPLATE = <<~HTML.freeze
    <div class="vera-popup vera-popup--fo">
      <div class="vera-popup__head">
        <span class="vera-popup__pill vera-popup__pill--fo-{{state}}">{{state}}</span>
      </div>
      <div class="vera-popup__customer">{{name}}</div>
    </div>
  HTML

  def view_template
    render Vera::Map.new(id: @id, height: @height, style: :voyager) do |m|
      m.source :service_areas,  url: "/service_areas/regional.json",
                                fit_bounds: { padding: 20 }
      m.source :jobs,           url: "/jobs/regional.json"
      m.source :field_officers, url: "/field_officers/regional.json"

      m.image :truck, svg: PhlexIcons::Hero::Truck.new(class: "size-8"),
                      tintable: true

      # ... existing service_areas and jobs layers ...

      m.layer :field_officers, source: :field_officers, type: :symbol,
              layout: {
                icon_image:         "truck",
                icon_size:          1.0,
                icon_anchor:        "center",
                icon_allow_overlap: true
              },
              paint: {
                icon_color: ["match", ["get", "state"],
                             "available", FO_STATE_COLOURS["available"],
                             "en_route",  FO_STATE_COLOURS["en_route"],
                             "at_job",    FO_STATE_COLOURS["at_job"],
                             "#64748b"]
              },
              popup: { template: FO_POPUP_TEMPLATE }
    end
  end
end

A few things distinguish a symbol layer from a circle layer.

type: :symbol tells MapLibre to render this layer as icons (or text). The layout: hash carries placement and sizing properties — which icon to use (icon_image), how big (icon_size), where it sits relative to the coordinate (icon_anchor: "center" puts the truck centred on the FO’s location; "bottom" would have the truck sitting on top of it like a pin).

icon_allow_overlap: true matters because MapLibre’s default is to hide icons that would overlap others — useful for label-heavy maps, unhelpful for “show me all my drivers.” With overlap allowed, every FO icon renders even when two are close together.

paint: { icon_color: [...] } is the same ["match", ["get", "state"], ...] pattern as the jobs layer’s circle_color, but operating on the icon’s tint rather than a fill colour. This works because we registered the truck sprite as tintable: true; for a non-SDF icon this paint property would be ignored.

Layer order matters. Vera adds layers in declaration order; later layers render on top. The FO icons are the last m.layer call in the block, so they sit above the jobs and the SAs. This is deliberate — the dispatcher’s attention should land on people first, work second, region third.

Popup styling. The popup templates rely on Tailwind classes plus a small set of vera-popup utility classes. Add these to app/assets/tailwind/application.css:

 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
@layer components {
  .maplibregl-popup-content {
    @apply rounded-lg shadow-lg p-0 overflow-hidden;
  }

  .vera-popup {
    @apply text-sm text-slate-900 min-w-[200px] max-w-[280px];
  }

  .vera-popup__head {
    @apply flex items-baseline justify-between gap-2 px-3 pt-3 pr-7;
  }

  .vera-popup__pill {
    @apply inline-block px-2 py-0.5 rounded text-xs font-medium;
  }

  .vera-popup__pill--pending     { @apply bg-amber-50 text-amber-700; }
  .vera-popup__pill--scheduled   { @apply bg-blue-50 text-blue-700; }
  .vera-popup__pill--in_progress { @apply bg-emerald-50 text-emerald-700; }

  .vera-popup__pill--fo-available { @apply bg-amber-50 text-amber-700; }
  .vera-popup__pill--fo-en_route  { @apply bg-blue-50 text-blue-700; }
  .vera-popup__pill--fo-at_job    { @apply bg-emerald-50 text-emerald-700; }

  .vera-popup__waiting       { @apply text-xs text-slate-500 tabular-nums; }
  .vera-popup__customer      { @apply font-semibold mt-2 px-3; }
  .vera-popup__address       { @apply text-xs text-slate-600 px-3 mt-0.5; }
  .vera-popup__phone         { @apply text-xs text-slate-600 px-3; }
  .vera-popup__description   { @apply text-sm text-slate-700 px-3 mt-2; }
  .vera-popup__assignment    { @apply text-xs text-slate-600 px-3 pb-3 pt-2 border-t border-slate-100 mt-2; }
  .vera-popup__fo-label      { @apply font-medium text-slate-500; }

  .maplibregl-popup-close-button {
    @apply absolute top-1 right-1 text-slate-400 hover:text-slate-600 text-lg leading-none;
  }
}

The classes are deliberately verbose — vera-popup__head rather than just head — so they don’t collide with anything else in the host app’s stylesheet.

Look what we just did. Refresh /dashboard. The map now has truck icons on top of everything — colour-coded by state, larger than the job circles, visually distinct as a different kind of pin. Hover one and the popup tells you who they are and what they’re doing.

The map is fully populated. SAs, jobs, FOs — three sources, four layers (SA fill, SA outline, jobs circle, FO symbol), all reading from per-dispatcher endpoints. The dispatcher can see, at a glance, where work is happening, where it’s piling up, and which drivers are doing what.

Activity 1 — Switch dispatchers, watch the map reshape

Sign out and sign in as a different dispatcher (the seeded accounts cover all 9 depots). Visit /dashboard. The same code, scoped to a different region:

  • The map centres on a different patch of the country
  • A different ~38 SA3s outline in red
  • Different job circles, different FO trucks
  • The dispatcher in Brisbane and the dispatcher in Perth see entirely different maps from the same code

Each Current.user flowing through the same three services produces three GeoJSON FeatureCollections scoped to that user’s depot, and MapLibre renders whatever it receives.

Activity 2 — Confirm the field officer case still errors

Sign in as a field officer and visit /dashboard. You’ll see a routing error or a nil rendering — the dashboard controller’s case statements don’t yet handle field_officer. That’s Lesson 7.

Where this leaves us

The dispatcher’s map shows what role-scoped GIS work looks like end to end:

  • Per-user GeoJSON endpoints. Three URLs that serve whatever the current dispatcher is allowed to see — scoped at the database layer by the relational service_areas.depot_id link. The same URLs return different data for different dispatchers; the browser doesn’t know or care.
  • Multiple sources, multiple layers, one map. Three sources (SAs, jobs, FOs), four layers, all coexisting in one Vera component.
  • Data-driven styling. MapLibre expressions like ["match", ["get", "status"], ...] paint each feature by its own properties. One layer, many appearances — evaluated by the renderer per feature, not by Ruby.
  • Symbol layers for non-circle markers. Tintable Heroicons via m.image svg: register once, render at scale, take colour from layer paint expressions.
  • Properties carry the popup. What the server puts into a feature’s properties is what the popup template can read. The wire format (GeoJSON) carries everything the user-facing UI needs.
  • RGeo::GeoJSON.encode is the bridge between PostGIS geometries in the database and the GeoJSON shape MapLibre consumes. Multipolygons for SA boundaries, points for job locations, and centroid for the faked-for-now FO positions — same encoding mechanism, three different geometry types.

The map works. But its construction in RegionalMap is starting to feel busy — three sources, four layers, two popup templates, an image registration, all stacked into one method. The next lesson refactors that into something cleaner before we wrap the dashboard around it.