mirror of
https://github.com/olehomelchenko/chart-sins.git
synced 2026-08-08 02:22:43 +00:00
Scaffold Chart Sins site with Astro + build-time Vega-Lite
Set up the static-site tech stack: Astro 5 with Markdown content collections, Vega-Lite specs rendered to static SVG at build time (zero client JS), plain scoped CSS, and a GitHub Pages deploy workflow. Includes three sample sins (truncated y-axis, dual-axis deception, pie chart overload), each with a bad and fixed chart spec, plus a gallery index and per-sin detail pages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPEvmMbac2fCvKXpQcovj8
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
name: Deploy to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
# Allow one concurrent deployment; skip runs queued behind a newer one.
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Build with Astro
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: ./dist
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
# lockfile is regenerated on install; CI uses `npm install`
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# editors
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Chart Sins
|
||||||
|
|
||||||
|
A catalogue of data-visualization sins — for each one: the chart that misleads,
|
||||||
|
why it fools the eye, and the honest version that fixes it.
|
||||||
|
|
||||||
|
Built with [Astro](https://astro.build). Charts are authored as
|
||||||
|
[Vega-Lite](https://vega.github.io/vega-lite/) specs and rendered to **static
|
||||||
|
SVG at build time**, so pages ship with zero client-side JavaScript.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Concern | Choice |
|
||||||
|
| ---------- | --------------------------------------------------- |
|
||||||
|
| Framework | Astro 5 (static output) |
|
||||||
|
| Content | Markdown/MDX via Astro Content Collections |
|
||||||
|
| Charts | Vega-Lite specs → SVG at build (headless Vega) |
|
||||||
|
| Styling | Plain scoped CSS + one global stylesheet |
|
||||||
|
| Hosting | GitHub Pages (via GitHub Actions) |
|
||||||
|
| Language | TypeScript |
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── content.config.ts # typed frontmatter schema for a "sin"
|
||||||
|
├── content/sins/ # one Markdown file per sin
|
||||||
|
├── charts/ # Vega-Lite JSON specs (bad + fixed)
|
||||||
|
├── components/VegaChart.astro # build-time Vega-Lite → SVG renderer
|
||||||
|
├── layouts/BaseLayout.astro
|
||||||
|
├── lib/charts.ts # spec lookup + severity helper
|
||||||
|
├── pages/
|
||||||
|
│ ├── index.astro # the gallery
|
||||||
|
│ └── sins/[...slug].astro # a sin's detail page
|
||||||
|
└── styles/global.css
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a new sin
|
||||||
|
|
||||||
|
1. Author two Vega-Lite specs in `src/charts/`, e.g.
|
||||||
|
`my-sin-bad.json` and `my-sin-fixed.json`.
|
||||||
|
> Omit `$schema`, `config`, and outer `width`/`height` tuning you don't need —
|
||||||
|
> keep specs focused on the data and encoding.
|
||||||
|
2. Create `src/content/sins/my-sin.md` with frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: "My Sin"
|
||||||
|
summary: "One-line description shown on the gallery."
|
||||||
|
category: "Misleading Scales"
|
||||||
|
severity: 3 # 1 (venial) … 5 (mortal)
|
||||||
|
tags: ["bar chart"]
|
||||||
|
badChart: "my-sin-bad" # basename in src/charts/, no .json
|
||||||
|
fixedChart: "my-sin-fixed"
|
||||||
|
date: 2026-08-05
|
||||||
|
---
|
||||||
|
|
||||||
|
Prose explaining the sin, why it deceives, and the repentance.
|
||||||
|
```
|
||||||
|
3. `npm run dev` and check it. The typed schema will flag a mistyped field or a
|
||||||
|
missing chart spec at build time.
|
||||||
|
|
||||||
|
### Making a chart interactive (opt-in)
|
||||||
|
|
||||||
|
Charts are static SVG by default. To make one interactive, render it client-side
|
||||||
|
with `vega-embed` in a small Astro island (`client:visible`) on that page only,
|
||||||
|
instead of using `<VegaChart>`. This keeps the default fast while allowing
|
||||||
|
hover/zoom where a specific sin benefits from it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| ----------------- | ----------------------------------------- |
|
||||||
|
| `npm install` | Install dependencies |
|
||||||
|
| `npm run dev` | Start the dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build the static site to `./dist` |
|
||||||
|
| `npm run preview` | Preview the production build locally |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Pushing to `main` triggers `.github/workflows/deploy.yml`, which builds the site
|
||||||
|
and publishes it to GitHub Pages. Enable it once under
|
||||||
|
**Settings → Pages → Build and deployment → Source: GitHub Actions**.
|
||||||
|
|
||||||
|
The site is configured for the project path `https://olehomelchenko.github.io/chart-sins`
|
||||||
|
(`site` + `base` in `astro.config.mjs`). If you move to a custom domain, set
|
||||||
|
`site` to it and remove `base`.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import mdx from '@astrojs/mdx';
|
||||||
|
|
||||||
|
// This is a *project* GitHub Pages site, so it is served from a sub-path.
|
||||||
|
// If you later use a custom domain, set `site` to it and drop `base`.
|
||||||
|
export default defineConfig({
|
||||||
|
site: 'https://olehomelchenko.github.io',
|
||||||
|
base: '/chart-sins',
|
||||||
|
integrations: [mdx()],
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "chart-sins",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "A catalogue of data-visualization sins — the bad chart, the repentance, the fixed version.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/mdx": "^4.2.0",
|
||||||
|
"astro": "^5.6.0",
|
||||||
|
"vega": "^5.30.0",
|
||||||
|
"vega-lite": "^5.21.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"title": "Ice Cream Sales vs. Shark Attacks",
|
||||||
|
"width": 360,
|
||||||
|
"height": 240,
|
||||||
|
"data": {
|
||||||
|
"values": [
|
||||||
|
{ "month": "Jan", "icecream": 20, "sharks": 1 },
|
||||||
|
{ "month": "Feb", "icecream": 24, "sharks": 1 },
|
||||||
|
{ "month": "Mar", "icecream": 38, "sharks": 2 },
|
||||||
|
{ "month": "Apr", "icecream": 55, "sharks": 3 },
|
||||||
|
{ "month": "May", "icecream": 78, "sharks": 5 },
|
||||||
|
{ "month": "Jun", "icecream": 95, "sharks": 6 },
|
||||||
|
{ "month": "Jul", "icecream": 100, "sharks": 7 },
|
||||||
|
{ "month": "Aug", "icecream": 96, "sharks": 6 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"encoding": {
|
||||||
|
"x": { "field": "month", "type": "ordinal", "sort": null, "axis": { "title": null, "labelAngle": 0 } }
|
||||||
|
},
|
||||||
|
"layer": [
|
||||||
|
{
|
||||||
|
"mark": { "type": "line", "color": "#c0392b", "point": true },
|
||||||
|
"encoding": {
|
||||||
|
"y": { "field": "icecream", "type": "quantitative", "title": "Ice cream sales", "axis": { "titleColor": "#c0392b" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mark": { "type": "line", "color": "#2980b9", "point": true },
|
||||||
|
"encoding": {
|
||||||
|
"y": { "field": "sharks", "type": "quantitative", "title": "Shark attacks", "axis": { "titleColor": "#2980b9" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resolve": { "scale": { "y": "independent" } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"title": "Two Series, Shown Separately",
|
||||||
|
"data": {
|
||||||
|
"values": [
|
||||||
|
{ "month": "Jan", "icecream": 20, "sharks": 1 },
|
||||||
|
{ "month": "Feb", "icecream": 24, "sharks": 1 },
|
||||||
|
{ "month": "Mar", "icecream": 38, "sharks": 2 },
|
||||||
|
{ "month": "Apr", "icecream": 55, "sharks": 3 },
|
||||||
|
{ "month": "May", "icecream": 78, "sharks": 5 },
|
||||||
|
{ "month": "Jun", "icecream": 95, "sharks": 6 },
|
||||||
|
{ "month": "Jul", "icecream": 100, "sharks": 7 },
|
||||||
|
{ "month": "Aug", "icecream": 96, "sharks": 6 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"vconcat": [
|
||||||
|
{
|
||||||
|
"width": 360,
|
||||||
|
"height": 110,
|
||||||
|
"mark": { "type": "line", "color": "#c0392b", "point": true },
|
||||||
|
"encoding": {
|
||||||
|
"x": { "field": "month", "type": "ordinal", "sort": null, "axis": { "title": null, "labels": false } },
|
||||||
|
"y": { "field": "icecream", "type": "quantitative", "scale": { "zero": true }, "title": "Ice cream" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"width": 360,
|
||||||
|
"height": 110,
|
||||||
|
"mark": { "type": "line", "color": "#2980b9", "point": true },
|
||||||
|
"encoding": {
|
||||||
|
"x": { "field": "month", "type": "ordinal", "sort": null, "axis": { "title": null, "labelAngle": 0 } },
|
||||||
|
"y": { "field": "sharks", "type": "quantitative", "scale": { "zero": true }, "title": "Shark attacks" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"title": "Market Share by Browser (%)",
|
||||||
|
"width": 300,
|
||||||
|
"height": 260,
|
||||||
|
"data": {
|
||||||
|
"values": [
|
||||||
|
{ "browser": "Chrome", "share": 63 },
|
||||||
|
{ "browser": "Safari", "share": 20 },
|
||||||
|
{ "browser": "Edge", "share": 5 },
|
||||||
|
{ "browser": "Firefox", "share": 3 },
|
||||||
|
{ "browser": "Samsung", "share": 3 },
|
||||||
|
{ "browser": "Opera", "share": 2 },
|
||||||
|
{ "browser": "UC", "share": 2 },
|
||||||
|
{ "browser": "Other", "share": 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mark": { "type": "arc" },
|
||||||
|
"encoding": {
|
||||||
|
"theta": { "field": "share", "type": "quantitative" },
|
||||||
|
"color": { "field": "browser", "type": "nominal", "legend": { "title": "Browser" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"title": "Market Share by Browser (%)",
|
||||||
|
"width": 320,
|
||||||
|
"height": 260,
|
||||||
|
"data": {
|
||||||
|
"values": [
|
||||||
|
{ "browser": "Chrome", "share": 63 },
|
||||||
|
{ "browser": "Safari", "share": 20 },
|
||||||
|
{ "browser": "Edge", "share": 5 },
|
||||||
|
{ "browser": "Firefox", "share": 3 },
|
||||||
|
{ "browser": "Samsung", "share": 3 },
|
||||||
|
{ "browser": "Opera", "share": 2 },
|
||||||
|
{ "browser": "UC", "share": 2 },
|
||||||
|
{ "browser": "Other", "share": 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mark": { "type": "bar", "color": "#1e8b5b" },
|
||||||
|
"encoding": {
|
||||||
|
"y": { "field": "browser", "type": "nominal", "sort": "-x", "axis": { "title": null } },
|
||||||
|
"x": { "field": "share", "type": "quantitative", "title": "Share (%)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"title": "Monthly Revenue ($M)",
|
||||||
|
"width": 340,
|
||||||
|
"height": 240,
|
||||||
|
"data": {
|
||||||
|
"values": [
|
||||||
|
{ "month": "Jan", "revenue": 92 },
|
||||||
|
{ "month": "Feb", "revenue": 94 },
|
||||||
|
{ "month": "Mar", "revenue": 93 },
|
||||||
|
{ "month": "Apr", "revenue": 96 },
|
||||||
|
{ "month": "May", "revenue": 95 },
|
||||||
|
{ "month": "Jun", "revenue": 98 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mark": { "type": "bar", "color": "#c0392b" },
|
||||||
|
"encoding": {
|
||||||
|
"x": {
|
||||||
|
"field": "month",
|
||||||
|
"type": "nominal",
|
||||||
|
"sort": null,
|
||||||
|
"axis": { "labelAngle": 0, "title": null }
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"field": "revenue",
|
||||||
|
"type": "quantitative",
|
||||||
|
"scale": { "domain": [90, 100] },
|
||||||
|
"title": "Revenue ($M)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"title": "Monthly Revenue ($M)",
|
||||||
|
"width": 340,
|
||||||
|
"height": 240,
|
||||||
|
"data": {
|
||||||
|
"values": [
|
||||||
|
{ "month": "Jan", "revenue": 92 },
|
||||||
|
{ "month": "Feb", "revenue": 94 },
|
||||||
|
{ "month": "Mar", "revenue": 93 },
|
||||||
|
{ "month": "Apr", "revenue": 96 },
|
||||||
|
{ "month": "May", "revenue": 95 },
|
||||||
|
{ "month": "Jun", "revenue": 98 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mark": { "type": "bar", "color": "#1e8b5b" },
|
||||||
|
"encoding": {
|
||||||
|
"x": {
|
||||||
|
"field": "month",
|
||||||
|
"type": "nominal",
|
||||||
|
"sort": null,
|
||||||
|
"axis": { "labelAngle": 0, "title": null }
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"field": "revenue",
|
||||||
|
"type": "quantitative",
|
||||||
|
"scale": { "zero": true },
|
||||||
|
"title": "Revenue ($M)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
// Build-time Vega-Lite renderer.
|
||||||
|
//
|
||||||
|
// This component runs only during `astro build` (and dev SSR), never in the
|
||||||
|
// browser. It compiles a Vega-Lite spec to Vega, renders it to a static SVG
|
||||||
|
// string with Vega's headless view, and inlines that SVG. The result ships as
|
||||||
|
// plain markup — zero client-side JavaScript, no Vega runtime downloaded.
|
||||||
|
//
|
||||||
|
// To make a specific chart interactive later, you would swap this component
|
||||||
|
// out for a client-side vega-embed island on that page only.
|
||||||
|
import * as vega from 'vega';
|
||||||
|
import { compile, type TopLevelSpec } from 'vega-lite';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** A Vega-Lite spec (object). Do not include $schema/width/height/config. */
|
||||||
|
spec: TopLevelSpec;
|
||||||
|
/** Accessible description of what the chart shows. */
|
||||||
|
title: string;
|
||||||
|
/** Optional caption rendered under the chart. */
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { spec, title, caption } = Astro.props;
|
||||||
|
|
||||||
|
let svg = '';
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Compile Vega-Lite -> Vega, then render headlessly to SVG.
|
||||||
|
const vgSpec = compile(spec as TopLevelSpec).spec;
|
||||||
|
const view = new vega.View(vega.parse(vgSpec), { renderer: 'none' }).initialize();
|
||||||
|
svg = await view.toSVG();
|
||||||
|
view.finalize();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
// Fail loudly in the build log, but don't crash the whole build.
|
||||||
|
console.error(`[VegaChart] failed to render "${title}": ${error}`);
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure class="vega-chart" role="group" aria-label={title}>
|
||||||
|
{error ? (
|
||||||
|
<div class="vega-chart__error">
|
||||||
|
<strong>Chart failed to render.</strong>
|
||||||
|
<code>{error}</code>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="vega-chart__svg" set:html={svg} />
|
||||||
|
)}
|
||||||
|
{caption && <figcaption>{caption}</figcaption>}
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.vega-chart {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.vega-chart__svg :global(svg) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.vega-chart figcaption {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.vega-chart__error {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px dashed var(--sin-red);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--sin-red);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.vega-chart__error code {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineCollection, z } from 'astro:content';
|
||||||
|
import { glob } from 'astro/loaders';
|
||||||
|
|
||||||
|
// Each "sin" is one Markdown/MDX file in src/content/sins/.
|
||||||
|
// Charts are authored as Vega-Lite JSON specs in src/charts/ and referenced
|
||||||
|
// here by their basename (without the .json extension). The page template
|
||||||
|
// renders `badChart` and `fixedChart` side by side automatically.
|
||||||
|
const sins = defineCollection({
|
||||||
|
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/sins' }),
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
// Short one-line summary shown on the index gallery.
|
||||||
|
summary: z.string(),
|
||||||
|
// Grouping, e.g. "Misleading Scales", "Chart-Type Abuse".
|
||||||
|
category: z.string(),
|
||||||
|
// 1 (venial) … 5 (mortal). Drives the flame rating in the UI.
|
||||||
|
severity: z.number().int().min(1).max(5),
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
// Basenames of Vega-Lite specs in src/charts/ (without .json).
|
||||||
|
badChart: z.string(),
|
||||||
|
fixedChart: z.string(),
|
||||||
|
// Optional publish date; unset entries sort last.
|
||||||
|
date: z.coerce.date().optional(),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = { sins };
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
title: "The Dual-Axis Deception"
|
||||||
|
summary: "Two unrelated series on two independent axes, engineered to look correlated."
|
||||||
|
category: "False Relationships"
|
||||||
|
severity: 5
|
||||||
|
tags: ["dual axis", "correlation", "line chart"]
|
||||||
|
badChart: "dual-axis-bad"
|
||||||
|
fixedChart: "dual-axis-fixed"
|
||||||
|
date: 2026-08-05
|
||||||
|
---
|
||||||
|
|
||||||
|
## The sin
|
||||||
|
|
||||||
|
Put two series on the same plot with two independent y-axes and you can make
|
||||||
|
*anything* look correlated. Slide the scales until the lines overlap and the
|
||||||
|
reader's brain does the rest: "these move together, so one must cause the other."
|
||||||
|
|
||||||
|
## Why it deceives
|
||||||
|
|
||||||
|
With independent axes, the vertical position of each line is arbitrary — you
|
||||||
|
chose it. Overlapping lines imply a relationship that exists only because you
|
||||||
|
tuned two scales to coincide. Ice cream sales and shark attacks both rise in
|
||||||
|
summer; neither causes the other. The shared season is the hidden variable.
|
||||||
|
|
||||||
|
## The repentance
|
||||||
|
|
||||||
|
Don't force two series into one coordinate space just to imply a link. Show them
|
||||||
|
in **separate, stacked panels** with honest zero-based scales. If you truly want
|
||||||
|
to argue they move together, plot one **against** the other (a scatter plot) and
|
||||||
|
let the reader judge the relationship — don't manufacture it with axis scaling.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
title: "Pie Chart Overload"
|
||||||
|
summary: "Eight near-identical slices no human eye can rank. A bar chart was right there."
|
||||||
|
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
|
||||||
|
---
|
||||||
|
|
||||||
|
## The sin
|
||||||
|
|
||||||
|
The pie chart asks the reader to compare **angles** — something people are
|
||||||
|
genuinely bad at. One or two slices, fine. But eight slices, several of them
|
||||||
|
within a percentage point of each other? Nobody can tell whether Firefox beats
|
||||||
|
Samsung by eyeballing two thin wedges on opposite sides of the circle.
|
||||||
|
|
||||||
|
## Why it deceives
|
||||||
|
|
||||||
|
Angle and area are hard to judge precisely, and slices scattered around the
|
||||||
|
circle can't be lined up against a common baseline. The small categories blur
|
||||||
|
into an indistinguishable fringe, and the ranking — usually the whole point —
|
||||||
|
becomes guesswork.
|
||||||
|
|
||||||
|
## The repentance
|
||||||
|
|
||||||
|
Use a **sorted horizontal bar chart**. Every value shares one baseline, the
|
||||||
|
ordering is explicit, and long labels sit comfortably beside their bars. Reserve
|
||||||
|
the pie for the rare case of two or three parts of an obvious whole — and even
|
||||||
|
then, a bar chart rarely does worse.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
title: "The Truncated Y-Axis"
|
||||||
|
summary: "Chopping the baseline off a bar chart to make small differences look enormous."
|
||||||
|
category: "Misleading Scales"
|
||||||
|
severity: 4
|
||||||
|
tags: ["bar chart", "axis", "exaggeration"]
|
||||||
|
badChart: "truncated-axis-bad"
|
||||||
|
fixedChart: "truncated-axis-fixed"
|
||||||
|
date: 2026-08-05
|
||||||
|
---
|
||||||
|
|
||||||
|
## The sin
|
||||||
|
|
||||||
|
Bar charts encode value with **length**. When you start the y-axis somewhere
|
||||||
|
above zero, the bars no longer represent their true magnitudes — a jump from 92
|
||||||
|
to 98 (a ~6% change) can be drawn to look like a chart-topping surge. The reader
|
||||||
|
compares bar heights and walks away with a wildly inflated sense of the trend.
|
||||||
|
|
||||||
|
## Why it deceives
|
||||||
|
|
||||||
|
The eye reads the ratio of bar lengths, not the axis labels. Truncating the
|
||||||
|
baseline breaks the contract between "twice as tall" and "twice as much." It is
|
||||||
|
the single most common way an honest-looking bar chart tells a lie.
|
||||||
|
|
||||||
|
## The repentance
|
||||||
|
|
||||||
|
Start bar charts at zero. Always. If the interesting variation is genuinely
|
||||||
|
small and a zero baseline flattens it, that is a signal to switch encodings —
|
||||||
|
use a **line chart** or a **dot plot**, where position (not length) carries the
|
||||||
|
meaning and a non-zero range is legitimate.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
import '../styles/global.css';
|
||||||
|
import { href } from '../lib/url';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description = 'A catalogue of data-visualization sins.' } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href={href()}>⚡ Chart Sins</a>
|
||||||
|
<span class="tagline">The bad chart, the repentance, the fixed version.</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
Chart Sins — an educational catalogue of data-visualization mistakes.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { TopLevelSpec } from 'vega-lite';
|
||||||
|
|
||||||
|
// Eagerly load every Vega-Lite spec in src/charts/ at build time so pages can
|
||||||
|
// look one up by its basename (e.g. "dual-axis-bad").
|
||||||
|
const modules = import.meta.glob<{ default: TopLevelSpec }>('../charts/*.json', {
|
||||||
|
eager: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const specsByName = new Map<string, TopLevelSpec>();
|
||||||
|
for (const [path, mod] of Object.entries(modules)) {
|
||||||
|
const name = path.split('/').pop()!.replace(/\.json$/, '');
|
||||||
|
specsByName.set(name, mod.default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a Vega-Lite spec by basename; throws at build time if missing. */
|
||||||
|
export function getChartSpec(name: string): TopLevelSpec {
|
||||||
|
const spec = specsByName.get(name);
|
||||||
|
if (!spec) {
|
||||||
|
throw new Error(
|
||||||
|
`Chart spec "${name}" not found in src/charts/. Available: ${[...specsByName.keys()].join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a 1–5 severity as flame glyphs for display. */
|
||||||
|
export function severityFlames(severity: number): string {
|
||||||
|
return '🔥'.repeat(severity) + '·'.repeat(Math.max(0, 5 - severity));
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Join a path onto the configured base path with exactly one slash, so links
|
||||||
|
// work whether `base` is "/chart-sins" (no trailing slash) or "/".
|
||||||
|
const BASE = import.meta.env.BASE_URL;
|
||||||
|
|
||||||
|
export function href(path = ''): string {
|
||||||
|
const base = BASE.endsWith('/') ? BASE.slice(0, -1) : BASE;
|
||||||
|
if (path === '' || path === '/') return `${base}/`;
|
||||||
|
const p = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${base}${p}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import { severityFlames } from '../lib/charts';
|
||||||
|
import { href } from '../lib/url';
|
||||||
|
|
||||||
|
const sins = (await getCollection('sins', ({ data }) => !data.draft)).sort((a, b) => {
|
||||||
|
// Most severe first; ties broken by newest date.
|
||||||
|
if (b.data.severity !== a.data.severity) return b.data.severity - a.data.severity;
|
||||||
|
return (b.data.date?.getTime() ?? 0) - (a.data.date?.getTime() ?? 0);
|
||||||
|
});
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title="Chart Sins">
|
||||||
|
<section class="wrap hero">
|
||||||
|
<h1>A catalogue of chart sins.</h1>
|
||||||
|
<p>
|
||||||
|
Every entry is a data-visualization mistake in the wild: the chart that
|
||||||
|
misleads, why it fools the eye, and the honest version that fixes it.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wrap">
|
||||||
|
<div class="gallery">
|
||||||
|
{
|
||||||
|
sins.map((sin) => (
|
||||||
|
<a class="card" href={href(`sins/${sin.id}/`)}>
|
||||||
|
<span class="pill">{sin.data.category}</span>
|
||||||
|
<h3>{sin.data.title}</h3>
|
||||||
|
<p>{sin.data.summary}</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="severity" title={`Severity ${sin.data.severity} of 5`}>
|
||||||
|
{severityFlames(sin.data.severity)}
|
||||||
|
</span>
|
||||||
|
<span class="pill">Read →</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</BaseLayout>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
import { getCollection, render } from 'astro:content';
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import VegaChart from '../../components/VegaChart.astro';
|
||||||
|
import { getChartSpec, severityFlames } from '../../lib/charts';
|
||||||
|
import { href } from '../../lib/url';
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const sins = await getCollection('sins', ({ data }) => !data.draft);
|
||||||
|
return sins.map((sin) => ({
|
||||||
|
params: { slug: sin.id },
|
||||||
|
props: { sin },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sin } = Astro.props;
|
||||||
|
const { Content } = await render(sin);
|
||||||
|
|
||||||
|
const badSpec = getChartSpec(sin.data.badChart);
|
||||||
|
const fixedSpec = getChartSpec(sin.data.fixedChart);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${sin.data.title} — Chart Sins`} description={sin.data.summary}>
|
||||||
|
<article class="wrap sin">
|
||||||
|
<header>
|
||||||
|
<span class="pill">{sin.data.category}</span>
|
||||||
|
<h1>{sin.data.title}</h1>
|
||||||
|
<span class="severity" title={`Severity ${sin.data.severity} of 5`}>
|
||||||
|
{severityFlames(sin.data.severity)}
|
||||||
|
</span>
|
||||||
|
<div class="tags">
|
||||||
|
{sin.data.tags.map((t) => <span class="pill">{t}</span>)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="compare">
|
||||||
|
<div class="panel bad">
|
||||||
|
<h2>The Sin</h2>
|
||||||
|
<VegaChart spec={badSpec} title={`Misleading chart: ${sin.data.title}`} />
|
||||||
|
</div>
|
||||||
|
<div class="panel good">
|
||||||
|
<h2>The Fix</h2>
|
||||||
|
<VegaChart spec={fixedSpec} title={`Honest chart: ${sin.data.title}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose">
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="back-link" href={href()}>← All sins</a>
|
||||||
|
</article>
|
||||||
|
</BaseLayout>
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #fbf9f6;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--text: #1c1a17;
|
||||||
|
--muted: #6b6660;
|
||||||
|
--border: #e6e1d9;
|
||||||
|
--accent: #7c3aed;
|
||||||
|
--sin-red: #c0392b;
|
||||||
|
--good-green: #1e8b5b;
|
||||||
|
--maxw: 68rem;
|
||||||
|
--radius: 12px;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #16140f;
|
||||||
|
--surface: #201d17;
|
||||||
|
--text: #f3efe8;
|
||||||
|
--muted: #a8a196;
|
||||||
|
--border: #35302a;
|
||||||
|
--accent: #a78bfa;
|
||||||
|
--sin-red: #e07668;
|
||||||
|
--good-green: #4cc38a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
max-width: var(--maxw);
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.site-header .wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-block: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.site-header a.brand {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.site-header .tagline {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 4rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.site-footer .wrap {
|
||||||
|
padding-block: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity {
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--sin-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- index gallery --- */
|
||||||
|
.hero {
|
||||||
|
padding-block: 3rem 1.5rem;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.hero p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding-block: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: transform 0.12s ease, border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.card .meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- sin detail page --- */
|
||||||
|
.sin {
|
||||||
|
padding-block: 2.5rem;
|
||||||
|
}
|
||||||
|
.sin header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.sin h1 {
|
||||||
|
font-size: clamp(1.8rem, 4vw, 2.6rem);
|
||||||
|
margin: 0.4rem 0 0.5rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.sin .tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
@media (min-width: 46rem) {
|
||||||
|
.compare {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.compare .panel {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.compare .panel.bad {
|
||||||
|
border-top: 4px solid var(--sin-red);
|
||||||
|
}
|
||||||
|
.compare .panel.good {
|
||||||
|
border-top: 4px solid var(--good-green);
|
||||||
|
}
|
||||||
|
.compare .panel h2 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.compare .panel.bad h2 {
|
||||||
|
color: var(--sin-red);
|
||||||
|
}
|
||||||
|
.compare .panel.good h2 {
|
||||||
|
color: var(--good-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
max-width: 42rem;
|
||||||
|
}
|
||||||
|
.prose h2 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.prose code {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 3rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user