Choosing the Right Chart
⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.
"Add a chart" is the moment most dashboards go wrong: a pie for five categories, a tapering funnel graphic for numbers that aren't a funnel, a rainbow heatmap nobody can read. Chart choice is not taste — there is a research answer. A chart is an answer to a question, and the question picks the chart — not the data shape, and never the chart that looks impressive. This guide holds the chooser, the perception rules behind it, and dependency-free ERB/CSS/SVG recipes that hot-reload with the view.
When to use: before adding any chart, graph, sparkline, or progress bar — or when an existing chart "looks wrong but I can't say why". When not to: fewer than ~4 data points. Print the numbers; a three-bar chart is a table with extra steps.
The 80/20 in one breath
- Name the question the chart answers, out loud, before choosing anything.
- Map the question to a relationship (the chooser table below) — ranking, change over time, part-to-whole, deviation — and take that row's default chart.
- Prefer position and length encodings (bars on a shared zero baseline, sorted). Angle (pies) and area (bubbles, trapezoids) decode far less accurately.
- Fewer than 4 points → no chart. Print a big number instead.
- Build it as plain divs + Tailwind widths or inline SVG — no chart library, no CDN, no build step. A partial per chart type so the encoding is identical everywhere.
- Label everything and print the values as text. A chart is never the only carrier of a number.
Layer 1 — The chooser: question → relationship → chart
| The question | Relationship | Default chart |
|---|---|---|
| How did this move over time? | Change over time | line (many points) or columns (few, discrete points like days) |
| Which item is biggest? | Ranking | sorted horizontal bar |
| How big are these against each other? | Magnitude | column or bar, shared zero baseline |
| How does the total split up? | Part-to-whole | stacked bar — almost never a pie |
| How did the number get from A to B? | Deviation / flow | waterfall (gains green, losses rose) |
| How are values spread out? | Distribution | histogram |
| Are these two variables related? | Correlation | scatter |
| Is this one number on track? | — | no chart — a big number, with a delta |
If you can't name the row, you don't yet know what the chart is for — go back to the page's Job Sentence (see the progressive-disclosure guide).
Why the defaults lean on bars and lines: experiments on graphical perception ranked how accurately people decode encodings. Position on a common scale is best, then length; angle (pie slices) and area (bubbles) are far worse; color saturation is dead last. Three consequences worth enforcing:
- A sorted horizontal bar beats a pie, always, for "which is biggest" — and long labels fit on the left, where pie labels never do.
- Bars share a zero baseline or they lie. Length is the encoding; truncating the axis multiplies the apparent ratio. (Lines may use a non-zero baseline — position, not length, is their encoding.)
- Color is the weakest quantitative channel, so a heatmap is for spotting a pattern, never for reading a value. Print the number in the cell.
Layer 2 — Sorted horizontal bar (ranking), plain divs
The workhorse. Sorting IS the message; the number is printed so the bar is never the only carrier.
<%# app/views/shared/_bar_chart.html.erb
locals: rows: [["Organic search", 412], ["Direct", 288], ...]
color: (optional) ->(label) { css class } — entity colors from your tokens helper %>
<% max = rows.map(&:last).max.to_f %>
<div class="space-y-2">
<% rows.sort_by { |_, v| -v }.each do |label, value| %>
<div class="flex items-center gap-3 text-sm">
<span class="w-40 shrink-0 text-right text-slate-600 truncate"><%= label %></span>
<div class="flex-1 bg-slate-100 rounded h-5 overflow-hidden">
<div class="h-full rounded <%= local_assigns[:color] ? color.call(label) : "bg-slate-500" %>"
style="width: <%= max.zero? ? 0 : (value / max * 100).round(1) %>%"></div>
</div>
<span class="w-14 shrink-0 tabular-nums font-medium text-slate-900"><%= value %></span>
</div>
<% end %>
</div>
Note the fill is bg-slate-500, not bg-slate-200 — a bar that carries a value owes
3:1 contrast (WCAG 1.4.11). The pale bg-slate-100 is only the empty track behind it.
This same partial, un-sorted and with one bar per stage, is also the correct funnel: aligned bars on a shared baseline. Never the tapering-trapezoid graphic — it encodes value in area and breaks the moment users can skip or re-enter stages.
Layer 3 — Cohort heatmap: sequential, fixed scale, numbers printed
<%# app/views/shared/_heatmap_cell.html.erb — locals: pct: (0..100) %>
<%# Sequential SINGLE hue, light → dark. The thresholds are FIXED so the same color
means the same value in every month's report — never rescale to the current page. %>
<% shade =
if pct >= 60 then "bg-indigo-600 text-white"
elsif pct >= 40 then "bg-indigo-400 text-white"
elsif pct >= 20 then "bg-indigo-200 text-slate-800"
else "bg-indigo-50 text-slate-500"
end %>
<td class="px-2 py-1 text-center text-xs tabular-nums <%= shade %>"><%= pct %>%</td>
Read a row to watch one cohort age; read a column to see whether newer cohorts do better. The color finds the pattern; the printed number gives the value. Never a rainbow, and never red-to-green — that's the exact pair lost to the most common color-vision deficiency.
Layer 4 — Waterfall: how the net number got there
The standard view for "new minus churned = net" (revenue movement, headcount, stock). A flat net figure can hide heavy churn masked by heavy new — the waterfall shows how the number got there. This is the one place status color touches a chart mark, because the mark is the direction: emerald gain, rose loss, slate total.
<%# app/views/shared/_waterfall.html.erb — locals: gained:, lost: (both positive numbers) %>
<% net = gained - lost %>
<% scale = 120.0 / [gained, 1].max # px per unit; tallest bar = 120px %>
<% px = ->(v) { (v * scale).round } %>
<div class="flex items-end gap-8 h-40">
<div class="text-center">
<div class="w-16 bg-emerald-500 rounded-t" style="height: <%= px.(gained) %>px"></div>
<div class="mt-1 text-xs text-slate-600">New<div class="font-medium text-slate-900">+<%= gained %></div></div>
</div>
<div class="text-center">
<%# the floating bar: its BOTTOM sits at the net level, its top at the gained level %>
<div class="w-16 bg-rose-500 rounded-b" style="height: <%= px.(lost) %>px; margin-bottom: <%= px.(net) %>px"></div>
<div class="mt-1 text-xs text-slate-600">Churned<div class="font-medium text-slate-900">−<%= lost %></div></div>
</div>
<div class="text-center">
<div class="w-16 bg-slate-500 rounded-t" style="height: <%= px.(net) %>px"></div>
<div class="mt-1 text-xs text-slate-600">Net<div class="font-medium text-slate-900"><%= net %></div></div>
</div>
</div>
Every bar is labeled with its value — an unlabeled chart is decoration.
When NOT to chart
- Fewer than ~4 data points → print the numbers, ideally one big number plus a
delta indicator (see the color guide's
_deltapartial). - The number only matters against a threshold → state the number and the threshold in words: "3 of 5 seats used".
- A table with exact values sits right next to it → keep one. Exact values needed: keep the table. Pattern needed: keep the chart.
- You can't label it → don't ship it.
Gotchas (the hard-won stuff)
- The truncated bar axis is the classic lie. A bar chart starting at 80 makes a 5% difference look like 3×. Bars start at zero, full stop.
- Ranking charts must actually be sorted. An unsorted bar chart makes the reader do the sorting the chart was supposed to do. (Time series are the exception — those sort by time.)
- Heatmap thresholds rescale silently. If you compute shade breakpoints from the current page's min/max, the same color means different values in different months and cross-report comparison is dead. Hard-code the scale.
- Columns for days, lines for trends. ~30 discrete daily values read better as columns; a line implies continuity between the points. Flip to a line when the point count makes columns unreadable.
- Pastel marks fail accessibility.
bg-gray-200/bg-amber-100bars carrying real values fail the 3:1 non-text contrast floor. Meaningful fill ≥ the 400 weight. - Double encoding with no gain — the same value as a bar and a pie and a gauge on one screen is three times the ink for one number. Pick the one the question picks.
- A 40px sparkline with no scale is decoration, not information. If there's no room to label it, there's no room for it.
- If you genuinely need a library (zoom, brushing, thousands of points), vendor a
prebuilt UMD bundle into
app/javascript/vendor/<lib>/and load it withjavascript_include_tag— never a<script src="https://cdn...">tag, which breaks offline and under a strict CSP. - Every chart needs a text equivalent — printed values, a table, or an
aria-labelsummarizing the finding. Screen readers get nothing from a div width.
Ship checklist
[ ] I named the QUESTION and its relationship row before choosing the chart
[ ] Encoding is position or length wherever accuracy matters
[ ] Bars share a zero baseline; ranking charts are sorted
[ ] No pie (if a pie: exactly 2 slices, and a comment says why)
[ ] Heatmap is sequential single-hue with a FIXED scale and printed values
[ ] Every chart is labeled, and every value is readable as text too
[ ] Marks pass 3:1 contrast; entity colors match the rest of the app
[ ] Fewer than 4 points ⇒ I printed numbers instead
[ ] Plain ERB/CSS/SVG — no CDN, no new dependency
Files this pattern touches
app/views/shared/_bar_chart.html.erb # sorted horizontal bars (ranking + funnel)
app/views/shared/_heatmap_cell.html.erb # fixed-scale sequential cell
app/views/shared/_waterfall.html.erb # gained / lost / net
How to adapt to your schema
- Feed
_bar_chartany[["label", value], ...]array from your controller — traffic sources, top customers, stage counts. Passcolor:only when the labels are entities with established hues (see the color guide'sENTITY_COLORS). - The heatmap cell works for any cohort-style grid (retention, usage, attendance). Re-pick the fixed thresholds once for your metric's realistic range, then leave them.
- The waterfall generalizes to any gained/lost/net triple. For multi-step waterfalls
(4+ segments), compute each bar's
margin-bottomas the running total after it — same technique, one loop. - Small apps can skip the partials and inline the markup — but the moment a second page needs the same chart, extract the partial so the encoding can't drift.