Compare commits
5 Commits
761e048811
...
b6140c75ea
| Author | SHA1 | Date | |
|---|---|---|---|
| b6140c75ea | |||
| 53e9803a61 | |||
| fd70ccfc13 | |||
| 673f265398 | |||
| 444b89660f |
@@ -88,7 +88,42 @@ Each sin page follows one shape: **poke → proof → why → the fix → receip
|
||||
`badChart`/`fixedChart` are the spec basenames (no `.json`). `citations[].key`
|
||||
must exist in `src/lib/references.ts` (build throws otherwise).
|
||||
3. New reference? Add it once to `src/lib/references.ts`, then cite its key.
|
||||
4. `npm run dev` and check. The OG card for the sin is generated automatically.
|
||||
Optional frontmatter worth knowing about:
|
||||
- `wild[]` — real specimens of the sin, rendered above the citations. Always
|
||||
`credit` the original and link `sourceUrl` where we found it. A chart that
|
||||
commits two sins goes on both pages with a different `note` on each.
|
||||
- `related[]` — slugs of neighbouring sins; validated at build, so a typo or
|
||||
a renamed slug fails the build rather than shipping a dead link.
|
||||
- `compareNote` — overrides "Same numbers, both charts," which is wrong on a
|
||||
sin whose whole point is an omission.
|
||||
4. Draw the thumbnail: `src/thumbs/<slug>.svg` (see "Thumbnails" below). The
|
||||
build throws if it's missing.
|
||||
5. `npm run dev` and check. The OG card for the sin is generated automatically.
|
||||
|
||||
## Thumbnails
|
||||
|
||||
Every sin has a hand-drawn pictogram at `src/thumbs/<slug>.svg`, used as a
|
||||
banner across the top of its gallery tile and as a small glyph in the
|
||||
"Often found together" cross-links. Lookup is `src/lib/thumbs.ts`, rendered by
|
||||
`SinThumb.astro`; a missing file fails the build.
|
||||
|
||||
- **A symbol of the sin, not a shrunken chart.** In nearly every sin the tell
|
||||
lives in the axis, the scale or the legend — exactly what a chart loses when
|
||||
you shrink it to 120px. A miniature of the bad chart throws away the evidence
|
||||
and leaves a gallery of indistinguishable squiggles. Draw the *tell*: a cut
|
||||
baseline, a down-pointing axis arrow, class breaks that moved.
|
||||
- **The glyph shows the sin, not the fix.** The tile is a rogues' gallery you
|
||||
scan for the one you recognise; the correction lives on the page.
|
||||
- `viewBox="0 0 72 48"`, `fill="none"`, stroke-width 2 for structure. Neutral
|
||||
linework is `currentColor`; the offending element takes `var(--thumb-accent)`.
|
||||
`--thumb-alt` and `--thumb-third` exist for glyphs that need to show two or
|
||||
three distinct series (dual axis, mismatched category colours).
|
||||
- Those three vars resolve to site tokens in `global.css`, so **unlike the
|
||||
charts, thumbnails follow the light/dark theme** — they don't need the fixed
|
||||
`--chart-canvas`, and they must stay legible on both `--layer-02` surfaces.
|
||||
- Check the result at both sizes: the banner is ~72px tall, the cross-link
|
||||
glyph ~36px. A glyph that needs more than about six strokes won't survive the
|
||||
smaller one.
|
||||
|
||||
## Design & rendering
|
||||
|
||||
@@ -126,12 +161,23 @@ Each sin page follows one shape: **poke → proof → why → the fix → receip
|
||||
- GitHub Pages via `.github/workflows/deploy.yml`, triggered on push to `main`
|
||||
(plus `workflow_dispatch`). Project path is configured in `astro.config.mjs`
|
||||
(`site` + `base` → `/chart-sins`).
|
||||
- **Known gotcha — the default branch.** The repo's default branch is currently
|
||||
the feature branch (it was the first branch pushed into the empty repo). The
|
||||
auto-created `github-pages` environment only allows deploys from the **default
|
||||
branch**, so a push to `main` builds but the *deploy* job is rejected. Until
|
||||
this is fixed we deploy by **dispatching the workflow on the default branch**.
|
||||
**Fix:** set the default branch to `main` (Settings → General); then plain
|
||||
pushes to `main` deploy normally and this note can go away.
|
||||
- **Known gotcha — the `github-pages` environment's branch allow-list.** The
|
||||
build job goes green and the *deploy* job fails with **zero steps** and this
|
||||
annotation:
|
||||
`Branch "main" is not allowed to deploy to github-pages due to environment
|
||||
protection rules.`
|
||||
When GitHub auto-creates the `github-pages` environment it pins a deployment
|
||||
branch allow-list to whatever branch was default *at that moment* — here, the
|
||||
feature branch that was pushed into the empty repo first. Changing the repo's
|
||||
default branch later does **not** rewrite that list; we tried on 2026-08-05
|
||||
and the deploy failed again, identically.
|
||||
**Fix:** Settings → Environments → `github-pages` → *Deployment branches and
|
||||
tags* → add `main` (or drop the restriction). Nothing in the workflow file or
|
||||
the default-branch setting can work around it.
|
||||
- A zero-step deploy failure is always this: a protection rule refusing the job
|
||||
before it runs, never a build problem. Read the check-run annotation rather
|
||||
than the logs — a rejected job has no logs.
|
||||
- The environments API is blocked by the agent proxy, so an agent can neither
|
||||
read nor fix this. It has to be a human in repo settings.
|
||||
- Pages had to be **enabled manually** once (Settings → Pages → Source: GitHub
|
||||
Actions) — the Actions token can't create the Pages site itself.
|
||||
|
||||
@@ -17,7 +17,7 @@ runs 2–5 on purpose.
|
||||
|
||||
## Sources in play
|
||||
|
||||
`src/lib/references.ts` currently holds 24 entries, 20 of them cited by the five
|
||||
`src/lib/references.ts` currently holds 24 entries, 22 of them cited by the 8
|
||||
published sins. This table is a **convenience, not a scoreboard** — it's here so
|
||||
you can spot a source that fits a sin you're drafting, not so we can drive the
|
||||
right-hand column to zero blanks. An uncited reference is not a debt.
|
||||
@@ -29,28 +29,28 @@ seven sources, give it seven and add whatever's missing to the file.
|
||||
| --- | --- |
|
||||
| `ftvisvocab` | — |
|
||||
| `healy` | — |
|
||||
| `munzner` | — |
|
||||
| `schwabish` | — |
|
||||
| `fewdualaxes` | dual-axis-correlation |
|
||||
| `fewpies` | too-many-pie-slices |
|
||||
| `huff` | truncated-y-axis |
|
||||
| `isenberg2011` | dual-axis-correlation |
|
||||
| `schwabish` | category-colors |
|
||||
| `shapeparameter` | aspect-ratio |
|
||||
| `showmenumbers` | too-many-pie-slices |
|
||||
| `spurious` | dual-axis-correlation |
|
||||
| `swd` | dual-axis-correlation |
|
||||
| `tufte` | truncated-y-axis |
|
||||
| `vizwtf` | inverted-y-axis |
|
||||
| `callingbullshit` | inverted-y-axis, truncated-y-axis |
|
||||
| `datatoviz` | dual-axis-correlation, too-many-pie-slices |
|
||||
| `datawrapper` | dual-axis-correlation, too-many-pie-slices |
|
||||
| `eagereyes` | aspect-ratio, too-many-pie-slices |
|
||||
| `fewpies` | incomplete-pie, too-many-pie-slices |
|
||||
| `junkcharts` | inverted-y-axis, too-many-pie-slices |
|
||||
| `swd` | category-colors, dual-axis-correlation |
|
||||
| `truthfulart` | aspect-ratio, dual-axis-correlation |
|
||||
| `wilke` | too-many-pie-slices, truncated-y-axis |
|
||||
| `callingbullshit` | inverted-y-axis, rebinned-colour-scale, truncated-y-axis |
|
||||
| `clevelandmcgill` | aspect-ratio, too-many-pie-slices, truncated-y-axis |
|
||||
| `datatoviz` | dual-axis-correlation, incomplete-pie, too-many-pie-slices |
|
||||
| `economistmistakes` | dual-axis-correlation, inverted-y-axis, truncated-y-axis |
|
||||
| `howchartslie` | aspect-ratio, dual-axis-correlation, inverted-y-axis, truncated-y-axis |
|
||||
| `munzner` | category-colors, incomplete-pie, rebinned-colour-scale |
|
||||
| `vizwtf` | category-colors, incomplete-pie, inverted-y-axis, rebinned-colour-scale |
|
||||
| `datawrapper` | category-colors, dual-axis-correlation, incomplete-pie, rebinned-colour-scale, too-many-pie-slices |
|
||||
| `howchartslie` | aspect-ratio, dual-axis-correlation, inverted-y-axis, rebinned-colour-scale, truncated-y-axis |
|
||||
|
||||
The citation lists in each entry below are **starting points, not budgets** —
|
||||
add to them while drafting.
|
||||
@@ -74,7 +74,32 @@ we can build.
|
||||
**Citations:** `huff` (the original con), `callingbullshit`, `howchartslie`,
|
||||
`junkcharts`.
|
||||
|
||||
### 2. Pie Slices That Sum to More Than 100% — *Impossible Wholes*, severity 5
|
||||
### 2. Results From a Hand-Picked Subgroup — *Deceptive Framing*, severity 4
|
||||
|
||||
**Poke:** "You didn't pick the wrong chart, you picked the wrong people. The
|
||||
number is real; the group it describes was chosen after the fact."
|
||||
|
||||
The population-shaped twin of entry 1. Rather than trimming the time window, you
|
||||
report the slice of the audience, region or segment where the number flatters —
|
||||
often labelled honestly, which is what makes it feel above board.
|
||||
|
||||
The published `incomplete-pie` specimen is **also an example of this one**: it
|
||||
reports the primary "in the countryside," excluding the capital, on a graphic
|
||||
whose banner argues the capital can't win. Reuse the same image here with a note
|
||||
about this failure mode — that's what the `wild` array is for, and it's the case
|
||||
for keeping these as two pages rather than one.
|
||||
|
||||
Worth its own page rather than folding into entry 1: the tell is different (no
|
||||
axis to inspect — you have to know what got left out), and the fix is different
|
||||
(report the whole population, or every subgroup side by side, not just the
|
||||
flattering one).
|
||||
|
||||
**Charts:** bad = one subgroup's result. Fixed = every subgroup plus the overall
|
||||
figure, same underlying data.
|
||||
|
||||
**Citations:** `callingbullshit`, `howchartslie`, `huff`, `vizwtf`.
|
||||
|
||||
### 3. Pie Slices That Sum to More Than 100% — *Impossible Wholes*, severity 5
|
||||
|
||||
**Poke:** "Your slices add up to 180%. A pie has one job — dividing a whole —
|
||||
and this data has no whole to divide."
|
||||
@@ -97,7 +122,7 @@ pie-chart post says "one pie chart can only show one total and its shares," and
|
||||
it does *not* separately argue that parts must be mutually exclusive. Don't
|
||||
stretch it further than that. Plus `datatoviz`, `junkcharts`.
|
||||
|
||||
### 3. A Pie Chart of Rates or Averages — *Impossible Wholes*, severity 5
|
||||
### 4. A Pie Chart of Rates or Averages — *Impossible Wholes*, severity 5
|
||||
|
||||
**Poke:** "You made a pie out of averages. Adding them together produces a
|
||||
number that means nothing, and that number is your denominator."
|
||||
@@ -120,7 +145,7 @@ same averages, plus the volume behind each one so the weighting is visible.
|
||||
or it is nothing), `datawrapper` (same one-total premise as above), `schwabish`,
|
||||
`ftvisvocab`.
|
||||
|
||||
### 4. Group Averages Shown Without the Distribution — *Hiding the Data*, severity 4
|
||||
### 5. Group Averages Shown Without the Distribution — *Hiding the Data*, severity 4
|
||||
|
||||
**Poke:** "Four bars, four averages, and no idea whether the difference is real."
|
||||
|
||||
@@ -133,7 +158,7 @@ observations, means still marked.
|
||||
|
||||
**Citations:** `wilke`, `healy`, `schwabish`, `datatoviz`.
|
||||
|
||||
### 5. Bubbles Sized by Radius Instead of Area — *Perceptual Traps*, severity 4
|
||||
### 6. Bubbles Sized by Radius Instead of Area — *Perceptual Traps*, severity 4
|
||||
|
||||
**Poke:** "You doubled the number and quadrupled the ink."
|
||||
|
||||
@@ -148,7 +173,7 @@ position does the work.
|
||||
|
||||
**Citations:** `munzner`, `clevelandmcgill`, `tufte`, `howchartslie`.
|
||||
|
||||
### 6. Stacked Segments Without a Shared Baseline — *Perceptual Traps*, severity 3
|
||||
### 7. Stacked Segments Without a Shared Baseline — *Perceptual Traps*, severity 3
|
||||
|
||||
**Poke:** "Only the bottom band of a stacked chart has a straight edge to
|
||||
measure against. The rest are guesses."
|
||||
@@ -175,7 +200,7 @@ out to its own zero baseline), totals preserved.
|
||||
|
||||
**Citations:** `datawrapper`, `wilke`, `munzner`, `ftvisvocab`.
|
||||
|
||||
### 7. Categories Sorted Alphabetically Instead of by Value — *Sloppy Craft*, severity 2
|
||||
### 8. Categories Sorted Alphabetically Instead of by Value — *Sloppy Craft*, severity 2
|
||||
|
||||
**Poke:** "Your categories are sorted by name. Nobody wants to know which
|
||||
region starts with A."
|
||||
@@ -210,6 +235,22 @@ rediscovering them:
|
||||
too: the fixed chart needs a sequential ramp validated on `--chart-canvas` in
|
||||
both themes. Don't draft this one until a real source is in hand.
|
||||
|
||||
## Two new levers, as of the first wild specimen
|
||||
|
||||
Three specimens are live so far (`incomplete-pie`, `category-colors`,
|
||||
`rebinned-colour-scale`), all collected from WTF Visualizations. Each one
|
||||
arrived before its sin existed, which is turning out to be the healthier order:
|
||||
a real chart in hand makes the failure mode obvious and stops us inventing sins
|
||||
nobody commits.
|
||||
|
||||
- **`wild`** — real examples, credited and linked, rendered above the citations.
|
||||
Zero, one or many per sin. A specimen that commits two sins belongs on both
|
||||
pages, with a different note on each; that's a reason to split sins, not merge
|
||||
them.
|
||||
- **`related`** — cross-links between sins, validated at build. Charts rarely
|
||||
break one rule at a time, and the person you sent a link to may need the
|
||||
neighbouring page more than the one they landed on.
|
||||
|
||||
## Conventions reminder
|
||||
|
||||
Note what the two pie entries above do and don't say. Neither condemns the pie
|
||||
|
||||
@@ -37,6 +37,8 @@ Two standing rules for step 3:
|
||||
| 11 | Robert Kosara, *Understanding Pie Charts* (eagereyes) | URL, and what his experiments actually found. We lean on this to justify not condemning pie charts outright (claim V7). |
|
||||
| 12 | The Economist, "Mistakes, we've drawn a few" | **Canonical URL only** — the content arrived and is settled below, but `economistmistakes` still ships without a link. |
|
||||
|
||||
| 13 | `viz.wtf/post/664318320965795840/primary-election-result` | The post's caption and date. viz.wtf 403s automated fetches, so the specimen on `incomplete-pie` is credited from the logos legible in the image itself (Demokratikus Koalíció / Dobrev Klára) rather than from the post. The arithmetic and the measured arc angles are ours and don't depend on it. |
|
||||
|
||||
## Claims to verify
|
||||
|
||||
Live on the site, resting on recall rather than a source anyone opened.
|
||||
|
||||
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"title": "Cars Sold by Colour",
|
||||
"width": 340,
|
||||
"height": 220,
|
||||
"data": {
|
||||
"values": [
|
||||
{ "colour": "White", "sold": 34 },
|
||||
{ "colour": "Black", "sold": 27 },
|
||||
{ "colour": "Grey", "sold": 18 },
|
||||
{ "colour": "Blue", "sold": 9 },
|
||||
{ "colour": "Red", "sold": 7 },
|
||||
{ "colour": "Green", "sold": 3 },
|
||||
{ "colour": "Yellow", "sold": 2 }
|
||||
]
|
||||
},
|
||||
"mark": "bar",
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": "colour",
|
||||
"type": "nominal",
|
||||
"sort": null,
|
||||
"axis": { "labelAngle": 0, "title": null }
|
||||
},
|
||||
"y": { "field": "sold", "type": "quantitative", "scale": { "zero": true }, "title": "Cars sold" },
|
||||
"color": {
|
||||
"field": "colour",
|
||||
"type": "nominal",
|
||||
"sort": null,
|
||||
"legend": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"title": "Cars Sold by Colour",
|
||||
"width": 340,
|
||||
"height": 220,
|
||||
"data": {
|
||||
"values": [
|
||||
{ "colour": "White", "sold": 34 },
|
||||
{ "colour": "Black", "sold": 27 },
|
||||
{ "colour": "Grey", "sold": 18 },
|
||||
{ "colour": "Blue", "sold": 9 },
|
||||
{ "colour": "Red", "sold": 7 },
|
||||
{ "colour": "Green", "sold": 3 },
|
||||
{ "colour": "Yellow", "sold": 2 }
|
||||
]
|
||||
},
|
||||
"mark": { "type": "bar", "stroke": "#8d8d8d", "strokeWidth": 0.6 },
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": "colour",
|
||||
"type": "nominal",
|
||||
"sort": null,
|
||||
"axis": { "labelAngle": 0, "title": null }
|
||||
},
|
||||
"y": { "field": "sold", "type": "quantitative", "scale": { "zero": true }, "title": "Cars sold" },
|
||||
"color": {
|
||||
"field": "colour",
|
||||
"type": "nominal",
|
||||
"sort": null,
|
||||
"scale": {
|
||||
"domain": ["White", "Black", "Grey", "Blue", "Red", "Green", "Yellow"],
|
||||
"range": ["#ffffff", "#161616", "#8d8d8d", "#0f62fe", "#da1e28", "#198038", "#f1c21b"]
|
||||
},
|
||||
"legend": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"title": "Vote Share by Candidate (%)",
|
||||
"width": 300,
|
||||
"height": 260,
|
||||
"data": {
|
||||
"values": [
|
||||
{ "candidate": "Alvarez", "share": 38 },
|
||||
{ "candidate": "Boone", "share": 22 },
|
||||
{ "candidate": "Chen", "share": 19 },
|
||||
{ "candidate": "Everyone else", "share": 21 }
|
||||
]
|
||||
},
|
||||
"transform": [{ "filter": "datum.candidate !== 'Everyone else'" }],
|
||||
"mark": { "type": "arc" },
|
||||
"encoding": {
|
||||
"theta": { "field": "share", "type": "quantitative" },
|
||||
"color": {
|
||||
"field": "candidate",
|
||||
"type": "nominal",
|
||||
"sort": ["Alvarez", "Boone", "Chen", "Everyone else"],
|
||||
"legend": { "title": "Candidate" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"title": "Vote Share by Candidate (%)",
|
||||
"width": 300,
|
||||
"height": 260,
|
||||
"data": {
|
||||
"values": [
|
||||
{ "candidate": "Alvarez", "share": 38 },
|
||||
{ "candidate": "Boone", "share": 22 },
|
||||
{ "candidate": "Chen", "share": 19 },
|
||||
{ "candidate": "Everyone else", "share": 21 }
|
||||
]
|
||||
},
|
||||
"mark": { "type": "arc" },
|
||||
"encoding": {
|
||||
"theta": { "field": "share", "type": "quantitative" },
|
||||
"color": {
|
||||
"field": "candidate",
|
||||
"type": "nominal",
|
||||
"sort": ["Alvarez", "Boone", "Chen", "Everyone else"],
|
||||
"legend": { "title": "Candidate" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
{
|
||||
"title": "7-Day Incidence by District",
|
||||
"data": {
|
||||
"values": [
|
||||
{
|
||||
"district": "A",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 22
|
||||
},
|
||||
{
|
||||
"district": "B",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 38
|
||||
},
|
||||
{
|
||||
"district": "C",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 45
|
||||
},
|
||||
{
|
||||
"district": "D",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 60
|
||||
},
|
||||
{
|
||||
"district": "E",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 85
|
||||
},
|
||||
{
|
||||
"district": "F",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 110
|
||||
},
|
||||
{
|
||||
"district": "G",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 140
|
||||
},
|
||||
{
|
||||
"district": "H",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 190
|
||||
},
|
||||
{
|
||||
"district": "A",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 45
|
||||
},
|
||||
{
|
||||
"district": "B",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"district": "C",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 88
|
||||
},
|
||||
{
|
||||
"district": "D",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 120
|
||||
},
|
||||
{
|
||||
"district": "E",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 150
|
||||
},
|
||||
{
|
||||
"district": "F",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 175
|
||||
},
|
||||
{
|
||||
"district": "G",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 210
|
||||
},
|
||||
{
|
||||
"district": "H",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 260
|
||||
}
|
||||
]
|
||||
},
|
||||
"hconcat": [
|
||||
{
|
||||
"title": "17 March",
|
||||
"width": 190,
|
||||
"height": 46,
|
||||
"transform": [
|
||||
{
|
||||
"filter": "datum.when === '17 March'"
|
||||
}
|
||||
],
|
||||
"mark": {
|
||||
"type": "rect",
|
||||
"stroke": "#e0e0e0",
|
||||
"strokeWidth": 0.5
|
||||
},
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": "district",
|
||||
"type": "nominal",
|
||||
"sort": null,
|
||||
"axis": {
|
||||
"labelAngle": 0,
|
||||
"title": null
|
||||
}
|
||||
},
|
||||
"y": {
|
||||
"field": "row",
|
||||
"type": "nominal",
|
||||
"axis": {
|
||||
"title": null,
|
||||
"labels": false,
|
||||
"ticks": false,
|
||||
"domain": false
|
||||
}
|
||||
},
|
||||
"color": {
|
||||
"field": "value",
|
||||
"type": "quantitative",
|
||||
"scale": {
|
||||
"type": "threshold",
|
||||
"domain": [
|
||||
25,
|
||||
50,
|
||||
100,
|
||||
250
|
||||
],
|
||||
"range": [
|
||||
"#fff1f1",
|
||||
"#ffb3b8",
|
||||
"#fa4d56",
|
||||
"#da1e28",
|
||||
"#750e13"
|
||||
]
|
||||
},
|
||||
"legend": {
|
||||
"title": "cases / 100k",
|
||||
"orient": "bottom",
|
||||
"direction": "horizontal"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "9 April",
|
||||
"width": 190,
|
||||
"height": 46,
|
||||
"transform": [
|
||||
{
|
||||
"filter": "datum.when === '9 April'"
|
||||
}
|
||||
],
|
||||
"mark": {
|
||||
"type": "rect",
|
||||
"stroke": "#e0e0e0",
|
||||
"strokeWidth": 0.5
|
||||
},
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": "district",
|
||||
"type": "nominal",
|
||||
"sort": null,
|
||||
"axis": {
|
||||
"labelAngle": 0,
|
||||
"title": null
|
||||
}
|
||||
},
|
||||
"y": {
|
||||
"field": "row",
|
||||
"type": "nominal",
|
||||
"axis": {
|
||||
"title": null,
|
||||
"labels": false,
|
||||
"ticks": false,
|
||||
"domain": false
|
||||
}
|
||||
},
|
||||
"color": {
|
||||
"field": "value",
|
||||
"type": "quantitative",
|
||||
"scale": {
|
||||
"type": "threshold",
|
||||
"domain": [
|
||||
20,
|
||||
35,
|
||||
50,
|
||||
100,
|
||||
200
|
||||
],
|
||||
"range": [
|
||||
"#fff1f1",
|
||||
"#ffd7d9",
|
||||
"#ffb3b8",
|
||||
"#fa4d56",
|
||||
"#da1e28",
|
||||
"#750e13"
|
||||
]
|
||||
},
|
||||
"legend": {
|
||||
"title": "cases / 100k",
|
||||
"orient": "bottom",
|
||||
"direction": "horizontal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"resolve": {
|
||||
"scale": {
|
||||
"color": "independent"
|
||||
},
|
||||
"legend": {
|
||||
"color": "independent"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"title": "7-Day Incidence by District",
|
||||
"data": {
|
||||
"values": [
|
||||
{
|
||||
"district": "A",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 22
|
||||
},
|
||||
{
|
||||
"district": "B",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 38
|
||||
},
|
||||
{
|
||||
"district": "C",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 45
|
||||
},
|
||||
{
|
||||
"district": "D",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 60
|
||||
},
|
||||
{
|
||||
"district": "E",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 85
|
||||
},
|
||||
{
|
||||
"district": "F",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 110
|
||||
},
|
||||
{
|
||||
"district": "G",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 140
|
||||
},
|
||||
{
|
||||
"district": "H",
|
||||
"row": "7-day incidence",
|
||||
"when": "17 March",
|
||||
"value": 190
|
||||
},
|
||||
{
|
||||
"district": "A",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 45
|
||||
},
|
||||
{
|
||||
"district": "B",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"district": "C",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 88
|
||||
},
|
||||
{
|
||||
"district": "D",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 120
|
||||
},
|
||||
{
|
||||
"district": "E",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 150
|
||||
},
|
||||
{
|
||||
"district": "F",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 175
|
||||
},
|
||||
{
|
||||
"district": "G",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 210
|
||||
},
|
||||
{
|
||||
"district": "H",
|
||||
"row": "7-day incidence",
|
||||
"when": "9 April",
|
||||
"value": 260
|
||||
}
|
||||
]
|
||||
},
|
||||
"hconcat": [
|
||||
{
|
||||
"title": "17 March",
|
||||
"width": 190,
|
||||
"height": 46,
|
||||
"transform": [
|
||||
{
|
||||
"filter": "datum.when === '17 March'"
|
||||
}
|
||||
],
|
||||
"mark": {
|
||||
"type": "rect",
|
||||
"stroke": "#e0e0e0",
|
||||
"strokeWidth": 0.5
|
||||
},
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": "district",
|
||||
"type": "nominal",
|
||||
"sort": null,
|
||||
"axis": {
|
||||
"labelAngle": 0,
|
||||
"title": null
|
||||
}
|
||||
},
|
||||
"y": {
|
||||
"field": "row",
|
||||
"type": "nominal",
|
||||
"axis": {
|
||||
"title": null,
|
||||
"labels": false,
|
||||
"ticks": false,
|
||||
"domain": false
|
||||
}
|
||||
},
|
||||
"color": {
|
||||
"field": "value",
|
||||
"type": "quantitative",
|
||||
"scale": {
|
||||
"type": "threshold",
|
||||
"domain": [
|
||||
25,
|
||||
50,
|
||||
100,
|
||||
250
|
||||
],
|
||||
"range": [
|
||||
"#fff1f1",
|
||||
"#ffb3b8",
|
||||
"#fa4d56",
|
||||
"#da1e28",
|
||||
"#750e13"
|
||||
]
|
||||
},
|
||||
"legend": {
|
||||
"title": "cases / 100k",
|
||||
"orient": "bottom",
|
||||
"direction": "horizontal"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "9 April",
|
||||
"width": 190,
|
||||
"height": 46,
|
||||
"transform": [
|
||||
{
|
||||
"filter": "datum.when === '9 April'"
|
||||
}
|
||||
],
|
||||
"mark": {
|
||||
"type": "rect",
|
||||
"stroke": "#e0e0e0",
|
||||
"strokeWidth": 0.5
|
||||
},
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": "district",
|
||||
"type": "nominal",
|
||||
"sort": null,
|
||||
"axis": {
|
||||
"labelAngle": 0,
|
||||
"title": null
|
||||
}
|
||||
},
|
||||
"y": {
|
||||
"field": "row",
|
||||
"type": "nominal",
|
||||
"axis": {
|
||||
"title": null,
|
||||
"labels": false,
|
||||
"ticks": false,
|
||||
"domain": false
|
||||
}
|
||||
},
|
||||
"color": {
|
||||
"field": "value",
|
||||
"type": "quantitative",
|
||||
"scale": {
|
||||
"type": "threshold",
|
||||
"domain": [
|
||||
25,
|
||||
50,
|
||||
100,
|
||||
250
|
||||
],
|
||||
"range": [
|
||||
"#fff1f1",
|
||||
"#ffb3b8",
|
||||
"#fa4d56",
|
||||
"#da1e28",
|
||||
"#750e13"
|
||||
]
|
||||
},
|
||||
"legend": null
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"resolve": {
|
||||
"scale": {
|
||||
"color": "shared"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
// "While you're here" — the other sins a chart like this usually commits.
|
||||
// Charts rarely break one rule at a time, and the person you sent this to may
|
||||
// need the neighbouring page more than this one. Renders nothing when empty.
|
||||
import SinThumb from './SinThumb.astro';
|
||||
import { href } from '../lib/url';
|
||||
import { severityUnits } from '../lib/charts';
|
||||
|
||||
interface Props {
|
||||
sins: { id: string; data: { title: string; summary: string; severity: number } }[];
|
||||
}
|
||||
|
||||
const { sins } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
sins.length > 0 && (
|
||||
<nav class="related" aria-labelledby="related-h">
|
||||
<h2 id="related-h" class="related__title">Often found together</h2>
|
||||
<ul class="related__list">
|
||||
{sins.map((s) => (
|
||||
<li class="related__item">
|
||||
<SinThumb slug={s.id} variant="glyph" />
|
||||
<a class="related__link" href={href(`sins/${s.id}/`)}>
|
||||
<span class="related__name">{s.data.title}</span>
|
||||
<span class="related__summary">{s.data.summary}</span>
|
||||
</a>
|
||||
<span class="related__sev" aria-label={`Severity ${s.data.severity} of 5`}>
|
||||
{severityUnits(s.data.severity).map((on) => (
|
||||
<i class={`related__unit${on ? ' related__unit--on' : ''}`} />
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
// The pictogram that stands in for a sin in navigation — a banner across the
|
||||
// top of a gallery tile, or a small glyph beside a cross-link.
|
||||
//
|
||||
// Always decorative: the sin's title sits right next to it and carries the
|
||||
// meaning, so announcing the glyph too would just be a duplicate.
|
||||
import { getThumb } from '../lib/thumbs';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
/** "banner" for the gallery tile, "glyph" for the compact cross-link row. */
|
||||
variant?: 'banner' | 'glyph';
|
||||
}
|
||||
|
||||
const { slug, variant = 'banner' } = Astro.props;
|
||||
const svg = getThumb(slug);
|
||||
---
|
||||
|
||||
<span class={`thumb thumb--${variant}`} aria-hidden="true" set:html={svg} />
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
// Real specimens of a sin, published by someone else. Sits between the
|
||||
// explanation and the citations: first the argument, then proof it happens,
|
||||
// then the sources. Renders nothing when a sin has no examples yet.
|
||||
import { href } from '../lib/url';
|
||||
|
||||
interface WildExample {
|
||||
caption: string;
|
||||
note: string;
|
||||
image?: string;
|
||||
alt?: string;
|
||||
credit: string;
|
||||
sourceUrl?: string;
|
||||
sourceLabel?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
examples: WildExample[];
|
||||
}
|
||||
|
||||
const { examples } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
examples.length > 0 && (
|
||||
<section class="wild" aria-labelledby="wild-h">
|
||||
<h2 id="wild-h" class="wild__title">Caught in the wild</h2>
|
||||
<p class="wild__lede">
|
||||
{examples.length === 1
|
||||
? 'Not a mock-up. Someone published this.'
|
||||
: 'Not mock-ups. Someone published each of these.'}
|
||||
</p>
|
||||
<ol class="wild__list">
|
||||
{examples.map((ex) => (
|
||||
<li class="wild__item">
|
||||
{ex.image && (
|
||||
<figure class="wild__figure">
|
||||
<img
|
||||
class="wild__img"
|
||||
src={href(`wild/${ex.image}`)}
|
||||
alt={ex.alt ?? ex.caption}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</figure>
|
||||
)}
|
||||
<p class="wild__caption">{ex.caption}</p>
|
||||
<p class="wild__note">{ex.note}</p>
|
||||
<p class="wild__credit">
|
||||
<span>{ex.credit}</span>
|
||||
{ex.sourceUrl && (
|
||||
<>
|
||||
{' · '}
|
||||
<a href={ex.sourceUrl} rel="noopener">
|
||||
{ex.sourceLabel ?? 'source'}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,29 @@ const sins = defineCollection({
|
||||
// Basenames of Vega-Lite specs in src/charts/ (without .json).
|
||||
badChart: z.string(),
|
||||
fixedChart: z.string(),
|
||||
// Overrides the line under the before/after pair. Default asserts the two
|
||||
// charts share one dataset; set it when the sin *is* an omission and that
|
||||
// wording would be wrong.
|
||||
compareNote: z.string().optional(),
|
||||
// Real specimens of this sin, published by someone else and found in the
|
||||
// wild. Zero, one or many. Rendered above the citations. Always credit the
|
||||
// original and link where it was collected when we can.
|
||||
wild: z
|
||||
.array(
|
||||
z.object({
|
||||
caption: z.string(),
|
||||
note: z.string(),
|
||||
image: z.string().optional(),
|
||||
alt: z.string().optional(),
|
||||
credit: z.string(),
|
||||
sourceUrl: z.string().optional(),
|
||||
sourceLabel: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
// Slugs of other sins worth reading next — a chart usually commits more
|
||||
// than one. Validated at build; an unknown slug throws.
|
||||
related: z.array(z.string()).default([]),
|
||||
// Optional publish date; unset entries sort last.
|
||||
date: z.coerce.date().optional(),
|
||||
draft: z.boolean().default(false),
|
||||
|
||||
@@ -8,6 +8,9 @@ tags: ["line chart", "aspect ratio", "exaggeration"]
|
||||
badChart: "aspect-ratio-bad"
|
||||
fixedChart: "aspect-ratio-fixed"
|
||||
date: 2026-08-05
|
||||
related:
|
||||
- truncated-y-axis
|
||||
- dual-axis-correlation
|
||||
citations:
|
||||
- key: shapeparameter
|
||||
note: "The paper that made this measurable: a graph's width-to-height ratio changes how slopes are judged, and gave us “bank the average slope toward 45°.”"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "Bar Colours That Contradict Their Labels"
|
||||
summary: "The categories already have colours. The chart assigned different ones anyway."
|
||||
poke: "Your categories are colours, and you let the software pick different ones. The reader now has to override what they can see in order to read the chart."
|
||||
category: "Sloppy Craft"
|
||||
severity: 2
|
||||
tags: ["bar chart", "colour", "encoding"]
|
||||
badChart: "category-color-bad"
|
||||
fixedChart: "category-color-fixed"
|
||||
related:
|
||||
- too-many-pie-slices
|
||||
date: 2026-08-05
|
||||
wild:
|
||||
- caption: "“Shoe color Frequency” — a survey of shoe colours, plotted in a spreadsheet's default palette."
|
||||
note: "Every category is a colour, and not one bar matches. Black is a green bar. White is a blue bar. Red is orange, grey is teal, brown is navy. The numbers are all correct — you simply cannot use the fastest channel on the chart, because it has been filled with an answer to a different question."
|
||||
image: "shoe-colour-frequency.jpg"
|
||||
alt: "Bar chart titled Shoe color Frequency with categories White, Blue, Yellow, Black, Red, Grey and Brown, each drawn in an unrelated default palette colour."
|
||||
credit: "Spotted in the wild; original author unknown"
|
||||
sourceLabel: "collected by WTF Visualizations"
|
||||
sourceUrl: "https://viz.wtf/post/639589038118125568"
|
||||
citations:
|
||||
- key: munzner
|
||||
note: "Colour is a channel that carries meaning whether or not you meant it to. Spending it on nothing is the cheap version of this mistake; spending it on the wrong thing is this one."
|
||||
- key: swd
|
||||
note: "Knaflic's rule that every colour on a chart should be a decision, made for a reason the reader can recover."
|
||||
- key: datawrapper
|
||||
note: "Their colour writing keeps returning to the same point: a palette is a mapping to data, not decoration."
|
||||
- key: vizwtf
|
||||
note: "Where the specimen above was collected."
|
||||
- key: schwabish
|
||||
note: "Chart-by-chart on matching the encoding to what the reader is actually being asked to do."
|
||||
---
|
||||
|
||||
## The sin
|
||||
|
||||
Some categories arrive with a colour already attached. Colours themselves,
|
||||
obviously — but also political parties, sports teams, brands, traffic-light
|
||||
statuses, the red and black of a balance sheet. The reader knows the mapping
|
||||
before they reach your chart.
|
||||
|
||||
Ignore it and you don't get a neutral chart. You get one that argues with
|
||||
itself: a bar labelled *Black* drawn in green, one labelled *White* drawn in
|
||||
blue. Every value is correct and the chart is still exhausting, because the
|
||||
fastest channel on the page is now pointing somewhere the labels aren't.
|
||||
|
||||
## Why it deceives
|
||||
|
||||
"Deceives" is generous — nothing here is false, which is why this is a two and
|
||||
not a four. But colour is pre-attentive: it lands before you've read a single
|
||||
label. So the chart hands the reader an answer, the labels hand them a different
|
||||
one, and the reader has to consciously suppress the first to trust the second.
|
||||
|
||||
That cost is paid on every glance, by every reader, forever, to save the author
|
||||
one minute. And it doesn't stay merely annoying: anyone skimming, quoting from
|
||||
memory, or looking at a thumbnail will take the colour at face value.
|
||||
|
||||
## The repentance
|
||||
|
||||
If a category has a natural colour, use it. The fix above is not clever — it
|
||||
assigns each bar the colour it is named after, and adds a hairline stroke so
|
||||
white and yellow keep an edge against the canvas.
|
||||
|
||||
If your categories *don't* have natural colours, the other repentance applies:
|
||||
don't colour them at all. Seven bars in seven hues where a single hue would do
|
||||
is the same mistake wearing a nicer coat — colour that encodes nothing but is
|
||||
read as though it encodes something. One colour, or one highlighted bar and the
|
||||
rest in grey.
|
||||
|
||||
The test is simple. Ask what a reader would conclude from the colours alone. If
|
||||
the answer is "something wrong," or "nothing," change them.
|
||||
@@ -8,6 +8,9 @@ tags: ["dual axis", "correlation", "line chart"]
|
||||
badChart: "dual-axis-bad"
|
||||
fixedChart: "dual-axis-fixed"
|
||||
date: 2026-08-05
|
||||
related:
|
||||
- aspect-ratio
|
||||
- truncated-y-axis
|
||||
citations:
|
||||
- key: datatoviz
|
||||
note: "Lists “using two Y axes” as a top caveat — a way to manipulate the story."
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: "Pie Slices That Don't Sum to the Whole"
|
||||
summary: "Four-fifths of a result, drawn as if it were all of it — so every share on the chart comes out too big."
|
||||
poke: "Your slices don't add up to the whole, but they fill the circle anyway. Every number on that chart is bigger than the truth."
|
||||
category: "Impossible Wholes"
|
||||
severity: 5
|
||||
tags: ["pie chart", "part-to-whole", "share"]
|
||||
badChart: "incomplete-pie-bad"
|
||||
fixedChart: "incomplete-pie-fixed"
|
||||
compareNote: "One set of results in both charts. The sinful one just leaves a slice out."
|
||||
date: 2026-08-05
|
||||
wild:
|
||||
- caption: "Hungarian opposition primary, 2021 — results “in the countryside.”"
|
||||
note: "The three labelled slices read 38%, 22% and 19%: 79% in all. The remaining 21% — every other candidate — isn't drawn, and the circle is full anyway, so each share was quietly scaled up by a factor of about 1.27. Measuring the image, the 38% slice occupies 173.6° of arc, which is 48.3% of the pie. The graphic argues its own candidate is the one who can win outside the capital, and it draws her a whisker short of half the vote when she polled 38%."
|
||||
image: "hu-primary-countryside.jpg"
|
||||
alt: "Pie chart headed AZ ELLENZÉKI ELŐVÁLASZTÁS VÉGEREDMÉNYE VIDÉKEN, with three slices labelled Dobrev Klára 38%, Karácsony Gergely 22%, and Márki-Zay Péter 19%, filling the whole circle."
|
||||
credit: "Campaign graphic by Demokratikus Koalíció / Dobrev Klára"
|
||||
sourceLabel: "collected by WTF Visualizations"
|
||||
sourceUrl: "https://viz.wtf/post/664318320965795840/primary-election-result"
|
||||
related:
|
||||
- too-many-pie-slices
|
||||
citations:
|
||||
- key: datawrapper
|
||||
note: "Their pie-chart guidance starts from the premise this chart breaks: “one pie chart can only show one total and its shares.”"
|
||||
- key: munzner
|
||||
note: "Part-to-whole is a composition of a whole. Remove part of the whole and the encoding no longer means anything."
|
||||
- key: fewpies
|
||||
note: "Few's case against the pie, which lands hardest when the circle isn't even a real total."
|
||||
- key: vizwtf
|
||||
note: "Where the specimen above was collected — the archive exists because this ships to real audiences."
|
||||
- key: datatoviz
|
||||
note: "Its pie-chart caveats cover the arithmetic a pie quietly assumes."
|
||||
---
|
||||
|
||||
## The sin
|
||||
|
||||
A pie chart makes exactly one promise: the circle is everything, and each wedge
|
||||
is its share of that everything. Every reading depends on it. "Nearly half"
|
||||
means nearly half *of the total* — otherwise the phrase is empty.
|
||||
|
||||
So when the parts don't add up to the whole, the tool has two options, and both
|
||||
are bad. Either it leaves a gap in the circle — which nobody ships — or it
|
||||
scales the parts up until they close it. Charting libraries do the second
|
||||
silently, because they compute each angle as a fraction of *the values you
|
||||
gave them*, not of the total you meant.
|
||||
|
||||
Above, three candidates hold 38%, 22% and 19% of the vote. That's 79%. The
|
||||
remaining 21% went to people not shown, and dropping them doesn't shrink the
|
||||
chart — it inflates everyone left in it. The leading candidate's 38% is drawn as
|
||||
48% of the circle.
|
||||
|
||||
## Why it deceives
|
||||
|
||||
Nothing signals the omission. The circle is closed, the percentages are printed
|
||||
correctly next to the slices, and the labels and the geometry disagree with each
|
||||
other in a way almost nobody checks — because checking means adding three
|
||||
numbers and noticing they don't reach 100.
|
||||
|
||||
The reader is doing what the chart asked: comparing areas. The areas are wrong.
|
||||
And because every slice is inflated by the same factor, the chart stays
|
||||
internally consistent — it looks exactly like a well-formed pie right up until
|
||||
you do the arithmetic.
|
||||
|
||||
This is worse than a hard-to-read chart. A crowded pie makes you guess; this one
|
||||
answers confidently and wrong.
|
||||
|
||||
## The repentance
|
||||
|
||||
Include the whole whole. If the small categories are too numerous or too dull to
|
||||
list, that's what an **"Other" wedge** is for — it costs one slice and one label,
|
||||
and it makes every other slice true. The fixed chart above does nothing more
|
||||
clever than that.
|
||||
|
||||
If you can't account for the remainder — a poll with undecideds, a total that
|
||||
genuinely isn't known — then you don't have a part-to-whole relationship, and a
|
||||
pie is the wrong form. Use a bar chart, where each value stands on its own
|
||||
baseline and nothing has to sum to anything.
|
||||
|
||||
And if you're tempted to drop the remainder because it makes your number look
|
||||
better: that's not a charting decision.
|
||||
@@ -8,6 +8,9 @@ tags: ["axis", "area chart", "direction"]
|
||||
badChart: "inverted-axis-bad"
|
||||
fixedChart: "inverted-axis-fixed"
|
||||
date: 2026-08-05
|
||||
related:
|
||||
- truncated-y-axis
|
||||
- aspect-ratio
|
||||
citations:
|
||||
- key: callingbullshit
|
||||
note: "Their “Misleading Axes” module collects the axis manipulations, this one included."
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: "A Colour Scale That Changed Between Two Charts"
|
||||
summary: "Two snapshots of the same measure, placed side by side, with the legend quietly rebinned in between."
|
||||
poke: "You changed the bins between the before and the after. Whatever the reader sees in that comparison, some of it is your legend, not your data."
|
||||
category: "Misleading Scales"
|
||||
severity: 4
|
||||
tags: ["colour scale", "comparison", "heatmap", "map"]
|
||||
badChart: "rebinned-scale-bad"
|
||||
fixedChart: "rebinned-scale-fixed"
|
||||
compareNote: "The same readings in both, at both dates. Only the legend changed."
|
||||
related:
|
||||
- truncated-y-axis
|
||||
- aspect-ratio
|
||||
date: 2026-08-05
|
||||
wild:
|
||||
- caption: "Tagesschau, 7-day COVID incidence by German district: 17 March 2021 beside 9 April 2021."
|
||||
note: "The two maps are the same measure three weeks apart, and they invite exactly one reading: look how much worse it got. But the legend was rebinned in between. On 17 March the bands ran to 25 / 50 / 100 / 250 / 500; on 9 April, to 20 / 35 / 50 / 100 / 200 / above. A district sitting at 150 is a mid-orange on the left map and near-black on the right, without having moved at all. Incidence did rise over those three weeks — that part is real. What the pair can't tell you is how much of the colour change is the virus and how much is the key."
|
||||
image: "tagesschau-incidence-bins.jpg"
|
||||
alt: "Two choropleth maps of Germany showing 7-day COVID incidence by district, dated 17 March and 9 April 2021, each with a different set of legend thresholds."
|
||||
credit: "Maps by Tagesschau (ARD), data from the Robert Koch-Institut"
|
||||
sourceLabel: "collected by WTF Visualizations"
|
||||
sourceUrl: "https://viz.wtf/post/648282602475798528/german-main-news-website-changed-the-categrories"
|
||||
citations:
|
||||
- key: howchartslie
|
||||
note: "Cairo on how the encoding, not the data, does the arguing when a reader compares two pictures."
|
||||
- key: callingbullshit
|
||||
note: "The axis-crime module, generalised: a colour key is an axis, and rescaling it mid-comparison is the same move."
|
||||
- key: datawrapper
|
||||
note: "Their colour-scale series is explicit that where you put the bin edges changes what readers see — the choice is never neutral."
|
||||
- key: munzner
|
||||
note: "Binning a continuous quantity is an encoding decision, and comparability depends on holding it fixed."
|
||||
- key: vizwtf
|
||||
note: "Where the specimen above was collected."
|
||||
---
|
||||
|
||||
## The sin
|
||||
|
||||
Two panels, same measure, different dates, side by side. The reader is being
|
||||
invited to compare them — that's the only reason to publish them together.
|
||||
|
||||
Then the bins move. The right-hand panel above uses a key that turns darker,
|
||||
sooner, than the left one. Its districts are genuinely worse, but the picture
|
||||
overstates by however much the key was tightened, and there is no way to
|
||||
separate the two by looking.
|
||||
|
||||
## Why it deceives
|
||||
|
||||
A colour key is an axis. It maps a number to something the eye reads directly,
|
||||
and every intuition about not moving the y-axis between two charts applies to it
|
||||
unchanged — we just police it less, because a legend looks like a formatting
|
||||
choice rather than a scale.
|
||||
|
||||
It's also uniquely hard to catch. A shifted y-axis leaves visible evidence: the
|
||||
numbers are right there, and a suspicious reader can check them. Here the reader
|
||||
would have to hold five thresholds from one legend in their head, read the five
|
||||
in the other, and notice they differ — while looking at two attractive maps that
|
||||
are clearly telling a story.
|
||||
|
||||
And rebinning is often *innocent* in origin, which is what keeps it alive. As a
|
||||
number climbs, the old bands stop discriminating — everything saturates at the
|
||||
top colour and the map goes flat. Rescaling genuinely does make each map, on its
|
||||
own, more informative. The sin is doing it and still presenting the pair as a
|
||||
before-and-after.
|
||||
|
||||
## The repentance
|
||||
|
||||
One key for every panel you want compared. That's the whole rule, and the fixed
|
||||
version above does nothing else: same data, same dates, one legend serving both.
|
||||
The rise is still perfectly visible — it just isn't amplified.
|
||||
|
||||
If the range really has outgrown the old bands, you have two honest options.
|
||||
Rebin *both* panels, so the comparison stays like-for-like. Or keep the panels
|
||||
separate, drop the side-by-side framing, and let each map be a snapshot rather
|
||||
than half of an implied trend.
|
||||
|
||||
And if the two panels must carry different keys, say so loudly, in the chart —
|
||||
not in a caption underneath, where it will be cropped off the first time anyone
|
||||
shares it.
|
||||
@@ -8,6 +8,8 @@ tags: ["pie chart", "part-to-whole", "comparison"]
|
||||
badChart: "pie-overload-bad"
|
||||
fixedChart: "pie-overload-fixed"
|
||||
date: 2026-08-05
|
||||
related:
|
||||
- incomplete-pie
|
||||
citations:
|
||||
- key: fewpies
|
||||
note: "“Save the Pies for Dessert” — the definitive, free takedown of the overloaded pie."
|
||||
|
||||
@@ -8,6 +8,9 @@ tags: ["bar chart", "axis", "exaggeration"]
|
||||
badChart: "truncated-axis-bad"
|
||||
fixedChart: "truncated-axis-fixed"
|
||||
date: 2026-08-05
|
||||
related:
|
||||
- aspect-ratio
|
||||
- inverted-y-axis
|
||||
citations:
|
||||
- key: callingbullshit
|
||||
note: "Their “Misleading Axes” module is the standard explainer for exactly this move."
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Sin thumbnails.
|
||||
//
|
||||
// Each sin gets one hand-drawn pictogram at src/thumbs/<slug>.svg — a *symbol*
|
||||
// of the sin, not a shrunken chart. The distinction matters: in nearly every
|
||||
// sin the tell lives in the axis, the scale or the legend, which is exactly
|
||||
// what a chart loses when you shrink it to thumbnail size. A miniature of the
|
||||
// bad chart would throw away the evidence and leave eight indistinguishable
|
||||
// squiggles.
|
||||
//
|
||||
// The glyphs are drawn against `currentColor` plus three theme tokens
|
||||
// (--thumb-accent / --thumb-alt / --thumb-third, set in global.css), so unlike
|
||||
// the build-time-baked Vega charts they follow the light/dark theme instead of
|
||||
// needing the fixed --chart-canvas surface.
|
||||
|
||||
const modules = import.meta.glob<string>('../thumbs/*.svg', {
|
||||
eager: true,
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
const bySlug = new Map<string, string>();
|
||||
for (const [path, svg] of Object.entries(modules)) {
|
||||
const slug = path.split('/').pop()!.replace(/\.svg$/, '');
|
||||
bySlug.set(slug, svg);
|
||||
}
|
||||
|
||||
/** Inline SVG markup for a sin's thumbnail; throws at build time if missing. */
|
||||
export function getThumb(slug: string): string {
|
||||
const svg = bySlug.get(slug);
|
||||
if (!svg) {
|
||||
throw new Error(
|
||||
`No thumbnail for sin "${slug}". Add src/thumbs/${slug}.svg. ` +
|
||||
`Available: ${[...bySlug.keys()].join(', ')}`,
|
||||
);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import SinThumb from '../components/SinThumb.astro';
|
||||
import { severityUnits, tagModifier } from '../lib/charts';
|
||||
import { href } from '../lib/url';
|
||||
|
||||
@@ -27,6 +28,7 @@ const sins = (await getCollection('sins', ({ data }) => !data.draft)).sort((a, b
|
||||
{
|
||||
sins.map((sin) => (
|
||||
<a class="tile" href={href(`sins/${sin.id}/`)}>
|
||||
<SinThumb slug={sin.id} />
|
||||
<span class={`tag tag--${tagModifier(sin.data.category)}`}>{sin.data.category}</span>
|
||||
<h2 class="tile__title">{sin.data.title}</h2>
|
||||
<p class="tile__summary">{sin.data.summary}</p>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import VegaChart from '../../components/VegaChart.astro';
|
||||
import WildExamples from '../../components/WildExamples.astro';
|
||||
import RelatedSins from '../../components/RelatedSins.astro';
|
||||
import Citations from '../../components/Citations.astro';
|
||||
import ShareLink from '../../components/ShareLink.astro';
|
||||
import { getChartSpec, severityUnits, tagModifier } from '../../lib/charts';
|
||||
@@ -11,11 +13,23 @@ export async function getStaticPaths() {
|
||||
const sins = await getCollection('sins', ({ data }) => !data.draft);
|
||||
return sins.map((sin) => ({
|
||||
params: { slug: sin.id },
|
||||
props: { sin },
|
||||
props: { sin, all: sins },
|
||||
}));
|
||||
}
|
||||
|
||||
const { sin } = Astro.props;
|
||||
const { sin, all } = Astro.props;
|
||||
|
||||
// Resolve `related` slugs against the collection, failing the build on a typo
|
||||
// or a slug that was renamed out from under us.
|
||||
const related = sin.data.related.map((slug) => {
|
||||
const target = all.find((s) => s.id === slug);
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`Sin "${sin.id}" lists related sin "${slug}", which doesn't exist. Known: ${all.map((s) => s.id).join(', ')}`,
|
||||
);
|
||||
}
|
||||
return target;
|
||||
});
|
||||
const { Content } = await render(sin);
|
||||
|
||||
const badSpec = getChartSpec(sin.data.badChart);
|
||||
@@ -59,14 +73,18 @@ const ogImage = new URL(href(`og/${sin.id}.png`), Astro.site).toString();
|
||||
<VegaChart spec={fixedSpec} title={`Honest chart: ${sin.data.title}`} />
|
||||
</div>
|
||||
</div>
|
||||
<p class="compare__note">Same numbers, both charts. Only the design changed.</p>
|
||||
<p class="compare__note">{sin.data.compareNote ?? 'Same numbers, both charts. Only the design changed.'}</p>
|
||||
|
||||
<div class="prose">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<WildExamples examples={sin.data.wild} />
|
||||
|
||||
<Citations citations={sin.data.citations} />
|
||||
|
||||
<RelatedSins sins={related} />
|
||||
|
||||
<ShareLink url={pageUrl} />
|
||||
|
||||
<p class="more-refs">
|
||||
|
||||
@@ -334,11 +334,53 @@ code {
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
|
||||
gap: 1px; /* tiles sit in a 1px grid of border color */
|
||||
background: var(--border-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
/* Real gutters, not hairlines: each tile is banded with a pictogram at the
|
||||
top, so a 1px rule left one card's footer touching the next card's banner
|
||||
and the rows read as one continuous column. */
|
||||
gap: var(--spacing-06);
|
||||
margin-block: var(--spacing-09) var(--spacing-10);
|
||||
}
|
||||
/* ------------------------------------------------------------------ *\
|
||||
Sin thumbnails — a pictogram of the sin, not a shrunken chart.
|
||||
Drawn against currentColor plus three accents, so unlike the baked
|
||||
chart SVGs these follow the theme instead of a fixed canvas.
|
||||
\* ------------------------------------------------------------------ */
|
||||
.thumb {
|
||||
--thumb-accent: var(--support-error);
|
||||
--thumb-alt: var(--interactive);
|
||||
--thumb-third: var(--support-success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
color: var(--text-secondary);
|
||||
transition: color var(--dur-fast) var(--ease-productive);
|
||||
}
|
||||
.thumb svg {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
/* banner: bleeds to the tile edges, above everything else in the tile */
|
||||
.thumb--banner {
|
||||
height: 7rem;
|
||||
padding: var(--spacing-05) var(--spacing-06);
|
||||
background: var(--layer-02);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin: calc(-1 * var(--spacing-06)) calc(-1 * var(--spacing-06)) var(--spacing-02);
|
||||
transition:
|
||||
color var(--dur-fast) var(--ease-productive),
|
||||
background var(--dur-fast) var(--ease-productive);
|
||||
}
|
||||
.tile:hover .thumb {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
/* glyph: the compact form for cross-link rows */
|
||||
.thumb--glyph {
|
||||
height: 2.25rem;
|
||||
width: 3.375rem;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -348,7 +390,7 @@ code {
|
||||
background: var(--layer-01);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
position: relative;
|
||||
transition: background var(--dur-fast) var(--ease-productive);
|
||||
}
|
||||
@@ -675,6 +717,117 @@ a.receipt__work {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* often found together — cross-links between sins */
|
||||
.related {
|
||||
margin-top: var(--spacing-09);
|
||||
padding-top: var(--spacing-07);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.related__title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
margin: 0 0 var(--spacing-05);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.related__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
background: var(--border-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
.related__item {
|
||||
background: var(--layer-01);
|
||||
padding: var(--spacing-05) var(--spacing-06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-05);
|
||||
}
|
||||
.related__link {
|
||||
text-decoration: none;
|
||||
display: grid;
|
||||
gap: var(--spacing-02);
|
||||
flex: 1;
|
||||
}
|
||||
.related__name {
|
||||
font-weight: 600;
|
||||
color: var(--link-primary);
|
||||
}
|
||||
.related__link:hover .related__name {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.related__summary {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.related__sev {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
flex: none;
|
||||
}
|
||||
.related__unit {
|
||||
width: 6px;
|
||||
height: 14px;
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
.related__unit--on {
|
||||
background: var(--support-error);
|
||||
}
|
||||
|
||||
/* caught in the wild — real specimens */
|
||||
.wild {
|
||||
margin-top: var(--spacing-09);
|
||||
padding-top: var(--spacing-07);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.wild__title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.wild__lede {
|
||||
color: var(--text-secondary);
|
||||
margin: var(--spacing-03) 0 var(--spacing-06);
|
||||
}
|
||||
.wild__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--spacing-06);
|
||||
}
|
||||
.wild__item {
|
||||
background: var(--layer-01);
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: var(--spacing-05) var(--spacing-06) var(--spacing-06);
|
||||
}
|
||||
.wild__figure {
|
||||
margin: 0 0 var(--spacing-05);
|
||||
background: var(--chart-canvas);
|
||||
}
|
||||
.wild__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.wild__caption {
|
||||
margin: 0 0 var(--spacing-03);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wild__note {
|
||||
margin: 0 0 var(--spacing-04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.wild__credit {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-helper);
|
||||
}
|
||||
|
||||
/* references page */
|
||||
.refs {
|
||||
padding-block: var(--spacing-08) var(--spacing-10);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<!-- One series, two frames: a cliff on the left, a shrug on the right. -->
|
||||
<svg viewBox="0 0 72 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="8" y="6" width="20" height="36" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M9 38l6-11 6-5 6-10" stroke="var(--thumb-accent)" stroke-width="2" />
|
||||
<rect x="38" y="19" width="28" height="14" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M39 31l9-4 9-2 8-3" stroke="var(--thumb-accent)" stroke-width="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 489 B |
@@ -0,0 +1,14 @@
|
||||
<!-- Bars in one order of colours, their labels in another. -->
|
||||
<svg viewBox="0 0 72 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-width="10">
|
||||
<path d="M18 32V12" stroke="var(--thumb-alt)" />
|
||||
<path d="M36 32V18" stroke="var(--thumb-third)" />
|
||||
<path d="M54 32V8" stroke="var(--thumb-accent)" />
|
||||
</g>
|
||||
<path d="M8 33h56" stroke="currentColor" stroke-width="2" />
|
||||
<g>
|
||||
<rect x="14" y="38" width="8" height="8" fill="var(--thumb-third)" />
|
||||
<rect x="32" y="38" width="8" height="8" fill="var(--thumb-accent)" />
|
||||
<rect x="50" y="38" width="8" height="8" fill="var(--thumb-alt)" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 635 B |
@@ -0,0 +1,7 @@
|
||||
<!-- Two scales, tuned until the two series trace one path. -->
|
||||
<svg viewBox="0 0 72 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 8v32M6 14h4M6 24h4M6 34h4" stroke="var(--thumb-accent)" stroke-width="2" />
|
||||
<path d="M62 8v32M62 12h4M62 22h4M62 32h4" stroke="var(--thumb-alt)" stroke-width="2" />
|
||||
<path d="M15 31l12-7 12 3 18-13" stroke="var(--thumb-accent)" stroke-width="2" />
|
||||
<path d="M15 37l12-7 12 3 18-13" stroke="var(--thumb-alt)" stroke-width="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 491 B |
@@ -0,0 +1,7 @@
|
||||
<!-- Three quarters of a result, with the fourth simply absent. -->
|
||||
<svg viewBox="0 0 72 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M36 24h17A17 17 0 1 1 36 7z" fill="currentColor" opacity=".14" />
|
||||
<path d="M53 24A17 17 0 1 1 36 7" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M36 24v17M36 24H19" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M36 24h17M36 24V7" stroke="var(--thumb-accent)" stroke-width="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 455 B |
@@ -0,0 +1,6 @@
|
||||
<!-- The axis arrow points down, so a rising series draws as a fall. -->
|
||||
<svg viewBox="0 0 72 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 8v30" stroke="var(--thumb-accent)" stroke-width="2" />
|
||||
<path d="M7 32l5 7 5-7" stroke="var(--thumb-accent)" stroke-width="2" />
|
||||
<path d="M22 11l12 8 12 5 14 12" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 375 B |
@@ -0,0 +1,16 @@
|
||||
<!-- The same ramp twice, with the class breaks moved in between. -->
|
||||
<svg viewBox="0 0 72 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="currentColor">
|
||||
<rect x="10" y="8" width="13" height="11" opacity=".15" />
|
||||
<rect x="23" y="8" width="13" height="11" opacity=".4" />
|
||||
<rect x="36" y="8" width="13" height="11" opacity=".65" />
|
||||
<rect x="49" y="8" width="13" height="11" opacity=".9" />
|
||||
<rect x="10" y="29" width="9" height="11" opacity=".15" />
|
||||
<rect x="19" y="29" width="17" height="11" opacity=".4" />
|
||||
<rect x="36" y="29" width="17" height="11" opacity=".65" />
|
||||
<rect x="53" y="29" width="9" height="11" opacity=".9" />
|
||||
</g>
|
||||
<path d="M10 8h52v11H10zM10 29h52v11H10z" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M23 8v11M36 8v11M49 8v11" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M19 26v17M36 26v17M53 26v17" stroke="var(--thumb-accent)" stroke-width="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 943 B |
@@ -0,0 +1,10 @@
|
||||
<!-- A whole cut into more wedges than anyone can rank by eye. -->
|
||||
<svg viewBox="0 0 72 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="36" cy="24" r="17" stroke="currentColor" stroke-width="2" />
|
||||
<path
|
||||
d="M36 24 36 7M36 24 44.5 9.3M36 24 50.7 15.5M36 24 53 24M36 24 50.7 32.5M36 24 44.5 38.7M36 24 36 41M36 24 27.5 38.7M36 24 21.3 32.5M36 24 19 24M36 24 21.3 15.5M36 24 27.5 9.3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.25"
|
||||
opacity=".85"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,5 @@
|
||||
<!-- Bars floating on a baseline that has been cut away. -->
|
||||
<svg viewBox="0 0 72 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 36V14M36 36V8M54 36V20" stroke="currentColor" stroke-width="8" />
|
||||
<path d="M6 39h13l3-5 4 10 3-5h37" stroke="var(--thumb-accent)" stroke-width="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |