Lesson 4 — The choropleth report
Lesson 3 produced a query that counts jobs per service area in about 0.4 seconds. That data on its own is just a list of numbers. This lesson turns it into the most-recognisable spatial visualisation there is: a choropleth — polygons coloured by a value, revealing geographic patterns at a glance.
The deliverable is a new page in the app — Choropleth Report, accessible from the manager’s sidebar — that shows a national map with each SA filled by job density. Plus a legend that explains the colour scale.
This lesson introduces three new things:
- The choropleth pattern —
paint: { fill_color: ... }with an["interpolate", ...]expression driven by a feature property - Geometry-bearing aggregations — building a GeoJSON feature collection from a query result that includes both shape and computed metric
- A legend component that visually communicates the colour scale
By the end you can point at any SA on the map and see at a glance whether it’s quiet, average, or a hotspot.
What a choropleth is
A choropleth is a map where each region is filled with a colour representing some measured value for that region. Population density. Election results. Job counts. Anything that has one number per polygon.
The colour is the entire information channel. A reader doesn’t need to read individual numbers — the visual gradient is the data. A page of statistics becomes a glance.
The technique only works when:
- Polygons are roughly comparable in significance
- The value range is interesting (if every region is around the same number, the choropleth shows a uniform colour and conveys nothing)
- There’s a sensible colour scale (more on this in a moment)
For our SA density data, the first two conditions are met. The third we’ll design.
The data needs geometry
Lesson 3’s SaDensity returned plain hashes:
|
|
For a choropleth we need each row to carry its polygon. The map
will fill the polygon based on job_count. So the service object
needs to produce a GeoJSON feature collection where each feature
is boundary geometry + properties including job_count.
Add a new method to SaDensity:
|
|
Two things changed from current_state:
ST_AsGeoJSON(sa.boundary)::jsonb — PostGIS converts the
geometry to a GeoJSON string, then casts to jsonb so it
returns as parsed JSON rather than a raw string. (We still call
JSON.parse in Ruby because select_all returns it as a string
regardless. The cast just makes the SQL intent clearer.)
GROUP BY sa.id, sa.code, sa.name, sa.boundary — Postgres
requires every non-aggregated SELECT column to appear in
GROUP BY, including geometry. Adding sa.boundary is technically
redundant since sa.id is the primary key (functional dependency
rules let Postgres infer the rest), but being explicit is safer
across databases.
The result is a feature collection ready to drop into a MapLibre
source. Each feature has its boundary polygon, plus job_count
accessible via ["get", "job_count"] in paint expressions.
The controller and route
Add an action to Api::ServiceAreasController:
|
|
Wire the route — under namespace :api:
|
|
The endpoint becomes /api/service_areas/density_current_state.json.
The page
A new top-level route for the report. In config/routes.rb:
|
|
Create app/controllers/reports_controller.rb:
|
|
The dispatch deck uses Phlex throughout, so the controller renders
a Phlex component directly rather than relying on an ERB template.
No app/views/reports/choropleth.html.erb to create.
Add the page component at
app/components/reports/choropleth.rb:
|
|
A header strip plus a full-bleed map area. The map component handles rendering; the legend component is positioned as an overlay on top of it (more on positioning in a moment).
The map component
Create app/components/choropleth_map.rb:
|
|
A walkthrough of the substantive bits.
COLOUR_RAMP is the colour scale, defined as a constant at
the top of the component. Five stops covering the range 0 → 300
jobs. Each [threshold, hex] pair anchors the interpolation: at
exactly 0 jobs, fill is #f0fdf4; at 10 jobs, #86efac; at 50,
#22c55e; etc. Between stops, the colour blends smoothly.
The colours are Tailwind’s green palette steps 50, 300, 500, 700, 900. A sequential single-hue ramp — light to dark within one colour family — is the right choice for a single-direction metric like “more is more.” (For metrics where high and low are both meaningful — divergent scales like “above/below target” — you’d use a different palette type. The next lesson does exactly that.)
choropleth_expression builds MapLibre’s ["interpolate", ...]
expression, which is how continuous colour scales are expressed in
their style language. The shape is:
["interpolate", ["linear"], <input>, stop1_value, stop1_color, stop2_value, stop2_color, ...]["linear"] means colours interpolate linearly between stops.
Other interpolation types exist — ["exponential", base] for
non-linear scaling — but linear is right for most single-hue
ramps.
["get", "job_count"] reads the feature property at runtime. For
each polygon, MapLibre evaluates this expression with that
feature’s job_count, finds the right segment of the ramp, and
interpolates.
The *COLOUR_RAMP.flatten splat unpacks the constant into the
expression. So if COLOUR_RAMP is [[0, "#f0fdf4"], [10, "#86efac"], ...], the splat produces 0, "#f0fdf4", 10, "#86efac", ... — matching what the interpolate expression needs.
fill_opacity: 0.75 keeps the basemap visible underneath. A
choropleth at full opacity feels heavy and disconnects from the
underlying geography; 0.75 lets coastlines, roads, and labels
show through faintly. On hover it bumps to 0.9 so the hovered SA
reads more clearly than its neighbours.
The outline layer is separate from the fill layer because they need different paint and hover behaviour. A subtle slate line at low opacity normally; a dark, thicker line on hover to clearly mark the focused SA.
on_hover on the outline uses the same hover state as the
fill — both layers hover together because Vera’s hover system
keys on the feature, not the layer. Hovering an SA highlights
its fill and its outline at once.
The legend component
The legend visually communicates what the colour scale means. A choropleth without a legend is decoration; with a legend it becomes a chart.
Create app/components/choropleth_legend.rb:
|
|
A few things worth understanding here.
Components::ChoroplethMap::COLOUR_RAMP is referenced
directly. The legend and the map must show the same scale, and
the cleanest way to ensure they stay in sync is to share the
constant. Change the ramp in one place; the legend updates
automatically.
The CSS gradient is built from the same ramp. A
linear-gradient(to right, #f0fdf4 0%, #86efac 3.3%, #22c55e 16.7%, #15803d 50%, #14532d 100%) gives a smooth bar that
matches the map’s interpolation. The percentage of each colour
stop is its threshold’s position in the total range — so “50
jobs” sits at 16.7% of the bar (50 / 300).
Threshold labels sit underneath the bar at the same positions. The reader sees both the gradient and the numbers it maps to.
absolute top-4 right-4 z-10 positions the legend in the
top-right corner of the map’s container. The page component puts
both the map and the legend inside the same relative parent,
so absolute positioning anchors to that. z-10 keeps the legend
above the map’s canvas.
shadow-lg p-4 bg-white rounded-lg — standard card styling.
The legend reads as a UI element overlaid on the map, not part
of the map’s decoration.
Adding the report to the sidebar
The sidebar already has an ITEMS constant for operational
navigation and a DEV_ITEMS constant for development tools.
Reports are conceptually distinct from both — they’re a separate
section that’s going to grow as Module 6 progresses (SLA report,
trends, depot comparison). Time to introduce a third section.
In Components::Sidebar, add a new constant alongside the
existing two:
|
|
Add the section to view_template, between Operations and
Development:
|
|
Add visible_reports alongside the existing visible_items:
|
|
visible_reports mirrors visible_items exactly — same role
filter, same nil guard. (You could DRY these into a single
filter_by_role(items) helper, but with two callers the
duplication is negligible and the explicit names read clearly.)
Reports section is manager-only for now. A dispatcher could plausibly want a regional version of the choropleth (their depot’s SAs only), but that’s a separate report. The lesson makes the choropleth a national-scope manager tool; future lessons can add scoped variants.
Look what just happened
Sign in as the manager. Choropleth Report appears in the new Reports section of the sidebar. Click it.
The map fills with Australia. Each of the 340 SAs is filled with a green from the ramp — pale near-white where there’s no activity (regional Western Australia, the outback), through mid-green for normal volume, into deep forest green for the busiest metro SAs. Sydney metropolitan SAs glow distinctively darker; Brisbane and Melbourne similar. The pattern of Australian population density is now visible as a colour map.
Hover any SA. The outline darkens, the fill brightens, the popup shows the SA name and active job count. Click an empty spot to dismiss.
Look at the legend in the top-right. The gradient bar shows the colour ramp; the numbers underneath show what counts each colour represents. A reader who’s never seen the data can read the map immediately.
Sign in as a dispatcher. The Reports section doesn’t appear — the role filter excluded the choropleth. Sign back in as the manager; it’s there.
What this introduced
Three patterns that recur in spatial reporting:
Geometry-bearing aggregation queries. The service object
returns shape and metric in one query. ST_AsGeoJSON lets
PostGIS deliver the boundary as JSON-ready data; the rest of the
row carries the computed values.
["interpolate", ...] paint expressions. Continuous colour
scales driven by feature properties. Different from ["match", ...] (discrete values) and ["step", ...] (threshold buckets);
pick interpolate when the input is a continuous numeric range.
Legend as sibling component. The legend lives outside the map but reads from the same constants. Sharing the ramp constant keeps the legend and map in sync structurally.
Where this leaves us
The dispatch deck now has its first true reporting page. SA density at a glance, legibly visualised, accessible from the manager’s sidebar.
The next lesson takes the same choropleth pattern and applies it to a more nuanced metric — SLA performance per SA. Same technique, different colour scale (divergent rather than sequential, because being late is qualitatively different from being early), different operational story.