SPB Git forge

spb/qc26

Public
10commits 1branches 0releases
2.8 MBsize
maindefault branch
17 days agolast push
Python 51.6% TypeScript 46.7% CSS 1.7%
13.9 KB · 221 lines tsx
Raw Blame History
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  /** overlay = encarts superposés (desktop) ; below = encarts rendus sous la carte (mobile) */30  insets?: "overlay" | "below" | "none";31}3233function fillFor(r: MapRiding | undefined, mode: MapMode): string {34  if (!r) return "var(--p-none)";35  if (mode === "live") {36    const l = r.live;37    if (!l || l.status === "NOT_STARTED" || !l.leader) return "var(--p-none)";38    const strong = l.status === "ELECTED" || l.status === "FINAL" || l.status === "PROJECTED";39    return strong ? partyVar(l.leader) : partyAlpha(l.leader, 0.45);40  }41  if (mode === "2022") return r.baseline2022 ? partyVar(r.baseline2022) : "var(--p-none)";42  const f = r.forecast;43  if (!f) return "var(--p-none)";44  if (mode === "projection") return partyAlpha(f.favorite, 0.35 + 0.65 * Math.min(1, (f.p - 0.4) / 0.6));45  if (mode === "probabilite") return partyAlpha(f.favorite, 0.25 + 0.75 * f.p);46  if (mode === "marge") return partyAlpha(f.favorite, Math.min(1, 0.25 + f.margin / 30));47  if (mode === "variation") {48    if (r.baseline2022 && r.baseline2022 !== f.favorite) return partyVar(f.favorite);49    return partyAlpha(f.favorite, 0.18);50  }51  return partyVar(f.favorite);52}5354export function QuebecMap({ geo, ridings, mode, height = 560, interactive = true, focus = null, className, onSelect, compact = false, insets = "overlay" }: Props) {55  const router = useRouter();56  const W = 900, H = height;57  const [hover, setHover] = useState<number | null>(null);58  const [tip, setTip] = useState<{ x: number; y: number } | null>(null);59  const [view, setView] = useState<{ k: number; x: number; y: number }>({ k: 1, x: 0, y: 0 });60  const svgRef = useRef<SVGSVGElement>(null);61  const drag = useRef<{ x: number; y: number; vx: number; vy: number } | null>(null);62  const pinch = useRef<{ d: number; k: number } | null>(null);6364  const { path, byCode } = useMemo(() => {65    // Cadrage sur le Québec habité : Ungava (Nord-du-Québec, code 831) est volontairement tronqué au nord,66    // sinon il occupe les deux tiers de la carte. Il reste visible et cliquable dans la partie haute.67    const south = { type: "FeatureCollection", features: geo.features.filter((f) => f.properties.code !== 831) } as FeatureCollection<Geometry>;68    const proj = geoMercator().fitExtent([[8, 40], [W - 8, H - 8]], south);69    const p = geoPath(proj);70    const m = new Map<number, string>();71    geo.features.forEach((f) => m.set(f.properties.code, p(f) ?? ""));72    return { path: p, byCode: m };73  }, [geo, H]);7475  // insets : Montréal (zoom) — bounding box calculée sur les circonscriptions de l'île + Laval76  const insetMtl = useMemo(() => {77    const feats = geo.features.filter((f) => ["montreal", "laval"].includes((ridings[f.properties.code]?.region ?? "")));78    if (!feats.length) return null;79    const fc = { type: "FeatureCollection", features: feats } as FeatureCollection<Geometry>;80    const size = compact ? 200 : 260;81    const proj = geoMercator().fitExtent([[4, 4], [size - 4, size - 4]], fc);82    const p = geoPath(proj);83    return { size, paths: feats.map((f) => [f.properties.code, p(f) ?? ""] as const) };84  }, [geo, ridings, compact]);85  const insetQc = useMemo(() => {86    const feats = geo.features.filter((f) => ["capitale-nationale", "chaudiere-appalaches"].includes(ridings[f.properties.code]?.region ?? ""));87    if (!feats.length) return null;88    const fc = { type: "FeatureCollection", features: feats } as FeatureCollection<Geometry>;89    const size = compact ? 150 : 200;90    const proj = geoMercator().fitExtent([[4, 4], [size - 4, size - 4]], fc);91    const p = geoPath(proj);92    return { size, paths: feats.map((f) => [f.properties.code, p(f) ?? ""] as const) };93  }, [geo, ridings, compact]);9495  useEffect(() => {96    if (!focus) return;97    const f = geo.features.find((x) => x.properties.code === focus);98    if (!f) return;99    const [[x0, y0], [x1, y1]] = path.bounds(f);100    const k = Math.min(8, 0.5 / Math.max((x1 - x0) / W, (y1 - y0) / H));101    setView({ k, x: W / 2 - (k * (x0 + x1)) / 2, y: H / 2 - (k * (y0 + y1)) / 2 });102  }, [focus, geo, path, H]);103104  const onWheel = useCallback((e: React.WheelEvent) => {105    if (!interactive) return;106    e.preventDefault();107    const rect = svgRef.current!.getBoundingClientRect();108    const mx = ((e.clientX - rect.left) / rect.width) * W, my = ((e.clientY - rect.top) / rect.height) * H;109    setView((v) => {110      const k = Math.max(1, Math.min(12, v.k * (e.deltaY < 0 ? 1.15 : 0.87)));111      return { k, x: mx - ((mx - v.x) / v.k) * k, y: my - ((my - v.y) / v.k) * k };112    });113  }, [interactive]);114115  const toSvg = (cx: number, cy: number) => { const r = svgRef.current!.getBoundingClientRect(); return [((cx - r.left) / r.width) * W, ((cy - r.top) / r.height) * H]; };116  const onPointerDown = (e: React.PointerEvent) => { if (!interactive) return; drag.current = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y }; };117  const onPointerMove = (e: React.PointerEvent) => {118    if (drag.current && e.buttons) {119      const r = svgRef.current!.getBoundingClientRect();120      const dx = ((e.clientX - drag.current.x) / r.width) * W, dy = ((e.clientY - drag.current.y) / r.height) * H;121      if (Math.abs(dx) + Math.abs(dy) > 2) setView((v) => ({ ...v, x: drag.current!.vx + dx, y: drag.current!.vy + dy }));122    }123    setTip({ x: e.clientX, y: e.clientY });124  };125  const onPointerUp = () => { drag.current = null; };126  const onTouchMove = (e: React.TouchEvent) => {127    if (e.touches.length === 2) {128      const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);129      if (!pinch.current) pinch.current = { d, k: view.k };130      else {131        const k = Math.max(1, Math.min(12, (pinch.current.k * d) / pinch.current.d));132        const [mx, my] = toSvg((e.touches[0].clientX + e.touches[1].clientX) / 2, (e.touches[0].clientY + e.touches[1].clientY) / 2);133        setView((v) => ({ k, x: mx - ((mx - v.x) / v.k) * k, y: my - ((my - v.y) / v.k) * k }));134      }135    }136  };137  const onTouchEnd = () => { pinch.current = null; };138139  const r = hover !== null ? ridings[hover] : undefined;140  const select = (code: number) => { if (onSelect) onSelect(code); else router.push(`/circonscription/${ridings[code]?.slug ?? code}`); };141142  const renderPath = (code: number, d: string, key?: string) => {143    const rr = ridings[code];144    return (145      <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}146        className={interactive ? "cursor-pointer" : undefined} onMouseEnter={() => setHover(code)} onMouseLeave={() => setHover(null)}147        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}148        onKeyDown={(e) => { if (e.key === "Enter") select(code); }} onFocus={() => setHover(code)} onBlur={() => setHover(null)} />149    );150  };151152  return (153    <div className={cls("relative select-none", className)} style={{ touchAction: interactive ? "none" : "auto" }}>154      <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">155        <g transform={`translate(${view.x},${view.y}) scale(${view.k})`}>156          {geo.features.map((f) => renderPath(f.properties.code, byCode.get(f.properties.code) ?? ""))}157        </g>158        {insets === "overlay" && insetMtl && view.k < 1.8 && (159          <g transform={`translate(${W - insetMtl.size - 10},${H - insetMtl.size - 10})`}>160            <rect width={insetMtl.size} height={insetMtl.size} rx={8} fill="var(--surface)" stroke="var(--border-strong)" />161            <text x={8} y={14} fontSize={10} fontWeight={700} fill="var(--text-2)" letterSpacing={1}>MONTRÉAL · LAVAL</text>162            <g>{insetMtl.paths.map(([code, d]) => renderPath(code, d, `m${code}`))}</g>163          </g>164        )}165        {insets === "overlay" && insetQc && view.k < 1.8 && (166          <g transform={`translate(${W - (insetMtl?.size ?? 0) - insetQc.size - 20},${H - insetQc.size - 10})`}>167            <rect width={insetQc.size} height={insetQc.size} rx={8} fill="var(--surface)" stroke="var(--border-strong)" />168            <text x={8} y={14} fontSize={10} fontWeight={700} fill="var(--text-2)" letterSpacing={1}>QUÉBEC · LÉVIS</text>169            <g>{insetQc.paths.map(([code, d]) => renderPath(code, d, `q${code}`))}</g>170          </g>171        )}172      </svg>173      {insets === "below" && (insetMtl || insetQc) && (174        <div className="grid grid-cols-2 gap-2 mt-2">175          {insetMtl && (176            <div className="card p-2">177              <div className="mono text-[10px] font-semibold tracking-[0.08em] text-ink-2 mb-1">MONTRÉAL · LAVAL</div>178              <svg viewBox={`0 0 ${insetMtl.size} ${insetMtl.size}`} className="w-full h-auto block" role="img" aria-label="Encart Montréal et Laval">{insetMtl.paths.map(([code, d]) => renderPath(code, d, `mb${code}`))}</svg>179            </div>180          )}181          {insetQc && (182            <div className="card p-2">183              <div className="mono text-[10px] font-semibold tracking-[0.08em] text-ink-2 mb-1">QUÉBEC · LÉVIS</div>184              <svg viewBox={`0 0 ${insetQc.size} ${insetQc.size}`} className="w-full h-auto block" role="img" aria-label="Encart Québec et Lévis">{insetQc.paths.map(([code, d]) => renderPath(code, d, `qb${code}`))}</svg>185            </div>186          )}187        </div>188      )}189      {interactive && (190        <div className="absolute top-2 right-2 flex flex-col gap-1">191          <button className="w-9 h-9 md:w-8 md: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>192          <button className="w-9 h-9 md:w-8 md: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>193          <button className="w-9 h-9 md:w-8 md:h-8 card text-[11px] font-bold" onClick={() => setView({ k: 1, x: 0, y: 0 })} aria-label="Réinitialiser">⤢</button>194        </div>195      )}196      {r && tip && (197        <div className="pointer-events-none fixed z-50 card p-3 text-[12.5px] shadow-md min-w-[210px] max-md:left-3 max-md:right-3 max-md:bottom-[calc(var(--bottom-nav-h)+env(safe-area-inset-bottom)+12px)] max-md:top-auto" style={typeof window !== "undefined" && window.innerWidth >= 768 ? { left: Math.min(tip.x + 14, window.innerWidth - 240), top: tip.y + 14 } : undefined}>198          <div className="font-semibold text-[13.5px]">{r.name}</div>199          {mode === "live" && r.live ? (200            <div className="mt-1 space-y-0.5">201              <div className="flex justify-between gap-3"><span className="text-ink-2">Statut</span><span>{statusLabel(r.live.status)}</span></div>202              <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>203              <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>204              {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>}205            </div>206          ) : mode === "2022" ? (207            <div className="mt-1 text-ink-2">2022 (transposé) : <span className="font-semibold" style={{ color: partyVar(r.baseline2022) }}>{partyLabel(r.baseline2022)}</span></div>208          ) : r.forecast ? (209            <div className="mt-1 space-y-0.5">210              {Object.entries(r.forecast.pWin).filter(([id]) => id !== "aut").sort((a, b) => b[1] - a[1]).slice(0, 4).map(([id, p]) => (211                <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>212              ))}213              <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>214            </div>215          ) : <div className="text-ink-3 mt-1">Pas de projection.</div>}216        </div>217      )}218    </div>219  );220}221