gestion_table_tournante_libre/src/interface/qualite/paires.js
Mathieu Benoit 01c9296c73 [ADD] quality page views: header, profiles, memberships, pairs, goals
The Qualité tab now draws its five views from the placement's detail.
A numeric header with each figure's percentage and worst case; the
profile as counts with the diversity bound dotted, and as percentages
on a second chart; membership collisions and a table grid behind a
three-position selector, seats by default; repeated pairs with matrix
and a card section; goals per round on servable couples and
facilitators over the evening and per round; the course of the search.
Each chart has its twin table and keyboard access.
Checked: node 5083, navigateur 1102, node-long 128; captures stable.

--- FR ---

[ADD] vues de la qualité : en-tête, profils, appartenances, paires

L'onglet Qualité dessine désormais ses cinq vues à partir du détail du
placement : un en-tête chiffré avec le pourcentage et le pire cas de
chaque chiffre ; le profil en comptes, borne de diversité en pointillé,
et en pourcentages sur un second graphique ; les collisions et la grille
des tables derrière un sélecteur à trois positions, les sièges par
défaut ; les paires répétées, leur matrice et la fiche ; les objectifs
par tour et les animateurs sur la soirée et par tour ; la course. Chaque
graphique a son tableau jumeau et se parcourt au clavier.
Vérifié : node 5083, navigateur 1102, longues 128 ; captures stables.

Assisted-by: Claude Opus 5.5
2026-10-12 11:26:02 -04:00

