Skip to content

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 patternpaint: { 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:

1
2
3
4
5
[
  { id: 1, code: "10101", name: "Bondi - Bondi Junction", job_count: 247 },
  { id: 2, code: "10102", name: "Glebe", job_count: 183 },
  ...
]

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:

 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
def current_state_geojson
  rows = ActiveRecord::Base.connection.select_all(<<~SQL).rows
    SELECT sa.id,
           sa.code,
           sa.name,
           ST_AsGeoJSON(sa.boundary)::jsonb AS geometry,
           COUNT(j.id) AS job_count
    FROM service_areas sa
    LEFT JOIN jobs j ON j.service_area_id = sa.id
                  AND j.status IN ('pending', 'scheduled', 'in_progress')
    GROUP BY sa.id, sa.code, sa.name, sa.boundary
  SQL

  {
    type: "FeatureCollection",
    features: rows.map { |id, code, name, geometry, count|
      {
        type: "Feature",
        id:   id,
        geometry:   JSON.parse(geometry),
        properties: {
          code:      code,
          name:      name,
          job_count: count.to_i
        }
      }
    }
  }
end

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:

1
2
3
def density_current_state
  render json: SaDensity.current_state_geojson
end

Wire the route — under namespace :api:

1
2
3
4
5
6
7
8
resources :service_areas, only: [] do
  collection do
    get :national
    get :regional
    get :local
    get :density_current_state
  end
end

The endpoint becomes /api/service_areas/density_current_state.json.

The page

A new top-level route for the report. In config/routes.rb:

1
get "/reports/choropleth", to: "reports#choropleth", as: :choropleth_report

Create app/controllers/reports_controller.rb:

1
2
3
4
5
class ReportsController < ApplicationController
  def choropleth
    render Components::Reports::Choropleth.new
  end
end

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class Components::Reports::Choropleth < Components::Base
  def view_template
    div(class: "h-full flex flex-col") do
      header(class: "px-6 py-4 border-b border-slate-200") do
        h1(class: "text-2xl font-bold text-slate-900") { "Choropleth Report" }
        p(class: "text-sm text-slate-500 mt-1") do
          plain "Service areas coloured by current active job count."
        end
      end

      div(class: "flex-1 relative") do
        render Components::ChoroplethMap.new
        render Components::ChoroplethLegend.new
      end
    end
  end
end

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:

 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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
class Components::ChoroplethMap < Components::Base
  prop :id,     String, default: -> { "choropleth-map" }
  prop :height, String, default: -> { "100%" }

  # The colour scale — sequential green ramp from light (low
  # density) to dark (high density). Five stops give enough
  # gradient resolution without becoming a rainbow.
  COLOUR_RAMP = [
    [0,   "#f0fdf4"],   # green-50  - empty / near-empty
    [10,  "#86efac"],   # green-300 - quiet
    [50,  "#22c55e"],   # green-500 - normal
    [150, "#15803d"],   # green-700 - busy
    [300, "#14532d"]    # green-900 - hotspot
  ].freeze

  POPUP_TEMPLATE = <<~HTML.freeze
    <div class="vera-popup">
      <div class="vera-popup__customer">{{name}}</div>
      <div class="vera-popup__address">SA3 {{code}}</div>
      <div class="vera-popup__description">
        Active jobs: <strong>{{job_count}}</strong>
      </div>
    </div>
  HTML

  def view_template
    render Vera::Map.new(id: @id, height: @height, style: :voyager,
                         zoom_indicator: :bottom_left, loading_indicator: true) do |m|
      m.control :navigation
      m.control :fullscreen
      m.control :scale, unit: :metric

      add_choropleth(m)
    end
  end

  private

  def add_choropleth(m)
    m.source :sa_density,
             url:        "/api/service_areas/density_current_state.json",
             fit_bounds: { padding: 20 }

    m.layer :sa_fill, source: :sa_density, type: :fill,
            paint: {
              fill_color:   choropleth_expression,
              fill_opacity: 0.75
            },
            on_hover: { fill_opacity: 0.9 },
            popup:    { template: POPUP_TEMPLATE }

    m.layer :sa_outline, source: :sa_density, type: :line,
            paint: {
              line_color:   "#475569",
              line_width:   0.5,
              line_opacity: 0.5
            },
            on_hover: {
              line_color:   "#0f172a",
              line_width:   2,
              line_opacity: 1
            }
  end

  # An interpolation expression: smoothly transition the fill
  # colour through the ramp's stops based on each feature's
  # job_count.
  def choropleth_expression
    [
      "interpolate",
      ["linear"],
      ["get", "job_count"],
      *COLOUR_RAMP.flatten
    ]
  end
end

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:

 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
class Components::ChoroplethLegend < Components::Base
  # Same ramp as the map. Pulled into a constant so the legend
  # and the map's paint expression cannot drift apart.
  RAMP = Components::ChoroplethMap::COLOUR_RAMP

  def view_template
    div(class: "absolute top-4 right-4 z-10 bg-white rounded-lg shadow-lg p-4 w-64") do
      div(class: "text-sm font-semibold text-slate-700 mb-3") do
        plain "Active jobs"
      end

      div(class: "space-y-1.5") do
        gradient_bar
        scale_labels
      end
    end
  end

  private

  def gradient_bar
    div(
      class: "h-3 rounded",
      style: "background: linear-gradient(to right, #{gradient_stops})"
    )
  end

  def scale_labels
    div(class: "flex justify-between text-xs text-slate-500") do
      RAMP.each do |threshold, _|
        span { threshold.to_s }
      end
    end
  end

  # Convert the ramp into CSS gradient stops. CSS gradients use
  # percentages along the bar; we map each ramp threshold to a
  # percentage of the total range.
  def gradient_stops
    max = RAMP.last.first.to_f
    RAMP.map { |threshold, color|
      pct = (threshold / max * 100).round(1)
      "#{color} #{pct}%"
    }.join(", ")
  end
end

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:

1
2
3
REPORTS = [
  { label: "Choropleth Report", path: "/reports/choropleth", icon: :chart_bar, roles: %w[manager] }
].freeze

Add the section to view_template, between Operations and Development:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def view_template
  nav(class: "w-56 shrink-0 bg-slate-900 text-slate-100 h-full overflow-y-auto",
      aria_label: "Primary navigation") do
    div(class: "py-3 px-3 space-y-4") do
      render_section("Operations",  visible_items)
      render_section("Reports",     visible_reports)
      render_section("Development", DEV_ITEMS) if Rails.env.development?
    end
  end
end

Add visible_reports alongside the existing visible_items:

1
2
3
4
5
def visible_reports
  return [] if @current_user.nil?
  role = @current_user.role.to_s
  REPORTS.select { |item| item[:roles].include?(role) }
end

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.