Skip to content

Module 01 — Foundation: Your First Chart

Before You Start

This tutorial series assumes familiarity with Phlex and the Literal gem. If you haven’t used them before, work through the Phlex v2 on Rails series first, or visit Phlex Home and read the section on Literal Properties.

The tutorial repo contains a pre-configured Rails 8 application with the chart library already installed. Unzip it, run the database setup, and you’re ready:

1
2
3
4
ruby db/seeds/fetch_abs_data.rb
ruby db/seeds/generate_daily_activity.rb
rails db:migrate db:seed
bin/dev

Always use bin/dev, not bin/rails server. bin/dev runs both the Rails server and the Tailwind CSS watcher. Without it, new Tailwind classes won’t compile and the page will be unstyled.

Here’s what we’ll be building:

foundation_chart.png


1.1 — The Component

The component is the heart of this library. Everything you build in this series starts here. Let’s look at one in full before explaining each part:

 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
# app/views/components/charts/gdp_by_industry.rb
module Components
  module Charts
    class GdpByIndustry < Components::Chart
      prop :data, _Any, default: -> { {} }

      private

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

      def build_series
        @data.map do |industry, values|
          ::Chart::Series::Line.new(
            name: industry,
            data: values
          )
        end
      end

      def years
        (2000..2024).map(&:to_s)
      end
    end
  end
end

What the component does

The component inherits from Components::Chart — the base class that handles rendering. Its one job is to implement chart_options, returning a Chart::Options object that describes the chart.

Chart::Options is Ruby code that maps directly to ECharts’ JavaScript configuration. To understand the relationship, here is the equivalent raw JavaScript you would write without this library:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
chart.setOption({
  tooltip: { trigger: "axis" },
  legend:  { bottom: 5 },
  xAxis:   { type: "category", data: ["2000", "2001", ...] },
  yAxis:   { type: "value" },
  grid:    { left: 8, right: 8, bottom: 40, containLabel: true },
  series: [
    { type: "line", name: "Mining",       data: [12.3, 14.1, ...] },
    { type: "line", name: "Construction", data: [8.4, 9.2, ...] },
    ...
  ]
})

The Ruby and JavaScript structures are identical in shape. The differences:

  • Snake_case to camelCase — you write x_axis:, the library converts it to xAxis in the JSON. You write y_axis:, it becomes yAxis. Ruby convention, ECharts format — no mental switching.
  • Series type — you write ::Chart::Series::Line.new(...), the library adds type: "line" automatically. No boilerplate.
  • No JavaScript — the entire chart configuration is Ruby. Testable, readable, reusable.

Applying defaults

Components::Chart provides sensible defaults through its props:

1
2
3
4
prop :height,     String, default: -> { "400px" }
prop :group,      String, default: -> { "" }
prop :color,      String, default: -> { "" }
prop :select_url, String, default: -> { "" }

When you render the component without specifying height:, it defaults to 400px. When you don’t specify color:, ECharts uses its own default palette. You only specify what you want to change — everything else just works.

:group, :color and :select_url are placeholders at this stage for some of the functionality we will gain in upcoming chapters.

Why not just write JavaScript?

Three reasons:

Testabilitychart_options returns a plain Ruby hash. You can test it without a browser, without JavaScript, without Rails. A unit test can call component.send(:chart_options).to_h and assert on the result directly.

Reusability — a component is a Ruby class. It takes data: and renders a chart. Render it from any controller action, any view, any context — with different data each time.

One pattern — once you understand how one chart component works, you understand all of them. Every chart in this series follows the same structure.


1.2 — The Data

This tutorial uses real data from the Australian Bureau of Statistics — GDP by industry, quarterly, from 2000 to 2024.

For our first chart we show five industries that together tell the story of structural change in the Australian economy:

Industry Story
Mining Dominant and volatile — the resources boom is clearly visible
Construction Steady growth with a post-2020 housing boom
Financial and Insurance Services Large and stable — GFC dip visible
Health Care and Social Assistance Consistent growth, now one of the largest sectors
Manufacturing Long decline — structural change away from goods production

We aggregate quarterly data to annual totals — one value per year, 25 years, a clean simple x axis. Quarterly detail is available in later modules.


1.3 — The Service

The service transforms raw ActiveRecord data into a plain Ruby hash the component can use. It has one job — data transformation — and knows nothing about charts:

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

    INDUSTRIES = [
      "Mining",
      "Construction",
      "Financial and Insurance Services",
      "Health Care and Social Assistance",
      "Manufacturing"
    ].freeze

    # Returns annual GDP totals per industry:
    # {
    #   "Mining"        => [12.3, 14.1, 18.7, ...],  # 2000, 2001, 2002...
    #   "Construction"  => [8.4, 9.2, 10.1, ...],
    #   ...
    # }
    def call(readings)
      readings
        .select { |r| INDUSTRIES.include?(r.industry) }
        .group_by(&:industry)
        .transform_values do |rows|
          rows
            .group_by(&:year)
            .sort
            .map { |_year, year_rows|
              year_rows.sum { |r| r.value_billions.to_f }.round(1)
            }
        end
    end
  end
