[ADD] propositions: explanation tooltips on criteria and column headers

The propositions table names its measures with the glossary's terms, which
an organiser cannot be expected to know. Each ranking criterion of the
order sentence and each column header now shows a short explanation on
hover or keyboard focus: what the number counts and whether lower or
higher is better, written from the spec and the engine. The tooltip is a
reusable primitive, read through aria-describedby, kept in the window,
never over its word, closed by Escape; the rendering guards measure it.
Checked: 2882 node, 592 browser tests, from the index alone.

--- FR ---

[ADD] propositions : infobulles d'explication des critères et colonnes

Le tableau des propositions nomme ses mesures par les termes du glossaire,
qu'un organisateur n'a pas à connaître. Chaque critère de la phrase de
l'ordre et chaque en-tête de colonne montre une courte explication au
survol ou au focus : ce que compte le nombre, et s'il vaut mieux bas ou
haut, écrite d'après le spec et le moteur. L'infobulle est une primitive
réutilisable, lue par aria-describedby, gardée dans la fenêtre, jamais sur
son mot, fermée par Échap ; les gardes du rendu la mesurent.
Vérifié : 2882 node, 592 navigateur, depuis l'index seul.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-08 04:56:52 -04:00
parent b969304c2a
commit afdad31ef3
10 changed files with 784 additions and 12 deletions

View file

