diff --git a/src/app/api/economy/series/route.ts b/src/app/api/economy/series/route.ts new file mode 100644 index 0000000..37d971f --- /dev/null +++ b/src/app/api/economy/series/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getEconomicSeries } from "@/lib/api/economy"; +import { MEASURE_SLUGS } from "@/app/economic-indicators/indicators"; + +// Same-origin proxy for york_factory's public series endpoint so client +// components (the canvas page) can fetch without cross-origin config. The +// upstream fetch is server-cached for an hour by getEconomicSeries. +export async function GET(request: NextRequest) { + const measure = request.nextUrl.searchParams.get("measure"); + + if (!measure || !MEASURE_SLUGS.has(measure)) { + return NextResponse.json({ error: "Unknown measure" }, { status: 400 }); + } + + try { + const response = await getEconomicSeries(measure); + return NextResponse.json(response, { + headers: { "Cache-Control": "public, s-maxage=3600, max-age=600" }, + }); + } catch { + return NextResponse.json( + { error: "Upstream data unavailable" }, + { status: 502 }, + ); + } +} diff --git a/src/app/economic-indicators/CombinedSectionChart.tsx b/src/app/economic-indicators/CombinedSectionChart.tsx new file mode 100644 index 0000000..fb8ef91 --- /dev/null +++ b/src/app/economic-indicators/CombinedSectionChart.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useMemo } from "react"; +import { + Bounds, + createTestDataset, + DimensionProperty, + GRAPHER_CHART_TYPES, + Grapher, + GrapherState, + legacyToChartsTableAndDimensionsWithMandatorySlug, +} from "@buildcanada/charts"; +import { + humanizeSourceName, + type EconomySeriesResponse, +} from "@/lib/api/economy"; +import { + BENCHMARK_COLOR, + CANADA_COLOR, + type IndicatorBenchmark, +} from "./indicators"; +import { benchmarkValue, daysSinceGrapherEpoch } from "./IndicatorChart"; +import { displayUnit } from "./units"; +import { useChartSize } from "./useChartSize"; + +// Overlays one section's indicators as lines on a single chart — one entity +// per measure instead of one per jurisdiction. Assumes every item shares the +// same unit and frequency (e.g. the Cost of Living CPI components), and that +// each measure is Canada-only or that its Canada series is the one to show. +export type CombinedChartItem = { + label: string; + response: EconomySeriesResponse; +}; + +function buildGrapherState( + heading: string, + items: CombinedChartItem[], + bounds: Bounds, + benchmark?: IndicatorBenchmark, +): GrapherState | null { + const first = items[0]?.response; + if (!first) return null; + const monthly = first.data.measure.frequency === "monthly"; + const { source } = first.meta; + + const itemSeries = items.map( + (item) => + item.response.data.series.find((s) => s.jurisdiction.slug === "ca") ?? + item.response.data.series[0], + ); + + const data = items.flatMap((item, idx) => { + const series = itemSeries[idx]; + if (!series) return []; + return series.points.map((p) => ({ + year: monthly && p.date ? daysSinceGrapherEpoch(p.date) : p.year, + entity: { id: idx + 1, code: item.label, name: item.label }, + value: p.value, + })); + }); + if (data.length === 0) return null; + + if (benchmark) { + // One benchmark point per time in the longest series, so the reference + // line spans exactly the observed range. + const longest = itemSeries + .filter((s) => s !== undefined) + .reduce((a, b) => (b.points.length > a.points.length ? b : a)); + const entity = { + id: items.length + 1, + code: "TARGET", + name: benchmark.label, + }; + data.push( + ...longest.points.map((p) => ({ + year: monthly && p.date ? daysSinceGrapherEpoch(p.date) : p.year, + entity, + value: benchmarkValue(benchmark, p.year), + })), + ); + } + + const variableId = 1; + const dimensions = [{ variableId, property: DimensionProperty.y }]; + + const metadata = { + id: variableId, + display: { + name: heading, + ...displayUnit(first.data.measure.unit), + ...(monthly ? { yearIsDay: true } : {}), + }, + origins: source + ? [ + { + id: 1, + title: humanizeSourceName(source.name), + urlMain: source.url ?? undefined, + datePublished: source.last_fetched_at ?? undefined, + }, + ] + : [], + }; + + // Emphasize the first item (the section's headline series) in brand red; + // the component lines render in Grapher's palette, muted until hovered. + const headlineLabel = items[0].label; + const entityColors: Record = { + [headlineLabel]: CANADA_COLOR, + ...(benchmark ? { [benchmark.label]: BENCHMARK_COLOR } : {}), + }; + const selectedEntityNames = [ + ...items.map((item) => item.label), + ...(benchmark ? [benchmark.label] : []), + ]; + + const grapherState = new GrapherState({ + bounds, + isEmbeddedInPage: true, + chartTypes: [GRAPHER_CHART_TYPES.LineChart], + selectedEntityNames, + selectedEntityColors: entityColors, + dimensions, + }); + + grapherState.entityType = "series"; + grapherState.focusedSeriesNames = [headlineLabel]; + grapherState.focusArray.clearAllAndAdd(headlineLabel); + grapherState.variant = "uncaptioned" as typeof grapherState.variant; + + grapherState.inputTable = legacyToChartsTableAndDimensionsWithMandatorySlug( + createTestDataset([{ data, metadata }]), + dimensions, + entityColors, + ); + + return grapherState; +} + +export default function CombinedSectionChart({ + heading, + items, + benchmark, +}: { + heading: string; + items: CombinedChartItem[]; + benchmark?: IndicatorBenchmark; +}) { + const { containerRef, size } = useChartSize(); + + const grapherState = useMemo( + () => + buildGrapherState( + heading, + items, + new Bounds(0, 0, size.width, size.height), + benchmark, + ), + [heading, items, size.width, size.height, benchmark], + ); + + return ( +
+ {grapherState ? ( +
+ +
+ ) : ( +
+

+ No data available for this chart. +

+
+ )} +
+ ); +} diff --git a/src/app/economic-indicators/CombinedSectionChartClient.tsx b/src/app/economic-indicators/CombinedSectionChartClient.tsx new file mode 100644 index 0000000..5935577 --- /dev/null +++ b/src/app/economic-indicators/CombinedSectionChartClient.tsx @@ -0,0 +1,28 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { CombinedChartItem } from "./CombinedSectionChart"; +import type { IndicatorBenchmark } from "./indicators"; + +const CombinedSectionChart = dynamic(() => import("./CombinedSectionChart"), { + ssr: false, + loading: () => ( + // Mirrors the useChartSize clamp so deep-link hash scrolls stay accurate + // when the placeholder swaps for the real chart. +
+ ), +}); + +export default function CombinedSectionChartClient({ + heading, + items, + benchmark, +}: { + heading: string; + items: CombinedChartItem[]; + benchmark?: IndicatorBenchmark; +}) { + return ( + + ); +} diff --git a/src/app/economic-indicators/IndicatorChart.tsx b/src/app/economic-indicators/IndicatorChart.tsx new file mode 100644 index 0000000..d3f1027 --- /dev/null +++ b/src/app/economic-indicators/IndicatorChart.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useMemo } from "react"; +import { + Bounds, + createTestDataset, + DimensionProperty, + GRAPHER_CHART_TYPES, + Grapher, + GrapherState, + legacyToChartsTableAndDimensionsWithMandatorySlug, +} from "@buildcanada/charts"; +import { + humanizeSourceName, + type EconomySeriesResponse, +} from "@/lib/api/economy"; +import { + BENCHMARK_COLOR, + CANADA_COLOR, + type IndicatorBenchmark, +} from "./indicators"; +import { displayUnit } from "./units"; +import { useChartSize } from "./useChartSize"; + +const ENTITY_COLORS = { Canada: CANADA_COLOR }; + +// Grapher represents sub-annual time as integer days since its epoch date +// (EPOCH_DATE in @buildcanada/charts) on a column flagged yearIsDay — the +// OWID convention for daily/monthly series. +const GRAPHER_EPOCH_MS = Date.UTC(2020, 0, 21); +const DAY_MS = 86_400_000; + +export function daysSinceGrapherEpoch(isoDate: string): number { + return Math.round((Date.parse(isoDate) - GRAPHER_EPOCH_MS) / DAY_MS); +} + +// Benchmark values compound through the anchor point; `year` is fractional +// for monthly points, so the line stays smooth between Januaries. +export function benchmarkValue( + benchmark: IndicatorBenchmark, + year: number, +): number { + return ( + benchmark.anchorValue * + Math.pow(1 + benchmark.annualRatePct / 100, year - benchmark.anchorYear) + ); +} + +function buildGrapherState( + response: EconomySeriesResponse, + bounds: Bounds, + benchmark?: IndicatorBenchmark, +): GrapherState | null { + const { measure, series } = response.data; + const { source } = response.meta; + const monthly = measure.frequency === "monthly"; + + const data = series.flatMap((s, idx) => + s.points.map((p) => ({ + year: monthly && p.date ? daysSinceGrapherEpoch(p.date) : p.year, + entity: { id: idx + 1, code: s.jurisdiction.code, name: s.jurisdiction.name }, + value: p.value, + })), + ); + if (data.length === 0) return null; + + if (benchmark) { + // One benchmark point per time in the longest series, so the reference + // line spans exactly the observed range. + const longest = series.reduce((a, b) => + b.points.length > a.points.length ? b : a, + ); + const entity = { + id: series.length + 1, + code: "TARGET", + name: benchmark.label, + }; + data.push( + ...longest.points.map((p) => ({ + year: monthly && p.date ? daysSinceGrapherEpoch(p.date) : p.year, + entity, + value: benchmarkValue(benchmark, p.year), + })), + ); + } + + const variableId = 1; + const dimensions = [{ variableId, property: DimensionProperty.y }]; + + const metadata = { + id: variableId, + display: { + name: measure.name, + ...displayUnit(measure.unit), + ...(monthly ? { yearIsDay: true } : {}), + }, + origins: source + ? [ + { + id: 1, + title: humanizeSourceName(source.name), + urlMain: source.url ?? undefined, + datePublished: source.last_fetched_at ?? undefined, + }, + ] + : [], + }; + + const entityColors = benchmark + ? { ...ENTITY_COLORS, [benchmark.label]: BENCHMARK_COLOR } + : ENTITY_COLORS; + const selectedEntityNames = [ + ...series.map((s) => s.jurisdiction.name), + ...(benchmark ? [benchmark.label] : []), + ]; + + const grapherState = new GrapherState({ + bounds, + // Fill the available bounds exactly instead of scaling to Grapher's + // ideal 680x480 aspect ratio. + isEmbeddedInPage: true, + chartTypes: [GRAPHER_CHART_TYPES.LineChart], + selectedEntityNames, + selectedEntityColors: entityColors, + dimensions, + }); + + grapherState.entityType = "country"; + // Emphasize Canada; the other series render muted until hovered. + grapherState.focusedSeriesNames = ["Canada"]; + grapherState.focusArray.clearAllAndAdd("Canada"); + // Chart area only — no header (title/logo), tabs, entity selector, + // timeline, or footer (data source, download, full-screen). The enum + // isn't re-exported from the package root, hence the cast. + grapherState.variant = "uncaptioned" as typeof grapherState.variant; + + grapherState.inputTable = legacyToChartsTableAndDimensionsWithMandatorySlug( + createTestDataset([{ data, metadata }]), + dimensions, + entityColors, + ); + + return grapherState; +} + +export default function IndicatorChart({ + response, + benchmark, +}: { + response: EconomySeriesResponse; + benchmark?: IndicatorBenchmark; +}) { + const { containerRef, size } = useChartSize(); + + const grapherState = useMemo( + () => + buildGrapherState( + response, + new Bounds(0, 0, size.width, size.height), + benchmark, + ), + [response, size.width, size.height, benchmark], + ); + + return ( + // In the uncaptioned variant, Grapher draws chart content at the padded + // origin (24,24) inside an svg whose viewBox starts at 0,0 — the bottom + // axis and right-edge labels get clipped unless the svg can overflow. +
+ {grapherState ? ( +
+ +
+ ) : ( +
+

+ No data available for this indicator. +

+
+ )} +
+ ); +} diff --git a/src/app/economic-indicators/IndicatorChartClient.tsx b/src/app/economic-indicators/IndicatorChartClient.tsx new file mode 100644 index 0000000..4591b9d --- /dev/null +++ b/src/app/economic-indicators/IndicatorChartClient.tsx @@ -0,0 +1,24 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { EconomySeriesResponse } from "@/lib/api/economy"; +import type { IndicatorBenchmark } from "./indicators"; + +const IndicatorChart = dynamic(() => import("./IndicatorChart"), { + ssr: false, + loading: () => ( + // Mirrors the useChartSize clamp so deep-link hash scrolls stay accurate + // when the placeholder swaps for the real chart. +
+ ), +}); + +export default function IndicatorChartClient({ + response, + benchmark, +}: { + response: EconomySeriesResponse; + benchmark?: IndicatorBenchmark; +}) { + return ; +} diff --git a/src/app/economic-indicators/SectionNav.tsx b/src/app/economic-indicators/SectionNav.tsx new file mode 100644 index 0000000..65d9ce4 --- /dev/null +++ b/src/app/economic-indicators/SectionNav.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; +import { SECTIONS } from "./indicators"; + +// Sits below the global navbar (~70px) in the sticky stack. +export default function SectionNav({ currentId }: { currentId?: string }) { + return ( + + ); +} diff --git a/src/app/economic-indicators/SectionSparkline.tsx b/src/app/economic-indicators/SectionSparkline.tsx new file mode 100644 index 0000000..8faac19 --- /dev/null +++ b/src/app/economic-indicators/SectionSparkline.tsx @@ -0,0 +1,129 @@ +import type { EconomySeriesResponse } from "@/lib/api/economy"; +import { CANADA_COLOR } from "./indicators"; + +// Decorative preview of a section's featured chart, rendered as a static +// inline SVG on the server — Canada in brand red over muted peer lines, +// mirroring the focused/muted treatment of the full Grapher charts. + +const VIEW_W = 100; +const VIEW_H = 32; +const PAD_Y = 2; +// Century-scale series (CO₂ reaches back to 1750) would render as a long +// flat tail at card size — preview only the recent era. +const MAX_SPAN_YEARS = 60; + +type Line = { name: string; points: string; endX: number; endY: number }; + +function buildLines(response: EconomySeriesResponse): { + lines: Line[]; + minYear: number; + maxYear: number; +} | null { + const allYears = response.data.series.flatMap((s) => + s.points.map((p) => p.year), + ); + if (allYears.length === 0) return null; + const windowStart = Math.max(...allYears) - MAX_SPAN_YEARS; + + const series = response.data.series + .map((s) => ({ + ...s, + points: s.points.filter((p) => p.year >= windowStart), + })) + .filter((s) => s.points.length >= 2); + if (series.length === 0) return null; + + const allPoints = series.flatMap((s) => s.points); + const minYear = Math.min(...allPoints.map((p) => p.year)); + const maxYear = Math.max(...allPoints.map((p) => p.year)); + const minValue = Math.min(...allPoints.map((p) => p.value)); + const maxValue = Math.max(...allPoints.map((p) => p.value)); + if (minYear === maxYear) return null; + + const valueSpan = maxValue - minValue; + const toX = (year: number) => + ((year - minYear) / (maxYear - minYear)) * VIEW_W; + const toY = (value: number) => + valueSpan === 0 + ? VIEW_H / 2 + : VIEW_H - PAD_Y - ((value - minValue) / valueSpan) * (VIEW_H - 2 * PAD_Y); + + const lines = series.map((s) => { + const points = s.points + .map((p) => `${toX(p.year).toFixed(2)},${toY(p.value).toFixed(2)}`) + .join(" "); + const last = s.points[s.points.length - 1]; + return { + name: s.jurisdiction.name, + points, + endX: toX(last.year), + endY: toY(last.value), + }; + }); + + return { lines, minYear, maxYear }; +} + +export default function SectionSparkline({ + response, +}: { + response: EconomySeriesResponse; +}) { + const built = buildLines(response); + if (!built) return null; + + const { lines, minYear, maxYear } = built; + const canada = lines.find((l) => l.name === "Canada"); + const peers = lines.filter((l) => l !== canada); + + return ( + + ); +} diff --git a/src/app/economic-indicators/[section]/page.tsx b/src/app/economic-indicators/[section]/page.tsx new file mode 100644 index 0000000..09143ea --- /dev/null +++ b/src/app/economic-indicators/[section]/page.tsx @@ -0,0 +1,283 @@ +import "@buildcanada/charts/styles.css"; + +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getSiteConfig } from "@/lib/api"; +import { + getEconomicSeries, + humanizeSourceName, + type EconomySeriesResponse, +} from "@/lib/api/economy"; +import { PageHeader } from "@/components/ui/page-header"; +import { buildGraph } from "@/lib/schemas/graph"; +import { generateOrganizationSchema } from "@/lib/schemas/generators/organization"; +import { generateBreadcrumbSchema } from "@/lib/schemas/generators/breadcrumb"; +import { Signpost } from "@/components/custom/signpost"; +import CombinedSectionChartClient from "../CombinedSectionChartClient"; +import IndicatorChartClient from "../IndicatorChartClient"; +import SectionNav from "../SectionNav"; +import { SECTIONS } from "../indicators"; + +export const dynamicParams = false; + +export function generateStaticParams() { + return SECTIONS.map((section) => ({ section: section.id })); +} + +type PageProps = { params: Promise<{ section: string }> }; + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { section: sectionId } = await params; + const section = SECTIONS.find((s) => s.id === sectionId); + if (!section) return {}; + const title = `${section.title} — Economic Indicators`; + return { + title, + description: section.description, + alternates: { canonical: `/economic-indicators/${section.id}` }, + openGraph: { + title, + description: section.description, + type: "website", + }, + twitter: { + card: "summary_large_image", + title, + }, + }; +} + +function formatFetchedDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + return new Intl.DateTimeFormat("en-CA", { dateStyle: "long" }).format(date); +} + +function SourceLine({ response }: { response: EconomySeriesResponse }) { + const source = response.meta.source; + if (!source) return null; + const updated = source.last_fetched_at + ? formatFetchedDate(source.last_fetched_at) + : ""; + const sourceName = humanizeSourceName(source.name); + return ( +

