Python 51.6%
TypeScript 46.7%
CSS 1.7%
1"use client";23import Link from "next/link";4import { useMemo, useState } from "react";5import type { RidingListItem } from "@/lib/api";6import { cls, prob, ratingLabel } from "@/lib/format";7import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";8import { PartyBadge } from "./party";9import { Badge } from "./ui";1011function norm(s: string) { return s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]/g, ""); }1213export function RidingsGrid({ ridings, regions, initialSort = "nom" }: { ridings: RidingListItem[]; regions: { id: string; name: string }[]; initialSort?: string }) {14 const [q, setQ] = useState("");15 const [region, setRegion] = useState("all");16 const [fav, setFav] = useState("all");17 const [sort, setSort] = useState<string>(initialSort);18 const rows = useMemo(() => {19 const nq = norm(q);20 let r = ridings.filter((x) => (!nq || norm(x.name).includes(nq)) && (region === "all" || x.region === region) && (fav === "all" || x.forecast?.favorite === fav));21 if (sort === "serre") r = r.slice().sort((a, b) => (a.forecast?.p ?? 1) - (b.forecast?.p ?? 1));22 else if (sort === "sur") r = r.slice().sort((a, b) => (b.forecast?.p ?? 0) - (a.forecast?.p ?? 0));23 else if (sort === "swing") r = r.slice().sort((a, b) => Math.abs(b.forecast?.swing ?? 0) - Math.abs(a.forecast?.swing ?? 0));24 return r;25 }, [ridings, q, region, fav, sort]);26 return (27 <div>28 <div className="flex flex-wrap gap-2 items-center mb-4 text-[13.5px]">29 <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Rechercher une circonscription…" className="card px-3 py-2 w-full sm:w-[280px] outline-none focus:border-accent" aria-label="Rechercher" />30 <select value={region} onChange={(e) => setRegion(e.target.value)} className="card px-2.5 py-2" aria-label="Région"><option value="all">Toutes les régions</option>{regions.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}</select>31 <select value={fav} onChange={(e) => setFav(e.target.value)} className="card px-2.5 py-2" aria-label="Favori"><option value="all">Tous les favoris</option>{PARTY_IDS.map((id) => <option key={id} value={id}>{partyLabel(id)} favori</option>)}</select>32 <select value={sort} onChange={(e) => setSort(e.target.value)} className="card px-2.5 py-2" aria-label="Tri"><option value="nom">Ordre alphabétique</option><option value="serre">Les plus serrées</option><option value="sur">Les plus sûres</option><option value="swing">Plus grand mouvement vs 2022</option></select>33 <span className="ml-auto text-ink-3 num">{rows.length} / {ridings.length}</span>34 </div>35 <div className="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">36 {rows.map((r) => {37 const f = r.forecast;38 const pw = f ? Object.entries(f.pWin).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1]) : [];39 return (40 <Link key={r.code} href={`/circonscription/${r.slug}`} className="card p-4 block hover:shadow-md transition-shadow">41 <div className="flex items-start justify-between gap-2">42 <div><div className="font-semibold text-[15px] leading-tight">{r.name}</div><div className="text-[11.5px] text-ink-3 mt-0.5">{r.regionName}{r.isNew ? " · nouvelle / renommée" : r.changed ? " · limites modifiées" : ""}</div></div>43 {f && <Badge tone={f.rating === "tossup" ? "warn" : f.rating === "lean" ? "accent" : "neutral"}>{ratingLabel(f.rating)}</Badge>}44 </div>45 {f ? (46 <>47 <div className="mt-3 space-y-1">48 {pw.slice(0, 5).map(([id, p]) => (49 <div key={id} className={cls("grid grid-cols-[38px_1fr_46px] items-center gap-2 text-[12.5px]", p < 0.02 && "opacity-50")}>50 <span className="font-semibold num" style={{ color: partyVar(id) }}>{partyLabel(id)}</span>51 <div className="h-[6px] rounded-full bg-surface-3 overflow-hidden"><div className="h-full" style={{ width: `${p * 100}%`, background: partyVar(id) }} /></div>52 <span className="num text-right">{prob(p)}</span>53 </div>54 ))}55 </div>56 <div className="mt-3 flex items-center justify-between text-[12px]"><span className="inline-flex items-center gap-1.5"><PartyBadge id={f.favorite} size="sm" /> favori</span>{r.live && r.live.status !== "NOT_STARTED" && <span className="text-ink-2 num">{r.live.bureaux[0]}/{r.live.bureaux[1]} bureaux</span>}<span className="text-ink-3">sortant·e {r.incumbent.party ? partyLabel(r.incumbent.party) : "—"}</span></div>57 </>58 ) : <div className="text-ink-3 text-[12.5px] mt-3">Pas de projection.</div>}59 </Link>60 );61 })}62 </div>63 <p className="text-[12px] text-ink-3 mt-4">Probabilité de victoire = part des simulations où le parti obtient le plus de voix dans la circonscription.</p>64 </div>65 );66}67