Skip to content

Module 04 — Bar and Stacked Bar: Labour Force by State

What We’re Building

The Labour Force dataset gives us monthly employment figures across 8 Australian states from 2012–2024 — 1,248 records covering employed persons, unemployed persons, participation rate, and unemployment rate. It is richer than the GDP data and has a monthly cadence that requires bucketing before charting.

By the end of this module you will have:

  • A service that buckets monthly data into annual averages
  • A grouped bar chart showing employment by state with ECharts’ native toolbox
  • A horizontal bar chart showing the latest year’s employment snapshot
  • An unemployment rate line chart demonstrating that one service can feed multiple chart types

4.1 — The Data Problem: Monthly Granularity

The labour_force_readings table has 1,248 records — one per state per month. Charting all 156 months per state produces an illegible chart. We need to bucket monthly data into annual averages before passing it to ECharts.

The naive approach

1
2
3
readings
  .group_by { |r| [r.state, r.year] }
  .transform_values { |rows| (rows.sum(&:employed_thousands) / rows.size.to_f).round(1) }

This works but has edge cases — partial years at the start or end of the date range produce lower averages because fewer months contribute to the sum. Writing robust time-series bucketing by hand is tedious.

Introducing groupdate

groupdate solves time-series bucketing cleanly. Add it to the Gemfile:

1
gem "groupdate"
1
bundle install

groupdate adds scopes to ActiveRecord — group_by_year, group_by_month, group_by_quarter — and handles partial periods, timezones, and sparse data correctly.

Our labour_force_readings table uses integer year and month columns rather than a single date column, so we cannot use group_by_year directly on this model. We bucket by the year integer instead. The daily_activity_readings table in Module 07 uses a proper date column — that is where groupdate earns its place fully.


4.2 — The Service

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

    # Returns annual averages per state:
    # {
    #   "New South Wales" => [
    #     { year: 2012, employed: 3562.4, unemployed: 198.1, participation: 62.3, rate: 5.3 },
    #     ...
    #   ],
    #   ...
    # }
    def call(readings)
      readings
        .group_by(&:state)
        .transform_values do |state_rows|
          state_rows
            .group_by(&:year)
            .map do |year, rows|
              {
                year:          year,
                employed:      avg(rows, :employed_thousands),
                unemployed:    avg(rows, :unemployed_thousands),
                participation: avg(rows, :participation_rate),
                rate:          avg(rows, :unemployment_rate)
              }
            end
            .sort_by { |r| r[:year] }
        end
    end

    private

    def avg(rows, attr)
      values = rows.map { |r| r.send(attr).to_f }.reject(&:zero?)
      return 0.0 if values.empty?
      (values.sum / values.size).round(1)
    end
  end
end

The service groups by state, then by year, computing averages across all months in each year. Zeros are excluded from averages — a missing month should not drag down the annual figure.


4.3 — Controller and Routes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# app/controllers/charts_controller.rb
def labour_force
  @readings = LabourForceReading.ordered
end

def labour_force_horizontal
  @readings = LabourForceReading.ordered
end

def unemployment_rate
  @readings = LabourForceReading.ordered
end

Add to config/routes.rb:

1
2
3
get "charts/labour_force",            to: "charts#labour_force"
get "charts/labour_force_horizontal", to: "charts#labour_force_horizontal"
get "charts/unemployment_rate",       to: "charts#unemployment_rate"

Add a scope to the model if not already present:

1
2
# app/models/labour_force_reading.rb
scope :ordered, -> { order(:state, :year, :month) }

4.4 — Grouped Bar Chart

The main chart shows annual average employment by state. The ECharts toolbox provides stack/unstack, line/bar toggle, and save as image — no custom JavaScript needed.

 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/labour_force.rb
module Components
  module Charts
    class LabourForce < Components::Chart
      prop :readings, _Any, default: -> { [] }

      private

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

        ::Chart::Options.new(
          color:   "cool",
          toolbox: {
            feature: {
              magicType:   { type: ["bar", "line", "stack"] },
              saveAsImage: {},
              restore:     {}
            }
          },
          tooltip: { trigger: "axis", formatter: "thousands" },
          legend:  { type: "scroll", bottom: 5 },
          x_axis:  { type: "category", data: years(data) },
          y_axis:  { type: "value", axisLabel: { formatter: "thousands" } },
          grid:    { bottom: 60, left: 8, right: 8, containLabel: true },
          series:  build_series(data)
        )
      end

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

      def years(data)
        data.values.first&.map { |r| r[:year].to_s } || []
      end
    end
  end
