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/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