Skip to content

Module 04 — Scatter Charts: Labour Market 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 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. One controller action, one service, three components.

By the end of this module you will have:

  • Chart::Series::Scatter — a new series type
  • visualMap — encoding a third dimension as colour
  • markLine — reference line annotations
  • A data story page mixing prose and charts
  • Colour correspondence across multiple charts on the same page

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

Here’s what we’re building:

scatter_chart.png


4.1 — Chart::Series::Scatter

Scatter charts plot points at [x, y] coordinates. Each series is a collection of points — one per state, one per year in our case.

The equivalent JavaScript:

1
2
3
4
5
6
7
8
series: [
  {
    type: "scatter",
    name: "New South Wales",
    data: [[63.2, 5.3], [63.4, 5.1], ...]  // [participation, unemployment]
  },
  ...
]

In Ruby:

1
2
3
4
::Chart::Series::Scatter.new(
  name: "New South Wales",
  data: [[63.2, 5.3], [63.4, 5.1], ...]
)

Chart::Series::Scatter adds type: "scatter" automatically. The data is an array of [x, y] pairs — or [x, y, z] if a third dimension is needed.

Value axes — start from the data, not zero. Scatter charts use type: "value" on both axes. ECharts defaults to starting value axes at zero — which bunches all points into one corner when your data doesn’t go near zero. Set min: "dataMin" on both axes to fit the scale to the actual data range:

1
2
x_axis: { type: "value", min: "dataMin" },
y_axis: { type: "value", min: "dataMin" }

4.2 — 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 its own series — its own colour, its own legend entry, toggleable independently.

visualMap

visualMap encodes a third dimension as a visual property — in this case, year as colour lightness. Earlier years appear darker, recent years lighter. The time progression becomes visible without adding a separate series per year.

1
2
3
4
5
6
7
visualMap: {
  show:      false,      # hide the control widget
  dimension: 2,          # use the third element of each [x, y, year] point
  min:       2012,
  max:       2024,
  inRange:   { colorLightness: [0.35, 0.85] }
}

dimension: 2 — array index 2, the third element. colorLightness maps the min year to shade 0.35 (darker) and the max year to 0.85 (lighter). Reverse the array to invert.

show: false — the legend already identifies states by colour. A second colour scale widget would be confusing.

With visualMap active, each data point needs the third dimension:

1
rows.map { |r| [r[:participation], r[:rate], r[:year]] }

The component

 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 :data, _Any, default: -> { {} }

      private

      def chart_options
        ::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",
            min:          "dataMin",
            name:         "Participation Rate (%)",
            nameLocation: "middle",
            nameGap:      30,
            axisLabel:    { formatter: "rate" }
          },
          y_axis: {
            type:         "value",
            min:          "dataMin",
            name:         "Unemployment Rate (%)",
            nameLocation: "middle",
            nameGap:      40,
            axisLabel:    { formatter: "rate" }
          },
          grid:   { bottom: 60, left: 16, right: 16, containLabel: true },
          series: build_series
        )
      end

      def build_series
        @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

Custom tooltip formatter

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

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

The key is "item:participationScatter" — the item: prefix matches the trigger: "item" on the tooltip. The resolver in chart_controller.js qualifies the lookup automatically when you write formatter: "participationScatter" in Ruby. Custom formatters were introduced in Module 02 — this is the first one we add to custom_chart_formatters.js.


4.3 — Chart 2: Employment Volume Over Time

A line chart showing absolute employment numbers per state. Same service, same data structure, 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
# app/views/components/charts/employment_trends.rb
module Components
  module Charts
    class EmploymentTrends < Components::Chart
      prop :data, _Any, default: -> { {} }

      private

      def chart_options
        ::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 },
          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
        )
      end

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

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

4.4 — Chart 3: Participation Rate Trends

The third chart shows participation rate over time. A clean multi-series line chart with min: "dataMin" on the Y axis — participation rates range from roughly 60–76%, so starting at zero wastes most of the chart height.

 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
# app/views/components/charts/participation_trends.rb
module Components
  module Charts
    class ParticipationTrends < Components::Chart
      prop :data, _Any, default: -> { {} }

      private

      def chart_options
        ::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 },
          y_axis:  {
            type:         "value",
            min:          "dataMin",
            name:         "Participation Rate (%)",
            nameLocation: "middle",
            nameGap:      40,
            axisLabel:    { formatter: "rate" }
          },
          grid:    { bottom: 60, left: 16, right: 8, containLabel: true },
          series:  build_series
        )
      end

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

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

