diff --git a/src/app/toronto/vote/2026/issues/page.tsx b/src/app/toronto/vote/2026/issues/page.tsx index 9de5209..73b5796 100644 --- a/src/app/toronto/vote/2026/issues/page.tsx +++ b/src/app/toronto/vote/2026/issues/page.tsx @@ -31,11 +31,14 @@ import { ELECTION } from "../data"; * is not "what did this candidate say" anyway. It is where the people running * to govern Toronto converge, and where they split. * - * A card per question, with the candidates filed under the answer they gave — - * the same roll-call form the ward and mayoral pages use, so a reader who has - * learned to read one page of the tracker can read all of them. The only - * difference here is scale: the field is the whole city, so a name plate also - * carries the seat that candidate is running for. + * A card per question, and on this page the card is a band: how the + * field divided, with the options in full under it. The ward and mayoral pages name + * names under each answer, because four or five candidates under a question is + * the comparison their reader came for. Here the same form put thirty-odd + * names under each of two dozen questions — several hundred plates, none of + * them a ballot line any one reader votes on, and all of them standing between + * the reader and the split the page exists to show. The names stay on the + * pages where a reader can act on them; this one shows the shape of the field. * * The shell around them is deliberately short. Two dozen questions is the * page; every band of prose above them is a band the reader scrolls past to @@ -77,9 +80,11 @@ export default async function IssuesPage() { const wards = new Set(respondents.filter((r) => r.ward).map((r) => r.ward)) .size; - /* The roster the cards name, in the order `fieldSentiment` sorted it - (surname), and the seat each of them is running for — which is both what - prints on a plate and what splits each answer into its two races. */ + /* The field the bands are drawn over, in the order `fieldSentiment` sorted it + (surname), and the seat each of them is running for. The cards lead with + shares rather than names, but the names are behind every slice, and a name + on a city-wide page is only useful to a reader once they can see whether + it is on their ballot. */ const roster = respondents.map((r) => ({ key: r.key, name: r.name })); const seats = Object.fromEntries( respondents.map((r) => [ @@ -153,15 +158,25 @@ export default async function IssuesPage() { Theirs names one ballot line and links each candidate to their campaign; this page's field is thirty-odd people across a mayoral race and two dozen wards, and a flat list of them is a - list with no ballot behind it. The seat on each plate is the - pointer instead. */} + list with no ballot behind it. The links out at the foot of the + page are the pointer instead. */} + Each segment is the share of the candidates who answered + that particular question who gave that answer. Options + nobody picked are not shown. Select an answer to see which + candidates gave it; what they wrote about it is on the{" "} + mayoral and{" "} + ward pages. + + } /> @@ -202,17 +217,23 @@ export default async function IssuesPage() { {/* ── Method ─────────────────────────────────────────── */}

- Each card is one question, and each block inside it is one of the - answers offered, printed in the wording the candidates were shown. - Under it are the candidates who gave that answer, with the seat - they are running for. Options nobody picked are not shown, and a - candidate who answered in their own words sits on no option. + Each card is one question, drawn as the share of the field that + gave each answer. The options are listed in full under the band, + in the wording the candidates were shown, with the number who chose + each. Options nobody picked are not shown, and a candidate who + answered in their own words is counted in the unshaded segment + rather than on any option. Shares are of the candidates who + answered that particular question, not of the whole field — a + questionnaire can come back half filled in, so the number behind a + card is the counts in its own legend added up. Hover or select any + answer to see the candidates who gave it, with the seat each is + running for.

- Most candidates also wrote a note explaining their answer. Thirty - of them under every question is more reading than this page can - carry, so the notes live on the ward and mayoral pages, where the - field is small enough to read them in full. Answers appear as + The note most candidates wrote to explain their answer lives on + the ward and mayoral pages — thirty notes under every question is + more reading than this page can carry, and it is on those pages + that a reader has a ballot to weigh them against. Answers appear as candidates return the questionnaire and staff review them, so the field shown here grows through the campaign.

diff --git a/src/components/charts/trilemma/OptionBar.tsx b/src/components/charts/trilemma/OptionBar.tsx index 9e5c475..950156e 100644 --- a/src/components/charts/trilemma/OptionBar.tsx +++ b/src/components/charts/trilemma/OptionBar.tsx @@ -41,6 +41,19 @@ export interface OptionBarProps { */ showLabels?: boolean + /* ---- pointer ---- */ + /** + * The pointer entering a segment, by option index — for a caller whose bar + * drives something beside it, a key or a panel of names. + * + * Leaving is reported from the bar as a whole rather than per segment, + * because the segments are drawn a pixel apart to keep their joins visible: + * per-segment, dragging across the bar would report a leave and an enter at + * every join, and whatever the caller is driving would blink on each one. + */ + onSegmentEnter?: (index: number) => void + onSegmentLeave?: () => void + /* ---- chrome ---- */ colors?: string[] theme?: ThemeName @@ -67,6 +80,8 @@ export function OptionBar({ showCounts = true, valueFormat = String, showLabels = false, + onSegmentEnter, + onSegmentLeave, colors: colorsProp, theme = 'light', fontFamily, @@ -101,17 +116,42 @@ export function OptionBar({ const labelRows = labelLines.reduce((n, lines) => Math.max(n, lines.length), 0) const labelBlock = labelRows > 0 ? 6 + labelRows * 13 : 0 + /* A viewBox, so the band fits whatever it is put in rather than whatever it + was drawn at. + * + * `responsive` measures its container with a ResizeObserver, which does not + * run on a server and does not run at all where scripting is off. Until it + * has (or for good, without JS), the bar is laid out at whatever `width` the + * caller asked for — and drawn at a fixed pixel width it simply overflowed a + * narrower parent and was clipped by whatever had the hidden overflow. + * + * Scaled into the box instead, the fallback width stops being a promise + * about the reader's screen and becomes what it should be: a unit system. + * `none` for the aspect ratio, so the band keeps its stated height while the + * width takes up the slack — a bar that shortened itself on a phone to hold + * its ratio would be a different chart. Once the observer reports, the + * viewBox and the box agree exactly and the scale factor is 1, so the + * segments and any type in them are pixel-true from then on. */ + const boxHeight = height + labelBlock + return (
{segments.map(({ i, x: sx, w, count, color }) => ( - + onSegmentEnter(i))} + style={onSegmentEnter ? { cursor: 'pointer' } : undefined} + > {/* A 1px bite out of each segment keeps the joins visible without a stroke. */} {showCounts && w > 26 && ( diff --git a/src/components/elections/QuestionRollCall.tsx b/src/components/elections/QuestionRollCall.tsx index 56afa46..021a2bf 100644 --- a/src/components/elections/QuestionRollCall.tsx +++ b/src/components/elections/QuestionRollCall.tsx @@ -330,7 +330,15 @@ function AnswerPanel({ * sentence is not, and running the two along one line makes the plate read as * the first few words of the sentence. A candidate who took the trouble to * explain has left the most useful thing on the page, so it prints in the - * open: a note behind a disclosure is a note nobody reads. */ + * open: a note behind a disclosure is a note nobody reads. + * + * Those written lines are ruled off from each other. Stacked, a plate, a + * paragraph, a plate and a paragraph run together into one column of prose + * with names in it, and the reader has to work out where one candidate stops + * and the next starts from the shape of the text. A hairline above each one + * after the first says it instead — faint enough to stay out of the way of + * the tinted panel it sits in, present enough that the block reads as a list + * of people rather than a passage. */ function Plates({ candidates, seats, @@ -346,11 +354,20 @@ function Plates({ }) { return (
))}