SPB Git

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%
8.6 KB · 275 lines tsx
Raw Blame History
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