diff --git a/docs/content-backlog.md b/docs/content-backlog.md index c141739..144dde0 100644 --- a/docs/content-backlog.md +++ b/docs/content-backlog.md @@ -17,7 +17,7 @@ runs 2–5 on purpose. ## Sources in play -`src/lib/references.ts` currently holds 24 entries, 21 of them cited by the 6 +`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` | — | -| `schwabish` | — | | `fewdualaxes` | dual-axis-correlation | | `huff` | truncated-y-axis | | `isenberg2011` | dual-axis-correlation | -| `munzner` | incomplete-pie | +| `schwabish` | category-colors | | `shapeparameter` | aspect-ratio | | `showmenumbers` | too-many-pie-slices | | `spurious` | dual-axis-correlation | -| `swd` | dual-axis-correlation | | `tufte` | truncated-y-axis | -| `callingbullshit` | inverted-y-axis, truncated-y-axis | | `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 | -| `vizwtf` | incomplete-pie, inverted-y-axis | | `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 | -| `datawrapper` | 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. @@ -237,6 +237,12 @@ rediscovering them: ## 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 diff --git a/public/wild/shoe-colour-frequency.jpg b/public/wild/shoe-colour-frequency.jpg new file mode 100644 index 0000000..e3c9e0b Binary files /dev/null and b/public/wild/shoe-colour-frequency.jpg differ diff --git a/public/wild/tagesschau-incidence-bins.jpg b/public/wild/tagesschau-incidence-bins.jpg new file mode 100644 index 0000000..b9377a2 Binary files /dev/null and b/public/wild/tagesschau-incidence-bins.jpg differ diff --git a/src/charts/category-color-bad.json b/src/charts/category-color-bad.json new file mode 100644 index 0000000..35f8bea --- /dev/null +++ b/src/charts/category-color-bad.json @@ -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 + } + } +} diff --git a/src/charts/category-color-fixed.json b/src/charts/category-color-fixed.json new file mode 100644 index 0000000..5fbf747 --- /dev/null +++ b/src/charts/category-color-fixed.json @@ -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 + } + } +} diff --git a/src/charts/rebinned-scale-bad.json b/src/charts/rebinned-scale-bad.json new file mode 100644 index 0000000..eb61ea9 --- /dev/null +++ b/src/charts/rebinned-scale-bad.json @@ -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" + } + } +} diff --git a/src/charts/rebinned-scale-fixed.json b/src/charts/rebinned-scale-fixed.json new file mode 100644 index 0000000..c0862a1 --- /dev/null +++ b/src/charts/rebinned-scale-fixed.json @@ -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" + } + } +} diff --git a/src/content/sins/category-colors.md b/src/content/sins/category-colors.md new file mode 100644 index 0000000..98c2a96 --- /dev/null +++ b/src/content/sins/category-colors.md @@ -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. diff --git a/src/content/sins/rebinned-colour-scale.md b/src/content/sins/rebinned-colour-scale.md new file mode 100644 index 0000000..86f2b69 --- /dev/null +++ b/src/content/sins/rebinned-colour-scale.md @@ -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.