@ -64,6 +64,136 @@ export const TABLE = new Map([
['propositions.colonne.imposes', 'Imposé par les réservations'],
['propositions.colonne.commandes', 'Commandes'],
// --- Les explications, une par colonne (§ 5.4, § 5.5, § 5.7, § 12.10.4) --------
// Le texte de l'infobulle d'un en-tête, et celui du nom de critère qui
// désigne la colonne dans la phrase de l'ordre : un critère et sa colonne
// partagent la clé. Chacune dit ce que la colonne compte, sur quelle
// population, et dans quel sens elle sert le mieux quand ce sens existe ;
// les termes sont ceux du glossaire du § 5.5.
[
'propositions.explication.rang',
"La place de la proposition dans le classement, 1 pour la première, selon l'ordre écrit au-dessus du tableau. " +
"Une proposition hors classement n'a pas de rang.",
],
[
'propositions.explication.proposition',
'Le numéro de la proposition, donné à sa génération ; un numéro effacé ne revient jamais. ' +
'Il sert de dernier départage : deux propositions égales sur tous les critères se rangent par numéro croissant.',
],
[
'propositions.explication.rencontresTous',
'Le nombre de personnes différentes que rencontre, sur toute la soirée, la personne la moins bien servie : ' +
"quelqu'un revu à plusieurs tours compte une fois. Il porte sur toutes les personnes placées, ancrées comprises. " +
"Plus il est grand, mieux c'est.",
],
[
'propositions.explication.rencontresMobiles',
"Le même minimum, sur les seules personnes mobiles, celles qu'aucune réservation n'assied à la même table à tous les tours, " +
"partiellement fixées comprises ; « \u{2014} » s'il n'y en a aucune. Comparé aux deux autres, il dit si un minimum bas vient " +
"du placement ou des réservations. Plus il est grand, mieux c'est.",
],
[
'propositions.explication.rencontresAncres',
"Le même minimum, sur les seules personnes ancrées, celles qu'une réservation assied à la même table à chaque tour ; " +
"« \u{2014} » s'il n'y en a aucune. Plus il est grand, mieux c'est.",
],
[
'propositions.explication.collisionsCumulees',
'Chaque fois que deux personnes de la même appartenance partagent une table, compté une fois par paire et par tour : ' +
"deux collègues ensemble aux quatre tours font 4. Critère du classement après l'excédent, il départage deux " +
"propositions de même excédent. Plus il est bas, mieux c'est.",
],
[
'propositions.explication.pairesDistinctes',
'Le nombre de paires différentes de personnes de la même appartenance réunies au moins une fois : deux collègues ' +
"ensemble aux quatre tours font 1. Ce n'est pas un critère du classement : il vaut les collisions cumulées moins " +
"l'excédent.",
],
[
'propositions.explication.excedentCollisions',
'Les retrouvailles entre personnes de la même appartenance au-delà de leur première rencontre, soit les collisions ' +
'cumulées moins les paires distinctes : deux collègues ensemble aux quatre tours font 3, six collègues croisés une ' +
'fois chacun font 0. Critère du classement avant les collisions cumulées, car passer la soirée avec le même ' +
"collègue est pire que croiser six collègues une fois. Plus il est bas, mieux c'est.",
],
[
'propositions.explication.rencontresRepetees',
"Le nombre de paires de personnes réunies à plus d'un tour que le placement a choisies ; celles qu'imposent les " +
'réservations sont dans la colonne des imposés. Critère du classement après les collisions cumulées. ' +
"Plus il est bas, mieux c'est.",
],
[
'propositions.explication.maxRencontresPaire',
'Le plus grand nombre de tours que passe une même paire de personnes à la même table, réservations comprises ; ' +
"à 1, personne ne retrouve personne. Plus il est bas, mieux c'est.",
],
[
'propositions.explication.retoursChoisis',
"Le nombre de fois où une personne revient à une table où elle s'est déjà assise, additionné sur toutes les personnes. " +
"Seuls comptent les retours que le placement a choisis ; ceux qu'imposent les réservations sont dans la colonne " +
"des imposés. Plus il est bas, mieux c'est.",
],
[
'propositions.explication.appartenancesVues',
"A(p) : le nombre d'appartenances différentes parmi les personnes que p rencontre et qui en ont une, la sienne " +
"comptée dès qu'elle rencontre un collègue. La colonne donne le plus petit A(p), celui de la personne qui en voit " +
"le moins. Plus il est grand, mieux c'est.",
],
[
'propositions.explication.totalRedondance',
"r(p) : le nombre de personnes que p rencontre et qui ont une appartenance, moins A(p), le nombre d'appartenances " +
"différentes qu'elles portent ; à 0, aucune appartenance n'est recroisée. La colonne donne la somme sur toutes les " +
"personnes, Σ r(p), dernier critère du classement avant le numéro. Plus elle est basse, mieux c'est.",
],
[
'propositions.explication.diversite',
"d(p) : A(p) divisé par le nombre de personnes que p rencontre et qui ont une appartenance ; à 1, elles portent " +
"toutes des appartenances différentes. La colonne donne le plus petit d(p), « \u{2014} » quand personne ne rencontre " +
"quelqu'un qui en a une. Plus il est grand, mieux c'est.",
],
[
'propositions.explication.manqueMax',
"Le manque d'une personne : son plafond réalisé, le plus de rencontres que permettent les tables où la proposition " +
"l'assoit et leur occupation, moins ses rencontres. La colonne donne le plus grand manque ; un manque nul ne dit " +
"rien d'un itinéraire bas, que l'écart d'itinéraire montre. Plus il est bas, mieux c'est.",
],
[
'propositions.explication.effectifManque',
"Le nombre de personnes dont le manque vaut au moins 1 ; 0 veut dire que personne n'a de manque. " +
"Il dit si le plus grand manque touche une personne ou beaucoup : déplacer quelqu'un, ou ajouter une table. " +
"Plus il est bas, mieux c'est.",
],
[
'propositions.explication.ecartItineraireMax',
"L'écart d'itinéraire d'une personne : son plafond a priori moins son plafond réalisé, ce que son itinéraire lui " +
'retire avant toute rencontre \u{2014} moins de tables différentes, des tables incomplètes, des tours en réserve. ' +
"La colonne donne le plus grand, « inconnu » quand le plafond a priori n'a pas pu être calculé. " +
"Plus il est bas, mieux c'est.",
],
[
'propositions.explication.ecartAuPlafondAPriori',
"L'écart au plafond a priori d'une personne : son plafond a priori, le plus de rencontres que permettent les " +
"capacités des tables sur le meilleur itinéraire, moins ses rencontres, soit son manque plus son écart d'itinéraire. " +
"La colonne donne le plus grand, premier critère du classement, sauté quand le plafond a priori n'a pas pu être " +
"calculé, la cellule écrivant alors « inconnu ». Plus il est bas, mieux c'est.",
],
[
'propositions.explication.certificat',
'« minimum atteint » quand chaque personne rencontre autant de monde que son plafond a priori le permet : ' +
'personne ne peut en rencontrer davantage. Sinon, « atteignabilité inconnue » : le logiciel ne sait pas si un ' +
'placement peut y parvenir pour tous.',
],
[
'propositions.explication.imposes',
'Ce que les réservations imposent, compté à part et jamais ajouté aux colonnes du placement : les retours à une ' +
'table que fixent les ancrages, et les rencontres répétées entre personnes réservées ensemble à la même table.',
],
[
'propositions.explication.commandes',
'« Voir sur le plan » montre la proposition sur le plan de salle, sans rien écrire. « Retenir » en fait le ' +
"placement retenu, celui qu'on ajuste à la main ; les autres propositions restent dans la liste.",
],
// --- Les valeurs écrites en mots (§ 5.5, § 12.6) ------------------------------
['propositions.valeur.inconnu', 'inconnu'],
['propositions.certificat.atteint', 'minimum atteint'],

View file

@ -0,0 +1,113 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// L'infobulle d'un terme (§ 12.7, § 14.5, § 14.6) : un seul élément
// [role=tooltip], toujours présent, caché tant qu'aucun terme n'est
// expliqué. Le porteur l'ouvre au survol et au focus du clavier d'un terme
// — un survol n'est jamais le seul chemin —, la referme quand le pointeur
// ou le focus quitte ce terme, et donne au terme expliqué
// aria-describedby={id} : un lecteur d'écran lit l'explication avec lui.
// Échap la referme, le focus restant où il est.
//
// ouverte : null, ou {ancre, cle, details} — l'élément du terme expliqué et
// la clé de traduction de son texte ; fermer() : rendu au porteur, qui
// remet ouverte à null. id : celui que l'aria-describedby du terme désigne.
//
// La place se calcule par placerInfobulle (infobulle.js) sur les rectangles
// relus du rendu, à l'ouverture puis à chaque défilement — un conteneur
// défilant compris, que l'écoute en capture atteint — et à chaque
// redimensionnement : sous le terme, ou au-dessus, jamais dessus, dans la
// fenêtre. Sa largeur est celle de son texte, bornée par max-width : elle
// ne dépend pas de sa place, et se mesure où qu'elle soit. Tant qu'elle
// n'est pas placée, elle reste invisible. Une ancre retirée du document la
// referme. Elle ne capte aucun pointeur : le terme reste survolé.
import { getContext } from 'svelte';
import { placerInfobulle } from './infobulle.js';
let { id, ouverte = null, fermer } = $props();
const t = getContext('t');
let element = $state(null);
// Le coin haut gauche de l'infobulle dans la fenêtre, en px ; null tant
// qu'elle n'est pas placée.
let place = $state(null);
function placer() {
if (ouverte === null || element === null) return;
if (!ouverte.ancre.isConnected) {
fermer();
return;
}
const a = ouverte.ancre.getBoundingClientRect();
const b = element.getBoundingClientRect();
const fenetre = document.documentElement;
place = placerInfobulle(
{ gauche: a.left, haut: a.top, droite: a.right, bas: a.bottom },
{ largeur: b.width, hauteur: b.height },
{ largeur: fenetre.clientWidth, hauteur: fenetre.clientHeight },
);
}
// À chaque terme expliqué, après le rendu de son texte.
$effect(() => {
if (ouverte === null) {
place = null;
return;
}
placer();
});
function surTouche(evenement) {
if (ouverte !== null && evenement.key === 'Escape') fermer();
}
</script>
<svelte:window onkeydown={surTouche} onscrollcapture={placer} onresize={placer} />
<div
bind:this={element}
class="infobulle"
class:placee={place !== null}
role="tooltip"
{id}
hidden={ouverte === null}
style:left={place === null ? null : `${place.gauche}px`}
style:top={place === null ? null : `${place.haut}px`}
>
{#if ouverte !== null}{t(ouverte.cle, ouverte.details)}{/if}
</div>
<style>
.infobulle {
position: fixed;
top: 0;
left: 0;
z-index: 3;
box-sizing: border-box;
width: max-content;
max-width: min(26rem, calc(100% - 16px));
padding: var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background: var(--couleur-page-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-1);
font-size: var(--taille-texte);
font-weight: normal;
line-height: 1.4;
text-align: left;
white-space: normal;
pointer-events: none;
visibility: hidden;
}
.infobulle.placee {
visibility: visible;
}
.infobulle[hidden] {
display: none;
}
</style>

View file

@ -0,0 +1,51 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La place d'une infobulle à côté du terme qu'elle explique, pure :
// Infobulle.svelte la calcule à chaque ouverture, défilement et
// redimensionnement, sur les rectangles relus du rendu. Toutes les
// grandeurs sont en px CSS, dans le repère de la fenêtre.
//
// L'infobulle se pose sous le terme, à ECART_INFOBULLE_PX de lui, quand
// elle y tient ; au-dessus sinon : elle ne couvre jamais le terme qu'elle
// explique dès qu'un des deux côtés la reçoit. Quand aucun ne la reçoit, elle
// prend le plus grand et reste dans le cadre, quitte à couvrir le terme.
// Horizontalement, elle s'aligne sur le bord gauche du terme, puis recule
// pour garder MARGE_INFOBULLE_PX entre elle et chaque bord du cadre ; plus
// large que le cadre, elle se colle à la marge gauche.
/** Distance entre le terme et l'infobulle, en px. */
export const ECART_INFOBULLE_PX = 6;
/** Distance minimale entre l'infobulle et chaque bord du cadre, en px. */
export const MARGE_INFOBULLE_PX = 8;
/**
* La place d'une infobulle.
*
* @param {{gauche: number, haut: number, droite: number, bas: number}} ancre
* le rectangle du terme expliqué
* @param {{largeur: number, hauteur: number}} taille celle de l'infobulle
* @param {{largeur: number, hauteur: number}} cadre la partie visible de la
* fenêtre, barres de défilement exclues
* @returns {{gauche: number, haut: number, cote: 'dessous'|'dessus'}} le coin
* haut gauche de l'infobulle, et le côté du terme où elle se pose
*/
export function placerInfobulle(ancre, { largeur, hauteur }, cadre) {
const gaucheMax = cadre.largeur - MARGE_INFOBULLE_PX - largeur;
const gauche = Math.max(MARGE_INFOBULLE_PX, Math.min(ancre.gauche, gaucheMax));
const dessous = ancre.bas + ECART_INFOBULLE_PX;
const dessus = ancre.haut - ECART_INFOBULLE_PX - hauteur;
const basMax = cadre.hauteur - MARGE_INFOBULLE_PX;
if (dessous + hauteur <= basMax) return { gauche, haut: dessous, cote: 'dessous' };
if (dessus >= MARGE_INFOBULLE_PX) return { gauche, haut: dessus, cote: 'dessus' };
// Aucun côté ne la reçoit : le plus grand, la boîte ramenée dans le cadre.
const placeDessous = basMax - dessous;
const placeDessus = ancre.haut - ECART_INFOBULLE_PX - MARGE_INFOBULLE_PX;
const cote = placeDessous >= placeDessus ? 'dessous' : 'dessus';
const voulu = cote === 'dessous' ? dessous : dessus;
const haut = Math.max(MARGE_INFOBULLE_PX, Math.min(voulu, basMax - hauteur));
return { gauche, haut, cote };
}

View file

@ -0,0 +1,84 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves de la place d'une infobulle (§ 12.7, § 14.5) : sous le terme
// quand elle y tient, au-dessus sinon ; jamais sur le terme qu'elle
// explique quand un côté la reçoit ; toujours dans le cadre, à la marge
// près.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { MARGE_INFOBULLE_PX, ECART_INFOBULLE_PX, placerInfobulle } from './infobulle.js';
const CADRE = { largeur: 1366, hauteur: 700 };
const TAILLE = { largeur: 300, hauteur: 90 };
// Le rectangle d'un terme de largeur et de hauteur données, coin haut gauche
// en (gauche, haut).
const terme = (gauche, haut, largeur = 120, hauteur = 20) => ({ gauche, haut, droite: gauche + largeur, bas: haut + hauteur });
// La boîte de l'infobulle placée.
const boite = ({ gauche, haut }, { largeur, hauteur }) => ({ gauche, haut, droite: gauche + largeur, bas: haut + hauteur });
const recouvre = (a, b) => a.gauche < b.droite && b.gauche < a.droite && a.haut < b.bas && b.haut < a.bas;
const dansLeCadre = (b, cadre) =>
b.gauche >= MARGE_INFOBULLE_PX &&
b.haut >= MARGE_INFOBULLE_PX &&
b.droite <= cadre.largeur - MARGE_INFOBULLE_PX &&
b.bas <= cadre.hauteur - MARGE_INFOBULLE_PX;
describe('placerInfobulle : sous le terme, au-dessus, dans le cadre', () => {
test('la place sous le terme suffit : l’infobulle s’y pose, alignée sur son bord gauche, à l’écart du terme', () => {
const ancre = terme(200, 100);
const place = placerInfobulle(ancre, TAILLE, CADRE);
assert.deepEqual(place, { gauche: 200, haut: 120 + ECART_INFOBULLE_PX, cote: 'dessous' });
assert.ok(!recouvre(boite(place, TAILLE), ancre));
});
test('trop bas pour elle : l’infobulle passe au-dessus du terme', () => {
const ancre = terme(200, 640);
const place = placerInfobulle(ancre, TAILLE, CADRE);
assert.deepEqual(place, { gauche: 200, haut: 640 - ECART_INFOBULLE_PX - TAILLE.hauteur, cote: 'dessus' });
assert.ok(!recouvre(boite(place, TAILLE), ancre));
});
test('près du bord droit, l’infobulle recule dans le cadre ; un terme coupé par le bord gauche la laisse à la marge', () => {
const droite = placerInfobulle(terme(1300, 100, 60), TAILLE, CADRE);
assert.equal(droite.gauche, CADRE.largeur - MARGE_INFOBULLE_PX - TAILLE.largeur);
const gauche = placerInfobulle(terme(-50, 100), TAILLE, CADRE);
assert.equal(gauche.gauche, MARGE_INFOBULLE_PX);
});
test('plus large que le cadre : collée à la marge gauche', () => {
const place = placerInfobulle(terme(400, 100), { largeur: 2000, hauteur: 90 }, CADRE);
assert.equal(place.gauche, MARGE_INFOBULLE_PX);
});
test('aucun côté ne la reçoit : le côté le plus grand, et la boîte reste dans le cadre', () => {
const petit = { largeur: 1366, hauteur: 200 };
const haute = { largeur: 300, hauteur: 150 };
const bas = placerInfobulle(terme(200, 40), haute, petit);
assert.equal(bas.cote, 'dessous');
assert.ok(dansLeCadre(boite(bas, haute), petit), JSON.stringify(bas));
const haut = placerInfobulle(terme(200, 140), haute, petit);
assert.equal(haut.cote, 'dessus');
assert.ok(dansLeCadre(boite(haut, haute), petit), JSON.stringify(haut));
});
test('sur une grille de termes qui couvre le cadre : dès qu’un côté la reçoit, l’infobulle reste dans le cadre et ne couvre jamais le terme', () => {
let essais = 0;
for (let gauche = -100; gauche <= CADRE.largeur; gauche += 37) {
for (let haut = 0; haut <= CADRE.hauteur - 20; haut += 23) {
for (const largeur of [10, 120, 400]) {
const ancre = terme(gauche, haut, largeur);
const place = placerInfobulle(ancre, TAILLE, CADRE);
const b = boite(place, TAILLE);
assert.ok(dansLeCadre(b, CADRE), `${gauche}, ${haut}, ${largeur} : ${JSON.stringify(place)}`);
assert.ok(!recouvre(b, ancre), `${gauche}, ${haut}, ${largeur} : ${JSON.stringify(place)}`);
essais += 1;
}
}
}
assert.ok(essais > 1000, String(essais));
});
});

View file

@ -689,3 +689,192 @@ describe('la phrase de l’ordre désigne les colonnes (§ 5.7)', () => {
expect(cible.map((cellule) => [getComputedStyle(cellule).color, getComputedStyle(cellule).backgroundColor])).toEqual(avant);
});
});
// --- Les infobulles d'explication (§ 5.4, § 5.5, § 5.7, § 12.7) -------------------
// La clé de l'explication d'une colonne, celle du contrat de modele.js, que
// l'épreuve node du modèle confronte à COLONNES ; t lève sur une clé absente.
const explicationDeColonne = (nom) => `propositions.explication.${nom}`;
// Les colonnes du tableau, dans l'ordre d'affichage.
const NOMS_DES_COLONNES = [
'rang',
'proposition',
'rencontresTous',
'rencontresMobiles',
'rencontresAncres',
'collisionsCumulees',
'pairesDistinctes',
'excedentCollisions',
'rencontresRepetees',
'maxRencontresPaire',
'retoursChoisis',
'appartenancesVues',
'totalRedondance',
'diversite',
'manqueMax',
'effectifManque',
'ecartItineraireMax',
'ecartAuPlafondAPriori',
'certificat',
'imposes',
'commandes',
];
// Les infobulles du document : une seule, [role=tooltip], que chaque nom de
// la phrase et chaque en-tête de colonne ouvrent tour à tour.
const infobulles = () => [...document.querySelectorAll('[role="tooltip"]')];
// L'infobulle unique, ouverte sur l'ancre donnée : visible, au texte de la
// clé, désignée par l'aria-describedby de l'ancre et de nulle autre, dans
// la fenêtre, et hors du rectangle de l'ancre.
function exigerInfobulle(ancre, cle) {
const bulles = infobulles();
expect(bulles.length).toBe(1);
const [bulle] = bulles;
expect(bulle.hidden, cle).toBe(false);
expect(getComputedStyle(bulle).visibility, cle).toBe('visible');
expect(texteDe(bulle), cle).toBe(t(cle));
expect(bulle.id, cle).not.toBe('');
expect(ancre.getAttribute('aria-describedby'), cle).toBe(bulle.id);
expect(document.querySelectorAll(`[aria-describedby~="${bulle.id}"]`).length, cle).toBe(1);
const b = bulle.getBoundingClientRect();
const a = ancre.getBoundingClientRect();
expect(b.width, cle).toBeGreaterThan(0);
expect(b.height, cle).toBeGreaterThan(0);
const racine = document.documentElement;
expect(b.left >= 0 && b.top >= 0 && b.right <= racine.clientWidth && b.bottom <= racine.clientHeight, `${cle} dans la fenêtre`).toBe(true);
const recouvre = b.left < a.right && a.left < b.right && b.top < a.bottom && a.top < b.bottom;
expect(recouvre, `${cle} sur son ancre`).toBe(false);
}
// L'infobulle fermée : cachée, et aucun élément ne la désigne.
function exigerFermee() {
const bulles = infobulles();
expect(bulles.length).toBe(1);
const [bulle] = bulles;
expect(bulle.hidden || bulle.getBoundingClientRect().width === 0).toBe(true);
expect(document.querySelectorAll(`[aria-describedby~="${bulle.id}"]`).length).toBe(0);
}
const enTetes = (m) => [...section(m).querySelectorAll('thead th[data-colonne]')];
describe('les infobulles d’explication : chaque nom de critère, chaque en-tête de colonne', () => {
const chargeRetenue = () => executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 3 });
// Le pointeur réel part du coin du cadre, hors de la phrase et du tableau.
beforeEach(async () => {
await pointer('bouger', 0, 0);
});
test('survoler chaque nom de critère ouvre l’explication de sa colonne, qui se surligne toujours ; le quitter la ferme', async () => {
const m = await ouvrir(chargeRetenue());
exigerFermee();
expect(nomsDesignants(m).length).toBe(COLONNE_DESIGNEE.length);
for (const [critere, nomColonne] of COLONNE_DESIGNEE) {
const nom = section(m).querySelector(`[data-ordre] [data-critere="${critere}"]`);
const { x, y } = await centre(nom);
await pointer('bouger', x, y);
await attendre(() => exigerInfobulle(nom, explicationDeColonne(nomColonne)));
await attendre(() => expect(surlignees(m), critere).toEqual(colonne(m, nomColonne)));
}
const { x, y } = await centre(section(m).querySelector('h2'));
await pointer('bouger', x, y);
await attendre(() => exigerFermee());
await attendre(() => expect(surlignees(m)).toEqual([]));
});
test('survoler chaque en-tête de colonne ouvre son explication, dans la fenêtre, hors de l’en-tête ; le quitter la ferme', async () => {
const m = await ouvrir(chargeRetenue());
const ths = enTetes(m);
expect(ths.map((th) => th.dataset.colonne)).toEqual(NOMS_DES_COLONNES);
// Le pointeur quitte le tableau pour le titre de la section : aucun
// terme n'est plus survolé, l'infobulle se ferme.
const sortir = async () => {
const { x, y } = await centre(section(m).querySelector('h2'));
await pointer('bouger', x, y);
await attendre(() => exigerFermee());
};
for (const th of ths) {
const { x, y } = await centre(th);
await pointer('bouger', x, y);
await attendre(() => exigerInfobulle(th, explicationDeColonne(th.dataset.colonne)));
}
await sortir();
// Le dernier en-tête, collé au bord droit de la fenêtre : l'infobulle
// recule dans la fenêtre.
const dernier = ths.at(-1);
dernier.scrollIntoView({ block: 'nearest', inline: 'end' });
const b = dernier.getBoundingClientRect();
await pointer('bouger', b.right - 4, b.top + b.height / 2);
await attendre(() => exigerInfobulle(dernier, explicationDeColonne(dernier.dataset.colonne)));
await sortir();
});
test('au clavier : Tab focalise chaque nom puis chaque en-tête et ouvre son explication ; Échap la ferme, le focus reste ; sortir la ferme', async () => {
const m = await ouvrir(chargeRetenue());
const ancres = [...nomsDesignants(m), ...enTetes(m)];
expect(ancres.length).toBe(COLONNE_DESIGNEE.length + NOMS_DES_COLONNES.length);
for (const ancre of ancres) expect(ancre.tabIndex).toBe(0);
const cleDe = (ancre) =>
explicationDeColonne(ancre.dataset.critere === undefined ? ancre.dataset.colonne : COLONNE_DESIGNEE.find(([critere]) => critere === ancre.dataset.critere)[1]);
ancres[0].focus();
for (const [rang, ancre] of ancres.entries()) {
if (rang > 0) await touche('Tab');
await attendre(() => expect(document.activeElement).toBe(ancre));
await attendre(() => exigerInfobulle(ancre, cleDe(ancre)));
if (rang === 2 || rang === COLONNE_DESIGNEE.length + 1) {
await touche('Escape');
await attendre(() => exigerFermee());
expect(document.activeElement).toBe(ancre);
}
}
await touche('Tab');
await attendre(() => expect(ancres).not.toContain(document.activeElement));
await attendre(() => exigerFermee());
});
test('le tableau défile sous un en-tête focalisé : l’infobulle suit son en-tête', async () => {
const m = await ouvrir(chargeRetenue());
const th = enTetes(m).find((cellule) => cellule.dataset.colonne === 'retoursChoisis');
const defilement = th.closest('table').parentElement;
expect(defilement.scrollWidth).toBeGreaterThan(defilement.clientWidth + 200);
th.scrollIntoView({ block: 'nearest', inline: 'center' });
th.focus({ preventScroll: true });
await attendre(() => exigerInfobulle(th, explicationDeColonne('retoursChoisis')));
const [bulle] = infobulles();
const decalage = () => bulle.getBoundingClientRect().left - th.getBoundingClientRect().left;
expect(decalage()).toBe(0);
const avant = th.getBoundingClientRect().left;
defilement.scrollLeft += 150;
await attendre(() => expect(th.getBoundingClientRect().left).not.toBe(avant));
await attendre(() => expect(decalage()).toBe(0));
exigerInfobulle(th, explicationDeColonne('retoursChoisis'));
});
test('un nom trop près du bas de la fenêtre : l’infobulle s’ouvre au-dessus de lui', async () => {
const m = await ouvrir(chargeRetenue());
const nom = () => section(m).querySelector('[data-ordre] [data-critere="ecartAuPlafondAPrioriMax"]');
await fixerCadre(1366, Math.ceil(nom().getBoundingClientRect().bottom) + 40);
await attendre(() => expect(nom().getBoundingClientRect().bottom).toBeLessThanOrEqual(document.documentElement.clientHeight));
const { x, y } = await centre(nom());
await pointer('bouger', x, y);
await attendre(() => exigerInfobulle(nom(), explicationDeColonne('ecartAuPlafondAPriori')));
const [bulle] = infobulles();
expect(bulle.getBoundingClientRect().bottom).toBeLessThanOrEqual(nom().getBoundingClientRect().top);
});
test('Échap ferme l’infobulle ouverte au survol ; un autre nom survolé la rouvre sur le sien', async () => {
const m = await ouvrir(chargeRetenue());
const premier = section(m).querySelector('[data-ordre] [data-critere="excedentCollisions"]');
const second = enTetes(m).find((th) => th.dataset.colonne === 'pairesDistinctes');
let point = await centre(premier);
await pointer('bouger', point.x, point.y);
await attendre(() => exigerInfobulle(premier, explicationDeColonne('excedentCollisions')));
await touche('Escape');
await attendre(() => exigerFermee());
point = await centre(second);
await pointer('bouger', point.x, point.y);
await attendre(() => exigerInfobulle(second, explicationDeColonne('pairesDistinctes')));
});
});

