1"use client";23import { geoMercator, geoPath } from "d3-geo";4import type { FeatureCollection, Geometry } from "geojson";5import { useRouter } from "next/navigation";6import { useCallback, useEffect, useMemo, useRef, useState } from "react";7import { cls, pct, prob, ratingLabel, statusLabel } from "@/lib/format";8import { partyAlpha, partyLabel, partyVar } from "@/lib/parties";910export type MapMode = "projection" | "probabilite" | "marge" | "variation" | "2022" | "live";1112export interface MapRiding {13 code: number; name: string; slug: string; region: string | null;14 forecast: { favorite: string; p: number; runnerUp: string | null; margin: number; rating: string; swing: number; pWin: Record<string, number> } | null;15 baseline2022?: string | null;16 live?: { status: string; leader: string | null; bureaux: [number, number]; marginPct: number | null } | null;17}1819interface Props {20 geo: FeatureCollection<Geometry, { code: number; name: string; slug: string }>;21 ridings: Record<number, MapRiding>;22 mode: MapMode;23 height?: number;24 interactive?: boolean;25 focus?: number | null;26 className?: string;27 onSelect?: (code: number) => void;28 compact?: boolean;29}3031function fillFor(r: MapRiding | undefined, mode: MapMode): string {32 if (!r) return "var(--p-none)";33 if (mode === "live") {34 const l = r.live;35 if (!l || l.status === "NOT_STARTED" || !l.leader) return "var(--p-none)";36 const strong = l.status === "ELECTED" || l.status === "FINAL" || l.status === "PROJECTED";37 return strong ? partyVar(l.leader) : partyAlpha(l.leader, 0.45);38 }39 if (mode === "2022") return r.baseline2022 ? partyVar(r.baseline2022) : "var(--p-none)";40 const f = r.forecast;41 if (!f) return "var(--p-none)";42 if (mode === "projection") return partyAlpha(f.favorite, 0.35 + 0.65 * Math.min(1, (f.p - 0.4) / 0.6));43 if (mode === "probabilite") return partyAlpha(f.favorite, 0.25 + 0.75 * f.p);44 if (mode === "marge") return partyAlpha(f.favorite, Math.min(1, 0.25 + f.margin / 30));45 if (mode === "variation") {46 if (r.baseline2022 && r.baseline2022 !== f.favorite) return partyVar(f.favorite);47 return partyAlpha(f.favorite, 0.18);48 }49 return partyVar(f.favorite);50}5152export function QuebecMap({ geo, ridings, mode, height = 560, interactive = true, focus = null, className, onSelect, compact = false }: Props) {53 const router = useRouter();54 const W = 900, H = height;55 const [hover, setHover] = useState<number | null>(null);56 const [tip, setTip] = useState<{ x: number; y: number } | null>(null);57 const [view, setView] = useState<{ k: number; x: number; y: number }>({ k: 1, x: 0, y: 0 });58 const svgRef = useRef<SVGSVGElement>(null);59 const drag = useRef<{ x: number; y: number; vx: number; vy: number } | null>(null);60 const pinch = useRef<{ d: number; k: number } | null>(null);6162 const { path, byCode } = useMemo(() => {63 // Cadrage sur le Québec habité : Ungava (Nord-du-Québec, code 831) est volontairement tronqué au nord,64 // sinon il occupe les deux tiers de la carte. Il reste visible et cliquable dans la partie haute.65 const south = { type: "FeatureCollection", features: geo.features.filter((f) => f.properties.code !== 831) } as FeatureCollection<Geometry>;66 const proj = geoMercator().fitExtent([[8, 40], [W - 8, H - 8]], south);67 const p = geoPath(proj);68 const m = new Map<number, string>();69 geo.features.forEach((f) => m.set(f.properties.code, p(f) ?? ""));70 return { path: p, byCode: m };71 }, [geo, H]);7273 // insets : Montréal (zoom) — bounding box calculée sur les circonscriptions de l'île + Laval74 const insetMtl = useMemo(() => {75 const feats = geo.features.filter((f) => ["montreal", "laval"].includes((ridings[f.properties.code]?.region ?? "")));76 if (!feats.length) return null;77 const fc = { type: "FeatureCollection", features: feats } as FeatureCollection<Geometry>;78 const size = compact ? 200 : 260;79 const proj = geoMercator().fitExtent([[4, 4], [size - 4, size - 4]], fc);80 const p = geoPath(proj);81 return { size, paths: feats.map((f) => [f.properties.code, p(f) ?? ""] as const) };82 }, [geo, ridings, compact]);83 const insetQc = useMemo(() => {84 const feats = geo.features.filter((f) => ["capitale-nationale", "chaudiere-appalaches"].includes(ridings[f.properties.code]?.region ?? ""));85 if (!feats.length) return null;86 const fc = { type: "FeatureCollection", features: feats } as FeatureCollection<Geometry>;87 const size = compact ? 150 : 200;88 const proj = geoMercator().fitExtent([[4, 4], [size - 4, size - 4]], fc);89 const p = geoPath(proj);90 return { size, paths: feats.map((f) => [f.properties.code, p(f) ?? ""] as const) };91 }, [geo, ridings, compact]);9293 useEffect(() => {94 if (!focus) return;95 const f = geo.features.find((x) => x.properties.code === focus);96 if (!f) return;97 const [[x0, y0], [x1, y1]] = path.bounds(f);98 const k = Math.min(8, 0.5 / Math.max((x1 - x0) / W, (y1 - y0) / H));99 setView({ k, x: W / 2 - (k * (x0 + x1)) / 2, y: H / 2 - (k * (y0 + y1)) / 2 });100 }, [focus, geo, path, H]);101102 const onWheel = useCallback((e: React.WheelEvent) => {103 if (!interactive) return;104 e.preventDefault();105 const rect = svgRef.current!.getBoundingClientRect();106 const mx = ((e.clientX - rect.left) / rect.width) * W, my = ((e.clientY - rect.top) / rect.height) * H;107 setView((v) => {108 const k = Math.max(1, Math.min(12, v.k * (e.deltaY < 0 ? 1.15 : 0.87)));109 return { k, x: mx - ((mx - v.x) / v.k) * k, y: my - ((my - v.y) / v.k) * k };110 });111 }, [interactive]);112113 const toSvg = (cx: number, cy: number) => { const r = svgRef.current!.getBoundingClientRect(); return [((cx - r.left) / r.width) * W, ((cy - r.top) / r.height) * H]; };114 const onPointerDown = (e: React.PointerEvent) => { if (!interactive) return; drag.current = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y }; };115 const onPointerMove = (e: React.PointerEvent) => {116 if (drag.current && e.buttons) {117 const r = svgRef.current!.getBoundingClientRect();118 const dx = ((e.clientX - drag.current.x) / r.width) * W, dy = ((e.clientY - drag.current.y) / r.height) * H;119 if (Math.abs(dx) + Math.abs(dy) > 2) setView((v) => ({ ...v, x: drag.current!.vx + dx, y: drag.current!.vy + dy }));120 }121 setTip({ x: e.clientX, y: e.clientY });122 };123 const onPointerUp = () => { drag.current = null; };124 const onTouchMove = (e: React.TouchEvent) => {125 if (e.touches.length === 2) {126 const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);127 if (!pinch.current) pinch.current = { d, k: view.k };128 else {129 const k = Math.max(1, Math.min(12, (pinch.current.k * d) / pinch.current.d));130 const [mx, my] = toSvg((e.touches[0].clientX + e.touches[1].clientX) / 2, (e.touches[0].clientY + e.touches[1].clientY) / 2);131 setView((v) => ({ k, x: mx - ((mx - v.x) / v.k) * k, y: my - ((my - v.y) / v.k) * k }));132 }133 }134 };135 const onTouchEnd = () => { pinch.current = null; };136137 const r = hover !== null ? ridings[hover] : undefined;138 const select = (code: number) => { if (onSelect) onSelect(code); else router.push(`/circonscription/${ridings[code]?.slug ?? code}`); };139140 const renderPath = (code: number, d: string, key?: string) => {141 const rr = ridings[code];142 return (143 <path key={key ?? code} d={d} fill={fillFor(rr, mode)} stroke={hover === code ? "var(--text)" : "var(--surface)"} strokeWidth={hover === code ? 1.6 / view.k : 0.6 / view.k}144 className={interactive ? "cursor-pointer" : undefined} onMouseEnter={() => setHover(code)} onMouseLeave={() => setHover(null)}145 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}146 onKeyDown={(e) => { if (e.key === "Enter") select(code); }} onFocus={() => setHover(code)} onBlur={() => setHover(null)} />147 );148 };149150 return (151 <div className={cls("relative select-none", className)} style={{ touchAction: interactive ? "none" : "auto" }}>152 <svg ref={svgRef} viewBox={`0 0 ${W} ${H}`} className="w-full h-auto block rounded-[10px] bg-surface-2" onWheel={onWheel} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerLeave={() => { onPointerUp(); setHover(null); }} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} role="img" aria-label="Carte électorale du Québec, 127 circonscriptions">153 <g transform={`translate(${view.x},${view.y}) scale(${view.k})`}>154 {geo.features.map((f) => renderPath(f.properties.code, byCode.get(f.properties.code) ?? ""))}155 </g>156 {insetMtl && view.k < 1.8 && (157 <g transform={`translate(${W - insetMtl.size - 10},${H - insetMtl.size - 10})`}>158 <rect width={insetMtl.size} height={insetMtl.size} rx={8} fill="var(--surface)" stroke="var(--border-strong)" />159 <text x={8} y={14} fontSize={10} fontWeight={700} fill="var(--text-2)" letterSpacing={1}>MONTRÉAL · LAVAL</text>160 <g>{insetMtl.paths.map(([code, d]) => renderPath(code, d, `m${code}`))}</g>161 </g>162 )}163 {insetQc && view.k < 1.8 && (164 <g transform={`translate(${W - (insetMtl?.size ?? 0) - insetQc.size - 20},${H - insetQc.size - 10})`}>165 <rect width={insetQc.size} height={insetQc.size} rx={8} fill="var(--surface)" stroke="var(--border-strong)" />166 <text x={8} y={14} fontSize={10} fontWeight={700} fill="var(--text-2)" letterSpacing={1}>QUÉBEC · LÉVIS</text>167 <g>{insetQc.paths.map(([code, d]) => renderPath(code, d, `q${code}`))}</g>168 </g>169 )}170 </svg>171 {interactive && (172 <div className="absolute top-2 right-2 flex flex-col gap-1">173 <button className="w-8 h-8 card text-[16px] font-bold" onClick={() => setView((v) => ({ k: Math.min(12, v.k * 1.4), x: W / 2 - ((W / 2 - v.x) / v.k) * Math.min(12, v.k * 1.4), y: H / 2 - ((H / 2 - v.y) / v.k) * Math.min(12, v.k * 1.4) }))} aria-label="Zoom avant">+</button>174 <button className="w-8 h-8 card text-[16px] font-bold" onClick={() => setView((v) => { const k = Math.max(1, v.k / 1.4); return { k, x: W / 2 - ((W / 2 - v.x) / v.k) * k, y: H / 2 - ((H / 2 - v.y) / v.k) * k }; })} aria-label="Zoom arrière">−</button>175 <button className="w-8 h-8 card text-[11px] font-bold" onClick={() => setView({ k: 1, x: 0, y: 0 })} aria-label="Réinitialiser">⤢</button>176 </div>177 )}178 {r && tip && (179 <div className="pointer-events-none fixed z-50 card p-3 text-[12.5px] shadow-md min-w-[210px]" style={{ left: Math.min(tip.x + 14, (typeof window !== "undefined" ? window.innerWidth : 1000) - 240), top: tip.y + 14 }}>180 <div className="font-semibold text-[13.5px]">{r.name}</div>181 {mode === "live" && r.live ? (182 <div className="mt-1 space-y-0.5">183 <div className="flex justify-between gap-3"><span className="text-ink-2">Statut</span><span>{statusLabel(r.live.status)}</span></div>184 <div className="flex justify-between gap-3"><span className="text-ink-2">En tête</span><span className="font-semibold" style={{ color: partyVar(r.live.leader) }}>{partyLabel(r.live.leader)}</span></div>185 <div className="flex justify-between gap-3"><span className="text-ink-2">Bureaux</span><span className="num">{r.live.bureaux[0]} / {r.live.bureaux[1]}</span></div>186 {r.live.marginPct !== null && <div className="flex justify-between gap-3"><span className="text-ink-2">Marge</span><span className="num">{pct(r.live.marginPct)}</span></div>}187 </div>188 ) : mode === "2022" ? (189 <div className="mt-1 text-ink-2">2022 (transposé) : <span className="font-semibold" style={{ color: partyVar(r.baseline2022) }}>{partyLabel(r.baseline2022)}</span></div>190 ) : r.forecast ? (191 <div className="mt-1 space-y-0.5">192 {Object.entries(r.forecast.pWin).filter(([id]) => id !== "aut").sort((a, b) => b[1] - a[1]).slice(0, 4).map(([id, p]) => (193 <div key={id} className="flex justify-between gap-3"><span className="font-semibold" style={{ color: partyVar(id) }}>{partyLabel(id)}</span><span className="num">{prob(p)}</span></div>194 ))}195 <div className="text-ink-3 pt-1 border-t border-border mt-1">Probabilité de victoire · {ratingLabel(r.forecast.rating)} · marge {r.forecast.margin.toFixed(1).replace(".", ",")} pt</div>196 </div>197 ) : <div className="text-ink-3 mt-1">Pas de projection.</div>}198 </div>199 )}200 </div>201 );202}203