end

What each step does:

select filters to our five industries. group_by(&:industry) groups all readings by industry name, producing a hash of arrays. transform_values maps over each industry’s readings. Inside: group_by(&:year) groups by year, .sort ensures chronological order, .map sums the four quarters for each year and rounds to one decimal place.

The result is a hash where each key is an industry name and each value is an array of 25 annual totals — exactly the shape build_series in the component expects.

Why a separate service?

The same data could feed a CSV export, an API response, or a different chart type. The service doesn’t know or care — it just transforms data. This principle applies throughout the series.


1.4 — The Plumbing

The controller, route, and view are standard Rails. You’ll see this same pattern in every module — it won’t be explained again in detail.

1
2
3
4
5
6
# app/controllers/charts_controller.rb
class ChartsController < ApplicationController
  def gdp_by_industry
    @data = Stats::GdpByIndustry.call(GdpReading.ordered)
  end
end
1
2
# config/routes.rb
get "charts/gdp_by_industry", to: "charts#gdp_by_industry"
<%# app/views/charts/gdp_by_industry.html.erb %>
<h1 class="text-2xl font-bold mb-2">GDP by Industry</h1>
<p class="text-neutral-500 text-sm mb-6">
  Annual chain volume measures, five key industries, 2000–2024.
  Source: ABS National Accounts, CC BY 4.0.
</p>
<%= render Components::Charts::GdpByIndustry.new(
  data:   @data,
  height: "460px"
) %>

The controller calls the service and passes the result to the view. The view renders the component with the data. The component builds the chart options. chart_controller.js takes it from there — resolving palette names, formatter names, and calling ECharts setOption.


1.5 — The Gallery

The gallery is a simple index page that links to each chart as you build them. Create it once — you’ll add cards throughout the series.

1
2
3
# app/controllers/charts_controller.rb
def index
end
1
2
# config/routes.rb
root "charts#index"
<%# app/views/charts/index.html.erb %>
<div class="max-w-5xl mx-auto px-4 py-8">
  <h1 class="text-3xl font-bold mb-2">Chart Gallery</h1>
  <p class="text-neutral-500 text-sm mb-8">
    Built with Phlex, ECharts, and real ABS data.
  </p>
  <div class="grid grid-cols-2 gap-4">
    <%= render "charts/gallery_card",
      title:       "GDP by Industry",
      description: "Annual GDP — Mining, Construction, Finance, Health, Manufacturing.",
      path:        charts_gdp_by_industry_path %>
  </div>
</div>
<%# app/views/charts/_gallery_card.html.erb %>
<div class="border border-neutral-200 rounded-lg p-4
            hover:border-neutral-400 transition-colors">
  <%= link_to path, class: "block" do %>
    <h2 class="font-semibold mb-1"><%= title %></h2>
    <p class="text-sm text-neutral-500"><%= description %></p>
  <% end %>
</div>

1.6 — What You Should See

Visit /charts/gdp_by_industry. You should see:

  • Five coloured lines on a single chart
  • Years 2000–2024 on the x axis
  • GDP in billions on the y axis
  • A legend at the bottom — click any industry to hide or show its line
  • A tooltip on hover showing all five values for that year

The stories in the data:

Mining rises sharply through the 2000s resources boom, peaks around 2011–2013, then falls back. Health Care grows steadily and is now larger than Manufacturing, which has declined consistently. Construction shows a clear post-2020 acceleration. Financial Services dipped during the GFC (2008–2009) but recovered quickly.

This is real data. These are real economic shifts. The tooltip makes them precisely readable.


1.7 — Troubleshooting

Chart container visible but empty:

  • Open the browser console — look for JavaScript errors
  • Confirm bin/dev is running, not just bin/rails server
  • Check the mount div has an explicit height in the rendered HTML

undefined method 'chart_options':

  • The component must implement chart_options as a private method
  • Check the class inherits from Components::Chart

No data — blank chart with axes but no lines:

  • Run rails runner "puts GdpReading.count" — should be 1900
  • If zero, run rails db:seed

1.8 — Summary

What we built:

Layer File Responsibility
Service stats/gdp_by_industry.rb Filter 5 industries, aggregate to annual totals
Component charts/gdp_by_industry.rb Build Chart::Options, render the mount div
Controller charts_controller.rb Call service, pass data to view
View gdp_by_industry.html.erb Render the component

The pattern — repeated in every module:

controller → service → @data → view → component → Chart::Options → chart_controller.js → ECharts

Key ideas introduced:

  • Chart::Options maps Ruby to ECharts configuration — same structure, Ruby syntax
  • Snake_case keys (x_axis:) convert to camelCase (xAxis) automatically
  • Chart::Series::Line adds type: "line" — no boilerplate
  • The service is chart-agnostic — it transforms data, nothing more
  • The component is testable in plain Ruby — no browser required
  • Components::Chart provides defaults — only specify what you want to change

Next: Module 02 — Formatters and Colour Palettes