Skip to content

Appendix C — The Components::Chart Base Class

Every chart component in this series inherits from Components::Chart. This appendix explains what the base class provides, how its props work, and how to extend it.


C.1 — The Base Class

 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/chart.rb
#
# Base class for all chart components.
#
# Props (inherited by every chart component):
#
#   height:     String  — chart container height (default "400px")
#   group:      String  — ECharts group name for linked charts
#   color:      String  — palette override (replaces chart's own palette)
#   select_url: String  — URL to visit when a data point is clicked
#
# All props default to safe values — a chart with no extra props renders
# as a standalone 400px chart using whatever palette chart_options specifies.

class Components::Chart < Components::Base
  prop :height,     String, default: -> { "400px" }
  prop :group,      String, default: -> { "" }
  prop :color,      String, default: -> { "" }
  prop :select_url, String, default: -> { "" }

  def view_template
    options = chart_options.to_h
    options[:color] = @color if @color.present?

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

  private

  def chart_options
    raise NotImplementedError, "#{self.class} must implement chart_options"
  end
end

Props

Prop Type Default Purpose
height String "400px" Chart container height
group String "" ECharts group for linked charts — see Module 10
color String "" Palette override — replaces component’s own palette
select_url String "" URL to visit when a data point is clicked — see Module 10

All props default to safe empty values. A chart rendered with no extra props is standalone, 400px tall, using whatever palette chart_options specifies, with no click behaviour and no group linking.

view_template

The base view_template renders a two-div structure:

1
2
3
4
5
6
7
8
9
<div class="p-2 rounded-lg bg-white border border-neutral-200">
  <div
    data-controller="chart"
    data-chart-target="mount"
    data-chart-options-value="{ ... }"
    data-chart-group-value=""
    style="height: 400px; width: 100%;"
  ></div>
</div>

The outer div provides the card-like appearance. The inner div is the ECharts mount target — chart_controller.js initialises the ECharts instance on this element.

**@html passes any additional HTML attributes through to the outer div — the standard Phlex pattern for attribute forwarding.

The color override

1
2
options = chart_options.to_h
options[:color] = @color if @color.present?

chart_options sets the palette the component normally uses. When color: is passed by the caller, it replaces that palette. This allows any component to be used with a different palette in a dashboard context without changing the component itself:

1
2
3
4
5
6
7
8
9
# Component uses its own palette
render Components::Charts::LabourForce.new(readings: @readings)

# Dashboard overrides the palette for consistency
render Components::Charts::LabourForce.new(
  readings: @readings,
  group:    "labour_dashboard",
  color:    "tableau"
)

The component’s chart_options method is unchanged — the override happens in the base class after chart_options returns.


C.2 — Writing a Chart Component

Every chart component follows the same structure:

 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
module Components
  module Charts
    class MyChart < Components::Chart
      # 1. Declare props
      prop :data, _Any, default: -> { [] }

      private

      # 2. Implement chart_options — returns a Chart::Options instance
      def chart_options
        ::Chart::Options.new(
          color:   "cool",
          tooltip: { trigger: "axis" },
          x_axis:  { type: "category", data: labels },
          y_axis:  { type: "value" },
          series:  build_series
        )
      end

      # 3. Helper methods — keep chart_options readable
      def build_series
        @data.map { |r|
          ::Chart::Series::Line.new(name: r[:label], data: r[:values])
        }
      end

      def labels
        @data.first&.dig(:timestamps) || []
      end
    end
  end
end

Rules:

  • Always call super if overriding view_template — or don’t override it at all
  • Keep chart_options readable — extract helpers for series construction
  • Use :: prefix for model constants — ::GdpReading not GdpReading
  • Never call services from chart_options — the controller passes data in

C.3 — The stream_name Extension

Real-time chart components add one prop and one data attribute:

 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
module Components
  module Charts
    class LiveStocks < Components::Chart
      prop :prices,      Hash,   default: -> { {} }
      prop :stream_name, String, default: -> { "" }

      private

      def chart_options
        # ... options with animation: false
      end

      def view_template
        options = chart_options.to_h

        div(class: "p-2 rounded-lg bg-white border border-neutral-200", **@html) do
          div(
            data: {
              controller:              "chart",
              chart_target:            "mount",
              chart_options_value:     options.to_json,
              chart_group_value:       @group,
              chart_stream_name_value: @stream_name    # ← real-time addition
            },
            style: "height: #{@height}; width: 100%;"
          )
        end
      end
    end
  end
end

Real-time components override view_template to add chart_stream_name_value. The base class view_template does not include it — static charts don’t need it.

An alternative approach is to add stream_name: to the base class alongside group: and color:. The tradeoff: simpler for real-time components but adds an unused data attribute to every static chart. For this series, real-time components override view_template explicitly — making the intent clear.


C.4 — The Ruby DSL

