Choosing the Right Chart | LlamaPress Cookbook
LlamaPress
Cookbook
Design Stable

Choosing the Right Chart

A question-first chart chooser plus the no-library ERB/CSS/SVG recipes — why sorted bars beat pies, when a big number beats any chart, the perceptual ranking behind it, and working partials for a sorted bar chart, a fixed-scale heatmap, and a waterfall.

Proven on llamapress.ai admin dashboards view

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

  1. Name the question the chart answers, out loud, before choosing anything.
  2. 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.
  3. Prefer position and length encodings (bars on a shared zero baseline, sorted). Angle (pies) and area (bubbles, trapezoids) decode far less accurately.
  4. Fewer than 4 points → no chart. Print a big number instead.
  5. 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.
  6. 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 _delta partial).
  • 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-100 bars 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 with javascript_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-label summarizing 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

  1. Feed _bar_chart any [["label", value], ...] array from your controller — traffic sources, top customers, stage counts. Pass color: only when the labels are entities with established hues (see the color guide's ENTITY_COLORS).
  2. 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.
  3. The waterfall generalizes to any gained/lost/net triple. For multi-step waterfalls (4+ segments), compute each bar's margin-bottom as the running total after it — same technique, one loop.
  4. 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.

Related