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%

QC26 2.0.0 — au-delà des sondages + refonte visuelle

Moteur : signaux (Polymarket, Wikipédia, presse Serper+LLM, GDELT, questions de leadership, partielles DGEQ),
ensemble (prior fondamentaux, momentum borné, tempérage par repondération IPF vers les marchés, ajustement
partielles), statistiques pondérées, summary.components (sondages seuls / sans marchés / ensemble), API
/api/signals*, /api/forecast/components, jobs planifiés, admin signals/refresh, changelog 2.0.0.
Correctifs : sortants depuis la liste DGEQ des candidatures (5 circonscriptions de partielles erronées),
chemins d'archive relocalisables, horodatages UTC explicites (TZDateTime).
Web : design system clair/sombre (Fraunces, Inter, JetBrains Mono, accent sarcelle), couleurs de partis en
variables CSS, héros éditorial avec gaufre des 127 sièges, comparaison sondages/marchés/ensemble, page
/signaux, tendance interactive, refonte nav/mobile/footer, méthodologie v2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 17 days ago (Sep 7, 2026) parent 570fe76

70 changed files +3,279 −634

modified CLAUDE.md +7 −2
@@ -12,11 +12,16 @@ Spécification produit complète : voir la mission QC26 (design « Editorial Dat
12 12 ## Où sont les choses
13 13 - Moteur : `engine/` (FastAPI :8201). Base SQLite `engine/data/qc26.db` (WAL). Archives `engine/data/archive/{polls,documents,live}` par SHA-256. Copies officielles DGEQ `engine/data/official/`.
14 14 - Web : `web/` (Next 16, :8200), rewrite `/api/*` → moteur. Composants du design system dans `web/src/components/`.
15 - Modèle : `engine/qc26/model/` (aggregate = poids/effets maison/Kalman ; ridings = swing proportionnel + erreurs ; run = orchestration, diff, impacts). Changer un paramètre = bump `model_version` + entrée `CHANGELOG` dans `api/public.py`.
15 +- Modèle : `engine/qc26/model/` (aggregate = poids/effets maison/Kalman ; ridings = swing proportionnel + erreurs ; ensemble = v2 : prior fondamentaux, momentum borné, tempérage par les marchés via repondération IPF des simulations, ajustement partielles ; run = orchestration, stats pondérées, `summary.components`, diff). Changer un paramètre = bump `model_version` + entrée `CHANGELOG` dans `api/public.py`.
16 +- Signaux (v2) : `engine/qc26/signals/` — markets (Polymarket Gamma/CLOB), attention (Wikimedia pageviews), media (Serper news + classement LLM ; GDELT best-effort, 1 req/6,5 s), fundamentals (questions de leadership des sondages → satisfaction, meilleur PM, favorabilité…), byelections (DGEQ via Firecrawl, code URL = code DGEQ carte 2017). Tables `signals`, `markets`, `market_prices`, `media_items`, `by_elections`. API `/api/signals*`, `/api/forecast/components`, admin `POST /api/admin/signals/refresh?what=all`. Page `/signaux`.
17 +- Design : tokens clair/sombre dans `web/src/app/globals.css` (`data-theme`, script inline `THEME_INIT_SCRIPT`), Fraunces (display) + Inter + JetBrains Mono ; couleurs de partis via `partyVar()/partyAlpha()` (variables CSS `--p-*`, éclaircies en sombre) — `partyColor()` (hex) réservé aux images OG.
16 18 - Soirée électorale : `engine/qc26/live/` (feed officiel, simulation 2022, nowcast). `POST /api/admin/live/simulation/start` rejoue 2022 en accéléré pour tester la chaîne complète.
17 19
18 20 ## Gotchas
19 - SQLite : ne jamais tenir une transaction ouverte pendant un appel LLM (commits explicites dans les connecteurs) ; timeout 90 s.
21 +- SQLite : ne jamais tenir une transaction ouverte pendant un appel LLM (commits explicites dans les connecteurs) ; timeout 90 s. Les `DateTime` passent par `TZDateTime` (UTC explicite à la relecture) : toujours `.isoformat()` sur des objets tz-aware.
22 +- Chemins d'archive stockés absolus : lire via `util.resolve_archive_path()` (la base a été créée sur le laptop puis déplacée).
23 +- Sortant·e = drapeau `depute_sortant` de la liste DGEQ des candidatures, puis gagnant de partielle, puis gagnant 2022 transposé (jamais l'inverse).
24 +- Fraunces via `next/font/google` : `axes` seulement sans `weight` (police variable).
20 25 - DGEQ : « PCQ » = Parti communiste, le Parti conservateur est « PCOQ » ; CSV en cp1252 avec tiret 0x96 (`util.fix_dgeq_dash`).
21 26 - Wikipédia n'est qu'un index d'amorçage (tier 5) : les lignes `secondary-index` sont remplacées par le PDF de la firme dès qu'il est archivé ; les URL médias (La Presse, L'actualité, X…) ne sont pas des documents primaires.
22 27 - La règle « pas de candidature → part résiduelle » ne s'active que pour un parti dont la liste est ≥ 90 % complète.
modified README.md +5 −0
@@ -50,3 +50,8 @@ Le premier démarrage sur base vide télécharge les données officielles, amorc
50 50 ## Production
51 51
52 52 Déployé par `mld` (passerelle M1M32) : `mld stage <dir> qc26``mld deploy qc26`. Deux processus PM2 (`qc26-engine` :8201, `qc26-web` :8200) + `qc26-ngrok` (www.qc26.ca → 8200). Le web réécrit `/api/*` vers le moteur.
53 +
54 +
55 +## Modèle 2.0 (2026-09-07) — au-delà des sondages
56 +
57 +Quatre familles de signaux indépendants, archivés avec leur provenance et publiés séparément (`/signaux`, `summary.components`) : marchés de prédiction Polymarket (repondération des simulations, poids 30 %), fondamentaux (satisfaction du gouvernement → prior sur le parti sortant, poids décroissant vers 0 le jour du vote), momentum borné (prime au chef, attention Wikipédia, ton de presse classé par LLM), élections partielles depuis 2022 (excès de swing local). La vue « sondages seuls » reste publiée. Refonte visuelle « Editorial Data Intelligence » : thème clair/sombre, Fraunces + Inter + JetBrains Mono, gaufre des 127 sièges, tendance interactive.
modified deploy/qc26.manifest.json +125 −123
@@ -1,127 +1,129 @@
1 1 {
2 "app": "qc26",
3 "label": "QC26 — L'élection québécoise 2026, en données",
4 "domain": "www.qc26.ca",
5 "port": 8200,
6 "health_path": "/healthz",
7 "dir": "~/apps/qc26",
8 "extra_paths": [],
9 "sync_excludes": [
10 "engine/.venv/",
11 "__pycache__/",
12 ".pytest_cache/",
13 ".ruff_cache/",
14 "*.egg-info/",
15 ".git/",
16 "web/node_modules/",
17 "web/.next/",
18 "engine/logs/",
19 "logs/",
20 ".DS_Store",
21 "engine/data/qc26.db",
22 "engine/data/qc26.db-shm",
23 "engine/data/qc26.db-wal",
24 "engine/data/archive/",
25 "engine/data/cache/"
2 + "app": "qc26",
3 + "label": "QC26 — L'élection québécoise 2026, en données",
4 + "domain": "www.qc26.ca",
5 + "port": 8200,
6 + "health_path": "/healthz",
7 + "dir": "~/apps/qc26",
8 + "extra_paths": [],
9 + "sync_excludes": [
10 + "engine/.venv/",
11 + "__pycache__/",
12 + ".pytest_cache/",
13 + ".ruff_cache/",
14 + "*.egg-info/",
15 + ".git/",
16 + "web/node_modules/",
17 + "web/.next/",
18 + "engine/logs/",
19 + "logs/",
20 + ".DS_Store",
21 + "engine/data/qc26.db",
22 + "engine/data/qc26.db-shm",
23 + "engine/data/qc26.db-wal",
24 + "engine/data/archive/",
25 + "engine/data/cache/"
26 + ],
27 + "requires": {
28 + "runtimes": [
29 + "pm2",
30 + "ngrok",
31 + "node",
32 + "pnpm",
33 + "uv",
34 + "uv-python@3.12"
26 35 ],
27 "requires": {
28 "runtimes": [
29 "pm2",
30 "ngrok",
31 "node",
32 "pnpm",
33 "uv",
34 "uv-python@3.12"
35 ],
36 "ram_gb": 4,
37 "ports": [
38 8200,
39 8201
40 ]
41 },
42 "ram_mb_observed": 1200,
43 "size_mb": 120,
44 "placement": {
45 "pin": null,
46 "prefer": "M3U96a",
47 "avoid": [
48 "M3U96b",
49 "M1M32"
50 ],
51 "reason": "Monte Carlo numpy + build Next.js : cœurs et RAM ; uv-py3.12 + pnpm présents ; M3U96b réservé hfmarketdata"
52 },
53 "processes": [
54 {
55 "name": "qc26-engine",
56 "manager": "pm2",
57 "script": "{{HOME}}/apps/qc26/engine/.venv/bin/python",
58 "args": [
59 "-m",
60 "uvicorn",
61 "qc26.main:app",
62 "--host",
63 "127.0.0.1",
64 "--port",
65 "8201",
66 "--no-access-log"
67 ],
68 "interpreter": null,
69 "cwd": "{{HOME}}/apps/qc26/engine",
70 "env": {
71 "QC26_ENVIRONMENT": "production",
72 "QC26_PORT": "8201",
73 "QC26_PUBLIC_URL": "https://www.qc26.ca",
74 "QC26_ADMIN_TOKEN": "qc26-admin-2026-Kp8vT3nQ7zLw",
75 "QC26_FIRECRAWL_API_KEY": "fc-8cc44cb683044aee82e8cbad6505a01a",
76 "QC26_ANTHROPIC_API_KEY": "__SET_AT_DEPLOY__",
77 "QC26_LLM_MODEL": "claude-opus-5",
78 "QC26_N_SIMULATIONS": "20000",
79 "PYTHONUNBUFFERED": "1"
80 },
81 "cron_restart": null,
82 "autorestart": true,
83 "max_memory_restart": "3G"
84 },
85 {
86 "name": "qc26-web",
87 "manager": "pm2",
88 "script": "/opt/homebrew/bin/node",
89 "args": [
90 "node_modules/next/dist/bin/next",
91 "start",
92 "-p",
93 "8200",
94 "-H",
95 "0.0.0.0"
96 ],
97 "interpreter": null,
98 "cwd": "{{HOME}}/apps/qc26/web",
99 "env": {
100 "NODE_ENV": "production",
101 "QC26_ENGINE_URL": "http://127.0.0.1:8201",
102 "NEXT_PUBLIC_SITE_URL": "https://www.qc26.ca",
103 "NEXT_TELEMETRY_DISABLED": "1"
104 },
105 "cron_restart": null,
106 "autorestart": true,
107 "max_memory_restart": "1500M"
108 }
36 + "ram_gb": 4,
37 + "ports": [
38 + 8200,
39 + 8201
40 + ]
41 + },
42 + "ram_mb_observed": 1200,
43 + "size_mb": 120,
44 + "placement": {
45 + "pin": null,
46 + "prefer": "M3U96a",
47 + "avoid": [
48 + "M3U96b",
49 + "M1M32"
109 50 ],
110 "ngrok": {
111 "name": "qc26-ngrok",
112 "url": "www.qc26.ca",
113 "port": 8200
51 + "reason": "Monte Carlo numpy + build Next.js : cœurs et RAM ; uv-py3.12 + pnpm présents ; M3U96b réservé hfmarketdata"
52 + },
53 + "processes": [
54 + {
55 + "name": "qc26-engine",
56 + "manager": "pm2",
57 + "script": "{{HOME}}/apps/qc26/engine/.venv/bin/python",
58 + "args": [
59 + "-m",
60 + "uvicorn",
61 + "qc26.main:app",
62 + "--host",
63 + "127.0.0.1",
64 + "--port",
65 + "8201",
66 + "--no-access-log"
67 + ],
68 + "interpreter": null,
69 + "cwd": "{{HOME}}/apps/qc26/engine",
70 + "env": {
71 + "QC26_ENVIRONMENT": "production",
72 + "QC26_PORT": "8201",
73 + "QC26_PUBLIC_URL": "https://www.qc26.ca",
74 + "QC26_ADMIN_TOKEN": "qc26-admin-2026-Kp8vT3nQ7zLw",
75 + "QC26_FIRECRAWL_API_KEY": "fc-8cc44cb683044aee82e8cbad6505a01a",
76 + "QC26_ANTHROPIC_API_KEY": "__SET_AT_DEPLOY__",
77 + "QC26_LLM_MODEL": "claude-opus-5",
78 + "QC26_N_SIMULATIONS": "20000",
79 + "PYTHONUNBUFFERED": "1",
80 + "QC26_SERPER_API_KEY": "__SET_AT_DEPLOY__",
81 + "QC26_TAVILY_API_KEY": "__SET_AT_DEPLOY__"
82 + },
83 + "cron_restart": null,
84 + "autorestart": true,
85 + "max_memory_restart": "3G"
114 86 },
115 "launchd": [],
116 "env_overrides": {},
117 "hooks": {
118 "post_sync": [
119 "export PATH=\"$HOME/.local/bin:/opt/homebrew/bin:$PATH\"; cd engine && (test -x .venv/bin/python || uv venv --python 3.12 .venv) && uv pip install -q --python .venv/bin/python -e . && echo ' engine deps ok'",
120 "export PATH=\"/opt/homebrew/bin:$PATH\"; cd web && pnpm install --frozen-lockfile --silent && echo ' web deps ok'",
121 "export PATH=\"/opt/homebrew/bin:$PATH\"; cd web && QC26_ENGINE_URL=http://127.0.0.1:8201 NEXT_PUBLIC_SITE_URL=https://www.qc26.ca NEXT_TELEMETRY_DISABLED=1 pnpm build 2>&1 | tail -3 && echo ' web build ok'",
122 "mkdir -p engine/data/archive/polls engine/data/archive/documents engine/data/archive/live engine/data/cache engine/logs && echo ' data ok'"
123 ],
124 "post_start": []
125 },
126 "notes": "Après le premier déploiement (2026-09-06), la base et les archives du nœud sont la source de vérité : exclues des syncs suivants."
127 }
\ No newline at end of file
87 + {
88 + "name": "qc26-web",
89 + "manager": "pm2",
90 + "script": "/opt/homebrew/bin/node",
91 + "args": [
92 + "node_modules/next/dist/bin/next",
93 + "start",
94 + "-p",
95 + "8200",
96 + "-H",
97 + "0.0.0.0"
98 + ],
99 + "interpreter": null,
100 + "cwd": "{{HOME}}/apps/qc26/web",
101 + "env": {
102 + "NODE_ENV": "production",
103 + "QC26_ENGINE_URL": "http://127.0.0.1:8201",
104 + "NEXT_PUBLIC_SITE_URL": "https://www.qc26.ca",
105 + "NEXT_TELEMETRY_DISABLED": "1"
106 + },
107 + "cron_restart": null,
108 + "autorestart": true,
109 + "max_memory_restart": "1500M"
110 + }
111 + ],
112 + "ngrok": {
113 + "name": "qc26-ngrok",
114 + "url": "www.qc26.ca",
115 + "port": 8200
116 + },
117 + "launchd": [],
118 + "env_overrides": {},
119 + "hooks": {
120 + "post_sync": [
121 + "export PATH=\"$HOME/.local/bin:/opt/homebrew/bin:$PATH\"; cd engine && (test -x .venv/bin/python || uv venv --python 3.12 .venv) && uv pip install -q --python .venv/bin/python -e . && echo ' engine deps ok'",
122 + "export PATH=\"/opt/homebrew/bin:$PATH\"; cd web && pnpm install --frozen-lockfile --silent && echo ' web deps ok'",
123 + "export PATH=\"/opt/homebrew/bin:$PATH\"; cd web && QC26_ENGINE_URL=http://127.0.0.1:8201 NEXT_PUBLIC_SITE_URL=https://www.qc26.ca NEXT_TELEMETRY_DISABLED=1 pnpm build 2>&1 | tail -3 && echo ' web build ok'",
124 + "mkdir -p engine/data/archive/polls engine/data/archive/documents engine/data/archive/live engine/data/cache engine/logs && echo ' data ok'"
125 + ],
126 + "post_start": []
127 + },
128 + "notes": "Après le premier déploiement (2026-09-06), la base et les archives du nœud sont la source de vérité : exclues des syncs suivants. v2.0 (2026-09-07) : clés Serper/Tavily requises pour la revue de presse ; les tables signaux sont créées au démarrage (create_all)."
129 +}
modified engine/qc26/api/admin.py +26 −0
@@ -110,6 +110,32 @@ def compass_positions(only_missing: bool = True, s: Session = Depends(db)):
110 110 return positions.compute_positions(s, only_missing=only_missing)
111 111
112 112
113 +@router.post("/signals/refresh", dependencies=[Depends(require_admin)])
114 +def signals_refresh(what: str = "all", run: bool = True, s: Session = Depends(db)):
115 + """Rafraîchit les signaux (markets | attention | fundamentals | byelections | news | gdelt | all) puis recalcule."""
116 + from ..ingest import official
117 + from ..signals import attention, byelections, fundamentals, markets, media
118 + out = {}
119 + parts = [x.strip() for x in what.split(",")] if what != "all" else ["markets", "attention", "fundamentals", "byelections", "news"]
120 + if "markets" in parts:
121 + out["markets"] = markets.sync_markets(s); s.commit()
122 + if "attention" in parts:
123 + out["attention"] = attention.sync_attention(s); s.commit()
124 + if "fundamentals" in parts:
125 + out["fundamentals"] = fundamentals.extract_from_polls(s); s.commit()
126 + if "byelections" in parts:
127 + out["byelections"] = byelections.sync_by_elections(s); official.apply_by_election_incumbents(s); s.commit()
128 + if "news" in parts:
129 + out["news"] = media.sync_news(s, classify=True); s.commit()
130 + if "gdelt" in parts:
131 + out["gdelt"] = media.sync_gdelt(s); s.commit()
132 + if run:
133 + from ..model.run import run_forecast
134 + r = run_forecast(s, trigger="admin:signals")
135 + out["run"] = r.id
136 + return out
137 +
138 +
113 139 @router.get("/review", dependencies=[Depends(require_admin)])
114 140 def review_queue(s: Session = Depends(db)):
115 141 polls = s.scalars(select(Poll).where(Poll.validated == False).order_by(desc(Poll.fieldwork_end))).all() # noqa: E712
modified engine/qc26/api/public.py +74 −4
@@ -389,7 +389,11 @@ def poll_document_file(sha: str, s: Session = Depends(db)):
389 389 doc = s.scalar(select(PollDocument).where(PollDocument.sha256 == sha))
390 390 if not doc:
391 391 raise HTTPException(404)
392 return FileResponse(doc.path, media_type=doc.content_type or "application/octet-stream", filename=f"qc26-archive-{sha[:12]}{'.pdf' if doc.path.endswith('.pdf') else '.html'}")
392 + from ..util import resolve_archive_path
393 + fp = resolve_archive_path(doc.path)
394 + if not fp or not fp.exists():
395 + raise HTTPException(404, "fichier d'archive introuvable")
396 + return FileResponse(str(fp), media_type=doc.content_type or "application/octet-stream", filename=f"qc26-archive-{sha[:12]}{'.pdf' if doc.path.endswith('.pdf') else '.html'}")
393 397
394 398
395 399 @router.get("/documents/{doc_id}")
@@ -413,7 +417,11 @@ def document_file(doc_id: int, vid: int, s: Session = Depends(db)):
413 417 v = s.get(DocumentVersion, vid)
414 418 if not v or v.document_id != doc_id:
415 419 raise HTTPException(404)
416 return FileResponse(v.path, media_type=v.content_type or "application/octet-stream")
420 + from ..util import resolve_archive_path
421 + fp = resolve_archive_path(v.path)
422 + if not fp or not fp.exists():
423 + raise HTTPException(404, "fichier d'archive introuvable")
424 + return FileResponse(str(fp), media_type=v.content_type or "application/octet-stream")
417 425
418 426
419 427 @router.get("/proposals")
@@ -623,7 +631,8 @@ def replay(t: str | None = None, s: Session = Depends(db)):
623 631 if not target:
624 632 raise HTTPException(404)
625 633 from pathlib import Path
626 data = json.loads(Path(target.path).read_text())
634 + from ..util import resolve_archive_path
635 + data = json.loads((resolve_archive_path(target.path) or Path(target.path)).read_text())
627 636 ridings_by_name = {}
628 637 from ..util import fix_dgeq_dash, norm_name
629 638 for r in s.scalars(select(Riding)).all():
@@ -678,7 +687,8 @@ def status(s: Session = Depends(db)):
678 687 return {"components": {"pollWatcher": "operational" if poll_srcs and all(x.status == "operational" for x in poll_srcs) else ("degraded" if poll_srcs else "ready"),
679 688 "forecastEngine": "operational" if run and run.status == "ok" else "degraded",
680 689 "electionFeed": next((x.status for x in sources if x.id == "eq-live"), "ready"),
681 "documentWatcher": comp("party-docs"), "officialData": "operational" if all(x.status == "operational" for x in sources if x.kind == "official") else "degraded"},
690 + "documentWatcher": comp("party-docs"), "officialData": "operational" if all(x.status == "operational" for x in sources if x.kind == "official") else "degraded",
691 + "signals": ("operational" if [x for x in sources if x.kind == "signal"] and all(x.status in ("operational", "ready") for x in sources if x.kind == "signal") else ("degraded" if [x for x in sources if x.kind == "signal"] else "ready"))},
682 692 "sources": [{"id": x.id, "name": x.name, "url": x.url, "tier": x.tier, "kind": x.kind, "status": x.status, "lastCheckedAt": x.last_checked_at.isoformat() if x.last_checked_at else None,
683 693 "lastOkAt": x.last_ok_at.isoformat() if x.last_ok_at else None, "detail": x.detail} for x in sources],
684 694 "jobs": [{"id": j.id, "kind": j.kind, "status": j.status, "startedAt": j.started_at.isoformat(), "finishedAt": j.finished_at.isoformat() if j.finished_at else None,
@@ -688,6 +698,13 @@ def status(s: Session = Depends(db)):
688 698
689 699
690 700 CHANGELOG = [
701 + {"version": "2.0.0", "date": "2026-09-07", "title": "Au-delà des sondages : ensemble à quatre composantes",
702 + "notes": ["Marchés de prédiction (Polymarket, ~800 k$ de volume) : marchés « plus de sièges », « majorité PQ », tranches de sièges (PQ, CAQ, PLQ), 2e/3e place, historique quotidien archivé. Les 20 000 simulations sont repondérées (ajustement proportionnel itératif) pour que les probabilités de l'ensemble valent 70 % modèle + 30 % marché (15 % pour les tranches, moins liquides). La vue « sondages seuls » reste publiée.",
703 + "Fondamentaux : la satisfaction à l'égard du gouvernement (questions de leadership des rapports de sondage, pondérées récence) fixe un prior sur la part du parti sortant (≈ 6 + 0,6 × satisfaction, σ 5,5 pt) mélangé aux sondages avec un poids qui décroît de 20 % à 120 jours du vote jusqu'à 0 le jour J.",
704 + "Momentum borné (± 1 pt, somme nulle) : prime au chef (meilleur premier ministre vs intention de vote, κ 0,15), attention publique (pages vues Wikipédia des partis et chef·fes, part 7 j vs 28 j, κ 0,08), ton médiatique (titres de presse classés par LLM, κ 0,5, atténué sous 10 articles).",
705 + "Élections partielles depuis 2022 (Saint-Henri–Sainte-Anne, Jean-Talon, Terrebonne, Arthabaska, Chicoutimi — résultats officiels DGEQ archivés) : l'excès de swing local observé, net du swing national du jour, corrige le prior de la circonscription (κ 0,35).",
706 + "Correction : le·la député·e sortant·e vient désormais de la liste officielle des candidatures (drapeau DGEQ) puis du résultat des partielles, et non plus du seul gagnant 2022 transposé (Jean-Talon, Terrebonne, Arthabaska–L'Érable, Chicoutimi, Saint-Henri–Sainte-Anne étaient erronés).",
707 + "Publication de chaque composante (summary.components) : sondages seuls, modèle sans marchés, prior fondamentaux, décomposition du momentum, cibles et probabilités atteintes du tempérage, taille d'échantillon effective."]},
691 708 {"version": "1.1.0", "date": "2026-09-06", "title": "Recensement 2021 et swing par segments",
692 709 "notes": ["Recensement de la population 2021 (Statistique Canada, profil des aires de diffusion agrégées + limites) agrégé par intersection surfacique aux 127 circonscriptions : langue maternelle, première langue officielle parlée, immigration, minorités visibles, âge, revenu, tenure, scolarité, chômage.",
693 710 "Excès de swing par langue : ventilations francophones / non-francophones des sondages (90 jours, pondérées récence × √n) comparées à une référence 2022 par régression écologique des résultats sur la part francophone ; appliqué à chaque circonscription selon sa composition (κ = 0,6).",
@@ -704,6 +721,59 @@ CHANGELOG = [
704 721 ]
705 722
706 723
724 +# ---------------------------------------------------------------- signaux (v2)
725 +@router.get("/signals")
726 +def signals_all(s: Session = Depends(db)):
727 + from ..signals import attention, byelections, fundamentals, markets, media
728 + run = _latest_run(s)
729 + comp = (run.summary or {}).get("components") if run else None
730 + return {"asOf": datetime.now(timezone.utc).isoformat(), "runId": run.id if run else None, "components": comp,
731 + "markets": {**markets.implied(s), "history": markets.history(s)["series"]},
732 + "attention": attention.attention_summary(s), "media": media.media_summary(s),
733 + "fundamentals": fundamentals.fundamentals_summary(s), "byElections": byelections.byelection_summary(s)}
734 +
735 +
736 +@router.get("/signals/markets")
737 +def signals_markets(days: int = 120, s: Session = Depends(db)):
738 + from ..signals import markets
739 + return {**markets.implied(s), "history": markets.history(s, days)["series"]}
740 +
741 +
742 +@router.get("/signals/attention")
743 +def signals_attention(s: Session = Depends(db)):
744 + from ..signals import attention
745 + return attention.attention_summary(s)
746 +
747 +
748 +@router.get("/signals/media")
749 +def signals_media(window: int = 7, limit: int = 60, s: Session = Depends(db)):
750 + from ..signals import media
751 + d = media.media_summary(s, window=window)
752 + d["latest"] = d["latest"][:limit]
753 + return d
754 +
755 +
756 +@router.get("/signals/fundamentals")
757 +def signals_fundamentals(s: Session = Depends(db)):
758 + from ..signals import fundamentals
759 + return fundamentals.fundamentals_summary(s)
760 +
761 +
762 +@router.get("/signals/byelections")
763 +def signals_byelections(s: Session = Depends(db)):
764 + from ..signals import byelections
765 + return byelections.byelection_summary(s)
766 +
767 +
768 +@router.get("/forecast/components")
769 +def forecast_components(s: Session = Depends(db)):
770 + run = _latest_run(s)
771 + if not run:
772 + raise HTTPException(404)
773 + return {"runId": run.id, "modelVersion": run.model_version, "completedAt": run.completed_at.isoformat() if run.completed_at else None,
774 + "components": (run.summary or {}).get("components"), "parties": run.summary.get("parties"), "pMinority": run.summary.get("p_minority")}
775 +
776 +
707 777 @router.get("/changelog")
708 778 def changelog(s: Session = Depends(db)):
709 779 runs = s.scalars(select(ForecastRun).where(ForecastRun.kind == "forecast", ForecastRun.status == "ok").order_by(desc(ForecastRun.id)).limit(30)).all()
modified engine/qc26/config.py +32 −2
@@ -52,7 +52,7 @@ class Settings(BaseSettings):
52 52 # ils dérivent de la liste officielle des circonscriptions (127 en 2026 → 64).
53 53
54 54 # --- Modèle ---
55 model_version: str = "1.1.0"
55 + model_version: str = "2.0.0"
56 56 n_simulations: int = 20000
57 57 random_seed: int | None = None
58 58 recency_halflife_days: float = 14.0 # hors campagne
@@ -68,9 +68,39 @@ class Settings(BaseSettings):
68 68 riding_error_sd: float = 0.20 # logit
69 69 incumbent_retirement_penalty: float = 0.06 # logit (~1,3 pp)
70 70
71 + # --- Ensemble v2 : au-delà des sondages ---
72 + # Fondamentaux (satisfaction du gouvernement → part du parti sortant) : poids maximal loin du
73 + # scrutin, décroissant linéairement vers 0 le jour du vote (horizon en jours).
74 + fundamentals_weight_max: float = 0.20
75 + fundamentals_horizon_days: int = 120
76 + fundamentals_alpha: float = 6.0 # part sortant ≈ α + β·satisfaction (%), calibration indicative QC 2003-2022
77 + fundamentals_beta: float = 0.60
78 + fundamentals_sd_pp: float = 5.5
79 + # Momentum : leadership (meilleur PM vs vote), attention (Wikipédia), ton médiatique → nudge borné (pp)
80 + momentum_leader_kappa: float = 0.15
81 + momentum_attention_kappa: float = 0.08 # pp de vote par pp de variation de la part d'attention (7 j vs 28 j)
82 + momentum_media_kappa: float = 0.5 # pp de vote par unité de ton médiatique (−1…+1), atténué sous 10 articles
83 + momentum_max_pp: float = 1.0
84 + # Marchés de prédiction : tempérage des simulations (pondération itérative) vers les prix implicites
85 + markets_weight: float = 0.30
86 + markets_min_volume: float = 5000.0
87 + markets_max_age_hours: int = 72
88 + # Partielles : ajustement des priors de circonscription du swing observé (rétréci)
89 + byelection_kappa: float = 0.35
90 +
71 91 # --- Sources / connecteurs ---
72 92 firecrawl_api_key: str = ""
73 93 anthropic_api_key: str = ""
94 + serper_api_key: str = ""
95 + tavily_api_key: str = ""
96 + polymarket_gamma: str = "https://gamma-api.polymarket.com"
97 + polymarket_clob: str = "https://clob.polymarket.com"
98 + polymarket_event_slugs: str = ("quebec-general-election-winner,of-seats-won-by-pq-in-the-2026-quebec-general-election,"
99 + "of-seats-won-by-caq-in-the-2026-quebec-general-election,of-seats-won-by-plq-in-the-2026-quebec-general-election,"
100 + "will-the-pq-win-a-majority-in-the-2026-quebec-general-election,quebec-general-election-second-place-20260825152158,"
101 + "quebec-general-election-third-place-20260825152158")
102 + signals_markets_minutes: int = 30
103 + signals_media_hours: int = 3
74 104 llm_model: str = "claude-opus-5"
75 105 llm_enabled: bool = True
76 106 user_agent: str = (
@@ -111,5 +141,5 @@ class Settings(BaseSettings):
111 141
112 142 settings = Settings()
113 143 for _d in (settings.archive_dir / "polls", settings.archive_dir / "documents",
114 settings.archive_dir / "live", settings.cache_dir):
144 + settings.archive_dir / "live", settings.archive_dir / "partielles", settings.cache_dir):
115 145 _d.mkdir(parents=True, exist_ok=True)
modified engine/qc26/connectors/parties/documents.py +8 −5
@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
21 21 from ... import llm
22 22 from ...config import settings
23 23 from ...db import (DocumentChunk, DocumentVersion, IngestionJob, Party, PartyDocument, PolicyProposal, Source, audit, utcnow)
24 from ...util import fetch_bytes, html_to_text, http, parse_fr_date, pdf_pages_text, sha256_bytes
24 +from ...util import fetch_bytes, html_to_text, http, parse_fr_date, pdf_pages_text, resolve_archive_path, sha256_bytes
25 25 from ..polls.registry import firecrawl_map, firecrawl_scrape
26 26
27 27 log = logging.getLogger("qc26.parties.docs")
@@ -232,8 +232,9 @@ def fetch_version(s: Session, doc: PartyDocument) -> DocumentVersion | None:
232 232
233 233 def diff_versions(old: DocumentVersion, new: DocumentVersion) -> dict:
234 234 import difflib
235 a = Path(old.text_path).read_text().splitlines() if old.text_path and Path(old.text_path).exists() else []
236 b = Path(new.text_path).read_text().splitlines() if new.text_path and Path(new.text_path).exists() else []
235 + _pa, _pb = resolve_archive_path(old.text_path), resolve_archive_path(new.text_path)
236 + a = _pa.read_text().splitlines() if _pa and _pa.exists() else []
237 + b = _pb.read_text().splitlines() if _pb and _pb.exists() else []
237 238 sm = difflib.SequenceMatcher(None, a, b, autojunk=False)
238 239 added, removed = [], []
239 240 for tag, i1, i2, j1, j2 in sm.get_opcodes():
@@ -276,7 +277,8 @@ Si l'extrait ne contient aucune proposition (page de navigation, formulaire, bio
276 277
277 278
278 279 def analyze_version(s: Session, doc: PartyDocument, v: DocumentVersion, max_chunks: int = 12) -> int:
279 text = Path(v.text_path).read_text() if v.text_path else ""
280 + _tp = resolve_archive_path(v.text_path)
281 + text = _tp.read_text() if _tp and _tp.exists() else ""
280 282 if len(text.strip()) < 300:
281 283 doc.status = "ignored"
282 284 v.analyzed_at = utcnow()
@@ -365,7 +367,8 @@ def sync_party(s: Session, party_id: str, max_docs: int = 80, analyze: bool = Tr
365 367 s.commit()
366 368 if not doc.title or doc.title == url.rsplit("/", 1)[-1][:200]:
367 369 # titre depuis le texte (première ligne significative)
368 txt = Path(v.text_path).read_text()[:2000] if v.text_path else ""
370 + _tp2 = resolve_archive_path(v.text_path)
371 + txt = _tp2.read_text()[:2000] if _tp2 and _tp2.exists() else ""
369 372 first = next((l.strip() for l in txt.splitlines() if len(l.strip()) > 12), None)
370 373 if first:
371 374 doc.title = first[:200]
modified engine/qc26/connectors/polls/base.py +4 −2
@@ -20,7 +20,7 @@ from ... import llm
20 20 from ...config import settings
21 21 from ...db import Poll, PollDocument, PollResult, PollSubsample, Pollster, audit, utcnow
22 22 from ...parties import PARTY_IDS, party_id_from_label
23 from ...util import fetch_bytes, html_to_text, pdf_pages_text, pdf_tables_text, sha256_bytes
23 +from ...util import fetch_bytes, html_to_text, pdf_pages_text, pdf_tables_text, resolve_archive_path, sha256_bytes
24 24
25 25 log = logging.getLogger("qc26.polls.connector")
26 26
@@ -95,7 +95,9 @@ class PollConnector:
95 95 return doc
96 96
97 97 def extract_text(self, doc: PollDocument) -> tuple[list[str], list[tuple[int, str]]]:
98 path = Path(doc.path)
98 + path = resolve_archive_path(doc.path) or Path(doc.path)
99 + if not path.exists():
100 + raise FileNotFoundError(f"archive absente : {doc.path}")
99 101 if path.suffix == ".pdf":
100 102 pages = pdf_pages_text(path)
101 103 tables = pdf_tables_text(path)
modified engine/qc26/db.py +135 −26
@@ -11,7 +11,7 @@ from contextlib import contextmanager
11 11 from datetime import date, datetime, timezone
12 12 from typing import Any, Iterator
13 13
14 from sqlalchemy import (JSON, Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text,
14 +from sqlalchemy import (JSON, Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, TypeDecorator,
15 15 UniqueConstraint, create_engine, event, text)
16 16 from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship, sessionmaker
17 17
@@ -22,6 +22,23 @@ def utcnow() -> datetime:
22 22 return datetime.now(timezone.utc)
23 23
24 24
25 +class TZDateTime(TypeDecorator):
26 + """SQLite ne conserve pas le fuseau : on stocke en UTC naïf et on relit en UTC explicite, pour que
27 + `.isoformat()` porte toujours « +00:00 » (sinon le navigateur interprète l'heure comme locale)."""
28 + impl = DateTime(timezone=True)
29 + cache_ok = True
30 +
31 + def process_bind_param(self, value, dialect):
32 + if value is not None and value.tzinfo is not None:
33 + return value.astimezone(timezone.utc).replace(tzinfo=None)
34 + return value
35 +
36 + def process_result_value(self, value, dialect):
37 + if value is not None and value.tzinfo is None:
38 + return value.replace(tzinfo=timezone.utc)
39 + return value
40 +
41 +
25 42 class Base(DeclarativeBase):
26 43 pass
27 44
@@ -47,7 +64,7 @@ class Party(Base):
47 64 vote_2022: Mapped[float | None] = mapped_column(Float)
48 65 order: Mapped[int] = mapped_column(Integer, default=0)
49 66 color_note: Mapped[str | None] = mapped_column(Text)
50 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
67 + updated_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow)
51 68
52 69
53 70 class Region(Base):
@@ -115,7 +132,7 @@ class RidingDemographics(Base):
115 132 ada_count: Mapped[int] = mapped_column(Integer, default=0)
116 133 coverage: Mapped[float | None] = mapped_column(Float) # part de la population de la circ. couverte par des ADA
117 134 source: Mapped[str] = mapped_column(String(300))
118 computed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
135 + computed_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
119 136
120 137
121 138 class HistoricalResult(Base):
@@ -149,8 +166,8 @@ class Candidate(Base):
149 166 incumbent: Mapped[bool] = mapped_column(Boolean, default=False)
150 167 status: Mapped[str] = mapped_column(String(8)) # CPA (confirmé parti) / CIA (indépendant) …
151 168 state: Mapped[str] = mapped_column(String(8))
152 dgeq_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
153 fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
169 + dgeq_updated_at: Mapped[datetime | None] = mapped_column(TZDateTime)
170 + fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
154 171
155 172
156 173 # --------------------------------------------------------------------- sondages
@@ -175,7 +192,7 @@ class PollDocument(Base):
175 192 content_type: Mapped[str | None] = mapped_column(String(80))
176 193 bytes: Mapped[int] = mapped_column(Integer)
177 194 pages: Mapped[int | None] = mapped_column(Integer)
178 downloaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
195 + downloaded_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
179 196 text_path: Mapped[str | None] = mapped_column(String(300))
180 197 http_last_modified: Mapped[str | None] = mapped_column(String(80))
181 198 pollster_id: Mapped[str | None] = mapped_column(String(32))
@@ -204,8 +221,8 @@ class Poll(Base):
204 221 validation_notes: Mapped[dict | list | None] = mapped_column(JSON)
205 222 undecided: Mapped[float | None] = mapped_column(Float)
206 223 notes: Mapped[dict | list | None] = mapped_column(JSON)
207 ingested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
208 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
224 + ingested_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
225 + updated_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow)
209 226 excluded: Mapped[bool] = mapped_column(Boolean, default=False)
210 227 exclusion_reason: Mapped[str | None] = mapped_column(String(200))
211 228 results: Mapped[list["PollResult"]] = relationship(cascade="all, delete-orphan", lazy="selectin")
@@ -250,8 +267,8 @@ class PartyDocument(Base):
250 267 url: Mapped[str] = mapped_column(String(600), unique=True)
251 268 doc_type: Mapped[str] = mapped_column(String(32)) # plateforme / cadre_financier / communique / page / engagement
252 269 published_date: Mapped[date | None] = mapped_column(Date)
253 discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
254 last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
270 + discovered_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
271 + last_checked_at: Mapped[datetime | None] = mapped_column(TZDateTime)
255 272 current_version_id: Mapped[int | None] = mapped_column(Integer)
256 273 proposals_count: Mapped[int] = mapped_column(Integer, default=0)
257 274 status: Mapped[str] = mapped_column(String(16), default="new") # new / analyzed / failed / ignored
@@ -268,8 +285,8 @@ class DocumentVersion(Base):
268 285 content_type: Mapped[str | None] = mapped_column(String(80))
269 286 bytes: Mapped[int] = mapped_column(Integer)
270 287 pages: Mapped[int | None] = mapped_column(Integer)
271 fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
272 analyzed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
288 + fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
289 + analyzed_at: Mapped[datetime | None] = mapped_column(TZDateTime)
273 290 diff_summary: Mapped[dict | None] = mapped_column(JSON)
274 291
275 292
@@ -298,7 +315,7 @@ class PolicyProposal(Base):
298 315 quote: Mapped[str] = mapped_column(Text)
299 316 confidence: Mapped[float] = mapped_column(Float)
300 317 status: Mapped[str] = mapped_column(String(16), default="accepted") # accepted / review / rejected
301 extracted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
318 + extracted_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
302 319
303 320
304 321 class CompassQuestion(Base):
@@ -324,7 +341,7 @@ class PartyPosition(Base):
324 341 proposal_ids: Mapped[list | None] = mapped_column(JSON)
325 342 rationale: Mapped[str | None] = mapped_column(Text)
326 343 status: Mapped[str] = mapped_column(String(16), default="pending") # pending / extracted / review / manual
327 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
344 + updated_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow)
328 345
329 346
330 347 # -------------------------------------------------------------------- forecast
@@ -334,8 +351,8 @@ class ForecastRun(Base):
334 351 model_version: Mapped[str] = mapped_column(String(16))
335 352 kind: Mapped[str] = mapped_column(String(16), default="forecast") # forecast / nowcast
336 353 data_cutoff: Mapped[date] = mapped_column(Date)
337 started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
338 completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
354 + started_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
355 + completed_at: Mapped[datetime | None] = mapped_column(TZDateTime)
339 356 poll_count: Mapped[int] = mapped_column(Integer, default=0)
340 357 n_simulations: Mapped[int] = mapped_column(Integer, default=0)
341 358 seed: Mapped[int | None] = mapped_column(Integer)
@@ -427,7 +444,7 @@ class PollWeight(Base):
427 444 class LiveSnapshot(Base):
428 445 __tablename__ = "live_snapshots"
429 446 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
430 fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
447 + fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, index=True)
431 448 source_updated_at: Mapped[str | None] = mapped_column(String(40))
432 449 sha256: Mapped[str] = mapped_column(String(64), index=True)
433 450 path: Mapped[str] = mapped_column(String(300))
@@ -445,7 +462,7 @@ class LiveSnapshot(Base):
445 462 class LiveRidingState(Base):
446 463 __tablename__ = "live_riding_states"
447 464 riding_code: Mapped[int] = mapped_column(Integer, primary_key=True)
448 updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
465 + updated_at: Mapped[datetime | None] = mapped_column(TZDateTime)
449 466 source_updated_at: Mapped[str | None] = mapped_column(String(40))
450 467 status: Mapped[str] = mapped_column(String(16), default="NOT_STARTED") # NOT_STARTED/REPORTING/LEADING/PROJECTED/ELECTED/FINAL
451 468 bureaux_completes: Mapped[int] = mapped_column(Integer, default=0)
@@ -466,7 +483,7 @@ class LiveRidingState(Base):
466 483 class ElectionEvent(Base):
467 484 __tablename__ = "election_events"
468 485 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
469 ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
486 + ts: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, index=True)
470 487 kind: Mapped[str] = mapped_column(String(32), index=True) # lead_change / riding_complete / seats_update / reporting / first_results / final
471 488 riding_code: Mapped[int | None] = mapped_column(Integer)
472 489 party_id: Mapped[str | None] = mapped_column(String(8))
@@ -478,12 +495,104 @@ class ElectionEvent(Base):
478 495 class NowcastRun(Base):
479 496 __tablename__ = "nowcast_runs"
480 497 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
481 ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
498 + ts: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, index=True)
482 499 snapshot_id: Mapped[int | None] = mapped_column(Integer)
483 500 ridings_reporting: Mapped[int] = mapped_column(Integer)
484 501 summary: Mapped[dict] = mapped_column(JSON) # par parti : élus, en avance, total, p_majority, seats mean/ci
485 502
486 503
504 +# ------------------------------------------------------- signaux alternatifs (v2)
505 +class Signal(Base):
506 + """Série temporelle d'un signal « au-delà des sondages » : marchés de prédiction, attention
507 + (Wikipédia), médias (volume / ton), fondamentaux tirés des questions de leadership, partielles.
508 + Une ligne = une observation datée, avec sa provenance (URL) et ses métadonnées."""
509 + __tablename__ = "signals"
510 + __table_args__ = (UniqueConstraint("source", "key", "party_id", "date"),)
511 + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
512 + source: Mapped[str] = mapped_column(String(32), index=True) # polymarket / wikipedia / gdelt / serper-news / polls-leadership / dgeq-partielles
513 + key: Mapped[str] = mapped_column(String(64), index=True) # ex. p_most_seats / pageviews / tone / best_pm / satisfaction
514 + party_id: Mapped[str | None] = mapped_column(String(8), index=True)
515 + date: Mapped[date] = mapped_column(Date, index=True)
516 + value: Mapped[float] = mapped_column(Float)
517 + n: Mapped[int | None] = mapped_column(Integer)
518 + meta: Mapped[dict | list | None] = mapped_column(JSON)
519 + url: Mapped[str | None] = mapped_column(String(600))
520 + fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow)
521 +
522 +
523 +class Market(Base):
524 + """Marché de prédiction (Polymarket) rattaché à l'élection : question, type, parti, tranche."""
525 + __tablename__ = "markets"
526 + id: Mapped[str] = mapped_column(String(32), primary_key=True) # id Polymarket
527 + source: Mapped[str] = mapped_column(String(24), default="polymarket")
528 + event_slug: Mapped[str] = mapped_column(String(160), index=True)
529 + event_title: Mapped[str | None] = mapped_column(String(200))
530 + question: Mapped[str] = mapped_column(String(300))
531 + kind: Mapped[str] = mapped_column(String(24), index=True) # most_seats / majority / seats_band / place
532 + party_id: Mapped[str | None] = mapped_column(String(8), index=True)
533 + band_lo: Mapped[int | None] = mapped_column(Integer)
534 + band_hi: Mapped[int | None] = mapped_column(Integer)
535 + place: Mapped[int | None] = mapped_column(Integer)
536 + condition_id: Mapped[str | None] = mapped_column(String(80))
537 + token_id: Mapped[str | None] = mapped_column(String(100))
538 + volume: Mapped[float | None] = mapped_column(Float)
539 + liquidity: Mapped[float | None] = mapped_column(Float)
540 + end_date: Mapped[str | None] = mapped_column(String(40))
541 + url: Mapped[str | None] = mapped_column(String(300))
542 + last_price: Mapped[float | None] = mapped_column(Float)
543 + best_bid: Mapped[float | None] = mapped_column(Float)
544 + best_ask: Mapped[float | None] = mapped_column(Float)
545 + active: Mapped[bool] = mapped_column(Boolean, default=True)
546 + updated_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow)
547 +
548 +
549 +class MarketPrice(Base):
550 + __tablename__ = "market_prices"
551 + __table_args__ = (UniqueConstraint("market_id", "ts"),)
552 + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
553 + market_id: Mapped[str] = mapped_column(ForeignKey("markets.id"), index=True)
554 + ts: Mapped[datetime] = mapped_column(TZDateTime, index=True)
555 + price: Mapped[float] = mapped_column(Float) # prix « Oui » (0–1)
556 +
557 +
558 +class MediaItem(Base):
559 + """Article de presse repéré (titre, média, date) et classé par le LLM : partis mentionnés et ton."""
560 + __tablename__ = "media_items"
561 + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
562 + url: Mapped[str] = mapped_column(String(600), unique=True)
563 + title: Mapped[str] = mapped_column(String(400))
564 + domain: Mapped[str | None] = mapped_column(String(120), index=True)
565 + snippet: Mapped[str | None] = mapped_column(Text)
566 + published_at: Mapped[datetime | None] = mapped_column(TZDateTime, index=True)
567 + fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
568 + query: Mapped[str | None] = mapped_column(String(200))
569 + source: Mapped[str] = mapped_column(String(24), default="serper-news")
570 + parties: Mapped[dict | None] = mapped_column(JSON) # {party_id: tone −1…+1}
571 + topic: Mapped[str | None] = mapped_column(String(48))
572 + summary: Mapped[str | None] = mapped_column(String(300))
573 + confidence: Mapped[float | None] = mapped_column(Float)
574 + classified: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
575 +
576 +
577 +class ByElection(Base):
578 + """Élection partielle provinciale depuis 2022 (résultats officiels DGEQ) comparée au résultat
579 + de 2022 dans la même circonscription : mesure directe du swing en conditions réelles."""
580 + __tablename__ = "by_elections"
581 + id: Mapped[str] = mapped_column(String(32), primary_key=True) # ex. 2025-03-17-542
582 + date: Mapped[date] = mapped_column(Date, index=True)
583 + dgeq_code: Mapped[int] = mapped_column(Integer)
584 + riding_name: Mapped[str | None] = mapped_column(String(80))
585 + url: Mapped[str] = mapped_column(String(300))
586 + sha256: Mapped[str | None] = mapped_column(String(64))
587 + path: Mapped[str | None] = mapped_column(String(300))
588 + results: Mapped[list | None] = mapped_column(JSON) # [{candidate, party_label, party_id, votes, share}]
589 + previous: Mapped[dict | None] = mapped_column(JSON) # {party_id: share 2022} (résultat général précédent, mêmes limites)
590 + turnout: Mapped[float | None] = mapped_column(Float)
591 + winner_party_id: Mapped[str | None] = mapped_column(String(8))
592 + status: Mapped[str] = mapped_column(String(16), default="new")
593 + fetched_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow)
594 +
595 +
487 596 # ------------------------------------------------------------------ exploitation
488 597 class Source(Base):
489 598 __tablename__ = "sources"
@@ -492,8 +601,8 @@ class Source(Base):
492 601 url: Mapped[str] = mapped_column(String(600))
493 602 tier: Mapped[int] = mapped_column(Integer, default=1) # 1 officiel, 2 firme, 3 média, 5 index
494 603 kind: Mapped[str] = mapped_column(String(32))
495 last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
496 last_ok_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
604 + last_checked_at: Mapped[datetime | None] = mapped_column(TZDateTime)
605 + last_ok_at: Mapped[datetime | None] = mapped_column(TZDateTime)
497 606 status: Mapped[str] = mapped_column(String(16), default="unknown") # operational / degraded / down / ready
498 607 detail: Mapped[str | None] = mapped_column(String(300))
499 608
@@ -502,8 +611,8 @@ class IngestionJob(Base):
502 611 __tablename__ = "ingestion_jobs"
503 612 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
504 613 kind: Mapped[str] = mapped_column(String(48), index=True)
505 started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
506 finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
614 + started_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow)
615 + finished_at: Mapped[datetime | None] = mapped_column(TZDateTime)
507 616 status: Mapped[str] = mapped_column(String(16), default="running")
508 617 items: Mapped[int] = mapped_column(Integer, default=0)
509 618 message: Mapped[str | None] = mapped_column(Text)
@@ -513,7 +622,7 @@ class IngestionJob(Base):
513 622 class AuditLog(Base):
514 623 __tablename__ = "audit_logs"
515 624 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
516 ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
625 + ts: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, index=True)
517 626 actor: Mapped[str] = mapped_column(String(64)) # system:<job> / admin
518 627 entity: Mapped[str] = mapped_column(String(48))
519 628 entity_id: Mapped[str] = mapped_column(String(80))
@@ -537,7 +646,7 @@ class Setting(Base):
537 646 __tablename__ = "settings"
538 647 key: Mapped[str] = mapped_column(String(64), primary_key=True)
539 648 value: Mapped[dict | list | str | None] = mapped_column(JSON)
540 updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
649 + updated_at: Mapped[datetime] = mapped_column(TZDateTime, default=utcnow, onupdate=utcnow)
541 650
542 651
543 652 # -------------------------------------------------------------------- moteur
modified engine/qc26/ingest/official.py +31 −0
@@ -224,16 +224,47 @@ def sync_candidates(s: Session, refresh: bool = True) -> int:
224 224 counts[pid] += 1
225 225 if c.get("depute_sortant") == "O":
226 226 incumbents_running[code] = True
227 + # la liste officielle des candidatures est la vérité sur le·la député·e sortant·e :
228 + # elle corrige le gagnant 2022 transposé quand une partielle a changé la donne (Jean-Talon, Terrebonne, Arthabaska, Chicoutimi…)
229 + rd = ridings[code]
230 + if pid and (rd.incumbent_party_id != pid or not rd.incumbent_name):
231 + rd.incumbent_party_id = pid
232 + rd.incumbent_name = f"{c.get('prenom_bulletin_vote') or ''} {c.get('nom_bulletin_vote') or ''}".strip()
227 233 for pid, n in counts.items():
228 234 p = s.get(Party, pid)
229 235 if p:
230 236 p.candidates_count = n
231 237 for code, rd in ridings.items():
232 238 rd.incumbent_running = incumbents_running.get(code, False)
239 + apply_by_election_incumbents(s, ridings)
233 240 _mark_source(s, "eq-candidates", "Élections Québec — candidatures officielles", settings.eq_candidates_url, True)
234 241 return len(data)
235 242
236 243
244 +def apply_by_election_incumbents(s: Session, ridings: dict | None = None) -> int:
245 + """Sortant·e des circonscriptions ayant connu une partielle depuis 2022 : le·la gagnant·e de la partielle
246 + (résultat officiel DGEQ archivé), sauf si la liste des candidatures désigne déjà un·e sortant·e."""
247 + from ..db import ByElection, Candidate
248 + ridings = ridings or {r.code: r for r in s.scalars(select(Riding)).all()}
249 + by_name = {norm_name(r.name): r for r in ridings.values()}
250 + n = 0
251 + for b in s.scalars(select(ByElection).where(ByElection.status == "ok").order_by(ByElection.date)).all():
252 + if not b.riding_name or not b.results:
253 + continue
254 + key = norm_name(b.riding_name)
255 + rd = by_name.get(key) or next((r for k, r in by_name.items() if k.startswith(key)), None)
256 + if not rd:
257 + continue
258 + has_official = s.scalar(select(Candidate).where(Candidate.riding_code == rd.code, Candidate.incumbent == True).limit(1)) # noqa: E712
259 + if has_official:
260 + continue
261 + win = max(b.results, key=lambda r: r["votes"])
262 + if win.get("party_id") and rd.incumbent_party_id != win["party_id"]:
263 + rd.incumbent_party_id, rd.incumbent_name = win["party_id"], win.get("candidate")
264 + n += 1
265 + return n
266 +
267 +
237 268 # ------------------------------------------------------------------ résultats
238 269 def _load_results_json(path_dir: Path, url_dir: str, refresh: bool) -> dict:
239 270 raw = _download_or_local(f"{url_dir}/resultats.json", path_dir / "resultats.json", refresh)
modified engine/qc26/main.py +7 −0
@@ -44,6 +44,13 @@ def bootstrap() -> None:
44 44 run_forecast(s, trigger="reference_polls_2022")
45 45 except Exception as e: # noqa: BLE001
46 46 log.warning("sondages de référence 2022 : %s", e)
47 + try:
48 + from .db import Signal
49 + from .signals import fundamentals
50 + if not s.scalar(select(Signal).where(Signal.source == "polls-leadership").limit(1)):
51 + fundamentals.extract_from_polls(s) # dérivé des sondages déjà en base, sans réseau
52 + except Exception as e: # noqa: BLE001
53 + log.warning("fondamentaux : %s", e)
47 54 if not s.scalar(select(ForecastRun).where(ForecastRun.status == "ok").limit(1)):
48 55 from .model.run import run_forecast
49 56 try:
added engine/qc26/model/ensemble.py +258 −0
@@ -0,0 +1,258 @@
1 +"""Ensemble QC26 (modèle 2.0) — au-delà des sondages.
2 +
3 +Quatre composantes, toutes optionnelles (désactivées si le signal est absent de la base), toutes
4 +paramétrées dans `config.py` et toutes publiées séparément dans le run (« Sondages seuls »
5 +vs « Ensemble QC26 ») :
6 +
7 +1. **Fondamentaux** — prior structurel sur la part du parti sortant : part ≈ α + β·satisfaction
8 + (satisfaction à l'égard du gouvernement, pondérée récence, tirée des rapports de sondage). Mélangé
9 + à l'état latent des sondages dans l'espace logit avec un poids w_f qui décroît linéairement des
10 + `fundamentals_horizon_days` jours précédant le scrutin jusqu'à 0 le jour du vote.
11 +2. **Momentum** — nudge additif borné (± `momentum_max_pp`) sur la moyenne du vote au jour du vote :
12 + prime au chef (meilleur PM vs intention de vote), attention (Wikipédia, 7 j vs 28 j), ton médiatique
13 + (titres classés). Recentré à somme nulle entre les cinq partis.
14 +3. **Marchés de prédiction** — tempérage : les simulations Monte Carlo sont **repondérées**
15 + (ajustement proportionnel itératif) pour que les probabilités « plus de sièges », « majorité » et les
16 + tranches de sièges implicites de l'ensemble valent (1 − w_m)·modèle + w_m·marché. Toute la
17 + distribution (sièges, circonscriptions) reste cohérente : ce sont les mêmes simulations, pesées.
18 +4. **Partielles** — excès de swing local observé lors d'une partielle (net du swing national à la même
19 + date) appliqué, rétréci (κ), au prior de la circonscription concernée.
20 +"""
21 +from __future__ import annotations
22 +
23 +import math
24 +from dataclasses import dataclass, field
25 +from datetime import date
26 +
27 +import numpy as np
28 +
29 +from ..config import settings
30 +from ..parties import PARTY_IDS
31 +from .aggregate import PARTIES_ALL
32 +
33 +
34 +def _logit(p: float) -> float:
35 + p = min(max(p, 0.002), 0.998)
36 + return math.log(p / (1 - p))
37 +
38 +
39 +def _clip(x: float, lo: float, hi: float) -> float:
40 + return max(lo, min(hi, x))
41 +
42 +
43 +# --------------------------------------------------------------- pondérations
44 +def wmean(x: np.ndarray, w: np.ndarray) -> float:
45 + return float(np.average(x, weights=w))
46 +
47 +
48 +def wquantile(x: np.ndarray, w: np.ndarray, q: float) -> float:
49 + idx = np.argsort(x)
50 + xs, ws = x[idx], w[idx]
51 + cw = np.cumsum(ws)
52 + cw = cw / cw[-1]
53 + return float(xs[min(len(xs) - 1, int(np.searchsorted(cw, q, side="left")))])
54 +
55 +
56 +def whist(x: np.ndarray, w: np.ndarray, minlength: int) -> list[float]:
57 + h = np.bincount(x, weights=w, minlength=minlength)
58 + return [round(float(v), 2) for v in h]
59 +
60 +
61 +def ess(w: np.ndarray) -> float:
62 + return float(w.sum() ** 2 / (w ** 2).sum())
63 +
64 +
65 +# ------------------------------------------------------------- fondamentaux
66 +def time_weight(days_left: int) -> float:
67 + if settings.fundamentals_horizon_days <= 0:
68 + return 0.0
69 + return settings.fundamentals_weight_max * _clip(days_left / settings.fundamentals_horizon_days, 0.0, 1.0)
70 +
71 +
72 +def fundamentals_blend(mu_logit: np.ndarray, var_logit: np.ndarray, last_pct: np.ndarray, fund: dict | None, days_left: int, incumbent: str = "caq") -> tuple[np.ndarray, np.ndarray, dict]:
73 + """Mélange logit de l'état latent (sondages) et du prior fondamentaux. Retourne (mu, var, détail)."""
74 + detail = {"enabled": False, "weight": 0.0}
75 + if not fund or not fund.get("enabled") or fund.get("satisfaction") is None or incumbent not in PARTIES_ALL:
76 + return mu_logit, var_logit, detail
77 + w = time_weight(days_left)
78 + sat = float(fund["satisfaction"])
79 + inc_prior = _clip(settings.fundamentals_alpha + settings.fundamentals_beta * sat, 5.0, 60.0)
80 + j_inc = PARTIES_ALL.index(incumbent)
81 + polls = last_pct.copy() # % somme 100
82 + # les autres partis se partagent le reste au prorata des sondages (le prior n'informe que le sortant)
83 + rest_polls = polls.sum() - polls[j_inc]
84 + rest_prior = 100.0 - inc_prior
85 + prior = polls * (rest_prior / rest_polls) if rest_polls > 0 else polls.copy()
86 + prior[j_inc] = inc_prior
87 + prior_logit = np.array([_logit(p / 100.0) for p in prior])
88 + sd_pp = settings.fundamentals_sd_pp
89 + prior_var_logit = np.array([(sd_pp / 100.0) ** 2 / max((p / 100.0) * (1 - p / 100.0), 1e-3) ** 2 for p in prior])
90 + mu = (1 - w) * mu_logit + w * prior_logit
91 + var = var_logit + (w ** 2) * prior_var_logit
92 + # traduction en points de % (pour l'affichage) : moyenne après mélange vs avant
93 + def to_pct(z: np.ndarray) -> np.ndarray:
94 + p = 1 / (1 + np.exp(-z))
95 + return 100.0 * p / p.sum()
96 + before, after = to_pct(mu_logit), to_pct(mu)
97 + detail = {"enabled": True, "weight": round(w, 3), "daysLeft": days_left, "satisfaction": round(sat, 1), "satisfactionPolls": fund.get("satisfactionPolls"),
98 + "incumbent": incumbent, "incumbentPrior": round(inc_prior, 1), "prior": {p: round(float(v), 1) for p, v in zip(PARTIES_ALL, prior)},
99 + "shiftPp": {p: round(float(a - b), 2) for p, a, b in zip(PARTIES_ALL, after, before)},
100 + "params": {"alpha": settings.fundamentals_alpha, "beta": settings.fundamentals_beta, "sdPp": sd_pp, "weightMax": settings.fundamentals_weight_max, "horizonDays": settings.fundamentals_horizon_days}}
101 + return mu, var, detail
102 +
103 +
104 +# ----------------------------------------------------------------- momentum
105 +def momentum_shift(last_pct: np.ndarray, fund: dict | None, attention: dict | None, media: dict | None) -> tuple[np.ndarray, dict]:
106 + """Nudge additif (pp) par parti, borné et recentré ; détail par composante."""
107 + P = len(PARTIES_ALL)
108 + parts = {"leader": {}, "attention": {}, "media": {}}
109 + shift = np.zeros(P)
110 + for j, pid in enumerate(PARTY_IDS):
111 + v = float(last_pct[j])
112 + comp = 0.0
113 + if fund and fund.get("enabled"):
114 + bp = (fund.get("bestPmShare") or {}).get(pid)
115 + if bp is not None:
116 + x = settings.momentum_leader_kappa * (float(bp) - v)
117 + parts["leader"][pid] = round(x, 2)
118 + comp += x
119 + if attention and attention.get("enabled"):
120 + d = (attention.get("delta") or {}).get(pid)
121 + if d is not None:
122 + x = settings.momentum_attention_kappa * float(d)
123 + parts["attention"][pid] = round(x, 2)
124 + comp += x
125 + if media and media.get("enabled"):
126 + t = (media.get("tone") or {}).get(pid)
127 + n = (media.get("counts") or {}).get(pid, 0)
128 + if t is not None and n:
129 + x = settings.momentum_media_kappa * float(t) * min(1.0, n / 10.0)
130 + parts["media"][pid] = round(x, 2)
131 + comp += x
132 + shift[j] = _clip(comp, -settings.momentum_max_pp, settings.momentum_max_pp)
133 + main = shift[: len(PARTY_IDS)]
134 + if np.any(main):
135 + main = main - main.mean() # somme nulle : le momentum redistribue, il ne crée pas de vote
136 + shift[: len(PARTY_IDS)] = main
137 + detail = {"enabled": bool(np.any(shift)), "shiftPp": {p: round(float(x), 2) for p, x in zip(PARTIES_ALL, shift)}, "parts": parts,
138 + "params": {"leaderKappa": settings.momentum_leader_kappa, "attentionKappa": settings.momentum_attention_kappa, "mediaKappa": settings.momentum_media_kappa, "maxPp": settings.momentum_max_pp}}
139 + return shift, detail
140 +
141 +
142 +def apply_shift(national: np.ndarray, shift: np.ndarray) -> np.ndarray:
143 + """Décale les tirages nationaux (S, P) de `shift` pp, puis renormalise (plancher 0,2 %)."""
144 + if not np.any(shift):
145 + return national
146 + out = np.clip(national + shift[None, :], 0.2, None)
147 + return 100.0 * out / out.sum(axis=1, keepdims=True)
148 +
149 +
150 +# ------------------------------------------------------------------ marchés
151 +@dataclass
152 +class Tempering:
153 + weights: np.ndarray
154 + detail: dict = field(default_factory=dict)
155 +
156 +
157 +def market_tempering(seats: np.ndarray, majority: int, markets: dict | None, w_m: float | None = None) -> Tempering:
158 + """Repondération IPF des simulations vers (1−w)·modèle + w·marché sur : gagnant (plus de sièges),
159 + majorité (parti concerné), tranches de sièges (poids réduit de moitié : marchés moins liquides)."""
160 + S, P = seats.shape
161 + w = np.ones(S)
162 + w_m = settings.markets_weight if w_m is None else w_m
163 + detail = {"enabled": False, "weight": w_m}
164 + if not markets or w_m <= 0:
165 + return Tempering(w, detail)
166 + most = seats.argmax(axis=1)
167 + constraints: list[tuple[np.ndarray, dict[int, float], str]] = []
168 + # 1) plus de sièges
169 + ms = markets.get("mostSeats") or {}
170 + if ms:
171 + model = {j: float((most == j).mean()) for j in range(P)}
172 + target = {}
173 + for j, pid in enumerate(PARTIES_ALL):
174 + mk = ms.get(pid)
175 + target[j] = (1 - w_m) * model[j] + w_m * float(mk) if mk is not None else model[j]
176 + tot = sum(target.values()) or 1
177 + target = {j: v / tot for j, v in target.items()}
178 + constraints.append((most, target, "mostSeats"))
179 + # 2) majorité
180 + for pid, pm in (markets.get("majority") or {}).items():
181 + if pid in PARTIES_ALL:
182 + j = PARTIES_ALL.index(pid)
183 + ind = (seats[:, j] >= majority).astype(int)
184 + model_p = float(ind.mean())
185 + t = (1 - w_m) * model_p + w_m * float(pm)
186 + constraints.append((ind, {1: t, 0: 1 - t}, f"majority:{pid}"))
187 + # 3) tranches de sièges (poids réduit)
188 + wb = w_m / 2
189 + for pid, sb in (markets.get("seatBands") or {}).items():
190 + if pid not in PARTIES_ALL or not sb.get("bands"):
191 + continue
192 + j = PARTIES_ALL.index(pid)
193 + bands = sb["bands"]
194 + cat = np.full(S, -1)
195 + for k, b in enumerate(bands):
196 + lo, hi = b["lo"], b["hi"] if b["hi"] is not None else 10**6
197 + cat[(seats[:, j] >= lo) & (seats[:, j] <= hi)] = k
198 + if (cat < 0).any():
199 + continue
200 + model = {k: float((cat == k).mean()) for k in range(len(bands))}
201 + target = {k: (1 - wb) * model[k] + wb * float(bands[k]["p"]) for k in range(len(bands))}
202 + tot = sum(target.values()) or 1
203 + constraints.append((cat, {k: v / tot for k, v in target.items()}, f"seatBands:{pid}"))
204 + if not constraints:
205 + return Tempering(w, detail)
206 + for _ in range(25):
207 + for cat, target, _name in constraints:
208 + cur = {k: float(w[cat == k].sum() / w.sum()) for k in target}
209 + for k, t in target.items():
210 + if cur.get(k, 0) > 1e-9 and t > 0:
211 + w[cat == k] *= t / cur[k]
212 + elif t <= 0:
213 + w[cat == k] *= 0.0
214 + w = np.clip(w, 0.02, 50.0)
215 + w *= S / w.sum()
216 + achieved = {}
217 + for cat, target, name in constraints:
218 + achieved[name] = {str(k): round(float(w[cat == k].sum() / w.sum()), 4) for k in target}
219 + detail = {"enabled": True, "weight": w_m, "bandWeight": wb, "ess": round(ess(w), 0), "constraints": [c[2] for c in constraints], "achieved": achieved,
220 + "marketAsOf": markets.get("asOf"), "marketVolume": markets.get("volume"), "mostSeatsMarket": markets.get("mostSeats"), "majorityMarket": markets.get("majority"),
221 + "seatBandsImpliedMean": {p: v.get("impliedMean") for p, v in (markets.get("seatBands") or {}).items()}}
222 + return Tempering(w, detail)
223 +
224 +
225 +# --------------------------------------------------------------- partielles
226 +def byelection_adjustments(byel: dict | None, ridings: list, trend: list[dict], national_2022: dict[str, float]) -> tuple[np.ndarray, dict]:
227 + """Matrice (R, P) d'ajustements logit : κ × (swing local observé à la partielle − swing national du jour)."""
228 + R, P = len(ridings), len(PARTIES_ALL)
229 + adj = np.zeros((R, P))
230 + detail = {"enabled": False, "ridings": []}
231 + if not byel or not byel.get("enabled") or not trend:
232 + return adj, detail
233 + from ..util import norm_name
234 + by_name = {norm_name(r.name): i for i, r in enumerate(ridings) if getattr(r, "name", None)}
235 + by_date = {t["date"]: t["mean"] for t in trend}
236 + dates = sorted(by_date)
237 + for b in byel.get("byElections", []):
238 + key = norm_name(b.get("riding") or "")
239 + i = by_name.get(key)
240 + if i is None:
241 + i = next((idx for k, idx in by_name.items() if k.startswith(key) and key), None)
242 + if i is None or not b.get("previous"):
243 + continue
244 + d = b["date"]
245 + nat_d = by_date.get(d) or by_date.get(next((x for x in dates if x >= d), dates[-1]))
246 + res = {r["party_id"]: r["share"] for r in b.get("results", []) if r.get("party_id")}
247 + row = {}
248 + for j, pid in enumerate(PARTY_IDS):
249 + if pid not in res or pid not in b["previous"] or pid not in nat_d:
250 + continue
251 + local = _logit(res[pid] / 100) - _logit(b["previous"][pid] / 100)
252 + national = _logit(nat_d[pid] / 100) - _logit(national_2022.get(pid, 1) / 100)
253 + x = _clip(settings.byelection_kappa * (local - national), -1.0, 1.0)
254 + adj[i, j] = x
255 + row[pid] = round(x, 3)
256 + detail["ridings"].append({"code": ridings[i].code, "riding": b.get("riding"), "date": d, "adjLogit": row, "kappa": settings.byelection_kappa})
257 + detail["enabled"] = bool(detail["ridings"])
258 + return adj, detail
modified engine/qc26/model/ridings.py +1 −0
@@ -31,6 +31,7 @@ class RidingInput:
31 31 electors: int = 50000
32 32 pct_french: float | None = None # langue maternelle française (recensement 2021)
33 33 poll_region: str = "reste"
34 + name: str = ""
34 35
35 36
36 37 def simulate_ridings(ridings: list[RidingInput], national_draws: np.ndarray, national_2022: dict[str, float],
modified engine/qc26/model/run.py +131 −50
@@ -1,5 +1,7 @@
1 """Exécution d'un run de prévision : sondages → latent → Monte Carlo → sièges →
2 persistance versionnée → « Qu'est-ce qui a changé ? »."""
1 +"""Exécution d'un run de prévision (modèle 2.0) : sondages → latent → mélange fondamentaux →
2 +Monte Carlo → momentum → sièges → tempérage par les marchés (repondération) → persistance
3 +versionnée → « Qu'est-ce qui a changé ? ». Chaque composante est publiée séparément (summary.components)
4 +et la vue « sondages seuls » (pondération uniforme, sans fondamentaux ni momentum) est conservée."""
3 5 from __future__ import annotations
4 6
5 7 import logging
@@ -17,6 +19,7 @@ from ..db import (Candidate, ForecastHouseEffect, ForecastPartyResult, ForecastR
17 19 from ..parties import PARTY_IDS
18 20 from ..regions import POLL_REGION_LABELS, poll_region_for
19 21 from .aggregate import PARTIES_ALL, Obs, compute_weights, election_day_distribution, house_effects, kalman_latent
22 +from .ensemble import (apply_shift, byelection_adjustments, ess, fundamentals_blend, market_tempering, momentum_shift, whist, wmean, wquantile)
20 23 from .ridings import RidingInput, explain_factors, rating, simulate_ridings
21 24 from .segments import build_segment_model, riding_adjustments
22 25
@@ -57,7 +60,7 @@ def load_ridings(s: Session) -> tuple[list[RidingInput], dict[str, float]]:
57 60 out = [RidingInput(code=r.code, region_id=r.region_id or "inconnue", baseline=baselines.get(r.code, {}),
58 61 incumbent_party=r.incumbent_party_id, incumbent_running=r.incumbent_running, candidates=cands.get(r.code, set()),
59 62 electors=r.electors_2026 or 50000, pct_french=(demo[r.code].pct_french_mt if r.code in demo else None),
60 poll_region=poll_region_for(r.code))
63 + poll_region=poll_region_for(r.code), name=r.name)
61 64 for r in rows if baselines.get(r.code)]
62 65 national_2022 = {p.id: (p.vote_2022 or 0.0) for p in s.scalars(select(Party)).all()}
63 66 national_2022["aut"] = max(0.5, 100.0 - sum(national_2022.values()))
@@ -70,6 +73,36 @@ def _draw_national(mu: np.ndarray, cov: np.ndarray, n: int, rng: np.random.Gener
70 73 return 100.0 * p / p.sum(axis=1, keepdims=True)
71 74
72 75
76 +def _load_signals(s: Session, cutoff: date) -> dict:
77 + """Signaux disponibles en base (jamais interrogés en ligne pendant un run)."""
78 + out = {"markets": None, "fundamentals": None, "attention": None, "media": None, "byelections": None}
79 + try:
80 + from ..signals import attention, byelections, fundamentals, markets, media
81 + out["markets"] = markets.implied(s)
82 + out["fundamentals"] = fundamentals.fundamentals_summary(s, cutoff)
83 + out["attention"] = attention.attention_summary(s, cutoff)
84 + out["media"] = media.media_summary(s, cutoff)
85 + out["byelections"] = byelections.byelection_summary(s)
86 + except Exception as e: # noqa: BLE001
87 + log.warning("signaux indisponibles : %s", e)
88 + return out
89 +
90 +
91 +def _party_stats(national: np.ndarray, seats: np.ndarray, w: np.ndarray, majority: int, seats_total: int) -> dict:
92 + most = seats.argmax(axis=1)
93 + out = {}
94 + for j, pid in enumerate(PARTIES_ALL):
95 + v, sj = national[:, j], seats[:, j]
96 + out[pid] = {"vote": round(wmean(v, w), 2), "vote_sd": round(float(np.sqrt(np.average((v - wmean(v, w)) ** 2, weights=w))), 2),
97 + "vote80": [round(wquantile(v, w, 0.10), 1), round(wquantile(v, w, 0.90), 1)],
98 + "vote95": [round(wquantile(v, w, 0.025), 1), round(wquantile(v, w, 0.975), 1)],
99 + "seats": int(wquantile(sj.astype(float), w, 0.5)), "seats_mean": round(wmean(sj, w), 1),
100 + "seats80": [int(wquantile(sj.astype(float), w, 0.10)), int(wquantile(sj.astype(float), w, 0.90))],
101 + "seats95": [int(wquantile(sj.astype(float), w, 0.025)), int(wquantile(sj.astype(float), w, 0.975))],
102 + "p_most_seats": round(float(np.average(most == j, weights=w)), 4), "p_majority": round(float(np.average(sj >= majority, weights=w)), 4)}
103 + return out
104 +
105 +
73 106 def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None, publish: bool = True,
74 107 n_sims: int | None = None) -> ForecastRun:
75 108 cutoff = cutoff or date.today()
@@ -84,66 +117,87 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None
84 117 obs = load_observations(s, cutoff)
85 118 if len(obs) < 3:
86 119 raise RuntimeError("trop peu de sondages")
120 + days_left = max(0, (settings.election_date - cutoff).days)
87 121 weights = compute_weights(obs, cutoff)
88 122 effects = house_effects(obs, cutoff)
89 123 latent = kalman_latent(obs, cutoff, effects)
90 mu, cov = election_day_distribution(latent, cutoff, settings.election_date)
91 national = _draw_national(mu, cov, n_sims, rng) # (S, P) %
124 + signals = _load_signals(s, cutoff)
125 +
126 + # --- 1. vote national : sondages seuls, puis mélange fondamentaux ---
127 + mu_polls, cov_polls = election_day_distribution(latent, cutoff, settings.election_date)
128 + national_polls = _draw_national(mu_polls, cov_polls, n_sims, rng) # (S, P) % — sondages seuls
129 + mu_f, var_f, fund_detail = fundamentals_blend(latent["mean_logit"], latent["var_logit"], latent["last_pct"], signals["fundamentals"], days_left)
130 + if fund_detail["enabled"]:
131 + latent_f = {**latent, "mean_logit": mu_f, "var_logit": var_f}
132 + mu_e, cov_e = election_day_distribution(latent_f, cutoff, settings.election_date)
133 + rng_e = np.random.default_rng(seed) # mêmes aléas → différence attribuable au seul mélange
134 + national = _draw_national(mu_e, cov_e, n_sims, rng_e)
135 + else:
136 + national = national_polls.copy()
137 + # --- 2. momentum (nudge borné, somme nulle) ---
138 + shift, mom_detail = momentum_shift(latent["last_pct"], signals["fundamentals"], signals["attention"], signals["media"])
139 + national = apply_shift(national, shift)
92 140
141 + # --- 3. circonscriptions ---
93 142 ridings, national_2022 = load_ridings(s)
94 143 national_now_mean = {p: float(national[:, j].mean()) for j, p in enumerate(PARTIES_ALL)}
95 144 seg_model, f_r = build_segment_model(s, cutoff, national_now_mean, national_2022)
96 145 seg_adj = riding_adjustments(seg_model, [r.code for r in ridings], f_r)
97 winners, shares = simulate_ridings(ridings, national, national_2022, rng, seg_adj=seg_adj)
146 + be_adj, be_detail = byelection_adjustments(signals["byelections"], ridings, latent["series"], national_2022)
147 + total_adj = seg_adj + be_adj
148 + rng_r = np.random.default_rng(seed + 1)
149 + winners, shares = simulate_ridings(ridings, national, national_2022, rng_r, seg_adj=total_adj)
98 150 S, R, P = shares.shape
99 151 seats_total = int(get_setting(s, "seats_total", R))
100 152 majority = int(get_setting(s, "majority_threshold", seats_total // 2 + 1))
101 153 seats = np.zeros((S, P), dtype=int)
102 154 for j in range(P):
103 155 seats[:, j] = (winners == j).sum(axis=1)
104 most = seats.argmax(axis=1)
156 + # sondages seuls (mêmes aléas de circonscription, sans fondamentaux, momentum ni partielles) — pour comparaison
157 + rng_r0 = np.random.default_rng(seed + 1)
158 + winners0, _ = simulate_ridings(ridings, national_polls, national_2022, rng_r0, seg_adj=seg_adj)
159 + seats0 = np.zeros((S, P), dtype=int)
160 + for j in range(P):
161 + seats0[:, j] = (winners0 == j).sum(axis=1)
162 +
163 + # --- 4. tempérage par les marchés : repondération des simulations ---
164 + temp = market_tempering(seats, majority, signals["markets"])
165 + w = temp.weights
166 + uniform = np.ones(S)
105 167
106 summary_parties = {}
168 + # --- statistiques pondérées ---
169 + most = seats.argmax(axis=1)
170 + summary_parties = _party_stats(national, seats, w, majority, seats_total)
171 + polls_only = _party_stats(national_polls, seats0, uniform, majority, seats_total)
172 + model_only = _party_stats(national, seats, uniform, majority, seats_total) # fondamentaux + momentum, sans marchés
107 173 s.execute(delete(ForecastPartyResult).where(ForecastPartyResult.run_id == run.id))
108 174 for j, pid in enumerate(PARTIES_ALL):
109 v = national[:, j]
110 sj = seats[:, j]
111 hist = np.bincount(sj, minlength=seats_total + 1).tolist()
112 fpr = ForecastPartyResult(
113 run_id=run.id, party_id=pid, vote_mean=float(v.mean()), vote_sd=float(v.std()),
114 vote_lo80=float(np.percentile(v, 10)), vote_hi80=float(np.percentile(v, 90)),
115 vote_lo95=float(np.percentile(v, 2.5)), vote_hi95=float(np.percentile(v, 97.5)),
116 seats_mean=float(sj.mean()), seats_median=int(np.median(sj)),
117 seats_lo80=int(np.percentile(sj, 10)), seats_hi80=int(np.percentile(sj, 90)),
118 seats_lo95=int(np.percentile(sj, 2.5)), seats_hi95=int(np.percentile(sj, 97.5)),
119 p_most_seats=float((most == j).mean()), p_majority=float((sj >= majority).mean()),
120 seat_distribution=hist)
121 s.add(fpr)
122 summary_parties[pid] = {"vote": round(fpr.vote_mean, 2), "vote_sd": round(fpr.vote_sd, 2),
123 "vote80": [round(fpr.vote_lo80, 1), round(fpr.vote_hi80, 1)],
124 "seats": fpr.seats_median, "seats_mean": round(fpr.seats_mean, 1),
125 "seats80": [fpr.seats_lo80, fpr.seats_hi80], "seats95": [fpr.seats_lo95, fpr.seats_hi95],
126 "p_most_seats": round(fpr.p_most_seats, 4), "p_majority": round(fpr.p_majority, 4)}
127 p_minority = float((seats.max(axis=1) < majority).mean())
175 + v, sj, sp = national[:, j], seats[:, j], summary_parties[pid]
176 + s.add(ForecastPartyResult(
177 + run_id=run.id, party_id=pid, vote_mean=sp["vote"], vote_sd=sp["vote_sd"],
178 + vote_lo80=sp["vote80"][0], vote_hi80=sp["vote80"][1], vote_lo95=sp["vote95"][0], vote_hi95=sp["vote95"][1],
179 + seats_mean=sp["seats_mean"], seats_median=sp["seats"], seats_lo80=sp["seats80"][0], seats_hi80=sp["seats80"][1],
180 + seats_lo95=sp["seats95"][0], seats_hi95=sp["seats95"][1], p_most_seats=sp["p_most_seats"], p_majority=sp["p_majority"],
181 + seat_distribution=whist(sj, w, seats_total + 1)))
182 + p_minority = float(np.average(seats.max(axis=1) < majority, weights=w))
128 183
129 184 # circonscriptions
130 185 s.execute(delete(ForecastRidingResult).where(ForecastRidingResult.run_id == run.id))
131 186 s.execute(delete(ForecastRidingSummary).where(ForecastRidingSummary.run_id == run.id))
132 national_now = {p: round(float(national[:, j].mean()), 2) for j, p in enumerate(PARTIES_ALL)}
133 battlegrounds = []
134 riding_rows = []
187 + national_now = {p: summary_parties[p]["vote"] for p in PARTIES_ALL}
188 + battlegrounds, riding_rows = [], []
189 + be_codes = {x["code"]: x for x in be_detail.get("ridings", [])}
135 190 for i, r in enumerate(ridings):
136 191 sh = shares[:, i, :]
137 pw = np.array([(winners[:, i] == j).mean() for j in range(P)])
192 + pw = np.array([float(np.average(winners[:, i] == j, weights=w)) for j in range(P)])
138 193 order = np.argsort(-pw)
139 194 fav, second = int(order[0]), int(order[1])
140 share_mean = {p: float(sh[:, j].mean()) for j, p in enumerate(PARTIES_ALL)}
195 + share_mean = {p: wmean(sh[:, j], w) for j, p in enumerate(PARTIES_ALL)}
141 196 for j, pid in enumerate(PARTIES_ALL):
142 197 if pid == "aut" and share_mean[pid] < 1.5:
143 198 continue
144 199 s.add(ForecastRidingResult(run_id=run.id, riding_code=r.code, party_id=pid, vote_mean=share_mean[pid],
145 vote_lo80=float(np.percentile(sh[:, j], 10)), vote_hi80=float(np.percentile(sh[:, j], 90)),
146 p_win=float(pw[j])))
200 + vote_lo80=wquantile(sh[:, j], w, 0.10), vote_hi80=wquantile(sh[:, j], w, 0.90), p_win=float(pw[j])))
147 201 fav_id = PARTIES_ALL[fav]
148 202 margin = share_mean[fav_id] - share_mean[PARTIES_ALL[second]]
149 203 swing = share_mean[fav_id] - r.baseline.get(fav_id, 0.0)
@@ -154,11 +208,21 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None
154 208 if r.poll_region in seg_model.current and fav_id in seg_model.current[r.poll_region]:
155 209 seg_detail["region"] = {"label": POLL_REGION_LABELS.get(r.poll_region, r.poll_region), "now": seg_model.current[r.poll_region][fav_id],
156 210 "2022": seg_model.baseline.get(r.poll_region, {}).get(fav_id, 0)}
211 + factors = explain_factors(r, national_now, national_2022, fav_id, share_mean, seg_adj_fav=float(seg_adj[i, fav]), seg_detail=seg_detail)
212 + if r.code in be_codes:
213 + be = be_codes[r.code]
214 + a = be["adjLogit"].get(fav_id, 0.0)
215 + _d = date.fromisoformat(be["date"])
216 + _mois = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"][_d.month - 1]
217 + factors.insert(2, {"factor": "partielle", "label": f"Élection partielle du {_d.day}{'er' if _d.day == 1 else ''} {_mois} {_d.year} (résultat officiel)",
218 + "detail": f"excès de swing local observé, net du swing national du jour, rétréci (κ {be['kappa']}) : {a:+.2f} logit pour {fav_id.upper()}",
219 + "direction": "+" if a > 0.02 else "-" if a < -0.02 else "±"})
220 + if temp.detail.get("enabled"):
221 + factors.append({"factor": "marches", "label": "Tempérage par les marchés de prédiction (Polymarket)",
222 + "detail": f"simulations repondérées (poids {temp.detail['weight']}) vers les probabilités implicites nationales ; effet indirect sur la circonscription", "direction": "±"})
157 223 summ = ForecastRidingSummary(run_id=run.id, riding_code=r.code, favorite_party_id=fav_id, favorite_p=float(pw[fav]),
158 224 runner_up_party_id=PARTIES_ALL[second], margin_mean=float(margin),
159 volatility=float(1 - pw[fav]), rating=rating(float(pw[fav])), swing_from_2022=float(swing),
160 factors=explain_factors(r, national_now, national_2022, fav_id, share_mean,
161 seg_adj_fav=float(seg_adj[i, fav]), seg_detail=seg_detail))
225 + volatility=float(1 - pw[fav]), rating=rating(float(pw[fav])), swing_from_2022=float(swing), factors=factors)
162 226 s.add(summ)
163 227 riding_rows.append((r.code, fav_id, float(pw[fav]), PARTIES_ALL[second], float(margin)))
164 228 battlegrounds.append((float(1 - pw[fav]), r.code))
@@ -177,7 +241,7 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None
177 241 recent_ids = [o.poll_id for o in obs if (cutoff - o.end).days <= 45]
178 242 base_last = {p: float(x) for p, x in zip(PARTIES_ALL, latent["last_pct"])}
179 243 for o in obs:
180 w = weights[o.poll_id]
244 + wgt = weights[o.poll_id]
181 245 impact = None
182 246 if o.poll_id in recent_ids and len(obs) > 4:
183 247 sub = [x for x in obs if x.poll_id != o.poll_id]
@@ -186,29 +250,38 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None
186 250 impact = {p: round(base_last[p] - float(x), 2) for p, x in zip(PARTIES_ALL, lat2["last_pct"])}
187 251 except Exception: # noqa: BLE001
188 252 impact = None
189 s.add(PollWeight(run_id=run.id, poll_id=o.poll_id, weight=w["weight"], recency_weight=w["recency"],
190 sample_weight=w["sample"], impact=impact))
253 + s.add(PollWeight(run_id=run.id, poll_id=o.poll_id, weight=wgt["weight"], recency_weight=wgt["recency"],
254 + sample_weight=wgt["sample"], impact=impact))
191 255
192 # tendance quotidienne (série pour les graphiques) — allégée à 1 point / jour
193 256 trend = latent["series"]
194
195 # résumé + diff avec le run publié précédent
196 257 prev = s.scalar(select(ForecastRun).where(ForecastRun.is_published == True, ForecastRun.kind == "forecast", # noqa: E712
197 258 ForecastRun.id != run.id).order_by(ForecastRun.id.desc()))
198 259 top = sorted(summary_parties.items(), key=lambda kv: -kv[1]["seats_mean"])
260 + components = {
261 + "pollsOnly": {"parties": polls_only, "p_minority": round(float((seats0.max(axis=1) < majority).mean()), 4)},
262 + "modelNoMarkets": {"parties": model_only, "p_minority": round(float((seats.max(axis=1) < majority).mean()), 4)},
263 + "fundamentals": fund_detail, "momentum": mom_detail, "markets": temp.detail, "byelections": be_detail,
264 + "signals": {"attention": {k: v for k, v in (signals["attention"] or {}).items() if k in ("enabled", "asOf", "share7", "share28", "delta")},
265 + "media": {k: v for k, v in (signals["media"] or {}).items() if k in ("enabled", "asOf", "articles", "shareOfVoice", "tone", "counts")},
266 + "fundamentals": {k: v for k, v in (signals["fundamentals"] or {}).items() if k in ("enabled", "asOf", "satisfaction", "satisfactionNet", "bestPmShare", "leaderNetFavorable", "secondChoice", "sovereigntyYes")},
267 + "markets": {k: v for k, v in (signals["markets"] or {}).items() if k in ("asOf", "mostSeats", "majority", "volume")} if signals["markets"] else None,
268 + "byelections": {"n": (signals["byelections"] or {}).get("n", 0), "meanSwing": (signals["byelections"] or {}).get("meanSwing")}},
269 + "ess": round(ess(w), 0), "nSimulations": n_sims,
270 + }
199 271 summary = {
200 272 "seats_total": seats_total, "majority": majority, "parties": summary_parties, "p_minority": round(p_minority, 4),
201 273 "leader": top[0][0], "poll_count": len(obs), "latest_poll": max(o.end for o in obs).isoformat(),
202 "national_now": national_now, "cutoff": cutoff.isoformat(), "days_to_election": (settings.election_date - cutoff).days,
274 + "national_now": national_now, "cutoff": cutoff.isoformat(), "days_to_election": days_left,
203 275 "battlegrounds": [c for _, c in sorted(battlegrounds, reverse=True)[:15]],
204 276 "segments": seg_model.payload(),
205 277 "demographics_coverage": sum(1 for r in ridings if r.pct_french is not None),
206 278 "ridings": {str(c): {"fav": f, "p": round(p, 3), "second": sec, "margin": round(m, 1)} for c, f, p, sec, m in riding_rows},
279 + "components": components,
207 280 }
208 281 diff = None
209 if prev and prev.summary:
210 new_polls = [o.poll_id for o in obs if o.end > date.fromisoformat(prev.summary.get("latest_poll", "1900-01-01")) or
211 o.poll_id not in {pw.poll_id for pw in s.scalars(select(PollWeight).where(PollWeight.run_id == prev.id)).all()}]
282 + if prev and prev.summary and "parties" in prev.summary:
283 + prev_ids = {pw.poll_id for pw in s.scalars(select(PollWeight).where(PollWeight.run_id == prev.id)).all()}
284 + new_polls = [o.poll_id for o in obs if o.end > date.fromisoformat(prev.summary.get("latest_poll", "1900-01-01")) or o.poll_id not in prev_ids]
212 285 changes = {}
213 286 for pid in PARTIES_ALL:
214 287 a, b = prev.summary["parties"].get(pid), summary_parties[pid]
@@ -224,8 +297,16 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None
224 297 causes = []
225 298 if new_polls:
226 299 causes.append({"kind": "new_polls", "detail": new_polls[:6]})
227 if not new_polls:
228 causes.append({"kind": "time", "detail": "recalcul sans nouveau sondage (récence, dérive vers le scrutin)"})
300 + prev_mk = ((prev.summary.get("components") or {}).get("markets") or {}).get("mostSeatsMarket")
301 + cur_mk = temp.detail.get("mostSeatsMarket")
302 + if prev_mk and cur_mk:
303 + moved = {p: round(cur_mk.get(p, 0) - prev_mk.get(p, 0), 3) for p in cur_mk if abs(cur_mk.get(p, 0) - prev_mk.get(p, 0)) >= 0.02}
304 + if moved:
305 + causes.append({"kind": "markets", "detail": moved})
306 + if prev.model_version != settings.model_version:
307 + causes.append({"kind": "model", "detail": f"modèle {prev.model_version}{settings.model_version}"})
308 + if not causes:
309 + causes.append({"kind": "time", "detail": "recalcul sans nouveau sondage (récence, dérive vers le scrutin, signaux mis à jour)"})
229 310 diff = {"previous_run": prev.id, "previous_at": prev.completed_at.isoformat() if prev.completed_at else None,
230 311 "changes": changes, "flips": flips, "causes": causes}
231 312
@@ -239,7 +320,7 @@ def run_forecast(s: Session, trigger: str = "manual", cutoff: date | None = None
239 320 run.is_published = True
240 321 set_setting(s, "latest_forecast_run", run.id)
241 322 s.flush()
242 log.info("run %d : %s — sièges %s", run.id, summary["leader"], {k: v["seats"] for k, v in summary_parties.items()})
323 + log.info("run %d : %s — sièges %s (ESS %s)", run.id, summary["leader"], {k: v["seats"] for k, v in summary_parties.items()}, components["ess"])
243 324 return run
244 325 except Exception as e: # noqa: BLE001
245 326 run.status = "failed"
modified engine/qc26/scheduler.py +52 −0
@@ -92,6 +92,54 @@ def job_documents():
92 92 return {"docs": res, "positions": pos}
93 93
94 94
95 +@_guarded("signals_markets")
96 +def job_signals_markets():
97 + from .signals import markets
98 + with session_scope() as s:
99 + return markets.sync_markets(s, with_history=True)
100 +
101 +
102 +@_guarded("signals_daily")
103 +def job_signals_daily():
104 + """Attention (Wikipédia), fondamentaux (questions de leadership), partielles, GDELT — une fois par jour, puis recalcul."""
105 + from .ingest import official
106 + from .model.run import run_forecast
107 + from .signals import attention, byelections, fundamentals, media
108 + out = {}
109 + with session_scope() as s:
110 + out["attention"] = attention.sync_attention(s)
111 + with session_scope() as s:
112 + out["fundamentals"] = fundamentals.extract_from_polls(s)
113 + with session_scope() as s:
114 + out["byelections"] = byelections.sync_by_elections(s)
115 + official.apply_by_election_incumbents(s)
116 + try:
117 + with session_scope() as s:
118 + out["gdelt"] = media.sync_gdelt(s)
119 + except Exception as e: # noqa: BLE001
120 + out["gdelt"] = {"error": str(e)[:200]}
121 + with session_scope() as s:
122 + run = run_forecast(s, trigger="signals_daily")
123 + out["run"] = run.id
124 + return out
125 +
126 +
127 +@_guarded("signals_media")
128 +def job_signals_media():
129 + from .signals import media
130 + with session_scope() as s:
131 + return media.sync_news(s, classify=True)
132 +
133 +
134 +@_guarded("periodic_forecast")
135 +def job_periodic_forecast():
136 + """Recalcul toutes les 6 h : les marchés bougent en continu, la récence des sondages aussi."""
137 + from .model.run import run_forecast
138 + with session_scope() as s:
139 + run = run_forecast(s, trigger="periodic")
140 + return {"run": run.id}
141 +
142 +
95 143 def _in_live_window() -> bool:
96 144 if settings.election_simulation:
97 145 return True
@@ -126,6 +174,10 @@ def start() -> BackgroundScheduler:
126 174 sch.add_job(job_daily_forecast, "cron", hour=6, minute=15, id="daily_forecast")
127 175 sch.add_job(job_documents, "interval", minutes=settings.doc_watch_minutes, id="documents", next_run_time=datetime.now(timezone.utc) + timedelta(minutes=30))
128 176 sch.add_job(job_live, "interval", seconds=settings.live_poll_seconds, id="live")
177 + sch.add_job(job_signals_markets, "interval", minutes=settings.signals_markets_minutes, id="signals_markets", next_run_time=datetime.now(timezone.utc) + timedelta(minutes=5))
178 + sch.add_job(job_signals_media, "interval", hours=settings.signals_media_hours, id="signals_media", next_run_time=datetime.now(timezone.utc) + timedelta(minutes=12))
179 + sch.add_job(job_signals_daily, "cron", hour=5, minute=40, id="signals_daily")
180 + sch.add_job(job_periodic_forecast, "cron", hour="0,12,18", minute=20, id="periodic_forecast")
129 181 sch.start()
130 182 _sched = sch
131 183 log.info("planificateur démarré")
added engine/qc26/signals/__init__.py +8 −0
@@ -0,0 +1,8 @@
1 +"""Signaux « au-delà des sondages » (modèle 2.0) : marchés de prédiction, attention publique,
2 +couverture médiatique, fondamentaux tirés des questions de leadership, élections partielles.
3 +
4 +Chaque connecteur écrit des observations datées et sourcées dans `signals` (et les tables
5 +dédiées `markets`, `market_prices`, `media_items`, `by_elections`). Le modèle (`model/ensemble.py`)
6 +n'utilise que ce qui est en base : aucun signal n'est inventé, l'absence d'un signal désactive
7 +simplement la composante correspondante.
8 +"""
added engine/qc26/signals/attention.py +113 −0
@@ -0,0 +1,113 @@
1 +"""Attention publique — pages vues Wikipédia (API REST Wikimedia, publique, sans clé).
2 +
3 +Pour chaque parti : page du parti + page de son·sa chef·fe (Wikipédia FR), vues quotidiennes
4 +« user » (robots exclus) depuis le 1er juin 2026. Le signal dérivé est la **part d'attention**
5 +(part des vues du parti dans le total des cinq partis) et son évolution 7 j vs 28 j. C'est un
6 +indicateur de saillance, pas d'intention de vote : il n'entre dans le modèle que comme
7 +composante de momentum bornée.
8 +"""
9 +from __future__ import annotations
10 +
11 +import logging
12 +from datetime import date, datetime, timedelta
13 +from urllib.parse import quote
14 +
15 +import httpx
16 +from sqlalchemy import select
17 +from sqlalchemy.orm import Session
18 +
19 +from ..config import settings
20 +from ..db import Signal, Source, utcnow
21 +from ..parties import PARTY_IDS
22 +from .markets import _upsert_signal
23 +
24 +log = logging.getLogger("qc26.signals.attention")
25 +
26 +# Pages Wikipédia FR (titres exacts) — parti puis chef·fe (registre DGEQ ; pour QS la cheffe
27 +# officielle est peu connue du public : on suit la co-porte-parole parlementaire).
28 +PAGES: dict[str, list[str]] = {
29 + "caq": ["Coalition_avenir_Québec", "Christine_Fréchette"],
30 + "plq": ["Parti_libéral_du_Québec", "Charles_Milliard"],
31 + "pq": ["Parti_québécois", "Paul_St-Pierre_Plamondon"],
32 + "qs": ["Québec_solidaire", "Ruba_Ghazal"],
33 + "pcq": ["Parti_conservateur_du_Québec_(2009)", "Éric_Duhaime"],
34 +}
35 +ELECTION_PAGE = "Élections_générales_québécoises_de_2026"
36 +START = date(2026, 6, 1)
37 +
38 +
39 +def fetch_views(title: str, start: date, end: date) -> dict[date, int]:
40 + url = (f"https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/fr.wikipedia/all-access/user/"
41 + f"{quote(title, safe='')}/daily/{start:%Y%m%d}/{end:%Y%m%d}")
42 + with httpx.Client(timeout=30, headers={"User-Agent": "QC26Bot/1.0 (https://www.qc26.ca; contact@qc26.ca)"}) as c:
43 + r = c.get(url)
44 + if r.status_code == 404:
45 + return {}
46 + r.raise_for_status()
47 + items = r.json().get("items", [])
48 + return {datetime.strptime(i["timestamp"][:8], "%Y%m%d").date(): int(i["views"]) for i in items}
49 +
50 +
51 +def sync_attention(s: Session) -> dict:
52 + src = s.get(Source, "wikipedia-pageviews")
53 + if not src:
54 + src = Source(id="wikipedia-pageviews", name="Wikimedia — pages vues Wikipédia FR (partis, chef·fes)", url="https://wikimedia.org/api/rest_v1/", tier=3, kind="signal")
55 + s.add(src)
56 + src.last_checked_at = utcnow()
57 + end = date.today() - timedelta(days=1)
58 + n, errors = 0, 0
59 + # reprise : on repart 10 jours avant la dernière observation connue
60 + last = s.scalar(select(Signal.date).where(Signal.source == "wikipedia", Signal.key == "pageviews").order_by(Signal.date.desc()))
61 + start = max(START, (last - timedelta(days=10))) if last else START
62 + for pid, titles in PAGES.items():
63 + total: dict[date, int] = {}
64 + detail: dict[str, dict[date, int]] = {}
65 + for t in titles:
66 + try:
67 + v = fetch_views(t, start, end)
68 + detail[t] = v
69 + for d, x in v.items():
70 + total[d] = total.get(d, 0) + x
71 + except Exception as e: # noqa: BLE001
72 + errors += 1
73 + log.warning("pageviews %s : %s", t, e)
74 + for d, x in total.items():
75 + _upsert_signal(s, "wikipedia", "pageviews", pid, d, float(x), url=f"https://fr.wikipedia.org/wiki/{titles[0]}",
76 + meta={"pages": {t: detail[t].get(d) for t in detail}})
77 + n += 1
78 + try:
79 + for d, x in fetch_views(ELECTION_PAGE, start, end).items():
80 + _upsert_signal(s, "wikipedia", "pageviews_election", None, d, float(x), url=f"https://fr.wikipedia.org/wiki/{ELECTION_PAGE}")
81 + except Exception as e: # noqa: BLE001
82 + errors += 1
83 + log.warning("pageviews élection : %s", e)
84 + src.last_ok_at, src.status = utcnow(), "operational" if not errors else "degraded"
85 + src.detail = f"{n} observations parti-jour"
86 + return {"observations": n, "errors": errors, "from": start.isoformat(), "to": end.isoformat()}
87 +
88 +
89 +def attention_summary(s: Session, cutoff: date | None = None) -> dict:
90 + """Part d'attention 7 j, 28 j et variation, par parti ; série quotidienne des parts."""
91 + cutoff = cutoff or date.today()
92 + rows = s.execute(select(Signal.party_id, Signal.date, Signal.value).where(Signal.source == "wikipedia", Signal.key == "pageviews", Signal.date <= cutoff)).all()
93 + if not rows:
94 + return {"enabled": False}
95 + by_day: dict[date, dict[str, float]] = {}
96 + for pid, d, v in rows:
97 + by_day.setdefault(d, {})[pid] = v
98 + days = sorted(by_day)
99 + series = []
100 + for d in days:
101 + tot = sum(by_day[d].values()) or 1
102 + series.append({"date": d.isoformat(), "share": {p: round(100 * by_day[d].get(p, 0) / tot, 2) for p in PARTY_IDS}, "views": {p: by_day[d].get(p, 0) for p in PARTY_IDS}})
103 + last = days[-1]
104 + def window(n: int) -> dict[str, float]:
105 + w = [d for d in days if (last - d).days < n]
106 + tot = {p: sum(by_day[d].get(p, 0) for d in w) for p in PARTY_IDS}
107 + all_ = sum(tot.values()) or 1
108 + return {p: 100 * tot[p] / all_ for p in PARTY_IDS}
109 + s7, s28 = window(7), window(28)
110 + election = s.execute(select(Signal.date, Signal.value).where(Signal.source == "wikipedia", Signal.key == "pageviews_election", Signal.date <= cutoff).order_by(Signal.date)).all()
111 + return {"enabled": True, "asOf": last.isoformat(), "share7": {p: round(v, 2) for p, v in s7.items()}, "share28": {p: round(v, 2) for p, v in s28.items()},
112 + "delta": {p: round(s7[p] - s28[p], 2) for p in PARTY_IDS}, "series": series[-120:],
113 + "election": [{"date": d.isoformat(), "views": v} for d, v in election][-120:], "pages": PAGES}
added engine/qc26/signals/byelections.py +177 −0
@@ -0,0 +1,177 @@
1 +"""Élections partielles provinciales depuis le scrutin de 2022 — résultats officiels d'Élections Québec.
2 +
3 +La liste des partielles est lue sur la page « Résultats et statistiques » (liens
4 +`resultats-partielles/AAAA-MM-JJ/<id>/`) ; la page de chaque partielle est rendue par Firecrawl
5 +(tableau injecté en JavaScript) puis archivée (SHA-256). Le swing observé (résultat de la
6 +partielle − résultat de l'élection générale 2022 dans la même circonscription, sur les mêmes
7 +limites : `historical_results`) est un signal réel de terrain, utilisé avec un fort rétrécissement.
8 +"""
9 +from __future__ import annotations
10 +
11 +import logging
12 +import re
13 +from datetime import date
14 +
15 +import httpx
16 +from sqlalchemy import select
17 +from sqlalchemy.orm import Session
18 +
19 +from ..config import settings
20 +from ..db import ByElection, HistoricalResult, Riding, Source, utcnow
21 +from ..parties import PARTY_IDS, party_id_from_label
22 +from ..util import http, norm_name, sha256_bytes
23 +
24 +log = logging.getLogger("qc26.signals.byelections")
25 +
26 +LIST_URL = "https://www.electionsquebec.qc.ca/resultats-et-statistiques/"
27 +LINK_RE = re.compile(r"https://www\.electionsquebec\.qc\.ca/resultats-et-statistiques/resultats-partielles/(20(?:2[3-9]|3\d)-\d{2}-\d{2})/(\d+)/")
28 +
29 +
30 +def list_by_elections() -> list[tuple[date, int, str]]:
31 + r = http().get(LIST_URL)
32 + r.raise_for_status()
33 + out = {}
34 + for m in LINK_RE.finditer(r.text):
35 + d = date.fromisoformat(m.group(1))
36 + if d > date(2022, 10, 3):
37 + out[(d, int(m.group(2)))] = m.group(0)
38 + return sorted([(d, c, u) for (d, c), u in out.items()])
39 +
40 +
41 +def _firecrawl_markdown(url: str) -> tuple[str, dict]:
42 + r = httpx.post("https://api.firecrawl.dev/v1/scrape", timeout=120, headers={"Authorization": f"Bearer {settings.firecrawl_api_key}"},
43 + json={"url": url, "formats": ["markdown"], "onlyMainContent": True, "waitFor": 5000})
44 + r.raise_for_status()
45 + d = r.json().get("data", {})
46 + return d.get("markdown") or "", d.get("metadata") or {}
47 +
48 +
49 +def parse_results(md: str) -> tuple[list[dict], float | None, str | None]:
50 + """Lit le tableau « Résultats par candidature » : Personne candidate | Appartenance | Bulletins valides | % | Majorité."""
51 + rows = []
52 + riding = None
53 + m = re.search(r"circonscription (?:électorale )?(?:de |d')([A-ZÉÈÀÂÎÔÛ][^\n\|]{2,60}?)(?:\s*[\n\|]|$)", md)
54 + if m:
55 + riding = m.group(1).strip().rstrip(".")
56 + for line in md.splitlines():
57 + if not line.strip().startswith("|"):
58 + continue
59 + cells = [c.strip() for c in line.strip().strip("|").split("|")]
60 + if len(cells) < 4 or cells[0].lower().startswith(("personne", "---", ":--")) or set(cells[0]) <= set("-: "):
61 + continue
62 + votes = re.sub(r"[^\d]", "", cells[2])
63 + pct = cells[3].replace(",", ".").replace("%", "").strip()
64 + if not votes.isdigit():
65 + continue
66 + try:
67 + share = float(re.sub(r"[^\d.]", "", pct)) if pct else None
68 + except ValueError:
69 + share = None
70 + label = re.sub(r"\s+", " ", cells[1])
71 + rows.append({"candidate": re.sub(r"\*+", "", cells[0]).strip(), "party_label": label, "party_id": party_id_from_label(label), "votes": int(votes), "share": share})
72 + turnout = None
73 + m = re.search(r"[Tt]aux de participation[^\d]{0,40}(\d{1,2}[,\.]\d{1,2})\s*%", md)
74 + if m:
75 + turnout = float(m.group(1).replace(",", "."))
76 + if rows and any(r["share"] is None for r in rows):
77 + tot = sum(r["votes"] for r in rows) or 1
78 + for r in rows:
79 + r["share"] = round(100 * r["votes"] / tot, 2)
80 + return rows, turnout, riding
81 +
82 +
83 +def sync_by_elections(s: Session) -> dict:
84 + src = s.get(Source, "dgeq-partielles")
85 + if not src:
86 + src = Source(id="dgeq-partielles", name="Élections Québec — résultats des élections partielles depuis 2022", url=LIST_URL, tier=1, kind="signal")
87 + s.add(src)
88 + src.last_checked_at = utcnow()
89 + try:
90 + items = list_by_elections()
91 + except Exception as e: # noqa: BLE001
92 + src.status, src.detail = "degraded", str(e)[:300]
93 + return {"error": str(e)[:200]}
94 + n_new, errors = 0, 0
95 + for d, code, url in items:
96 + bid = f"{d.isoformat()}-{code}"
97 + row = s.get(ByElection, bid)
98 + if row and row.status == "ok":
99 + continue
100 + if not row:
101 + row = ByElection(id=bid, date=d, dgeq_code=code, url=url)
102 + s.add(row)
103 + try:
104 + if not settings.firecrawl_api_key:
105 + raise RuntimeError("Firecrawl requis (tableau rendu en JavaScript)")
106 + md, meta = _firecrawl_markdown(url)
107 + if not md.strip():
108 + raise RuntimeError("page vide")
109 + results, turnout, riding = parse_results(md)
110 + if len(results) < 2:
111 + raise RuntimeError("tableau de résultats introuvable")
112 + sha = sha256_bytes(md.encode())
113 + path = settings.archive_dir / "partielles" / f"{sha}.md"
114 + path.write_text(md)
115 + row.sha256, row.path = sha, str(path)
116 + row.results, row.turnout = results, turnout
117 + # l'identifiant de l'URL est le code DGEQ de la circonscription (carte 2017) → résultats 2022 par code
118 + name, prev = previous_general_by_code(s, code)
119 + row.riding_name = name or riding or _riding_from_title(meta.get("title") or "")
120 + row.winner_party_id = max(results, key=lambda r: r["votes"])["party_id"]
121 + row.previous = prev or previous_general(s, row.riding_name)
122 + row.status = "ok"
123 + n_new += 1
124 + except Exception as e: # noqa: BLE001
125 + errors += 1
126 + row.status = "failed"
127 + log.warning("partielle %s : %s", url, e)
128 + s.commit()
129 + src.last_ok_at, src.status = utcnow(), "operational" if not errors else "degraded"
130 + src.detail = f"{len(items)} partielles depuis 2022, {n_new} mises à jour"
131 + return {"listed": len(items), "updated": n_new, "errors": errors}
132 +
133 +
134 +def _riding_from_title(title: str) -> str | None:
135 + m = re.search(r"partielle[^\-]*[–\-]\s*([^\-|]+?)\s*(?:[–\-|]|$)", title)
136 + return m.group(1).strip() if m else None
137 +
138 +
139 +def previous_general_by_code(s: Session, code: int) -> tuple[str | None, dict | None]:
140 + rows = s.scalars(select(HistoricalResult).where(HistoricalResult.election_date == date(2022, 10, 3), HistoricalResult.riding_code == code)).all()
141 + if not rows:
142 + return None, None
143 + return rows[0].riding_name, {h.party_id: round(h.share, 2) for h in rows if h.party_id} or None
144 +
145 +
146 +def previous_general(s: Session, riding_name: str | None) -> dict | None:
147 + """Parts 2022 (élection générale, mêmes limites) de la circonscription nommée."""
148 + if not riding_name:
149 + return None
150 + key = norm_name(riding_name)
151 + rows = s.scalars(select(HistoricalResult).where(HistoricalResult.election_date == date(2022, 10, 3))).all()
152 + match = [h for h in rows if norm_name(h.riding_name) == key]
153 + if not match:
154 + # tolère un nom partiel (ex. « Jean-Talon » vs « Jean-Talon (Québec) »)
155 + match = [h for h in rows if key in norm_name(h.riding_name) or norm_name(h.riding_name) in key]
156 + if not match:
157 + return None
158 + out = {}
159 + for h in match:
160 + if h.party_id:
161 + out[h.party_id] = round(h.share, 2)
162 + return out or None
163 +
164 +
165 +def byelection_summary(s: Session) -> dict:
166 + rows = s.scalars(select(ByElection).where(ByElection.status == "ok").order_by(ByElection.date)).all()
167 + out = []
168 + swings: dict[str, list[float]] = {p: [] for p in PARTY_IDS}
169 + for b in rows:
170 + res = {r["party_id"]: r["share"] for r in (b.results or []) if r.get("party_id")}
171 + swing = {p: round(res.get(p, 0.0) - (b.previous or {}).get(p, 0.0), 1) for p in PARTY_IDS if b.previous and (p in res or p in b.previous)}
172 + for p, v in swing.items():
173 + swings[p].append(v)
174 + out.append({"id": b.id, "date": b.date.isoformat(), "riding": b.riding_name, "code": b.dgeq_code, "url": b.url, "turnout": b.turnout, "winner": b.winner_party_id,
175 + "results": sorted((b.results or []), key=lambda r: -r["votes"]), "previous": b.previous, "swing": swing, "archiveSha": b.sha256})
176 + return {"enabled": bool(out), "byElections": out,
177 + "meanSwing": {p: (round(sum(v) / len(v), 1) if v else None) for p, v in swings.items()}, "n": len(out)}
added engine/qc26/signals/fundamentals.py +230 −0
@@ -0,0 +1,230 @@
1 +"""Fondamentaux tirés des questions de leadership des sondages déjà archivés.
2 +
3 +Les rapports des firmes contiennent, au-delà des intentions de vote, des questions que le modèle
4 +1.x ignorait : satisfaction à l'égard du gouvernement, meilleur·e premier·ère ministre, favorabilité
5 +des chef·fes, deuxième choix, appui à la souveraineté. Elles sont extraites du champ
6 +`notes.leadership` de chaque sondage (page citée dans le document) et normalisées ici en séries
7 +(`signals`, source = polls-leadership). Aucune valeur n'est saisie à la main.
8 +
9 +Utilisation dans le modèle 2.0 :
10 +- satisfaction du gouvernement → prior « fondamentaux » sur la part du parti sortant ;
11 +- meilleur PM vs intention de vote → composante de momentum « prime au chef » (bornée).
12 +"""
13 +from __future__ import annotations
14 +
15 +import logging
16 +import re
17 +from collections import defaultdict
18 +from datetime import date, timedelta
19 +
20 +from sqlalchemy import select
21 +from sqlalchemy.orm import Session
22 +
23 +from ..config import settings
24 +from ..db import Poll, Signal, Source, utcnow
25 +from ..parties import PARTY_IDS, party_id_from_label
26 +from .markets import _upsert_signal
27 +
28 +log = logging.getLogger("qc26.signals.fundamentals")
29 +
30 +LEADERS: dict[str, str] = { # nom (normalisé, sans accents) → parti — chef·fes / porte-parole interrogés par les firmes
31 + "frechette": "caq", "legault": "caq",
32 + "milliard": "plq", "tanguay": "plq", "rodriguez": "plq",
33 + "plamondon": "pq", "pspp": "pq",
34 + "ghazal": "qs", "nadeau-dubois": "qs", "nadeau dubois": "qs", "palardy": "qs", "zaga": "qs",
35 + "duhaime": "pcq",
36 +}
37 +INCUMBENT = "caq"
38 +
39 +
40 +def _norm(s: str) -> str:
41 + import unicodedata
42 + s = unicodedata.normalize("NFKD", s or "")
43 + return "".join(c for c in s if not unicodedata.combining(c)).lower()
44 +
45 +
46 +def leader_party(name: str) -> str | None:
47 + n = _norm(name)
48 + for k, p in LEADERS.items():
49 + if k in n:
50 + return p
51 + return party_id_from_label(name)
52 +
53 +
54 +def _num(v) -> float | None:
55 + try:
56 + return float(v)
57 + except (TypeError, ValueError):
58 + return None
59 +
60 +
61 +def _satisfied(values: dict) -> float | None:
62 + """Part « satisfait » : total satisfait, ou somme très/plutôt/assez satisfait (hors NSP)."""
63 + vals = {_norm(k): _num(v) for k, v in values.items()}
64 + for k, v in vals.items():
65 + if v is not None and ("total satisfait" in k or k.strip() == "satisfait" or k.startswith("satisfait")):
66 + return v
67 + parts = [v for k, v in vals.items() if v is not None and re.search(r"(tres|plutot|assez) satisfait", k) and "insatisfait" not in k]
68 + if parts:
69 + return sum(parts)
70 + for k, v in vals.items():
71 + if v is not None and ("bonne direction" in k or "excellent" in k):
72 + return v
73 + return None
74 +
75 +
76 +def _dissatisfied(values: dict) -> float | None:
77 + vals = {_norm(k): _num(v) for k, v in values.items()}
78 + for k, v in vals.items():
79 + if v is not None and ("total insatisfait" in k or k.strip() == "insatisfait" or k.startswith("insatisfait")):
80 + return v
81 + parts = [v for k, v in vals.items() if v is not None and re.search(r"(tres|plutot|assez) insatisfait", k)]
82 + if parts:
83 + return sum(parts)
84 + for k, v in vals.items():
85 + if v is not None and "mauvaise direction" in k:
86 + return v
87 + return None
88 +
89 +
90 +def extract_from_polls(s: Session) -> dict:
91 + """Relit toutes les questions de leadership archivées et (ré)écrit les séries dérivées."""
92 + src = s.get(Source, "polls-leadership")
93 + if not src:
94 + src = Source(id="polls-leadership", name="Questions de leadership des rapports de sondage (satisfaction, meilleur PM, favorabilité, 2e choix, souveraineté)", url="/sondages", tier=2, kind="signal")
95 + s.add(src)
96 + src.last_checked_at = utcnow()
97 + polls = s.scalars(select(Poll).where(Poll.excluded == False)).all() # noqa: E712
98 + n = 0
99 + for p in polls:
100 + lead = (p.notes or {}).get("leadership") or []
101 + if not isinstance(lead, list):
102 + continue
103 + url = p.source_url
104 + for q in lead:
105 + if not isinstance(q, dict):
106 + continue
107 + question = _norm(str(q.get("question") or ""))
108 + values = q.get("values") or {}
109 + if not isinstance(values, dict) or not values:
110 + continue
111 + meta = {"poll_id": p.id, "pollster": p.pollster_id, "question": q.get("question"), "page": q.get("page")}
112 + if "satisf" in question or "direction du quebec" in question or "appreciation du travail" in question:
113 + # satisfaction du gouvernement en place (exclure les questions rétrospectives « huit années »)
114 + if "huit ann" in question or "8 ann" in question or "bilan" in question:
115 + key = "satisfaction_bilan"
116 + else:
117 + key = "satisfaction"
118 + sat, dis = _satisfied(values), _dissatisfied(values)
119 + if sat is not None:
120 + _upsert_signal(s, "polls-leadership", key, INCUMBENT, p.fieldwork_end, sat, url=url, meta={**meta, "dissatisfied": dis}, n=p.sample_size)
121 + n += 1
122 + if sat is not None and dis is not None:
123 + _upsert_signal(s, "polls-leadership", key + "_net", INCUMBENT, p.fieldwork_end, sat - dis, url=url, meta=meta, n=p.sample_size)
124 + elif "meilleur premier" in question or "meilleur chef" in question or "meilleure premi" in question or "meilleur pm" in question:
125 + by_party: dict[str, float] = defaultdict(float)
126 + for k, v in values.items():
127 + pid = leader_party(k)
128 + x = _num(v)
129 + if pid and x is not None:
130 + by_party[pid] += x
131 + tot = sum(by_party.values())
132 + for pid, x in by_party.items():
133 + _upsert_signal(s, "polls-leadership", "best_pm", pid, p.fieldwork_end, x, url=url, meta={**meta, "decided_total": tot}, n=p.sample_size)
134 + if tot > 0:
135 + _upsert_signal(s, "polls-leadership", "best_pm_share", pid, p.fieldwork_end, round(100 * x / tot, 2), url=url, meta=meta, n=p.sample_size)
136 + n += 1
137 + elif "changement" in question and "leader" in question or "represente le plus le changement" in question:
138 + for k, v in values.items():
139 + pid = leader_party(k)
140 + x = _num(v)
141 + if pid and x is not None:
142 + _upsert_signal(s, "polls-leadership", "change_leader", pid, p.fieldwork_end, x, url=url, meta=meta, n=p.sample_size)
143 + n += 1
144 + elif "favorab" in question or "impression" in question or "opinion favorable" in question:
145 + # deux formes : un chef par question (échelle) ou tous les chefs (part favorable)
146 + single = None
147 + m = re.search(r"-\s*([a-z' \-]+?)\s*\(", question)
148 + for k in LEADERS:
149 + if k in question:
150 + single = LEADERS[k]
151 + break
152 + if single:
153 + vals = {_norm(k): _num(v) for k, v in values.items()}
154 + fav = sum(v for k, v in vals.items() if v is not None and "favorable" in k and "defavorable" not in k)
155 + unf = sum(v for k, v in vals.items() if v is not None and "defavorable" in k)
156 + if fav or unf:
157 + _upsert_signal(s, "polls-leadership", "leader_favorable", single, p.fieldwork_end, fav, url=url, meta={**meta, "unfavorable": unf}, n=p.sample_size)
158 + _upsert_signal(s, "polls-leadership", "leader_net_favorable", single, p.fieldwork_end, fav - unf, url=url, meta=meta, n=p.sample_size)
159 + n += 1
160 + else:
161 + key = "leader_unfavorable" if "defavorable" in question and "favorable)" not in question.replace("defavorable", "") else "leader_favorable"
162 + if "(defavorable)" in question or "défavorable)" in question:
163 + key = "leader_unfavorable"
164 + for k, v in values.items():
165 + pid = leader_party(k)
166 + x = _num(v)
167 + if pid and x is not None:
168 + _upsert_signal(s, "polls-leadership", key, pid, p.fieldwork_end, x, url=url, meta=meta, n=p.sample_size)
169 + n += 1
170 + elif "deuxieme choix" in question or "second choix" in question or "2e choix" in question:
171 + for k, v in values.items():
172 + pid = party_id_from_label(k)
173 + x = _num(v)
174 + if pid and x is not None:
175 + _upsert_signal(s, "polls-leadership", "second_choice", pid, p.fieldwork_end, x, url=url, meta=meta, n=p.sample_size)
176 + n += 1
177 + elif "souverainet" in question or "referendum" in question or "independance" in question:
178 + vals = {_norm(k): _num(v) for k, v in values.items()}
179 + yes = next((v for k, v in vals.items() if v is not None and (k.startswith("pour") or k.startswith("oui"))), None)
180 + no = next((v for k, v in vals.items() if v is not None and (k.startswith("contre") or k.startswith("non"))), None)
181 + if yes is not None:
182 + _upsert_signal(s, "polls-leadership", "sovereignty_yes", None, p.fieldwork_end, yes, url=url, meta={**meta, "no": no, "redistributed": "repartition" in question}, n=p.sample_size)
183 + n += 1
184 + src.last_ok_at, src.status, src.detail = utcnow(), "operational", f"{n} observations dérivées"
185 + return {"observations": n}
186 +
187 +
188 +def _weighted_recent(rows: list[tuple[date, float, int | None]], cutoff: date, halflife: float = 30.0) -> tuple[float | None, int]:
189 + if not rows:
190 + return None, 0
191 + num = den = 0.0
192 + for d, v, n in rows:
193 + w = 0.5 ** (max(0, (cutoff - d).days) / halflife) * ((min(n or 1000, 2500) / 1000.0) ** 0.5)
194 + num += w * v
195 + den += w
196 + return (num / den if den else None), len(rows)
197 +
198 +
199 +def fundamentals_summary(s: Session, cutoff: date | None = None) -> dict:
200 + """État courant des fondamentaux : satisfaction (pondérée récence), meilleur PM par parti, favorabilité nette, 2e choix, souveraineté."""
201 + cutoff = cutoff or date.today()
202 + since = cutoff - timedelta(days=150)
203 + rows = s.execute(select(Signal.key, Signal.party_id, Signal.date, Signal.value, Signal.n, Signal.meta).where(Signal.source == "polls-leadership", Signal.date <= cutoff, Signal.date >= since)).all()
204 + by: dict[tuple[str, str | None], list] = defaultdict(list)
205 + for key, pid, d, v, n, meta in rows:
206 + by[(key, pid)].append((d, v, n))
207 + sat, n_sat = _weighted_recent(by.get(("satisfaction", INCUMBENT), []), cutoff)
208 + sat_net, _ = _weighted_recent(by.get(("satisfaction_net", INCUMBENT), []), cutoff)
209 + bilan, _ = _weighted_recent(by.get(("satisfaction_bilan", INCUMBENT), []), cutoff)
210 + best_pm = {p: _weighted_recent(by.get(("best_pm_share", p), []), cutoff)[0] for p in PARTY_IDS}
211 + best_pm_raw = {p: _weighted_recent(by.get(("best_pm", p), []), cutoff)[0] for p in PARTY_IDS}
212 + net_fav = {p: _weighted_recent(by.get(("leader_net_favorable", p), []), cutoff)[0] for p in PARTY_IDS}
213 + fav = {p: _weighted_recent(by.get(("leader_favorable", p), []), cutoff)[0] for p in PARTY_IDS}
214 + change = {p: _weighted_recent(by.get(("change_leader", p), []), cutoff)[0] for p in PARTY_IDS}
215 + second = {p: _weighted_recent(by.get(("second_choice", p), []), cutoff)[0] for p in PARTY_IDS}
216 + sov, n_sov = _weighted_recent(by.get(("sovereignty_yes", None), []), cutoff)
217 + # séries pour les graphiques
218 + all_rows = s.execute(select(Signal.key, Signal.party_id, Signal.date, Signal.value, Signal.meta).where(Signal.source == "polls-leadership", Signal.date <= cutoff).order_by(Signal.date)).all()
219 + series: dict[str, list] = defaultdict(list)
220 + for key, pid, d, v, meta in all_rows:
221 + if key in ("satisfaction", "best_pm_share", "leader_net_favorable", "sovereignty_yes", "satisfaction_net"):
222 + series[key].append({"date": d.isoformat(), "party": pid, "value": v, "poll": (meta or {}).get("poll_id"), "pollster": (meta or {}).get("pollster")})
223 + return {"enabled": sat is not None or any(v is not None for v in best_pm.values()), "asOf": cutoff.isoformat(), "incumbent": INCUMBENT,
224 + "satisfaction": None if sat is None else round(sat, 1), "satisfactionNet": None if sat_net is None else round(sat_net, 1), "satisfactionPolls": n_sat,
225 + "satisfactionBilan": None if bilan is None else round(bilan, 1),
226 + "bestPmShare": {p: (round(v, 1) if v is not None else None) for p, v in best_pm.items()}, "bestPm": {p: (round(v, 1) if v is not None else None) for p, v in best_pm_raw.items()},
227 + "leaderNetFavorable": {p: (round(v, 1) if v is not None else None) for p, v in net_fav.items()}, "leaderFavorable": {p: (round(v, 1) if v is not None else None) for p, v in fav.items()},
228 + "changeLeader": {p: (round(v, 1) if v is not None else None) for p, v in change.items()}, "secondChoice": {p: (round(v, 1) if v is not None else None) for p, v in second.items()},
229 + "sovereigntyYes": None if sov is None else round(sov, 1), "sovereigntyPolls": n_sov, "series": dict(series),
230 + "params": {"alpha": settings.fundamentals_alpha, "beta": settings.fundamentals_beta, "sd": settings.fundamentals_sd_pp, "weightMax": settings.fundamentals_weight_max, "horizonDays": settings.fundamentals_horizon_days}}
added engine/qc26/signals/markets.py +226 −0
@@ -0,0 +1,226 @@
1 +"""Marchés de prédiction — Polymarket (API Gamma publique, lecture seule, sans clé).
2 +
3 +Marchés suivis (événements listés dans `settings.polymarket_event_slugs`) :
4 +- « le plus de sièges » par parti (kind = most_seats) ;
5 +- « majorité PQ » (kind = majority) ;
6 +- tranches de sièges par parti (kind = seats_band) ;
7 +- 2e / 3e place (kind = place).
8 +
9 +Chaque prix « Oui » est horodaté (historique quotidien via l'API CLOB `prices-history`) et
10 +stocké tel quel. Les probabilités implicites d'un même événement (plusieurs marchés
11 +mutuellement exclusifs) sont renormalisées à la lecture, jamais à l'écriture.
12 +"""
13 +from __future__ import annotations
14 +
15 +import json
16 +import logging
17 +import re
18 +from datetime import date, datetime, timedelta, timezone
19 +
20 +import httpx
21 +from sqlalchemy import select
22 +from sqlalchemy.orm import Session
23 +
24 +from ..config import settings
25 +from ..db import Market, MarketPrice, Signal, Source, utcnow
26 +from ..parties import PARTY_IDS, party_id_from_label
27 +
28 +log = logging.getLogger("qc26.signals.markets")
29 +
30 +PARTY_PATTERNS = [
31 + ("caq", re.compile(r"coalition avenir|\bCAQ\b", re.I)),
32 + ("plq", re.compile(r"lib[ée]ral|\bPLQ\b", re.I)),
33 + ("pq", re.compile(r"parti qu[ée]b[ée]cois|\bPQ\b", re.I)),
34 + ("qs", re.compile(r"solidaire|\bQS\b", re.I)),
35 + ("pcq", re.compile(r"conservateur|conservative|\bPCQ\b", re.I)),
36 +]
37 +
38 +
39 +def _party(question: str) -> str | None:
40 + for pid, rx in PARTY_PATTERNS:
41 + if rx.search(question):
42 + return pid
43 + return None
44 +
45 +
46 +def classify(question: str, event_slug: str) -> tuple[str | None, dict]:
47 + q = question
48 + pid = _party(q)
49 + if re.search(r"most seats", q, re.I):
50 + return ("most_seats" if pid else None), {"party_id": pid}
51 + if re.search(r"majority", q, re.I):
52 + return "majority", {"party_id": pid}
53 + m = re.search(r"win (?:fewer than|less than) (\d+) seats", q, re.I)
54 + if m:
55 + return "seats_band", {"party_id": pid, "band_lo": 0, "band_hi": int(m.group(1)) - 1}
56 + m = re.search(r"win (\d+)-(\d+) seats", q, re.I)
57 + if m:
58 + return "seats_band", {"party_id": pid, "band_lo": int(m.group(1)), "band_hi": int(m.group(2))}
59 + m = re.search(r"win (\d+) or more seats", q, re.I)
60 + if m:
61 + return "seats_band", {"party_id": pid, "band_lo": int(m.group(1)), "band_hi": None}
62 + m = re.search(r"finish in (second|third|2nd|3rd) place", q, re.I)
63 + if m:
64 + place = 2 if m.group(1).lower() in ("second", "2nd") else 3
65 + return ("place" if pid else None), {"party_id": pid, "place": place}
66 + return None, {}
67 +
68 +
69 +def _client() -> httpx.Client:
70 + return httpx.Client(timeout=httpx.Timeout(40.0, connect=15.0), headers={"User-Agent": settings.user_agent, "Accept": "application/json"})
71 +
72 +
73 +def fetch_event(slug: str) -> dict | None:
74 + with _client() as c:
75 + r = c.get(f"{settings.polymarket_gamma}/events", params={"slug": slug})
76 + r.raise_for_status()
77 + data = r.json()
78 + return data[0] if data else None
79 +
80 +
81 +def fetch_history(token_id: str, fidelity_minutes: int = 1440) -> list[tuple[datetime, float]]:
82 + with _client() as c:
83 + r = c.get(f"{settings.polymarket_clob}/prices-history", params={"market": token_id, "interval": "max", "fidelity": fidelity_minutes})
84 + r.raise_for_status()
85 + hist = r.json().get("history", []) or []
86 + return [(datetime.fromtimestamp(h["t"], tz=timezone.utc), float(h["p"])) for h in hist if "t" in h and "p" in h]
87 +
88 +
89 +def sync_markets(s: Session, with_history: bool = True) -> dict:
90 + """Met à jour les marchés suivis, leurs prix courants et l'historique quotidien."""
91 + slugs = [x.strip() for x in settings.polymarket_event_slugs.split(",") if x.strip()]
92 + src = s.get(Source, "polymarket")
93 + if not src:
94 + src = Source(id="polymarket", name="Polymarket — marchés de prédiction (Québec 2026)", url="https://polymarket.com/event/quebec-general-election-winner", tier=3, kind="signal")
95 + s.add(src)
96 + src.last_checked_at = utcnow()
97 + n_markets, n_prices, errors = 0, 0, 0
98 + today = date.today()
99 + for slug in slugs:
100 + try:
101 + ev = fetch_event(slug)
102 + except Exception as e: # noqa: BLE001
103 + errors += 1
104 + log.warning("polymarket %s : %s", slug, e)
105 + continue
106 + if not ev:
107 + continue
108 + for m in ev.get("markets", []) or []:
109 + q = m.get("question") or ""
110 + kind, meta = classify(q, slug)
111 + if not kind or (kind != "majority" and not meta.get("party_id")):
112 + continue
113 + prices = m.get("outcomePrices")
114 + try:
115 + prices = json.loads(prices) if isinstance(prices, str) else prices
116 + yes = float(prices[0]) if prices else None
117 + except (TypeError, ValueError, IndexError):
118 + yes = None
119 + if yes is None:
120 + continue
121 + tokens = m.get("clobTokenIds")
122 + try:
123 + tokens = json.loads(tokens) if isinstance(tokens, str) else tokens
124 + except (TypeError, ValueError):
125 + tokens = None
126 + mid = str(m.get("id"))
127 + row = s.get(Market, mid)
128 + if not row:
129 + row = Market(id=mid, event_slug=slug)
130 + s.add(row)
131 + row.event_title = ev.get("title")
132 + row.question = q[:300]
133 + row.kind = kind
134 + row.party_id = meta.get("party_id")
135 + row.band_lo, row.band_hi, row.place = meta.get("band_lo"), meta.get("band_hi"), meta.get("place")
136 + row.condition_id = m.get("conditionId")
137 + row.token_id = tokens[0] if tokens else None
138 + row.volume = float(m.get("volume") or 0) if m.get("volume") is not None else None
139 + row.liquidity = float(m.get("liquidity") or 0) if m.get("liquidity") is not None else None
140 + row.end_date = m.get("endDate")
141 + row.url = f"https://polymarket.com/event/{slug}"
142 + row.last_price, row.best_bid, row.best_ask = yes, m.get("bestBid"), m.get("bestAsk")
143 + row.active = not bool(m.get("closed"))
144 + n_markets += 1
145 + # prix courant → observation du jour (une par jour et par marché, écrasée)
146 + _upsert_signal(s, "polymarket", f"{kind}:{mid}", row.party_id, today, yes, url=row.url,
147 + meta={"question": q, "kind": kind, "band": [row.band_lo, row.band_hi], "place": row.place, "volume": row.volume})
148 + if with_history and row.token_id:
149 + try:
150 + hist = fetch_history(row.token_id)
151 + except Exception as e: # noqa: BLE001
152 + log.debug("historique %s : %s", mid, e)
153 + hist = []
154 + known = {ts for (ts,) in s.execute(select(MarketPrice.ts).where(MarketPrice.market_id == mid)).all()}
155 + known_days = {k.date() for k in known}
156 + for ts, p in hist:
157 + d = ts.date()
158 + if d in known_days:
159 + continue
160 + s.add(MarketPrice(market_id=mid, ts=ts, price=p))
161 + known_days.add(d)
162 + n_prices += 1
163 + s.flush()
164 + src.last_ok_at, src.status = utcnow(), "operational" if not errors else "degraded"
165 + src.detail = f"{n_markets} marchés suivis, {n_prices} points d'historique ajoutés"
166 + return {"markets": n_markets, "prices_added": n_prices, "errors": errors}
167 +
168 +
169 +def _upsert_signal(s: Session, source: str, key: str, party_id: str | None, d: date, value: float, url: str | None = None, meta: dict | None = None, n: int | None = None) -> None:
170 + row = s.scalar(select(Signal).where(Signal.source == source, Signal.key == key, Signal.party_id == party_id, Signal.date == d))
171 + if row:
172 + row.value, row.meta, row.url, row.n = value, meta, url, n
173 + else:
174 + s.add(Signal(source=source, key=key, party_id=party_id, date=d, value=value, meta=meta, url=url, n=n))
175 +
176 +
177 +# ------------------------------------------------------------------ lecture
178 +def implied(s: Session) -> dict:
179 + """Probabilités implicites courantes : plus de sièges (renormalisées entre partis), majorité,
180 + tranches de sièges (renormalisées → moyenne implicite), 2e/3e place. Marchés trop peu liquides
181 + ou périmés ignorés (settings)."""
182 + cutoff = utcnow() - timedelta(hours=settings.markets_max_age_hours)
183 + rows = s.scalars(select(Market).where(Market.active == True)).all() # noqa: E712
184 + rows = [m for m in rows if m.updated_at and m.updated_at.replace(tzinfo=timezone.utc) >= cutoff and m.last_price is not None]
185 + out: dict = {"source": "polymarket", "asOf": None, "mostSeats": {}, "majority": {}, "seatBands": {}, "place": {2: {}, 3: {}}, "volume": 0.0, "markets": []}
186 + if not rows:
187 + return out
188 + out["asOf"] = max(m.updated_at for m in rows).isoformat()
189 + most = {m.party_id: m.last_price for m in rows if m.kind == "most_seats" and m.party_id}
190 + tot = sum(most.values())
191 + vol_most = sum((m.volume or 0) for m in rows if m.kind == "most_seats")
192 + if tot > 0 and vol_most >= settings.markets_min_volume:
193 + out["mostSeats"] = {p: round(v / tot, 4) for p, v in most.items()}
194 + out["mostSeatsRaw"] = {p: round(v, 4) for p, v in most.items()}
195 + for m in rows:
196 + if m.kind == "majority" and m.party_id:
197 + out["majority"][m.party_id] = round(m.last_price, 4)
198 + bands: dict[str, list] = {}
199 + for m in rows:
200 + if m.kind == "seats_band" and m.party_id:
201 + bands.setdefault(m.party_id, []).append((m.band_lo or 0, m.band_hi, m.last_price))
202 + for pid, bs in bands.items():
203 + tot = sum(p for _, _, p in bs) or 1
204 + norm = [(lo, hi, p / tot) for lo, hi, p in sorted(bs, key=lambda z: z[0])]
205 + mean = sum(((lo + (hi if hi is not None else lo + 8)) / 2) * p for lo, hi, p in norm)
206 + out["seatBands"][pid] = {"bands": [{"lo": lo, "hi": hi, "p": round(p, 4)} for lo, hi, p in norm], "impliedMean": round(mean, 1)}
207 + for m in rows:
208 + if m.kind == "place" and m.party_id and m.place in (2, 3):
209 + out["place"][m.place][m.party_id] = round(m.last_price, 4)
210 + out["volume"] = round(sum((m.volume or 0) for m in rows), 0)
211 + out["markets"] = [{"id": m.id, "question": m.question, "kind": m.kind, "party": m.party_id, "price": m.last_price, "bid": m.best_bid, "ask": m.best_ask,
212 + "volume": m.volume, "liquidity": m.liquidity, "url": m.url, "updatedAt": m.updated_at.isoformat() if m.updated_at else None,
213 + "band": [m.band_lo, m.band_hi] if m.kind == "seats_band" else None, "place": m.place} for m in sorted(rows, key=lambda z: -(z.volume or 0))]
214 + return out
215 +
216 +
217 +def history(s: Session, days: int = 120) -> dict:
218 + """Séries quotidiennes des prix « plus de sièges » et « majorité » (pour les graphiques)."""
219 + since = utcnow() - timedelta(days=days)
220 + rows = s.execute(select(MarketPrice, Market).join(Market, Market.id == MarketPrice.market_id)
221 + .where(MarketPrice.ts >= since, Market.kind.in_(["most_seats", "majority"])).order_by(MarketPrice.ts)).all()
222 + series: dict[str, dict[str, float]] = {}
223 + for mp, m in rows:
224 + key = f"{m.kind}:{m.party_id}"
225 + series.setdefault(key, {})[mp.ts.date().isoformat()] = round(mp.price, 4)
226 + return {"series": {k: [{"date": d, "p": p} for d, p in sorted(v.items())] for k, v in series.items()}}
added engine/qc26/signals/media.py +267 −0
@@ -0,0 +1,267 @@
1 +"""Couverture médiatique — deux sources complémentaires :
2 +
3 +1. **GDELT DOC 2.0** (public, sans clé, 1 requête / 5 s) : volume de couverture (part des articles
4 + mondiaux surveillés mentionnant le parti/chef, source Canada, français) et ton moyen, par jour.
5 +2. **Actualités (Serper — Google Actualités, clé requise)** : titres des dernières 48 h par parti ;
6 + un modèle de langage classe chaque titre (partis concernés, ton −1…+1, sujet). Les titres, médias,
7 + dates et URL sont conservés (`media_items`) : la « revue de presse » du site est reconstituable.
8 +
9 +Le LLM propose, la validation décide : partis inconnus ignorés, ton hors [−1, 1] écarté,
10 +titres non politiques rejetés (topic « hors sujet »). Le ton n'entre dans le modèle que comme
11 +composante de momentum bornée et il est toujours affiché séparément.
12 +"""
13 +from __future__ import annotations
14 +
15 +import logging
16 +import re
17 +import time
18 +from datetime import date, datetime, timedelta, timezone
19 +from urllib.parse import urlparse
20 +
21 +import httpx
22 +from sqlalchemy import func, select
23 +from sqlalchemy.orm import Session
24 +
25 +from .. import llm
26 +from ..config import settings
27 +from ..db import MediaItem, Signal, Source, utcnow
28 +from ..parties import PARTY_IDS
29 +from ..util import parse_fr_date
30 +from .markets import _upsert_signal
31 +
32 +log = logging.getLogger("qc26.signals.media")
33 +
34 +GDELT_QUERIES: dict[str, str] = {
35 + "caq": '("Coalition avenir Québec" OR "Coalition Avenir Quebec" OR "Christine Fréchette" OR "Christine Frechette")',
36 + "plq": '("Parti libéral du Québec" OR "Charles Milliard" OR "libéraux provinciaux")',
37 + "pq": '("Parti québécois" OR "Parti Québécois" OR "St-Pierre Plamondon")',
38 + "qs": '("Québec solidaire" OR "Quebec solidaire" OR "Ruba Ghazal")',
39 + "pcq": '("Parti conservateur du Québec" OR "Éric Duhaime" OR "Eric Duhaime")',
40 +}
41 +NEWS_QUERIES: dict[str, str] = {
42 + "caq": "CAQ Christine Fréchette",
43 + "plq": "PLQ Charles Milliard libéraux",
44 + "pq": "Parti québécois St-Pierre Plamondon",
45 + "qs": "Québec solidaire Ruba Ghazal",
46 + "pcq": "Parti conservateur du Québec Duhaime",
47 + "general": "élections Québec 2026 campagne électorale",
48 +}
49 +
50 +CLASSIFY_SYSTEM = """Tu es l'analyste de presse de QC26, plateforme indépendante et neutre d'analyse de l'élection québécoise 2026.
51 +On te donne une liste de titres d'articles (avec média et extrait). Pour CHAQUE titre, indique :
52 +- "parties" : dictionnaire {sigle: ton} pour les partis provinciaux québécois réellement concernés par le titre parmi CAQ, PLQ, PQ, QS, PCQ.
53 + Le ton est un nombre entre -1 (très défavorable au parti : scandale, recul, critique) et +1 (très favorable : gain, appui, succès), 0 = neutre/factuel.
54 + Ne mets un parti que s'il est explicitement concerné (nom du parti, de son chef ou de sa cheffe, d'un candidat clairement identifié).
55 +- "topic" : un mot-clé parmi economie, sante, education, immigration, langue, environnement, souverainete, ethique, campagne, sondage, autre, hors_sujet (hors_sujet = pas de politique provinciale québécoise).
56 +- "summary" : reformulation neutre en ≤ 15 mots.
57 +Ne devine pas : si le ton est indéterminable, mets 0. N'invente aucun parti.
58 +Réponds avec {"items": [{"i": index, "parties": {...}, "topic": "...", "summary": "..."}]}."""
59 +
60 +
61 +def _gdelt(query: str, mode: str, timespan: str = "60d") -> dict:
62 + with httpx.Client(timeout=60, headers={"User-Agent": settings.user_agent}) as c:
63 + r = c.get("https://api.gdeltproject.org/api/v2/doc/doc", params={"query": f"{query} sourcecountry:CA sourcelang:french", "mode": mode, "timespan": timespan, "format": "json"})
64 + r.raise_for_status()
65 + txt = r.text.strip()
66 + if not txt.startswith("{"):
67 + raise RuntimeError(txt[:160])
68 + return r.json()
69 +
70 +
71 +def sync_gdelt(s: Session) -> dict:
72 + src = s.get(Source, "gdelt")
73 + if not src:
74 + src = Source(id="gdelt", name="GDELT DOC 2.0 — volume et ton de la couverture (Canada, français)", url="https://api.gdeltproject.org/api/v2/doc/doc", tier=3, kind="signal")
75 + s.add(src)
76 + src.last_checked_at = utcnow()
77 + n, errors = 0, 0
78 + for pid, q in GDELT_QUERIES.items():
79 + for mode, key in (("timelinevol", "volume"), ("timelinetone", "tone")):
80 + try:
81 + try:
82 + d = _gdelt(q, mode)
83 + except httpx.HTTPStatusError as e:
84 + if e.response.status_code != 429:
85 + raise
86 + time.sleep(20) # GDELT : limite par IP, on patiente puis on réessaie une fois
87 + d = _gdelt(q, mode)
88 + for tl in d.get("timeline", []):
89 + for pt in tl.get("data", []):
90 + day = parse_fr_date(pt.get("date", "")[:8][:4] + "-" + pt.get("date", "")[4:6] + "-" + pt.get("date", "")[6:8])
91 + if not day or pt.get("value") is None:
92 + continue
93 + _upsert_signal(s, "gdelt", key, pid, day, float(pt["value"]), url="https://api.gdeltproject.org/api/v2/doc/doc", meta={"query": q, "series": tl.get("series")})
94 + n += 1
95 + except Exception as e: # noqa: BLE001
96 + errors += 1
97 + log.warning("gdelt %s/%s : %s", pid, mode, e)
98 + time.sleep(6.5) # politique d'usage GDELT : 1 requête / 5 s
99 + src.last_ok_at, src.status = utcnow(), "operational" if not errors else "degraded"
100 + src.detail = f"{n} observations"
101 + return {"observations": n, "errors": errors}
102 +
103 +
104 +def _serper_news(q: str, num: int = 40) -> list[dict]:
105 + with httpx.Client(timeout=40) as c:
106 + r = c.post("https://google.serper.dev/news", headers={"X-API-KEY": settings.serper_api_key, "Content-Type": "application/json"},
107 + json={"q": q, "gl": "ca", "hl": "fr", "tbs": "qdr:d2", "num": num})
108 + r.raise_for_status()
109 + return r.json().get("news", []) or []
110 +
111 +
112 +def _parse_news_date(txt: str | None, ref: datetime) -> datetime | None:
113 + if not txt:
114 + return None
115 + t = txt.lower().strip()
116 + m = re.match(r"il y a (\d+)\s*(minute|heure|jour|semaine)", t)
117 + if m:
118 + n = int(m.group(1))
119 + unit = m.group(2)
120 + delta = {"minute": timedelta(minutes=n), "heure": timedelta(hours=n), "jour": timedelta(days=n), "semaine": timedelta(weeks=n)}[unit]
121 + return ref - delta
122 + m = re.match(r"(\d+)\s*(minute|hour|day|week)s? ago", t)
123 + if m:
124 + n = int(m.group(1))
125 + delta = {"minute": timedelta(minutes=n), "hour": timedelta(hours=n), "day": timedelta(days=n), "week": timedelta(weeks=n)}[m.group(2)]
126 + return ref - delta
127 + d = parse_fr_date(t)
128 + return datetime(d.year, d.month, d.day, 12, tzinfo=timezone.utc) if d else None
129 +
130 +
131 +def sync_news(s: Session, classify: bool = True) -> dict:
132 + if not settings.serper_api_key:
133 + return {"skipped": "SERPER_API_KEY absente"}
134 + src = s.get(Source, "serper-news")
135 + if not src:
136 + src = Source(id="serper-news", name="Google Actualités (via Serper) — titres de presse par parti", url="https://serper.dev", tier=3, kind="signal")
137 + s.add(src)
138 + src.last_checked_at = utcnow()
139 + now = utcnow()
140 + known = set(s.scalars(select(MediaItem.url)).all())
141 + new_items: list[MediaItem] = []
142 + errors = 0
143 + for pid, q in NEWS_QUERIES.items():
144 + try:
145 + for it in _serper_news(q):
146 + url = (it.get("link") or "").split("#")[0]
147 + if not url or url in known:
148 + continue
149 + known.add(url)
150 + mi = MediaItem(url=url[:600], title=(it.get("title") or "")[:400], domain=urlparse(url).netloc.replace("www.", "")[:120], snippet=(it.get("snippet") or None),
151 + published_at=_parse_news_date(it.get("date"), now), query=q[:200], source="serper-news")
152 + s.add(mi)
153 + new_items.append(mi)
154 + except Exception as e: # noqa: BLE001
155 + errors += 1
156 + log.warning("serper %s : %s", pid, e)
157 + s.commit()
158 + classified = 0
159 + if classify:
160 + classified = classify_pending(s)
161 + aggregate_media(s)
162 + src.last_ok_at, src.status = utcnow(), "operational" if not errors else "degraded"
163 + src.detail = f"{len(new_items)} nouveaux titres, {classified} classés"
164 + return {"new": len(new_items), "classified": classified, "errors": errors}
165 +
166 +
167 +def classify_pending(s: Session, batch: int = 60) -> int:
168 + """Classement LLM des titres non classés (lot de 60 ; commits explicites autour de l'appel)."""
169 + if not llm.available():
170 + return 0
171 + pending = s.scalars(select(MediaItem).where(MediaItem.classified == False).order_by(MediaItem.id.desc()).limit(batch)).all() # noqa: E712
172 + if not pending:
173 + return 0
174 + lines = [f"[{i}] ({it.domain}) {it.title}" + (f" — {it.snippet[:160]}" if it.snippet else "") for i, it in enumerate(pending)]
175 + ids = [it.id for it in pending]
176 + s.commit() # libère le verrou pendant l'appel LLM
177 + try:
178 + data, usage = llm.complete_json(CLASSIFY_SYSTEM, "\n".join(lines), effort="low", max_tokens=6000)
179 + except Exception as e: # noqa: BLE001
180 + log.warning("classement médias : %s", e)
181 + return 0
182 + by_i = {int(x.get("i")): x for x in (data.get("items") or []) if isinstance(x, dict) and str(x.get("i", "")).lstrip("-").isdigit()}
183 + done = 0
184 + for i, mid in enumerate(ids):
185 + it = s.get(MediaItem, mid)
186 + if not it:
187 + continue
188 + x = by_i.get(i)
189 + it.classified = True
190 + if not x:
191 + it.topic = "hors_sujet"
192 + continue
193 + parties = {}
194 + for k, v in (x.get("parties") or {}).items():
195 + pid = k.strip().lower()
196 + if pid in PARTY_IDS:
197 + try:
198 + tone = float(v)
199 + except (TypeError, ValueError):
200 + continue
201 + if -1 <= tone <= 1:
202 + parties[pid] = round(tone, 2)
203 + it.parties = parties or None
204 + it.topic = str(x.get("topic") or "autre")[:48]
205 + it.summary = str(x.get("summary") or "")[:300] or None
206 + it.confidence = 0.7 if parties else 0.4
207 + done += 1
208 + s.commit()
209 + return done
210 +
211 +
212 +def aggregate_media(s: Session, days: int = 45) -> int:
213 + """Agrège les titres classés en signaux quotidiens : part de voix (nb d'articles) et ton moyen par parti."""
214 + since = utcnow() - timedelta(days=days)
215 + rows = s.scalars(select(MediaItem).where(MediaItem.classified == True, MediaItem.parties.is_not(None), MediaItem.published_at >= since)).all() # noqa: E712
216 + per_day: dict[date, dict[str, list[float]]] = {}
217 + for it in rows:
218 + d = (it.published_at or it.fetched_at).date()
219 + for pid, tone in (it.parties or {}).items():
220 + per_day.setdefault(d, {}).setdefault(pid, []).append(tone)
221 + n = 0
222 + for d, parts in per_day.items():
223 + tot = sum(len(v) for v in parts.values()) or 1
224 + for pid, tones in parts.items():
225 + _upsert_signal(s, "news", "articles", pid, d, float(len(tones)), n=len(tones))
226 + _upsert_signal(s, "news", "share_of_voice", pid, d, round(100 * len(tones) / tot, 2), n=len(tones))
227 + _upsert_signal(s, "news", "tone", pid, d, round(sum(tones) / len(tones), 3), n=len(tones))
228 + n += 3
229 + return n
230 +
231 +
232 +def media_summary(s: Session, cutoff: date | None = None, window: int = 7) -> dict:
233 + cutoff = cutoff or date.today()
234 + since = cutoff - timedelta(days=window - 1)
235 + rows = s.scalars(select(MediaItem).where(MediaItem.classified == True, MediaItem.parties.is_not(None))).all() # noqa: E712
236 + items = [it for it in rows if since <= (it.published_at or it.fetched_at).date() <= cutoff]
237 + tone: dict[str, list[float]] = {p: [] for p in PARTY_IDS}
238 + for it in items:
239 + for pid, t in (it.parties or {}).items():
240 + if pid in tone:
241 + tone[pid].append(t)
242 + tot = sum(len(v) for v in tone.values()) or 1
243 + # GDELT : volume moyen 7 j et 28 j, ton moyen 7 j
244 + g = s.execute(select(Signal.party_id, Signal.key, Signal.date, Signal.value).where(Signal.source == "gdelt", Signal.date <= cutoff, Signal.date >= cutoff - timedelta(days=28))).all()
245 + gd: dict[str, dict[str, list[tuple[date, float]]]] = {}
246 + for pid, key, d, v in g:
247 + gd.setdefault(pid, {}).setdefault(key, []).append((d, v))
248 + gdelt = {}
249 + for pid in PARTY_IDS:
250 + vol = gd.get(pid, {}).get("volume", [])
251 + tn = gd.get(pid, {}).get("tone", [])
252 + v7 = [v for d, v in vol if (cutoff - d).days < 7]
253 + v28 = [v for d, v in vol]
254 + t7 = [v for d, v in tn if (cutoff - d).days < 7]
255 + gdelt[pid] = {"volume7": round(sum(v7) / len(v7), 4) if v7 else None, "volume28": round(sum(v28) / len(v28), 4) if v28 else None, "tone7": round(sum(t7) / len(t7), 2) if t7 else None}
256 + # séries quotidiennes (part de voix + ton) depuis les signaux agrégés
257 + ser = s.execute(select(Signal.party_id, Signal.key, Signal.date, Signal.value).where(Signal.source == "news", Signal.date <= cutoff, Signal.date >= cutoff - timedelta(days=45)).order_by(Signal.date)).all()
258 + series: dict[str, dict[str, dict[str, float]]] = {}
259 + for pid, key, d, v in ser:
260 + series.setdefault(d.isoformat(), {}).setdefault(key, {})[pid] = v
261 + latest = sorted(items, key=lambda z: (z.published_at or z.fetched_at), reverse=True)[:60]
262 + return {"enabled": bool(items) or any(v["volume28"] is not None for v in gdelt.values()), "window": window, "asOf": cutoff.isoformat(), "articles": len(items),
263 + "shareOfVoice": {p: round(100 * len(tone[p]) / tot, 1) for p in PARTY_IDS}, "tone": {p: (round(sum(tone[p]) / len(tone[p]), 2) if tone[p] else None) for p in PARTY_IDS},
264 + "counts": {p: len(tone[p]) for p in PARTY_IDS}, "gdelt": gdelt,
265 + "series": [{"date": d, **{k: v for k, v in kv.items()}} for d, kv in sorted(series.items())],
266 + "latest": [{"id": it.id, "title": it.title, "domain": it.domain, "url": it.url, "publishedAt": (it.published_at or it.fetched_at).isoformat(), "parties": it.parties, "topic": it.topic, "summary": it.summary} for it in latest],
267 + "topics": dict(sorted(((t, n) for t, n in s.execute(select(MediaItem.topic, func.count()).where(MediaItem.classified == True, MediaItem.topic.is_not(None), MediaItem.topic != "hors_sujet").group_by(MediaItem.topic)).all()), key=lambda z: -z[1]))} # noqa: E712
modified engine/qc26/util.py +18 −0
@@ -43,6 +43,24 @@ def sha256_bytes(b: bytes) -> str:
43 43 return hashlib.sha256(b).hexdigest()
44 44
45 45
46 +def resolve_archive_path(p: str | Path | None) -> Path | None:
47 + """Les chemins d'archive sont stockés absolus ; si la base a été déplacée (laptop → nœud),
48 + on retrouve le fichier par son nom dans l'archive locale (identité = SHA-256 dans le nom)."""
49 + if not p:
50 + return None
51 + path = Path(p)
52 + if path.exists():
53 + return path
54 + for sub in ("polls", "documents", "live", "partielles"):
55 + cand = settings.archive_dir / sub / path.name
56 + if cand.exists():
57 + return cand
58 + if path.parent.name in ("polls", "documents", "live", "partielles"):
59 + cand = settings.archive_dir / path.parent.name / path.name
60 + return cand if cand.exists() else path
61 + return path
62 +
63 +
46 64 def slugify(s: str) -> str:
47 65 s = unicodedata.normalize("NFKD", s)
48 66 s = "".join(c for c in s if not unicodedata.combining(c))
modified web/src/app/boussole/page.tsx +1 −1
@@ -13,7 +13,7 @@ export default async function Page() {
13 13 <div className="space-y-6">
14 14 <div>
15 15 <div className="eyebrow">Boussole électorale · {data.questions.length} énoncés · 6 axes</div>
16 <h1 className="display text-[36px] md:text-[48px] mt-2">Boussole QC26</h1>
16 + <h1 className="display text-[40px] md:text-[56px] mt-3">Boussole QC26</h1>
17 17 <p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Répondez aux énoncés qui vous parlent, pondérez leur importance, puis comparez. Chaque position de parti provient <b>exclusivement</b> de documents officiels ingérés par QC26 (plateforme, programme, engagements) avec page et citation ; quand un document ne permet pas de conclure, la question ne compte pas pour ce parti. Vos réponses restent dans votre navigateur.</p>
18 18 </div>
19 19 <Compass data={data} />
modified web/src/app/carte/page.tsx +1 −1
@@ -13,7 +13,7 @@ export default async function Page() {
13 13 <div className="space-y-6">
14 14 <div>
15 15 <div className="eyebrow">Carte · 127 circonscriptions · limites officielles 2026</div>
16 <h1 className="display text-[36px] md:text-[48px] mt-2">Carte électorale</h1>
16 + <h1 className="display text-[40px] md:text-[56px] mt-3">Carte électorale</h1>
17 17 <p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Géométrie officielle d&apos;Élections Québec (simplifiée pour le web). Avant le scrutin, aucune circonscription n&apos;est « gagnée » : l&apos;opacité traduit la probabilité. Molette ou pincement pour zoomer ; cliquez pour ouvrir la fiche.</p>
18 18 </div>
19 19 <Card pad={false} className="p-3 md:p-4"><MapSection height={680} live={live} /></Card>
modified web/src/app/circonscription/[slug]/page.tsx +6 −6
@@ -7,7 +7,7 @@ import { Badge, Card, KV, LiveBadge, ProbabilityBar, ProjectionBadge, SectionHea
7 7 import { RidingMap } from "@/components/riding-map";
8 8 import { RidingLive } from "@/components/riding-live";
9 9 import { dateFr, dateTimeFr, delta, int, pct, prob, ratingLabel } from "@/lib/format";
10 import { partyColor, partyLabel, partyName } from "@/lib/parties";
10 +import { partyVar, partyLabel, partyName } from "@/lib/parties";
11 11
12 12 export const revalidate = 60;
13 13
@@ -51,14 +51,14 @@ export default async function Page({ params }: { params: Promise<{ slug: string
51 51 <div className="mt-6 card card-lg p-5 md:p-6">
52 52 <div className="flex items-center justify-between flex-wrap gap-2"><div className="eyebrow">Projection QC26 · run #{f.runId} · {dateTimeFr(f.completedAt)}</div><ProjectionBadge /></div>
53 53 <div className="mt-3 flex items-end gap-6 flex-wrap">
54 <div><div className="flex items-center gap-2"><PartyLogo id={f.favorite} height={22} /><span className="font-semibold">{partyName(f.favorite)} favori</span></div><div className="num display text-[64px]" style={{ color: partyColor(f.favorite) }}>{prob(f.p)}</div><div className="text-[13px] text-ink-2 -mt-1">probabilité de victoire · <b>{ratingLabel(f.rating)}</b></div></div>
54 + <div><div className="flex items-center gap-2"><PartyLogo id={f.favorite} height={22} /><span className="font-semibold">{partyName(f.favorite)} favori</span></div><div className="num display text-[64px]" style={{ color: partyVar(f.favorite) }}>{prob(f.p)}</div><div className="text-[13px] text-ink-2 -mt-1">probabilité de victoire · <b>{ratingLabel(f.rating)}</b></div></div>
55 55 <div className="ml-auto grid grid-cols-2 gap-x-6 gap-y-3 text-[13px]"><div><div className="eyebrow">Marge projetée</div><div className="num text-[22px] font-bold">{f.margin.toFixed(1).replace(".", ",")} pt</div><div className="text-ink-3">vs {partyLabel(f.runnerUp)}</div></div><div><div className="eyebrow">Swing vs 2022</div><div className="num text-[22px] font-bold">{delta(f.swing, 1, " pt")}</div><div className="text-ink-3">{partyLabel(f.favorite)}</div></div></div>
56 56 </div>
57 57 <div className="mt-5 space-y-2">
58 58 {f.parties.filter((p) => p.party !== "aut" || p.voteMean > 2).map((p) => (
59 59 <div key={p.party} className="grid grid-cols-[46px_1fr_92px_60px] items-center gap-3 text-[13px]">
60 <span className="font-semibold num" style={{ color: partyColor(p.party) }}>{partyLabel(p.party)}</span>
61 <div className="relative h-[14px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(p.vote80[0] / 70) * 100}%`, width: `${((p.vote80[1] - p.vote80[0]) / 70) * 100}%`, background: partyColor(p.party), opacity: 0.3 }} /><div className="absolute inset-y-0 w-[2px] bg-ink" style={{ left: `${(p.voteMean / 70) * 100}%` }} /></div>
60 + <span className="font-semibold num" style={{ color: partyVar(p.party) }}>{partyLabel(p.party)}</span>
61 + <div className="relative h-[14px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(p.vote80[0] / 70) * 100}%`, width: `${((p.vote80[1] - p.vote80[0]) / 70) * 100}%`, background: partyVar(p.party), opacity: 0.3 }} /><div className="absolute inset-y-0 w-[2px] bg-ink" style={{ left: `${(p.voteMean / 70) * 100}%` }} /></div>
62 62 <span className="num text-ink-2">{p.voteMean.toFixed(1).replace(".", ",")} % <span className="text-ink-3 text-[11px]">({p.vote80[0].toFixed(0)}–{p.vote80[1].toFixed(0)})</span></span>
63 63 <span className="num font-semibold text-right">{prob(p.pWin)}</span>
64 64 </div>
@@ -99,7 +99,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
99 99 <div>
100 100 <SectionHeader eyebrow="2022 sur la carte 2026" title="Résultat précédent transposé" description="Résultats officiels 2022 par section de vote, réaffectés aux limites 2026 (calcul QC26)." action={<SourceBadge source={{ name: "Élections Québec — résultats 2022 par section de vote + carte 2026", url: "https://www.dgeq.org/donnees.html", detail: d.baseline2022[0]?.source, tier: "Officiel (DGEQ) · transposition QC26" }} />} />
101 101 <Card>
102 <div className="space-y-2">{d.baseline2022.filter((b) => b.party !== "aut" || b.share > 1).map((b) => (<div key={b.party} className="grid grid-cols-[46px_1fr_64px] items-center gap-3 text-[13px]"><span className="font-semibold num" style={{ color: partyColor(b.party) }}>{partyLabel(b.party)}</span><div className="h-[12px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="h-full" style={{ width: `${(b.share / 70) * 100}%`, background: partyColor(b.party) }} /></div><span className="num text-right">{pct(b.share)}</span></div>))}</div>
102 + <div className="space-y-2">{d.baseline2022.filter((b) => b.party !== "aut" || b.share > 1).map((b) => (<div key={b.party} className="grid grid-cols-[46px_1fr_64px] items-center gap-3 text-[13px]"><span className="font-semibold num" style={{ color: partyVar(b.party) }}>{partyLabel(b.party)}</span><div className="h-[12px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="h-full" style={{ width: `${(b.share / 70) * 100}%`, background: partyVar(b.party) }} /></div><span className="num text-right">{pct(b.share)}</span></div>))}</div>
103 103 <div className="text-[12px] text-ink-3 mt-3">{d.baseline2022[0]?.coverage !== null && d.baseline2022[0]?.coverage !== undefined ? `${pct(d.baseline2022[0].coverage * 100, 0)} des votes proviennent de la circonscription 2022 principale.` : ""} Hypothèse : les votes par anticipation se répartissent comme ceux du jour du vote.</div>
104 104 </Card>
105 105 </div>
@@ -148,7 +148,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
148 148 <Card>
149 149 <div className="eyebrow mb-2">Fiche</div>
150 150 <KV items={[["Code DGEQ", String(d.code)], ["Région (classification QC26)", d.regionName ?? "—"], ["Électeurs inscrits (décret 2026)", int(d.electors)], ["Superficie approx.", d.areaKm2 ? `${int(d.areaKm2)} km²` : "—"], ["Sortant·e", d.incumbent.name ? `${d.incumbent.name} · ${partyName(d.incumbent.party)}` : "—"], ["Volatilité (1 − p favori)", f ? f.volatility.toFixed(2) : "—"], ["Dernière mise à jour", f ? dateTimeFr(f.completedAt) : "—"]]} />
151 <div className="mt-3 flex gap-2 flex-wrap"><PartyBadge id={f?.favorite} /><ProbabilityBar p={f?.p ?? 0} color={partyColor(f?.favorite)} className="flex-1 min-w-[200px]" /></div>
151 + <div className="mt-3 flex gap-2 flex-wrap"><PartyBadge id={f?.favorite} /><ProbabilityBar p={f?.p ?? 0} color={partyVar(f?.favorite)} className="flex-1 min-w-[200px]" /></div>
152 152 </Card>
153 153 </div>
154 154 );
modified web/src/app/circonscriptions/page.tsx +1 −1
@@ -14,7 +14,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ t
14 14 <div className="space-y-6">
15 15 <div>
16 16 <div className="eyebrow">Circonscriptions · {data.ridings.length}</div>
17 <h1 className="display text-[36px] md:text-[48px] mt-2">127 circonscriptions</h1>
17 + <h1 className="display text-[40px] md:text-[56px] mt-3">127 circonscriptions</h1>
18 18 <p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Les pourcentages sont des <b>probabilités de victoire</b> issues des simulations, pas des intentions de vote. Nouvelle carte 2026 : Bellefeuille et Marie-Lacoste-Gérin-Lajoie s&apos;ajoutent, 39 circonscriptions sont modifiées.</p>
19 19 </div>
20 20 <RidingsGrid ridings={data.ridings} regions={data.regions} initialSort={sp.tri === "serre" ? "serre" : "nom"} />
modified web/src/app/documents/[id]/page.tsx +3 −3
@@ -5,7 +5,7 @@ import { apiSafe } from "@/lib/api";
5 5 import { PartyBadge } from "@/components/party";
6 6 import { Badge, Card, KV, SectionHeader, SourceBadge } from "@/components/ui";
7 7 import { dateTimeFr } from "@/lib/format";
8 import { partyColor, partyName } from "@/lib/parties";
8 +import { partyVar, partyName } from "@/lib/parties";
9 9
10 10 export const revalidate = 300;
11 11
@@ -42,7 +42,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
42 42 <Card key={p.id} className="scroll-mt-24" >
43 43 <div id={`p${p.id}`} className="grid md:grid-cols-[1fr_auto] gap-3">
44 44 <div><div className="font-medium text-[14.5px]">{p.proposal}</div>{p.position && <div className="text-[13px] text-ink-2 mt-1">{p.position}</div>}{p.magnitude && <div className="num text-[13px] mt-1">{p.magnitude}</div>}
45 <blockquote className="mt-3 italic text-ink-2 text-[13px] border-l-2 pl-3 leading-relaxed" style={{ borderColor: partyColor(d.party) }}>« {p.quote} »</blockquote></div>
45 + <blockquote className="mt-3 italic text-ink-2 text-[13px] border-l-2 pl-3 leading-relaxed" style={{ borderColor: partyVar(d.party) }}>« {p.quote} »</blockquote></div>
46 46 <div className="text-[12px] text-ink-3 num md:text-right shrink-0">{p.page ? <div>Page {p.page}</div> : null}<div>Confiance {Math.round(p.confidence * 100)} %</div>{p.direction && p.direction !== "neutre" && <div className="mt-1"><Badge>{p.direction.replace(/_/g, " ")}</Badge></div>}{p.subtopic && <div className="mt-1">{p.subtopic}</div>}</div>
47 47 </div>
48 48 </Card>))}</div>
@@ -51,7 +51,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
51 51 </div>
52 52 <div className="space-y-4">
53 53 <Card><div className="eyebrow mb-2">Fiche</div><KV items={[["Parti", partyName(d.party)], ["Type", d.type], ["Découvert", dateTimeFr(d.discoveredAt)], ["Vérifié", dateTimeFr(d.lastCheckedAt)], ["Versions", String(d.versions.length)], ["Propositions", String(d.proposals.filter((p) => p.status === "accepted").length)], ["En révision", String(d.proposals.filter((p) => p.status === "review").length)]]} /></Card>
54 <Card><div className="eyebrow mb-2">Versions</div><ul className="space-y-3 text-[12.5px]">{d.versions.slice().reverse().map((v) => (<li key={v.id} className="border-l-2 pl-3" style={{ borderColor: v.current ? partyColor(d.party) : "var(--border)" }}><div className="flex justify-between"><b>{dateTimeFr(v.fetchedAt)}</b>{v.current && <Badge tone="accent">courante</Badge>}</div><div className="mono text-[11px] text-ink-3 break-all mt-0.5">{v.sha256}</div><div className="text-ink-2 mt-0.5 num">{(v.bytes / 1024).toFixed(0)} Ko{v.pages ? ` · ${v.pages} p.` : ""} · <a href={v.archiveUrl} className="link" target="_blank" rel="noopener noreferrer">archive QC26</a></div>{v.diff && <div className="mt-1 text-ink-2">Δ vs version précédente : similarité {Math.round(v.diff.ratio * 100)} %, +{v.diff.added_count} / −{v.diff.removed_count} lignes</div>}</li>))}</ul></Card>
54 + <Card><div className="eyebrow mb-2">Versions</div><ul className="space-y-3 text-[12.5px]">{d.versions.slice().reverse().map((v) => (<li key={v.id} className="border-l-2 pl-3" style={{ borderColor: v.current ? partyVar(d.party) : "var(--border)" }}><div className="flex justify-between"><b>{dateTimeFr(v.fetchedAt)}</b>{v.current && <Badge tone="accent">courante</Badge>}</div><div className="mono text-[11px] text-ink-3 break-all mt-0.5">{v.sha256}</div><div className="text-ink-2 mt-0.5 num">{(v.bytes / 1024).toFixed(0)} Ko{v.pages ? ` · ${v.pages} p.` : ""} · <a href={v.archiveUrl} className="link" target="_blank" rel="noopener noreferrer">archive QC26</a></div>{v.diff && <div className="mt-1 text-ink-2">Δ vs version précédente : similarité {Math.round(v.diff.ratio * 100)} %, +{v.diff.added_count} / −{v.diff.removed_count} lignes</div>}</li>))}</ul></Card>
55 55 {cur?.diff && (cur.diff.added.length > 0 || cur.diff.removed.length > 0) && (
56 56 <Card><div className="eyebrow mb-2">Document diff</div><div className="text-[12px] space-y-1 max-h-[360px] overflow-auto">{cur.diff.added.slice(0, 30).map((l, i) => <div key={`a${i}`} className="bg-[rgba(21,128,61,.08)] text-ok rounded px-1.5 py-0.5">+ {l}</div>)}{cur.diff.removed.slice(0, 30).map((l, i) => <div key={`r${i}`} className="bg-[rgba(220,38,38,.07)] text-live rounded px-1.5 py-0.5 line-through">− {l}</div>)}</div></Card>
57 57 )}
modified web/src/app/documents/page.tsx +1 −1
@@ -21,7 +21,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ p
21 21 const types = ["plateforme", "cadre_financier", "communique", "document", "page"];
22 22 return (
23 23 <div className="space-y-6">
24 <div><div className="eyebrow">Document Library · sources officielles uniquement</div><h1 className="display text-[36px] md:text-[48px] mt-2">Bibliothèque documentaire</h1><p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Chaque document est téléchargé depuis le site officiel du parti, haché (SHA-256), versionné et analysé. Quand un parti modifie un document, QC26 conserve les deux versions et affiche les changements.</p></div>
24 + <div><div className="eyebrow eyebrow-accent">Document Library · sources officielles uniquement</div><h1 className="display text-[40px] md:text-[56px] mt-3">Bibliothèque documentaire</h1><p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Chaque document est téléchargé depuis le site officiel du parti, haché (SHA-256), versionné et analysé. Quand un parti modifie un document, QC26 conserve les deux versions et affiche les changements.</p></div>
25 25 <div className="flex flex-wrap gap-1.5 items-center text-[13px]">
26 26 <Pill href="/documents" active={!sp.party}>Tous</Pill>{PARTY_IDS.map((id) => <Pill key={id} href={`/documents?party=${id}${sp.type ? `&type=${sp.type}` : ""}`} active={sp.party === id}>{partyLabel(id)}</Pill>)}
27 27 <span className="mx-2 text-ink-3">·</span>{types.map((t) => <Pill key={t} href={`/documents?type=${t}${sp.party ? `&party=${sp.party}` : ""}`} active={sp.type === t}>{t.replace("_", " ")}</Pill>)}
modified web/src/app/enjeux/[topic]/page.tsx +3 −3
@@ -4,7 +4,7 @@ import { notFound } from "next/navigation";
4 4 import { apiSafe, type Meta } from "@/lib/api";
5 5 import { PartyLogo } from "@/components/party";
6 6 import { Badge, Card, Empty } from "@/components/ui";
7 import { PARTY_IDS, partyColor, partyName } from "@/lib/parties";
7 +import { PARTY_IDS, partyVar, partyName } from "@/lib/parties";
8 8
9 9 export const revalidate = 300;
10 10
@@ -30,13 +30,13 @@ export default async function Page({ params }: { params: Promise<{ topic: string
30 30 <div className="grid lg:grid-cols-5 gap-3">
31 31 {PARTY_IDS.map((id) => (
32 32 <div key={id} className="space-y-2">
33 <div className="flex items-center gap-2 pb-2 border-b-2" style={{ borderColor: partyColor(id) }}><PartyLogo id={id} height={16} /><span className="font-semibold text-[13px]">{partyName(id)}</span><span className="num text-[12px] text-ink-3 ml-auto">{by[id]?.length ?? 0}</span></div>
33 + <div className="flex items-center gap-2 pb-2 border-b-2" style={{ borderColor: partyVar(id) }}><PartyLogo id={id} height={16} /><span className="font-semibold text-[13px]">{partyName(id)}</span><span className="num text-[12px] text-ink-3 ml-auto">{by[id]?.length ?? 0}</span></div>
34 34 {(by[id] ?? []).length === 0 && <div className="text-[12.5px] text-ink-3 p-2">Aucune proposition documentée.</div>}
35 35 {(by[id] ?? []).map((p) => (
36 36 <Card key={p.id} className="!p-3 text-[12.5px] leading-snug" >
37 37 <div className="font-medium">{p.proposal}</div>
38 38 {p.magnitude && <div className="num text-ink-2 mt-1">{p.magnitude}</div>}
39 <blockquote className="mt-2 italic text-ink-2 border-l-2 pl-2" style={{ borderColor: partyColor(id) }}>« {p.quote.length > 240 ? p.quote.slice(0, 240) + "…" : p.quote} »</blockquote>
39 + <blockquote className="mt-2 italic text-ink-2 border-l-2 pl-2" style={{ borderColor: partyVar(id) }}>« {p.quote.length > 240 ? p.quote.slice(0, 240) + "…" : p.quote} »</blockquote>
40 40 <div className="mt-2 flex items-center justify-between gap-2 text-[11.5px] text-ink-3"><Link href={`/documents/${p.document.id}#p${p.id}`} className="link truncate">{p.document.title ?? "Document"}</Link><span className="num shrink-0">{p.page ? `p. ${p.page} · ` : ""}{Math.round(p.confidence * 100)} %</span></div>
41 41 {p.direction && p.direction !== "neutre" && <div className="mt-1.5"><Badge>{p.direction.replace(/_/g, " ")}</Badge></div>}
42 42 </Card>
modified web/src/app/enjeux/page.tsx +4 −4
@@ -3,7 +3,7 @@ import Link from "next/link";
3 3 import { apiSafe } from "@/lib/api";
4 4 import { AskBox } from "@/components/ask-box";
5 5 import { Card, Empty } from "@/components/ui";
6 import { PARTY_IDS, partyColor, partyLabel } from "@/lib/parties";
6 +import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";
7 7
8 8 export const metadata: Metadata = { title: "Enjeux — ce que proposent les partis", description: "Positions et propositions des partis par enjeu (santé, logement, immigration, langue, environnement…), tirées de leurs documents officiels." };
9 9 export const revalidate = 300;
@@ -15,7 +15,7 @@ export default async function Page() {
15 15 if (!m) return <Empty title="Enjeux indisponibles" />;
16 16 return (
17 17 <div className="space-y-8">
18 <div><div className="eyebrow">Issues tracker · {m.topics.length} enjeux documentés</div><h1 className="display text-[36px] md:text-[48px] mt-2">Enjeux</h1><p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Les principaux enjeux détectés dans les documents officiels des partis. Le nombre indique les propositions extraites (avec page et citation) par parti.</p></div>
18 + <div><div className="eyebrow eyebrow-accent">Issues tracker · {m.topics.length} enjeux documentés</div><h1 className="display text-[40px] md:text-[56px] mt-3">Enjeux</h1><p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Les principaux enjeux détectés dans les documents officiels des partis. Le nombre indique les propositions extraites (avec page et citation) par parti.</p></div>
19 19 <AskBox />
20 20 {m.topics.length === 0 ? <Empty title="Extraction en cours" /> : (
21 21 <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
@@ -24,8 +24,8 @@ export default async function Page() {
24 24 return (
25 25 <Link key={t.id} href={`/enjeux/${t.id}`} className="card p-4 block hover:shadow-md">
26 26 <div className="flex items-baseline justify-between"><div className="font-semibold text-[15px]">{t.label}</div><div className="num text-[12px] text-ink-3">{total} propositions</div></div>
27 <div className="flex h-[8px] rounded-full overflow-hidden mt-3 bg-surface-3">{PARTY_IDS.map((id) => <div key={id} style={{ width: `${((t.counts[id] ?? 0) / Math.max(total, 1)) * 100}%`, background: partyColor(id) }} />)}</div>
28 <div className="flex flex-wrap gap-x-3 mt-2 text-[12px] num">{PARTY_IDS.map((id) => <span key={id}><b style={{ color: partyColor(id) }}>{partyLabel(id)}</b> {t.counts[id] ?? 0}</span>)}</div>
27 + <div className="flex h-[8px] rounded-full overflow-hidden mt-3 bg-surface-3">{PARTY_IDS.map((id) => <div key={id} style={{ width: `${((t.counts[id] ?? 0) / Math.max(total, 1)) * 100}%`, background: partyVar(id) }} />)}</div>
28 + <div className="flex flex-wrap gap-x-3 mt-2 text-[12px] num">{PARTY_IDS.map((id) => <span key={id}><b style={{ color: partyVar(id) }}>{partyLabel(id)}</b> {t.counts[id] ?? 0}</span>)}</div>
29 29 </Link>
30 30 );
31 31 })}
modified web/src/app/globals.css +166 −46
@@ -1,30 +1,96 @@
1 1 @import "tailwindcss";
2 2
3 /* ------------------------------------------------------------------ tokens */
3 +@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
4 +
5 +/* ==================================================================== tokens
6 + QC26 « Editorial Data Intelligence » — papier chaud, encre, un seul accent (sarcelle,
7 + jamais une couleur de parti). Les couleurs de partis vivent ici en variables, adaptées au
8 + thème sombre (le marine du PQ et le violet du PCQ sont éclaircis pour rester lisibles). */
4 9 :root {
5 --background: #f7f8fa;
10 + --background: #f6f5f1;
6 11 --surface: #ffffff;
7 --surface-2: #f1f3f5;
8 --surface-3: #e9ecf0;
9 --border: rgba(20, 25, 35, 0.09);
10 --border-strong: rgba(20, 25, 35, 0.18);
11 --text: #111318;
12 --text-2: #656b76;
13 --text-3: #8a909b;
14 --qc26: #111827;
15 --qc26-accent: #2563eb;
16 --qc26-accent-soft: rgba(37, 99, 235, 0.1);
17 --live: #dc2626;
18 --ok: #15803d;
12 + --surface-2: #f0eee8;
13 + --surface-3: #e6e3db;
14 + --surface-4: #d9d5cb;
15 + --border: rgba(20, 22, 28, 0.1);
16 + --border-strong: rgba(20, 22, 28, 0.22);
17 + --text: #15171c;
18 + --text-2: #5c6270;
19 + --text-3: #8b909b;
20 + --text-inverse: #f6f5f1;
21 + --qc26: #15171c;
22 + --qc26-accent: #0f766e;
23 + --qc26-accent-strong: #0b5d57;
24 + --qc26-accent-soft: rgba(15, 118, 110, 0.1);
25 + --qc26-accent-ink: #ffffff;
26 + --live: #d12f2f;
27 + --live-soft: rgba(209, 47, 47, 0.1);
28 + --ok: #1f7a3a;
29 + --ok-soft: rgba(31, 122, 58, 0.1);
19 30 --warn: #b45309;
20 --radius: 10px;
21 --radius-lg: 16px;
22 --shadow-sm: 0 1px 2px rgba(17, 19, 24, 0.04), 0 1px 1px rgba(17, 19, 24, 0.03);
23 --shadow-md: 0 6px 24px -12px rgba(17, 19, 24, 0.18), 0 2px 6px -2px rgba(17, 19, 24, 0.06);
31 + --warn-soft: rgba(180, 83, 9, 0.12);
32 + --grid: rgba(20, 22, 28, 0.07);
33 + --p-caq: #20a8e0;
34 + --p-plq: #ed1b2d;
35 + --p-pq: #1e295c;
36 + --p-qs: #ff5505;
37 + --p-pcq: #6e4c9f;
38 + --p-aut: #9aa0a6;
39 + --p-none: #c9c5bb;
40 + --radius: 12px;
41 + --radius-lg: 18px;
42 + --radius-sm: 8px;
43 + --shadow-sm: 0 1px 2px rgba(21, 23, 28, 0.05), 0 1px 1px rgba(21, 23, 28, 0.03);
44 + --shadow-md: 0 10px 30px -18px rgba(21, 23, 28, 0.35), 0 2px 8px -4px rgba(21, 23, 28, 0.08);
45 + --shadow-lg: 0 30px 60px -30px rgba(21, 23, 28, 0.4);
24 46 --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
25 --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
26 --header-h: 56px;
27 --bottom-nav-h: 60px;
47 + --font-display: var(--font-fraunces), "Iowan Old Style", "Palatino Linotype", Georgia, serif;
48 + --font-mono: var(--font-jetbrains), ui-monospace, "SF Mono", Menlo, Consolas, monospace;
49 + --header-h: 60px;
50 + --bottom-nav-h: 62px;
51 + --header-bg: rgba(246, 245, 241, 0.82);
52 + --nav-bg: rgba(255, 255, 255, 0.9);
53 + --hero-glow: radial-gradient(1200px 500px at 15% -10%, rgba(15, 118, 110, 0.14), transparent 60%), radial-gradient(900px 400px at 95% 0%, rgba(21, 23, 28, 0.06), transparent 60%);
54 + color-scheme: light;
55 +}
56 +:root[data-theme="dark"] {
57 + --background: #0e1013;
58 + --surface: #15181d;
59 + --surface-2: #1c2027;
60 + --surface-3: #252a33;
61 + --surface-4: #313743;
62 + --border: rgba(255, 255, 255, 0.09);
63 + --border-strong: rgba(255, 255, 255, 0.2);
64 + --text: #edeff3;
65 + --text-2: #a5abb8;
66 + --text-3: #737a88;
67 + --text-inverse: #0e1013;
68 + --qc26: #edeff3;
69 + --qc26-accent: #2fb8aa;
70 + --qc26-accent-strong: #5fd3c7;
71 + --qc26-accent-soft: rgba(47, 184, 170, 0.14);
72 + --qc26-accent-ink: #0e1013;
73 + --live: #f0554f;
74 + --live-soft: rgba(240, 85, 79, 0.14);
75 + --ok: #3fbf6b;
76 + --ok-soft: rgba(63, 191, 107, 0.14);
77 + --warn: #e0a23a;
78 + --warn-soft: rgba(224, 162, 58, 0.14);
79 + --grid: rgba(255, 255, 255, 0.07);
80 + --p-caq: #3db8ea;
81 + --p-plq: #f24c5a;
82 + --p-pq: #7385dc;
83 + --p-qs: #ff7a3d;
84 + --p-pcq: #ab8ee3;
85 + --p-aut: #8b919c;
86 + --p-none: #3a404b;
87 + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
88 + --shadow-md: 0 12px 30px -18px rgba(0, 0, 0, 0.8), 0 2px 8px -4px rgba(0, 0, 0, 0.5);
89 + --shadow-lg: 0 30px 60px -30px rgba(0, 0, 0, 0.9);
90 + --header-bg: rgba(14, 16, 19, 0.78);
91 + --nav-bg: rgba(21, 24, 29, 0.92);
92 + --hero-glow: radial-gradient(1200px 500px at 15% -10%, rgba(47, 184, 170, 0.16), transparent 60%), radial-gradient(900px 400px at 95% 0%, rgba(115, 133, 220, 0.1), transparent 60%);
93 + color-scheme: dark;
28 94 }
29 95
30 96 @theme inline {
@@ -32,26 +98,33 @@
32 98 --color-surface: var(--surface);
33 99 --color-surface-2: var(--surface-2);
34 100 --color-surface-3: var(--surface-3);
101 + --color-surface-4: var(--surface-4);
35 102 --color-border: var(--border);
36 103 --color-border-strong: var(--border-strong);
37 104 --color-ink: var(--text);
38 105 --color-ink-2: var(--text-2);
39 106 --color-ink-3: var(--text-3);
107 + --color-ink-inverse: var(--text-inverse);
40 108 --color-brand: var(--qc26);
41 109 --color-accent: var(--qc26-accent);
110 + --color-accent-strong: var(--qc26-accent-strong);
42 111 --color-accent-soft: var(--qc26-accent-soft);
112 + --color-accent-ink: var(--qc26-accent-ink);
43 113 --color-live: var(--live);
114 + --color-live-soft: var(--live-soft);
44 115 --color-ok: var(--ok);
116 + --color-ok-soft: var(--ok-soft);
45 117 --color-warn: var(--warn);
118 + --color-warn-soft: var(--warn-soft);
46 119 --font-sans: var(--font-sans);
120 + --font-display: var(--font-display);
47 121 --font-mono: var(--font-mono);
48 122 --radius-card: var(--radius);
49 123 --radius-card-lg: var(--radius-lg);
50 124 }
51 125
52 /* ------------------------------------------------------------------- base */
126 +/* ====================================================================== base */
53 127 html {
54 color-scheme: light;
55 128 -webkit-text-size-adjust: 100%;
56 129 scroll-behavior: smooth;
57 130 }
@@ -67,51 +140,98 @@ body {
67 140 -webkit-font-smoothing: antialiased;
68 141 text-rendering: optimizeLegibility;
69 142 min-height: 100dvh;
143 + transition: background-color 0.25s ease, color 0.25s ease;
144 +}
145 +@layer base {
146 + a { color: inherit; text-decoration: none; }
147 + a:hover { text-decoration: none; }
148 + ::selection { background: var(--qc26-accent-soft); }
149 + :focus-visible { outline: 2px solid var(--qc26-accent); outline-offset: 2px; border-radius: 4px; }
70 150 }
151 +
152 +/* ================================================================ typography */
153 +.display { font-family: var(--font-display); font-weight: 600; letter-spacing: -0.02em; line-height: 0.98; font-variation-settings: "opsz" 144, "SOFT" 30; }
154 +.display-tight { letter-spacing: -0.035em; }
155 +.figure { font-family: var(--font-display); font-weight: 500; letter-spacing: -0.03em; line-height: 0.9; font-variant-numeric: tabular-nums lining-nums; font-variation-settings: "opsz" 144, "SOFT" 0; }
156 +.serif { font-family: var(--font-display); }
71 157 .num { font-variant-numeric: tabular-nums lining-nums; letter-spacing: -0.01em; }
72 .display { letter-spacing: -0.035em; line-height: 0.95; font-weight: 700; }
73 .eyebrow { font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; font-weight: 600; color: var(--text-2); }
74 .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
158 +.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
159 +.eyebrow { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 600; color: var(--text-2); }
160 +.eyebrow-accent { color: var(--qc26-accent); }
161 +.lede { font-size: 16px; line-height: 1.6; color: var(--text-2); }
162 +.prose-qc26 p { line-height: 1.7; }
163 +.prose-qc26 b { color: var(--text); }
75 164
165 +/* ================================================================ surfaces */
76 166 .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
77 167 .card-lg { border-radius: var(--radius-lg); }
168 +.card-flat { box-shadow: none; }
169 +.card-hover { transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; }
170 +.card-hover:hover { box-shadow: var(--shadow-md); border-color: var(--border-strong); transform: translateY(-1px); }
78 171 .hairline { border-color: var(--border); }
79 172 .surface-2 { background: var(--surface-2); }
173 +.surface-3 { background: var(--surface-3); }
174 +.inset { background: var(--surface-2); border-radius: var(--radius-sm); }
175 +.ink-panel { background: var(--qc26); color: var(--text-inverse); }
176 +.hero-glow { background-image: var(--hero-glow); }
177 +.glass { background: var(--header-bg); backdrop-filter: saturate(160%) blur(14px); -webkit-backdrop-filter: saturate(160%) blur(14px); }
178 +.divider { height: 1px; background: var(--border); }
179 +.rule-top { border-top: 2px solid var(--text); }
180 +.section-num { font-family: var(--font-mono); font-size: 11px; color: var(--text-3); letter-spacing: 0.08em; }
80 181
81 /* Dans @layer base pour que les utilitaires Tailwind (text-white, etc.) gardent la priorité. */
82 @layer base {
83 a { color: inherit; text-decoration: none; }
84 a:hover { text-decoration: none; }
85 }
86 .link { color: var(--qc26-accent); }
182 +/* ================================================================ buttons */
183 +.btn { display: inline-flex; align-items: center; gap: 8px; border-radius: 10px; padding: 10px 16px; font-size: 14px; font-weight: 600; border: 1px solid transparent; transition: background-color 0.15s, color 0.15s, border-color 0.15s, transform 0.1s; white-space: nowrap; }
184 +.btn:active { transform: translateY(1px); }
185 +.btn-primary { background: var(--text); color: var(--text-inverse); }
186 +.btn-primary:hover { background: var(--qc26-accent-strong); color: #fff; }
187 +.btn-accent { background: var(--qc26-accent); color: var(--qc26-accent-ink); }
188 +.btn-accent:hover { background: var(--qc26-accent-strong); }
189 +.btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--text); }
190 +.btn-ghost:hover { background: var(--surface-2); }
191 +.btn-sm { padding: 6px 11px; font-size: 12.5px; border-radius: 8px; }
192 +.link { color: var(--qc26-accent); font-weight: 500; }
87 193 .link:hover { text-decoration: underline; text-underline-offset: 3px; }
194 +.chip { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 6px 12px; font-size: 13px; font-weight: 500; border: 1px solid var(--border); background: var(--surface); transition: background-color 0.15s, border-color 0.15s, color 0.15s; }
195 +.chip:hover { background: var(--surface-2); }
196 +.chip-active { background: var(--text); color: var(--text-inverse); border-color: var(--text); }
197 +.chip-active:hover { background: var(--text); }
88 198
89 ::selection { background: rgba(37, 99, 235, 0.18); }
90 :focus-visible { outline: 2px solid var(--qc26-accent); outline-offset: 2px; border-radius: 4px; }
199 +/* ================================================================ tables */
200 +.data-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13.5px; }
201 +.data-table th { text-align: left; font-family: var(--font-mono); font-weight: 600; color: var(--text-2); font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; padding: 10px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; position: sticky; top: 0; background: var(--surface); z-index: 1; }
202 +.data-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
203 +.data-table tr:last-child td { border-bottom: 0; }
204 +.data-table tbody tr:hover { background: var(--surface-2); }
205 +.data-table .r { text-align: right; }
206 +select, input[type="text"], input[type="search"] { color: var(--text); background: var(--surface); }
207 +input[type="range"] { accent-color: var(--qc26-accent); }
208 +input[type="checkbox"] { accent-color: var(--qc26-accent); }
91 209
92 /* -------------------------------------------------------------- utilities */
210 +/* ================================================================ utilities */
93 211 .scrollbar-none { scrollbar-width: none; }
94 212 .scrollbar-none::-webkit-scrollbar { display: none; }
95 213 .safe-bottom { padding-bottom: max(env(safe-area-inset-bottom), 0px); }
96 214 .tabular { font-variant-numeric: tabular-nums; }
215 +.pcolor { color: var(--pc); }
216 +.pbg { background: var(--pc); }
97 217
98 218 @keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
99 219 .live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--live); display: inline-block; animation: pulse-dot 1.6s ease-in-out infinite; }
100 @keyframes fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
101 .fade-up { animation: fade-up 0.35s ease-out both; }
220 +@keyframes fade-up { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
221 +.fade-up { animation: fade-up 0.45s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
222 +.fade-up-2 { animation-delay: 0.08s; }
223 +.fade-up-3 { animation-delay: 0.16s; }
102 224 @keyframes ticker-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
103 225 .ticker-in { animation: ticker-in 0.3s ease-out both; }
226 +@keyframes grow-x { from { transform: scaleX(0); } to { transform: scaleX(1); } }
227 +.grow-x { transform-origin: left; animation: grow-x 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
104 228
105 /* table */
106 .data-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13.5px; }
107 .data-table th { text-align: left; font-weight: 600; color: var(--text-2); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; padding: 10px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; position: sticky; top: 0; background: var(--surface); z-index: 1; }
108 .data-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
109 .data-table tr:last-child td { border-bottom: 0; }
110 .data-table tbody tr:hover { background: var(--surface-2); }
111 .data-table .r { text-align: right; }
229 +/* charts */
230 +.chart-grid line { stroke: var(--grid); }
231 +.chart-tip { position: absolute; pointer-events: none; z-index: 20; background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; box-shadow: var(--shadow-md); padding: 8px 10px; font-size: 12px; line-height: 1.35; min-width: 150px; }
112 232
113 /* range input */
114 input[type="range"] { accent-color: var(--qc26); }
233 +/* waffle */
234 +.waffle-dot { transition: fill 0.3s ease, opacity 0.3s ease; }
115 235
116 236 /* print */
117 237 @media print { header, nav, footer, .no-print { display: none !important; } body { background: #fff; } }
modified web/src/app/layout.tsx +21 −15
@@ -1,43 +1,49 @@
1 1 import type { Metadata, Viewport } from "next";
2 import { Inter } from "next/font/google";
2 +import { Fraunces, Inter, JetBrains_Mono } from "next/font/google";
3 3 import Link from "next/link";
4 4 import "./globals.css";
5 5 import { apiSafe, SITE_URL, type Meta } from "@/lib/api";
6 6 import { MobileNav, TopNav, Wordmark } from "@/components/shell";
7 7 import { StatusStrip } from "@/components/status-strip";
8 +import { THEME_INIT_SCRIPT } from "@/components/theme";
8 9 import { Disclaimer } from "@/components/ui";
9 10
10 11 const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
12 +const fraunces = Fraunces({ subsets: ["latin"], variable: "--font-fraunces", display: "swap", axes: ["opsz", "SOFT"] });
13 +const jetbrains = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains", display: "swap", weight: ["400", "500", "600"] });
11 14
12 15 export const metadata: Metadata = {
13 16 metadataBase: new URL(SITE_URL),
14 17 title: { default: "QC26 — Québec 2026. Comprendre. Prévoir. Suivre.", template: "%s | QC26" },
15 description: "Prévisions, sondages, boussole électorale et résultats en direct de l'élection québécoise du 5 octobre 2026.",
18 + description: "Prévisions, sondages, marchés de prédiction, presse, boussole électorale et résultats en direct de l'élection québécoise du 5 octobre 2026.",
16 19 applicationName: "QC26",
17 openGraph: { type: "website", siteName: "QC26", locale: "fr_CA", url: SITE_URL, title: "QC26 — L'élection québécoise, en données", description: "Prévisions, sondages, boussole électorale et résultats en direct de l'élection québécoise 2026." },
20 + openGraph: { type: "website", siteName: "QC26", locale: "fr_CA", url: SITE_URL, title: "QC26 — L'élection québécoise, en données", description: "Prévisions, sondages, signaux alternatifs, boussole électorale et résultats en direct de l'élection québécoise 2026." },
18 21 twitter: { card: "summary_large_image", title: "QC26", description: "L'élection québécoise 2026, en données." },
19 22 robots: { index: true, follow: true },
20 23 alternates: { canonical: "/" },
21 24 };
22 export const viewport: Viewport = { themeColor: "#F7F8FA", width: "device-width", initialScale: 1, viewportFit: "cover" };
25 +export const viewport: Viewport = { themeColor: "#f6f5f1", width: "device-width", initialScale: 1, viewportFit: "cover" };
23 26
24 27 export default async function RootLayout({ children }: { children: React.ReactNode }) {
25 28 const meta = await apiSafe<Meta>("/api/meta", { revalidate: 60 });
26 29 const mode = meta?.mode ?? "forecast";
27 30 const days = meta?.daysToElection ?? Math.ceil((new Date("2026-10-05T00:00:00-04:00").getTime() - Date.now()) / 86400000);
28 31 return (
29 <html lang="fr-CA" className={inter.variable}>
32 + <html lang="fr-CA" className={`${inter.variable} ${fraunces.variable} ${jetbrains.variable}`} suppressHydrationWarning>
33 + <head>
34 + <script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
35 + </head>
30 36 <body className="pb-[calc(var(--bottom-nav-h)+env(safe-area-inset-bottom))] lg:pb-0">
31 <TopNav daysToElection={days} mode={mode} />
32 <main className="mx-auto max-w-[1280px] px-4 md:px-6 py-6 md:py-8">{children}</main>
33 <footer className="border-t border-border mt-16">
34 <div className="mx-auto max-w-[1280px] px-4 md:px-6 py-10 grid gap-8 md:grid-cols-[1.4fr_1fr_1fr_1fr]">
37 + <TopNav daysToElection={days} mode={mode} modelVersion={meta?.modelVersion} />
38 + <main className="mx-auto max-w-[1320px] px-4 md:px-6 py-6 md:py-10">{children}</main>
39 + <footer className="border-t border-border mt-20">
40 + <div className="mx-auto max-w-[1320px] px-4 md:px-6 py-12 grid gap-10 md:grid-cols-[1.5fr_1fr_1fr_1fr]">
35 41 <div>
36 <Wordmark size={26} />
37 <p className="text-[13.5px] text-ink-2 mt-3 max-w-sm leading-relaxed">Québec 2026. Comprendre. Prévoir. Suivre. Plateforme indépendante de données, d&apos;analyse et de prévision consacrée à l&apos;élection générale du 5 octobre 2026.</p>
38 <div className="mt-4"><Disclaimer text={meta?.disclaimer} /></div>
42 + <Wordmark size={28} />
43 + <p className="text-[14px] text-ink-2 mt-4 max-w-sm leading-relaxed">Québec 2026. Comprendre. Prévoir. Suivre. Plateforme indépendante de données, d&apos;analyse et de prévision consacrée à l&apos;élection générale du 5 octobre 2026.</p>
44 + <div className="mt-5"><Disclaimer text={meta?.disclaimer} /></div>
39 45 </div>
40 <FooterCol title="Données" links={[["/prevision", "Prévision"], ["/sondages", "Sondages"], ["/carte", "Carte"], ["/circonscriptions", "Circonscriptions"], ["/live", mode === "forecast" ? "Soirée électorale" : "Direct"]]} />
46 + <FooterCol title="Prévision" links={[["/prevision", "Prévision"], ["/signaux", "Au-delà des sondages"], ["/sondages", "Sondages"], ["/carte", "Carte"], ["/circonscriptions", "Circonscriptions"], ["/live", mode === "forecast" ? "Soirée électorale" : "Direct"]]} />
41 47 <FooterCol title="Partis" links={[["/partis", "Partis"], ["/partis/comparateur", "Comparateur"], ["/enjeux", "Enjeux"], ["/documents", "Bibliothèque documentaire"], ["/boussole", "Boussole"]]} />
42 48 <FooterCol title="Transparence" links={[["/methodologie", "Méthodologie"], ["/methodologie/changelog", "Changelog du modèle"], ["/statut", "Statut des données"], ["/api/docs", "API"], ["/admin", "Administration"]]} />
43 49 </div>
@@ -53,8 +59,8 @@ function FooterCol({ title, links }: { title: string; links: [string, string][]
53 59 return (
54 60 <div>
55 61 <div className="eyebrow mb-3">{title}</div>
56 <ul className="space-y-2 text-[13.5px]">
57 {links.map(([href, label]) => (<li key={href}><Link href={href} className="text-ink-2 hover:text-ink">{label}</Link></li>))}
62 + <ul className="space-y-2 text-[14px]">
63 + {links.map(([href, label]) => (<li key={href}><Link href={href} className="text-ink-2 hover:text-ink transition-colors">{label}</Link></li>))}
58 64 </ul>
59 65 </div>
60 66 );
modified web/src/app/live/model-vs-reality/page.tsx +3 −3
@@ -3,7 +3,7 @@ import Link from "next/link";
3 3 import { apiSafe } from "@/lib/api";
4 4 import { PartyBadge } from "@/components/party";
5 5 import { Card, Empty, SectionHeader } from "@/components/ui";
6 import { PARTY_IDS, partyColor, partyLabel } from "@/lib/parties";
6 +import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";
7 7 import { pct, prob } from "@/lib/format";
8 8
9 9 export const metadata: Metadata = { title: "Modèle vs réalité", description: "Précision de la prévision QC26 face aux résultats : circonscriptions correctes, surprises, tossups, calibration, erreur sur les sièges." };
@@ -17,7 +17,7 @@ export default async function Page() {
17 17 const acc = d.ridingsCompared ? d.correct / d.ridingsCompared : null;
18 18 return (
19 19 <div className="space-y-10">
20 <div><Link href="/live" className="text-[12.5px] text-ink-3 hover:text-ink">← Direct</Link><h1 className="display text-[36px] md:text-[48px] mt-2">Modèle vs réalité</h1><p className="text-ink-2 mt-3 max-w-2xl text-[15px]">QC26 assume publiquement ses erreurs. Cette page compare la dernière prévision publiée avant 20 h (run #{d.runId}) aux résultats officiels, au fil de la soirée puis en version finale.</p></div>
20 + <div><Link href="/live" className="text-[12.5px] text-ink-3 hover:text-ink">← Direct</Link><h1 className="display text-[40px] md:text-[56px] mt-3">Modèle vs réalité</h1><p className="text-ink-2 mt-3 max-w-2xl text-[15px]">QC26 assume publiquement ses erreurs. Cette page compare la dernière prévision publiée avant 20 h (run #{d.runId}) aux résultats officiels, au fil de la soirée puis en version finale.</p></div>
21 21 {d.ridingsCompared === 0 ? <Empty title="En attente des premiers résultats">Les métriques apparaîtront dès que des circonscriptions rapporteront.</Empty> : (
22 22 <>
23 23 <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3">
@@ -26,7 +26,7 @@ export default async function Page() {
26 26 <Card><div className="eyebrow">Tossups corrects</div><div className="num display text-[40px] mt-1">{d.tossups.correct}<span className="text-ink-3 text-[20px]"> / {d.tossups.total}</span></div><div className="text-[12.5px] text-ink-2">courses classées « serré »</div></Card>
27 27 <Card><div className="eyebrow">Seat MAE</div><div className="num display text-[40px] mt-1">{d.seatMAE !== null ? d.seatMAE.toFixed(1).replace(".", ",") : "—"}</div><div className="text-[12.5px] text-ink-2">{d.seatMAE !== null ? "erreur absolue moyenne (sièges, final)" : "disponible au dépouillement complet"}</div></Card>
28 28 </div>
29 <section><SectionHeader eyebrow="Sièges" title="Forecast vs Live" /><Card pad={false}><table className="data-table"><thead><tr><th>Parti</th><th className="r">Forecast (médiane)</th><th className="r">Élus</th><th className="r">En avance</th><th className="r">Live</th><th className="r">Écart</th></tr></thead><tbody>{PARTY_IDS.map((id) => { const live = (d.liveSeats[id]?.elected ?? 0) + (d.liveSeats[id]?.leading ?? 0); return <tr key={id}><td><PartyBadge id={id} size="sm" /></td><td className="r num">{d.forecastSeats[id]}</td><td className="r num">{d.liveSeats[id]?.elected ?? 0}</td><td className="r num">{d.liveSeats[id]?.leading ?? 0}</td><td className="r num font-bold" style={{ color: partyColor(id) }}>{live}</td><td className="r num">{live - d.forecastSeats[id] > 0 ? "+" : ""}{live - d.forecastSeats[id]}</td></tr>; })}</tbody></table></Card></section>
29 + <section><SectionHeader eyebrow="Sièges" title="Forecast vs Live" /><Card pad={false}><table className="data-table"><thead><tr><th>Parti</th><th className="r">Forecast (médiane)</th><th className="r">Élus</th><th className="r">En avance</th><th className="r">Live</th><th className="r">Écart</th></tr></thead><tbody>{PARTY_IDS.map((id) => { const live = (d.liveSeats[id]?.elected ?? 0) + (d.liveSeats[id]?.leading ?? 0); return <tr key={id}><td><PartyBadge id={id} size="sm" /></td><td className="r num">{d.forecastSeats[id]}</td><td className="r num">{d.liveSeats[id]?.elected ?? 0}</td><td className="r num">{d.liveSeats[id]?.leading ?? 0}</td><td className="r num font-bold" style={{ color: partyVar(id) }}>{live}</td><td className="r num">{live - d.forecastSeats[id] > 0 ? "+" : ""}{live - d.forecastSeats[id]}</td></tr>; })}</tbody></table></Card></section>
30 30 <section><SectionHeader eyebrow="Calibration" title="Probabilité annoncée vs fréquence observée" description="Dans une prévision calibrée, les favoris à 70–80 % gagnent environ 75 % du temps." /><Card><div className="grid grid-cols-2 sm:grid-cols-5 gap-2">{Object.entries(d.calibration).map(([b, v]) => (<div key={b} className="surface-2 rounded-[8px] p-3 text-center"><div className="text-[11.5px] text-ink-3 num">{b} %</div><div className="num font-bold text-[20px]">{v.n ? Math.round((100 * v.hits) / v.n) : "—"} %</div><div className="text-[11.5px] text-ink-3 num">{v.hits}/{v.n} corrects</div></div>))}</div></Card></section>
31 31 <section><SectionHeader eyebrow="Surprises de la soirée" title="Upsets" /><Card pad={false}>{d.upsets.length === 0 ? <div className="p-6 text-center text-ink-3 text-[13.5px]">Aucune surprise pour l&apos;instant.</div> : <table className="data-table"><thead><tr><th>Circonscription</th><th>Favori QC26</th><th className="r">Probabilité initiale</th><th>Leader actuel</th><th className="r">Bureaux</th><th className="r">Marge</th></tr></thead><tbody>{d.upsets.map((u) => <tr key={u.code}><td><Link href={`/circonscription/${u.slug}`} className="hover:underline font-medium">{u.name}</Link></td><td><PartyBadge id={u.favorite} size="sm" /></td><td className="r num">{prob(u.p)}</td><td><PartyBadge id={u.leader} size="sm" /> {u.final && <span className="text-[10.5px] uppercase text-ok">final</span>}</td><td className="r num">{u.bureaux[0]}/{u.bureaux[1]}</td><td className="r num">{pct(u.marginPct)}</td></tr>)}</tbody></table>}</Card></section>
32 32 <p className="text-[12px] text-ink-3">{partyLabel("pq")} et autres sigles : voir <Link href="/partis" className="link">Partis</Link>.</p>
modified web/src/app/live/page.tsx +1 −1
@@ -14,7 +14,7 @@ export default async function Page() {
14 14 if (meta.mode === "forecast" && !st?.simulation) {
15 15 return (
16 16 <div className="space-y-8 max-w-3xl">
17 <div><div className="eyebrow">QC26 Live · prêt</div><h1 className="display text-[36px] md:text-[48px] mt-2">Soirée électorale du {dateFr(meta.electionDate)}</h1><p className="text-ink-2 mt-3 text-[15px] leading-relaxed">À la fermeture des bureaux (20 h), QC26 bascule automatiquement en mode DIRECT : résultats officiels d&apos;Élections Québec toutes les 2 à 5 minutes, carte qui se colore bureau par bureau, ticker, nowcast et probabilité de majorité en temps réel, comparaison prévision–réalité. Même produit, même adresse.</p></div>
17 + <div><div className="eyebrow eyebrow-accent">QC26 Live · prêt</div><h1 className="display text-[40px] md:text-[56px] mt-3">Soirée électorale du {dateFr(meta.electionDate)}</h1><p className="text-ink-2 mt-3 text-[15px] leading-relaxed">À la fermeture des bureaux (20 h), QC26 bascule automatiquement en mode DIRECT : résultats officiels d&apos;Élections Québec toutes les 2 à 5 minutes, carte qui se colore bureau par bureau, ticker, nowcast et probabilité de majorité en temps réel, comparaison prévision–réalité. Même produit, même adresse.</p></div>
18 18 <div className="grid sm:grid-cols-2 gap-3">
19 19 {[["Flux officiel", "donnees.electionsquebec.qc.ca — JSON officiel, validé, archivé à chaque changement, mis en quarantaine en cas d'anomalie."], ["Forecast vs Live", "La dernière projection publiée avant 20 h est figée et comparée aux résultats, siège par siège."], ["Nowcast", "Prior QC26 + résultats partiels → probabilité de majorité mise à jour à chaque snapshot."], ["Replay", "Chaque snapshot est conservé : la soirée pourra être rejouée seconde par seconde."]].map(([t, d]) => <Card key={t}><div className="font-semibold">{t}</div><div className="text-[13.5px] text-ink-2 mt-1">{d}</div></Card>)}
20 20 </div>
modified web/src/app/methodologie/changelog/page.tsx +1 −1
@@ -14,7 +14,7 @@ export default async function Page() {
14 14 if (!d) return <Empty title="Indisponible" />;
15 15 return (
16 16 <div className="space-y-10 max-w-3xl">
17 <div><Link href="/methodologie" className="text-[12.5px] text-ink-3 hover:text-ink">← Méthodologie</Link><h1 className="display text-[36px] md:text-[48px] mt-2">Changelog</h1><p className="text-ink-2 mt-3 text-[15px]">La transparence est une caractéristique du produit : chaque changement de méthode est daté et expliqué ; chaque run est conservé.</p></div>
17 + <div><Link href="/methodologie" className="text-[12.5px] text-ink-3 hover:text-ink">← Méthodologie</Link><h1 className="display text-[40px] md:text-[56px] mt-3">Changelog</h1><p className="text-ink-2 mt-3 text-[15px]">La transparence est une caractéristique du produit : chaque changement de méthode est daté et expliqué ; chaque run est conservé.</p></div>
18 18 <section><SectionHeader eyebrow="Versions du modèle" title="Changements de méthode" />
19 19 <div className="space-y-3">{d.changelog.map((c) => (<Card key={c.version}><div className="flex items-center gap-3"><Badge tone="ink">v{c.version}</Badge><b>{c.title}</b><span className="text-ink-3 text-[12.5px] ml-auto num">{dateFr(c.date)}</span></div><ul className="list-disc pl-5 mt-3 space-y-1 text-[14px] text-ink-2">{c.notes.map((n, i) => <li key={i}>{n}</li>)}</ul></Card>))}</div>
20 20 </section>
modified web/src/app/methodologie/page.tsx +15 −5
@@ -9,7 +9,7 @@ export const revalidate = 600;
9 9
10 10 const SECTIONS = [
11 11 ["vue", "Vue d'ensemble"], ["agregation", "Agrégation des sondages"], ["maison", "Effets maison"], ["temporel", "Modèle temporel"], ["regional", "Projection régionale"],
12 ["circonscriptions", "Modèle de circonscription"], ["montecarlo", "Monte Carlo"], ["incertitude", "Incertitude"], ["nowcast", "Nowcast"], ["boussole", "Boussole"], ["sources", "Sources"], ["limites", "Limites"],
12 + ["circonscriptions", "Modèle de circonscription"], ["montecarlo", "Monte Carlo"], ["signaux", "Au-delà des sondages (v2)"], ["incertitude", "Incertitude"], ["nowcast", "Nowcast"], ["boussole", "Boussole"], ["sources", "Sources"], ["limites", "Limites"],
13 13 ];
14 14
15 15 export default async function Page() {
@@ -19,9 +19,9 @@ export default async function Page() {
19 19 <div className="grid lg:grid-cols-[220px_1fr] gap-10">
20 20 <nav className="hidden lg:block sticky top-20 self-start text-[13px] space-y-1.5" aria-label="Sections"><div className="eyebrow mb-2">Méthodologie</div>{SECTIONS.map(([id, l]) => <a key={id} href={`#${id}`} className="block text-ink-2 hover:text-ink">{l}</a>)}<Link href="/methodologie/changelog" className="block link mt-3">Changelog →</Link></nav>
21 21 <article className="prose-qc26 max-w-3xl space-y-10 text-[15px] leading-relaxed text-ink">
22 <div><div className="eyebrow">Méthodologie · Modèle {meta?.modelVersion ?? "1.0.0"}</div><h1 className="display text-[36px] md:text-[48px] mt-2">Comment QC26 calcule</h1><p className="text-ink-2 mt-3">Ce document décrit précisément ce que fait le moteur QC26, dans l&apos;ordre où il le fait. Chaque paramètre cité est celui du code en production ; chaque changement est consigné dans le <Link href="/methodologie/changelog" className="link">changelog</Link>.</p></div>
22 + <div><div className="eyebrow eyebrow-accent">Méthodologie · Modèle {meta?.modelVersion ?? "2.0.0"}</div><h1 className="display text-[40px] md:text-[56px] mt-3">Comment QC26 calcule</h1><p className="text-ink-2 mt-3">Ce document décrit précisément ce que fait le moteur QC26, dans l&apos;ordre où il le fait. Chaque paramètre cité est celui du code en production ; chaque changement est consigné dans le <Link href="/methodologie/changelog" className="link">changelog</Link>.</p></div>
23 23
24 <Section id="vue" title="Vue d'ensemble"><p>QC26 combine trois couches : (1) une <b>moyenne des sondages</b> corrigée et lissée dans le temps, (2) un <b>modèle de circonscription</b> qui projette cette moyenne sur les {seats} circonscriptions à partir des résultats de 2022 transposés sur la carte 2026, (3) une <b>simulation Monte Carlo</b> ({int(f?.nSimulations ?? 20000)} élections) qui propage toutes les sources d&apos;erreur et produit des probabilités. Le seuil de majorité ({maj}) est dérivé du nombre officiel de circonscriptions, jamais codé en dur.</p></Section>
24 + <Section id="vue" title="Vue d'ensemble"><p>QC26 combine, depuis la version 2.0, une moyenne des sondages et quatre familles de signaux indépendants (<Link href="#signaux" className="link">marchés de prédiction, fondamentaux, momentum, partielles</Link>), puis trois couches : (1) une <b>moyenne des sondages</b> corrigée et lissée dans le temps, (2) un <b>modèle de circonscription</b> qui projette cette moyenne sur les {seats} circonscriptions à partir des résultats de 2022 transposés sur la carte 2026, (3) une <b>simulation Monte Carlo</b> ({int(f?.nSimulations ?? 20000)} élections) qui propage toutes les sources d&apos;erreur et produit des probabilités. Le seuil de majorité ({maj}) est dérivé du nombre officiel de circonscriptions, jamais codé en dur.</p></Section>
25 25
26 26 <Section id="agregation" title="Agrégation des sondages"><p>Tout sondage provincial publié depuis le scrutin du 3 octobre 2022 est ingéré. Le document original de la firme (PDF) est téléchargé, haché (SHA-256), archivé et extrait page par page ; un modèle de langage propose une lecture structurée qui est ensuite validée mécaniquement : partis reconnus, somme des intentions entre 94 et 106 %, dates cohérentes, taille d&apos;échantillon plausible, concordance avec un index tiers. Un sondage qui échoue à la validation est exclu et affiché comme tel.</p>
27 27 <p>Chaque sondage reçoit un poids = récence × échantillon × qualité. La récence suit une décroissance exponentielle de demi-vie 14 jours (7 jours en période électorale) ; l&apos;échantillon compte pour √(n/1000), n plafonné à 2 500 pour éviter la domination des très gros sondages ; la qualité vaut 1 pour un document original validé, 0,85 pour une valeur d&apos;index en attente de document.</p></Section>
@@ -40,15 +40,25 @@ export default async function Page() {
40 40
41 41 <Section id="montecarlo" title="Monte Carlo"><p>{int(f?.nSimulations ?? 20000)} tirages du vote national (loi normale multivariée dans l&apos;espace logit), chacun propagé aux {seats} circonscriptions. On en déduit les distributions de sièges, P(plus de sièges), P(majorité), P(minoritaire), les probabilités de victoire par circonscription et le classement des circonscriptions « à surveiller » (1 − probabilité du favori). Chaque run enregistre sa graine, son commit et sa date d&apos;arrêt des données.</p></Section>
42 42
43 + <Section id="signaux" title="Au-delà des sondages (modèle 2.0)">
44 + <p>Les sondages restent le cœur du modèle. Quatre signaux indépendants, tous archivés avec leur provenance et publiés séparément (<Link href="/signaux" className="link">page Signaux</Link>, champ <code className="mono text-[13px]">summary.components</code> de l&apos;API), les corrigent à la marge. Aucun n&apos;est saisi à la main ; l&apos;absence d&apos;un signal désactive simplement sa composante.</p>
45 + <ol className="list-decimal pl-5 space-y-2">
46 + <li><b>Marchés de prédiction (Polymarket)</b>. Marchés « plus de sièges » par parti, « majorité PQ », tranches de sièges (PQ, CAQ, PLQ), 2e et 3e place ; prix « Oui » lus par l&apos;API publique, historique quotidien archivé, marchés de moins de 5 000 $ ou périmés (&gt; 72 h) ignorés. Plutôt que de mélanger des probabilités incohérentes, QC26 <b>repondère ses 20 000 simulations</b> (ajustement proportionnel itératif) pour que les probabilités de l&apos;ensemble valent (1 − w)·modèle + w·marché, avec w = 0,30 pour le vainqueur et la majorité et w = 0,15 pour les tranches. Sièges, circonscriptions et intervalles restent ceux des mêmes simulations, pesées ; la taille d&apos;échantillon effective (ESS) est publiée.</li>
47 + <li><b>Fondamentaux</b>. Les rapports de sondage contiennent des questions structurantes (satisfaction à l&apos;égard du gouvernement, meilleur·e premier·ère ministre, favorabilité des chef·fes, deuxième choix, souveraineté) que QC26 extrait avec la page citée. La satisfaction, pondérée récence (demi-vie 30 j) × √n, fixe un prior sur la part du parti sortant : part ≈ 6 + 0,6 × satisfaction (σ 5,5 pt ; calibration indicative sur les scrutins québécois de 2003 à 2022, les autres partis se partageant le reste au prorata des sondages). Ce prior est mélangé à l&apos;état latent dans l&apos;espace logit avec un poids qui vaut 20 % à 120 jours du vote et décroît linéairement jusqu&apos;à 0 le jour J : loin du scrutin, les fondamentaux tempèrent les sondages ; le jour du vote, seuls les sondages comptent.</li>
48 + <li><b>Momentum</b>. Trois indicateurs de dynamique sont convertis en un déplacement de la moyenne du vote au jour du scrutin : prime au chef (part « meilleur PM » parmi les répondants décidés − intention de vote, κ 0,15), attention publique (variation de la part des pages vues Wikipédia des partis et chef·fes, 7 j vs 28 j, κ 0,08 pt par point) et ton médiatique (titres de presse des 7 derniers jours classés par un modèle de langage, ton −1…+1 par parti, κ 0,5, atténué sous 10 articles). Le total est écrêté à ± 1 pt puis recentré à somme nulle : le momentum déplace du vote entre partis, il n&apos;en crée pas.</li>
49 + <li><b>Élections partielles</b>. Les cinq partielles tenues depuis octobre 2022 (résultats officiels DGEQ, tableau rendu puis archivé) sont comparées au résultat de l&apos;élection générale 2022 dans la même circonscription. L&apos;excès de swing local — le swing observé moins le swing national de la moyenne QC26 à la date de la partielle — corrige le prior de la circonscription 2026 correspondante, rétréci (κ 0,35, borné ± 1 logit). Les partielles corrigent aussi la liste des député·es sortant·es, désormais tirée du drapeau officiel de la liste des candidatures.</li>
50 + </ol>
51 + <p>La vue « sondages seuls » (v1 : mêmes aléas, sans fondamentaux, momentum, partielles ni marchés) et la vue « modèle sans marchés » sont calculées à chaque run et publiées à côté de l&apos;ensemble, pour que l&apos;effet de chaque composante soit toujours lisible.</p></Section>
52 +
43 53 <Section id="incertitude" title="Incertitude"><p>Les intervalles affichés sont des percentiles des simulations (80 % : 10–90 ; 95 % : 2,5–97,5). Ils intègrent l&apos;erreur d&apos;échantillonnage, l&apos;erreur d&apos;industrie, la dérive jusqu&apos;au scrutin et les erreurs régionales et locales. Une carte QC26 n&apos;affiche jamais une circonscription comme « gagnée » avant le vote : l&apos;opacité traduit la probabilité.</p></Section>
44 54
45 55 <Section id="nowcast" title="Nowcast (soirée électorale)"><p>Après 20 h, un modèle distinct combine le dernier forecast (prior) et les résultats officiels partiels d&apos;Élections Québec. Dans chaque circonscription, la part estimée = w·observé + (1 − w)·(prior + swing national observé), où w est la fraction des bureaux dépouillés et le swing observé (écart pondéré entre résultats et prior dans les circonscriptions qui rapportent) est rétréci quand la couverture est faible. 4 000 simulations recalculent sièges et probabilité de majorité à chaque mise à jour. Un statut « Projeté QC26 » exige p ≥ 99,5 %, ≥ 30 % des bureaux et une marge ≥ 5 pts ; « Élu·e » et « Final » viennent uniquement des données officielles.</p></Section>
46 56
47 57 <Section id="boussole" title="Boussole"><p>48 énoncés sur 6 axes. Les positions des partis sont dérivées exclusivement des propositions extraites de leurs documents officiels (page + citation vérifiée dans le texte) : un modèle de langage situe le parti sur l&apos;échelle −2…+2 <i>uniquement</i> si les propositions le permettent, sinon la position est « introuvable » et la question ne compte pas pour ce parti. La concordance utilisateur–parti est 1 − |u − p|/4, pondérée par l&apos;importance. Les réponses restent dans le navigateur.</p></Section>
48 58
49 <Section id="sources" title="Sources"><ul className="list-disc pl-5 space-y-1"><li><b>Élections Québec</b> (données ouvertes officielles) : liste et géométrie des 127 circonscriptions 2026, électeurs inscrits, candidatures, partis autorisés et chef·fes, résultats 2018 et 2022 par circonscription et par section de vote, résultats du soir du vote.</li><li><b>Firmes de sondage</b> : documents originaux (Léger, Pallas Data, Synopsis, Angus Reid, Mainstreet, SEGMA, Abacus, Research Co., etc.).</li><li><b>Sites officiels des partis</b> : plateformes, programmes, engagements, cadres financiers, communiqués — versionnés.</li><li><b>Statistique Canada</b> : Recensement de la population 2021, profil des aires de diffusion agrégées et fichier des limites, agrégés aux 127 circonscriptions (langue, immigration, âge, revenu, tenure, scolarité, chômage).</li><li>Un index tiers (Wikipédia) sert uniquement à amorcer la liste des sondages et à repérer les documents originaux ; il est remplacé dès que le document est archivé.</li></ul></Section>
59 + <Section id="sources" title="Sources"><ul className="list-disc pl-5 space-y-1"><li><b>Élections Québec</b> (données ouvertes officielles) : liste et géométrie des 127 circonscriptions 2026, électeurs inscrits, candidatures, partis autorisés et chef·fes, résultats 2018 et 2022 par circonscription et par section de vote, résultats du soir du vote.</li><li><b>Firmes de sondage</b> : documents originaux (Léger, Pallas Data, Synopsis, Angus Reid, Mainstreet, SEGMA, Abacus, Research Co., etc.).</li><li><b>Sites officiels des partis</b> : plateformes, programmes, engagements, cadres financiers, communiqués — versionnés.</li><li><b>Polymarket</b> (API Gamma / CLOB publiques) : marchés « Quebec General Election » — prix, volumes, liquidité, historique quotidien.</li><li><b>Wikimedia</b> (API REST pageviews) : pages vues quotidiennes des articles Wikipédia FR des cinq partis et de leurs chef·fes.</li><li><b>Presse québécoise</b> via Google Actualités (Serper) : titres, médias, dates et URL des 48 dernières heures par parti ; classement (partis concernés, ton, sujet) par modèle de langage, validé mécaniquement. GDELT DOC 2.0 (volume et ton, Canada, français) en complément quand l&apos;API répond.</li><li><b>Élections Québec</b> : résultats officiels des élections partielles depuis 2022.</li><li><b>Statistique Canada</b> : Recensement de la population 2021, profil des aires de diffusion agrégées et fichier des limites, agrégés aux 127 circonscriptions (langue, immigration, âge, revenu, tenure, scolarité, chômage).</li><li>Un index tiers (Wikipédia) sert uniquement à amorcer la liste des sondages et à repérer les documents originaux ; il est remplacé dès que le document est archivé.</li></ul></Section>
50 60
51 <Section id="limites" title="Limites"><ul className="list-disc pl-5 space-y-1"><li>Le swing est uniforme ; les dynamiques régionales spécifiques (Québec, Montréal, régions) ne sont captées que par un bruit régional, pas encore par les ventilations des sondages.</li><li>Les effets candidats se limitent au retrait du sortant ; notoriété locale et vedettes ne sont pas modélisées.</li><li>L&apos;erreur d&apos;industrie est calibrée sur deux élections seulement.</li><li>Le nombre de sondages par firme est faible pour plusieurs maisons : leurs effets maison ne sont pas estimés (aucune correction appliquée).</li><li>L&apos;extraction documentaire dépend d&apos;un modèle de langage ; les propositions à faible confiance sont mises en file de révision et non publiées.</li></ul><div className="mt-4"><Disclaimer /></div></Section>
61 + <Section id="limites" title="Limites"><ul className="list-disc pl-5 space-y-1"><li>Le swing est proportionnel, corrigé par langue et région ; les dynamiques locales ne sont captées que par le bruit régional, les partielles et le retrait des sortants.</li><li>Les marchés de prédiction sur le Québec sont minces (quelques centaines de milliers de dollars, marchés de tranches peu liquides) : leur poids est plafonné à 30 % et ils n&apos;entrent que par repondération.</li><li>Le prior fondamentaux repose sur une calibration indicative (sept scrutins) et disparaît le jour du vote ; le ton médiatique dépend d&apos;un classement automatique de titres, borné et affiché.</li><li>Les effets candidats se limitent au retrait du sortant ; notoriété locale et vedettes ne sont pas modélisées.</li><li>L&apos;erreur d&apos;industrie est calibrée sur deux élections seulement.</li><li>Le nombre de sondages par firme est faible pour plusieurs maisons : leurs effets maison ne sont pas estimés (aucune correction appliquée).</li><li>L&apos;extraction documentaire dépend d&apos;un modèle de langage ; les propositions à faible confiance sont mises en file de révision et non publiées.</li></ul><div className="mt-4"><Disclaimer /></div></Section>
52 62 </article>
53 63 </div>
54 64 );
modified web/src/app/page.tsx +54 −29
@@ -1,103 +1,128 @@
1 1 import Link from "next/link";
2 import { api, apiSafe, type Forecast, type Meta, type Party, type Poll, type RidingListItem } from "@/lib/api";
3 import { Battlegrounds, HeroForecast, LatestPolls, MajorityPanel, PopularVoteTable, RegionalGrid, SeatProjectionTable, WhatChanged } from "@/components/forecast-blocks";
2 +import { apiSafe, type Forecast, type Meta, type Party, type Poll, type RidingListItem, type SignalsPayload } from "@/lib/api";
3 +import { Battlegrounds, ComponentsCompare, HeroForecast, LatestPolls, MajorityPanel, PopularVoteTable, RegionalGrid, SeatProjectionTable, WhatChanged } from "@/components/forecast-blocks";
4 4 import { MapSection } from "@/components/map-section";
5 import { Legend, SeatMeter, TrendChart } from "@/components/charts";
5 +import { Legend, SeatMeter } from "@/components/charts";
6 +import { TrendChartInteractive } from "@/components/trend-chart";
7 +import { MarketsPanel, MediaPanel, SignalsOverview } from "@/components/signals";
6 8 import { PartyLogo } from "@/components/party";
7 9 import { Card, Empty, SectionHeader } from "@/components/ui";
8 import { PARTY_IDS, partyColor } from "@/lib/parties";
10 +import { PARTY_IDS, partyVar } from "@/lib/parties";
9 11 import { int, prob } from "@/lib/format";
10 12 import { LiveHome } from "@/components/live";
11 13
12 14 export const revalidate = 60;
13 15
14 16 export default async function Home() {
15 const [meta, f, polls, ridings, regions, parties] = await Promise.all([
17 + const [meta, f, polls, ridings, regions, parties, sig] = await Promise.all([
16 18 apiSafe<Meta>("/api/meta"),
17 19 apiSafe<Forecast>("/api/forecast/latest"),
18 20 apiSafe<{ polls: Poll[] }>("/api/polls?limit=8"),
19 21 apiSafe<{ ridings: RidingListItem[] }>("/api/ridings"),
20 22 apiSafe<{ regions: { id: string; name: string; ridings: number; favorites: Record<string, number>; voteNow: Record<string, number>; vote2022: Record<string, number> }[] }>("/api/regions"),
21 23 apiSafe<{ parties: Party[] }>("/api/parties"),
24 + apiSafe<SignalsPayload>("/api/signals"),
22 25 ]);
23 26 if (!meta || !f) return <Empty title="Le moteur QC26 est temporairement indisponible">Les projections seront de retour dans quelques instants.</Empty>;
24 27 if (meta.mode !== "forecast") return <LiveHome meta={meta} forecast={f} />;
25 28
26 29 const ridingNames = Object.fromEntries((ridings?.ridings ?? []).map((r) => [r.code, { name: r.name, slug: r.slug }]));
27 30 const seats = Object.fromEntries(Object.entries(f.parties).map(([k, v]) => [k, v.seatsMedian]));
28 const from = new Date(); from.setDate(from.getDate() - 180);
29 const pollPts = (polls?.polls ?? []).map((p) => ({ date: p.fieldworkEnd, values: p.results }));
31 + const from = new Date(); from.setDate(from.getDate() - 182);
32 + const pollPts = (polls?.polls ?? []).map((p) => ({ date: p.fieldworkEnd, values: p.results, pollster: p.pollster.name }));
33 + const comp = f.summary.components;
30 34
31 35 return (
32 <div className="space-y-14">
36 + <div className="space-y-16">
33 37 <HeroForecast f={f} meta={meta} />
34 38
39 + {comp && (
40 + <section>
41 + <SectionHeader num="02" eyebrow="Au-delà des sondages" title="Marchés, presse, attention, terrain" description="Depuis le modèle 2.0, quatre familles de signaux indépendants corrigent la moyenne des sondages, chacune avec un poids borné et publié. La vue « sondages seuls » reste toujours visible." action={<Link href="/signaux" className="link text-[13.5px]">Tous les signaux →</Link>} />
42 + <SignalsOverview comp={comp} />
43 + <Card className="mt-4"><ComponentsCompare f={f} /></Card>
44 + </section>
45 + )}
46 +
35 47 <section>
36 <SectionHeader eyebrow="02 · Sièges" title="Prévision des sièges" description="Médiane, intervalles de crédibilité et probabilités issus des simulations Monte Carlo. Le seuil de 64 sièges est dérivé des 127 circonscriptions officielles." action={<Link href="/prevision" className="link text-[13.5px]">Prévision complète →</Link>} />
48 + <SectionHeader num="03" eyebrow="Sièges" title="Prévision des sièges" description={`Médiane, intervalles de crédibilité et probabilités issus des simulations pondérées. Le seuil de ${meta.majority} sièges est dérivé des ${meta.seatsTotal} circonscriptions officielles.`} action={<Link href="/prevision" className="link text-[13.5px]">Prévision complète →</Link>} />
37 49 <Card><SeatMeter seats={seats} total={meta.seatsTotal} majority={meta.majority} className="mb-6" title="Sièges médians projetés" /><SeatProjectionTable f={f} /></Card>
38 50 </section>
39 51
40 52 <section className="grid lg:grid-cols-2 gap-6">
41 53 <div>
42 <SectionHeader eyebrow="03 · Vote populaire" title="Intentions de vote projetées" description="Vote au jour du scrutin (moyenne QC26 corrigée des effets maison, projetée au 5 octobre)." />
54 + <SectionHeader num="04" eyebrow="Vote populaire" title="Intentions de vote projetées" description="Vote au jour du scrutin (moyenne QC26 corrigée des effets maison, projetée au 5 octobre)." />
43 55 <Card><PopularVoteTable f={f} /></Card>
44 56 </div>
45 57 <div>
46 <SectionHeader eyebrow="04 · Majorité" title="Probabilité de majorité" description="Part des simulations où le parti obtient au moins 64 sièges." />
58 + <SectionHeader num="05" eyebrow="Majorité" title="Probabilité de majorité" description={`Part des simulations où le parti obtient au moins ${meta.majority} sièges.`} />
47 59 <Card><MajorityPanel f={f} /></Card>
48 60 </div>
49 61 </section>
50 62
51 63 <section>
52 <SectionHeader eyebrow="05 · Carte" title="Carte électorale" description="Favori par circonscription ; l'intensité de la couleur traduit la certitude, jamais un résultat acquis." action={<Link href="/carte" className="link text-[13.5px]">Explorer la carte →</Link>} />
64 + <SectionHeader num="06" eyebrow="Carte" title="Carte électorale" description="Favori par circonscription ; l'intensité de la couleur traduit la certitude, jamais un résultat acquis." action={<Link href="/carte" className="link text-[13.5px]">Explorer la carte →</Link>} />
53 65 <Card pad={false} className="p-3 md:p-4"><MapSection height={520} compact /></Card>
54 66 </section>
55 67
56 68 <section>
57 <SectionHeader eyebrow="06 · Sondages" title="Derniers sondages" description={`${f.pollCount} sondages depuis novembre 2022 alimentent la moyenne QC26.`} action={<Link href="/sondages" className="link text-[13.5px]">Tous les sondages →</Link>} />
69 + <SectionHeader num="07" eyebrow="Sondages" title="Tendance et derniers sondages" description={`${f.pollCount} sondages depuis novembre 2022 alimentent la moyenne QC26.`} action={<Link href="/sondages" className="link text-[13.5px]">Tous les sondages →</Link>} />
58 70 <Card pad={false}>
59 71 <div className="p-4 md:p-5 border-b border-border">
60 {f.trend && <TrendChart trend={f.trend} polls={pollPts} from={from.toISOString().slice(0, 10)} height={240} />}
72 + {f.trend && <TrendChartInteractive trend={f.trend} polls={pollPts} from={from.toISOString().slice(0, 10)} height={260} markers={[{ date: meta.campaignStart, label: "Déclenchement" }]} />}
61 73 <Legend ids={PARTY_IDS} className="mt-2" />
62 74 </div>
63 75 {polls && <LatestPolls polls={polls.polls} limit={6} compact />}
64 76 </Card>
65 77 </section>
66 78
79 + {sig && sig.markets.asOf && (
80 + <section className="grid lg:grid-cols-2 gap-6">
81 + <div>
82 + <SectionHeader num="08" eyebrow="Marchés de prédiction" title="Ce que parient les marchés" description="Polymarket, prix « Oui » renormalisés. Comparés à la vue « sondages seuls » et à l'ensemble ci-dessus." action={<Link href="/signaux#marches" className="link text-[13.5px]">Détail →</Link>} />
83 + <Card><MarketsPanel m={sig.markets} comp={comp} compact /></Card>
84 + </div>
85 + <div>
86 + <SectionHeader num="09" eyebrow="Presse" title="Le ton de la couverture" description={`${sig.media.articles} titres des ${sig.media.window} derniers jours, classés par parti et par ton.`} action={<Link href="/signaux#presse" className="link text-[13.5px]">Revue de presse →</Link>} />
87 + <Card><MediaPanel m={sig.media} compact /></Card>
88 + </div>
89 + </section>
90 + )}
91 +
67 92 <section>
68 <SectionHeader eyebrow="07 · Qu'est-ce qui a changé ?" title="Depuis le dernier recalcul" description="Différences entre les deux derniers runs publiés et les entrées qui les expliquent." />
93 + <SectionHeader num="10" eyebrow="Qu'est-ce qui a changé ?" title="Depuis le dernier recalcul" description="Différences entre les deux derniers runs publiés et les entrées qui les expliquent." />
69 94 <Card><WhatChanged f={f} ridingNames={ridingNames} /></Card>
70 95 </section>
71 96
72 97 {ridings && (
73 98 <section>
74 <SectionHeader eyebrow="08 · À surveiller" title="Circonscriptions les plus disputées" description="Classées par incertitude (1 − probabilité du favori)." action={<Link href="/circonscriptions?tri=serre" className="link text-[13.5px]">Toutes les circonscriptions →</Link>} />
99 + <SectionHeader num="11" eyebrow="À surveiller" title="Circonscriptions les plus disputées" description="Classées par incertitude (1 − probabilité du favori)." action={<Link href="/circonscriptions?tri=serre" className="link text-[13.5px]">Toutes les circonscriptions →</Link>} />
75 100 <Battlegrounds ridings={ridings.ridings} codes={f.summary.battlegrounds} limit={10} />
76 101 </section>
77 102 )}
78 103
79 104 {regions && (
80 105 <section>
81 <SectionHeader eyebrow="09 · Régions" title="Prévision régionale" description="Favoris et vote projeté par région, comparés au résultat 2022 transposé sur la carte 2026." />
106 + <SectionHeader num="12" eyebrow="Régions" title="Prévision régionale" description="Favoris et vote projeté par région, comparés au résultat 2022 transposé sur la carte 2026." />
82 107 <RegionalGrid regions={regions.regions} />
83 108 </section>
84 109 )}
85 110
86 111 <section className="grid lg:grid-cols-2 gap-6">
87 <div className="card card-lg p-6 md:p-8 bg-brand text-white flex flex-col">
88 <div className="eyebrow !text-[rgba(255,255,255,.6)]">10 · Boussole électorale</div>
89 <h2 className="text-[26px] font-bold tracking-tight mt-2 leading-tight">Quel parti correspond le mieux à vos positions ?</h2>
90 <p className="text-[14px] text-[rgba(255,255,255,.75)] mt-2 leading-relaxed">48 énoncés sur 6 axes. Chaque position de parti est tirée d&apos;un document officiel, avec page et citation : vous pourrez voir pourquoi.</p>
91 <Link href="/boussole" className="mt-auto pt-6 inline-flex items-center gap-2 self-start bg-white text-brand font-semibold rounded-[8px] px-4 py-2.5 text-[14px]">Commencer la boussole →</Link>
112 + <div className="card card-lg p-6 md:p-8 ink-panel flex flex-col">
113 + <div className="eyebrow" style={{ color: "color-mix(in srgb, var(--text-inverse) 60%, transparent)" }}>13 · Boussole électorale</div>
114 + <h2 className="display text-[30px] mt-3 leading-tight">Quel parti correspond le mieux à vos positions ?</h2>
115 + <p className="text-[14px] opacity-75 mt-3 leading-relaxed">48 énoncés sur 6 axes. Chaque position de parti est tirée d&apos;un document officiel, avec page et citation : vous pourrez voir pourquoi.</p>
116 + <Link href="/boussole" className="mt-auto pt-6 btn self-start" style={{ background: "var(--text-inverse)", color: "var(--text)" }}>Commencer la boussole →</Link>
92 117 </div>
93 118 <div>
94 <SectionHeader eyebrow="11 · Partis" title="Comparaison des partis" action={<Link href="/partis/comparateur" className="link text-[13.5px]">Comparateur →</Link>} />
119 + <SectionHeader num="14" eyebrow="Partis" title="Comparaison des partis" action={<Link href="/partis/comparateur" className="link text-[13.5px]">Comparateur →</Link>} />
95 120 <div className="grid grid-cols-1 gap-2">
96 121 {(parties?.parties ?? []).sort((a, b) => (b.forecast?.seatsMedian ?? 0) - (a.forecast?.seatsMedian ?? 0)).map((p) => (
97 <Link key={p.id} href={`/partis/${p.id}`} className="card p-3 flex items-center gap-4 hover:shadow-md transition-shadow">
122 + <Link key={p.id} href={`/partis/${p.id}`} className="card card-hover p-3 flex items-center gap-4">
98 123 <PartyLogo id={p.id} height={22} className="w-[92px] justify-start" />
99 124 <div className="min-w-0 flex-1"><div className="font-semibold text-[14px] truncate">{p.officialName}</div><div className="text-[12px] text-ink-3 truncate">{p.leader} · {p.candidatesCount} candidatures · {p.proposals} propositions documentées</div></div>
100 <div className="text-right num shrink-0"><div className="font-bold text-[18px]" style={{ color: partyColor(p.id) }}>{p.forecast?.seatsMedian ?? "—"}</div><div className="text-[11px] text-ink-3">P(maj) {prob(p.forecast?.pMajority)}</div></div>
125 + <div className="text-right num shrink-0"><div className="figure text-[22px]" style={{ color: partyVar(p.id) }}>{p.forecast?.seatsMedian ?? "—"}</div><div className="text-[11px] text-ink-3">P(maj) {prob(p.forecast?.pMajority)}</div></div>
101 126 </Link>
102 127 ))}
103 128 </div>
@@ -106,11 +131,11 @@ export default async function Home() {
106 131
107 132 <section className="card card-lg p-6 md:p-8 grid md:grid-cols-[1fr_auto] gap-6 items-center">
108 133 <div>
109 <div className="eyebrow">12 · Méthodologie</div>
110 <h2 className="text-[22px] font-bold tracking-tight mt-1">Comment QC26 calcule</h2>
111 <p className="text-[14px] text-ink-2 mt-2 leading-relaxed max-w-2xl">Sondages pondérés et corrigés des effets maison → état latent du vote (filtre de Kalman) → swing proportionnel sur les résultats 2022 transposés → {int(f.nSimulations)} simulations à erreurs corrélées. Chaque run est versionné (modèle {f.modelVersion}, run #{f.id}{f.codeCommit ? `, commit ${f.codeCommit}` : ""}).</p>
134 + <div className="eyebrow">15 · Méthodologie</div>
135 + <h2 className="display text-[26px] mt-2">Comment QC26 calcule</h2>
136 + <p className="text-[14px] text-ink-2 mt-2 leading-relaxed max-w-2xl">Sondages pondérés et corrigés des effets maison → état latent du vote (filtre de Kalman) → prior fondamentaux et momentum bornés → swing proportionnel sur les résultats 2022 transposés → {int(f.nSimulations)} simulations à erreurs corrélées, repondérées vers les marchés. Chaque run est versionné (modèle {f.modelVersion}, run #{f.id}{f.codeCommit ? `, commit ${f.codeCommit}` : ""}).</p>
112 137 </div>
113 <div className="flex gap-3"><Link href="/methodologie" className="inline-flex items-center rounded-[8px] bg-brand text-white px-4 py-2.5 text-[14px] font-semibold">Méthodologie</Link><Link href="/methodologie/changelog" className="inline-flex items-center rounded-[8px] border border-border px-4 py-2.5 text-[14px] font-semibold">Changelog</Link></div>
138 + <div className="flex gap-3"><Link href="/methodologie" className="btn btn-primary">Méthodologie</Link><Link href="/methodologie/changelog" className="btn btn-ghost">Changelog</Link></div>
114 139 </section>
115 140 </div>
116 141 );
modified web/src/app/partis/[id]/page.tsx +2 −2
@@ -6,7 +6,7 @@ import { SeatDistribution } from "@/components/charts";
6 6 import { PartyLogo } from "@/components/party";
7 7 import { Badge, Card, Empty, KV, SectionHeader } from "@/components/ui";
8 8 import { dateFr, int, pct, prob } from "@/lib/format";
9 import { PARTIES, partyColor, partyLabel } from "@/lib/parties";
9 +import { PARTIES, partyLabel } from "@/lib/parties";
10 10
11 11 export const revalidate = 120;
12 12 export function generateStaticParams() { return PARTIES.map((p) => ({ id: p.id })); }
@@ -42,7 +42,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
42 42 </div>
43 43 )}
44 44 </div>
45 {f?.seatDistribution && <div className="mt-6"><SeatDistribution dist={f.seatDistribution} color={d.color} majority={meta.majority} median={f.seatsMedian} lo80={f.seats80[0]} hi80={f.seats80[1]} lo95={f.seats95[0]} hi95={f.seats95[1]} height={90} total={meta.seatsTotal} /></div>}
45 + {f?.seatDistribution && <div className="mt-6 max-w-[760px]"><SeatDistribution dist={f.seatDistribution} color={d.color} majority={meta.majority} median={f.seatsMedian} lo80={f.seats80[0]} hi80={f.seats80[1]} lo95={f.seats95[0]} hi95={f.seats95[1]} height={90} total={meta.seatsTotal} /></div>}
46 46 </div>
47 47
48 48 <section className="grid lg:grid-cols-3 gap-6">
modified web/src/app/partis/comparateur/page.tsx +3 −3
@@ -3,7 +3,7 @@ import Link from "next/link";
3 3 import { apiSafe, type Meta } from "@/lib/api";
4 4 import { PartyLogo } from "@/components/party";
5 5 import { Card, Empty } from "@/components/ui";
6 import { PARTY_IDS, partyColor, partyLabel } from "@/lib/parties";
6 +import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";
7 7
8 8 export const metadata: Metadata = { title: "Comparateur des partis — matrice enjeux × partis", description: "Ce que proposent la CAQ, le PQ, le PLQ, QS et le PCQ sur chaque enjeu, d'après leurs documents officiels ; chaque cellule renvoie à la source." };
9 9 export const revalidate = 300;
@@ -15,7 +15,7 @@ export default async function Page() {
15 15 if (!m || !meta) return <Empty title="Comparateur indisponible" />;
16 16 return (
17 17 <div className="space-y-6">
18 <div><div className="eyebrow">Policy Matrix · documents officiels</div><h1 className="display text-[36px] md:text-[48px] mt-2">Comparateur des partis</h1><p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Une cellule = la proposition la mieux documentée du parti sur l&apos;enjeu (cliquez pour la source, la page et la citation). Une case vide signifie qu&apos;aucun document officiel ingéré n&apos;aborde le sujet, pas que le parti n&apos;a pas de position.</p></div>
18 + <div><div className="eyebrow eyebrow-accent">Policy Matrix · documents officiels</div><h1 className="display text-[40px] md:text-[56px] mt-3">Comparateur des partis</h1><p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Une cellule = la proposition la mieux documentée du parti sur l&apos;enjeu (cliquez pour la source, la page et la citation). Une case vide signifie qu&apos;aucun document officiel ingéré n&apos;aborde le sujet, pas que le parti n&apos;a pas de position.</p></div>
19 19 {m.topics.length === 0 ? <Empty title="Extraction en cours">Les documents officiels des partis sont en cours d&apos;analyse ; la matrice se remplit au fil des heures.</Empty> : (
20 20 <Card pad={false} className="overflow-x-auto">
21 21 <table className="data-table min-w-[900px]">
@@ -25,7 +25,7 @@ export default async function Page() {
25 25 <tr key={t.id}>
26 26 <td className="font-semibold align-top"><Link href={`/enjeux/${t.id}`} className="hover:underline">{t.label}</Link></td>
27 27 {PARTY_IDS.map((id) => { const cell = m.top[`${t.id}:${id}`]; return (
28 <td key={id} className="align-top text-[12.5px] leading-snug" style={{ borderLeft: `3px solid ${cell ? partyColor(id) : "transparent"}` }}>
28 + <td key={id} className="align-top text-[12.5px] leading-snug" style={{ borderLeft: `3px solid ${cell ? partyVar(id) : "transparent"}` }}>
29 29 {cell ? <Link href={`/documents/${cell.documentId}#p${cell.id}`} className="hover:underline">{cell.proposal.length > 150 ? cell.proposal.slice(0, 150) + "…" : cell.proposal}<span className="text-ink-3"> {cell.page ? `· p. ${cell.page}` : ""} · {t.counts[id] ?? 0} prop.</span></Link> : <span className="text-ink-3">—</span>}
30 30 </td>); })}
31 31 </tr>
modified web/src/app/partis/page.tsx +3 −3
@@ -4,7 +4,7 @@ import { apiSafe, type Party } from "@/lib/api";
4 4 import { PartyLogo } from "@/components/party";
5 5 import { Empty } from "@/components/ui";
6 6 import { dateFr, int, pct, prob } from "@/lib/format";
7 import { partyColor } from "@/lib/parties";
7 +import { partyVar } from "@/lib/parties";
8 8
9 9 export const metadata: Metadata = { title: "Partis — chefs, candidatures, projections, documents", description: "Les cinq principaux partis de l'élection québécoise 2026 : chef·fe (registre DGEQ), candidatures officielles, sièges projetés, documents et propositions." };
10 10 export const revalidate = 120;
@@ -15,7 +15,7 @@ export default async function Page() {
15 15 return (
16 16 <div className="space-y-6">
17 17 <div className="flex items-end justify-between flex-wrap gap-3">
18 <div><div className="eyebrow">Partis · registre officiel des partis autorisés (REPAQ)</div><h1 className="display text-[36px] md:text-[48px] mt-2">Party Explorer</h1></div>
18 + <div><div className="eyebrow eyebrow-accent">Partis · registre officiel des partis autorisés (REPAQ)</div><h1 className="display text-[40px] md:text-[56px] mt-3">Party Explorer</h1></div>
19 19 <Link href="/partis/comparateur" className="inline-flex items-center rounded-[8px] bg-brand text-white px-4 py-2.5 text-[14px] font-semibold">Comparateur des positions →</Link>
20 20 </div>
21 21 <div className="grid md:grid-cols-2 xl:grid-cols-3 gap-4">
@@ -25,7 +25,7 @@ export default async function Page() {
25 25 <div className="font-bold text-[18px] mt-3 leading-tight">{p.officialName}</div>
26 26 <div className="text-[13px] text-ink-2 mt-1">{p.leader ? <>Chef·fe : <b className="text-ink">{p.leader}</b>{p.leaderSince ? <span className="text-ink-3"> · depuis {dateFr(p.leaderSince, { month: "long", year: "numeric" })}</span> : null}</> : "—"}</div>
27 27 <div className="grid grid-cols-3 gap-3 mt-4 text-center">
28 <div className="surface-2 rounded-[8px] p-2.5"><div className="text-[11px] text-ink-3">Sièges projetés</div><div className="num font-bold text-[22px]" style={{ color: partyColor(p.id) }}>{p.forecast?.seatsMedian ?? "—"}</div><div className="text-[11px] text-ink-3 num">{p.forecast ? `${p.forecast.seats80[0]}–${p.forecast.seats80[1]}` : ""}</div></div>
28 + <div className="surface-2 rounded-[8px] p-2.5"><div className="text-[11px] text-ink-3">Sièges projetés</div><div className="num font-bold text-[22px]" style={{ color: partyVar(p.id) }}>{p.forecast?.seatsMedian ?? "—"}</div><div className="text-[11px] text-ink-3 num">{p.forecast ? `${p.forecast.seats80[0]}–${p.forecast.seats80[1]}` : ""}</div></div>
29 29 <div className="surface-2 rounded-[8px] p-2.5"><div className="text-[11px] text-ink-3">Vote projeté</div><div className="num font-bold text-[22px]">{p.forecast ? pct(p.forecast.voteMean, 1, "") : "—"}<span className="text-[13px]"> %</span></div><div className="text-[11px] text-ink-3 num">2022 : {pct(p.vote2022, 1)}</div></div>
30 30 <div className="surface-2 rounded-[8px] p-2.5"><div className="text-[11px] text-ink-3">P(majorité)</div><div className="num font-bold text-[22px]">{prob(p.forecast?.pMajority)}</div><div className="text-[11px] text-ink-3 num">2022 : {p.seats2022} sièges</div></div>
31 31 </div>
modified web/src/app/plus/page.tsx +3 −3
@@ -5,7 +5,7 @@ import { Card } from "@/components/ui";
5 5 export const metadata: Metadata = { title: "Plus", robots: { index: false } };
6 6
7 7 const GROUPS: [string, [string, string, string][]][] = [
8 ["Données", [["/prevision", "Prévision", "Sièges, vote, probabilités, historique"], ["/sondages", "Sondages", "Moyenne QC26, Poll Inspector, effets maison"], ["/circonscriptions", "Circonscriptions", "127 fiches, recherche instantanée"], ["/live", "Soirée électorale", "Direct, forecast vs live, nowcast"], ["/replay", "Replay", "Rejouer la soirée"]]],
8 + ["Données", [["/prevision", "Prévision", "Sièges, vote, probabilités, historique"], ["/signaux", "Au-delà des sondages", "Marchés, presse, attention, fondamentaux, partielles"], ["/sondages", "Sondages", "Moyenne QC26, Poll Inspector, effets maison"], ["/circonscriptions", "Circonscriptions", "127 fiches, recherche instantanée"], ["/live", "Soirée électorale", "Direct, forecast vs live, nowcast"], ["/replay", "Replay", "Rejouer la soirée"]]],
9 9 ["Partis", [["/partis", "Partis", "Chefs, candidatures, projections"], ["/partis/comparateur", "Comparateur", "Matrice enjeux × partis"], ["/enjeux", "Enjeux", "Propositions par sujet, recherche documentaire"], ["/documents", "Bibliothèque", "Documents officiels versionnés"]]],
10 10 ["Transparence", [["/methodologie", "Méthodologie", "Comment QC26 calcule"], ["/methodologie/changelog", "Changelog", "Versions du modèle et runs"], ["/statut", "Statut des données", "Connecteurs, sources, travaux"], ["/live/model-vs-reality", "Modèle vs réalité", "Précision le soir du vote"]]],
11 11 ];
@@ -13,8 +13,8 @@ const GROUPS: [string, [string, string, string][]][] = [
13 13 export default function Page() {
14 14 return (
15 15 <div className="space-y-8">
16 <h1 className="display text-[32px]">Plus</h1>
17 {GROUPS.map(([g, items]) => (<section key={g}><div className="eyebrow mb-2">{g}</div><div className="grid gap-2">{items.map(([href, t, d]) => <Link key={href} href={href}><Card className="!p-3.5 hover:bg-surface-2"><div className="font-semibold text-[15px]">{t}</div><div className="text-[12.5px] text-ink-2">{d}</div></Card></Link>)}</div></section>))}
16 + <h1 className="display text-[36px]">Plus</h1>
17 + {GROUPS.map(([g, items]) => (<section key={g}><div className="eyebrow mb-2">{g}</div><div className="grid gap-2">{items.map(([href, t, d]) => <Link key={href} href={href}><Card className="!p-3.5 card-hover"><div className="font-semibold text-[15px]">{t}</div><div className="text-[12.5px] text-ink-2">{d}</div></Card></Link>)}</div></section>))}
18 18 </div>
19 19 );
20 20 }
modified web/src/app/prevision/page.tsx +58 −20
@@ -1,15 +1,17 @@
1 1 import type { Metadata } from "next";
2 2 import Link from "next/link";
3 3 import { apiSafe, type Forecast, type Meta, type RidingListItem } from "@/lib/api";
4 import { MajorityPanel, PopularVoteTable, SeatProjectionTable, WhatChanged } from "@/components/forecast-blocks";
5 import { Legend, SeatDistribution, SeatMeter, TrendChart } from "@/components/charts";
4 +import { ComponentsCompare, MajorityPanel, PopularVoteTable, SeatProjectionTable, WhatChanged } from "@/components/forecast-blocks";
5 +import { Legend, SeatDistribution, SeatMeter, SeatWaffle } from "@/components/charts";
6 +import { TrendChartInteractive } from "@/components/trend-chart";
7 +import { SignalsOverview } from "@/components/signals";
6 8 import { PartyLogo } from "@/components/party";
7 import { Badge, Card, Empty, KV, SectionHeader } from "@/components/ui";
9 +import { Badge, Card, Empty, KV, PageTitle, SectionHeader } from "@/components/ui";
8 10 import { ForecastHistory } from "@/components/forecast-history";
9 11 import { dateFr, dateTimeFr, int, prob } from "@/lib/format";
10 import { PARTY_IDS, partyColor, partyLabel, sortParties } from "@/lib/parties";
12 +import { PARTY_IDS, partyLabel, partyVar, sortParties } from "@/lib/parties";
11 13
12 export const metadata: Metadata = { title: "Prévision — sièges, vote populaire et probabilités", description: "Projection QC26 de l'élection québécoise 2026 : sièges par parti, intervalles, probabilité de majorité, historique du modèle." };
14 +export const metadata: Metadata = { title: "Prévision — sièges, vote populaire et probabilités", description: "Projection QC26 de l'élection québécoise 2026 : sièges par parti, intervalles, probabilité de majorité, composantes (sondages, marchés, fondamentaux, momentum), historique du modèle." };
13 15 export const revalidate = 60;
14 16
15 17 export default async function Page() {
@@ -18,22 +20,57 @@ export default async function Page() {
18 20 const seats = Object.fromEntries(Object.entries(f.parties).map(([k, v]) => [k, v.seatsMedian]));
19 21 const ridingNames = Object.fromEntries((ridings?.ridings ?? []).map((r) => [r.code, { name: r.name, slug: r.slug }]));
20 22 const rows = sortParties(f.parties, (v) => v.seatsMean);
23 + const comp = f.summary.components;
24 + const leader = f.summary.leader;
21 25 return (
22 <div className="space-y-12">
23 <div>
24 <div className="eyebrow">Prévision · Modèle {f.modelVersion} · run #{f.id}</div>
25 <h1 className="display text-[36px] md:text-[48px] mt-2">Prévision QC26</h1>
26 <p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Projection probabiliste de l&apos;Assemblée nationale au 5 octobre 2026, recalculée à chaque nouveau sondage. Données arrêtées au {dateFr(f.dataCutoff)} · {f.pollCount} sondages · {int(f.nSimulations)} simulations · mise à jour {dateTimeFr(f.completedAt)}.</p>
27 </div>
28 <Card><SeatMeter seats={seats} total={meta.seatsTotal} majority={meta.majority} className="mb-6" /><SeatProjectionTable f={f} /></Card>
26 + <div className="space-y-14">
27 + <PageTitle eyebrow={`Prévision · modèle ${f.modelVersion} · run #${f.id}`} title="Prévision QC26"
28 + lede={<>Projection probabiliste de l&apos;Assemblée nationale au 5 octobre 2026, recalculée à chaque nouveau sondage et à chaque mise à jour des signaux. Données arrêtées au {dateFr(f.dataCutoff)} · {f.pollCount} sondages · {int(f.nSimulations)} simulations · mise à jour {dateTimeFr(f.completedAt)}.</>} />
29 +
30 + <section className="grid lg:grid-cols-[1fr_1.2fr] gap-6 items-start">
31 + <Card className="card-lg">
32 + <div className="eyebrow mb-3">Assemblée nationale · médiane</div>
33 + <SeatWaffle seats={seats} total={meta.seatsTotal} majority={meta.majority} cols={16} />
34 + <div className="mt-4 grid grid-cols-3 gap-3 text-center">
35 + {rows.slice(0, 3).map(([id, v]) => (<div key={id} className="inset p-2.5"><div className="text-[11px] text-ink-3">P(majorité)</div><div className="figure text-[24px]" style={{ color: partyVar(id) }}>{prob(v.pMajority)}</div><div className="text-[11px] font-semibold">{partyLabel(id)}</div></div>))}
36 + </div>
37 + </Card>
38 + <Card><SeatMeter seats={seats} total={meta.seatsTotal} majority={meta.majority} className="mb-6" /><SeatProjectionTable f={f} /></Card>
39 + </section>
40 +
41 + {comp && (
42 + <section>
43 + <SectionHeader eyebrow="Composantes" title="Sondages seuls, marchés, ensemble" description="Le modèle 2.0 publie chaque composante. Les points creux sont la vue « sondages seuls » (v1) ; les losanges, les marchés de prédiction ; les points pleins, l'ensemble QC26." action={<Link href="/signaux" className="link text-[13.5px]">Détail des signaux →</Link>} />
44 + <SignalsOverview comp={comp} className="mb-4" />
45 + <Card><ComponentsCompare f={f} /></Card>
46 + <div className="grid md:grid-cols-3 gap-3 mt-4">
47 + {(["pollsOnly", "modelNoMarkets"] as const).map((k) => {
48 + const c = comp[k];
49 + return (
50 + <Card key={k}>
51 + <div className="eyebrow mb-2">{k === "pollsOnly" ? "Sondages seuls" : "Modèle sans marchés"}</div>
52 + <div className="space-y-1.5 text-[13px]">{PARTY_IDS.map((id) => <div key={id} className="flex items-center justify-between"><span className="font-semibold inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span><span className="mono"><b>{c.parties[id]?.seats}</b> <span className="text-ink-3">{c.parties[id]?.seats80?.join("–")}</span> · {prob(c.parties[id]?.p_majority)}</span></div>)}</div>
53 + <div className="text-[11.5px] text-ink-3 mt-2">minoritaire {prob(c.p_minority)}</div>
54 + </Card>
55 + );
56 + })}
57 + <Card>
58 + <div className="eyebrow mb-2">Ensemble QC26</div>
59 + <div className="space-y-1.5 text-[13px]">{PARTY_IDS.map((id) => <div key={id} className="flex items-center justify-between"><span className="font-semibold inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span><span className="mono"><b>{f.parties[id]?.seatsMedian}</b> <span className="text-ink-3">{f.parties[id]?.seats80.join("–")}</span> · {prob(f.parties[id]?.pMajority)}</span></div>)}</div>
60 + <div className="text-[11.5px] text-ink-3 mt-2">minoritaire {prob(f.summary.p_minority)} · ESS {int(comp.ess)} / {int(comp.nSimulations)}</div>
61 + </Card>
62 + </div>
63 + </section>
64 + )}
65 +
29 66 <section>
30 <SectionHeader eyebrow="Distributions" title="Incertitude sur les sièges" description="Histogrammes des simulations. La ligne pointillée marque 64 sièges ; les bandes, les intervalles 80 % et 95 %." />
67 + <SectionHeader eyebrow="Distributions" title="Incertitude sur les sièges" description={`Histogrammes pondérés des simulations. La ligne marque ${meta.majority} sièges ; les bandes, les intervalles 80 % et 95 %.`} />
31 68 <div className="grid md:grid-cols-2 gap-4">
32 69 {rows.map(([id, v]) => (
33 70 <Card key={id}>
34 <div className="flex items-center justify-between mb-2"><span className="inline-flex items-center gap-2"><PartyLogo id={id} height={18} /><b>{partyLabel(id)}</b></span><span className="num text-[13px] text-ink-2">médiane <b className="text-ink">{v.seatsMedian}</b> · 80 % {v.seats80[0]}–{v.seats80[1]} · 95 % {v.seats95[0]}–{v.seats95[1]}</span></div>
35 {v.seatDistribution && <SeatDistribution dist={v.seatDistribution} color={partyColor(id)} majority={meta.majority} median={v.seatsMedian} lo80={v.seats80[0]} hi80={v.seats80[1]} lo95={v.seats95[0]} hi95={v.seats95[1]} height={110} total={meta.seatsTotal} />}
36 <div className="grid grid-cols-3 gap-2 mt-2 text-center text-[12.5px]"><div className="surface-2 rounded-[6px] p-2"><div className="text-ink-3">Majorité</div><b className="num">{prob(v.pMajority)}</b></div><div className="surface-2 rounded-[6px] p-2"><div className="text-ink-3">Pluralité</div><b className="num">{prob(v.pMostSeats)}</b></div><div className="surface-2 rounded-[6px] p-2"><div className="text-ink-3">Vote</div><b className="num">{v.voteMean.toFixed(1).replace(".", ",")} % ± {v.voteSd.toFixed(1).replace(".", ",")}</b></div></div>
71 + <div className="flex items-center justify-between mb-2"><span className="inline-flex items-center gap-2"><PartyLogo id={id} height={18} /><b>{partyLabel(id)}</b></span><span className="num text-[13px] text-ink-2">médiane <b className="text-ink mono">{v.seatsMedian}</b> · 80 % <span className="mono">{v.seats80[0]}–{v.seats80[1]}</span> · 95 % <span className="mono">{v.seats95[0]}–{v.seats95[1]}</span></span></div>
72 + {v.seatDistribution && <SeatDistribution dist={v.seatDistribution} color={partyVar(id)} majority={meta.majority} median={v.seatsMedian} lo80={v.seats80[0]} hi80={v.seats80[1]} lo95={v.seats95[0]} hi95={v.seats95[1]} height={110} total={meta.seatsTotal} />}
73 + <div className="grid grid-cols-3 gap-2 mt-2 text-center text-[12.5px]"><div className="inset p-2"><div className="text-ink-3">Majorité</div><b className="num">{prob(v.pMajority)}</b></div><div className="inset p-2"><div className="text-ink-3">Pluralité</div><b className="num">{prob(v.pMostSeats)}</b></div><div className="inset p-2"><div className="text-ink-3">Vote</div><b className="num">{v.voteMean.toFixed(1).replace(".", ",")} % ± {v.voteSd.toFixed(1).replace(".", ",")}</b></div></div>
37 74 </Card>
38 75 ))}
39 76 </div>
@@ -43,8 +80,8 @@ export default async function Page() {
43 80 <div><SectionHeader eyebrow="Majorité" title="Probabilités" /><Card><MajorityPanel f={f} /></Card></div>
44 81 </section>
45 82 <section>
46 <SectionHeader eyebrow="Tendance" title="État latent du vote depuis 2022" description="Moyenne QC26 (filtre de Kalman, effets maison corrigés) avec bande à 80 %. Les traits à gauche marquent le résultat de 2022." />
47 <Card>{f.trend && <TrendChart trend={f.trend} height={320} election2022={{ caq: 40.98, plq: 14.37, qs: 15.43, pq: 14.61, pcq: 12.91 }} markers={[{ date: meta.campaignStart, label: "Déclenchement" }]} />}<Legend ids={PARTY_IDS} className="mt-2" /></Card>
83 + <SectionHeader eyebrow="Tendance" title="État latent du vote depuis 2022" description="Moyenne QC26 (filtre de Kalman, effets maison corrigés) avec bande à 80 %. Les traits à gauche marquent le résultat de 2022. Survolez pour lire une date." />
84 + <Card>{f.trend && <TrendChartInteractive trend={f.trend} height={320} election2022={{ caq: 40.98, plq: 14.37, qs: 15.43, pq: 14.61, pcq: 12.91 }} markers={[{ date: meta.campaignStart, label: "Déclenchement" }]} />}<Legend ids={PARTY_IDS} className="mt-2" /></Card>
48 85 </section>
49 86 <section>
50 87 <SectionHeader eyebrow="Historique" title="Revoir QC26 à n'importe quelle date" description="Chaque run publié est conservé avec ses paramètres. Déplacez le curseur pour revoir la projection d'un jour donné." />
@@ -57,14 +94,15 @@ export default async function Page() {
57 94 <section className="grid md:grid-cols-2 gap-6">
58 95 <Card>
59 96 <div className="eyebrow mb-3">Fiche du run</div>
60 <KV items={[["Run", `#${f.id}`], ["Modèle", f.modelVersion], ["Déclencheur", f.trigger ?? "—"], ["Données arrêtées", dateFr(f.dataCutoff)], ["Terminé", dateTimeFr(f.completedAt)], ["Sondages", String(f.pollCount)], ["Simulations", int(f.nSimulations)], ["Graine", String(f.seed)], ["Commit", f.codeCommit ?? "—"]]} />
97 + <KV items={[["Run", `#${f.id}`], ["Modèle", f.modelVersion], ["Déclencheur", f.trigger ?? "—"], ["Données arrêtées", dateFr(f.dataCutoff)], ["Terminé", dateTimeFr(f.completedAt)], ["Sondages", String(f.pollCount)], ["Simulations", int(f.nSimulations)], ["ESS (repondération)", comp ? int(comp.ess) : "—"], ["Graine", String(f.seed)], ["Commit", f.codeCommit ?? "—"]]} />
61 98 </Card>
62 99 <Card>
63 100 <div className="eyebrow mb-3">Lire la prévision</div>
64 101 <ul className="text-[13.5px] text-ink-2 space-y-2 leading-relaxed list-disc pl-4">
65 102 <li>Une projection n&apos;est pas une prédiction : un parti à 70 % de chances de majorité perd cette majorité dans 3 simulations sur 10.</li>
66 103 <li>Les intervalles 80 % couvrent le résultat 8 fois sur 10 si le modèle est bien calibré ; les erreurs des sondages sont corrélées et c&apos;est intégré (σ industrie ≈ 2,2 pt).</li>
67 <li>Chaque chiffre est audité : <Link href="/sondages" className="link">sondages</Link>, <Link href="/methodologie" className="link">méthodologie</Link>, <Link href="/methodologie/changelog" className="link">changelog</Link>.</li>
104 + <li>L&apos;ensemble QC26 ({partyLabel(leader)} favori) tempère les sondages par les marchés, les fondamentaux et le momentum ; la vue « sondages seuls » reste publiée ci-dessus pour comparaison.</li>
105 + <li>Chaque chiffre est audité : <Link href="/sondages" className="link">sondages</Link>, <Link href="/signaux" className="link">signaux</Link>, <Link href="/methodologie" className="link">méthodologie</Link>, <Link href="/methodologie/changelog" className="link">changelog</Link>.</li>
68 106 </ul>
69 107 <div className="mt-3"><Badge tone="accent">Projection QC26</Badge></div>
70 108 </Card>
modified web/src/app/replay/page.tsx +1 −1
@@ -7,7 +7,7 @@ export const revalidate = 0;
7 7 export default function Page() {
8 8 return (
9 9 <div className="space-y-6">
10 <div><div className="eyebrow">Election Replay</div><h1 className="display text-[36px] md:text-[48px] mt-2">Rejouer la soirée</h1><p className="text-ink-2 mt-3 max-w-2xl text-[15px]">Chaque snapshot officiel reçu le soir du vote est conservé. Déplacez le curseur : la carte, les sièges et le nowcast reviennent à l&apos;état exact de ce moment.</p></div>
10 + <div><div className="eyebrow eyebrow-accent">Election Replay</div><h1 className="display text-[40px] md:text-[56px] mt-3">Rejouer la soirée</h1><p className="text-ink-2 mt-3 max-w-2xl text-[15px]">Chaque snapshot officiel reçu le soir du vote est conservé. Déplacez le curseur : la carte, les sièges et le nowcast reviennent à l&apos;état exact de ce moment.</p></div>
11 11 <Replay />
12 12 </div>
13 13 );
added web/src/app/signaux/page.tsx +69 −0
@@ -0,0 +1,69 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { apiSafe, type Forecast, type Meta, type SignalsPayload } from "@/lib/api";
4 +import { AttentionPanel, ByElectionsPanel, FundamentalsPanel, MarketsPanel, MediaPanel, MomentumPanel, SignalsOverview } from "@/components/signals";
5 +import { ComponentsCompare } from "@/components/forecast-blocks";
6 +import { Card, Empty, PageTitle, SectionHeader } from "@/components/ui";
7 +import { dateTimeFr } from "@/lib/format";
8 +
9 +export const metadata: Metadata = { title: "Au-delà des sondages — marchés, presse, attention, fondamentaux, partielles", description: "Les signaux alternatifs du modèle QC26 : marchés de prédiction Polymarket, revue de presse classée, attention Wikipédia, satisfaction et leadership, élections partielles depuis 2022 — et leur poids exact dans la prévision." };
10 +export const revalidate = 120;
11 +
12 +export default async function Page() {
13 + const [sig, f, meta] = await Promise.all([apiSafe<SignalsPayload>("/api/signals"), apiSafe<Forecast>("/api/forecast/latest"), apiSafe<Meta>("/api/meta")]);
14 + if (!sig || !f || !meta) return <Empty title="Signaux indisponibles" />;
15 + const comp = f.summary.components ?? sig.components;
16 + return (
17 + <div className="space-y-14">
18 + <PageTitle eyebrow="Au-delà des sondages · modèle 2.0" title={<>Ce que disent les marchés, la presse, l&apos;attention et le terrain.</>}
19 + lede={<>Depuis la version 2.0, QC26 combine les sondages à quatre familles de signaux indépendants. Chacun est archivé avec sa provenance, publié séparément et pesé de façon explicite : cette page montre les données brutes <i>et</i> l&apos;effet exact de chaque composante sur la prévision du {dateTimeFr(f.completedAt)}.</>} />
20 +
21 + {comp && (
22 + <section>
23 + <SectionHeader num="01" eyebrow="Composantes" title="Cinq entrées, une prévision" description="Le point de départ reste la moyenne des sondages. Les autres signaux la corrigent à la marge, chacun avec un poids borné et documenté." />
24 + <SignalsOverview comp={comp} />
25 + <Card className="mt-4"><ComponentsCompare f={f} /></Card>
26 + </section>
27 + )}
28 +
29 + <section id="marches" className="scroll-mt-24">
30 + <SectionHeader num="02" eyebrow="Marchés de prédiction" title="Polymarket" description="Des participants misent de l'argent réel sur le vainqueur, la majorité et les tranches de sièges. Le prix « Oui » est une probabilité implicite. QC26 repondère ses simulations vers 70 % modèle + 30 % marché, sans jamais casser la cohérence sièges ↔ circonscriptions." />
31 + <Card><MarketsPanel m={sig.markets} comp={comp} /></Card>
32 + </section>
33 +
34 + <section id="fondamentaux" className="scroll-mt-24">
35 + <SectionHeader num="03" eyebrow="Fondamentaux" title="Satisfaction, leadership, souveraineté" description="Les rapports des firmes contiennent bien plus que des intentions de vote. QC26 en extrait les questions structurantes, page citée, et en tire un prior sur la part du gouvernement sortant." />
36 + <Card><FundamentalsPanel f={sig.fundamentals} comp={comp} /></Card>
37 + </section>
38 +
39 + <section id="momentum" className="scroll-mt-24">
40 + <SectionHeader num="04" eyebrow="Momentum" title="Chef, attention, ton médiatique" description="Trois indicateurs de dynamique convertis en un déplacement borné (± 1 pt, somme nulle) de la moyenne du vote au jour du scrutin." />
41 + {comp && <Card><MomentumPanel comp={comp} /></Card>}
42 + </section>
43 +
44 + <section id="presse" className="scroll-mt-24">
45 + <SectionHeader num="05" eyebrow="Presse" title="Revue de presse classée" description="Titres des médias québécois des dernières 48 h par parti (Google Actualités), classés par un modèle de langage : partis concernés, ton −1…+1, sujet. Chaque titre reste consultable ; le ton n'entre que dans le momentum." />
46 + <Card><MediaPanel m={sig.media} /></Card>
47 + </section>
48 +
49 + <section id="attention" className="scroll-mt-24">
50 + <SectionHeader num="06" eyebrow="Attention publique" title="Pages vues Wikipédia" description="Combien de personnes consultent la page d'un parti ou de sa cheffe ou son chef ? Un indicateur de saillance, pas d'appui : il n'entre que par sa variation (7 j vs 28 j), avec un coefficient très faible." />
51 + <Card><AttentionPanel a={sig.attention} /></Card>
52 + </section>
53 +
54 + <section id="partielles" className="scroll-mt-24">
55 + <SectionHeader num="07" eyebrow="Terrain" title="Élections partielles depuis 2022" description="Cinq scrutins réels, aux limites de 2022, comparés au résultat général précédent. Le modèle en retient l'excès de swing local, net du swing national du jour, pour la circonscription concernée." />
56 + <Card><ByElectionsPanel b={sig.byElections} comp={comp} /></Card>
57 + </section>
58 +
59 + <section className="card card-lg p-6 md:p-8 grid md:grid-cols-[1fr_auto] gap-6 items-center">
60 + <div>
61 + <div className="eyebrow">Lire ces signaux</div>
62 + <h2 className="display text-[24px] mt-2">Des indices, pas des verdicts</h2>
63 + <p className="text-[14px] text-ink-2 mt-2 leading-relaxed max-w-2xl">Aucun de ces signaux ne remplace un sondage. Les marchés peuvent être minces, la presse suit l&apos;actualité plutôt que l&apos;opinion, l&apos;attention mesure la curiosité, les partielles ont une participation faible. C&apos;est pourquoi chaque poids est petit, borné, publié, et pourquoi la vue « sondages seuls » reste toujours affichée à côté de l&apos;ensemble.</p>
64 + </div>
65 + <div className="flex gap-3"><Link href="/methodologie#signaux" className="btn btn-primary">Méthodologie</Link><Link href="/prevision" className="btn btn-ghost">Prévision</Link></div>
66 + </section>
67 + </div>
68 + );
69 +}
modified web/src/app/sitemap.ts +1 −1
@@ -4,7 +4,7 @@ import { PARTIES } from "@/lib/parties";
4 4
5 5 export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
6 6 const now = new Date();
7 const statics = ["", "/prevision", "/sondages", "/carte", "/circonscriptions", "/boussole", "/partis", "/partis/comparateur", "/enjeux", "/documents", "/methodologie", "/methodologie/changelog", "/live", "/statut"].map((p) => ({ url: `${SITE_URL}${p}`, lastModified: now, changeFrequency: "hourly" as const, priority: p === "" ? 1 : 0.7 }));
7 + const statics = ["", "/prevision", "/signaux", "/sondages", "/carte", "/circonscriptions", "/boussole", "/partis", "/partis/comparateur", "/enjeux", "/documents", "/methodologie", "/methodologie/changelog", "/live", "/statut"].map((p) => ({ url: `${SITE_URL}${p}`, lastModified: now, changeFrequency: "hourly" as const, priority: p === "" ? 1 : 0.7 }));
8 8 const ridings = await apiSafe<{ ridings: RidingListItem[] }>("/api/ridings", { revalidate: 3600 });
9 9 const r = (ridings?.ridings ?? []).map((x) => ({ url: `${SITE_URL}/circonscription/${x.slug}`, lastModified: now, changeFrequency: "daily" as const, priority: 0.6 }));
10 10 const p = PARTIES.map((x) => ({ url: `${SITE_URL}/partis/${x.id}`, lastModified: now, changeFrequency: "daily" as const, priority: 0.6 }));
modified web/src/app/sondages/[id]/page.tsx +5 −5
@@ -4,7 +4,7 @@ import { notFound } from "next/navigation";
4 4 import { apiSafe, type Poll } from "@/lib/api";
5 5 import { PartyBadge } from "@/components/party";
6 6 import { Badge, Card, ConfidenceBadge, KV, SectionHeader, SourceBadge } from "@/components/ui";
7 import { PARTY_IDS, partyColor, partyLabel } from "@/lib/parties";
7 +import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";
8 8 import { dateFr, dateTimeFr, delta, int, modeLabel } from "@/lib/format";
9 9
10 10 export const revalidate = 120;
@@ -36,8 +36,8 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
36 36 <div className="space-y-2.5">
37 37 {order.map((pid) => (
38 38 <div key={pid} className="grid grid-cols-[52px_1fr_60px] items-center gap-3">
39 <span className="font-semibold num" style={{ color: partyColor(pid) }}>{partyLabel(pid)}</span>
40 <div className="h-[18px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="h-full" style={{ width: `${((p.results[pid] ?? 0) / 50) * 100}%`, background: partyColor(pid) }} /></div>
39 + <span className="font-semibold num" style={{ color: partyVar(pid) }}>{partyLabel(pid)}</span>
40 + <div className="h-[18px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="h-full" style={{ width: `${((p.results[pid] ?? 0) / 50) * 100}%`, background: partyVar(pid) }} /></div>
41 41 <span className="num font-bold text-[17px] text-right">{p.results[pid] !== undefined ? p.results[pid].toFixed(1).replace(".", ",") : "—"}</span>
42 42 </div>
43 43 ))}
@@ -47,7 +47,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
47 47 {p.weight?.impact && (
48 48 <div className="mt-5 pt-4 border-t border-border">
49 49 <div className="eyebrow mb-2">Impact sur QC26</div>
50 <div className="grid grid-cols-5 gap-2">{PARTY_IDS.map((pid) => <div key={pid} className="surface-2 rounded-[6px] p-2 text-center"><div className="text-[11px] font-semibold" style={{ color: partyColor(pid) }}>{partyLabel(pid)}</div><div className={"num font-bold " + ((p.weight!.impact![pid] ?? 0) > 0 ? "text-ok" : (p.weight!.impact![pid] ?? 0) < 0 ? "text-live" : "text-ink-3")}>{delta(p.weight!.impact![pid], 1)}</div></div>)}</div>
50 + <div className="grid grid-cols-5 gap-2">{PARTY_IDS.map((pid) => <div key={pid} className="surface-2 rounded-[6px] p-2 text-center"><div className="text-[11px] font-semibold" style={{ color: partyVar(pid) }}>{partyLabel(pid)}</div><div className={"num font-bold " + ((p.weight!.impact![pid] ?? 0) > 0 ? "text-ok" : (p.weight!.impact![pid] ?? 0) < 0 ? "text-live" : "text-ink-3")}>{delta(p.weight!.impact![pid], 1)}</div></div>)}</div>
51 51 <div className="text-[12px] text-ink-3 mt-2">Variation de la moyenne QC26 du jour attribuable à ce sondage (recalcul sans lui). Poids dans le run : {p.weight.weight.toFixed(3)} (récence {p.weight.recency.toFixed(2)} × échantillon {p.weight.sample.toFixed(2)}).</div>
52 52 </div>
53 53 )}
@@ -70,7 +70,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
70 70 <div className="grid md:grid-cols-2 gap-4">
71 71 {Object.entries(subs).map(([dim, segs]) => (
72 72 <Card key={dim} pad={false} className="overflow-x-auto">
73 <table className="data-table"><thead><tr><th>{dimLabel[dim] ?? dim}</th><th className="r">n</th>{PARTY_IDS.map((pid) => <th key={pid} className="r" style={{ color: partyColor(pid) }}>{partyLabel(pid)}</th>)}<th className="r">p.</th></tr></thead>
73 + <table className="data-table"><thead><tr><th>{dimLabel[dim] ?? dim}</th><th className="r">n</th>{PARTY_IDS.map((pid) => <th key={pid} className="r" style={{ color: partyVar(pid) }}>{partyLabel(pid)}</th>)}<th className="r">p.</th></tr></thead>
74 74 <tbody>{Object.entries(segs).map(([seg, vals]) => (<tr key={seg}><td className="font-medium">{seg}</td><td className="r num text-ink-3">{vals._n ? int(vals._n as number) : "—"}</td>{PARTY_IDS.map((pid) => <td key={pid} className="r num">{vals[pid] !== undefined && vals[pid] !== null ? Number(vals[pid]).toFixed(0) : "—"}</td>)}<td className="r num text-ink-3">{vals._page ?? "—"}</td></tr>))}</tbody></table>
75 75 </Card>
76 76 ))}
modified web/src/app/sondages/page.tsx +9 −11
@@ -1,9 +1,10 @@
1 1 import type { Metadata } from "next";
2 2 import { apiSafe, type Forecast, type Poll } from "@/lib/api";
3 import { Legend, TrendChart } from "@/components/charts";
4 import { Card, Empty, SectionHeader } from "@/components/ui";
3 +import { Legend } from "@/components/charts";
4 +import { TrendChartInteractive } from "@/components/trend-chart";
5 +import { Card, Empty, PageTitle, SectionHeader } from "@/components/ui";
5 6 import { PollsTable } from "@/components/polls-table";
6 import { PARTY_IDS, partyColor, partyLabel } from "@/lib/parties";
7 +import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";
7 8 import { dateShort } from "@/lib/format";
8 9
9 10 export const metadata: Metadata = { title: "Sondages Québec 2026 — Agrégateur et tendances", description: "Tous les sondages provinciaux depuis l'élection de 2022, avec documents originaux archivés, poids dans le modèle QC26, effets maison et sous-échantillons." };
@@ -15,17 +16,14 @@ export default async function Page() {
15 16 const [data, f, ps] = await Promise.all([apiSafe<{ polls: Poll[]; trend: Forecast["trend"] }>("/api/polls?limit=600"), apiSafe<Forecast>("/api/forecast/latest"), apiSafe<{ pollsters: Pollster[] }>("/api/pollsters")]);
16 17 if (!data) return <Empty title="Sondages indisponibles" />;
17 18 const polls = data.polls;
18 const pollPts = polls.filter((p) => !p.excluded).map((p) => ({ date: p.fieldworkEnd, values: p.results }));
19 + const pollPts = polls.filter((p) => !p.excluded).map((p) => ({ date: p.fieldworkEnd, values: p.results, pollster: p.pollster.name }));
19 20 const primary = polls.filter((p) => p.sourceTier === "primary").length;
20 21 return (
21 22 <div className="space-y-12">
22 <div>
23 <div className="eyebrow">Sondages · {polls.length} sondages · {primary} documents originaux archivés</div>
24 <h1 className="display text-[36px] md:text-[48px] mt-2">Moyenne QC26</h1>
25 <p className="text-ink-2 mt-3 max-w-2xl text-[15px] leading-relaxed">Chaque sondage provincial depuis le scrutin d&apos;octobre 2022 : téléchargé chez la firme, haché (SHA-256), archivé, extrait page par page et validé avant d&apos;entrer dans le modèle. Les lignes marquées « index » attendent encore leur document original.</p>
26 </div>
23 + <PageTitle eyebrow={`Sondages · ${polls.length} sondages · ${primary} documents originaux archivés`} title="Moyenne QC26"
24 + lede="Chaque sondage provincial depuis le scrutin d'octobre 2022 : téléchargé chez la firme, haché (SHA-256), archivé, extrait page par page et validé avant d'entrer dans le modèle. Les lignes marquées « index » attendent encore leur document original." />
27 25 <Card>
28 {f?.trend && <TrendChart trend={f.trend} polls={pollPts} height={360} election2022={{ caq: 40.98, plq: 14.37, qs: 15.43, pq: 14.61, pcq: 12.91 }} />}
26 + {f?.trend && <TrendChartInteractive trend={f.trend} polls={pollPts} height={360} election2022={{ caq: 40.98, plq: 14.37, qs: 15.43, pq: 14.61, pcq: 12.91 }} />}
29 27 <div className="flex flex-wrap items-center justify-between gap-2 mt-2"><Legend ids={PARTY_IDS} /><span className="text-[12px] text-ink-3">Points = sondages bruts · ligne = moyenne QC26 corrigée des effets maison · bande 80 %</span></div>
30 28 </Card>
31 29 <section>
@@ -36,7 +34,7 @@ export default async function Page() {
36 34 <section>
37 35 <SectionHeader eyebrow="Firmes" title="Effets maison" description="Écart moyen de chaque firme à la tendance des autres firmes (points), rétréci de moitié et borné à ±3 ; positif = la firme mesure le parti plus haut que la moyenne." />
38 36 <Card pad={false} className="overflow-x-auto">
39 <table className="data-table min-w-[640px]"><thead><tr><th>Firme</th><th className="r">Sondages</th><th>Dernier</th>{PARTY_IDS.map((id) => <th key={id} className="r" style={{ color: partyColor(id) }}>{partyLabel(id)}</th>)}</tr></thead>
37 + <table className="data-table min-w-[640px]"><thead><tr><th>Firme</th><th className="r">Sondages</th><th>Dernier</th>{PARTY_IDS.map((id) => <th key={id} className="r" style={{ color: partyVar(id) }}>{partyLabel(id)}</th>)}</tr></thead>
40 38 <tbody>{ps.pollsters.sort((a, b) => b.polls - a.polls).map((p) => (
41 39 <tr key={p.id}><td className="font-semibold">{p.website ? <a href={p.website} target="_blank" rel="noopener noreferrer" className="hover:underline">{p.name}</a> : p.name}</td><td className="r num">{p.polls}</td><td className="num">{dateShort(p.lastPoll)}</td>{PARTY_IDS.map((id) => <td key={id} className="r num">{p.houseEffects?.[id] !== undefined ? (p.houseEffects[id] > 0 ? "+" : "") + p.houseEffects[id].toFixed(1).replace(".", ",") : <span className="text-ink-3">—</span>}</td>)}</tr>
42 40 ))}</tbody></table>
modified web/src/app/statut/page.tsx +4 −4
@@ -8,7 +8,7 @@ export const revalidate = 30;
8 8
9 9 interface Status { components: Record<string, string>; sources: { id: string; name: string; url: string; tier: number; kind: string; status: string; lastCheckedAt: string | null; lastOkAt: string | null; detail: string | null }[]; jobs: { id: number; kind: string; status: string; startedAt: string; finishedAt: string | null; items: number; message: string | null }[]; latestRun: { id: number; completedAt: string | null } | null; dbLatencyMs: number; sseClients: number; serverTime: string }
10 10
11 const LABELS: Record<string, string> = { pollWatcher: "Poll watcher", forecastEngine: "Forecast engine", electionFeed: "Election feed", documentWatcher: "Document watcher", officialData: "Données officielles" };
11 +const LABELS: Record<string, string> = { pollWatcher: "Poll watcher", forecastEngine: "Forecast engine", electionFeed: "Election feed", documentWatcher: "Document watcher", officialData: "Données officielles", signals: "Signaux (marchés, presse, attention)" };
12 12
13 13 export default async function Page() {
14 14 const st = await apiSafe<Status>("/api/status", { revalidate: 30 });
@@ -16,9 +16,9 @@ export default async function Page() {
16 16 const tone = (s: string) => (s === "operational" ? "ok" : s === "ready" ? "accent" : "warn");
17 17 return (
18 18 <div className="space-y-8">
19 <div><div className="eyebrow">QC26 Data Status</div><h1 className="display text-[36px] md:text-[48px] mt-2">Statut des données</h1><p className="text-ink-2 mt-2 text-[15px] num">Heure serveur {dateTimeFr(st.serverTime)} · latence base {st.dbLatencyMs} ms · {st.sseClients} clients SSE</p></div>
20 <div className="grid sm:grid-cols-2 lg:grid-cols-5 gap-3">{Object.entries(st.components).map(([k, v]) => <Card key={k}><div className="text-[13px] font-semibold">{LABELS[k] ?? k}</div><div className="mt-2"><Badge tone={tone(v)}>● {v}</Badge></div></Card>)}</div>
21 <section><SectionHeader eyebrow="Sources" title="Connecteurs et sources" description="Tier 1 = officiel (Élections Québec, partis), 2 = firmes de sondage, 5 = index tiers (amorçage uniquement)." /><Card pad={false} className="overflow-x-auto"><table className="data-table min-w-[720px]"><thead><tr><th>Source</th><th>Tier</th><th>Statut</th><th>Vérifié</th><th>Dernier succès</th><th>Détail</th></tr></thead><tbody>{st.sources.sort((a, b) => a.tier - b.tier).map((s) => <tr key={s.id}><td><a href={s.url} target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">{s.name}</a></td><td className="num">{s.tier}</td><td><Badge tone={tone(s.status)}>{s.status}</Badge></td><td className="num text-ink-2">{s.lastCheckedAt ? relTime(s.lastCheckedAt) : "—"}</td><td className="num text-ink-2">{s.lastOkAt ? relTime(s.lastOkAt) : "—"}</td><td className="text-[12px] text-ink-3">{s.detail}</td></tr>)}</tbody></table></Card></section>
19 + <div><div className="eyebrow eyebrow-accent">QC26 Data Status</div><h1 className="display text-[40px] md:text-[56px] mt-3">Statut des données</h1><p className="text-ink-2 mt-2 text-[15px] num">Heure serveur {dateTimeFr(st.serverTime)} · latence base {st.dbLatencyMs} ms · {st.sseClients} clients SSE</p></div>
20 + <div className="grid sm:grid-cols-2 lg:grid-cols-6 gap-3">{Object.entries(st.components).map(([k, v]) => <Card key={k}><div className="text-[13px] font-semibold">{LABELS[k] ?? k}</div><div className="mt-2"><Badge tone={tone(v)}>● {v}</Badge></div></Card>)}</div>
21 + <section><SectionHeader eyebrow="Sources" title="Connecteurs et sources" description="Tier 1 = officiel (Élections Québec, partis), 2 = firmes de sondage, 3 = signaux (marchés, presse, attention), 5 = index tiers (amorçage uniquement)." /><Card pad={false} className="overflow-x-auto"><table className="data-table min-w-[720px]"><thead><tr><th>Source</th><th>Tier</th><th>Statut</th><th>Vérifié</th><th>Dernier succès</th><th>Détail</th></tr></thead><tbody>{st.sources.sort((a, b) => a.tier - b.tier).map((s) => <tr key={s.id}><td><a href={s.url} target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">{s.name}</a></td><td className="num">{s.tier}</td><td><Badge tone={tone(s.status)}>{s.status}</Badge></td><td className="num text-ink-2">{s.lastCheckedAt ? relTime(s.lastCheckedAt) : "—"}</td><td className="num text-ink-2">{s.lastOkAt ? relTime(s.lastOkAt) : "—"}</td><td className="text-[12px] text-ink-3">{s.detail}</td></tr>)}</tbody></table></Card></section>
22 22 <section><SectionHeader eyebrow="Travaux" title="Derniers travaux d'ingestion" /><Card pad={false}><table className="data-table"><thead><tr><th>Travail</th><th>Statut</th><th>Début</th><th>Fin</th><th className="r">Éléments</th><th>Message</th></tr></thead><tbody>{st.jobs.map((j) => <tr key={j.id}><td className="font-medium">{j.kind}</td><td><Badge tone={j.status === "ok" ? "ok" : j.status === "running" ? "accent" : "warn"}>{j.status}</Badge></td><td className="num">{dateTimeFr(j.startedAt)}</td><td className="num">{dateTimeFr(j.finishedAt)}</td><td className="r num">{j.items}</td><td className="text-[12px] text-ink-3">{j.message}</td></tr>)}</tbody></table></Card></section>
23 23 </div>
24 24 );
modified web/src/components/ask-box.tsx +2 −2
@@ -2,7 +2,7 @@
2 2
3 3 import Link from "next/link";
4 4 import { useState } from "react";
5 import { PARTY_IDS, partyColor, partyLabel, partyName } from "@/lib/parties";
5 +import { PARTY_IDS, partyVar, partyLabel, partyName } from "@/lib/parties";
6 6 import { Card } from "./ui";
7 7
8 8 interface Ask { query: string; terms: string[]; parties: Record<string, { id: number; proposal: string; quote: string; page: number | null; topic: string; confidence: number; document: { id: number; title: string | null; url: string | null } }[]>; note: string }
@@ -27,7 +27,7 @@ export function AskBox() {
27 27 {res && (
28 28 <div className="mt-5 grid md:grid-cols-2 xl:grid-cols-5 gap-3">
29 29 {PARTY_IDS.map((id) => (
30 <div key={id} className="surface-2 rounded-[8px] p-3" style={{ borderTop: `3px solid ${partyColor(id)}` }}>
30 + <div key={id} className="surface-2 rounded-[8px] p-3" style={{ borderTop: `3px solid ${partyVar(id)}` }}>
31 31 <div className="font-semibold text-[13px] mb-2">{partyName(id)}</div>
32 32 {(res.parties[id] ?? []).length === 0 ? <div className="text-[12px] text-ink-3">Aucune proposition trouvée dans les documents ingérés.</div> : (res.parties[id] ?? []).map((p) => (
33 33 <div key={p.id} className="mb-2.5 text-[12.5px] leading-snug"><div>{p.proposal}</div><div className="text-[11.5px] text-ink-3 mt-0.5"><Link href={`/documents/${p.document.id}#p${p.id}`} className="link">{p.document.title ?? "Document"}</Link>{p.page ? ` · p. ${p.page}` : ""}</div></div>
modified web/src/components/charts.tsx +162 −49
@@ -1,7 +1,8 @@
1 /** Graphiques SVG QC26 (rendu serveur possible, sans dépendance). */
1 +/** Graphiques SVG QC26 (rendu serveur, sans dépendance). Marques fines, grille en filigrane,
2 + * étiquettes directes sélectives ; les couleurs de partis viennent de variables CSS (thème clair/sombre). */
2 3 import type { ReactNode } from "react";
3 4 import type { TrendPoint } from "@/lib/api";
4 import { OTHERS, PARTY_IDS, partyColor, partyLabel } from "@/lib/parties";
5 +import { OTHERS, PARTY_IDS, partyAlpha, partyLabel, partyVar } from "@/lib/parties";
5 6 import { cls } from "@/lib/format";
6 7
7 8 // -------------------------------------------------------------- SeatMeter
@@ -11,29 +12,25 @@ export function SeatMeter({ seats, total, majority, height = 22, showLabels = tr
11 12 }) {
12 13 const entries = Object.entries(seats).filter(([id]) => id !== "aut").sort((a, b) => b[1] - a[1]);
13 14 const others = seats["aut"] ?? 0;
14 let x = 0;
15 15 const majX = (majority / total) * 100;
16 16 return (
17 17 <div className={cls("w-full", className)}>
18 <div className="relative" style={{ height: height + 22 }} role="img" aria-label={title ?? `Répartition des sièges sur ${total}, majorité à ${majority}`}>
19 <div className="absolute inset-x-0 top-[14px] rounded-[6px] overflow-hidden bg-surface-3 flex" style={{ height }}>
20 {entries.map(([id, n]) => {
21 const w = (n / total) * 100;
22 const el = <div key={id} className="h-full relative" style={{ width: `${w}%`, background: partyColor(id) }} title={`${partyLabel(id)} : ${n}`} />;
23 x += w;
24 return el;
25 })}
18 + <div className="relative" style={{ height: height + 24 }} role="img" aria-label={title ?? `Répartition des sièges sur ${total}, majorité à ${majority}`}>
19 + <div className="absolute inset-x-0 top-[16px] rounded-[7px] overflow-hidden bg-surface-3 flex gap-[2px]" style={{ height }}>
20 + {entries.map(([id, n]) => (
21 + <div key={id} className="h-full relative" style={{ width: `calc(${(n / total) * 100}% - 2px)`, background: partyVar(id) }} title={`${partyLabel(id)} : ${n}`} />
22 + ))}
26 23 {others > 0 && <div className="h-full" style={{ width: `${(others / total) * 100}%`, background: OTHERS.color }} title={`Autres : ${others}`} />}
27 24 </div>
28 <div className="absolute top-0 bottom-0 w-[2px] bg-ink" style={{ left: `calc(${majX}% - 1px)` }} aria-hidden />
29 <div className="absolute -top-[2px] text-[10.5px] font-bold uppercase tracking-wider num translate-x-[-50%] bg-background px-1" style={{ left: `${majX}%` }}>
30 {majority} · Majorité
25 + <div className="absolute top-[10px] bottom-0 w-[2px] bg-ink" style={{ left: `calc(${majX}% - 1px)` }} aria-hidden />
26 + <div className="absolute top-0 mono text-[10px] font-semibold uppercase tracking-[0.08em] translate-x-[-50%] bg-background px-1 text-ink" style={{ left: `${majX}%` }}>
27 + {majority} · majorité
31 28 </div>
32 29 </div>
33 30 {showLabels && (
34 <div className="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-[12.5px]">
31 + <div className="flex flex-wrap gap-x-4 gap-y-1 mt-1.5 text-[12.5px]">
35 32 {entries.map(([id, n]) => (
36 <span key={id} className="inline-flex items-center gap-1.5 num"><span className="w-2.5 h-2.5 rounded-[3px] inline-block" style={{ background: partyColor(id) }} /><span className="font-semibold">{partyLabel(id)}</span> {n}</span>
33 + <span key={id} className="inline-flex items-center gap-1.5 num"><span className="w-2.5 h-2.5 rounded-[3px] inline-block" style={{ background: partyVar(id) }} /><span className="font-semibold">{partyLabel(id)}</span> {n}</span>
37 34 ))}
38 35 {others > 0 && <span className="inline-flex items-center gap-1.5 num"><span className="w-2.5 h-2.5 rounded-[3px] inline-block" style={{ background: OTHERS.color }} />Autres {others}</span>}
39 36 </div>
@@ -42,6 +39,27 @@ export function SeatMeter({ seats, total, majority, height = 22, showLabels = tr
42 39 );
43 40 }
44 41
42 +// -------------------------------------------------------------- SeatWaffle
43 +/** Les 127 sièges, un point chacun, colorés par la médiane projetée ; le trait marque le seuil de majorité. */
44 +export function SeatWaffle({ seats, total, majority, cols = 16, className, dot = 14, gap = 4 }: { seats: Record<string, number>; total: number; majority: number; cols?: number; className?: string; dot?: number; gap?: number }) {
45 + const entries = Object.entries(seats).filter(([id]) => id !== "aut").sort((a, b) => b[1] - a[1]);
46 + const cells: (string | null)[] = [];
47 + entries.forEach(([id, n]) => { for (let i = 0; i < n; i++) cells.push(id); });
48 + while (cells.length < total) cells.push(null);
49 + const rows = Math.ceil(total / cols);
50 + const W = cols * (dot + gap) - gap, H = rows * (dot + gap) - gap;
51 + const mi = majority - 1, mr = Math.floor(mi / cols), mc = mi % cols;
52 + return (
53 + <svg viewBox={`0 0 ${W} ${H}`} className={cls("w-full h-auto", className)} role="img" aria-label={`${total} sièges ; ${entries.map(([id, n]) => `${partyLabel(id)} ${n}`).join(", ")} ; majorité à ${majority}`}>
54 + {cells.slice(0, total).map((id, i) => {
55 + const r = Math.floor(i / cols), c = i % cols;
56 + return <rect key={i} className="waffle-dot" x={c * (dot + gap)} y={r * (dot + gap)} width={dot} height={dot} rx={dot * 0.32} fill={id ? partyVar(id) : "var(--surface-3)"} />;
57 + })}
58 + <line x1={(mc + 1) * (dot + gap) - gap / 2} x2={(mc + 1) * (dot + gap) - gap / 2} y1={mr * (dot + gap) - 3} y2={(mr + 1) * (dot + gap) - gap + 3} stroke="var(--text)" strokeWidth={2.5} strokeLinecap="round" />
59 + </svg>
60 + );
61 +}
62 +
45 63 // ------------------------------------------------------- SeatDistribution
46 64 /** Distribution des sièges d'un parti (histogramme Monte Carlo) avec seuil de majorité et intervalles. */
47 65 export function SeatDistribution({ dist, color, majority, median, lo80, hi80, lo95, hi95, height = 120, className, total }: {
@@ -50,8 +68,6 @@ export function SeatDistribution({ dist, color, majority, median, lo80, hi80, lo
50 68 const n = dist.length;
51 69 const max = Math.max(...dist, 1);
52 70 const W = 640, H = height, pad = 18;
53 const bw = (W - 2 * pad) / n;
54 const xs = (i: number) => pad + i * bw;
55 71 const vis = dist.map((v, i) => [i, v] as const).filter(([, v]) => v > 0);
56 72 const minI = Math.max(0, (vis[0]?.[0] ?? 0) - 2), maxI = Math.min(n - 1, (vis[vis.length - 1]?.[0] ?? n - 1) + 2);
57 73 const span = Math.max(1, maxI - minI);
@@ -60,34 +76,34 @@ export function SeatDistribution({ dist, color, majority, median, lo80, hi80, lo
60 76 return (
61 77 <svg viewBox={`0 0 ${W} ${H + 26}`} className={cls("w-full h-auto", className)} role="img" aria-label={`Distribution des sièges, médiane ${median}, intervalle 80 % ${lo80}–${hi80}, 95 % ${lo95}–${hi95}, majorité à ${majority}`}>
62 78 <rect x={xz(lo95)} y={0} width={Math.max(1, xz(hi95) - xz(lo95) + bwz)} height={H} fill={color} opacity={0.06} />
63 <rect x={xz(lo80)} y={0} width={Math.max(1, xz(hi80) - xz(lo80) + bwz)} height={H} fill={color} opacity={0.08} />
79 + <rect x={xz(lo80)} y={0} width={Math.max(1, xz(hi80) - xz(lo80) + bwz)} height={H} fill={color} opacity={0.09} />
64 80 {dist.map((v, i) => (i < minI || i > maxI || v === 0) ? null : (
65 <rect key={i} x={xz(i) + 0.5} y={H - (v / max) * (H - 8)} width={Math.max(1, bwz - 1)} height={(v / max) * (H - 8)} fill={color} opacity={i >= majority ? 1 : 0.55} rx={1} />
81 + <rect key={i} x={xz(i) + 0.5} y={H - (v / max) * (H - 8)} width={Math.max(1, bwz - 1)} height={(v / max) * (H - 8)} fill={color} opacity={i >= majority ? 1 : 0.5} rx={1.5} />
66 82 ))}
67 83 {majority >= minI && majority <= maxI && (
68 84 <>
69 <line x1={xz(majority)} x2={xz(majority)} y1={0} y2={H + 4} stroke="var(--text)" strokeWidth={1.5} strokeDasharray="3 3" />
70 <text x={xz(majority) + 4} y={11} fontSize={10.5} fontWeight={700} fill="var(--text)" className="num">{majority} · majorité</text>
85 + <line x1={xz(majority)} x2={xz(majority)} y1={0} y2={H + 4} stroke="var(--text)" strokeWidth={1.5} />
86 + <text x={xz(majority) + 4} y={11} fontSize={10.5} fontWeight={600} fill="var(--text)" fontFamily="var(--font-mono)">{majority} · majorité</text>
71 87 </>
72 88 )}
73 <line x1={xz(median) + bwz / 2} x2={xz(median) + bwz / 2} y1={0} y2={H} stroke={color} strokeWidth={2} />
74 {[minI, Math.round((minI + maxI) / 2), maxI].map((i) => (
75 <text key={i} x={xz(i) + bwz / 2} y={H + 20} fontSize={10.5} textAnchor="middle" fill="var(--text-2)" className="num">{i}</text>
89 + <line x1={xz(median) + bwz / 2} x2={xz(median) + bwz / 2} y1={0} y2={H} stroke="var(--text)" strokeWidth={1} strokeOpacity={0.6} />
90 + {[minI, Math.round((minI + maxI) / 2), maxI].filter((i) => Math.abs(i - median) > Math.max(3, span * 0.06)).map((i) => (
91 + <text key={i} x={xz(i) + bwz / 2} y={H + 20} fontSize={10.5} textAnchor="middle" fill="var(--text-3)" fontFamily="var(--font-mono)">{i}</text>
76 92 ))}
77 <text x={xz(median) + bwz / 2} y={H + 20} fontSize={10.5} textAnchor="middle" fontWeight={700} fill={color} className="num">{median}</text>
93 + <text x={xz(median) + bwz / 2} y={H + 20} fontSize={10.5} textAnchor="middle" fontWeight={700} fill="var(--text)" fontFamily="var(--font-mono)">{median}</text>
78 94 <title>{`Sur ${total} sièges`}</title>
79 95 </svg>
80 96 );
81 97 }
82 98
83 // ----------------------------------------------------------------- Trend
99 +// ----------------------------------------------------------------- Trend (statique)
84 100 export function TrendChart({ trend, polls, from, height = 300, showBand = true, ids = PARTY_IDS, className, markers = [], election2022 }: {
85 101 trend: TrendPoint[]; polls?: { date: string; values: Record<string, number>; id?: string }[]; from?: string; height?: number; showBand?: boolean; ids?: string[]; className?: string;
86 102 markers?: { date: string; label: string }[]; election2022?: Record<string, number> | null;
87 103 }) {
88 104 const pts = from ? trend.filter((t) => t.date >= from) : trend;
89 105 if (pts.length < 2) return <div className="text-ink-3 text-sm">Pas assez de données.</div>;
90 const W = 900, H = height, pl = 34, pr = 78, pt = 12, pb = 28;
106 + const W = 900, H = height, pl = 34, pr = 84, pt = 12, pb = 28;
91 107 const t0 = new Date(pts[0].date).getTime(), t1 = new Date(pts[pts.length - 1].date).getTime();
92 108 const x = (d: string) => pl + ((new Date(d).getTime() - t0) / Math.max(1, t1 - t0)) * (W - pl - pr);
93 109 const allVals = pts.flatMap((p) => ids.map((id) => p.mean[id] ?? 0));
@@ -95,7 +111,6 @@ export function TrendChart({ trend, polls, from, height = 300, showBand = true,
95 111 const ymax = Math.min(100, Math.ceil((Math.max(...allVals, ...pollVals, 10) + 4) / 5) * 5);
96 112 const y = (v: number) => pt + (1 - v / ymax) * (H - pt - pb);
97 113 const ticks = Array.from({ length: Math.floor(ymax / 10) + 1 }, (_, i) => i * 10);
98 // repères temporels : premier jour de chaque mois (ou trimestre si long)
99 114 const months: { d: Date; label: string }[] = [];
100 115 const spanDays = (t1 - t0) / 86400000;
101 116 const step = spanDays > 900 ? 6 : spanDays > 400 ? 3 : 1;
@@ -112,39 +127,41 @@ export function TrendChart({ trend, polls, from, height = 300, showBand = true,
112 127 };
113 128 const last = pts[pts.length - 1];
114 129 const labels = ids.map((id) => ({ id, v: last.mean[id] ?? 0 })).sort((a, b) => b.v - a.v);
115 // évite les chevauchements d'étiquettes
116 130 let prevY = -Infinity;
117 131 const labelY = labels.map((l) => { let yy = y(l.v); if (yy - prevY < 13) yy = prevY + 13; prevY = yy; return yy; });
118 132 return (
119 133 <svg viewBox={`0 0 ${W} ${H}`} className={cls("w-full h-auto", className)} role="img" aria-label="Évolution des intentions de vote (moyenne QC26) et sondages">
120 134 {ticks.map((t) => (
121 135 <g key={t}>
122 <line x1={pl} x2={W - pr} y1={y(t)} y2={y(t)} stroke="var(--border)" strokeWidth={1} />
123 <text x={pl - 6} y={y(t) + 3.5} fontSize={10.5} textAnchor="end" fill="var(--text-3)" className="num">{t}</text>
136 + <line x1={pl} x2={W - pr} y1={y(t)} y2={y(t)} stroke="var(--grid)" strokeWidth={1} />
137 + <text x={pl - 6} y={y(t) + 3.5} fontSize={10.5} textAnchor="end" fill="var(--text-3)" fontFamily="var(--font-mono)">{t}</text>
124 138 </g>
125 139 ))}
126 140 {months.map((m, i) => (
127 141 <g key={i}>
128 <line x1={x(m.d.toISOString())} x2={x(m.d.toISOString())} y1={pt} y2={H - pb + 4} stroke="var(--border)" strokeWidth={1} />
129 <text x={x(m.d.toISOString())} y={H - 8} fontSize={10.5} textAnchor="middle" fill="var(--text-3)">{m.label}</text>
142 + <line x1={x(m.d.toISOString())} x2={x(m.d.toISOString())} y1={pt} y2={H - pb + 4} stroke="var(--grid)" strokeWidth={1} />
143 + <text x={x(m.d.toISOString())} y={H - 8} fontSize={10.5} textAnchor="middle" fill="var(--text-3)" fontFamily="var(--font-mono)">{m.label}</text>
130 144 </g>
131 145 ))}
132 146 {markers.filter((m) => new Date(m.date).getTime() >= t0 && new Date(m.date).getTime() <= t1).map((m, i) => (
133 147 <g key={`m${i}`}>
134 148 <line x1={x(m.date)} x2={x(m.date)} y1={pt} y2={H - pb} stroke="var(--text-2)" strokeWidth={1} strokeDasharray="2 3" />
135 <text x={x(m.date) + 3} y={pt + 9} fontSize={9.5} fill="var(--text-2)">{m.label}</text>
149 + <text x={x(m.date) + 4} y={pt + 9} fontSize={9.5} fill="var(--text-2)" fontFamily="var(--font-mono)">{m.label}</text>
136 150 </g>
137 151 ))}
138 152 {polls?.filter((p) => !from || p.date >= from).map((p, i) => ids.map((id) => p.values[id] === undefined ? null : (
139 <circle key={`${i}-${id}`} cx={x(p.date)} cy={y(p.values[id])} r={2.6} fill={partyColor(id)} opacity={0.35} />
153 + <circle key={`${i}-${id}`} cx={x(p.date)} cy={y(p.values[id])} r={2.6} fill={partyVar(id)} opacity={0.3} />
140 154 )))}
141 {showBand && ids.map((id) => <path key={`b${id}`} d={band(id)} fill={partyColor(id)} opacity={0.10} />)}
142 {ids.map((id) => <path key={id} d={path(id)} fill="none" stroke={partyColor(id)} strokeWidth={2.4} strokeLinejoin="round" strokeLinecap="round" />)}
155 + {showBand && ids.map((id) => <path key={`b${id}`} d={band(id)} fill={partyVar(id)} opacity={0.1} />)}
156 + {ids.map((id) => <path key={id} d={path(id)} fill="none" stroke={partyVar(id)} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />)}
157 + {ids.map((id) => <circle key={`e${id}`} cx={x(last.date)} cy={y(last.mean[id] ?? 0)} r={3.5} fill={partyVar(id)} stroke="var(--surface)" strokeWidth={2} />)}
143 158 {election2022 && ids.map((id) => election2022[id] !== undefined && (
144 <g key={`e${id}`}><rect x={pl - 2} y={y(election2022[id]) - 1.5} width={5} height={3} fill={partyColor(id)} /></g>
159 + <g key={`e22${id}`}><rect x={pl - 2} y={y(election2022[id]) - 1.5} width={5} height={3} fill={partyVar(id)} /></g>
145 160 ))}
146 161 {labels.map((l, i) => (
147 <text key={l.id} x={W - pr + 6} y={labelY[i] + 4} fontSize={12} fontWeight={700} fill={partyColor(l.id)} className="num">{partyLabel(l.id)} {l.v.toFixed(1).replace(".", ",")}</text>
162 + <text key={l.id} x={W - pr + 8} y={labelY[i] + 4} fontSize={12} fontWeight={600} fill="var(--text)" fontFamily="var(--font-sans)">
163 + <tspan fill={partyVar(l.id)}>■</tspan> {partyLabel(l.id)} <tspan fontFamily="var(--font-mono)">{l.v.toFixed(1).replace(".", ",")}</tspan>
164 + </text>
148 165 ))}
149 166 </svg>
150 167 );
@@ -158,12 +175,12 @@ export function VoteBars({ values, ci, max, className, order }: { values: Record
158 175 <div className={cls("space-y-2", className)}>
159 176 {ids.map((id) => (
160 177 <div key={id} className="grid grid-cols-[44px_1fr_58px] items-center gap-3 text-[13.5px]">
161 <span className="font-semibold num" style={{ color: partyColor(id) }}>{partyLabel(id)}</span>
162 <div className="relative h-[14px] bg-surface-3 rounded-[4px] overflow-hidden">
163 {ci?.[id] && <div className="absolute top-0 bottom-0 rounded-[4px]" style={{ left: `${(ci[id][0] / m) * 100}%`, width: `${((ci[id][1] - ci[id][0]) / m) * 100}%`, background: partyColor(id), opacity: 0.25 }} />}
164 <div className="absolute top-0 bottom-0 left-0 rounded-[4px]" style={{ width: `${(values[id] / m) * 100}%`, background: partyColor(id) }} />
178 + <span className="font-semibold num inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span>
179 + <div className="relative h-[12px] bg-surface-3 rounded-[4px] overflow-hidden">
180 + {ci?.[id] && <div className="absolute top-0 bottom-0 rounded-[4px]" style={{ left: `${(ci[id][0] / m) * 100}%`, width: `${((ci[id][1] - ci[id][0]) / m) * 100}%`, background: partyAlpha(id, 0.25) }} />}
181 + <div className="absolute top-0 bottom-0 left-0 rounded-[4px]" style={{ width: `${(values[id] / m) * 100}%`, background: partyVar(id) }} />
165 182 </div>
166 <span className="num font-semibold text-right">{values[id].toFixed(1).replace(".", ",")} %</span>
183 + <span className="num font-semibold text-right mono">{values[id].toFixed(1).replace(".", ",")} %</span>
167 184 </div>
168 185 ))}
169 186 </div>
@@ -174,19 +191,115 @@ export function Legend({ ids, className }: { ids: string[]; className?: string }
174 191 return (
175 192 <div className={cls("flex flex-wrap gap-x-4 gap-y-1 text-[12.5px]", className)}>
176 193 {ids.map((id) => (
177 <span key={id} className="inline-flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full inline-block" style={{ background: partyColor(id) }} />{partyLabel(id)}</span>
194 + <span key={id} className="inline-flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full inline-block" style={{ background: partyVar(id) }} />{partyLabel(id)}</span>
178 195 ))}
179 196 </div>
180 197 );
181 198 }
182 199
183 export function Sparkline({ values, color, width = 120, height = 32, className }: { values: number[]; color: string; width?: number; height?: number; className?: string }) {
200 +export function Sparkline({ values, color, width = 120, height = 32, className, fill = false }: { values: number[]; color: string; width?: number; height?: number; className?: string; fill?: boolean }) {
184 201 if (values.length < 2) return null;
185 202 const min = Math.min(...values), max = Math.max(...values), span = Math.max(0.001, max - min);
186 const pts = values.map((v, i) => `${(i / (values.length - 1)) * width},${height - ((v - min) / span) * (height - 4) - 2}`).join(" ");
187 return <svg width={width} height={height} className={className} aria-hidden><polyline points={pts} fill="none" stroke={color} strokeWidth={1.8} strokeLinejoin="round" /></svg>;
203 + const pts = values.map((v, i) => [(i / (values.length - 1)) * width, height - ((v - min) / span) * (height - 6) - 3] as const);
204 + const line = pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
205 + const last = pts[pts.length - 1];
206 + return (
207 + <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={className} aria-hidden>
208 + {fill && <polygon points={`0,${height} ${line} ${width},${height}`} fill={color} opacity={0.12} />}
209 + <polyline points={line} fill="none" stroke={color} strokeWidth={1.8} strokeLinejoin="round" strokeLinecap="round" />
210 + <circle cx={last[0]} cy={last[1]} r={2.5} fill={color} />
211 + </svg>
212 + );
188 213 }
189 214
190 215 export function Note({ children }: { children: ReactNode }) {
191 216 return <p className="text-[12px] text-ink-3 leading-relaxed mt-2">{children}</p>;
192 217 }
218 +
219 +// -------------------------------------------------------- DotComparison
220 +/** Points comparés (ex. P(plus de sièges) : sondages seuls · marchés · ensemble) par parti, sur une échelle 0–max. */
221 +export function DotComparison({ rows, series, className, max = 1, format = (v: number) => `${Math.round(v * 100)} %` }: {
222 + rows: { id: string; label: string; values: Record<string, number | null | undefined> }[];
223 + series: { key: string; label: string; shape: "dot" | "ring" | "diamond" }[];
224 + className?: string; max?: number; format?: (v: number) => string;
225 +}) {
226 + const W = 520, rowH = 38, pl = 56, pr = 16, H = rows.length * rowH + 16;
227 + const x = (v: number) => pl + (Math.max(0, Math.min(max, v)) / max) * (W - pl - pr);
228 + return (
229 + <div className={className}>
230 + <svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label="Comparaison des probabilités par source">
231 + {[0, 0.25, 0.5, 0.75, 1].map((t) => <line key={t} x1={x(t * max)} x2={x(t * max)} y1={4} y2={H - 12} stroke="var(--grid)" />)}
232 + {rows.map((r, i) => {
233 + const cy = i * rowH + rowH / 2 + 4;
234 + const vals = series.map((s) => r.values[s.key]).filter((v): v is number => typeof v === "number");
235 + const lo = Math.min(...vals), hi = Math.max(...vals);
236 + return (
237 + <g key={r.id}>
238 + <text x={pl - 10} y={cy + 4.5} textAnchor="end" fontSize={13} fontWeight={600} fill="var(--text)" fontFamily="var(--font-sans)">{r.label}</text>
239 + <line x1={pl} x2={W - pr} y1={cy} y2={cy} stroke="var(--grid)" />
240 + {vals.length > 1 && <line x1={x(lo)} x2={x(hi)} y1={cy} y2={cy} stroke={partyVar(r.id)} strokeWidth={2} opacity={0.5} />}
241 + {series.map((s) => {
242 + const v = r.values[s.key];
243 + if (typeof v !== "number") return null;
244 + const cx = x(v);
245 + if (s.shape === "ring") return <circle key={s.key} cx={cx} cy={cy} r={6} fill="var(--surface)" stroke={partyVar(r.id)} strokeWidth={2.2} />;
246 + if (s.shape === "diamond") return <rect key={s.key} x={cx - 5} y={cy - 5} width={10} height={10} transform={`rotate(45 ${cx} ${cy})`} fill={partyVar(r.id)} stroke="var(--surface)" strokeWidth={1.5} />;
247 + return <circle key={s.key} cx={cx} cy={cy} r={6.5} fill={partyVar(r.id)} stroke="var(--surface)" strokeWidth={2} />;
248 + })}
249 + </g>
250 + );
251 + })}
252 + {[0, 0.5, 1].map((t) => <text key={t} x={x(t * max)} y={H - 1} textAnchor="middle" fontSize={11} fill="var(--text-3)" fontFamily="var(--font-mono)">{format(t * max)}</text>)}
253 + </svg>
254 + <div className="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-[12px] text-ink-2">
255 + {series.map((s) => (
256 + <span key={s.key} className="inline-flex items-center gap-1.5">
257 + {s.shape === "ring" ? <span className="w-3 h-3 rounded-full border-2 border-ink-2 inline-block" /> : s.shape === "diamond" ? <span className="w-2.5 h-2.5 bg-ink-2 inline-block rotate-45" /> : <span className="w-3 h-3 rounded-full bg-ink-2 inline-block" />}
258 + {s.label}
259 + </span>
260 + ))}
261 + </div>
262 + </div>
263 + );
264 +}
265 +
266 +// ------------------------------------------------------------ DivergingBars
267 +/** Barres divergentes centrées sur zéro (ton médiatique −1…+1, momentum en pp), par parti. */
268 +export function DivergingBars({ values, max, format, className, ids = PARTY_IDS }: { values: Record<string, number | null | undefined>; max: number; format: (v: number) => string; className?: string; ids?: string[] }) {
269 + return (
270 + <div className={cls("space-y-1.5", className)}>
271 + {ids.map((id) => {
272 + const v = values[id];
273 + const has = typeof v === "number";
274 + const w = has ? Math.min(1, Math.abs(v) / max) * 50 : 0;
275 + return (
276 + <div key={id} className="grid grid-cols-[40px_1fr_64px] items-center gap-3 text-[12.5px]">
277 + <span className="font-semibold num inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span>
278 + <div className="relative h-[10px] bg-surface-3 rounded-[4px] overflow-hidden">
279 + <div className="absolute inset-y-0 left-1/2 w-px bg-ink-3 opacity-60" />
280 + {has && <div className="absolute inset-y-0 rounded-[3px]" style={{ left: v >= 0 ? "50%" : `${50 - w}%`, width: `${w}%`, background: partyVar(id) }} />}
281 + </div>
282 + <span className={cls("num text-right mono", has ? "font-semibold" : "text-ink-3")}>{has ? format(v) : "—"}</span>
283 + </div>
284 + );
285 + })}
286 + </div>
287 + );
288 +}
289 +
290 +// ------------------------------------------------------------- StackedShare
291 +/** Part (%) empilée horizontalement par parti (part de voix, part d'attention). */
292 +export function StackedShare({ values, className, height = 12 }: { values: Record<string, number | null | undefined>; className?: string; height?: number }) {
293 + const ids = PARTY_IDS.filter((id) => typeof values[id] === "number" && (values[id] as number) > 0);
294 + const tot = ids.reduce((a, id) => a + (values[id] as number), 0) || 1;
295 + return (
296 + <div className={className}>
297 + <div className="flex gap-[2px] rounded-[6px] overflow-hidden" style={{ height }}>
298 + {ids.map((id) => <div key={id} style={{ width: `${((values[id] as number) / tot) * 100}%`, background: partyVar(id) }} title={`${partyLabel(id)} ${(values[id] as number).toFixed(0)} %`} />)}
299 + </div>
300 + <div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1.5 text-[12px] num">
301 + {ids.map((id) => <span key={id} className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} /><b>{partyLabel(id)}</b> {Math.round(((values[id] as number) / tot) * 100)} %</span>)}
302 + </div>
303 + </div>
304 + );
305 +}
modified web/src/components/compass.tsx +7 −7
@@ -3,7 +3,7 @@
3 3 import Link from "next/link";
4 4 import { useEffect, useMemo, useState } from "react";
5 5 import { cls, prob } from "@/lib/format";
6 import { PARTY_IDS, partyColor, partyLabel, partyName } from "@/lib/parties";
6 +import { PARTY_IDS, partyVar, partyLabel, partyName } from "@/lib/parties";
7 7 import { PartyLogo } from "./party";
8 8 import { Badge, Card, ProbabilityBar, SectionHeader } from "./ui";
9 9
@@ -44,8 +44,8 @@ export function Compass({ data }: { data: CompassData }) {
44 44 <div className="mt-4 space-y-4">
45 45 {ranking.map((r) => (
46 46 <div key={r.id}>
47 <div className="flex items-center justify-between gap-3 mb-1"><span className="inline-flex items-center gap-2"><PartyLogo id={r.id} height={18} /><b>{partyName(r.id)}</b><span className="text-[11.5px] text-ink-3">{r.coverage} énoncés documentés</span></span><span className="num font-bold text-[22px]" style={{ color: partyColor(r.id) }}>{r.score === null ? "—" : `${Math.round(r.score)} %`}</span></div>
48 <ProbabilityBar p={(r.score ?? 0) / 100} color={partyColor(r.id)} showValue={false} height={12} />
47 + <div className="flex items-center justify-between gap-3 mb-1"><span className="inline-flex items-center gap-2"><PartyLogo id={r.id} height={18} /><b>{partyName(r.id)}</b><span className="text-[11.5px] text-ink-3">{r.coverage} énoncés documentés</span></span><span className="num font-bold text-[22px]" style={{ color: partyVar(r.id) }}>{r.score === null ? "—" : `${Math.round(r.score)} %`}</span></div>
48 + <ProbabilityBar p={(r.score ?? 0) / 100} color={partyVar(r.id)} showValue={false} height={12} />
49 49 </div>
50 50 ))}
51 51 </div>
@@ -56,7 +56,7 @@ export function Compass({ data }: { data: CompassData }) {
56 56 <div className="grid md:grid-cols-2 gap-3">
57 57 {Object.entries(data.axes).map(([axis, a]) => (
58 58 <Card key={axis}><div className="font-semibold text-[14px]">{a.label}</div><div className="flex justify-between text-[11.5px] text-ink-3 mt-1"><span>{a.left}</span><span>{a.right}</span></div>
59 <div className="mt-2 space-y-1">{ranking.filter((r) => r.axes[axis] !== undefined).map((r) => (<div key={r.id} className="grid grid-cols-[40px_1fr_44px] items-center gap-2 text-[12.5px]"><b style={{ color: partyColor(r.id) }}>{partyLabel(r.id)}</b><div className="h-[6px] bg-surface-3 rounded-full overflow-hidden"><div className="h-full" style={{ width: `${r.axes[axis]}%`, background: partyColor(r.id) }} /></div><span className="num text-right">{Math.round(r.axes[axis])} %</span></div>))}</div>
59 + <div className="mt-2 space-y-1">{ranking.filter((r) => r.axes[axis] !== undefined).map((r) => (<div key={r.id} className="grid grid-cols-[40px_1fr_44px] items-center gap-2 text-[12.5px]"><b style={{ color: partyVar(r.id) }}>{partyLabel(r.id)}</b><div className="h-[6px] bg-surface-3 rounded-full overflow-hidden"><div className="h-full" style={{ width: `${r.axes[axis]}%`, background: partyVar(r.id) }} /></div><span className="num text-right">{Math.round(r.axes[axis])} %</span></div>))}</div>
60 60 </Card>
61 61 ))}
62 62 </div>
@@ -71,9 +71,9 @@ export function Compass({ data }: { data: CompassData }) {
71 71 <div className="border-t border-border p-4 grid md:grid-cols-5 gap-3">
72 72 {PARTY_IDS.map((pid) => { const d = res.results[pid].details.find((dd) => dd.question_id === x.id); return (
73 73 <div key={pid} className="surface-2 rounded-[8px] p-3 text-[12.5px]">
74 <div className="flex items-center justify-between"><b style={{ color: partyColor(pid) }}>{partyLabel(pid)}</b>{d ? <span className="num">{Math.round(d.agreement * 100)} %</span> : <Badge>non documenté</Badge>}</div>
74 + <div className="flex items-center justify-between"><b style={{ color: partyVar(pid) }}>{partyLabel(pid)}</b>{d ? <span className="num">{Math.round(d.agreement * 100)} %</span> : <Badge>non documenté</Badge>}</div>
75 75 {d ? (<><div className="mt-1">Position : <b>{SCALE.find((s) => s[0] === d.party)?.[1]}</b> <span className="text-ink-3">(confiance {Math.round(d.confidence * 100)} %)</span></div>{d.rationale && <div className="text-ink-2 mt-1 leading-relaxed">{d.rationale}</div>}
76 <ul className="mt-2 space-y-1.5">{d.proposal_ids.slice(0, 3).map((pid2) => { const p = res.proposals[String(pid2)]; return p ? (<li key={pid2} className="border-l-2 pl-2" style={{ borderColor: partyColor(pid) }}><div className="italic text-ink-2">« {p.quote.slice(0, 220)}{p.quote.length > 220 ? "…" : ""} »</div><div className="text-[11.5px] text-ink-3 mt-0.5"><Link href={`/documents/${p.documentId}`} className="link">{p.documentTitle ?? "Document"}</Link>{p.page ? ` · p. ${p.page}` : ""} · confiance {Math.round(p.confidence * 100)} %</div></li>) : null; })}</ul></>) : <div className="text-ink-3 mt-1">Aucune proposition officielle ingérée ne permet de situer ce parti sur cet énoncé.</div>}
76 + <ul className="mt-2 space-y-1.5">{d.proposal_ids.slice(0, 3).map((pid2) => { const p = res.proposals[String(pid2)]; return p ? (<li key={pid2} className="border-l-2 pl-2" style={{ borderColor: partyVar(pid) }}><div className="italic text-ink-2">« {p.quote.slice(0, 220)}{p.quote.length > 220 ? "…" : ""} »</div><div className="text-[11.5px] text-ink-3 mt-0.5"><Link href={`/documents/${p.documentId}`} className="link">{p.documentTitle ?? "Document"}</Link>{p.page ? ` · p. ${p.page}` : ""} · confiance {Math.round(p.confidence * 100)} %</div></li>) : null; })}</ul></>) : <div className="text-ink-3 mt-1">Aucune proposition officielle ingérée ne permet de situer ce parti sur cet énoncé.</div>}
77 77 </div>); })}
78 78 </div>
79 79 )}
@@ -105,7 +105,7 @@ export function Compass({ data }: { data: CompassData }) {
105 105 </Card>
106 106 <div className="space-y-3">
107 107 <Card><div className="eyebrow mb-2">Progression par axe</div><div className="space-y-2">{Object.entries(data.axes).map(([axis, a]) => { const n = qs.filter((x) => x.axis === axis).length; return (<div key={axis} className="text-[12.5px]"><div className="flex justify-between"><span>{a.label}</span><span className="num text-ink-3">{byAxis[axis] ?? 0}/{n}</span></div><div className="h-[4px] bg-surface-3 rounded-full mt-1 overflow-hidden"><div className="h-full bg-accent" style={{ width: `${((byAxis[axis] ?? 0) / n) * 100}%` }} /></div></div>); })}</div></Card>
108 <Card><div className="eyebrow mb-2">Couverture documentaire</div><div className="space-y-1.5 text-[12.5px]">{PARTY_IDS.map((id) => (<div key={id} className="flex items-center justify-between"><span className="inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyColor(id) }} />{partyLabel(id)}</span><span className="num text-ink-2">{data.coverage[id]?.positions ?? 0} / {data.coverage[id]?.total ?? total} positions</span></div>))}</div><p className="text-[11.5px] text-ink-3 mt-2">Positions extraites des documents officiels ; sans document, pas de position. <Link href="/documents" className="link">Bibliothèque →</Link></p></Card>
108 + <Card><div className="eyebrow mb-2">Couverture documentaire</div><div className="space-y-1.5 text-[12.5px]">{PARTY_IDS.map((id) => (<div key={id} className="flex items-center justify-between"><span className="inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span><span className="num text-ink-2">{data.coverage[id]?.positions ?? 0} / {data.coverage[id]?.total ?? total} positions</span></div>))}</div><p className="text-[11.5px] text-ink-3 mt-2">Positions extraites des documents officiels ; sans document, pas de position. <Link href="/documents" className="link">Bibliothèque →</Link></p></Card>
109 109 <Card><div className="eyebrow mb-1">Grille de réponse</div><div className="grid grid-cols-8 gap-1 mt-2">{qs.map((x, i) => (<button key={x.id} onClick={() => setIdx(i)} aria-label={`Énoncé ${i + 1}`} className={cls("h-6 rounded-[4px] text-[10px] num", answers[x.id] !== undefined ? "bg-brand text-white" : i === idx ? "bg-accent-soft text-accent" : "bg-surface-2 text-ink-3")}>{i + 1}</button>))}</div></Card>
110 110 <div className="text-[11.5px] text-ink-3 px-1">{prob(answered / total)} des énoncés répondus. Minimum 5 pour un résultat.</div>
111 111 </div>
modified web/src/components/forecast-blocks.tsx +125 −83
@@ -1,8 +1,8 @@
1 1 import Link from "next/link";
2 2 import type { Forecast, Poll, RidingListItem } from "@/lib/api";
3 3 import { cls, dateShort, dateTimeFr, delta, int, pct, prob, ratingLabel } from "@/lib/format";
4 import { PARTY_IDS, party, partyColor, partyLabel, partyName, sortParties } from "@/lib/parties";
5 import { SeatDistribution } from "./charts";
4 +import { PARTY_IDS, party, partyAlpha, partyLabel, partyName, partyVar, sortParties } from "@/lib/parties";
5 +import { DotComparison, SeatDistribution, SeatWaffle } from "./charts";
6 6 import { PartyBadge, PartyDot, PartyLogo } from "./party";
7 7 import { Badge, Card, Metric, MetricDelta, ProbabilityBar, SectionHeader, SourceBadge } from "./ui";
8 8
@@ -19,17 +19,17 @@ export function SeatProjectionTable({ f, className }: { f: Forecast; className?:
19 19 {rows.map(([id, v]) => (
20 20 <tr key={id}>
21 21 <td><Link href={`/partis/${id}`} className="inline-flex items-center gap-2.5"><PartyLogo id={id} height={18} /><span className="font-semibold">{partyLabel(id)}</span></Link></td>
22 <td className="r num text-[18px] font-bold" style={{ color: partyColor(id) }}>{v.seatsMedian}</td>
23 <td className="r num">{v.seats80[0]}–{v.seats80[1]}</td>
24 <td className="r num text-ink-2">{v.seats95[0]}–{v.seats95[1]}</td>
22 + <td className="r figure text-[22px]" style={{ color: partyVar(id) }}>{v.seatsMedian}</td>
23 + <td className="r num mono">{v.seats80[0]}–{v.seats80[1]}</td>
24 + <td className="r num mono text-ink-2">{v.seats95[0]}–{v.seats95[1]}</td>
25 25 <td className="r num font-semibold">{prob(v.pMostSeats)}</td>
26 26 <td className="r num font-semibold">{prob(v.pMajority)}</td>
27 <td>{v.seatDistribution && <SeatDistribution dist={v.seatDistribution} color={partyColor(id)} majority={maj} median={v.seatsMedian} lo80={v.seats80[0]} hi80={v.seats80[1]} lo95={v.seats95[0]} hi95={v.seats95[1]} height={44} total={f.summary.seats_total} />}</td>
27 + <td>{v.seatDistribution && <SeatDistribution dist={v.seatDistribution} color={partyVar(id)} majority={maj} median={v.seatsMedian} lo80={v.seats80[0]} hi80={v.seats80[1]} lo95={v.seats95[0]} hi95={v.seats95[1]} height={44} total={f.summary.seats_total} />}</td>
28 28 </tr>
29 29 ))}
30 30 </tbody>
31 31 </table>
32 <p className="text-[12px] text-ink-3 mt-2">Médiane des {int(f.nSimulations)} simulations ; intervalles = percentiles 10–90 et 2,5–97,5. « Autres » n&apos;est jamais déclaré gagnant dans la version 1 du modèle.</p>
32 + <p className="text-[12px] text-ink-3 mt-2">Médiane pondérée des {int(f.nSimulations)} simulations ; intervalles = percentiles 10–90 et 2,5–97,5. « Autres » n&apos;est jamais déclaré gagnant.</p>
33 33 </div>
34 34 );
35 35 }
@@ -41,14 +41,14 @@ export function PopularVoteTable({ f, className }: { f: Forecast; className?: st
41 41 <div className={cls("space-y-2.5", className)}>
42 42 {rows.map(([id, v]) => (
43 43 <div key={id} className="grid grid-cols-[52px_1fr_auto] items-center gap-3">
44 <span className="font-semibold num text-[13.5px]" style={{ color: partyColor(id) }}>{partyLabel(id)}</span>
45 <div className="relative h-[16px] bg-surface-3 rounded-[4px] overflow-hidden">
46 <div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(v.vote95[0] / 50) * 100}%`, width: `${((v.vote95[1] - v.vote95[0]) / 50) * 100}%`, background: partyColor(id), opacity: 0.15 }} />
47 <div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(v.vote80[0] / 50) * 100}%`, width: `${((v.vote80[1] - v.vote80[0]) / 50) * 100}%`, background: partyColor(id), opacity: 0.3 }} />
44 + <span className="font-semibold num text-[13.5px] inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span>
45 + <div className="relative h-[14px] bg-surface-3 rounded-[4px] overflow-hidden">
46 + <div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(v.vote95[0] / 50) * 100}%`, width: `${((v.vote95[1] - v.vote95[0]) / 50) * 100}%`, background: partyAlpha(id, 0.15) }} />
47 + <div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(v.vote80[0] / 50) * 100}%`, width: `${((v.vote80[1] - v.vote80[0]) / 50) * 100}%`, background: partyAlpha(id, 0.35) }} />
48 48 <div className="absolute inset-y-0 w-[2px] bg-ink" style={{ left: `${(v.voteMean / 50) * 100}%` }} />
49 49 </div>
50 50 <div className="flex items-baseline gap-2 num">
51 <span className="font-bold text-[16px] w-[58px] text-right">{v.voteMean.toFixed(1).replace(".", ",")}</span>
51 + <span className="font-bold text-[16px] w-[58px] text-right mono">{v.voteMean.toFixed(1).replace(".", ",")}</span>
52 52 {ch?.[id] ? <MetricDelta value={ch[id].vote} /> : <span className="w-[54px]" />}
53 53 </div>
54 54 </div>
@@ -65,12 +65,12 @@ export function MajorityPanel({ f }: { f: Forecast }) {
65 65 {rows.map(([id, v]) => (
66 66 <div key={id}>
67 67 <div className="flex items-baseline justify-between mb-1 text-[13px]"><span className="font-semibold inline-flex items-center gap-1.5"><PartyDot id={id} />{partyName(id)}</span><span className="text-ink-2">plus de sièges {prob(v.pMostSeats)}</span></div>
68 <ProbabilityBar p={v.pMajority} color={partyColor(id)} label={`majorité ${partyLabel(id)}`} height={12} />
68 + <ProbabilityBar p={v.pMajority} color={partyVar(id)} label={`majorité ${partyLabel(id)}`} height={12} />
69 69 </div>
70 70 ))}
71 71 <div>
72 72 <div className="flex items-baseline justify-between mb-1 text-[13px]"><span className="font-semibold">Aucune majorité (gouvernement minoritaire)</span></div>
73 <ProbabilityBar p={f.summary.p_minority} color="#111827" label="minoritaire" height={12} />
73 + <ProbabilityBar p={f.summary.p_minority} color="var(--text)" label="minoritaire" height={12} />
74 74 </div>
75 75 </div>
76 76 );
@@ -82,21 +82,26 @@ export function WhatChanged({ f, ridingNames }: { f: Forecast; ridingNames?: Rec
82 82 const leader = f.summary.leader;
83 83 const ch = d.changes[leader];
84 84 const biggest = Object.entries(d.changes).sort((a, b) => Math.abs(b[1].p_majority) - Math.abs(a[1].p_majority))[0];
85 const cause = d.causes[0];
86 85 return (
87 <div className="grid md:grid-cols-[1fr_1.2fr] gap-6">
86 + <div className="grid md:grid-cols-[1fr_1.2fr] gap-8">
88 87 <div>
89 88 <div className="eyebrow">Majorité {partyLabel(leader)}</div>
90 <div className="num display text-[40px] mt-1">
91 <span className="text-ink-3">{prob(f.parties[leader].pMajority - (ch?.p_majority ?? 0))}</span> <span className="text-ink-3 text-[24px]">→</span> {prob(f.parties[leader].pMajority)}
89 + <div className="figure text-[44px] mt-2">
90 + <span className="text-ink-3">{prob(f.parties[leader].pMajority - (ch?.p_majority ?? 0))}</span> <span className="text-ink-3 text-[24px] serif">→</span> {prob(f.parties[leader].pMajority)}
92 91 </div>
93 <div className="mt-3 text-[13.5px]">
94 <div className="eyebrow mb-1">Cause principale</div>
95 {cause?.kind === "new_polls" ? (
96 <div>Nouveau{(cause.detail as string[]).length > 1 ? "x" : ""} sondage{(cause.detail as string[]).length > 1 ? "s" : ""} : {(cause.detail as string[]).map((id) => <Link key={id} href={`/sondages/${id}`} className="link mr-2">{id}</Link>)}</div>
97 ) : <div className="text-ink-2">{String(cause?.detail ?? "—")}</div>}
92 + <div className="mt-4 text-[13.5px]">
93 + <div className="eyebrow mb-1.5">Causes identifiées</div>
94 + <ul className="space-y-1">
95 + {d.causes.map((cause, i) => (
96 + <li key={i}>
97 + {cause.kind === "new_polls" ? <span>Nouveau{(cause.detail as string[]).length > 1 ? "x" : ""} sondage{(cause.detail as string[]).length > 1 ? "s" : ""} : {(cause.detail as string[]).map((id) => <Link key={id} href={`/sondages/${id}`} className="link mr-2">{id}</Link>)}</span>
98 + : cause.kind === "markets" ? <span>Marchés de prédiction : {Object.entries(cause.detail as unknown as Record<string, number>).map(([p, v]) => `${partyLabel(p)} ${delta(v * 100, 0, " pt")}`).join(" · ")}</span>
99 + : <span className="text-ink-2">{String(cause.detail ?? "—")}</span>}
100 + </li>
101 + ))}
102 + </ul>
98 103 </div>
99 <div className="text-[12px] text-ink-3 mt-3">Comparé au run précédent{d.previous_at ? ` (${dateTimeFr(d.previous_at)})` : ""}. Les causes listées sont les seules différences d&apos;entrée entre les deux runs ; QC26 n&apos;infère pas de causalité au-delà.</div>
104 + <div className="text-[12px] text-ink-3 mt-4">Comparé au run précédent{d.previous_at ? ` (${dateTimeFr(d.previous_at)})` : ""}. Les causes listées sont les seules différences d&apos;entrée entre les deux runs ; QC26 n&apos;infère pas de causalité au-delà.</div>
100 105 </div>
101 106 <div>
102 107 <div className="eyebrow mb-2">Variations par parti</div>
@@ -107,8 +112,8 @@ export function WhatChanged({ f, ridingNames }: { f: Forecast; ridingNames?: Rec
107 112 ))}
108 113 </tbody></table>
109 114 {d.flips.length > 0 && (
110 <div className="mt-3 text-[13px]">
111 <div className="eyebrow mb-1">Circonscriptions qui changent de favori</div>
115 + <div className="mt-4 text-[13px]">
116 + <div className="eyebrow mb-1.5">Circonscriptions qui changent de favori</div>
112 117 <ul className="space-y-1">
113 118 {d.flips.slice(0, 8).map((fl) => (
114 119 <li key={fl.code} className="flex items-center gap-2"><Link href={`/circonscription/${ridingNames?.[fl.code]?.slug ?? fl.code}`} className="link">{ridingNames?.[fl.code]?.name ?? fl.code}</Link><PartyBadge id={fl.from} size="sm" /> → <PartyBadge id={fl.to} size="sm" /> <span className="num text-ink-3">{prob(fl.p)}</span></li>
@@ -116,7 +121,7 @@ export function WhatChanged({ f, ridingNames }: { f: Forecast; ridingNames?: Rec
116 121 </ul>
117 122 </div>
118 123 )}
119 {biggest && biggest[0] !== leader && <div className="text-[12.5px] text-ink-2 mt-2">Autre mouvement notable : {partyLabel(biggest[0])} P(majorité) {delta(biggest[1].p_majority * 100, 0, " pt")}.</div>}
124 + {biggest && biggest[0] !== leader && <div className="text-[12.5px] text-ink-2 mt-3">Autre mouvement notable : {partyLabel(biggest[0])} P(majorité) {delta(biggest[1].p_majority * 100, 0, " pt")}.</div>}
120 125 </div>
121 126 </div>
122 127 );
@@ -127,17 +132,17 @@ export function LatestPolls({ polls, limit = 6, compact = false }: { polls: Poll
127 132 return (
128 133 <div className="overflow-x-auto">
129 134 <table className="data-table min-w-[560px]">
130 <thead><tr><th>Firme</th><th>Terrain</th><th className="r">n</th>{PARTY_IDS.map((id) => <th key={id} className="r" style={{ color: partyColor(id) }}>{partyLabel(id)}</th>)}{!compact && <th className="r">Poids</th>}<th></th></tr></thead>
135 + <thead><tr><th>Firme</th><th>Terrain</th><th className="r">n</th>{PARTY_IDS.map((id) => <th key={id} className="r"><span className="inline-flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span></th>)}{!compact && <th className="r">Poids</th>}<th></th></tr></thead>
131 136 <tbody>
132 137 {rows.map((p) => {
133 138 const lead = Object.entries(p.results).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1])[0]?.[0];
134 139 return (
135 140 <tr key={p.id}>
136 <td><Link href={`/sondages/${p.id}`} className="font-semibold hover:underline">{p.pollster.name}</Link>{p.sponsor && <span className="text-ink-3 text-[12px]"> · {p.sponsor}</span>}{!p.validated && <span className="ml-1.5 text-[10px] uppercase tracking-wide text-warn font-semibold" title="Document original en cours de vérification">index</span>}</td>
141 + <td><Link href={`/sondages/${p.id}`} className="font-semibold hover:underline">{p.pollster.name}</Link>{p.sponsor && <span className="text-ink-3 text-[12px]"> · {p.sponsor}</span>}{!p.validated && <span className="ml-1.5 text-[10px] uppercase tracking-wide text-warn font-semibold mono" title="Document original en cours de vérification">index</span>}</td>
137 142 <td className="num whitespace-nowrap">{p.fieldworkStart ? `${dateShort(p.fieldworkStart)} – ` : ""}{dateShort(p.fieldworkEnd)}</td>
138 143 <td className="r num">{int(p.sampleSize)}</td>
139 {PARTY_IDS.map((id) => <td key={id} className={cls("r num", id === lead && "font-bold")}>{p.results[id]?.toFixed(0) ?? "—"}</td>)}
140 {!compact && <td className="r num text-ink-2">{p.weight ? p.weight.weight.toFixed(2) : "—"}</td>}
144 + {PARTY_IDS.map((id) => <td key={id} className={cls("r num mono", id === lead && "font-bold")}>{p.results[id]?.toFixed(0) ?? "—"}</td>)}
145 + {!compact && <td className="r num text-ink-2 mono">{p.weight ? p.weight.weight.toFixed(2) : "—"}</td>}
141 146 <td className="r"><SourceBadge source={{ name: p.document ? "Document original (PDF de la firme)" : "Index tiers en attente du document original", url: p.sourceUrl, archiveUrl: p.document?.archiveUrl, retrievedAt: p.document?.downloadedAt, page: p.resultsPage, tier: p.sourceTier === "primary" ? "Primaire (firme)" : "Secondaire (index)", detail: p.methodology ?? undefined }} /></td>
142 147 </tr>
143 148 );
@@ -154,12 +159,12 @@ export function Battlegrounds({ ridings, codes, limit = 10 }: { ridings: RidingL
154 159 return (
155 160 <div className="grid sm:grid-cols-2 lg:grid-cols-5 gap-3">
156 161 {rows.map((r) => (
157 <Link key={r.code} href={`/circonscription/${r.slug}`} className="card p-3.5 hover:shadow-md transition-shadow block">
162 + <Link key={r.code} href={`/circonscription/${r.slug}`} className="card card-hover p-4 block" style={{ borderTop: `3px solid ${partyVar(r.forecast!.favorite)}` }}>
158 163 <div className="flex items-start justify-between gap-2"><div className="font-semibold text-[14px] leading-tight">{r.name}</div><Badge tone={r.forecast!.rating === "tossup" ? "warn" : "neutral"}>{ratingLabel(r.forecast!.rating)}</Badge></div>
159 164 <div className="text-[11.5px] text-ink-3 mt-0.5">{r.regionName}</div>
160 165 <div className="mt-3 space-y-1">
161 166 {Object.entries(r.forecast!.pWin).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id, p]) => (
162 <div key={id} className="flex items-center gap-2 text-[12.5px]"><span className="w-8 font-semibold num" style={{ color: partyColor(id) }}>{partyLabel(id)}</span><div className="flex-1 h-[6px] rounded-full bg-surface-3 overflow-hidden"><div className="h-full" style={{ width: `${p * 100}%`, background: partyColor(id) }} /></div><span className="num w-10 text-right">{prob(p)}</span></div>
167 + <div key={id} className="flex items-center gap-2 text-[12.5px]"><span className="w-8 font-semibold num" style={{ color: partyVar(id) }}>{partyLabel(id)}</span><div className="flex-1 h-[6px] rounded-full bg-surface-3 overflow-hidden"><div className="h-full" style={{ width: `${p * 100}%`, background: partyVar(id) }} /></div><span className="num w-10 text-right mono">{prob(p)}</span></div>
163 168 ))}
164 169 </div>
165 170 </Link>
@@ -176,12 +181,12 @@ export function RegionalGrid({ regions }: { regions: { id: string; name: string;
176 181 const top = Object.entries(g.voteNow).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1]).slice(0, 4);
177 182 return (
178 183 <div key={g.id} className="card p-4">
179 <div className="flex items-baseline justify-between"><div className="font-semibold">{g.name}</div><div className="text-[12px] text-ink-3 num">{g.ridings} circ.</div></div>
180 <div className="flex h-[8px] rounded-full overflow-hidden mt-2 bg-surface-3">{favs.map(([id, n]) => <div key={id} style={{ width: `${(n / g.ridings) * 100}%`, background: partyColor(id) }} title={`${partyLabel(id)} ${n}`} />)}</div>
181 <div className="flex flex-wrap gap-x-3 mt-1.5 text-[12px] num">{favs.map(([id, n]) => <span key={id}><b style={{ color: partyColor(id) }}>{partyLabel(id)}</b> {n}</span>)}</div>
184 + <div className="flex items-baseline justify-between"><div className="font-semibold">{g.name}</div><div className="text-[12px] text-ink-3 num mono">{g.ridings} circ.</div></div>
185 + <div className="flex gap-[2px] h-[8px] rounded-full overflow-hidden mt-2 bg-surface-3">{favs.map(([id, n]) => <div key={id} style={{ width: `${(n / g.ridings) * 100}%`, background: partyVar(id) }} title={`${partyLabel(id)} ${n}`} />)}</div>
186 + <div className="flex flex-wrap gap-x-3 mt-1.5 text-[12px] num">{favs.map(([id, n]) => <span key={id}><b style={{ color: partyVar(id) }}>{partyLabel(id)}</b> {n}</span>)}</div>
182 187 <div className="mt-3 space-y-1">
183 188 {top.map(([id, v]) => (
184 <div key={id} className="grid grid-cols-[34px_1fr_44px_54px] items-center gap-2 text-[12px]"><span className="font-semibold num" style={{ color: partyColor(id) }}>{partyLabel(id)}</span><div className="h-[5px] rounded-full bg-surface-3 overflow-hidden"><div className="h-full" style={{ width: `${v * 2}%`, background: partyColor(id) }} /></div><span className="num text-right font-semibold">{v.toFixed(0)} %</span><span className="num text-right text-ink-3">{delta(v - (g.vote2022[id] ?? 0), 0, " pt")}</span></div>
189 + <div key={id} className="grid grid-cols-[34px_1fr_44px_54px] items-center gap-2 text-[12px]"><span className="font-semibold num" style={{ color: partyVar(id) }}>{partyLabel(id)}</span><div className="h-[5px] rounded-full bg-surface-3 overflow-hidden"><div className="h-full" style={{ width: `${v * 2}%`, background: partyVar(id) }} /></div><span className="num text-right font-semibold mono">{v.toFixed(0)} %</span><span className="num text-right text-ink-3 mono">{delta(v - (g.vote2022[id] ?? 0), 0, " pt")}</span></div>
185 190 ))}
186 191 </div>
187 192 <div className="text-[11px] text-ink-3 mt-2">Vote projeté (moyenne des circonscriptions) · variation vs 2022 transposé</div>
@@ -192,66 +197,103 @@ export function RegionalGrid({ regions }: { regions: { id: string; name: string;
192 197 );
193 198 }
194 199
200 +// ------------------------------------------------------------------- HERO
195 201 export function HeroForecast({ f, meta }: { f: Forecast; meta: { seatsTotal: number; majority: number; daysToElection: number } }) {
196 202 const leader = f.summary.leader;
197 203 const lp = f.parties[leader];
198 204 const rows = sortParties(f.parties, (v) => v.seatsMean);
199 205 const ch = f.diff?.changes?.[leader];
206 + const seats = Object.fromEntries(Object.entries(f.parties).map(([k, v]) => [k, v.seatsMedian]));
207 + const comp = f.summary.components;
208 + const pollsOnly = comp?.pollsOnly?.parties?.[leader];
209 + const nSources = comp ? [comp.fundamentals?.enabled, comp.momentum?.enabled, comp.markets?.enabled, comp.byelections?.enabled].filter(Boolean).length : 0;
200 210 return (
201 <section className="grid lg:grid-cols-[1.1fr_1fr] gap-6 lg:gap-10 items-stretch">
202 <div className="card card-lg p-6 md:p-8 flex flex-col">
203 <div className="flex items-center justify-between flex-wrap gap-2">
204 <div className="eyebrow">QC26 Forecast · Québec · 5 octobre 2026</div>
205 <Badge tone="accent">Projection · Modèle {f.modelVersion}</Badge>
206 </div>
207 <div className="mt-6 flex items-end gap-6 flex-wrap">
208 <div>
209 <div className="text-[13px] text-ink-2 mb-1">Projection actuelle</div>
210 <div className="flex items-center gap-3"><PartyLogo id={leader} height={26} /><span className="text-[15px] font-semibold">{partyName(leader)}</span></div>
211 <div className="num display text-[84px] md:text-[112px] mt-1" style={{ color: partyColor(leader) }}>{lp.seatsMedian}</div>
212 <div className="text-[14px] text-ink-2 -mt-2">sièges sur {meta.seatsTotal} · intervalle 80 % <span className="num font-semibold text-ink">{lp.seats80[0]}–{lp.seats80[1]}</span></div>
211 + <section className="relative overflow-hidden rounded-[var(--radius-lg)] border border-border card hero-glow p-6 md:p-10">
212 + <div className="grid lg:grid-cols-[1.05fr_1fr] gap-10 items-start">
213 + <div className="fade-up">
214 + <div className="flex items-center gap-2 flex-wrap">
215 + <span className="eyebrow eyebrow-accent">Prévision QC26</span>
216 + <span className="text-ink-3 text-[11px] mono">·</span>
217 + <span className="eyebrow">Modèle {f.modelVersion} · run #{f.id}</span>
213 218 </div>
214 <div className="ml-auto grid grid-cols-2 gap-x-8 gap-y-4">
215 <Metric label="Majorité" value={prob(lp.pMajority)} size="lg" delta={ch ? <MetricDelta value={ch.p_majority * 100} digits={0} suffix=" pt" /> : undefined} sub={`seuil ${meta.majority}`} />
216 <Metric label="Plus de sièges" value={prob(lp.pMostSeats)} size="lg" delta={ch ? <MetricDelta value={ch.p_most_seats * 100} digits={0} suffix=" pt" /> : undefined} />
217 <Metric label="Vote populaire" value={pct(lp.voteMean)} size="md" delta={ch ? <MetricDelta value={ch.vote} /> : undefined} sub={`± ${lp.voteSd.toFixed(1).replace(".", ",")}`} />
218 <Metric label="Minoritaire" value={prob(f.summary.p_minority)} size="md" sub="aucun parti à 64" />
219 + <h1 className="display text-[36px] md:text-[54px] mt-4 max-w-[14ch]">
220 + Le <span style={{ color: partyVar(leader) }}>{partyLabel(leader)}</span> favori, {lp.pMajority >= 0.5 ? "une majorité plus probable qu'improbable" : lp.pMajority >= 0.3 ? "la majorité en jeu" : "un gouvernement minoritaire probable"}.
221 + </h1>
222 + <div className="mt-6 flex items-end gap-8 flex-wrap">
223 + <div>
224 + <div className="flex items-center gap-2 text-[13px] text-ink-2"><PartyLogo id={leader} height={20} /> {partyName(leader)}</div>
225 + <div className="figure text-[96px] md:text-[136px] mt-1" style={{ color: partyVar(leader) }}>{lp.seatsMedian}</div>
226 + <div className="text-[14px] text-ink-2 -mt-1">sièges sur {meta.seatsTotal} · 80 % : <span className="mono font-semibold text-ink">{lp.seats80[0]}–{lp.seats80[1]}</span></div>
227 + </div>
228 + <div className="grid grid-cols-2 gap-x-8 gap-y-5 mb-2">
229 + <Metric label="Majorité" value={prob(lp.pMajority)} size="lg" delta={ch ? <MetricDelta value={ch.p_majority * 100} digits={0} suffix=" pt" /> : undefined} sub={`seuil ${meta.majority}`} />
230 + <Metric label="Plus de sièges" value={prob(lp.pMostSeats)} size="lg" delta={ch ? <MetricDelta value={ch.p_most_seats * 100} digits={0} suffix=" pt" /> : undefined} />
231 + <Metric label="Vote populaire" value={pct(lp.voteMean)} size="md" delta={ch ? <MetricDelta value={ch.vote} /> : undefined} sub={`± ${lp.voteSd.toFixed(1).replace(".", ",")}`} />
232 + <Metric label="Minoritaire" value={prob(f.summary.p_minority)} size="md" sub={`aucun parti à ${meta.majority}`} />
233 + </div>
234 + </div>
235 + <div className="mt-8 flex flex-wrap items-center gap-x-5 gap-y-2 text-[12.5px] text-ink-3">
236 + <span className="num">Mis à jour {dateTimeFr(f.completedAt)}</span>
237 + <span className="num">{f.pollCount} sondages · {int(f.nSimulations)} simulations{comp?.ess ? ` · ESS ${int(comp.ess)}` : ""}</span>
238 + {nSources > 0 && <Link href="/signaux" className="link">{nSources} source{nSources > 1 ? "s" : ""} au-delà des sondages →</Link>}
239 + <Link href="/methodologie" className="link">Méthodologie</Link>
219 240 </div>
220 241 </div>
221 <div className="mt-auto pt-6 flex flex-wrap items-center gap-x-5 gap-y-2 text-[12.5px] text-ink-3">
222 <span className="num">Dernière mise à jour {dateTimeFr(f.completedAt)}</span>
223 <span className="num">{f.pollCount} sondages · {int(f.nSimulations)} simulations</span>
224 <span className="num">Dernier terrain {dateShort(f.summary.latest_poll)}</span>
225 <Link href="/methodologie" className="link">Méthodologie</Link>
226 </div>
227 </div>
228 <div className="card card-lg p-6 md:p-8">
229 <div className="eyebrow mb-4">Sièges projetés · médiane et intervalle 80 %</div>
230 <div className="space-y-4">
231 {rows.map(([id, v]) => (
232 <div key={id}>
233 <div className="flex items-baseline justify-between text-[13.5px] mb-1">
234 <span className="inline-flex items-center gap-2"><PartyLogo id={id} height={16} /><span className="font-semibold">{partyLabel(id)}</span><span className="text-ink-3 hidden sm:inline">{party(id)?.officialName}</span></span>
235 <span className="num"><b className="text-[18px]">{v.seatsMedian}</b> <span className="text-ink-3">{v.seats80[0]}–{v.seats80[1]}</span></span>
236 </div>
237 <div className="relative h-[14px] rounded-[4px] bg-surface-3 overflow-hidden">
238 <div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(v.seats95[0] / meta.seatsTotal) * 100}%`, width: `${((v.seats95[1] - v.seats95[0]) / meta.seatsTotal) * 100}%`, background: partyColor(id), opacity: 0.16 }} />
239 <div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(v.seats80[0] / meta.seatsTotal) * 100}%`, width: `${((v.seats80[1] - v.seats80[0]) / meta.seatsTotal) * 100}%`, background: partyColor(id), opacity: 0.45 }} />
240 <div className="absolute inset-y-0 w-[3px] rounded-sm" style={{ left: `calc(${(v.seatsMedian / meta.seatsTotal) * 100}% - 1px)`, background: partyColor(id) }} />
241 <div className="absolute inset-y-0 w-[1.5px] bg-ink" style={{ left: `${(meta.majority / meta.seatsTotal) * 100}%` }} />
242 + <div className="fade-up fade-up-2">
243 + <div className="flex items-center justify-between mb-3">
244 + <div className="eyebrow">Assemblée nationale · médiane projetée</div>
245 + <div className="text-[11px] mono text-ink-3">{meta.seatsTotal} sièges · majorité {meta.majority}</div>
246 + </div>
247 + <SeatWaffle seats={seats} total={meta.seatsTotal} majority={meta.majority} cols={16} />
248 + <div className="mt-5 space-y-3">
249 + {rows.map(([id, v]) => (
250 + <div key={id}>
251 + <div className="flex items-baseline justify-between text-[13px] mb-1">
252 + <span className="inline-flex items-center gap-2"><span className="w-2.5 h-2.5 rounded-[3px]" style={{ background: partyVar(id) }} /><span className="font-semibold">{partyLabel(id)}</span><span className="text-ink-3 hidden sm:inline">{party(id)?.officialName}</span></span>
253 + <span className="num"><b className="mono text-[16px]">{v.seatsMedian}</b> <span className="text-ink-3 mono text-[12px]">{v.seats80[0]}–{v.seats80[1]}</span>{pollsOnly && comp?.pollsOnly?.parties?.[id] && Math.abs(comp.pollsOnly.parties[id].seats - v.seatsMedian) >= 2 ? <span className="text-ink-3 text-[11px] ml-2" title="Sondages seuls">(sondages seuls {comp.pollsOnly.parties[id].seats})</span> : null}</span>
254 + </div>
255 + <div className="relative h-[10px] rounded-[4px] bg-surface-3 overflow-hidden">
256 + <div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(v.seats95[0] / meta.seatsTotal) * 100}%`, width: `${((v.seats95[1] - v.seats95[0]) / meta.seatsTotal) * 100}%`, background: partyAlpha(id, 0.18) }} />
257 + <div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(v.seats80[0] / meta.seatsTotal) * 100}%`, width: `${((v.seats80[1] - v.seats80[0]) / meta.seatsTotal) * 100}%`, background: partyAlpha(id, 0.5) }} />
258 + <div className="absolute inset-y-0 w-[3px] rounded-sm" style={{ left: `calc(${(v.seatsMedian / meta.seatsTotal) * 100}% - 1px)`, background: partyVar(id) }} />
259 + <div className="absolute inset-y-0 w-[1.5px] bg-ink" style={{ left: `${(meta.majority / meta.seatsTotal) * 100}%` }} />
260 + </div>
242 261 </div>
243 </div>
244 ))}
245 </div>
246 <div className="flex justify-between text-[11px] text-ink-3 num mt-2"><span>0</span><span className="font-bold text-ink">{meta.majority} · majorité</span><span>{meta.seatsTotal}</span></div>
247 <div className="mt-5 grid grid-cols-3 gap-3 text-center">
248 {rows.slice(0, 3).map(([id, v]) => (
249 <div key={id} className="rounded-[8px] surface-2 p-2.5"><div className="text-[11px] text-ink-3">P(majorité)</div><div className="num font-bold text-[18px]" style={{ color: partyColor(id) }}>{prob(v.pMajority)}</div><div className="text-[11px] font-semibold">{partyLabel(id)}</div></div>
250 ))}
262 + ))}
263 + </div>
264 + <div className="flex justify-between text-[11px] text-ink-3 mono mt-2"><span>0</span><span className="font-bold text-ink">{meta.majority} · majorité</span><span>{meta.seatsTotal}</span></div>
251 265 </div>
252 266 </div>
253 267 </section>
254 268 );
255 269 }
256 270
271 +// ------------------------------------------------------ BEYOND POLLS (accueil / prévision)
272 +export function ComponentsCompare({ f, compact = false }: { f: Forecast; compact?: boolean }) {
273 + const comp = f.summary.components;
274 + if (!comp) return null;
275 + const ids = PARTY_IDS.filter((id) => (f.parties[id]?.pMostSeats ?? 0) > 0.005 || (comp.pollsOnly.parties[id]?.p_most_seats ?? 0) > 0.005 || (comp.markets.mostSeatsMarket?.[id] ?? 0) > 0.005);
276 + const rows = ids.map((id) => ({ id, label: partyLabel(id), values: { polls: comp.pollsOnly.parties[id]?.p_most_seats, market: comp.markets.enabled ? comp.markets.mostSeatsMarket?.[id] : undefined, ensemble: f.parties[id]?.pMostSeats } }));
277 + const series = [{ key: "polls", label: "Sondages seuls", shape: "ring" as const }, ...(comp.markets.enabled ? [{ key: "market", label: "Marchés (Polymarket)", shape: "diamond" as const }] : []), { key: "ensemble", label: "Ensemble QC26", shape: "dot" as const }];
278 + const leader = f.summary.leader;
279 + return (
280 + <div className={cls("grid gap-6", !compact && "lg:grid-cols-[1.2fr_1fr]")}>
281 + <div>
282 + <div className="eyebrow mb-2">Probabilité d&apos;obtenir le plus de sièges</div>
283 + <DotComparison rows={rows} series={series} />
284 + </div>
285 + <div className="grid grid-cols-2 gap-3 content-start">
286 + <div className="inset p-3"><div className="text-[11px] text-ink-3">Majorité {partyLabel(leader)} · sondages seuls</div><div className="figure text-[26px] mt-1">{prob(comp.pollsOnly.parties[leader]?.p_majority)}</div></div>
287 + <div className="inset p-3"><div className="text-[11px] text-ink-3">Majorité {partyLabel(leader)} · marchés</div><div className="figure text-[26px] mt-1">{comp.markets.enabled && comp.markets.majorityMarket?.[leader] !== undefined ? prob(comp.markets.majorityMarket[leader]) : "—"}</div></div>
288 + <div className="inset p-3"><div className="text-[11px] text-ink-3">Majorité {partyLabel(leader)} · ensemble</div><div className="figure text-[26px] mt-1" style={{ color: partyVar(leader) }}>{prob(f.parties[leader].pMajority)}</div></div>
289 + <div className="inset p-3"><div className="text-[11px] text-ink-3">Poids des marchés</div><div className="figure text-[26px] mt-1">{comp.markets.enabled ? `${Math.round(comp.markets.weight * 100)} %` : "0 %"}</div></div>
290 + <div className="col-span-2 text-[12px] text-ink-3 leading-relaxed">
291 + Fondamentaux {comp.fundamentals.enabled ? `poids ${Math.round(comp.fundamentals.weight * 100)} % (satisfaction ${String(comp.fundamentals.satisfaction).replace(".", ",")} %)` : "inactifs"} · momentum {comp.momentum.enabled ? Object.entries(comp.momentum.shiftPp).filter(([k]) => k !== "aut").map(([k, v]) => `${partyLabel(k)} ${delta(v, 1)}`).join(", ") : "inactif"} · partielles {comp.byelections.enabled ? `${comp.byelections.ridings.length} circonscriptions` : "aucune"}.
292 + {!compact && <> <Link href="/signaux" className="link">Voir tous les signaux →</Link></>}
293 + </div>
294 + </div>
295 + </div>
296 + );
297 +}
298 +
257 299 export { SectionHeader, Card };
modified web/src/components/forecast-history.tsx +3 −3
@@ -2,7 +2,7 @@
2 2
3 3 import { useEffect, useMemo, useState } from "react";
4 4 import { dateFr, dateTimeFr, prob } from "@/lib/format";
5 import { PARTY_IDS, partyColor, partyLabel } from "@/lib/parties";
5 +import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";
6 6 import { SeatMeter } from "./charts";
7 7
8 8 interface Run { id: number; date: string; completedAt: string | null; modelVersion: string; pollCount: number; parties: Record<string, { vote: number; seats: number; seats80: [number, number]; pMajority: number; pMostSeats: number }>; pMinority: number }
@@ -30,7 +30,7 @@ export function ForecastHistory({ majority, total }: { majority: number; total:
30 30 <div className="mt-4"><SeatMeter seats={Object.fromEntries(PARTY_IDS.map((id) => [id, run.parties[id]?.seats ?? 0]))} total={total} majority={majority} /></div>
31 31 <div className="grid grid-cols-2 md:grid-cols-5 gap-2 mt-4">
32 32 {PARTY_IDS.slice().sort((a, b) => (run.parties[b]?.seats ?? 0) - (run.parties[a]?.seats ?? 0)).map((id) => (
33 <div key={id} className="surface-2 rounded-[8px] p-2.5 text-[12.5px]"><b style={{ color: partyColor(id) }}>{partyLabel(id)}</b><div className="num mt-1"><b className="text-[18px]">{run.parties[id]?.seats}</b> <span className="text-ink-3">{run.parties[id]?.seats80?.join("–")}</span></div><div className="num text-ink-2">{run.parties[id]?.vote?.toFixed(1)} % · maj. {prob(run.parties[id]?.pMajority)}</div></div>
33 + <div key={id} className="surface-2 rounded-[8px] p-2.5 text-[12.5px]"><b style={{ color: partyVar(id) }}>{partyLabel(id)}</b><div className="num mt-1"><b className="text-[18px]">{run.parties[id]?.seats}</b> <span className="text-ink-3">{run.parties[id]?.seats80?.join("–")}</span></div><div className="num text-ink-2">{run.parties[id]?.vote?.toFixed(1)} % · maj. {prob(run.parties[id]?.pMajority)}</div></div>
34 34 ))}
35 35 </div>
36 36 {path && (
@@ -38,7 +38,7 @@ export function ForecastHistory({ majority, total }: { majority: number; total:
38 38 <div className="eyebrow mb-1">P(majorité) par run</div>
39 39 <svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label="Probabilité de majorité par run">
40 40 {[0, 0.5, 1].map((p) => <line key={p} x1={8} x2={W - 8} y1={6 + (1 - p) * (H - 12)} y2={6 + (1 - p) * (H - 12)} stroke="var(--border)" />)}
41 {path.map((p) => <path key={p.id} d={p.d} fill="none" stroke={partyColor(p.id)} strokeWidth={2} />)}
41 + {path.map((p) => <path key={p.id} d={p.d} fill="none" stroke={partyVar(p.id)} strokeWidth={2} />)}
42 42 <line x1={8 + (i / Math.max(1, runs.length - 1)) * (W - 16)} x2={8 + (i / Math.max(1, runs.length - 1)) * (W - 16)} y1={0} y2={H} stroke="var(--text)" strokeDasharray="3 3" />
43 43 </svg>
44 44 </div>
modified web/src/components/live.tsx +7 −7
@@ -4,7 +4,7 @@ import Link from "next/link";
4 4 import { useEffect, useMemo, useRef, useState } from "react";
5 5 import type { Forecast, LiveState, Meta } from "@/lib/api";
6 6 import { cls, dateTimeFr, int, pct, prob, relTime, statusLabel, timeFr } from "@/lib/format";
7 import { PARTY_IDS, partyColor, partyLabel, partyName } from "@/lib/parties";
7 +import { PARTY_IDS, partyVar, partyLabel, partyName } from "@/lib/parties";
8 8 import { SeatMeter } from "./charts";
9 9 import { MapSection } from "./map-section";
10 10 import { PartyBadge, PartyDot, PartyLogo } from "./party";
@@ -76,15 +76,15 @@ export function LiveHome({ meta, forecast, initial }: { meta: Meta; forecast: Fo
76 76 <div key={id} className="grid grid-cols-[86px_1fr_auto] items-center gap-3 text-[13.5px]">
77 77 <PartyLogo id={id} height={16} />
78 78 <div className="text-ink-2 num"><b className="text-ink">{seatsNow[id]?.elected ?? 0}</b> élus · <b className="text-ink">{seatsNow[id]?.leading ?? 0}</b> en avance</div>
79 <div className="num font-bold text-[22px]" style={{ color: partyColor(id) }}>{totalNow(id)}</div>
79 + <div className="num font-bold text-[22px]" style={{ color: partyVar(id) }}>{totalNow(id)}</div>
80 80 </div>
81 81 ))}
82 82 </div>
83 83 {nc && (
84 84 <div className="mt-5 pt-4 border-t border-border">
85 85 <div className="flex items-baseline justify-between"><div className="eyebrow">Probabilité de majorité {partyLabel(leaderNow)} · Nowcast QC26</div><ProjectionBadge>Nowcast</ProjectionBadge></div>
86 <div className="num display text-[44px] mt-1" style={{ color: partyColor(leaderNow) }}>{prob(nc.parties[leaderNow]?.p_majority)}</div>
87 <ProbabilityBar p={nc.parties[leaderNow]?.p_majority ?? 0} color={partyColor(leaderNow)} height={14} showValue={false} />
86 + <div className="num display text-[44px] mt-1" style={{ color: partyVar(leaderNow) }}>{prob(nc.parties[leaderNow]?.p_majority)}</div>
87 + <ProbabilityBar p={nc.parties[leaderNow]?.p_majority ?? 0} color={partyVar(leaderNow)} height={14} showValue={false} />
88 88 <div className="text-[12px] text-ink-3 mt-1 num">Minoritaire {prob(nc.p_minority)} · couverture {pct(nc.coverage * 100, 0)} · {relTime(nc.ts)}</div>
89 89 </div>
90 90 )}
@@ -103,7 +103,7 @@ export function LiveHome({ meta, forecast, initial }: { meta: Meta; forecast: Fo
103 103 <td className="r num text-ink-2">{forecast.parties[id].seatsMedian} <span className="text-ink-3 text-[11px]">({forecast.parties[id].seats80[0]}–{forecast.parties[id].seats80[1]})</span></td>
104 104 <td className="r num">{seatsNow[id]?.elected ?? 0}</td>
105 105 <td className="r num">{seatsNow[id]?.leading ?? 0}</td>
106 <td className="r num font-bold text-[16px]" style={{ color: partyColor(id) }}>{totalNow(id)}</td>
106 + <td className="r num font-bold text-[16px]" style={{ color: partyVar(id) }}>{totalNow(id)}</td>
107 107 <td className="r num">{nc ? `${nc.parties[id]?.seats_median} ` : "—"}{nc && <span className="text-ink-3 text-[11px]">({nc.parties[id]?.seats80[0]}–{nc.parties[id]?.seats80[1]})</span>}</td>
108 108 <td className="r num">{nc ? prob(nc.parties[id]?.p_majority) : "—"}</td>
109 109 </tr>
@@ -143,7 +143,7 @@ export function Ticker({ events, limit = 30 }: { events: LiveEvent[]; limit?: nu
143 143 {events.slice(0, limit).map((e) => (
144 144 <li key={e.id} className="ticker-in flex gap-3 px-4 py-2.5 border-b border-border last:border-0 text-[13px]">
145 145 <span className="num text-ink-3 shrink-0 w-[60px]">{timeFr(e.ts).slice(0, 5)}</span>
146 <span className={cls("shrink-0 w-2 h-2 rounded-full mt-1.5", e.kind === "lead_change" ? "bg-warn" : e.kind === "riding_complete" ? "bg-ok" : e.kind === "quarantine" ? "bg-live" : "bg-ink-3")} style={e.party ? { background: partyColor(e.party) } : undefined} />
146 + <span className={cls("shrink-0 w-2 h-2 rounded-full mt-1.5", e.kind === "lead_change" ? "bg-warn" : e.kind === "riding_complete" ? "bg-ok" : e.kind === "quarantine" ? "bg-live" : "bg-ink-3")} style={e.party ? { background: partyVar(e.party) } : undefined} />
147 147 <span className="min-w-0">{e.ridingCode ? <Link href={`/circonscription/${e.ridingCode}`} className="hover:underline">{e.message}</Link> : e.message}</span>
148 148 </li>
149 149 ))}
@@ -170,7 +170,7 @@ export function MajorityTimeline() {
170 170 <Card>
171 171 <svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label="Évolution de la probabilité de majorité par parti">
172 172 {[0, 0.5, 1].map((p) => (<g key={p}><line x1={pl} x2={W - 8} y1={y(p)} y2={y(p)} stroke="var(--border)" /><text x={pl - 4} y={y(p) + 3} fontSize={9.5} textAnchor="end" fill="var(--text-3)">{Math.round(p * 100)}</text></g>))}
173 {PARTY_IDS.map((id) => (<polyline key={id} fill="none" stroke={partyColor(id)} strokeWidth={2} points={hist.map((h, i) => `${x(i)},${y(h.parties[id]?.pMajority ?? 0)}`).join(" ")} />))}
173 + {PARTY_IDS.map((id) => (<polyline key={id} fill="none" stroke={partyVar(id)} strokeWidth={2} points={hist.map((h, i) => `${x(i)},${y(h.parties[id]?.pMajority ?? 0)}`).join(" ")} />))}
174 174 <polyline fill="none" stroke="var(--text)" strokeWidth={1.5} strokeDasharray="3 3" points={hist.map((h, i) => `${x(i)},${y(h.pMinority ?? 0)}`).join(" ")} />
175 175 {[0, Math.floor(hist.length / 2), hist.length - 1].map((i) => (<text key={i} x={x(i)} y={H - 6} fontSize={9.5} textAnchor="middle" fill="var(--text-3)" className="num">{timeFr(hist[i].ts).slice(0, 5)}</text>))}
176 176 </svg>
modified web/src/components/map-section.tsx +2 −2
@@ -4,7 +4,7 @@ import type { FeatureCollection, Geometry } from "geojson";
4 4 import Link from "next/link";
5 5 import { useEffect, useMemo, useState } from "react";
6 6 import { cls } from "@/lib/format";
7 import { PARTY_IDS, partyColor, partyLabel } from "@/lib/parties";
7 +import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";
8 8 import { QuebecMap, type MapMode, type MapRiding } from "./map";
9 9
10 10 const MODES: { id: MapMode; label: string; hint: string }[] = [
@@ -58,7 +58,7 @@ export function MapSection({ height = 560, compact = false, initialMode = "proje
58 58 <QuebecMap geo={geo} ridings={ridings} mode={mode} height={height} compact={compact} interactive />
59 59 <div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-3 text-[12.5px]">
60 60 {PARTY_IDS.filter((id) => counts[id]).sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0)).map((id) => (
61 <span key={id} className="inline-flex items-center gap-1.5 num"><span className="w-2.5 h-2.5 rounded-[3px]" style={{ background: partyColor(id) }} /><b>{partyLabel(id)}</b> {counts[id]}</span>
61 + <span key={id} className="inline-flex items-center gap-1.5 num"><span className="w-2.5 h-2.5 rounded-[3px]" style={{ background: partyVar(id) }} /><b>{partyLabel(id)}</b> {counts[id]}</span>
62 62 ))}
63 63 <span className="text-ink-3">{mode === "live" ? "En tête / élu" : mode === "2022" ? "Sièges 2022 transposés" : "Favoris (probabilité, pas certitude)"}</span>
64 64 <Link href="/carte" className="link ml-auto text-[12.5px]">Carte complète →</Link>
modified web/src/components/map.tsx +16 −16
@@ -5,7 +5,7 @@ import type { FeatureCollection, Geometry } from "geojson";
5 5 import { useRouter } from "next/navigation";
6 6 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7 7 import { cls, pct, prob, ratingLabel, statusLabel } from "@/lib/format";
8 import { partyColor, partyLabel, withAlpha } from "@/lib/parties";
8 +import { partyAlpha, partyLabel, partyVar } from "@/lib/parties";
9 9
10 10 export type MapMode = "projection" | "probabilite" | "marge" | "variation" | "2022" | "live";
11 11
@@ -29,24 +29,24 @@ interface Props {
29 29 }
30 30
31 31 function fillFor(r: MapRiding | undefined, mode: MapMode): string {
32 if (!r) return "#E5E7EB";
32 + if (!r) return "var(--p-none)";
33 33 if (mode === "live") {
34 34 const l = r.live;
35 if (!l || l.status === "NOT_STARTED" || !l.leader) return "#E5E7EB";
35 + if (!l || l.status === "NOT_STARTED" || !l.leader) return "var(--p-none)";
36 36 const strong = l.status === "ELECTED" || l.status === "FINAL" || l.status === "PROJECTED";
37 return strong ? partyColor(l.leader) : withAlpha(partyColor(l.leader), 0.45);
37 + return strong ? partyVar(l.leader) : partyAlpha(l.leader, 0.45);
38 38 }
39 if (mode === "2022") return r.baseline2022 ? partyColor(r.baseline2022) : "#E5E7EB";
39 + if (mode === "2022") return r.baseline2022 ? partyVar(r.baseline2022) : "var(--p-none)";
40 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));
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 45 if (mode === "variation") {
46 if (r.baseline2022 && r.baseline2022 !== f.favorite) return partyColor(f.favorite);
47 return withAlpha(partyColor(f.favorite), 0.18);
46 + if (r.baseline2022 && r.baseline2022 !== f.favorite) return partyVar(f.favorite);
47 + return partyAlpha(f.favorite, 0.18);
48 48 }
49 return partyColor(f.favorite);
49 + return partyVar(f.favorite);
50 50 }
51 51
52 52 export function QuebecMap({ geo, ridings, mode, height = 560, interactive = true, focus = null, className, onSelect, compact = false }: Props) {
@@ -140,7 +140,7 @@ export function QuebecMap({ geo, ridings, mode, height = 560, interactive = true
140 140 const renderPath = (code: number, d: string, key?: string) => {
141 141 const rr = ridings[code];
142 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}
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 144 className={interactive ? "cursor-pointer" : undefined} onMouseEnter={() => setHover(code)} onMouseLeave={() => setHover(null)}
145 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 146 onKeyDown={(e) => { if (e.key === "Enter") select(code); }} onFocus={() => setHover(code)} onBlur={() => setHover(null)} />
@@ -181,16 +181,16 @@ export function QuebecMap({ geo, ridings, mode, height = 560, interactive = true
181 181 {mode === "live" && r.live ? (
182 182 <div className="mt-1 space-y-0.5">
183 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>
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 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 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 187 </div>
188 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>
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 190 ) : r.forecast ? (
191 191 <div className="mt-1 space-y-0.5">
192 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>
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 194 ))}
195 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 196 </div>
modified web/src/components/party.tsx +3 −3
@@ -1,5 +1,5 @@
1 1 import Image from "next/image";
2 import { OTHERS, party, partyColor, partyLabel, partyName } from "@/lib/parties";
2 +import { OTHERS, party, partyVar, partyLabel, partyName } from "@/lib/parties";
3 3 import { cls } from "@/lib/format";
4 4
5 5 /** Logo officiel du parti, jamais déformé ni recoloré. Le PCQ (logo blanc) est posé sur sa pastille marine officielle. */
@@ -23,7 +23,7 @@ export function PartyLogo({ id, height = 20, className, white = false }: { id: s
23 23
24 24 /** Pastille sigle + couleur (accessibilité : texte toujours présent). */
25 25 export function PartyBadge({ id, className, size = "md" }: { id: string | null | undefined; className?: string; size?: "sm" | "md" | "lg" }) {
26 const color = partyColor(id);
26 + const color = partyVar(id);
27 27 const label = partyLabel(id);
28 28 const sz = size === "sm" ? "text-[10px] px-1.5 py-[2px]" : size === "lg" ? "text-sm px-2.5 py-1" : "text-[11px] px-2 py-[3px]";
29 29 return (
@@ -34,7 +34,7 @@ export function PartyBadge({ id, className, size = "md" }: { id: string | null |
34 34 }
35 35
36 36 export function PartyDot({ id, size = 8, className }: { id: string | null | undefined; size?: number; className?: string }) {
37 return <span aria-hidden className={cls("inline-block rounded-full shrink-0", className)} style={{ width: size, height: size, background: partyColor(id) }} />;
37 + return <span aria-hidden className={cls("inline-block rounded-full shrink-0", className)} style={{ width: size, height: size, background: partyVar(id) }} />;
38 38 }
39 39
40 40 export function PartyText({ id, full = false, className }: { id: string | null | undefined; full?: boolean; className?: string }) {
modified web/src/components/polls-table.tsx +3 −3
@@ -4,7 +4,7 @@ import Link from "next/link";
4 4 import { useMemo, useState } from "react";
5 5 import type { Poll } from "@/lib/api";
6 6 import { cls, dateShort, int, modeLabel } from "@/lib/format";
7 import { PARTY_IDS, partyColor, partyLabel } from "@/lib/parties";
7 +import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";
8 8
9 9 export function PollsTable({ polls }: { polls: Poll[] }) {
10 10 const [firm, setFirm] = useState("all");
@@ -23,7 +23,7 @@ export function PollsTable({ polls }: { polls: Poll[] }) {
23 23 </div>
24 24 <div className="overflow-x-auto max-h-[70vh]">
25 25 <table className="data-table min-w-[820px]">
26 <thead><tr><th>Firme</th><th>Terrain</th><th className="r">n</th>{PARTY_IDS.map((id) => <th key={id} className="r" style={{ color: partyColor(id) }}>{partyLabel(id)}</th>)}<th className="r">Autres</th><th className="r">Poids</th><th>Source</th></tr></thead>
26 + <thead><tr><th>Firme</th><th>Terrain</th><th className="r">n</th>{PARTY_IDS.map((id) => <th key={id} className="r" style={{ color: partyVar(id) }}>{partyLabel(id)}</th>)}<th className="r">Autres</th><th className="r">Poids</th><th>Source</th></tr></thead>
27 27 <tbody>
28 28 {rows.map((p) => {
29 29 const lead = Object.entries(p.results).filter(([k]) => k !== "aut").sort((a, b) => b[1] - a[1])[0]?.[0];
@@ -32,7 +32,7 @@ export function PollsTable({ polls }: { polls: Poll[] }) {
32 32 <td><Link href={`/sondages/${p.id}`} className="font-semibold hover:underline">{p.pollster.name}</Link>{p.sponsor && <div className="text-[11.5px] text-ink-3">{p.sponsor}</div>}</td>
33 33 <td className="num whitespace-nowrap"><Link href={`/sondages/${p.id}`}>{p.fieldworkStart ? `${dateShort(p.fieldworkStart)} – ` : ""}{dateShort(p.fieldworkEnd)}</Link></td>
34 34 <td className="r num">{int(p.sampleSize)}</td>
35 {PARTY_IDS.map((id) => <td key={id} className={cls("r num", id === lead && "font-bold")} style={id === lead ? { color: partyColor(id) } : undefined}>{p.results[id] !== undefined ? p.results[id].toFixed(p.results[id] % 1 ? 1 : 0).replace(".", ",") : "—"}</td>)}
35 + {PARTY_IDS.map((id) => <td key={id} className={cls("r num", id === lead && "font-bold")} style={id === lead ? { color: partyVar(id) } : undefined}>{p.results[id] !== undefined ? p.results[id].toFixed(p.results[id] % 1 ? 1 : 0).replace(".", ",") : "—"}</td>)}
36 36 <td className="r num text-ink-2">{p.results["aut"] !== undefined ? p.results["aut"].toFixed(0) : "—"}</td>
37 37 <td className="r num text-ink-2">{p.weight ? p.weight.weight.toFixed(2) : "—"}</td>
38 38 <td className="whitespace-nowrap">{p.sourceTier === "primary" ? <span className="text-[10.5px] font-semibold uppercase tracking-wide text-ok">PDF archivé</span> : <span className="text-[10.5px] font-semibold uppercase tracking-wide text-warn" title="Valeur relevée par un index tiers ; document original en cours de récupération">index</span>}{p.excluded && <span className="ml-1 text-[10.5px] uppercase text-live">exclu</span>}</td>
modified web/src/components/replay.tsx +2 −2
@@ -2,7 +2,7 @@
2 2
3 3 import { useEffect, useMemo, useState } from "react";
4 4 import { prob, timeFr } from "@/lib/format";
5 import { PARTY_IDS, partyColor, partyLabel } from "@/lib/parties";
5 +import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";
6 6 import { SeatMeter } from "./charts";
7 7 import { MapSection } from "./map-section";
8 8 import { Card, Empty } from "./ui";
@@ -28,7 +28,7 @@ export function Replay() {
28 28 <div className="flex items-center gap-3 flex-wrap"><button onClick={() => setPlaying((p) => !p)} className="rounded-[8px] bg-brand text-white px-3 py-1.5 text-[13px] font-semibold">{playing ? "Pause" : "▶ Lecture"}</button><input type="range" min={0} max={snaps.length - 1} value={i} onChange={(e) => { setPlaying(false); setI(Number(e.target.value)); }} className="flex-1 min-w-[200px]" aria-label="Moment" /><span className="num text-[13.5px]"><b>{timeFr(cur.fetchedAt)}</b> · {cur.reporting} circ. rapportent{cur.simulated ? " · simulation" : ""}{cur.final ? " · FINAL" : ""}</span></div>
29 29 <div className="flex justify-between text-[11px] text-ink-3 num mt-1"><span>{timeFr(snaps[0].fetchedAt)}</span><span>{timeFr(snaps[snaps.length - 1].fetchedAt)}</span></div>
30 30 <div className="mt-4"><SeatMeter seats={seats} total={127} majority={state?.nowcast?.majority ?? 64} /></div>
31 {state?.nowcast && <div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mt-4">{PARTY_IDS.map((id) => (<div key={id} className="surface-2 rounded-[8px] p-2.5 text-[12.5px]"><b style={{ color: partyColor(id) }}>{partyLabel(id)}</b><div className="num mt-0.5">en tête <b>{state.nowcast!.parties[id]?.total_now ?? 0}</b> · nowcast {state.nowcast!.parties[id]?.seats_median}</div><div className="num text-ink-2">P(maj) {prob(state.nowcast!.parties[id]?.p_majority)}</div></div>))}</div>}
31 + {state?.nowcast && <div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mt-4">{PARTY_IDS.map((id) => (<div key={id} className="surface-2 rounded-[8px] p-2.5 text-[12.5px]"><b style={{ color: partyVar(id) }}>{partyLabel(id)}</b><div className="num mt-0.5">en tête <b>{state.nowcast!.parties[id]?.total_now ?? 0}</b> · nowcast {state.nowcast!.parties[id]?.seats_median}</div><div className="num text-ink-2">P(maj) {prob(state.nowcast!.parties[id]?.p_majority)}</div></div>))}</div>}
32 32 </Card>
33 33 <Card pad={false} className="p-3 md:p-4"><MapSection height={560} live liveState={liveState} showModes={false} /></Card>
34 34 </div>
modified web/src/components/riding-live.tsx +2 −2
@@ -2,7 +2,7 @@
2 2
3 3 import { useEffect, useState } from "react";
4 4 import { int, pct, prob, statusLabel, timeFr } from "@/lib/format";
5 import { partyColor, partyLabel } from "@/lib/parties";
5 +import { partyVar, partyLabel } from "@/lib/parties";
6 6 import { PartyDot } from "./party";
7 7 import { Badge, Card, LiveBadge, SectionHeader } from "./ui";
8 8
@@ -27,7 +27,7 @@ export function RidingLive({ code, initial, forecast }: { code: number; initial:
27 27 {(live.candidates ?? []).map((c, i) => (
28 28 <div key={c.name} className="grid grid-cols-[1fr_120px_70px_60px] items-center gap-3 text-[13.5px]">
29 29 <span className="inline-flex items-center gap-2 truncate"><PartyDot id={c.party_id} />{c.name} <span className="text-ink-3">{partyLabel(c.party_id)}</span>{i === 0 && live.leader && <span className="text-[10.5px] uppercase font-semibold text-ink-2">en tête</span>}</span>
30 <div className="h-[12px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="h-full" style={{ width: `${total ? (c.votes / total) * 100 : 0}%`, background: partyColor(c.party_id) }} /></div>
30 + <div className="h-[12px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="h-full" style={{ width: `${total ? (c.votes / total) * 100 : 0}%`, background: partyVar(c.party_id) }} /></div>
31 31 <span className="num text-right">{int(c.votes)}</span><span className="num text-right font-semibold">{pct(total ? (100 * c.votes) / total : 0)}</span>
32 32 </div>
33 33 ))}
modified web/src/components/riding-map.tsx +2 −2
@@ -25,12 +25,12 @@ export function RidingMap({ code }: { code: number }) {
25 25 return (
26 26 <div className="grid grid-cols-2">
27 27 <svg viewBox={`0 0 ${svg.W} ${svg.H}`} className="w-full h-auto bg-surface-2" role="img" aria-label="Localisation au Québec">
28 {svg.all.map((d, i) => <path key={i} d={d} fill="#E5E7EB" stroke="#fff" strokeWidth={0.5} />)}
28 + {svg.all.map((d, i) => <path key={i} d={d} fill="var(--p-none)" stroke="var(--surface)" strokeWidth={0.5} />)}
29 29 <path d={svg.me} fill="var(--qc26-accent)" stroke="var(--qc26-accent)" strokeWidth={4} strokeLinejoin="round" />
30 30 <rect x={svg.box[0] - 8} y={svg.box[1] - 8} width={svg.box[2] + 16} height={svg.box[3] + 16} fill="none" stroke="var(--text)" strokeWidth={1} strokeDasharray="3 2" />
31 31 </svg>
32 32 <svg viewBox={`0 0 ${svg.W} ${svg.H}`} className="w-full h-auto bg-surface border-l border-border" role="img" aria-label="Zoom sur la circonscription">
33 {svg.neighbors.map((d, i) => <path key={i} d={d} fill="#F1F3F5" stroke="#D5D9DF" strokeWidth={0.8} />)}
33 + {svg.neighbors.map((d, i) => <path key={i} d={d} fill="var(--surface-3)" stroke="var(--border-strong)" strokeWidth={0.8} />)}
34 34 <path d={svg.localMe} fill="rgba(37,99,235,.18)" stroke="var(--qc26-accent)" strokeWidth={2} />
35 35 </svg>
36 36 </div>
modified web/src/components/ridings-grid.tsx +3 −3
@@ -4,7 +4,7 @@ import Link from "next/link";
4 4 import { useMemo, useState } from "react";
5 5 import type { RidingListItem } from "@/lib/api";
6 6 import { cls, prob, ratingLabel } from "@/lib/format";
7 import { PARTY_IDS, partyColor, partyLabel } from "@/lib/parties";
7 +import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";
8 8 import { PartyBadge } from "./party";
9 9 import { Badge } from "./ui";
10 10
@@ -47,8 +47,8 @@ export function RidingsGrid({ ridings, regions, initialSort = "nom" }: { ridings
47 47 <div className="mt-3 space-y-1">
48 48 {pw.slice(0, 5).map(([id, p]) => (
49 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: partyColor(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: partyColor(id) }} /></div>
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 52 <span className="num text-right">{prob(p)}</span>
53 53 </div>
54 54 ))}
modified web/src/components/shell.tsx +50 −38
@@ -5,61 +5,73 @@ import { usePathname, useRouter } from "next/navigation";
5 5 import { useCallback, useEffect, useRef, useState } from "react";
6 6 import { cls, daysLabel } from "@/lib/format";
7 7 import { partyLabel } from "@/lib/parties";
8 +import { ThemeToggle } from "./theme";
8 9
9 10 const NAV = [
10 11 { href: "/prevision", label: "Prévision" },
12 + { href: "/signaux", label: "Signaux" },
11 13 { href: "/sondages", label: "Sondages" },
12 14 { href: "/carte", label: "Carte" },
13 15 { href: "/circonscriptions", label: "Circonscriptions" },
14 { href: "/boussole", label: "Boussole" },
15 16 { href: "/partis", label: "Partis" },
16 { href: "/enjeux", label: "Enjeux" },
17 { href: "/methodologie", label: "Méthodologie" },
17 + { href: "/boussole", label: "Boussole" },
18 + { href: "/methodologie", label: "Méthode" },
18 19 ];
19 20
20 21 export function Wordmark({ className, size = 22 }: { className?: string; size?: number }) {
21 22 return (
22 <span className={cls("inline-flex items-baseline font-bold tracking-tight select-none", className)} style={{ fontSize: size, letterSpacing: "-0.04em" }} aria-label="QC26">
23 + <span className={cls("inline-flex items-baseline select-none display", className)} style={{ fontSize: size, letterSpacing: "-0.03em", fontWeight: 700 }} aria-label="QC26">
23 24 <span>QC</span>
24 <span className="mx-[3px] inline-block w-[2px] self-stretch bg-accent rounded-sm translate-y-[1px]" style={{ height: size * 0.82 }} aria-hidden />
25 + <span className="mx-[3px] inline-block w-[3px] self-stretch bg-accent rounded-sm translate-y-[1px]" style={{ height: size * 0.78 }} aria-hidden />
25 26 <span className="text-accent">26</span>
26 27 </span>
27 28 );
28 29 }
29 30
30 export function TopNav({ daysToElection, mode }: { daysToElection: number; mode: "forecast" | "live" | "archive" }) {
31 +export function TopNav({ daysToElection, mode, modelVersion }: { daysToElection: number; mode: "forecast" | "live" | "archive"; modelVersion?: string }) {
31 32 const path = usePathname();
32 33 const [paletteOpen, setPaletteOpen] = useState(false);
34 + const [scrolled, setScrolled] = useState(false);
33 35 useEffect(() => {
34 36 const onKey = (e: KeyboardEvent) => {
35 37 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setPaletteOpen((v) => !v); }
36 38 if (e.key === "Escape") setPaletteOpen(false);
37 39 };
40 + const onScroll = () => setScrolled(window.scrollY > 8);
38 41 window.addEventListener("keydown", onKey);
39 return () => window.removeEventListener("keydown", onKey);
42 + window.addEventListener("scroll", onScroll, { passive: true });
43 + return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("scroll", onScroll); };
40 44 }, []);
41 45 const liveNav = mode !== "forecast" ? [{ href: "/live", label: mode === "live" ? "Direct" : "Résultats" }] : [];
42 46 return (
43 47 <>
44 <header className="sticky top-0 z-40 bg-[rgba(247,248,250,.85)] backdrop-blur-md border-b border-border" style={{ height: "var(--header-h)" }}>
45 <div className="mx-auto max-w-[1280px] h-full px-4 md:px-6 flex items-center gap-4">
46 <Link href="/" className="shrink-0 flex items-center" aria-label="QC26 — accueil"><Wordmark /></Link>
47 <nav className="hidden lg:flex items-center gap-0.5 ml-4" aria-label="Navigation principale">
48 + <header className={cls("sticky top-0 z-40 glass border-b transition-colors", scrolled ? "border-border" : "border-transparent")} style={{ height: "var(--header-h)" }}>
49 + <div className="mx-auto max-w-[1320px] h-full px-4 md:px-6 flex items-center gap-4">
50 + <Link href="/" className="shrink-0 flex items-center gap-3" aria-label="QC26 — accueil">
51 + <Wordmark />
52 + <span className="hidden xl:inline text-[11px] mono text-ink-3 uppercase tracking-[0.1em] border-l border-border pl-3">Québec 2026</span>
53 + </Link>
54 + <nav className="hidden lg:flex items-center gap-0.5 ml-2" aria-label="Navigation principale">
48 55 {[...liveNav, ...NAV].map((n) => {
49 56 const active = path === n.href || (n.href !== "/" && path.startsWith(n.href));
50 57 return (
51 <Link key={n.href} href={n.href} className={cls("px-3 py-1.5 rounded-[7px] text-[13.5px] font-medium transition-colors", active ? "bg-brand text-white" : "text-ink-2 hover:text-ink hover:bg-surface-2", n.href === "/live" && !active && "text-live")}>
58 + <Link key={n.href} href={n.href} className={cls("relative px-3 py-1.5 rounded-[8px] text-[13.5px] font-medium transition-colors", active ? "text-ink" : "text-ink-2 hover:text-ink hover:bg-surface-2", n.href === "/live" && !active && "text-live")}>
52 59 {n.href === "/live" && mode === "live" ? <span className="inline-flex items-center gap-1.5"><span className="live-dot" />DIRECT</span> : n.label}
60 + {active && <span className="absolute left-3 right-3 -bottom-[2px] h-[2px] rounded-full bg-accent" aria-hidden />}
53 61 </Link>
54 62 );
55 63 })}
56 64 </nav>
57 65 <div className="ml-auto flex items-center gap-2">
58 <button onClick={() => setPaletteOpen(true)} className="hidden md:inline-flex items-center gap-2 text-[12.5px] text-ink-2 border border-border rounded-[7px] px-2.5 py-1.5 hover:bg-surface-2" aria-label="Rechercher (⌘K)">
59 <span aria-hidden>⌕</span> Rechercher <kbd className="mono text-[10.5px] border border-border rounded px-1 text-ink-3">⌘K</kbd>
66 + <button onClick={() => setPaletteOpen(true)} className="hidden md:inline-flex items-center gap-2 text-[12.5px] text-ink-2 border border-border rounded-[9px] px-2.5 py-1.5 hover:bg-surface-2 transition-colors" aria-label="Rechercher (⌘K)">
67 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
68 + Rechercher <kbd className="mono text-[10.5px] border border-border rounded px-1 text-ink-3">⌘K</kbd>
69 + </button>
70 + <button onClick={() => setPaletteOpen(true)} className="md:hidden w-9 h-9 inline-flex items-center justify-center rounded-[9px] border border-border" aria-label="Rechercher">
71 + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
60 72 </button>
61 <button onClick={() => setPaletteOpen(true)} className="md:hidden w-9 h-9 inline-flex items-center justify-center rounded-[7px] border border-border" aria-label="Rechercher">⌕</button>
62 <ElectionChip days={daysToElection} mode={mode} />
73 + <ThemeToggle />
74 + <ElectionChip days={daysToElection} mode={mode} modelVersion={modelVersion} />
63 75 </div>
64 76 </div>
65 77 </header>
@@ -68,13 +80,13 @@ export function TopNav({ daysToElection, mode }: { daysToElection: number; mode:
68 80 );
69 81 }
70 82
71 function ElectionChip({ days, mode }: { days: number; mode: string }) {
72 if (mode === "live") return <Link href="/live" className="inline-flex items-center gap-1.5 rounded-[7px] bg-live text-white px-2.5 py-1.5 text-[12px] font-bold tracking-wide"><span className="live-dot !bg-white" /> DIRECT</Link>;
73 if (mode === "archive") return <span className="inline-flex items-center rounded-[7px] bg-brand text-white px-2.5 py-1.5 text-[11.5px] font-bold tracking-wide uppercase">Archive · 5 oct. 2026</span>;
83 +function ElectionChip({ days, mode, modelVersion }: { days: number; mode: string; modelVersion?: string }) {
84 + if (mode === "live") return <Link href="/live" className="inline-flex items-center gap-1.5 rounded-[9px] bg-live text-white px-2.5 py-1.5 text-[12px] font-bold tracking-wide"><span className="live-dot !bg-white" /> DIRECT</Link>;
85 + if (mode === "archive") return <span className="inline-flex items-center rounded-[9px] bg-brand text-ink-inverse px-2.5 py-1.5 text-[11.5px] font-bold tracking-wide uppercase">Archive · 5 oct. 2026</span>;
74 86 return (
75 <span className="inline-flex items-center gap-2 rounded-[7px] border border-border bg-surface px-2.5 py-1.5 text-[11.5px] font-semibold tracking-wide uppercase text-ink-2">
76 <span className="hidden sm:inline">Élection · 5 oct. 2026</span>
77 <span className="num text-ink font-bold">{daysLabel(days)}</span>
87 + <span className="inline-flex items-center gap-2 rounded-[9px] border border-border bg-surface px-2.5 py-1.5 text-[11px] mono uppercase tracking-[0.08em] text-ink-2" title={modelVersion ? `Modèle ${modelVersion}` : undefined}>
88 + <span className="hidden sm:inline">5 oct. 2026</span>
89 + <span className="text-ink font-bold">{daysLabel(days)}</span>
78 90 </span>
79 91 );
80 92 }
@@ -82,21 +94,21 @@ function ElectionChip({ days, mode }: { days: number; mode: string }) {
82 94 export function MobileNav({ mode }: { mode: "forecast" | "live" | "archive" }) {
83 95 const path = usePathname();
84 96 const items = [
85 { href: "/", label: "Accueil", icon: "◉" },
86 mode === "forecast" ? { href: "/prevision", label: "Prévision", icon: "◭" } : { href: "/live", label: mode === "live" ? "Direct" : "Résultats", icon: "●" },
87 { href: "/carte", label: "Carte", icon: "◫" },
88 { href: "/boussole", label: "Boussole", icon: "◎" },
89 { href: "/plus", label: "Plus", icon: "≡" },
97 + { href: "/", label: "Accueil", icon: <path d="M3 11.5 12 4l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" /> },
98 + mode === "forecast" ? { href: "/prevision", label: "Prévision", icon: <path d="M3 20h18M5 17V9M10 17V4M15 17v-6M20 17v-3" /> } : { href: "/live", label: mode === "live" ? "Direct" : "Résultats", icon: <circle cx="12" cy="12" r="5" /> },
99 + { href: "/signaux", label: "Signaux", icon: <path d="M2 12h4l3-8 4 16 3-8h6" /> },
100 + { href: "/carte", label: "Carte", icon: <path d="M3 6l6-2 6 2 6-2v14l-6 2-6-2-6 2zM9 4v14M15 6v14" /> },
101 + { href: "/plus", label: "Plus", icon: <path d="M4 7h16M4 12h16M4 17h16" /> },
90 102 ];
91 103 return (
92 <nav className="lg:hidden fixed bottom-0 inset-x-0 z-40 bg-[rgba(255,255,255,.92)] backdrop-blur-md border-t border-border safe-bottom" style={{ height: "calc(var(--bottom-nav-h) + env(safe-area-inset-bottom))" }} aria-label="Navigation mobile">
93 <div className="grid grid-cols-5 h-[60px]">
104 + <nav className="lg:hidden fixed bottom-0 inset-x-0 z-40 border-t border-border safe-bottom" style={{ height: "calc(var(--bottom-nav-h) + env(safe-area-inset-bottom))", background: "var(--nav-bg)", backdropFilter: "blur(14px)", WebkitBackdropFilter: "blur(14px)" }} aria-label="Navigation mobile">
105 + <div className="grid grid-cols-5 h-[62px]">
94 106 {items.map((it) => {
95 107 const active = it.href === "/" ? path === "/" : path.startsWith(it.href);
96 108 const live = it.href === "/live" && mode === "live";
97 109 return (
98 <Link key={it.href} href={it.href} className={cls("flex flex-col items-center justify-center gap-0.5 text-[10.5px] font-semibold", active ? "text-brand" : "text-ink-3", live && "text-live")}>
99 <span className={cls("text-[17px] leading-none", live && "live-dot !w-[10px] !h-[10px] mb-1")} aria-hidden>{live ? "" : it.icon}</span>
110 + <Link key={it.href} href={it.href} className={cls("flex flex-col items-center justify-center gap-1 text-[10.5px] font-semibold transition-colors", active ? "text-accent" : "text-ink-3", live && "text-live")}>
111 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden>{it.icon}</svg>
100 112 {it.label}
101 113 </Link>
102 114 );
@@ -125,7 +137,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
125 137 }, [q]);
126 138 const items: { label: string; sub?: string; href: string }[] = [];
127 139 const shortcuts = [
128 { label: "Prévision", href: "/prevision" }, { label: "Sondages", href: "/sondages" }, { label: "Carte électorale", href: "/carte" }, { label: "Boussole électorale", href: "/boussole" },
140 + { label: "Prévision", href: "/prevision" }, { label: "Au-delà des sondages (signaux)", href: "/signaux" }, { label: "Sondages", href: "/sondages" }, { label: "Carte électorale", href: "/carte" }, { label: "Boussole électorale", href: "/boussole" },
129 141 { label: "Comparateur des partis", href: "/partis/comparateur" }, { label: "Méthodologie", href: "/methodologie" }, { label: "Statut des données", href: "/statut" },
130 142 ];
131 143 if (!res) items.push(...shortcuts.filter((s) => !q || s.label.toLowerCase().includes(q.toLowerCase())));
@@ -140,22 +152,22 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
140 152 }
141 153 const go = useCallback((href: string) => { onClose(); router.push(href); }, [onClose, router]);
142 154 return (
143 <div className="fixed inset-0 z-50 bg-[rgba(17,19,24,.35)] backdrop-blur-[2px] flex items-start justify-center pt-[10vh] px-4" onClick={onClose} role="dialog" aria-modal aria-label="Recherche">
144 <div className="w-full max-w-[620px] card card-lg overflow-hidden shadow-md" onClick={(e) => e.stopPropagation()}>
145 <input ref={ref} value={q} onChange={(e) => { setQ(e.target.value); setSel(0); }} placeholder="Circonscription, candidat·e, firme, enjeu, parti…" className="w-full px-4 py-3.5 text-[16px] outline-none bg-transparent border-b border-border"
155 + <div className="fixed inset-0 z-50 bg-[rgba(10,12,16,.45)] backdrop-blur-[3px] flex items-start justify-center pt-[10vh] px-4" onClick={onClose} role="dialog" aria-modal aria-label="Recherche">
156 + <div className="w-full max-w-[640px] card card-lg overflow-hidden shadow-lg fade-up" onClick={(e) => e.stopPropagation()}>
157 + <input ref={ref} value={q} onChange={(e) => { setQ(e.target.value); setSel(0); }} placeholder="Circonscription, candidat·e, firme, enjeu, parti…" className="w-full px-5 py-4 text-[16px] outline-none bg-transparent border-b border-border"
146 158 onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); setSel((s) => Math.min(items.length - 1, s + 1)); } if (e.key === "ArrowUp") { e.preventDefault(); setSel((s) => Math.max(0, s - 1)); } if (e.key === "Enter" && items[sel]) go(items[sel].href); }} />
147 159 <ul className="max-h-[50vh] overflow-auto py-1">
148 160 {items.length === 0 && <li className="px-4 py-6 text-ink-3 text-sm text-center">Aucun résultat.</li>}
149 161 {items.slice(0, 30).map((it, i) => (
150 162 <li key={it.href + i}>
151 <button onClick={() => go(it.href)} onMouseEnter={() => setSel(i)} className={cls("w-full text-left px-4 py-2.5 flex items-center justify-between gap-3", i === sel && "bg-surface-2")}>
163 + <button onClick={() => go(it.href)} onMouseEnter={() => setSel(i)} className={cls("w-full text-left px-5 py-2.5 flex items-center justify-between gap-3", i === sel && "bg-surface-2")}>
152 164 <span className="truncate text-[14px]">{it.label}</span>
153 {it.sub && <span className="text-[11.5px] text-ink-3 shrink-0">{it.sub}</span>}
165 + {it.sub && <span className="text-[11.5px] text-ink-3 shrink-0 mono uppercase tracking-wide">{it.sub}</span>}
154 166 </button>
155 167 </li>
156 168 ))}
157 169 </ul>
158 <div className="px-4 py-2 border-t border-border text-[11px] text-ink-3 flex gap-3"><span>↑↓ naviguer</span><span>↵ ouvrir</span><span>esc fermer</span></div>
170 + <div className="px-5 py-2 border-t border-border text-[11px] text-ink-3 flex gap-3 mono"><span>↑↓ naviguer</span><span>↵ ouvrir</span><span>esc fermer</span></div>
159 171 </div>
160 172 </div>
161 173 );
added web/src/components/signals.tsx +284 −0
@@ -0,0 +1,284 @@
1 +import Link from "next/link";
2 +import type { AttentionPayload, ByElectionsPayload, ForecastComponents, FundamentalsPayload, MarketsPayload, MediaPayload } from "@/lib/api";
3 +import { cls, dateFr, dateShort, delta, int, pct, prob, relTime } from "@/lib/format";
4 +import { PARTY_IDS, partyAlpha, partyLabel, partyName, partyVar } from "@/lib/parties";
5 +import { DivergingBars, Sparkline, StackedShare } from "./charts";
6 +import { PartyBadge, PartyDot } from "./party";
7 +import { Badge, SourceBadge, SourceChip } from "./ui";
8 +
9 +const fmtPp = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v).toFixed(2).replace(".", ",")} pt`;
10 +const fmtTone = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v).toFixed(2).replace(".", ",")}`;
11 +
12 +// ------------------------------------------------------------- MARCHÉS
13 +export function MarketsPanel({ m, comp, compact = false }: { m: MarketsPayload; comp?: ForecastComponents | null; compact?: boolean }) {
14 + const most = Object.entries(m.mostSeats).sort((a, b) => b[1] - a[1]);
15 + const hist = m.history ?? {};
16 + const asOf = m.asOf;
17 + return (
18 + <div className="space-y-5">
19 + <div className="flex flex-wrap items-center gap-2">
20 + <SourceChip name="Polymarket" at={asOf} tone={asOf ? "ok" : "warn"} />
21 + <span className="text-[12px] text-ink-3 num">volume cumulé {int(m.volume)} $ US · {m.markets.length} marchés suivis</span>
22 + <span className="ml-auto"><SourceBadge source={{ name: "Polymarket — Quebec General Election (API Gamma publique, prix « Oui »)", url: "https://polymarket.com/event/quebec-general-election-winner", retrievedAt: asOf, tier: "Tier 3 · marché de prédiction (signal)", detail: "Prix renormalisés entre partis pour « plus de sièges » ; historique quotidien archivé." }} /></span>
23 + </div>
24 + <div className={cls("grid gap-4", compact ? "md:grid-cols-2" : "lg:grid-cols-3")}>
25 + <div className="inset p-4">
26 + <div className="eyebrow mb-3">Plus de sièges · probabilité implicite</div>
27 + <div className="space-y-2.5">
28 + {most.filter(([, p]) => p >= 0.003).map(([id, p]) => {
29 + const series = (hist[`most_seats:${id}`] ?? []).map((x) => x.p);
30 + return (
31 + <div key={id} className="grid grid-cols-[38px_1fr_52px_84px] items-center gap-3 text-[13px]">
32 + <span className="font-semibold inline-flex items-center gap-1.5"><PartyDot id={id} />{partyLabel(id)}</span>
33 + <div className="h-[8px] rounded-full bg-surface-3 overflow-hidden"><div className="h-full rounded-full" style={{ width: `${p * 100}%`, background: partyVar(id) }} /></div>
34 + <span className="mono font-semibold text-right">{prob(p)}</span>
35 + <Sparkline values={series.slice(-60)} color={partyVar(id)} width={84} height={22} />
36 + </div>
37 + );
38 + })}
39 + </div>
40 + <div className="text-[11px] text-ink-3 mt-3">Étincelles : 60 derniers jours du prix « Oui ».</div>
41 + </div>
42 + <div className="inset p-4">
43 + <div className="eyebrow mb-3">Majorité &amp; classement</div>
44 + {Object.entries(m.majority).map(([id, p]) => (
45 + <div key={id} className="flex items-center justify-between text-[13px] mb-2"><span className="inline-flex items-center gap-1.5"><PartyDot id={id} />Majorité {partyLabel(id)}</span><span className="mono font-semibold">{prob(p)}</span></div>
46 + ))}
47 + {[2, 3].map((pl) => {
48 + const rows = Object.entries(m.place?.[String(pl)] ?? m.place?.[pl as unknown as string] ?? {}).sort((a, b) => b[1] - a[1]).slice(0, 3);
49 + if (!rows.length) return null;
50 + return <div key={pl} className="mt-3"><div className="text-[11px] text-ink-3 mb-1">{pl}e place</div><div className="flex flex-wrap gap-1.5">{rows.map(([id, p]) => <span key={id} className="inline-flex items-center gap-1.5 text-[12px] rounded-[6px] border border-border px-2 py-[3px]"><PartyDot id={id} />{partyLabel(id)} <b className="mono">{prob(p)}</b></span>)}</div></div>;
51 + })}
52 + </div>
53 + <div className="inset p-4">
54 + <div className="eyebrow mb-3">Tranches de sièges · marché vs QC26</div>
55 + {Object.entries(m.seatBands).map(([id, sb]) => (
56 + <div key={id} className="mb-3">
57 + <div className="flex items-baseline justify-between text-[12.5px]"><span className="font-semibold inline-flex items-center gap-1.5"><PartyDot id={id} />{partyLabel(id)}</span><span className="text-ink-3 mono">moy. implicite {String(sb.impliedMean).replace(".", ",")}</span></div>
58 + <div className="flex gap-[2px] mt-1 h-[16px]">
59 + {sb.bands.map((b, i) => <div key={i} className="relative rounded-[3px] overflow-hidden bg-surface-3" style={{ flex: 1 }} title={`${b.lo}${b.hi !== null ? `–${b.hi}` : "+"} : ${prob(b.p)}`}><div className="absolute bottom-0 inset-x-0" style={{ height: `${Math.min(100, b.p * 100 * 1.6)}%`, background: partyAlpha(id, 0.8) }} /></div>)}
60 + </div>
61 + <div className="flex justify-between text-[10px] mono text-ink-3 mt-0.5"><span>{sb.bands[0]?.lo === 0 ? `< ${(sb.bands[0]?.hi ?? 0) + 1}` : sb.bands[0]?.lo}</span><span>{sb.bands[sb.bands.length - 1]?.lo}+</span></div>
62 + </div>
63 + ))}
64 + {comp?.markets?.enabled && <div className="text-[11px] text-ink-3">Tempérage QC26 : poids {Math.round(comp.markets.weight * 100)} % (tranches {Math.round((comp.markets.bandWeight ?? 0) * 100)} %), ESS {int(comp.markets.ess)}.</div>}
65 + </div>
66 + </div>
67 + {!compact && (
68 + <div className="overflow-x-auto">
69 + <table className="data-table min-w-[720px]">
70 + <thead><tr><th>Marché</th><th>Type</th><th className="r">Oui</th><th className="r">Achat / vente</th><th className="r">Volume</th><th className="r">Liquidité</th><th></th></tr></thead>
71 + <tbody>
72 + {m.markets.slice(0, 40).map((r) => (
73 + <tr key={r.id}><td className="max-w-[420px]">{r.party && <PartyBadge id={r.party} size="sm" className="mr-2" />}{r.question}</td><td><Badge>{r.kind === "most_seats" ? "plus de sièges" : r.kind === "majority" ? "majorité" : r.kind === "seats_band" ? "tranche" : `${r.place}e place`}</Badge></td><td className="r mono font-semibold">{prob(r.price)}</td><td className="r mono text-ink-2">{r.bid !== null && r.ask !== null ? `${Math.round(r.bid * 100)} / ${Math.round(r.ask * 100)}` : "—"}</td><td className="r mono">{int(r.volume)} $</td><td className="r mono text-ink-2">{int(r.liquidity)} $</td><td className="r">{r.url && <a href={r.url} target="_blank" rel="noopener noreferrer" className="link text-[12px]">ouvrir</a>}</td></tr>
74 + ))}
75 + </tbody>
76 + </table>
77 + </div>
78 + )}
79 + </div>
80 + );
81 +}
82 +
83 +// ------------------------------------------------------------ FONDAMENTAUX
84 +export function FundamentalsPanel({ f, comp }: { f: FundamentalsPayload; comp?: ForecastComponents | null }) {
85 + const satSeries = (f.series?.satisfaction ?? []).filter((x) => x.party === f.incumbent);
86 + return (
87 + <div className="space-y-5">
88 + <div className="flex flex-wrap items-center gap-2">
89 + <SourceChip name="Rapports de sondage · questions de leadership" at={f.asOf} tone="ok" />
90 + <span className="text-[12px] text-ink-3">{f.satisfactionPolls} sondages avec question de satisfaction (150 j) · pondération récence (demi-vie 30 j) × √n</span>
91 + </div>
92 + <div className="grid md:grid-cols-3 gap-4">
93 + <div className="inset p-4">
94 + <div className="eyebrow">Satisfaction · gouvernement {partyLabel(f.incumbent)}</div>
95 + <div className="figure text-[52px] mt-2">{f.satisfaction !== null ? pct(f.satisfaction, 0) : "—"}</div>
96 + <div className="text-[12.5px] text-ink-2">net {f.satisfactionNet !== null ? delta(f.satisfactionNet, 0, " pt") : "—"}{f.satisfactionBilan !== null ? ` · bilan des huit années : ${pct(f.satisfactionBilan, 0)}` : ""}</div>
97 + {satSeries.length > 1 && <div className="mt-3"><Sparkline values={satSeries.map((x) => x.value)} color={partyVar(f.incumbent)} width={220} height={36} fill /><div className="text-[10.5px] mono text-ink-3 mt-1">{dateShort(satSeries[0].date)} → {dateShort(satSeries[satSeries.length - 1].date)}</div></div>}
98 + {comp?.fundamentals?.enabled && <div className="mt-3 text-[12px] text-ink-2 leading-relaxed">Prior fondamentaux : {partyLabel(f.incumbent)} ≈ <b className="mono">{pct(comp.fundamentals.incumbentPrior)}</b> ({String(comp.fundamentals.params?.alpha).replace(".", ",")} + {String(comp.fundamentals.params?.beta).replace(".", ",")} × satisfaction), poids <b className="mono">{Math.round(comp.fundamentals.weight * 100)} %</b> à J−{comp.fundamentals.daysLeft}. Effet sur la moyenne : {Object.entries(comp.fundamentals.shiftPp ?? {}).filter(([k]) => k !== "aut").map(([k, v]) => `${partyLabel(k)} ${fmtPp(v)}`).join(", ")}.</div>}
99 + </div>
100 + <div className="inset p-4">
101 + <div className="eyebrow mb-3">Meilleur·e premier·ère ministre · part des répondants décidés</div>
102 + <div className="space-y-2">
103 + {PARTY_IDS.map((id) => ({ id, v: f.bestPmShare[id] })).sort((a, b) => (b.v ?? 0) - (a.v ?? 0)).map(({ id, v }) => (
104 + <div key={id} className="grid grid-cols-[38px_1fr_52px] items-center gap-3 text-[13px]"><span className="font-semibold inline-flex items-center gap-1.5"><PartyDot id={id} />{partyLabel(id)}</span><div className="h-[8px] rounded-full bg-surface-3 overflow-hidden">{v !== null && <div className="h-full rounded-full" style={{ width: `${v}%`, background: partyVar(id) }} />}</div><span className="mono font-semibold text-right">{v !== null ? pct(v, 0) : "—"}</span></div>
105 + ))}
106 + </div>
107 + <div className="text-[11px] text-ink-3 mt-3">Écart « meilleur PM − intention de vote » = prime au chef (κ {comp?.momentum?.params?.leaderKappa ?? 0.15}) dans le momentum.</div>
108 + </div>
109 + <div className="inset p-4">
110 + <div className="eyebrow mb-3">Favorabilité nette des chef·fes</div>
111 + <DivergingBars values={f.leaderNetFavorable} max={50} format={(v) => delta(v, 0, " pt")} />
112 + <div className="grid grid-cols-2 gap-3 mt-4 text-[12.5px]">
113 + <div><div className="text-[11px] text-ink-3">Souveraineté (Oui)</div><div className="figure text-[24px]">{f.sovereigntyYes !== null ? pct(f.sovereigntyYes, 0) : "—"}</div></div>
114 + <div><div className="text-[11px] text-ink-3">« Représente le changement »</div><div className="text-[13px] mt-1">{PARTY_IDS.map((id) => ({ id, v: f.changeLeader[id] })).filter((x) => x.v !== null).sort((a, b) => (b.v ?? 0) - (a.v ?? 0)).slice(0, 3).map((x) => `${partyLabel(x.id)} ${pct(x.v, 0)}`).join(" · ") || "—"}</div></div>
115 + </div>
116 + {Object.values(f.secondChoice).some((v) => v !== null) && <div className="mt-3 text-[12px] text-ink-2">Deuxième choix : {PARTY_IDS.map((id) => ({ id, v: f.secondChoice[id] })).filter((x) => x.v !== null).sort((a, b) => (b.v ?? 0) - (a.v ?? 0)).map((x) => `${partyLabel(x.id)} ${pct(x.v, 0)}`).join(" · ")}</div>}
117 + </div>
118 + </div>
119 + </div>
120 + );
121 +}
122 +
123 +// ---------------------------------------------------------------- MÉDIAS
124 +export function MediaPanel({ m, compact = false }: { m: MediaPayload; compact?: boolean }) {
125 + const topics = Object.entries(m.topics ?? {}).slice(0, 8);
126 + return (
127 + <div className="space-y-5">
128 + <div className="flex flex-wrap items-center gap-2">
129 + <SourceChip name="Presse québécoise · Google Actualités" at={m.asOf} tone={m.enabled ? "ok" : "warn"} />
130 + <span className="text-[12px] text-ink-3">{m.articles} titres classés sur {m.window} jours · ton par parti attribué par LLM, validé (parti reconnu, ton borné)</span>
131 + </div>
132 + <div className={cls("grid gap-4", compact ? "md:grid-cols-2" : "lg:grid-cols-3")}>
133 + <div className="inset p-4">
134 + <div className="eyebrow mb-3">Part de voix · {m.window} j</div>
135 + <StackedShare values={m.shareOfVoice} height={14} />
136 + <div className="text-[11px] text-ink-3 mt-3">Part des titres mentionnant chaque parti ({Object.values(m.counts).reduce((a, b) => a + b, 0)} mentions).</div>
137 + </div>
138 + <div className="inset p-4">
139 + <div className="eyebrow mb-3">Ton moyen · −1 défavorable … +1 favorable</div>
140 + <DivergingBars values={m.tone} max={0.6} format={fmtTone} />
141 + <div className="text-[11px] text-ink-3 mt-3">Moyenne des tons attribués aux titres ; n faible = signal fragile ({PARTY_IDS.map((id) => `${partyLabel(id)} ${m.counts[id] ?? 0}`).join(", ")}).</div>
142 + </div>
143 + <div className="inset p-4">
144 + <div className="eyebrow mb-3">Sujets de la couverture</div>
145 + <div className="flex flex-wrap gap-1.5">{topics.map(([t, n]) => <span key={t} className="chip !py-1 !px-2.5 !text-[12px]">{t} <b className="mono">{n}</b></span>)}</div>
146 + {Object.values(m.gdelt).some((g) => g.volume28 !== null) && (
147 + <div className="mt-4 text-[12px]"><div className="text-[11px] text-ink-3 mb-1">GDELT · volume 7 j vs 28 j (‰ des articles surveillés)</div>{PARTY_IDS.map((id) => { const g = m.gdelt[id]; return g?.volume28 !== null ? <div key={id} className="flex justify-between"><span>{partyLabel(id)}</span><span className="mono">{g.volume7?.toFixed(3)} / {g.volume28?.toFixed(3)}{g.tone7 !== null ? ` · ton ${g.tone7}` : ""}</span></div> : null; })}</div>
148 + )}
149 + </div>
150 + </div>
151 + {!compact && (
152 + <div>
153 + <div className="eyebrow mb-2">Revue de presse · derniers titres classés</div>
154 + <div className="grid md:grid-cols-2 gap-2">
155 + {m.latest.slice(0, 24).map((it) => (
156 + <a key={it.id} href={it.url} target="_blank" rel="noopener noreferrer" className="card card-hover p-3.5 block">
157 + <div className="flex items-center gap-2 text-[11px] text-ink-3 mono"><span className="uppercase tracking-[0.06em]">{it.domain}</span><span>·</span><span>{relTime(it.publishedAt)}</span>{it.topic && <span className="ml-auto chip !py-0 !px-1.5 !text-[10.5px]">{it.topic}</span>}</div>
158 + <div className="font-semibold text-[14px] mt-1.5 leading-snug">{it.title}</div>
159 + {it.summary && <div className="text-[12.5px] text-ink-2 mt-1">{it.summary}</div>}
160 + {it.parties && <div className="flex flex-wrap gap-1.5 mt-2">{Object.entries(it.parties).map(([p, t]) => <span key={p} className={cls("inline-flex items-center gap-1 text-[11px] mono rounded-[5px] px-1.5 py-[2px]", t > 0.15 ? "bg-ok-soft text-ok" : t < -0.15 ? "bg-live-soft text-live" : "bg-surface-2 text-ink-2")}><span className="w-1.5 h-1.5 rounded-full" style={{ background: partyVar(p) }} />{partyLabel(p)} {fmtTone(t)}</span>)}</div>}
161 + </a>
162 + ))}
163 + </div>
164 + </div>
165 + )}
166 + </div>
167 + );
168 +}
169 +
170 +// ------------------------------------------------------------- ATTENTION
171 +export function AttentionPanel({ a, compact = false }: { a: AttentionPayload; compact?: boolean }) {
172 + if (!a.enabled) return <div className="text-ink-3 text-[13.5px]">Signal d&apos;attention indisponible.</div>;
173 + const series = a.series ?? [];
174 + return (
175 + <div className="space-y-5">
176 + <div className="flex flex-wrap items-center gap-2">
177 + <SourceChip name="Wikipédia FR · pages vues" at={a.asOf} tone="ok" />
178 + <span className="text-[12px] text-ink-3">vues humaines des pages des partis et de leurs chef·fes (API Wikimedia), part du total des cinq partis</span>
179 + <span className="ml-auto"><SourceBadge source={{ name: "Wikimedia REST API — pageviews per-article (fr.wikipedia, all-access, user)", url: "https://wikimedia.org/api/rest_v1/", tier: "Tier 3 · attention publique (signal)", detail: Object.entries(a.pages ?? {}).map(([p, t]) => `${p.toUpperCase()} : ${t.join(" + ")}`).join(" · ") }} /></span>
180 + </div>
181 + <div className={cls("grid gap-4", compact ? "md:grid-cols-2" : "lg:grid-cols-[1fr_1.4fr]")}>
182 + <div className="inset p-4">
183 + <div className="eyebrow mb-3">Part d&apos;attention · 7 derniers jours</div>
184 + <StackedShare values={a.share7 ?? {}} height={14} />
185 + <div className="mt-4"><div className="text-[11px] text-ink-3 mb-1.5">Variation 7 j vs 28 j (pt de part)</div><DivergingBars values={a.delta ?? {}} max={8} format={(v) => delta(v, 1, " pt")} /></div>
186 + </div>
187 + <div className="inset p-4">
188 + <div className="eyebrow mb-3">Vues quotidiennes · partis + chef·fes</div>
189 + <AttentionChart series={series} />
190 + </div>
191 + </div>
192 + </div>
193 + );
194 +}
195 +
196 +function AttentionChart({ series }: { series: { date: string; views: Record<string, number> }[] }) {
197 + if (series.length < 3) return <div className="text-ink-3 text-[13px]">Pas assez de données.</div>;
198 + const W = 640, H = 200, pl = 40, pr = 12, pt = 10, pb = 22;
199 + const max = Math.max(...series.flatMap((s) => PARTY_IDS.map((id) => s.views[id] ?? 0)), 10);
200 + const x = (i: number) => pl + (i / (series.length - 1)) * (W - pl - pr);
201 + const y = (v: number) => pt + (1 - v / max) * (H - pt - pb);
202 + const path = (id: string) => series.map((s, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(s.views[id] ?? 0).toFixed(1)}`).join(" ");
203 + const ticks = [0, Math.round(max / 2), Math.round(max)];
204 + return (
205 + <svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label="Pages vues quotidiennes par parti">
206 + {ticks.map((t) => <g key={t}><line x1={pl} x2={W - pr} y1={y(t)} y2={y(t)} stroke="var(--grid)" /><text x={pl - 6} y={y(t) + 3.5} fontSize={10} textAnchor="end" fill="var(--text-3)" fontFamily="var(--font-mono)">{int(t)}</text></g>)}
207 + {PARTY_IDS.map((id) => <path key={id} d={path(id)} fill="none" stroke={partyVar(id)} strokeWidth={1.8} strokeLinejoin="round" />)}
208 + {[0, Math.floor(series.length / 2), series.length - 1].map((i) => <text key={i} x={x(i)} y={H - 6} fontSize={10} textAnchor={i === 0 ? "start" : i === series.length - 1 ? "end" : "middle"} fill="var(--text-3)" fontFamily="var(--font-mono)">{dateShort(series[i].date)}</text>)}
209 + </svg>
210 + );
211 +}
212 +
213 +// ------------------------------------------------------------ PARTIELLES
214 +export function ByElectionsPanel({ b, comp }: { b: ByElectionsPayload; comp?: ForecastComponents | null }) {
215 + if (!b.enabled) return <div className="text-ink-3 text-[13.5px]">Aucune partielle archivée.</div>;
216 + const adj = new Map((comp?.byelections?.ridings ?? []).map((r) => [r.riding, r]));
217 + return (
218 + <div className="space-y-4">
219 + <div className="flex flex-wrap items-center gap-2">
220 + <SourceChip name="Élections Québec · résultats officiels" tone="ok" />
221 + <span className="text-[12px] text-ink-3">{b.n} partielles depuis le 3 octobre 2022 · swing = résultat de la partielle − résultat général 2022 dans la même circonscription</span>
222 + </div>
223 + <div className="grid md:grid-cols-2 xl:grid-cols-3 gap-3">
224 + {b.byElections.slice().reverse().map((e) => {
225 + const a = adj.get(e.riding ?? "");
226 + return (
227 + <div key={e.id} className="card p-4" style={{ borderTop: `3px solid ${partyVar(e.winner)}` }}>
228 + <div className="flex items-start justify-between gap-2"><div><div className="font-semibold text-[15px]">{e.riding}</div><div className="text-[12px] text-ink-3 num">{dateFr(e.date)}{e.turnout !== null ? ` · participation ${pct(e.turnout, 0)}` : ""}</div></div><PartyBadge id={e.winner} /></div>
229 + <div className="mt-3 space-y-1.5">
230 + {e.results.filter((r) => r.party_id).slice(0, 5).map((r) => (
231 + <div key={r.candidate} className="grid grid-cols-[36px_1fr_48px_56px] items-center gap-2 text-[12.5px]"><span className="font-semibold" style={{ color: partyVar(r.party_id) }}>{partyLabel(r.party_id)}</span><div className="h-[6px] rounded-full bg-surface-3 overflow-hidden"><div className="h-full" style={{ width: `${r.share}%`, background: partyVar(r.party_id) }} /></div><span className="mono text-right">{r.share.toFixed(0)} %</span><span className={cls("mono text-right", (e.swing[r.party_id ?? ""] ?? 0) > 0 ? "text-ok" : "text-live")}>{e.swing[r.party_id ?? ""] !== undefined ? delta(e.swing[r.party_id ?? ""], 0, " pt") : ""}</span></div>
232 + ))}
233 + </div>
234 + <div className="mt-3 flex items-center justify-between text-[11.5px] text-ink-3"><span>{a ? `ajustement QC26 ${partyLabel(e.winner)} ${(a.adjLogit[e.winner ?? ""] ?? 0) >= 0 ? "+" : ""}${(a.adjLogit[e.winner ?? ""] ?? 0).toFixed(2)} logit` : "hors carte 2026"}</span><a href={e.url} target="_blank" rel="noopener noreferrer" className="link">DGEQ</a></div>
235 + </div>
236 + );
237 + })}
238 + </div>
239 + <div className="text-[12.5px] text-ink-2">Swing moyen sur {b.n} partielles : {PARTY_IDS.map((id) => `${partyLabel(id)} ${b.meanSwing[id] !== null ? delta(b.meanSwing[id], 0, " pt") : "—"}`).join(" · ")}. Les partielles ont une participation faible et des dynamiques locales : le modèle n&apos;en retient que l&apos;excès local net du swing national, rétréci (κ {comp?.byelections?.ridings?.[0]?.kappa ?? 0.35}).</div>
240 + </div>
241 + );
242 +}
243 +
244 +// ------------------------------------------------------------- MOMENTUM
245 +export function MomentumPanel({ comp }: { comp: ForecastComponents }) {
246 + const m = comp.momentum;
247 + return (
248 + <div className="grid md:grid-cols-[1.1fr_1fr] gap-5">
249 + <div className="inset p-4">
250 + <div className="eyebrow mb-3">Nudge appliqué à la moyenne du vote (pp, somme nulle, borné ± {m.params.maxPp})</div>
251 + <DivergingBars values={m.shiftPp} max={m.params.maxPp} format={fmtPp} />
252 + </div>
253 + <div className="inset p-4 text-[12.5px]">
254 + <div className="eyebrow mb-3">Décomposition avant bornage</div>
255 + <table className="w-full"><thead><tr className="text-[11px] text-ink-3 mono uppercase"><th className="text-left font-medium pb-1">Parti</th><th className="text-right font-medium pb-1">Chef</th><th className="text-right font-medium pb-1">Attention</th><th className="text-right font-medium pb-1">Médias</th></tr></thead>
256 + <tbody>{PARTY_IDS.map((id) => <tr key={id} className="border-t border-border"><td className="py-1 font-semibold inline-flex items-center gap-1.5"><PartyDot id={id} />{partyLabel(id)}</td><td className="text-right mono">{m.parts.leader[id] !== undefined ? fmtPp(m.parts.leader[id]) : "—"}</td><td className="text-right mono">{m.parts.attention[id] !== undefined ? fmtPp(m.parts.attention[id]) : "—"}</td><td className="text-right mono">{m.parts.media[id] !== undefined ? fmtPp(m.parts.media[id]) : "—"}</td></tr>)}</tbody></table>
257 + <div className="text-[11px] text-ink-3 mt-3">κ chef {m.params.leaderKappa} · κ attention {m.params.attentionKappa} · κ médias {m.params.mediaKappa}. Le total est écrêté puis recentré : le momentum déplace du vote entre partis, il n&apos;en crée pas.</div>
258 + </div>
259 + </div>
260 + );
261 +}
262 +
263 +export function SignalsOverview({ comp, className }: { comp: ForecastComponents; className?: string }) {
264 + const items = [
265 + { k: "Sondages", v: `${comp.nSimulations ? int(comp.nSimulations) : ""} simulations`, on: true, href: "/sondages", sub: "filtre de Kalman, effets maison" },
266 + { k: "Marchés", v: comp.markets.enabled ? `poids ${Math.round(comp.markets.weight * 100)} %` : "inactif", on: comp.markets.enabled, href: "/signaux#marches", sub: comp.markets.marketVolume ? `${int(comp.markets.marketVolume)} $ de volume` : "Polymarket" },
267 + { k: "Fondamentaux", v: comp.fundamentals.enabled ? `poids ${Math.round(comp.fundamentals.weight * 100)} %` : "inactif", on: comp.fundamentals.enabled, href: "/signaux#fondamentaux", sub: comp.fundamentals.satisfaction !== undefined ? `satisfaction ${String(comp.fundamentals.satisfaction).replace(".", ",")} %` : "satisfaction du gouvernement" },
268 + { k: "Momentum", v: comp.momentum.enabled ? `± ${Math.max(...Object.values(comp.momentum.shiftPp).map(Math.abs)).toFixed(1).replace(".", ",")} pt max` : "inactif", on: comp.momentum.enabled, href: "/signaux#momentum", sub: "chef · attention · presse" },
269 + { k: "Partielles", v: comp.byelections.enabled ? `${comp.byelections.ridings.length} circonscriptions` : "aucune", on: comp.byelections.enabled, href: "/signaux#partielles", sub: "excès de swing local" },
270 + ];
271 + return (
272 + <div className={cls("grid grid-cols-2 md:grid-cols-5 gap-3", className)}>
273 + {items.map((it) => (
274 + <Link key={it.k} href={it.href} className="card card-hover p-3.5 block">
275 + <div className="flex items-center gap-1.5 text-[11px] mono uppercase tracking-[0.06em] text-ink-2"><span className={cls("w-1.5 h-1.5 rounded-full", it.on ? "bg-ok" : "bg-ink-3")} />{it.k}</div>
276 + <div className="font-semibold text-[15px] mt-1.5">{it.v}</div>
277 + <div className="text-[12px] text-ink-3">{it.sub}</div>
278 + </Link>
279 + ))}
280 + </div>
281 + );
282 +}
283 +
284 +export { partyName };
modified web/src/components/status-strip.tsx +2 −2
@@ -4,14 +4,14 @@ import { cls, dateTimeFr } from "@/lib/format";
4 4
5 5 interface Status { components: Record<string, string>; latestRun: { id: number; completedAt: string | null } | null; serverTime: string }
6 6
7 const LABELS: Record<string, string> = { pollWatcher: "Poll watcher", forecastEngine: "Forecast engine", electionFeed: "Election feed", documentWatcher: "Document watcher", officialData: "Données officielles" };
7 +const LABELS: Record<string, string> = { pollWatcher: "Poll watcher", forecastEngine: "Forecast engine", electionFeed: "Election feed", documentWatcher: "Document watcher", officialData: "Données officielles", signals: "Signaux" };
8 8
9 9 export async function StatusStrip() {
10 10 const st = await apiSafe<Status>("/api/status", { revalidate: 60 });
11 11 return (
12 12 <div className="border-t border-border">
13 13 <div className="mx-auto max-w-[1280px] px-4 md:px-6 py-3 flex flex-wrap items-center gap-x-5 gap-y-2 text-[11.5px] text-ink-3">
14 <Link href="/statut" className="eyebrow !text-[10.5px] hover:text-ink">QC26 Data Status</Link>
14 + <Link href="/statut" className="eyebrow !text-[10.5px] hover:text-ink">QC26 · statut des données</Link>
15 15 {st ? Object.entries(st.components).map(([k, v]) => (
16 16 <span key={k} className="inline-flex items-center gap-1.5">
17 17 <span className={cls("w-1.5 h-1.5 rounded-full", v === "operational" ? "bg-ok" : v === "ready" ? "bg-accent" : "bg-warn")} aria-hidden />
added web/src/components/theme.tsx +35 −0
@@ -0,0 +1,35 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +
5 +type Theme = "light" | "dark";
6 +
7 +/** Script inline (avant hydratation) : applique le thème mémorisé ou celui du système, sans flash. */
8 +export const THEME_INIT_SCRIPT = `(function(){try{var t=localStorage.getItem('qc26-theme');if(t!=='light'&&t!=='dark'){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.setAttribute('data-theme',t);var m=document.querySelector('meta[name="theme-color"]');if(m){m.setAttribute('content',t==='dark'?'#0e1013':'#f6f5f1')}}catch(e){}})();`;
9 +
10 +function current(): Theme {
11 + if (typeof document === "undefined") return "light";
12 + return (document.documentElement.getAttribute("data-theme") as Theme) || "light";
13 +}
14 +
15 +export function ThemeToggle({ className }: { className?: string }) {
16 + const [theme, setTheme] = useState<Theme>("light");
17 + // eslint-disable-next-line react-hooks/set-state-in-effect -- synchronisation unique avec l'attribut posé avant hydratation
18 + useEffect(() => { setTheme(current()); }, []);
19 + const toggle = () => {
20 + const next: Theme = current() === "dark" ? "light" : "dark";
21 + document.documentElement.setAttribute("data-theme", next);
22 + try { localStorage.setItem("qc26-theme", next); } catch { /* privé */ }
23 + document.querySelector('meta[name="theme-color"]')?.setAttribute("content", next === "dark" ? "#0e1013" : "#f6f5f1");
24 + setTheme(next);
25 + };
26 + return (
27 + <button onClick={toggle} className={className ?? "w-9 h-9 inline-flex items-center justify-center rounded-[9px] border border-border hover:bg-surface-2 text-ink-2"} aria-label={theme === "dark" ? "Passer au thème clair" : "Passer au thème sombre"} title={theme === "dark" ? "Thème clair" : "Thème sombre"}>
28 + {theme === "dark" ? (
29 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></svg>
30 + ) : (
31 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg>
32 + )}
33 + </button>
34 + );
35 +}
added web/src/components/trend-chart.tsx +115 −0
@@ -0,0 +1,115 @@
1 +"use client";
2 +
3 +import { useMemo, useRef, useState } from "react";
4 +import type { TrendPoint } from "@/lib/api";
5 +import { cls, dateFr } from "@/lib/format";
6 +import { PARTY_IDS, partyLabel, partyVar } from "@/lib/parties";
7 +
8 +/** Tendance interactive : réticule + infobulle (moyenne QC26 ± bande 80 %, sondages du jour). */
9 +export function TrendChartInteractive({ trend, polls, from, height = 320, ids = PARTY_IDS, markers = [], election2022, className, showBand = true }: {
10 + trend: TrendPoint[]; polls?: { date: string; values: Record<string, number>; id?: string; pollster?: string }[]; from?: string; height?: number; ids?: string[];
11 + markers?: { date: string; label: string }[]; election2022?: Record<string, number> | null; className?: string; showBand?: boolean;
12 +}) {
13 + const [range, setRange] = useState<string | undefined>(from);
14 + const pts = useMemo(() => (range ? trend.filter((t) => t.date >= range) : trend), [trend, range]);
15 + const [hover, setHover] = useState<number | null>(null);
16 + const svgRef = useRef<SVGSVGElement>(null);
17 + const W = 900, H = height, pl = 34, pr = 84, pt = 14, pb = 28;
18 + if (pts.length < 2) return <div className="text-ink-3 text-sm">Pas assez de données.</div>;
19 + const t0 = new Date(pts[0].date).getTime(), t1 = new Date(pts[pts.length - 1].date).getTime();
20 + const x = (d: string) => pl + ((new Date(d).getTime() - t0) / Math.max(1, t1 - t0)) * (W - pl - pr);
21 + const visPolls = (polls ?? []).filter((p) => p.date >= pts[0].date && p.date <= pts[pts.length - 1].date);
22 + const allVals = pts.flatMap((p) => ids.map((id) => p.mean[id] ?? 0));
23 + const pollVals = visPolls.flatMap((p) => ids.map((id) => p.values[id] ?? 0));
24 + const ymax = Math.min(100, Math.ceil((Math.max(...allVals, ...pollVals, 10) + 4) / 5) * 5);
25 + const y = (v: number) => pt + (1 - v / ymax) * (H - pt - pb);
26 + const ticks = Array.from({ length: Math.floor(ymax / 10) + 1 }, (_, i) => i * 10);
27 + const months: { d: Date; label: string }[] = [];
28 + const spanDays = (t1 - t0) / 86400000;
29 + const step = spanDays > 900 ? 6 : spanDays > 400 ? 3 : 1;
30 + const cur = new Date(t0); cur.setDate(1); cur.setMonth(cur.getMonth() + 1);
31 + while (cur.getTime() <= t1) {
32 + if (cur.getMonth() % step === 0) months.push({ d: new Date(cur), label: cur.toLocaleDateString("fr-CA", { month: "short", year: spanDays > 200 ? "2-digit" : undefined }) });
33 + cur.setMonth(cur.getMonth() + 1);
34 + }
35 + const path = (id: string) => pts.map((p, i) => `${i ? "L" : "M"}${x(p.date).toFixed(1)},${y(p.mean[id] ?? 0).toFixed(1)}`).join(" ");
36 + const band = (id: string) => {
37 + const up = pts.map((p) => `${x(p.date).toFixed(1)},${y((p.mean[id] ?? 0) + 1.28 * (p.sd[id] ?? 0)).toFixed(1)}`);
38 + const dn = [...pts].reverse().map((p) => `${x(p.date).toFixed(1)},${y((p.mean[id] ?? 0) - 1.28 * (p.sd[id] ?? 0)).toFixed(1)}`);
39 + return `M${up.join(" L")} L${dn.join(" L")} Z`;
40 + };
41 + const last = pts[pts.length - 1];
42 + const labels = ids.map((id) => ({ id, v: last.mean[id] ?? 0 })).sort((a, b) => b.v - a.v);
43 + const labelY = labels.reduce<number[]>((acc, l) => { const prev = acc.length ? acc[acc.length - 1] : -Infinity; const yy = y(l.v); acc.push(yy - prev < 13 ? prev + 13 : yy); return acc; }, []);
44 + const onMove = (e: React.PointerEvent) => {
45 + const r = svgRef.current!.getBoundingClientRect();
46 + const px = ((e.clientX - r.left) / r.width) * W;
47 + const t = t0 + ((px - pl) / (W - pl - pr)) * (t1 - t0);
48 + let best = 0, bd = Infinity;
49 + pts.forEach((p, i) => { const d = Math.abs(new Date(p.date).getTime() - t); if (d < bd) { bd = d; best = i; } });
50 + setHover(best);
51 + };
52 + const hp = hover !== null ? pts[hover] : null;
53 + const hpolls = hp ? visPolls.filter((p) => Math.abs(new Date(p.date).getTime() - new Date(hp.date).getTime()) <= 2 * 86400000) : [];
54 + const ref = new Date(trend[trend.length - 1].date).getTime(); // date du dernier point (déterministe, pas d'horloge au rendu)
55 + const ranges: { label: string; from?: string }[] = [
56 + { label: "Campagne", from: "2026-08-20" }, { label: "6 mois", from: new Date(ref - 182 * 86400000).toISOString().slice(0, 10) },
57 + { label: "1 an", from: new Date(ref - 365 * 86400000).toISOString().slice(0, 10) }, { label: "Depuis 2022" },
58 + ];
59 + return (
60 + <div className={cls("relative", className)}>
61 + <div className="flex flex-wrap items-center gap-1.5 mb-3">
62 + {ranges.map((r) => <button key={r.label} onClick={() => { setRange(r.from); setHover(null); }} className={cls("chip !py-1 !px-2.5 !text-[12px]", (range ?? "") === (r.from ?? "") && "chip-active")}>{r.label}</button>)}
63 + <span className="text-[12px] text-ink-3 ml-auto hidden sm:inline">Survolez pour lire la moyenne QC26 du jour</span>
64 + </div>
65 + <svg ref={svgRef} viewBox={`0 0 ${W} ${H}`} className="w-full h-auto touch-pan-y" role="img" aria-label="Évolution des intentions de vote (moyenne QC26) et sondages" onPointerMove={onMove} onPointerLeave={() => setHover(null)}>
66 + {ticks.map((t) => (
67 + <g key={t}>
68 + <line x1={pl} x2={W - pr} y1={y(t)} y2={y(t)} stroke="var(--grid)" strokeWidth={1} />
69 + <text x={pl - 6} y={y(t) + 3.5} fontSize={10.5} textAnchor="end" fill="var(--text-3)" fontFamily="var(--font-mono)">{t}</text>
70 + </g>
71 + ))}
72 + {months.map((m, i) => (
73 + <g key={i}>
74 + <line x1={x(m.d.toISOString())} x2={x(m.d.toISOString())} y1={pt} y2={H - pb + 4} stroke="var(--grid)" strokeWidth={1} />
75 + <text x={x(m.d.toISOString())} y={H - 8} fontSize={10.5} textAnchor="middle" fill="var(--text-3)" fontFamily="var(--font-mono)">{m.label}</text>
76 + </g>
77 + ))}
78 + {markers.filter((m) => new Date(m.date).getTime() >= t0 && new Date(m.date).getTime() <= t1).map((m, i) => (
79 + <g key={`m${i}`}>
80 + <line x1={x(m.date)} x2={x(m.date)} y1={pt} y2={H - pb} stroke="var(--text-2)" strokeWidth={1} strokeDasharray="2 3" />
81 + <text x={x(m.date) + 4} y={pt + 9} fontSize={9.5} fill="var(--text-2)" fontFamily="var(--font-mono)">{m.label}</text>
82 + </g>
83 + ))}
84 + {visPolls.map((p, i) => ids.map((id) => p.values[id] === undefined ? null : (
85 + <circle key={`${i}-${id}`} cx={x(p.date)} cy={y(p.values[id])} r={2.6} fill={partyVar(id)} opacity={0.3} />
86 + )))}
87 + {showBand && ids.map((id) => <path key={`b${id}`} d={band(id)} fill={partyVar(id)} opacity={0.1} />)}
88 + {ids.map((id) => <path key={id} d={path(id)} fill="none" stroke={partyVar(id)} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />)}
89 + {election2022 && ids.map((id) => election2022[id] !== undefined && (
90 + <rect key={`e22${id}`} x={pl - 2} y={y(election2022[id]) - 1.5} width={5} height={3} fill={partyVar(id)} />
91 + ))}
92 + {hp ? (
93 + <g>
94 + <line x1={x(hp.date)} x2={x(hp.date)} y1={pt} y2={H - pb} stroke="var(--text)" strokeWidth={1} strokeOpacity={0.6} />
95 + {ids.map((id) => <circle key={`h${id}`} cx={x(hp.date)} cy={y(hp.mean[id] ?? 0)} r={4} fill={partyVar(id)} stroke="var(--surface)" strokeWidth={2} />)}
96 + </g>
97 + ) : ids.map((id) => <circle key={`e${id}`} cx={x(last.date)} cy={y(last.mean[id] ?? 0)} r={3.5} fill={partyVar(id)} stroke="var(--surface)" strokeWidth={2} />)}
98 + {labels.map((l, i) => (
99 + <text key={l.id} x={W - pr + 8} y={labelY[i] + 4} fontSize={12} fontWeight={600} fill="var(--text)" fontFamily="var(--font-sans)">
100 + <tspan fill={partyVar(l.id)}>■</tspan> {partyLabel(l.id)} <tspan fontFamily="var(--font-mono)">{(hp ? hp.mean[l.id] ?? 0 : l.v).toFixed(1).replace(".", ",")}</tspan>
101 + </text>
102 + ))}
103 + </svg>
104 + {hp && (
105 + <div className="chart-tip" style={{ left: `min(calc(${((x(hp.date) / W) * 100).toFixed(1)}% + 12px), calc(100% - 200px))`, top: 40 }}>
106 + <div className="font-semibold text-ink mb-1">{dateFr(hp.date, { day: "numeric", month: "short", year: "numeric" })}</div>
107 + {ids.map((id) => ({ id, v: hp.mean[id] ?? 0, sd: hp.sd[id] ?? 0 })).sort((a, b) => b.v - a.v).map((r) => (
108 + <div key={r.id} className="flex items-center justify-between gap-4"><span className="inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(r.id) }} />{partyLabel(r.id)}</span><span className="mono">{r.v.toFixed(1).replace(".", ",")} <span className="text-ink-3">± {(1.28 * r.sd).toFixed(1).replace(".", ",")}</span></span></div>
109 + ))}
110 + {hpolls.length > 0 && <div className="text-ink-3 mt-1 pt-1 border-t border-border">{hpolls.length} sondage{hpolls.length > 1 ? "s" : ""} à ± 2 j{hpolls[0].pollster ? ` · ${hpolls.slice(0, 2).map((p) => p.pollster).join(", ")}` : ""}</div>}
111 + </div>
112 + )}
113 + </div>
114 + );
115 +}
modified web/src/components/ui.tsx +45 −22
@@ -2,13 +2,27 @@ import Link from "next/link";
2 2 import type { ReactNode } from "react";
3 3 import { cls, dateTimeFr, relTime } from "@/lib/format";
4 4
5 export function SectionHeader({ eyebrow, title, description, action, id, className }: { eyebrow?: string; title: string; description?: ReactNode; action?: ReactNode; id?: string; className?: string }) {
5 +export function SectionHeader({ eyebrow, title, description, action, id, className, num }: { eyebrow?: string; title: string; description?: ReactNode; action?: ReactNode; id?: string; className?: string; num?: string }) {
6 6 return (
7 <div id={id} className={cls("flex flex-wrap items-end justify-between gap-3 mb-4 scroll-mt-20", className)}>
7 + <div id={id} className={cls("flex flex-wrap items-end justify-between gap-3 mb-5 scroll-mt-24", className)}>
8 8 <div className="min-w-0">
9 {eyebrow && <div className="eyebrow mb-1">{eyebrow}</div>}
10 <h2 className="text-[22px] md:text-[26px] font-bold tracking-tight leading-tight">{title}</h2>
11 {description && <p className="text-ink-2 text-[14px] mt-1 max-w-2xl leading-relaxed">{description}</p>}
9 + {(eyebrow || num) && <div className="flex items-center gap-2 mb-1.5">{num && <span className="section-num">{num}</span>}{eyebrow && <span className="eyebrow">{eyebrow}</span>}</div>}
10 + <h2 className="display text-[26px] md:text-[32px] leading-[1.05]">{title}</h2>
11 + {description && <p className="text-ink-2 text-[14.5px] mt-2 max-w-2xl leading-relaxed">{description}</p>}
12 + </div>
13 + {action && <div className="shrink-0">{action}</div>}
14 + </div>
15 + );
16 +}
17 +
18 +export function PageTitle({ eyebrow, title, lede, action, children }: { eyebrow?: ReactNode; title: ReactNode; lede?: ReactNode; action?: ReactNode; children?: ReactNode }) {
19 + return (
20 + <div className="flex flex-wrap items-end justify-between gap-6">
21 + <div className="min-w-0 max-w-3xl">
22 + {eyebrow && <div className="eyebrow eyebrow-accent">{eyebrow}</div>}
23 + <h1 className="display text-[40px] md:text-[60px] mt-3">{title}</h1>
24 + {lede && <p className="lede mt-4">{lede}</p>}
25 + {children}
12 26 </div>
13 27 {action && <div className="shrink-0">{action}</div>}
14 28 </div>
@@ -19,14 +33,14 @@ export function Card({ children, className, pad = true }: { children: ReactNode;
19 33 return <div className={cls("card", pad && "p-4 md:p-5", className)}>{children}</div>;
20 34 }
21 35
22 export function Metric({ label, value, sub, delta, className, size = "md", color }: { label: string; value: ReactNode; sub?: ReactNode; delta?: ReactNode; className?: string; size?: "sm" | "md" | "lg" | "xl"; color?: string }) {
23 const vs = { sm: "text-xl", md: "text-[28px]", lg: "text-[40px]", xl: "text-[64px] md:text-[88px]" }[size];
36 +export function Metric({ label, value, sub, delta, className, size = "md", color, serif = true }: { label: string; value: ReactNode; sub?: ReactNode; delta?: ReactNode; className?: string; size?: "sm" | "md" | "lg" | "xl"; color?: string; serif?: boolean }) {
37 + const vs = { sm: "text-[22px]", md: "text-[30px]", lg: "text-[44px]", xl: "text-[72px] md:text-[96px]" }[size];
24 38 return (
25 39 <div className={cls("min-w-0", className)}>
26 40 <div className="eyebrow">{label}</div>
27 <div className={cls("num display mt-1", vs)} style={color ? { color } : undefined}>{value}</div>
41 + <div className={cls(serif ? "figure" : "num font-bold tracking-tight", "mt-1.5", vs)} style={color ? { color } : undefined}>{value}</div>
28 42 {(sub || delta) && (
29 <div className="flex items-baseline gap-2 mt-1 text-[13px] text-ink-2">
43 + <div className="flex items-baseline gap-2 mt-1.5 text-[13px] text-ink-2">
30 44 {delta}
31 45 {sub && <span>{sub}</span>}
32 46 </div>
@@ -43,7 +57,7 @@ export function MetricDelta({ value, digits = 1, suffix = " pt", invert = false
43 57 const good = invert ? !up : up;
44 58 const txt = zero ? "0" : (up ? "+" : "−") + Math.abs(value).toFixed(digits).replace(".", ",");
45 59 return (
46 <span className={cls("num inline-flex items-center gap-0.5 font-semibold text-[12.5px] px-1.5 py-[1px] rounded-[5px]", zero ? "text-ink-3 bg-surface-2" : good ? "text-ok bg-[rgba(21,128,61,.09)]" : "text-live bg-[rgba(220,38,38,.08)]")} aria-label={`variation ${txt}${suffix}`}>
60 + <span className={cls("num inline-flex items-center gap-0.5 font-semibold text-[12px] px-1.5 py-[2px] rounded-[6px] mono", zero ? "text-ink-3 bg-surface-2" : good ? "text-ok bg-ok-soft" : "text-live bg-live-soft")} aria-label={`variation ${txt}${suffix}`}>
47 61 {!zero && (up ? "▲" : "▼")} {txt}{suffix}
48 62 </span>
49 63 );
@@ -53,12 +67,12 @@ export function Badge({ children, tone = "neutral", className, title }: { childr
53 67 const t = {
54 68 neutral: "bg-surface-2 text-ink-2 border-border",
55 69 accent: "bg-accent-soft text-accent border-transparent",
56 live: "bg-[rgba(220,38,38,.08)] text-live border-transparent",
57 ok: "bg-[rgba(21,128,61,.09)] text-ok border-transparent",
58 warn: "bg-[rgba(180,83,9,.1)] text-warn border-transparent",
59 ink: "bg-brand text-white border-transparent",
70 + live: "bg-live-soft text-live border-transparent",
71 + ok: "bg-ok-soft text-ok border-transparent",
72 + warn: "bg-warn-soft text-warn border-transparent",
73 + ink: "bg-brand text-ink-inverse border-transparent",
60 74 }[tone];
61 return <span title={title} className={cls("inline-flex items-center gap-1 rounded-[6px] border px-2 py-[3px] text-[11px] font-semibold tracking-wide uppercase", t, className)}>{children}</span>;
75 + return <span title={title} className={cls("inline-flex items-center gap-1 rounded-[6px] border px-2 py-[3px] text-[10.5px] font-semibold tracking-[0.06em] uppercase mono", t, className)}>{children}</span>;
62 76 }
63 77
64 78 export function LiveBadge({ label = "Direct" }: { label?: string }) {
@@ -91,10 +105,10 @@ export function Timestamp({ iso, prefix = "Mise à jour", className, relative =
91 105 export function SourceBadge({ source }: { source: { name: string; url?: string | null; archiveUrl?: string | null; retrievedAt?: string | null; page?: number | null; detail?: string | null; tier?: string | null } }) {
92 106 return (
93 107 <details className="relative inline-block group">
94 <summary className="list-none cursor-pointer inline-flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wide text-ink-2 border border-border rounded-[6px] px-2 py-[3px] hover:bg-surface-2 select-none">
95 <span aria-hidden>⌕</span> Source
108 + <summary className="list-none cursor-pointer inline-flex items-center gap-1.5 text-[10.5px] mono font-semibold uppercase tracking-[0.06em] text-ink-2 border border-border rounded-[6px] px-2 py-[3px] hover:bg-surface-2 select-none">
109 + <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg> Source
96 110 </summary>
97 <div className="absolute z-30 left-0 mt-2 w-[300px] max-w-[calc(100vw-2rem)] card p-3 text-[12.5px] shadow-md text-left leading-relaxed">
111 + <div className="absolute z-30 left-0 mt-2 w-[320px] max-w-[calc(100vw-2rem)] card p-3.5 text-[12.5px] shadow-md text-left leading-relaxed">
98 112 <div className="font-semibold text-ink">{source.name}</div>
99 113 {source.detail && <div className="text-ink-2 mt-1">{source.detail}</div>}
100 114 <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-ink-2">
@@ -123,7 +137,7 @@ export function ProbabilityBar({ p, color, label, height = 10, showValue = true,
123 137
124 138 export function Pill({ href, children, active, className }: { href: string; children: ReactNode; active?: boolean; className?: string }) {
125 139 return (
126 <Link href={href} className={cls("inline-flex items-center rounded-full px-3 py-1.5 text-[13px] font-medium border transition-colors", active ? "bg-brand text-white border-brand" : "bg-surface border-border hover:bg-surface-2", className)}>
140 + <Link href={href} className={cls("chip", active && "chip-active", className)}>
127 141 {children}
128 142 </Link>
129 143 );
@@ -131,9 +145,9 @@ export function Pill({ href, children, active, className }: { href: string; chil
131 145
132 146 export function Empty({ title, children }: { title: string; children?: ReactNode }) {
133 147 return (
134 <div className="card p-8 text-center">
135 <div className="font-semibold">{title}</div>
136 {children && <div className="text-ink-2 text-[14px] mt-1">{children}</div>}
148 + <div className="card p-10 text-center">
149 + <div className="display text-[22px]">{title}</div>
150 + {children && <div className="text-ink-2 text-[14px] mt-2">{children}</div>}
137 151 </div>
138 152 );
139 153 }
@@ -158,3 +172,12 @@ export function KV({ items, className }: { items: [ReactNode, ReactNode][]; clas
158 172 </dl>
159 173 );
160 174 }
175 +
176 +/** Petite étiquette « signal » : nom de la source, fraîcheur, statut. */
177 +export function SourceChip({ name, at, tone = "neutral" }: { name: string; at?: string | null; tone?: "neutral" | "ok" | "warn" }) {
178 + return (
179 + <span className={cls("inline-flex items-center gap-1.5 text-[11px] mono uppercase tracking-[0.06em] rounded-[6px] px-2 py-[3px] border", tone === "ok" ? "border-transparent bg-ok-soft text-ok" : tone === "warn" ? "border-transparent bg-warn-soft text-warn" : "border-border text-ink-2")}>
180 + <span className={cls("w-1.5 h-1.5 rounded-full", tone === "ok" ? "bg-ok" : tone === "warn" ? "bg-warn" : "bg-ink-3")} aria-hidden />{name}{at && <span className="text-ink-3 normal-case tracking-normal">· {relTime(at)}</span>}
181 + </span>
182 + );
183 +}
modified web/src/lib/api.ts +24 −1
@@ -120,18 +120,41 @@ export interface Forecast {
120 120 days_to_election: number;
121 121 battlegrounds: number[];
122 122 ridings: Record<string, { fav: string; p: number; second: string; margin: number }>;
123 + components?: ForecastComponents;
123 124 };
124 125 diff: {
125 126 previous_run: number;
126 127 previous_at: string | null;
127 128 changes: Record<string, { vote: number; seats: number; p_majority: number; p_most_seats: number }>;
128 129 flips: { code: number; from: string; to: string; p: number }[];
129 causes: { kind: string; detail: string | string[] }[];
130 + causes: { kind: string; detail: string | string[] | Record<string, number> }[];
130 131 } | null;
131 132 parties: Record<string, PartyForecast>;
132 133 trend: TrendPoint[] | null;
133 134 }
134 135
136 +export interface ComponentParties { parties: Record<string, ForecastSummaryParty & { vote95?: [number, number] }>; p_minority: number }
137 +export interface ForecastComponents {
138 + pollsOnly: ComponentParties;
139 + modelNoMarkets: ComponentParties;
140 + fundamentals: { enabled: boolean; weight: number; daysLeft?: number; satisfaction?: number; satisfactionPolls?: number; incumbent?: string; incumbentPrior?: number; prior?: Record<string, number>; shiftPp?: Record<string, number>; params?: Record<string, number> };
141 + momentum: { enabled: boolean; shiftPp: Record<string, number>; parts: { leader: Record<string, number>; attention: Record<string, number>; media: Record<string, number> }; params: Record<string, number> };
142 + markets: { enabled: boolean; weight: number; bandWeight?: number; ess?: number; constraints?: string[]; achieved?: Record<string, Record<string, number>>; marketAsOf?: string | null; marketVolume?: number; mostSeatsMarket?: Record<string, number>; majorityMarket?: Record<string, number>; seatBandsImpliedMean?: Record<string, number> };
143 + byelections: { enabled: boolean; ridings: { code: number; riding: string; date: string; adjLogit: Record<string, number>; kappa: number }[] };
144 + signals: { attention: { enabled?: boolean; asOf?: string; share7?: Record<string, number>; share28?: Record<string, number>; delta?: Record<string, number> }; media: { enabled?: boolean; asOf?: string; articles?: number; shareOfVoice?: Record<string, number>; tone?: Record<string, number | null>; counts?: Record<string, number> }; fundamentals: { enabled?: boolean; satisfaction?: number | null; satisfactionNet?: number | null; bestPmShare?: Record<string, number | null>; leaderNetFavorable?: Record<string, number | null>; secondChoice?: Record<string, number | null>; sovereigntyYes?: number | null }; markets: { asOf?: string | null; mostSeats?: Record<string, number>; majority?: Record<string, number>; volume?: number } | null; byelections: { n: number; meanSwing: Record<string, number | null> | null } };
145 + ess: number;
146 + nSimulations: number;
147 +}
148 +
149 +export interface MarketRow { id: string; question: string; kind: string; party: string | null; price: number; bid: number | null; ask: number | null; volume: number | null; liquidity: number | null; url: string | null; updatedAt: string | null; band: [number | null, number | null] | null; place: number | null }
150 +export interface MarketsPayload { source: string; asOf: string | null; mostSeats: Record<string, number>; mostSeatsRaw?: Record<string, number>; majority: Record<string, number>; seatBands: Record<string, { bands: { lo: number; hi: number | null; p: number }[]; impliedMean: number }>; place: Record<string, Record<string, number>>; volume: number; markets: MarketRow[]; history: Record<string, { date: string; p: number }[]> }
151 +export interface AttentionPayload { enabled: boolean; asOf?: string; share7?: Record<string, number>; share28?: Record<string, number>; delta?: Record<string, number>; series?: { date: string; share: Record<string, number>; views: Record<string, number> }[]; election?: { date: string; views: number }[]; pages?: Record<string, string[]> }
152 +export interface MediaItem { id: number; title: string; domain: string | null; url: string; publishedAt: string; parties: Record<string, number> | null; topic: string | null; summary: string | null }
153 +export interface MediaPayload { enabled: boolean; window: number; asOf: string; articles: number; shareOfVoice: Record<string, number>; tone: Record<string, number | null>; counts: Record<string, number>; gdelt: Record<string, { volume7: number | null; volume28: number | null; tone7: number | null }>; series: { date: string; articles?: Record<string, number>; share_of_voice?: Record<string, number>; tone?: Record<string, number> }[]; latest: MediaItem[]; topics: Record<string, number> }
154 +export interface FundamentalsPayload { enabled: boolean; asOf: string; incumbent: string; satisfaction: number | null; satisfactionNet: number | null; satisfactionPolls: number; satisfactionBilan: number | null; bestPmShare: Record<string, number | null>; bestPm: Record<string, number | null>; leaderNetFavorable: Record<string, number | null>; leaderFavorable: Record<string, number | null>; changeLeader: Record<string, number | null>; secondChoice: Record<string, number | null>; sovereigntyYes: number | null; sovereigntyPolls: number; series: Record<string, { date: string; party: string | null; value: number; poll: string | null; pollster: string | null }[]>; params: Record<string, number> }
155 +export interface ByElectionsPayload { enabled: boolean; n: number; meanSwing: Record<string, number | null>; byElections: { id: string; date: string; riding: string | null; code: number; url: string; turnout: number | null; winner: string | null; results: { candidate: string; party_label: string; party_id: string | null; votes: number; share: number }[]; previous: Record<string, number> | null; swing: Record<string, number>; archiveSha: string | null }[] }
156 +export interface SignalsPayload { asOf: string; runId: number | null; components: ForecastComponents | null; markets: MarketsPayload; attention: AttentionPayload; media: MediaPayload; fundamentals: FundamentalsPayload; byElections: ByElectionsPayload }
157 +
135 158 export interface Party extends RegistryParty {
136 159 leader: string | null;
137 160 leaderSince: string | null;
modified web/src/lib/parties.ts +11 −0
@@ -51,6 +51,17 @@ export function sortParties<T>(rec: Record<string, T>, key: (v: T) => number, in
51 51 .filter(([id]) => includeOthers || id !== "aut")
52 52 .sort((a, b) => key(b[1]) - key(a[1]));
53 53 }
54 +/** Couleur de parti via variable CSS (adaptée au thème clair/sombre dans globals.css). À utiliser dans l'UI ;
55 + * `partyColor` (hex) reste pour les images OG (satori) et les exports. */
56 +export function partyVar(id: string | null | undefined): string {
57 + if (!id) return "var(--p-none)";
58 + return PARTY_BY_ID[id] ? `var(--p-${id})` : "var(--p-aut)";
59 +}
60 +/** Couleur de parti mélangée à la surface : alpha 0…1 → color-mix (thème-safe). */
61 +export function partyAlpha(id: string | null | undefined, alpha: number): string {
62 + const a = Math.max(0, Math.min(1, alpha));
63 + return `color-mix(in srgb, ${partyVar(id)} ${Math.round(a * 100)}%, transparent)`;
64 +}
54 65 /** Couleur atténuée par une probabilité (incertitude → moins d'opacité). */
55 66 export function withAlpha(hex: string, alpha: number): string {
56 67 const h = hex.replace("#", "");
57 68