Skip to content

Module 06 — Calendar Heatmap: Daily Economic Activity

What We’re Building

The calendar heatmap maps values onto a calendar grid — each cell is one day, colour encodes the value. Seasonal patterns, weekly rhythms, and one-off events are immediately readable.

This module also establishes the correct data flow pattern that applies from here forward: the controller calls the service and passes data to the component.

By the end of this module you will have:

  • A single-year calendar heatmap
  • A multi-year calendar heatmap using the same component
  • visualMap for the colour scale

Here’s what we’ll be building:

daily_calendar.png

6.1 — The Service

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# app/services/stats/daily_activity.rb
module Stats
  module DailyActivity
    extend self

    # Returns data shaped for ECharts calendar series:
    # [["2022-01-01", 94.15], ["2022-01-02", 97.67], ...]
    #
    # year_range: [from_year] or [from_year, to_year]
    def call(year_range:)
      from = year_range.first
      to   = year_range.last

      DailyActivityReading
        .where(date: Date.new(from, 1, 1)..Date.new(to, 12, 31))
        .ordered
        .pluck(:date, :value)
        .map { |date, value| [date.to_s, value.to_f.round(2)] }
    end
  end
end

pluck retrieves only the two columns needed — no full ActiveRecord objects for 2,557 rows. The result is an array of [date_string, value] pairs — the exact format ECharts calendar series expects.


6.2 — The Calendar Coordinate System

ECharts’ calendar coordinate system is unlike any other. Instead of X/Y axes, data is plotted onto a calendar grid. Two options work together:

calendar — defines the grid:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
calendar: {
  range:      "2022",
  top:        60,
  left:       30,
  right:      30,
  cellSize:   ["auto", 13],
  yearLabel:  { show: true, position: "top", margin: 8 },
  monthLabel: { nameMap: "en" },
  dayLabel:   { firstDay: 1, nameMap: "en" }
}

The series — references the calendar:

1
2
3
4
5
{
  type:             "heatmap",
  coordinateSystem: "calendar",
  data:             data
}

range accepts a year string ("2022"), a month ("2022-03"), or a date range (["2022-01-01", "2022-06-30"]).

dayLabel.firstDay: 1 starts weeks on Monday — correct for Australian calendars.


6.3 — The Component

A single year is a special case of a multi-year range. One component handles both by building arrays of calendar grids and 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
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
# app/views/components/charts/activity_calendar.rb
module Components
  module Charts
    class ActivityCalendar < Components::Chart
      prop :data,       _Any, default: -> { [] }
      prop :year_range, _Any, default: -> { [2023, 2023] }

      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",
            bottom:      10,
            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
            },
            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

When year_range: [2022, 2022] — one grid, one series, height 220px. When year_range: [2019, 2021] — three grids, three series, height 540px. The logic is identical — only the number of iterations changes.

calendarIndex assigns each series to the correct grid. Without it all series plot on the first grid.

Shared visualMap — this is the key advantage over rendering multiple single-year components in a loop. All years share one colour scale, so the COVID dip in 2020 is visually comparable to 2019 and 2021.

dynamic_height — the component calculates its own height from the year range, overriding the base height prop.


6.4 — Tooltip Formatter

Add to custom_chart_formatters.js:

1
2
3
4
5
6
7
8
9
"item:calendarDay": params => {
  const [date, value] = params.value
  const d = new Date(date + "T00:00:00")  // force local time, not UTC
  const formatted = d.toLocaleDateString("en-AU", {
    weekday: "short", day: "numeric",
    month:   "long",  year: "numeric"
  })
  return `${formatted}<br/><strong>Activity Index: ${value.toFixed(1)}</strong>`
}

Note: the “item:xxx” prefix for the custom formatter. Since the tooltip is for a single item, we differentiate it from our tabular (axis) based tooltips'

The + "T00:00:00" is important — new Date("2022-01-15") parses as UTC midnight, which displays as January 14 in Australian timezones. Appending the time forces local time parsing.


6.5 — Controller and Views

1
2
3
4
5
6
7
8
# app/controllers/charts_controller.rb
def single_year_calendar
  @data = Stats::DailyActivity.call(year_range: [2022])
end

def multi_year_calendar
  @data = Stats::DailyActivity.call(year_range: [2019, 2021])
end
1
2
3
# config/routes.rb
get "charts/single_year_calendar", to: "charts#single_year_calendar"
get "charts/multi_year_calendar",  to: "charts#multi_year_calendar"
<%# app/views/charts/single_year_calendar.html.erb %>
<h1 class="text-2xl font-bold mb-2">Daily Activity — 2022</h1>
<p class="text-neutral-500 text-sm mb-6">
  Illustrative daily index — generated data, not real ABS statistics.
</p>
<%= render Components::Charts::ActivityCalendar.new(
  data:       @data,
  year_range: [2022, 2022]
) %>
<%# app/views/charts/multi_year_calendar.html.erb %>
<h1 class="text-2xl font-bold mb-2">Daily Activity — COVID Years</h1>
<p class="text-neutral-500 text-sm mb-6">
  The COVID shock of 2020 is simulated as a sustained period of low activity
  from March through December, followed by gradual recovery through 2021.
  All three years share the same colour scale for direct comparison.
</p>
<%= render Components::Charts::ActivityCalendar.new(
  data:       @data,
  year_range: [2019, 2021]
) %>

And add the cards to the index:

1
2
3
4
5
6
7
8
9
<%= render "charts/gallery_card",
  title:       "Daily Activity — Single Year",
  description: "Calendar heatmap for 2022.",
  path:        charts_single_year_calendar_path %>

<%= render "charts/gallery_card",
  title:       "Daily Activity — COVID Years",
  description: "Multi-year calendar heatmap 2019–2021 on a shared colour scale.",
  path:        charts_multi_year_calendar_path %>

6.6 — Module Summary

New files:

File Purpose
app/services/stats/daily_activity.rb Query and shape for calendar series
app/services/stats/daily_activity_monthly.rb groupdate monthly bucketing
app/views/components/charts/activity_calendar.rb Single and multi-year heatmap

Patterns introduced:

  • ECharts calendar coordinate system — calendar + coordinateSystem: "calendar"
  • Multi-year calendar — arrays of calendar and series with calendarIndex
  • Single component as general case — single year is [2022, 2022]
  • Shared visualMap — one colour scale across all years for direct comparison
  • Dynamic height — component calculates its own height from the year range
  • UTC date fix in JavaScript tooltip formatters

calendar option reference:

Key Purpose
range "2022" (year), "2022-03" (month), date range array
cellSize [width, height] — use "auto" for responsive width
dayLabel.firstDay 0 = Sunday, 1 = Monday
calendarIndex Which grid a series plots on — multi-year only