spb/qwhpi Public
QHPI — Quebec Housing Price Index: quality-adjusted, hierarchically pooled housing price indexes.
Python 63.9%
TypeScript 25.4%
CSS 5.5%
TeX 3.5%
SQL 0.8%
Makefile 0.5%
Dockerfile 0.5%
1/**2 * =============================================================================3 * QWHPI — Quebec Weekly Housing Price Index4 * Author : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * File : web/components/IndexChart.tsx7 * Purpose : Weekly index chart — smoothed line, 95% CI band, optional raw8 * overlay, index-points or dollar-value unit, volume subchart.9 * =============================================================================10 */11"use client";1213import {14 Area,15 Bar,16 BarChart,17 Brush,18 CartesianGrid,19 ComposedChart,20 Line,21 ReferenceLine,22 ResponsiveContainer,23 Tooltip,24 XAxis,25 YAxis,26} from "recharts";27import type { Observation } from "../lib/api";28import type { MarketEvent } from "../lib/events";2930export type ChartUnit = "points" | "dollars";3132interface Props {33 observations: Observation[];34 showRaw?: boolean;35 showVolume?: boolean;36 unit?: ChartUnit;37 annotations?: MarketEvent[];38 showBrush?: boolean;39}4041interface Point {42 period: string;43 smoothed: number;44 raw: number | null;45 band: [number, number];46 transactions: number;47 partial: boolean;48 mom: number | null;49}5051function money(v: number): string {52 return "$" + Math.round(v).toLocaleString("en-CA");53}5455function moneyCompact(v: number): string {56 return v >= 1_000_00057 ? "$" + (v / 1_000_000).toFixed(2) + "M"58 : "$" + Math.round(v / 1000) + "k";59}6061function fmtVal(v: number | null | undefined, unit: ChartUnit): string {62 if (v == null || Number.isNaN(v)) return "—";63 return unit === "dollars" ? money(v) : v.toFixed(1);64}6566function ChartTooltip({ active, payload, label, unit }: {67 active?: boolean;68 payload?: ReadonlyArray<{ payload?: unknown }>;69 label?: unknown;70 unit: ChartUnit;71}) {72 const p = payload?.[0]?.payload as Point | undefined;73 if (!active || !p) return null;74 return (75 <div className="chart-tooltip">76 <div className="tt-label">77 Month of {String(label)}78 {p.partial ? " · partial" : ""}79 </div>80 <div className="tt-value">81 {fmtVal(p.smoothed, unit)}{" "}82 <span className="tt-ci">83 [{fmtVal(p.band[0], unit)} – {fmtVal(p.band[1], unit)}]84 </span>85 </div>86 {p.mom != null && (87 <div className={p.mom >= 0 ? "delta up" : "delta down"}88 style={{ fontSize: 12.5 }}>89 {p.mom >= 0 ? "▲" : "▼"} {Math.abs(p.mom).toFixed(2)}% vs previous month90 </div>91 )}92 {p.raw != null && (93 <div className="tt-raw">raw monthly: {fmtVal(p.raw, unit)}</div>94 )}95 <div className="tt-n">{p.transactions} transactions</div>96 </div>97 );98}99100export default function IndexChart({101 observations,102 showRaw = false,103 showVolume = true,104 unit = "points",105 annotations = [],106 showBrush = false,107}: Props) {108 // Fixed-basket conversion: representative_value = basket × index/100, so109 // one constant per cell converts every variant (raw, band) to dollars.110 const withRep = observations.find(111 (o) => o.representative_value != null && o.index_smoothed > 0,112 );113 const basket =114 unit === "dollars" && withRep115 ? (withRep.representative_value as number) / withRep.index_smoothed116 : 1 / 100;117 const k = unit === "dollars" ? basket : 1;118119 const data: Point[] = observations.map((o, i) => ({120 period: o.period,121 smoothed: o.index_smoothed * (unit === "dollars" ? k : 1),122 raw: o.index == null ? null : o.index * (unit === "dollars" ? k : 1),123 band: [124 o.lower_95 * (unit === "dollars" ? k : 1),125 o.upper_95 * (unit === "dollars" ? k : 1),126 ],127 transactions: o.transactions,128 partial: o.is_partial_month,129 mom: i > 0130 ? (o.index_smoothed / observations[i - 1].index_smoothed - 1) * 100131 : null,132 }));133134 const visibleEvents = annotations.filter((e) =>135 data.some((d) => d.period === e.period));136137 // Scale to the index line, not the CI band: the first thin weeks carry138 // huge honest intervals that would otherwise crush the whole chart.139 const lineVals = data.flatMap((d) =>140 d.raw != null && showRaw ? [d.smoothed, d.raw] : [d.smoothed],141 );142 const lo = Math.min(...lineVals);143 const hi = Math.max(...lineVals);144 const pad = (hi - lo) * 0.12 || 5;145 const yDomain: [number, number] = [lo - pad, hi + pad];146147 const tickFmt = (v: number) =>148 unit === "dollars" ? moneyCompact(v) : String(Math.round(v));149150 return (151 <div>152 <div style={{ width: "100%", height: showBrush ? 396 : 340 }}153 role="img"154 aria-label={`Monthly index chart, ${data.length} observations with 95% confidence band`}>155 <ResponsiveContainer>156 <ComposedChart data={data} margin={{ top: 22, right: 12, left: 4, bottom: 0 }}>157 <CartesianGrid stroke="var(--border)" strokeDasharray="2 4" vertical={false} />158 <XAxis159 dataKey="period"160 tick={{ fill: "var(--text-muted)", fontSize: 12 }}161 tickLine={false}162 axisLine={{ stroke: "var(--border)" }}163 minTickGap={70}164 />165 <YAxis166 domain={yDomain}167 allowDataOverflow168 tickFormatter={tickFmt}169 tick={{ fill: "var(--text-muted)", fontSize: 12 }}170 tickLine={false}171 axisLine={false}172 width={unit === "dollars" ? 58 : 46}173 />174 <Tooltip175 content={(p) => <ChartTooltip {...p} unit={unit} />}176 cursor={{ stroke: "var(--text-muted)", strokeWidth: 1 }}177 />178 <Area179 dataKey="band"180 stroke="none"181 fill="var(--band)"182 isAnimationActive={false}183 name="95% CI"184 />185 {showRaw && (186 <Line187 dataKey="raw"188 stroke="var(--series-2)"189 strokeWidth={1}190 dot={false}191 connectNulls192 isAnimationActive={false}193 name="Raw monthly"194 />195 )}196 <Line197 dataKey="smoothed"198 stroke="var(--series-1)"199 strokeWidth={2}200 dot={false}201 isAnimationActive202 animationDuration={700}203 animationEasing="ease-out"204 name={unit === "dollars" ? "Representative value" : "Index (smoothed)"}205 />206 {visibleEvents.map((e) => (207 <ReferenceLine208 key={e.period}209 x={e.period}210 stroke="var(--text-muted)"211 strokeDasharray="4 4"212 strokeWidth={1}213 label={{214 value: e.short,215 position: "top",216 fill: "var(--text-muted)",217 fontSize: 9.5,218 }}219 />220 ))}221 {showBrush && (222 <Brush223 dataKey="period"224 height={26}225 travellerWidth={8}226 stroke="var(--border-strong)"227 fill="var(--surface-2)"228 />229 )}230 </ComposedChart>231 </ResponsiveContainer>232 </div>233 {showRaw && (234 <div className="note" style={{ display: "flex", gap: 16 }}>235 <span><span style={{ color: "var(--series-1)" }}>―</span>{" "}236 {unit === "dollars" ? "Representative value (smoothed)" : "Index (one-sided smoothed)"}</span>237 <span><span style={{ color: "var(--series-2)" }}>―</span> Raw monthly estimate</span>238 <span style={{ color: "var(--text-muted)" }}>▮ 95% CI</span>239 </div>240 )}241 {showVolume && (242 <div style={{ width: "100%", height: 90, marginTop: 8 }}>243 <ResponsiveContainer>244 <BarChart data={data} margin={{ top: 0, right: 12, left: 4, bottom: 0 }}>245 <XAxis dataKey="period" hide />246 <YAxis247 tick={{ fill: "var(--text-muted)", fontSize: 11 }}248 tickLine={false}249 axisLine={false}250 width={unit === "dollars" ? 58 : 46}251 />252 <Tooltip253 content={({ active, payload, label }) =>254 active && payload?.length ? (255 <div className="chart-tooltip">256 {label}: {(payload[0].payload as Point).transactions} transactions257 </div>258 ) : null259 }260 />261 <Bar262 dataKey="transactions"263 fill="var(--series-1)"264 opacity={0.55}265 isAnimationActive={false}266 name="Transactions"267 />268 </BarChart>269 </ResponsiveContainer>270 </div>271 )}272 </div>273 );274}275