Skip to content

Appendix B — The JavaScript Infrastructure

The tutorial modules treat the JavaScript layer as a black box — you install it once and never touch it again. This appendix explains how it works for those who want to understand or extend it.

There are three files:

File Purpose
app/javascript/controllers/chart_controller.js Stimulus controller — owns the ECharts instance
app/javascript/charts/chart_formatters.js Formatter registry — named tooltip and axis formatters
app/javascript/charts/chart_palettes.js Palette registry — named colour arrays

Together they form a thin runtime that bridges Ruby-generated ECharts options with the ECharts JavaScript library. The design goal is that all chart configuration lives in Ruby — the JavaScript layer is infrastructure, not logic.


B.1 — chart_controller.js

Overview

chart_controller.js is a Stimulus controller. Stimulus connects it to any DOM element that carries data-controller="chart". The controller:

  1. Initialises an ECharts instance on the mount target
  2. Resolves palette names and formatter names in the options
  3. Calls setOption to render the chart
  4. Watches for option changes via a Stimulus value observer
  5. Handles resize automatically via ResizeObserver
  6. Optionally subscribes to an ActionCable stream for real-time updates
  7. Optionally joins an ECharts group for linked chart interactions
  8. Disposes the ECharts instance cleanly on disconnect

Stimulus values

1
2
3
4
5
static values = {
  options:    { type: Object, default: {} },  // ECharts options — set by Ruby
  streamName: { type: String, default: "" },  // ActionCable stream — optional
  group:      { type: String, default: "" }   // ECharts group — optional
}

options carries the full ECharts configuration generated by the Ruby DSL. streamName and group are opt-in features — absent means the chart is static and standalone.

The connect/disconnect lifecycle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
connect() {
  this.#initChart()
  if (this.groupValue)      this.#joinGroup()
  if (this.streamNameValue) this.#subscribe()
}

disconnect() {
  this.subscription?.unsubscribe()
  this.#disposeChart()
}

connect() fires when the controller’s element enters the DOM — on page load, after a Turbo navigation, or when Turbo Streams adds the element. disconnect() fires when the element leaves — on navigation away or when Turbo Streams removes it. ECharts instances are disposed on disconnect to prevent memory leaks.

optionsValueChanged

Stimulus calls this automatically whenever data-chart-options-value changes:

1
2
3
4
optionsValueChanged(options) {
  if (!this.chart) return
  this.#applyOptions(options)
}

This is the mechanism that makes real-time updates work without custom JavaScript. When the broadcaster sends new options, the controller sets the data attribute, Stimulus detects the change, and optionsValueChanged fires — calling setOption on the existing ECharts instance.

#initChart

1
2
3
4
5
#initChart() {
  this.chart = echarts.init(this.mountTarget, null, { renderer: "svg" })
  this.#applyOptions(this.optionsValue)
  this.#startResizeObserver()
}

echarts.init creates the ECharts instance on the mount target element. renderer: "svg" uses SVG rather than Canvas — better for accessibility, print, and server-side rendering. The ResizeObserver calls chart.resize() whenever the container changes size — making charts responsive without any extra code.

#applyOptions

1
2
3
4
#applyOptions(options) {
  this.#resolveOptions(options)
  this.chart.setOption(options, { notMerge: true })
}

#resolveOptions runs before setOption — it replaces palette names with colour arrays and formatter names with functions. notMerge: true replaces the full option state rather than merging — correct for most chart updates.

For real-time partial updates (where only series data changes) the broadcaster uses notMerge: false — merging the new data with the existing configuration.

#joinGroup

1
2
3
4
#joinGroup() {
  this.chart.group = this.groupValue
  echarts.connect(this.groupValue)
}

chart.group assigns this instance to a named group. echarts.connect(group) tells ECharts to synchronise tooltip and zoom state across all instances in the group. All charts with the same group name will show coordinated tooltips when any one is hovered.

#subscribe

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#subscribe() {
  this.subscription = consumer.subscriptions.create(
    { channel: "ChartChannel", stream: this.streamNameValue },
    {
      received: (data) => {
        const updated = { ...this.optionsValue, ...data }
        this.element.dataset.chartOptionsValue = JSON.stringify(updated)
      }
    }
  )
}

