[ADD] plan: one hue and pattern per conflict rule, legend, appartenance

The plan drew every conflict in one hue, so a table that seats colleagues
together looked like one where people meet again. Each active rule now has
its own reserved hue and pattern: same appartenance hatched one way, met
again hatched the other, back at a table dotted; rules on one table
overlap, the badge keeps the total. A legend from the view bar shows each
active rule's sample. A table's tooltip names the appartenance that brings
a pair together. The hues are validated by computation in both themes and
the spec (7.5, 12.7) follows. Checked: 2848 node, 580 browser tests.

--- FR ---

[ADD] plan : une teinte et une trame par règle, légende, appartenance

Le plan dessinait tout conflit d'une seule teinte : une table qui réunit
des collègues ressemblait à une table où des gens se revoient. Chaque
règle active a sa teinte réservée et sa trame : même appartenance hachurée
dans un sens, voisins revus dans l'autre, table revue en pointillé ; les
règles d'une table se superposent, la pastille garde le total. Une légende
dans la barre de vue montre l'échantillon de chaque règle active.
L'infobulle nomme l'appartenance qui réunit une paire. Teintes validées
par calcul dans les deux thèmes ; spec § 7.5, § 12.7. Vérifié : 2848, 580.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-08 04:33:43 -04:00
parent 5f62698c82
commit 8afa53ebba
22 changed files with 1203 additions and 165 deletions

25
spec.md
View file

@ -1057,6 +1057,24 @@ Le signal **ne repose pas sur la seule couleur** : contour épaissi, trame, et
une **pastille portant le nombre de violations**. Une part notable des
opérateurs ne distingue pas un rouge d'un vert.
**Chaque règle a sa trame, d'une forme et d'une teinte propres** — hachures
descendantes pour « séparer les appartenances », hachures montantes pour
« nouveaux voisins », points pour « nouvelle table ». La forme suffit sans la
couleur ; la teinte la redit. Une table qui viole plusieurs règles superpose
leurs trames — les deux hachures se croisent en quadrillage — sous un même
contour épaissi, commun aux trois règles ; la pastille compte toutes ses
violations.
**Une légende des conflits reste accessible depuis le plan**, par la barre de
vue : pour chaque règle active, un échantillon de sa trame — la forme et la
teinte que le plan peint — et son nom ; elle dit aussi ce que compte la
pastille. Une règle inactive n'y paraît pas.
Le détail d'une table en conflit nomme les personnes de chaque violation ;
pour « séparer les appartenances », il nomme aussi **l'appartenance qui les
réunit**, telle que la liste la porte : « Même appartenance (Club des Merles) :
Iris Ombrelle et Théo Pervenche ».
**Quand toutes les tables sont en conflit, le plan cesse de signaler quoi que ce
soit** — cas que le diagnostic sait prédire. La pastille chiffrée garde sa
valeur puisqu'elle ordonne les tables entre elles, et le bandeau énonce le
@ -2603,8 +2621,11 @@ Une page de statistiques se casse par ses bords, et chacun se produira.
- **L'identité d'une série ne repose jamais sur la seule couleur** : au plus
deux séries, **étiquetées directement sur le tracé** en plus de la légende. La
page s'imprime, souvent en noir et blanc.
- **Les couleurs d'état sont réservées** : la teinte qui signale un conflit ici
est **la même** que sur le plan de salle, et ne sert à rien d'autre.
- **Les couleurs d'état sont réservées** : **les teintes de conflit** — trois,
une par règle que le plan localise (§ 7.5) — sont ici **les mêmes** que sur
le plan de salle, et ne servent à rien d'autre ; chacune ne peint que les
marques de sa règle. Elles se distinguent entre elles et des autres marques
du plan **une fois les trois dichromaties simulées**.
- **Une échelle séquentielle est une seule teinte, du clair au foncé.** Jamais
d'arc-en-ciel : un dégradé multicolore invente des frontières là où la
grandeur est continue.

View file

@ -19,8 +19,16 @@
les trois dichromaties. Aucun jeton de texte ou de marque n'a de canal
alpha : la couleur relue dans le style calculé est alors la couleur peinte.
La teinte de --couleur-conflit est réservée (§ 12.7) : tout autre jeton
chromatique s'en tient à 30° au moins, et aucun ne porte sa valeur.
Trois teintes sont réservées aux conflits (§ 7.5, § 12.7), une par règle
que le plan localise : --couleur-conflit-appartenance (séparer les
appartenances), --couleur-conflit-voisins (nouveaux voisins) et
--couleur-conflit-table (nouvelle table). Chacune ne peint que la trame de
sa règle ; tout autre jeton chromatique se tient à 30° au moins de chacune,
et aucun ne porte la valeur de l'une. Elles se distinguent entre elles et
des autres marques du plan sous les trois dichromaties (SEPARATIONS,
marques.js), et tiennent 3:1 sur le fond du plateau, qui borde leurs
traits. Le contour épaissi d'une table en conflit, commun aux trois, est
de l'encre forte du plan, --couleur-contour-fort.
La hachure d'une place à pourvoir peint des traits --couleur-hachure sur le
fond de la chaise, --couleur-chaise-fond : ce sont ses deux couleurs. */
@ -91,7 +99,10 @@
--couleur-chaise-pleine: #1f1f1f;
--couleur-hachure: #1f1f1f;
--couleur-marque: #767676;
--couleur-conflit: #b3261e;
--couleur-contour-fort: #2b2f36;
--couleur-conflit-appartenance: #8f1d14;
--couleur-conflit-voisins: #c0189f;
--couleur-conflit-table: #6a9400;
--couleur-pastille-fond: #2b2f36;
--couleur-pastille-texte: #ffffff;
--couleur-poignee: #4a5059;
@ -133,7 +144,10 @@
--couleur-chaise-pleine: #e8e8e8;
--couleur-hachure: #ededed;
--couleur-marque: #7a7a7a;
--couleur-conflit: #ff8a80;
--couleur-contour-fort: #e6e8eb;
--couleur-conflit-appartenance: #fc9890;
--couleur-conflit-voisins: #c0689c;
--couleur-conflit-table: #549c00;
--couleur-pastille-fond: #e6e8eb;
--couleur-pastille-texte: #15181c;
--couleur-poignee: #c5ccd6;

View file

