Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions scripts/data/README.md
Original file line number Diff line number Diff line change
@@ -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`.
27 changes: 27 additions & 0 deletions scripts/data/toronto-ward-census-2021.csv
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions scripts/data/toronto-ward-councillors.csv
Original file line number Diff line number Diff line change
@@ -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
216 changes: 216 additions & 0 deletions scripts/gen-ward-profiles.mjs
Original file line number Diff line number Diff line change
@@ -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<string, WardStat[]> = ${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<string, string> = {
${councillors
.map((c) => ` "${c.ward}": ${JSON.stringify(c.councillor)},`)
.join("\n")}
};
`;

writeFileSync(COUNCILLORS_OUT, councillorFile);
console.log(`${COUNCILLORS_OUT}: ${councillors.length} councillors`);
39 changes: 39 additions & 0 deletions src/app/toronto/vote/2026/wardCouncillors.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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",
};
Loading
Loading