"use client"; import { geoMercator, geoPath } from "d3-geo"; import type { FeatureCollection, Geometry } from "geojson"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cls, pct, prob, ratingLabel, statusLabel } from "@/lib/format"; import { partyAlpha, partyLabel, partyVar } from "@/lib/parties"; export type MapMode = "projection" | "probabilite" | "marge" | "variation" | "2022" | "live"; export interface MapRiding { code: number; name: string; slug: string; region: string | null; forecast: { favorite: string; p: number; runnerUp: string | null; margin: number; rating: string; swing: number; pWin: Record } | null; baseline2022?: string | null; live?: { status: string; leader: string | null; bureaux: [number, number]; marginPct: number | null } | null; } interface Props { geo: FeatureCollection; ridings: Record; mode: MapMode; height?: number; interactive?: boolean; focus?: number | null; className?: string; onSelect?: (code: number) => void; compact?: boolean; /** overlay = encarts superposés (desktop) ; below = encarts rendus sous la carte (mobile) */ insets?: "overlay" | "below" | "none"; } function fillFor(r: MapRiding | undefined, mode: MapMode): string { if (!r) return "var(--p-none)"; if (mode === "live") { const l = r.live; if (!l || l.status === "NOT_STARTED" || !l.leader) return "var(--p-none)"; const strong = l.status === "ELECTED" || l.status === "FINAL" || l.status === "PROJECTED"; return strong ? partyVar(l.leader) : partyAlpha(l.leader, 0.45); } if (mode === "2022") return r.baseline2022 ? partyVar(r.baseline2022) : "var(--p-none)"; const f = r.forecast; if (!f) return "var(--p-none)"; if (mode === "projection") return partyAlpha(f.favorite, 0.35 + 0.65 * Math.min(1, (f.p - 0.4) / 0.6)); if (mode === "probabilite") return partyAlpha(f.favorite, 0.25 + 0.75 * f.p); if (mode === "marge") return partyAlpha(f.favorite, Math.min(1, 0.25 + f.margin / 30)); if (mode === "variation") { if (r.baseline2022 && r.baseline2022 !== f.favorite) return partyVar(f.favorite); return partyAlpha(f.favorite, 0.18); } return partyVar(f.favorite); } export function QuebecMap({ geo, ridings, mode, height = 560, interactive = true, focus = null, className, onSelect, compact = false, insets = "overlay" }: Props) { const router = useRouter(); const W = 900, H = height; const [hover, setHover] = useState(null); const [tip, setTip] = useState<{ x: number; y: number } | null>(null); const [view, setView] = useState<{ k: number; x: number; y: number }>({ k: 1, x: 0, y: 0 }); const svgRef = useRef(null); const drag = useRef<{ x: number; y: number; vx: number; vy: number } | null>(null); const pinch = useRef<{ d: number; k: number } | null>(null); const { path, byCode } = useMemo(() => { // Cadrage sur le Québec habité : Ungava (Nord-du-Québec, code 831) est volontairement tronqué au nord, // sinon il occupe les deux tiers de la carte. Il reste visible et cliquable dans la partie haute. const south = { type: "FeatureCollection", features: geo.features.filter((f) => f.properties.code !== 831) } as FeatureCollection; const proj = geoMercator().fitExtent([[8, 40], [W - 8, H - 8]], south); const p = geoPath(proj); const m = new Map(); geo.features.forEach((f) => m.set(f.properties.code, p(f) ?? "")); return { path: p, byCode: m }; }, [geo, H]); // insets : Montréal (zoom) — bounding box calculée sur les circonscriptions de l'île + Laval const insetMtl = useMemo(() => { const feats = geo.features.filter((f) => ["montreal", "laval"].includes((ridings[f.properties.code]?.region ?? ""))); if (!feats.length) return null; const fc = { type: "FeatureCollection", features: feats } as FeatureCollection; const size = compact ? 200 : 260; const proj = geoMercator().fitExtent([[4, 4], [size - 4, size - 4]], fc); const p = geoPath(proj); return { size, paths: feats.map((f) => [f.properties.code, p(f) ?? ""] as const) }; }, [geo, ridings, compact]); const insetQc = useMemo(() => { const feats = geo.features.filter((f) => ["capitale-nationale", "chaudiere-appalaches"].includes(ridings[f.properties.code]?.region ?? "")); if (!feats.length) return null; const fc = { type: "FeatureCollection", features: feats } as FeatureCollection; const size = compact ? 150 : 200; const proj = geoMercator().fitExtent([[4, 4], [size - 4, size - 4]], fc); const p = geoPath(proj); return { size, paths: feats.map((f) => [f.properties.code, p(f) ?? ""] as const) }; }, [geo, ridings, compact]); useEffect(() => { if (!focus) return; const f = geo.features.find((x) => x.properties.code === focus); if (!f) return; const [[x0, y0], [x1, y1]] = path.bounds(f); const k = Math.min(8, 0.5 / Math.max((x1 - x0) / W, (y1 - y0) / H)); setView({ k, x: W / 2 - (k * (x0 + x1)) / 2, y: H / 2 - (k * (y0 + y1)) / 2 }); }, [focus, geo, path, H]); const onWheel = useCallback((e: React.WheelEvent) => { if (!interactive) return; e.preventDefault(); const rect = svgRef.current!.getBoundingClientRect(); const mx = ((e.clientX - rect.left) / rect.width) * W, my = ((e.clientY - rect.top) / rect.height) * H; setView((v) => { const k = Math.max(1, Math.min(12, v.k * (e.deltaY < 0 ? 1.15 : 0.87))); return { k, x: mx - ((mx - v.x) / v.k) * k, y: my - ((my - v.y) / v.k) * k }; }); }, [interactive]); const toSvg = (cx: number, cy: number) => { const r = svgRef.current!.getBoundingClientRect(); return [((cx - r.left) / r.width) * W, ((cy - r.top) / r.height) * H]; }; const onPointerDown = (e: React.PointerEvent) => { if (!interactive) return; drag.current = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y }; }; const onPointerMove = (e: React.PointerEvent) => { if (drag.current && e.buttons) { const r = svgRef.current!.getBoundingClientRect(); const dx = ((e.clientX - drag.current.x) / r.width) * W, dy = ((e.clientY - drag.current.y) / r.height) * H; if (Math.abs(dx) + Math.abs(dy) > 2) setView((v) => ({ ...v, x: drag.current!.vx + dx, y: drag.current!.vy + dy })); } setTip({ x: e.clientX, y: e.clientY }); }; const onPointerUp = () => { drag.current = null; }; const onTouchMove = (e: React.TouchEvent) => { if (e.touches.length === 2) { const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY); if (!pinch.current) pinch.current = { d, k: view.k }; else { const k = Math.max(1, Math.min(12, (pinch.current.k * d) / pinch.current.d)); const [mx, my] = toSvg((e.touches[0].clientX + e.touches[1].clientX) / 2, (e.touches[0].clientY + e.touches[1].clientY) / 2); setView((v) => ({ k, x: mx - ((mx - v.x) / v.k) * k, y: my - ((my - v.y) / v.k) * k })); } } }; const onTouchEnd = () => { pinch.current = null; }; const r = hover !== null ? ridings[hover] : undefined; const select = (code: number) => { if (onSelect) onSelect(code); else router.push(`/circonscription/${ridings[code]?.slug ?? code}`); }; const renderPath = (code: number, d: string, key?: string) => { const rr = ridings[code]; return ( setHover(code)} onMouseLeave={() => setHover(null)} onClick={() => interactive && select(code)} tabIndex={interactive ? 0 : -1} role={interactive ? "button" : undefined} aria-label={rr ? `${rr.name}${rr.forecast ? ` — ${partyLabel(rr.forecast.favorite)} favori ${prob(rr.forecast.p)}` : ""}` : undefined} onKeyDown={(e) => { if (e.key === "Enter") select(code); }} onFocus={() => setHover(code)} onBlur={() => setHover(null)} /> ); }; return (
{ onPointerUp(); setHover(null); }} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} role="img" aria-label="Carte électorale du Québec, 127 circonscriptions"> {geo.features.map((f) => renderPath(f.properties.code, byCode.get(f.properties.code) ?? ""))} {insets === "overlay" && insetMtl && view.k < 1.8 && ( MONTRÉAL · LAVAL {insetMtl.paths.map(([code, d]) => renderPath(code, d, `m${code}`))} )} {insets === "overlay" && insetQc && view.k < 1.8 && ( QUÉBEC · LÉVIS {insetQc.paths.map(([code, d]) => renderPath(code, d, `q${code}`))} )} {insets === "below" && (insetMtl || insetQc) && (
{insetMtl && (
MONTRÉAL · LAVAL
{insetMtl.paths.map(([code, d]) => renderPath(code, d, `mb${code}`))}
)} {insetQc && (
QUÉBEC · LÉVIS
{insetQc.paths.map(([code, d]) => renderPath(code, d, `qb${code}`))}
)}
)} {interactive && (
)} {r && tip && (
= 768 ? { left: Math.min(tip.x + 14, window.innerWidth - 240), top: tip.y + 14 } : undefined}>
{r.name}
{mode === "live" && r.live ? (
Statut{statusLabel(r.live.status)}
En tête{partyLabel(r.live.leader)}
Bureaux{r.live.bureaux[0]} / {r.live.bureaux[1]}
{r.live.marginPct !== null &&
Marge{pct(r.live.marginPct)}
}
) : mode === "2022" ? (
2022 (transposé) : {partyLabel(r.baseline2022)}
) : r.forecast ? (
{Object.entries(r.forecast.pWin).filter(([id]) => id !== "aut").sort((a, b) => b[1] - a[1]).slice(0, 4).map(([id, p]) => (
{partyLabel(id)}{prob(p)}
))}
Probabilité de victoire · {ratingLabel(r.forecast.rating)} · marge {r.forecast.margin.toFixed(1).replace(".", ",")} pt
) :
Pas de projection.
}
)}
); }