@ -113,15 +113,38 @@ export const TABLE = new Map([
// Le plan est un seul arrêt de tabulation : sa description dit comment s'y
// déplacer. L'infobulle d'une table en conflit nomme chaque règle violée,
// puis ses groupes : les noms sont des données, chacun dans son nœud,
// reliés par « et » dans une paire et séparés d'un groupe à l'autre.
// reliés par « et » dans une paire et séparés d'un groupe à l'autre. Une
// ligne de séparer les appartenances nomme l'appartenance de ses paires,
// une donnée dans son nœud, entre separerAppartenances et finAppartenance.
['plan.vue.role', 'plan de salle interactif'],
['plan.infobulle.entete', ({ numero, nombre }) => `Table ${numero} : ${compte(nombre, 'conflit', 'conflits')}`],
['plan.infobulle.separerAppartenances', 'appartenances réunies : '],
['plan.infobulle.separerAppartenances', 'Même appartenance ('],
['plan.infobulle.finAppartenance', ') : '],
['plan.infobulle.nouveauxVoisins', 'voisins revus : '],
['plan.infobulle.nouvelleTable', 'table revue : '],
['plan.infobulle.et', ' et '],
['plan.infobulle.separateur', ', '],
// --- La légende des conflits (§ 7.5) --------------------------------------
// Une entrée par règle active : son échantillon, son nom, puis la forme de
// sa trame, qui la distingue sans la couleur. L'exemple de la pastille est
// le chiffre que porte son échantillon.
['plan.legende.ouvrir', 'Légende'],
['plan.legende.titre', 'Légende des conflits'],
['plan.legende.separerAppartenances', 'Séparer les appartenances : même appartenance à la table'],
['plan.legende.nouveauxVoisins', 'Nouveaux voisins à chaque tour : voisins revus'],
['plan.legende.nouvelleTable', 'Nouvelle table à chaque tour : table revue'],
['plan.legende.forme.hachuresDescendantes', '(hachures descendantes)'],
['plan.legende.forme.hachuresMontantes', '(hachures montantes)'],
['plan.legende.forme.points', '(points)'],
['plan.legende.aucune', 'Aucune règle de conflit n\u{2019}est active.'],
[
'plan.legende.superposition',
'Une table qui viole plusieurs règles superpose leurs trames ; son contour épaissi la signale quelle que soit la règle.',
],
['plan.legende.pastille', 'Pastille : le nombre de conflits de la table à ce tour, toutes règles confondues.'],
['plan.legende.pastilleExemple', '2'],
// --- Les panneaux latéraux (§ 2.1, étape 8) --------------------------------
['plan.panneaux', 'Panneaux du plan'],
['plan.listesTravail.titre', 'Listes de travail'],

View file

@ -23,6 +23,38 @@ function entree(nom, jeton, fonds, seuil) {
const marque = (nom, jeton, fonds) => entree(nom, jeton, fonds, 3);
const texte = (nom, jeton, fonds) => entree(nom, jeton, fonds, 4.5);
// Les sortes de conflit : règle, marque de la trame, jeton court, forme.
const SORTES_CONFLIT = [
['separerAppartenances', 'trameAppartenance', 'conflit-appartenance', 'hachuresDescendantes'],
['nouveauxVoisins', 'trameVoisins', 'conflit-voisins', 'hachuresMontantes'],
['nouvelleTable', 'trameTable', 'conflit-table', 'points'],
];
/** Les trois sortes de conflit qu'une table porte (§ 7.5, § 12.7), une par
* règle que le moteur localise (conflitsParTable), dans son ordre — l'ordre
* du dessin, de la légende et de l'infobulle : { regle, marque, jeton,
* forme } — la règle du moteur, la marque data-marque de sa trame, le jeton
* de sa teinte, réservée à cette trame, et la forme de son motif
* (Motifs.svelte), qui la distingue sans la couleur. Deux règles violées à
* une même table superposent leurs trames : les hachures descendantes et
* montantes se croisent en quadrillage, les points se posent par-dessus. */
export const CONFLITS = Object.freeze(
SORTES_CONFLIT.map(([regle, trame, jeton, forme]) => Object.freeze({ regle, marque: trame, jeton: c(jeton), forme })),
);
/** Les jetons des teintes de conflit, dans l'ordre de CONFLITS. */
export const JETONS_CONFLIT = Object.freeze(CONFLITS.map(({ jeton }) => jeton));
/** Écarts perçus minimaux d'une teinte de conflit, en distance OKLab × 100
* mesurée en couleurs d'origine et sous chaque dichromatie (ecartPercu,
* palette.js) : entreConflits, d'une teinte de conflit à une autre — la
* couleur redit la forme de la règle ; autresMarques, d'une teinte de
* conflit à chaque autre marque du plan. Le second seuil est plus bas : les
* gris du mobilier occupent la clarté où une teinte de conflit tient 3:1
* sur le plateau sombre, et la forme distingue toujours la trame de ce
* qu'elle côtoie. */
export const SEPARATIONS = Object.freeze({ entreConflits: 12, autresMarques: 5 });
/** Les marques porteuses d'information (§ 14.5) : { nom, jeton, fonds, seuil: 3 } — nom, la
* désignation data-marque ; jeton, celui qui la peint ; fonds, chaque jeton sur lequel elle
* se peint, chaque remplissage d'une chaise pour une marque qui passe sur une chaise. */
@ -34,10 +66,12 @@ export const MARQUES = Object.freeze([
marque('anneau', 'marque', ['sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
marque('hachure', 'hachure', ['chaise-fond']),
marque('remplissagePlein', 'chaise-pleine', ['sol', 'table-fond']),
// Le conflit (§ 7.5) : contour épaissi sur le sol, trame sur le plateau,
// pastille chiffrée près de la table.
marque('contourConflit', 'conflit', ['sol', 'table-fond']),
marque('trameConflit', 'conflit', ['table-fond']),
// Le conflit (§ 7.5) : contour épaissi sur le sol, commun aux trois
// règles et peint de l'encre forte du plan ; une trame par règle violée
// sur le plateau, chacune de sa teinte, ses traits détachés par un halo
// du fond du plateau ; pastille chiffrée près de la table.
marque('contourConflit', 'contour-fort', ['sol', 'table-fond']),
...SORTES_CONFLIT.map(([, trame, jeton]) => marque(trame, jeton, ['table-fond'])),
marque('pastille', 'pastille-fond', ['sol', 'table-fond']),
marque('poignee', 'poignee', ['sol']),
marque('gel', 'gel', ['sol', 'table-fond']),

View file

@ -8,15 +8,15 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { MARQUES, TEXTES } from './marques.js';
import { lireBlocs, lireJetons, verifierPalette } from './palette.js';
import { CONFLITS, JETONS_CONFLIT, MARQUES, SEPARATIONS, TEXTES } from './marques.js';
import { lireBlocs, lireJetons, verifierPalette, verifierSeparation } from './palette.js';
const JETONS = readFileSync(new URL('./jetons.css', import.meta.url), 'utf8');
const THEMES = ['clair', 'sombre'];
const NOMS_MARQUES = [
'cadenasFerme', 'cadenasOuvert', 'anneau', 'hachure', 'remplissagePlein',
'contourConflit', 'trameConflit', 'pastille', 'poignee', 'gel', 'miseEnEvidence',
'contourConflit', 'trameAppartenance', 'trameVoisins', 'trameTable', 'pastille', 'poignee', 'gel', 'miseEnEvidence',
'selection', 'cadreLecture', 'cadreEcriture', 'cadreBloque', 'focus',
'contourTable', 'contourChaise', 'echelle', 'fantome',
];
@ -39,7 +39,7 @@ const FONDS_REQUIS = {
const parNom = (registre, nom) => registre.find((e) => e.nom === nom);
describe('registre des marques', () => {
test('les vingt marques du contrat, noms uniques, seuil 3, au moins un fond chacune', () => {
test('les vingt-deux marques du contrat, noms uniques, seuil 3, au moins un fond chacune', () => {
assert.equal(MARQUES.length, NOMS_MARQUES.length);
assert.deepEqual(MARQUES.map((m) => m.nom), NOMS_MARQUES);
assert.equal(new Set(MARQUES.map((m) => m.nom)).size, MARQUES.length);
@ -91,6 +91,44 @@ describe('registre des marques', () => {
});
});
describe('les trois sortes de conflit (§ 7.5, § 12.7)', () => {
test('une par règle localisable, dans l’ordre du moteur : sa règle, la marque de sa trame, le jeton de sa teinte, la forme de son motif', () => {
assert.deepEqual(
CONFLITS.map((c) => [c.regle, c.marque, c.jeton, c.forme]),
[
['separerAppartenances', 'trameAppartenance', '--couleur-conflit-appartenance', 'hachuresDescendantes'],
['nouveauxVoisins', 'trameVoisins', '--couleur-conflit-voisins', 'hachuresMontantes'],
['nouvelleTable', 'trameTable', '--couleur-conflit-table', 'points'],
],
);
assert.ok(CONFLITS.every((c) => Object.keys(c).sort().join() === 'forme,jeton,marque,regle'));
assert.ok(Object.isFrozen(CONFLITS) && CONFLITS.every((c) => Object.isFrozen(c)));
assert.deepEqual(JETONS_CONFLIT, CONFLITS.map(({ jeton }) => jeton));
assert.ok(Object.isFrozen(JETONS_CONFLIT));
// Les seuils d'écart perçu : entre deux teintes de conflit, et d'une
// teinte de conflit à une autre marque du plan.
assert.deepEqual(SEPARATIONS, { entreConflits: 12, autresMarques: 5 });
assert.ok(Object.isFrozen(SEPARATIONS));
});
test('chaque trame est au registre, peinte de son jeton sur le fond du plateau', () => {
for (const { marque, jeton } of CONFLITS) {
const entree = parNom(MARQUES, marque);
assert.notEqual(entree, undefined, marque);
assert.equal(entree.jeton, jeton, marque);
assert.deepEqual(entree.fonds, ['--couleur-table-fond'], marque);
}
});
test('teinte réservée au registre : une teinte de conflit ne peint que sa trame, aucune autre marque ni aucun texte', () => {
const peintes = [...MARQUES, ...TEXTES].filter((e) => JETONS_CONFLIT.includes(e.jeton) || e.fonds.some((f) => JETONS_CONFLIT.includes(f)));
assert.deepEqual(peintes.map((e) => e.nom), CONFLITS.map(({ marque }) => marque));
// Le contour épaissi d'une table en conflit est commun aux trois règles :
// il ne porte aucune de leurs teintes.
assert.ok(!JETONS_CONFLIT.includes(parNom(MARQUES, 'contourConflit').jeton));
});
});
describe('registre des textes', () => {
test('page, secondaire, bandeau, refus, avis, listes du plan, pastille ; seuil 4,5', () => {
assert.deepEqual(TEXTES.map((t) => t.nom), ['page', 'secondaire', 'bandeau', 'refus', 'avis', 'listes', 'pastille']);
@ -129,4 +167,25 @@ describe('palette de jetons.css validée par calcul (§ 12.7, § 14.5)', () => {
test('chaque texte tient 4,5:1 sur chacun de ses fonds, dans les deux thèmes, sous les trois simulations', () => {
assert.deepEqual(verifierPalette(JETONS, TEXTES), []);
});
// Les autres marques du plan : celles qui se peignent sur le sol, le
// plateau ou une chaise, trames de conflit exceptées, chaque jeton une fois.
const FONDS_DU_PLAN = new Set(['--couleur-sol', '--couleur-table-fond', ...REMPLISSAGES_CHAISE]);
const autresMarquesDuPlan = () => [
...new Set(MARQUES.filter((m) => !JETONS_CONFLIT.includes(m.jeton) && m.fonds.some((f) => FONDS_DU_PLAN.has(f))).map((m) => m.jeton)),
];
test(`les trois teintes de conflit se séparent deux à deux de ${SEPARATIONS.entreConflits} au moins (OKLab × 100), dans les deux thèmes, en couleurs d’origine et sous les trois simulations`, () => {
assert.equal(JETONS_CONFLIT.length, 3);
assert.deepEqual(verifierSeparation(JETONS, JETONS_CONFLIT, [], SEPARATIONS.entreConflits), []);
});
test(`chaque teinte de conflit se sépare de ${SEPARATIONS.autresMarques} au moins de chaque autre marque du plan, dans les deux thèmes, sous les trois simulations`, () => {
const autres = autresMarquesDuPlan();
assert.ok(autres.length >= 10, `${autres.length} autres marques`);
for (const jeton of ['--couleur-gel', '--couleur-mise-en-evidence', '--couleur-selection', '--couleur-focus', '--couleur-fantome', '--couleur-pastille-fond']) {
assert.ok(autres.includes(jeton), jeton);
}
assert.deepEqual(verifierSeparation(JETONS, JETONS_CONFLIT, autres, SEPARATIONS.autresMarques), []);
});
});

View file

@ -3,9 +3,10 @@
// La palette se valide par calcul (§ 12.7, § 14.5). Le module lit les jetons
// de couleur dans le texte de jetons.css, simule les trois dichromaties,
// mesure le contraste au sens des WCAG et contrôle la teinte réservée au
// conflit. Il est pur : il reçoit le texte de la feuille, ne lit aucun
// fichier et ne touche pas le document.
// mesure le contraste au sens des WCAG et l'écart perçu entre deux
// couleurs, et contrôle les teintes réservées aux conflits. Il est pur : il
// reçoit le texte de la feuille, ne lit aucun fichier et ne touche pas le
// document.
//
// Une couleur est un tableau [r, g, b, a] : composantes sRGB de 0 à 255,
// alpha de 0 à 1.
@ -262,30 +263,92 @@ export function teinte(couleur) {
return degres < 0 ? degres + 360 : degres;
}
/** § 12.7 : la teinte de chaque jeton chromatique se tient à ECART_TEINTE
* degrés au moins de celle du conflit, dans chaque thème. jetonsDuConflit :
* le jeton de conflit d'abord, puis ceux qui ne peignent que les marques de
* conflit, que le contrôle ne mesure pas. Rend les fautes [{theme, jeton,
* ecart}], ecart en degrés sur le cercle ; un jeton de conflit achromatique
* est lui-même une faute, d'écart null — aucune teinte n'est alors réservée. */
export function verifierTeinteReservee(texteCss, jetonsDuConflit) {
const [conflit] = jetonsDuConflit;
/** § 12.7 : chaque teinte réservée — celles des conflits — est chromatique,
* et la teinte de chaque autre jeton chromatique se tient à ECART_TEINTE
* degrés au moins de chacune, dans chaque thème. Les teintes réservées ne
* se mesurent pas entre elles : verifierSeparation les distingue. Rend les
* fautes [{theme, reservee, jeton, ecart}], ecart en degrés sur le cercle,
* dans l'ordre thème, teinte réservée, jeton ; une teinte réservée
* achromatique est elle-même une faute, jeton égal à reservee et ecart
* null — aucune teinte n'est alors réservée. Un jeton réservé absent d'un
* thème lève. */
export function verifierTeintesReservees(texteCss, reservees) {
const fautes = [];
for (const theme of THEMES) {
const jetons = lireJetons(texteCss, theme);
if (!jetons.has(conflit)) throw new Error(`jeton absent du thème ${theme} : ${conflit}`);
const reference = teinte(jetons.get(conflit));
if (reference === null) {
fautes.push({ theme, jeton: conflit, ecart: null });
continue;
}
for (const [nom, couleur] of jetons) {
if (jetonsDuConflit.includes(nom)) continue;
const h = teinte(couleur);
if (h === null) continue;
const brut = Math.abs(h - reference);
const ecart = Math.min(brut, 360 - brut);
if (ecart < ECART_TEINTE) fautes.push({ theme, jeton: nom, ecart });
for (const reservee of reservees) {
if (!jetons.has(reservee)) throw new Error(`jeton absent du thème ${theme} : ${reservee}`);
const reference = teinte(jetons.get(reservee));
if (reference === null) {
fautes.push({ theme, reservee, jeton: reservee, ecart: null });
continue;
}
for (const [nom, couleur] of jetons) {
if (reservees.includes(nom)) continue;
const h = teinte(couleur);
if (h === null) continue;
const brut = Math.abs(h - reference);
const ecart = Math.min(brut, 360 - brut);
if (ecart < ECART_TEINTE) fautes.push({ theme, reservee, jeton: nom, ecart });
}
}
}
return fautes;
}
/** Les coordonnées OKLab [L, a, b] d'une couleur sRGB (Ottosson) : L vaut 0
* pour le noir et 1 pour le blanc, a et b sont nuls pour un gris. Les
* composantes passent en RGB linéaire, puis dans l'espace LMS, dont la
* racine cubique se recompose en L, a, b. Alpha ignoré. */
export function oklab(couleur) {
const [r, g, b] = couleur.slice(0, 3).map(versLineaire);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
];
}
/** L'écart perçu entre deux couleurs : la distance euclidienne de leurs
* coordonnées OKLab, multipliée par 100 — 100 du noir au blanc, 0 d'une
* couleur à elle-même. Symétrique. Sous une dichromatie, il se mesure sur
* les couleurs simulées (§ 12.7 : simuler d'abord, puis mesurer). */
export function ecartPercu(a, b) {
const [la, aa, ba] = oklab(a);
const [lb, ab, bb] = oklab(b);
return 100 * Math.hypot(la - lb, aa - ab, ba - bb);
}
/** Les jetons se distinguent, sur les couleurs d'origine puis sous chaque
* simulation, dans les deux thèmes : chaque paire de jetons, puis chaque
* jeton contre chacun des autres, tient un écart perçu (ecartPercu) d'au
* moins seuil ; les autres ne se mesurent pas entre eux. Rend les fautes
* [{theme, simulation, jeton, autre, ecart, seuil}], simulation valant
* 'origine' pour les couleurs d'origine, dans l'ordre thème, simulation,
* paires de jetons, puis jetons contre autres ; [] quand tout tient. Un
* jeton absent d'un thème lève. */
export function verifierSeparation(texteCss, jetons, autres, seuil) {
const paires = [];
jetons.forEach((jeton, i) => {
for (const autre of jetons.slice(i + 1)) paires.push([jeton, autre]);
});
for (const jeton of jetons) for (const autre of autres) paires.push([jeton, autre]);
const fautes = [];
for (const theme of THEMES) {
const lus = lireJetons(texteCss, theme);
const lire = (nom) => {
if (!lus.has(nom)) throw new Error(`jeton absent du thème ${theme} : ${nom}`);
return lus.get(nom);
};
for (const simulation of ['origine', ...ORDRE_SIMULATIONS]) {
const vue = (c) => (simulation === 'origine' ? c : simuler(c, simulation));
for (const [jeton, autre] of paires) {
const ecart = ecartPercu(vue(lire(jeton)), vue(lire(autre)));
if (ecart < seuil) fautes.push({ theme, simulation, jeton, autre, ecart, seuil });
}
}
}
return fautes;

View file

@ -5,21 +5,25 @@
// réservée (§ 12.7, § 13.3, § 14.5). Les fonctions s'éprouvent d'abord sur des
// feuilles d'épreuve écrites ici, où chaque défaut est posé exprès, puis sur
// le vrai fichier de jetons : ses familles, ses deux thèmes, les jetons que
// lisent les composants, la teinte du conflit.
// lisent les composants, les teintes des trois sortes de conflit.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import { CONFLITS, JETONS_CONFLIT } from './marques.js';
import {
SIMULATIONS,
contraste,
ecartPercu,
lireBlocs,
lireJetons,
oklab,
simuler,
teinte,
verifierPalette,
verifierTeinteReservee,
verifierSeparation,
verifierTeintesReservees,
} from './palette.js';
const DOSSIER = fileURLToPath(new URL('.', import.meta.url));
@ -228,7 +232,7 @@ describe('verifierPalette', () => {
});
});
describe('teinte et verifierTeinteReservee (§ 12.7)', () => {
describe('teinte et verifierTeintesReservees (§ 12.7)', () => {
test('la teinte HSL ne vaut que pour une couleur dont les composantes s’écartent de 26 au moins', () => {
assert.equal(teinte([255, 0, 0, 1]), 0);
assert.equal(teinte([0, 255, 0, 1]), 120);
@ -239,28 +243,47 @@ describe('teinte et verifierTeinteReservee (§ 12.7)', () => {
assert.notEqual(teinte([126, 100, 100, 1]), null); // écart 26 : chromatique
});
test('un refus de la teinte du conflit et d’une autre clarté est refusé, valeurs pourtant distinctes', () => {
test('un refus de la teinte d’un conflit et d’une autre clarté est refusé, valeurs pourtant distinctes', () => {
const conflit = '#c62828';
const refus = '#5a1212';
assert.notEqual(conflit, refus);
assert.ok(Math.abs(teinte(hex(conflit)) - teinte(hex(refus))) < 1);
const css = feuille({
'--couleur-conflit': conflit,
'--couleur-conflit-trame': '#e57373',
'--couleur-conflit-a': conflit,
'--couleur-conflit-b': '#1f8a3a',
'--couleur-refus': refus,
'--couleur-gris-rose': '#8a7d7d',
'--couleur-bleu': '#1f3a5f',
});
const fautes = verifierTeinteReservee(css, ['--couleur-conflit', '--couleur-conflit-trame']);
assert.deepEqual(fautes.map((f) => `${f.theme}:${f.jeton}`), ['clair:--couleur-refus', 'sombre:--couleur-refus']);
const fautes = verifierTeintesReservees(css, ['--couleur-conflit-a', '--couleur-conflit-b']);
assert.deepEqual(fautes.map((f) => `${f.theme}:${f.reservee}:${f.jeton}`), [
'clair:--couleur-conflit-a:--couleur-refus',
'sombre:--couleur-conflit-a:--couleur-refus',
]);
assert.ok(fautes.every((f) => f.ecart < 30));
});
test('le jeton de conflit lui-même doit être chromatique ; une teinte à 30° passe, à 29° non ; le cercle se referme', () => {
test('chaque teinte réservée est une référence : un jeton proche de la seconde est refusé ; deux teintes réservées ne se mesurent pas entre elles', () => {
// Rouge à 0°, vert à 120° : le jeton à 135° n'est proche que du vert.
const css = feuille({
'--couleur-conflit-a': '#ff0000',
'--couleur-conflit-b': '#00ff00',
'--couleur-conflit-c': '#ff1000',
'--couleur-sarcelle': '#00ff40',
'--couleur-bleu': '#0000ff',
});
const fautes = verifierTeintesReservees(css, ['--couleur-conflit-a', '--couleur-conflit-b', '--couleur-conflit-c']);
assert.deepEqual(fautes.map((f) => `${f.theme}:${f.reservee}:${f.jeton}`), [
'clair:--couleur-conflit-b:--couleur-sarcelle',
'sombre:--couleur-conflit-b:--couleur-sarcelle',
]);
});
test('une teinte réservée doit être chromatique ; une teinte à 30° passe, à 29° non ; le cercle se referme', () => {
const gris = feuille({ '--couleur-conflit': '#808080', '--couleur-bleu': '#1f3a5f' });
assert.deepEqual(verifierTeinteReservee(gris, ['--couleur-conflit']), [
{ theme: 'clair', jeton: '--couleur-conflit', ecart: null },
{ theme: 'sombre', jeton: '--couleur-conflit', ecart: null },
assert.deepEqual(verifierTeintesReservees(gris, ['--couleur-conflit']), [
{ theme: 'clair', reservee: '--couleur-conflit', jeton: '--couleur-conflit', ecart: null },
{ theme: 'sombre', reservee: '--couleur-conflit', jeton: '--couleur-conflit', ecart: null },
]);
// Conflit à 0° ; 30° et 330° passent, 29° et 331° non.
const css = feuille({
@ -270,13 +293,65 @@ describe('teinte et verifierTeinteReservee (§ 12.7)', () => {
'--couleur-c': '#ff0080', // 329,9°
'--couleur-d': '#ff0079', // 331,5°
});
const fautes = verifierTeinteReservee(css, ['--couleur-conflit']);
const fautes = verifierTeintesReservees(css, ['--couleur-conflit']);
assert.deepEqual(fautes.map((f) => `${f.theme}:${f.jeton}`), [
'clair:--couleur-b', 'clair:--couleur-d', 'sombre:--couleur-b', 'sombre:--couleur-d',
]);
});
});
describe('ecartPercu et verifierSeparation (§ 12.7 : simuler d’abord, puis mesurer l’écart)', () => {
test('OKLab : le blanc vaut L = 1, le noir L = 0, un gris n’a ni a ni b ; l’écart du noir au blanc vaut 100, d’une couleur à elle-même 0, dans les deux sens', () => {
const blanc = oklab([255, 255, 255, 1]);
const noir = oklab([0, 0, 0, 1]);
assert.ok(Math.abs(blanc[0] - 1) < 1e-4 && Math.abs(noir[0]) < 1e-9);
const gris = oklab([118, 118, 118, 1]);
assert.ok(Math.abs(gris[1]) < 1e-4 && Math.abs(gris[2]) < 1e-4, `${gris}`);
assert.ok(Math.abs(ecartPercu([0, 0, 0, 1], [255, 255, 255, 1]) - 100) < 0.01);
assert.equal(ecartPercu([90, 120, 30, 1], [90, 120, 30, 1]), 0);
assert.equal(ecartPercu([200, 30, 30, 1], [30, 120, 200, 1]), ecartPercu([30, 120, 200, 1], [200, 30, 30, 1]));
});
test('un rouge et un vert de même clarté se séparent en couleurs d’origine et se confondent sous la deutéranopie : la faute n’est relevée que simulée', () => {
// Cherche un vert de la clarté d'un rouge donné : leurs écarts d'origine
// sont grands, et la deutéranopie les rapproche.
const rouge = [200, 40, 40, 1];
let vert = null;
for (let v = 40; v <= 255 && vert === null; v += 1) {
const candidat = [40, v, 40, 1];
if (Math.abs(oklab(candidat)[0] - oklab(rouge)[0]) < 0.01) vert = candidat;
}
assert.notEqual(vert, null);
assert.ok(ecartPercu(rouge, vert) > 20);
assert.ok(ecartPercu(simuler(rouge, 'deuteranopie'), simuler(vert, 'deuteranopie')) < 10);
const css = feuille({ '--couleur-rouge': enHex(rouge.slice(0, 3)), '--couleur-vert': enHex(vert.slice(0, 3)) });
const fautes = verifierSeparation(css, ['--couleur-rouge', '--couleur-vert'], [], 10);
assert.ok(fautes.length > 0);
assert.ok(fautes.every((f) => f.simulation !== 'origine' && f.ecart < 10 && f.seuil === 10), JSON.stringify(fautes));
assert.ok(fautes.some((f) => f.simulation === 'deuteranopie'));
assert.ok(fautes.every((f) => f.jeton === '--couleur-rouge' && f.autre === '--couleur-vert'));
assert.deepEqual([...new Set(fautes.map((f) => f.theme))], ['clair', 'sombre']);
});
test('chaque paire des jetons, puis chaque jeton contre chacun des autres ; les autres ne se mesurent pas entre eux ; un jeton absent lève', () => {
const css = feuille({
'--couleur-a': '#c01010',
'--couleur-b': '#c11111',
'--couleur-x': '#1010c0',
'--couleur-y': '#1111c1',
});
const fautes = verifierSeparation(css, ['--couleur-a', '--couleur-x'], ['--couleur-b', '--couleur-y'], 5);
// Origine et trois simulations, deux thèmes : a contre b et x contre y
// se confondent partout ; a contre x, a contre y, x contre b se séparent.
assert.deepEqual(
[...new Set(fautes.map((f) => `${f.jeton}/${f.autre}`))],
['--couleur-a/--couleur-b', '--couleur-x/--couleur-y'],
);
assert.equal(fautes.length, 2 * 2 * 4);
assert.throws(() => verifierSeparation(css, ['--couleur-a'], ['--couleur-absente'], 5), /--couleur-absente/);
});
});
// Fichiers .svelte et .css de src/interface, sous-dossiers compris, triés.
function sourcesDeStyle() {
return readdirSync(DOSSIER, { recursive: true, withFileTypes: true })
@ -342,15 +417,21 @@ describe('jetons.css', () => {
}
});
test('la teinte du conflit est réservée (§ 12.7) : chromatique, sa valeur à lui seul, à 30° de tout autre jeton chromatique', () => {
test('les trois teintes de conflit sont réservées (§ 12.7) : chromatiques, chacune sa valeur, à 30° de tout autre jeton chromatique', () => {
assert.deepEqual(JETONS_CONFLIT, ['--couleur-conflit-appartenance', '--couleur-conflit-voisins', '--couleur-conflit-table']);
for (const theme of ['clair', 'sombre']) {
const jetons = lireJetons(JETONS, theme);
const conflit = jetons.get('--couleur-conflit');
assert.notEqual(conflit, undefined, theme);
assert.notEqual(teinte(conflit), null, `${theme} : conflit achromatique`);
const memeValeur = [...jetons].filter(([n, c]) => n !== '--couleur-conflit' && String(c) === String(conflit));
assert.deepEqual(memeValeur.map(([n]) => n), [], theme);
for (const nom of JETONS_CONFLIT) {
const couleur = jetons.get(nom);
assert.notEqual(couleur, undefined, `${theme} : ${nom}`);
assert.notEqual(teinte(couleur), null, `${theme} : ${nom} achromatique`);
const memeValeur = [...jetons].filter(([n, c]) => n !== nom && String(c) === String(couleur));
assert.deepEqual(memeValeur.map(([n]) => n), [], `${theme} : ${nom}`);
}
// Aucun autre jeton ne porte le préfixe des teintes de conflit.
assert.deepEqual([...jetons.keys()].filter((n) => n.startsWith('--couleur-conflit')), JETONS_CONFLIT, theme);
}
assert.deepEqual(verifierTeinteReservee(JETONS, ['--couleur-conflit']), []);
assert.deepEqual(verifierTeintesReservees(JETONS, JETONS_CONFLIT), []);
assert.deepEqual(CONFLITS.map(({ jeton }) => jeton), JETONS_CONFLIT);
});
});

View file

@ -2,9 +2,9 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La barre de vue du plan (§ 7.2, § 7.7, § 8.4) : le sélecteur de tour,
// « Zoom + », « Zoom moins », « Ajuster », l'affichage des listes et le plein
// écran. Des contrôles HTML, hors du <svg data-plan>, tous vivants en
// La barre de vue du plan (§ 7.2, § 7.5, § 7.7, § 8.4) : le sélecteur de
// tour, « Zoom + », « Zoom moins », « Ajuster », l'affichage des listes, le
// plein écran et la légende des conflits. Des contrôles HTML, hors du <svg data-plan>, tous vivants en
// lecture comme sur un plan bloqué : aucun n'écrit l'événement. Le tour,
// les listes et le plein écran passent par l'état de navigation ('ui') ;
// les zooms et l'ajustement par Plan.svelte, qui tient la vue :
@ -17,6 +17,12 @@
// ou non ; le menu de la personne la pose (MenuPersonne). Une personne que
// la charge n'a plus se dit telle, sans nom.
//
// legende : vrai quand la légende des conflits est ouverte ; basculerLegende
// l'ouvre ou la ferme ; idLegende, l'identifiant de la légende, que le
// bouton désigne par aria-controls. Plan.svelte tient cet état : ouvrir la
// légende n'écrit rien. Le plein écran reste le dernier contrôle de la
// barre, celui qui précède le plan dans l'ordre de tabulation.
//
// source : ce que la barre dit du placement montré, que rend sourceMontree
// (vue_plan.js) — {libelle, revenir} — : la barre le nomme, [data-source],
// et, quand une proposition est demandée, offre de ne plus la montrer
@ -25,7 +31,7 @@
import { getContext } from 'svelte';
import { toursOfferts } from './vue_plan.js';
let { tour, tours, zoomer, ajuster, evidence = null, source } = $props();
let { tour, tours, zoomer, ajuster, evidence = null, source, legende = false, basculerLegende = () => {}, idLegende = undefined } = $props();
const t = getContext('t');
const ui = getContext('ui');
@ -54,6 +60,9 @@
<button type="button" data-commande="listes" aria-pressed={$ui.listes} onclick={() => ui.basculerListes()}
>{t('plan.vue.listes')}</button
>
<button type="button" data-commande="legende" aria-pressed={legende} aria-controls={idLegende} onclick={() => basculerLegende()}
>{t('plan.legende.ouvrir')}</button
>
<button type="button" data-commande="pleinEcran" aria-pressed={$ui.pleinEcran} onclick={() => ui.basculerPleinEcran()}
>{t('plan.vue.pleinEcran')}</button
>

View file

@ -32,13 +32,15 @@ import { rechercher } from '../../moteur/recherche.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { versFichier } from '../../stockage/placements.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { composantes, couleursPeintes, fondPeintSous, sousTheme, visible } from '../../../test/navigateur/rendu.js';
import { composantes, contraste, couleurTexte, couleursPeintes, fondPeintSous, sousTheme, visible } from '../../../test/navigateur/rendu.js';
import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw';
import { TABLES_INTERFACE } from '../libelles.js';
import { CONFLITS } from '../marques.js';
import BandeauPlan from './BandeauPlan.svelte';
import Legende from './Legende.svelte';
import EchellePlan from './EchellePlan.svelte';
import { creerMesureur } from './mesure_texte.js';
import { LARGEUR_ECHELLE_PX, modeleDessin } from './modele.js';
import { LARGEUR_ECHELLE_PX, legendeConflits, modeleDessin } from './modele.js';
import PlanDessin from './PlanDessin.svelte';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
@ -227,6 +229,8 @@ function rencontreRepetee() {
// --- Lectures --------------------------------------------------------------------
const tous = (racine, selecteur) => [...racine.querySelectorAll(selecteur)];
// Les trames de conflit, des trois sortes (CONFLITS).
const TRAMES = CONFLITS.map(({ marque }) => `[data-marque=${marque}]`).join(', ');
const plateauDe = (svg, id) => svg.querySelector(`[data-table="${id}"] :is([data-marque=contourTable], [data-marque=contourConflit])`);
const centre = (element) => {
const b = element.getBoundingClientRect();
@ -610,11 +614,13 @@ describe('le signal de conflit (§ 7.5, § 13.3)', () => {
expect(enConflit.dataset.marque).toBe('contourConflit');
expect(parseFloat(getComputedStyle(enConflit).strokeWidth)).toBeGreaterThan(parseFloat(getComputedStyle(saine).strokeWidth));
expect(couleursPeintes(enConflit).stroke).not.toEqual(couleursPeintes(saine).stroke);
const trames = tous(svg, '[data-marque=trameConflit]');
// La seule règle active, nouveaux voisins : sa trame seule.
const trames = tous(svg, TRAMES);
expect(trames.length).toBe(1);
expect(trames[0].dataset.marque).toBe('trameVoisins');
expect(trames[0].closest('[data-table]').dataset.table).toBe('1');
expect(getComputedStyle(trames[0]).fill).toMatch(/^url\("?#/);
expect(couleursPeintes(trames[0]).fill.some((c) => memeCouleur(c, jeton('--couleur-conflit')))).toBe(true);
expect(couleursPeintes(trames[0]).fill.some((c) => memeCouleur(c, jeton('--couleur-conflit-voisins')))).toBe(true);
const pastilles = tous(svg, '[data-pastille]');
expect(pastilles.length).toBe(1);
expect(pastilles[0].dataset.pastille).toBe('1');
@ -631,7 +637,7 @@ describe('le signal de conflit (§ 7.5, § 13.3)', () => {
const tour1 = monterPlan(modeleDe(charge, { tour: 1, proposition: 1, k: 1.5 }), vue).svg;
expect(plateauDe(tour1, 1).dataset.marque).toBe('contourTable');
expect(tous(tour1, '[data-marque=trameConflit], [data-pastille]').length).toBe(0);
expect(tous(tour1, `${TRAMES}, [data-pastille]`).length).toBe(0);
expect(getComputedStyle(plateauDe(tour1, 1)).strokeWidth).toBe(getComputedStyle(plateauDe(tour1, 2)).strokeWidth);
const reserve = chargeDeTables([8]);
@ -648,7 +654,7 @@ describe('le signal de conflit (§ 7.5, § 13.3)', () => {
const modele = modeleDe(charge, { proposition: 1, k: 1 });
expect(modele.signalerConflits).toBe(false);
const { svg } = monterPlan(modele, { k: 1, tx: 150, ty: 250 });
expect(tous(svg, '[data-marque=contourConflit], [data-marque=trameConflit]').length).toBe(0);
expect(tous(svg, `[data-marque=contourConflit], ${TRAMES}`).length).toBe(0);
const saine = monterPlan(modeleDe(chargeDeTables([3]), { k: 1 }), { k: 1, tx: 150, ty: 600 }).svg;
const reference = getComputedStyle(plateauDe(saine, 1));
for (const id of [1, 2, 3, 4]) {
@ -682,6 +688,139 @@ describe('le signal de conflit (§ 7.5, § 13.3)', () => {
});
});
// --- Les trois sortes de conflit (§ 7.5, § 7.6, § 12.7) ---------------------------
// Le motif d'une trame : le <pattern> que désigne son remplissage.
function motifDe(trame) {
const id = /^url\("?#([^")]+)"?\)/.exec(getComputedStyle(trame).fill)?.[1];
return id === undefined ? null : document.getElementById(id);
}
// La forme d'un motif, lue dans le document : 'points' pour des disques ;
// pour des traits, leur sens à l'écran — la direction d'un trait du carreau
// passée par patternTransform —, 'hachuresDescendantes' quand il descend
// vers la droite, 'hachuresMontantes' quand il monte.
function formeDe(motif) {
const formes = [...motif.children].filter((n) => n instanceof SVGGeometryElement);
if (formes.length > 0 && formes.every((n) => n.localName === 'circle')) return 'points';
if (formes.length === 0 || !formes.every((n) => n.localName === 'line')) return `formes ${formes.map((n) => n.localName)}`;
const [trait] = formes;
const dx = trait.x2.baseVal.value - trait.x1.baseVal.value;
const dy = trait.y2.baseVal.value - trait.y1.baseVal.value;
const m = motif.patternTransform.baseVal.consolidate()?.matrix ?? new DOMMatrix();
const [ex, ey] = [m.a * dx + m.c * dy, m.b * dx + m.d * dy];
if (Math.abs(ex) < 1e-6 || Math.abs(ey) < 1e-6) return 'traits droits';
return ex * ey > 0 ? 'hachuresDescendantes' : 'hachuresMontantes';
}
// Les couleurs d'une trame, en texte trié, pour une comparaison d'ensembles.
const couleursEnTexte = (couleurs) => couleurs.map((c) => c.slice(0, 3).join(',')).sort();
// La petite démonstration en conflit, une proposition de rechercher, graine
// 3 : son tour 3 signale les trois sortes, une table saine, une table qui
// viole les trois règles et une qui en viole deux.
function troisSortes() {
const charge = analyser(texteLivre('petite-conflit')).charge;
const configuration = configurationDepuisCharge(charge);
const [proposition] = rechercher(configuration, { graine: 3, nombre: 1, arret: 300, historique: 1000 });
return avecProposition(charge, versFichier(proposition, configuration, { produitVersion: 'epreuve', attribuerSieges: false, decalage: 0 }));
}
describe('les trois sortes de conflit : une forme et une teinte par règle (§ 7.5, § 7.6, § 12.7)', () => {
for (const theme of ['clair', 'sombre']) {
test(`thème ${theme} : chaque règle dessine sa forme dans sa teinte, sur le fond du plateau ; une table qui en viole plusieurs les superpose, dans l\u{2019}ordre du moteur`, async () => {
await sousTheme(theme, async () => {
const charge = troisSortes();
const fond = couleursEnTexte([jeton('--couleur-table-fond')]);
const formes = new Map();
let superposees = 0;
for (let tour = 1; tour <= charge.evenement.tours; tour += 1) {
const modele = modeleDe(charge, { tour, proposition: 1, k: 1 });
if (!modele.signalerConflits) continue;
const { svg } = monterPlan(modele, { k: 1, tx: 150, ty: 250 });
for (const table of modele.tables) {
const trames = tous(svg, `[data-table="${table.id}"] :is(${TRAMES})`);
expect(trames.map((e) => e.dataset.marque), `tour ${tour}, table ${table.id}`).toEqual(table.trames.map(({ marque }) => marque));
if (trames.length > 1) superposees += 1;
for (const [rang, trame] of trames.entries()) {
const { regle, jeton: nom, forme } = table.trames[rang];
expect(trame.checkVisibility({ opacityProperty: true, visibilityProperty: true })).toBe(true);
const motif = motifDe(trame);
expect(motif?.localName, regle).toBe('pattern');
expect(formeDe(motif), regle).toBe(forme);
// Le motif ne peint que sa teinte et le fond du plateau : le
// halo de ses traits, qui les détache d'une trame dessous.
expect(couleursEnTexte(couleursPeintes(trame).fill), regle).toEqual([...couleursEnTexte([jeton(nom)]), ...fond].sort());
formes.set(regle, forme);
}
// Les trames suivent le plateau, et la pastille reste sur son socle.
if (trames.length > 0) {
const plateau = plateauDe(svg, table.id);
expect(trames.every((e) => plateau.compareDocumentPosition(e) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
const pastille = svg.querySelector(`[data-pastille="${table.id}"] [data-marque=pastille]`);
expect(fondPeintSous(pastille)).toEqual([jeton('--couleur-table-fond')]);
}
}
demonterTout();
}
expect(Object.fromEntries(formes)).toEqual(Object.fromEntries(CONFLITS.map(({ regle, forme }) => [regle, forme])));
expect(new Set(formes.values()).size).toBe(3);
expect(superposees).toBeGreaterThan(0);
});
});
}
});
describe('la légende des conflits (§ 7.5, § 14.5)', () => {
const REGLAGES = { separerAppartenances: true, nouveauxVoisins: true, nouvelleTable: true, varierAppartenances: true, attribuerSieges: false };
for (const theme of ['clair', 'sombre']) {
test(`thème ${theme} : un échantillon par règle active, de la forme et de la teinte du plan, et son nom ; la pastille et ce qu\u{2019}elle compte ; texte à 4,5:1`, async () => {
await sousTheme(theme, async () => {
const entrees = legendeConflits(REGLAGES);
expect(entrees.length).toBe(3);
const conteneur = monterHtml(Legende, { entrees });
const legende = conteneur.querySelector('[data-legende]');
expect(visible(legende.querySelector('h3, h2, p, span'))).toBe(true);
const items = tous(legende, '[data-legende-regle]');
expect(items.map((e) => e.dataset.legendeRegle)).toEqual(CONFLITS.map(({ regle }) => regle));
for (const [rang, item] of items.entries()) {
const { marque, jeton: nom, forme, cle, cleForme } = entrees[rang];
const trame = item.querySelector(`svg [data-marque="${marque}"]`);
expect(trame, marque).not.toBeNull();
expect(trame.checkVisibility({ opacityProperty: true, visibilityProperty: true })).toBe(true);
expect(formeDe(motifDe(trame))).toBe(forme);
expect(couleursEnTexte(couleursPeintes(trame).fill)).toEqual([...couleursEnTexte([jeton(nom)]), ...couleursEnTexte([jeton('--couleur-table-fond')])].sort());
expect(item.textContent).toContain(t(cle));
expect(item.textContent).toContain(t(cleForme));
expect(item.querySelectorAll('svg [data-marque="contourConflit"]').length).toBe(1);
}
const pastille = legende.querySelector('[data-legende-pastille]');
expect(pastille.querySelector('svg [data-marque="pastille"]')).not.toBeNull();
expect(pastille.textContent).toContain(t('plan.legende.pastille'));
expect(pastille.querySelector('svg text').textContent).toBe(t('plan.legende.pastilleExemple'));
// Chaque texte HTML de la légende tient 4,5:1 sur son fond peint.
const textes = tous(legende, '*').filter((e) => !(e instanceof SVGElement) && [...e.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim() !== ''));
expect(textes.length).toBeGreaterThan(3);
for (const element of textes) {
const [fond] = fondPeintSous(element);
expect(contraste(couleurTexte(element), fond), element.textContent).toBeGreaterThanOrEqual(4.5);
}
});
});
}
test('une règle inactive n\u{2019}y paraît pas ; aucune règle active : la légende le dit, et garde la pastille', () => {
const deux = monterHtml(Legende, { entrees: legendeConflits({ ...REGLAGES, nouveauxVoisins: false }) });
expect(tous(deux, '[data-legende-regle]').map((e) => e.dataset.legendeRegle)).toEqual(['separerAppartenances', 'nouvelleTable']);
expect(deux.querySelector('[data-marque="trameVoisins"]')).toBeNull();
const aucune = monterHtml(Legende, { entrees: legendeConflits({ ...REGLAGES, separerAppartenances: false, nouveauxVoisins: false, nouvelleTable: false }) });
expect(tous(aucune, '[data-legende-regle]').length).toBe(0);
expect(aucune.querySelector('[data-legende]').textContent).toContain(t('plan.legende.aucune'));
expect(aucune.querySelector('[data-legende-pastille]')).not.toBeNull();
});
});
describe('l\u{2019}échelle graphique et la mention (§ 7.7, § 19.3 07)', () => {
test('la mention se voit, portée par EchellePlan, tirée des libellés', () => {
const conteneur = monterHtml(EchellePlan, { k: 1 });

View file

@ -8,9 +8,11 @@
// trois déclencheurs — le survol, le focus du clavier, la pression longue —
// et lui passe le détail des conflits (detailConflits, modele.js) et sa
// position dans la surface, en px. Elle énonce la table et son nombre de
// violations, puis chaque règle violée et ses groupes : chaque nom occupe
// son propre nœud, relié à l'autre nom d'une paire par « et », séparé du
// groupe suivant.
// violations, puis chaque ligne du détail et ses groupes : chaque nom
// occupe son propre nœud, relié à l'autre nom d'une paire par « et »,
// séparé du groupe suivant. Une ligne qui porte une appartenance — séparer
// les appartenances — la nomme dans son propre nœud, entre son libellé et
// finAppartenance.
//
// infobulle : null, ou {detail, x, y} ; id : l'identifiant que la racine
// du plan désigne par aria-describedby quand l'infobulle s'ouvre par le
@ -32,9 +34,11 @@
>
{#if infobulle !== null}
<p class="entete">{t('plan.infobulle.entete', { numero: infobulle.detail.numero, nombre: infobulle.detail.nombre })}</p>
{#each infobulle.detail.regles as regle, rang (rang)}
{#each infobulle.detail.lignes as ligne, rang (rang)}
<p>
<span>{t(regle.cle)}</span>{#each regle.groupes as groupe, g (g)}{#if g > 0}<span
<span>{t(ligne.cle)}</span>{#if ligne.appartenance !== null}<span class="donnee">{ligne.appartenance}</span><span
>{t('plan.infobulle.finAppartenance')}</span
>{/if}{#each ligne.groupes as groupe, g (g)}{#if g > 0}<span
>{t('plan.infobulle.separateur')}</span
>{/if}{#each groupe as nom, n (n)}{#if n > 0}<span>{t('plan.infobulle.et')}</span>{/if}<span class="donnee"
>{nom}</span

View file

@ -0,0 +1,126 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La légende des conflits du plan (§ 7.5, § 12.7) : pour chaque règle
// active, un échantillon dessiné en SVG — un plateau au contour épaissi,
// couvert de la trame de la règle, de la forme et de la teinte que le plan
// peint, tirée des mêmes motifs (Motifs.svelte) — et son nom, suivi du nom
// de sa forme ; puis la pastille et ce qu'elle compte. Une règle inactive
// n'y paraît pas ; sans règle active, la légende le dit.
//
// entrees : celles de legendeConflits (modele.js), dans l'ordre du moteur ;
// id : l'identifiant que le bouton de la barre de vue désigne par
// aria-controls. Les motifs se définissent dans un <svg> sans taille,
// propre à la légende : leurs identifiants portent son préfixe, et les
// échantillons les désignent. Les tailles des échantillons sont en px.
import { getContext } from 'svelte';
import Motifs from './Motifs.svelte';
let { entrees, id = undefined } = $props();
const t = getContext('t');
const prefixe = $props.id();
// Le centre et le rayon du plateau d'un échantillon, et le rayon de la
// pastille, en px.
const CENTRE = 20;
const RAYON = 16;
const RAYON_PASTILLE = 12;
</script>
<section class="legende" data-legende {id} aria-labelledby="{prefixe}-titre">
<svg class="motifs" width="0" height="0" aria-hidden="true"><Motifs {prefixe} /></svg>
<h3 id="{prefixe}-titre">{t('plan.legende.titre')}</h3>
{#if entrees.length === 0}
<p>{t('plan.legende.aucune')}</p>
{/if}
<ul>
{#each entrees as entree (entree.regle)}
<li data-legende-regle={entree.regle}>
<svg class="echantillon" width={2 * CENTRE} height={2 * CENTRE} aria-hidden="true">
<circle class="plateau" data-marque="contourConflit" cx={CENTRE} cy={CENTRE} r={RAYON} />
<circle class="trame" data-marque={entree.marque} fill="url(#{prefixe}-trame-{entree.regle})" cx={CENTRE} cy={CENTRE} r={RAYON} />
</svg>
<span>{t(entree.cle)}</span>
<span>{t(entree.cleForme)}</span>
</li>
{/each}
<li data-legende-pastille>
<svg class="echantillon" width={2 * CENTRE} height={2 * CENTRE} aria-hidden="true">
<circle class="disque" data-marque="pastille" cx={CENTRE} cy={CENTRE} r={RAYON_PASTILLE} />
<text class="nombre" x={CENTRE} y={CENTRE} text-anchor="middle" dominant-baseline="central">{t('plan.legende.pastilleExemple')}</text>
</svg>
<span>{t('plan.legende.pastille')}</span>
</li>
</ul>
{#if entrees.length > 1}
<p>{t('plan.legende.superposition')}</p>
{/if}
</section>
<style>
.legende {
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
}
.motifs {
position: absolute;
overflow: hidden;
}
h3 {
margin: 0 0 var(--espace-2);
font-size: var(--taille-texte);
}
p {
margin: var(--espace-2) 0 0;
}
ul {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2) var(--espace-5);
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
align-items: center;
gap: var(--espace-1);
}
.echantillon {
flex: none;
}
/* Le plateau de l'échantillon : le fond et le contour épaissi d'une table
en conflit, sous la trame. */
.plateau {
fill: var(--couleur-table-fond);
stroke: var(--couleur-contour-fort);
stroke-width: 3;
}
.trame {
stroke: none;
}
.disque {
fill: var(--couleur-pastille-fond);
}
.nombre {
fill: var(--couleur-pastille-texte);
font-family: var(--police-interface);
font-size: var(--taille-texte-petit);
font-weight: 700;
}
</style>

View file

@ -5,11 +5,24 @@
<script>
// Les motifs du plan (§ 7.5, § 7.6) : la hachure d'une place à pourvoir —
// des traits --couleur-hachure sur le fond de la chaise — et la trame d'une
// table en conflit — des traits --couleur-conflit sur le fond du plateau.
// Chaque motif peint son fond, opaque, puis ses traits : la couleur relue
// dans le style calculé est la couleur peinte. Les tailles sont en cm de
// dessin ; prefixe rend leurs identifiants uniques dans le document.
// des traits --couleur-hachure sur le fond de la chaise — et les trois
// trames de conflit, une par règle (CONFLITS, marques.js), chacune d'une
// forme et d'une teinte : hachures descendantes pour séparer les
// appartenances, hachures montantes pour nouveaux voisins, points pour
// nouvelle table. L'identifiant d'une trame est <prefixe>-trame-<règle>.
//
// La hachure peint son fond, opaque, puis ses traits. Une trame de conflit
// ne peint pas de fond : elle se pose sur le plateau, et une table qui
// viole plusieurs règles superpose ses trames — les hachures des deux sens
// se croisent en quadrillage, les points se posent par-dessus. Chaque trait
// ou point se peint sur un halo du fond du plateau, plus large que lui, qui
// le détache de la trame dessous : une trame ne peint que sa teinte et le
// fond du plateau, et sa teinte se mesure contre lui (§ 14.5). La couleur
// relue dans le style calculé est la couleur peinte.
//
// Les tailles sont dans l'unité du dessin qui porte les motifs : des cm sur
// le plan, des px dans la légende. prefixe rend les identifiants uniques
// dans le document.
//
// Les cadenas et l'anneau ne sont pas des symboles référencés par <use> :
// chaque chaise les dessine elle-même (TablePlan), et les aides de rendu
@ -23,9 +36,26 @@
<rect class="fond-chaise" width="8" height="8" />
<line class="trait-hachure" x1="4" y1="0" x2="4" y2="8" />
</pattern>
<pattern id="{prefixe}-trame" patternUnits="userSpaceOnUse" width="12" height="12" patternTransform="rotate(-45)">
<rect class="fond-table" width="12" height="12" />
<line class="trait-conflit" x1="6" y1="0" x2="6" y2="12" />
<!-- Séparer les appartenances : un trait du carreau, tourné de −45°, descend vers la droite. -->
<pattern
id="{prefixe}-trame-separerAppartenances"
patternUnits="userSpaceOnUse"
width="12"
height="12"
patternTransform="rotate(-45)"
>
<line class="halo" x1="6" y1="0" x2="6" y2="12" />
<line class="trait appartenance-conflit" x1="6" y1="0" x2="6" y2="12" />
</pattern>
<!-- Nouveaux voisins : tourné de 45°, le trait monte vers la droite. -->
<pattern id="{prefixe}-trame-nouveauxVoisins" patternUnits="userSpaceOnUse" width="12" height="12" patternTransform="rotate(45)">
<line class="halo" x1="6" y1="0" x2="6" y2="12" />
<line class="trait voisins-conflit" x1="6" y1="0" x2="6" y2="12" />
</pattern>
<!-- Nouvelle table : un point par carreau. -->
<pattern id="{prefixe}-trame-nouvelleTable" patternUnits="userSpaceOnUse" width="12" height="12">
<circle class="halo-point" cx="6" cy="6" r="4.5" />
<circle class="point table-conflit" cx="6" cy="6" r="3" />
</pattern>
</defs>
@ -39,12 +69,30 @@
stroke-width: 3;
}
.fond-table {
/* Le halo d'un trait ou d'un point : le fond du plateau, plus large que
lui d'un de chaque côté. */
.halo {
stroke: var(--couleur-table-fond);
stroke-width: 6;
}
.halo-point {
fill: var(--couleur-table-fond);
}
.trait-conflit {
stroke: var(--couleur-conflit);
stroke-width: 3;
.trait {
stroke-width: 4;
}
.trait.appartenance-conflit {
stroke: var(--couleur-conflit-appartenance);
}
.trait.voisins-conflit {
stroke: var(--couleur-conflit-voisins);
}
.point.table-conflit {
fill: var(--couleur-conflit-table);
}
</style>

View file

@ -574,6 +574,75 @@ describe('le plan est un seul arrêt de tabulation (§ 7.3, § 13.5)', () => {
});
});
// --- L'appartenance nommée, la légende des conflits (§ 7.5) -------------------
describe('l’infobulle nomme l’appartenance d’une paire réunie ; la légende des conflits (§ 7.5)', () => {
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
// Deux tables de trois, six personnes, un tour : la table 1 réunit Iris
// et Théo, du Club des Merles ; la table 2, Basile et Capucine, d'une
// appartenance écrite avec une minuscule et un blanc final, telle que la
// liste la porte.
function deuxAppartenances(reglages = {}) {
const charge = chargeDe({ id: 'evt-appartenances', defaut: 3, places: [null, null], personnes: 6, nombreTours: 1, tours: [[[1, 2, 4], [3, 5, 6]]] });
const appartenances = new Map([[1, 'Club des Merles'], [2, 'Club des Merles'], [5, 'cercle des Hérons '], [6, 'cercle des Hérons ']]);
for (const personne of charge.participants) personne.appartenance = appartenances.get(personne.id) ?? null;
Object.assign(charge.reglages, reglages);
return charge;
}
test('le survol d’une table en conflit d’appartenance : « Même appartenance (…) : … et … », l’appartenance dans son propre nœud', async () => {
const m = await ouvrir(deuxAppartenances());
expect(etatMontre(m).tables.map(({ conflits }) => conflits.nombre)).toEqual([1, 1]);
const infobulle = () => document.querySelector('[role="tooltip"]');
// Le pointeur part du sol : un survol commence par un mouvement.
await pointer('bouger', pointSol(m).x, pointSol(m).y);
for (const [table, appartenance, noms] of [
[1, 'Club des Merles', ['Iris Ombrelle', 'Théo Pervenche']],
[2, 'cercle des Hérons ', ['Basile Tourmaline', 'Capucine Arrosoir']],
]) {
const point = pointTable(m, table);
await pointer('bouger', point.x, point.y);
await attendre(() => expect(infobulleVisible(infobulle())).toBe(true));
const lignes = [...infobulle().querySelectorAll('p')];
expect(lignes.map((p) => p.textContent)).toEqual([
t('plan.infobulle.entete', { numero: table, nombre: 1 }),
`${t('plan.infobulle.separerAppartenances')}${appartenance}${t('plan.infobulle.finAppartenance')}${noms.join(t('plan.infobulle.et'))}`,
]);
expect(lignes[1].textContent).toMatch(/^Même appartenance \(/);
const noeuds = [...lignes[1].children].map((e) => e.textContent);
expect(noeuds).toContain(appartenance);
for (const nom of noms) expect(noeuds).toContain(nom);
await pointer('bouger', pointSol(m).x, pointSol(m).y);
await attendre(() => expect(infobulleVisible(infobulle())).toBe(false));
}
});
for (const mode of ['ecriture', 'lecture']) {
test(`mode ${mode} : le bouton « Légende » de la barre de vue ouvre la légende des seules règles actives, et la referme ; rien ne s’écrit`, async () => {
const m = await ouvrir(deuxAppartenances({ nouvelleTable: false }), { mode });
const revision = m.seance.etat().evenement.revision;
const bouton = dans(m, '[data-commande="legende"]');
expect(bouton).not.toBeNull();
expect(bouton.closest('[role="toolbar"]')).not.toBeNull();
expect(bouton.textContent).toBe(t('plan.legende.ouvrir'));
expect(bouton.getAttribute('aria-pressed')).toBe('false');
expect(dans(m, '[data-legende]')).toBeNull();
await cliquer(bouton);
await attendre(() => expect(dans(m, '[data-legende]')).not.toBeNull());
expect(bouton.getAttribute('aria-pressed')).toBe('true');
expect(bouton.getAttribute('aria-controls')).toBe(dans(m, '[data-legende]').id);
expect(tous(m, '[data-legende] [data-legende-regle]').map((e) => e.dataset.legendeRegle)).toEqual(['separerAppartenances', 'nouveauxVoisins']);
expect(dans(m, '[data-legende] [data-marque="trameTable"]')).toBeNull();
expect(infobulleVisible(dans(m, '[data-legende]'))).toBe(true);
await cliquer(bouton);
await attendre(() => expect(dans(m, '[data-legende]')).toBeNull());
expect(bouton.getAttribute('aria-pressed')).toBe('false');
expect(m.seance.etat().evenement.revision).toBe(revision);
});
}
});
// --- L'infobulle (§ 7.1, § 7.3, § 19.4) ---------------------------------------
describe('l’infobulle : un composant, trois déclencheurs (§ 7.1, § 7.3)', () => {

View file

@ -2,9 +2,12 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le plan de salle et sa vue (§ 7.2, § 7.3, § 7.7, § 8.4, § 8.5) : la
// barre de vue, puis la surface [data-surface] qui porte le bandeau du
// plan, le <svg data-plan> et l'échelle graphique. Le <svg> racine n'a ni
// Le plan de salle et sa vue (§ 7.2, § 7.3, § 7.5, § 7.7, § 8.4, § 8.5) :
// la barre de vue, la légende des conflits quand elle est ouverte — les
// règles actives des réglages (legendeConflits) —, puis la surface
// [data-surface] qui porte le bandeau du plan, le <svg data-plan> et
// l'échelle graphique. Ouvrir la légende change la taille du cadre, que le
// ResizeObserver relit comme tout autre changement. Le <svg> racine n'a ni
// viewBox, ni bordure, ni remplissage : son unité est le pixel CSS, et le
// dessin vit dans un seul groupe <g data-vue>, dont l'attribut transform
// est la vue, {k, tx, ty}. Un zoom n'écrit que cet attribut sous la
@ -99,10 +102,12 @@
import Fantome from './Fantome.svelte';
import { REPOS, cleFantome, geste, memoriser } from './gestes.js';
import Infobulle from './Infobulle.svelte';
import { detailConflits, modeleDessin } from './modele.js';
import Legende from './Legende.svelte';
import { detailConflits, legendeConflits, modeleDessin } from './modele.js';
import PlanDessin from './PlanDessin.svelte';
import RefusPlace from './RefusPlace.svelte';
import {
appartenanceDeCharge,
cadreDepuisRect,
englobantDuTour,
nomDeCharge,
@ -147,6 +152,11 @@
const prefixe = $props.id();
const idPose = `${prefixe}-pose`;
const idInfobulle = `${prefixe}-infobulle`;
const idLegende = `${prefixe}-legende`;
// La légende des conflits, ouverte ou non : un état de la vue, que le
// bouton de la barre bascule ; rien ne s'écrit.
let legendeOuverte = $state(false);
const charge = $derived($seance.charge);
const tours = $derived(charge.evenement.tours);
@ -154,6 +164,9 @@
const tour = $derived(etat.tour);
const englobantPlan = $derived(englobantDuTour(etat));
const nomDe = $derived(nomDeCharge(charge));
const appartenanceDe = $derived(appartenanceDeCharge(charge));
// Les règles actives des réglages, que la légende montre.
const legende = $derived(legendeConflits(charge.reglages));
const selection = $derived($ui.selection);
const pose = $derived($ui.pose);
// La personne mise en évidence, que la barre de vue nomme : {id, nom}, nom
@ -197,7 +210,7 @@
const etatTable = etat.tables.find(({ id }) => id === infobulleTable);
const dessin = modele.tables.find(({ id }) => id === infobulleTable);
if (etatTable === undefined || dessin === undefined) return null;
const detail = detailConflits(etatTable, nomDe);
const detail = detailConflits(etatTable, nomDe, appartenanceDe);
if (detail === null) return null;
const { centre, rayonEmprise } = dessin.geometrie;
const { x, y } = ecranDepuisDessin(vue, origineSvg, centre.x + rayonEmprise, centre.y - rayonEmprise);
@ -712,7 +725,20 @@
</script>
<div class="plan">
<BarreVue {tour} {tours} {zoomer} {ajuster} {evidence} {source} />
<BarreVue
{tour}
{tours}
{zoomer}
{ajuster}
{evidence}
{source}
legende={legendeOuverte}
basculerLegende={() => (legendeOuverte = !legendeOuverte)}
{idLegende}
/>
{#if legendeOuverte}
<Legende entrees={legende} id={idLegende} />
{/if}
<div class="surface" data-surface bind:this={surface}>
<BandeauPlan bandeau={modele === null ? [] : modele.bandeau} />
<!-- Le plan est un composant interactif à deux dimensions, que le

View file

@ -21,12 +21,15 @@
// le premier disque du groupe reste la chaise.
//
// Une table en conflit, quand le plan signale, porte un contour épaissi
// (contourConflit) et une trame (trameConflit) par-dessus son plateau ; sa
// pastille porte le nombre de violations, signalée ou non (§ 7.5). Sur la
// trame, un socle [data-socle] du fond du plateau, centré sous la
// pastille, la sépare des traits de la trame : la pastille se mesure
// contre le fond du plateau (§ 14.5), et la trame reste peinte autour.
// Le socle précède la marque de gel, qui se peint sur lui.
// (contourConflit), commun aux trois règles, et, par-dessus son plateau,
// une trame par règle violée (table.trames, dans l'ordre du moteur) : la
// forme et la teinte de sa sorte (CONFLITS, Motifs.svelte), superposées
// quand elle en viole plusieurs. Sa pastille porte le nombre de
// violations, signalée ou non (§ 7.5). Sur les trames, un socle
// [data-socle] du fond du plateau, centré sous la pastille, la sépare de
// leurs traits : la pastille se mesure contre le fond du plateau (§ 14.5),
// et les trames restent peintes autour. Le socle précède la marque de gel,
// qui se peint sur lui.
//
// Le groupe de la table porte id="table-<id>", celui d'une chaise
// id="siege-<table>-<siège>" : la racine du plan, seul arrêt de tabulation,
@ -65,7 +68,6 @@
const poignee = $derived(table.geometrie.poignee);
const signal = $derived(signaler && table.conflit);
const hachure = $derived(`url(#${prefixe}-hachure)`);
const trame = $derived(`url(#${prefixe}-trame)`);
// Les marques de désignation de la table, autour de l'emprise.
const autour = $derived(formesTable({ ...table.geometrie, rayonEmprise: table.geometrie.rayonEmprise + ECART_DESIGNATION }));
const cadreFocus = $derived(carreAutour(table.geometrie.centre, table.geometrie.rayonEmprise + ECART_FOCUS));
@ -90,11 +92,13 @@
<rect class="plateau" class:conflit={signal} data-marque={signal ? 'contourConflit' : 'contourTable'} {...formes.plateau} />
{/if}
{#if signal}
{#if formes.ronde}
<circle class="trame" data-marque="trameConflit" fill={trame} {...formes.plateau} />
{:else}
<rect class="trame" data-marque="trameConflit" fill={trame} {...formes.plateau} />
{/if}
{#each table.trames as trame (trame.regle)}
{#if formes.ronde}
<circle class="trame" data-marque={trame.marque} fill="url(#{prefixe}-trame-{trame.regle})" {...formes.plateau} />
{:else}
<rect class="trame" data-marque={trame.marque} fill="url(#{prefixe}-trame-{trame.regle})" {...formes.plateau} />
{/if}
{/each}
<circle class="socle" data-socle cx={formes.pastille.x} cy={formes.pastille.y} r={RAYON_SOCLE_PASTILLE} />
{/if}
{#if table.gelee}
@ -180,8 +184,10 @@
stroke: var(--couleur-table-trait);
}
/* Le contour d'une table en conflit : épaissi, de l'encre forte du plan ;
les teintes des règles sont sur les trames. */
.plateau.conflit {
stroke: var(--couleur-conflit);
stroke: var(--couleur-contour-fort);
stroke-width: 4;
}

View file

@ -12,6 +12,7 @@ import { constante } from '../../geometrie/constantes.js';
import { regimeCibles } from '../../geometrie/designation.js';
import { geometrieTable } from '../../geometrie/tables.js';
import { decalagesLignes, tronquer } from '../../geometrie/texte.js';
import { CONFLITS } from '../marques.js';
/** Largeur maximale de la barre de l'échelle graphique, en px d'écran. */
export const LARGEUR_ECHELLE_PX = 120;
@ -289,6 +290,9 @@ function bandeauDe(toutesEnConflit, diagnostic) {
return entrees;
}
// La sorte de conflit de chaque règle du moteur (CONFLITS).
const SORTE_DE_REGLE = new Map(CONFLITS.map((sorte) => [sorte.regle, sorte]));
// Une chaise libre au siège s : celle qu'ajoute la poignée au-delà de la
// capacité, que rien ne tient ni n'occupe.
const chaiseLibre = (siege) => ({ siege, occupant: null, reservation: null, titre: null, remplissage: 'normal', marques: [] });
@ -314,6 +318,7 @@ function porteurDe(place, id, regime) {
function tableDessin(etatTable, { k, miseEnEvidence, nomDe, selection = null, actif = null }, places = etatTable.capacite) {
const { id, numero, gelee, surchargee, conflits } = etatTable;
const geometrie = geometrieTable(etatTable, places);
const trames = conflits === null ? [] : conflits.regles.map(({ regle }) => ({ ...SORTE_DE_REGLE.get(regle) }));
const regime = regimeCibles(k, geometrie);
const pastille = conflits === null ? 0 : conflits.nombre;
const etatChaises = chaisesA(etatTable, places);
@ -342,6 +347,7 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe, selection = null, ac
regime,
conflit: pastille > 0,
pastille,
trames,
selection: selectionnee === 'table',
focus: active === 'table',
gelee,
@ -362,7 +368,9 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe, selection = null, ac
* Chaque table, dans l'ordre de l'état des places, porte sa géométrie
* (geometrieTable, P2), son régime des cibles au zoom k (regimeCibles) —
* aucune chaise dessinée en régime table —, son conflit et sa pastille, lus
* dans conflits — null : ni conflit, ni pastille —, son nom accessible, et
* dans conflits — null : ni conflit, ni pastille —, ses trames — la sorte
* de conflit (CONFLITS, marques.js) de chaque règle violée, {regle, marque,
* jeton, forme}, dans l'ordre du moteur —, son nom accessible, et
* une ligne par siège, de 1 à la capacité, précédée de l'en-tête d'une
* table gelée. Quand toutes les tables sont en conflit, aucune n'est
* signalée — signalerConflits faux — et le bandeau énonce le diagnostic ;
@ -412,28 +420,65 @@ export function modeleDessin(
}
/**
* Le détail des conflits d'une table au tour montré (§ 7.1, § 7.3), que
* l'infobulle énonce : son numéro, le nombre de violations, et chaque règle
* violée dans l'ordre du moteur (conflitsParTable, M1), sa clé de libellé
* plan.infobulle.<règle> et ses groupes nommés — une paire de noms pour
* séparer les appartenances et nouveaux voisins, un nom seul pour nouvelle
* table. Les noms sont des données : l'infobulle pose chacun dans son nœud.
* null quand les conflits ne s'évaluent pas ou qu'il n'y en a aucun.
* Le détail des conflits d'une table au tour montré (§ 7.1, § 7.3, § 7.5),
* que l'infobulle énonce : son numéro, le nombre de violations, puis ses
* lignes, dans l'ordre des règles du moteur (conflitsParTable, M1). Chaque
* ligne porte sa règle, sa clé de libellé plan.infobulle.<règle>, une
* appartenance et ses groupes nommés. Séparer les appartenances donne une
* ligne par appartenance, dans l'ordre de sa première paire, qui nomme
* l'appartenance qu'ont en commun les deux personnes de chacune de ses
* paires — le moteur ne réunit que des personnes de même appartenance non
* nulle, la chaîne même que la liste porte — ; nouveaux voisins et nouvelle
* table, une ligne chacune, appartenance null, une paire de noms ou un nom
* seul par groupe. Les noms et l'appartenance sont des données :
* l'infobulle pose chacun dans son nœud. null quand les conflits ne
* s'évaluent pas ou qu'il n'y en a aucun.
*
* @param {Object} etatTable EtatTable (A5)
* @param {(id: number) => string} nomDe
* @returns {null|{numero: number, nombre: number, regles: Array<{cle: string, groupes: string[][]}>}}
* @param {(id: number) => string|null} appartenanceDe
* @returns {null|{numero: number, nombre: number,
* lignes: Array<{regle: string, cle: string, appartenance: string|null, groupes: string[][]}>}}
*/
export function detailConflits({ numero, conflits }, nomDe) {
export function detailConflits({ numero, conflits }, nomDe, appartenanceDe) {
if (conflits === null || conflits.nombre === 0) return null;
return {
numero,
nombre: conflits.nombre,
regles: conflits.regles.map((regle) => ({
cle: `plan.infobulle.${regle.regle}`,
groupes: (regle.paires ?? regle.personnes.map((id) => [id])).map((groupe) => groupe.map(nomDe)),
})),
};
const lignes = [];
for (const { regle, paires, personnes } of conflits.regles) {
const cle = `plan.infobulle.${regle}`;
if (regle !== 'separerAppartenances') {
lignes.push({ regle, cle, appartenance: null, groupes: (paires ?? personnes.map((id) => [id])).map((groupe) => groupe.map(nomDe)) });
continue;
}
const parAppartenance = new Map();
for (const paire of paires) {
const appartenance = appartenanceDe(paire[0]);
if (!parAppartenance.has(appartenance)) {
const ligne = { regle, cle, appartenance, groupes: [] };
parAppartenance.set(appartenance, ligne);
lignes.push(ligne);
}
parAppartenance.get(appartenance).groupes.push(paire.map(nomDe));
}
}
return { numero, nombre: conflits.nombre, lignes };
}
/**
* La légende des conflits (§ 7.5) : la sorte de conflit (CONFLITS) de
* chaque règle active des réglages, dans l'ordre du moteur, avec la clé de
* son nom, plan.legende.<règle>, et celle de sa forme,
* plan.legende.forme.<forme>. Une règle inactive n'y paraît pas ; varier
* les appartenances, qui ne se localise pas à une table, n'a pas de sorte.
*
* @param {{separerAppartenances: boolean, nouveauxVoisins: boolean, nouvelleTable: boolean}} reglages
* @returns {Array<{regle: string, marque: string, jeton: string, forme: string, cle: string, cleForme: string}>}
*/
export function legendeConflits(reglages) {
return CONFLITS.filter(({ regle }) => reglages[regle] === true).map((sorte) => ({
...sorte,
cle: `plan.legende.${sorte.regle}`,
cleForme: `plan.legende.forme.${sorte.forme}`,
}));
}
/**

View file

@ -25,6 +25,7 @@ import { serialiser } from '../../stockage/canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { planDepuisFichier, versFichier } from '../../stockage/placements.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { CONFLITS } from '../marques.js';
import {
ECART_ANNEAU,
ECART_DESIGNATION,
@ -39,6 +40,7 @@ import {
detailConflits,
executerSurPlace,
formesTable,
legendeConflits,
libelleLongueur,
ligneListe,
modeleDessin,
@ -880,38 +882,175 @@ describe('modeleDessin : les options selection et actif marquent leur chaise, ou
// --- Le détail d'une table en conflit (I11) --------------------------------------
// L'appartenance de chaque participant de la charge, par identifiant.
const appartenancesDe = (charge) => {
const appartenances = new Map(charge.participants.map(({ id, appartenance }) => [id, appartenance]));
return (id) => appartenances.get(id) ?? null;
};
describe('detailConflits : le détail que rend le moteur, nommé (§ 7.1, § 7.3)', () => {
test('petite démonstration en conflit : chaque règle violée, ses paires ou ses personnes, dans l’ordre du moteur', () => {
// Les lignes attendues d'une table : séparer les appartenances, une ligne
// par appartenance, dans l'ordre de leur première paire ; les autres
// règles, une ligne chacune.
function lignesAttendues(table, nomDe, appartenanceDe) {
const lignes = [];
for (const regle of table.conflits.regles) {
const cle = `plan.infobulle.${regle.regle}`;
if (regle.regle !== 'separerAppartenances') {
const groupes = (regle.paires ?? regle.personnes.map((id) => [id])).map((groupe) => groupe.map(nomDe));
lignes.push({ regle: regle.regle, cle, appartenance: null, groupes });
continue;
}
for (const [a, b] of regle.paires) {
const appartenance = appartenanceDe(a);
assert.equal(appartenanceDe(b), appartenance);
const ligne = lignes.find((l) => l.regle === regle.regle && l.appartenance === appartenance);
if (ligne === undefined) lignes.push({ regle: regle.regle, cle, appartenance, groupes: [[nomDe(a), nomDe(b)]] });
else ligne.groupes.push([nomDe(a), nomDe(b)]);
}
}
return lignes;
}
test('petite démonstration en conflit : chaque règle violée, ses paires ou ses personnes, dans l’ordre du moteur ; chaque paire réunie nomme son appartenance', () => {
const charge = avecPropositions(petite('petite-conflit'));
const nomDe = nomsDe(charge);
const appartenanceDe = appartenancesDe(charge);
let vus = 0;
let separees = 0;
for (let tour = 1; tour <= charge.evenement.tours; tour += 1) {
for (const table of etatPlaces(charge, { tour, proposition: 1 }).tables) {
const detail = detailConflits(table, nomDe);
const detail = detailConflits(table, nomDe, appartenanceDe);
if (table.conflits.nombre === 0) {
assert.equal(detail, null);
continue;
}
vus += 1;
assert.deepEqual(detail, {
numero: table.numero,
nombre: table.conflits.nombre,
regles: table.conflits.regles.map((regle) => ({
cle: `plan.infobulle.${regle.regle}`,
groupes: (regle.paires ?? regle.personnes.map((id) => [id])).map((groupe) => groupe.map(nomDe)),
})),
});
for (const { cle } of detail.regles) assert.ok(typeof t(cle) === 'string' && t(cle).length > 0, cle);
assert.deepEqual(detail, { numero: table.numero, nombre: table.conflits.nombre, lignes: lignesAttendues(table, nomDe, appartenanceDe) });
for (const ligne of detail.lignes) {
assert.ok(typeof t(ligne.cle) === 'string' && t(ligne.cle).length > 0, ligne.cle);
if (ligne.regle === 'separerAppartenances') {
separees += 1;
assert.equal(typeof ligne.appartenance, 'string');
}
}
}
}
assert.ok(vus > 0, 'au moins une table en conflit');
assert.ok(separees > 0, 'au moins une paire réunie');
});
test('séparer les appartenances : une ligne par appartenance, ses paires dans l’ordre du moteur ; l’appartenance telle que la liste la porte', () => {
const noms = new Map([[1, 'Iris Ombrelle'], [2, 'Théo Pervenche'], [3, 'Ondine Lacasse'], [4, 'Grisaille'], [5, 'Basile Tourmaline']]);
const appartenances = new Map([[1, 'Club des Merles'], [2, 'cercle des Hérons '], [3, 'Club des Merles'], [4, 'cercle des Hérons '], [5, 'Club des Merles']]);
const table = {
numero: 7,
conflits: {
nombre: 6,
regles: [
{ regle: 'separerAppartenances', paires: [[1, 3], [1, 5], [2, 4], [3, 5]] },
{ regle: 'nouveauxVoisins', paires: [[1, 2]] },
{ regle: 'nouvelleTable', personnes: [4] },
],
},
};
const detail = detailConflits(table, (id) => noms.get(id), (id) => appartenances.get(id));
assert.deepEqual(detail, {
numero: 7,
nombre: 6,
lignes: [
{
regle: 'separerAppartenances',
cle: 'plan.infobulle.separerAppartenances',
appartenance: 'Club des Merles',
groupes: [['Iris Ombrelle', 'Ondine Lacasse'], ['Iris Ombrelle', 'Basile Tourmaline'], ['Ondine Lacasse', 'Basile Tourmaline']],
},
{ regle: 'separerAppartenances', cle: 'plan.infobulle.separerAppartenances', appartenance: 'cercle des Hérons ', groupes: [['Théo Pervenche', 'Grisaille']] },
{ regle: 'nouveauxVoisins', cle: 'plan.infobulle.nouveauxVoisins', appartenance: null, groupes: [['Iris Ombrelle', 'Théo Pervenche']] },
{ regle: 'nouvelleTable', cle: 'plan.infobulle.nouvelleTable', appartenance: null, groupes: [['Grisaille']] },
],
});
});
test('conflits non évalués, ou nuls : null', () => {
const charge = petite();
const [table] = etatPlaces(charge, { tour: 1 }).tables;
assert.equal(detailConflits({ ...table, conflits: null }, nomsDe(charge)), null);
assert.equal(detailConflits({ ...table, conflits: { nombre: 0, regles: [] } }, nomsDe(charge)), null);
assert.equal(detailConflits({ ...table, conflits: null }, nomsDe(charge), appartenancesDe(charge)), null);
assert.equal(detailConflits({ ...table, conflits: { nombre: 0, regles: [] } }, nomsDe(charge), appartenancesDe(charge)), null);
});
});
// --- Les trois sortes de conflit : trames et légende ----------------------------
describe('les trames de conflit : une par règle violée, sa marque et son jeton (§ 7.5, § 12.7)', () => {
const PAR_REGLE = new Map(CONFLITS.map((c) => [c.regle, c]));
test('petite démonstration en conflit, chaque tour : les trames d’une table sont les règles qu’elle viole, dans l’ordre du moteur ; aucune pour une table saine', () => {
const charge = avecPropositions(petite('petite-conflit'));
const sortes = new Set();
let multiples = 0;
let saines = 0;
for (let tour = 1; tour <= charge.evenement.tours; tour += 1) {
const etat = etatPlaces(charge, { tour, proposition: 1 });
const modele = modeleDessin(etat, options(charge));
assert.equal(modele.tables.length, etat.tables.length);
etat.tables.forEach((table, i) => {
const attendu = table.conflits.regles.map(({ regle }) => ({ ...PAR_REGLE.get(regle) }));
assert.deepEqual(modele.tables[i].trames, attendu, `tour ${tour}, table ${table.id}`);
for (const { regle } of attendu) sortes.add(regle);
if (attendu.length > 1) multiples += 1;
if (attendu.length === 0) saines += 1;
});
}
assert.deepEqual([...sortes].sort(), ['nouveauxVoisins', 'nouvelleTable', 'separerAppartenances']);
assert.ok(multiples > 0, 'une table au moins viole plusieurs règles');
assert.ok(saines > 0, 'une table au moins est saine');
});
test('conflits non évalués : aucune trame', () => {
const charge = petite();
const etat = etatPlaces(charge, { tour: 1 });
const modele = modeleDessin({ ...etat, tables: etat.tables.map((table) => ({ ...table, conflits: null })) }, options(charge));
assert.ok(modele.tables.length > 0);
assert.ok(modele.tables.every(({ trames, conflit, pastille }) => trames.length === 0 && !conflit && pastille === 0));
});
});
describe('legendeConflits : les règles actives, leur marque et leur libellé (§ 7.5)', () => {
const reglages = (actives) => ({
separerAppartenances: actives.includes('separerAppartenances'),
nouveauxVoisins: actives.includes('nouveauxVoisins'),
nouvelleTable: actives.includes('nouvelleTable'),
varierAppartenances: true,
attribuerSieges: false,
});
const entree = (regle) => {
const { marque, jeton, forme } = CONFLITS.find((c) => c.regle === regle);
return { regle, marque, jeton, forme, cle: `plan.legende.${regle}`, cleForme: `plan.legende.forme.${forme}` };
};
test('les trois règles actives, dans l’ordre du moteur', () => {
assert.deepEqual(legendeConflits(reglages(['nouvelleTable', 'separerAppartenances', 'nouveauxVoisins'])), [
entree('separerAppartenances'),
entree('nouveauxVoisins'),
entree('nouvelleTable'),
]);
});
test('une règle inactive n’y paraît pas ; aucune règle active, une légende vide ; varier les appartenances ne se localise pas', () => {
assert.deepEqual(legendeConflits(reglages(['separerAppartenances', 'nouvelleTable'])), [entree('separerAppartenances'), entree('nouvelleTable')]);
assert.deepEqual(legendeConflits(reglages(['nouveauxVoisins'])), [entree('nouveauxVoisins')]);
assert.deepEqual(legendeConflits(reglages([])), []);
});
test('chaque libellé de la légende existe', () => {
for (const { regle, forme } of CONFLITS) {
assert.ok(t(`plan.legende.${regle}`).length > 0, regle);
assert.ok(t(`plan.legende.forme.${forme}`).length > 0, forme);
}
for (const cle of ['plan.legende.titre', 'plan.legende.ouvrir', 'plan.legende.pastille', 'plan.legende.pastilleExemple', 'plan.legende.aucune']) {
assert.ok(t(cle).length > 0, cle);
}
});
});
@ -1066,5 +1205,6 @@ describe('les clés que les panneaux composent existent (§ 14.6)', () => {
for (const action of actions) assert.equal(typeof t(`plan.action.${action}`), 'string', action);
for (const action of menu) assert.equal(typeof t(`plan.menu.${action}`), 'string', action);
for (const regle of ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']) assert.equal(typeof t(`plan.infobulle.${regle}`), 'string');
assert.equal(typeof t('plan.infobulle.finAppartenance'), 'string');
});
});

View file

@ -128,6 +128,18 @@ export function nomDeCharge(charge) {
return (id) => noms.get(id);
}
/**
* La fonction appartenanceDe de l'infobulle (detailConflits) : l'identifiant
* d'un participant de la charge vers son appartenance, la chaîne même que la
* liste porte, ou null — sans appartenance, ou pour un identifiant que la
* charge n'a pas.
* @returns {(id: number) => string|null}
*/
export function appartenanceDeCharge(charge) {
const appartenances = new Map(charge.participants.map(({ id, appartenance }) => [id, appartenance]));
return (id) => appartenances.get(id) ?? null;
}
/**
* Ce que la barre de vue dit du placement que le plan montre (§ 2.1, étape
* 11 ; § 5.8), lu sur l'état des places du tour : le placement retenu, les

View file

@ -24,6 +24,7 @@ import {
import { creerCharge } from '../../stockage/document.js';
import {
FACTEUR_BOUTON,
appartenanceDeCharge,
cadreDepuisRect,
englobantDuTour,
nomDeCharge,
@ -249,6 +250,15 @@ describe('l’englobant d’un tour, le tour montré, les noms', () => {
assert.equal(nomDe(1), 'Iris Ombrelle');
assert.equal(nomDe(2), 'Grisaille');
});
test('appartenanceDeCharge : l’appartenance de chaque participant telle que la liste la porte, null sans appartenance ni participant', () => {
const charge = chargeDEpreuve();
charge.participants[0].appartenance = ' Cercle des Hérons';
const appartenanceDe = appartenanceDeCharge(charge);
assert.equal(appartenanceDe(1), ' Cercle des Hérons');
assert.equal(appartenanceDe(2), null);
assert.equal(appartenanceDe(99), null);
});
});
describe('sourceMontree : ce que la barre de vue dit du placement montré (§ 2.1 étape 11, § 5.8)', () => {

View file

@ -57,7 +57,7 @@ import {
} from '../../test/navigateur/rendu.js';
import { provenanceVisible } from './apropos.js';
import { TABLES_INTERFACE } from './libelles.js';
import { MARQUES } from './marques.js';
import { CONFLITS, MARQUES } from './marques.js';
const { t: tTemoin } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue: 'temoin' });
const { ouverture: OUVERTURE, fermeture: FERMETURE } = MARQUE_TEMOIN;
@ -78,8 +78,9 @@ const TEXTES_NON_PEINTS = new Set(['script', 'style', 'template', 'noscript', 't
// Conteneurs SVG qui ne peignent qu'à travers une référence.
const CONTENEURS_DE_REFERENCE = 'defs, pattern, clipPath, mask, marker, symbol, linearGradient, radialGradient';
// Les marques que la teinte du conflit peint seules (§ 12.7, § 7.5).
const MARQUES_DU_CONFLIT = new Set(['contourConflit', 'trameConflit']);
// Les trames de conflit, et le jeton que chacune peint seule (§ 12.7, § 7.5) :
// une teinte de conflit ne peint que la trame de sa règle.
const JETON_DE_TRAME = new Map(CONFLITS.map(({ marque, jeton }) => [marque, jeton]));
const PICTOGRAPHIQUE = /\p{Extended_Pictographic}/u;
const LETTRE = /\p{L}/u;
@ -651,27 +652,49 @@ function peinturesDe(element) {
return peintures;
}
/** Fautes de la teinte réservée sous racine (§ 12.7, § 7.5) : un élément
* rendu qui peint la couleur de conflit sans être une marque de conflit.
* → { fautes, lus }, lus le nombre d'éléments rendus, hors marques de
* conflit, qui peignent au moins une couleur. */
export function fautesTeinte(racine, conflit) {
/** Fautes des teintes réservées sous racine (§ 12.7, § 7.5) : un élément
* rendu qui peint une teinte de conflit sans être la trame de sa règle —
* aucun autre élément, ni la trame d'une autre règle. teintes : [{ jeton,
* couleur }], la couleur résolue de chaque jeton de conflit dans le thème
* courant. → { fautes, lus }, lus le nombre d'éléments rendus qui peignent
* au moins une couleur. */
export function fautesTeinte(racine, teintes) {
const fautes = [];
let lus = 0;
for (const element of [racine, ...racine.querySelectorAll('*')]) {
if (element.closest(CONTENEURS_DE_REFERENCE) !== null || !rendu(element)) continue;
if (MARQUES_DU_CONFLIT.has(element.dataset?.marque)) continue;
const sienne = JETON_DE_TRAME.get(element.dataset?.marque) ?? null;
const peintures = peinturesDe(element);
if (peintures.some(({ couleurs }) => couleurs.length > 0)) lus += 1;
for (const { propriete, couleurs } of peintures) {
if (couleurs.some((c) => egales(c, conflit))) fautes.push(`${situer(element)} : ${propriete} ${rgb(conflit)}`);
for (const { jeton, couleur } of teintes) {
if (jeton !== sienne && couleurs.some((c) => egales(c, couleur))) fautes.push(`${situer(element)} : ${propriete} ${rgb(couleur)} (${jeton})`);
}
}
}
return { fautes, lus };
}
/** Les teintes de conflit du thème courant : [{ jeton, couleur }]. */
const teintesDeConflit = () => CONFLITS.map(({ jeton }) => ({ jeton, couleur: couleurDeJeton(jeton) }));
// --- Jamais la seule couleur ------------------------------------------------------------
// La géométrie d'un motif, sans ses couleurs : sa transformation, son
// carreau, et chacune de ses formes — balise, attributs de géométrie,
// épaisseur de trait. Deux trames qui ne diffèrent que par la couleur de
// leur motif ont la même.
const GEOMETRIE = ['x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'width', 'height', 'd'];
function formeDuMotif(element) {
const id = /^url\("?#([^")]+)"?\)/.exec(getComputedStyle(element).fill)?.[1];
const motif = id === undefined ? null : element.ownerDocument.getElementById(id);
if (motif === null) return 'motif absent';
const formes = [...motif.querySelectorAll('*')].map((n) =>
[n.localName, ...GEOMETRIE.map((a) => n.getAttribute(a) ?? ''), getComputedStyle(n).strokeWidth].join(' '),
);
return `motif ${motif.getAttribute('patternTransform') ?? ''} ${motif.getAttribute('width')}×${motif.getAttribute('height')} [${formes.join(', ')}]`;
}
/**
* La signature non chromatique d'un état : pour chaque élément rendu des
* racines, hors des conteneurs de référence, sa balise, l'épaisseur et les
@ -692,7 +715,7 @@ export function signature(racines) {
[
n.localName,
svg && s.stroke !== 'none' ? `trait ${s.strokeWidth} ${s.strokeDasharray}` : '',
svg ? `remplissage ${s.fill === 'none' ? 'aucun' : /^url\(/.test(s.fill) ? 'motif' : 'aplat'}` : '',
svg ? `remplissage ${s.fill === 'none' ? 'aucun' : /^url\(/.test(s.fill) ? formeDuMotif(n) : 'aplat'}` : '',
svg ? '' : `bords ${s.borderTopStyle} ${s.borderTopWidth}`,
texte,
].join('|'),
@ -707,7 +730,7 @@ export function signature(racines) {
// signature, classé par l'état des places que l'application calcule.
function releverEtats(m) {
const racine = m.racine;
const etats = { saine: [], conflit: [], libre: [], reservee: [], nonPourvu: [], tour: [], tous: [] };
const etats = { saine: [], conflit: [], libre: [], reservee: [], nonPourvu: [], tour: [], tous: [], trames: Object.fromEntries(CONFLITS.map(({ marque }) => [marque, []])) };
const charge = m.seance.etat().charge;
if (charge === null || racine.querySelector('[data-plan]') === null) return etats;
const { tour, proposition } = m.ui.etat();
@ -718,6 +741,10 @@ function releverEtats(m) {
const pastille = racine.querySelector(`[data-pastille="${table.id}"]`);
const sig = signature([...propres, ...(pastille === null ? [] : [pastille])]);
etats[(table.conflits?.nombre ?? 0) > 0 ? 'conflit' : 'saine'].push({ ou: `table ${table.id}`, sig });
// Chaque trame de conflit, par sa sorte.
for (const trame of groupe.querySelectorAll(CONFLITS.map(({ marque }) => `[data-marque="${marque}"]`).join(', '))) {
if (rendu(trame)) etats.trames[trame.dataset.marque].push({ ou: `trame de la table ${table.id}`, sig: signature([trame]) });
}
for (const place of table.chaises) {
const element = racine.querySelector(`[data-siege="${table.id}:${place.siege}"]`);
if (element === null) continue;
@ -829,7 +856,7 @@ function releverTheme(racine) {
try {
const textes = releverTextes(racine);
const marques = releverMarques(racine);
return { textes, marques, teinte: fautesTeinte(racine, couleurDeJeton('--couleur-conflit')) };
return { textes, marques, teinte: fautesTeinte(racine, teintesDeConflit()) };
} finally {
window.scrollTo(0, 0);
}
@ -942,10 +969,17 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
expect(fautes[2]).toContain('1.00:1');
});
test('teinte réservée : un texte peint de la couleur de conflit est relevé', () => {
const { fautes, lus } = avecFragment('<p style="color:var(--couleur-conflit)">alerte</p>', (c) => fautesTeinte(c, couleurDeJeton('--couleur-conflit')));
expect(lus).toBeGreaterThan(0);
expect(fautes).toHaveLength(1);
test('teintes réservées : un texte peint d’une teinte de conflit, et la trame d’une règle peinte de la teinte d’une autre, sont relevés ; une trame de sa propre teinte passe', () => {
const { fautes, lus } = avecFragment(
'<p style="color:var(--couleur-conflit-voisins)">alerte</p>' +
'<svg width="80" height="20"><rect data-marque="trameAppartenance" width="20" height="20" style="fill:var(--couleur-conflit-table)" />' +
'<rect data-marque="trameTable" x="40" width="20" height="20" style="fill:var(--couleur-conflit-table)" /></svg>',
(c) => fautesTeinte(c, teintesDeConflit()),
);
expect(lus).toBeGreaterThan(2);
expect(fautes).toHaveLength(2);
expect(fautes[0]).toContain('--couleur-conflit-voisins');
expect(fautes[1]).toMatch(/trameAppartenance.*--couleur-conflit-table/);
});
test('listes : une partie qui traverse une chaise loin de son centre est relevée ; une boîte qui n’entre que dans le coin d’un cercle passe', () => {
@ -1010,6 +1044,20 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
expect(fautesPaire('vide', [], [{ ou: 'b', sig: '' }])).toHaveLength(1);
});
test('jamais la seule couleur : deux trames dont les motifs ne diffèrent que par la couleur ont la même signature ; deux formes de motif, deux signatures', () => {
const motif = (id, angle, teinte) =>
`<pattern id="${id}" patternUnits="userSpaceOnUse" width="12" height="12" patternTransform="rotate(${angle})">` +
`<line x1="6" y1="0" x2="6" y2="12" style="stroke:${teinte};stroke-width:4" /></pattern>`;
const sigs = avecFragment(
`<svg width="120" height="40"><defs>${motif('m-a', -45, '#a00')}${motif('m-b', -45, '#0a0')}${motif('m-c', 45, '#a00')}</defs>` +
'<rect id="a" width="30" height="30" style="fill:url(#m-a)" /><rect id="b" x="40" width="30" height="30" style="fill:url(#m-b)" />' +
'<rect id="c" x="80" width="30" height="30" style="fill:url(#m-c)" /></svg>',
(c) => ['a', 'b', 'c'].map((id) => signature([c.querySelector(`#${id}`)])),
);
expect(sigs[0]).toBe(sigs[1]);
expect(sigs[0]).not.toBe(sigs[2]);
});
test('bandeau et formulaires : une version absente, un formulaire validé par le navigateur et un champ de fichier visible sont relevés', () => {
const bandeau = avecFragment('<header data-bandeau>Nom</header>', (c) => fautesBandeau(c, null, { version: 'version-epreuve', provenance: 'épreuve' }));
expect(bandeau).toHaveLength(2);
@ -1146,7 +1194,7 @@ describe('chaque écran de l’itération (§ 14.5, § 14.6, § 19.11)', () => {
aucuneFaute([...new Set(fautes)], 'atténuations');
});
test('jamais la seule couleur : table saine et en conflit, place libre, réservée et à titre non pourvu, portée tour et tous, cadre en lecture, en écriture et bloqué', () => {
test('jamais la seule couleur : table saine et en conflit, les trois sortes de conflit, place libre, réservée et à titre non pourvu, portée tour et tous, cadre en lecture, en écriture et bloqué', () => {
const plan = atteints().find((r) => r.nom === 'evenement.plan.ecriture');
expect(plan, 'le plan en écriture relevé').toBeDefined();
const e = plan.etats;
@ -1157,6 +1205,11 @@ describe('chaque écran de l’itération (§ 14.5, § 14.6, § 19.11)', () => {
...fautesPaire('place réservée / titre non pourvu', e.reservee, e.nonPourvu),
...fautesPaire('portée tour / tous', e.tour, e.tous),
];
// Les trois sortes de conflit, sur le plan qui les montre ensemble.
const trois = atteints().find((r) => r.nom === 'plan.troisConflits');
expect(trois, 'le plan des trois sortes de conflit relevé').toBeDefined();
const [a, b, c] = CONFLITS.map(({ marque }) => trois.etats.trames[marque]);
fautes.push(...fautesPaire('trame appartenance / voisins', a, b), ...fautesPaire('trame appartenance / table', a, c), ...fautesPaire('trame voisins / table', b, c));
const cadres = ['lecture', 'ecriture', 'bloque'].map((mode) => {
const r = atteints().find((x) => x.nom === `evenement.plan.${mode}`);
return { ou: `cadre ${mode}`, sig: r?.cadre ?? null };
@ -1168,12 +1221,12 @@ describe('chaque écran de l’itération (§ 14.5, § 14.6, § 19.11)', () => {
aucuneFaute(fautes, 'jamais la seule couleur');
});
test('teinte réservée : la couleur calculée de conflit ne peint que les marques de conflit, dans les deux thèmes', () => {
test('teintes réservées : chaque teinte de conflit calculée ne peint que la trame de sa règle, dans les deux thèmes', () => {
for (const theme of THEMES) {
expect(atteints().reduce((n, r) => n + r[theme].teinte.lus, 0), `éléments peints lus [${theme}]`).toBeGreaterThan(0);
}
const fautes = atteints().flatMap((r) => THEMES.flatMap((theme) => r[theme].teinte.fautes.map((f) => `${r.nom} [${theme}] — ${f}`)));
aucuneFaute([...new Set(fautes)], 'teinte réservée');
aucuneFaute([...new Set(fautes)], 'teintes réservées');
});
test('aucun emoji au rendu', () => {

View file

@ -453,6 +453,31 @@ export const ECRANS = Object.freeze([
return m.racine;
},
},
{
nom: 'plan.troisConflits',
mode: 'ecriture',
async atteindre(contexte) {
// La petite démonstration en conflit et une proposition, graine 3,
// montrée au tour 3, choisi au clavier : la table 1 viole les trois
// règles et superpose leurs trames, la table 2 est saine ; puis la
// légende des conflits, ouverte par la barre de vue.
let charge = demonstration('petite-conflit');
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 3, nombre: 1, arret: 300, historique: 1000 });
charge = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge;
const m = await ouvrirEvenement(contexte, charge, { ecriture: true, section: 'plan' });
const choix = await element(m, '[data-champ="tour"]');
choix.focus();
for (let essai = 0; essai < choix.options.length && choix.value !== '3'; essai += 1) {
await touche('ArrowDown');
flushSync();
}
for (const marque of ['trameAppartenance', 'trameVoisins', 'trameTable']) await element(m, `[data-table="1"] [data-marque="${marque}"]`);
await cliquer(m, '[data-commande="legende"]');
await element(m, '[data-legende] [data-legende-regle="nouvelleTable"]');
return m.racine;
},
},
{
nom: 'participants.refusChamp',
mode: 'ecriture',

View file

@ -427,7 +427,11 @@ function localisateur(racine) {
// --- Garde 4 : les couleurs (§ 12.7, § 13.3, § 13.5) --------------------------
const FEUILLE_DES_JETONS = 'src/interface/jetons.css';
const JETON_CONFLIT = '--couleur-conflit';
// La famille des teintes de conflit (§ 7.5, § 12.7) : --couleur-conflit et
// chaque --couleur-conflit-…, une par règle que le plan localise ; un nom
// qui les prolonge sans tiret n'en est pas.
const JETON_CONFLIT = /--couleur-conflit(?:-[\w-]+)?(?![\w-])/;
const litConflit = (valeur) => JETON_CONFLIT.test(valeur);
// Les couleurs nommées de CSS, et les couleurs du système : chacune est un
// littéral. transparent et currentColor ne portent aucune teinte et restent
@ -530,8 +534,8 @@ function regleDeConflit(chemin, prelude) {
// Relevé des couleurs : littéral de couleur dans une feuille, un attribut
// style ou de couleur, une directive style:, ou une chaîne du script d'un
// composant ; déclaration de jeton hors de la feuille des jetons ; lecture du
// jeton de conflit hors des règles de conflit du plan. Les composants de
// composant ; déclaration de jeton hors de la feuille des jetons ; lecture
// d'une teinte de conflit hors des règles de conflit du plan. Les composants de
// src/interface et les feuilles .css de src/ sont lus, la feuille des jetons
// exceptée.
function releverCouleurs(racine) {
@ -549,7 +553,7 @@ function releverCouleurs(racine) {
const couleur = couleurLitterale(valeur);
if (couleur !== null) relever(debut, `couleur ${extrait(couleur)} dans ${propriete}`);
if (propriete.startsWith('--')) relever(debut, `jeton déclaré ${propriete}`);
if (valeur.includes(JETON_CONFLIT) && (enLigne || !regleDeConflit(chemin, prelude))) {
if (litConflit(valeur) && (enLigne || !regleDeConflit(chemin, prelude))) {
relever(debut, `jeton de conflit hors d'une règle de conflit du plan, ${enLigne ? 'en ligne' : extrait(selecteur)}`);
}
};
@ -581,7 +585,7 @@ function releverCouleurs(racine) {
for (const { valeur, debut } of valeurs) {
const couleur = couleurLitterale(valeur);
if (couleur !== null) relever(debut, `couleur ${extrait(couleur)} dans ${noeud.name}`);
if (valeur.includes(JETON_CONFLIT)) relever(debut, "jeton de conflit hors d'une règle de conflit du plan, en ligne");
if (litConflit(valeur)) relever(debut, "jeton de conflit hors d'une règle de conflit du plan, en ligne");
}
}
}
@ -594,7 +598,7 @@ function releverCouleurs(racine) {
if (COULEUR_ENTIERE.test(valeur)) {
relever(noeud.start, `couleur ${extrait(valeur)} dans le script`);
}
if (valeur.includes(JETON_CONFLIT)) relever(noeud.start, "jeton de conflit hors d'une règle de conflit du plan, dans le script");
if (litConflit(valeur)) relever(noeud.start, "jeton de conflit hors d'une règle de conflit du plan, dans le script");
});
}
}
@ -890,8 +894,8 @@ const composant = ({ script = '', gabarit = '', style = '' }) =>
// Une feuille des jetons minimale, ses deux thèmes complets.
const JETONS_EPREUVE = [
':root, :root[data-theme="clair"] { --couleur-a: #ffffff; --couleur-conflit: #b3261e; }',
":root[data-theme='sombre'] { --couleur-a: #000000; --couleur-conflit: #ff8a80; }",
':root, :root[data-theme="clair"] { --couleur-a: #ffffff; --couleur-conflit: #b3261e; --couleur-conflit-voisins: #c0189f; --couleur-conflit-table: #6a9400; }',
":root[data-theme='sombre'] { --couleur-a: #000000; --couleur-conflit: #ff8a80; --couleur-conflit-voisins: #c0689c; --couleur-conflit-table: #549c00; }",
'',
].join('\n');
@ -1158,6 +1162,33 @@ describe('sources de l’interface : les couleurs (§ 12.7, § 13.3, § 13.5)',
]);
});
test('la garde relève chaque teinte de conflit — toute la famille --couleur-conflit-… — lue hors d’une règle de conflit du plan, et l’admet dans une règle de conflit du plan', () => {
const fichiers = {
'src/interface/jetons.css': JETONS_EPREUVE,
'src/interface/accueil/Alerte.svelte': composant({
script: " const teinte = 'var(--couleur-conflit-table)';",
gabarit: '<p class="alerte">x</p>',
style: ' .alerte { color: var(--couleur-conflit-voisins); }',
}),
'src/interface/plan/Motif.svelte': composant({
gabarit: '<svg><line class="trait voisins-conflit" /><line class="trait table-conflit" /><circle class="trait" style="fill: var(--couleur-conflit-table)" /></svg>',
style: [
' .trait.voisins-conflit { stroke: var(--couleur-conflit-voisins); }',
' .trait.table-conflit { stroke: var(--couleur-conflit-table); }',
' .trait { fill: var(--couleur-conflit-voisins); }',
' .conflit-voisins { fill: var(--couleur-conflit-table); }',
].join('\n'),
}),
};
assert.deepEqual(avecArbre(fichiers, releverCouleurs), [
"src/interface/accueil/Alerte.svelte:2 jeton de conflit hors d'une règle de conflit du plan, dans le script",
"src/interface/accueil/Alerte.svelte:6 jeton de conflit hors d'une règle de conflit du plan, « .alerte »",
"src/interface/plan/Motif.svelte:1 jeton de conflit hors d'une règle de conflit du plan, en ligne",
"src/interface/plan/Motif.svelte:5 jeton de conflit hors d'une règle de conflit du plan, « .trait »",
"src/interface/plan/Motif.svelte:6 jeton de conflit hors d'une règle de conflit du plan, « .conflit-voisins »",
]);
});
test('la garde des thèmes relève un jeton qu’un thème ne redéfinit pas', () => {
const fichiers = {
'src/interface/jetons.css': [