From 4da293898b55af2ca4797d5aa852067882c0ae23 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Tue, 4 Aug 2026 11:39:19 -0400 Subject: [PATCH 1/4] change copy --- src/app/state-of-the-nation/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/state-of-the-nation/page.tsx b/src/app/state-of-the-nation/page.tsx index eb2cb6d..4ffe2fc 100644 --- a/src/app/state-of-the-nation/page.tsx +++ b/src/app/state-of-the-nation/page.tsx @@ -120,7 +120,7 @@ export default async function StateOfTheNationPage() { State of the Nation

- We are not yet the most prosperous country on earth. But we could be. + Canada should be the most prosperous country on earth. Here's the current state of play.

From a92cfdf8f63bffe61e21494c9d404e5ce0bbd71e Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Tue, 4 Aug 2026 12:57:55 -0400 Subject: [PATCH 2/4] swap over charts --- src/app/state-of-the-nation/StateChart.tsx | 350 ++---------------- .../state-of-the-nation/StateChartPlot.tsx | 195 ++++++++++ .../canvas/CanvasClient.tsx | 22 +- .../canvas/OverlayChart.tsx | 128 +++---- .../canvas/OverlayIndexedChart.tsx | 159 ++++++++ src/app/state-of-the-nation/canvas/page.tsx | 1 + src/app/state-of-the-nation/page.tsx | 1 + src/app/state-of-the-nation/useChartSize.ts | 32 +- 8 files changed, 467 insertions(+), 421 deletions(-) create mode 100644 src/app/state-of-the-nation/StateChartPlot.tsx create mode 100644 src/app/state-of-the-nation/canvas/OverlayIndexedChart.tsx diff --git a/src/app/state-of-the-nation/StateChart.tsx b/src/app/state-of-the-nation/StateChart.tsx index 7a2d0e3..8481d03 100644 --- a/src/app/state-of-the-nation/StateChart.tsx +++ b/src/app/state-of-the-nation/StateChart.tsx @@ -1,8 +1,10 @@ -// SVG charts for the State of the Nation page — a direct translation of the -// claude.ai/design "State of the Nation.dc.html" line and bar charts. -// Server-rendered, no client JS. The warm greys (#E3D9CE grid, #6f6a63 -// labels) are the design's own values and have no site token; ink and -// auburn map to the site palette. +// Chart cards for the State of the Nation page. The plot itself renders on +// @buildcanada/charts (see StateChartPlot), matching the section pages and the +// dashboard; this component keeps the design's card furniture — the plain +// language title, the unit subtext, and the mono legend — as server-rendered +// HTML around it. + +import StateChartPlot from "./StateChartPlot"; export type ChartFmt = | "money" @@ -13,10 +15,15 @@ export type ChartFmt = | "count" | "num"; -// au = brand accent for the headline series; ink = comparison (usually -// dashed); clay/stone/sand = warm muted tones for additional series. +// au = brand accent for the headline series; ink = comparison; clay/stone/sand +// are warm muted tones for additional series. export type SeriesColor = "au" | "ink" | "clay" | "stone" | "sand"; +// `dash` is retained because the specs still declare it, but Grapher reserves +// dashed strokes for projected data (setting isProjection also filters the +// line legend and adds a "Projected data" tooltip notice), so comparison +// series now read by colour alone. Every dashed series is `ink` against an +// `au` headline, so nothing becomes ambiguous. export type LegendItem = { label: string; color: SeriesColor; dash?: boolean }; export type LineSpec = { @@ -28,13 +35,13 @@ export type LineSpec = { // graph; series that begin later simply start partway in. xDomain: [number, number]; // Tick labels for the left edge, middle, and right edge of the domain. + // Grapher derives its own x ticks from xDomain, so these are unused by the + // renderer and kept only so the specs still typecheck. xLabels: [string, string, string]; - // Optional explicit x-axis tick years (e.g. decade marks). When present they - // replace the start/mid/end labels and sit at their true date on the axis. xTicks?: number[]; - // When set, the y axis frames the data around this reference value instead - // of anchoring at zero, and a floating horizontal rule is drawn at it. Used - // by indexed series (e.g. 100 = base year) where zero carries no meaning. + // When set, the y axis frames the data around this reference value and a + // comparison line is drawn at it. Used by indexed series (e.g. 100 = base + // year) where zero carries no meaning. baseline?: number; legend?: LegendItem[]; // xs holds each point's fractional year, parallel to points. @@ -46,301 +53,19 @@ export type LineSpec = { }[]; }; -export type BarSpec = { - kind: "bar"; - unit: string; - fmt: ChartFmt; - bars: { label: string; value: number; accent?: boolean }[]; -}; - -export type ChartSpec = LineSpec | BarSpec; +export type ChartSpec = LineSpec; -const INK = "var(--color-dark)"; -const AU = "var(--color-auburn-800)"; -const GRID = "#E3D9CE"; +// Warm grey for secondary mono text — the design's own value, no site token. const GRAY = "#6f6a63"; -const MONO = "var(--font-label)"; const SERIES_COLORS: Record = { - au: AU, - ink: INK, + au: "var(--color-auburn-800)", + ink: "var(--color-dark)", clay: "#c2724d", stone: "#8a8178", sand: "#c7bdb2", }; -function fmt(v: number, f: ChartFmt): string { - if (f === "money") return "$" + Math.round(v).toLocaleString("en-CA"); - if (f === "pct") return Math.round(v) + "%"; - if (f === "pct1") return v.toFixed(1) + "%"; - if (f === "x") return v.toFixed(1) + "×"; - if (f === "index") return String(Math.round(v)); - if (f === "num") - return v.toLocaleString("en-CA", { maximumFractionDigits: 1 }); - return Math.round(v).toLocaleString("en-CA"); -} - -// A round-number step (1, 2, 2.5, 5, ×10ⁿ) closest to the raw interval, so -// axis labels land on clean values. -function niceStep(raw: number): number { - const mag = Math.pow(10, Math.floor(Math.log10(raw))); - const norm = raw / mag; - const nice = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10; - return nice * mag; -} - -// Builds the tick array for a snapped [mn, mx] range. The 0.001·step slack -// absorbs floating-point drift so the top tick isn't dropped or doubled. -function buildTicks(mn: number, mx: number, step: number): number[] { - const ticks: number[] = []; - for (let v = mn; v <= mx + step * 0.001; v += step) { - ticks.push(Math.abs(v) < step * 1e-9 ? 0 : v); - } - return ticks; -} - -// Chooses the y-axis range and round tick values. -// Frames the data itself — the axis fits [dataMin, dataMax] (plus the -// `baseline`, when given, e.g. an index's 100) with a little padding on each -// side, rather than forcing every chart to anchor at zero. Each series shows -// its own variation instead of flattening against a distant zero. -function niceAxis( - dataMin: number, - dataMax: number, - baseline?: number, -): { mn: number; mx: number; ticks: number[] } { - let lo = baseline !== undefined ? Math.min(dataMin, baseline) : dataMin; - let hi = baseline !== undefined ? Math.max(dataMax, baseline) : dataMax; - if (hi === lo) { - lo -= 1; - hi += 1; - } - const span = hi - lo; - lo -= span * 0.08; - hi += span * 0.08; - const step = niceStep((hi - lo) / 4); - const mn = Math.floor(lo / step) * step; - const mx = Math.ceil(hi / step) * step; - return { mn, mx, ticks: buildTicks(mn, mx, step) }; -} - -function LineChart({ spec, wide }: { spec: LineSpec; wide?: boolean }) { - // Full-width (wide) charts get a panoramic viewBox so the SVG renders at - // roughly 1:1 scale instead of stretching the half-column geometry — which - // blew the type and strokes up ~2× on desktop. - const W = wide ? 1240 : 600, - H = wide ? 420 : 320, - L = 8, - R = 62, - T = 30, - B = 46; - const iw = W - L - R, - ih = H - T - B; - const all = spec.series.flatMap((s) => s.points); - const { mn, mx, ticks } = niceAxis( - Math.min(...all), - Math.max(...all), - spec.baseline, - ); - const [d0, d1] = spec.xDomain; - const X = (x: number) => L + iw * ((x - d0) / (d1 - d0)); - const Y = (v: number) => T + ih * (1 - (v - mn) / (mx - mn)); - // The emphasized horizontal rule: the declared baseline (e.g. an index's - // 100) when present, otherwise the zero axis. Area fills close here. - const refVal = spec.baseline ?? 0; - const refOnAxis = refVal >= mn - 1e-9 && refVal <= mx + 1e-9; - // Drawn as a floating line only when it sits inside the range and isn't - // already one of the round ticks (which carry the ink weight themselves). - const refOnTick = ticks.some((t) => Math.abs(t - refVal) < 1e-9); - const showFloatingRef = - refOnAxis && !refOnTick && refVal > mn + 1e-9 && refVal < mx - 1e-9; - const baseY = Y(refOnAxis ? refVal : mn); - // Ticks sit at the domain edges and middle — identical across charts. - const tickX = [L, L + iw / 2, L + iw]; - - return ( - - {ticks.map((tv) => { - const gy = Y(tv); - // The reference line (baseline, or zero by default) carries the ink - // weight; every other tick is a light gridline. - const isRef = Math.abs(tv - refVal) < 1e-9; - return ( - - - - {fmt(tv, spec.fmt)} - - - ); - })} - {showFloatingRef && ( - - )} - {spec.xTicks - ? spec.xTicks.map((t) => { - const [d0, d1] = spec.xDomain; - // Keep a tick that lands on the domain edge from spilling past it. - const anchor = - t <= d0 ? "start" : t >= d1 ? "end" : "middle"; - return ( - - {t} - - ); - }) - : spec.xLabels.map((label, k) => ( - - {label} - - ))} - {spec.series.map((s, si) => { - const col = SERIES_COLORS[s.color]; - const end = s.points.length - 1; - const pts = s.points.map((v, i) => `${X(s.xs[i])},${Y(v)}`).join(" "); - // Fill only single-series trend charts. On multi-line comparisons the - // fill sits under whichever series happens to be first (often not the - // dominant line), which reads as arbitrary shading. - const area = - si === 0 && spec.series.length === 1 - ? `M${X(s.xs[0])},${Y(s.points[0])} ` + - s.points.map((v, i) => `L${X(s.xs[i])},${Y(v)}`).join(" ") + - ` L${X(s.xs[end])},${baseY} L${X(s.xs[0])},${baseY} Z` - : null; - return ( - - {area && } - - - - ); - })} - - ); -} - -function BarChart({ spec }: { spec: BarSpec }) { - const W = 600, - H = 320, - L = 8, - R = 8, - T = 30, - B = 52; - const iw = W - L - R, - ih = H - T - B; - const bars = spec.bars; - const mx = Math.max(...bars.map((b) => b.value)) * 1.12; - const baseY = T + ih; - const slot = iw / bars.length; - const bw = Math.min(slot * 0.56, 64); - - return ( - - {[0, 1, 2, 3, 4].map((g) => ( - - ))} - {bars.map((b, i) => { - const cx = L + slot * (i + 0.5); - const bh = (b.value / mx) * ih; - return ( - - - - {fmt(b.value, spec.fmt)} - - - {b.label.toUpperCase()} - - - ); - })} - - ); -} - export default function StateChart({ spec, title, @@ -348,6 +73,7 @@ export default function StateChart({ }: { spec: ChartSpec; title?: string; + // Full-width cards (the headline GDP chart) get a panoramic plot. wide?: boolean; }) { return ( @@ -365,18 +91,15 @@ export default function StateChart({ > {spec.unit} - {spec.kind === "line" && spec.legend && ( + {spec.legend && (
{spec.legend.map((lg) => (
@@ -390,24 +113,7 @@ export default function StateChart({ ))}
)} - {spec.kind === "line" ? ( - wide ? ( - // The panoramic viewBox only reads at full-column width; phones - // get the standard geometry. - <> -
- -
-
- -
- - ) : ( - - ) - ) : ( - - )} +
); } diff --git a/src/app/state-of-the-nation/StateChartPlot.tsx b/src/app/state-of-the-nation/StateChartPlot.tsx new file mode 100644 index 0000000..ce07b26 --- /dev/null +++ b/src/app/state-of-the-nation/StateChartPlot.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useMemo } from "react"; +import { + Bounds, + createTestDataset, + DimensionProperty, + GRAPHER_CHART_TYPES, + Grapher, + GrapherState, + legacyToChartsTableAndDimensionsWithMandatorySlug, +} from "@buildcanada/charts"; +import { daysSinceGrapherEpoch } from "./IndicatorChart"; +import { useChartSize } from "./useChartSize"; +import type { ChartFmt, LineSpec, SeriesColor } from "./StateChart"; + +// Renders a State of the Nation LineSpec on @buildcanada/charts, so the +// landing page draws the same Grapher as the section pages. The spec stays +// the interchange format — every derivation in state-of-the-nation.ts is +// untouched; only the renderer changed. + +// Grapher writes colours into SVG `stroke` attributes, where `var(--x)` +// doesn't resolve, so these are literals rather than the tokens the card +// furniture uses. `au` and `ink` mirror --color-auburn-800 and --color-dark +// as they resolve outside the .theme-toronto / .theme-election overrides, +// which State of the Nation never sits under; clay/stone/sand are the +// design's own values and have no token. Same approach as CANADA_COLOR in +// indicators.ts, which the section-page Graphers already use. +const SERIES_COLORS: Record = { + au: "#932f2f", + ink: "#272727", + clay: "#c2724d", + stone: "#8a8178", + sand: "#c7bdb2", +}; + +// The spec's x values are fractional years (2020.5 = July 2020). Grapher wants +// either integer years or integer days since its epoch, so a chart carrying +// any sub-annual point moves wholesale onto the day axis. +function fractionalYearToDays(x: number): number { + const year = Math.floor(x + 1e-9); + const month = Math.min(11, Math.max(0, Math.round((x - year) * 12))); + return daysSinceGrapherEpoch( + new Date(Date.UTC(year, month, 1)).toISOString(), + ); +} + +// The SVG renderer formatted tick labels itself; Grapher formats from the +// column's display config instead. +function displayForFmt(f: ChartFmt): { + unit: string; + shortUnit: string; + numDecimalPlaces: number; +} { + switch (f) { + case "money": + return { unit: "", shortUnit: "$", numDecimalPlaces: 0 }; + case "pct": + return { unit: "", shortUnit: "%", numDecimalPlaces: 0 }; + case "pct1": + return { unit: "", shortUnit: "%", numDecimalPlaces: 1 }; + case "x": + return { unit: "", shortUnit: "×", numDecimalPlaces: 1 }; + case "num": + return { unit: "", shortUnit: "", numDecimalPlaces: 1 }; + default: + return { unit: "", shortUnit: "", numDecimalPlaces: 0 }; + } +} + +// Grapher keys series by entity name, so two series sharing a legend label +// (or a spec with no legend at all) would collapse into one line. +function seriesLabels(spec: LineSpec): string[] { + const seen = new Map(); + return spec.series.map((_, i) => { + const base = spec.legend?.[i]?.label ?? `Series ${i + 1}`; + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + return count === 0 ? base : `${base} (${count + 1})`; + }); +} + +function buildGrapherState( + spec: LineSpec, + bounds: Bounds, +): GrapherState | null { + const labels = seriesLabels(spec); + const dated = spec.series.some((s) => + s.xs.some((x) => Math.abs(x - Math.round(x)) > 1e-6), + ); + const time = (x: number) => (dated ? fractionalYearToDays(x) : Math.round(x)); + + const data = spec.series.flatMap((s, idx) => { + const entity = { id: idx + 1, code: `S${idx + 1}`, name: labels[idx] }; + return s.points.map((value, i) => ({ + year: time(s.xs[i]), + entity, + value, + })); + }); + if (data.length === 0) return null; + + const variableId = 1; + const dimensions = [{ variableId, property: DimensionProperty.y }]; + + const metadata = { + id: variableId, + display: { + name: "", + ...displayForFmt(spec.fmt), + ...(dated ? { yearIsDay: true } : {}), + }, + // The page prints source attribution beneath each card already. + origins: [], + }; + + const entityColors = Object.fromEntries( + spec.series.map((s, idx) => [labels[idx], SERIES_COLORS[s.color]]), + ); + + const state = new GrapherState({ + bounds, + isEmbeddedInPage: true, + chartTypes: [GRAPHER_CHART_TYPES.LineChart], + selectedEntityNames: labels, + selectedEntityColors: entityColors, + dimensions, + }); + + state.entityType = "series"; + // The card renders the design's own mono legend above the plot, so Grapher's + // end-of-line labels would duplicate it. + state.hideLegend = true; + state.variant = "uncaptioned" as typeof state.variant; + + // Every chart on the page shares one x window, so a horizontal position + // means the same date on all of them — the property the hand-rolled SVG + // got from passing xDomain straight through. + state.xAxis.min = time(spec.xDomain[0]); + state.xAxis.max = time(spec.xDomain[1]); + + // Frame the data rather than anchoring at zero, matching niceAxis in the + // SVG version — otherwise a series that varies in a narrow band flattens + // against a distant zero. The enum isn't re-exported from the package root. + const auto = "auto" as unknown as number; + state.yAxis.min = auto; + state.yAxis.max = auto; + + // The declared baseline (e.g. an index's 100) drew as a floating rule in the + // SVG; Grapher's comparison lines are the same idea and dash by default. + if (spec.baseline !== undefined) { + state.comparisonLines = [{ yEquals: String(spec.baseline) }]; + } + + state.inputTable = legacyToChartsTableAndDimensionsWithMandatorySlug( + createTestDataset([{ data, metadata }]), + dimensions, + entityColors, + ); + + return state; +} + +// The headline chart spans both grid columns, so it gets a panoramic plot +// rather than the column-width default — roughly the 1240x420 geometry the +// hand-rolled SVG used for wide cards. +const WIDE_SIZE = { maxWidth: 1440, aspectRatio: 0.34, maxHeight: 500 }; + +export default function StateChartPlot({ + spec, + wide, +}: { + spec: LineSpec; + wide?: boolean; +}) { + const { containerRef, size } = useChartSize(wide ? WIDE_SIZE : undefined); + + const grapherState = useMemo( + () => buildGrapherState(spec, new Bounds(0, 0, size.width, size.height)), + [spec, size.width, size.height], + ); + + return ( + // Grapher's uncaptioned variant draws from a padded origin inside a + // viewBox starting at 0,0 — the bottom axis and right-edge labels clip + // unless the svg can overflow. +
+ {grapherState && ( +
+ +
+ )} +
+ ); +} diff --git a/src/app/state-of-the-nation/canvas/CanvasClient.tsx b/src/app/state-of-the-nation/canvas/CanvasClient.tsx index e8c7da0..d1947a9 100644 --- a/src/app/state-of-the-nation/canvas/CanvasClient.tsx +++ b/src/app/state-of-the-nation/canvas/CanvasClient.tsx @@ -7,11 +7,21 @@ import type { EconomySeriesResponse } from "@/lib/api/economy"; import { SECTIONS, MEASURE_SLUGS, indicatorHeading } from "../indicators"; import type { OverlaySeries, OverlayMode } from "./overlay-types"; +const CHART_LOADING = () => ( +
+); + +// Split by mode so the default (indexed) path never pulls chart.js: indexed +// renders on @buildcanada/charts, and only raw values — which need one y-axis +// per unit, something Grapher can't express — still loads chart.js. +const OverlayIndexedChart = dynamic(() => import("./OverlayIndexedChart"), { + ssr: false, + loading: CHART_LOADING, +}); + const OverlayChart = dynamic(() => import("./OverlayChart"), { ssr: false, - loading: () => ( -
- ), + loading: CHART_LOADING, }); // auburn-600, lake-600, pine-600 from the brand palette. @@ -189,7 +199,11 @@ export default function CanvasClient() { {/* Chart is the primary content — it comes first. */}
{loadedSeries.length > 0 ? ( - + mode === "indexed" ? ( + + ) : ( + + ) ) : (

diff --git a/src/app/state-of-the-nation/canvas/OverlayChart.tsx b/src/app/state-of-the-nation/canvas/OverlayChart.tsx index 4c1a249..616df88 100644 --- a/src/app/state-of-the-nation/canvas/OverlayChart.tsx +++ b/src/app/state-of-the-nation/canvas/OverlayChart.tsx @@ -11,7 +11,14 @@ import { Legend, } from "chart.js"; import { axisLabel, formatValue } from "../units"; -import type { OverlaySeries, OverlayMode } from "./overlay-types"; +import type { OverlaySeries } from "./overlay-types"; + +// Raw-value overlay: one y-axis per feed, because feeds carry different units +// (a rate against a dollar figure against a count). This is the one canvas +// mode @buildcanada/charts can't render — Grapher has a single yAxis, and its +// faceting alternative splits the feeds into separate panels, which defeats +// the point of overlaying them. Indexed mode is on Grapher; see +// OverlayIndexedChart.tsx. ChartJS.register( LinearScale, @@ -32,25 +39,7 @@ function formatMonth(isoDate: string): string { return MONTH_FORMAT.format(new Date(isoDate)); } -// The first year present in every series, so indexed mode rebases all series -// to a shared point. Falls back to each series' own first year when the -// series never overlap. -function firstSharedYear(series: OverlaySeries[]): number | null { - if (series.length === 0) return null; - const shared = series - .map((s) => new Set(s.points.map((p) => p.year))) - .reduce((acc, years) => new Set([...acc].filter((y) => years.has(y)))); - if (shared.size === 0) return null; - return Math.min(...shared); -} - -export default function OverlayChart({ - series, - mode, -}: { - series: OverlaySeries[]; - mode: OverlayMode; -}) { +export default function OverlayChart({ series }: { series: OverlaySeries[] }) { const canvasRef = useRef(null); const chartRef = useRef(null); @@ -58,53 +47,38 @@ export default function OverlayChart({ const canvas = canvasRef.current; if (!canvas) return; - const baseYear = mode === "indexed" ? firstSharedYear(series) : null; - - const datasets = series.map((s, i) => { - const base = - baseYear !== null - ? (s.points.find((p) => p.year === baseYear)?.value ?? - s.points[0]?.value) - : s.points[0]?.value; - - return { - label: s.label, - data: s.points.map((p) => ({ - x: p.year, - y: - mode === "indexed" && base - ? (p.value / base) * 100 - : p.value, - raw: p.value, - date: p.date, - })), - borderColor: s.color, - backgroundColor: s.color, - pointRadius: 0, - pointHoverRadius: 4, - borderWidth: 2, - tension: 0.15, - yAxisID: mode === "raw" ? `y${i}` : "y", - }; - }); + const datasets = series.map((s, i) => ({ + label: s.label, + data: s.points.map((p) => ({ + x: p.year, + y: p.value, + raw: p.value, + date: p.date, + })), + borderColor: s.color, + backgroundColor: s.color, + pointRadius: 0, + pointHoverRadius: 4, + borderWidth: 2, + tension: 0.15, + yAxisID: `y${i}`, + })); const rawAxes = Object.fromEntries( - mode === "raw" - ? series.map((s, i) => [ - `y${i}`, - { - type: "linear" as const, - position: i === 0 ? ("left" as const) : ("right" as const), - title: { - display: true, - text: axisLabel(s.unitSymbol), - color: s.color, - }, - ticks: { color: s.color }, - grid: { drawOnChartArea: i === 0 }, - }, - ]) - : [], + series.map((s, i) => [ + `y${i}`, + { + type: "linear" as const, + position: i === 0 ? ("left" as const) : ("right" as const), + title: { + display: true, + text: axisLabel(s.unitSymbol), + color: s.color, + }, + ticks: { color: s.color }, + grid: { drawOnChartArea: i === 0 }, + }, + ]), ); chartRef.current?.destroy(); @@ -121,20 +95,7 @@ export default function OverlayChart({ ticks: { callback: (v) => String(v), precision: 0 }, grid: { display: false }, }, - ...(mode === "indexed" - ? { - y: { - type: "linear" as const, - title: { - display: true, - text: - baseYear !== null - ? `Index (${baseYear} = 100)` - : "Index (first year = 100)", - }, - }, - } - : rawAxes), + ...rawAxes, }, plugins: { legend: { position: "bottom", labels: { boxWidth: 12 } }, @@ -149,12 +110,7 @@ export default function OverlayChart({ label: (item) => { const s = series[item.datasetIndex]; const raw = (item.raw as { raw: number }).raw; - const formatted = formatValue(raw, s.unitSymbol); - if (mode === "indexed") { - const indexed = item.parsed.y ?? 0; - return `${s.label}: ${indexed.toFixed(1)} (${formatted})`; - } - return `${s.label}: ${formatted}`; + return `${s.label}: ${formatValue(raw, s.unitSymbol)}`; }, }, }, @@ -166,7 +122,7 @@ export default function OverlayChart({ chartRef.current?.destroy(); chartRef.current = null; }; - }, [series, mode]); + }, [series]); return (

diff --git a/src/app/state-of-the-nation/canvas/OverlayIndexedChart.tsx b/src/app/state-of-the-nation/canvas/OverlayIndexedChart.tsx new file mode 100644 index 0000000..5c4e766 --- /dev/null +++ b/src/app/state-of-the-nation/canvas/OverlayIndexedChart.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useMemo } from "react"; +import { + Bounds, + createTestDataset, + DimensionProperty, + GRAPHER_CHART_TYPES, + Grapher, + GrapherState, + legacyToChartsTableAndDimensionsWithMandatorySlug, +} from "@buildcanada/charts"; +import { daysSinceGrapherEpoch } from "../IndicatorChart"; +import { useChartSize } from "../useChartSize"; +import type { OverlaySeries } from "./overlay-types"; + +// Indexed overlay — every feed rebased to 100 at a shared point, so series in +// different units share one y-axis and Grapher can render them together. +// Raw mode can't come here: it needs one axis per unit, and Grapher has a +// single yAxis by design (see OverlayChart.tsx). + +// The first year present in every series, so indexed mode rebases all series +// to a shared point. Falls back to each series' own first year when the +// series never overlap. +function firstSharedYear(series: OverlaySeries[]): number | null { + if (series.length === 0) return null; + const shared = series + .map((s) => new Set(s.points.map((p) => p.year))) + .reduce((acc, years) => new Set([...acc].filter((y) => years.has(y)))); + if (shared.size === 0) return null; + return Math.min(...shared); +} + +// Grapher keys series by entity name, so two feeds resolving to the same label +// (the same indicator and jurisdiction picked twice) would collapse into one. +function uniqueLabels(series: OverlaySeries[]): string[] { + const seen = new Map(); + return series.map((s) => { + const count = seen.get(s.label) ?? 0; + seen.set(s.label, count + 1); + return count === 0 ? s.label : `${s.label} (${count + 1})`; + }); +} + +function buildGrapherState( + series: OverlaySeries[], + bounds: Bounds, +): { state: GrapherState; baseYear: number | null } | null { + const baseYear = firstSharedYear(series); + const labels = uniqueLabels(series); + + // Feeds can mix frequencies (an annual measure against a monthly one). A + // Grapher column is either day-based or year-based for every entity in it, + // so as soon as one feed carries ISO dates the whole chart moves onto the + // day axis, with annual points pinned to January 1. + const dated = series.some((s) => s.points.some((p) => p.date)); + const time = (p: { year: number; date?: string }) => + dated + ? daysSinceGrapherEpoch(p.date ?? `${Math.round(p.year)}-01-01`) + : p.year; + + const data = series.flatMap((s, idx) => { + const base = + (baseYear !== null + ? s.points.find((p) => p.year === baseYear)?.value + : undefined) ?? s.points[0]?.value; + // A zero or missing base can't be rebased; drop the feed rather than + // emitting Infinity across the axis. + if (!base) return []; + const entity = { id: idx + 1, code: `F${idx + 1}`, name: labels[idx] }; + return s.points.map((p) => ({ + year: time(p), + entity, + value: (p.value / base) * 100, + })); + }); + if (data.length === 0) return null; + + const variableId = 1; + const dimensions = [{ variableId, property: DimensionProperty.y }]; + + const metadata = { + id: variableId, + display: { + name: + baseYear !== null + ? `Index (${baseYear} = 100)` + : "Index (first year = 100)", + unit: "", + shortUnit: "", + numDecimalPlaces: 1, + ...(dated ? { yearIsDay: true } : {}), + }, + // Each feed carries its own source; the canvas credits them in the + // controls beneath the chart rather than in a single chart footer. + origins: [], + }; + + const entityColors = Object.fromEntries( + series.map((s, idx) => [labels[idx], s.color]), + ); + + const state = new GrapherState({ + bounds, + isEmbeddedInPage: true, + chartTypes: [GRAPHER_CHART_TYPES.LineChart], + selectedEntityNames: labels, + selectedEntityColors: entityColors, + dimensions, + }); + + state.entityType = "series"; + // Unlike the indicator charts there is no headline series here — every feed + // is equally the subject, so none are focused and all render at full colour. + state.variant = "uncaptioned" as typeof state.variant; + + state.inputTable = legacyToChartsTableAndDimensionsWithMandatorySlug( + createTestDataset([{ data, metadata }]), + dimensions, + entityColors, + ); + + return { state, baseYear }; +} + +export default function OverlayIndexedChart({ + series, +}: { + series: OverlaySeries[]; +}) { + const { containerRef, size } = useChartSize(); + + const built = useMemo( + () => buildGrapherState(series, new Bounds(0, 0, size.width, size.height)), + [series, size.width, size.height], + ); + + return ( + // Grapher's uncaptioned variant draws from a padded origin inside a + // viewBox starting at 0,0 — the bottom axis and right-edge labels clip + // unless the svg can overflow. +
+ {built ? ( +
+ +
+ ) : ( +
+

+ These feeds can’t be indexed — try raw values. +

+
+ )} +
+ ); +} diff --git a/src/app/state-of-the-nation/canvas/page.tsx b/src/app/state-of-the-nation/canvas/page.tsx index 71ec7a4..ec64620 100644 --- a/src/app/state-of-the-nation/canvas/page.tsx +++ b/src/app/state-of-the-nation/canvas/page.tsx @@ -1,3 +1,4 @@ +import "@buildcanada/charts/styles.css"; import type { Metadata } from "next"; import { Suspense } from "react"; import Link from "next/link"; diff --git a/src/app/state-of-the-nation/page.tsx b/src/app/state-of-the-nation/page.tsx index 4ffe2fc..a6aeed8 100644 --- a/src/app/state-of-the-nation/page.tsx +++ b/src/app/state-of-the-nation/page.tsx @@ -1,3 +1,4 @@ +import "@buildcanada/charts/styles.css"; import type { Metadata } from "next"; import { getSiteConfig } from "@/lib/api"; import { getEconomicSeries } from "@/lib/api/economy"; diff --git a/src/app/state-of-the-nation/useChartSize.ts b/src/app/state-of-the-nation/useChartSize.ts index dfc568f..bf9ff68 100644 --- a/src/app/state-of-the-nation/useChartSize.ts +++ b/src/app/state-of-the-nation/useChartSize.ts @@ -2,10 +2,24 @@ import { useEffect, useRef, useState } from "react"; -// Tracks the chart container's rendered width and derives clamped ~2:1 -// Grapher bounds from it. Shared by the single-indicator and combined -// section charts. -export function useChartSize() { +// Tracks the chart container's rendered width and derives clamped Grapher +// bounds from it. Shared by the single-indicator and combined section charts +// (which take the ~2:1 defaults) and the State of the Nation cards, whose +// full-width headline chart passes a panoramic override. +export type ChartSizeOptions = { + // Upper bound on plot width. Defaults to 1040 — comfortable for a chart + // sitting in a text column, too narrow for a chart spanning the page. + maxWidth?: number; + // Plot height as a fraction of width, above the phone breakpoint. + aspectRatio?: number; + maxHeight?: number; +}; + +export function useChartSize({ + maxWidth = 1040, + aspectRatio = 0.5, + maxHeight = 460, +}: ChartSizeOptions = {}) { const containerRef = useRef(null); const [size, setSize] = useState({ width: 800, height: 480 }); @@ -15,21 +29,21 @@ export function useChartSize() { const measure = () => { // Never exceed the container: a floor above the phone-width inner size // (~264px on a 320px screen) would overflow and clip the right edge. - const w = Math.min(1040, Math.max(240, el.clientWidth - 16)); + const w = Math.min(maxWidth, Math.max(240, el.clientWidth - 16)); // Portrait-ish on phones — Grapher's line legend eats up to a third of - // the width there, so extra height keeps the plot area readable. Wide - // 2:1 on larger screens. + // the width there, so extra height keeps the plot area readable. The + // caller's aspect ratio applies on larger screens. const h = w < 480 ? Math.max(320, Math.min(400, Math.round(w * 1.05))) - : Math.max(320, Math.min(460, Math.round(w * 0.5))); + : Math.max(320, Math.min(maxHeight, Math.round(w * aspectRatio))); setSize({ width: w, height: h }); }; measure(); const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); - }, []); + }, [maxWidth, aspectRatio, maxHeight]); return { containerRef, size }; } From e48c9a28a9493db537d2ebf35bd30daaf08f7ede Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Tue, 4 Aug 2026 13:23:26 -0400 Subject: [PATCH 3/4] UI tweaks --- src/app/state-of-the-nation/StateChart.tsx | 14 ++-- src/app/state-of-the-nation/page.tsx | 65 +++++++++---------- .../state-of-the-nation.ts | 1 - 3 files changed, 34 insertions(+), 46 deletions(-) diff --git a/src/app/state-of-the-nation/StateChart.tsx b/src/app/state-of-the-nation/StateChart.tsx index 8481d03..0e71e27 100644 --- a/src/app/state-of-the-nation/StateChart.tsx +++ b/src/app/state-of-the-nation/StateChart.tsx @@ -55,9 +55,6 @@ export type LineSpec = { export type ChartSpec = LineSpec; -// Warm grey for secondary mono text — the design's own value, no site token. -const GRAY = "#6f6a63"; - const SERIES_COLORS: Record = { au: "var(--color-auburn-800)", ink: "var(--color-dark)", @@ -81,14 +78,11 @@ export default function StateChart({ {/* The plain-language title leads, large and bold; the descriptive unit line sits under it as smaller subtext. */} {title && ( -
+
{title}
)} -
+
{spec.unit}
{spec.legend && ( @@ -104,8 +98,8 @@ export default function StateChart({ }} /> {lg.label} diff --git a/src/app/state-of-the-nation/page.tsx b/src/app/state-of-the-nation/page.tsx index a6aeed8..dfca73f 100644 --- a/src/app/state-of-the-nation/page.tsx +++ b/src/app/state-of-the-nation/page.tsx @@ -34,10 +34,10 @@ export const metadata: Metadata = { const GRAY = "#6f6a63"; const BD = "#CDC4BD"; -// A chart card: meta row, chart title + chart, and the source attribution -// the data licences require. Cards tile 2×2 on desktop within their section, -// ruled by the design's warm hairlines; `wide` cards span both columns -// (the headline chart). +// A chart card: chart title + chart, and the source attribution the data +// licences require. Each card is its own bordered panel so the charts read as +// distinct objects rather than cells in a ruled grid; they tile 2-up on +// desktop within their section. function IndicatorCard({ indicator, wide, @@ -47,9 +47,7 @@ function IndicatorCard({ }) { return (
@@ -117,7 +115,7 @@ export default async function StateOfTheNationPage() {
State of the Nation · 2026
-

+

State of the Nation

@@ -126,34 +124,31 @@ export default async function StateOfTheNationPage() {

- {sections.map((section) => { - // Wide cards render full-width above the 2×2 grid, outside it so - // the grid's odd/even column rules stay aligned. - const wideIndicators = section.indicators.filter((i) => i.wide); - const gridIndicators = section.indicators.filter((i) => !i.wide); - return ( -
-
-

- {section.title} -

-
- {wideIndicators.map((indicator) => ( - + {sections.map((section) => ( +
+
+

+ {section.title} +

+
+ {/* Cards carry their own borders now, so the page padding and the + spacing between panels live on the grid rather than on each + card. `wide` indicators still span both columns if any are + ever reinstated. */} +
+ {section.indicators.map((indicator) => ( + ))} - {gridIndicators.length > 0 && ( -
- {gridIndicators.map((indicator) => ( - - ))} -
- )} -
- ); - })} +
+
+ ))}
diff --git a/src/app/state-of-the-nation/state-of-the-nation.ts b/src/app/state-of-the-nation/state-of-the-nation.ts index 89abb5d..32c0b24 100644 --- a/src/app/state-of-the-nation/state-of-the-nation.ts +++ b/src/app/state-of-the-nation/state-of-the-nation.ts @@ -290,7 +290,6 @@ const INDICATORS: SotnIndicator[] = [ n: "01", title: "GDP per capita", verdict: "lag", - wide: true, headline: "Living standards have gone sideways since 2022.", body: "Real output per person, in chained 2017 dollars — the clearest single measure of whether living standards are rising. StatCan's quarterly series runs within about two months of the present, and it shows a country producing no more per person than it did four years ago.", build: (get) => { From 356c62aea3e24f184127f66ee09ffd26f1fa5045 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Tue, 4 Aug 2026 13:38:32 -0400 Subject: [PATCH 4/4] fix escaped characters --- src/app/state-of-the-nation/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/state-of-the-nation/page.tsx b/src/app/state-of-the-nation/page.tsx index dfca73f..965d68e 100644 --- a/src/app/state-of-the-nation/page.tsx +++ b/src/app/state-of-the-nation/page.tsx @@ -119,7 +119,8 @@ export default async function StateOfTheNationPage() { State of the Nation

- Canada should be the most prosperous country on earth. Here's the current state of play. + Canada should be the most prosperous country on earth. Here’s + the current state of play.