---
title: Choosing the Right Chart
slug: choosing-the-right-chart
category: Design
summary: 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.
tags: [design, charts, dataviz, dashboard, svg, tailwind, ux-default]
status: stable
visibility: public
source_project: llamapress.ai admin dashboards
layers: [view]
related:
  - title: Progressive Disclosure for Dense Detail Pages
    url: /cookbook/progressive-disclosure-detail-page
    summary: Run that guide FIRST — a chart nobody needs is still clutter. It decides whether the chart earns a place on the screen at all.
  - title: Color, Icons, and Contrast
    url: /cookbook/color-icons-and-contrast
    summary: The palette authority — entity vs status colors, the channel rule, never-color-alone, and the 3:1 contrast floor every chart mark owes.
---

# 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.

```erb
<%# 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

```erb
<%# 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.

```erb
<%# 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.
