From e0d3f9f773592df9c14757cbdac5f8789cdfa08a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:21:07 +0000 Subject: [PATCH] Reframe as a linkable, cited callout ("nohello for charts") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make each sin a self-contained page you send instead of re-explaining: - Per-sin "poke" one-liner (the shareable callout) - Citations system: a canonical bibliography (src/lib/references.ts) of the field's giants — Munzner, Knaflic, Tufte, Cairo, Few, Cleveland & McGill, Wilke, Healy, Calling Bullshit, data-to-viz, Junk Charts, Spurious Correlations, FT Visual Vocabulary — cited per sin with a relevance note - "Don't take our word for it" receipts block on each sin - /canon page: the full annotated bibliography - Share control: copy-link "send them the receipt" (progressive enhancement) - Restructured sin page: poke -> proof -> why -> receipts -> share - Header nav (Sins / Canon); homepage reframed to the "send the link" mission Also fix the truncated-axis bad chart: clamp the [90,100] domain so the bar baseline doesn't overflow thousands of pixels off-canvas. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XPEvmMbac2fCvKXpQcovj8 --- src/charts/truncated-axis-bad.json | 2 +- src/components/Citations.astro | 36 +++++ src/components/ShareLink.astro | 43 +++++ src/content.config.ts | 7 + src/content/sins/dual-axis-deception.md | 10 ++ src/content/sins/pie-chart-overload.md | 10 ++ src/content/sins/truncated-y-axis.md | 10 ++ src/layouts/BaseLayout.astro | 5 +- src/lib/references.ts | 183 +++++++++++++++++++++ src/pages/canon.astro | 51 ++++++ src/pages/index.astro | 10 +- src/pages/sins/[...slug].astro | 17 +- src/styles/global.css | 205 +++++++++++++++++++++++- 13 files changed, 578 insertions(+), 11 deletions(-) create mode 100644 src/components/Citations.astro create mode 100644 src/components/ShareLink.astro create mode 100644 src/lib/references.ts create mode 100644 src/pages/canon.astro diff --git a/src/charts/truncated-axis-bad.json b/src/charts/truncated-axis-bad.json index 4d740b6..6e09b95 100644 --- a/src/charts/truncated-axis-bad.json +++ b/src/charts/truncated-axis-bad.json @@ -23,7 +23,7 @@ "y": { "field": "revenue", "type": "quantitative", - "scale": { "domain": [90, 100] }, + "scale": { "domain": [90, 100], "zero": false, "clamp": true }, "title": "Revenue ($M)" } } diff --git a/src/components/Citations.astro b/src/components/Citations.astro new file mode 100644 index 0000000..6499fea --- /dev/null +++ b/src/components/Citations.astro @@ -0,0 +1,36 @@ +--- +import { getReference, citeLabel } from '../lib/references'; + +interface Props { + citations: { key: string; note?: string }[]; +} +const { citations } = Astro.props; +const resolved = citations.map((c) => ({ ...c, ref: getReference(c.key) })); +--- + +{ + resolved.length > 0 && ( +
+

Don’t take our word for it

+

The giants who say the same thing, so you don’t have to argue:

+
    + {resolved.map(({ ref, note }) => ( +
  1. +
    + {ref.url ? ( + + {citeLabel(ref)} + + ) : ( + {citeLabel(ref)} + )} + {ref.kind} + {ref.free && free online} +
    + {note &&

    {note}

    } +
  2. + ))} +
