diff --git a/src/interface/parcours.navigateur.test.js b/src/interface/parcours.navigateur.test.js index 917d60a..33b6811 100644 --- a/src/interface/parcours.navigateur.test.js +++ b/src/interface/parcours.navigateur.test.js @@ -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 diff --git a/src/interface/participants/Fiche.navigateur.test.js b/src/interface/participants/Fiche.navigateur.test.js index 07a62fe..5d4e163 100644 --- a/src/interface/participants/Fiche.navigateur.test.js +++ b/src/interface/participants/Fiche.navigateur.test.js @@ -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)); } } diff --git a/src/interface/plan/mesure_texte.js b/src/interface/plan/mesure_texte.js index 913a467..e28c564 100644 --- a/src/interface/plan/mesure_texte.js +++ b/src/interface/plan/mesure_texte.js @@ -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 . +// 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 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: (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 }; +} diff --git a/src/interface/qualite/Appartenances.navigateur.test.js b/src/interface/qualite/Appartenances.navigateur.test.js index e39c555..712e75f 100644 --- a/src/interface/qualite/Appartenances.navigateur.test.js +++ b/src/interface/qualite/Appartenances.navigateur.test.js @@ -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); diff --git a/src/interface/qualite/Paires.navigateur.test.js b/src/interface/qualite/Paires.navigateur.test.js index c3a510b..0e851d7 100644 --- a/src/interface/qualite/Paires.navigateur.test.js +++ b/src/interface/qualite/Paires.navigateur.test.js @@ -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)); } } diff --git a/src/interface/qualite/Performance.navigateur.test.js b/src/interface/qualite/Performance.navigateur.test.js index 92c9a67..363c6fe 100644 --- a/src/interface/qualite/Performance.navigateur.test.js +++ b/src/interface/qualite/Performance.navigateur.test.js @@ -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)); } } diff --git a/src/interface/qualite/Qualite.navigateur.test.js b/src/interface/qualite/Qualite.navigateur.test.js index 5992136..d403d19 100644 --- a/src/interface/qualite/Qualite.navigateur.test.js +++ b/src/interface/qualite/Qualite.navigateur.test.js @@ -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()); diff --git a/src/interface/qualite/SectionQualite.svelte b/src/interface/qualite/SectionQualite.svelte index b7057a4..e370c1b 100644 --- a/src/interface/qualite/SectionQualite.svelte +++ b/src/interface/qualite/SectionQualite.svelte @@ -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..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 }); } diff --git a/src/interface/qualite/graphiques/Barres.svelte b/src/interface/qualite/graphiques/Barres.svelte index aa0454d..b9e87c0 100644 --- a/src/interface/qualite/graphiques/Barres.svelte +++ b/src/interface/qualite/graphiques/Barres.svelte @@ -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} diff --git a/src/interface/qualite/graphiques/Escalier.svelte b/src/interface/qualite/graphiques/Escalier.svelte index 261cf5f..e96cd0a 100644 --- a/src/interface/qualite/graphiques/Escalier.svelte +++ b/src/interface/qualite/graphiques/Escalier.svelte @@ -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} diff --git a/src/interface/qualite/graphiques/Graphique.svelte b/src/interface/qualite/graphiques/Graphique.svelte index dcf9e8b..c4f4ca3 100644 --- a/src/interface/qualite/graphiques/Graphique.svelte +++ b/src/interface/qualite/graphiques/Graphique.svelte @@ -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 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 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 porte + // data-dessin-differe tant que ses marques attendent : un + // 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 @@ {/snippet} -
+
- {@render children()} + {#if dessine} + {@render children()} + {/if} {#if zone !== null} , 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 , 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. -->
-{#if disposition !== null && !disposition.dessinable} +{#if cadre !== null && !cadre.dessinable}

{#if typeof phrase === 'function'}{@render phrase()}{:else}{phrase}{/if}

{:else} {#if disposition !== null} @@ -136,7 +158,6 @@ style:fill={`var(${c.jeton})`} /> {/if} - {#if !c.diagonale} {/each} + {#if pointe !== null && disposition.cases[pointe] !== undefined} {@const c = disposition.cases[pointe]} @@ -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 , et un trait sans + lissage peint son jeton entier, sur la même rangée de pixels qu'un + 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. */ diff --git a/src/interface/qualite/graphiques/dessin_differe.js b/src/interface/qualite/graphiques/dessin_differe.js new file mode 100644 index 0000000..2b00925 --- /dev/null +++ b/src/interface/qualite/graphiques/dessin_differe.js @@ -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(); + } + }, + }; +} diff --git a/src/interface/qualite/graphiques/graphiques.navigateur.test.js b/src/interface/qualite/graphiques/graphiques.navigateur.test.js index 4675bd8..52b8a2e 100644 --- a/src/interface/qualite/graphiques/graphiques.navigateur.test.js +++ b/src/interface/qualite/graphiques/graphiques.navigateur.test.js @@ -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 , 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 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); + }); +}); diff --git a/src/interface/qualite/graphiques/pointage.js b/src/interface/qualite/graphiques/pointage.js index ffe05fc..e7675a2 100644 --- a/src/interface/qualite/graphiques/pointage.js +++ b/src/interface/qualite/graphiques/pointage.js @@ -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 + * trace ce que traceraient autant de , 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(''), }; } diff --git a/src/interface/qualite/graphiques/pointage.test.js b/src/interface/qualite/graphiques/pointage.test.js index 768cfd5..d9679fd 100644 --- a/src/interface/qualite/graphiques/pointage.test.js +++ b/src/interface/qualite/graphiques/pointage.test.js @@ -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' }, diff --git a/src/interface/transversal.navigateur.test.js b/src/interface/transversal.navigateur.test.js index 3c562cb..69fb547 100644 --- a/src/interface/transversal.navigateur.test.js +++ b/src/interface/transversal.navigateur.test.js @@ -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); diff --git a/src/stockage/courses.js b/src/stockage/courses.js index 222c2f9..eb0ee54 100644 --- a/src/stockage/courses.js +++ b/src/stockage/courses.js @@ -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} 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; } /** diff --git a/src/stockage/courses.test.js b/src/stockage/courses.test.js index 80406e2..99a9913 100644 --- a/src/stockage/courses.test.js +++ b/src/stockage/courses.test.js @@ -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"); + }); +}); diff --git a/src/stockage/depot.js b/src/stockage/depot.js index f5bb746..84ea8d8 100644 --- a/src/stockage/depot.js +++ b/src/stockage/depot.js @@ -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); } /** diff --git a/src/stockage/depot.test.js b/src/stockage/depot.test.js index 9731b34..1323269 100644 --- a/src/stockage/depot.test.js +++ b/src/stockage/depot.test.js @@ -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); diff --git a/test/navigateur/designations.js b/test/navigateur/designations.js index 85ecc54..4d0b164 100644 --- a/test/navigateur/designations.js +++ b/test/navigateur/designations.js @@ -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 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é', ['']], diff --git a/test/navigateur/ecrans.js b/test/navigateur/ecrans.js index 1ac8288..0704bac 100644 --- a/test/navigateur/ecrans.js +++ b/test/navigateur/ecrans.js @@ -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; diff --git a/test/navigateur/entrees.js b/test/navigateur/entrees.js index 7044e06..152b499 100644 --- a/test/navigateur/entrees.js +++ b/test/navigateur/entrees.js @@ -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; +}