From 90a564ef7e174352412dee0fd021e13d42c06d88 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 9 Sep 2026 12:01:42 -0400 Subject: [PATCH 1/3] Take Toronto's ward figures from the City's own profiles The 2021 Census numbers behind a ward brief are the sort of thing that gets hand-typed once and then quietly rots. So they are extracted instead: a small checked-in CSV per source, and a dependency-free generator that turns them into the display strings the pages want. scripts/data/README.md carries the provenance of every column down to its row in the City's spreadsheet, so a reviewer can check any figure without opening an XLSX. The extraction asserted each field against its source row label rather than trusting row offsets. Statistics come from Ward Profiles (25-Ward Model); the sitting councillor for each ward comes from Ward and Elected Councillor. Both are City of Toronto Open Data, over Statistics Canada's 2021 and 2016 Census. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/data/README.md | 69 +++++++ scripts/data/toronto-ward-census-2021.csv | 27 +++ scripts/data/toronto-ward-councillors.csv | 26 +++ scripts/gen-ward-profiles.mjs | 216 ++++++++++++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 scripts/data/README.md create mode 100644 scripts/data/toronto-ward-census-2021.csv create mode 100644 scripts/data/toronto-ward-councillors.csv create mode 100644 scripts/gen-ward-profiles.mjs diff --git a/scripts/data/README.md b/scripts/data/README.md new file mode 100644 index 00000000..a25cafb2 --- /dev/null +++ b/scripts/data/README.md @@ -0,0 +1,69 @@ +# scripts/data + +Checked-in intermediates for the `scripts/gen-*.mjs` generators. Each one is a +small, human-auditable extract of a larger official file, so the generators can +run with no extra dependencies and a reviewer can diff the numbers. + +## toronto-ward-census-2021.csv + +Per-ward figures for Toronto's 25-ward model, extracted from the City of +Toronto's own ward profiles. Consumed by `scripts/gen-ward-profiles.mjs`, which +emits `src/app/toronto/vote/2026/wardStats.ts`. + +- Dataset: Ward Profiles (25-Ward Model), City of Toronto Open Data + https://open.toronto.ca/dataset/ward-profiles-25-ward-model/ +- Underlying source: Statistics Canada, 2021 and 2016 Census of Population +- Files used: + - `2023-wardprofiles-2011-2021-censusdata_rev0719.xlsx` + (sheets "2021 One Variable" and "2016 Census One Variable") + - `2023-wardprofiles-geographicareas.xlsx` (ward land area) + +The `ward` column is the zero-padded ward token used throughout the app +("01".."25"), plus one `toronto` row holding the citywide figure for each +column so the UI can show every ward against the city. + +Column provenance, by row number in the "2021 One Variable" sheet: + +| column | source row | +| --- | --- | +| `pop_2021` | 19 — Total - Age | +| `pop_2016` | 19 of the "2016 Census One Variable" sheet | +| `median_age` | 40 — Median age | +| `dwellings_total` | 44 — Total - Occupied private dwellings by structural type | +| `single_detached` | 45 — Single-detached house | +| `apt_5plus` | 50 — Apartment in a building that has five or more storeys | +| `tenure_total` | 55 — Total - Tenure (includes band housing) | +| `owned` | 56 — Owned | +| `rented` | 59 — Rented | +| `hhtype_total` | 114 — Total - Household type | +| `one_person_hh` | 124 — One-person households | +| `privhh_pop` | 144 — Total - Immigrant status and period of immigration | +| `immigrants` | 146 — Immigrants | +| `edu_total_15plus` | 997 — Total - Highest certificate, diploma or degree | +| `bachelor_plus` | 1007 — Bachelor's degree or higher | +| `med_hh_income` | 1385 — Median total income of households in 2020 ($) | +| `avg_rent` | 1392 — Average monthly shelter costs for rented dwellings ($) | +| `tenant_burden_30` | 1393 — % of tenant households spending 30%+ on shelter | +| `avg_owner_cost` | 1396 — Average monthly shelter costs for owned dwellings ($) | +| `owner_burden_30` | 1397 — % of owner households spending 30%+ on shelter | +| `lim_at_rate` | 1405 — Prevalence of low income (LIM-AT), % | +| `area_sq_km` | ward land area, from the geographic-areas file | + +Counts are Statistics Canada's, and are rounded to the nearest 5 at source, so +percentages derived from them carry that rounding. Refresh this file when the +City publishes ward profiles for the 2026 Census. + +## toronto-ward-councillors.csv + +The councillor sitting for each of Toronto's 25 wards. Consumed by the same +generator, which emits `src/app/toronto/vote/2026/wardCouncillors.ts`. + +- Dataset: Ward and Elected Councillor, City of Toronto Open Data + https://open.toronto.ca/dataset/wards-and-elected-councillors/ +- Columns: the zero-padded `ward` token, the `councillor` holding it, and the + `date_effective` the City records for that row. + +Refresh after any by-election, and again once the 2026 results are declared. +Whether a councillor is seeking re-election is deliberately *not* recorded +here — that is read off the live candidate roster at render time, so it cannot +go stale. See `wardProfile()` in `src/app/toronto/vote/2026/wardProfiles.ts`. diff --git a/scripts/data/toronto-ward-census-2021.csv b/scripts/data/toronto-ward-census-2021.csv new file mode 100644 index 00000000..26fc1ac0 --- /dev/null +++ b/scripts/data/toronto-ward-census-2021.csv @@ -0,0 +1,27 @@ +ward,pop_2021,pop_2016,median_age,dwellings_total,single_detached,apt_5plus,tenure_total,owned,rented,hhtype_total,one_person_hh,privhh_pop,immigrants,edu_total_15plus,bachelor_plus,med_hh_income,avg_rent,tenant_burden_30,avg_owner_cost,owner_burden_30,lim_at_rate,area_sq_km +toronto,2761285,2691665,39.6,1160895,270350,542625,1160885,602910,557970,1160890,385530,2761285,1286140,2377950,976620,84000,1562,40,2038,25.6,13.2, +01,115120,116955,38,38135,11270,15980,38130,20790,17340,38130,7785,115115,64675,96620,22120,81000,1328,34.2,1664,23.5,12.9,48.37 +02,117200,116055,45.2,45345,21860,17860,45345,30565,14780,45345,11105,117205,48485,99900,37540,100000,1574,39.2,1930,18.8,7.9,37.35 +03,139920,127525,40.8,65575,18585,30645,65570,37310,28260,65575,24520,139920,54670,121460,54175,90000,1592,40.9,2108,24.7,11,39.99 +04,104715,106445,40,49445,8855,18955,49440,21610,27835,49440,19625,104715,32400,89700,45405,85000,1492,39.7,2280,20.3,12,15.33 +05,115675,115130,40.4,45055,13190,18300,45050,21685,23370,45050,12850,115675,58205,97205,17910,72000,1196,34.8,1660,22.3,14.7,24.98 +06,107355,103760,41.6,41265,10350,17860,41270,20535,20735,41270,11095,107360,59525,91800,30650,82000,1448,36.1,1918,25.3,11.5,35.36 +07,111200,107725,37.6,37675,4690,15480,37675,18070,19605,37675,8280,111200,62700,92430,16310,73000,1261,33.2,1622,24,15.1,30.7 +08,114820,112920,41.2,45910,17190,15340,45910,24515,21395,45915,14090,114820,44725,95710,45065,97000,1588,38,2418,20.3,10.1,22.66 +09,104730,107395,38.4,45670,6470,8520,45670,23650,22025,45670,14995,104730,39645,91630,34340,85000,1552,41.3,1910,21.6,10.8,12.1 +10,135400,114315,32.8,80730,500,71070,80735,33845,46885,80735,42205,135405,53910,126130,82560,89000,1988,43.7,2290,35.2,14.1,12.81 +11,102385,100520,36.8,53510,4180,27355,53505,23085,30420,53505,24065,102385,34755,93395,57405,84000,1976,48.8,2592,29.8,15.3,13.6 +12,114095,104940,39.6,58110,8850,32900,58110,22430,35680,58110,25500,114095,40430,100635,57100,86000,1768,44.6,2492,24.8,12.5,13.12 +13,116930,99590,34.8,68965,250,58730,68965,20665,48300,68965,37570,116935,48730,107625,58605,65000,1520,43.1,2172,35.1,22.2,5.86 +14,104555,105395,40.8,46235,8410,10185,46235,25380,20855,46235,16025,104555,31915,88370,40500,93000,1424,37.8,2138,18.2,11.5,20.8 +15,101025,101790,41.2,40030,14730,16035,40030,20815,19215,40030,11845,101025,41420,83725,46220,102000,1716,40.9,2968,21.8,12.6,30.3 +16,94335,93165,41.2,38285,5425,22545,38285,16940,21340,38285,11695,94335,50390,79750,29065,78500,1492,39.1,1846,26.1,14.4,22.96 +17,112590,109055,40.4,45780,8825,25675,45785,25990,19790,45785,12555,112590,68280,98425,48980,84000,1754,43.6,1904,31.4,14.4,24.42 +18,117130,117400,39.2,50550,12425,30705,50555,29340,21210,50555,15970,117125,70535,103695,57030,81000,1880,49.3,2202,37.6,17.8,19.76 +19,108500,108440,41.2,45705,12790,11505,45700,25515,20190,45700,14595,108500,34420,89815,38480,89000,1370,38.6,2168,18.3,11.9,16.8 +20,110095,108290,40.8,41905,14850,14260,41905,22735,19165,41905,11655,110095,50370,91970,28980,79000,1196,32.3,1872,22.7,13.9,28.14 +21,111560,110445,40,40565,13880,16840,40560,23045,17520,40560,10070,111560,61670,94595,27210,78000,1338,35.8,1668,26,13.3,28.21 +22,103690,104230,45.2,38345,11075,18080,38350,25370,12975,38350,9200,103690,66065,90390,28905,77000,1358,36,1640,32,14.7,21.4 +23,94025,97610,42.8,29545,11790,6630,29545,23465,6085,29545,4420,94025,60740,80925,21660,87000,1408,33.3,1636,28.8,11.8,30.44 +24,102755,101110,38.4,36240,12225,17140,36245,19925,16320,36245,8655,102755,54840,85580,25115,78000,1197,31.6,1776,25.4,14.7,26.11 +25,101485,101445,42,32315,17685,4025,32315,25650,6665,32315,5160,101485,52660,86445,25270,105000,1362,32.7,1802,20.7,7.8,54.08 diff --git a/scripts/data/toronto-ward-councillors.csv b/scripts/data/toronto-ward-councillors.csv new file mode 100644 index 00000000..fe4ba05d --- /dev/null +++ b/scripts/data/toronto-ward-councillors.csv @@ -0,0 +1,26 @@ +ward,councillor,date_effective +01,Vincent Crisanti,2025-04-16 +02,Stephen Holyday,2025-04-16 +03,Amber Morley,2025-04-16 +04,Gord Perks,2025-04-16 +05,Frances Nunziata,2025-04-16 +06,James Pasternak,2025-04-16 +07,Anthony Perruzza,2025-04-16 +08,Mike Colle,2025-04-16 +09,Alejandra Bravo,2025-04-16 +10,Ausma Malik,2025-04-16 +11,Dianne Saxe,2025-04-16 +12,Josh Matlow,2025-04-16 +13,Chris Moise,2025-04-16 +14,Paula Fletcher,2025-04-16 +15,Rachel Chernos Lin,2025-04-16 +16,Jon Burnside,2025-04-16 +17,Shelley Carroll,2025-04-16 +18,Lily Cheng,2025-04-16 +19,Brad Bradford,2025-04-16 +20,Parthi Kandavel,2025-04-16 +21,Michael Thompson,2025-04-16 +22,Nick Mantas,2025-04-16 +23,Jamaal Myers,2025-04-16 +24,Paul Ainslie,2025-04-16 +25,Neethan Shan,2025-04-16 diff --git a/scripts/gen-ward-profiles.mjs b/scripts/gen-ward-profiles.mjs new file mode 100644 index 00000000..f80c5afe --- /dev/null +++ b/scripts/gen-ward-profiles.mjs @@ -0,0 +1,216 @@ +// Generate Toronto's per-ward Census statistics (wardStats.ts) from the +// checked-in extract of the City's own ward profiles. +// +// node scripts/gen-ward-profiles.mjs +// +// Inputs scripts/data/toronto-ward-census-2021.csv (see scripts/data/README.md +// for every column's provenance in the City's XLSX) +// scripts/data/toronto-ward-councillors.csv +// Outputs src/app/toronto/vote/2026/wardStats.ts +// src/app/toronto/vote/2026/wardCouncillors.ts +// +// The statistics are the generated half of a ward's profile; the hand-written +// half — the brief prose — lives in wardProfiles.ts and this script never +// touches it. Same split as wardGeo.ts (generated) and candidates.ts (by hand). +// +// Every stat is emitted pre-formatted as a display string, with the citywide +// figure alongside it, because the only consumer is a UI that wants to print +// "45.5%" next to "48.1% citywide" — not to do arithmetic. Deliberately +// dependency-free: this runs once per Census release. + +import { readFileSync, writeFileSync } from "node:fs"; + +const INPUT = "scripts/data/toronto-ward-census-2021.csv"; +const OUT = "src/app/toronto/vote/2026/wardStats.ts"; +const COUNCILLORS_INPUT = "scripts/data/toronto-ward-councillors.csv"; +const COUNCILLORS_OUT = "src/app/toronto/vote/2026/wardCouncillors.ts"; + +// ── Read ─────────────────────────────────────────────────────────────────── + +const lines = readFileSync(INPUT, "utf8").trim().split(/\r?\n/); +const header = lines[0].split(","); +const rows = new Map( + lines.slice(1).map((line) => { + const cells = line.split(","); + const row = Object.fromEntries(header.map((h, i) => [h, cells[i]])); + return [row.ward, row]; + }), +); + +const city = rows.get("toronto"); +if (!city) throw new Error(`${INPUT}: no "toronto" row for citywide figures`); + +const WARDS = [...rows.keys()].filter((w) => w !== "toronto").sort(); +if (WARDS.length !== 25) { + throw new Error(`${INPUT}: expected 25 wards, found ${WARDS.length}`); +} + +// ── Format ───────────────────────────────────────────────────────────────── + +const num = (row, key) => { + const raw = row[key]; + if (raw === undefined || raw === "") return null; + const n = Number(raw); + if (!Number.isFinite(n)) throw new Error(`${key}: not a number (${raw})`); + return n; +}; + +const need = (row, key) => { + const n = num(row, key); + if (n === null) throw new Error(`ward ${row.ward}: ${key} is empty`); + return n; +}; + +const count = (n) => n.toLocaleString("en-CA"); +const money = (n) => `$${n.toLocaleString("en-CA")}`; +const pct = (n) => `${n.toFixed(1)}%`; +const share = (part, whole) => pct((part / whole) * 100); +const signedPct = (n) => `${n >= 0 ? "+" : "−"}${Math.abs(n).toFixed(1)}%`; + +/** Population change since the 2016 Census, as a display string. A ward that + * moved by less than 0.05% would print as "+0.0% since 2016", which reads as + * a rounding artefact rather than the fact it is, so it is spelled out. */ +const popChange = (row) => { + const change = (need(row, "pop_2021") / need(row, "pop_2016") - 1) * 100; + if (Math.abs(change) < 0.05) return "No change since 2016"; + return `${signedPct(change)} since 2016`; +}; + +// Each entry is one row of the ward profile: a label, the ward's value, the +// citywide value to read it against, and an optional note under it. `city` +// returns null where a citywide comparison is meaningless (density). +const STATS = [ + { + label: "Population", + ward: (r) => count(need(r, "pop_2021")), + note: (r) => popChange(r), + }, + { + label: "Residents per km²", + ward: (r) => count(Math.round(need(r, "pop_2021") / need(r, "area_sq_km"))), + city: () => null, + note: (r) => `${need(r, "area_sq_km").toFixed(1)} km² of land`, + }, + { + label: "Median household income", + ward: (r) => money(need(r, "med_hh_income")), + }, + { + label: "Renter households", + ward: (r) => share(need(r, "rented"), need(r, "tenure_total")), + note: (r) => `${money(need(r, "avg_rent"))} average monthly rent`, + }, + { + label: "Renters spending 30%+ of income on housing", + ward: (r) => pct(need(r, "tenant_burden_30")), + }, + { + label: "Homes in buildings of five storeys or more", + ward: (r) => share(need(r, "apt_5plus"), need(r, "dwellings_total")), + note: (r) => + `${share(need(r, "single_detached"), need(r, "dwellings_total"))} single-detached`, + }, + { + label: "Residents born outside Canada", + ward: (r) => share(need(r, "immigrants"), need(r, "privhh_pop")), + }, + { + label: "Residents in low income", + ward: (r) => pct(need(r, "lim_at_rate")), + note: () => "Low-income measure, after tax", + }, +]; + +const statsFor = (row) => + STATS.map((stat) => { + const entry = { label: stat.label, value: stat.ward(row) }; + const cityValue = stat.city ? stat.city(row) : stat.ward(city); + if (cityValue !== null) entry.cityValue = cityValue; + const note = stat.note ? stat.note(row) : null; + if (note !== null) entry.note = note; + return entry; + }); + +// Serialised by hand rather than with JSON.stringify over an object, because +// JS reorders integer-like keys ("10" before "01") and the emitted file should +// read 01..25 for whoever reviews the diff. +const table = + "{\n" + + WARDS.map( + (w) => + ` "${w}": ${JSON.stringify(statsFor(rows.get(w)), null, 2).replace(/\n/g, "\n ")},`, + ).join("\n") + + "\n}"; + +// ── Emit ─────────────────────────────────────────────────────────────────── + +const file = `// AUTO-GENERATED — do not edit by hand. +// Toronto's per-ward Census statistics, formatted for display. +// Source: Ward Profiles (25-Ward Model), City of Toronto Open Data, from +// Statistics Canada's 2021 and 2016 Census of Population. +// https://open.toronto.ca/dataset/ward-profiles-25-ward-model/ +// Regenerate with: node scripts/gen-ward-profiles.mjs +// The hand-written briefs that accompany these live in ./wardProfiles.ts. + +import type { WardStat } from "@/components/elections/WardProfile"; + +/** Statistics for each ward, keyed by the zero-padded ward token ("01".."25"), + * in the order they should be shown. */ +export const WARD_STATS: Record = ${table}; + +/** Where these figures come from, worded for the source line shown under a + * ward's statistics. */ +export const WARD_STATS_SOURCE = + "the 2021 Census of Population, via the City of Toronto\u2019s ward profiles"; +`; + +writeFileSync(OUT, file); +console.log( + `${OUT}: ${WARDS.length} wards × ${STATS.length} stats, ${(Buffer.byteLength(file) / 1024).toFixed(1)} KB`, +); + +// ── Sitting councillors ──────────────────────────────────────────────────── + +/* Who holds each seat today, so a ward page can name the incumbent when they + register to run again. Whether they have is not knowable here — it comes + from the live candidate roster — so this file only says who sits, and the + page decides whether to show them. */ + +const councillorLines = readFileSync(COUNCILLORS_INPUT, "utf8") + .trim() + .split(/\r?\n/); +const councillorHeader = councillorLines[0].split(","); +const councillors = councillorLines.slice(1).map((line) => { + const cells = line.split(","); + return Object.fromEntries(councillorHeader.map((h, i) => [h, cells[i]])); +}); + +if (councillors.length !== 25) { + throw new Error( + `${COUNCILLORS_INPUT}: expected 25 councillors, found ${councillors.length}`, + ); +} + +const effective = [...new Set(councillors.map((c) => c.date_effective))].sort(); + +const councillorFile = `// AUTO-GENERATED — do not edit by hand. +// The councillor sitting for each Toronto ward, as of ${effective[effective.length - 1]}. +// Source: Ward and Elected Councillor, City of Toronto Open Data. +// https://open.toronto.ca/dataset/wards-and-elected-councillors/ +// Regenerate with: node scripts/gen-ward-profiles.mjs +// +// Refresh this after any by-election, and again once the 2026 results are in. +// Nothing here says whether a councillor is seeking re-election — that is read +// off the live candidate roster; see wardProfiles.ts. + +/** The sitting councillor for each ward, keyed by the zero-padded ward token + * ("01".."25"). */ +export const WARD_COUNCILLORS: Record = { +${councillors + .map((c) => ` "${c.ward}": ${JSON.stringify(c.councillor)},`) + .join("\n")} +}; +`; + +writeFileSync(COUNCILLORS_OUT, councillorFile); +console.log(`${COUNCILLORS_OUT}: ${councillors.length} councillors`); From ddced429f0296b660394dc6c6f391e734b451c39 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 9 Sep 2026 12:01:58 -0400 Subject: [PATCH 2/3] Say what each Toronto ward actually is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ward page opened with a name, a map and a list of candidates, which told a reader who was running and nothing at all about the place they would represent. Every ward now gets a short brief and eight statistics. The two halves are kept apart deliberately, the same way wardGeo.ts and candidates.ts already are: the figures are generated and must not be edited by hand, the briefs are prose and cannot be generated. wardProfiles.ts holds the briefs and combines the two. Every figure is shown against the citywide one, because a ward statistic read alone means very little — 45% of households renting is unremarkable until you know the city sits at 48%, and Ward 10's 88% of homes in towers is only legible beside a city average of 47%. The briefs stay on geography and the census, and leave the campaign alone. Anything that turns over mid-campaign would go stale, cannot be verified twenty-five times over, and on a page built to compare candidates risks reading as favouring one of them. Neighbourhood names are the City's own, assigned by which ward contains each neighbourhood's centroid. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/toronto/vote/2026/wardProfiles.ts | 141 +++ src/app/toronto/vote/2026/wardStats.ts | 1169 +++++++++++++++++++++ src/components/elections/WardProfile.tsx | 131 +++ 3 files changed, 1441 insertions(+) create mode 100644 src/app/toronto/vote/2026/wardProfiles.ts create mode 100644 src/app/toronto/vote/2026/wardStats.ts create mode 100644 src/components/elections/WardProfile.tsx diff --git a/src/app/toronto/vote/2026/wardProfiles.ts b/src/app/toronto/vote/2026/wardProfiles.ts new file mode 100644 index 00000000..8aa473ae --- /dev/null +++ b/src/app/toronto/vote/2026/wardProfiles.ts @@ -0,0 +1,141 @@ +// ───────────────────────────────────────────────────────────────────────── +// Toronto 2026 election — ward briefs (edit this file by hand). +// +// A ward's profile has two halves. The statistics are generated from the +// City's own ward profiles into ./wardStats — never edit those by hand, run +// `node scripts/gen-ward-profiles.mjs` instead. The brief is the half a +// person writes: what the ward actually is, and what the numbers next to it +// mean. This file holds the briefs, and combines the two. +// +// WRITING A BRIEF +// Two to four sentences. Say where the ward is and which neighbourhoods it +// takes in, then what distinguishes it — ideally something a reader can see +// confirmed in the statistics beside it. Describe the place, not the race: +// these are read by voters weighing candidates, so they must not favour any +// of them. Anything that turns over during a campaign — who holds the seat, +// which applications are at committee — belongs on the candidates, not here. +// +// Neighbourhood names are the City's own (the 158-neighbourhood model), +// assigned to wards by which ward contains each neighbourhood's centroid. +// +// A ward with no brief simply shows its statistics, so it is safe to leave +// one blank rather than write filler. +// ───────────────────────────────────────────────────────────────────────── + +import { WARD_STATS, WARD_STATS_SOURCE } from "./wardStats"; +import { WARD_COUNCILLORS } from "./wardCouncillors"; +import { nameKey } from "@/lib/elections/election-data"; +import { candidateSlug } from "@/lib/elections/candidate-profile"; +import { getElection } from "@/lib/elections/registry"; +import type { + WardProfile, + WardStat, +} from "@/components/elections/WardProfile"; + +// The shapes live with the component that renders them, as WardGeo does with +// . Re-exported so ./wardStats and this file's callers have one +// import site for a ward's data. +export type { WardProfile, WardStat }; + +// ── Briefs ───────────────────────────────────────────────────────────────── + +/** Keyed by the zero-padded ward token ("01".."25"), as everything else in + * this directory is. */ +export const WARD_BRIEFS: Record = { + "01": "Toronto's northwest corner, running from the Humber River to the airport lands and taking in Rexdale, Mount Olive-Silverstone-Jamestown, Kingsview Village-The Westway and Thistletown. Much of its 48 square kilometres is the Clairville employment lands rather than housing, which is why one of the city's largest wards is also one of its least dense. Well over half its residents were born outside Canada, and its households are among the largest in Toronto \u2014 just over three people on average, against a citywide 2.4.", + + "02": "The postwar heart of Etobicoke — Islington, Princess-Rosethorn, Markland Wood, Eringate-Centennial-West Deane and Willowridge-Martingrove-Richview. Close to half its homes are single-detached houses and only a third of households rent, both far from the city average. It has Toronto's oldest median age alongside Scarborough-Agincourt, and one of the two lowest rates of low income in the city.", + + "03": "Toronto's southwest lakeshore, from The Kingsway and Stonegate-Queensway down through Mimico, New Toronto, Long Branch and Alderwood. It grew almost 10 per cent between the 2016 and 2021 Censuses — among the fastest in the city — on condominium towers at Humber Bay Shores and around Etobicoke City Centre, which now sit beside streets of prewar lakeside cottages and postwar bungalows.", + + "04": "The west end between the Humber and Dufferin, taking in Roncesvalles, South Parkdale, High Park-Swansea, High Park North and Runnymede-Bloor West Village. More than half of households rent, but unusually little of that housing is in towers: fewer than one home in five is single-detached and fewer than two in five are in a building of five storeys or more, so most of the ward lives in the walk-up apartments, duplexes and converted houses in between. Its population edged down between Censuses.", + + "05": "The old City of York and the valleys north of it — Weston, Mount Dennis, Rockcliffe-Smythe, Brookhaven-Amesbury and Keelesdale-Eglinton West. It has one of the lowest median household incomes of any Toronto ward and, with Scarborough Southwest, the lowest average rent. The Eglinton Crosstown's western end runs through it, and Mount Dennis sits at the line's terminus.", + + "06": "North York west of Bathurst — Downsview, Bathurst Manor, Clanton Park, Westminster-Branson and York University Heights. More than half its residents were born outside Canada. The former Downsview Airport lands, one of the largest redevelopment sites anywhere in the city, fall inside this ward.", + + "07": "The northwest tower neighbourhoods, from Humber Summit and Humbermede down through Glenfield-Jane Heights, Oakdale-Beverley Heights and Black Creek. Only about one home in eight is single-detached and two in five are in buildings of five storeys or more, a housing mix that looks nothing like the suburbs around it. It has the lowest share of residents holding a bachelor's degree or higher in Toronto and one of the highest rates of low income.", + + "08": "A north-south strip of midtown and North York taking in Lawrence Park, Bedford Park-Nortown, Forest Hill North, Englemount-Lawrence, Briar Hill-Belgravia and Yorkdale-Glen Park. Its median household income is among the city's highest, but that single figure spans a wide range — some of Toronto's most expensive streets and some of its apartment neighbourhoods sit within a few blocks of each other here. The Eglinton Crosstown crosses its southern end.", + + "09": "A dense, mostly low-rise wedge of the west end: Little Portugal, Dufferin Grove, Dovercourt Village, Corso Italia-Davenport, Caledonia-Fairbank and Junction-Wallace Emerson. It is the closest thing Toronto has to a missing-middle ward — under a fifth of homes are in buildings of five storeys or more, and under a sixth are single-detached, with the rest in semis, rows, duplexes and small apartment buildings. Its population fell between the 2016 and 2021 Censuses, among the steeper declines in the city.", + + "10": "The downtown waterfront and the ward built on former rail and industrial land: Liberty Village, CityPlace, Fort York, the East Bayfront, St. Lawrence, the Toronto Islands, West Queen West and Trinity-Bellwoods. It grew 18 per cent between Censuses, the fastest in Toronto, and is now the most vertical ward in the city — 88 per cent of its homes are in buildings of five storeys or more and fewer than one in a hundred is single-detached. It also has the youngest median age, the highest share of degree holders and the highest average rent.", + + "11": "Downtown's western and northern core, from Kensington-Chinatown and Palmerston-Little Italy up through the Annex and the University of Toronto's campus to Rosedale-Moore Park. It holds two very different housing markets at once: student and rental housing around the university, and some of the oldest and most expensive houses in Toronto a short walk north. Nearly half of its renter households spend 30 per cent or more of their income on housing.", + + "12": "Midtown along the Yonge and Eglinton corridors, taking in Yonge-Eglinton, Davisville, North Toronto, Yonge-St. Clair, Casa Loma, Forest Hill South, Wychwood, Humewood-Cedarvale and Oakwood Village. It grew almost 9 per cent between Censuses on tower development around the Yonge-Eglinton and St. Clair subway stations, and more than 60 per cent of its households now rent — the second-highest share in the city.", + + "13": "The smallest ward in Toronto by land area and by far the densest, at roughly 20,000 residents per square kilometre. It covers Regent Park, Moss Park, St. James Town, Cabbagetown, Church-Wellesley and Downtown Yonge East. Seven in ten households rent and 85 per cent of homes are in buildings of five storeys or more. It has the lowest median household income and the highest rate of low income of any ward, and it grew more than 17 per cent between Censuses.", + + "14": "The east end from the Don Valley to Coxwell, taking in Riverdale, the Danforth, Greenwood-Coxwell, Playter Estates, Blake-Jones, Broadview North and Old East York. Housing here is mostly the semi-detached and row housing of the streetcar city — barely a fifth of homes are in buildings of five storeys or more. Its population has been essentially flat across recent Censuses.", + + "15": "The ward with the sharpest internal contrast in Toronto. It runs from the Bridle Path and St. Andrew-Windfields, among the wealthiest addresses in Canada, through Leaside and Mount Pleasant East, down to the apartment towers of Thorncliffe Park. Its median household income is among the highest in the city while its rate of low income sits close to the city average, because the ward's two halves are averaged into one number.", + + "16": "The Don Mills area and the neighbourhoods around it — Banbury-Don Mills, Flemingdon Park, Victoria Village, Parkwoods-O'Connor Hills and Fenside-Parkwoods. Don Mills was among Canada's first planned postwar communities, and the towers-in-parkland form it pioneered still shapes the ward: nearly 60 per cent of homes are in buildings of five storeys or more and more than half of households rent.", + + "17": "North York east of Bayview — Bayview Village, Don Valley Village, Hillcrest Village, Henry Farm, Pleasant View and Bayview Woods-Steeles. Three in five residents were born outside Canada, among the highest shares in Toronto, and a similar share of homes are in buildings of five storeys or more, concentrated around Sheppard and the Don Valley.", + + "18": "The Yonge Street corridor through North York, from Lansing-Westgate up to Newtonbrook at Steeles, including Yonge-Doris, Willowdale West and Avondale. Sixty per cent of its residents were born outside Canada and 60 per cent of its homes are in towers, most of them along Yonge. It has the highest share of rent-burdened tenant households in Toronto — nearly half spend 30 per cent or more of their income on housing — and one of the highest rates of low income.", + + "19": "The lakeshore east of Coxwell and the neighbourhoods above it: The Beaches, East End-Danforth, Woodbine Corridor, Woodbine-Lumsden, O'Connor-Parkview and Taylor-Massey. Its housing is almost entirely low-rise: a quarter of its homes are in buildings of five storeys or more and a little over a quarter are single-detached, with the rest in the semis, rows and walk-ups in between. Incomes vary widely across it, from the streets near the boardwalk to the apartment blocks along Victoria Park.", + + "20": "Scarborough's southwest corner along the Bluffs — Birchcliffe-Cliffside, Cliffcrest, Clairlea-Birchmount, Kennedy Park and Oakridge. It has the lowest average rent in Toronto alongside York South-Weston, and one of the lowest shares of rent-burdened tenant households of any ward. Roughly a third of its homes are single-detached houses.", + + "21": "The centre of Scarborough, taking in Wexford/Maryvale, Dorset Park, Bendale, Eglinton East and Ionview, and wrapping around the Scarborough Centre transit and civic hub. More than half its residents were born outside Canada. Its housing splits almost evenly between single-detached houses and apartment buildings, with little in between.", + + "22": "Northern Scarborough west of McCowan — Tam O'Shanter-Sullivan, L'Amoreaux and Steeles. It has Toronto's oldest median age alongside Etobicoke Centre, and nearly two-thirds of its residents were born outside Canada. Only a third of its households rent, though nearly half its homes are in apartment buildings, so much of the ward is owner-occupied condominium.", + + "23": "Agincourt, Milliken and Malvern West, at Toronto's northeast edge. It has the highest share of residents born outside Canada of any ward — close to two in three — and, with Scarborough-Rouge Park, the lowest share of renters, at about one household in five. Its population fell almost 4 per cent between the 2016 and 2021 Censuses, the steepest decline in the city.", + + "24": "Scarborough between Highland Creek and Bellamy: Woburn, Golfdale-Cedarbrae-Woburn, Morningside, Guildwood and Scarborough Village. About a third of its homes are single-detached and nearly half are in buildings of five storeys or more \u2014 the towers along Kingston Road, Eglinton and Markham Road, with subdivisions on the streets behind them. It has one of the lowest shares of rent-burdened tenant households in the city.", + + "25": "Toronto's eastern edge and its largest ward by land area, taking in West Hill, West Rouge, Highland Creek, Centennial Scarborough, Morningside Heights and Malvern East, along with much of Rouge National Urban Park. It is the least dense ward in the city, at under 1,900 residents per square kilometre. It also has the highest median household income, the largest share of single-detached houses, the smallest share of tower housing and the lowest rate of low income — and its population did not grow at all between the 2016 and 2021 Censuses.", +}; + +// ── Accessor ─────────────────────────────────────────────────────────────── + +/** One candidate on this ward's ballot, as much of them as the incumbent + * check needs. */ +type Standing = { name: string; withdrawn?: boolean }; + +/** + * The profile for one ward, or null for a ward with neither brief nor + * statistics — so a caller can render the section only when there is one. + * + * `standing` is the ward's council candidates as the roster has them right + * now. The sitting councillor is named only if they appear among the ones who + * have not withdrawn, which is the only honest way to say "running again": + * incumbency is a fact about the seat, candidacy is a fact about the roster, + * and the second changes daily. Matching is by `nameKey`, so the Clerk's + * spelling and the City's need only agree up to case, accents and + * punctuation. Pass nothing and no incumbent is named. + */ +export function wardProfile( + wardToken: string, + standing: Standing[] = [], +): WardProfile | null { + const stats = WARD_STATS[wardToken]; + const brief = WARD_BRIEFS[wardToken] ?? ""; + if (!stats?.length && !brief) return null; + + const councillor = WARD_COUNCILLORS[wardToken]; + const running = + councillor !== undefined && + standing.some( + (candidate) => + !candidate.withdrawn && nameKey(candidate.name) === nameKey(councillor), + ); + + return { + brief, + stats: stats ?? [], + source: stats?.length ? WARD_STATS_SOURCE : undefined, + incumbent: running + ? { + name: councillor, + href: `${getElection("toronto-2026").basePath}/candidates/${candidateSlug(councillor)}`, + } + : undefined, + }; +} diff --git a/src/app/toronto/vote/2026/wardStats.ts b/src/app/toronto/vote/2026/wardStats.ts new file mode 100644 index 00000000..30e32473 --- /dev/null +++ b/src/app/toronto/vote/2026/wardStats.ts @@ -0,0 +1,1169 @@ +// AUTO-GENERATED — do not edit by hand. +// Toronto's per-ward Census statistics, formatted for display. +// Source: Ward Profiles (25-Ward Model), City of Toronto Open Data, from +// Statistics Canada's 2021 and 2016 Census of Population. +// https://open.toronto.ca/dataset/ward-profiles-25-ward-model/ +// Regenerate with: node scripts/gen-ward-profiles.mjs +// The hand-written briefs that accompany these live in ./wardProfiles.ts. + +import type { WardStat } from "@/components/elections/WardProfile"; + +/** Statistics for each ward, keyed by the zero-padded ward token ("01".."25"), + * in the order they should be shown. */ +export const WARD_STATS: Record = { + "01": [ + { + "label": "Population", + "value": "115,120", + "cityValue": "2,761,285", + "note": "−1.6% since 2016" + }, + { + "label": "Residents per km²", + "value": "2,380", + "note": "48.4 km² of land" + }, + { + "label": "Median household income", + "value": "$81,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "45.5%", + "cityValue": "48.1%", + "note": "$1,328 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "34.2%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "41.9%", + "cityValue": "46.7%", + "note": "29.6% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "56.2%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "12.9%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "02": [ + { + "label": "Population", + "value": "117,200", + "cityValue": "2,761,285", + "note": "+1.0% since 2016" + }, + { + "label": "Residents per km²", + "value": "3,138", + "note": "37.4 km² of land" + }, + { + "label": "Median household income", + "value": "$100,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "32.6%", + "cityValue": "48.1%", + "note": "$1,574 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "39.2%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "39.4%", + "cityValue": "46.7%", + "note": "48.2% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "41.4%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "7.9%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "03": [ + { + "label": "Population", + "value": "139,920", + "cityValue": "2,761,285", + "note": "+9.7% since 2016" + }, + { + "label": "Residents per km²", + "value": "3,499", + "note": "40.0 km² of land" + }, + { + "label": "Median household income", + "value": "$90,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "43.1%", + "cityValue": "48.1%", + "note": "$1,592 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "40.9%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "46.7%", + "cityValue": "46.7%", + "note": "28.3% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "39.1%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "11.0%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "04": [ + { + "label": "Population", + "value": "104,715", + "cityValue": "2,761,285", + "note": "−1.6% since 2016" + }, + { + "label": "Residents per km²", + "value": "6,831", + "note": "15.3 km² of land" + }, + { + "label": "Median household income", + "value": "$85,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "56.3%", + "cityValue": "48.1%", + "note": "$1,492 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "39.7%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "38.3%", + "cityValue": "46.7%", + "note": "17.9% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "30.9%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "12.0%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "05": [ + { + "label": "Population", + "value": "115,675", + "cityValue": "2,761,285", + "note": "+0.5% since 2016" + }, + { + "label": "Residents per km²", + "value": "4,631", + "note": "25.0 km² of land" + }, + { + "label": "Median household income", + "value": "$72,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "51.9%", + "cityValue": "48.1%", + "note": "$1,196 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "34.8%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "40.6%", + "cityValue": "46.7%", + "note": "29.3% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "50.3%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "14.7%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "06": [ + { + "label": "Population", + "value": "107,355", + "cityValue": "2,761,285", + "note": "+3.5% since 2016" + }, + { + "label": "Residents per km²", + "value": "3,036", + "note": "35.4 km² of land" + }, + { + "label": "Median household income", + "value": "$82,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "50.2%", + "cityValue": "48.1%", + "note": "$1,448 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "36.1%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "43.3%", + "cityValue": "46.7%", + "note": "25.1% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "55.4%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "11.5%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "07": [ + { + "label": "Population", + "value": "111,200", + "cityValue": "2,761,285", + "note": "+3.2% since 2016" + }, + { + "label": "Residents per km²", + "value": "3,622", + "note": "30.7 km² of land" + }, + { + "label": "Median household income", + "value": "$73,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "52.0%", + "cityValue": "48.1%", + "note": "$1,261 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "33.2%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "41.1%", + "cityValue": "46.7%", + "note": "12.4% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "56.4%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "15.1%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "08": [ + { + "label": "Population", + "value": "114,820", + "cityValue": "2,761,285", + "note": "+1.7% since 2016" + }, + { + "label": "Residents per km²", + "value": "5,067", + "note": "22.7 km² of land" + }, + { + "label": "Median household income", + "value": "$97,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "46.6%", + "cityValue": "48.1%", + "note": "$1,588 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "38.0%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "33.4%", + "cityValue": "46.7%", + "note": "37.4% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "39.0%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "10.1%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "09": [ + { + "label": "Population", + "value": "104,730", + "cityValue": "2,761,285", + "note": "−2.5% since 2016" + }, + { + "label": "Residents per km²", + "value": "8,655", + "note": "12.1 km² of land" + }, + { + "label": "Median household income", + "value": "$85,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "48.2%", + "cityValue": "48.1%", + "note": "$1,552 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "41.3%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "18.7%", + "cityValue": "46.7%", + "note": "14.2% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "37.9%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "10.8%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "10": [ + { + "label": "Population", + "value": "135,400", + "cityValue": "2,761,285", + "note": "+18.4% since 2016" + }, + { + "label": "Residents per km²", + "value": "10,570", + "note": "12.8 km² of land" + }, + { + "label": "Median household income", + "value": "$89,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "58.1%", + "cityValue": "48.1%", + "note": "$1,988 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "43.7%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "88.0%", + "cityValue": "46.7%", + "note": "0.6% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "39.8%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "14.1%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "11": [ + { + "label": "Population", + "value": "102,385", + "cityValue": "2,761,285", + "note": "+1.9% since 2016" + }, + { + "label": "Residents per km²", + "value": "7,528", + "note": "13.6 km² of land" + }, + { + "label": "Median household income", + "value": "$84,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "56.9%", + "cityValue": "48.1%", + "note": "$1,976 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "48.8%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "51.1%", + "cityValue": "46.7%", + "note": "7.8% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "33.9%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "15.3%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "12": [ + { + "label": "Population", + "value": "114,095", + "cityValue": "2,761,285", + "note": "+8.7% since 2016" + }, + { + "label": "Residents per km²", + "value": "8,696", + "note": "13.1 km² of land" + }, + { + "label": "Median household income", + "value": "$86,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "61.4%", + "cityValue": "48.1%", + "note": "$1,768 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "44.6%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "56.6%", + "cityValue": "46.7%", + "note": "15.2% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "35.4%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "12.5%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "13": [ + { + "label": "Population", + "value": "116,930", + "cityValue": "2,761,285", + "note": "+17.4% since 2016" + }, + { + "label": "Residents per km²", + "value": "19,954", + "note": "5.9 km² of land" + }, + { + "label": "Median household income", + "value": "$65,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "70.0%", + "cityValue": "48.1%", + "note": "$1,520 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "43.1%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "85.2%", + "cityValue": "46.7%", + "note": "0.4% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "41.7%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "22.2%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "14": [ + { + "label": "Population", + "value": "104,555", + "cityValue": "2,761,285", + "note": "−0.8% since 2016" + }, + { + "label": "Residents per km²", + "value": "5,027", + "note": "20.8 km² of land" + }, + { + "label": "Median household income", + "value": "$93,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "45.1%", + "cityValue": "48.1%", + "note": "$1,424 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "37.8%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "22.0%", + "cityValue": "46.7%", + "note": "18.2% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "30.5%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "11.5%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "15": [ + { + "label": "Population", + "value": "101,025", + "cityValue": "2,761,285", + "note": "−0.8% since 2016" + }, + { + "label": "Residents per km²", + "value": "3,334", + "note": "30.3 km² of land" + }, + { + "label": "Median household income", + "value": "$102,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "48.0%", + "cityValue": "48.1%", + "note": "$1,716 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "40.9%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "40.1%", + "cityValue": "46.7%", + "note": "36.8% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "41.0%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "12.6%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "16": [ + { + "label": "Population", + "value": "94,335", + "cityValue": "2,761,285", + "note": "+1.3% since 2016" + }, + { + "label": "Residents per km²", + "value": "4,109", + "note": "23.0 km² of land" + }, + { + "label": "Median household income", + "value": "$78,500", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "55.7%", + "cityValue": "48.1%", + "note": "$1,492 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "39.1%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "58.9%", + "cityValue": "46.7%", + "note": "14.2% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "53.4%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "14.4%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "17": [ + { + "label": "Population", + "value": "112,590", + "cityValue": "2,761,285", + "note": "+3.2% since 2016" + }, + { + "label": "Residents per km²", + "value": "4,611", + "note": "24.4 km² of land" + }, + { + "label": "Median household income", + "value": "$84,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "43.2%", + "cityValue": "48.1%", + "note": "$1,754 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "43.6%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "56.1%", + "cityValue": "46.7%", + "note": "19.3% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "60.6%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "14.4%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "18": [ + { + "label": "Population", + "value": "117,130", + "cityValue": "2,761,285", + "note": "−0.2% since 2016" + }, + { + "label": "Residents per km²", + "value": "5,928", + "note": "19.8 km² of land" + }, + { + "label": "Median household income", + "value": "$81,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "42.0%", + "cityValue": "48.1%", + "note": "$1,880 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "49.3%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "60.7%", + "cityValue": "46.7%", + "note": "24.6% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "60.2%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "17.8%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "19": [ + { + "label": "Population", + "value": "108,500", + "cityValue": "2,761,285", + "note": "+0.1% since 2016" + }, + { + "label": "Residents per km²", + "value": "6,458", + "note": "16.8 km² of land" + }, + { + "label": "Median household income", + "value": "$89,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "44.2%", + "cityValue": "48.1%", + "note": "$1,370 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "38.6%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "25.2%", + "cityValue": "46.7%", + "note": "28.0% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "31.7%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "11.9%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "20": [ + { + "label": "Population", + "value": "110,095", + "cityValue": "2,761,285", + "note": "+1.7% since 2016" + }, + { + "label": "Residents per km²", + "value": "3,912", + "note": "28.1 km² of land" + }, + { + "label": "Median household income", + "value": "$79,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "45.7%", + "cityValue": "48.1%", + "note": "$1,196 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "32.3%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "34.0%", + "cityValue": "46.7%", + "note": "35.4% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "45.8%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "13.9%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "21": [ + { + "label": "Population", + "value": "111,560", + "cityValue": "2,761,285", + "note": "+1.0% since 2016" + }, + { + "label": "Residents per km²", + "value": "3,955", + "note": "28.2 km² of land" + }, + { + "label": "Median household income", + "value": "$78,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "43.2%", + "cityValue": "48.1%", + "note": "$1,338 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "35.8%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "41.5%", + "cityValue": "46.7%", + "note": "34.2% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "55.3%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "13.3%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "22": [ + { + "label": "Population", + "value": "103,690", + "cityValue": "2,761,285", + "note": "−0.5% since 2016" + }, + { + "label": "Residents per km²", + "value": "4,845", + "note": "21.4 km² of land" + }, + { + "label": "Median household income", + "value": "$77,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "33.8%", + "cityValue": "48.1%", + "note": "$1,358 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "36.0%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "47.2%", + "cityValue": "46.7%", + "note": "28.9% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "63.7%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "14.7%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "23": [ + { + "label": "Population", + "value": "94,025", + "cityValue": "2,761,285", + "note": "−3.7% since 2016" + }, + { + "label": "Residents per km²", + "value": "3,089", + "note": "30.4 km² of land" + }, + { + "label": "Median household income", + "value": "$87,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "20.6%", + "cityValue": "48.1%", + "note": "$1,408 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "33.3%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "22.4%", + "cityValue": "46.7%", + "note": "39.9% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "64.6%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "11.8%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "24": [ + { + "label": "Population", + "value": "102,755", + "cityValue": "2,761,285", + "note": "+1.6% since 2016" + }, + { + "label": "Residents per km²", + "value": "3,935", + "note": "26.1 km² of land" + }, + { + "label": "Median household income", + "value": "$78,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "45.0%", + "cityValue": "48.1%", + "note": "$1,197 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "31.6%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "47.3%", + "cityValue": "46.7%", + "note": "33.7% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "53.4%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "14.7%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], + "25": [ + { + "label": "Population", + "value": "101,485", + "cityValue": "2,761,285", + "note": "No change since 2016" + }, + { + "label": "Residents per km²", + "value": "1,877", + "note": "54.1 km² of land" + }, + { + "label": "Median household income", + "value": "$105,000", + "cityValue": "$84,000" + }, + { + "label": "Renter households", + "value": "20.6%", + "cityValue": "48.1%", + "note": "$1,362 average monthly rent" + }, + { + "label": "Renters spending 30%+ of income on housing", + "value": "32.7%", + "cityValue": "40.0%" + }, + { + "label": "Homes in buildings of five storeys or more", + "value": "12.5%", + "cityValue": "46.7%", + "note": "54.7% single-detached" + }, + { + "label": "Residents born outside Canada", + "value": "51.9%", + "cityValue": "46.6%" + }, + { + "label": "Residents in low income", + "value": "7.8%", + "cityValue": "13.2%", + "note": "Low-income measure, after tax" + } + ], +}; + +/** Where these figures come from, worded for the source line shown under a + * ward's statistics. */ +export const WARD_STATS_SOURCE = + "the 2021 Census of Population, via the City of Toronto’s ward profiles"; diff --git a/src/components/elections/WardProfile.tsx b/src/components/elections/WardProfile.tsx new file mode 100644 index 00000000..d72cb1f7 --- /dev/null +++ b/src/components/elections/WardProfile.tsx @@ -0,0 +1,131 @@ +import Link from "next/link"; +import { IncumbentBadge } from "./ElectionLanding"; + +/* What a ward is, above the race to represent it. + + A ward page opens with a name, a map and a candidate list, which tells a + reader who is running but nothing about the place they would represent. + This section is the missing half: a few sentences on the ward, and the + statistics that back them up. + + Every figure is shown against the citywide figure, because a ward statistic + read alone means very little — 45% of households renting is unremarkable + until you know the city sits at 48%, and 88% of homes in towers is only + legible next to a city average of 47%. Regions supply their own profiles + (see scripts/gen-ward-profiles.mjs for how Toronto's statistics are + generated); a region with none simply passes no profile and the section + does not render. */ + +export type WardStat = { + /** e.g. "Renter households" */ + label: string; + /** the ward's figure, formatted — e.g. "45.5%", "$81,000", "115,120" */ + value: string; + /** the same figure for the whole city; omit where a comparison would be + * meaningless, as with population density */ + cityValue?: string; + /** a secondary figure or qualifier shown under the value */ + note?: string; +}; + +export type WardProfile = { + /** two to four sentences on the ward; may be empty */ + brief: string; + stats: WardStat[]; + /** + * The sitting councillor, named only when they are registered to run in + * this ward again. Absent both when the seat's holder is not seeking + * re-election and when the region does not track who holds it, so the line + * appears only where it is known to be true. + */ + incumbent?: { name: string; href?: string }; + /** where the statistics come from, as it should read under them — e.g. + * "the 2021 Census of Population, via the City of Toronto's ward + * profiles". Each region words its own; omit to show no source line. */ + source?: string; +}; + +export function WardProfileSection({ profile }: { profile: WardProfile }) { + const { brief, stats, source, incumbent } = profile; + + return ( +
+
+

