Chart Kit for Future Complaints
Animated-ish, themed, and reusable chart patterns for future posts.
grievances
charts
template
Reusable chart patterns for future Civic Ledger posts.
Published
August 20, 2026
This is the chart shelf. Not a real argument, just the part of the workshop where a grievance tries on different outfits until one of them looks like evidence.
Show code
theme = ({
height: 340,
marginLeft: 62,
marginBottom: 42,
width: Math.max(320, width || 760),
colors: ["#ffd21f", "#55f06a", "#5fd7ff", "#ff6bd6", "#ff7a45", "#b8f032"]
})
formatPoint = (value) => value instanceof Date
? value.toLocaleDateString("en-GB", {day: "2-digit", month: "short", year: "numeric"})
: value
hoverGuides = (data, x, y, label = "Value") => [
Plot.ruleX(data, Plot.pointerX({
x,
y,
clip: true,
stroke: "#cfd3dc",
strokeDasharray: "4 5",
strokeOpacity: 0.75
})),
Plot.ruleY(data, Plot.pointerX({
x,
y,
clip: true,
stroke: "#cfd3dc",
strokeDasharray: "4 5",
strokeOpacity: 0.75
})),
Plot.dot(data, Plot.pointerX({
x,
y,
r: 5,
fill: "#ffd21f",
stroke: "#101010",
strokeWidth: 2
})),
Plot.tip(data, Plot.pointerX({
x,
y,
anchor: "top",
title: (d) => `${formatPoint(d[x])}\n${label}: ${d[y]}`
}))
]
zoomOptions = ["Full range", "Last 4 points", "Last 3 points"]
zoomRows = (data, label) => label === "Full range"
? data
: data.slice(-Number(label.match(/\d+/)[0]))
rows = [
{year: 2020, category: "Rent", group: "Low income", value: 100, share: 34, income: 38, burden: 42, region: "North", status: "Applied"},
{year: 2021, category: "Rent", group: "Low income", value: 112, share: 38, income: 41, burden: 44, region: "North", status: "Reviewed"},
{year: 2022, category: "Rent", group: "Low income", value: 129, share: 43, income: 43, burden: 48, region: "North", status: "Delayed"},
{year: 2023, category: "Rent", group: "Low income", value: 143, share: 46, income: 45, burden: 51, region: "North", status: "Funded"},
{year: 2024, category: "Rent", group: "Low income", value: 157, share: 49, income: 47, burden: 54, region: "North", status: "Funded"},
{year: 2020, category: "Wages", group: "Low income", value: 100, share: 22, income: 38, burden: 42, region: "South", status: "Applied"},
{year: 2021, category: "Wages", group: "Low income", value: 104, share: 24, income: 41, burden: 44, region: "South", status: "Reviewed"},
{year: 2022, category: "Wages", group: "Low income", value: 110, share: 26, income: 43, burden: 48, region: "South", status: "Delayed"},
{year: 2023, category: "Wages", group: "Low income", value: 117, share: 27, income: 45, burden: 51, region: "South", status: "Funded"},
{year: 2024, category: "Wages", group: "Low income", value: 124, share: 29, income: 47, burden: 54, region: "South", status: "Funded"},
{year: 2020, category: "Food", group: "Middle income", value: 100, share: 18, income: 58, burden: 31, region: "East", status: "Applied"},
{year: 2021, category: "Food", group: "Middle income", value: 106, share: 20, income: 61, burden: 33, region: "East", status: "Reviewed"},
{year: 2022, category: "Food", group: "Middle income", value: 118, share: 23, income: 64, burden: 36, region: "East", status: "Delayed"},
{year: 2023, category: "Food", group: "Middle income", value: 128, share: 25, income: 68, burden: 37, region: "East", status: "Funded"},
{year: 2024, category: "Food", group: "Middle income", value: 135, share: 27, income: 72, burden: 39, region: "East", status: "Funded"},
{year: 2020, category: "Transport", group: "Middle income", value: 100, share: 12, income: 58, burden: 31, region: "West", status: "Applied"},
{year: 2021, category: "Transport", group: "Middle income", value: 103, share: 13, income: 61, burden: 33, region: "West", status: "Reviewed"},
{year: 2022, category: "Transport", group: "Middle income", value: 119, share: 15, income: 64, burden: 36, region: "West", status: "Delayed"},
{year: 2023, category: "Transport", group: "Middle income", value: 124, share: 16, income: 68, burden: 37, region: "West", status: "Funded"},
{year: 2024, category: "Transport", group: "Middle income", value: 132, share: 18, income: 72, burden: 39, region: "West", status: "Funded"}
]
latest = rows.filter((d) => d.year === 2024)
rent = rows.filter((d) => d.category === "Rent")
baseline = rows.filter((d) => d.year === 2020)
change = latest.map((d) => ({
category: d.category,
value: d.value,
change: d.value - baseline.find((b) => b.category === d.category).value
}))
candles = [
{date: new Date("2026-01-15"), open: 18.1, high: 18.9, low: 17.8, close: 18.7, mood: "up"},
{date: new Date("2026-02-15"), open: 18.7, high: 19.1, low: 18.2, close: 18.4, mood: "down"},
{date: new Date("2026-03-15"), open: 18.4, high: 19.6, low: 18.3, close: 19.3, mood: "up"},
{date: new Date("2026-04-15"), open: 19.3, high: 19.9, low: 19.0, close: 19.1, mood: "down"},
{date: new Date("2026-05-15"), open: 19.1, high: 20.4, low: 19.0, close: 20.2, mood: "up"},
{date: new Date("2026-06-15"), open: 20.2, high: 21.6, low: 20.1, close: 21.4, mood: "up"}
]Rent Index
157
2024 sample
Wage Index
124
2024 sample
Gap
33 pts
Rent minus wages
Confidence
low
Toy data, relax
Interactive Controls
Show code
viewof selectedCategory = Inputs.select(["Rent", "Wages", "Food", "Transport"], {
label: "Category",
value: "Rent"
})
viewof emphasis = Inputs.range([1, 5], {
label: "Drama level",
value: 3,
step: 1
})
viewof chartZoom = Inputs.radio(zoomOptions, {
label: "Zoom",
value: "Full range"
})
selectedRows = zoomRows(rows.filter((d) => d.category === selectedCategory), chartZoom)
visibleRows = zoomRows(rows, chartZoom)Time-Series Line
Show code
Plot.plot({
width: theme.width,
height: theme.height,
marginLeft: theme.marginLeft,
color: {legend: true, range: theme.colors},
y: {grid: true, label: "Index"},
x: {label: null, tickFormat: "d"},
marks: [
Plot.ruleY([100], {stroke: "#5f6470", strokeDasharray: "4 4"}),
Plot.lineY(visibleRows, {x: "year", y: "value", stroke: "category", strokeWidth: 3}),
Plot.dot(visibleRows, {x: "year", y: "value", fill: "category", r: 3, title: (d) => `${d.category} ${d.year}: ${d.value}`}),
...hoverGuides(visibleRows, "year", "value", "Index")
]
})Annotated Time Series
Show code
Plot.plot({
width: theme.width,
height: theme.height,
marginLeft: theme.marginLeft,
y: {grid: true, label: `${selectedCategory} index`},
x: {label: null, tickFormat: "d"},
marks: [
Plot.areaY(selectedRows, {x: "year", y: "value", fill: "#ffd21f", fillOpacity: 0.12 + emphasis * 0.025}),
Plot.lineY(selectedRows, {x: "year", y: "value", stroke: "#ffd21f", strokeWidth: emphasis}),
Plot.dot(selectedRows, {x: "year", y: "value", fill: "#ffd21f", r: 4}),
Plot.ruleX([2022], {stroke: "#ffd21f", strokeDasharray: "4 4"}),
Plot.text([{year: 2022, value: selectedRows.find((d) => d.year === 2022).value, label: "this is where I start side-eyeing it"}], {x: "year", y: "value", text: "label", dy: -20, fill: "#f7f7f2"}),
...hoverGuides(selectedRows, "year", "value", "Index")
]
})Candlestick Chart
Show code
Plot.plot({
width: theme.width,
height: 340,
marginLeft: 62,
marginBottom: 42,
color: {domain: ["up", "down"], range: ["#ffd21f", "#5fd7ff"]},
y: {grid: true, label: "Price"},
x: {
label: null,
domain: [
new Date(Math.min(...zoomRows(candles, chartZoom).map((d) => +d.date))),
new Date(Math.max(...zoomRows(candles, chartZoom).map((d) => +d.date)))
]
},
marks: [
Plot.ruleX(zoomRows(candles, chartZoom), {x: "date", y1: "low", y2: "high", stroke: "mood", strokeWidth: 2}),
Plot.tickX(zoomRows(candles, chartZoom), {
x: "date",
y1: "open",
y2: "close",
stroke: "mood",
strokeWidth: Math.max(4, Math.min(18, theme.width / Math.max(12, zoomRows(candles, chartZoom).length * 3))),
strokeLinecap: "butt"
}),
...hoverGuides(zoomRows(candles, chartZoom), "date", "close", "Close")
]
})Bar Ranking
Show code
ranked = latest.slice().sort((a, b) => b.value - a.value)
Plot.plot({
height: 320,
marginLeft: 104,
x: {grid: true, label: "Index"},
y: {label: null},
marks: [
Plot.barX(ranked, {y: "category", x: "value", fill: "#55f06a", rx: 6}),
Plot.textX(ranked, {y: "category", x: "value", text: "value", dx: 14, fill: "#f7f7f2"})
]
})Grouped Bars
Stacked Bars
Percentage Change
Show code
Plot.plot({
height: 320,
marginLeft: 104,
x: {grid: true, label: "Change since 2020"},
y: {label: null},
marks: [
Plot.ruleX([0], {stroke: "#5f6470"}),
Plot.barX(change, {y: "category", x: "change", fill: (d) => d.change >= 30 ? "#ffd21f" : "#5fd7ff", rx: 6}),
Plot.textX(change, {y: "category", x: "change", text: (d) => `+${d.change}%`, dx: 14, fill: "#f7f7f2"})
]
})Scatter + Regression
Show code
regression = [
{income: 38, burden: 42, label: "Aster"},
{income: 41, burden: 44, label: "Briar"},
{income: 43, burden: 48, label: "Cedar"},
{income: 45, burden: 51, label: "Dover"},
{income: 47, burden: 54, label: "Elm"},
{income: 58, burden: 31, label: "Firth"},
{income: 61, burden: 33, label: "Glen"},
{income: 72, burden: 39, label: "Harbor"}
]
Plot.plot({
height: 360,
marginLeft: 58,
x: {grid: true, label: "Income"},
y: {grid: true, label: "Burden"},
marks: [
Plot.dot(regression, {x: "income", y: "burden", fill: "#ffd21f", r: 6, title: (d) => `${d.label}: ${d.burden}`}),
Plot.linearRegressionY(regression, {x: "income", y: "burden", stroke: "#ff6bd6", strokeWidth: 3})
]
})Histogram
Box Plot
Heatmap
Show code
Choropleth Map
Show code
mapTiles = [
{x: 0, y: 0, region: "North", value: 54},
{x: 1, y: 0, region: "East", value: 47},
{x: 0, y: 1, region: "South", value: 39},
{x: 1, y: 1, region: "West", value: 31}
]
Plot.plot({
height: 340,
marginLeft: 30,
color: {scheme: "YlOrBr", legend: true, label: "Burden"},
x: {axis: null},
y: {axis: null},
marks: [
Plot.cell(mapTiles, {x: "x", y: "y", fill: "value", inset: 4, rx: 10, title: (d) => `${d.region}: ${d.value}`}),
Plot.text(mapTiles, {x: "x", y: "y", text: "region", fill: "#111", fontWeight: 800})
]
})Point / Marker Map
Show code
points = [
{x: 0.25, y: 0.35, place: "Clinic", value: 12},
{x: 0.62, y: 0.5, place: "School", value: 19},
{x: 0.48, y: 0.76, place: "Market", value: 31},
{x: 0.82, y: 0.26, place: "Terminal", value: 24}
]
Plot.plot({
height: 340,
x: {axis: null, domain: [0, 1]},
y: {axis: null, domain: [0, 1]},
marks: [
Plot.frame({stroke: "#393939"}),
Plot.dot(points, {x: "x", y: "y", r: "value", fill: "#ffd21f", fillOpacity: 0.7, stroke: "#101010", title: (d) => `${d.place}: ${d.value}`}),
Plot.text(points, {x: "x", y: "y", text: "place", dy: -22, fill: "#f7f7f2"})
]
})Project Progress
Show code
progress = [
{stage: "Data", done: 80},
{stage: "Draft", done: 45},
{stage: "Charts", done: 65},
{stage: "Sources", done: 30}
]
Plot.plot({
height: 280,
marginLeft: 82,
x: {domain: [0, 100], grid: true, label: "Percent done"},
y: {label: null},
marks: [
Plot.barX(progress, {y: "stage", x: 100, fill: "#242424", rx: 8}),
Plot.barX(progress, {y: "stage", x: "done", fill: "#ffd21f", rx: 8}),
Plot.textX(progress, {y: "stage", x: "done", text: (d) => `${d.done}%`, dx: 14, fill: "#f7f7f2"})
]
})Gantt Chart
Show code
tasks = [
{task: "Collect data", start: new Date("2026-01-01"), end: new Date("2026-02-10")},
{task: "Clean data", start: new Date("2026-02-01"), end: new Date("2026-03-15")},
{task: "Write draft", start: new Date("2026-03-10"), end: new Date("2026-04-20")},
{task: "Regret everything", start: new Date("2026-04-05"), end: new Date("2026-05-15")}
]
Plot.plot({
height: 300,
marginLeft: 120,
x: {label: null},
y: {label: null},
marks: [
Plot.barX(tasks, {y: "task", x1: "start", x2: "end", fill: "#55f06a", rx: 8}),
Plot.textX(tasks, {y: "task", x: "end", text: "task", dx: 8, fill: "#f7f7f2"})
]
})Timeline
Show code
events = [
{date: new Date("2020-01-01"), label: "baseline"},
{date: new Date("2021-06-01"), label: "costs creep"},
{date: new Date("2022-03-01"), label: "shock"},
{date: new Date("2024-01-01"), label: "new normal, allegedly"}
]
Plot.plot({
height: 220,
marginLeft: 40,
x: {label: null},
y: {axis: null},
marks: [
Plot.ruleY([0], {stroke: "#5f6470"}),
Plot.dot(events, {x: "date", y: 0, fill: "#ffd21f", r: 6}),
Plot.text(events, {x: "date", y: 0, text: "label", dy: -18, fill: "#f7f7f2"})
]
})Sankey
Network Diagram
Custom HTML Overlay Chart
For the chart that only needs to exist once: use raw HTML/SVG. Keep the data small, keep the SVG responsive with viewBox, and use custom-html-chart so it still belongs to the site.
observed line
comparison line
policy window
This is just HTML. Perfect for weird overlays, screenshots traced into SVG, policy bands, custom labels, or one-off annotations.
KPI Cards
Use the card pattern near the top of this post. It is plain Quarto markup, so it is fast, stable, and easy to copy.
Interactive Table
| Category | 2024 Index | Share | Income | Burden | Region |
|---|---|---|---|---|---|
| Rent | 157 | 49 | 47 | 54 | North |
| Wages | 124 | 29 | 47 | 54 | South |
| Food | 135 | 27 | 72 | 39 | East |
| Transport | 132 | 18 | 72 | 39 | West |
Before / After Image Comparison
Before: the official line
After: the spreadsheet starts coughing