From 2e17f8fa5907f9a187b0914e1c024cdb875c2624 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 11:57:52 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01XPEvmMbac2fCvKXpQcovj8 --- .github/workflows/deploy.yml | 50 +++++ .gitignore | 26 +++ README.md | 86 +++++++++ astro.config.mjs | 11 ++ package.json | 19 ++ src/charts/dual-axis-bad.json | 35 ++++ src/charts/dual-axis-fixed.json | 35 ++++ src/charts/pie-overload-bad.json | 22 +++ src/charts/pie-overload-fixed.json | 22 +++ src/charts/truncated-axis-bad.json | 30 +++ src/charts/truncated-axis-fixed.json | 30 +++ src/components/VegaChart.astro | 80 ++++++++ src/content.config.ts | 28 +++ src/content/sins/dual-axis-deception.md | 30 +++ src/content/sins/pie-chart-overload.md | 31 ++++ src/content/sins/truncated-y-axis.md | 30 +++ src/layouts/BaseLayout.astro | 39 ++++ src/lib/charts.ts | 29 +++ src/lib/url.ts | 10 + src/pages/index.astro | 42 +++++ src/pages/sins/[...slug].astro | 53 ++++++ src/styles/global.css | 235 ++++++++++++++++++++++++ tsconfig.json | 5 + 23 files changed, 978 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 astro.config.mjs create mode 100644 package.json create mode 100644 src/charts/dual-axis-bad.json create mode 100644 src/charts/dual-axis-fixed.json create mode 100644 src/charts/pie-overload-bad.json create mode 100644 src/charts/pie-overload-fixed.json create mode 100644 src/charts/truncated-axis-bad.json create mode 100644 src/charts/truncated-axis-fixed.json create mode 100644 src/components/VegaChart.astro create mode 100644 src/content.config.ts create mode 100644 src/content/sins/dual-axis-deception.md create mode 100644 src/content/sins/pie-chart-overload.md create mode 100644 src/content/sins/truncated-y-axis.md create mode 100644 src/layouts/BaseLayout.astro create mode 100644 src/lib/charts.ts create mode 100644 src/lib/url.ts create mode 100644 src/pages/index.astro create mode 100644 src/pages/sins/[...slug].astro create mode 100644 src/styles/global.css create mode 100644 tsconfig.json diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..36a6153 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95e6132 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4860d11 --- /dev/null +++ b/README.md @@ -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 ``. 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`. diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 0000000..0d0d63d --- /dev/null +++ b/astro.config.mjs @@ -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()], +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..ecd0725 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/charts/dual-axis-bad.json b/src/charts/dual-axis-bad.json new file mode 100644 index 0000000..dcce7db --- /dev/null +++ b/src/charts/dual-axis-bad.json @@ -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" } } +} diff --git a/src/charts/dual-axis-fixed.json b/src/charts/dual-axis-fixed.json new file mode 100644 index 0000000..65d10ad --- /dev/null +++ b/src/charts/dual-axis-fixed.json @@ -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" } + } + } + ] +} diff --git a/src/charts/pie-overload-bad.json b/src/charts/pie-overload-bad.json new file mode 100644 index 0000000..bf90445 --- /dev/null +++ b/src/charts/pie-overload-bad.json @@ -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" } } + } +} diff --git a/src/charts/pie-overload-fixed.json b/src/charts/pie-overload-fixed.json new file mode 100644 index 0000000..00d43f8 --- /dev/null +++ b/src/charts/pie-overload-fixed.json @@ -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 (%)" } + } +} diff --git a/src/charts/truncated-axis-bad.json b/src/charts/truncated-axis-bad.json new file mode 100644 index 0000000..4c1649b --- /dev/null +++ b/src/charts/truncated-axis-bad.json @@ -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)" + } + } +} diff --git a/src/charts/truncated-axis-fixed.json b/src/charts/truncated-axis-fixed.json new file mode 100644 index 0000000..9929bdc --- /dev/null +++ b/src/charts/truncated-axis-fixed.json @@ -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)" + } + } +} diff --git a/src/components/VegaChart.astro b/src/components/VegaChart.astro new file mode 100644 index 0000000..4fc9fbe --- /dev/null +++ b/src/components/VegaChart.astro @@ -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}`); +} +--- + +
+ {error ? ( +
+ Chart failed to render. + {error} +
+ ) : ( +
+ )} + {caption &&
{caption}
} +
+ + diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..9a735b8 --- /dev/null +++ b/src/content.config.ts @@ -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 }; diff --git a/src/content/sins/dual-axis-deception.md b/src/content/sins/dual-axis-deception.md new file mode 100644 index 0000000..4049221 --- /dev/null +++ b/src/content/sins/dual-axis-deception.md @@ -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. diff --git a/src/content/sins/pie-chart-overload.md b/src/content/sins/pie-chart-overload.md new file mode 100644 index 0000000..8de8e9f --- /dev/null +++ b/src/content/sins/pie-chart-overload.md @@ -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. diff --git a/src/content/sins/truncated-y-axis.md b/src/content/sins/truncated-y-axis.md new file mode 100644 index 0000000..b3798ad --- /dev/null +++ b/src/content/sins/truncated-y-axis.md @@ -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. diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..ee6d79c --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -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; +--- + + + + + + + + {title} + + + + +
+ +
+ +
+
+ Chart Sins — an educational catalogue of data-visualization mistakes. +
+
+ + diff --git a/src/lib/charts.ts b/src/lib/charts.ts new file mode 100644 index 0000000..8f75bf7 --- /dev/null +++ b/src/lib/charts.ts @@ -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(); +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)); +} diff --git a/src/lib/url.ts b/src/lib/url.ts new file mode 100644 index 0000000..a67edb6 --- /dev/null +++ b/src/lib/url.ts @@ -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}`; +} diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..c998cb1 --- /dev/null +++ b/src/pages/index.astro @@ -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); +}); +--- + + +
+

A catalogue of chart sins.

+

+ 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. +

+
+ +
+ +
+
diff --git a/src/pages/sins/[...slug].astro b/src/pages/sins/[...slug].astro new file mode 100644 index 0000000..c27105a --- /dev/null +++ b/src/pages/sins/[...slug].astro @@ -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); +--- + + +
+
+ {sin.data.category} +

{sin.data.title}

+ + {severityFlames(sin.data.severity)} + +
+ {sin.data.tags.map((t) => {t})} +
+
+ +
+
+

The Sin

+ +
+
+

The Fix

+ +
+
+ +
+ +
+ + ← All sins +
+
diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..22ea7f8 --- /dev/null +++ b/src/styles/global.css @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8bf91d3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +}