Skip to content

Appendix D — Chart Template Library

Copy a template, rename it, fill in the data-specific parts. Each template is a complete, working component with sensible defaults. Comments mark what to customise.

All templates inherit height:, group:, and color: from Components::Chart automatically — see Appendix C for details.


D.1 — Line Chart

When to use: Time series, trends, continuous data, multiple series comparison.

 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
# app/views/components/charts/templates/line_chart.rb
module Components
  module Charts
    module Templates
      class LineChart < Components::Chart
        # ── Customise: props ──────────────────────────────────────────
        prop :data, _Any, default: -> { [] }
        # data shape: { series_name => [{ label: "2020 Q1", value: 42.3 }, ...] }

        private

        def chart_options
          ::Chart::Options.new(
            # ── Customise: palette ────────────────────────────────────
            color:   "cool",

            # ── Customise: tooltip formatter ──────────────────────────
            tooltip: { trigger: "axis", formatter: "thousands" },

            legend:  { type: "scroll", bottom: 5 },

            # ── Customise: x axis ─────────────────────────────────────
            x_axis:  {
              type: "category",
              data: x_labels
            },

            # ── Customise: y axis ─────────────────────────────────────
            y_axis:  {
              type:      "value",
              scale:     false,           # true = don't start at zero
              axisLabel: { formatter: "thousands" }
            },

            grid:    { left: 8, right: 8, bottom: 40, containLabel: true },
            series:  build_series
          )
        end

        def build_series
          @data.map do |name, rows|
            ::Chart::Series::Line.new(
              name:   name,
              data:   rows.map { |r| r[:value] },
              # ── Customise: series options ──────────────────────────
              smooth: true,
              symbol: "none"
            )
          end
        end

        def x_labels
          @data.values.first&.map { |r| r[:label] } || []
        end
      end
    end
  end
end

D.2 — Stacked Bar Chart

When to use: Part-to-whole over time, cumulative comparison across categories.

 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/templates/stacked_bar_chart.rb
module Components
  module Charts
    module Templates
      class StackedBarChart < Components::Chart
        # ── Customise: props ──────────────────────────────────────────
        prop :data, _Any, default: -> { [] }
        # data shape: { series_name => [{ label: "NSW", value: 3421.3 }, ...] }

        private

        def chart_options
          ::Chart::Options.new(
            color:   "tableau",
            tooltip: { trigger: "axis", formatter: "thousands" },
            legend:  { type: "scroll", bottom: 5 },
            x_axis:  { type: "category", data: x_labels },
            y_axis:  {
              type:      "value",
              axisLabel: { formatter: "thousands" }
            },
            grid:    { left: 8, right: 8, bottom: 40, containLabel: true },
            series:  build_series
          )
        end

        def build_series
          @data.map do |name, rows|
            ::Chart::Series::Bar.new(
              name:  name,
              data:  rows.map { |r| r[:value] },
              # ── stack: groups bars — all with same name stack together
              stack: "total"
            )
          end
        end

        def x_labels
          @data.values.first&.map { |r| r[:label] } || []
        end
      end
    end
  end
end

D.3 — Grouped Bar Chart

When to use: Side-by-side comparison across categories.

 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
# app/views/components/charts/templates/grouped_bar_chart.rb
module Components
  module Charts
    module Templates
      class GroupedBarChart < Components::Chart
        prop :data, _Any, default: -> { [] }
        # data shape: { series_name => [{ label: "2020", value: 42.3 }, ...] }

        private

        def chart_options
          ::Chart::Options.new(
            color:   "cool",
            tooltip: { trigger: "axis", formatter: "thousands" },
            legend:  { type: "scroll", bottom: 5 },
            x_axis:  { type: "category", data: x_labels },
            y_axis:  {
              type:      "value",
              axisLabel: { formatter: "thousands" }
            },
            grid:    { left: 8, right: 8, bottom: 40, containLabel: true },
            series:  build_series
          )
        end

        def build_series
          @data.map do |name, rows|
            ::Chart::Series::Bar.new(
              name: name,
              data: rows.map { |r| r[:value] }
              # No stack: key — bars render side by side
            )
          end
        end

        def x_labels
          @data.values.first&.map { |r| r[:label] } || []
        end
      end
    end
  end
