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:
Claude
2026-08-05 11:57:52 +00:00
commit 2e17f8fa59
23 changed files with 978 additions and 0 deletions
+35
View File
@@ -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" } }
}
+35
View File
@@ -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" }
}
}
]
}
+22
View File
@@ -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" } }
}
}
+22
View File
@@ -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 (%)" }
}
}
+30
View File
@@ -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)"
}
}
}
+30
View File
@@ -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)"
}
}
}
+80
View File
@@ -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>
+28
View File
@@ -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 };
+30
View File
@@ -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.
+31
View File
@@ -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.
+30
View File
@@ -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.
+39
View File
@@ -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>
+29
View File
@@ -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 15 severity as flame glyphs for display. */
export function severityFlames(severity: number): string {
return '🔥'.repeat(severity) + '·'.repeat(Math.max(0, 5 - severity));
}
+10
View File
@@ -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}`;
}
+42
View File
@@ -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>
+53
View File
@@ -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>
+235
View File
@@ -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;
}