+ Source:{" "} + {source.url ? ( + + {sourceName} + + ) : ( + sourceName + )} + {updated && <> · Updated {updated}} +

+ ); +} + +// Deep-link targets must clear the sticky navbar + SectionNav stack, whose +// height grows as the section links wrap on narrower screens. +const SECTION_SCROLL_MT = + "scroll-mt-[300px] sm:scroll-mt-[220px] md:scroll-mt-[200px]"; + +// Chart headings link to their own anchor so a reader can grab a URL +// straight to one chart. +function AnchoredHeading({ id, text }: { id: string; text: string }) { + return ( +

+ + {text} + + +

+ ); +} + +function UnavailablePanel() { + return ( +
+

+ Data is temporarily unavailable. Please check back soon. +

+
+ ); +} + +export default async function IndicatorSectionPage({ params }: PageProps) { + const { section: sectionId } = await params; + const sectionIndex = SECTIONS.findIndex((s) => s.id === sectionId); + if (sectionIndex === -1) notFound(); + + const section = SECTIONS[sectionIndex]; + const prev = sectionIndex > 0 ? SECTIONS[sectionIndex - 1] : null; + const next = + sectionIndex < SECTIONS.length - 1 ? SECTIONS[sectionIndex + 1] : null; + + const configData = getSiteConfig(); + + const jsonLd = buildGraph( + generateOrganizationSchema(configData), + generateBreadcrumbSchema( + `/economic-indicators/${section.id}`, + section.title, + configData.siteUrl, + ), + ); + + const results = await Promise.all( + section.indicators.map((indicator) => + getEconomicSeries(indicator.slug).catch(() => null), + ), + ); + + const combinedItems = section.combined + ? section.indicators.flatMap((indicator, i) => { + const response = results[i]; + return response + ? [{ label: indicator.chartLabel ?? indicator.heading, response }] + : []; + }) + : []; + + const signpostHeadings = [ + ...(section.combined && combinedItems.length > 0 + ? [{ id: "combined", text: section.combined.heading, level: 2 as const }] + : []), + ...section.indicators.map((indicator) => ({ + id: indicator.slug, + text: indicator.heading, + level: 2 as const, + })), + ]; + + return ( +
+