+
+ ) +} diff --git a/src/components/ShareLink.astro b/src/components/ShareLink.astro new file mode 100644 index 0000000..c896282 --- /dev/null +++ b/src/components/ShareLink.astro @@ -0,0 +1,43 @@ +--- +// The "send this to them" mechanic — the whole point of the site. +// Progressive enhancement: the URL is always visible and selectable; the +// button just makes copying a one-click affair when JS is available. +interface Props { + url: string; +} +const { url } = Astro.props; +--- + + + + diff --git a/src/content.config.ts b/src/content.config.ts index 9a735b8..da78de4 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -11,6 +11,13 @@ const sins = defineCollection({ title: z.string(), // Short one-line summary shown on the index gallery. summary: z.string(), + // The cheeky one-line callout — the thing you send the sinner. + poke: z.string(), + // Citations into the canon (src/lib/references.ts) with a per-sin note on + // why that source is relevant. This is the "don't take our word for it". + citations: z + .array(z.object({ key: z.string(), note: z.string().optional() })) + .default([]), // Grouping, e.g. "Misleading Scales", "Chart-Type Abuse". category: z.string(), // 1 (venial) … 5 (mortal). Drives the flame rating in the UI. diff --git a/src/content/sins/dual-axis-deception.md b/src/content/sins/dual-axis-deception.md index 4049221..95cb157 100644 --- a/src/content/sins/dual-axis-deception.md +++ b/src/content/sins/dual-axis-deception.md @@ -1,12 +1,22 @@ --- title: "The Dual-Axis Deception" summary: "Two unrelated series on two independent axes, engineered to look correlated." +poke: "Two y-axes, tuned until the lines kiss. Congratulations — you can now make anything 'cause' anything." category: "False Relationships" severity: 5 tags: ["dual axis", "correlation", "line chart"] badChart: "dual-axis-bad" fixedChart: "dual-axis-fixed" date: 2026-08-05 +citations: + - key: datatoviz + note: "Lists “using two Y axes” as a top caveat — a way to manipulate the story." + - key: spurious + note: "Thousands of nonsense correlations: proof that “they move together” proves nothing." + - key: howchartslie + note: "Cairo on how aligned axes manufacture relationships that aren’t there." + - key: swd + note: "Knaflic’s standing advice: avoid the second axis; separate or index instead." --- ## The sin diff --git a/src/content/sins/pie-chart-overload.md b/src/content/sins/pie-chart-overload.md index 8de8e9f..1378385 100644 --- a/src/content/sins/pie-chart-overload.md +++ b/src/content/sins/pie-chart-overload.md @@ -1,12 +1,22 @@ --- title: "Pie Chart Overload" summary: "Eight near-identical slices no human eye can rank. A bar chart was right there." +poke: "Eight slices, three of them the same size. Nobody can read this. A bar chart could." category: "Chart-Type Abuse" severity: 3 tags: ["pie chart", "part-to-whole", "comparison"] badChart: "pie-overload-bad" fixedChart: "pie-overload-fixed" date: 2026-08-05 +citations: + - key: fewpies + note: "“Save the Pies for Dessert” — the definitive, free takedown of the overloaded pie." + - key: clevelandmcgill + note: "Their ranking shows angle and area are judged far worse than position along a scale." + - key: junkcharts + note: "Kaiser Fung’s running #onelesspie campaign exists for precisely this chart." + - key: datatoviz + note: "Pie charts are the first entry in its catalogue of caveats." --- ## The sin diff --git a/src/content/sins/truncated-y-axis.md b/src/content/sins/truncated-y-axis.md index b3798ad..7911e98 100644 --- a/src/content/sins/truncated-y-axis.md +++ b/src/content/sins/truncated-y-axis.md @@ -1,12 +1,22 @@ --- title: "The Truncated Y-Axis" summary: "Chopping the baseline off a bar chart to make small differences look enormous." +poke: "You started the y-axis at 90. That's not a bar chart — it's a magic trick." category: "Misleading Scales" severity: 4 tags: ["bar chart", "axis", "exaggeration"] badChart: "truncated-axis-bad" fixedChart: "truncated-axis-fixed" date: 2026-08-05 +citations: + - key: callingbullshit + note: "Their “Misleading Axes” module is the canonical explainer for exactly this move." + - key: clevelandmcgill + note: "The perception research: readers compare bar length, so length must map to value from zero." + - key: tufte + note: "The “Lie Factor” puts an actual number on how much the graphic exaggerates." + - key: howchartslie + note: "Cairo devotes a chapter to scale manipulation like this one." --- ## The sin diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 236f57a..ac7f8f8 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -32,7 +32,10 @@ const { title, description = 'A catalogue of data-visualization sins, styled on Chart Sins / honest data-viz - built on Carbon + diff --git a/src/lib/references.ts b/src/lib/references.ts new file mode 100644 index 0000000..652def3 --- /dev/null +++ b/src/lib/references.ts @@ -0,0 +1,183 @@ +// The Canon — the shoulders of giants Chart Sins stands on. +// +// Each sin cites into this list by key, so a page can say "don't take our word +// for it" and point at the authority that actually condemns the practice. +// Add a reference once here; cite it from any number of sins. + +export type RefKind = 'book' | 'site' | 'paper'; + +export interface Reference { + authors: string; + title: string; + /** Publisher / journal / site, for books and papers. */ + work?: string; + year?: number; + url?: string; + kind: RefKind; + /** One line on why it belongs in the canon. */ + blurb: string; + /** True for the free-to-read-online works, surfaced as a hint. */ + free?: boolean; +} + +export const REFERENCES = { + // --- books --- + munzner: { + authors: 'Tamara Munzner', + title: 'Visualization Analysis & Design', + work: 'CRC Press', + year: 2014, + url: 'https://www.cs.ubc.ca/~tmm/vadbook/', + kind: 'book', + blurb: 'The rigorous framework: what channels the eye reads accurately, and why position beats angle, length, and area.', + }, + swd: { + authors: 'Cole Nussbaumer Knaflic', + title: 'Storytelling with Data', + work: 'Wiley', + year: 2015, + url: 'https://www.storytellingwithdata.com/books', + kind: 'book', + blurb: 'The practitioner’s bible on decluttering charts and directing attention honestly.', + }, + tufte: { + authors: 'Edward R. Tufte', + title: 'The Visual Display of Quantitative Information', + work: 'Graphics Press (2nd ed.)', + year: 2001, + url: 'https://www.edwardtufte.com/book/the-visual-display-of-quantitative-information/', + kind: 'book', + blurb: 'Origin of the “Lie Factor,” chartjunk, and the data-ink ratio.', + }, + howchartslie: { + authors: 'Alberto Cairo', + title: 'How Charts Lie', + work: 'W. W. Norton', + year: 2019, + url: 'http://www.thefunctionalart.com/', + kind: 'book', + blurb: 'A whole book on exactly this: the tricks charts use to mislead, and how to read past them.', + }, + fewpies: { + authors: 'Stephen Few', + title: 'Save the Pies for Dessert', + work: 'Perceptual Edge', + year: 2007, + url: 'https://www.perceptualedge.com/articles/visual_business_intelligence/save_the_pies_for_dessert.pdf', + kind: 'paper', + blurb: 'The definitive takedown of the pie chart, from the field’s most stubborn advocate for clarity.', + free: true, + }, + wilke: { + authors: 'Claus O. Wilke', + title: 'Fundamentals of Data Visualization', + work: 'O’Reilly', + year: 2019, + url: 'https://clauswilke.com/dataviz/', + kind: 'book', + blurb: 'Free, thorough, and organized by problem — a per-topic reference for doing it right.', + free: true, + }, + healy: { + authors: 'Kieran Healy', + title: 'Data Visualization: A Practical Introduction', + work: 'Princeton University Press', + year: 2018, + url: 'https://socviz.co/', + kind: 'book', + blurb: 'Free online; strong on why perception, not taste, decides what works.', + free: true, + }, + callingbullshit: { + authors: 'Carl T. Bergstrom & Jevin D. West', + title: 'Calling Bullshit', + work: 'Random House', + year: 2020, + url: 'https://www.callingbullshit.org/tools/tools_misleading_axes.html', + kind: 'book', + blurb: 'Book and free course; the “Misleading Axes” module is required reading for axis crimes.', + free: true, + }, + huff: { + authors: 'Darrell Huff', + title: 'How to Lie with Statistics', + work: 'W. W. Norton', + year: 1954, + kind: 'book', + blurb: 'The 1954 classic that named the game; the truncated axis is right there in it.', + }, + clevelandmcgill: { + authors: 'William S. Cleveland & Robert McGill', + title: 'Graphical Perception: Theory, Experimentation, and Application', + work: 'Journal of the American Statistical Association', + year: 1984, + url: 'https://www.jstor.org/stable/2288400', + kind: 'paper', + blurb: 'The experiments proving people judge position accurately and angle/area poorly — the root of most of these sins.', + }, + schwabish: { + authors: 'Jonathan Schwabish', + title: 'Better Data Visualizations', + work: 'Columbia University Press', + year: 2021, + url: 'https://policyviz.com/books/', + kind: 'book', + blurb: 'A modern, chart-by-chart guide to which form fits which job.', + }, + + // --- sites --- + datatoviz: { + authors: 'Yan Holtz & Conor Healy', + title: 'From Data to Viz — Caveats', + work: 'data-to-viz.com', + url: 'https://www.data-to-viz.com/caveats.html', + kind: 'site', + blurb: 'A browsable catalogue of common dataviz mistakes, each with a fix.', + free: true, + }, + junkcharts: { + authors: 'Kaiser Fung', + title: 'Junk Charts', + work: 'junkcharts.com', + url: 'https://junkcharts.typepad.com/', + kind: 'site', + blurb: 'Fifteen years of chart critique-and-redesign, plus the #onelesspie campaign.', + free: true, + }, + spurious: { + authors: 'Tyler Vigen', + title: 'Spurious Correlations', + work: 'tylervigen.com', + url: 'https://www.tylervigen.com/spurious-correlations', + kind: 'site', + blurb: 'Thousands of nonsense correlations — the canonical proof that “they move together” proves nothing.', + free: true, + }, + ftvisvocab: { + authors: 'Financial Times Visual Journalism', + title: 'Visual Vocabulary', + work: 'FT', + url: 'https://github.com/Financial-Times/chart-doctor/tree/main/visual-vocabulary', + kind: 'site', + blurb: 'A cheat sheet mapping what you want to say to the chart that says it.', + free: true, + }, +} as const satisfies Record; + +export type ReferenceKey = keyof typeof REFERENCES; + +/** Resolve a citation key; throws at build time if it isn't in the canon. */ +export function getReference(key: string): Reference { + const ref = (REFERENCES as Record)[key]; + if (!ref) { + throw new Error( + `Reference "${key}" is not in the canon (src/lib/references.ts). Known: ${Object.keys(REFERENCES).join(', ')}`, + ); + } + return ref; +} + +/** A short "Author, Title (Year)" label. */ +export function citeLabel(ref: Reference): string { + return `${ref.authors}, ${ref.title}${ref.year ? ` (${ref.year})` : ''}`; +} diff --git a/src/pages/canon.astro b/src/pages/canon.astro new file mode 100644 index 0000000..ffcd850 --- /dev/null +++ b/src/pages/canon.astro @@ -0,0 +1,51 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import { REFERENCES, citeLabel, type RefKind } from '../lib/references'; + +const groups: { kind: RefKind; heading: string; blurb: string }[] = [ + { kind: 'book', heading: 'The books', blurb: 'The shelf every chart argument can lean on.' }, + { kind: 'paper', heading: 'The papers & essays', blurb: 'The primary sources — where the evidence actually lives.' }, + { kind: 'site', heading: 'The sites', blurb: 'Living references you can link straight into a thread.' }, +]; + +const byKind = (kind: RefKind) => Object.values(REFERENCES).filter((r) => r.kind === kind); +--- + + +
+