229 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le modèle de la vue des paires répétées (§ 12.5, B14, D32, D33, D50) et de
// la rubrique « Rencontres » de la fiche, pur : VuePaires.svelte et
// Fiche.svelte le câblent.
//
// Il met en forme ce que le détail du placement porte, dans l'ordre où il
// le porte : les paires choisies puis les paires imposées, chacune triée par
// le moteur (fois décroissant, puis identifiants) ; la matrice dans l'ordre
// du profil (§ 12.3), jamais par l'alphabet. Il ne trie, ne recompte et ne
// mesure rien : un tour du détail, compté à partir de 0, s'écrit à partir
// de 1, une table, nommée par son identifiant, s'écrit par son numéro, et
// une liste du détail se lit par sa longueur et sa tête, sans s'ajouter à
// une autre ni s'y comparer.
import { rencontresDePersonne } from '../../application/evaluation.js';
import { nomAffiche } from '../../application/personnes.js';
import { pagine } from '../propositions/modele.js';
/** Les cas de la vue des paires, dans l'ordre où elle les éprouve :
* uneTable, une seule table ; unTour, un seul tour ; aucune, aucune paire
* ne se revoit ; tableau, le tableau des paires. */
export const CAS_PAIRES = Object.freeze(['uneTable', 'unTour', 'aucune', 'tableau']);
/**
* Le cas de la vue des paires pour un détail comparable : une seule table
* l'emporte, puis un seul tour ; sans paire choisie ni imposée, aucune ;
* sinon le tableau.
*
* @param {{T: number, R: number, paires: {choisies: Object[], imposees: Object[]}}} detail
* @returns {string} l'un de CAS_PAIRES
*/
export function casDesPaires({ T, R, paires }) {
if (T === 1) return 'uneTable';
if (R === 1) return 'unTour';
if (paires.choisies.length === 0 && paires.imposees.length === 0) return 'aucune';
return 'tableau';
}
// Les personnes données par identifiant, chacune {id, nom, appartenance,
// homonyme} : homonyme vrai quand une autre personne de la liste porte le
// même nom affiché — la vue écrit alors son identifiant à côté du nom.
function personnesParId(personnes) {
const parNom = new Map();
for (const { nom } of personnes) parNom.set(nom, (parNom.get(nom) ?? 0) + 1);
return new Map(
personnes.map(({ id, nom, appartenance }) => [id, { id, nom, appartenance, homonyme: parNom.get(nom) > 1 }]),
);
}
/**
* Le nom d'une personne là où un seul texte la désigne — une infobulle, un
* intitulé de la matrice, un nom accessible : son nom, suivi de son
* identifiant pour un homonyme, comme une ligne l'écrit en deux morceaux.
*
* @param {{id: number, nom: string, homonyme: boolean}} personne
* @param {(cle: string, details?: Object) => string} t
*/
export const nomDistinct = ({ id, nom, homonyme }, t) => (homonyme ? t('qualite.paires.nomHomonyme', { nom, id }) : nom);
// Le numéro de chaque table du détail, par son identifiant.
const numerosParTable = ({ idsTables, numerosTables }) => new Map(idsTables.map((id, t) => [id, numerosTables[t]]));
/**
* Les lignes du tableau des paires (§ 12.5, D32) : {choisies, imposees},
* chacune dans l'ordre du détail. Une ligne : {cle, genre, a, b, fois,
* tours, repetition} — cle, « a:b » par identifiants ; genre, 'choisie' ou
* 'imposee' ; a et b, {id, nom, appartenance, homonyme} ; tours, chaque
* tour où la paire est assise ensemble, {tour, table}, tour à partir de 1,
* table son numéro ; repetition, le premier tour où la paire se revoit —
* son second tour ensemble —, où mène la première commande de la ligne.
*
* @param {Object} detail un détail comparable (detaillerQualite)
* @returns {{choisies: Object[], imposees: Object[]}}
*/
export function lignesDesPaires(detail) {
const personnes = personnesParId(detail.personnes);
const numeros = numerosParTable(detail);
const ligne = (genre) => ({ a, b, fois, tours }) => {
const ecrits = tours.map(({ tour, table }) => ({ tour: tour + 1, table: numeros.get(table) }));
return { cle: `${a}:${b}`, genre, a: personnes.get(a), b: personnes.get(b), fois, tours: ecrits, repetition: ecrits[1].tour };
};
return { choisies: detail.paires.choisies.map(ligne('choisie')), imposees: detail.paires.imposees.map(ligne('imposee')) };
}
/**
* La page p du tableau des paires (§ 12.5, D50) : le découpage du tableau
* des propositions (pagine, TAILLE_PAGE lignes par page), les paires
* choisies d'abord, puis les imposées, comptées à la suite. Rend
* {choisies, imposees, page, pages, premier, dernier, total, paginee} :
* les lignes de chaque groupe que la page porte, puis la forme de pagine.
*
* @param {{choisies: Object[], imposees: Object[]}} lignes lignesDesPaires
* @param {number} page entière, à partir de 1 ; bornée aux pages existantes
*/
export function pageDesPaires({ choisies, imposees }, page) {
const { lignes, horsClassement, ...reste } = pagine({ lignes: choisies, horsClassement: imposees }, page);
return { choisies: lignes, imposees: horsClassement, ...reste };
}
/**
* La matrice « qui a rencontré qui » (§ 12.5, D33) mise en forme pour la
* grille, son infobulle et son jumeau ; null quand le détail ne la porte
* pas (au-delà du seuil). Les personnes viennent dans l'ordre de la
* matrice, celui du profil, chacune sous son rang à partir de 1 : une ligne
* de la grille s'intitule « rang. nom », une colonne par le rang seul. Le
* nom d'une personne, celui de nomDistinct, s'écrit une fois et sert à
* l'intitulé de sa ligne comme à l'infobulle de chacune de ses cases. Les
* valeurs sont le triangle du détail déplié en n × n, ligne après ligne,
* symétrique, null sur la diagonale, que la grille laisse vide.
*
* Rend {personnes, lignes, colonnes, valeurs, infobulle, jumeau} :
* personnes, [{id, nom, rang}], nom celui de nomDistinct ; lignes et
* colonnes, [{libelle}] ;
* infobulle, {colonnes, lignes}, une ligne par case dans l'ordre des
* valeurs — la paire et son compte, la personne seule sur la diagonale ;
* jumeau, {nom, colonnes, lignes}, une ligne par personne, son intitulé
* puis un compte par colonne, vide sur la diagonale.
*
* @param {Object} detail un détail comparable
* @param {{t: Function, format: {nombre: Function}}} outils
*/
export function matriceDesPaires(detail, { t, format }) {
if (detail.matrice === null) return null;
const { ordre, comptes } = detail.matrice;
const parId = personnesParId(detail.personnes);
const n = ordre.length;
const personnes = ordre.map((id, i) => ({ id, nom: nomDistinct(parId.get(id), t), rang: i + 1 }));
// Le rang d'une paire i < j dans le triangle du détail, ligne après ligne.
const rangPaire = (i, j) => (i * (2 * n - i - 1)) / 2 + (j - i - 1);
const compte = (i, j) => (i === j ? null : comptes[i < j ? rangPaire(i, j) : rangPaire(j, i)]);
const lignes = personnes.map(({ nom, rang }) => ({ libelle: t('qualite.paires.matrice.ligne', { rang: format.nombre(rang), nom }) }));
const colonnes = personnes.map(({ rang }) => ({ libelle: format.nombre(rang) }));
const valeurs = [];
const bulles = [];
for (let i = 0; i < n; i += 1) {
for (let j = 0; j < n; j += 1) {
valeurs.push(compte(i, j));
bulles.push({
paire: i === j ? personnes[i].nom : t('qualite.paires.matrice.paire', { a: personnes[i].nom, b: personnes[j].nom }),
fois: compte(i, j),
});
}
}
const colonnesBulle = [
{ cle: 'paire', libelle: t('qualite.paires.colonne.paire'), genre: 'texte' },
{ cle: 'fois', libelle: t('qualite.paires.colonne.fois'), genre: 'nombre' },
];
const colonnesJumeau = [
{ cle: 'personne', libelle: t('qualite.paires.matrice.personne'), genre: 'texte' },
...personnes.map(({ rang }, j) => ({ cle: `c${j}`, libelle: format.nombre(rang), genre: 'nombre' })),
];
const lignesJumeau = personnes.map((personne, i) => {
const ligne = { personne: lignes[i].libelle };
for (let j = 0; j < n; j += 1) ligne[`c${j}`] = i === j ? '' : compte(i, j);
return ligne;
});
return {
personnes,
lignes,
colonnes,
valeurs,
infobulle: { colonnes: colonnesBulle, lignes: bulles },
jumeau: { nom: t('qualite.paires.matrice.titre'), colonnes: colonnesJumeau, lignes: lignesJumeau },
};
}
/**
* La phrase de synthèse de la matrice (§ 12.7) : chaque liste du détail qui
* n'est pas vide, les choisies puis les imposées, lue telle que le moteur la
* rend — son effectif et le fois de sa tête, la plus revue de la liste — ;
* sans paire revue, la phrase qui le dit. Rien ne s'y additionne ni ne s'y
* compare : les deux têtes s'écrivent l'une après l'autre. Rend {cle,
* details}, details {choisies, imposees}, chacune {paires, fois}, null pour
* une liste vide.
*
* @param {{paires: {choisies: Object[], imposees: Object[]}}} detail
*/
export function syntheseMatrice({ paires: { choisies, imposees } }) {
if (choisies.length === 0 && imposees.length === 0) return { cle: 'qualite.paires.matrice.syntheseAucune', details: {} };
const liste = (paires) => (paires.length === 0 ? null : { paires: paires.length, fois: paires[0].fois });
return { cle: 'qualite.paires.matrice.synthese', details: { choisies: liste(choisies), imposees: liste(imposees) } };
}
/**
* La rubrique « Rencontres » de la fiche d'une personne (§ 12.5, B14, D33)
* : la ligne de la personne dans le placement que le plan montre, la
* source de la page de qualité — la proposition demandée au plan quand la
* liste la porte, sinon le retenu, sinon aucun, comme sourceQualite
* (source.js) le choisit, sans en évaluer la table.
*
* Rend {statut, source, lignes} : statut, celui de rencontresDePersonne —
* mesuree, nonMesurable, exclue ou sansPlacement ; source, {cle, details},
* le libellé du placement, null sans placement ; lignes, chaque personne
* rencontrée, {id, nom, appartenance, homonyme, fois, tours: [{tour,
* table}]}, les plus revues d'abord, tour à partir de 1, table son numéro.
* appartenance et homonyme sont ceux du tableau des paires, sur les
* personnes non exclues de la charge, celles que le placement mesure. Ne
* modifie pas la charge.
*
* @param {Object} charge la charge de l'état observable de la séance
* @param {number|null} demandee la proposition demandée au plan (ui.proposition)
* @param {number} personne l'identifiant de la personne de la fiche
*/
export function rubriqueRencontres(charge, demandee, personne) {
let id = null;
if (demandee !== null && charge.propositions.some((proposition) => proposition.id === demandee)) id = demandee;
else if (charge.retenu !== null) id = 'retenu';
const { statut, lignes } = rencontresDePersonne(charge, id, personne);
let source = null;
if (id === 'retenu') source = { cle: 'participants.fiche.rencontres.retenu', details: {} };
else if (id !== null) source = { cle: 'participants.fiche.rencontres.proposition', details: { proposition: id } };
const numeros = new Map(charge.tables.map(({ id: table, numero }) => [table, numero]));
const personnes = personnesParId(
charge.participants
.filter(({ exclu }) => !exclu)
.map((p) => ({ id: p.id, nom: nomAffiche(p), appartenance: (p.appartenance ?? '').trim() === '' ? null : p.appartenance })),
);
return {
statut,
source,
lignes: lignes.map(({ id: autre, fois, tours }) => ({
...personnes.get(autre),
fois,
tours: tours.map(({ tour, table }) => ({ tour: tour + 1, table: numeros.get(table) })),
})),
};
}