markLine — for adding reference lines such as a national average — is covered in Module 09 with a dedicated example.


4.5 — Colour Correspondence

All three charts use color: "tableau". The service returns states in alphabetical order — Stats::LabourForce uses group_by which preserves insertion order, and LabourForceReading.ordered orders by state name. ECharts assigns colours from the palette in series order.

The result: ACT is always the first tableau colour, NSW always the second — across the scatter chart, the line chart, and the participation chart. A reader identifies Queensland by colour on Chart 1 and finds Queensland immediately on Charts 2 and 3 without reading the legend again.

The rule: same palette + same series order = same colour mapping. The service guarantees the order. You guarantee the palette by using the same color: value on all charts on the page.


4.6 — Testing

Chart option generation is pure Ruby — no browser, no JavaScript, no Rails. Pass plain model objects built with .new, call chart_options, assert on the 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
35
36
37
38
39
40
41
42
43
# test/components/participation_scatter_test.rb
require "test_helper"

class ParticipationScatterTest < ActiveSupport::TestCase
  def sample_data
    {
      "New South Wales" => [
        { year: 2022, employed: 4100.0, rate: 3.8,
          participation: 63.2, unemployed: 158.0 }
      ],
      "Victoria" => [
        { year: 2022, employed: 3400.0, rate: 4.1,
          participation: 65.1, unemployed: 140.0 }
      ]
    }
  end

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

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

  test "each data point is [participation, rate, year]" do
    component = Components::Charts::ParticipationScatter.new(data: sample_data)
    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
    assert_equal 2022, point[2]
  end

  test "includes visualMap on dimension 2" do
    component = Components::Charts::ParticipationScatter.new(data: sample_data)
    options   = component.send(:chart_options).to_h

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

The component now receives data: directly — no service call inside the component — so the test passes shaped data straight in. No database, no fixtures, no ActiveRecord.


4.7 — The Plumbing

1
2
3
4
5
# app/controllers/charts_controller.rb
def labour_market_analysis
  readings = LabourForceReading.ordered
  @data    = Stats::LabourForce.call(readings)
end
1
2
3
4
# config/routes.rb
get "charts/labour_market_analysis",
    to:  "charts#labour_market_analysis",
    as:  :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 cat. 6202.0), CC BY 4.0.
  </p>

  <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 cluster
    toward the bottom-right — high participation, low unemployment. The ACT
    consistently occupies this position. 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.
  </p>
  <%= render Components::Charts::ParticipationScatter.new(
    data:   @data,
    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>

  <h2 class="text-xl font-semibold mb-2">Employment Volume</h2>
  <p class="text-neutral-600 mb-4">
    New South Wales and Victoria together employ more than the other six states
    combined. The COVID dip in 2020 is followed by a sharp recovery across all
    states. Western Australia shows the most volatility — the resources sector
    is sensitive to commodity cycles.
  </p>
  <%= render Components::Charts::EmploymentTrends.new(
    data:   @data,
    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>

  <h2 class="text-xl font-semibold mb-2">Participation Rate Trends</h2>
  <p class="text-neutral-600 mb-4">
    The ACT's participation rate is structurally higher due to its demographic
    profile. The national average has been broadly flat, masking diverging trends:
    Queensland and Western Australia have lifted, while South Australia and
    Tasmania have lagged.
  </p>
  <%= render Components::Charts::ParticipationTrends.new(
    data:   @data,
    height: "380px"
  ) %>
  <p class="text-neutral-500 text-xs mt-2 mb-10">
    Annual average participation rate (%).
  </p>

  <div class="border-t border-neutral-200 pt-6 mt-4">
    <p class="text-neutral-400 text-xs">
      Data: ABS Labour Force, Australia (cat. 6202.0).
      Accessed via ABS Data API. CC BY 4.0.
    </p>
  </div>

</div>

Gallery card:

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

4.8 — 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 trends
app/views/charts/labour_market_analysis.html.erb Data story page

No new service — Stats::LabourForce from Module 03 feeds all three components.

Patterns introduced:

  • Chart::Series::Scatter[x, y] or [x, y, z] data points
  • visualMap — third dimension encoded as colour lightness
  • min: "dataMin" — fit axis scale to data, avoid bunching
  • Custom tooltip formatter for scatter — "item:participationScatter"
  • Data story pattern — prose → chart → caption → repeat
  • Colour correspondence — same palette + same series order across all page charts
  • Testing with shaped data — no database, no service call in the component

Next: Module 05 — Pie, Donut and Rose: Industry Composition