Chart
A chart drawn as inline SVG, no script: columns over a set of labels, stacked where there is more than one series, or a line over time. Every column answers to hover, and the same numbers sit under it as a table for anybody who can't or won't.
Stacked columns over days
One { label:, values: } per series, values keyed by the label. format: says how every number reads; a total: shows in the legend.
- Suno · $152
- ElevenLabs · $156
- Meshy · $140
As a table
| Label | Suno | ElevenLabs | Meshy |
|---|---|---|---|
| 1 Sep | $8.90 | $1.90 | $0.30 |
| 2 Sep | $8.10 | $3.80 | $0.60 |
| 3 Sep | $7.30 | $5.70 | $0.90 |
| 4 Sep | $6.50 | $7.60 | $1.20 |
| 5 Sep | $5.70 | $9.50 | $1.50 |
| 6 Sep | $4.90 | $1.70 | $1.80 |
| 7 Sep | $4.10 | $3.60 | $2.10 |
| 8 Sep | $3.30 | $5.50 | $2.40 |
| 9 Sep | $2.50 | $7.40 | $2.70 |
| 10 Sep | $1.70 | $9.30 | $3.00 |
| 11 Sep | $0.90 | $1.50 | $3.30 |
| 12 Sep | $0.10 | $3.40 | $3.60 |
| 13 Sep | $9.00 | $5.30 | $3.90 |
| 14 Sep | $8.20 | $7.20 | $4.20 |
| 15 Sep | $7.40 | $9.10 | $4.50 |
| 16 Sep | $6.60 | $1.30 | $4.80 |
| 17 Sep | $5.80 | $3.20 | $5.10 |
| 18 Sep | $5.00 | $5.10 | $5.40 |
| 19 Sep | $4.20 | $7.00 | $5.70 |
| 20 Sep | $3.40 | $8.90 | $6.00 |
| 21 Sep | $2.60 | $1.10 | $6.30 |
| 22 Sep | $1.80 | $3.00 | $6.60 |
| 23 Sep | $1.00 | $4.90 | $6.90 |
| 24 Sep | $0.20 | $6.80 | $7.20 |
| 25 Sep | $9.10 | $8.70 | $7.50 |
| 26 Sep | $8.30 | $0.90 | $7.80 |
| 27 Sep | $7.50 | $2.80 | $8.10 |
| 28 Sep | $6.70 | $4.70 | $8.40 |
| 29 Sep | $5.90 | $6.60 | $8.70 |
| 30 Sep | $5.10 | $8.50 | $9.00 |
<% days = (Date.new(2026, 9, 1)..Date.new(2026, 9, 30)).to_a %>
<% seed = ->(d, k) { ((d.day * k * 7919) % 97) / 10.0 } %>
<%= chart [
{ label: "Suno", values: days.to_h { |d| [ d, seed.(d, 3) ] }, total: days.sum { |d| seed.(d, 3) } },
{ label: "ElevenLabs", values: days.to_h { |d| [ d, seed.(d, 5) ] }, total: days.sum { |d| seed.(d, 5) } },
{ label: "Meshy", values: days.to_h { |d| [ d, seed.(d, 11) ] }, total: days.sum { |d| seed.(d, 11) } }
], labels: days, title: "Spend by provider", format: :money %>A line over time
type: :line for a reading that's always there. A nil value is a gap the line doesn't bridge, and the newest reading gets a dot. max: pins the top of the axis.
As a table
| Label | CPU |
|---|---|
| 04:53 | 35% |
| 04:54 | 40% |
| 04:55 | 45% |
| 04:56 | 49% |
| 04:57 | 52% |
| 04:58 | 54% |
| 04:59 | 55% |
| 05:00 | 55% |
| 05:01 | 53% |
| 05:02 | 51% |
| 05:03 | 47% |
| 05:04 | 43% |
| 05:05 | 38% |
| 05:06 | 33% |
| 05:07 | 28% |
| 05:08 | 24% |
| 05:09 | 20% |
| 05:10 | 17% |
| 05:11 | 15% |
| 05:12 | 15% |
| 05:18 | 34% |
| 05:19 | 39% |
| 05:20 | 44% |
| 05:21 | 48% |
| 05:22 | 51% |
| 05:23 | 54% |
| 05:24 | 55% |
| 05:25 | 55% |
| 05:26 | 53% |
| 05:27 | 51% |
| 05:28 | 47% |
| 05:29 | 43% |
| 05:30 | 38% |
| 05:31 | 33% |
| 05:32 | 29% |
| 05:33 | 24% |
| 05:34 | 20% |
| 05:35 | 17% |
| 05:36 | 16% |
| 05:37 | 15% |
| 05:38 | 16% |
| 05:39 | 17% |
| 05:40 | 20% |
| 05:41 | 24% |
| 05:42 | 29% |
| 05:43 | 34% |
| 05:44 | 39% |
| 05:45 | 43% |
| 05:46 | 48% |
| 05:47 | 51% |
| 05:48 | 54% |
| 05:49 | 55% |
| 05:50 | 55% |
| 05:51 | 54% |
| 05:52 | 51% |
<% times = (0..59).map { |i| @now - (59 - i).minutes } %>
<% values = times.each_with_index.map { |_, i| i.between?(20, 24) ? nil : 35 + (Math.sin(i / 4.0) * 20).round } %>
<%= chart [ { label: "CPU", values: values } ], labels: times, type: :line, format: :percent, max: 100,
title: "CPU, last hour", label_format: ->(t) { t.strftime("%H:%M") } %>Three abreast
width: is the drawing's own width; it scales to its box, so a narrow one keeps a readable axis.
<% days = (Date.new(2026, 9, 24)..Date.new(2026, 9, 30)).to_a %>
<div class="grid gap-4 sm:grid-cols-3">
<% [ [ "Renders", 3 ], [ "Imports", 5 ], [ "Chats", 7 ] ].each do |name, k| %>
<%= card do %>
<%= chart [ { label: name, values: days.map { |d| (d.day * k) % 40 + 5 } } ], labels: days, title: name, width: 300, table: false %>
<% end %>
<% end %>
</div>