View file

@ -29,12 +29,20 @@
// focalisable qui désigne sa colonne. Le survoler ou le focaliser surligne
// cette colonne dans le tableau ; le quitter — pointeur sorti, focus perdu —
// efface le surlignage qu'il a posé, et laisse celui d'un autre nom.
//
// Chaque nom de critère et chaque en-tête de colonne s'expliquent dans
// l'infobulle de la section, une seule (Infobulle, primitive) : le survol
// ou le focus l'ouvre sur le terme, le quitter la referme quand elle est
// encore la sienne, Échap aussi. Un critère et sa colonne partagent leur
// explication (explicationDeColonne, modele.js). Le terme expliqué porte
// aria-describedby vers elle, et lui seul.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import Confirmation from '../primitives/Confirmation.svelte';
import Infobulle from '../primitives/Infobulle.svelte';
import Refus from '../primitives/Refus.svelte';
import TableauPropositions from './TableauPropositions.svelte';
import { demandeConfirmation, evaluation, nomDe, retenuAjuste, tableau } from './modele.js';
import { demandeConfirmation, evaluation, explicationDeColonne, nomDe, retenuAjuste, tableau } from './modele.js';
const t = getContext('t');
const seance = getContext('seance');
@ -82,6 +90,33 @@
if (surlignee === colonne) surlignee = null;
}
// Le terme expliqué : {ancre, terme, cle, details} — son élément, sa
// désignation, 'critere:<critère>' pour un nom de la phrase ou
// 'colonne:<nom>' pour un en-tête, et la clé de son explication — ; null
// sans infobulle. Ouvrir sur un terme remplace l'explication d'un autre ;
// quitter un terme ne ferme que la sienne.
const uid = $props.id();
const idExplication = `${uid}-explication`;
let expliquee = $state(null);
function expliquer(evenement, terme, colonne) {
expliquee = { ancre: evenement.currentTarget, terme, cle: explicationDeColonne(colonne), details: {} };
}
function taire(terme) {
if (expliquee?.terme === terme) expliquee = null;
}
const fermerExplication = () => (expliquee = null);
// L'aria-describedby d'un terme : l'infobulle quand elle l'explique.
const decrit = (terme) => (expliquee?.terme === terme ? idExplication : undefined);
function entrer(evenement, { critere, colonne }) {
surligner(colonne);
expliquer(evenement, `critere:${critere}`, colonne);
}
function sortir({ critere, colonne }) {
quitter(colonne);
taire(`critere:${critere}`);
}
const effacer = () => seance.executer('effacerPropositions', {});
const nommer = (id) => nomDe(charge, id);
</script>
@ -114,19 +149,22 @@
class:actif={surlignee === morceau.colonne}
data-critere={morceau.critere}
tabindex="0"
onmouseenter={() => surligner(morceau.colonne)}
onmouseleave={() => quitter(morceau.colonne)}
onfocus={() => surligner(morceau.colonne)}
onblur={() => quitter(morceau.colonne)}>{t(morceau.cle, morceau.details)}</span
aria-describedby={decrit(`critere:${morceau.critere}`)}
onmouseenter={(evenement) => entrer(evenement, morceau)}
onmouseleave={() => sortir(morceau)}
onfocus={(evenement) => entrer(evenement, morceau)}
onblur={() => sortir(morceau)}>{t(morceau.cle, morceau.details)}</span
>
{/if}
{/each}
</p>
{/each}
</div>
<TableauPropositions {vue} {surlignee} {nommer} {voir} {retenir} />
<TableauPropositions {vue} {surlignee} {nommer} {voir} {retenir} {expliquer} {taire} {decrit} />
{/if}
<Infobulle id={idExplication} ouverte={expliquee} fermer={fermerExplication} />
{#if charge !== null && charge.propositions.length > 0}
<div class="effacer">
<Commande cle="propositions.commande.effacer" executer={effacer} />

View file

@ -20,6 +20,12 @@
// la cellule des raisons d'une ligne hors classement, qui en couvre
// plusieurs.
//
// Chaque en-tête explique sa colonne dans l'infobulle de la section : il se
// focalise au clavier (tabindex 0), et son survol ou son focus appelle
// expliquer(evenement, 'colonne:<nom>', nom), le quitter taire('colonne:<nom>').
// decrit('colonne:<nom>') rend l'identifiant de l'infobulle quand elle
// explique cet en-tête, pour son aria-describedby, undefined sinon.
//
// Désignations : data-retenu sur la ligne du retenu, data-proposition sur
// chaque autre ligne, data-colonne sur chaque cellule et chaque en-tête ;
// data-classement et data-hors-classement sur les deux groupes de lignes.
@ -30,7 +36,7 @@
import Commande from '../primitives/Commande.svelte';
import { texteCellule } from './modele.js';
let { vue, surlignee = null, nommer, voir, retenir } = $props();
let { vue, surlignee = null, nommer, voir, retenir, expliquer, taire, decrit } = $props();
// L'attribut data-marque d'une cellule de la colonne nommée : 'selection'
// quand elle est la colonne surlignée, undefined sinon, qui l'omet.
@ -98,7 +104,21 @@
<thead>
<tr>
{#each vue.colonnes as colonne (colonne.nom)}
<th scope="col" data-colonne={colonne.nom} data-marque={marque(colonne.nom)} class:surlignee={colonne.nom === surlignee}>{t(colonne.cle)}</th>
{@const terme = `colonne:${colonne.nom}`}
<!-- L'en-tête se focalise pour montrer au clavier l'explication que
le pointeur montre au survol ; il n'agit sur rien. -->
<th
scope="col"
data-colonne={colonne.nom}
data-marque={marque(colonne.nom)}
class:surlignee={colonne.nom === surlignee}
tabindex="0"
aria-describedby={decrit(terme)}
onmouseenter={(evenement) => expliquer(evenement, terme, colonne.nom)}
onmouseleave={() => taire(terme)}
onfocus={(evenement) => expliquer(evenement, terme, colonne.nom)}
onblur={() => taire(terme)}>{t(colonne.cle)}</th
>
{/each}
</tr>
</thead>
@ -227,6 +247,13 @@
border-right: 2px solid var(--couleur-selection);
}
/* L'anneau de focus se pose dans la cellule : le conteneur défilant ne le
rogne pas. */
thead th:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: -2px;
}
thead th.surlignee {
text-decoration: underline;
text-decoration-thickness: 2px;

View file

@ -64,14 +64,30 @@ 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é. */
* tête —, le certificat, les imposés et les commandes. Chacune porte son nom,
* la clé de son en-tête, qui nomme son unité, et celle de son explication,
* le texte de l'infobulle de son en-tête et du critère qui la désigne. */
export const COLONNES = Object.freeze(
['rang', 'proposition', ...LECTURES.keys(), 'imposes', 'commandes'].map((nom) =>
Object.freeze({ nom, cle: `propositions.colonne.${nom}` }),
Object.freeze({ nom, cle: `propositions.colonne.${nom}`, explication: `propositions.explication.${nom}` }),
),
);
/**
* La clé de l'explication d'une colonne : le texte de l'infobulle de son
* en-tête, et celui du nom de critère qui la désigne dans la phrase de
* l'ordre (colonneDuCritere) — un critère 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,

View file

@ -29,7 +29,17 @@ import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../.
import { planDepuisFichier } from '../../stockage/placements.js';
import { VERSION } from '../../version.genere.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { COLONNES, colonneDuCritere, demandeConfirmation, evaluation, nomDe, retenuAjuste, tableau, texteCellule } from './modele.js';
import {
COLONNES,
colonneDuCritere,
demandeConfirmation,
evaluation,
explicationDeColonne,
nomDe,
retenuAjuste,
tableau,
texteCellule,
} from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const format = creerFormat(t);
@ -656,3 +666,96 @@ describe('evaluation : la table d’une charge, ou ce qui l’empêche', () => {
assert.equal(refus.code, 'SURRESERVATION');
});
});
describe('les explications des colonnes et des critères (infobulles, § 5.4, § 5.5, § 5.7)', () => {
// Le sens de chaque colonne : 'bas' quand la plus faible valeur sert le
// mieux, 'grand' quand c'est la plus forte ; null quand l'explication ne
// dit aucun sens — le rang, le numéro, les paires distinctes, que l'ordre
// ne lit pas et dont le compte le plus faible n'est pas le meilleur
// (§ 5.4), le certificat, les imposés et les commandes.
const SENS = new Map([
['rang', null],
['proposition', null],
['rencontresTous', 'grand'],
['rencontresMobiles', 'grand'],
['rencontresAncres', 'grand'],
['collisionsCumulees', 'bas'],
['pairesDistinctes', null],
['excedentCollisions', 'bas'],
['rencontresRepetees', 'bas'],
['maxRencontresPaire', 'bas'],
['retoursChoisis', 'bas'],
['appartenancesVues', 'grand'],
['totalRedondance', 'bas'],
['diversite', 'grand'],
['manqueMax', 'bas'],
['effectifManque', 'bas'],
['ecartItineraireMax', 'bas'],
['ecartAuPlafondAPriori', 'bas'],
['certificat', null],
['imposes', null],
['commandes', null],
]);
const MIEUX = new Map([
['bas', /Plus (?:il|elle) est (?:bas|basse), mieux c'est\./],
['grand', /Plus (?:il|elle) est (?:grand|grande), mieux c'est\./],
]);
// Les phrases d'un texte : un point, puis un blanc, puis une capitale ou
// un guillemet ouvrant.
const phrases = (texte) => texte.split(/(?<=\.)\s+(?=[\p{Lu}\u{00AB}])/u);
test('chaque colonne de COLONNES, sans exception, porte la clé de son explication : elle existe, compte une à trois phrases, et diffère de l’en-tête et des autres explications', () => {
assert.equal(COLONNES.length, 21);
assert.deepEqual(COLONNES.map(({ nom }) => nom), [...SENS.keys()]);
const textes = new Set();
for (const { nom, cle, explication } of COLONNES) {
assert.equal(explication, `propositions.explication.${nom}`, nom);
assert.equal(explicationDeColonne(nom), explication, nom);
const texte = t(explication);
const nombre = phrases(texte).length;
assert.ok(nombre >= 1 && nombre <= 3, `${nom} : ${nombre} phrases`);
assert.ok(texte.length <= 420, `${nom} : ${texte.length} caractères`);
assert.notEqual(texte, t(cle), nom);
textes.add(texte);
}
assert.equal(textes.size, COLONNES.length);
});
test('chaque explication dit dans quel sens la colonne sert le mieux, et seulement quand ce sens existe', () => {
assert.ok(SENS.size > 0);
for (const [nom, sens] of SENS) {
const texte = t(explicationDeColonne(nom));
for (const [autre, motif] of MIEUX) {
if (autre === sens) assert.match(texte, motif, nom);
else assert.doesNotMatch(texte, motif, nom);
}
if (sens === null) assert.doesNotMatch(texte, /mieux c'est/, nom);
}
});
test('chaque critère, et l’identifiant croissant, partage l’explication de la colonne qu’il désigne ; seule l’explication d’un critère le dit critère du classement', () => {
const criteres = new Set(CRITERES.map(colonneDuCritere));
assert.equal(criteres.size, 5);
for (const { nom } of COLONNES) {
const texte = t(explicationDeColonne(nom));
if (criteres.has(nom)) assert.match(texte, /critère du classement/i, nom);
else assert.doesNotMatch(texte, /(?<!pas un )critère du classement/i, nom);
}
assert.match(t(explicationDeColonne(colonneDuCritere('identifiant'))), /départage/, 'identifiant');
for (const options of [{}, { plafondsAPriori: null }]) {
const vue = tableau(evaluer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), options));
const designants = vue.ordre.flat().filter(({ colonne }) => colonne !== null);
assert.ok(designants.length >= 5);
for (const { critere, colonne } of designants) {
assert.equal(explicationDeColonne(colonne), explicationDeColonne(colonneDuCritere(critere)), critere);
assert.ok(t(explicationDeColonne(colonne)).length > 0, critere);
}
}
});
test('explicationDeColonne lève pour un nom qui n’est pas une colonne', () => {
assert.equal(explicationDeColonne('rang'), 'propositions.explication.rang');
assert.throws(() => explicationDeColonne('redondance'), TypeError);
assert.throws(() => explicationDeColonne('moyenneRencontres'), TypeError);
});
});

View file

@ -620,6 +620,27 @@ export const ECRANS = Object.freeze([
return m.racine;
},
},
{
nom: 'propositions.infobulleColonne',
mode: 'lecture',
async atteindre(contexte) {
// Le pointeur réel sur un en-tête de colonne : l'infobulle qui
// l'explique paraît, visible, de largeur non nulle.
const m = await ouvrirEvenement(contexte, chargePrincipale(), { section: 'propositions' });
const { x, y } = await centre(await element(m, 'thead th[data-colonne="excedentCollisions"]'));
await pointer('bouger', x, y);
await attendre("l'infobulle de la colonne", () => {
const bulle = m.racine.querySelector('[data-section="propositions"] [role="tooltip"]');
return (
bulle !== null &&
!bulle.hidden &&
bulle.getBoundingClientRect().width > 0 &&
getComputedStyle(bulle).visibility === 'visible'
);
});
return m.racine;
},
},
{
nom: 'refus.modeLecture',
mode: 'lecture',