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%
4.7 KB · 113 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/HeroChart.tsx7 * Purpose : Full-width hero area chart with draw-in animation, CI band and8 *           pulsing end point — geometry rebuilt to the measured width.9 * =============================================================================10 */11"use client";1213import { useEffect, useMemo, useRef, useState } from "react";14import type { Observation } from "../lib/api";1516export default function HeroChart({ observations }: { observations: Observation[] }) {17  const wrapRef = useRef<HTMLDivElement>(null);18  const [width, setWidth] = useState(1120);1920  useEffect(() => {21    const el = wrapRef.current;22    if (!el) return;23    const ro = new ResizeObserver((entries) => {24      const w = entries[0]?.contentRect.width;25      if (w) setWidth(Math.max(280, Math.round(w)));26    });27    ro.observe(el);28    return () => ro.disconnect();29  }, []);3031  const small = width < 560;32  const W = width;33  const H = small ? 190 : 240;34  const PAD = { l: small ? 6 : 30, r: small ? 64 : 96, t: 24, b: 22 };3536  const geom = useMemo(() => {37    const obs = observations.filter((o) => !o.is_partial_month);38    if (obs.length < 2) return null;39    const vals = obs.map((o) => o.index_smoothed);40    const los = obs.map((o) => o.lower_95);41    const his = obs.map((o) => o.upper_95);42    const min = Math.min(...los);43    const max = Math.max(...his);44    const x = (i: number) =>45      PAD.l + (i / (obs.length - 1)) * (W - PAD.l - PAD.r);46    const y = (v: number) =>47      H - PAD.b - ((v - min) / (max - min || 1)) * (H - PAD.t - PAD.b);48    const pts = vals.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`);49    const line = "M" + pts.join(" L");50    const area = line + ` L${x(obs.length - 1)},${H - PAD.b} L${x(0)},${H - PAD.b} Z`;51    const upper = his.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`);52    const lower = los53      .map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`)54      .reverse();55    const band = "M" + upper.join(" L") + " L" + lower.join(" L") + " Z";56    const lastObs = obs[obs.length - 1];57    const yearStep = small ? 2 : 1; // fewer year labels on narrow screens58    return {59      line, area, band,60      lastX: x(obs.length - 1), lastY: y(vals[vals.length - 1]),61      last: lastObs,62      years: obs63        .map((o, i) => ({ o, i }))64        .filter(({ o }) => o.period.endsWith("-01")65          && Number(o.period.slice(0, 4)) % yearStep === (small ? 0 : 0))66        .map(({ o, i }) => ({ label: o.period.slice(0, 4), x: x(i) })),67    };68    // eslint-disable-next-line react-hooks/exhaustive-deps69  }, [observations, W, H, PAD.l, PAD.r]);7071  if (!geom) return <div ref={wrapRef} />;7273  return (74    <div ref={wrapRef} className="hero-chart card"75         aria-label="Quebec index history chart" role="img">76      <svg viewBox={`0 0 ${W} ${H}`}77           style={{ width: "100%", height: "auto", display: "block" }}>78        <defs>79          <linearGradient id="heroFill" x1="0" y1="0" x2="0" y2="1">80            <stop offset="0%" stopColor="var(--series-1)" stopOpacity="0.28" />81            <stop offset="100%" stopColor="var(--series-1)" stopOpacity="0.02" />82          </linearGradient>83        </defs>84        <path d={geom.band} fill="var(--band)" className="hero-band" />85        <path d={geom.area} fill="url(#heroFill)" className="hero-area" />86        <path d={geom.line} fill="none" stroke="var(--series-1)"87              strokeWidth={small ? 2.2 : 2.6} strokeLinejoin="round"88              strokeLinecap="round" className="hero-line" pathLength={1} />89        {geom.years.map((yr) => (90          <g key={yr.label}>91            <line x1={yr.x} x2={yr.x} y1={H - PAD.b} y2={H - PAD.b + 4}92                  stroke="var(--border-strong)" />93            <text x={yr.x} y={H - 6} fontSize={small ? 11 : 10.5}94                  textAnchor="middle" fill="var(--text-muted)">{yr.label}</text>95          </g>96        ))}97        <circle cx={geom.lastX} cy={geom.lastY} r="4.5"98                fill="var(--series-1)" className="hero-dot" />99        <circle cx={geom.lastX} cy={geom.lastY} r="9"100                fill="var(--series-1)" opacity="0.25" className="hero-pulse" />101        <text x={geom.lastX + 12} y={geom.lastY - 5} fontSize={small ? 13 : 15}102              fontWeight="700" fill="var(--text-primary)" className="hero-dot">103          {geom.last.index_smoothed.toFixed(1)}104        </text>105        <text x={geom.lastX + 12} y={geom.lastY + 10} fontSize={small ? 9.5 : 10}106              fill="var(--text-muted)" className="hero-dot">107          {geom.last.period}108        </text>109      </svg>110    </div>111  );112}113