Skip to content

Module 05 — Scatter Charts: Labour Force Analysis

What We’re Building

This module introduces scatter charts and a pattern that matters beyond chart mechanics: charts as supporting evidence for prose. Real dashboards and data pages rarely show a chart in isolation — they surround it with context, explanation, and narrative.

We build a single page that tells a story about the Australian labour market using three charts interspersed with text. The page is driven by one controller action, one service, and three Phlex chart components.

By the end of this module you will have:

  • A service that shapes Labour Force data for scatter charts
  • Three chart components consuming the same service differently
  • A data story page that mixes prose and charts
  • Consistent colour correspondence across all three charts using a shared palette
  • visualMap for encoding a third dimension as colour
  • markLine for reference lines

5.1 — The Data Story

Australia’s labour market varies significantly by state. The ACT consistently leads on participation rate — a high-income, public-sector dominated economy keeps more people in the workforce. Tasmania and South Australia have historically higher unemployment. The COVID-19 pandemic of 2020 disrupted all states but unevenly — the speed of recovery differed markedly.

Three questions worth charting:

  1. How do participation rate and unemployment rate relate across states? A scatter chart — one point per state per year — reveals the structural differences and the COVID disruption.

  2. How has employment volume changed over time? A line chart per state shows the absolute numbers and the different scales of state economies.

  3. How have participation rates trended? A multi-series line chart shows which states are improving, stagnating, or declining in workforce engagement.

One service. Three charts. One page.


5.2 — The Service

This module reuses Stats::LabourForce from Module 04 — unchanged. All three charts consume the same service call:

1
data = ::Stats::LabourForce.call(@readings)

This is the service layer principle in practice. Stats::LabourForce transforms raw monthly readings into annual averages per state. It has no knowledge of how that data will be used — whether as a bar chart, a scatter chart, or a line chart. The same hash structure feeds all three visualisations on this page.

The service already returns everything we need:

1
2
3
4
5
6
7
{
  "New South Wales" => [
    { year: 2012, employed: 3562.4, rate: 5.3, participation: 62.3 },
    ...
  ],
  ...
}

No new service file. No duplication. Three charts, one service call per component.


5.3 — Chart 1: Participation vs Unemployment Scatter

Each point is one state in one year. X axis is participation rate, Y axis is unemployment rate. Each state is a separate series so it gets its own colour and can be toggled in the legend.

The trajectory of each state traces a path through the chart over 12 years — in a healthy economy, points move right (higher participation) and down (lower unemployment). The COVID year appears as an outlier point for every state.

visualMap — encoding year as colour

Adding visualMap encodes the year dimension as colour intensity within each series — earlier years appear lighter, recent years darker. This makes the time progression visible without adding a separate series per 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
29
::Chart::Options.new(
  color:   "tableau",
  toolbox: { feature: { saveAsImage: {}, restore: {} } },
  tooltip: { trigger: "item", formatter: "participationScatter" },
  legend:  { type: "scroll", bottom: 5 },
  visualMap: {
    show:       false,
    dimension:  2,        # the third value in each data point [x, y, year]
    min:        2012,
    max:        2024,
    inRange:    { colorLightness: [0.35, 0.85] }
  },
  x_axis:  {
    type: "value",
    name: "Participation Rate (%)",
    nameLocation: "middle",
    nameGap: 30,
    axisLabel: { formatter: "rate" }
  },
  y_axis:  {
    type: "value",
    name: "Unemployment Rate (%)",
    nameLocation: "middle",
    nameGap: 40,
    axisLabel: { formatter: "rate" }
  },
  grid:    { bottom: 60, left: 16, right: 16, containLabel: true },
  series:  build_series(data)
)

With visualMap, each data point becomes [participation, rate, year] — a three-element array where the third element drives the colour lightness:

1
2
3
4
5
6
7
8
def build_series(data)
  data.map do |state, rows|
    ::Chart::Series::Scatter.new(
      name: state,
      data: rows.map { |r| [r[:participation], r[:rate], r[:year]] }
    )
  end
