Skip to content

Module 05 — Pie, Donut, and Rose Charts: Industry Sales Composition

What We’re Building

Pie and donut charts show composition — how a whole divides into parts. They work best with a small number of meaningful slices (ideally 5–10) where the proportional differences matter. With too many slices they become unreadable; with too few they are trivial.

This module uses quarterly business sales by industry from the ABS Business Indicators dataset. The composition of Australian business sales by industry is genuinely surprising — and the differences between industries are large enough to make the proportions visually clear.

By the end of this module you will have:

  • A basic pie chart with a rich custom tooltip
  • A donut chart with a centre label showing the total
  • A rose chart showing variation in a different dimension
  • A half-donut dashboard gauge variant
  • A data story page showcasing all four variants with explanatory prose
  • Custom tooltip formatters for item-trigger charts

5.1 — The Data

The business_indicator_readings table has quarterly sales figures by industry from 2005–2024. For composition charts we want a single point in time — the most recent complete quarter. The service selects the latest quarter and returns industries with their sales values sorted descending.

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

    # Returns industries sorted by sales descending for the latest quarter.
    # [
    #   { industry: "Mining", sales: 142.3, share: 18.4 },
    #   ...
    # ]
    def call(readings)
      latest = readings.maximum(:year) * 10 + readings.maximum(:quarter)

      latest_readings = readings.select { |r| r.year * 10 + r.quarter == latest}
                        .reject { |r| r.industry == "Total" || r.sales_billions.to_f.zero? }
                        .sort_by { |r| -r.sales_billions.to_f }

      total = latest_readings.sum { |r| r.sales_billions.to_f }

      latest_readings.map do |r|
        {
          industry: r.industry,
          sales:    r.sales_billions.to_f.round(1),
          share:    ((r.sales_billions.to_f / total) * 100).round(1)
        }
      end
    end
  end
end

The share field — percentage of total — is computed in the service. ECharts calculates its own percent value for tooltips, but having it in the data means we can use it in custom formatters and test it independently.


5.2 — Custom Tooltip Formatter

Pie and donut charts use trigger: "item" — the tooltip fires on a single slice, not on a cross-axis position. The params object contains the slice name, value, and ECharts’ calculated percentage.

Add a rich formatter 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
23
24
25
26
27
28
29
30
31
32
33
// app/javascript/charts/custom_chart_formatters.js
export default {
  // ... existing formatters

  industrySlice: params => {
    const rank = params.dataIndex + 1
    return `
      <div style="min-width:200px">
        <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">Sales</td>
            <td style="text-align:right;font-weight:600">
              $${params.value.toLocaleString()}B
            </td>
          </tr>
          <tr>
            <td style="color:#999;padding-right:12px">Share</td>
            <td style="text-align:right;font-weight:600">
              ${params.percent.toFixed(1)}%
            </td>
          </tr>
          <tr>
            <td style="color:#999;padding-right:12px">Rank</td>
            <td style="text-align:right;font-weight:600">#${rank}</td>
          </tr>
        </table>
      </div>
    `
  }
}

Reference from Ruby:

1
tooltip: { trigger: "item", formatter: "industrySlice" }

Because the trigger is "item", the resolver qualifies this as "item:industrySlice". Add the qualified key to custom_chart_formatters.js:

1
"item:industrySlice": params => { ... }

5.3 — The Service Call Helper

All four components call the same service. To avoid repeating the call in each component, add a private helper to Components::Chart — or simply call it in each component. The latter is simpler and keeps components independent:

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

The service result is an array of hashes — not grouped by a key — so it maps directly to ECharts data format:

1
series_data = data.map { |r| { name: r[:industry], value: r[:sales] } }

5.4 — Chart 1: Basic Pie Chart

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

      private

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

        ::Chart::Options.new(
          color:   "vivid",
          toolbox: { feature: { saveAsImage: {} } },
          tooltip: { trigger: "item", formatter: "industrySlice" },
          legend:  { type: "scroll", bottom: 5 },
          series: [
            ::Chart::Series::Pie.new(
              name:   "Industry Sales",
              data:   data.map { |r| { name: r[:industry], value: r[:sales] } },
              radius: "65%",
              center: ["50%", "45%"],
              label:  { formatter: "{b}\n{d}%" }
            )
          ]
        )
      end
    end
  end
end

label: { formatter: "{b}\n{d}%" } uses ECharts string template syntax:

  • {b} — the slice name
  • {d} — the percentage (calculated by ECharts)
  • {c} — the value
  • {a} — the series name

These are ECharts’ own template variables and pass through the resolver unchanged.


5.5 — Chart 2: Donut with Centre Label

A donut chart is a pie chart with an inner radius. The hole creates space for a centre label showing the total — a common dashboard pattern.

