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/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