Skip to content

Module 10 — Interactive Dashboard: Labour Market

What We’re Building

A three-chart dashboard where clicking a year on the top chart updates the two charts below — no custom JavaScript, no Turbo Frames. Turbo Morph handles the DOM update: only what changed re-renders.

The parent chart is a line chart showing employment trends over time. Clicking any year updates two child charts: a grouped bar showing employment and unemployment by state, and a pie chart showing employment share by state — both for the selected year.

The mechanism is adding a select_url value to the main chart. This action will be triggered when the user clicks on this chart.

1
2
3
4
render Components::Charts::LabourForceTimeline.new(
  readings:   @readings,
  select_url: charts_labour_dashboard_path
)

Here’s what we’ll develop:

linked_dashboard.png


10.1 — The Service

One service feeds both child charts — annual averages by state for a single year:

 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
# app/services/stats/labour_force_snapshot.rb
module Stats
  module LabourForceSnapshot
    extend self

    def call(year:)
      LabourForceReading
        .where(year: year)
        .group(:state)
        .select(
          :state,
          "ROUND(AVG(employed_thousands), 1)   AS employed",
          "ROUND(AVG(unemployed_thousands), 1) AS unemployed",
          "ROUND(AVG(participation_rate), 1)   AS participation",
          "ROUND(AVG(unemployment_rate), 1)    AS rate"
        )
        .order(:state)
        .each_with_object({}) do |r, h|
          h[r.state] = {
            employed:      r.employed.to_f,
            unemployed:    r.unemployed.to_f,
            participation: r.participation.to_f,
            rate:          r.rate.to_f
          }
        end
    end
  end
end

SQL does the aggregation — one query, no Ruby-side averaging. The result is a Hash keyed by state name, each value containing all four metrics.


10.2 — The Components

Parent — LabourForceTimeline (line chart)

Based on the Line Chart template. Adds triggerEvent: true to the x axis so year labels are clickable:

 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
# app/views/components/charts/labour_force_timeline.rb
module Components
  module Charts
    class LabourForceTimeline < Components::Chart
      prop :readings, _Any, default: -> { [] }

      private

      def chart_options
        data = ::Stats::LabourForce.call(@readings)

        ::Chart::Options.new(
          color:   "tableau",
          tooltip: { trigger: "axis", formatter: "thousands" },
          legend:  { type: "scroll", bottom: 5 },
          x_axis:  {
            type:         "category",
            data:         years(data),
            triggerEvent: true   # enables click events on axis labels
          },
          y_axis:  {
            type:      "value",
            axisLabel: { formatter: "thousands" }
          },
          grid:    { left: 8, right: 8, bottom: 40, containLabel: true },
          series:  build_series(data)
        )
      end

      def build_series(data)
        data.map do |state, rows|
          ::Chart::Series::Line.new(
            name:   state,
            data:   rows.map { |r| r[:employed] },
            smooth: true,
            symbolSize: 6
          )
        end
      end

      def years(data)
        data.values.first&.map { |r| r[:year].to_s } || []
      end
    end
  end
end

Child 1 — LabourForceStateBar (grouped bar)

Based on the Grouped Bar Chart template. Receives the snapshot data hash:

 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
# app/views/components/charts/labour_force_state_bar.rb
module Components
  module Charts
    class LabourForceStateBar < Components::Chart
      prop :data, _Any,    default: -> { {} }
      prop :year, Integer, default: -> { Date.today.year }

      private

      # app/views/components/charts/labour_force_state_bar.rb
      def chart_options
        states = @data.keys

        ::Chart::Options.new(
          color:   "tableau",
          tooltip: { trigger: "axis", formatter: "thousands" },
          legend:  { type: "scroll", bottom: 5 },
          x_axis:  { type: "category", data: ["Employment"] },
          y_axis:  {
            type:      "value",
            axisLabel: { formatter: "thousands" }
          },
          grid:    { left: 8, right: 8, bottom: 40, containLabel: true },
          series:  states.map do |state|
            ::Chart::Series::Bar.new(
              name:  state,
              data:  [@data[state][:employed]]
            )
          end
        )
      end
    end
  end
end

Child 2 — LabourForceSharePie (pie chart)

Based on the Donut Chart template. Shows employment share by state:

 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
# app/views/components/charts/labour_force_share_pie.rb
module Components
  module Charts
    class LabourForceSharePie < Components::Chart
      prop :data, _Any,    default: -> { {} }
      prop :year, Integer, default: -> { Date.today.year }

      private

      def chart_options
        ::Chart::Options.new(
          color:   "tableau",
          tooltip: { trigger: "item", formatter: "stateSlice" },
          legend:  { type: "scroll", bottom: 5 },
          series: [
            ::Chart::Series::Pie.new(
              name:   "Employment Share #{@year}",
              data:   @data.map { |state, m|
                { name: state, value: m[:employed] }
              },
              radius: ["35%", "65%"],
              center: ["50%", "45%"],
              label:  { formatter: "{b}\n{d}%" }
            )
          ]
        )
      end
    end
  end
end

Same "tableau" palette on all three components — the colour assigned to each state is consistent across all three charts. NSW is always the same colour whether it appears as a line, a bar, or a pie slice.


10.3 — The Tooltip Formatter