end

D.4 — Horizontal Bar Chart

When to use: Category labels too long for vertical bars, ranking comparisons.

 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
# app/views/components/charts/templates/horizontal_bar_chart.rb
module Components
  module Charts
    module Templates
      class HorizontalBarChart < Components::Chart
        prop :data, _Any, default: -> { [] }
        # data shape: [{ label: "Mining", value: 142.3 }, ...]

        private

        def chart_options
          ::Chart::Options.new(
            color:   "earth",
            tooltip: { trigger: "axis", formatter: "thousands" },
            legend:  { show: false },
            # ── Swap axis types for horizontal orientation ─────────────
            x_axis:  {
              type:      "value",
              axisLabel: { formatter: "thousands" }
            },
            y_axis:  {
              type: "category",
              data: @data.map { |r| r[:label] }
            },
            grid:    { left: 8, right: 16, top: 8, bottom: 8, containLabel: true },
            series: [
              ::Chart::Series::Bar.new(
                data: @data.map { |r| r[:value] }
              )
            ]
          )
        end
      end
    end
  end
end

D.5 — Pie Chart

When to use: Part-to-whole composition, 5–8 categories.

 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/templates/pie_chart.rb
module Components
  module Charts
    module Templates
      class PieChart < Components::Chart
        prop :data, _Any, default: -> { [] }
        # data shape: [{ label: "Mining", value: 142.3 }, ...]

        private

        def chart_options
          ::Chart::Options.new(
            color:   "vivid",
            toolbox: { feature: { saveAsImage: {} } },
            tooltip: { trigger: "item", formatter: "industrySlice" },
            legend:  { type: "scroll", bottom: 5 },
            series: [
              ::Chart::Series::Pie.new(
                name:   "Value",
                data:   @data.map { |r| { name: r[:label], value: r[:value] } },
                # ── Customise: radius and position ────────────────────
                radius: "65%",
                center: ["50%", "45%"],
                label:  { formatter: "{b}\n{d}%" }
              )
            ]
          )
        end
      end
    end
  end
end

D.6 — Donut Chart

When to use: Composition with a summary value in the centre.

 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
# app/views/components/charts/templates/donut_chart.rb
module Components
  module Charts
    module Templates
      class DonutChart < Components::Chart
        prop :data, _Any, default: -> { [] }
        # data shape: [{ label: "Mining", value: 142.3 }, ...]

        private

        def chart_options
          total = @data.sum { |r| r[:value].to_f }.round(1)

          ::Chart::Options.new(
            color:   "vivid",
            toolbox: { feature: { saveAsImage: {} } },
            tooltip: { trigger: "item", formatter: "industrySlice" },
            legend:  { type: "scroll", bottom: 5 },
            # ── Centre label via graphic overlay ──────────────────────
            graphic: [
              {
                type:  "text",
                left:  "center",
                top:   "middle",
                style: {
                  text:       "#{total}\nTotal",
                  textAlign:  "center",
                  fontSize:   14,
                  fontWeight: "bold",
                  lineHeight: 20
                }
              }
            ],
            series: [
              ::Chart::Series::Pie.new(
                name:   "Value",
                data:   @data.map { |r| { name: r[:label], value: r[:value] } },
                # ── [inner_radius, outer_radius] creates the donut hole ─
                radius: ["40%", "68%"],
                center: ["50%", "45%"],
                label:  { show: false },
                emphasis: {
                  label: { show: true, fontSize: 13, fontWeight: "bold" }
                }
              )
            ]
          )
        end
      end
    end
  end
end

D.7 — Scatter Chart

When to use: Correlation between two variables, distribution analysis.

 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
