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%
8.3 KB · 242 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/map/page.tsx7 * Purpose : Map — choropleth of the 17 administrative regions (YoY, 13w,8 *           index level, assessment gap) rendered as inline SVG.9 * =============================================================================10 */11"use client";1213import { useEffect, useMemo, useRef, useState } from "react";14import { fetchMap, fetchSeries, type MapCell } from "../../lib/api";1516const METRICS = [17  { id: "yoy", label: "YoY %" },18  { id: "six_month", label: "6-month %" },19  { id: "three_month", label: "3-month %" },20  { id: "index_level", label: "Index level" },21  { id: "assessment_gap", label: "Assessment gap" },22];23const TYPES = ["all", "unifamilial", "condo", "plex"];2425// Sequential blue ramp (light→dark) from the validated palette.26const RAMP = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"];2728interface Feature {29  properties: { geography_id: string; name: string };30  geometry: { type: string; coordinates: number[][][] | number[][][][] };31}3233function project([lng, lat]: number[], w: number, h: number): [number, number] {34  // Quebec bbox in EPSG:4326; simple equirectangular with cos(lat0) x-scale.35  const [minLng, maxLng, minLat, maxLat] = [-79.9, -56.9, 44.9, 62.8];36  const k = Math.cos((52 * Math.PI) / 180);37  const spanX = (maxLng - minLng) * k;38  const spanY = maxLat - minLat;39  const s = Math.min(w / spanX, h / spanY);40  const x = ((lng - minLng) * k) * s;41  const y = h - (lat - minLat) * s - (h - spanY * s) / 2;42  return [x, y];43}4445function ringsOf(f: Feature): number[][][] {46  return f.geometry.type === "Polygon"47    ? (f.geometry.coordinates as number[][][])48    : (f.geometry.coordinates as number[][][][]).flat();49}5051function pathFor(f: Feature, w: number, h: number): string {52  return ringsOf(f)53    .map((ring) => {54      const pts = ring.map((c) => project(c, w, h));55      return "M" + pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join("L") + "Z";56    })57    .join(" ");58}5960export default function MapPage() {61  const [metric, setMetric] = useState("yoy");62  const [ptype, setPtype] = useState("all");63  const [cells, setCells] = useState<MapCell[]>([]);64  const [periods, setPeriods] = useState<string[]>([]);65  const [idx, setIdx] = useState(0);66  const [playing, setPlaying] = useState(false);67  const [features, setFeatures] = useState<Feature[]>([]);68  const [hover, setHover] = useState<MapCell | null>(null);69  const [error, setError] = useState<string | null>(null);70  const playRef = useRef<ReturnType<typeof setInterval> | null>(null);7172  useEffect(() => {73    fetch("/regions.geojson")74      .then((r) => r.json())75      .then((g) => setFeatures(g.features))76      .catch((e) => setError(String(e)));77    fetchSeries("quebec", "all")78      .then((s) => {79        const ps = s.observations80          .filter((o) => !o.is_partial_month)81          .map((o) => o.period);82        setPeriods(ps);83        setIdx(ps.length - 1);84      })85      .catch((e) => setError(String(e)));86  }, []);8788  const period = periods[idx] ?? "";8990  useEffect(() => {91    if (!period) return;92    fetchMap(metric, ptype, period)93      .then((m) => {94        setCells(m.cells);95        setError(null);96      })97      .catch((e) => setError(String(e)));98  }, [metric, ptype, period]);99100  // Time-lapse playback across all months.101  useEffect(() => {102    if (!playing) {103      if (playRef.current) clearInterval(playRef.current);104      return;105    }106    playRef.current = setInterval(() => {107      setIdx((i) => {108        if (i >= periods.length - 1) {109          setPlaying(false);110          return i;111        }112        return i + 1;113      });114    }, 550);115    return () => {116      if (playRef.current) clearInterval(playRef.current);117    };118  }, [playing, periods.length]);119120  const { colorOf, domain } = useMemo(() => {121    const vals = cells.map((c) => c.value).filter((v): v is number => v != null);122    const lo = Math.min(...vals);123    const hi = Math.max(...vals);124    const f = (v: number | null) => {125      if (v == null || vals.length === 0) return "var(--surface-2)";126      const t = hi === lo ? 0.5 : (v - lo) / (hi - lo);127      return RAMP[Math.min(RAMP.length - 1, Math.floor(t * RAMP.length))];128    };129    return { colorOf: f, domain: vals.length ? ([lo, hi] as const) : null };130  }, [cells]);131132  const byId = useMemo(133    () => new Map(cells.map((c) => [c.geography_id, c])),134    [cells],135  );136137  const W = 720, H = 560;138  const fmt = (v: number | null) =>139    v == null ? "—" : metric === "assessment_gap" ? v.toFixed(2) : v.toFixed(1);140141  return (142    <>143      <h1>Regional map</h1>144      <div className="controls">145        <label>Metric</label>146        <select value={metric} onChange={(e) => setMetric(e.target.value)}>147          {METRICS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}148        </select>149        <label>Type</label>150        <select value={ptype} onChange={(e) => setPtype(e.target.value)}>151          {TYPES.map((t) => <option key={t} value={t}>{t}</option>)}152        </select>153        <button className="ctrl" aria-pressed={playing}154                aria-label={playing ? "Pause time-lapse" : "Play time-lapse"}155                onClick={() => {156                  if (!playing && idx >= periods.length - 1) setIdx(12);157                  setPlaying((v) => !v);158                }}>159          {playing ? "⏸ Pause" : "▶ Play"}160        </button>161        <input162          type="range"163          min={0}164          max={Math.max(periods.length - 1, 0)}165          value={idx}166          aria-label="Month selector"167          style={{ flex: "1 1 180px", accentColor: "var(--series-1)" }}168          onChange={(e) => {169            setPlaying(false);170            setIdx(Number(e.target.value));171          }}172        />173        <span className="note" style={{ fontVariantNumeric: "tabular-nums",174                                        minWidth: 84 }}>175          <strong>{period}</strong>176        </span>177      </div>178      {error && <div className="card"><span className="note">{error}</span></div>}179180      <div className="grid cols-2">181        <div className="card">182          <svg viewBox={`0 0 ${W} ${H}`} role="img"183               aria-label={`Choropleth of Quebec regions, ${metric}`}>184            {features.map((f) => {185              const cell = byId.get(f.properties.geography_id);186              return (187                <path188                  key={f.properties.geography_id}189                  d={pathFor(f, W, H)}190                  fill={colorOf(cell?.value ?? null)}191                  stroke="var(--surface-1)"192                  strokeWidth={1.5}193                  style={{ cursor: "pointer", transition: "fill 0.4s ease" }}194                  onMouseEnter={() => setHover(cell ?? null)}195                  onMouseLeave={() => setHover(null)}196                />197              );198            })}199          </svg>200          {domain && (201            <div className="note" style={{ display: "flex", gap: 4, alignItems: "center" }}>202              {fmt(domain[0])}203              {RAMP.map((c) => (204                <span key={c} style={{ background: c, width: 22, height: 10, display: "inline-block" }} />205              ))}206              {fmt(domain[1])}207            </div>208          )}209          {hover && (210            <div className="note">211              <strong>{hover.geography_name}</strong>: {fmt(hover.value)} ·{" "}212              {hover.transactions} tx · reliability {hover.reliability_grade}213            </div>214          )}215        </div>216217        <div className="card">218          <strong>All regions</strong>219          <table className="data">220            <thead>221              <tr><th>Region</th><th>{METRICS.find((m) => m.id === metric)?.label}</th>222                  <th>Tx</th><th>Grade</th></tr>223            </thead>224            <tbody>225              {[...cells]226                .sort((a, b) => (b.value ?? -1e9) - (a.value ?? -1e9))227                .map((c) => (228                  <tr key={c.geography_id}>229                    <td>{c.geography_name}</td>230                    <td>{fmt(c.value)}</td>231                    <td>{c.transactions}</td>232                    <td>{c.reliability_grade}</td>233                  </tr>234                ))}235            </tbody>236          </table>237        </div>238      </div>239    </>240  );241}242