ECharts does not have a native “centre label” feature. The label is rendered using the graphic option — ECharts’ drawing API for arbitrary SVG elements overlaid on the chart.

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

      private

      def chart_options
        data  = ::Stats::BusinessComposition.call(@readings)
        total = data.sum { |r| r[:sales] }.round(1)

        ::Chart::Options.new(
          color:   "vivid",
          toolbox: { feature: { saveAsImage: {} } },
          tooltip: { trigger: "item", formatter: "industrySlice" },
          legend:  { type: "scroll", bottom: 5 },
          graphic: [
            {
              type:  "text",
              left:  "center",
              top:   "middle",
              style: {
                text:     "$#{total}B\nTotal Sales",
                textAlign: "center",
                fontSize:  14,
                fontWeight: "bold",
                lineHeight: 20
              }
            }
          ],
          series: [
            ::Chart::Series::Pie.new(
              name:   "Industry Sales",
              data:   data.map { |r| { name: r[:industry], value: r[:sales] } },
              radius: ["40%", "68%"],
              center: ["50%", "45%"],
              label:  { show: false },
              emphasis: {
                label: { show: true, fontSize: 13, fontWeight: "bold" }
              }
            )
          ]
        )
      end
    end
  end
end

radius: ["40%", "68%"] sets inner radius to 40% and outer to 68% — the gap creates the donut hole. label: { show: false } hides slice labels on the donut itself; emphasis.label shows a label only when a slice is hovered.

The graphic option positions arbitrary text over the chart. left: "center" and top: "middle" centre it in the chart container — which aligns with the donut hole because center: ["50%", "45%"] positions the donut centrally.


5.6 — Chart 3: Rose Chart

A rose chart (also called a nightingale chart) uses varying radius rather than varying angle to encode value — each slice spans an equal angle but its radius varies with its value. This makes small differences between slices more visible than a standard pie chart.

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

      private

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

        ::Chart::Options.new(
          color:   "vivid",
          toolbox: { feature: { saveAsImage: {} } },
          tooltip: { trigger: "item", formatter: "industrySlice" },
          legend:  { type: "scroll", bottom: 5 },
          series: [
            ::Chart::Series::Pie.new(
              name:     "Industry Sales",
              data:     data.map { |r| { name: r[:industry], value: r[:sales] } },
              radius:   ["15%", "70%"],
              center:   ["50%", "45%"],
              roseType: "radius",
              label:    { formatter: "{b}" },
              itemStyle: { borderRadius: 6 }
            )
          ]
        )
      end
    end
  end
end

roseType: "radius" switches from standard pie to rose mode. itemStyle: { borderRadius: 6 } rounds the corners of each slice — a subtle refinement that improves readability when slices are narrow.

radius: ["15%", "70%"] gives the rose chart an inner radius — combining donut and rose modes — which prevents the smallest slices from collapsing to a point at the centre.


5.7 — Chart 4: Half Donut

A half donut uses startAngle and endAngle to render only the top half of the chart — a compact format suited to dashboards where vertical space is limited.

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

      private

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

        # Top 6 industries only — half donut gets crowded quickly
        top6 = data.first(6)
        other_sales = data[6..].sum { |r| r[:sales] }.round(1)
        top6 << { industry: "Other", sales: other_sales,
                  share: (other_sales / data.sum { |r| r[:sales] } * 100).round(1) }

        ::Chart::Options.new(
          color:   "vivid",
          toolbox: { feature: { saveAsImage: {} } },
          tooltip: { trigger: "item", formatter: "industrySlice" },
          legend:  { type: "scroll", bottom: 0 },
          series: [
            ::Chart::Series::Pie.new(
              name:       "Industry Sales",
              data:       top6.map { |r| { name: r[:industry], value: r[:sales] } },
              radius:     ["45%", "72%"],
              center:     ["50%", "72%"],
              startAngle: 180,
              endAngle:   360,
              label:      { position: "outside", formatter: "{b}\n{d}%" }
            )
          ]
        )
      end
    end
  end
end

center: ["50%", "72%"] shifts the centre point down so the flat edge of the half-donut sits near the bottom of the chart area, using the space efficiently. startAngle: 180 and endAngle: 360 render only the top semicircle.

Grouping smaller industries into “Other” is important — a half donut with 15 slices is unreadable. Six meaningful slices plus “Other” is the practical limit.