// the shoulders we stand on

+

The Canon

+

+ Every sin here cites its sources. This is the whole shelf in one place — + the giants of data visualization who already made the case, so you can + link the receipt instead of relitigating it. +

+
+ +
+ { + groups.map((g) => ( +
+

{g.heading}

+

{g.blurb}

+
    + {byKind(g.kind).map((ref) => ( +
  • +
    + {ref.url ? ( + {citeLabel(ref)} + ) : ( + {citeLabel(ref)} + )} + {ref.work && {ref.work}} + {ref.free && free online} +
    +

    {ref.blurb}

    +
  • + ))} +
+
+ )) + } +
+
diff --git a/src/pages/index.astro b/src/pages/index.astro index 0b5b591..d7dcc79 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -13,11 +13,13 @@ const sins = (await getCollection('sins', ({ data }) => !data.draft)).sort((a, b
-

// a catalogue of data-visualization sins

-

Charts that lie,
and the honest fix.

+

// stop re-explaining the same chart mistake

+

Someone made a
misleading chart again.

- Each entry is a data-visualization mistake in the wild: the chart that - misleads, why it fools the eye, and the honest version that repents. + Don’t type the whole explanation for the hundredth time — send the link. + Each sin shows the deceptive chart beside its honest twin, names the tell, + and cites the giants who back you up. It’s nohello.net, + for bad charts.

diff --git a/src/pages/sins/[...slug].astro b/src/pages/sins/[...slug].astro index bb62403..d0549f2 100644 --- a/src/pages/sins/[...slug].astro +++ b/src/pages/sins/[...slug].astro @@ -2,6 +2,8 @@ import { getCollection, render } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; import VegaChart from '../../components/VegaChart.astro'; +import Citations from '../../components/Citations.astro'; +import ShareLink from '../../components/ShareLink.astro'; import { getChartSpec, severityUnits, tagModifier } from '../../lib/charts'; import { href } from '../../lib/url'; @@ -18,9 +20,12 @@ const { Content } = await render(sin); const badSpec = getChartSpec(sin.data.badChart); const fixedSpec = getChartSpec(sin.data.fixedChart); + +// Canonical, shareable URL for this sin — the thing you paste into a thread. +const pageUrl = new URL(href(`sins/${sin.id}/`), Astro.site).toString(); --- - +
@@ -37,6 +42,7 @@ const fixedSpec = getChartSpec(sin.data.fixedChart);

{sin.data.title}

+

{sin.data.poke}

{sin.data.tags.map((t) => {t})}
@@ -52,9 +58,18 @@ const fixedSpec = getChartSpec(sin.data.fixedChart); +

Same numbers, both charts. Only the design changed.

+ + + + + +

+ Building a case? See the full canon → +

diff --git a/src/styles/global.css b/src/styles/global.css index 5ffd8ca..a554218 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -235,10 +235,20 @@ code { font-family: var(--font-mono); letter-spacing: 0; } -@media (max-width: 40rem) { - .cds-header__tag { - display: none; - } +.cds-header__nav { + margin-left: auto; + display: flex; + gap: var(--spacing-06); +} +.cds-header__nav a { + color: var(--shell-text-secondary); + font-size: 0.875rem; + text-decoration: none; + border: 0; + padding-block: var(--spacing-02); +} +.cds-header__nav a:hover { + color: var(--shell-text); } /* ------------------------------------------------------------------ *\ @@ -527,6 +537,193 @@ code { color: var(--text-primary); } +/* the poke — the shareable one-liner under the title */ +.sin__poke { + font-size: 1.375rem; + line-height: 1.35; + font-weight: 300; + color: var(--text-primary); + max-width: 42rem; + margin: var(--spacing-05) 0 0; +} +.compare__note { + margin: calc(-1 * var(--spacing-05)) 0 0; + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--text-helper); +} + +/* the receipts — citations block */ +.receipts { + margin-top: var(--spacing-09); + padding-top: var(--spacing-07); + border-top: 1px solid var(--border-subtle); +} +.receipts__title { + font-size: 1.5rem; + font-weight: 400; + margin: 0; + letter-spacing: -0.01em; +} +.receipts__lede { + color: var(--text-secondary); + margin: var(--spacing-03) 0 var(--spacing-06); +} +.receipts__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 1px; + background: var(--border-subtle); + border: 1px solid var(--border-subtle); +} +.receipt { + background: var(--layer-01); + padding: var(--spacing-05) var(--spacing-06); +} +.receipt__cite { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--spacing-03); +} +.receipt__work { + font-weight: 600; + color: var(--text-primary); +} +a.receipt__work { + color: var(--link-primary); +} +.receipt__kind { + font-family: var(--font-mono); + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-helper); + border: 1px solid var(--border-subtle); + padding: 0 var(--spacing-02); +} +.receipt__free { + font-family: var(--font-mono); + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--support-success); +} +.receipt__note { + margin: var(--spacing-03) 0 0; + color: var(--text-secondary); + font-size: 0.9375rem; + line-height: 1.5; +} + +/* the share box */ +.share { + margin-top: var(--spacing-08); + padding: var(--spacing-06); + background: var(--layer-01); + border: 1px solid var(--border-subtle); + border-left: 3px solid var(--interactive); +} +.share__prompt { + margin: 0 0 var(--spacing-04); + font-weight: 600; +} +.share__row { + display: flex; + gap: 0; + max-width: 34rem; +} +.share__url { + flex: 1; + min-width: 0; + font-family: var(--font-mono); + font-size: 0.8125rem; + padding: var(--spacing-04) var(--spacing-05); + background: var(--background); + color: var(--text-secondary); + border: 1px solid var(--border-strong); + border-right: 0; +} +.share__url:focus-visible { + outline: 2px solid var(--focus); + outline-offset: -2px; +} +.share__btn { + flex: 0 0 auto; + font-family: var(--font-sans); + font-size: 0.875rem; + font-weight: 600; + color: var(--text-on-color); + background: var(--interactive); + border: 1px solid var(--interactive); + padding-inline: var(--spacing-05); + cursor: pointer; + transition: background var(--dur-fast) var(--ease-productive); +} +.share__btn:hover { + background: var(--link-hover); +} +.share__btn.is-copied { + background: var(--support-success); + border-color: var(--support-success); +} + +.more-canon { + margin-top: var(--spacing-07); + font-family: var(--font-mono); + font-size: 0.875rem; +} + +/* canon page */ +.canon { + padding-block: var(--spacing-08) var(--spacing-10); +} +.canon__group { + margin-bottom: var(--spacing-09); +} +.canon__heading { + margin: 0; +} +.canon__list { + list-style: none; + margin: var(--spacing-05) 0 0; + padding: 0; + display: grid; + gap: 1px; + background: var(--border-subtle); + border: 1px solid var(--border-subtle); +} +.canon__item { + background: var(--layer-01); + padding: var(--spacing-05) var(--spacing-06); +} +.canon__cite { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--spacing-03); +} +.canon__work { + font-weight: 600; + font-size: 1.0625rem; + color: var(--text-primary); +} +a.canon__work { + color: var(--link-primary); +} +.canon__pub { + font-size: 0.8125rem; + color: var(--text-helper); +} +.canon__blurb { + margin: var(--spacing-03) 0 0; + color: var(--text-secondary); + max-width: 44rem; + line-height: 1.5; +} + /* ------------------------------------------------------------------ *\ Footer + colophon \* ------------------------------------------------------------------ */