/** * ============================================================================= * QWHPI — Quebec Weekly Housing Price Index * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : web/components/HeroChart.tsx * Purpose : Full-width hero area chart with draw-in animation, CI band and * pulsing end point — geometry rebuilt to the measured width. * ============================================================================= */ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; import type { Observation } from "../lib/api"; export default function HeroChart({ observations }: { observations: Observation[] }) { const wrapRef = useRef(null); const [width, setWidth] = useState(1120); useEffect(() => { const el = wrapRef.current; if (!el) return; const ro = new ResizeObserver((entries) => { const w = entries[0]?.contentRect.width; if (w) setWidth(Math.max(280, Math.round(w))); }); ro.observe(el); return () => ro.disconnect(); }, []); const small = width < 560; const W = width; const H = small ? 190 : 240; const PAD = { l: small ? 6 : 30, r: small ? 64 : 96, t: 24, b: 22 }; const geom = useMemo(() => { const obs = observations.filter((o) => !o.is_partial_month); if (obs.length < 2) return null; const vals = obs.map((o) => o.index_smoothed); const los = obs.map((o) => o.lower_95); const his = obs.map((o) => o.upper_95); const min = Math.min(...los); const max = Math.max(...his); const x = (i: number) => PAD.l + (i / (obs.length - 1)) * (W - PAD.l - PAD.r); const y = (v: number) => H - PAD.b - ((v - min) / (max - min || 1)) * (H - PAD.t - PAD.b); const pts = vals.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`); const line = "M" + pts.join(" L"); const area = line + ` L${x(obs.length - 1)},${H - PAD.b} L${x(0)},${H - PAD.b} Z`; const upper = his.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`); const lower = los .map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`) .reverse(); const band = "M" + upper.join(" L") + " L" + lower.join(" L") + " Z"; const lastObs = obs[obs.length - 1]; const yearStep = small ? 2 : 1; // fewer year labels on narrow screens return { line, area, band, lastX: x(obs.length - 1), lastY: y(vals[vals.length - 1]), last: lastObs, years: obs .map((o, i) => ({ o, i })) .filter(({ o }) => o.period.endsWith("-01") && Number(o.period.slice(0, 4)) % yearStep === (small ? 0 : 0)) .map(({ o, i }) => ({ label: o.period.slice(0, 4), x: x(i) })), }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [observations, W, H, PAD.l, PAD.r]); if (!geom) return
; return (
{geom.years.map((yr) => ( {yr.label} ))} {geom.last.index_smoothed.toFixed(1)} {geom.last.period}
); }