/** * ============================================================================= * QWHPI — Quebec Weekly Housing Price Index * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : web/app/compare/page.tsx * Purpose : Compare — multi-series chart in index points, dollar values, or * rebased to a common week (max 5 series, fixed hue order). * ============================================================================= */ "use client"; import { Suspense, useEffect, useMemo, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { CartesianGrid, Line, LineChart, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { fetchGeographies, fetchSeries, reportUrl, type GeographyNode, type Observation, } from "../../lib/api"; const SERIES_COLORS = [ "var(--series-1)", "var(--series-2)", "var(--series-3)", "var(--series-4)", "var(--series-5)", ]; const TYPES = ["all", "unifamilial", "condo", "plex"]; const DEFAULT = ["montreal:condo", "quebec-city:condo", "gatineau:condo"]; type Mode = "index" | "dollars" | "rebased"; function moneyCompact(v: number): string { return v >= 1_000_000 ? "$" + (v / 1_000_000).toFixed(2) + "M" : "$" + Math.round(v / 1000) + "k"; } function CompareInner() { const params = useSearchParams(); const router = useRouter(); const pathname = usePathname(); const initSeries = params.get("series")?.split(",").filter(Boolean); const [geos, setGeos] = useState([]); const [picked, setPicked] = useState( initSeries?.length ? initSeries.slice(0, 5) : DEFAULT); const [hidden, setHidden] = useState>(new Set()); const [pendingGeo, setPendingGeo] = useState("laval"); const [pendingType, setPendingType] = useState("condo"); const [mode, setMode] = useState( (["index", "dollars", "rebased"] as readonly string[]).includes( params.get("mode") ?? "") ? (params.get("mode") as Mode) : "index"); const [rebase, setRebase] = useState(params.get("rebase") ?? "2022-01"); const [obs, setObs] = useState>({}); const [error, setError] = useState(null); // Shareable URL useEffect(() => { const q = new URLSearchParams({ series: picked.join(",") }); if (mode !== "index") q.set("mode", mode); if (mode === "rebased") q.set("rebase", rebase); router.replace(`${pathname}?${q.toString()}`, { scroll: false }); }, [picked, mode, rebase, router, pathname]); useEffect(() => { fetchGeographies() .then((g) => setGeos(g.published_series)) .catch((e) => setError(String(e))); }, []); useEffect(() => { let alive = true; Promise.all( picked.map(async (p) => { const [g, t] = p.split(":"); return [p, (await fetchSeries(g, t)).observations] as const; }), ) .then((pairs) => { if (!alive) return; setObs(Object.fromEntries(pairs)); setError(null); }) .catch((e) => setError(String(e))); return () => { alive = false; }; }, [picked]); const rows = useMemo(() => { const byWeek: Record> = {}; for (const [name, series] of Object.entries(obs)) { if (!picked.includes(name)) continue; let baseVal = 1; if (mode === "rebased") { const b = series.find((o) => o.period === rebase); if (!b) continue; baseVal = b.index_smoothed; } for (const o of series) { const v = mode === "dollars" ? o.representative_value : mode === "rebased" ? (o.index_smoothed / baseVal) * 100 : o.index_smoothed; if (v == null) continue; (byWeek[o.period] ??= { week: o.period })[name] = Math.round(v * 100) / 100; } } return Object.values(byWeek).sort((a, b) => String(a.week).localeCompare(String(b.week))); }, [obs, picked, mode, rebase]); const nameOf = useMemo(() => { const m = new Map(geos.map((g) => [g.geography_id, g.geography_name])); return (spec: string) => { const [g, t] = spec.split(":"); return `${m.get(g) ?? g} · ${t}`; }; }, [geos]); const add = () => { const spec = `${pendingGeo}:${pendingType}`; if (!picked.includes(spec) && picked.length < 5) setPicked([...picked, spec]); }; return ( <>

Compare series

Up to five series; colors keep their series when you add or remove. View index levels, dollar values, or rebase to a common month.

{mode === "rebased" && ( setRebase(e.target.value)} /> )} {picked.length >= 1 && ( ⤓ Comparison report (PDF) )}
{picked.map((s) => ( ))} click a chip to toggle · ✕ removes
{error &&
{error}
}
mode === "dollars" ? moneyCompact(v) : String(Math.round(v))} tick={{ fill: "var(--text-muted)", fontSize: 12 }} tickLine={false} axisLine={false} width={mode === "dollars" ? 58 : 48} /> [ mode === "dollars" ? "$" + Math.round(v).toLocaleString("en-CA") : v.toFixed(1), nameOf(name), ]} /> ( {nameOf(v)} )} /> {picked.map((s, i) => ( ))}

{mode === "rebased" ? `All series = 100 at ${rebase}.` : mode === "dollars" ? "Representative dollar value per series (fixed 2021 basket carried by the index)." : "Levels shown on each series' own 2021 = 100 base."}

); } export default function Compare() { return ( ); }