Creates an ActionCable subscription to the named stream. Incoming data is merged with the current options using the spread operator — the broadcaster can send a complete options object (full replace) or just the changed keys (partial merge).

Setting dataset.chartOptionsValue triggers optionsValueChanged automatically. No explicit call needed — Stimulus watches the attribute.

The complete file

  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
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
// app/javascript/controllers/chart_controller.js
import { Controller } from "@hotwired/stimulus"
import * as echarts   from "echarts"
import formatters     from "chart_formatters"
import palettes       from "chart_palettes"
import consumer       from "channels/consumer"

export default class extends Controller {
  static targets = ["mount"]

  static values = {
    options:    { type: Object, default: {} },
    streamName: { type: String, default: "" },
    group:      { type: String, default: "" }
  }

  connect() {
    this.#initChart()
    if (this.groupValue)      this.#joinGroup()
    if (this.streamNameValue) this.#subscribe()
  }

  disconnect() {
    this.subscription?.unsubscribe()
    this.#disposeChart()
  }

  optionsValueChanged(options) {
    if (!this.chart) return
    this.#applyOptions(options)
  }

  #initChart() {
    this.chart = echarts.init(this.mountTarget, null, { renderer: "svg" })
    this.#applyOptions(this.optionsValue)
    this.#startResizeObserver()
  }

  #disposeChart() {
    this.#stopResizeObserver()
    if (this.chart) {
      this.chart.dispose()
      this.chart = null
    }
  }

  #applyOptions(options) {
    this.#resolveOptions(options)
    this.chart.setOption(options, { notMerge: true })
  }

  #startResizeObserver() {
    this.resizeObserver = new ResizeObserver(() => this.chart?.resize())
    this.resizeObserver.observe(this.mountTarget)
  }

  #stopResizeObserver() {
    this.resizeObserver?.disconnect()
    this.resizeObserver = null
  }

  #joinGroup() {
    this.chart.group = this.groupValue
    echarts.connect(this.groupValue)
  }

  #subscribe() {
    this.subscription = consumer.subscriptions.create(
      { channel: "ChartChannel", stream: this.streamNameValue },
      {
        received: (data) => {
          const updated = { ...this.optionsValue, ...data }
          this.element.dataset.chartOptionsValue = JSON.stringify(updated)
        }
      }
    )
  }

  #resolveOptions(options) {
    this.#resolvePalette(options)
    this.#resolveFormatters(options)
  }

  #resolvePalette(options) {
    if (typeof options.color === "string" && palettes[options.color]) {
      options.color = palettes[options.color]
    }
  }

  #resolveFormatters(obj) {
    if (typeof obj !== "object" || obj === null) return
    Object.keys(obj).forEach(key => {
      const value = obj[key]
      if (key === "formatter" && typeof value === "string") {
        const trigger   = obj.trigger
        const qualified = trigger ? `${trigger}:${value}` : null
        obj[key] = (qualified && formatters[qualified])
                     ?? formatters[value]
                     ?? value
      } else if (typeof value === "object") {
        this.#resolveFormatters(value)
      }
    })
  }
}

B.2 — The Formatter Registry

How formatters work

ECharts formatter options accept either a string template or a JavaScript function. The Ruby DSL passes formatter names as strings — "currency", "rate", "thousands". The #resolveFormatters method in chart_controller.js walks the options tree and replaces these strings with the corresponding functions from the registry.

Trigger qualification

The same formatter name resolves differently depending on context:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Axis label — receives a single value
y_axis: { axisLabel: { formatter: "currency" } }
# → resolves to the "currency" function

# Tooltip — receives params array (axis trigger) or params object (item trigger)
tooltip: { trigger: "axis", formatter: "currency" }
# → resolves to "axis:currency" (falls back to "currency" if not found)

tooltip: { trigger: "item", formatter: "currency" }
# → resolves to "item:currency"

The resolver reads the sibling trigger key to qualify the lookup. This means the same name works correctly in all three contexts — you never need to remember qualified names when writing Ruby.

