/** * ============================================================================= * QWHPI — Quebec Weekly Housing Price Index * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : web/app/map/page.tsx * Purpose : Map — choropleth of the 17 administrative regions (YoY, 13w, * index level, assessment gap) rendered as inline SVG. * ============================================================================= */ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { fetchMap, fetchSeries, type MapCell } from "../../lib/api"; const METRICS = [ { id: "yoy", label: "YoY %" }, { id: "six_month", label: "6-month %" }, { id: "three_month", label: "3-month %" }, { id: "index_level", label: "Index level" }, { id: "assessment_gap", label: "Assessment gap" }, ]; const TYPES = ["all", "unifamilial", "condo", "plex"]; // Sequential blue ramp (light→dark) from the validated palette. const RAMP = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"]; interface Feature { properties: { geography_id: string; name: string }; geometry: { type: string; coordinates: number[][][] | number[][][][] }; } function project([lng, lat]: number[], w: number, h: number): [number, number] { // Quebec bbox in EPSG:4326; simple equirectangular with cos(lat0) x-scale. const [minLng, maxLng, minLat, maxLat] = [-79.9, -56.9, 44.9, 62.8]; const k = Math.cos((52 * Math.PI) / 180); const spanX = (maxLng - minLng) * k; const spanY = maxLat - minLat; const s = Math.min(w / spanX, h / spanY); const x = ((lng - minLng) * k) * s; const y = h - (lat - minLat) * s - (h - spanY * s) / 2; return [x, y]; } function ringsOf(f: Feature): number[][][] { return f.geometry.type === "Polygon" ? (f.geometry.coordinates as number[][][]) : (f.geometry.coordinates as number[][][][]).flat(); } function pathFor(f: Feature, w: number, h: number): string { return ringsOf(f) .map((ring) => { const pts = ring.map((c) => project(c, w, h)); return "M" + pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join("L") + "Z"; }) .join(" "); } export default function MapPage() { const [metric, setMetric] = useState("yoy"); const [ptype, setPtype] = useState("all"); const [cells, setCells] = useState([]); const [periods, setPeriods] = useState([]); const [idx, setIdx] = useState(0); const [playing, setPlaying] = useState(false); const [features, setFeatures] = useState([]); const [hover, setHover] = useState(null); const [error, setError] = useState(null); const playRef = useRef | null>(null); useEffect(() => { fetch("/regions.geojson") .then((r) => r.json()) .then((g) => setFeatures(g.features)) .catch((e) => setError(String(e))); fetchSeries("quebec", "all") .then((s) => { const ps = s.observations .filter((o) => !o.is_partial_month) .map((o) => o.period); setPeriods(ps); setIdx(ps.length - 1); }) .catch((e) => setError(String(e))); }, []); const period = periods[idx] ?? ""; useEffect(() => { if (!period) return; fetchMap(metric, ptype, period) .then((m) => { setCells(m.cells); setError(null); }) .catch((e) => setError(String(e))); }, [metric, ptype, period]); // Time-lapse playback across all months. useEffect(() => { if (!playing) { if (playRef.current) clearInterval(playRef.current); return; } playRef.current = setInterval(() => { setIdx((i) => { if (i >= periods.length - 1) { setPlaying(false); return i; } return i + 1; }); }, 550); return () => { if (playRef.current) clearInterval(playRef.current); }; }, [playing, periods.length]); const { colorOf, domain } = useMemo(() => { const vals = cells.map((c) => c.value).filter((v): v is number => v != null); const lo = Math.min(...vals); const hi = Math.max(...vals); const f = (v: number | null) => { if (v == null || vals.length === 0) return "var(--surface-2)"; const t = hi === lo ? 0.5 : (v - lo) / (hi - lo); return RAMP[Math.min(RAMP.length - 1, Math.floor(t * RAMP.length))]; }; return { colorOf: f, domain: vals.length ? ([lo, hi] as const) : null }; }, [cells]); const byId = useMemo( () => new Map(cells.map((c) => [c.geography_id, c])), [cells], ); const W = 720, H = 560; const fmt = (v: number | null) => v == null ? "—" : metric === "assessment_gap" ? v.toFixed(2) : v.toFixed(1); return ( <>

Regional map

{ setPlaying(false); setIdx(Number(e.target.value)); }} /> {period}
{error &&
{error}
}
{features.map((f) => { const cell = byId.get(f.properties.geography_id); return ( setHover(cell ?? null)} onMouseLeave={() => setHover(null)} /> ); })} {domain && (
{fmt(domain[0])} {RAMP.map((c) => ( ))} {fmt(domain[1])}
)} {hover && (
{hover.geography_name}: {fmt(hover.value)} ·{" "} {hover.transactions} tx · reliability {hover.reliability_grade}
)}
All regions {[...cells] .sort((a, b) => (b.value ?? -1e9) - (a.value ?? -1e9)) .map((c) => ( ))}
Region{METRICS.find((m) => m.id === metric)?.label} TxGrade
{c.geography_name} {fmt(c.value)} {c.transactions} {c.reliability_grade}
); }