Chart Lab
Reusable interactive chart patterns for public-interest research.
Published
August 20, 2026
Chart Components
A small library for economic stories.
Use these blocks as starting points for briefs, dashboards, appendices, and methods pages. The examples are intentionally simple so your own data can replace the sample arrays quickly.
Trend With Scenario Control
Show code
viewof region = Inputs.select(["Metro core", "Inner ring", "Outer towns"], {
label: "Region",
value: "Metro core"
})
trendRows = [
{region: "Metro core", year: 2020, value: 100},
{region: "Metro core", year: 2021, value: 112},
{region: "Metro core", year: 2022, value: 129},
{region: "Metro core", year: 2023, value: 143},
{region: "Metro core", year: 2024, value: 157},
{region: "Inner ring", year: 2020, value: 100},
{region: "Inner ring", year: 2021, value: 107},
{region: "Inner ring", year: 2022, value: 118},
{region: "Inner ring", year: 2023, value: 126},
{region: "Inner ring", year: 2024, value: 134},
{region: "Outer towns", year: 2020, value: 100},
{region: "Outer towns", year: 2021, value: 104},
{region: "Outer towns", year: 2022, value: 111},
{region: "Outer towns", year: 2023, value: 119},
{region: "Outer towns", year: 2024, value: 128}
]
selectedTrend = trendRows.filter((d) => d.region === region)
Plot.plot({
height: 320,
marginLeft: 52,
y: {grid: true, label: "Index"},
x: {label: null, tickFormat: "d"},
marks: [
Plot.ruleY([100], {strokeDasharray: "4 4"}),
Plot.areaY(selectedTrend, {x: "year", y: "value", fill: "#ffd21f", fillOpacity: 0.16}),
Plot.lineY(selectedTrend, {x: "year", y: "value", stroke: "#ffd21f", strokeWidth: 3}),
Plot.dot(selectedTrend, {x: "year", y: "value", fill: "#ffd21f", title: (d) => `${d.region}, ${d.year}: ${d.value}`})
]
})Budget Pressure Bars
Show code
viewof household = Inputs.select(["Single renter", "Family renter", "Owner with mortgage"], {
label: "Household",
value: "Family renter"
})
budgetRows = [
{household: "Single renter", category: "Rent", share: 42},
{household: "Single renter", category: "Food", share: 16},
{household: "Single renter", category: "Transport", share: 11},
{household: "Single renter", category: "Debt", share: 8},
{household: "Family renter", category: "Rent", share: 38},
{household: "Family renter", category: "Food", share: 22},
{household: "Family renter", category: "Transport", share: 14},
{household: "Family renter", category: "Debt", share: 9},
{household: "Owner with mortgage", category: "Mortgage", share: 31},
{household: "Owner with mortgage", category: "Food", share: 18},
{household: "Owner with mortgage", category: "Transport", share: 15},
{household: "Owner with mortgage", category: "Debt", share: 10}
]
selectedBudget = budgetRows.filter((d) => d.household === household)
Plot.plot({
height: 320,
marginLeft: 88,
x: {grid: true, label: "Share of monthly income"},
y: {label: null},
color: {range: ["#ffd21f", "#55f06a", "#5fd7ff", "#ff6bd6"]},
marks: [
Plot.barX(selectedBudget, {y: "category", x: "share", fill: "category", rx: 8}),
Plot.textX(selectedBudget, {y: "category", x: "share", text: (d) => `${d.share}%`, dx: 16, fill: "#f7f7f2"})
]
})Inequality Scatter
Show code
viewof highlight = Inputs.select(["All", "Low mobility", "High burden"], {
label: "Highlight",
value: "All"
})
scatterRows = [
{place: "Aster", income: 42, rent: 31, mobility: 62},
{place: "Briar", income: 51, rent: 39, mobility: 45},
{place: "Cedar", income: 66, rent: 34, mobility: 58},
{place: "Dover", income: 39, rent: 44, mobility: 33},
{place: "Elm", income: 72, rent: 27, mobility: 71},
{place: "Firth", income: 58, rent: 42, mobility: 39},
{place: "Glen", income: 47, rent: 36, mobility: 48},
{place: "Harbor", income: 83, rent: 29, mobility: 76}
]
markedScatter = scatterRows.map((d) => ({
...d,
group: highlight === "Low mobility" && d.mobility < 45 ? "Selected" :
highlight === "High burden" && d.rent > 38 ? "Selected" : "Context"
}))
Plot.plot({
height: 320,
marginLeft: 56,
x: {grid: true, label: "Median income"},
y: {grid: true, label: "Rent burden"},
color: {domain: ["Selected", "Context"], range: ["#ffd21f", "#59606b"]},
marks: [
Plot.dot(markedScatter, {x: "income", y: "rent", r: "mobility", fill: "group", fillOpacity: 0.82, title: (d) => `${d.place}: ${d.rent}% rent burden`}),
Plot.text(markedScatter.filter((d) => d.group === "Selected"), {x: "income", y: "rent", text: "place", dy: -14, fill: "#f7f7f2"})
]
})Small Multiples
Show code
multipleRows = [
{group: "Youth", year: 2021, value: 18}, {group: "Youth", year: 2022, value: 21}, {group: "Youth", year: 2023, value: 25}, {group: "Youth", year: 2024, value: 28},
{group: "Women", year: 2021, value: 15}, {group: "Women", year: 2022, value: 17}, {group: "Women", year: 2023, value: 19}, {group: "Women", year: 2024, value: 22},
{group: "Disabled", year: 2021, value: 24}, {group: "Disabled", year: 2022, value: 27}, {group: "Disabled", year: 2023, value: 29}, {group: "Disabled", year: 2024, value: 33},
{group: "Migrants", year: 2021, value: 20}, {group: "Migrants", year: 2022, value: 24}, {group: "Migrants", year: 2023, value: 26}, {group: "Migrants", year: 2024, value: 31}
]
Plot.plot({
height: 380,
facet: {data: multipleRows, y: "group", marginRight: 90},
x: {label: null, tickFormat: "d"},
y: {grid: true, label: "Share affected"},
marks: [
Plot.frame(),
Plot.lineY(multipleRows, {x: "year", y: "value", stroke: "#55f06a", strokeWidth: 2.5}),
Plot.dot(multipleRows, {x: "year", y: "value", fill: "#55f06a", title: (d) => `${d.group} ${d.year}: ${d.value}%`})
]
})Median Rent
158
Index, 2018 = 100
Wage Growth
31%
Illustrative sample
Pressure Gap
27 pts
Rent minus wages
Vulnerable Groups
4
Tracked in small multiples