/** * ============================================================================= * QWHPI — Quebec Weekly Housing Price Index * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : web/components/IndexChart.tsx * Purpose : Weekly index chart — smoothed line, 95% CI band, optional raw * overlay, index-points or dollar-value unit, volume subchart. * ============================================================================= */ "use client"; import { Area, Bar, BarChart, Brush, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import type { Observation } from "../lib/api"; import type { MarketEvent } from "../lib/events"; export type ChartUnit = "points" | "dollars"; interface Props { observations: Observation[]; showRaw?: boolean; showVolume?: boolean; unit?: ChartUnit; annotations?: MarketEvent[]; showBrush?: boolean; } interface Point { period: string; smoothed: number; raw: number | null; band: [number, number]; transactions: number; partial: boolean; mom: number | null; } function money(v: number): string { return "$" + Math.round(v).toLocaleString("en-CA"); } function moneyCompact(v: number): string { return v >= 1_000_000 ? "$" + (v / 1_000_000).toFixed(2) + "M" : "$" + Math.round(v / 1000) + "k"; } function fmtVal(v: number | null | undefined, unit: ChartUnit): string { if (v == null || Number.isNaN(v)) return "—"; return unit === "dollars" ? money(v) : v.toFixed(1); } function ChartTooltip({ active, payload, label, unit }: { active?: boolean; payload?: ReadonlyArray<{ payload?: unknown }>; label?: unknown; unit: ChartUnit; }) { const p = payload?.[0]?.payload as Point | undefined; if (!active || !p) return null; return (
Month of {String(label)} {p.partial ? " · partial" : ""}
{fmtVal(p.smoothed, unit)}{" "} [{fmtVal(p.band[0], unit)} – {fmtVal(p.band[1], unit)}]
{p.mom != null && (
= 0 ? "delta up" : "delta down"} style={{ fontSize: 12.5 }}> {p.mom >= 0 ? "▲" : "▼"} {Math.abs(p.mom).toFixed(2)}% vs previous month
)} {p.raw != null && (
raw monthly: {fmtVal(p.raw, unit)}
)}
{p.transactions} transactions
); } export default function IndexChart({ observations, showRaw = false, showVolume = true, unit = "points", annotations = [], showBrush = false, }: Props) { // Fixed-basket conversion: representative_value = basket × index/100, so // one constant per cell converts every variant (raw, band) to dollars. const withRep = observations.find( (o) => o.representative_value != null && o.index_smoothed > 0, ); const basket = unit === "dollars" && withRep ? (withRep.representative_value as number) / withRep.index_smoothed : 1 / 100; const k = unit === "dollars" ? basket : 1; const data: Point[] = observations.map((o, i) => ({ period: o.period, smoothed: o.index_smoothed * (unit === "dollars" ? k : 1), raw: o.index == null ? null : o.index * (unit === "dollars" ? k : 1), band: [ o.lower_95 * (unit === "dollars" ? k : 1), o.upper_95 * (unit === "dollars" ? k : 1), ], transactions: o.transactions, partial: o.is_partial_month, mom: i > 0 ? (o.index_smoothed / observations[i - 1].index_smoothed - 1) * 100 : null, })); const visibleEvents = annotations.filter((e) => data.some((d) => d.period === e.period)); // Scale to the index line, not the CI band: the first thin weeks carry // huge honest intervals that would otherwise crush the whole chart. const lineVals = data.flatMap((d) => d.raw != null && showRaw ? [d.smoothed, d.raw] : [d.smoothed], ); const lo = Math.min(...lineVals); const hi = Math.max(...lineVals); const pad = (hi - lo) * 0.12 || 5; const yDomain: [number, number] = [lo - pad, hi + pad]; const tickFmt = (v: number) => unit === "dollars" ? moneyCompact(v) : String(Math.round(v)); return (
} cursor={{ stroke: "var(--text-muted)", strokeWidth: 1 }} /> {showRaw && ( )} {visibleEvents.map((e) => ( ))} {showBrush && ( )}
{showRaw && (
{" "} {unit === "dollars" ? "Representative value (smoothed)" : "Index (one-sided smoothed)"} Raw monthly estimate ▮ 95% CI
)} {showVolume && (
active && payload?.length ? (
{label}: {(payload[0].payload as Point).transactions} transactions
) : null } />
)}
); }