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>
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'Élections Québec (simplifiée pour le web). Avant le scrutin, aucune circonscription n'est « gagnée » : l'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'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'analyse et de prévision consacrée à l'é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'analyse et de prévision consacrée à l'é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'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'É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'É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'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'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'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'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'é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'é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'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'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'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'API), les corrigent à la marge. Aucun n'est saisi à la main ; l'absence d'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'API publique, historique quotidien archivé, marchés de moins de 5 000 $ ou périmés (> 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'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'échantillon effective (ESS) est publiée.</li> | |
| 47 | + <li><b>Fondamentaux</b>. Les rapports de sondage contiennent des questions structurantes (satisfaction à l'é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'état latent dans l'espace logit avec un poids qui vaut 20 % à 120 jours du vote et décroît linéairement jusqu'à 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'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'élection générale 2022 dans la même circonscription. L'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'ensemble, pour que l'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'erreur d'échantillonnage, l'erreur d'industrie, la dérive jusqu'au scrutin et les erreurs régionales et locales. Une carte QC26 n'affiche jamais une circonscription comme « gagnée » avant le vote : l'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'É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'é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'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'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'erreur d'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'extraction documentaire dépend d'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'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'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'erreur d'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'extraction documentaire dépend d'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'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'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'enjeu (cliquez pour la source, la page et la citation). Une case vide signifie qu'aucun document officiel ingéré n'aborde le sujet, pas que le parti n'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'enjeu (cliquez pour la source, la page et la citation). Une case vide signifie qu'aucun document officiel ingéré n'aborde le sujet, pas que le parti n'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'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'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'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'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'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'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'é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'é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'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'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'actualité plutôt que l'opinion, l'attention mesure la curiosité, les partielles ont une participation faible. C'est pourquoi chaque poids est petit, borné, publié, et pourquoi la vue « sondages seuls » reste toujours affichée à côté de l'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'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.</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'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'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'entrée entre les deux runs ; QC26 n'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'entrée entre les deux runs ; QC26 n'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'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é & 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'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'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'en retient que l'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'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 | |