SPB Git

spb/qc26

Public
12.5 KB · 203 lines tsx
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 { partyColor, partyLabel, withAlpha } 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 "#E5E7EB";33  if (mode === "live") {34    const l = r.live;35    if (!l || l.status === "NOT_STARTED" || !l.leader) return "#E5E7EB";36    const strong = l.status === "ELECTED" || l.status === "FINAL" || l.status === "PROJECTED";37    return strong ? partyColor(l.leader) : withAlpha(partyColor(l.leader), 0.45);38  }39  if (mode === "2022") return r.baseline2022 ? partyColor(r.baseline2022) : "#E5E7EB";40  const f = r.forecast;41  if (!f) return "#E5E7EB";42  if (mode === "projection") return withAlpha(partyColor(f.favorite), 0.35 + 0.65 * Math.min(1, (f.p - 0.4) / 0.6));43  if (mode === "probabilite") return withAlpha(partyColor(f.favorite), 0.25 + 0.75 * f.p);44  if (mode === "marge") return withAlpha(partyColor(f.favorite), Math.min(1, 0.25 + f.margin / 30));45  if (mode === "variation") {46    if (r.baseline2022 && r.baseline2022 !== f.favorite) return partyColor(f.favorite);47    return withAlpha(partyColor(f.favorite), 0.18);48  }49  return partyColor(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)" : "rgba(255,255,255,.9)"} 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: partyColor(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: partyColor(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: partyColor(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
Files · f3bb07025804f73080c3fe80ab3c20f6aeea6682
Menu

Theme