From 8afa53ebba7b4a340e0e92d0c9e3cafa6156d84b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 8 Oct 2026 04:33:43 -0400 Subject: [PATCH] [ADD] plan: one hue and pattern per conflict rule, legend, appartenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- spec.md | 25 ++- src/interface/jetons.css | 22 ++- src/interface/libelles/plan.js | 27 ++- src/interface/marques.js | 42 ++++- src/interface/marques.test.js | 67 ++++++- src/interface/palette.js | 111 +++++++++--- src/interface/palette.test.js | 121 ++++++++++--- src/interface/plan/BarreVue.svelte | 17 +- src/interface/plan/Dessin.navigateur.test.js | 151 +++++++++++++++- src/interface/plan/Infobulle.svelte | 14 +- src/interface/plan/Legende.svelte | 126 +++++++++++++ src/interface/plan/Motifs.svelte | 72 ++++++-- .../plan/Panneaux.navigateur.test.js | 69 ++++++++ src/interface/plan/Plan.svelte | 38 +++- src/interface/plan/TablePlan.svelte | 32 ++-- src/interface/plan/modele.js | 81 +++++++-- src/interface/plan/modele.test.js | 166 ++++++++++++++++-- src/interface/plan/vue_plan.js | 12 ++ src/interface/plan/vue_plan.test.js | 10 ++ src/interface/transversal.navigateur.test.js | 93 +++++++--- test/navigateur/ecrans.js | 25 +++ test/sources_interface.test.js | 47 ++++- 22 files changed, 1203 insertions(+), 165 deletions(-) create mode 100644 src/interface/plan/Legende.svelte diff --git a/spec.md b/spec.md index 6bb76aa..e7fb2cc 100644 --- a/spec.md +++ b/spec.md @@ -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. diff --git a/src/interface/jetons.css b/src/interface/jetons.css index c91cc15..4545524 100644 --- a/src/interface/jetons.css +++ b/src/interface/jetons.css @@ -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; diff --git a/src/interface/libelles/plan.js b/src/interface/libelles/plan.js index 8a70e84..a2f0b9f 100644 --- a/src/interface/libelles/plan.js +++ b/src/interface/libelles/plan.js @@ -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'], diff --git a/src/interface/marques.js b/src/interface/marques.js index 87b03c3..44ba41d 100644 --- a/src/interface/marques.js +++ b/src/interface/marques.js @@ -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']), diff --git a/src/interface/marques.test.js b/src/interface/marques.test.js index 2d06db1..6fe3398 100644 --- a/src/interface/marques.test.js +++ b/src/interface/marques.test.js @@ -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), []); + }); }); diff --git a/src/interface/palette.js b/src/interface/palette.js index a82acaf..1c041da 100644 --- a/src/interface/palette.js +++ b/src/interface/palette.js @@ -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; diff --git a/src/interface/palette.test.js b/src/interface/palette.test.js index 5f18d76..2dbcfa3 100644 --- a/src/interface/palette.test.js +++ b/src/interface/palette.test.js @@ -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); }); }); diff --git a/src/interface/plan/BarreVue.svelte b/src/interface/plan/BarreVue.svelte index 8790cb3..07b81ad 100644 --- a/src/interface/plan/BarreVue.svelte +++ b/src/interface/plan/BarreVue.svelte @@ -2,9 +2,9 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> + +
+ +

{t('plan.legende.titre')}

+ {#if entrees.length === 0} +

{t('plan.legende.aucune')}

+ {/if} + + {#if entrees.length > 1} +

{t('plan.legende.superposition')}

+ {/if} +
+ + diff --git a/src/interface/plan/Motifs.svelte b/src/interface/plan/Motifs.svelte index c6178ae..662bc4a 100644 --- a/src/interface/plan/Motifs.svelte +++ b/src/interface/plan/Motifs.svelte @@ -5,11 +5,24 @@
- + (legendeOuverte = !legendeOuverte)} + {idLegende} + /> + {#if legendeOuverte} + + {/if}