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%
9.8 KB · 294 lines tsx
Raw Blame History
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