Resolver logic

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#resolveFormatters(obj) {
  if (typeof obj !== "object" || obj === null) return
  Object.keys(obj).forEach(key => {
    const value = obj[key]
    if (key === "formatter" && typeof value === "string") {
      const trigger   = obj.trigger
      const qualified = trigger ? `${trigger}:${value}` : null
      obj[key] = (qualified && formatters[qualified])
                   ?? formatters[value]
                   ?? value
    } else if (typeof value === "object") {
      this.#resolveFormatters(value)
    }
  })
}

The resolver walks every object in the options tree recursively. When it finds a formatter key with a string value, it attempts three lookups in order:

  1. "axis:currency" — trigger-qualified (most specific)
  2. "currency" — unqualified (axis label context)
  3. The original string — passed through unchanged (ECharts string templates)

ECharts string templates like "{value}%" or "{b}: {c}" pass through step 3 unchanged — they are not in the registry and ECharts handles them natively.

Adding custom formatters

Add to custom_chart_formatters.js. Custom formatters override base formatters when names clash:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// app/javascript/charts/custom_chart_formatters.js
export default {
  // Axis label formatter — receives a single value
  "myFormat": v => `${v} units`,

  // Axis tooltip formatter — receives params array
  "axis:myFormat": params => {
    const header = params[0]?.axisValueLabel ?? ""
    const rows   = params.map(p =>
      `<tr><td>${p.marker}${p.seriesName}</td>
           <td><strong>${p.value} units</strong></td></tr>`
    ).join("")
    return `${header}<br/><table>${rows}</table>`
  },

  // Item tooltip formatter — receives single params object
  "item:myFormat": params =>
    `${params.marker}${params.name}: <strong>${params.value} units</strong>`
}

Then reference from Ruby:

1
2
tooltip:  { trigger: "axis", formatter: "myFormat" }
y_axis:   { axisLabel: { formatter: "myFormat" } }

Base formatters reference

Name Axis label Axis tooltip Item tooltip
integer
percent
rate
billions
millions
thousands
currency
default

B.3 — The Palette Registry

How palettes work

ECharts color accepts an array of colour strings. The Ruby DSL passes palette names as strings — color: "cool", color: "tableau". The #resolvePalette method replaces the name with the corresponding array before setOption is called.

1
2
3
4
5
#resolvePalette(options) {
  if (typeof options.color === "string" && palettes[options.color]) {
    options.color = palettes[options.color]
  }
}

If color is already an array (inline colours) it passes through unchanged. If color is a string not found in the registry (e.g. a CSS colour "#ef4444") it also passes through — ECharts treats a single colour string as a fixed colour for all series.

Colour correspondence

Charts on the same page with the same palette assign colours in series order. If two charts both use "tableau" and both have series in the same order, they will use the same colours — series 1 gets tableau colour 1 on both charts.

This is guaranteed when the service returns data in a consistent order (e.g. alphabetically by state). The palette correspondence principle from Module 03 applies: same palette + same series order = same colour mapping.

Adding custom palettes

1
2
3
4
5
6
7
// app/javascript/charts/custom_chart_palettes.js
export default {
  brand: [
    "#005f73", "#0a9396", "#94d2bd", "#e9d8a6",
    "#ee9b00", "#ca6702", "#bb3e03", "#ae2012"
  ]
}

Then from Ruby:

1
::Chart::Options.new(color: "brand", ...)

Available palettes

Name Character
default ECharts built-in — balanced and familiar
warm Reds, oranges, yellows — energy and urgency
cool Blues, greens, purples — professional and calm
earth Browns, tans, sage — grounded and natural
pastel Soft muted tones — gentle and accessible
vivid High saturation — bold and attention-grabbing
monochrome Single blue hue — print-friendly
accessible Okabe-Ito — colour-blind safe
tableau Tableau classic — widely recognised, perceptually distinct

B.4 — Extending the Infrastructure

Adding a new Stimulus value

To add a new opt-in capability (e.g. a theme value for ECharts themes):

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

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

Then add theme: to Components::Chart base class and pass it as chart_theme_value. Every chart gains the capability with no further changes.

Adding a new formatter

Add to custom_chart_formatters.js. The name is available immediately in Ruby via formatter: "myName".

Adding a new palette

Add to custom_chart_palettes.js. The name is available immediately in Ruby via color: "myName".

No changes to chart_controller.js are needed for new formatters or palettes — they are registered at import time and merged automatically.