end
<%# app/views/charts/labour_force.html.erb %>
<h1 class="text-2xl font-bold mb-6">Labour Force by State</h1>
<p class="text-neutral-500 mb-6 text-sm">
  Annual average employed persons ('000), seasonally adjusted. Source: ABS Labour Force.
</p>
<%= render Components::Charts::LabourForce.new(readings: @readings) %>

The toolbox sits inside the chart canvas — no custom buttons needed. Clicking the stack icon stacks the bars; clicking again unstacks them. The line icon switches between bar and line chart. Save as image downloads a PNG.


4.5 — The Toolbox

The ECharts toolbox is configured via the toolbox: option in Chart::Options. All features use ECharts’ native icons and interactions:

1
2
3
4
5
6
7
8
toolbox: {
  feature: {
    magicType:   { type: ["bar", "line", "stack"] },
    saveAsImage: {},
    restore:     {},
    dataView:    { readOnly: true }
  }
}
Feature What it does
magicType: ["bar", "line"] Toggles between bar and line chart
magicType: ["stack"] Adds a stack/unstack toggle
saveAsImage Downloads the chart as PNG
restore Resets to the original chart state
dataView Shows raw data in a table (read-only)

Include only the features relevant to each chart — a pie chart has no use for magicType: ["bar", "line"].


4.6 — Horizontal Bar Chart

Horizontal bars suit state comparisons where the category labels are more readable on the Y axis. In ECharts, horizontal orientation is achieved by swapping the axis types — no other changes needed.

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

      private

      def chart_options
        data   = ::Stats::LabourForce.call(@readings)
        latest = data.transform_values(&:last)

        ::Chart::Options.new(
          color:   "cool",
          toolbox: { feature: { saveAsImage: {}, restore: {} } },
          tooltip: { trigger: "axis", formatter: "thousands" },
          legend:  { show: false },
          x_axis:  { type: "value", axisLabel: { formatter: "thousands" } },
          y_axis:  { type: "category", data: latest.keys },
          grid:    { left: 8, right: 16, containLabel: true },
          series: [
            ::Chart::Series::Bar.new(
              name: "Employed (#{latest.values.first&.dig(:year)})",
              data: latest.values.map { |r| r[:employed] }
            )
          ]
        )
      end
    end
  end
end
<%# app/views/charts/labour_force_horizontal.html.erb %>
<h1 class="text-2xl font-bold mb-6">Employment by State — Latest Year</h1>
<p class="text-neutral-500 mb-6 text-sm">
  Annual average employed persons ('000), seasonally adjusted. Source: ABS Labour Force.
</p>
<%= render Components::Charts::LabourForceHorizontal.new(readings: @readings) %>

4.7 — Unemployment Rate Chart

The same service feeds a completely different chart type. The unemployment rate series demonstrates that services are chart-agnostic — they transform data, nothing more.

 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/unemployment_rate.rb
module Components
  module Charts
    class UnemploymentRate < Components::Chart
      prop :readings, _Any, default: -> { [] }

      private

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

        ::Chart::Options.new(
          color:   "warm",
          toolbox: {
            feature: {
              magicType:   { type: ["line", "bar"] },
              saveAsImage: {},
              restore:     {}
            }
          },
          tooltip: { trigger: "axis", formatter: "rate" },
          legend:  { type: "scroll", bottom: 5 },
          x_axis:  { type: "category", data: years(data) },
          y_axis:  { type: "value", axisLabel: { formatter: "rate" } },
          grid:    { bottom: 60, left: 8, 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[:rate] },
            smooth: true
          )
        end
      end

      def years(data)
        data.values.first&.map { |r| r[:year].to_s } || []
      end
    end
  end
end
<%# app/views/charts/unemployment_rate.html.erb %>
<h1 class="text-2xl font-bold mb-6">Unemployment Rate by State</h1>
<p class="text-neutral-500 mb-6 text-sm">
  Annual average unemployment rate (%), seasonally adjusted. Source: ABS Labour Force.
</p>
<%= render Components::Charts::UnemploymentRate.new(readings: @readings) %>

The COVID-19 spike in 2020 is visible across all states. Tasmania and South Australia consistently track above the national average. The ACT and Northern Territory show the most volatility — small populations amplify statistical noise.


4.8 — Gallery Index

Add to app/views/charts/index.html.erb:

<%= render "charts/gallery_card",
  title:       "Labour Force by State",
  description: "Annual average employment by state, 2012–2024. Toggle stacked/grouped or switch to line chart.",
  path:        charts_labour_force_path %>

<%= render "charts/gallery_card",
  title:       "Employment Snapshot",
  description: "Latest year employment by state — horizontal bar chart.",
  path:        charts_labour_force_horizontal_path %>

<%= render "charts/gallery_card",
  title:       "Unemployment Rate by State",
  description: "Annual average unemployment rate by state, 2012–2024.",
  path:        charts_unemployment_rate_path %>

4.9 — What groupdate Does For Us

Our Labour Force data uses integer year and month columns so we bucket manually. The daily_activity_readings table in Module 07 uses a proper date column — that is where groupdate earns its place:

1
2
3
4
5
# Without groupdate — verbose
readings.group_by { |r| r.date.year }.transform_values { ... }

# With groupdate — clean and timezone-aware
DailyActivityReading.group_by_year(:date).average(:value)

groupdate handles sparse data (missing dates), configurable week start, timezone awareness, and range boundaries. Module 07 covers this properly.


4.10 — Module Summary

New files:

File Purpose
app/services/stats/labour_force.rb Annual averages per state from monthly data
app/views/components/charts/labour_force.rb Grouped bar chart with toolbox
app/views/components/charts/labour_force_horizontal.rb Horizontal bar — latest year snapshot
app/views/components/charts/unemployment_rate.rb Unemployment rate line chart

Patterns introduced:

  • Annual bucketing of monthly data
  • stack: on Chart::Series::Bar for stacked bars
  • Horizontal bars via axis type swap (x_axis value, y_axis category)
  • ECharts native toolbox — magicType, saveAsImage, restore
  • Same service feeding multiple chart types

The toolbox replaces custom controls:

The ECharts toolbox handles stack/unstack, chart type switching, and PNG download natively — no custom Stimulus controllers, no extra JavaScript. Add it to any chart via the toolbox: option in Chart::Options.


Next: Module 05 — Scatter: Employment vs Participation Rate