[IMP] quality page: deferred charts, cached text, hash-free gestures

Opening the quality tab measured text and forced layout per chart, and every
gesture rehashed each placement because a cloned load lost its fingerprints.
A gesture now inherits the fingerprint of an unchanged placement; charts far
from view draw on approach, holding their drawn height and their legend, so
a link or a scroll lands where the fully drawn page puts it.
Checked, 200 propositions: tab opening 84.5 -> 27.1 ms (limit 50), 4x CPU
370.7 -> 113.9 ms, 1,973 -> 485 elements, a gesture back at its prior cost.
Every memePlacement mutant fails its own test; 44 captures byte-identical.

--- FR ---

[IMP] page qualité : graphiques différés, texte en cache, gestes légers

L'onglet Qualité mesurait le texte et forçait la mise en page à chaque
graphique, et chaque geste rehachait chaque placement, la charge clonée
perdant ses empreintes. Un geste hérite de l'empreinte d'un placement
inchangé ; un graphique loin de la vue se dessine à l'approche, sa hauteur
et sa légende déjà posées : un lien ou un défilement arrive où le met la
page entièrement dessinée.
Vérifié, 200 propositions : ouverture 84,5 -> 27,1 ms (limite 50), CPU 4x
370,7 -> 113,9 ms, 1 973 -> 485 éléments, un geste revenu à son coût.
Chaque mutant de memePlacement fait échouer son test ; 44 captures identiques.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-12 22:31:46 -04:00
parent 91a0cea4cb
commit 2cb1e771a2
24 changed files with 1162 additions and 132 deletions

View file

