Visualisation des scrutins publics de l'Assemblée nationale : pour chaque scrutin, la position de vote de chaque député, colorée par groupe dans l'hémicycle.
Site statique. Aucun serveur d'application, aucune base de données, aucun appel réseau au moment de la visite : un script d'ingestion transforme l'open data officiel en fichiers JSON, et le site les sert tels quels.
En ligne : https://kivotkoi.pages.dev
Un député appartient à exactement une case : pour, contre,
abstention, nonVotant, absent. Ni zéro, ni deux.
Cet invariant est codé dans scripts/partition.mjs, testé dans
test/partition.test.mjs, vérifié à l'ingestion, puis re-vérifié côté
client au chargement de chaque scrutin. Si un scrutin le rompt, il n'est pas
publié ; si un fichier publié le rompt, l'interface affiche « décompte non
fiable » au lieu d'un total d'apparence normale.
Trois distinctions volontairement maintenues :
nonVotant≠absent. Le premier est présent en séance mais ne prend pas part au vote (président de séance, notamment). Les fondre fabrique un faux taux d'absentéisme. La règle vaut aussi pour l'affichage : elle a déjà cédé là, et pas ailleurs — voir « Une régression instructive » plus bas.- L'identifiant, jamais le nom. Des homonymes siègent réellement à
l'Assemblée, et un nom se sérialise différemment selon la source. La clé est
toujours l'
uidofficiel (PA######). - Ce qui est publié ≠ ce qui est déduit. Toute valeur reconstituée est signalée comme telle dans le fichier produit et dans l'interface, et abandonnée dès qu'elle n'est plus démontrable.
Quatre archives, toutes en Licence Ouverte / Open Licence 2.0 (Etalab),
attribution à l'Assemblée nationale. Racine commune :
https://data.assemblee-nationale.fr/static/openData/repository/17/
| Jeu | Chemin | À quoi il sert |
|---|---|---|
| Scrutins | loi/scrutins/Scrutins.json.zip |
les votes eux-mêmes |
| Députés actifs | amo/deputes_actifs_mandats_actifs_organes/AMO10_….json.zip |
noms, groupes, effectifs |
| Historique depuis 1997 | amo/tous_acteurs_mandats_organes_xi_legislature/AMO30_….json.zip |
députés remplacés en cours de mandat |
| Dossiers législatifs | loi/dossiers_legislatifs/Dossiers_Legislatifs.json.zip |
lien vers le texte de loi |
AMO30 n'est pas un luxe : AMO10 ignore les députés remplacés en cours de
législature, ce qui rend l'effectif des groupes systématiquement trop court et
la reconstitution des absents impossible.
Pourquoi pas NosDéputés.fr. Regards Citoyens n'assure plus la maintenance au-delà de la 16ᵉ législature. Vérifié le 26 juillet 2026 :
nosdeputes.fr/organismes/groupe/jsonrenvoie encore Renaissance et les groupes NUPES avecgroupe_actuel: true. La source ne couvre pas la 17ᵉ législature. Une version antérieure du projet en dépendait — voirdocs/archive/.
npm install
npm run ingest # télécharge l'open data et produit public/donnees/
npm run dev # http://localhost:5173L'ingestion prend quelques minutes la première fois : quatre archives à télécharger et décompresser, puis un scrutin validé à la fois. Sans elle, l'application affiche un écran « Données indisponibles » explicite plutôt qu'une page blanche.
En pratique, cette commande n'est plus nécessaire : l'ingestion tourne chaque jour sur GitHub Actions et commite le résultat (voir plus bas). On la lance à la main pour déboguer, ou pour travailler sur des données fraîches sans attendre le lendemain.
npm test # 134 tests, sans dépendance externe (node:test)
npm run build # produit dist/
npm run preview # sert dist/ localement
npm run lint
npm run ingest -- --inspecter # imprime la forme réelle d'un scrutin
npm run ingest -- --mois 0 # lève la fenêtre de 12 mois.
├── index.html point d'entrée Vite
├── src/
│ ├── main.jsx montage React
│ ├── App.jsx interface : hémicycle, calendrier, analyse
│ ├── Entete.jsx titre et logo tricolore (SVG, pas d'image)
│ ├── Calendrier.jsx navigation par mois et par jour
│ ├── ListeVirtuelle.jsx liste virtualisée à hauteurs variables
│ ├── FicheDepute.jsx panneau de profil d'un député
│ ├── index.css feuille de style ; :root porte les couleurs
│ ├── tokens.js mêmes couleurs, pour les attributs SVG
│ ├── groupes.js ordre gauche→droite, couleurs, dénominations
│ ├── hemicycle.js géométrie des sièges en arcs
│ ├── fenetrage.js arithmétique de la virtualisation
│ ├── dates.js regroupement par mois et par jour
│ ├── intitule.js sépare le sujet du texte de l'objet procédural
│ ├── liens.js adresses des textes et dossiers législatifs
│ ├── decompte.js les nombres affichés sous l'hémicycle
│ └── chambres.js ce qui distingue l'Assemblée du Sénat
├── scripts/
│ ├── ingest.mjs orchestrateur : open data → public/donnees/
│ ├── partition.mjs l'invariant central
│ ├── absents.mjs reconstitution nominative des absents
│ ├── dossiers.mjs rattachement d'un scrutin à son texte
│ ├── deputes.mjs fiches individuelles
│ └── senat.mjs normalisation Sénat — écrit, pas branché
├── test/ 12 fichiers, 134 tests, node:test
├── docs/
│ ├── JOURNAL.md historique des décisions du projet
│ ├── ACCESSIBILITE.md audit RGAA 4.1 et points restants
│ └── archive/ documentation de la première phase
├── public/
│ ├── donnees/ sortie de l'ingestion — versionnée
│ ├── _headers en-têtes Cloudflare (CSP, cache)
│ └── _redirects réécriture SPA
└── .github/workflows/
└── ingestion.yml ingestion quotidienne + commit
Mesuré le 28 juillet 2026 sur une fenêtre de douze mois : 5 381 scrutins, du 8 septembre 2025 au 21 juillet 2026, 70 Mo au total.
| Fichier | Contenu | Poids |
|---|---|---|
index.json |
numéro, date, sujet, objet, sort et clé de dossier de chaque scrutin, plus le dictionnaire des 106 dossiers | ~1,9 Mo |
deputes.json |
identifiant → nom, y compris les députés remplacés | ~18 Ko |
scrutin-N.json |
par groupe : listes d'identifiants par case, effectif, absents nommés, ligne du groupe | ~9 Ko |
depute/PA###.json |
parti, mandats, participation, accord avec la ligne — 624 fiches | ~10 Ko |
Les fichiers de scrutin ne portent que des identifiants ; les noms vivent
uniquement dans deputes.json. Les répéter à chaque scrutin quadruplait la
sortie.
Ces fichiers sont versionnés dans git. Ce n'est pas un oubli : c'est ce qui permet au site de rester correct si l'open data de l'Assemblée est indisponible au moment d'un déploiement, et ce qui rend chaque publication auditable dans l'historique.
- Un hémicycle à 577 sièges, colorés par groupe ou par vote. La forme des sièges encode le vote dans les deux modes, pas seulement la couleur : sans cela, un daltonien ne distinguerait pas « pour » de « contre » (WCAG 1.4.1).
- Un calendrier plutôt qu'un rail sans fin : un mois à la fois, les jours avec scrutin marqués, la liste du jour dessous. La recherche par titre ou numéro bascule sur une liste virtualisée.
- L'analyse nominative : par groupe, la liste des députés dans chacune des cinq cases, chaque nom menant à sa fiche.
- Les liens vers la source : le texte de loi, le dossier législatif et la
page du scrutin sur
assemblee-nationale.fr. Le site dit comment on a voté, jamais ce que dit le texte.
libelleTypeVote et demandeur produisent des intitulés comme « amendement
n° 160 de Mme Cathala après l'article 2 » — illisible sans savoir de quel texte
il s'agit. src/intitule.js sépare le sujet (« Projet de loi relatif à la
protection des enfants ») de l'objet procédural, et affiche le premier en titre.
La séparation aboutit sur 99,6 % des scrutins ; sur le reste, l'intitulé
brut est affiché tel quel plutôt que découpé au hasard.
Un fichier de scrutin ne référence aucun dossier législatif. Le lien se fait
dans l'autre sens : les actes législatifs portent un champ voteRef. Le
rapprochement par titre avait été essayé d'abord — 29 % de couverture et des
faux positifs ; voteRef en donne 94,8 % sans ambiguïté. Quand deux
dossiers revendiquent la même clé de texte, la clé est supprimée : pas de lien
vaut mieux qu'un lien vers le mauvais texte.
Ces limites viennent des données, pas du code. Elles sont assumées et visibles dans l'interface plutôt que masquées.
L'Assemblée ne publie que les députés ayant pris part au vote : un scrutin
ordinaire en nomme environ 135 sur 577. Seul nombreMembresGroupe trahit
l'effectif réel.
scripts/absents.mjs reconstitue l'effectif de chaque groupe à la date du
scrutin depuis l'historique des mandats, puis en retire les votants. Le
résultat n'est retenu que s'il passe deux contrôles indépendants :
- l'effectif reconstitué doit égaler l'effectif annoncé par la source ;
- aucun votant ne doit se trouver hors de cet effectif.
Le procédé a été mesuré avant d'être publié : sur 8 102 couples (groupe, scrutin) où les deux méthodes pouvaient être comparées, elles s'accordaient à 100 %. En production, 98,5 % des absents sont nommés (2 139 867 sur 2 171 866). Le reste — surtout EPR, DR et LIOT, les groupes aux mouvements de mandats les plus fréquents — demeure anonyme : le siège est compté, gris, sans nom, et l'interface dit pourquoi.
Un absent n'est jamais inventé. Quand le contrôle échoue, on perd le nom, pas le décompte.
Les groupes dissous manquent au référentiel des mandats actifs (PO847173), et
PO0 apparaît à la place du RN dans quelques scrutins. Plutôt que de coder ces
cas en dur, resoudreGroupe() interroge les députés listés : si 70 % au moins
partagent un même groupe actuel, c'est celui-là. La déduction est signalée dans
le fichier produit (groupesDeduits), jamais présentée comme une lecture
directe. Un groupe non résolu garde son identifiant brut PO######.
L'open data ne dit pas où placer un groupe sur cet axe. Le choix vit dans
src/groupes.js ; un groupe absent de la liste est placé en fin de rang, en
gris, et signalé dans l'interface.
Il n'y a pas de taux d'absentéisme dans ce projet, et c'est délibéré.
Sur les douze mois ingérés, la répartition est la suivante :
| Type de scrutin | Nombre | Part | Médiane des votants |
|---|---|---|---|
| scrutin public ordinaire | 5 325 | 99,0 % | 135 |
| scrutin public solennel | 43 | 0,8 % | 532 |
| motion de censure | 13 | 0,2 % | 144 |
Les scrutins ordinaires se tiennent avec les députés présents en séance ; ne pas y figurer est la norme. Un taux calculé sur l'ensemble afficherait environ 77 % d'« absence » pour à peu près tout le monde — exact, et faux de sens.
scripts/deputes.mjs ventile donc la participation par type de scrutin, met en
avant les solennels, où la présence est attendue, et affiche systématiquement
la médiane du groupe en regard. Aucun pourcentage n'apparaît sans son
dénominateur.
Les motions de censure sont comptées à part, jamais comme une présence :
l'article 49 de la Constitution ne fait recenser que les voix favorables, si
bien que contre vaut 0 dans les treize motions de la période. S'abstenir,
s'opposer et être absent y sont indiscernables. Voter une censure est une
position politique.
L'accord avec la ligne du groupe, en revanche, repose sur une donnée
publiée : positionMajoritaire est fournie par l'Assemblée pour chaque groupe
et chaque scrutin. Elle n'est pas reconstituée à partir des décomptes. Les
« non votants » sont exclus du calcul — ne pas prendre part n'est ni un accord
ni un désaccord.
Ce sont ceux du champ libelleAbrev, pas ceux de l'usage courant. LFI-NFP et
non LFI, ECOS et non ECO, UDDPLR et non UDR. Une erreur ici ne casse
rien — elle déclasse silencieusement le groupe en gris. Le test
tokens.test.mjs verrouille les douze valeurs. L'interface affiche la
dénomination complète en regard de l'acronyme.
Le 28 juillet 2026, la légende sous l'hémicycle annonçait 213 non-votants sur le scrutin 8434. Il y en avait 2.
Les données étaient justes : le fichier publié comptait bien nonVotant: 2 et
211 absents. C'est l'affichage qui additionnait les deux cases et étiquetait la
somme du nom de la première — exactement la confusion que ce README interdit
plus haut, au seul endroit du site qui avance un chiffre rond.
Deux enseignements, tous deux appliqués :
- Un invariant qui n'est vrai que dans les données n'est pas tenu. Il doit l'être jusqu'au chiffre affiché.
- Le calcul vivait dans le JSX, où rien ne le testait. Il est sorti dans
src/decompte.js;test/decompte.test.mjsverrouille le cas 8434 nommément, et la barre comme la légende sont désormais construites depuis la même liste — elles ne peuvent plus diverger.
| Champ | Valeur |
|---|---|
| Dépôt | denvi44/kivotkoi, branche main |
| Framework preset | None |
| Build command | npm run build |
| Build output directory | dist |
Variable NODE_VERSION |
20 |
| Déploiements automatiques | activés |
Aucune variable d'environnement n'est nécessaire au build : les données sont
déjà dans le dépôt. Tout commit sur main déclenche un déploiement, y compris
celui du robot d'ingestion.
Cloudflare construit en Node 20, l'ingestion tourne en Node 22 sur GitHub Actions. L'écart est sans effet —
package.jsondéclarenode >= 20— mais il est volontaire : les runners GitHub déprécient Node 20.
.github/workflows/ingestion.yml s'exécute chaque jour à 07h20 UTC —
l'open data de l'Assemblée est republié vers 03h–06h UTC — et peut être
déclenché à la main (Actions → Ingestion des scrutins → Run workflow). Six
étapes :
- Cohérence du lockfile.
npm cirefuse de tourner sipackage.jsonetpackage-lock.jsonne s'accordent pas, et son message brut ne dit pas pourquoi. Un simple renommage du paquet suffit à les désynchroniser ; c'est arrivé au renommage en « kivotkoi ». npm ci- Tests d'invariant, avant l'ingestion : si
partition.mjsest cassé, on ne veut pas le découvrir dans les données publiées. - Ingestion des quatre archives.
- Contrôle de non-régression. Un index contenant moins de scrutins que celui déjà en ligne fait échouer le job : mieux vaut ne rien publier qu'amputer le site.
- Commit si changement, qui déclenche le déploiement Cloudflare.
Le cycle complet prend environ une minute. Deux variables de dépôt (Settings → Secrets and variables → Actions → Variables) permettent à l'Assemblée d'identifier le client dans ses journaux :
SITE_URL https://kivotkoi.pages.dev
SITE_CONTACT vous@example.org
Elles sont facultatives ; à défaut, l'agent utilisateur annonce
contact non renseigné.
public/_headers pose une CSP stricte. La seule origine tierce autorisée est
Google Fonts (fonts.googleapis.com, fonts.gstatic.com), importée par
src/index.css. Pour supprimer cette dépendance externe, self-hoster les trois
familles (Archivo, Spectral, IBM Plex Mono) et retirer les deux domaines de la
CSP.
Audit RGAA 4.1 / WCAG 2.1 AA dans docs/ACCESSIBILITE.md, avec les points
restants et les arbitrages assumés. En résumé :
- Les 577 sièges ne sont pas dans l'ordre de tabulation — ce serait 577 arrêts avant d'atteindre le reste de la page. L'équivalent accessible n'est pas un contournement mais la section « Analyse du scrutin », qui liste chaque député sous forme de bouton menant à la même fiche. Le graphique porte un résumé chiffré et y renvoie.
- Le vote est encodé par la forme autant que par la couleur.
- Deux couleurs de groupe restent sous le seuil de 3:1 exigé par WCAG 1.4.11 sur le fond du panneau : RN (1,36:1) et UDDPLR (2,05:1). Les corriger suppose de s'éloigner de la couleur d'usage du groupe. Les deux options restantes sont documentées ; aucune n'est encore tranchée.
L'architecture est prête : scripts/senat.mjs normalise les scrutins publics du
Sénat vers le même schéma, src/chambres.js isole les 348 sièges, la palette
des groupes et les différences de fond entre les deux sources — le Sénat nomme
tout le monde, y compris les non-votants, là où l'Assemblée ne nomme que les
votants.
Rien n'est publié, et le blocage n'est pas technique. Au 27 juillet 2026,
les fichiers de scrutin (scrutin-public/{session}/scr{session}-{n}.json) ne
figurent pas parmi les jeux listés sur data.senat.fr sous Licence Ouverte
2.0. Tant que le Sénat n'a pas confirmé leur statut, ces données ne seront pas
mises en ligne.
- Pas de vulgarisation des textes. Résumer en langage courant une loi sur l'aide à mourir ou le narcotrafic, c'est publier une interprétation. Le site renvoie vers le texte officiel et s'arrête là. Décision assumée, pas un manque de temps.
- Fenêtre glissante de 12 mois. La législature entière compte 8 400 scrutins pour 86 Mo, recommittés à chaque ingestion.
- Fraîcheur.
Scrutins.json.zipn'est pas republié tous les jours. Le pied de page affiche la date d'ingestion, pas celle du dernier scrutin — les deux peuvent diverger de plusieurs semaines sans que rien ne soit cassé. - Pas de PWA. Le service worker de la première version mettait en cache des
routes
/api/disparues. Rien ne le remplace.
docs/archive/ conserve la documentation de la première phase du projet.
Lire docs/archive/LISEZ-MOI.md avant d'y toucher : le README qui s'y trouve
décrit en détail une architecture qui n'a jamais fonctionné.
Code : MIT. Données : Licence Ouverte 2.0, Assemblée nationale.