- Across every population group examined, travel time to the nearest library is close to the citywide median. Immigrants, low-income households, children, seniors, and visible minority residents all face slightly longer trips on average, but the gaps are small, 71 seconds at most. By transit, every group's median is within 45 seconds of the citywide median.
+ Across every population group examined, travel time to the nearest library is close to the City of Toronto median. Immigrants, low-income households, children, seniors, and visible minority residents all face slightly longer trips on average, but the gaps are small, 71 seconds at most. By transit, every group's median is within 45 seconds of the City of Toronto median.
diff --git a/posts/spatial-access-to-public-libraries-toronto/charts.js b/posts/spatial-access-to-public-libraries-toronto/charts.js
index 7612755..9427cd0 100644
--- a/posts/spatial-access-to-public-libraries-toronto/charts.js
+++ b/posts/spatial-access-to-public-libraries-toronto/charts.js
@@ -24,7 +24,7 @@ const charts = {
graphicVerb: 'created'
},
'demographics-grid': {
- title: 'Where six population groups live in relation to public libraries',
+ title: 'Where six population groups live in relation to public libraries in Toronto',
subtitle:
'Visible minority, low income, recent immigrants, first-generation immigrants, seniors, and children',
source: 'Census of Population, Statistics Canada (2021); Toronto Public Library',
@@ -35,7 +35,7 @@ const charts = {
},
'travel-time-boxplot': {
title: 'Travel time to the nearest library by demographic group',
- subtitle: "Every group's median is at or just slightly above the citywide median",
+ subtitle: "Every group's median is at or just slightly above the City of Toronto median",
source: 'Census of Population, Statistics Canada (2021); Toronto Public Library, City of Toronto, Toronto Transit Commission, OpenStreetMap (2026)',
alt:
"Box plots of travel time to the nearest library for the total population and six demographic groups, in one panel for walking and one for public transit. Within each panel the rows are nearly identical, with every group's median within about a minute of the total population's.",
diff --git a/posts/spatial-access-to-public-libraries-toronto/charts/travel-time-boxplot.svelte b/posts/spatial-access-to-public-libraries-toronto/charts/travel-time-boxplot.svelte
index 2bcc31a..6397531 100644
--- a/posts/spatial-access-to-public-libraries-toronto/charts/travel-time-boxplot.svelte
+++ b/posts/spatial-access-to-public-libraries-toronto/charts/travel-time-boxplot.svelte
@@ -2,8 +2,8 @@
import { scaleLinear } from 'd3';
import percentiles from '../data/travel_time_percentiles.csv';
- // Reuses each group's own darkest choropleth-ramp color (demographics-grid.svelte)
- // so a group reads as "the same thing" across every chart on this page.
+ // Each group's darkest choropleth-ramp color from demographics-grid.svelte, so a
+ // group is the same color in every chart on this page.
const GROUP_COLORS = {
'Visible minority': '#6D247A',
'Low income': '#007FA3',
@@ -23,84 +23,18 @@
'Children (0-14)'
];
- // Each mode gets its own gridline range — walking takes much longer than
- // transit, so a shared axis either crowds transit or wastes space on it.
- // The two panels still share one minutes-per-pixel scale (see panelW below),
- // so a 5-minute gap looks like the same width in both — only the axis's
- // total span differs, not what a minute "is worth" on screen.
- // `gridlines` draws the vertical lines; `ticks` (a subset) get a number
- // under them — transit's 30 is a gridline only, no label. rightPad is extra
- // minutes of blank space after the last gridline (walking needs room for its
- // "40 min" text to not clip at the edge; transit's last gridline is
- // unlabeled and is meant to sit exactly at the panel's own right edge, to
- // line up with the Share button below). ticksNarrow is a sparser version of
- // ticks used only at the 360px width — at that width's font size, labeling
- // every 5-minute gridline packs adjacent numbers ("35"/"40 min") into each
- // other; every 10 minutes leaves room. Transit's last tick sits exactly at
- // the panel's right edge (rightPad/marginRight are both 0, unlike walking) —
- // panelBlock right-anchors whichever label lands there instead of centering
- // it, so the text draws leftward into the panel rather than clipping past it.
+ // Walking takes much longer than transit, so each mode gets its own axis range,
+ // but both share one minutes-per-pixel scale so their gridlines line up when
+ // stacked. Walking's rightPad leaves room for its "min" label; transit ends
+ // flush with its last gridline.
const MODES = [
- {
- mode: 'Walk',
- modeText: 'walking',
- gridlines: [0, 5, 10, 15, 20, 25, 30, 35, 40],
- ticks: [0, 5, 10, 15, 20, 25, 30, 35, 40],
- ticksNarrow: [0, 10, 20, 30, 40],
- rightPad: 3,
- marginRight: 20
- },
- {
- mode: 'Transit',
- modeText: 'public transit',
- gridlines: [0, 5, 10, 15, 20, 25, 30],
- ticks: [0, 5, 10, 15, 20, 25, 30],
- ticksNarrow: [0, 10, 20, 30],
- rightPad: 0,
- marginRight: 0
- }
+ { mode: 'Walk', modeText: 'walking', max: 40, rightPad: 3 },
+ { mode: 'Transit', modeText: 'public transit', max: 30, rightPad: 0 }
];
- const marginLeft = 157;
- // The 360px layout gets its own, wider row-label gutter (see narrowPanels
- // below) — at that width's font size (11px true), "First-gen. immigrants"
- // measures ~111 true px, well past what 157 viewBox units scales down to
- // (~82px). Unlike font-size, a gutter can't be decoupled from scale with a
- // CSS override — it's SVG geometry, not a text style — so it's built as a
- // second, wider geometry instead. 280 solves marginLeft_true ⋍ 122px at
- // NARROW_WIDTH, leaving ~6px clearance past the longest label.
- const NARROW_MARGIN_LEFT = 280;
- const marginTop = 10;
- const marginBottom = 30;
- const rowHeight = 42;
- const boxHalfHeight = 8;
- const PX_PER_MIN = 10; // nominal viewBox units — only the ratio between panels matters
-
- const H = marginTop + ROW_ORDER.length * rowHeight + marginBottom;
-
- const rowY = (i) => marginTop + i * rowHeight + rowHeight / 2;
-
- const rowLayout = { rowHeight, H, rowY };
-
- const byMode = (mode) =>
- Object.fromEntries(percentiles.filter((d) => d.mode === mode).map((d) => [d.group, d]));
-
- function buildPanel(
- { mode, modeText, gridlines, ticks, ticksNarrow, rightPad, marginRight },
- gutter = marginLeft
- ) {
- const xMin = 0;
- const xMax = gridlines[gridlines.length - 1] + rightPad;
- const plotWidth = (xMax - xMin) * PX_PER_MIN;
- const W = gutter + plotWidth + marginRight;
- const plotLeft = gutter;
- const plotRight = W - marginRight;
- const x = scaleLinear().domain([xMin, xMax]).range([plotLeft, plotRight]);
- const clip = (v) => Math.max(xMin, Math.min(xMax, v));
-
- const byGroup = byMode(mode);
- const rows = ROW_ORDER.map((group) => {
- const d = byGroup[group];
+ const rowsFor = (mode) =>
+ ROW_ORDER.map((group) => {
+ const d = percentiles.find((r) => r.mode === mode && r.group === group);
return {
group,
color: GROUP_COLORS[group] ?? 'var(--brandGray55)',
@@ -108,358 +42,205 @@
p25: +d.p25,
p50: +d.p50,
p75: +d.p75,
- p90: +d.p90,
- p10Over: +d.p10 < xMin,
- p90Over: +d.p90 > xMax
+ p90: +d.p90
+ };
+ });
+
+ // One geometry per snapped width (see meta.widths in charts.js), drawn 1:1 so
+ // every size below is in true pixels. The walking panel fills the width; the
+ // transit panel is narrower at the same scale.
+ function buildLayout({ W, left, right, top, bottom, rowHeight, boxHalf, capHalf, labelGap, medianGap, tickGap, tickStep, fontSize }) {
+ const widest = MODES[0].max + MODES[0].rightPad;
+ const pxPerMin = (W - left - right) / widest;
+ const plotBottom = top + ROW_ORDER.length * rowHeight;
+
+ const panels = MODES.map(({ mode, modeText, max, rightPad }) => {
+ const plotRight = left + (max + rightPad) * pxPerMin;
+ return {
+ modeText,
+ W: plotRight + (rightPad ? right : 0),
+ x: scaleLinear().domain([0, max + rightPad]).range([left, plotRight]),
+ gridlines: Array.from({ length: max / 5 + 1 }, (_, i) => i * 5),
+ ticks: Array.from({ length: max / tickStep + 1 }, (_, i) => i * tickStep),
+ rows: rowsFor(mode)
};
});
return {
- modeText,
- gridlines,
- ticks,
- ticksNarrow,
- xMin,
- xMax,
- W,
- plotLeft,
- x,
- clip,
- rows
+ panels,
+ H: plotBottom + bottom,
+ left,
+ top,
+ plotBottom,
+ rowY: (i) => top + i * rowHeight + rowHeight / 2,
+ boxHalf,
+ capHalf,
+ labelGap,
+ medianGap,
+ tickGap,
+ fontSize
};
}
- // Row and stacked share one geometry, reused at two different pixel scales
- // (below) — not a separate viewBox per layout. Walking and transit share
- // PX_PER_MIN here, so whatever uniform scale a layout applies, a given number
- // of minutes is the same number of pixels in both panels and their gridlines
- // land at the same x position when stacked — which a per-panel independent
- // width (this component's first attempt at the stacked layout) breaks, since
- // it gives each panel its own scale.
- const panels = MODES.map((m) => buildPanel(m));
- const anyOverflow = panels.some((p) => p.rows.some((r) => r.p10Over || r.p90Over));
- const maxW = Math.max(...panels.map((p) => p.W)); // walking; transit stays narrower at every scale
-
- // The narrow (360px) layout gets its own geometry off NARROW_MARGIN_LEFT (see
- // its comment above) rather than reusing panels/maxW — its gutter is wider in
- // viewBox units, so its own W and own scale differ from the row/stacked ones.
- const narrowPanels = MODES.map((m) => buildPanel(m, NARROW_MARGIN_LEFT));
- const narrowMaxW = Math.max(...narrowPanels.map((p) => p.W));
-
- // REFERENCE_ROW_WIDTH/STACKED_WIDTH/NARROW_WIDTH are ChartFrame's measured
- // content-box at each embed width (1080/720/360) minus this component's own 4px
- // inter-panel gap. Fixed constants, not computed from meta.widths, because this
- // component doesn't receive ChartFrame's width/padding — if ChartFrame's padding
- // changes, these (and the layouts they drive) would need updating to match.
- const REFERENCE_ROW_WIDTH = 1034;
- const STACKED_WIDTH = 678;
- const NARROW_WIDTH = 318;
-
- const totalW = panels.reduce((sum, p) => sum + p.W, 0);
- const ROW_SCALE = REFERENCE_ROW_WIDTH / totalW; // shared scale across both row-layout panels
- const STACKED_SCALE = STACKED_WIDTH / maxW; // walking fills exactly; transit stays proportionally narrower
- const NARROW_SCALE = NARROW_WIDTH / narrowMaxW;
-
- // H (viewBox height) is the same for every panel and every layout, and so is
- // whatever scale is active — so a layout's rendered height is just H times that
- // scale. Every width/height below is set explicitly rather than left as
- // width/height:auto: a replaced element's auto height inside a column-direction
- // flex container needs its final stretched width to compute an aspect-ratio
- // height, and that width isn't resolved yet when the browser calculates the flex
- // item's hypothetical main (height) size — auto falls back short of the real
- // content there, and the next panel overlaps it.
- const rowSvgStyle = () => `height: ${H * ROW_SCALE}px;`;
-
- // STACKED_SCALE (walking filling 678px) is bigger than ROW_SCALE (both panels
- // sharing 1034px) — that's the whole point, it's what closes up the whitespace —
- // but text size must not ride along with it: font-size is set in this SVG's own
- // viewBox units, so at a bigger scale, the same "13" units renders as visibly
- // bigger true pixels. STACKED_FONT_VIEWBOX is what "13" needs to be *in this
- // layout's own units* to still land on 13 true pixels once STACKED_SCALE is
- // applied — same fix as NARROW_FONT_VIEWBOX below, just holding size constant
- // instead of also shrinking it.
- const STACKED_FONT_PX = 13;
- const STACKED_FONT_VIEWBOX = STACKED_FONT_PX / STACKED_SCALE;
- const stackedSvgStyle = (panel) =>
- `width: ${panel.W * STACKED_SCALE}px; height: ${H * STACKED_SCALE}px; --chart-svg-font-size: ${STACKED_FONT_VIEWBOX}px;`;
-
- // The narrow (360px) layout additionally shrinks label text to make more room for
- // the plot itself. NARROW_FONT_VIEWBOX is the CSS font-size value (in this SVG's
- // own viewBox units) that renders at exactly NARROW_FONT_PX true pixels once
- // NARROW_SCALE is applied — i.e. NARROW_FONT_PX / NARROW_SCALE, so the "12px" the
- // text is styled with elsewhere isn't just riding along with NARROW_SCALE's own
- // shrink, it's a deliberate additional reduction on top of it. Passed down as a
- // CSS custom property (--chart-svg-font-size) that .row-label/.axis-label/
- // .median-label read with a 13px fallback, rather than a new class, so adding a
- // breakpoint doesn't mean adding a font-size rule per text class per breakpoint.
- const NARROW_FONT_PX = 11;
- const NARROW_FONT_VIEWBOX = NARROW_FONT_PX / NARROW_SCALE;
-
- // The narrow layout also gets 5 true px more row spacing than row/stacked —
- // row spacing is vertical (H, rowY), independent of the horizontal gutter
- // widening above, but the same problem: "5px" only means true px if it's
- // converted through NARROW_SCALE rather than added directly to rowHeight,
- // same as the font-size and gutter decoupling above.
- const NARROW_ROW_HEIGHT = rowHeight + 5 / NARROW_SCALE;
- const NARROW_H = marginTop + ROW_ORDER.length * NARROW_ROW_HEIGHT + marginBottom;
- const narrowRowY = (i) => marginTop + i * NARROW_ROW_HEIGHT + NARROW_ROW_HEIGHT / 2;
- const narrowLayout = { rowHeight: NARROW_ROW_HEIGHT, H: NARROW_H, rowY: narrowRowY };
-
- const narrowSvgStyle = (panel) =>
- `width: ${panel.W * NARROW_SCALE}px; height: ${NARROW_H * NARROW_SCALE}px; --chart-svg-font-size: ${NARROW_FONT_VIEWBOX}px;`;
+ const wide = buildLayout({
+ W: 678,
+ left: 175,
+ right: 22,
+ top: 11,
+ bottom: 34,
+ rowHeight: 47,
+ boxHalf: 9,
+ capHalf: 5.5,
+ labelGap: 11,
+ medianGap: 8,
+ tickGap: 18,
+ tickStep: 5,
+ fontSize: 13
+ });
+
+ // Wider label gutter than the wide layout, since "First-gen. immigrants" still
+ // needs ~111px at 11px. Ticks every 10 minutes so adjacent numbers don't touch.
+ const narrow = buildLayout({
+ W: 318,
+ left: 122,
+ right: 9,
+ top: 4,
+ bottom: 13,
+ rowHeight: 23,
+ boxHalf: 3.5,
+ capHalf: 2.2,
+ labelGap: 4,
+ medianGap: 3,
+ tickGap: 7,
+ tickStep: 10,
+ fontSize: 11
+ });
-
-
-
-
Percentile of travel time
-
-
- {#if anyOverflow}
-
-
- extends past the chart's edge
-
- {/if}
-
- Ontario holds municipal elections every four years, on the fourth Monday in October. Voters elect a mayor and council, as well as school board trustees, and in municipalities that sit within a region, members of regional council. We compared voter turnout data across the three most recent municipal elections in Ontario for every city and town with more than 75,000 residents. Together these 34 municipalities had 7.1 million eligible electors in 2022.
+ Every four years, on the fourth Monday in October, Ontarians go to the polls to elect their mayors, councillors and school board trustees. How many of them actually vote, i.e. voter turnout, is a common measure of civic engagement in local democracy.
+
+
+
+ We compared voter turnout data across the three most recent municipal election waves in Ontario for every city and town with more than 75,000 residents. Together these 34 municipalities had 7.1 million eligible electors in 2022.
@@ -49,28 +53,16 @@
-
-
-
- In 2014, only 3 of the 34 municipalities had turnout below 30%. In 2022, 21 did. We found no research that directly explains the 2022 drop. Commentators pointed to the COVID-19 pandemic and to election fatigue (the October 2022 municipal elections came four months after the June 2022 provincial election and 13 months after the September 2021 federal election). Research from Europe found that more frequent elections tend to lower turnout.
-
-
- Earlier research does point to some of the reasons turnout differs between municipalities. A study using Canadian Municipal Barometer data on 233 municipalities in Ontario, Quebec, British Columbia, and Alberta, in their 2017 and 2018 elections, found that turnout was higher where there were more local newspapers that published more often, where councils had more seats relative to population, and where mayoral races were closer. Turnout also tends to be lower where a larger share of residents were aged 18 to 34.
+ In 2014, only 3 of the 34 municipalities had turnout below 30%. In 2022, 21 did. Commentators pointed to the COVID-19 pandemic and to election fatigue as potential reasons (the October 2022 municipal elections came four months after the June 2022 provincial election and 13 months after the September 2021 federal election). Research from Europe has found that more frequent elections tend to lower turnout.
- In our look at Ontario's largest municipalities, a city's size says little about its turnout: population and turnout are essentially uncorrelated in each of the three elections. How a municipality is governed makes a small difference. Single-tier cities, which are not part of a regional government, had a higher median turnout in all three elections than lower-tier municipalities, which sit within a region (e.g. Peel, York, etc.).
+ Earlier research does point to some of the reasons turnout differs between municipalities. A study including Canadian Municipal Barometer data on 233 municipalities in Ontario, Quebec, British Columbia, and Alberta, in their 2017 and 2018 elections, found that turnout was higher where there were more local newspapers that published more often, where councils had more seats relative to population, and where there a lower share of residents aged 18 to 34.
- The closeness of the mayoral race accounts for more of the difference, in line with the earlier research. In each of the three elections, turnout tended to be lower where the winning mayoral candidate took a larger share of the vote.
+ In our look at Ontario's largest municipalities, a city's size says little about its turnout: population and turnout are essentially uncorrelated in each of the three elections. But how a municipality is governed makes a small difference. Single-tier cities, which are not part of a regional government, had a higher median turnout in all three elections than lower-tier municipalities, which sit within a region (e.g. Peel, York, etc.). The closeness of the mayoral race accounts for more of the difference. In each of the three elections, turnout tended to be lower where the winning mayoral candidate took a larger share of the vote.
@@ -89,34 +81,23 @@
Methods and data
- Turnout data are from the Municipal election results dataset published by the Government of Ontario, which has one file of municipal data for each of the 2014, 2018, and 2022 elections. Turnout is the number of electors who voted divided by the total number of eligible electors.
+ Turnout data are from the Municipal election results dataset published by the Government of Ontario, which has one file of municipal data for each of the 2014, 2018, and 2022 elections. We included every single-tier and lower-tier municipality with a population above 75,000 in the 2021 Census of Population.
- We included every single-tier and lower-tier municipality with a population above 75,000 in the 2021 Census of Population. That is 34 municipalities: 26 cities, six towns (Oakville, Whitby, Milton, Ajax, Newmarket, and Caledon), and two with the status of municipality (Chatham-Kent and Clarington). Upper-tier regions and counties are not included.
+ Turnout is the number of electors who voted divided by the total number of eligible electors. Turnout depends on the voters' list as well as on the number of people who vote. A list that includes people who have moved or died lowers measured turnout, and a list that misses eligible residents raises it. The quality of the list can differ between municipalities and between elections, so small differences in turnout should be read with caution.
- We replaced three values with figures published by the cities themselves. The provincial file has no figures for Toronto in 2014, so we used the City of Toronto's count (991,754 voters of 1,813,915 eligible electors). For Toronto in 2018, the provincial file lists about 204,000 fewer electors than the City's final count, which gives a turnout of 45.1% instead of the City's 40.9%; we used the City's figures (769,044 of 1,880,371). For Barrie in 2018, the provincial file gives a turnout of 47.4%, while the City of Barrie reported 29.65%; we used the City's figure. The downloadable data marks these three values.
+ We replaced three values with data published by the cities themselves. The provincial file had no data for Toronto in 2014, so we used the City of Toronto's count (991,754 voters of 1,813,915 eligible electors). For Toronto in 2018, the provincial file lists about 204,000 fewer electors than the City's final count, which gives a turnout of 45.1% instead of the City's 40.9%; we used the City's figures (769,044 of 1,880,371). For Barrie in 2018, the provincial file gives a turnout of 47.4%, while the City of Barrie reported 29.65%. We used the City's figure. The downloadable data above includes these three values.
- The mayoral results are from the same dataset's files of successful candidates, which give the votes cast for the winning candidate and the total votes cast for the office. We divided the first by the second. The files do not list losing candidates, so we could not calculate the margin of victory. Two of the 102 contests are left out of the third chart: Newmarket in 2022, where the mayor was acclaimed, and Milton in 2022, where the file has no total for the office.
+ The mayoral results are from the same dataset's files of successful candidates, which give the votes cast for the winning candidate and the total votes cast for the office. We divided the first by the second. The files do not list losing candidates, so we could not calculate the margin of victory with this data. Two of the 102 contests are left out of the third chart: Newmarket in 2022, where the mayor was acclaimed, and Milton in 2022, where the data had no total.
-
Limitations
+
-
- Municipalities report these figures to the Province voluntarily, and we did not compare every value with each municipality's own published results. The three substitutions above are the ones we found; there may be others.
-
-
-
- Turnout depends on the voters' list as well as on the number of people who vote. A list that includes people who have moved or died lowers measured turnout, and a list that misses eligible residents raises it. The quality of the list can differ between municipalities and between elections, so small differences in turnout should be read with caution.
-
-
-
- Three elections are a short series, and the analysis is descriptive. It does not test why turnout changed or why it differs between municipalities.
-
diff --git a/posts/voter-turnout-municipal-elections-ontario/charts.js b/posts/voter-turnout-municipal-elections-ontario/charts.js
index 7559110..3386aa1 100644
--- a/posts/voter-turnout-municipal-elections-ontario/charts.js
+++ b/posts/voter-turnout-municipal-elections-ontario/charts.js
@@ -19,7 +19,7 @@ const substitutionNote =
const charts = {
'turnout-by-election': {
- title: 'Voter turnout declined in most of Ontario’s largest municipalities between 2014 and 2022',
+ title: 'Voter turnout declined in most Ontario municipal elections between 2014 and 2022',
subtitle:
'Median turnout held near 36% in 2014 and 2018, then dropped to 28% in 2022. Toronto declined the most; Ottawa was the only city to rise in both elections',
source:
@@ -31,7 +31,7 @@ const charts = {
graphicVerb: 'created'
},
'turnout-by-mayoral-vote': {
- title: 'Turnout tended to be lower where the mayor won with a larger share of the vote',
+ title: 'Turnout in Ontario municipal elections tended to be lower where the mayor won with a larger share of the vote',
subtitle:
'Toronto’s highest turnout came in 2014, when its mayor was elected with only 40% of the vote. Ottawa’s came in 2022, when its mayor was elected with 51%',
source:
diff --git a/posts/voter-turnout-municipal-elections-ontario/charts/turnout-by-election.svelte b/posts/voter-turnout-municipal-elections-ontario/charts/turnout-by-election.svelte
index 856632f..c45ad37 100644
--- a/posts/voter-turnout-municipal-elections-ontario/charts/turnout-by-election.svelte
+++ b/posts/voter-turnout-municipal-elections-ontario/charts/turnout-by-election.svelte
@@ -87,7 +87,7 @@
// Too long for the label column on one line.
nameLines: ['Median of 34', 'municipalities'],
group: 'median',
- color: 'var(--brandDarkGreen)',
+ color: 'var(--brandMedGreen)',
width: 3,
dotR: 5,
points: years.map((year, i) => ({
@@ -234,6 +234,12 @@
Voter turnout in municipal elections
+
+
+
+
+ Each grey line is one municipality's turnout
+
@@ -436,6 +442,9 @@
}
.legend {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
padding-top: 6px;
padding-bottom: 10px;
margin-bottom: 14px;
diff --git a/posts/voter-turnout-municipal-elections-ontario/meta.json b/posts/voter-turnout-municipal-elections-ontario/meta.json
index 141afed..dd45596 100644
--- a/posts/voter-turnout-municipal-elections-ontario/meta.json
+++ b/posts/voter-turnout-municipal-elections-ontario/meta.json
@@ -1,5 +1,5 @@
{
- "title": "Voter turnout in Ontario's largest municipalities",
+ "title": "Voter turnout in Ontario municipal elections",
"type": "post",
"summary": "Median turnout in Ontario's 34 largest municipalities declined from 36% in 2014 to 28% in 2022.",
"authors": [