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 |
|---|---|
| 03:51 | 35% |
| 03:52 | 40% |
| 03:53 | 45% |
| 03:54 | 49% |
| 03:55 | 52% |
| 03:56 | 54% |
| 03:57 | 55% |
| 03:58 | 55% |
| 03:59 | 53% |
| 04:00 | 51% |
| 04:01 | 47% |
| 04:02 | 43% |
| 04:03 | 38% |
| 04:04 | 33% |
| 04:05 | 28% |
| 04:06 | 24% |
| 04:07 | 20% |
| 04:08 | 17% |
| 04:09 | 15% |
| 04:10 | 15% |
| 04:16 | 34% |
| 04:17 | 39% |
| 04:18 | 44% |
| 04:19 | 48% |
| 04:20 | 51% |
| 04:21 | 54% |
| 04:22 | 55% |
| 04:23 | 55% |
| 04:24 | 53% |
| 04:25 | 51% |
| 04:26 | 47% |
| 04:27 | 43% |
| 04:28 | 38% |
| 04:29 | 33% |
| 04:30 | 29% |
| 04:31 | 24% |
| 04:32 | 20% |
| 04:33 | 17% |
| 04:34 | 16% |
| 04:35 | 15% |
| 04:36 | 16% |
| 04:37 | 17% |
| 04:38 | 20% |
| 04:39 | 24% |
| 04:40 | 29% |
| 04:41 | 34% |
| 04:42 | 39% |
| 04:43 | 43% |
| 04:44 | 48% |
| 04:45 | 51% |
| 04:46 | 54% |
| 04:47 | 55% |
| 04:48 | 55% |
| 04:49 | 54% |
| 04:50 | 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>