The operator chooses among the generated plans. The ranked table orders propositions by the criteria of § 5.4 and shows, for encounters, the minimum only — the mean is the same for every proposition at full tables — with redundancy read through its sum. A proposition is retained or the list cleared through named commands; clearing spares the retained one, and a proposition that broke is shown apart, not ranked. Checked: 2400 node and 406 browser tests. --- FR --- [ADD] interface : propositions — tableau classé, retenir, effacer L'opérateur choisit parmi les plans générés. Le tableau classé ordonne les propositions selon les critères du § 5.4 et ne montre, pour les rencontres, que le minimum — la moyenne est la même pour toute proposition à tables pleines —, la redondance se lisant par sa somme. Une proposition se retient, ou la liste s'efface, par des commandes nommées ; effacer épargne la retenue, et une proposition abîmée se montre à part, hors classement. Vérifié : 2400 node et 406 navigateur. Assisted-by: Claude Opus 5.5
295 lines
14 KiB
JavaScript
295 lines
14 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 (A6), 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.
|
|
//
|
|
// 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],
|
|
['certificat', (l) => l.minimumAtteint],
|
|
]);
|
|
|
|
// 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 du tableau, dans l'ordre d'affichage : le rang et
|
|
* l'identifiant, puis les mesures — les trois minimums des rencontres en
|
|
* tête —, le certificat, les imposés et les commandes. Chacune porte son nom
|
|
* et la clé de son en-tête, qui nomme son unité. */
|
|
export const COLONNES = Object.freeze(
|
|
['rang', 'proposition', ...LECTURES.keys(), 'imposes', 'commandes'].map((nom) =>
|
|
Object.freeze({ nom, cle: `propositions.colonne.${nom}` }),
|
|
),
|
|
);
|
|
|
|
// 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, 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) {
|
|
const inconnu = ligne.troisChiffres.ecartItineraireMax === null;
|
|
return [...LECTURES].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 });
|
|
|
|
// Les colonnes que couvre la cellule des raisons d'une ligne hors
|
|
// classement : celles des mesures et celle des imposés.
|
|
const ETENDUE_RAISONS = LECTURES.size + 1;
|
|
|
|
// La ligne d'un placement mesuré, ou celle d'un placement hors classement.
|
|
// rang : la place dans le classement, à partir de 1 ; null hors classement
|
|
// et pour le retenu.
|
|
function ligneDe(ligne, table, idRetenu, 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),
|
|
imposes: imposes(ligne, table.imposes),
|
|
violations: ligne.violations.map(codeDe),
|
|
};
|
|
}
|
|
|
|
// La phrase de l'ordre (§ 5.7) : le critère sauté et celui appliqué à sa
|
|
// place quand le plafond a priori manque, puis les critères appliqués.
|
|
function ordre({ criteresAppliques, critereSaute }) {
|
|
const phrases = [];
|
|
if (critereSaute !== null) {
|
|
phrases.push({ cle: 'propositions.ordre.saute', details: { saute: critereSaute, applique: criteresAppliques[0] } });
|
|
}
|
|
phrases.push({ cle: 'propositions.ordre.criteres', details: { criteres: [...criteresAppliques] } });
|
|
return phrases;
|
|
}
|
|
|
|
/**
|
|
* Le tableau d'une table d'évaluation (A6).
|
|
*
|
|
* @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<{cle: string, details: Object}>, colonnes: typeof COLONNES,
|
|
* etendueRaisons: number, lignes: Object[], horsClassement: Object[], retenu: Object|null}}
|
|
* 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;
|
|
// 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, ligne.statut === 'comparable' ? place + 1 : null),
|
|
);
|
|
return {
|
|
ordre: ordre(tableEvaluation.classement),
|
|
colonnes: COLONNES,
|
|
etendueRaisons: ETENDUE_RAISONS,
|
|
lignes: toutes.filter(({ comparable }) => comparable),
|
|
horsClassement: toutes.filter(({ comparable }) => !comparable),
|
|
retenu: tableEvaluation.retenu === null ? null : ligneDuRetenu(tableEvaluation, idRetenu, ajuste),
|
|
};
|
|
}
|
|
|
|
// La ligne du retenu, et son libellé : « tel que » la proposition d'origine,
|
|
// ou « ajusté » depuis elle.
|
|
function ligneDuRetenu(tableEvaluation, idRetenu, ajuste) {
|
|
const cle = ajuste ? 'propositions.retenu.ajuste' : 'propositions.retenu.origine';
|
|
return { ...ligneDe(tableEvaluation.retenu, tableEvaluation, idRetenu), libelle: { cle, details: { proposition: idRetenu } } };
|
|
}
|
|
|
|
/**
|
|
* 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 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.
|
|
*
|
|
* @param {Object} charge
|
|
* @returns {{table: Object|null, refus: null|{code: string, details: Object}}}
|
|
*/
|
|
export function evaluation(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 ?? {} } };
|
|
}
|
|
}
|
|
|
|
/** 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);
|
|
}
|