# app/views/components/charts/templates/scatter_chart.rb
module Components
  module Charts
    module Templates
      class ScatterChart < Components::Chart
        prop :data, _Any, default: -> { [] }
        # data shape: { series_name => [[x, y], ...] }
        # or with third dimension: { series_name => [[x, y, z], ...] }

        private

        def chart_options
          ::Chart::Options.new(
            color:   "tableau",
            toolbox: { feature: { saveAsImage: {}, restore: {} } },
            # ── Customise: tooltip formatter ──────────────────────────
            tooltip: { trigger: "item", formatter: "myScatterTooltip" },
            legend:  { type: "scroll", bottom: 5 },
            # ── Use dataMin to avoid bunching in corner ────────────────
            x_axis:  {
              type:      "value",
              min:       "dataMin",
              name:      "X Axis Label",
              nameLocation: "middle",
              nameGap:   30,
              axisLabel: { formatter: "rate" }
            },
            y_axis:  {
              type:      "value",
              min:       "dataMin",
              name:      "Y Axis Label",
              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 |name, points|
            ::Chart::Series::Scatter.new(
              name: name,
              data: points
            )
          end
        end
      end
    end
  end
end

D.8 — Calendar Heatmap

When to use: Daily data over time, activity patterns, seasonal analysis.

 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
74
75
76
# app/views/components/charts/templates/calendar_chart.rb
module Components
  module Charts
    module Templates
      class CalendarChart < Components::Chart
        prop :data,       _Any, default: -> { [] }
        prop :year_range, _Any, default: -> { [2023, 2023] }
        # data shape: [["2022-01-01", 94.15], ...]

        private

        def chart_options
          years    = (@year_range.first..@year_range.last).to_a
          by_year  = @data.group_by { |date, _| date[0..3].to_i }
          all_vals = @data.map(&:last)
          min      = all_vals.min&.floor || 0
          max      = all_vals.max&.ceil  || 100

          ::Chart::Options.new(
            toolbox:   { feature: { saveAsImage: {} } },
            tooltip:   { trigger: "item", formatter: "calendarDay" },
            visualMap: {
              min:        min,
              max:        max,
              calculable: true,
              orient:     "horizontal",
              left:       "center",
              top:        10,
              # ── Customise: colour range ────────────────────────────
              inRange:    { color: ["#ebedf0", "#216e39"] }
            },
            calendar: years.each_with_index.map { |year, i|
              {
                range:      year.to_s,
                top:        60 + (i * 160),
                left:       30,
                right:      30,
                cellSize:   ["auto", 13],
                yearLabel:  { show: true, position: "top", margin: 8 },
                monthLabel: { nameMap: "en" },
                dayLabel:   { firstDay: 1, nameMap: "en" }
              }
            },
            series: years.each_with_index.map { |year, i|
              {
                type:             "heatmap",
                coordinateSystem: "calendar",
                calendarIndex:    i,
                data:             by_year[year] || []
              }
            }
          )
        end

        def view_template
          div(class: "p-2 rounded-lg bg-white border border-neutral-200", **@html) do
            div(
              data: {
                controller:          "chart",
                chart_target:        "mount",
                chart_options_value: chart_options.to_json,
                chart_group_value:   @group
              },
              style: "height: #{dynamic_height}; width: 100%;"
            )
          end
        end

        def dynamic_height
          years = @year_range.last - @year_range.first + 1
          "#{60 + (years * 160)}px"
        end
      end
    end
  end
end

D.9 — Gauge Chart

When to use: Single value in context, KPI dashboards, threshold indicators.

 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
# app/views/components/charts/templates/gauge_chart.rb
module Components
  module Charts
    module Templates
      class GaugeChart < Components::Chart
        prop :value,  Float,  default: -> { 0.0 }
        prop :name,   String, default: -> { "Value" }

        private

        def chart_options
          ::Chart::Options.new(
            series: [
              {
                type:       "gauge",
                # ── Customise: range ───────────────────────────────────
                min:        0,
                max:        100,
                radius:     "85%",
                center:     ["50%", "55%"],
                startAngle: 210,
                endAngle:   -30,
                axisLine: {
                  lineStyle: {
                    width: 18,
                    # ── Customise: colour bands [threshold, colour] ────
                    # threshold is fraction of range (0.0–1.0)
                    color: [
                      [0.3, "#f87171"],   # 0–30%: red
                      [0.7, "#fbbf24"],   # 30–70%: amber
                      [1.0, "#34d399"]    # 70–100%: green
                    ]
                  }
                },
                pointer:   { length: "65%", width: 5 },
                axisTick:  { distance: -22, length: 6 },
                splitLine: { distance: -28, length: 14 },
                axisLabel: { distance: -38, fontSize: 9 },
                detail: {
                  valueAnimation: true,
                  # ── Customise: value display format ───────────────────
                  formatter:      "{value}",
                  fontSize:       18,
                  fontWeight:     "bold",
                  offsetCenter:   ["0%", "15%"]
                },
                data: [{ value: @value, name: @name }]
              }
            ]
          )
        end
      end
    end
  end
end

D.10 — Mixed Chart (Bar + Line, Dual Axis)

When to use: Two related series with different scales or units — volumes and rates, absolute values and percentages.

 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
# app/views/components/charts/templates/mixed_chart.rb
module Components
  module Charts
    module Templates
      class MixedChart < Components::Chart
        prop :data, _Any, default: -> { [] }
        # data shape: {
        #   labels:  ["2020 Q1", ...],
        #   bars:    [{ name: "Sales", values: [42.3, ...] }],
        #   lines:   [{ name: "Growth", values: [2.1, ...] }]
        # }

        private

        def chart_options
          ::Chart::Options.new(
            color:   "cool",
            tooltip: { trigger: "axis" },
            legend:  { bottom: 5 },
            x_axis:  {
              type: "category",
              data: @data[:labels]
            },
            # ── Two Y axes — index 0 (left) and index 1 (right) ────────
            y_axis: [
              {
                type:      "value",
                name:      "Left Axis Label",
                axisLabel: { formatter: "thousands" }
              },
              {
                type:      "value",
                name:      "Right Axis Label",
                axisLabel: { formatter: "rate" },
                splitLine: { show: false }
              }
            ],
            grid:   { left: 8, right: 8, bottom: 40, containLabel: true },
            series: build_series
          )
        end

        def build_series
          bar_series = (@data[:bars] || []).map do |s|
            ::Chart::Series::Bar.new(
              name:       s[:name],
              data:       s[:values],
              yAxisIndex: 0          # left axis
            )
          end

          line_series = (@data[:lines] || []).map do |s|
            ::Chart::Series::Line.new(
              name:       s[:name],
              data:       s[:values],
              yAxisIndex: 1,         # right axis
              smooth:     true,
              symbol:     "none"
            )
          end

          bar_series + line_series
        end
      end
    end
  end
end

D.11 — Using the Templates

Copy and rename

1
2
cp app/views/components/charts/templates/line_chart.rb \
   app/views/components/charts/employment_trends.rb

Change the class name and module path:

1
2
3
4
5
6
7
# Before
module Templates
  class LineChart < Components::Chart

# After
module Charts
  class EmploymentTrends < Components::Chart

Customise

Update the props, palette, formatters, and build_series method. The comments in each template mark the customisation points.

Reference

All base class props are available without any additional code:

1
2
3
4
5
6
render Components::Charts::EmploymentTrends.new(
  data:   @data,
  height: "480px",    # inherited from Components::Chart
  group:  "dashboard", # inherited — links with other charts
  color:  "tableau"   # inherited — overrides palette
)

D.12 — Template Data Shapes

Each template expects data in a specific shape. Services should return data in the shape the template expects — or a thin transformation in the component maps between them.

Template Expected data shape
Line { "Series A" => [{ label:, value: }, ...] }
Stacked Bar { "Series A" => [{ label:, value: }, ...] }
Grouped Bar { "Series A" => [{ label:, value: }, ...] }
Horizontal Bar [{ label:, value: }, ...]
Pie [{ label:, value: }, ...]
Donut [{ label:, value: }, ...]
Scatter { "Series A" => [[x, y], ...] }
Calendar [["YYYY-MM-DD", value], ...]
Gauge Float (single value)
Mixed { labels:, bars: [{ name:, values: }], lines: [...] }