@ -102,7 +102,7 @@ import {
requeteHorsBoucle,
} from '../../test/navigateur/captures.js';
import { NOM_CAS_LIMITES, attendre, centre, octetsCasLimites } from '../../test/navigateur/ecrans.js';
import { fixerCadre, pointer, touche } from '../../test/navigateur/entrees.js';
import { dessinerGraphiques, fixerCadre, pointer, touche } from '../../test/navigateur/entrees.js';
import { poserFeuille } from '../../test/navigateur/feuilles.js';
import { monterApp } from '../../test/navigateur/montage.js';
import { visible } from '../../test/navigateur/rendu.js';
@ -588,10 +588,13 @@ function enHaut(element) {
// L'élément du document qu'atteint le point (x, y) de la fenêtre.
const atteintAuPoint = ({ x, y }) => document.elementFromPoint(x, y);
// Ouvre une section de l'événement par sa navigation.
// Ouvre une section de l'événement par sa navigation. La page de qualité
// ne dessine que les graphiques proches de la zone visible : chacun s'y
// amène à son tour (dessinerGraphiques), et l'épreuve lit la page entière.
async function ouvrirSection(nom) {
await cliquer(`[data-commande="section"][data-cible="${nom}"]`);
await attendre(`la section ${nom}`, () => dans(`[data-section="${nom}"]`));
if (nom === 'qualite') await dessinerGraphiques(dans('[data-section="qualite"]'));
}
// Ouvre le plan et l'ajuste au tour montré : toutes les tables dans le cadre.
@ -2950,6 +2953,7 @@ async function qualiteDeLaPremiere() {
await appuyer(premiere.querySelector('[data-commande="qualite"]'), 'la commande « Qualité »');
await attendre('la page de qualité de la proposition', () => dans('[data-section="qualite"] [data-vue-qualite="performance"]') && m.ui.etat().proposition === id);
await images(3);
await dessinerGraphiques(dans('[data-section="qualite"]'));
return id;
}
@ -3115,6 +3119,7 @@ async function revenirALaQualite(id) {
await ouvrirSection('qualite');
await attendre('la page de qualité', () => vueQualite('performance') && m.ui.etat().proposition === id);
await images(3);
await dessinerGraphiques(dans('[data-section="qualite"]'));
}
// La page mesure la proposition id que la séance montre, avant toute

View file

@ -24,7 +24,7 @@ import { serialiser } from '../../stockage/canonique.js';
import { configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { dessinerGraphiques, fixerCadre } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { creerEtatUi } from '../etat_ui.js';
import { TABLES_INTERFACE } from '../libelles.js';
@ -96,6 +96,10 @@ async function attendre(assertion) {
return assertion();
} catch (erreur) {
if (essai >= 200) throw erreur;
// Un graphique de la page de qualité que l'assertion lit attend
// peut-être d'approcher de la zone visible : chacun se dessine, puis
// elle se relit.
await dessinerGraphiques(document.body);
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}

View file

@ -5,6 +5,8 @@
// d'une ligne de liste et la largeur d'une chaîne, relevées sur la police
// rendue, jamais déduites de la taille de police. La hauteur se mesure dans
// un OffscreenCanvas, hors du document : le plan ne porte aucun <canvas>.
// Les graphiques de la page de qualité mesurent par mesuresDuTexte, qui
// garde les largeurs pour la séance et les relève par lots.
import { GABARIT_HAUTEUR_LIGNE } from '../../geometrie/texte.js';
@ -66,3 +68,115 @@ export function creerMesureur(elementTexte) {
return largeur;
};
}
// Les mesures déjà prises pendant la séance, par style de texte (cleDuStyle)
// : { largeurs, hauteur }, largeurs une Map de la chaîne à sa largeur,
// hauteur celle de hauteurLigne, null tant qu'elle n'est pas lue. Une
// police qui finit de se charger change les largeurs sous le même style
// calculé : chaque fin de chargement du document vide la table.
const MESURES_DE_SEANCE = new Map();
let polices = null;
// La clé du style de texte de l'élément : la densité de pixels de l'écran,
// que le lissage des glyphes suit, puis chaque propriété calculée qui
// change la largeur d'une chaîne.
function cleDuStyle(elementTexte) {
const style = getComputedStyle(elementTexte);
const proprietes = [
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'fontSize',
'fontFamily',
'letterSpacing',
'wordSpacing',
'fontKerning',
'fontFeatureSettings',
'fontVariationSettings',
'textRendering',
];
return [devicePixelRatio, ...proprietes.map((nom) => style[nom])].join('|');
}
// Les mesures de séance du style de l'élément, créées vides au premier
// appel ; la première écoute aussi les fins de chargement des polices.
function mesuresDuStyle(elementTexte) {
const documentTexte = elementTexte.ownerDocument;
if (polices !== documentTexte.fonts) {
polices = documentTexte.fonts;
polices.addEventListener('loadingdone', () => MESURES_DE_SEANCE.clear());
}
const cle = cleDuStyle(elementTexte);
let connues = MESURES_DE_SEANCE.get(cle);
if (connues === undefined) {
connues = { largeurs: new Map(), hauteur: null };
MESURES_DE_SEANCE.set(cle, connues);
}
return connues;
}
/**
* Les mesures du texte d'un graphique au style de l'élément, gardées pour
* la séance : deux éléments du même style calculé, à la même densité de
* pixels, partagent leurs largeurs. L'élément est un <text> d'un dessin
* sans viewBox, dont l'unité est le pixel CSS.
*
* - mesurer(chaine) : la largeur de la chaîne, mesurée à la première
* demande ;
* - calculer(calcul) : ce que calcul(mesurer) rend, calcul une fonction
* pure de ses arguments et des largeurs lues. Les chaînes inconnues se
* mesurent par lots : calcul s'exécute avec 0 pour chacune, ce qu'il
* a demandé et que la séance ignore se mesure d'un seul relevé, puis il
* s'exécute de nouveau, jusqu'à ce qu'il ne demande plus rien
* d'inconnu. Le rendu final ne lit que des largeurs mesurées, et le
* document ne se met en page qu'une fois par lot, au lieu d'une fois par
* chaîne ;
* - hauteurTexte : hauteurLigne de l'élément, lue une fois par style.
*
* Chaque chaîne d'un lot se mesure sur sa propre copie de l'élément
* (cloneNode, sans enfant), posée juste après lui le temps du relevé puis
* retirée : même style, même contexte, aucun crénage avec une voisine. Une
* largeur nulle d'une chaîne non vide — l'élément n'est pas rendu — ne se
* garde pas, comme pour creerMesureur : elle vaut 0 pour le calcul en
* cours et se mesure de nouveau à la demande suivante.
*
* @param {Element} elementTexte
* @returns {{mesurer: (chaine: string) => number, calculer: <T>(calcul: (mesurer: (chaine: string) => number) => T) => T, hauteurTexte: number}}
*/
export function mesuresDuTexte(elementTexte) {
const connues = mesuresDuStyle(elementTexte);
// Mesure les chaînes d'un seul relevé ; garde les largeurs non nulles,
// et rend la largeur de chacune, nulle comprise.
const relever = (chaines) => {
const copies = chaines.map((chaine) => {
const copie = elementTexte.cloneNode(false);
copie.textContent = chaine;
return copie;
});
elementTexte.after(...copies);
const largeurs = copies.map((copie) => copie.getComputedTextLength());
for (const copie of copies) copie.remove();
chaines.forEach((chaine, i) => {
if (largeurs[i] > 0 || chaine === '') connues.largeurs.set(chaine, largeurs[i]);
});
return largeurs;
};
const mesurer = (chaine) => connues.largeurs.get(chaine) ?? relever([chaine])[0];
const calculer = (calcul) => {
const nulles = new Set();
for (;;) {
const inconnues = new Set();
const resultat = calcul((chaine) => {
const connue = connues.largeurs.get(chaine);
if (connue === undefined && !nulles.has(chaine)) inconnues.add(chaine);
return connue ?? 0;
});
if (inconnues.size === 0) return resultat;
relever([...inconnues]);
for (const chaine of inconnues) if (!connues.largeurs.has(chaine)) nulles.add(chaine);
}
};
if (connues.hauteur === null) connues.hauteur = hauteurLigne(elementTexte);
return { mesurer, calculer, hauteurTexte: connues.hauteur };
}

View file

@ -32,7 +32,7 @@ import { serialiser } from '../../stockage/canonique.js';
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre, touche, pointer } from '../../../test/navigateur/entrees.js';
import { dessinerGraphiques, fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { teintesAppartenances } from '../appartenances.js';
import { TABLES_INTERFACE } from '../libelles.js';
@ -232,6 +232,9 @@ async function attendre(assertion) {
return assertion();
} catch (erreur) {
if (essai >= 200) throw erreur;
// Un graphique que l'assertion lit attend peut-être d'approcher de la
// zone visible : chacun se dessine, puis elle se relit.
await dessinerGraphiques(document.body);
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
@ -264,10 +267,18 @@ const positionCochee = (m) => [...vue(m, 'tables').querySelectorAll('[data-posit
const intitulesGrille = (svg) =>
[...svg.querySelectorAll('text')].map((x) => x.textContent).filter((x) => /^Table \d+$/.test(x));
// Les cases de la grille : par ligne et colonne, le texte écrit et le
// remplissage du niveau, null sans remplissage.
// Les cases de la grille : par ligne et colonne, le texte écrit, le
// remplissage du niveau, null sans remplissage, et le côté, lu sur le
// sous-chemin de sa bordure — le chemin des bordures en porte un par case,
// dans l'ordre des cases : « M x y h c v c h -c z », de côté c + 1.
function casesDe(svg) {
return [...svg.querySelectorAll('[data-case]')].map((g) => {
const cotes = svg
.querySelector('[data-marque="graphiqueBordureCase"]')
.getAttribute('d')
.split('z')
.filter((sousChemin) => sousChemin !== '')
.map((sousChemin) => Number(/h([\d.]+)/.exec(sousChemin)[1]) + 1);
return [...svg.querySelectorAll('[data-case]')].map((g, i) => {
const [ligne, colonne] = g.dataset.case.split(':').map(Number);
const fond = [...g.querySelectorAll('rect')].find((r) => r.style.fill !== '') ?? null;
return {
@ -276,7 +287,7 @@ function casesDe(svg) {
texte: g.querySelector('text').textContent,
marque: fond?.getAttribute('data-marque') ?? null,
fond: fond === null ? null : getComputedStyle(fond).fill,
cote: Number(g.querySelector('[data-marque="graphiqueBordureCase"]').getAttribute('width')) + 1,
cote: cotes[i],
};
});
}
@ -324,7 +335,7 @@ describe('collisions d’appartenance (§ 12.4, B13, D31)', () => {
const m = await ouvrir(charge);
const collisions = await attendre(() => {
const v = vue(m, 'collisions');
expect(v.querySelector('svg[data-graphique="collisions"]')).not.toBeNull();
expect(v.querySelector('svg[data-graphique="collisions"]:not([data-dessin-differe])')).not.toBeNull();
return v;
});
const ligne = collisions.querySelector(`[data-ligne-graphique="${plancher[0].groupe}"]`);
@ -476,7 +487,7 @@ describe('appartenances à chaque table (§ 12.11, B20, D35)', () => {
test('l’infobulle d’une case : le pourcentage de la position puis ses deux comptes, occupants, affiliés, capacité', async () => {
const m = await ouvrir(chargeMelange());
const svg = await attendre(() => {
const s = vue(m, 'tables').querySelector('svg[data-graphique="tables"]');
const s = vue(m, 'tables').querySelector('svg[data-graphique="tables"]:not([data-dessin-differe])');
expect(s).not.toBeNull();
return s;
});
@ -588,7 +599,7 @@ describe('appartenances à chaque table (§ 12.11, B20, D35)', () => {
const m = await ouvrir(chargeUneAppartenance());
const tables = await attendre(() => {
const v = vue(m, 'tables');
expect(v.querySelector('svg[data-graphique="tables"]')).not.toBeNull();
expect(v.querySelector('svg[data-graphique="tables"]:not([data-dessin-differe])')).not.toBeNull();
return v;
});
expect(tables.querySelectorAll('[data-position-tables]')).toHaveLength(3);

View file

@ -28,7 +28,7 @@ import { serialiser } from '../../stockage/canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { dessinerGraphiques, fixerCadre } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { creerEtatUi } from '../etat_ui.js';
import { TABLES_INTERFACE } from '../libelles.js';
@ -115,6 +115,9 @@ async function attendre(assertion) {
return assertion();
} catch (erreur) {
if (essai >= 200) throw erreur;
// Un graphique que l'assertion lit attend peut-être d'approcher de la
// zone visible : chacun se dessine, puis elle se relit.
await dessinerGraphiques(document.body);
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}

View file

@ -30,7 +30,7 @@ import { serialiser } from '../../stockage/canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre, touche } from '../../../test/navigateur/entrees.js';
import { dessinerGraphiques, fixerCadre, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { colonneDeValeur } from '../propositions/modele.js';
@ -115,6 +115,9 @@ async function attendre(assertion) {
return assertion();
} catch (erreur) {
if (essai >= 200) throw erreur;
// Un graphique que l'assertion lit attend peut-être d'approcher de la
// zone visible : chacun se dessine, puis elle se relit.
await dessinerGraphiques(document.body);
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}

View file

@ -21,12 +21,13 @@ import { appliquerCommande } from '../../application/commandes.js';
import { evaluer, premiereDuClassement } from '../../application/evaluation.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { texteLivre } from '../../demo/livrees.js';
import { rechercher } from '../../moteur/recherche.js';
import { serialiser } from '../../stockage/canonique.js';
import { configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { dessinerGraphiques, fixerCadre } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import '../impression.css';
import { TABLES_INTERFACE } from '../libelles.js';
@ -455,6 +456,81 @@ describe('les vues d’attente et la liste qui y mène (§ 12, B9, B10)', () =>
});
});
describe('le poids de la page à son ouverture (E2s)', () => {
test('la grande, une proposition : sous 1 500 éléments à l’ouverture, chaque vue posée, les graphiques loin de la zone visible en attente ; amenés, chacun se dessine', async () => {
const m = await ouvrir(generee(analyser(texteLivre('grande')).charge, { graine: 1, arret: 200_000, nombre: 1 }));
m.ui.montrerProposition(charge(m).propositions[0].id);
flushSync();
await attendre(() => expect(vuesRendues(m)).toEqual(VUES));
for (let n = 0; n < 3; n += 1) await new Promise((resoudre) => requestAnimationFrame(resoudre));
const elements = page(m).querySelectorAll('*').length + 1;
expect(elements).toBeLessThan(1500);
const graphiques = () => [...page(m).querySelectorAll('svg[data-graphique]')];
const enAttente = graphiques().filter((svg) => svg.hasAttribute('data-dessin-differe'));
expect(enAttente.length).toBeGreaterThan(0);
for (const svg of enAttente) expect(svg.querySelector('[data-marque]')).toBeNull();
expect(await dessinerGraphiques(page(m))).toBeGreaterThan(0);
for (const svg of graphiques()) {
expect(svg.hasAttribute('data-dessin-differe'), svg.dataset.graphique).toBe(false);
expect(svg.querySelector('[data-marque]'), svg.dataset.graphique).not.toBeNull();
}
}, 60_000);
});
describe('le dessin différé ne déplace pas la vue montrée (E2s, B9)', () => {
const image = () => new Promise((resoudre) => requestAnimationFrame(resoudre));
// Le plus proche ancêtre de l'élément qui défile à la verticale.
const defileur = (element) => {
for (let ancetre = element.parentElement; ancetre !== null; ancetre = ancetre.parentElement) {
if (['auto', 'scroll'].includes(getComputedStyle(ancetre).overflowY)) return ancetre;
}
return document.scrollingElement;
};
test('la grande : le lien vers la dernière vue, puis le défilement jusqu’au bas, la posent où la page dessinée entière la pose, et 30 images n’y changent rien', async () => {
const m = await ouvrir(generee(analyser(texteLivre('grande')).charge, { graine: 1, arret: 200_000, nombre: 1 }));
m.ui.montrerProposition(charge(m).propositions[0].id);
flushSync();
await attendre(() => expect(vuesRendues(m)).toEqual(VUES));
for (let n = 0; n < 3; n += 1) await image();
const zone = defileur(page(m));
const lien = page(m).querySelector('nav a[href$="-vue-performance"]');
const cible = document.getElementById(lien.getAttribute('href').slice(1));
const haut = () => cible.getBoundingClientRect().top - zone.getBoundingClientRect().top;
// Le haut de la vue à chacune des 30 images qui suivent le geste.
async function suite(geste) {
geste();
flushSync();
const hauts = [haut()];
for (let n = 0; n < 30; n += 1) {
await image();
flushSync();
hauts.push(haut());
}
return hauts;
}
const parLeLien = () => suite(() => lien.click());
const jusquAuBas = () => suite(() => zone.scrollTo(0, zone.scrollHeight));
expect(page(m).querySelectorAll('svg[data-dessin-differe]').length).toBeGreaterThan(0);
const lienDiffere = await parLeLien();
expect(document.activeElement).toBe(cible);
const basDiffere = await jusquAuBas();
for (const hauts of [lienDiffere, basDiffere]) {
expect(new Set(hauts).size, hauts.join(', ')).toBe(1);
expect(hauts[0]).toBeGreaterThanOrEqual(0);
expect(hauts[0]).toBeLessThan(zone.clientHeight);
}
// La même page, chaque graphique dessiné : les mêmes positions.
zone.scrollTo(0, 0);
await dessinerGraphiques(page(m));
expect(page(m).querySelectorAll('svg[data-dessin-differe]')).toHaveLength(0);
expect(await parLeLien()).toEqual(lienDiffere);
expect(await jusquAuBas()).toEqual(basDiffere);
}, 60_000);
});
describe('la page ne s’imprime pas (§ 11.7, B8)', () => {
test('aucune règle d’impression des feuilles du document ne vise la section de la qualité', async () => {
const generees = generee(chargeSix());

View file

@ -38,7 +38,11 @@
// seule source mesurable, avec la course que la séance garde et la
// génération qui l'a produite ; sa garde à quatre paramètres donne les
// contradictions qui retirent le dessin.
import { getContext } from 'svelte';
//
// Elle pose le contexte dessinDiffere : un graphique loin de la zone
// visible n'est dessiné que quand il en approche (Graphique), si bien que
// l'ouverture ne mesure et ne rend que ce qui se voit.
import { flushSync, getContext, setContext } from 'svelte';
import Diagnostic from '../diagnostic/Diagnostic.svelte';
import Carte from '../primitives/Carte.svelte';
import Commande from '../primitives/Commande.svelte';
@ -55,12 +59,14 @@
import VuePerformance from './VuePerformance.svelte';
import VueProfil from './VueProfil.svelte';
import VueTables from './VueTables.svelte';
import { creerDessinDiffere } from './graphiques/dessin_differe.js';
const t = getContext('t');
const ui = getContext('ui');
const seance = getContext('seance');
const generation = getContext('generation');
const uid = $props.id();
const dessinDiffere = setContext('dessinDiffere', creerDessinDiffere());
// Les vues, dans leur ordre à l'écran (§ 12, B9), chacune sous le nom de
// la clé de son titre, qualite.vue.<nom>.titre.
@ -94,12 +100,17 @@
const idVue = (nom) => `${uid}-vue-${nom}`;
// Porte le focus sur la vue nommée, qui se fait voir ; le lien ne change
// pas l'adresse de la page.
// pas l'adresse de la page. Les graphiques en attente qui la précèdent se
// dessinent d'abord, rendus avant le défilement : aucun dessin au-dessus
// d'elle ne la déplace ensuite.
function allerALaVue(evenement, nom) {
evenement.preventDefault();
const cible = document.getElementById(idVue(nom));
cible?.scrollIntoView({ block: 'start' });
cible?.focus({ preventScroll: true });
if (cible === null) return;
dessinDiffere.dessinerAvant(cible);
flushSync();
cible.scrollIntoView({ block: 'start' });
cible.focus({ preventScroll: true });
}
</script>

View file

@ -41,7 +41,7 @@
// pointée, par son rang à partir de 1. Chaque marque porte data-marque.
import { getContext } from 'svelte';
import Graphique from './Graphique.svelte';
import { ESPACE_PX, TRAIT_CONTOUR_PX, cibleLigne, dispositionBarres } from './pointage.js';
import { TRAIT_CONTOUR_PX, cibleLigne, dispositionBarres, hauteurBarres, legendeBarres } from './pointage.js';
let {
graphique,
@ -60,22 +60,32 @@
let largeur = $state(0);
let mesures = $state(null);
let dessine = $state(false);
const disposition = $derived(
mesures === null || largeur === 0
!dessine || mesures === null || largeur === 0
? null
: dispositionBarres({
largeur,
lignes,
segments,
colonnes,
mesurer: mesures.mesurer,
hauteurTexte: mesures.hauteurTexte,
graduer: graduer ?? ((v) => format.nombre(v)),
marque: t('graphiques.troncature'),
}),
: mesures.calculer((mesurer) =>
dispositionBarres({
largeur,
lignes,
segments,
colonnes,
mesurer,
hauteurTexte: mesures.hauteurTexte,
graduer: graduer ?? ((v) => format.nombre(v)),
marque: t('graphiques.troncature'),
}),
),
);
// La hauteur du dessin et la légende ne lisent aucune largeur de texte :
// un graphique qui attend son dessin les porte déjà.
const hauteur = $derived(
mesures === null ? 0 : hauteurBarres({ n: lignes.length, juxtaposees: colonnes !== null, hauteurTexte: mesures.hauteurTexte }),
);
const legende = $derived(legendeBarres({ segments, colonnes }));
// La surface de pointage couvre les lignes, intitulés compris.
const zone = $derived(
disposition === null ? null : { x: 0, y: disposition.haut, largeur, hauteur: disposition.hauteurTotale - disposition.haut },
@ -94,13 +104,14 @@
{nom}
{synthese}
{infobulle}
hauteur={disposition?.hauteurTotale ?? 0}
{hauteur}
{zone}
legende={disposition?.legende ?? []}
{legende}
{cibler}
{ancrer}
bind:largeur
bind:mesures
bind:dessine
bind:pointe
>
{#if disposition !== null}

View file

@ -57,6 +57,7 @@
cibleRang,
dispositionEscalier,
dispositionPoints,
legendeTraces,
margesVerticales,
} from './pointage.js';
@ -86,6 +87,7 @@
let largeur = $state(0);
let mesures = $state(null);
let dessine = $state(false);
const titre = $derived(titreX === undefined ? t(enPoints ? 'graphiques.axe.mouvements' : 'graphiques.axe.rangs') : titreX);
const reperesTraces = $derived(enPoints ? [] : reperes);
@ -95,29 +97,37 @@
const requises = $derived(
mesures === null
? null
: margesVerticales({ series, reperes: reperesTraces, mesurer: mesures.mesurer, graduer: graduer ?? nombre, titreX: titre }),
: mesures.calculer((mesurer) =>
margesVerticales({ series, reperes: reperesTraces, mesurer, graduer: graduer ?? nombre, titreX: titre }),
),
);
$effect(() => {
marges = requises;
});
const disposition = $derived.by(() => {
if (mesures === null || largeur === 0) return null;
const communes = {
largeur,
hauteur,
series,
mesurer: mesures.mesurer,
hauteurTexte: mesures.hauteurTexte,
graduer: graduer ?? nombre,
titreX: titre,
aligner: alignerSur,
};
return enPoints
? dispositionPoints({ ...communes, abscisses, graduerX: graduerX ?? nombre })
: dispositionEscalier({ ...communes, reperes, marques, graduerX: graduerX ?? nombre });
if (!dessine || mesures === null || largeur === 0) return null;
return mesures.calculer((mesurer) => {
const communes = {
largeur,
hauteur,
series,
mesurer,
hauteurTexte: mesures.hauteurTexte,
graduer: graduer ?? nombre,
titreX: titre,
aligner: alignerSur,
};
return enPoints
? dispositionPoints({ ...communes, abscisses, graduerX: graduerX ?? nombre })
: dispositionEscalier({ ...communes, reperes, marques, graduerX: graduerX ?? nombre });
});
});
// La légende, des seules données : un graphique qui attend son dessin la
// porte déjà.
const legende = $derived(legendeTraces({ series, reperes: reperesTraces, marques: enPoints ? null : marques }));
const cibler = (x) => (enPoints ? ciblePoint(x, disposition) : cibleRang(x, disposition));
const ancrer = (indice) => (enPoints ? ancrePoint(indice, disposition) : ancreRang(indice, disposition));
// L'attribut de désignation d'une trace : data-serie pour une série,
@ -132,11 +142,12 @@
{hauteur}
{infobulle}
zone={disposition?.zone ?? null}
legende={disposition?.legende ?? []}
{legende}
{cibler}
{ancrer}
bind:largeur
bind:mesures
bind:dessine
bind:pointe
>
{#if disposition !== null}

View file

@ -14,11 +14,20 @@
// qu'un dessin dont la hauteur suit la largeur — une grille — ne change
// pas, dans l'image même, la taille d'un élément déjà mesuré, ce que
// ResizeObserver signale comme une boucle. Le texte se mesure sur
// une sonde du dessin (mesures, liable : {mesurer, hauteurTexte}, null
// tant que la sonde n'est pas rendue) : le composant qui remplit le cadre
// en tire sa disposition (pointage.js), puis rend ses marques par
// une sonde du dessin (mesures, liable : {mesurer, calculer,
// hauteurTexte} de mesuresDuTexte, null tant que la sonde n'est pas
// rendue) : le composant qui remplit le cadre en tire sa disposition
// (pointage.js) par calculer, qui relève ses libellés inconnus par lots
// et garde leurs largeurs pour la séance, puis rend ses marques par
// children.
//
// dessine, liable, dit que les marques se rendent (le dessin différé,
// plus bas) : le composant qui remplit le cadre ne calcule sa disposition
// qu'alors. Avant, il ne mesure que ce qui fixe sa place dans la page :
// les marges d'alignement d'un escalier, qui alignent deux graphiques, et
// les intitulés d'une grille, qui décident de sa hauteur. La hauteur
// qu'il passe est déjà celle de son dessin.
//
// Le pointage au plus proche (§ 12.7) : zone, {x, y, largeur, hauteur} en
// px du dessin, porte la surface de pointage, un <rect fill="none"
// pointer-events="all"> posé après les marques, sœur d'elles, jamais leur
@ -44,13 +53,32 @@
//
// legende : [{genre, libelle, signe}] — un trait, un remplissage ou un
// signe, chacun dessiné de son jeton et de son motif, puis son libellé ;
// libelle null pour un signe, que la table des graphiques nomme. synthese :
// un texte déjà traduit, ou un snippet qui le rend.
// libelle null pour un signe, que la table des graphiques nomme. Elle se
// tire des données (legendeTraces, legendeBarres), jamais du dessin : la
// liste se rend dès le montage, marques en attente ou non. synthese : un
// texte déjà traduit, ou un snippet qui le rend.
//
// Le dessin différé : sous le contexte dessinDiffere (dessin_differe.js),
// que la page de qualité pose, les marques d'un graphique loin de la zone
// visible ne se disposent ni ne se rendent, et leurs libellés ne se
// mesurent pas. Le <svg> garde la hauteur de son dessin, la légende et la
// phrase de synthèse sont rendues : le dessin ne déplace rien dans la
// page. L'image nommée et décrite reste, et le <svg> porte
// data-dessin-differe tant que ses marques attendent : un <svg role="img">
// ne montre pas ses enfants au lecteur d'écran, et le jumeau, chemin du
// clavier et du lecteur d'écran, ne dépend pas du dessin. Une fois sa
// largeur lue, un graphique qui coupe la zone visible — celle du plus
// proche ancêtre qui défile, sinon la fenêtre — se dessine aussitôt ; un
// autre s'inscrit au registre du contexte, qui peut le dessiner sur
// demande, et attend qu'un IntersectionObserver, de marge une hauteur de
// cette zone au-dessus et au-dessous, le voie approcher. Un graphique
// dessiné le reste. Hors de ce contexte, chaque graphique se dessine dès
// sa largeur lue.
//
// Aucune opacité, aucun filtre, aucun mode de fusion sur une marque, un
// texte ou leur ascendance (§ 14.5).
import { getContext } from 'svelte';
import { creerMesureur, hauteurLigne, mesurable } from '../../plan/mesure_texte.js';
import { mesurable, mesuresDuTexte } from '../../plan/mesure_texte.js';
import { placerInfobulle } from '../../primitives/infobulle.js';
import { effetDe } from '../../raccourcis.js';
import Pourcentage from './Pourcentage.svelte';
@ -69,6 +97,7 @@
largeur = $bindable(0),
mesures = $bindable(null),
pointe = $bindable(null),
dessine = $bindable(false),
children,
} = $props();
@ -77,8 +106,10 @@
const uid = $props.id();
const idNom = `${uid}-nom`;
const idSynthese = `${uid}-synthese`;
const differe = getContext('dessinDiffere') ?? null;
let svg = $state(null);
let cadre = $state(null);
let sonde = $state(null);
let bulle = $state(null);
let survole = $state(false);
@ -86,11 +117,53 @@
// qu'elle n'est pas placée.
let place = $state(null);
// Le plus proche ancêtre de l'élément qui fait défiler son contenu à la
// verticale, ou null.
function ancetreQuiDefile(element) {
for (let ancetre = element.parentElement; ancetre !== null; ancetre = ancetre.parentElement) {
const { overflowY } = getComputedStyle(ancetre);
if (overflowY === 'auto' || overflowY === 'scroll') return ancetre;
}
return null;
}
// Le dessin, dès la largeur lue ; différé, le cadre lu une fois, dans la
// mise en page que la lecture de la largeur vient de faire, sinon le
// registre et l'observateur, retirés au dessin.
$effect(() => {
if (dessine || cadre === null || largeur === 0) return undefined;
if (differe === null) {
dessine = true;
return undefined;
}
const defile = ancetreQuiDefile(cadre);
const zone = defile === null ? { top: 0, bottom: innerHeight } : defile.getBoundingClientRect();
const boite = cadre.getBoundingClientRect();
if (boite.bottom >= zone.top && boite.top <= zone.bottom) {
dessine = true;
return undefined;
}
const observateur = new IntersectionObserver(
(entrees) => {
if (entrees.some((entree) => entree.isIntersecting)) dessine = true;
},
{ root: defile, rootMargin: '100% 0px' },
);
observateur.observe(cadre);
const retirer = differe.inscrire(cadre, () => {
dessine = true;
});
return () => {
observateur.disconnect();
retirer();
};
});
// La mesure du texte, relevée sur la sonde dès qu'elle est rendue : sous
// un ancêtre caché, elle attend que la largeur mesurée change.
$effect(() => {
if (largeur > 0 && sonde !== null && mesures === null && mesurable(sonde)) {
mesures = { mesurer: creerMesureur(sonde), hauteurTexte: hauteurLigne(sonde) };
mesures = mesuresDuTexte(sonde);
}
});
@ -116,14 +189,10 @@
// repère de la fenêtre, du plus proche ancêtre qui fait défiler son
// contenu à la verticale ; {} sans un tel ancêtre.
function zoneVisible(element) {
for (let ancetre = element.parentElement; ancetre !== null; ancetre = ancetre.parentElement) {
const { overflowY } = getComputedStyle(ancetre);
if (overflowY === 'auto' || overflowY === 'scroll') {
const boite = ancetre.getBoundingClientRect();
return { haut: boite.top, bas: boite.bottom };
}
}
return {};
const defile = ancetreQuiDefile(element);
if (defile === null) return {};
const boite = defile.getBoundingClientRect();
return { haut: boite.top, bas: boite.bottom };
}
function placer() {
@ -191,7 +260,7 @@
</svg>
{/snippet}
<div class="graphique">
<div class="graphique" bind:this={cadre}>
<div class="mesure" bind:clientWidth={largeur}></div>
<span id={idNom} hidden>{nom}</span>
<svg
@ -200,11 +269,14 @@
role="img"
aria-labelledby={idNom}
aria-describedby={idSynthese}
data-dessin-differe={dessine ? undefined : ''}
width={largeur}
height={hauteur}
>
<text bind:this={sonde} class="sonde" x="0" y="0"></text>
{@render children()}
{#if dessine}
{@render children()}
{/if}
{#if zone !== null}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<rect

View file

@ -28,11 +28,13 @@
// Désignations : data-graphique sur le <svg>, data-case sur chaque case,
// par « ligne:colonne » à partir de 0, data-rang sur la désignation de la
// case pointée, par son indice à partir de 1. Le niveau d'une case à
// partir de 1 et sa bordure portent chacun data-marque ; le niveau 0 est un
// fond, sans marque.
// partir de 1 porte data-marque ; le niveau 0 est un fond, sans marque.
// Les bordures de toutes les cases sont un seul <path>, après les cases,
// qui porte data-marque : aucune case ne chevauche la bordure d'une autre,
// et l'ordre de peinture ne change aucun pixel.
import { getContext } from 'svelte';
import Graphique from './Graphique.svelte';
import { ESPACE_PX, cibleCase, dispositionGrille } from './pointage.js';
import { ESPACE_PX, cadreGrille, cibleCase, dispositionGrille } from './pointage.js';
let {
graphique,
@ -55,25 +57,44 @@
let largeur = $state(0);
let mesures = $state(null);
let dessine = $state(false);
const disposition = $derived(
const coteMax = $derived(
coteMaxTextes === null || mesures === null ? Number.POSITIVE_INFINITY : coteMaxTextes * mesures.hauteurTexte,
);
// Le cadre, que les seuls intitulés décident (cadreGrille) : mesuré dès
// la sonde rendue, dessin fait ou non, il donne la hauteur que le
// graphique réserve — celle de son dessin — et dit si la grille cède la
// place.
const cadre = $derived(
mesures === null || largeur === 0
? null
: dispositionGrille({
largeur,
lignes,
colonnes,
valeurs,
diagonale,
mesurer: mesures.mesurer,
hauteurTexte: mesures.hauteurTexte,
ecrire: ecrire ?? ((v) => format.nombre(v)),
coteMax: coteMaxTextes === null ? Number.POSITIVE_INFINITY : coteMaxTextes * mesures.hauteurTexte,
}),
: mesures.calculer((mesurer) =>
cadreGrille({ largeur, lignes, colonnes, mesurer, hauteurTexte: mesures.hauteurTexte, coteMax }),
),
);
const disposition = $derived(
!dessine || mesures === null || largeur === 0
? null
: mesures.calculer((mesurer) =>
dispositionGrille({
largeur,
lignes,
colonnes,
valeurs,
diagonale,
mesurer,
hauteurTexte: mesures.hauteurTexte,
ecrire: ecrire ?? ((v) => format.nombre(v)),
coteMax,
}),
),
);
$effect(() => {
if (disposition !== null) dessinable = disposition.dessinable;
if (cadre !== null) dessinable = cadre.dessinable;
});
const zone = $derived(
@ -94,7 +115,7 @@
largeur, et le conteneur qui la porte ne se mesure pas. -->
<div>
<div class="mesure" bind:clientWidth={largeur}></div>
{#if disposition !== null && !disposition.dessinable}
{#if cadre !== null && !cadre.dessinable}
<p class="phrase">{#if typeof phrase === 'function'}{@render phrase()}{:else}{phrase}{/if}</p>
{:else}
<Graphique
@ -102,12 +123,13 @@
{nom}
{synthese}
{infobulle}
hauteur={disposition?.hauteur ?? 0}
hauteur={cadre?.hauteur ?? 0}
{zone}
{cibler}
{ancrer}
bind:largeur
bind:mesures
bind:dessine
bind:pointe
>
{#if disposition !== null}
@ -136,7 +158,6 @@
style:fill={`var(${c.jeton})`}
/>
{/if}
<rect class="bordure" data-marque="graphiqueBordureCase" x={c.bord.x} y={c.bord.y} width={c.bord.cote} height={c.bord.cote} />
{#if !c.diagonale}
<text
class="compte"
@ -149,6 +170,7 @@
{/if}
</g>
{/each}
<path class="bordure" data-marque="graphiqueBordureCase" d={disposition.bordures} />
{#if pointe !== null && disposition.cases[pointe] !== undefined}
{@const c = disposition.cases[pointe]}
<rect class="designation" data-rang={pointe + 1} x={c.x - ESPACE_PX / 3} y={c.y - ESPACE_PX / 3} width={c.cote + (2 * ESPACE_PX) / 3} height={c.cote + (2 * ESPACE_PX) / 3} />
@ -172,10 +194,15 @@
fill: var(--couleur-page-texte);
}
/* Les bordures, un seul chemin, sans lissage : un coin de sous-chemin
lissé se peint plus clair que celui d'un <rect>, et un trait sans
lissage peint son jeton entier, sur la même rangée de pixels qu'un
<rect> posé à la demi-unité. */
.bordure {
fill: none;
stroke: var(--couleur-graphique-bordure-case);
stroke-width: 1;
shape-rendering: crispEdges;
}
/* Un compte sans niveau — « — » — se lit de l'encre de la page. */

View file

@ -0,0 +1,31 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le contexte dessinDiffere (Graphique.svelte) : la page qui le pose tient
// le registre des graphiques dont le dessin attend d'approcher de la zone
// visible. Chaque graphique en attente s'y inscrit, son cadre et la
// fonction qui le dessine, et s'en retire une fois dessiné ou démonté.
//
// dessinerAvant(cible) dessine chaque graphique inscrit qui précède cible
// dans le document, hors de ses descendants : une page qui amène une vue
// dans la zone visible les dessine d'abord, puis, le rendu fait
// (flushSync), défile jusqu'à elle. Leur hauteur réservée est celle de
// leur dessin ; les dessiner avant le défilement garantit en plus
// qu'aucun dessin au-dessus de la vue ne la déplace ensuite.
/** Un registre neuf : {inscrire(cadre, dessiner) → retirer, dessinerAvant(cible)}. */
export function creerDessinDiffere() {
const enAttente = new Set();
return {
inscrire(cadre, dessiner) {
const entree = { cadre, dessiner };
enAttente.add(entree);
return () => enAttente.delete(entree);
},
dessinerAvant(cible) {
for (const { cadre, dessiner } of [...enAttente]) {
if (cible.compareDocumentPosition(cadre) & Node.DOCUMENT_POSITION_PRECEDING) dessiner();
}
},
};
}

View file

@ -20,10 +20,12 @@ import '../../jetons.css';
import { TABLES_APPLICATION } from '../../../application/libelles.js';
import { creerFormat } from '../../../application/format.js';
import { creerTraduction } from '../../../application/traduction.js';
import { creerMesureur, mesuresDuTexte } from '../../plan/mesure_texte.js';
import { releverMarques } from '../../../../test/navigateur/captures.js';
import { fixerCadre, pointer, touche } from '../../../../test/navigateur/entrees.js';
import { attenuations, sousTheme } from '../../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../../libelles.js';
import { creerDessinDiffere } from './dessin_differe.js';
import { LARGEUR_NOM_PX, margesCommunes } from './pointage.js';
import Barres from './Barres.svelte';
import Escalier from './Escalier.svelte';
@ -584,7 +586,16 @@ describe('graphiques : la grille (§ 12.5, § 12.11, D33)', () => {
expect(cases).toHaveLength(9);
const trois = cible.querySelector('[data-case="0:2"]');
expect(trois.querySelector('[data-marque="graphiqueNiveau3"]')).not.toBeNull();
expect(trois.querySelector('[data-marque="graphiqueBordureCase"]')).not.toBeNull();
// Les bordures : un seul <path>, un sous-chemin fermé par case,
// diagonale comprise ; le bord gauche de 0:2 est sur son trait, à
// RETRAIT_CASE_PX − 0,5 px à gauche de son remplissage.
const bordures = cible.querySelectorAll('[data-marque="graphiqueBordureCase"]');
expect(bordures).toHaveLength(1);
expect(bordures[0].getAttribute('d').match(/M/g)).toHaveLength(9);
const fond = trois.querySelector('[data-marque="graphiqueNiveau3"]');
const [x, y, cote] = ['x', 'y', 'height'].map((nom) => Number(fond.getAttribute(nom)));
const bord = new DOMPoint(x - 1.5, y + cote / 2);
expect(bordures[0].isPointInStroke(bord)).toBe(true);
expect(trois.querySelector('text').textContent).toBe('3');
expect(getComputedStyle(trois.querySelector('text')).fill).toBe(couleurDuJeton('--couleur-graphique-encre-claire'));
const diagonale = cible.querySelector('[data-case="1:1"]');
@ -601,13 +612,16 @@ describe('graphiques : la grille (§ 12.5, § 12.11, D33)', () => {
test('le pointage désigne la case ; la diagonale n’ouvre rien', async () => {
const cible = await monter(Grille, props());
const boite = cible.querySelector('[data-case="1:2"] [data-marque="graphiqueBordureCase"]').getBoundingClientRect();
const boite = cible.querySelector('[data-case="1:2"] rect').getBoundingClientRect();
await pointer('bouger', boite.left + boite.width / 2, boite.top + boite.height / 2);
flushSync();
expect(infobulleDe(cible).hidden).toBe(false);
expect(infobulleDe(cible).textContent).toContain('Théo Pervenche et Basile Tourteau');
const diag = cible.querySelector('[data-case="1:1"] [data-marque="graphiqueBordureCase"]').getBoundingClientRect();
await pointer('bouger', diag.left + diag.width / 2, diag.top + diag.height / 2);
// La case 1:1, vide : la colonne du compte de 0:1, la ligne de celui de
// 1:0.
const colonne = cible.querySelector('[data-case="0:1"] text').getBoundingClientRect();
const ligne = cible.querySelector('[data-case="1:0"] text').getBoundingClientRect();
await pointer('bouger', colonne.left + colonne.width / 2, ligne.top + ligne.height / 2);
flushSync();
expect(infobulleDe(cible).hidden).toBe(true);
});
@ -741,3 +755,215 @@ describe('graphiques : les marques mesurées dans les deux thèmes (§ 14.5)', (
}
});
});
describe('graphiques : le dessin différé et les mesures de la séance (E2s)', () => {
// Une zone qui défile de 400 px de haut, posée en tête du document : un
// graphique, puis un intervalle de 2 000 px, puis un second, puis fin, un
// élément vide. Chacun est monté sous les contextes de App, et sous
// dessinDiffere quand differe est vrai, comme la page de qualité les
// monte ; registre est ce contexte, null sans lui.
async function monterDansUneZone(premier, second, { differe = true } = {}) {
const zone = document.createElement('div');
zone.style.cssText = 'width: 800px; height: 400px; overflow-y: auto;';
const intervalle = document.createElement('div');
intervalle.style.height = '2000px';
const cibles = [document.createElement('div'), document.createElement('div')];
const fin = document.createElement('div');
zone.append(cibles[0], intervalle, cibles[1], fin);
document.body.prepend(zone);
const registre = differe ? creerDessinDiffere() : null;
const context = new Map([...CONTEXTE, ...(differe ? [['dessinDiffere', registre]] : [])]);
for (const [i, [Composant, props]] of [premier, second].entries()) {
const composant = mount(Composant, { target: cibles[i], props, context });
montes.push({ composant, cible: i === 0 ? zone : cibles[i] });
}
flushSync();
await tick();
flushSync();
return { zone, fin, registre, svgs: cibles.map((cible) => cible.querySelector('svg[data-graphique]')) };
}
const image = () => new Promise((resoudre) => requestAnimationFrame(resoudre));
test('sous dessinDiffere : le graphique visible se dessine au montage, l’autre attend, nommé et décrit, et se dessine en approchant ; leurs marges sont communes dès le montage', async () => {
const partager = margesPartagees(2);
const props = [partager(propsProfil(12, { graphique: 'haut' }), 0), partager(propsProfil(12, { graphique: 'bas' }), 1)];
const { zone, svgs } = await monterDansUneZone([Escalier, props[0]], [Escalier, props[1]]);
const [haut, bas] = svgs;
expect(haut.hasAttribute('data-dessin-differe')).toBe(false);
expect(haut.querySelector('[data-marque]')).not.toBeNull();
expect(bas.hasAttribute('data-dessin-differe')).toBe(true);
expect(bas.querySelector('[data-marque]')).toBeNull();
expect(document.getElementById(bas.getAttribute('aria-labelledby')).textContent).toBe('Qui rencontre le moins de monde ?');
expect(document.getElementById(bas.getAttribute('aria-describedby')).textContent).toBe('Minimum 10 rencontres, une personne.');
// Les marges du second se mesurent sans son dessin : le premier est
// aligné sur elles avant que le second ne se dessine, et ne bouge pas
// quand il se dessine.
expect(props[1].marges).not.toBeNull();
const avant = surfaceDe(haut).getAttribute('x');
bas.scrollIntoView({ block: 'center' });
for (let n = 0; n < 60 && bas.hasAttribute('data-dessin-differe'); n += 1) await image();
expect(bas.hasAttribute('data-dessin-differe')).toBe(false);
expect(bas.querySelector('[data-marque]')).not.toBeNull();
expect(surfaceDe(haut).getAttribute('x')).toBe(avant);
expect(surfaceDe(bas).getAttribute('x')).toBe(avant);
// Remonté, le second reste dessiné.
zone.scrollTo(0, 0);
await image();
await image();
expect(bas.querySelector('[data-marque]')).not.toBeNull();
});
test('en attente, Barres et Grille réservent la hauteur de leur dessin, chaque graphique porte déjà sa légende ; le registre dessine ce qui précède une cible, rien de ce qui la suit', async () => {
const barres = {
graphique: 'collisions',
nom: 'Collisions',
synthese: 'Trois appartenances.',
lignes: [
{ id: 'aubier', nom: 'Coopérative Aubier', appartenance: 1, valeurs: [0, 3] },
{ id: 'long', nom: NOM_LONG, appartenance: null, valeurs: [2, 0] },
{ id: 'ressac', nom: 'Atelier Ressac', appartenance: 2, valeurs: [1, 1] },
],
segments: [
{ genre: 'prouve', libelle: 'prouvé inévitable' },
{ genre: 'reste', libelle: 'reste' },
],
};
const colonnes = {
...barres,
graphique: 'diversite',
segments: undefined,
colonnes: [
{ genre: 'serie1', libelle: 'Appartenances' },
{ genre: 'serie2', libelle: 'Personnes' },
],
};
const grille = (extra) => ({
graphique: 'tables',
nom: 'Tables',
synthese: 'Quatre niveaux.',
lignes: [{ libelle: 'Tour 1' }, { libelle: 'Tour 2' }],
colonnes: [{ libelle: 'Table 1' }, { libelle: 'Table 2' }, { libelle: 'Table 3' }],
valeurs: [0, 1, 2, 3, null, 1],
...extra,
});
const cas = [
['barres empilées', Barres, barres],
['barres juxtaposées', Barres, colonnes],
['grille bornée', Grille, grille({ coteMaxTextes: 3 })],
['grille, intitulés tournés', Grille, grille({ colonnes: [{ libelle: 'Une table au nom très long' }, { libelle: 'Deux' }, { libelle: 'Trois' }], coteMaxTextes: 2 })],
['grille sans borne', Grille, grille()],
['escalier', Escalier, propsProfil(12, { graphique: 'bas' })],
];
for (const [quoi, Composant, props] of cas) {
const { svgs, registre, fin } = await monterDansUneZone([Escalier, propsProfil(12, { graphique: 'haut' })], [Composant, props]);
const [haut, bas] = svgs;
const cadre = bas.parentElement;
const releve = () => ({
svg: bas.getAttribute('height'),
cadre: cadre.getBoundingClientRect().height,
legende: [...cadre.querySelectorAll('ul li')].map((li) => li.textContent),
});
expect(bas.hasAttribute('data-dessin-differe'), quoi).toBe(true);
expect(bas.querySelector('[data-marque]'), quoi).toBeNull();
const avant = releve();
expect(Number(avant.svg), quoi).toBeGreaterThan(0);
if (quoi === 'barres empilées' || quoi === 'escalier') expect(avant.legende.length, quoi).toBeGreaterThan(0);
// Le graphique en attente suit la cible haut : il reste en attente.
registre.dessinerAvant(haut);
flushSync();
expect(bas.hasAttribute('data-dessin-differe'), quoi).toBe(true);
registre.dessinerAvant(fin);
flushSync();
expect(bas.hasAttribute('data-dessin-differe'), quoi).toBe(false);
expect(bas.querySelector('[data-marque]'), quoi).not.toBeNull();
expect(releve(), quoi).toEqual(avant);
for (const { composant, cible } of montes.splice(0)) {
unmount(composant);
cible.remove();
}
}
});
test('hors de dessinDiffere, un graphique loin de la zone visible se dessine au montage', async () => {
const haut = [Escalier, propsProfil(12, { graphique: 'haut' })];
const bas = [Escalier, propsProfil(12, { graphique: 'bas' })];
const { svgs } = await monterDansUneZone(haut, bas, { differe: false });
for (const svg of svgs) {
expect(svg.hasAttribute('data-dessin-differe')).toBe(false);
expect(svg.querySelector('[data-marque]')).not.toBeNull();
}
});
// Un <svg> sans viewBox au style des textes de graphique, une sonde, et le
// compte des appels de getComputedTextLength et des relevés (after) que
// faire() provoque.
function sousSonde(faire) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.fontSize = '13px';
const sonde = document.createElementNS('http://www.w3.org/2000/svg', 'text');
svg.append(sonde);
document.body.prepend(svg);
const longueur = SVGTextContentElement.prototype.getComputedTextLength;
const apres = Element.prototype.after;
const comptes = { longueurs: 0, releves: 0 };
SVGTextContentElement.prototype.getComputedTextLength = function compter() {
comptes.longueurs += 1;
return longueur.call(this);
};
Element.prototype.after = function compter(...noeuds) {
comptes.releves += 1;
return apres.apply(this, noeuds);
};
try {
return { rendu: faire(sonde), comptes };
} finally {
SVGTextContentElement.prototype.getComputedTextLength = longueur;
Element.prototype.after = apres;
svg.remove();
}
}
test('mesuresDuTexte : les largeurs de creerMesureur ; un relevé par lot, calcul relu jusqu’à ne rien demander d’inconnu ; gardées pour la séance, oubliées à la fin d’un chargement de police', () => {
document.fonts.dispatchEvent(new Event('loadingdone'));
const chaines = ['Iris Ombrelle', 'Théo Pervenche', '27', ''];
const temoins = sousSonde((sonde) => chaines.map(creerMesureur(sonde))).rendu;
// Le calcul demande d'abord trois chaînes, puis une quatrième qui
// dépend de la largeur de la première : deux lots, trois passages.
let passages = 0;
const calcul = (mesurer) => {
passages += 1;
const premieres = chaines.slice(0, 3).map(mesurer);
return premieres[0] > 0 ? [...premieres, mesurer(chaines[3])] : premieres;
};
const premier = sousSonde((sonde) => mesuresDuTexte(sonde).calculer(calcul));
expect(premier.rendu).toEqual(temoins);
expect(passages).toBe(3);
expect(premier.comptes).toEqual({ longueurs: 4, releves: 2 });
// Une autre sonde du même style : tout vient de la séance.
passages = 0;
const second = sousSonde((sonde) => mesuresDuTexte(sonde).calculer(calcul));
expect(second.rendu).toEqual(temoins);
expect(passages).toBe(1);
expect(second.comptes).toEqual({ longueurs: 0, releves: 0 });
// Une police chargée : les largeurs se relèvent.
document.fonts.dispatchEvent(new Event('loadingdone'));
const releve = sousSonde((sonde) => mesuresDuTexte(sonde).calculer(calcul));
expect(releve.rendu).toEqual(temoins);
expect(releve.comptes.longueurs).toBe(4);
});
test('mesuresDuTexte : sous un ancêtre caché, une largeur nulle vaut 0 pour le calcul, ne se garde pas, et le calcul finit', () => {
document.fonts.dispatchEvent(new Event('loadingdone'));
const { rendu, comptes } = sousSonde((sonde) => {
sonde.parentNode.style.display = 'none';
const mesures = mesuresDuTexte(sonde);
const cache = mesures.calculer((mesurer) => mesurer('Ondine Lacasse'));
sonde.parentNode.style.display = '';
return [cache, mesures.mesurer('Ondine Lacasse')];
});
expect(rendu[0]).toBe(0);
expect(rendu[1]).toBeGreaterThan(0);
expect(comptes.longueurs).toBe(2);
});
});

View file

@ -300,10 +300,17 @@ export function cheminSigne(genre, x, y) {
return `M ${n(x - s)} ${n(y - s)} L ${n(x + s)} ${n(y + s)} M ${n(x + s)} ${n(y - s)} L ${n(x - s)} ${n(y + s)}`;
}
// La légende d'un graphique à traces : chaque trace sous son libellé, puis
// chaque signe présent, dans l'ordre de SIGNES.
function legendeDe(traces, signes = []) {
const presents = new Set(signes.map((s) => s.genre));
/**
* La légende d'un escalier ou de points (Graphique) : chaque trace sous son
* libellé — les séries, puis les repères —, puis chaque signe que marques
* porte, dans l'ordre de SIGNES. Elle ne lit que les données reçues, aucune
* mesure ni disposition : un graphique qui attend son dessin la rend déjà.
* Lève comme exigerTraces.
* @returns {Array<{genre: string, libelle: string|null, signe: string|null}>}
*/
export function legendeTraces({ series, reperes = [], marques = null }) {
const { traces } = exigerTraces(series, reperes);
const presents = new Set(marques ?? []);
return [
...traces.map((t) => ({ genre: t.genre, libelle: t.libelle, signe: null })),
...SIGNES.filter((s) => presents.has(s)).map((signe) => ({ genre: 'signe', libelle: null, signe })),
@ -368,7 +375,7 @@ export function dispositionEscalier({
n === 0
? []
: graduations(1, n, GRADUATIONS_RANGS, { entieres: true }).map((rang) => ({ valeur: rang, texte: graduerX(rang), x: ex.versPx(rang - 0.5) }));
return { ...cadre, n, ex, traces: tracees, signes, graduationsX, legende: legendeDe(tracees, signes) };
return { ...cadre, n, ex, traces: tracees, signes, graduationsX, legende: legendeTraces({ series, reperes, marques }) };
}
/** L'indice du rang sous l'abscisse x d'un escalier, au plus proche, borné
@ -444,7 +451,7 @@ export function dispositionPoints({
gardees.unshift({ valeur: abscisses[i], texte, x });
}
}
return { ...cadre, n, ex, traces: tracees, abscissesPx, graduationsX: gardees, legende: legendeDe(tracees) };
return { ...cadre, n, ex, traces: tracees, abscissesPx, graduationsX: gardees, legende: legendeTraces({ series }) };
}
/** L'indice du point de relevé le plus proche de l'abscisse x, sur
@ -511,6 +518,33 @@ function exigerIdentifiants(lignes) {
}
}
/**
* La hauteur, en px, de barres de n lignes (dispositionBarres) : l'en-tête
* — le titre de chaque colonne juxtaposée, puis les graduations — et les
* lignes, chacune d'une hauteur de texte et d'ESPACE_PX, écartées de
* ECART_LIGNES_PX. Elle ne lit aucune largeur de texte : un graphique qui
* attend son dessin la réserve, et son dessin ne change pas sa hauteur.
*/
export function hauteurBarres({ n, juxtaposees, hauteurTexte }) {
const ligne = hauteurTexte + ESPACE_PX;
return enTeteBarres(juxtaposees, hauteurTexte) + (n === 0 ? 0 : n * ligne + (n - 1) * ECART_LIGNES_PX);
}
// La hauteur de l'en-tête des barres : le titre de chaque colonne
// juxtaposée, puis les graduations.
const enTeteBarres = (juxtaposees, hauteurTexte) => (juxtaposees ? 2 : 1) * (hauteurTexte + ESPACE_PX);
/**
* La légende de barres (Graphique) : chaque segment empilé sous son
* libellé, rien pour des colonnes juxtaposées, que leurs titres nomment.
* Elle ne lit que les données reçues : un graphique qui attend son dessin
* la rend déjà.
* @returns {Array<{genre: string, libelle: string, signe: null}>}
*/
export function legendeBarres({ segments = null, colonnes = null }) {
return colonnes !== null ? [] : segments.map((s) => ({ genre: s.genre, libelle: s.libelle, signe: null }));
}
/**
* La disposition de barres horizontales (§ 12.4, § 12.12, § 12.13) : une
* ligne par entrée de lignes, {id, nom, appartenance?, valeurs}, dans
@ -558,8 +592,7 @@ export function dispositionBarres({ largeur, lignes, segments = null, colonnes =
const retraitNom = avecPastille ? 2 * RETRAIT_PASTILLE_PX + 2 * POINT_PASTILLE_PX : 0;
const largeurNoms = Math.max(0, ...intitules.map((i) => i.largeur)) + retraitNom + (avecPastille ? RETRAIT_PASTILLE_PX : 0);
const x0 = largeurNoms + ESPACE_PX;
// En tête : le titre de chaque colonne juxtaposée, puis les graduations.
const haut = (juxtaposees ? 2 : 1) * (hauteurTexte + ESPACE_PX);
const haut = enTeteBarres(juxtaposees, hauteurTexte);
// À droite : les libellés des segments, au pire tous sur une même ligne,
// puis le plus long texte secondaire.
const etiquettes = juxtaposees ? [] : parts.map((s) => s.libelle);
@ -674,9 +707,9 @@ export function dispositionBarres({ largeur, lignes, segments = null, colonnes =
colonnes: juxtaposees ? colonnesDisposees : [],
axes: colonnesDisposees,
etiquettesSegments,
legende: juxtaposees ? [] : parts.map((s) => ({ genre: s.genre, libelle: s.libelle, signe: null })),
legende: legendeBarres({ segments, colonnes }),
haut,
hauteurTotale: haut + corps,
hauteurTotale: hauteurBarres({ n: lignes.length, juxtaposees, hauteurTexte }),
};
}
@ -690,6 +723,26 @@ export function cibleLigne(y, disposition) {
return Math.min(n - 1, Math.max(0, indice));
}
/**
* Le cadre d'une grille (dispositionGrille) : ce que ses seuls intitulés
* décident, mesurés par mesurer. x0, la largeur des intitulés de ligne et
* d'un espace ; cote et dessinable, la case que la largeur restante tient
* (grille, graphique.js) ; tournes, vrai quand un intitulé de colonne
* dépasse la case, et y0, la hauteur de l'en-tête : la hauteur d'un texte,
* ou du plus long intitulé de colonne tourné, et un espace ; largeur et
* hauteur, le dessin entier. Les comptes des cases n'y entrent pas : un
* graphique qui attend son dessin réserve cette hauteur, et sait déjà s'il
* cède la place.
*/
export function cadreGrille({ largeur, lignes, colonnes, mesurer, hauteurTexte, coteMax = Number.POSITIVE_INFINITY }) {
exiger(Array.isArray(lignes) && Array.isArray(colonnes), 'grille : lignes et colonnes attendues', TypeError);
const x0 = plusLarge(lignes.map((l) => l.libelle), mesurer) + ESPACE_PX;
const g = grille(lignes.length, colonnes.length, Math.max(0, largeur - x0), hauteurTexte, coteMax);
const tournes = colonnes.some((c) => mesurer(c.libelle) > g.cote);
const y0 = (tournes ? plusLarge(colonnes.map((c) => c.libelle), mesurer) : hauteurTexte) + ESPACE_PX;
return { x0, y0, cote: g.cote, dessinable: g.dessinable, tournes, largeur: x0 + g.largeur, hauteur: y0 + g.hauteur };
}
/**
* La disposition d'une grille (§ 12.5, § 12.11, D33, D35) : lignes ×
* colonnes de cases carrées, chacune {libelle}, valeurs les comptes en ordre
@ -701,7 +754,10 @@ export function cibleLigne(y, disposition) {
* « — ». Sous diagonale, une case de même ligne et colonne est vide et
* neutre. Une case porte sa bordure, bord, un trait d'un pixel au bord de
* la case, et son remplissage, fond, en retrait de RETRAIT_CASE_PX dans
* elle. dessinable faux quand les cases ne tiennent pas dans la largeur
* elle. bordures, le chemin de toutes les bordures, un sous-chemin fermé
* par case dans l'ordre des cases, chacun le carré de son bord : un seul
* <path> trace ce que traceraient autant de <rect>, sans un élément par
* case. dessinable faux quand les cases ne tiennent pas dans la largeur
* (grille, graphique.js) : la vue cède la place. Les intitulés de colonne
* se tournent d'un quart de tour quand l'un d'eux dépasse la case. ecrire(v)
* écrit un compte.
@ -721,29 +777,25 @@ export function dispositionGrille({
const nl = lignes.length;
const nc = colonnes.length;
exiger(Array.isArray(valeurs) && valeurs.length === nl * nc, `grille : ${nl * nc} valeurs attendues`);
const largeurIntitules = plusLarge(lignes.map((l) => l.libelle), mesurer) + ESPACE_PX;
const g = grille(nl, nc, Math.max(0, largeur - largeurIntitules), hauteurTexte, coteMax);
const tournes = colonnes.some((c) => mesurer(c.libelle) > g.cote);
const enTete = (tournes ? plusLarge(colonnes.map((c) => c.libelle), mesurer) : hauteurTexte) + ESPACE_PX;
const x0 = largeurIntitules;
const y0 = enTete;
const cadre = cadreGrille({ largeur, lignes, colonnes, mesurer, hauteurTexte, coteMax });
const { x0, y0, tournes, cote } = cadre;
const cases = valeurs.map((v, index) => {
const ligne = Math.floor(index / nc);
const colonne = index % nc;
const surDiagonale = diagonale && ligne === colonne;
const n = surDiagonale || v === null ? null : niveau(v);
const entree = n === null ? null : NIVEAUX_GRAPHIQUE[n];
const x = x0 + colonne * g.cote;
const y = y0 + ligne * g.cote;
const x = x0 + colonne * cote;
const y = y0 + ligne * cote;
return {
index,
ligne,
colonne,
x,
y,
cote: g.cote,
bord: { x: x + 0.5, y: y + 0.5, cote: Math.max(0, g.cote - 1) },
fond: { x: x + RETRAIT_CASE_PX, y: y + RETRAIT_CASE_PX, cote: Math.max(0, g.cote - 2 * RETRAIT_CASE_PX) },
cote,
bord: { x: x + 0.5, y: y + 0.5, cote: Math.max(0, cote - 1) },
fond: { x: x + RETRAIT_CASE_PX, y: y + RETRAIT_CASE_PX, cote: Math.max(0, cote - 2 * RETRAIT_CASE_PX) },
diagonale: surDiagonale,
niveau: n,
jeton: entree?.jeton ?? null,
@ -753,17 +805,18 @@ export function dispositionGrille({
};
});
return {
dessinable: g.dessinable,
cote: g.cote,
dessinable: cadre.dessinable,
cote,
x0,
y0,
largeur: x0 + g.largeur,
hauteur: y0 + g.hauteur,
largeur: cadre.largeur,
hauteur: cadre.hauteur,
nl,
nc,
intitulesLignes: lignes.map((l, i) => ({ texte: l.libelle, x: x0 - ESPACE_PX, y: y0 + (i + 0.5) * g.cote })),
intitulesColonnes: colonnes.map((c, j) => ({ texte: c.libelle, x: x0 + (j + 0.5) * g.cote, y: y0 - ESPACE_PX, tourne: tournes })),
intitulesLignes: lignes.map((l, i) => ({ texte: l.libelle, x: x0 - ESPACE_PX, y: y0 + (i + 0.5) * cote })),
intitulesColonnes: colonnes.map((c, j) => ({ texte: c.libelle, x: x0 + (j + 0.5) * cote, y: y0 - ESPACE_PX, tourne: tournes })),
cases,
bordures: cases.map(({ bord }) => `M${bord.x} ${bord.y}h${bord.cote}v${bord.cote}h${-bord.cote}z`).join(''),
};
}

View file

@ -25,6 +25,7 @@ import {
TRAITS,
ancrePoint,
ancreRang,
cadreGrille,
celluleDe,
ciblePoint,
cibleCase,
@ -38,6 +39,9 @@ import {
exigerColonnes,
exigerCompte,
exigerTraces,
hauteurBarres,
legendeBarres,
legendeTraces,
ligneInfobulle,
margesCommunes,
margesVerticales,
@ -502,6 +506,15 @@ describe('pointage : la grille (§ 12.5, § 12.11, D33)', () => {
assert.equal(c[2].fond.x + c[2].fond.cote, c[2].x + c[2].cote - RETRAIT_CASE_PX);
});
test('bordures : un sous-chemin fermé par case, diagonale comprise, dans l’ordre des cases, chacun le carré de son bord', () => {
const d = disposer();
const sousChemins = d.bordures.split('z').filter((x) => x !== '');
assert.equal(sousChemins.length, d.cases.length);
d.cases.forEach(({ bord }, i) => {
assert.equal(sousChemins[i], `M${bord.x} ${bord.y}h${bord.cote}v${bord.cote}h${-bord.cote}`);
});
});
test('une case sans compte écrit « — » sans couleur ; elle n’est pas sur la diagonale', () => {
const d = disposer({ diagonale: false, valeurs: [null, 1, 2, 0] , lignes: lignes.slice(0, 2), colonnes: lignes.slice(0, 2) });
assert.deepEqual([d.cases[0].texte, d.cases[0].niveau, d.cases[0].jeton, d.cases[0].diagonale], [null, null, null, false]);
@ -525,6 +538,95 @@ describe('pointage : la grille (§ 12.5, § 12.11, D33)', () => {
});
});
describe('pointage : ce qu’un graphique en attente de son dessin porte déjà (E2s)', () => {
const nom = (i) => (i % 3 === 0 ? 'Maëlle-Hortense Quenouille-Vasque de Belle-Rive' : `Personne ${i}`);
const segments = [
{ genre: 'prouve', libelle: 'prouvé inévitable' },
{ genre: 'reste', libelle: 'reste' },
];
const colonnes = [
{ genre: 'serie1', libelle: 'appartenances' },
{ genre: 'serie2', libelle: 'étiquettes' },
];
test('hauteurBarres est la hauteur du dessin, à tout nombre de lignes, empilées ou juxtaposées, et legendeBarres sa légende', () => {
for (const n of [0, 1, 2, 7, 74]) {
for (const hauteurTexte of [12, 15.5]) {
for (const forme of [{ segments }, { colonnes }]) {
const lignes = Array.from({ length: n }, (_, i) => ({ id: i, nom: nom(i), valeurs: [i % 2, i % 5], secondaire: i % 4 === 0 ? `${i} sur ${n}` : null }));
for (const largeur of [0, 320, 1200]) {
const d = dispositionBarres({ largeur, lignes, mesurer, hauteurTexte, graduer, marque: MARQUE, ...forme });
const juxtaposees = forme.colonnes !== undefined;
assert.equal(hauteurBarres({ n, juxtaposees, hauteurTexte }), d.hauteurTotale, `${n} lignes, ${largeur} px`);
assert.equal(d.axes[0].axe.y2, d.hauteurTotale, 'l’axe finit au bas du dessin');
assert.deepEqual(legendeBarres(forme), d.legende);
}
}
}
}
});
test('legendeBarres : chaque segment empilé sous son libellé ; rien pour des colonnes juxtaposées', () => {
assert.deepEqual(legendeBarres({ segments }), [
{ genre: 'prouve', libelle: 'prouvé inévitable', signe: null },
{ genre: 'reste', libelle: 'reste', signe: null },
]);
assert.deepEqual(legendeBarres({ colonnes }), []);
});
test('legendeTraces est la légende du dessin : séries, repères, puis les signes présents dans l’ordre de SIGNES', () => {
const series = [serie([3, 2, 1]), serie([1, 1, 2], 'à neuf')];
const reperes = [{ genre: 'plafond', libelle: 'plafond', valeurs: [3, 3, 3] }];
for (const marques of [null, [null, null, null], ['reserve', null, 'ancre'], ['partiel', 'partiel', null]]) {
const legende = legendeTraces({ series, reperes, marques });
assert.deepEqual(legende, escalier({ series, reperes, marques }).legende);
const signes = legende.filter((e) => e.genre === 'signe').map((e) => e.signe);
assert.deepEqual(signes, SIGNES.filter((s) => (marques ?? []).includes(s)));
}
assert.deepEqual(legendeTraces({ series, reperes })[2], { genre: 'plafond', libelle: 'plafond', signe: null });
const points = dispositionPoints({ largeur: 600, hauteur: 160, abscisses: [1, 10, 100], series, mesurer, hauteurTexte: HAUTEUR_TEXTE, graduer, graduerX: graduer });
assert.deepEqual(legendeTraces({ series }), points.legende);
assert.throws(() => legendeTraces({ series: [] }), RangeError);
});
test('cadreGrille est le cadre du dessin — hauteur, case, en-tête tourné ou non, dessinable — et ne mesure que les intitulés', () => {
const intitules = (n, prefixe) => Array.from({ length: n }, (_, i) => ({ libelle: `${prefixe} ${i + 1}` }));
const longues = [{ libelle: 'Une table au nom très long' }, { libelle: 'Deux' }];
const cas = [
[intitules(3, 'Tour'), intitules(8, 'Table')],
[intitules(5, 'Tour'), longues],
[intitules(2, 'Personne'), intitules(2, 'Personne')],
[intitules(1, 'Tour'), intitules(60, 'Table')],
[[], []],
];
for (const [lignes, colonnes] of cas) {
for (const largeur of [0, 90, 480, 1200]) {
for (const coteMax of [Number.POSITIVE_INFINITY, 3 * HAUTEUR_TEXTE, 2 * HAUTEUR_TEXTE]) {
const lus = new Set();
const compter = (texte) => {
lus.add(texte);
return mesurer(texte);
};
const cadre = cadreGrille({ largeur, lignes, colonnes, mesurer: compter, hauteurTexte: HAUTEUR_TEXTE, coteMax });
const valeurs = Array.from({ length: lignes.length * colonnes.length }, (_, i) => (i % 7 === 0 ? null : i % 4));
const d = dispositionGrille({ largeur, lignes, colonnes, valeurs, mesurer, hauteurTexte: HAUTEUR_TEXTE, ecrire: graduer, coteMax });
const quoi = `${lignes.length} × ${colonnes.length}, ${largeur} px, borne ${coteMax}`;
assert.deepEqual(
[cadre.hauteur, cadre.largeur, cadre.cote, cadre.x0, cadre.y0, cadre.dessinable],
[d.hauteur, d.largeur, d.cote, d.x0, d.y0, d.dessinable],
quoi,
);
assert.equal(cadre.tournes, d.intitulesColonnes.some((i) => i.tourne), quoi);
for (const texte of lus) assert.ok([...lignes, ...colonnes].some((l) => l.libelle === texte), `${quoi} : ${texte} mesuré`);
}
}
}
const tournee = cadreGrille({ largeur: 600, lignes: intitules(2, 'Tour'), colonnes: longues, mesurer, hauteurTexte: HAUTEUR_TEXTE, coteMax: 2 * HAUTEUR_TEXTE });
assert.equal(tournee.tournes, true);
assert.equal(tournee.y0, mesurer('Une table au nom très long') + ESPACE_PX);
});
});
describe('pointage : la ligne de l’infobulle et les cellules du jumeau (§ 12.7, D34, D44)', () => {
const colonnes = [
{ cle: 'nom', libelle: 'Personne', genre: 'texte' },

View file

@ -55,7 +55,7 @@ import {
octetsCasLimites,
ouvrirEvenement,
} from '../../test/navigateur/ecrans.js';
import { fixerCadre, pointer, touche } from '../../test/navigateur/entrees.js';
import { dessinerGraphiques, fixerCadre, pointer, touche } from '../../test/navigateur/entrees.js';
import { monterApp } from '../../test/navigateur/montage.js';
import {
CADRE,
@ -1708,6 +1708,8 @@ describe('la page de qualité, sur chacun de ses écrans (§ 12.7, § 14.5, D30,
try {
const { charge, fichiers } = preparer();
const m = await ouvrirEvenement(contexte, charge, { section: 'qualite', options: { fichiers } });
await attendre(`la page de qualité (${nom})`, () => m.racine.querySelector('[data-section="qualite"] [data-vue-qualite]'));
await dessinerGraphiques(m.racine.querySelector('[data-section="qualite"]'));
await attendre(`la page de qualité dessinée (${nom})`, () => m.racine.querySelector('[data-section="qualite"] svg[data-graphique] [data-marque]'));
const commandes = [...m.racine.querySelectorAll('[data-section="qualite"] [data-commande="ouvrirJumeau"]')];
expect(commandes.length, `commandes de jumeau (${nom})`).toBeGreaterThan(0);

View file

@ -87,8 +87,10 @@ function empreinteDuTexte(texte) {
// drapeau siegesAttribues sous lequel elle l'a été. Un placement de
// proposition ne se modifie jamais en place : une commande en fait un neuf,
// ou modifie une copie (structuredClone) avant toute lecture. Chaque
// placement se hache ainsi une fois, et les propositions qu'un geste garde,
// ou qu'une datation recopie, retrouvent la leur sans la recalculer.
// placement se hache ainsi une fois. Une datation, qui recopie la
// proposition sans copier son placement, retrouve la sienne ; la copie
// qu'un geste fait d'un placement qu'il garde reçoit celle de l'original
// (heriterEmpreintes).
const EMPREINTES = new WeakMap();
/**
@ -109,6 +111,64 @@ export function empreinteDe(p) {
return empreinte;
}
// Vrai quand les listes a et b portent les mêmes valeurs dans le même
// ordre, comparées par ===.
function memeListe(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
return true;
}
// Vrai quand les placements a et b, chacun de la forme du schéma — une
// entrée par tour, { sieges, reserve } —, portent les mêmes listes : leurs
// textes canoniques sont alors égaux sous un même drapeau siegesAttribues,
// puisque texteDuPlacement ne lit que ces deux champs. La comparaison lit
// chaque occupant une fois et n'alloue rien.
function memePlacement(a, b) {
if (a.length !== b.length) return false;
for (let tour = 0; tour < a.length; tour += 1) {
const x = a[tour];
const y = b[tour];
if (!memeListe(x.reserve, y.reserve) || x.sieges.length !== y.sieges.length) return false;
for (let table = 0; table < x.sieges.length; table += 1) {
if (!memeListe(x.sieges[table], y.sieges[table])) return false;
}
}
return true;
}
/**
* Reporte les empreintes déjà calculées des propositions precedentes sur
* leurs copies dans suivantes, sans rien hacher. Une proposition de
* suivantes reçoit l'empreinte de la proposition de precedentes de même
* identifiant quand celle-ci en a une (EMPREINTES) et que leurs
* placements portent les mêmes listes (memePlacement) ; une proposition
* dont le placement diffère, même sous la même identité, ne reçoit rien
* et se hachera à sa prochaine clé. Le drapeau siegesAttribues suit
* l'empreinte reportée : sous un autre drapeau, empreinteDe la recalcule.
* Ne modifie aucune proposition. Un geste copie la charge entière
* (structuredClone) : sans ce report, chaque geste hacherait de nouveau
* chaque placement qu'il garde.
*
* @param {{id: number, placement: Object[]}[]} precedentes
* @param {{id: number, placement: Object[]}[]} suivantes propositions
* qui suivent le schéma, comme celles d'une charge examinée
*/
export function heriterEmpreintes(precedentes, suivantes) {
const parId = new Map(precedentes.map((p) => [p.id, p]));
for (const q of suivantes) {
const p = parId.get(q.id);
if (p === undefined || p.placement === q.placement || EMPREINTES.has(q.placement)) continue;
const connue = EMPREINTES.get(p.placement);
if (connue !== undefined && memePlacement(p.placement, q.placement)) EMPREINTES.set(q.placement, connue);
}
}
// L'identité d'une proposition ou d'une entrée hors empreinte : son
// identifiant, sa graine, son compte d'arrêt, son historique et son
// horodatage, vide quand x n'en porte pas, joints par « | ».
const identiteDe = (x) => `${x.id}|${x.graine}|${x.arret}|${x.historique}|${x.horodatage ?? ''}`;
/**
* La clé d'une proposition dans les courses en mémoire : son identifiant,
* sa graine, son compte d'arrêt, son historique, son horodatage — vide
@ -124,7 +184,7 @@ export function empreinteDe(p) {
*/
export function cleDeCourse(x) {
const empreinte = Object.hasOwn(x, 'empreinte') ? x.empreinte : empreinteDe(x);
return `${x.id}|${x.graine}|${x.arret}|${x.historique}|${x.horodatage ?? ''}|${empreinte}`;
return `${identiteDe(x)}|${empreinte}`;
}
/**
@ -232,14 +292,24 @@ export function avecCourses(courses, entrees) {
/**
* Les entrées de courses pour les propositions données, dans leur ordre :
* celle de la clé de chacune, quand courses la connaît. C'est ce que le
* compagnon d'une charge porte.
* compagnon d'une charge porte. Seule une proposition dont l'identité hors
* empreinte est celle d'une entrée de courses peut avoir la sienne : la
* clé, et donc l'empreinte, ne se calcule que pour elle.
*
* @param {Map<string, Object>} courses
* @param {Object[]} propositions
* @returns {Object[]}
*/
export function entreesPour(courses, propositions) {
return propositions.map((p) => courses.get(cleDeCourse(p))).filter((entree) => entree !== undefined);
if (courses.size === 0) return [];
const identites = new Set();
for (const entree of courses.values()) identites.add(identiteDe(entree));
const entrees = [];
for (const p of propositions) {
const entree = identites.has(identiteDe(p)) ? courses.get(cleDeCourse(p)) : undefined;
if (entree !== undefined) entrees.push(entree);
}
return entrees;
}
/**

View file

@ -17,6 +17,7 @@ import {
courseDe,
entreeDeCourse,
entreesPour,
heriterEmpreintes,
lireCourses,
texteDesCourses,
} from './courses.js';
@ -71,6 +72,24 @@ function texteAbime(entrees, abimer) {
const cles = (courses) => [...courses.keys()];
// Le nombre d'appels de Math.imul pendant faire(), et ce que faire rend :
// l'empreinte d'un placement est le seul code de src à l'appeler, si bien
// qu'un compte nul dit qu'aucun placement ne s'est haché.
function hachages(faire) {
const imul = Math.imul;
let n = 0;
Math.imul = (a, b) => {
n += 1;
return imul(a, b);
};
try {
const rendu = faire();
return { n, rendu };
} finally {
Math.imul = imul;
}
}
describe('le compagnon des courses : son texte (§ 8.9, B31)', () => {
test("une entrée par ligne, l'identité de la proposition puis sa course ; aucune entrée, une seule ligne ; le texte finit par une fin de ligne", () => {
const { entrees } = chargeEtEntrees([1, 2]);
@ -281,3 +300,88 @@ describe("le compagnon des courses : la course d'une proposition (§ 8.9, B31)",
assert.deepEqual(entreesPour(apres, []), []);
});
});
describe("le compagnon des courses : l'empreinte d'un placement copié (§ 8.9)", () => {
test("heriterEmpreintes : la copie d'un placement déjà haché reçoit son empreinte sans rien hacher ; un autre placement sous la même identité, ou un autre drapeau siegesAttribues, se hache de nouveau", () => {
const { charge } = chargeEtEntrees([1, 2]);
const inverse = chargeEtEntrees([1, 2], PLAN_INVERSE).charge.propositions[1];
const avant = charge.propositions.map(cleDeCourse);
const copies = structuredClone(charge.propositions);
copies[1].placement = structuredClone(inverse.placement);
const copieSansAttribution = structuredClone(charge.propositions[0]);
const report = hachages(() => heriterEmpreintes(charge.propositions, [...copies, copieSansAttribution]));
assert.equal(report.n, 0, 'le report ne hache rien');
const gardee = hachages(() => cleDeCourse(copies[0]));
assert.equal(gardee.n, 0, 'la copie du même placement ne se hache pas');
assert.equal(gardee.rendu, avant[0]);
// Même identifiant, graine, arrêt et historique, autre placement : la
// clé suit le placement, jamais l'empreinte de l'original.
const changee = hachages(() => cleDeCourse(copies[1]));
assert.ok(changee.n > 0, 'un autre placement se hache');
assert.notEqual(changee.rendu, avant[1]);
assert.equal(changee.rendu, cleDeCourse(inverse));
// L'empreinte reportée porte le drapeau de l'original.
copieSansAttribution.siegesAttribues = true;
assert.ok(hachages(() => cleDeCourse(copieSansAttribution)).n > 0);
});
// Chaque liste que lit le texte canonique compte : une copie dont une
// seule liste diffère n'hérite de rien, se hache, et prend la clé d'un
// clone neuf, qui ne porte aucune empreinte.
for (const [quoi, changer] of [
['seule une réserve change', (placement) => placement[0].reserve.push(99)],
[
'seul le dernier tour change',
(placement) => {
const { sieges } = placement.at(-1);
[sieges[0][0], sieges[1][0]] = [sieges[1][0], sieges[0][0]];
},
],
['un tour compte une liste de sièges de plus', (placement) => placement[0].sieges.push([])],
[
'seule la deuxième liste de sièges change',
(placement) => {
const { sieges } = placement[0];
[sieges[1][0], sieges[2][0]] = [sieges[2][0], sieges[1][0]];
},
],
[
'seuls deux occupants après le premier de leur liste changent',
(placement) => {
const { sieges } = placement[0];
[sieges[0][1], sieges[1][1]] = [sieges[1][1], sieges[0][1]];
},
],
['le placement compte un tour de plus', (placement) => placement.push(structuredClone(placement.at(-1)))],
]) {
test(`heriterEmpreintes : ${quoi}, sous la même identité — la copie se hache et prend la clé d'un clone neuf`, () => {
const { charge } = chargeEtEntrees([1, 2]);
const avant = charge.propositions.map(cleDeCourse);
const copies = structuredClone(charge.propositions);
changer(copies[0].placement);
const neuve = structuredClone(copies[0]);
assert.equal(hachages(() => heriterEmpreintes(charge.propositions, copies)).n, 0, 'le report ne hache rien');
const changee = hachages(() => cleDeCourse(copies[0]));
assert.ok(changee.n > 0, 'le placement changé se hache');
assert.equal(changee.rendu, cleDeCourse(neuve));
assert.notEqual(changee.rendu, avant[0]);
const gardee = hachages(() => cleDeCourse(copies[1]));
assert.deepEqual(gardee, { n: 0, rendu: avant[1] }, 'la proposition inchangée hérite');
});
}
test("entreesPour ne hache que les propositions dont l'identité est celle d'une entrée ; sans aucune course, elle ne hache rien", () => {
const { charge, entrees } = chargeEtEntrees([1, 2, 3]);
const copies = structuredClone(charge.propositions);
const courses = avecCourses(new Map(), [entrees[1]]);
assert.deepEqual(hachages(() => entreesPour(new Map(), copies)), { n: 0, rendu: [] });
const une = hachages(() => entreesPour(courses, copies));
assert.deepEqual(une.rendu, [entrees[1]]);
const seule = hachages(() => cleDeCourse(structuredClone(charge.propositions[1])));
assert.equal(une.n, seule.n, "seule la proposition 2 s'est hachée");
});
});

View file

@ -107,7 +107,7 @@
// propositions écrites ne sont plus celles qu'il porte (poserCompagnon) ;
// un refus d'écrire le compagnon ne refuse pas l'écriture.
import { canoniser, serialiser } from './canonique.js';
import { avecCourses, cleDeCourse, entreesPour, lireCourses, texteDesCourses } from './courses.js';
import { avecCourses, cleDeCourse, entreesPour, heriterEmpreintes, lireCourses, texteDesCourses } from './courses.js';
import { FORMAT, analyser } from './document.js';
import { ErreurStockage } from './erreurs.js';
import {
@ -1186,11 +1186,14 @@ const memesCles = (a, b) => a.length === b.length && [...a].sort().join('\n') ==
// le disque porte, ou que celles-ci sont inconnues : une génération, un
// effacement, un retour qui rend ou retire des propositions, et l'écriture
// qui suit un compagnon refusé. Un geste qui garde les mêmes propositions
// ne l'écrit jamais. L'écriture est atomique, comme celle de l'état. Un
// refus du système de fichiers ne remonte pas : le compagnon reste ce
// qu'il était, l'Ouvert le dit inconnu, et la course reste connue de
// l'Ouvert pour la séance. Toute autre erreur remonte.
async function poserCompagnon(ouvert) {
// ne l'écrit jamais. precedentes, les propositions de l'Ouvert d'avant le
// geste, lui passent leurs empreintes (heriterEmpreintes) : un geste qui
// garde les mêmes placements n'en hache aucun. L'écriture est atomique,
// comme celle de l'état. Un refus du système de fichiers ne remonte pas :
// le compagnon reste ce qu'il était, l'Ouvert le dit inconnu, et la course
// reste connue de l'Ouvert pour la séance. Toute autre erreur remonte.
async function poserCompagnon(ouvert, precedentes = []) {
if (ouvert.courses.size > 0) heriterEmpreintes(precedentes, ouvert.charge.propositions);
const entrees = entreesPour(ouvert.courses, ouvert.charge.propositions);
const cles = entrees.map(cleDeCourse);
if (ouvert.compagnon !== null && memesCles(cles, ouvert.compagnon)) return ouvert;
@ -1366,7 +1369,7 @@ export async function enregistrer(ouvert, apres, options) {
const memoire = avecCourses(ouvert.courses, courses);
const avance = { ...suite.depart, ...ecrit, produitVersion, chargeJournal: canoniser(apres), courses: memoire };
const examine = apresEcriture(avance, { charge: apres, revision, journal: ecrit.journal, recree });
return poserCompagnon(await ecrireEtat(examine, texteEtat));
return poserCompagnon(await ecrireEtat(examine, texteEtat), ouvert.charge.propositions);
}
/**

View file

@ -2241,6 +2241,25 @@ function generation(charge) {
// Le texte du compagnon des courses de l'événement d'épreuve.
const compagnon = (entrees) => texteDesCourses('evt-lucioles', entrees);
// Le nombre d'appels de Math.imul pendant faire(), et ce que faire rend :
// l'empreinte d'un placement (courses.js) est le seul code de src à
// l'appeler, si bien qu'un compte nul dit qu'aucun placement ne s'est
// haché.
async function hachages(faire) {
const imul = Math.imul;
let n = 0;
Math.imul = (a, b) => {
n += 1;
return imul(a, b);
};
try {
const rendu = await faire();
return { n, rendu };
} finally {
Math.imul = imul;
}
}
describe('dépôt : le compagnon des courses (§ 8.6, § 8.9, B31)', () => {
test("une génération écrit le compagnon après l'état ; un geste qui garde les mêmes propositions ne l'écrit pas ; rouvert, l'événement retrouve chaque course", async () => {
const { disque, fs } = nouveauDisque();
@ -2272,6 +2291,34 @@ describe('dépôt : le compagnon des courses (§ 8.6, § 8.9, B31)', () => {
assert.deepEqual(courseDe(rouvert, 2), courseDe(genere, 2));
});
test("un geste qui garde les mêmes placements n'en hache aucun ; un placement changé sous la même identité se hache, et le compagnon se réécrit sans sa course", async () => {
const { fs } = nouveauDisque();
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
const { apres, courses } = generation(ouvert.charge);
const genere = await enregistrer(ouvert, apres, geste(2, { courses }));
const trace = tracer(fs);
let suite = genere;
for (const n of [3, 4]) {
const charge = gesteSur(suite.charge, n);
const ordinaire = await hachages(() => enregistrer(suite, charge, geste(n)));
assert.equal(ordinaire.n, 0, `le geste ${n} ne hache aucun placement`);
suite = ordinaire.rendu;
}
assert.deepEqual(trace.filter(([, chemin]) => chemin === COURSES), []);
assert.equal(courseDe(suite, 2).evalues, 100);
// La proposition 2 garde son identifiant, sa graine, son arrêt et son
// historique, sur un autre premier tour.
const changee = structuredClone(suite.charge);
changee.propositions[1].placement[0].sieges = [[1, 4], [2, 3]];
const geste5 = await hachages(() => enregistrer(suite, changee, geste(5)));
assert.ok(geste5.n > 0, 'le placement changé se hache');
assert.equal(fs.contenu('documents', COURSES), compagnon([courses[1]]));
assert.equal(courseDe(geste5.rendu, 2), null);
assert.equal(courseDe(geste5.rendu, 3).evalues, 1_000);
});
test("effacer puis défaire : le compagnon se réécrit sans les courses effacées, l'Ouvert les garde en mémoire, et le retour les réécrit ; après une réouverture, elles ne sont plus connues", async () => {
const { disque, fs } = nouveauDisque();
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);

View file

@ -63,6 +63,7 @@ const ENTREES = [
['data-demonstration', 'accueil : une démonstration, par sa clé', null],
['data-derive', 'événement : un placement en dérive, par son identifiant', null],
['data-dernier-changement', 'qualité : la phrase du dernier changement de la performance du calcul', ['']],
['data-dessin-differe', 'qualité : le <svg> d’un graphique dont les marques attendent d’approcher de la zone visible, posé sans valeur', null],
['data-devient', 'import : ce que devient une ligne', ['']],
['data-donnee', 'diagnostic : une donnée nommée dans une phrase', ['']],
['data-dossier', 'accueil : le dossier de travail montré', ['']],

View file

@ -34,7 +34,7 @@ import { SUFFIXES } from '../../src/stockage/noms.js';
import { FICHIER_REGLAGES, serialiserReglages } from '../../src/stockage/reglages_locaux.js';
import { VERSION } from '../../src/version.genere.js';
import urlCasLimites from '../../exemples/participants_cas_limites.csv?url';
import { pointer, touche } from './entrees.js';
import { dessinerGraphiques, pointer, touche } from './entrees.js';
import { monterApp } from './montage.js';
/** La base du fichier d'état de chaque événement d'épreuve. */
@ -507,6 +507,9 @@ function ecranDeSection(section, mode) {
async atteindre(contexte) {
const charge = mode === 'bloque' ? chargeBloquee() : chargePrincipale();
const m = await ouvrirEvenement(contexte, charge, { ecriture: mode === 'ecriture', section });
// La page de qualité se lit dessinée, chaque graphique amené à son
// tour : les gardes lisent ses marques sous chaque mode.
if (section === 'qualite') await qualiteDessinee(m);
return m.racine;
},
};
@ -516,8 +519,11 @@ function ecranDeSection(section, mode) {
const planEnEcriture = (contexte, options) => ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'plan', options });
// La page de qualité du montage dessinée : une vue posée au moins, chaque
// graphique de la section mesuré et porteur d'une marque au moins.
// graphique de la section amené à son tour dans la zone visible
// (dessinerGraphiques), mesuré et porteur d'une marque au moins.
async function qualiteDessinee(m) {
await attendre('une vue de la page de qualité', () => m.racine.querySelector('[data-section="qualite"] [data-vue-qualite]'));
await dessinerGraphiques(m.racine.querySelector('[data-section="qualite"]'));
await attendre('la page de qualité dessinée', () => {
const section = m.racine.querySelector('[data-section="qualite"]');
if (section === null || section.querySelector('[data-vue-qualite]') === null) return false;

View file

@ -179,3 +179,40 @@ export async function fixerCadre(largeur, hauteur, { poser = (l, h) => page.view
}
return lu;
}
// Images attendues au plus pour qu'un graphique amené dans la zone visible
// se dessine : son observateur répond à l'image qui suit le défilement, et
// le dessin vient avec le rendu suivant.
const IMAGES_DESSIN = 120;
/** Dessine chaque graphique de racine dont le dessin attend d'approcher de
* la zone visible (data-dessin-differe, Graphique.svelte) : le premier qui
* attend s'amène au centre de la zone visible, puis les images passent
* jusqu'à ce qu'il soit dessiné — ses voisins proches avec lui —, et ainsi
* de suite. Un graphique dessiné le reste. Chaque position de défilement
* que les amenées changent — la fenêtre, chaque ancêtre qui défile — se
* rétablit ensuite : l'épreuve retrouve la page où elle la regardait, ses
* marques toutes rendues. Lève en nommant le graphique qui ne se dessine
* pas en IMAGES_DESSIN images. Rend le nombre de graphiques amenés. */
export async function dessinerGraphiques(racine) {
const positions = new Map();
const garder = (element) => {
for (let ancetre = element.parentElement; ancetre !== null; ancetre = ancetre.parentElement) {
if (!positions.has(ancetre)) positions.set(ancetre, [ancetre.scrollLeft, ancetre.scrollTop]);
}
};
const fenetre = [scrollX, scrollY];
let amenes = 0;
for (let differe = racine.querySelector('svg[data-dessin-differe]'); differe !== null; differe = racine.querySelector('svg[data-dessin-differe]')) {
garder(differe);
differe.scrollIntoView({ block: 'center' });
amenes += 1;
for (let image = 0; differe.isConnected && differe.hasAttribute('data-dessin-differe'); image += 1) {
if (image === IMAGES_DESSIN) throw new Error(`le graphique ${differe.dataset.graphique} ne se dessine pas`);
await new Promise((resoudre) => requestAnimationFrame(resoudre));
}
}
for (const [ancetre, [gauche, haut]] of positions) ancetre.scrollTo(gauche, haut);
scrollTo(...fenetre);
return amenes;
}