diff --git a/posts/voter-turnout-municipal-elections-ontario/+page.svelte b/posts/voter-turnout-municipal-elections-ontario/+page.svelte
index 264c122..9ac9cf6 100644
--- a/posts/voter-turnout-municipal-elections-ontario/+page.svelte
+++ b/posts/voter-turnout-municipal-elections-ontario/+page.svelte
@@ -4,6 +4,7 @@
import meta from './meta.json';
import TurnoutByElection from './charts/turnout-by-election.svelte';
import TurnoutByMayoralVote from './charts/turnout-by-mayoral-vote.svelte';
+ import TurnoutByInternetVoting from './charts/turnout-by-internet-voting.svelte';
import Footnote from '$lib/footnotes/Footnote.svelte';
import Footnotes from '$lib/footnotes/Footnotes.svelte';
import { createFootnoteStore } from '$lib/footnotes/footnoteUtils.js';
@@ -33,16 +34,22 @@
"Andrews, B., & Pruysers, S. (2022). Does democracy die in darkness? An examination of the relationship between local newspaper health and turnout in municipal politics. *Canadian Journal of Political Science*, 55(4), 958–971. [DOI](https://doi.org/10.1017/S0008423922000737)."
].join("\n\n")
);
+ const fInternet = footnoteStore.addFootnote(
+ "Goodman, N., & Stokes, L. C. (2020). Reducing the cost of voting: An evaluation of internet voting's effect on turnout. *British Journal of Political Science*, 50(3), 1155–1167. [DOI](https://doi.org/10.1017/S0007123417000849)."
+ );
- 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.
+ Every four years, on the fourth Monday in October, Ontarians go to the polls to elect their mayors, councillors and school board trustees. The percentage of the electorate who actually vote, 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.
+
+ 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. Combined, these 34 municipalities had 7.1 million eligible electors in 2022.
+
+
+ We will likely update these charts with 2026 municipal election data when it becomes available.
@@ -54,15 +61,15 @@
- 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 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, though the 2014 and 2018 municipal elections also followed June provincial elections). Research from Europe has found that more frequent elections tend to lower turnout.
- 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.
+ Earlier research suggests reasons for why 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 was a lower share of residents aged 18 to 34.
- 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.
+ In our look at Ontario's largest municipalities, population size says little about turnout. Apart from Toronto's high turnout in 2014, a municipality's population and its turnout are not correlated. Single-tier cities, which are not part of a regional government, had higher median turnout than lower-tier municipalities within regions such as Peel and York in all three elections. Turnout also tended to be lower where the winning mayoral candidate took a larger share of the vote.
@@ -74,26 +81,48 @@
- We will likely update these charts after 2026 elections when the data are available.
+ Half of the 34 municipalities offered internet voting in 2022, up from 10 in 2014, though none of the six largest did. A study of Ontario municipal elections from 2000 to 2014 found that offering internet voting raised turnout by about 3.5 points. In our data, the four municipalities that introduced internet voting in 2022 saw turnout rise or change by less than a point, while it fell in every other municipality except Ottawa. The four that introduced it in 2018 had mixed results: turnout rose 4.9 points in Thunder Bay but fell in Newmarket, Kawartha Lakes and Pickering.
+
+
+
+
+
+
+
+
+
+
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. We included every single-tier and lower-tier municipality with a population above 75,000 in the 2021 Census of Population.
+ Turnout data are from the Municipal election results dataset published by the Government of Ontario. We included every single-tier and lower-tier municipality with a population above 75,000 (based on the 2021 Census of Population).
+
+
+
+ Turnout is the number of electors who voted divided by the number of electors on the voters' list. Voters' lists are not always up to date, since they can include people who have moved away or died and miss people who have recently moved in or become eligible. The accuracy of this can vary between places and between elections, so small differences in turnout should be read with caution.
+
+
+
+
+
+ We replaced three values with data published by the cities themselves. The provincial dataset had no data for Toronto in 2014, so we used the City of Toronto's 2014 election report. For Toronto in 2018, the provincial dataset lists far fewer electors than the City's final count, so we used the City's 2018 election report. For Barrie in 2018, the provincial dataset's turnout is much higher than the City of Barrie reported, so we used the City's data.
- 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.
+ 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 dataset does not list losing candidates, so we could not calculate the margin of victory with this data. Three of the 102 contests are left out of the second chart. Newmarket's mayor was acclaimed in 2022, and the data has no total for Milton in 2022. For St. Catharines in 2014, the data lists more votes for mayor than electors who voted. For Peterborough in 2018, the data's total votes for mayor does not match the sum of the two candidates' votes, so we used the sum.
- 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.
+ Whether each municipality offered internet voting is also from the provincial dataset, which records the voting methods each municipality used, including internet voting only during advance voting.
+
+ We noticed a couple discrepancies in the source data that we fixed before creating the chart. A) The provincial dataset lists internet voting in Oakville in 2018, but AMO's list and the Town's count of ballots by polling location show that every ballot was cast in person. B) The provincial dataset was blank for Toronto in 2014, but the City's 2014 election report does not indicate that the City offered internet voting.
- 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.
+ Provincial turnout in the first chart is from Elections Ontario's statistical summary of previous general elections, and is all ballots cast as a share of electors on the voters' list. Federal turnout is from Elections Canada's official voting results. We include voter turnout in Ontario only.
diff --git a/posts/voter-turnout-municipal-elections-ontario/charts.js b/posts/voter-turnout-municipal-elections-ontario/charts.js
index 3386aa1..3180e87 100644
--- a/posts/voter-turnout-municipal-elections-ontario/charts.js
+++ b/posts/voter-turnout-municipal-elections-ontario/charts.js
@@ -15,18 +15,18 @@ const source =
'Government of Ontario, Municipal election results (2014, 2018, 2022); City of Toronto and City of Barrie election results; Statistics Canada, Census of Population (2021)';
const substitutionNote =
- 'Turnout is the number of electors who voted divided by the number of eligible electors, as each municipality reported it to the Province. Toronto in 2014 and 2018 and Barrie in 2018 use figures published by those cities, because the provincial file is blank or differs from them. By-elections, such as Toronto’s 2023 and Mississauga’s 2024 mayoral by-elections, are not included. Provincial turnout (dashed line) is all ballots cast as a share of names on the voters list, as reported by Elections Ontario; provincial elections were held in June 2014, 2018 and 2022, and February 2025.';
+ 'Includes the 34 Ontario municipalities with more than 75,000 residents in the 2021 Census. Turnout is the number of electors who voted divided by the number of electors on the voters’ list, as each municipality reported it to the Province. Toronto in 2014 and 2018 and Barrie in 2018 use figures published by those cities, because the provincial dataset is blank or differs from them. By-elections, such as Toronto’s 2023 and Mississauga’s 2024 mayoral by-elections, are not included.';
const charts = {
'turnout-by-election': {
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',
+ 'Turnout fell in both 2018 and 2022 in 18 of 34 municipalities. Only Ottawa’s rose in both years',
source:
- 'Government of Ontario, Municipal election results (2014, 2018, 2022); City of Toronto and City of Barrie election results; Elections Ontario, General Elections Statistics from the Records; Statistics Canada, Census of Population (2021)',
- alt: 'Slope chart of voter turnout in the 2014, 2018 and 2022 municipal elections for the 34 Ontario municipalities with more than 75,000 residents. Most lines fall, most steeply between 2018 and 2022. The median declines from 36.1% to 28.2%. A dashed line shows provincial election turnout: 51.3% in 2014, 56.7% in 2018, 44.1% in 2022 and 45.2% in 2025.',
+ 'Government of Ontario, Municipal election results (2014, 2018, 2022); City of Toronto and City of Barrie election results; Elections Ontario, General Elections Statistics from the Records; Elections Canada, Official Voting Results (2015, 2019, 2021, 2025); Statistics Canada, Census of Population (2021)',
+ alt: 'Slope chart of voter turnout in the 2014, 2018 and 2022 municipal elections for 34 Ontario municipalities. Most lines fall, most steeply between 2018 and 2022. The median declines from 36.1% to 28.2%. A dotted line shows federal election turnout in Ontario: 67.8% in 2015, 66.3% in 2019, 62.0% in 2021 and 69.1% in 2025. A button adds provincial election turnout: 51.3% in 2014, 56.7% in 2018, 44.1% in 2022 and 45.2% in 2025.',
note: substitutionNote,
- data: ['turnout-by-municipality.csv', 'provincial-turnout.csv'],
+ data: ['turnout-by-municipality.csv', 'provincial-turnout.csv', 'federal-turnout.csv'],
widths: [360, 720],
graphicVerb: 'created'
},
@@ -36,11 +36,22 @@ const charts = {
'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:
'Government of Ontario, Municipal election results, municipal and successful candidate data (2014, 2018, 2022); City of Toronto and City of Barrie election results',
- alt: 'Three scatter plots, one for each of the 2014, 2018 and 2022 municipal elections, of voter turnout relative to the winning mayoral candidate’s share of the vote in Ontario municipalities with more than 75,000 residents. In each, the trend slopes downward: turnout is lower where the winner took a larger share. Toronto and Ottawa are highlighted.',
- note: 'Turnout is the percentage of eligible electors who voted. The provincial file lists only the winning candidate, so the measure is the winner’s share of votes cast for mayor, not the margin over the runner-up. The 2022 panel has 32 municipalities: Newmarket’s mayor was acclaimed and the file has no mayoral vote total for Milton. Three turnout values use city-published figures (Toronto 2014 and 2018, Barrie 2018). Correlations are Pearson’s r, with two-sided p-values.',
+ alt: 'Three scatter plots, one for each of the 2014, 2018 and 2022 municipal elections, of voter turnout relative to the winning mayoral candidate’s share of the vote in Ontario municipalities. In each, the trend slopes downward: turnout is lower where the winner took a larger share. Toronto and Ottawa are highlighted.',
+ note: 'Includes the 34 Ontario municipalities with more than 75,000 residents in the 2021 Census. Turnout is the percentage of electors on the voters’ list who voted. The provincial dataset lists only the winning candidate, so the measure is the winner’s share of votes cast for mayor, not the margin over the runner-up. The 2014 panel has 33 municipalities: St. Catharines is left out because the dataset lists more votes for mayor than electors who voted. The 2022 panel has 32: Newmarket’s mayor was acclaimed and the dataset has no mayoral vote total for Milton. Three turnout values use city-published figures (Toronto 2014 and 2018, Barrie 2018). Correlations are Pearson’s r, with two-sided p-values.',
data: 'turnout-and-mayoral-vote.csv',
widths: [360, 720],
graphicVerb: 'created'
+ },
+ 'turnout-by-internet-voting': {
+ title: 'Municipalities in Ontario that introduced internet voting in 2022 did not see the drop in turnout seen elsewhere',
+ subtitle: 'The four that introduced it in 2018 showed no such difference',
+ source:
+ 'Government of Ontario, Municipal election results (2014, 2018, 2022); Association of Municipalities of Ontario, vote methods (2018, 2022); municipal election reports',
+ alt: 'Arrow chart of the change in voter turnout for Ontario municipalities that started or stopped offering internet voting, compared with the median change of those that did not. From 2018 to 2022, the four that introduced it changed by less than 5 points while the median fell 8.1; from 2014 to 2018, Thunder Bay rose 4.9 points while three others fell more than the median of −0.9.',
+ note: 'Includes the 34 Ontario municipalities with more than 75,000 residents in the 2021 Census. Introduced internet voting means it was offered in that election but not the previous one, including Vaughan in 2022, which offered it only during advance voting. The medians are of the municipalities whose use of internet voting did not change between the two elections (29 for 2014 to 2018, 30 for 2018 to 2022). Oakville’s 2018 entry in the provincial dataset is corrected to no internet voting.',
+ data: ['internet-voting.csv', 'turnout-by-municipality.csv'],
+ widths: [360, 720],
+ graphicVerb: 'created'
}
};
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 c45ad37..9509daf 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
@@ -2,12 +2,24 @@
import { scaleLinear, median } from 'd3';
import data from '../data/turnout-by-municipality.csv';
import provincial from '../data/provincial-turnout.csv';
+ import federal from '../data/federal-turnout.csv';
- // The municipality highlighted in pink, chosen in the search box under the
+ // The municipality highlighted in purple, chosen in the search box under the
// chart's title. Empty by default, so exports and the no-JS page show the
// chart without a highlight.
let selected = $state('');
+ // The provincial and federal reference lines, each switched on and off by its
+ // own button in the legend. Federal starts on and provincial off, and exports
+ // and the no-JS page show that default. Hiding one leaves the year columns as
+ // they are, so the other lines don't shift sideways.
+ let showProvincial = $state(false);
+ let showFederal = $state(true);
+ const compareButtons = $derived([
+ { label: 'Provincial', color: 'var(--brandMedBlue)', dash: '6 4', on: showProvincial, toggle: () => (showProvincial = !showProvincial) },
+ { label: 'Federal', color: 'var(--brandDarkBlue)', dash: '2 4', on: showFederal, toggle: () => (showFederal = !showFederal) }
+ ]);
+
// Search box: typing filters the list of municipalities, and picking one (by
// click, or arrow keys and Enter) highlights it. The list shows about ten
// names at a time and scrolls for the rest.
@@ -63,53 +75,89 @@
const years = [2014, 2018, 2022];
const municipalities = data.map((d) => d.municipality).sort((a, b) => a.localeCompare(b));
- // Every municipality is a plain grey line; only the median, the provincial line
- // and a selected municipality are coloured and labelled.
- const lineStyle = { color: 'var(--brandGray30)', width: 1, dotR: 0 };
- const selectedStyle = { color: 'var(--brandPink)', width: 3, dotR: 5 };
+ // Every municipality is a faint line in the median's pink, so the median reads
+ // as their summary; only the median, the provincial and federal lines and a
+ // selected municipality are labelled.
+ const lineStyle = { color: 'var(--brandPink)', opacity: 0.36, width: 1, dotR: 0 };
+ const selectedStyle = { color: 'var(--brandPurple)', opacity: 1, width: 3, dotR: 5 };
const fmt = (v) => `${v.toFixed(1)}%`;
+ // '2014-06-12' -> '6/2014': the month and year shown with each provincial and
+ // federal value, since those elections fall in other months than October.
+ const monthYear = (date) => `${+date.slice(5, 7)}/${date.slice(0, 4)}`;
+
+ // The x-axis is time, by month: each election sits at the middle of its month,
+ // as a fraction of a year (October 2014 is 2014.79). Every municipal election
+ // was in October.
+ const monthT = (year, month) => year + (month - 0.5) / 12;
+ const dateT = (date) => monthT(+date.slice(0, 4), +date.slice(5, 7));
+ const municipalT = (year) => monthT(year, 10);
// The CSV holds turnout to one decimal, so a median of two values lands on a
// half step (28.15) that floating point then rounds down. The nudge makes it
// round up, which also matches the median of the unrounded figures (28.17).
const round1 = (v) => Math.round(v * 10 + 1e-6) / 10;
- // Every line is a list of { year, value } points, since the provincial line
- // has a point (2025) that the municipal lines don't.
+ // Every line is a list of { year, t, value } points: `t` places it on the time
+ // axis, `year` groups it with the municipal election of the same year.
const cities = data.map((d) => ({
name: d.municipality,
group: 'other',
...lineStyle,
- points: years.map((year) => ({ year, value: +d[`turnout_${year}`] }))
+ points: years.map((year) => ({ year, t: municipalT(year), value: +d[`turnout_${year}`] }))
}));
const medianLine = {
- name: 'Median of 34 municipalities',
+ name: 'Median of 34 municipal elections',
// Too long for the label column on one line.
- nameLines: ['Median of 34', 'municipalities'],
+ nameLines: ['Median of 34', 'municipal', 'elections'],
group: 'median',
- color: 'var(--brandMedGreen)',
+ color: 'var(--brandPink)',
width: 3,
dotR: 5,
points: years.map((year, i) => ({
year,
+ t: municipalT(year),
value: round1(median(cities, (c) => c.points[i].value))
}))
};
// Province-wide turnout in Ontario's general elections, dashed to set it apart
- // from the municipal lines. Plotted by year: the 2014, 2018 and 2022 elections
- // were in June, months before that year's municipal vote.
+ // from the municipal lines. Plotted at their own months: the 2014, 2018 and 2022
+ // elections were in June, four months before that year's municipal vote.
const provincialLine = {
name: 'Provincial elections',
nameLines: ['Provincial', 'elections'],
group: 'provincial',
color: 'var(--brandMedBlue)',
- width: 2,
+ width: 1.25,
dotR: 4,
dashed: true,
- // Its 2025 value is centred: pushed left, it runs into the 2022 value at 360px.
- valueCentred: true,
- points: provincial.map((d) => ({ year: +d.year, value: +d.turnout }))
+ points: provincial.map((d) => ({
+ year: +d.year,
+ t: dateT(d.election_date),
+ value: +d.turnout,
+ when: monthYear(d.election_date)
+ }))
+ };
+
+ // Turnout in Ontario in federal general elections, dotted to tell it apart from
+ // the provincial line. Its elections fall between the municipal columns, so each
+ // of its values sits above its dot.
+ const federalLine = {
+ name: 'Federal elections',
+ nameLines: ['Federal elections', '(Ontario turnout)'],
+ // The label column is too narrow at 360px for those two lines.
+ nameLinesNarrow: ['Federal', 'elections', '(Ontario', 'turnout)'],
+ group: 'federal',
+ color: 'var(--brandDarkBlue)',
+ width: 1.25,
+ dotR: 4,
+ dash: '2 4',
+ points: federal.map((d) => ({
+ year: +d.year,
+ t: dateT(d.election_date),
+ value: +d.turnout,
+ when: monthYear(d.election_date)
+ }))
};
// Grey lines first, so the median and the provincial line are drawn over them,
@@ -120,17 +168,22 @@
return [
...others,
medianLine,
- provincialLine,
+ ...(showProvincial ? [provincialLine] : []),
+ ...(showFederal ? [federalLine] : []),
...(chosen ? [{ ...chosen, ...selectedStyle, group: 'selected' }] : [])
];
});
const labelled = $derived(lines.filter((l) => l.group !== 'other'));
- // A column for each election year on the chart: the municipal years plus 2025,
- // which only the provincial line uses.
- const columns = [
- ...new Set([...years, ...provincialLine.points.map((p) => p.year)])
- ].sort();
+ // A column, headed by its month and year, for each municipal election. The
+ // provincial and federal elections fall in other months, so their values carry
+ // their own month and year instead. The time axis runs from the earliest
+ // election to the latest whether or not those lines are shown, so toggling them
+ // never moves the rest of the chart.
+ const columns = years;
+ const allT = [...years.map(municipalT), ...[...provincialLine.points, ...federalLine.points].map((p) => p.t)];
+ const firstT = Math.min(...allT);
+ const lastT = Math.max(...allT);
// The full 0-100% range, not one fitted to the data: an axis that stopped at
// 60% would make every turnout look like a larger share of electors than it is.
@@ -157,56 +210,83 @@
// One geometry per snapped width (see meta.widths in charts.js): drawn 1:1, so
// the font sizes below are true pixels. The container query in the styles
- // shows one of the two. The x-axis is in years, so the gap from 2022 to 2025
- // is drawn to scale.
- function buildLayout({ W, H, left, right, top, bottom, fontSize, gap }, labelled) {
- const x = scaleLinear()
- .domain([columns[0], columns[columns.length - 1]])
- .range([left, W - right]);
+ // shows one of the two. The x-axis is time, so every gap between elections is
+ // drawn to scale. `inset` is the space between the % labels and the earliest election.
+ function buildLayout({ W, H, left, inset, right, top, bottom, fontSize, gap, narrow = false }, labelled) {
+ const nameLines = (line) => (narrow && line.nameLinesNarrow) || line.nameLines || [line.name];
+ const x = scaleLinear().domain([firstT, lastT]).range([left + inset, W - right]);
const y = scaleLinear().domain([0, yMax]).range([H - bottom, top]);
- const path = (line) => line.points.map((p) => `${x(p.year)},${y(p.value)}`).join(' ');
+ const path = (line) => line.points.map((p) => `${x(p.t)},${y(p.value)}`).join(' ');
// Each name sits just right of its own line's last point: the municipal
- // lines end at 2022, the provincial line at 2025. All are dodged together,
+ // lines end in 2022, the provincial and federal lines in 2025. All are dodged together,
// since at 360px the two groups of names overlap horizontally.
const finishLabels = dodge(
// `rows` is how many text lines the label takes, so the next one clears it.
labelled.map((line) => {
const end = line.points[line.points.length - 1];
- return { line, x: x(end.year) + 10, y: y(end.value), rows: line.nameLines?.length ?? 1 };
+ const lines = nameLines(line);
+ return { line, lines, x: x(end.t) + 10, y: y(end.value), rows: lines.length };
}),
gap,
H - bottom
);
- // The value of each labelled line at each election. A value sits above its
- // dot unless that would run into the one above it, in which case it drops below.
- // A line with `labelBelow` always has its values below. Values at a line's
- // first point sit to the right, at its last point to the left, except for a
- // line with `valueCentred`, whose last value is centred over its dot.
- const valueLabels = columns.flatMap((year) => {
+ // A provincial or federal value has its month and year on a second, smaller line, on
+ // the side away from the dot: above the value when the label sits above the
+ // dot, below it when the label sits below.
+ const isReference = (line) => line.group === 'provincial' || line.group === 'federal';
+ const yearLine = fontSize + 1;
+ const withYear = (label, point) => ({
+ ...label,
+ year: isReference(label.line) ? point.when : null,
+ yearY: label.below ? label.y + yearLine : label.y - yearLine
+ });
+
+ // The value of each labelled line at each municipal election, together with a
+ // provincial value from the same year, since the two sit close. A value sits
+ // above its dot unless that would run into the one above it, in which case it
+ // drops below. A line with `labelBelow` always has its values below. Values at
+ // a line's first point sit to the right, at its last point to the left.
+ const columnLabels = columns.flatMap((year) => {
let previous = -Infinity;
return labelled
- .filter((line) => line.points.some((p) => p.year === year))
+ .filter((line) => line.group !== 'federal' && line.points.some((p) => p.year === year))
.map((line) => ({ line, i: line.points.findIndex((p) => p.year === year) }))
.sort((a, b) => b.line.points[b.i].value - a.line.points[a.i].value)
.map(({ line, i }) => {
const point = line.points[i];
const dotY = y(point.value);
- const labelY =
- line.labelBelow || dotY - 11 - previous < fontSize + 2 ? dotY + fontSize + 8 : dotY - 11;
- previous = labelY;
+ const extra = isReference(line) ? yearLine : 0;
+ const below = line.labelBelow || dotY - 11 - extra - previous < fontSize + 2;
+ const labelY = below ? dotY + fontSize + 8 : dotY - 11;
+ previous = below ? labelY + extra : labelY;
const first = i === 0;
- const last = i === line.points.length - 1 && !line.valueCentred;
- return {
- line,
- text: fmt(point.value),
- x: x(year) + (first ? 8 : last ? -8 : 0),
- y: labelY,
- anchor: first ? 'start' : last ? 'end' : 'middle'
- };
+ const last = i === line.points.length - 1;
+ return withYear(
+ {
+ line,
+ text: fmt(point.value),
+ x: x(point.t) + (first ? 8 : last ? -8 : 0),
+ y: labelY,
+ below,
+ anchor: first ? 'start' : last ? 'end' : 'middle'
+ },
+ point
+ );
});
});
+ // Points in years with no municipal election: every federal one, and the
+ // provincial 2025. Nothing else is near them, so each value goes above its dot.
+ const offColumnLabels = labelled.filter(isReference).flatMap((line) =>
+ line.points
+ .filter((p) => line.group === 'federal' || !columns.includes(p.year))
+ .map((p) =>
+ withYear({ line, text: fmt(p.value), x: x(p.t), y: y(p.value) - 11, below: false, anchor: 'middle' }, p)
+ )
+ );
+ const valueLabels = [...columnLabels, ...offColumnLabels];
+
return {
W,
H,
@@ -224,10 +304,10 @@
}
const wide = $derived(
- buildLayout({ W: 678, H: 560, left: 44, right: 118, top: 34, bottom: 16, fontSize: 13, gap: 16 }, labelled)
+ buildLayout({ W: 678, H: 560, left: 44, inset: 20, right: 130, top: 34, bottom: 16, fontSize: 13, gap: 16 }, labelled)
);
const narrow = $derived(
- buildLayout({ W: 318, H: 520, left: 36, right: 82, top: 34, bottom: 16, fontSize: 11, gap: 14 }, labelled)
+ buildLayout({ W: 318, H: 520, left: 36, inset: 12, right: 82, top: 34, bottom: 16, fontSize: 11, gap: 14, narrow: true }, labelled)
);
@@ -236,14 +316,14 @@
- Each grey line is one municipality's turnout
+ Each thin line is one municipality's turnout
@@ -291,19 +371,36 @@
{/if}
+
+
+ Compare with other elections:
+
+ {#each compareButtons as b}
+
+ {/each}
+
+
{#snippet slope(layout)}
{/snippet}
@@ -414,6 +517,7 @@
.line-group:not(.no-hover):hover .slope-line {
stroke: var(--brandBlack);
+ stroke-opacity: 1;
stroke-width: calc(var(--w) * 2);
}
@@ -423,8 +527,32 @@
font-size: var(--label-size);
}
+ /* Regular weight for the provincial and federal lines' text, which are
+ background references, and for the median's name; only a selected
+ municipality's name is bold. */
+ .end-label.reference,
+ .value-label.reference {
+ font-family: OpenSans;
+ }
+
+ /* A selected municipality's name. */
+ .end-label:not(.reference) {
+ font-family: OpenSansBoldItalic;
+ }
+
/* White halo, so a value or name sitting among the lines stays legible. The
municipal names sit under the provincial line's 2022-2025 stretch. */
+ /* 12px at 720px and 10px at 360px, the minimums for chart text. */
+ .year-tag {
+ font-family: OpenSans;
+ font-size: calc(var(--label-size) - 1px);
+ fill: var(--brandGray60);
+ paint-order: stroke;
+ stroke: var(--brandWhite);
+ stroke-width: 3px;
+ stroke-linejoin: round;
+ }
+
.end-label,
.value-label {
paint-order: stroke;
@@ -438,26 +566,84 @@
font-weight: normal;
font-size: 14px;
color: var(--brandGray90);
- padding-top: 15px;
+ line-height: 28px;
+ padding-top: 10px;
}
+ /* The title and each legend row are 28px tall, the height of the search box,
+ so the three lines sit at an even spacing. */
.legend {
display: flex;
flex-direction: column;
- gap: 8px;
- padding-top: 6px;
padding-bottom: 10px;
margin-bottom: 14px;
}
+ /* At 360px, a gap between the search box and the line below it. */
+ @container (max-width: 400px) {
+ .legend-item.compare {
+ margin-top: 10px;
+ }
+ }
+
+ /* At 720px, "Compare with" and its buttons sit to the right of the two
+ municipality items, spanning both rows; at 360px everything stacks. */
+ @container (min-width: 401px) {
+ .legend {
+ display: grid;
+ grid-template-columns: auto auto;
+ justify-content: start;
+ column-gap: 82px;
+ }
+
+ /* Two classes, to win over .legend-item's row layout below. */
+ .legend-item.compare {
+ grid-column: 2;
+ grid-row: 1 / span 2;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ }
+ }
+
.legend-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
+ min-height: 28px;
+ font-family: OpenSans;
+ font-size: 13px;
+ color: var(--brandGray90);
+ }
+
+ /* Two separate buttons; a pressed one is shaded. */
+ .compare-buttons {
+ display: flex;
+ gap: 6px;
+ }
+
+ /* The same look on or off; an unpressed button is faded. */
+ .compare-buttons button {
+ display: flex;
+ align-items: center;
+ gap: 5px;
font-family: OpenSans;
font-size: 13px;
color: var(--brandGray90);
+ background: var(--brandWhite);
+ border: solid 1px var(--brandGray45);
+ border-radius: 4px;
+ padding: 3px 10px;
+ cursor: pointer;
+ }
+
+ .compare-buttons button[aria-pressed='false'] {
+ opacity: 0.5;
+ }
+
+ .compare-buttons button:hover {
+ border-color: var(--brandGray70);
}
.legend-swatch {
@@ -470,7 +656,8 @@
.search input {
box-sizing: border-box;
- width: 200px;
+ width: 150px;
+ height: 28px;
font-family: OpenSans;
font-size: 13px;
color: var(--brandGray90);
@@ -505,7 +692,7 @@
z-index: 2;
top: calc(100% + 2px);
left: 0;
- width: 200px;
+ width: 150px;
max-height: 260px;
overflow-y: auto;
margin: 0;
diff --git a/posts/voter-turnout-municipal-elections-ontario/charts/turnout-by-internet-voting.svelte b/posts/voter-turnout-municipal-elections-ontario/charts/turnout-by-internet-voting.svelte
new file mode 100644
index 0000000..a29a78f
--- /dev/null
+++ b/posts/voter-turnout-municipal-elections-ontario/charts/turnout-by-internet-voting.svelte
@@ -0,0 +1,294 @@
+
+
+
Change in voter turnout from the previous municipal election
+
For example, turnout decreasing from 35% to 30% is shown as −5
+
+
diff --git a/posts/voter-turnout-municipal-elections-ontario/charts/turnout-by-mayoral-vote.svelte b/posts/voter-turnout-municipal-elections-ontario/charts/turnout-by-mayoral-vote.svelte
index f05f1ec..d445b00 100644
--- a/posts/voter-turnout-municipal-elections-ontario/charts/turnout-by-mayoral-vote.svelte
+++ b/posts/voter-turnout-municipal-elections-ontario/charts/turnout-by-mayoral-vote.svelte
@@ -4,11 +4,11 @@
const years = [2014, 2018, 2022];
- // Toronto and Ottawa keep the colours they have in the slope chart: pink for
- // the largest decline, blue for the one that rose.
+ // Colours used by no other chart in the post, so neither city reads as one of
+ // the other charts' categories.
const highlights = {
- Toronto: 'var(--brandPink)',
- Ottawa: 'var(--brandMedBlue)'
+ Toronto: 'var(--brandMedGreen)',
+ Ottawa: 'var(--brandOrange)'
};
const otherColor = 'var(--brandGray45)';
@@ -145,28 +145,6 @@
Voter turnout relative to the winning mayoral candidate's share of the vote
-
- {#each Object.entries(highlights) as [name, color]}
-