end

dimension: 2 tells visualMap to use the third element (index 2) of each data point. colorLightness: [0.35, 0.85] maps the min year to a darker shade and the max year to a lighter shade — or reverse the array to invert the mapping.

show: false hides the visual map control widget. Since the legend already identifies states by colour, a second colour scale widget would be confusing.

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

      private

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

        ::Chart::Options.new(
          color:   "tableau",
          toolbox: { feature: { saveAsImage: {}, restore: {} } },
          tooltip: { trigger: "item", formatter: "participationScatter" },
          legend:  { type: "scroll", bottom: 5 },
          visualMap: {
            show:      false,
            dimension: 2,
            min:       2012,
            max:       2024,
            inRange:   { colorLightness: [0.35, 0.85] }
          },
          x_axis: {
            type:         "value",
            name:         "Participation Rate (%)",
            nameLocation: "middle",
            nameGap:      30,
            axisLabel:    { formatter: "rate" }
          },
          y_axis: {
            type:         "value",
            name:         "Unemployment Rate (%)",
            nameLocation: "middle",
            nameGap:      40,
            axisLabel:    { formatter: "rate" }
          },
          grid:   { bottom: 60, left: 16, right: 16, containLabel: true },
          series: build_series(data)
        )
      end

      def build_series(data)
        data.map do |state, rows|
          ::Chart::Series::Scatter.new(
            name: state,
            data: rows.map { |r| [r[:participation], r[:rate], r[:year]] }
          )
        end
      end
    end
  end
end

Note: Axis minimum for scatter charts: ECharts defaults to starting value axes at zero. For scatter charts this almost always produces a chart where all points are bunched into one corner. Set min: “dataMin” on both axes to fit the scale to your data. Use an explicit value like min: 55 if you want a fixed lower bound rather than a dynamic one.

Custom tooltip for scatter

The default "item" formatter shows the raw array — not readable. Add a custom formatter to custom_chart_formatters.js:

1
2
3
4
5
6
7
8
9
// app/javascript/charts/custom_chart_formatters.js
export default {
  participationScatter: params => {
    const [participation, rate, year] = params.value
    return `${params.marker}${params.seriesName} (${year})<br/>
      Participation: <strong>${participation}%</strong><br/>
      Unemployment: <strong>${rate}%</strong>`
  }
}

Note: This is our first example of using the custom_chart_formatters.js that we introduced back in Lesson 02.


5.4 — Chart 2: Employment Volume Over Time