Chart::Options

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
::Chart::Options.new(
  color:   "tableau",    # palette name or colour array
  tooltip: { ... },
  legend:  { ... },
  x_axis:  { ... },      # maps to xAxis in ECharts JSON
  y_axis:  { ... },      # maps to yAxis in ECharts JSON
  grid:    { ... },
  series:  [ ... ],
  # Any other ECharts option passes through unchanged
)

Chart::Options converts snake_case Ruby keys to camelCase ECharts keys: x_axisxAxis, y_axisyAxis. All other keys pass through.

to_h returns the full options hash. to_json serialises it — used in view_template for the data attribute.

Chart::Series::*

 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
# Line series
::Chart::Series::Line.new(
  name:   "Series Name",
  data:   [1, 2, 3],
  smooth: true,
  symbol: "none"
)

# Bar series
::Chart::Series::Bar.new(
  name:  "Series Name",
  data:  [1, 2, 3],
  stack: "total"         # optional — for stacked bars
)

# Scatter series
::Chart::Series::Scatter.new(
  name: "Series Name",
  data: [[x1, y1], [x2, y2]]
)

# Pie series
::Chart::Series::Pie.new(
  name:   "Series Name",
  data:   [{ name: "A", value: 42 }],
  radius: ["40%", "70%"]
)

All series classes accept **extra — any ECharts series option not explicitly modelled passes through unchanged:

1
2
3
4
5
6
7
::Chart::Series::Line.new(
  name:      "GDP",
  data:      values,
  markLine:  { ... },    # passed through via **extra
  markArea:  { ... },    # passed through via **extra
  markPoint: { ... }     # passed through via **extra
)

C.5 — Component Props Reference

Base class props (inherited by all components)

Prop Type Default Pass as
height String "400px" height: "600px"
group String "" group: "my_dashboard"
color String "" color: "tableau"

Usage

<%# Minimal — all defaults %>
<%= render Components::Charts::GdpByIndustry.new(data: @data) %>

<%# Custom height %>
<%= render Components::Charts::GdpByIndustry.new(data: @data, height: "600px") %>

<%# Dashboard — linked and palette-overridden %>
<%= render Components::Charts::GdpByIndustry.new(
  data:   @data,
  group:  "gdp_dashboard",
  color:  "tableau",
  height: "320px"
) %>

<%# Real-time %>
<%= render Components::Charts::LiveStocks.new(
  prices:      @prices,
  stream_name: "live_stocks",
  height:      "400px"
) %>

C.6 — The Ruby DSL

The DSL lives in app/lib/chart/ and has one job: produce a Ruby Hash that serialises to valid ECharts options JSON. It is not a complete wrapper around ECharts — it wraps the most common options and passes everything else through unchanged.

Chart::Options

 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
# app/lib/chart/options.rb
module Chart
  class Options
    def initialize(
      title:     nil,
      tooltip:   nil,
      x_axis:    nil,
      y_axis:    nil,
      series:    [],
      legend:    nil,
      grid:      nil,
      color:     nil,
      animation: false,
      **extra
    )
      @title     = title
      @tooltip   = tooltip
      @x_axis    = x_axis
      @y_axis    = y_axis
      @series    = Array(series)
      @legend    = legend
      @grid      = grid
      @color     = color
      @animation = animation
      @extra     = extra
    end

    def to_h
      h = {
        animation: @animation,
        series:    @series.map { |s| resolve(s) }
      }
      h[:color]   = @color            if @color
      h[:title]   = resolve(@title)   if @title
      h[:tooltip] = resolve(@tooltip) if @tooltip
      h[:xAxis]   = resolve(@x_axis)  if @x_axis
      h[:yAxis]   = resolve(@y_axis)  if @y_axis
      h[:legend]  = resolve(@legend)  if @legend
      h[:grid]    = resolve(@grid)    if @grid
      h.merge!(@extra)
      h
    end

    def to_json(*) = to_h.to_json

    private

    def resolve(value)
      case value
      when Array then value.map { |v| resolve(v) }
      when Hash  then value
      else
        value.respond_to?(:to_h) ? value.to_h : value
      end
    end
  end
end

Key design decisions:

Snake_case to camelCasex_axis: becomes xAxis, y_axis: becomes yAxis in the output hash. This is handled by the explicit key mapping in to_h. Only the options explicitly modelled get this treatment — everything in **extra passes through with whatever key name you provide.

**extra escape hatch — any ECharts option not modelled by the DSL passes through unchanged via **extra. This is how visualMap:, calendar:, toolbox:, graphic:, markLine: etc. are passed — they are not modelled but work correctly:

1
2
3
4
5
6
7
::Chart::Options.new(
  color:    "cool",
  series:   build_series,
  toolbox:  { feature: { saveAsImage: {} } },   # via **extra
  visualMap: { ... },                            # via **extra
  calendar: [...]                                # via **extra — array handled by resolve
)

