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