A line chart showing absolute employment numbers per state. This answers a different question — not the ratio of workers to population, but the raw scale of each state’s economy.

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

      private

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

        ::Chart::Options.new(
          color:   "tableau",
          toolbox: { feature: { saveAsImage: {}, restore: {} } },
          tooltip: { trigger: "axis", formatter: "thousands" },
          legend:  { type: "scroll", bottom: 5 },
          x_axis:  { type: "category", data: years(data) },
          y_axis:  {
            type: "value",
            name: "Employed ('000)",
            nameLocation: "middle",
            nameGap: 50,
            axisLabel: { formatter: "thousands" }
          },
          grid:    { bottom: 60, left: 16, right: 8, 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
          )
        end
      end

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

5.5 — Chart 3: Participation Rate Trends

The third chart shows participation rate over time. A markLine draws the national average — this is more semantically correct than adding a fake series, and ECharts renders it as a distinct annotation rather than a legend entry.

markLine — statistical annotations

markLine adds reference lines to any series. We add it to the first series only to avoid duplicating the line eight times:

 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
def build_series(data)
  national_avg = compute_national_avg(data)

  data.map.with_index do |(state, rows), i|
    opts = {
      name:   state,
      data:   rows.map { |r| r[:participation] },
      smooth: true
    }

    # Add markLine to first series only
    if i == 0
      opts[:markLine] = {
        silent:    true,
        symbol:    "none",
        lineStyle: { type: "dashed", color: "#999" },
        label:     { formatter: "National Avg" },
        data:      national_avg.map { |year, val|
          { xAxis: year.to_s, yAxis: val }
        }
      }
    end

    ::Chart::Series::Line.new(**opts)
  end
end

def compute_national_avg(data)
  all_years = data.values.flatten
  all_years
    .group_by { |r| r[:year] }
    .transform_values { |rows|
      (rows.sum { |r| r[:participation] } / rows.size).round(1)
    }
end
 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
# app/views/components/charts/participation_trends.rb
module Components
  module Charts
    class ParticipationTrends < Components::Chart
      prop :readings, _Any, default: -> { [] }

      private

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

        ::Chart::Options.new(
          color:   "tableau",
          toolbox: { feature: { saveAsImage: {}, restore: {} } },
          tooltip: { trigger: "axis", formatter: "rate" },
          legend:  { type: "scroll", bottom: 5 },
          x_axis:  {
            type: "category",
            data: years(data)
          },
          y_axis:  {
            type:         "value",
            name:         "Participation Rate (%)",
            nameLocation: "middle",
            nameGap:      40,
            axisLabel:    { formatter: "rate" }
          },
          grid:    { bottom: 60, left: 16, right: 8, containLabel: true },
          series:  build_series(data)
        )
      end

      def build_series(data)
        national_avg = compute_national_avg(data)

        data.map.with_index do |(state, rows), i|
          opts = {
            name:   state,
            data:   rows.map { |r| r[:participation] },
            smooth: true
          }

          if i == 0
            opts[:markLine] = {
              silent:    true,
              symbol:    "none",
              lineStyle: { type: "dashed", color: "#999" },
              label:     { position: "insideEndTop", formatter: "National Avg" },
              data:      [[
                { type: "min", name: "National Avg" },
                { type: "max", name: "National Avg" }
              ]]
            }
          end

          ::Chart::Series::Line.new(**opts)
        end
      end

      def compute_national_avg(data)
        data.values.flatten
          .group_by { |r| r[:year] }
          .transform_values { |rows|
            (rows.sum { |r| r[:participation] } / rows.size).round(1)
          }
      end

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

Note: markLine with type: "average" draws a horizontal average line across the entire series. Here we want the national average across states, not the average of one state’s values — so we compute it separately and pass it as a custom data point. The markLine is attached to the first series (i == 0) to avoid rendering it eight times.


5.6 — Controller and View

One action loads the data once. The view renders three charts interspersed with prose.

1
2
3
4
# app/controllers/charts_controller.rb
def labour_market_analysis
  @readings = LabourForceReading.ordered
end
1
2
# config/routes.rb
get "charts/labour_market_analysis", to: "charts#labour_market_analysis"
<%# app/views/charts/labour_market_analysis.html.erb %>

<div class="max-w-4xl mx-auto px-4 py-8">

  <h1 class="text-3xl font-bold mb-2">Australian Labour Market by State</h1>
  <p class="text-neutral-500 text-sm mb-8">
    Annual averages, seasonally adjusted, 2012–2024.
    Source: <a href="https://www.abs.gov.au" class="underline">Australian Bureau of Statistics</a>,
    Labour Force (ABS catalogue 6202.0), CC BY 4.0.
  </p>

  <%# ── Chart 1 ────────────────────────────────────────────────── %>

  <h2 class="text-xl font-semibold mb-2">Participation vs Unemployment</h2>
  <p class="text-neutral-600 mb-4">
    Each point represents one state in one year. States that perform well tend to
    cluster toward the bottom-right — high participation, low unemployment. The
    ACT consistently occupies this position, reflecting its high-income public
    sector workforce. Tasmania and South Australia sit toward the upper-left.
    The COVID-19 disruption of 2020 appears as a visible break in each state's
    trajectory — unemployment spiked as participation fell.
  </p>

  <%= render Components::Charts::ParticipationScatter.new(
    readings: @readings,
    height:   "420px"
  ) %>

  <p class="text-neutral-500 text-xs mt-2 mb-10">
    Each point is one state's annual average. Hover for details. Toggle states
    using the legend.
  </p>

  <%# ── Chart 2 ────────────────────────────────────────────────── %>

  <h2 class="text-xl font-semibold mb-2">Employment Volume</h2>
  <p class="text-neutral-600 mb-4">
    The scale differences between states are striking. New South Wales and Victoria
    together employ more than the other six states combined. The long-run growth
    trend is visible across all states, with the brief COVID dip in 2020 followed
    by a sharp recovery. Western Australia shows the most volatility — driven by
    the resources sector's sensitivity to commodity cycles.
  </p>

  <%= render Components::Charts::EmploymentTrends.new(
    readings: @readings,
    height:   "380px"
  ) %>

  <p class="text-neutral-500 text-xs mt-2 mb-10">
    Annual average employed persons ('000). Hover to compare states at a given year.
  </p>

  <%# ── Chart 3 ────────────────────────────────────────────────── %>

  <h2 class="text-xl font-semibold mb-2">Participation Rate Trends</h2>
  <p class="text-neutral-600 mb-4">
    Participation rate — the share of the working-age population either employed
    or actively seeking work — tells a different story to headline unemployment.
    The ACT's rate is structurally higher due to its demographic profile. The
    national average has been broadly flat over the period, masking diverging
    trends: Queensland and Western Australia have lifted, while South Australia
    and Tasmania have lagged. The dashed line shows the national average across
    all states.
  </p>

  <%= render Components::Charts::ParticipationTrends.new(
    readings: @readings,
    height:   "380px"
  ) %>

  <p class="text-neutral-500 text-xs mt-2 mb-10">
    Annual average participation rate (%). National average shown as dashed line.
  </p>

  <%# ── Attribution ────────────────────────────────────────────── %>

  <div class="border-t border-neutral-200 pt-6 mt-4">
    <p class="text-neutral-400 text-xs">
      Data: Australian Bureau of Statistics, Labour Force, Australia (ABS cat. 6202.0).
      Accessed via ABS Data API. Licensed under
      <a href="https://www.abs.gov.au/website-privacy-copyright-and-disclaimer"
         class="underline">CC BY 4.0</a>.
    </p>
  </div>

</div>

And we can add an extra card to our index page:

<%= render “charts/gallery_card”, title: “Labour Market Analysis”, description: “Three charts with prose commentary — participation rate, employment volume, and unemployment trends by state, 2012–2024.”, path: charts_labour_market_analysis_path %>


5.7 — Colour Correspondence Across Charts

All three charts on this page use color: "tableau". Because the service returns states in the same alphabetical order, and ECharts assigns colours from the palette in series order, ACT always gets the first tableau colour, NSW the second, and so on — across all three charts on this page.

This is the palette consistency principle from Module 03 in action. A reader can identify “Queensland” in Chart 1 and immediately find Queensland in Charts 2 and 3 by colour without reading the legend again.

The rule: if charts on the same page share the same palette and the same series order, they will share the same colour mapping. The service guarantees consistent ordering by sorting states alphabetically.

Note that Module 04 uses a different palette ("cool") — that is fine. Colour consistency is a within-page concern. There is no requirement for the bar chart from Module 04 to match the scatter chart on this page.


5.8 — The National Average Line

The national average series in Chart 3 uses a plain hash for lineStyle and itemStyle — raw ECharts options passed through the DSL escape hatch:

1
2
3
4
5
6
7
::Chart::Series::Line.new(
  name:      "National Average",
  data:      national_avg.values,
  lineStyle: { type: "dashed", width: 2 },
  itemStyle: { color: "#666" },
  symbol:    "none"
)

symbol: "none" removes the data point markers. lineStyle: { type: "dashed" } draws a dashed line. These are standard ECharts series options — the **extra escape hatch in Chart::Series::Base passes them through unchanged.


5.9 — Testing Chart Configuration

Scatter chart option generation is pure Ruby — no browser, no JavaScript, no Rails required. The service and component produce plain hashes that are entirely inspectable in a unit test.

 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
# test/views/components/charts/participation_scatter_test.rb
class ParticipationScatterTest < ActiveSupport::TestCase
  def readings
    [
      LabourForceReading.new(state: "New South Wales", year: 2022, month: 1,
        employed_thousands: 4100.0, unemployment_rate: 3.8, participation_rate: 63.2),
      LabourForceReading.new(state: "New South Wales", year: 2022, month: 2,
        employed_thousands: 4110.0, unemployment_rate: 3.7, participation_rate: 63.4),
      LabourForceReading.new(state: "Victoria", year: 2022, month: 1,
        employed_thousands: 3400.0, unemployment_rate: 4.1, participation_rate: 65.1),
      LabourForceReading.new(state: "Victoria", year: 2022, month: 2,
        employed_thousands: 3410.0, unemployment_rate: 4.0, participation_rate: 65.3)
    ]
  end

  test "produces one series per state" do
    component = Components::Charts::ParticipationScatter.new(readings: readings)
    options   = component.send(:chart_options).to_h
    series    = options[:series]

    assert_equal 2, series.size
    assert_equal "scatter", series.first[:type]
  end

  test "each data point is [participation, rate, year]" do
    component = Components::Charts::ParticipationScatter.new(readings: readings)
    options   = component.send(:chart_options).to_h
    nsw       = options[:series].find { |s| s[:name] == "New South Wales" }

    point = nsw[:data].first
    assert_equal 3, point.size          # [participation, rate, year]
    assert_equal 2022, point[2]         # year is third element
  end

  test "includes visualMap" do
    component = Components::Charts::ParticipationScatter.new(readings: readings)
    options   = component.send(:chart_options).to_h

    assert options[:visualMap].present?
    assert_equal 2, options[:visualMap][:dimension]
  end
end

Running these tests requires no database, no browser, and no ECharts. The component is instantiated with plain LabourForceReading objects built with .new — no persistence needed.

1
rails test test/views/components/charts/participation_scatter_test.rb

5.10 — Routes and Gallery

1
2
3
4
# config/routes.rb
get "charts/labour_market_analysis",
    to:  "charts#labour_market_analysis",
    as:  :charts_labour_market_analysis

Add to the gallery index:

<%= render "charts/gallery_card",
  title:       "Labour Market Analysis",
  description: "Participation rate, unemployment, and employment trends by state. "\
               "Three charts with prose commentary.",
  path:        charts_labour_market_analysis_path %>

5.11 — Module Summary

New files:

File Purpose
app/views/components/charts/participation_scatter.rb Scatter — participation vs unemployment
app/views/components/charts/employment_trends.rb Line — employment volume by state
app/views/components/charts/participation_trends.rb Line — participation rate with national average
app/views/charts/labour_market_analysis.html.erb Data story page

No new service — all three charts reuse Stats::LabourForce from Module 04.

Patterns introduced:

  • Scatter chart with per-series data ([x, y, z] arrays)
  • visualMap encoding a third dimension (year) as colour lightness
  • markLine for statistical annotations — national average reference line
  • Multiple charts on one page sharing a palette for colour correspondence
  • Charts interspersed with prose — the data story pattern
  • Custom tooltip formatter for scatter data including the year dimension
  • Service reuse — Stats::LabourForce feeds three different chart types
  • Unit testing chart components in plain Ruby with no database or browser

The data story pattern:

Heading
↓
Prose — what to look for, why it matters
↓
Chart — the visual evidence
↓
Caption — what the chart shows
↓
(repeat)
↓
Attribution

This pattern works for any data-driven page. The prose gives readers the interpretive frame before they encounter the chart — making the chart more legible and more persuasive.


Next: Module 06 — Pie and Donut: CPI Composition