resolve — called on every value before it enters the output hash. Handles three cases:

  • Array — maps over elements, resolving each one. Handles y_axis: [...] for dual axes, calendar: [...] for multi-year calendar, series: [...].
  • Hash — passes through unchanged. Plain hashes are already in the right format.
  • Everything else — calls to_h if available (DSL series objects), otherwise passes through as-is (strings, numbers, booleans).

animation: false default — ECharts animates everything by default. For real-time charts this creates visual noise. The DSL defaults to false — you can override explicitly:

1
::Chart::Options.new(animation: true, ...)

Chart::Series::*

Each series type is a thin wrapper that produces a hash with type: set correctly and common options named explicitly. Uncommon options pass through via **extra.

 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
# app/lib/chart/series/base.rb
module Chart
  module Series
    class Base
      def initialize(**opts)
        @opts = opts
      end

      def to_h
        { type: series_type }.merge(resolved_opts)
      end

      private

      def series_type = raise NotImplementedError

      def resolved_opts
        @opts.transform_values do |v|
          case v
          when Array then v
          when Hash  then v
          else v.respond_to?(:to_h) ? v.to_h : v
          end
        end
      end
    end
  end
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
# app/lib/chart/series/line.rb
module Chart
  module Series
    class Line < Base
      private
      def series_type = "line"
    end
  end
end

# app/lib/chart/series/bar.rb
module Chart
  module Series
    class Bar < Base
      private
      def series_type = "bar"
    end
  end
end

# app/lib/chart/series/scatter.rb
module Chart
  module Series
    class Scatter < Base
      private
      def series_type = "scatter"
    end
  end
end

# app/lib/chart/series/pie.rb
module Chart
  module Series
    class Pie < Base
      private
      def series_type = "pie"
    end
  end
end

All series options are passed through **opts and resolved. This means any ECharts series option works without being explicitly modelled:

1
2
3
4
5
6
7
8
9
::Chart::Series::Line.new(
  name:      "GDP",
  data:      values,
  smooth:    true,        # passed through
  symbol:    "none",      # passed through
  markLine:  { ... },     # passed through
  markArea:  { ... },     # passed through
  yAxisIndex: 1           # passed through — dual axis
)

The to_h / to_json Contract

Every DSL object implements to_h and to_json. The base component calls chart_options.to_h then serialises with .to_json:

1
2
3
4
5
# In Components::Chart#view_template
options = chart_options.to_h
options[:color] = @color if @color.present?

data: { chart_options_value: options.to_json }

chart_options returns a Chart::Options instance. to_h resolves all nested DSL objects. to_json produces the JSON string that becomes the Stimulus value. chart_controller.js receives it as a parsed JavaScript object via the Stimulus value system.


Adding a New Series Type

To add a Chart::Series::Gauge for use in components:

1
2
3
4
5
6
7
8
9
# app/lib/chart/series/gauge.rb
module Chart
  module Series
    class Gauge < Base
      private
      def series_type = "gauge"
    end
  end
end

Then use it anywhere:

1
2
3
4
5
6
::Chart::Series::Gauge.new(
  name: "GDP",
  min:  300,
  max:  750,
  data: [{ value: @value, name: "GDP" }]
)

All gauge-specific options (min:, max:, axisLine:, pointer:, detail:) pass through **opts unchanged.


Why Not Wrap Everything?

The DSL deliberately wraps only a small subset of ECharts options. ECharts has hundreds of configuration keys — wrapping them all would produce a large, rigid abstraction that trails behind ECharts releases.

The **extra escape hatch means you always have access to the full ECharts API. The DSL adds value for the most common options (series types, axis configuration) and gets out of the way for everything else.

If you find yourself repeatedly writing the same **extra hash, that’s a signal to add it to the DSL. Until then, use the escape hatch.


C.7 — Extending the Base Class

To add a new inherited capability — for example, a theme: prop for ECharts built-in themes:

1. Add the prop to the base class:

1
prop :theme, String, default: -> { "" }

2. Pass it to the mount div:

1
2
3
4
5
6
7
data: {
  controller:        "chart",
  chart_target:      "mount",
  chart_options_value: options.to_json,
  chart_group_value:   @group,
  chart_theme_value:   @theme    # ← new
}

3. Add the Stimulus value to chart_controller.js:

1
2
3
4
5
6
static values = {
  options:    { type: Object, default: {} },
  streamName: { type: String, default: "" },
  group:      { type: String, default: "" },
  theme:      { type: String, default: "" }   // ← new
}

4. Use it in #initChart:

1
2
3
4
5
#initChart() {
  const theme = this.themeValue || null
  this.chart  = echarts.init(this.mountTarget, theme, { renderer: "svg" })
  // ...
}

Every chart in the application gains the theme: prop automatically — no changes to individual components. The pattern is identical for any new capability: one prop on the base class, one data attribute, one Stimulus value.