The operator now reaches tags, goals and priorities from the screens. A person's card adds and removes tags, the participant list carries a tag legend; each table takes an evening goal or one per round with its rule; the generation settings hold a keyboard-operable priority editor (move, join, split, restore, weights), the two optional criteria and the goal mode; the proposals table and its ranking sentence follow the order; the import screen reads a participants or a goals file. Removing a tag or a goal returns focus to a neighbouring control. Checked: node 4590, navigateur 942, node-long 124; captures republished. --- FR --- [ADD] écrans : étiquettes, objectifs des tables, priorités, import L'opérateur atteint étiquettes, objectifs et priorités depuis les écrans. La fiche ajoute et retire les étiquettes, la liste porte leur légende ; chaque table reçoit un objectif de soirée ou par tour avec sa règle ; les réglages de génération portent un éditeur des priorités utilisable au clavier (monter, joindre, séparer, rétablir, poids), les deux critères facultatifs et le mode des objectifs ; le tableau des propositions et sa phrase suivent l'ordre ; l'import lit un fichier de personnes ou d'objectifs. Retirer une étiquette ou un objectif rend le focus à côté. Vérifié : node 4590, navigateur 942, longues 124 ; captures republiées. Assisted-by: Claude Opus 5.5
717 lines
34 KiB
JavaScript
717 lines
34 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Le modèle du tableau des propositions (§ 5.4, § 5.5, § 5.7, § 9, § 12.1,
|
||
// § 12.6, § 12.9), pur : SectionPropositions.svelte et
|
||
// TableauPropositions.svelte le câblent.
|
||
//
|
||
// Le tableau lit la table d'évaluation d'evaluer (evaluation.js), la seule
|
||
// mesure : chaque cellule porte la valeur que la table rend, telle quelle, et
|
||
// sa clé de mise en forme ; aucune arithmétique ne s'y fait, aucun plafond
|
||
// ne s'y recompose. Des rencontres, il ne porte que le minimum, sur les trois
|
||
// populations du § 5.4 — tous, mobiles, ancrés —, en tête des colonnes de
|
||
// mesure ; la moyenne n'y entre pas. Le plafond réalisé, individuel, se lit
|
||
// par le manque, dont les cellules sont celles de troisChiffres. Les imposés
|
||
// se disent à part, sur chaque ligne, jamais ajoutés aux choisis.
|
||
//
|
||
// Chaque minimum affiché désigne la personne la plus mal servie (§ 12.1) :
|
||
// le moins de rencontres, d'appartenances vues, de diversité. La redondance
|
||
// r(p) va dans l'autre sens — plus elle est grande, pire est le sort —, et
|
||
// son minimum désignerait la personne la mieux servie : elle se lit par sa
|
||
// somme Σ r(p), la grandeur que la recherche et le classement lisent.
|
||
//
|
||
// Les objectifs des tables et les animateurs (A9, A11) forment un sixième
|
||
// groupe de colonnes, présentes chacune quand son critère compte — coché et
|
||
// avec un objet, tel que l'état des critères de la table le dit — : le
|
||
// tableau ne recalcule ni « sans objet » ni le mode, qu'il lit dans les
|
||
// valeurs que le critère des objectifs classe.
|
||
//
|
||
// Une cellule vaut { colonne, valeur, cle, decimales } : cle null, la valeur
|
||
// est un nombre que format.nombre écrit avec decimales chiffres après la
|
||
// virgule ; sinon le texte est t(cle) — « — » pour une population vide,
|
||
// « inconnu » pour une grandeur que l'a priori inconnu ne permet pas, le
|
||
// certificat en mots.
|
||
import { evaluer } from '../../application/evaluation.js';
|
||
import { nomAffiche } from '../../application/personnes.js';
|
||
|
||
const VIDE = 'format.vide';
|
||
const INCONNU = 'propositions.valeur.inconnu';
|
||
|
||
// Chaque colonne de mesure et la lecture de sa valeur dans une ligne
|
||
// comparable de la table d'évaluation, dans l'ordre d'affichage. La Map ne
|
||
// sert qu'à retrouver la lecture d'une colonne ; COLONNES fixe l'ordre.
|
||
const LECTURES = new Map([
|
||
['rencontresTous', (l) => l.mesures.aggRencontres.tous.min],
|
||
['rencontresMobiles', (l) => l.mesures.aggRencontres.mobiles.min],
|
||
['rencontresAncres', (l) => l.mesures.aggRencontres.ancres.min],
|
||
['collisionsCumulees', (l) => l.mesures.collisionsCumulees],
|
||
['pairesDistinctes', (l) => l.mesures.pairesDistinctes],
|
||
['excedentCollisions', (l) => l.mesures.excedentCollisions],
|
||
['rencontresRepetees', (l) => l.mesures.rencontresRepetees.choisies],
|
||
['maxRencontresPaire', (l) => l.mesures.maxRencontresPaire],
|
||
['retoursChoisis', (l) => l.mesures.totalRetoursChoisis],
|
||
['appartenancesVues', (l) => l.mesures.aggAppartenancesVues.tous.min],
|
||
['totalRedondance', (l) => l.mesures.totalRedondance],
|
||
['diversite', (l) => l.mesures.aggDiversite.tous.min],
|
||
['manqueMax', (l) => l.troisChiffres.manqueMax.tous],
|
||
['effectifManque', (l) => l.troisChiffres.effectifManque.tous],
|
||
['ecartItineraireMax', (l) => l.troisChiffres.ecartItineraireMax?.tous ?? null],
|
||
['ecartAuPlafondAPriori', (l) => l.ecartAuPlafondAPrioriMax],
|
||
['jamaisServies', (l) => l.mesures.objectifs?.jamaisServies ?? null],
|
||
['horsObjectifMax', (l) => l.mesures.objectifs?.horsObjectifMax ?? null],
|
||
['toursHorsObjectif', (l) => l.mesures.objectifs?.horsObjectif ?? null],
|
||
['toursSelonObjectif', (l) => l.mesures.objectifs?.agregat.tous.min ?? null],
|
||
['animateursRencontres', (l) => l.mesures.animateurs?.agregat.tous.min ?? null],
|
||
['animateursNonRencontres', (l) => l.mesures.animateurs?.nonRencontres ?? null],
|
||
['certificat', (l) => l.minimumAtteint],
|
||
]);
|
||
|
||
// Les colonnes que le tableau ne porte que sous une condition : [critère,
|
||
// valeur], la colonne présente quand le critère compte et, valeur non
|
||
// null, quand il classe cette valeur — les personnes jamais servies sous
|
||
// « chacun au moins une fois », le plus de tours hors objectif d'une
|
||
// personne sous le troisième mode. Toute autre colonne est toujours là. La
|
||
// Map ne sert qu'à retrouver la condition d'une colonne.
|
||
const SOUS_CONDITION = new Map([
|
||
['jamaisServies', ['toursHorsObjectif', 'jamaisServies']],
|
||
['horsObjectifMax', ['toursHorsObjectif', 'horsObjectifMax']],
|
||
['toursHorsObjectif', ['toursHorsObjectif', 'toursHorsObjectif']],
|
||
['toursSelonObjectif', ['toursHorsObjectif', null]],
|
||
['animateursRencontres', ['animateursNonRencontres', null]],
|
||
['animateursNonRencontres', ['animateursNonRencontres', 'animateursNonRencontres']],
|
||
]);
|
||
|
||
// Les colonnes dont la valeur n'existe pas quand le plafond a priori est
|
||
// inconnu : elles s'écrivent « inconnu », jamais « — » (§ 12.6).
|
||
const SELON_A_PRIORI = new Set(['ecartItineraireMax', 'ecartAuPlafondAPriori']);
|
||
|
||
// Le taux de diversité, un rapport, se lit au centième ; les autres colonnes
|
||
// sont des comptes.
|
||
const DECIMALES = new Map([['diversite', 2]]);
|
||
|
||
// Les colonnes dont les cellules sont du texte : toutes les autres portent
|
||
// un nombre, qui s'aligne à droite en chiffres tabulaires.
|
||
const TEXTUELLES = new Set(['proposition', 'certificat', 'imposes', 'commandes']);
|
||
|
||
/** Les colonnes du tableau, dans l'ordre d'affichage : le rang et
|
||
* l'identifiant, puis les mesures — les trois minimums des rencontres en
|
||
* tête, celles des objectifs et des animateurs en dernier —, le certificat,
|
||
* les imposés et les commandes ; toutes celles qu'un tableau peut porter,
|
||
* tableau() rendant celles que porte le sien. Chacune porte son nom,
|
||
* la clé de son en-tête, qui nomme son unité, celle de son explication,
|
||
* le texte de l'infobulle de son en-tête et de la valeur appliquée qui la
|
||
* désigne, et nombre, vrai quand ses cellules portent un nombre. */
|
||
export const COLONNES = Object.freeze(
|
||
['rang', 'proposition', ...LECTURES.keys(), 'imposes', 'commandes'].map((nom) =>
|
||
Object.freeze({ nom, cle: `propositions.colonne.${nom}`, explication: `propositions.explication.${nom}`, nombre: !TEXTUELLES.has(nom) }),
|
||
),
|
||
);
|
||
|
||
// Les groupes d'en-têtes : chacun nomme des colonnes de mesure voisines dans
|
||
// COLONNES, sous la clé propositions.groupe.<nom>.
|
||
const GROUPES = [
|
||
['rencontres', ['rencontresTous', 'rencontresMobiles', 'rencontresAncres']],
|
||
['collisions', ['collisionsCumulees', 'pairesDistinctes', 'excedentCollisions']],
|
||
['repetitions', ['rencontresRepetees', 'maxRencontresPaire', 'retoursChoisis']],
|
||
['diversite', ['appartenancesVues', 'totalRedondance', 'diversite']],
|
||
['manque', ['manqueMax', 'effectifManque', 'ecartItineraireMax', 'ecartAuPlafondAPriori']],
|
||
['objectifs', ['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif', 'animateursRencontres', 'animateursNonRencontres']],
|
||
];
|
||
|
||
// Les segments de la rangée des groupes sur les colonnes noms, une suite de
|
||
// COLONNES : un groupe couvre celles de ses colonnes que noms porte d'un seul
|
||
// segment, une colonne hors groupe forme le sien, sans clé ; un groupe dont
|
||
// noms ne porte aucune colonne n'a pas de segment. Un groupe dont les
|
||
// colonnes ne se suivent pas dans noms lève : au chargement du module pour
|
||
// COLONNES entier.
|
||
function segmenter(noms) {
|
||
const segments = [];
|
||
for (let rang = 0; rang < noms.length; ) {
|
||
const groupe = GROUPES.find(([, colonnes]) => colonnes.includes(noms[rang]));
|
||
if (groupe === undefined) {
|
||
segments.push({ cle: null, colonnes: [noms[rang]] });
|
||
rang += 1;
|
||
continue;
|
||
}
|
||
const [nom, colonnes] = groupe;
|
||
const portees = colonnes.filter((colonne) => noms.includes(colonne));
|
||
portees.forEach((colonne, decalage) => {
|
||
if (noms[rang + decalage] !== colonne) throw new Error(`groupe ${nom} : colonnes non voisines`);
|
||
});
|
||
segments.push({ cle: `propositions.groupe.${nom}`, colonnes: portees });
|
||
rang += portees.length;
|
||
}
|
||
return Object.freeze(
|
||
segments.map(({ cle, colonnes }) => Object.freeze({ cle, etendue: colonnes.length, colonnes: Object.freeze([...colonnes]) })),
|
||
);
|
||
}
|
||
|
||
/** La rangée des groupes d'en-têtes de COLONNES entier, au-dessus de celle
|
||
* des colonnes : { cle, etendue, colonnes } par segment, dans l'ordre de
|
||
* COLONNES — cle, la clé du libellé du groupe, null pour une colonne hors
|
||
* groupe ; etendue, le nombre de colonnes couvertes ; colonnes, leurs noms.
|
||
* Les segments couvrent chaque colonne une fois. tableau() rend ceux des
|
||
* colonnes que porte le sien. */
|
||
export const GROUPES_COLONNES = segmenter(COLONNES.map(({ nom }) => nom));
|
||
|
||
// Les colonnes que porte le tableau d'une table d'évaluation, dans l'ordre de
|
||
// COLONNES : chacune hors de SOUS_CONDITION, et celles dont la condition
|
||
// tient sur l'état des critères de la table, {nom, coche, sansObjet,
|
||
// valeurs}. Un critère compte quand il est coché et a un objet.
|
||
function colonnesPortees(criteres) {
|
||
const comptent = new Map(criteres.filter(({ coche, sansObjet }) => coche && !sansObjet).map(({ nom, valeurs }) => [nom, valeurs]));
|
||
return COLONNES.filter(({ nom }) => {
|
||
const condition = SOUS_CONDITION.get(nom);
|
||
if (condition === undefined) return true;
|
||
const [critere, valeur] = condition;
|
||
const valeurs = comptent.get(critere);
|
||
return valeurs !== undefined && (valeur === null || valeurs.includes(valeur));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* La clé de l'explication d'une colonne : le texte de l'infobulle de son
|
||
* en-tête, et celui du nom qui la désigne dans la phrase de l'ordre
|
||
* (colonneDeValeur) — une valeur appliquée et sa colonne partagent la même.
|
||
* Lève TypeError pour un nom qui n'est pas une colonne de COLONNES.
|
||
*
|
||
* @param {string} nom le nom d'une colonne
|
||
* @returns {string}
|
||
*/
|
||
export function explicationDeColonne(nom) {
|
||
const colonne = COLONNES.find((candidate) => candidate.nom === nom);
|
||
if (colonne === undefined) throw new TypeError(`colonne inconnue : ${nom}`);
|
||
return colonne.explication;
|
||
}
|
||
|
||
// Une cellule de nombre : null s'écrit « — ».
|
||
const nombre = (colonne, valeur) => ({
|
||
colonne,
|
||
valeur,
|
||
cle: valeur === null ? VIDE : null,
|
||
decimales: DECIMALES.get(colonne) ?? 0,
|
||
});
|
||
|
||
// Le certificat en mots (§ 5.5) : « minimum atteint » quand minimumAtteint
|
||
// est vrai ; « atteignabilité inconnue » quand il est faux, ou null faute de
|
||
// plafond a priori ; « — » sur une population vide.
|
||
function certificat(ligne) {
|
||
const valeur = ligne.minimumAtteint;
|
||
let cle = 'propositions.certificat.inconnue';
|
||
if (valeur === true) cle = 'propositions.certificat.atteint';
|
||
else if (ligne.mesures.rencontres.length === 0) cle = VIDE;
|
||
return { colonne: 'certificat', valeur, cle, decimales: 0 };
|
||
}
|
||
|
||
// Les cellules de mesure d'une ligne comparable, une par colonne de mesure
|
||
// portée, dans l'ordre de LECTURES. L'a priori est inconnu quand
|
||
// troisChiffres ne porte pas d'écart d'itinéraire du tout.
|
||
function cellules(ligne, portees) {
|
||
const inconnu = ligne.troisChiffres.ecartItineraireMax === null;
|
||
return [...LECTURES].filter(([colonne]) => portees.has(colonne)).map(([colonne, lire]) => {
|
||
if (colonne === 'certificat') return certificat(ligne);
|
||
if (inconnu && SELON_A_PRIORI.has(colonne)) return { colonne, valeur: null, cle: INCONNU, decimales: 0 };
|
||
return nombre(colonne, lire(ligne));
|
||
});
|
||
}
|
||
|
||
// Ce que les réservations imposent, énoncé à part (§ 5.4) : les retours et
|
||
// les ancrages qui les imposent, les mêmes pour toute ligne ; les rencontres
|
||
// répétées imposées, propres au plan.
|
||
const imposes = (ligne, { retours, ancrages }) => [
|
||
{ cle: 'propositions.imposes.retours', details: { retours, ancrages } },
|
||
{ cle: 'propositions.imposes.repetees', details: { repetees: ligne.mesures.rencontresRepetees.imposees } },
|
||
];
|
||
|
||
// Un code et ses détails : une raison de dérive, une faute, une violation.
|
||
const codeDe = (raison) => ({ cle: raison.code, details: raison });
|
||
|
||
// Une raison de dérive : son code, ses détails, et la personne qu'elle nomme,
|
||
// null quand elle n'en nomme aucune.
|
||
const raisonDe = (raison) => ({ ...codeDe(raison), participant: raison.participant ?? null });
|
||
|
||
// La ligne d'un placement mesuré, ou celle d'un placement hors classement.
|
||
// portees : les noms des colonnes que porte le tableau ; rang : la place
|
||
// dans le classement, à partir de 1 ; null hors classement et pour le
|
||
// retenu.
|
||
function ligneDe(ligne, table, idRetenu, portees, rang = null) {
|
||
const retenue = ligne.id === idRetenu;
|
||
if (ligne.statut !== 'comparable') {
|
||
return {
|
||
id: ligne.id,
|
||
rang: null,
|
||
retenue,
|
||
comparable: false,
|
||
statut: `propositions.statut.${ligne.statut}`,
|
||
raisons: ligne.raisons.map(raisonDe),
|
||
};
|
||
}
|
||
return {
|
||
id: ligne.id,
|
||
rang,
|
||
retenue,
|
||
comparable: true,
|
||
cellules: cellules(ligne, portees),
|
||
imposes: imposes(ligne, table.imposes),
|
||
violations: ligne.violations.map(codeDe),
|
||
};
|
||
}
|
||
|
||
// La colonne que désigne chaque valeur qui classe (§ 5.7, A11) : celle qui
|
||
// la porte ; l'identifiant croissant, le dernier départage, désigne la
|
||
// colonne des propositions. La Map ne sert qu'à retrouver la colonne d'une
|
||
// valeur ; la phrase suit l'ordre que le classement rend.
|
||
const COLONNE_DE_VALEUR = new Map([
|
||
['ecartAuPlafondAPrioriMax', 'ecartAuPlafondAPriori'],
|
||
['excedentCollisions', 'excedentCollisions'],
|
||
['collisionsCumulees', 'collisionsCumulees'],
|
||
['jamaisServies', 'jamaisServies'],
|
||
['horsObjectifMax', 'horsObjectifMax'],
|
||
['toursHorsObjectif', 'toursHorsObjectif'],
|
||
['rencontresRepeteesChoisies', 'rencontresRepetees'],
|
||
['retoursChoisis', 'retoursChoisis'],
|
||
['animateursNonRencontres', 'animateursNonRencontres'],
|
||
['redondance', 'totalRedondance'],
|
||
['identifiant', 'proposition'],
|
||
]);
|
||
|
||
/**
|
||
* La colonne du tableau que désigne une valeur qui classe (valeursQuiClassent,
|
||
* criteres.js), ou 'identifiant', le dernier départage, qui désigne la
|
||
* colonne des propositions. Lève TypeError pour un nom que la table ne
|
||
* connaît pas : une valeur ajoutée au classement doit dire laquelle elle
|
||
* compare.
|
||
*
|
||
* @param {string} valeur une valeur qui classe, ou 'identifiant'
|
||
* @returns {string} le nom d'une colonne de COLONNES
|
||
*/
|
||
export function colonneDeValeur(valeur) {
|
||
const colonne = COLONNE_DE_VALEUR.get(valeur);
|
||
if (colonne === undefined) throw new TypeError(`valeur sans colonne : ${valeur}`);
|
||
return colonne;
|
||
}
|
||
|
||
// Un morceau de phrase : un texte de liaison, ou un nom qui désigne une
|
||
// colonne — celui d'une valeur appliquée, sous la clé
|
||
// moteur.valeur.<valeur>, avec le critère qui la classe.
|
||
const liaison = (cle, details = {}) => ({ cle, details, critere: null, valeur: null, colonne: null });
|
||
const designant = (critere, valeur, cle = `moteur.valeur.${valeur}`) => ({ cle, details: {}, critere, valeur, colonne: colonneDeValeur(valeur) });
|
||
|
||
// Des listes de morceaux mises bout à bout, le morceau separateur entre deux.
|
||
const joindre = (listes, separateur) => listes.flatMap((morceaux, rang) => (rang === 0 ? morceaux : [separateur(), ...morceaux]));
|
||
|
||
// Les noms d'un critère appliqué, {nom, valeurs, poids} : chaque valeur
|
||
// qu'il classe, dans l'ordre, séparées par « puis » comme deux paliers.
|
||
const nomsDuCritere = ({ nom, valeurs }) =>
|
||
joindre(
|
||
valeurs.map((valeur) => [designant(nom, valeur)]),
|
||
() => liaison('propositions.ordre.separateur'),
|
||
);
|
||
|
||
// Un palier appliqué en morceaux : les noms de son critère, seul ; pour un
|
||
// palier partagé, « ensemble, » puis chaque critère suivi de son poids,
|
||
// séparés par une virgule.
|
||
function palierEnMorceaux({ criteres }) {
|
||
if (criteres.length === 1) return nomsDuCritere(criteres[0]);
|
||
return [
|
||
liaison('propositions.ordre.ensemble'),
|
||
...joindre(
|
||
criteres.map((critere) => [...nomsDuCritere(critere), liaison('propositions.ordre.poids', { poids: critere.poids })]),
|
||
() => liaison('propositions.ordre.virgule'),
|
||
),
|
||
];
|
||
}
|
||
|
||
// La phrase des critères appliqués, morceau par morceau (A11) : le début,
|
||
// chaque palier appliqué dans l'ordre, puis l'identifiant croissant, séparés
|
||
// par « puis », et la fin. Chaque nom est celui de la valeur que le palier
|
||
// applique — « retours » pour les rencontres répétées sous « nouvelle
|
||
// table » seule. Sous l'ordre par défaut et les quatre cases cochées, sans
|
||
// objectif ni case des animateurs, chaque palier porte un critère qui
|
||
// classe une valeur du nom de son critère, et les morceaux traduits bout à
|
||
// bout composent le texte de propositions.ordre.criteres, celui de l'ordre
|
||
// fixe de référence.
|
||
function phraseDesCriteres(criteresAppliques) {
|
||
const paliers = [...criteresAppliques.map(palierEnMorceaux), [designant('identifiant', 'identifiant', 'propositions.ordre.identifiant')]];
|
||
return [
|
||
liaison('propositions.ordre.debut'),
|
||
...joindre(paliers, () => liaison('propositions.ordre.separateur')),
|
||
liaison('propositions.ordre.fin'),
|
||
];
|
||
}
|
||
|
||
// Le palier qui prend la place du palier sauté : ses critères {nom, poids,
|
||
// valeurs}, ceux du palier appliqué au rang rangSaute ; null quand aucun
|
||
// palier ne suit le palier sauté.
|
||
function palierAppliqueASaPlace({ criteresAppliques, rangSaute }) {
|
||
const palier = criteresAppliques[rangSaute];
|
||
return palier === undefined ? null : palier.criteres.map(({ nom, poids, valeurs }) => ({ nom, poids, valeurs: [...valeurs] }));
|
||
}
|
||
|
||
// Les phrases de l'ordre (§ 5.7), chacune une liste de morceaux : quand le
|
||
// plafond a priori manque, le critère sauté et le palier appliqué à sa
|
||
// place, s'il en est un ; puis les paliers appliqués.
|
||
function ordre(classement) {
|
||
const { criteresAppliques, critereSaute } = classement;
|
||
const phrases = [];
|
||
if (critereSaute !== null) {
|
||
phrases.push([liaison('propositions.ordre.saute', { saute: critereSaute, applique: palierAppliqueASaPlace(classement) })]);
|
||
}
|
||
phrases.push(phraseDesCriteres(criteresAppliques));
|
||
return phrases;
|
||
}
|
||
|
||
/**
|
||
* Le tableau d'une table d'évaluation (evaluer, evaluation.js).
|
||
*
|
||
* @param {import('../../application/evaluation.js').TableEvaluation} tableEvaluation
|
||
* @param {{ajuste?: boolean}} [options] ajuste : le retenu ne porte plus le
|
||
* placement de sa proposition d'origine (retenuAjuste) ; il choisit le
|
||
* libellé de la ligne du retenu
|
||
* @returns {{ordre: Array<Array<{cle: string, details: Object, critere: string|null,
|
||
* valeur: string|null, colonne: string|null}>>, colonnes: typeof COLONNES,
|
||
* groupes: typeof GROUPES_COLONNES, etendueRaisons: number, lignes: Object[],
|
||
* horsClassement: Object[], retenu: Object|null}}
|
||
* ordre : les phrases de l'ordre, chacune une liste de morceaux que t
|
||
* traduit et que l'affichage met bout à bout ; un morceau qui nomme une
|
||
* valeur appliquée, ou l'identifiant croissant, porte le critère qui la
|
||
* classe, la valeur et la colonne qu'elle désigne — 'identifiant' pour
|
||
* le dernier départage —, null pour un texte de liaison ; colonnes : celles
|
||
* de COLONNES que le tableau porte, les colonnes des objectifs et des
|
||
* animateurs selon l'état des critères de la table ; groupes : les
|
||
* segments de la rangée des groupes sur ces colonnes ; lignes : les
|
||
* comparables dans l'ordre du classement, chacune { id, rang, retenue, comparable: true,
|
||
* cellules, imposes, violations } ;
|
||
* horsClassement : les autres par identifiant, { id, rang: null, retenue,
|
||
* comparable: false, statut, raisons } — statut la clé de « non comparable »,
|
||
* raisons leurs codes, chacune { cle, details, participant }, participant
|
||
* l'identifiant de la personne en cause ou null ; etendueRaisons : le nombre
|
||
* de colonnes que couvre la cellule des raisons ; retenu : la ligne du
|
||
* retenu sous l'une ou l'autre forme, plus libelle { cle, details } — « tel
|
||
* que » ou « ajusté » —, null sans retenu. retenue marque la proposition
|
||
* d'origine du retenu.
|
||
*/
|
||
export function tableau(tableEvaluation, { ajuste = false } = {}) {
|
||
const idRetenu = tableEvaluation.retenu?.id ?? null;
|
||
const colonnes = colonnesPortees(tableEvaluation.criteres);
|
||
const portees = new Set(colonnes.map(({ nom }) => nom));
|
||
// Les comparables ouvrent la liste, dans l'ordre du classement : leur
|
||
// place y est leur rang.
|
||
const toutes = tableEvaluation.propositions.map((ligne, place) => ligneDe(ligne, tableEvaluation, idRetenu, portees, place + 1));
|
||
return {
|
||
ordre: ordre(tableEvaluation.classement),
|
||
colonnes,
|
||
groupes: segmenter(colonnes.map(({ nom }) => nom)),
|
||
// La cellule des raisons d'une ligne hors classement couvre les colonnes
|
||
// de mesure portées et celle des imposés.
|
||
etendueRaisons: [...LECTURES.keys()].filter((nom) => portees.has(nom)).length + 1,
|
||
lignes: toutes.filter(({ comparable }) => comparable),
|
||
horsClassement: toutes.filter(({ comparable }) => !comparable),
|
||
retenu: tableEvaluation.retenu === null ? null : ligneDuRetenu(tableEvaluation, idRetenu, portees, ajuste),
|
||
};
|
||
}
|
||
|
||
// La ligne du retenu, et son libellé : « tel que » la proposition d'origine,
|
||
// ou « ajusté » depuis elle.
|
||
function ligneDuRetenu(tableEvaluation, idRetenu, portees, ajuste) {
|
||
const cle = ajuste ? 'propositions.retenu.ajuste' : 'propositions.retenu.origine';
|
||
return { ...ligneDe(tableEvaluation.retenu, tableEvaluation, idRetenu, portees), libelle: { cle, details: { proposition: idRetenu } } };
|
||
}
|
||
|
||
/**
|
||
* La colonne que la phrase de l'ordre surligne (§ 5.7), de deux états
|
||
* tenus à part : celle du nom survolé, sinon celle du nom qui a le focus,
|
||
* sinon null. Quitter l'un rend l'autre : un survol qui passe ne retire pas
|
||
* le surlignage du focus.
|
||
*
|
||
* @param {string|null} survolee la colonne du nom sous le pointeur
|
||
* @param {string|null} focalisee la colonne du nom qui a le focus
|
||
* @returns {string|null}
|
||
*/
|
||
export function surlignee(survolee, focalisee) {
|
||
return survolee ?? focalisee;
|
||
}
|
||
|
||
/**
|
||
* Le texte d'une cellule : t(cle) quand elle porte une clé, sinon son nombre
|
||
* mis en forme.
|
||
*
|
||
* @param {{valeur: number|boolean|null, cle: string|null, decimales: number}} cellule
|
||
* @param {{t: Function, format: {nombre: Function}}} outils
|
||
* @returns {string}
|
||
*/
|
||
export function texteCellule({ valeur, cle, decimales }, { t, format }) {
|
||
return cle === null ? format.nombre(valeur, { decimales }) : t(cle);
|
||
}
|
||
|
||
// Égalité en profondeur de deux valeurs du fichier d'état : nombres,
|
||
// chaînes, booléens, null, listes et objets simples.
|
||
function egaux(a, b) {
|
||
if (a === b) return true;
|
||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||
const clesA = Object.keys(a);
|
||
if (clesA.length !== Object.keys(b).length) return false;
|
||
return clesA.every((cle) => Object.hasOwn(b, cle) && egaux(a[cle], b[cle]));
|
||
}
|
||
|
||
// Ce que le retenu copie de sa proposition d'origine, et que retenirProposition
|
||
// remplacerait.
|
||
const CHAMPS_DU_PLACEMENT = ['siegesAttribues', 'tables', 'capacites', 'tours', 'participants', 'placement'];
|
||
|
||
/** Vrai quand un retenu existe et ne porte plus le placement de sa
|
||
* proposition d'origine : tables, capacités, tours, participants ou
|
||
* placement diffèrent, en profondeur, de ceux de la proposition
|
||
* retenu.proposition, ou celle-ci n'est plus dans la liste. */
|
||
export function retenuAjuste(charge) {
|
||
const { retenu } = charge;
|
||
if (retenu === null) return false;
|
||
const origine = charge.propositions.find(({ id }) => id === retenu.proposition);
|
||
if (origine === undefined) return true;
|
||
return !CHAMPS_DU_PLACEMENT.every((champ) => egaux(retenu[champ], origine[champ]));
|
||
}
|
||
|
||
/**
|
||
* La fenêtre à confirmer avant de retenir la proposition id (§ 2.1) : retenir
|
||
* remplace le retenu, et détruit ses ajustements quand il en porte. Elle
|
||
* s'ouvre quand la séance accepterait le geste — etat.refus null — et que le
|
||
* retenu est ajusté, y compris pour re-retenir sa proposition d'origine ; un
|
||
* geste que la séance refuserait s'exécute sans fenêtre, et son refus
|
||
* s'affiche.
|
||
*
|
||
* @param {{refus: Object|null, charge: Object|null}} etat l'état observable de la séance
|
||
* @param {number} id la proposition à retenir
|
||
* @returns {null|{id: number, titre: {cle: string, details: Object},
|
||
* elements: Array<{cle: string, details: Object}>}} null sans fenêtre ;
|
||
* sinon la proposition demandée, le titre, puis les éléments qui nomment le
|
||
* retenu ajusté et la proposition qui le remplace
|
||
*/
|
||
export function demandeConfirmation({ refus, charge }, id) {
|
||
if (refus !== null || charge === null || !retenuAjuste(charge)) return null;
|
||
return {
|
||
id,
|
||
titre: { cle: 'propositions.confirmation.titre', details: { proposition: id } },
|
||
elements: [
|
||
{ cle: 'propositions.confirmation.retenuAjuste', details: { proposition: charge.retenu.proposition } },
|
||
{ cle: 'propositions.confirmation.remplacant', details: { proposition: id } },
|
||
],
|
||
};
|
||
}
|
||
|
||
// La table d'évaluation de chaque charge figée en profondeur, sous l'identité
|
||
// de la charge. La charge de l'état observable de la séance est une copie
|
||
// figée en profondeur (seance.js, figer), remplacée par une copie neuve à
|
||
// chaque changement de l'événement ouvert et gardée telle quelle sinon : son
|
||
// identité suffit à dire que son contenu n'a pas changé, et Génération et
|
||
// Propositions relisent la même table. Une charge qui n'est pas figée
|
||
// partout se mesure à chaque appel, son contenu pouvant changer sous la
|
||
// même identité. Le § 13.3 compte sur la réactivité de Svelte plutôt que sur
|
||
// une mémoïsation posée à la main ; ce cache en est l'exception voulue, posé
|
||
// dans le modèle pur et tenu par l'immuabilité de son entrée.
|
||
const evaluees = new WeakMap();
|
||
|
||
/** Vrai quand chaque objet atteint depuis valeur est figé. Un objet figé ne
|
||
* redevient jamais modifiable : vrai une fois, vrai pour toujours. */
|
||
export function figeePartout(valeur) {
|
||
if (valeur === null || typeof valeur !== 'object') return true;
|
||
if (!Object.isFrozen(valeur)) return false;
|
||
for (const cle of Object.keys(valeur)) if (!figeePartout(valeur[cle])) return false;
|
||
return true;
|
||
}
|
||
|
||
// Fige une valeur neuve et tout ce qu'elle atteint.
|
||
function figer(valeur) {
|
||
if (valeur === null || typeof valeur !== 'object' || Object.isFrozen(valeur)) return valeur;
|
||
for (const cle of Object.keys(valeur)) figer(valeur[cle]);
|
||
return Object.freeze(valeur);
|
||
}
|
||
|
||
// La table d'une charge, mesurée à neuf.
|
||
function evaluationNeuve(charge) {
|
||
if (charge.propositions.length === 0 && charge.retenu === null) return { table: null, refus: null };
|
||
try {
|
||
return { table: evaluer(charge), refus: null };
|
||
} catch (erreur) {
|
||
if (typeof erreur?.code !== 'string') throw erreur;
|
||
return { table: null, refus: { code: erreur.code, details: erreur.details ?? {} } };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* La table d'évaluation d'une charge, ou ce qui l'empêche : null sans
|
||
* proposition ni retenu, rien n'étant à mesurer ; le refus de normaliser —
|
||
* une erreur qui porte un code — quand la configuration ne se tient pas.
|
||
* Toute autre erreur remonte, et rien n'est retenu. Le résultat est figé en
|
||
* profondeur ; pour une charge figée partout, c'est le même objet à chaque
|
||
* appel, mesuré au premier.
|
||
*
|
||
* @param {Object} charge
|
||
* @returns {{table: Object|null, refus: null|{code: string, details: Object}}}
|
||
*/
|
||
export function evaluation(charge) {
|
||
const connue = evaluees.get(charge);
|
||
if (connue !== undefined) return connue;
|
||
const evaluee = figer(evaluationNeuve(charge));
|
||
if (figeePartout(charge)) evaluees.set(charge, evaluee);
|
||
return evaluee;
|
||
}
|
||
|
||
/** Le nom affiché de la personne id de la charge, exclue comprise ; null
|
||
* pour une personne que la charge n'a plus, et pour id null. */
|
||
export function nomDe(charge, id) {
|
||
const personne = charge.participants.find((candidate) => candidate.id === id);
|
||
return personne === undefined ? null : nomAffiche(personne);
|
||
}
|
||
|
||
/** Le nombre de propositions qu'une page du tableau montre (§ 5.7) : le
|
||
* classement entier se découpe en pages de TAILLE_PAGE lignes, la ligne du
|
||
* retenu en plus, hors du compte. */
|
||
export const TAILLE_PAGE = 100;
|
||
|
||
/** Le nombre de pages d'un tableau de total propositions : une au moins,
|
||
* la page d'un tableau vide comprise. */
|
||
export function nombreDePages(total) {
|
||
return Math.max(1, Math.ceil(total / TAILLE_PAGE));
|
||
}
|
||
|
||
// La page bornée aux pages existantes : la première en deçà, la dernière
|
||
// au-delà.
|
||
const bornee = (page, pages) => Math.min(Math.max(1, page), pages);
|
||
|
||
/**
|
||
* La page d'un tableau (tableau()) : le classement unique découpé, jamais
|
||
* retrié. Les propositions se comptent dans l'ordre où le tableau les
|
||
* montre — les classées dans l'ordre du classement, puis celles hors
|
||
* classement —, et la page p porte celles de rang (p − 1) × TAILLE_PAGE + 1
|
||
* à p × TAILLE_PAGE. Chaque ligne garde le rang global que tableau() lui a
|
||
* donné. La page demandée se borne aux pages existantes.
|
||
*
|
||
* @param {{lignes: Object[], horsClassement: Object[]}} vue
|
||
* @param {number} page entière, à partir de 1
|
||
* @returns {{lignes: Object[], horsClassement: Object[], page: number,
|
||
* pages: number, premier: number, dernier: number, total: number,
|
||
* paginee: boolean}} lignes et horsClassement : celles de la page, dans
|
||
* l'ordre de la vue ; page : la page bornée ; pages : leur nombre ;
|
||
* premier et dernier : les places, à partir de 1, de la première et de la
|
||
* dernière proposition de la page, 0 et 0 sans proposition ; total : le
|
||
* nombre de propositions de la vue ; paginee : vrai au-delà d'une page,
|
||
* seul cas où des commandes de page existent
|
||
*/
|
||
export function pagine(vue, page) {
|
||
const classees = vue.lignes.length;
|
||
const total = classees + vue.horsClassement.length;
|
||
const pages = nombreDePages(total);
|
||
const montree = bornee(page, pages);
|
||
const debut = (montree - 1) * TAILLE_PAGE;
|
||
const fin = Math.min(debut + TAILLE_PAGE, total);
|
||
return {
|
||
lignes: vue.lignes.slice(debut, fin),
|
||
horsClassement: vue.horsClassement.slice(Math.max(0, debut - classees), Math.max(0, fin - classees)),
|
||
page: montree,
|
||
pages,
|
||
premier: total === 0 ? 0 : debut + 1,
|
||
dernier: fin,
|
||
total,
|
||
paginee: total > TAILLE_PAGE,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* La page du tableau qui porte la proposition id, classée ou hors
|
||
* classement ; null quand la vue ne la porte pas.
|
||
*
|
||
* @param {{lignes: Object[], horsClassement: Object[]}} vue
|
||
* @param {number|null} id
|
||
* @returns {number|null}
|
||
*/
|
||
export function pageDe(vue, id) {
|
||
const place = [...vue.lignes, ...vue.horsClassement].findIndex((ligne) => ligne.id === id);
|
||
return place === -1 ? null : Math.floor(place / TAILLE_PAGE) + 1;
|
||
}
|
||
|
||
// Ce qui distingue une proposition d'une autre : son identifiant, et ce qui
|
||
// la régénère. Un retour arrière rend des identifiants que la génération
|
||
// suivante réattribue, sous d'autres graines : l'identifiant seul ne dirait
|
||
// pas qu'elle est nouvelle.
|
||
const cleDe = ({ id, graine, arret, historique }) => `${id}/${graine}/${arret}/${historique}`;
|
||
|
||
/**
|
||
* @typedef {Readonly<{cles: ReadonlyArray<string>, visee: number, page: number,
|
||
* pages: number}>} SuiviDePage
|
||
* la page du tableau que l'état de navigation retient pour l'événement
|
||
* ouvert : les clés des propositions vues (cleDe), le numéro de la
|
||
* dernière visée prise en compte (EtatUi.visee), la page, et le nombre de
|
||
* pages
|
||
*/
|
||
|
||
// Vrai quand deux listes de clés portent les mêmes clés, dans le même ordre.
|
||
const memesCles = (a, b) => a.length === b.length && a.every((cle, place) => cle === b[place]);
|
||
|
||
/**
|
||
* La page à montrer, du suivi que l'état de navigation retient et de ce que
|
||
* l'écran montre maintenant (§ 8.4 : un état d'interface, que rien
|
||
* n'écrit) :
|
||
*
|
||
* - la première, sans suivi retenu — l'état de navigation le remet à null à
|
||
* chaque ouverture d'un événement —, ou quand une proposition est
|
||
* nouvelle : une génération montre son classement depuis le meilleur ;
|
||
* - sinon la page retenue, bornée à la dernière qui existe encore ;
|
||
* - à chaque visée nouvelle — un numéro de visée autre que celui du suivi :
|
||
* « Voir sur le plan », un remède qui montre une proposition, même celle
|
||
* que le plan montrait déjà —, la page qui porte la proposition visée, si
|
||
* la vue la porte. Une visée retirée (vise null) n'amène rien.
|
||
*
|
||
* Rien de changé, le suivi retenu revient lui-même : l'état de navigation,
|
||
* qui ne publie qu'un état différent, ne republie pas.
|
||
*
|
||
* @param {SuiviDePage|null} retenue
|
||
* @param {{vue: {lignes: Object[], horsClassement: Object[]},
|
||
* propositions: Object[], vise: number|null, visee: number}} observee le
|
||
* tableau, les propositions de la charge, la proposition que l'état de
|
||
* navigation demande au plan, et le numéro de cette visée
|
||
* @returns {SuiviDePage} le suivi à retenir, celui de la page montrée
|
||
*/
|
||
export function suivrePage(retenue, { vue, propositions, vise, visee }) {
|
||
const cles = propositions.map(cleDe);
|
||
const pages = nombreDePages(vue.lignes.length + vue.horsClassement.length);
|
||
const connues = new Set(retenue?.cles ?? []);
|
||
const neuve = retenue === null || cles.some((cle) => !connues.has(cle));
|
||
let page = neuve ? 1 : retenue.page;
|
||
const visant = vise !== null && (retenue === null || visee !== retenue.visee) ? pageDe(vue, vise) : null;
|
||
if (visant !== null) page = visant;
|
||
page = bornee(page, pages);
|
||
if (retenue !== null && retenue.visee === visee && retenue.page === page && retenue.pages === pages && memesCles(retenue.cles, cles)) {
|
||
return retenue;
|
||
}
|
||
return Object.freeze({ cles: Object.freeze(cles), visee, page, pages });
|
||
}
|
||
|
||
/**
|
||
* Le suivi à la page demandée, bornée aux pages existantes.
|
||
*
|
||
* @param {SuiviDePage} suivi
|
||
* @param {number} page
|
||
* @returns {SuiviDePage}
|
||
*/
|
||
export function changerDePage(suivi, page) {
|
||
return Object.freeze({ ...suivi, page: bornee(page, suivi.pages) });
|
||
}
|
||
|
||
/**
|
||
* Le texte de l'étendue d'une page (pagine) : « Propositions 101 à 200 sur
|
||
* 237 », au singulier pour une page d'une seule proposition. Ses nombres se
|
||
* mettent en forme par format.nombre ; le compte de la page accorde le nom.
|
||
*
|
||
* @param {{premier: number, dernier: number, total: number}} page
|
||
* @param {{t: Function, format: {nombre: Function}}} outils la traduction
|
||
* et la mise en forme du contexte
|
||
* @returns {string}
|
||
*/
|
||
export function texteEtendue({ premier, dernier, total }, { t, format }) {
|
||
return t('propositions.pages.etendue', {
|
||
premier: format.nombre(premier),
|
||
dernier: format.nombre(dernier),
|
||
total: format.nombre(total),
|
||
nombre: dernier - premier + 1,
|
||
});
|
||
}
|