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/app/compare/page.tsx7 * Purpose : Compare — multi-series chart in index points, dollar values, or8 * rebased to a common week (max 5 series, fixed hue order).9 * =============================================================================10 */11"use client";1213import { Suspense, useEffect, useMemo, useState } from "react";14import { usePathname, useRouter, useSearchParams } from "next/navigation";15import {16 CartesianGrid,17 Line,18 LineChart,19 Legend,20 ResponsiveContainer,21 Tooltip,22 XAxis,23 YAxis,24} from "recharts";25import {26 fetchGeographies,27 fetchSeries,28 reportUrl,29 type GeographyNode,30 type Observation,31} from "../../lib/api";3233const SERIES_COLORS = [34 "var(--series-1)", "var(--series-2)", "var(--series-3)",35 "var(--series-4)", "var(--series-5)",36];37const TYPES = ["all", "unifamilial", "condo", "plex"];38const DEFAULT = ["montreal:condo", "quebec-city:condo", "gatineau:condo"];3940type Mode = "index" | "dollars" | "rebased";4142function moneyCompact(v: number): string {43 return v >= 1_000_00044 ? "$" + (v / 1_000_000).toFixed(2) + "M"45 : "$" + Math.round(v / 1000) + "k";46}4748function CompareInner() {49 const params = useSearchParams();50 const router = useRouter();51 const pathname = usePathname();52 const initSeries = params.get("series")?.split(",").filter(Boolean);53 const [geos, setGeos] = useState<GeographyNode[]>([]);54 const [picked, setPicked] = useState<string[]>(55 initSeries?.length ? initSeries.slice(0, 5) : DEFAULT);56 const [hidden, setHidden] = useState<Set<string>>(new Set());57 const [pendingGeo, setPendingGeo] = useState("laval");58 const [pendingType, setPendingType] = useState("condo");59 const [mode, setMode] = useState<Mode>(60 (["index", "dollars", "rebased"] as readonly string[]).includes(61 params.get("mode") ?? "") ? (params.get("mode") as Mode) : "index");62 const [rebase, setRebase] = useState<string>(params.get("rebase") ?? "2022-01");63 const [obs, setObs] = useState<Record<string, Observation[]>>({});64 const [error, setError] = useState<string | null>(null);6566 // Shareable URL67 useEffect(() => {68 const q = new URLSearchParams({ series: picked.join(",") });69 if (mode !== "index") q.set("mode", mode);70 if (mode === "rebased") q.set("rebase", rebase);71 router.replace(`${pathname}?${q.toString()}`, { scroll: false });72 }, [picked, mode, rebase, router, pathname]);7374 useEffect(() => {75 fetchGeographies()76 .then((g) => setGeos(g.published_series))77 .catch((e) => setError(String(e)));78 }, []);7980 useEffect(() => {81 let alive = true;82 Promise.all(83 picked.map(async (p) => {84 const [g, t] = p.split(":");85 return [p, (await fetchSeries(g, t)).observations] as const;86 }),87 )88 .then((pairs) => {89 if (!alive) return;90 setObs(Object.fromEntries(pairs));91 setError(null);92 })93 .catch((e) => setError(String(e)));94 return () => { alive = false; };95 }, [picked]);9697 const rows = useMemo(() => {98 const byWeek: Record<string, Record<string, unknown>> = {};99 for (const [name, series] of Object.entries(obs)) {100 if (!picked.includes(name)) continue;101 let baseVal = 1;102 if (mode === "rebased") {103 const b = series.find((o) => o.period === rebase);104 if (!b) continue;105 baseVal = b.index_smoothed;106 }107 for (const o of series) {108 const v =109 mode === "dollars"110 ? o.representative_value111 : mode === "rebased"112 ? (o.index_smoothed / baseVal) * 100113 : o.index_smoothed;114 if (v == null) continue;115 (byWeek[o.period] ??= { week: o.period })[name] = Math.round(v * 100) / 100;116 }117 }118 return Object.values(byWeek).sort((a, b) =>119 String(a.week).localeCompare(String(b.week)));120 }, [obs, picked, mode, rebase]);121122 const nameOf = useMemo(() => {123 const m = new Map(geos.map((g) => [g.geography_id, g.geography_name]));124 return (spec: string) => {125 const [g, t] = spec.split(":");126 return `${m.get(g) ?? g} · ${t}`;127 };128 }, [geos]);129130 const add = () => {131 const spec = `${pendingGeo}:${pendingType}`;132 if (!picked.includes(spec) && picked.length < 5) setPicked([...picked, spec]);133 };134135 return (136 <>137 <h1>Compare series</h1>138 <p className="lede">139 Up to five series; colors keep their series when you add or remove.140 View index levels, dollar values, or rebase to a common month.141 </p>142 <div className="controls">143 <select value={pendingGeo} onChange={(e) => setPendingGeo(e.target.value)}144 aria-label="Geography to add">145 {geos.map((g) => (146 <option key={g.geography_id} value={g.geography_id}>147 {g.geography_name}148 </option>149 ))}150 </select>151 <select value={pendingType} onChange={(e) => setPendingType(e.target.value)}152 aria-label="Property type to add">153 {TYPES.map((t) => <option key={t} value={t}>{t}</option>)}154 </select>155 <button className="ctrl" onClick={add} disabled={picked.length >= 5}>156 Add series157 </button>158 <div className="seg" role="group" aria-label="Comparison mode">159 <button aria-pressed={mode === "index"} onClick={() => setMode("index")}>160 Index161 </button>162 <button aria-pressed={mode === "dollars"} onClick={() => setMode("dollars")}>163 $ value164 </button>165 <button aria-pressed={mode === "rebased"} onClick={() => setMode("rebased")}>166 Rebased167 </button>168 </div>169 {mode === "rebased" && (170 <input171 type="month"172 value={rebase}173 aria-label="Rebase month"174 onChange={(e) => setRebase(e.target.value)}175 />176 )}177 {picked.length >= 1 && (178 <a className="primary" href={reportUrl(picked)} target="_blank"179 rel="noreferrer">180 ⤓ Comparison report (PDF)181 </a>182 )}183 </div>184 <div className="controls">185 {picked.map((s) => (186 <button187 key={s}188 className="chip"189 aria-pressed={!hidden.has(s)}190 style={{ opacity: hidden.has(s) ? 0.4 : 1 }}191 onClick={() =>192 setHidden((h) => {193 const n = new Set(h);194 if (n.has(s)) n.delete(s);195 else n.add(s);196 return n;197 })}198 title={hidden.has(s) ? "Show series" : "Hide series"}199 >200 <span style={{ color: SERIES_COLORS[picked.indexOf(s)] }}>●</span>{" "}201 {nameOf(s)}202 <span203 role="button"204 aria-label={`Remove ${nameOf(s)}`}205 style={{ marginLeft: 4, color: "var(--text-muted)" }}206 onClick={(e) => {207 e.stopPropagation();208 setPicked(picked.filter((p) => p !== s));209 }}210 >✕</span>211 </button>212 ))}213 <span className="note">click a chip to toggle · ✕ removes</span>214 </div>215216 {error && <div className="card"><span className="note">{error}</span></div>}217218 <div className="card" style={{ height: 440 }}>219 <ResponsiveContainer>220 <LineChart data={rows} margin={{ top: 8, right: 12, left: 4, bottom: 0 }}>221 <CartesianGrid stroke="var(--border)" strokeDasharray="2 4" vertical={false} />222 <XAxis223 dataKey="week"224 tick={{ fill: "var(--text-muted)", fontSize: 12 }}225 tickLine={false}226 axisLine={{ stroke: "var(--border)" }}227 minTickGap={70}228 />229 <YAxis230 domain={["auto", "auto"]}231 tickFormatter={(v: number) =>232 mode === "dollars" ? moneyCompact(v) : String(Math.round(v))}233 tick={{ fill: "var(--text-muted)", fontSize: 12 }}234 tickLine={false}235 axisLine={false}236 width={mode === "dollars" ? 58 : 48}237 />238 <Tooltip239 contentStyle={{240 background: "var(--surface-1)",241 border: "1px solid var(--border-strong)",242 borderRadius: 10, fontSize: 13,243 }}244 labelStyle={{ color: "var(--text-secondary)" }}245 formatter={(v: number, name: string) => [246 mode === "dollars"247 ? "$" + Math.round(v).toLocaleString("en-CA")248 : v.toFixed(1),249 nameOf(name),250 ]}251 />252 <Legend253 formatter={(v: string) => (254 <span style={{ color: "var(--text-secondary)", fontSize: 13 }}>255 {nameOf(v)}256 </span>257 )}258 />259 {picked.map((s, i) => (260 <Line261 key={s}262 dataKey={s}263 stroke={SERIES_COLORS[i]}264 strokeWidth={2}265 dot={false}266 hide={hidden.has(s)}267 isAnimationActive268 animationDuration={700}269 animationEasing="ease-out"270 connectNulls271 />272 ))}273 </LineChart>274 </ResponsiveContainer>275 </div>276 <p className="note">277 {mode === "rebased"278 ? `All series = 100 at ${rebase}.`279 : mode === "dollars"280 ? "Representative dollar value per series (fixed 2021 basket carried by the index)."281 : "Levels shown on each series' own 2021 = 100 base."}282 </p>283 </>284 );285}286287export default function Compare() {288 return (289 <Suspense fallback={null}>290 <CompareInner />291 </Suspense>292 );293}294