Add to custom_chart_formatters.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
"item:stateSlice": params => {
  const share = params.percent.toFixed(1)
  return `
    <div style="min-width:180px">
      <div style="font-weight:600;margin-bottom:4px">
        ${params.marker}${params.name}
      </div>
      <table style="width:100%;border-collapse:collapse">
        <tr>
          <td style="color:#999;padding-right:12px">Employed</td>
          <td style="text-align:right;font-weight:600">
            ${params.value.toLocaleString()}k
          </td>
        </tr>
        <tr>
          <td style="color:#999;padding-right:12px">Share</td>
          <td style="text-align:right;font-weight:600">${share}%</td>
        </tr>
      </table>
    </div>
  `
}

10.4 — Controller and View

1
2
3
4
5
6
# app/controllers/charts_controller.rb
def labour_dashboard
  @year     = (params[:year] || LabourForceReading.maximum(:year)).to_i
  @readings = LabourForceReading.ordered
  @snapshot = Stats::LabourForceSnapshot.call(year: @year)
end
1
2
# config/routes.rb
get "charts/labour_dashboard", to: "charts#labour_dashboard"

Note: - we’re adding that labour_dashboard path to the select_url below.

<%# app/views/charts/labour_dashboard.html.erb %>
<div class="max-w-6xl mx-auto px-4 py-8">

  <div class="flex items-center justify-between mb-6">
    <div>
      <h1 class="text-3xl font-bold">Labour Market Dashboard</h1>
      <p class="text-neutral-500 text-sm mt-1">
        Click any year to see the breakdown for that year.
        Source: ABS Labour Force (cat. 6202.0), CC BY 4.0.
      </p>
    </div>
    <div class="text-2xl font-bold text-neutral-400">
      <%= @year %>
    </div>
  </div>

  <%# Parent chart — clicking a year updates the page %>
  <div class="mb-6">
    <h2 class="text-sm font-medium text-neutral-600 mb-1">
      Employment Volume by State ('000 persons) — click a year
    </h2>
    <%= render Components::Charts::LabourForceTimeline.new(
      readings:   @readings,
      select_url: charts_labour_dashboard_path,
      height:     "300px"
    ) %>
  </div>

  <%# Child charts — re-render via Turbo Morph on year selection %>
  <div class="grid grid-cols-2 gap-4">
    <div>
      <h2 class="text-sm font-medium text-neutral-600 mb-1">
        Employment and Unemployment by State — <%= @year %>
      </h2>
      <%= render Components::Charts::LabourForceStateBar.new(
        data:   @snapshot,
        year:   @year,
        height: "320px"
      ) %>
    </div>
    <div>
      <h2 class="text-sm font-medium text-neutral-600 mb-1">
        Employment Share by State — <%= @year %>
      </h2>
      <%= render Components::Charts::LabourForceSharePie.new(
        data:   @snapshot,
        year:   @year,
        height: "320px"
      ) %>
    </div>
  </div>

</div>

10.5— What Turbo Morph Does

When the user clicks “2020” on the timeline:

  1. chart_controller.js calls Turbo.visit("/charts/labour_dashboard?year=2020")
  2. Turbo fetches the new page
  3. Morph compares old and new HTML
  4. The timeline is identical — same options, same ECharts instance — Morph leaves it
  5. The bar and pie charts have new data — Morph replaces them
  6. ECharts reinitialises on the new mount divs
  7. The year heading updates from “2024” to “2020”

No Turbo Frames. No broadcast. No custom stream. The page re-renders completely server-side — Morph surgically updates only what changed.


10.6 — Colour Consistency

All three components use color: "tableau". The service returns states in alphabetical order — ACT, NSW, NT, QLD, SA, TAS, VIC, WA. ECharts assigns palette colours in series order.

The result: ACT is always the first tableau colour, NSW always the second — on the line chart, the bar chart, and the pie chart. A reader can identify a state by colour on any chart without reading the legend.

This is the colour correspondence principle from Module 03 applied across an interactive dashboard.


10.7 — Gallery

<%= render "charts/gallery_card",
  title:       "Labour Market Dashboard",
  description: "Click a year on the timeline to see the state breakdown. "\
               "Three chart types, one click, no JavaScript.",
  path:        charts_labour_dashboard_path %>

10.8 — Module Summary

New files:

File Purpose
app/services/stats/labour_force_snapshot.rb Single-year averages by state
app/views/components/charts/labour_force_timeline.rb Parent line chart
app/views/components/charts/labour_force_state_bar.rb Child grouped bar
app/views/components/charts/labour_force_share_pie.rb Child pie chart

Infrastructure changes (see Appendix B and C):

File Change
chart_controller.js selectUrl value + #bindClick method
app/views/components/chart.rb select_url: prop

Patterns introduced:

  • select_url: prop — opt-in click navigation on any chart
  • triggerEvent: true on x axis — enables click events on axis labels
  • Turbo.visit with action: "replace" — updates URL without history entry
  • Turbo Morph — only changed charts re-render, parent chart untouched
  • params[:year] — controller reads selected year, fetches snapshot data
  • Colour correspondence across chart types — same palette, same series order

The interaction principle:

The parent chart navigates. The controller fetches. Morph updates. No custom JavaScript per chart. No Turbo Frames. No broadcasts. Click a data point — the page responds.