/**
* =============================================================================
* 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
}
/>
)}
);
}