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:
|
|
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.
idmakes 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.geometryis what gets drawn — the polygon, point, or line.propertiesis 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.
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
Update the view to pass the user through. In
app/views/dashboard/index.html.erb:
|
|
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:
|
|
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:
|
|
Replace its contents:
|
|
Add the route:
|
|
The map layer. Update RegionalMap to add the new source
and layer (with popup):
|
|
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:
|
|
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:
|
|
|
|
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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_idlink. 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
propertiesis what the popup template can read. The wire format (GeoJSON) carries everything the user-facing UI needs. RGeo::GeoJSON.encodeis the bridge between PostGIS geometries in the database and the GeoJSON shape MapLibre consumes. Multipolygons for SA boundaries, points for job locations, andcentroidfor 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.