Civic Ledger
  • Home
  • Grievance Bin

Chart Lab

Reusable interactive chart patterns for public-interest research.

Published

August 20, 2026

Show code
Plot = require("@observablehq/plot")

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

Civic Ledger

 

Readable research for public life