5.8 — Controller and View

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

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

  <h1 class="text-3xl font-bold mb-2">Australian Business Sales by Industry</h1>
  <p class="text-neutral-500 text-sm mb-8">
    Latest quarter, seasonally adjusted.
    Source: <a href="https://www.abs.gov.au" class="underline">Australian Bureau of Statistics</a>,
    Business Indicators (ABS cat. 5676.0), CC BY 4.0.
  </p>

  <%# ── Pie + Donut side by side ───────────────────────────────── %>
  <div class="grid grid-cols-2 gap-6 mb-8">
    <div>
      <h2 class="text-lg font-semibold mb-1">Pie Chart</h2>
      <p class="text-neutral-500 text-sm mb-3">
        Area encodes share of total. Hover a slice for details.
      </p>
      <%= render Components::Charts::IndustryPie.new(
        readings: @readings,
        height:   "360px"
      ) %>
    </div>
    <div>
      <h2 class="text-lg font-semibold mb-1">Donut Chart</h2>
      <p class="text-neutral-500 text-sm mb-3">
        The hollow centre carries a summary value — a common dashboard pattern.
      </p>
      <%= render Components::Charts::IndustryDonut.new(
        readings: @readings,
        height:   "360px"
      ) %>
    </div>
  </div>

  <%# ── Rose + Half Donut side by side ─────────────────────────── %>
  <div class="grid grid-cols-2 gap-6 mb-8">
    <div>
      <h2 class="text-lg font-semibold mb-1">Rose Chart</h2>
      <p class="text-neutral-500 text-sm mb-3">
        Radius encodes value rather than angle — small differences between
        industries are more visible than in a standard pie chart.
      </p>
      <%= render Components::Charts::IndustryRose.new(
        readings: @readings,
        height:   "360px"
      ) %>
    </div>
    <div>
      <h2 class="text-lg font-semibold mb-1">Half Donut</h2>
      <p class="text-neutral-500 text-sm mb-3">
        A compact format for dashboards. Smaller industries are grouped into
        "Other" to keep the chart readable.
      </p>
      <%= render Components::Charts::IndustryHalfDonut.new(
        readings: @readings,
        height:   "360px"
      ) %>
    </div>
  </div>

  <%# ── Guidance ────────────────────────────────────────────────── %>
  <div class="bg-neutral-50 rounded-lg p-6 mb-8">
    <h2 class="text-lg font-semibold mb-3">Choosing Between Variants</h2>
    <div class="grid grid-cols-2 gap-6 text-sm text-neutral-600">
      <div>
        <p class="font-medium text-neutral-800 mb-1">Use a pie chart when:</p>
        <ul class="list-disc list-inside space-y-1">
          <li>You have 5–8 categories</li>
          <li>Proportions are the primary message</li>
          <li>One slice is clearly dominant</li>
        </ul>
      </div>
      <div>
        <p class="font-medium text-neutral-800 mb-1">Use a donut chart when:</p>
        <ul class="list-disc list-inside space-y-1">
          <li>You want a summary value in the centre</li>
          <li>The chart sits in a dashboard layout</li>
          <li>You want to reduce visual weight</li>
        </ul>
      </div>
      <div>
        <p class="font-medium text-neutral-800 mb-1">Use a rose chart when:</p>
        <ul class="list-disc list-inside space-y-1">
          <li>Values are similar in magnitude</li>
          <li>You want to emphasise relative differences</li>
          <li>Visual impact matters more than precision</li>
        </ul>
      </div>
      <div>
        <p class="font-medium text-neutral-800 mb-1">Use a half donut when:</p>
        <ul class="list-disc list-inside space-y-1">
          <li>Vertical space is constrained</li>
          <li>You are building a dashboard widget</li>
          <li>You have 6 or fewer categories</li>
        </ul>
      </div>
    </div>
  </div>

  <div class="border-t border-neutral-200 pt-6">
    <p class="text-neutral-400 text-xs">
      Data: Australian Bureau of Statistics, Business Indicators, Australia
      (ABS cat. 5676.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>

5.9 — Chart::Series::Pie Updates

The IndustryRose and IndustryHalfDonut components pass roseType:, startAngle:, endAngle:, and itemStyle: options. These are not modelled in Chart::Series::Pie — they pass through via **extra in Chart::Series::Base.

No DSL changes needed. The escape hatch handles them:

1
2
3
4
5
6
::Chart::Series::Pie.new(
  roseType:   "radius",   # passed through via **extra
  startAngle: 180,        # passed through via **extra
  endAngle:   360,        # passed through via **extra
  itemStyle:  { borderRadius: 6 }
)

This is the intended use of **extra — ECharts has hundreds of series options, and the DSL models only the most common ones.


5.10 — Gallery

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

<%= render "charts/gallery_card",
  title:       "Industry Composition",
  description: "Four pie/donut variants — pie, donut, rose, and half donut — "\
               "showing Australian business sales by industry.",
  path:        charts_industry_composition_path %>

5.11 — Module Summary

New files:

File Purpose
app/services/stats/business_composition.rb Latest quarter sales by industry with shares
app/views/components/charts/industry_pie.rb Standard pie chart
app/views/components/charts/industry_donut.rb Donut with centre label
app/views/components/charts/industry_rose.rb Rose/nightingale chart
app/views/components/charts/industry_half_donut.rb Half donut dashboard variant
app/views/charts/industry_composition.html.erb Four-chart showcase page

Patterns introduced:

  • trigger: "item" tooltip for single-slice charts
  • Custom rich tooltip formatter for pie/donut slices
  • radius: ["inner%", "outer%"] for donut charts
  • graphic option for centre label text overlay
  • roseType: "radius" for nightingale/rose charts
  • startAngle / endAngle for half donut
  • emphasis.label for hover-activated slice labels
  • ECharts label template variables — {b}, {c}, {d}, {a}
  • Grouping small slices into “Other” for readability
  • Side-by-side chart layout with Tailwind grid

Label template variables:

Variable Value
{a} Series name
{b} Data item name (slice label)
{c} Data value
{d} Percentage (calculated by ECharts)