+ About this ward +

+ {incumbent && ( +

+ + {incumbent.href ? ( + + {incumbent.name} + + ) : ( + + {incumbent.name} + + )} + + Seeking re-election + +

+ )} + {brief && ( +

+ {brief} +

+ )} +
+ + {stats.length > 0 && ( + <> + {/* Borders are drawn on the leading edge of each cell rather than the + trailing one, so the grid never paints a rule down its outer + right edge on a row that happens to be full. */} +
+ {stats.map((stat) => ( +
+
+ {stat.value} +
+
+ {stat.label} +
+ {(stat.note || stat.cityValue) && ( +

+ {stat.note} + {stat.note && stat.cityValue && " · "} + {stat.cityValue && ( + + {stat.cityValue} citywide + + )} +

+ )} +
+ ))} +
+ {source && ( +

+ Figures from {source}. +

+ )} + + )} +
+ ); +} From 1640c90977419fc4535b4e0a861ae7d824fc544e Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 9 Sep 2026 12:01:58 -0400 Subject: [PATCH 3/3] Name a ward's incumbent when they are running again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incumbency is a fact about the seat; candidacy is a fact about the roster, and the roster changes daily. So the two are read from different places and joined at render time: who sits comes from the City's elected-councillor data, and whether they are standing again is answered by looking for them among the ward's registered candidates who have not withdrawn. That is why this does not use the hand-kept `tag: "Incumbent"` in candidates.ts. Only 13 of 25 wards carry one, so twelve wards would have shown nothing whether or not their councillor was running — silence that reads as an answer. Deriving it covers all 25 and cannot fall behind the roster. Twenty-one wards name someone today. The four that do not are all correct: Ward 19's councillor is registered for mayor, Ward 11's is registered in Ward 14, and Wards 4 and 14 have no sitting councillor on their own ballot. Names are matched with nameKey, so the Clerk's spelling and the City's need only agree up to case, accents and punctuation. The section sits at the bottom of the page, after the ballot a reader came for. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/toronto/vote/2026/wardCouncillors.ts | 39 +++++++++++++++++++ .../toronto/vote/2026/wards/[ward]/page.tsx | 7 ++++ src/components/elections/WardDetail.tsx | 11 ++++++ 3 files changed, 57 insertions(+) create mode 100644 src/app/toronto/vote/2026/wardCouncillors.ts diff --git a/src/app/toronto/vote/2026/wardCouncillors.ts b/src/app/toronto/vote/2026/wardCouncillors.ts new file mode 100644 index 00000000..3bd8772e --- /dev/null +++ b/src/app/toronto/vote/2026/wardCouncillors.ts @@ -0,0 +1,39 @@ +// AUTO-GENERATED — do not edit by hand. +// The councillor sitting for each Toronto ward, as of 2025-04-16. +// Source: Ward and Elected Councillor, City of Toronto Open Data. +// https://open.toronto.ca/dataset/wards-and-elected-councillors/ +// Regenerate with: node scripts/gen-ward-profiles.mjs +// +// Refresh this after any by-election, and again once the 2026 results are in. +// Nothing here says whether a councillor is seeking re-election — that is read +// off the live candidate roster; see wardProfiles.ts. + +/** The sitting councillor for each ward, keyed by the zero-padded ward token + * ("01".."25"). */ +export const WARD_COUNCILLORS: Record = { + "01": "Vincent Crisanti", + "02": "Stephen Holyday", + "03": "Amber Morley", + "04": "Gord Perks", + "05": "Frances Nunziata", + "06": "James Pasternak", + "07": "Anthony Perruzza", + "08": "Mike Colle", + "09": "Alejandra Bravo", + "10": "Ausma Malik", + "11": "Dianne Saxe", + "12": "Josh Matlow", + "13": "Chris Moise", + "14": "Paula Fletcher", + "15": "Rachel Chernos Lin", + "16": "Jon Burnside", + "17": "Shelley Carroll", + "18": "Lily Cheng", + "19": "Brad Bradford", + "20": "Parthi Kandavel", + "21": "Michael Thompson", + "22": "Nick Mantas", + "23": "Jamaal Myers", + "24": "Paul Ainslie", + "25": "Neethan Shan", +}; diff --git a/src/app/toronto/vote/2026/wards/[ward]/page.tsx b/src/app/toronto/vote/2026/wards/[ward]/page.tsx index 85b0fe52..9eea2486 100644 --- a/src/app/toronto/vote/2026/wards/[ward]/page.tsx +++ b/src/app/toronto/vote/2026/wards/[ward]/page.tsx @@ -5,6 +5,7 @@ import { WardMap, WardMapDefs } from "@/components/elections/WardMap"; import { rosterSurvey } from "@/lib/elections/survey-answers"; import { ELECTION, WARD_NUMBERS, getToronto2026, getToronto2026Ward } from "../../data"; import { WARD_GEO, WARD_SHAPES } from "../../wardGeo"; +import { wardProfile } from "../../wardProfiles"; export function generateStaticParams() { return WARD_NUMBERS.map((n) => ({ ward: n })); @@ -57,6 +58,12 @@ export default async function WardDetailPage({ nominationCloseLabel={view.nominationCloseLabel} surveyAnswers={surveyAnswers} surveyShape={surveyShape} + profile={ + wardProfile( + data.ward.n, + data.councilRaces.flatMap((race) => race.candidates), + ) ?? undefined + } wardMapDefs={} wardMap={ w.number === ward.number); @@ -258,6 +266,9 @@ export function WardDetail({ )} + {/* ── About this ward ────────────────────────────────── */} + {profile && } + {/* ── Prev / next ward ───────────────────────────────── */}