[IMP] quality page: deferred charts, cached text, hash-free gestures
Opening the quality tab measured text and forced layout per chart, and every gesture rehashed each placement because a cloned load lost its fingerprints. A gesture now inherits the fingerprint of an unchanged placement; charts far from view draw on approach, holding their drawn height and their legend, so a link or a scroll lands where the fully drawn page puts it. Checked, 200 propositions: tab opening 84.5 -> 27.1 ms (limit 50), 4x CPU 370.7 -> 113.9 ms, 1,973 -> 485 elements, a gesture back at its prior cost. Every memePlacement mutant fails its own test; 44 captures byte-identical. --- FR --- [IMP] page qualité : graphiques différés, texte en cache, gestes légers L'onglet Qualité mesurait le texte et forçait la mise en page à chaque graphique, et chaque geste rehachait chaque placement, la charge clonée perdant ses empreintes. Un geste hérite de l'empreinte d'un placement inchangé ; un graphique loin de la vue se dessine à l'approche, sa hauteur et sa légende déjà posées : un lien ou un défilement arrive où le met la page entièrement dessinée. Vérifié, 200 propositions : ouverture 84,5 -> 27,1 ms (limite 50), CPU 4x 370,7 -> 113,9 ms, 1 973 -> 485 éléments, un geste revenu à son coût. Chaque mutant de memePlacement fait échouer son test ; 44 captures identiques. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
91a0cea4cb
commit
2cb1e771a2
24 changed files with 1162 additions and 132 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
// d'une ligne de liste et la largeur d'une chaîne, relevées sur la police
|
||||
// rendue, jamais déduites de la taille de police. La hauteur se mesure dans
|
||||
// un OffscreenCanvas, hors du document : le plan ne porte aucun <canvas>.
|
||||
// Les graphiques de la page de qualité mesurent par mesuresDuTexte, qui
|
||||
// garde les largeurs pour la séance et les relève par lots.
|
||||
|
||||
import { GABARIT_HAUTEUR_LIGNE } from '../../geometrie/texte.js';
|
||||
|
||||
|
|
@ -66,3 +68,115 @@ export function creerMesureur(elementTexte) {
|
|||
return largeur;
|
||||
};
|
||||
}
|
||||
|
||||
// Les mesures déjà prises pendant la séance, par style de texte (cleDuStyle)
|
||||
// : { largeurs, hauteur }, largeurs une Map de la chaîne à sa largeur,
|
||||
// hauteur celle de hauteurLigne, null tant qu'elle n'est pas lue. Une
|
||||
// police qui finit de se charger change les largeurs sous le même style
|
||||
// calculé : chaque fin de chargement du document vide la table.
|
||||
const MESURES_DE_SEANCE = new Map();
|
||||
let polices = null;
|
||||
|
||||
// La clé du style de texte de l'élément : la densité de pixels de l'écran,
|
||||
// que le lissage des glyphes suit, puis chaque propriété calculée qui
|
||||
// change la largeur d'une chaîne.
|
||||
function cleDuStyle(elementTexte) {
|
||||
const style = getComputedStyle(elementTexte);
|
||||
const proprietes = [
|
||||
'fontStyle',
|
||||
'fontVariant',
|
||||
'fontWeight',
|
||||
'fontStretch',
|
||||
'fontSize',
|
||||
'fontFamily',
|
||||
'letterSpacing',
|
||||
'wordSpacing',
|
||||
'fontKerning',
|
||||
'fontFeatureSettings',
|
||||
'fontVariationSettings',
|
||||
'textRendering',
|
||||
];
|
||||
return [devicePixelRatio, ...proprietes.map((nom) => style[nom])].join('|');
|
||||
}
|
||||
|
||||
// Les mesures de séance du style de l'élément, créées vides au premier
|
||||
// appel ; la première écoute aussi les fins de chargement des polices.
|
||||
function mesuresDuStyle(elementTexte) {
|
||||
const documentTexte = elementTexte.ownerDocument;
|
||||
if (polices !== documentTexte.fonts) {
|
||||
polices = documentTexte.fonts;
|
||||
polices.addEventListener('loadingdone', () => MESURES_DE_SEANCE.clear());
|
||||
}
|
||||
const cle = cleDuStyle(elementTexte);
|
||||
let connues = MESURES_DE_SEANCE.get(cle);
|
||||
if (connues === undefined) {
|
||||
connues = { largeurs: new Map(), hauteur: null };
|
||||
MESURES_DE_SEANCE.set(cle, connues);
|
||||
}
|
||||
return connues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les mesures du texte d'un graphique au style de l'élément, gardées pour
|
||||
* la séance : deux éléments du même style calculé, à la même densité de
|
||||
* pixels, partagent leurs largeurs. L'élément est un <text> d'un dessin
|
||||
* sans viewBox, dont l'unité est le pixel CSS.
|
||||
*
|
||||
* - mesurer(chaine) : la largeur de la chaîne, mesurée à la première
|
||||
* demande ;
|
||||
* - calculer(calcul) : ce que calcul(mesurer) rend, calcul une fonction
|
||||
* pure de ses arguments et des largeurs lues. Les chaînes inconnues se
|
||||
* mesurent par lots : calcul s'exécute avec 0 pour chacune, ce qu'il
|
||||
* a demandé et que la séance ignore se mesure d'un seul relevé, puis il
|
||||
* s'exécute de nouveau, jusqu'à ce qu'il ne demande plus rien
|
||||
* d'inconnu. Le rendu final ne lit que des largeurs mesurées, et le
|
||||
* document ne se met en page qu'une fois par lot, au lieu d'une fois par
|
||||
* chaîne ;
|
||||
* - hauteurTexte : hauteurLigne de l'élément, lue une fois par style.
|
||||
*
|
||||
* Chaque chaîne d'un lot se mesure sur sa propre copie de l'élément
|
||||
* (cloneNode, sans enfant), posée juste après lui le temps du relevé puis
|
||||
* retirée : même style, même contexte, aucun crénage avec une voisine. Une
|
||||
* largeur nulle d'une chaîne non vide — l'élément n'est pas rendu — ne se
|
||||
* garde pas, comme pour creerMesureur : elle vaut 0 pour le calcul en
|
||||
* cours et se mesure de nouveau à la demande suivante.
|
||||
*
|
||||
* @param {Element} elementTexte
|
||||
* @returns {{mesurer: (chaine: string) => number, calculer: <T>(calcul: (mesurer: (chaine: string) => number) => T) => T, hauteurTexte: number}}
|
||||
*/
|
||||
export function mesuresDuTexte(elementTexte) {
|
||||
const connues = mesuresDuStyle(elementTexte);
|
||||
// Mesure les chaînes d'un seul relevé ; garde les largeurs non nulles,
|
||||
// et rend la largeur de chacune, nulle comprise.
|
||||
const relever = (chaines) => {
|
||||
const copies = chaines.map((chaine) => {
|
||||
const copie = elementTexte.cloneNode(false);
|
||||
copie.textContent = chaine;
|
||||
return copie;
|
||||
});
|
||||
elementTexte.after(...copies);
|
||||
const largeurs = copies.map((copie) => copie.getComputedTextLength());
|
||||
for (const copie of copies) copie.remove();
|
||||
chaines.forEach((chaine, i) => {
|
||||
if (largeurs[i] > 0 || chaine === '') connues.largeurs.set(chaine, largeurs[i]);
|
||||
});
|
||||
return largeurs;
|
||||
};
|
||||
const mesurer = (chaine) => connues.largeurs.get(chaine) ?? relever([chaine])[0];
|
||||
const calculer = (calcul) => {
|
||||
const nulles = new Set();
|
||||
for (;;) {
|
||||
const inconnues = new Set();
|
||||
const resultat = calcul((chaine) => {
|
||||
const connue = connues.largeurs.get(chaine);
|
||||
if (connue === undefined && !nulles.has(chaine)) inconnues.add(chaine);
|
||||
return connue ?? 0;
|
||||
});
|
||||
if (inconnues.size === 0) return resultat;
|
||||
relever([...inconnues]);
|
||||
for (const chaine of inconnues) if (!connues.largeurs.has(chaine)) nulles.add(chaine);
|
||||
}
|
||||
};
|
||||
if (connues.hauteur === null) connues.hauteur = hauteurLigne(elementTexte);
|
||||
return { mesurer, calculer, hauteurTexte: connues.hauteur };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -38,7 +38,11 @@
|
|||
// seule source mesurable, avec la course que la séance garde et la
|
||||
// génération qui l'a produite ; sa garde à quatre paramètres donne les
|
||||
// contradictions qui retirent le dessin.
|
||||
import { getContext } from 'svelte';
|
||||
//
|
||||
// Elle pose le contexte dessinDiffere : un graphique loin de la zone
|
||||
// visible n'est dessiné que quand il en approche (Graphique), si bien que
|
||||
// l'ouverture ne mesure et ne rend que ce qui se voit.
|
||||
import { flushSync, getContext, setContext } from 'svelte';
|
||||
import Diagnostic from '../diagnostic/Diagnostic.svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
|
|
@ -55,12 +59,14 @@
|
|||
import VuePerformance from './VuePerformance.svelte';
|
||||
import VueProfil from './VueProfil.svelte';
|
||||
import VueTables from './VueTables.svelte';
|
||||
import { creerDessinDiffere } from './graphiques/dessin_differe.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const ui = getContext('ui');
|
||||
const seance = getContext('seance');
|
||||
const generation = getContext('generation');
|
||||
const uid = $props.id();
|
||||
const dessinDiffere = setContext('dessinDiffere', creerDessinDiffere());
|
||||
|
||||
// Les vues, dans leur ordre à l'écran (§ 12, B9), chacune sous le nom de
|
||||
// la clé de son titre, qualite.vue.<nom>.titre.
|
||||
|
|
@ -94,12 +100,17 @@
|
|||
const idVue = (nom) => `${uid}-vue-${nom}`;
|
||||
|
||||
// Porte le focus sur la vue nommée, qui se fait voir ; le lien ne change
|
||||
// pas l'adresse de la page.
|
||||
// pas l'adresse de la page. Les graphiques en attente qui la précèdent se
|
||||
// dessinent d'abord, rendus avant le défilement : aucun dessin au-dessus
|
||||
// d'elle ne la déplace ensuite.
|
||||
function allerALaVue(evenement, nom) {
|
||||
evenement.preventDefault();
|
||||
const cible = document.getElementById(idVue(nom));
|
||||
cible?.scrollIntoView({ block: 'start' });
|
||||
cible?.focus({ preventScroll: true });
|
||||
if (cible === null) return;
|
||||
dessinDiffere.dessinerAvant(cible);
|
||||
flushSync();
|
||||
cible.scrollIntoView({ block: 'start' });
|
||||
cible.focus({ preventScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,20 @@
|
|||
// qu'un dessin dont la hauteur suit la largeur — une grille — ne change
|
||||
// pas, dans l'image même, la taille d'un élément déjà mesuré, ce que
|
||||
// ResizeObserver signale comme une boucle. Le texte se mesure sur
|
||||
// une sonde du dessin (mesures, liable : {mesurer, hauteurTexte}, null
|
||||
// tant que la sonde n'est pas rendue) : le composant qui remplit le cadre
|
||||
// en tire sa disposition (pointage.js), puis rend ses marques par
|
||||
// une sonde du dessin (mesures, liable : {mesurer, calculer,
|
||||
// hauteurTexte} de mesuresDuTexte, null tant que la sonde n'est pas
|
||||
// rendue) : le composant qui remplit le cadre en tire sa disposition
|
||||
// (pointage.js) par calculer, qui relève ses libellés inconnus par lots
|
||||
// et garde leurs largeurs pour la séance, puis rend ses marques par
|
||||
// children.
|
||||
//
|
||||
// dessine, liable, dit que les marques se rendent (le dessin différé,
|
||||
// plus bas) : le composant qui remplit le cadre ne calcule sa disposition
|
||||
// qu'alors. Avant, il ne mesure que ce qui fixe sa place dans la page :
|
||||
// les marges d'alignement d'un escalier, qui alignent deux graphiques, et
|
||||
// les intitulés d'une grille, qui décident de sa hauteur. La hauteur
|
||||
// qu'il passe est déjà celle de son dessin.
|
||||
//
|
||||
// Le pointage au plus proche (§ 12.7) : zone, {x, y, largeur, hauteur} en
|
||||
// px du dessin, porte la surface de pointage, un <rect fill="none"
|
||||
// pointer-events="all"> posé après les marques, sœur d'elles, jamais leur
|
||||
|
|
@ -44,13 +53,32 @@
|
|||
//
|
||||
// legende : [{genre, libelle, signe}] — un trait, un remplissage ou un
|
||||
// signe, chacun dessiné de son jeton et de son motif, puis son libellé ;
|
||||
// libelle null pour un signe, que la table des graphiques nomme. synthese :
|
||||
// un texte déjà traduit, ou un snippet qui le rend.
|
||||
// libelle null pour un signe, que la table des graphiques nomme. Elle se
|
||||
// tire des données (legendeTraces, legendeBarres), jamais du dessin : la
|
||||
// liste se rend dès le montage, marques en attente ou non. synthese : un
|
||||
// texte déjà traduit, ou un snippet qui le rend.
|
||||
//
|
||||
// Le dessin différé : sous le contexte dessinDiffere (dessin_differe.js),
|
||||
// que la page de qualité pose, les marques d'un graphique loin de la zone
|
||||
// visible ne se disposent ni ne se rendent, et leurs libellés ne se
|
||||
// mesurent pas. Le <svg> garde la hauteur de son dessin, la légende et la
|
||||
// phrase de synthèse sont rendues : le dessin ne déplace rien dans la
|
||||
// page. L'image nommée et décrite reste, et le <svg> porte
|
||||
// data-dessin-differe tant que ses marques attendent : un <svg role="img">
|
||||
// ne montre pas ses enfants au lecteur d'écran, et le jumeau, chemin du
|
||||
// clavier et du lecteur d'écran, ne dépend pas du dessin. Une fois sa
|
||||
// largeur lue, un graphique qui coupe la zone visible — celle du plus
|
||||
// proche ancêtre qui défile, sinon la fenêtre — se dessine aussitôt ; un
|
||||
// autre s'inscrit au registre du contexte, qui peut le dessiner sur
|
||||
// demande, et attend qu'un IntersectionObserver, de marge une hauteur de
|
||||
// cette zone au-dessus et au-dessous, le voie approcher. Un graphique
|
||||
// dessiné le reste. Hors de ce contexte, chaque graphique se dessine dès
|
||||
// sa largeur lue.
|
||||
//
|
||||
// Aucune opacité, aucun filtre, aucun mode de fusion sur une marque, un
|
||||
// texte ou leur ascendance (§ 14.5).
|
||||
import { getContext } from 'svelte';
|
||||
import { creerMesureur, hauteurLigne, mesurable } from '../../plan/mesure_texte.js';
|
||||
import { mesurable, mesuresDuTexte } from '../../plan/mesure_texte.js';
|
||||
import { placerInfobulle } from '../../primitives/infobulle.js';
|
||||
import { effetDe } from '../../raccourcis.js';
|
||||
import Pourcentage from './Pourcentage.svelte';
|
||||
|
|
@ -69,6 +97,7 @@
|
|||
largeur = $bindable(0),
|
||||
mesures = $bindable(null),
|
||||
pointe = $bindable(null),
|
||||
dessine = $bindable(false),
|
||||
children,
|
||||
} = $props();
|
||||
|
||||
|
|
@ -77,8 +106,10 @@
|
|||
const uid = $props.id();
|
||||
const idNom = `${uid}-nom`;
|
||||
const idSynthese = `${uid}-synthese`;
|
||||
const differe = getContext('dessinDiffere') ?? null;
|
||||
|
||||
let svg = $state(null);
|
||||
let cadre = $state(null);
|
||||
let sonde = $state(null);
|
||||
let bulle = $state(null);
|
||||
let survole = $state(false);
|
||||
|
|
@ -86,11 +117,53 @@
|
|||
// qu'elle n'est pas placée.
|
||||
let place = $state(null);
|
||||
|
||||
// Le plus proche ancêtre de l'élément qui fait défiler son contenu à la
|
||||
// verticale, ou null.
|
||||
function ancetreQuiDefile(element) {
|
||||
for (let ancetre = element.parentElement; ancetre !== null; ancetre = ancetre.parentElement) {
|
||||
const { overflowY } = getComputedStyle(ancetre);
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') return ancetre;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Le dessin, dès la largeur lue ; différé, le cadre lu une fois, dans la
|
||||
// mise en page que la lecture de la largeur vient de faire, sinon le
|
||||
// registre et l'observateur, retirés au dessin.
|
||||
$effect(() => {
|
||||
if (dessine || cadre === null || largeur === 0) return undefined;
|
||||
if (differe === null) {
|
||||
dessine = true;
|
||||
return undefined;
|
||||
}
|
||||
const defile = ancetreQuiDefile(cadre);
|
||||
const zone = defile === null ? { top: 0, bottom: innerHeight } : defile.getBoundingClientRect();
|
||||
const boite = cadre.getBoundingClientRect();
|
||||
if (boite.bottom >= zone.top && boite.top <= zone.bottom) {
|
||||
dessine = true;
|
||||
return undefined;
|
||||
}
|
||||
const observateur = new IntersectionObserver(
|
||||
(entrees) => {
|
||||
if (entrees.some((entree) => entree.isIntersecting)) dessine = true;
|
||||
},
|
||||
{ root: defile, rootMargin: '100% 0px' },
|
||||
);
|
||||
observateur.observe(cadre);
|
||||
const retirer = differe.inscrire(cadre, () => {
|
||||
dessine = true;
|
||||
});
|
||||
return () => {
|
||||
observateur.disconnect();
|
||||
retirer();
|
||||
};
|
||||
});
|
||||
|
||||
// La mesure du texte, relevée sur la sonde dès qu'elle est rendue : sous
|
||||
// un ancêtre caché, elle attend que la largeur mesurée change.
|
||||
$effect(() => {
|
||||
if (largeur > 0 && sonde !== null && mesures === null && mesurable(sonde)) {
|
||||
mesures = { mesurer: creerMesureur(sonde), hauteurTexte: hauteurLigne(sonde) };
|
||||
mesures = mesuresDuTexte(sonde);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -116,14 +189,10 @@
|
|||
// repère de la fenêtre, du plus proche ancêtre qui fait défiler son
|
||||
// contenu à la verticale ; {} sans un tel ancêtre.
|
||||
function zoneVisible(element) {
|
||||
for (let ancetre = element.parentElement; ancetre !== null; ancetre = ancetre.parentElement) {
|
||||
const { overflowY } = getComputedStyle(ancetre);
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||
const boite = ancetre.getBoundingClientRect();
|
||||
return { haut: boite.top, bas: boite.bottom };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
const defile = ancetreQuiDefile(element);
|
||||
if (defile === null) return {};
|
||||
const boite = defile.getBoundingClientRect();
|
||||
return { haut: boite.top, bas: boite.bottom };
|
||||
}
|
||||
|
||||
function placer() {
|
||||
|
|
@ -191,7 +260,7 @@
|
|||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<div class="graphique">
|
||||
<div class="graphique" bind:this={cadre}>
|
||||
<div class="mesure" bind:clientWidth={largeur}></div>
|
||||
<span id={idNom} hidden>{nom}</span>
|
||||
<svg
|
||||
|
|
@ -200,11 +269,14 @@
|
|||
role="img"
|
||||
aria-labelledby={idNom}
|
||||
aria-describedby={idSynthese}
|
||||
data-dessin-differe={dessine ? undefined : ''}
|
||||
width={largeur}
|
||||
height={hauteur}
|
||||
>
|
||||
<text bind:this={sonde} class="sonde" x="0" y="0"></text>
|
||||
{@render children()}
|
||||
{#if dessine}
|
||||
{@render children()}
|
||||
{/if}
|
||||
{#if zone !== null}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<rect
|
||||
|
|
|
|||
|
|
@ -28,11 +28,13 @@
|
|||
// Désignations : data-graphique sur le <svg>, data-case sur chaque case,
|
||||
// par « ligne:colonne » à partir de 0, data-rang sur la désignation de la
|
||||
// case pointée, par son indice à partir de 1. Le niveau d'une case à
|
||||
// partir de 1 et sa bordure portent chacun data-marque ; le niveau 0 est un
|
||||
// fond, sans marque.
|
||||
// partir de 1 porte data-marque ; le niveau 0 est un fond, sans marque.
|
||||
// Les bordures de toutes les cases sont un seul <path>, après les cases,
|
||||
// qui porte data-marque : aucune case ne chevauche la bordure d'une autre,
|
||||
// et l'ordre de peinture ne change aucun pixel.
|
||||
import { getContext } from 'svelte';
|
||||
import Graphique from './Graphique.svelte';
|
||||
import { ESPACE_PX, cibleCase, dispositionGrille } from './pointage.js';
|
||||
import { ESPACE_PX, cadreGrille, cibleCase, dispositionGrille } from './pointage.js';
|
||||
|
||||
let {
|
||||
graphique,
|
||||
|
|
@ -55,25 +57,44 @@
|
|||
|
||||
let largeur = $state(0);
|
||||
let mesures = $state(null);
|
||||
let dessine = $state(false);
|
||||
|
||||
const disposition = $derived(
|
||||
const coteMax = $derived(
|
||||
coteMaxTextes === null || mesures === null ? Number.POSITIVE_INFINITY : coteMaxTextes * mesures.hauteurTexte,
|
||||
);
|
||||
|
||||
// Le cadre, que les seuls intitulés décident (cadreGrille) : mesuré dès
|
||||
// la sonde rendue, dessin fait ou non, il donne la hauteur que le
|
||||
// graphique réserve — celle de son dessin — et dit si la grille cède la
|
||||
// place.
|
||||
const cadre = $derived(
|
||||
mesures === null || largeur === 0
|
||||
? null
|
||||
: dispositionGrille({
|
||||
largeur,
|
||||
lignes,
|
||||
colonnes,
|
||||
valeurs,
|
||||
diagonale,
|
||||
mesurer: mesures.mesurer,
|
||||
hauteurTexte: mesures.hauteurTexte,
|
||||
ecrire: ecrire ?? ((v) => format.nombre(v)),
|
||||
coteMax: coteMaxTextes === null ? Number.POSITIVE_INFINITY : coteMaxTextes * mesures.hauteurTexte,
|
||||
}),
|
||||
: mesures.calculer((mesurer) =>
|
||||
cadreGrille({ largeur, lignes, colonnes, mesurer, hauteurTexte: mesures.hauteurTexte, coteMax }),
|
||||
),
|
||||
);
|
||||
|
||||
const disposition = $derived(
|
||||
!dessine || mesures === null || largeur === 0
|
||||
? null
|
||||
: mesures.calculer((mesurer) =>
|
||||
dispositionGrille({
|
||||
largeur,
|
||||
lignes,
|
||||
colonnes,
|
||||
valeurs,
|
||||
diagonale,
|
||||
mesurer,
|
||||
hauteurTexte: mesures.hauteurTexte,
|
||||
ecrire: ecrire ?? ((v) => format.nombre(v)),
|
||||
coteMax,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (disposition !== null) dessinable = disposition.dessinable;
|
||||
if (cadre !== null) dessinable = cadre.dessinable;
|
||||
});
|
||||
|
||||
const zone = $derived(
|
||||
|
|
@ -94,7 +115,7 @@
|
|||
largeur, et le conteneur qui la porte ne se mesure pas. -->
|
||||
<div>
|
||||
<div class="mesure" bind:clientWidth={largeur}></div>
|
||||
{#if disposition !== null && !disposition.dessinable}
|
||||
{#if cadre !== null && !cadre.dessinable}
|
||||
<p class="phrase">{#if typeof phrase === 'function'}{@render phrase()}{:else}{phrase}{/if}</p>
|
||||
{:else}
|
||||
<Graphique
|
||||
|
|
@ -102,12 +123,13 @@
|
|||
{nom}
|
||||
{synthese}
|
||||
{infobulle}
|
||||
hauteur={disposition?.hauteur ?? 0}
|
||||
hauteur={cadre?.hauteur ?? 0}
|
||||
{zone}
|
||||
{cibler}
|
||||
{ancrer}
|
||||
bind:largeur
|
||||
bind:mesures
|
||||
bind:dessine
|
||||
bind:pointe
|
||||
>
|
||||
{#if disposition !== null}
|
||||
|
|
@ -136,7 +158,6 @@
|
|||
style:fill={`var(${c.jeton})`}
|
||||
/>
|
||||
{/if}
|
||||
<rect class="bordure" data-marque="graphiqueBordureCase" x={c.bord.x} y={c.bord.y} width={c.bord.cote} height={c.bord.cote} />
|
||||
{#if !c.diagonale}
|
||||
<text
|
||||
class="compte"
|
||||
|
|
@ -149,6 +170,7 @@
|
|||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
<path class="bordure" data-marque="graphiqueBordureCase" d={disposition.bordures} />
|
||||
{#if pointe !== null && disposition.cases[pointe] !== undefined}
|
||||
{@const c = disposition.cases[pointe]}
|
||||
<rect class="designation" data-rang={pointe + 1} x={c.x - ESPACE_PX / 3} y={c.y - ESPACE_PX / 3} width={c.cote + (2 * ESPACE_PX) / 3} height={c.cote + (2 * ESPACE_PX) / 3} />
|
||||
|
|
@ -172,10 +194,15 @@
|
|||
fill: var(--couleur-page-texte);
|
||||
}
|
||||
|
||||
/* Les bordures, un seul chemin, sans lissage : un coin de sous-chemin
|
||||
lissé se peint plus clair que celui d'un <rect>, et un trait sans
|
||||
lissage peint son jeton entier, sur la même rangée de pixels qu'un
|
||||
<rect> posé à la demi-unité. */
|
||||
.bordure {
|
||||
fill: none;
|
||||
stroke: var(--couleur-graphique-bordure-case);
|
||||
stroke-width: 1;
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
|
||||
/* Un compte sans niveau — « — » — se lit de l'encre de la page. */
|
||||
|
|
|
|||
31
src/interface/qualite/graphiques/dessin_differe.js
Normal file
31
src/interface/qualite/graphiques/dessin_differe.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le contexte dessinDiffere (Graphique.svelte) : la page qui le pose tient
|
||||
// le registre des graphiques dont le dessin attend d'approcher de la zone
|
||||
// visible. Chaque graphique en attente s'y inscrit, son cadre et la
|
||||
// fonction qui le dessine, et s'en retire une fois dessiné ou démonté.
|
||||
//
|
||||
// dessinerAvant(cible) dessine chaque graphique inscrit qui précède cible
|
||||
// dans le document, hors de ses descendants : une page qui amène une vue
|
||||
// dans la zone visible les dessine d'abord, puis, le rendu fait
|
||||
// (flushSync), défile jusqu'à elle. Leur hauteur réservée est celle de
|
||||
// leur dessin ; les dessiner avant le défilement garantit en plus
|
||||
// qu'aucun dessin au-dessus de la vue ne la déplace ensuite.
|
||||
|
||||
/** Un registre neuf : {inscrire(cadre, dessiner) → retirer, dessinerAvant(cible)}. */
|
||||
export function creerDessinDiffere() {
|
||||
const enAttente = new Set();
|
||||
return {
|
||||
inscrire(cadre, dessiner) {
|
||||
const entree = { cadre, dessiner };
|
||||
enAttente.add(entree);
|
||||
return () => enAttente.delete(entree);
|
||||
},
|
||||
dessinerAvant(cible) {
|
||||
for (const { cadre, dessiner } of [...enAttente]) {
|
||||
if (cible.compareDocumentPosition(cadre) & Node.DOCUMENT_POSITION_PRECEDING) dessiner();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -20,10 +20,12 @@ import '../../jetons.css';
|
|||
import { TABLES_APPLICATION } from '../../../application/libelles.js';
|
||||
import { creerFormat } from '../../../application/format.js';
|
||||
import { creerTraduction } from '../../../application/traduction.js';
|
||||
import { creerMesureur, mesuresDuTexte } from '../../plan/mesure_texte.js';
|
||||
import { releverMarques } from '../../../../test/navigateur/captures.js';
|
||||
import { fixerCadre, pointer, touche } from '../../../../test/navigateur/entrees.js';
|
||||
import { attenuations, sousTheme } from '../../../../test/navigateur/rendu.js';
|
||||
import { TABLES_INTERFACE } from '../../libelles.js';
|
||||
import { creerDessinDiffere } from './dessin_differe.js';
|
||||
import { LARGEUR_NOM_PX, margesCommunes } from './pointage.js';
|
||||
import Barres from './Barres.svelte';
|
||||
import Escalier from './Escalier.svelte';
|
||||
|
|
@ -584,7 +586,16 @@ describe('graphiques : la grille (§ 12.5, § 12.11, D33)', () => {
|
|||
expect(cases).toHaveLength(9);
|
||||
const trois = cible.querySelector('[data-case="0:2"]');
|
||||
expect(trois.querySelector('[data-marque="graphiqueNiveau3"]')).not.toBeNull();
|
||||
expect(trois.querySelector('[data-marque="graphiqueBordureCase"]')).not.toBeNull();
|
||||
// Les bordures : un seul <path>, un sous-chemin fermé par case,
|
||||
// diagonale comprise ; le bord gauche de 0:2 est sur son trait, à
|
||||
// RETRAIT_CASE_PX − 0,5 px à gauche de son remplissage.
|
||||
const bordures = cible.querySelectorAll('[data-marque="graphiqueBordureCase"]');
|
||||
expect(bordures).toHaveLength(1);
|
||||
expect(bordures[0].getAttribute('d').match(/M/g)).toHaveLength(9);
|
||||
const fond = trois.querySelector('[data-marque="graphiqueNiveau3"]');
|
||||
const [x, y, cote] = ['x', 'y', 'height'].map((nom) => Number(fond.getAttribute(nom)));
|
||||
const bord = new DOMPoint(x - 1.5, y + cote / 2);
|
||||
expect(bordures[0].isPointInStroke(bord)).toBe(true);
|
||||
expect(trois.querySelector('text').textContent).toBe('3');
|
||||
expect(getComputedStyle(trois.querySelector('text')).fill).toBe(couleurDuJeton('--couleur-graphique-encre-claire'));
|
||||
const diagonale = cible.querySelector('[data-case="1:1"]');
|
||||
|
|
@ -601,13 +612,16 @@ describe('graphiques : la grille (§ 12.5, § 12.11, D33)', () => {
|
|||
|
||||
test('le pointage désigne la case ; la diagonale n’ouvre rien', async () => {
|
||||
const cible = await monter(Grille, props());
|
||||
const boite = cible.querySelector('[data-case="1:2"] [data-marque="graphiqueBordureCase"]').getBoundingClientRect();
|
||||
const boite = cible.querySelector('[data-case="1:2"] rect').getBoundingClientRect();
|
||||
await pointer('bouger', boite.left + boite.width / 2, boite.top + boite.height / 2);
|
||||
flushSync();
|
||||
expect(infobulleDe(cible).hidden).toBe(false);
|
||||
expect(infobulleDe(cible).textContent).toContain('Théo Pervenche et Basile Tourteau');
|
||||
const diag = cible.querySelector('[data-case="1:1"] [data-marque="graphiqueBordureCase"]').getBoundingClientRect();
|
||||
await pointer('bouger', diag.left + diag.width / 2, diag.top + diag.height / 2);
|
||||
// La case 1:1, vide : la colonne du compte de 0:1, la ligne de celui de
|
||||
// 1:0.
|
||||
const colonne = cible.querySelector('[data-case="0:1"] text').getBoundingClientRect();
|
||||
const ligne = cible.querySelector('[data-case="1:0"] text').getBoundingClientRect();
|
||||
await pointer('bouger', colonne.left + colonne.width / 2, ligne.top + ligne.height / 2);
|
||||
flushSync();
|
||||
expect(infobulleDe(cible).hidden).toBe(true);
|
||||
});
|
||||
|
|
@ -741,3 +755,215 @@ describe('graphiques : les marques mesurées dans les deux thèmes (§ 14.5)', (
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('graphiques : le dessin différé et les mesures de la séance (E2s)', () => {
|
||||
// Une zone qui défile de 400 px de haut, posée en tête du document : un
|
||||
// graphique, puis un intervalle de 2 000 px, puis un second, puis fin, un
|
||||
// élément vide. Chacun est monté sous les contextes de App, et sous
|
||||
// dessinDiffere quand differe est vrai, comme la page de qualité les
|
||||
// monte ; registre est ce contexte, null sans lui.
|
||||
async function monterDansUneZone(premier, second, { differe = true } = {}) {
|
||||
const zone = document.createElement('div');
|
||||
zone.style.cssText = 'width: 800px; height: 400px; overflow-y: auto;';
|
||||
const intervalle = document.createElement('div');
|
||||
intervalle.style.height = '2000px';
|
||||
const cibles = [document.createElement('div'), document.createElement('div')];
|
||||
const fin = document.createElement('div');
|
||||
zone.append(cibles[0], intervalle, cibles[1], fin);
|
||||
document.body.prepend(zone);
|
||||
const registre = differe ? creerDessinDiffere() : null;
|
||||
const context = new Map([...CONTEXTE, ...(differe ? [['dessinDiffere', registre]] : [])]);
|
||||
for (const [i, [Composant, props]] of [premier, second].entries()) {
|
||||
const composant = mount(Composant, { target: cibles[i], props, context });
|
||||
montes.push({ composant, cible: i === 0 ? zone : cibles[i] });
|
||||
}
|
||||
flushSync();
|
||||
await tick();
|
||||
flushSync();
|
||||
return { zone, fin, registre, svgs: cibles.map((cible) => cible.querySelector('svg[data-graphique]')) };
|
||||
}
|
||||
|
||||
const image = () => new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
|
||||
test('sous dessinDiffere : le graphique visible se dessine au montage, l’autre attend, nommé et décrit, et se dessine en approchant ; leurs marges sont communes dès le montage', async () => {
|
||||
const partager = margesPartagees(2);
|
||||
const props = [partager(propsProfil(12, { graphique: 'haut' }), 0), partager(propsProfil(12, { graphique: 'bas' }), 1)];
|
||||
const { zone, svgs } = await monterDansUneZone([Escalier, props[0]], [Escalier, props[1]]);
|
||||
const [haut, bas] = svgs;
|
||||
expect(haut.hasAttribute('data-dessin-differe')).toBe(false);
|
||||
expect(haut.querySelector('[data-marque]')).not.toBeNull();
|
||||
expect(bas.hasAttribute('data-dessin-differe')).toBe(true);
|
||||
expect(bas.querySelector('[data-marque]')).toBeNull();
|
||||
expect(document.getElementById(bas.getAttribute('aria-labelledby')).textContent).toBe('Qui rencontre le moins de monde ?');
|
||||
expect(document.getElementById(bas.getAttribute('aria-describedby')).textContent).toBe('Minimum 10 rencontres, une personne.');
|
||||
// Les marges du second se mesurent sans son dessin : le premier est
|
||||
// aligné sur elles avant que le second ne se dessine, et ne bouge pas
|
||||
// quand il se dessine.
|
||||
expect(props[1].marges).not.toBeNull();
|
||||
const avant = surfaceDe(haut).getAttribute('x');
|
||||
bas.scrollIntoView({ block: 'center' });
|
||||
for (let n = 0; n < 60 && bas.hasAttribute('data-dessin-differe'); n += 1) await image();
|
||||
expect(bas.hasAttribute('data-dessin-differe')).toBe(false);
|
||||
expect(bas.querySelector('[data-marque]')).not.toBeNull();
|
||||
expect(surfaceDe(haut).getAttribute('x')).toBe(avant);
|
||||
expect(surfaceDe(bas).getAttribute('x')).toBe(avant);
|
||||
// Remonté, le second reste dessiné.
|
||||
zone.scrollTo(0, 0);
|
||||
await image();
|
||||
await image();
|
||||
expect(bas.querySelector('[data-marque]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('en attente, Barres et Grille réservent la hauteur de leur dessin, chaque graphique porte déjà sa légende ; le registre dessine ce qui précède une cible, rien de ce qui la suit', async () => {
|
||||
const barres = {
|
||||
graphique: 'collisions',
|
||||
nom: 'Collisions',
|
||||
synthese: 'Trois appartenances.',
|
||||
lignes: [
|
||||
{ id: 'aubier', nom: 'Coopérative Aubier', appartenance: 1, valeurs: [0, 3] },
|
||||
{ id: 'long', nom: NOM_LONG, appartenance: null, valeurs: [2, 0] },
|
||||
{ id: 'ressac', nom: 'Atelier Ressac', appartenance: 2, valeurs: [1, 1] },
|
||||
],
|
||||
segments: [
|
||||
{ genre: 'prouve', libelle: 'prouvé inévitable' },
|
||||
{ genre: 'reste', libelle: 'reste' },
|
||||
],
|
||||
};
|
||||
const colonnes = {
|
||||
...barres,
|
||||
graphique: 'diversite',
|
||||
segments: undefined,
|
||||
colonnes: [
|
||||
{ genre: 'serie1', libelle: 'Appartenances' },
|
||||
{ genre: 'serie2', libelle: 'Personnes' },
|
||||
],
|
||||
};
|
||||
const grille = (extra) => ({
|
||||
graphique: 'tables',
|
||||
nom: 'Tables',
|
||||
synthese: 'Quatre niveaux.',
|
||||
lignes: [{ libelle: 'Tour 1' }, { libelle: 'Tour 2' }],
|
||||
colonnes: [{ libelle: 'Table 1' }, { libelle: 'Table 2' }, { libelle: 'Table 3' }],
|
||||
valeurs: [0, 1, 2, 3, null, 1],
|
||||
...extra,
|
||||
});
|
||||
const cas = [
|
||||
['barres empilées', Barres, barres],
|
||||
['barres juxtaposées', Barres, colonnes],
|
||||
['grille bornée', Grille, grille({ coteMaxTextes: 3 })],
|
||||
['grille, intitulés tournés', Grille, grille({ colonnes: [{ libelle: 'Une table au nom très long' }, { libelle: 'Deux' }, { libelle: 'Trois' }], coteMaxTextes: 2 })],
|
||||
['grille sans borne', Grille, grille()],
|
||||
['escalier', Escalier, propsProfil(12, { graphique: 'bas' })],
|
||||
];
|
||||
for (const [quoi, Composant, props] of cas) {
|
||||
const { svgs, registre, fin } = await monterDansUneZone([Escalier, propsProfil(12, { graphique: 'haut' })], [Composant, props]);
|
||||
const [haut, bas] = svgs;
|
||||
const cadre = bas.parentElement;
|
||||
const releve = () => ({
|
||||
svg: bas.getAttribute('height'),
|
||||
cadre: cadre.getBoundingClientRect().height,
|
||||
legende: [...cadre.querySelectorAll('ul li')].map((li) => li.textContent),
|
||||
});
|
||||
expect(bas.hasAttribute('data-dessin-differe'), quoi).toBe(true);
|
||||
expect(bas.querySelector('[data-marque]'), quoi).toBeNull();
|
||||
const avant = releve();
|
||||
expect(Number(avant.svg), quoi).toBeGreaterThan(0);
|
||||
if (quoi === 'barres empilées' || quoi === 'escalier') expect(avant.legende.length, quoi).toBeGreaterThan(0);
|
||||
// Le graphique en attente suit la cible haut : il reste en attente.
|
||||
registre.dessinerAvant(haut);
|
||||
flushSync();
|
||||
expect(bas.hasAttribute('data-dessin-differe'), quoi).toBe(true);
|
||||
registre.dessinerAvant(fin);
|
||||
flushSync();
|
||||
expect(bas.hasAttribute('data-dessin-differe'), quoi).toBe(false);
|
||||
expect(bas.querySelector('[data-marque]'), quoi).not.toBeNull();
|
||||
expect(releve(), quoi).toEqual(avant);
|
||||
for (const { composant, cible } of montes.splice(0)) {
|
||||
unmount(composant);
|
||||
cible.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('hors de dessinDiffere, un graphique loin de la zone visible se dessine au montage', async () => {
|
||||
const haut = [Escalier, propsProfil(12, { graphique: 'haut' })];
|
||||
const bas = [Escalier, propsProfil(12, { graphique: 'bas' })];
|
||||
const { svgs } = await monterDansUneZone(haut, bas, { differe: false });
|
||||
for (const svg of svgs) {
|
||||
expect(svg.hasAttribute('data-dessin-differe')).toBe(false);
|
||||
expect(svg.querySelector('[data-marque]')).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
// Un <svg> sans viewBox au style des textes de graphique, une sonde, et le
|
||||
// compte des appels de getComputedTextLength et des relevés (after) que
|
||||
// faire() provoque.
|
||||
function sousSonde(faire) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.style.fontSize = '13px';
|
||||
const sonde = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
svg.append(sonde);
|
||||
document.body.prepend(svg);
|
||||
const longueur = SVGTextContentElement.prototype.getComputedTextLength;
|
||||
const apres = Element.prototype.after;
|
||||
const comptes = { longueurs: 0, releves: 0 };
|
||||
SVGTextContentElement.prototype.getComputedTextLength = function compter() {
|
||||
comptes.longueurs += 1;
|
||||
return longueur.call(this);
|
||||
};
|
||||
Element.prototype.after = function compter(...noeuds) {
|
||||
comptes.releves += 1;
|
||||
return apres.apply(this, noeuds);
|
||||
};
|
||||
try {
|
||||
return { rendu: faire(sonde), comptes };
|
||||
} finally {
|
||||
SVGTextContentElement.prototype.getComputedTextLength = longueur;
|
||||
Element.prototype.after = apres;
|
||||
svg.remove();
|
||||
}
|
||||
}
|
||||
|
||||
test('mesuresDuTexte : les largeurs de creerMesureur ; un relevé par lot, calcul relu jusqu’à ne rien demander d’inconnu ; gardées pour la séance, oubliées à la fin d’un chargement de police', () => {
|
||||
document.fonts.dispatchEvent(new Event('loadingdone'));
|
||||
const chaines = ['Iris Ombrelle', 'Théo Pervenche', '27', ''];
|
||||
const temoins = sousSonde((sonde) => chaines.map(creerMesureur(sonde))).rendu;
|
||||
// Le calcul demande d'abord trois chaînes, puis une quatrième qui
|
||||
// dépend de la largeur de la première : deux lots, trois passages.
|
||||
let passages = 0;
|
||||
const calcul = (mesurer) => {
|
||||
passages += 1;
|
||||
const premieres = chaines.slice(0, 3).map(mesurer);
|
||||
return premieres[0] > 0 ? [...premieres, mesurer(chaines[3])] : premieres;
|
||||
};
|
||||
const premier = sousSonde((sonde) => mesuresDuTexte(sonde).calculer(calcul));
|
||||
expect(premier.rendu).toEqual(temoins);
|
||||
expect(passages).toBe(3);
|
||||
expect(premier.comptes).toEqual({ longueurs: 4, releves: 2 });
|
||||
// Une autre sonde du même style : tout vient de la séance.
|
||||
passages = 0;
|
||||
const second = sousSonde((sonde) => mesuresDuTexte(sonde).calculer(calcul));
|
||||
expect(second.rendu).toEqual(temoins);
|
||||
expect(passages).toBe(1);
|
||||
expect(second.comptes).toEqual({ longueurs: 0, releves: 0 });
|
||||
// Une police chargée : les largeurs se relèvent.
|
||||
document.fonts.dispatchEvent(new Event('loadingdone'));
|
||||
const releve = sousSonde((sonde) => mesuresDuTexte(sonde).calculer(calcul));
|
||||
expect(releve.rendu).toEqual(temoins);
|
||||
expect(releve.comptes.longueurs).toBe(4);
|
||||
});
|
||||
|
||||
test('mesuresDuTexte : sous un ancêtre caché, une largeur nulle vaut 0 pour le calcul, ne se garde pas, et le calcul finit', () => {
|
||||
document.fonts.dispatchEvent(new Event('loadingdone'));
|
||||
const { rendu, comptes } = sousSonde((sonde) => {
|
||||
sonde.parentNode.style.display = 'none';
|
||||
const mesures = mesuresDuTexte(sonde);
|
||||
const cache = mesures.calculer((mesurer) => mesurer('Ondine Lacasse'));
|
||||
sonde.parentNode.style.display = '';
|
||||
return [cache, mesures.mesurer('Ondine Lacasse')];
|
||||
});
|
||||
expect(rendu[0]).toBe(0);
|
||||
expect(rendu[1]).toBeGreaterThan(0);
|
||||
expect(comptes.longueurs).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -300,10 +300,17 @@ export function cheminSigne(genre, x, y) {
|
|||
return `M ${n(x - s)} ${n(y - s)} L ${n(x + s)} ${n(y + s)} M ${n(x + s)} ${n(y - s)} L ${n(x - s)} ${n(y + s)}`;
|
||||
}
|
||||
|
||||
// La légende d'un graphique à traces : chaque trace sous son libellé, puis
|
||||
// chaque signe présent, dans l'ordre de SIGNES.
|
||||
function legendeDe(traces, signes = []) {
|
||||
const presents = new Set(signes.map((s) => s.genre));
|
||||
/**
|
||||
* La légende d'un escalier ou de points (Graphique) : chaque trace sous son
|
||||
* libellé — les séries, puis les repères —, puis chaque signe que marques
|
||||
* porte, dans l'ordre de SIGNES. Elle ne lit que les données reçues, aucune
|
||||
* mesure ni disposition : un graphique qui attend son dessin la rend déjà.
|
||||
* Lève comme exigerTraces.
|
||||
* @returns {Array<{genre: string, libelle: string|null, signe: string|null}>}
|
||||
*/
|
||||
export function legendeTraces({ series, reperes = [], marques = null }) {
|
||||
const { traces } = exigerTraces(series, reperes);
|
||||
const presents = new Set(marques ?? []);
|
||||
return [
|
||||
...traces.map((t) => ({ genre: t.genre, libelle: t.libelle, signe: null })),
|
||||
...SIGNES.filter((s) => presents.has(s)).map((signe) => ({ genre: 'signe', libelle: null, signe })),
|
||||
|
|
@ -368,7 +375,7 @@ export function dispositionEscalier({
|
|||
n === 0
|
||||
? []
|
||||
: graduations(1, n, GRADUATIONS_RANGS, { entieres: true }).map((rang) => ({ valeur: rang, texte: graduerX(rang), x: ex.versPx(rang - 0.5) }));
|
||||
return { ...cadre, n, ex, traces: tracees, signes, graduationsX, legende: legendeDe(tracees, signes) };
|
||||
return { ...cadre, n, ex, traces: tracees, signes, graduationsX, legende: legendeTraces({ series, reperes, marques }) };
|
||||
}
|
||||
|
||||
/** L'indice du rang sous l'abscisse x d'un escalier, au plus proche, borné
|
||||
|
|
@ -444,7 +451,7 @@ export function dispositionPoints({
|
|||
gardees.unshift({ valeur: abscisses[i], texte, x });
|
||||
}
|
||||
}
|
||||
return { ...cadre, n, ex, traces: tracees, abscissesPx, graduationsX: gardees, legende: legendeDe(tracees) };
|
||||
return { ...cadre, n, ex, traces: tracees, abscissesPx, graduationsX: gardees, legende: legendeTraces({ series }) };
|
||||
}
|
||||
|
||||
/** L'indice du point de relevé le plus proche de l'abscisse x, sur
|
||||
|
|
@ -511,6 +518,33 @@ function exigerIdentifiants(lignes) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* La hauteur, en px, de barres de n lignes (dispositionBarres) : l'en-tête
|
||||
* — le titre de chaque colonne juxtaposée, puis les graduations — et les
|
||||
* lignes, chacune d'une hauteur de texte et d'ESPACE_PX, écartées de
|
||||
* ECART_LIGNES_PX. Elle ne lit aucune largeur de texte : un graphique qui
|
||||
* attend son dessin la réserve, et son dessin ne change pas sa hauteur.
|
||||
*/
|
||||
export function hauteurBarres({ n, juxtaposees, hauteurTexte }) {
|
||||
const ligne = hauteurTexte + ESPACE_PX;
|
||||
return enTeteBarres(juxtaposees, hauteurTexte) + (n === 0 ? 0 : n * ligne + (n - 1) * ECART_LIGNES_PX);
|
||||
}
|
||||
|
||||
// La hauteur de l'en-tête des barres : le titre de chaque colonne
|
||||
// juxtaposée, puis les graduations.
|
||||
const enTeteBarres = (juxtaposees, hauteurTexte) => (juxtaposees ? 2 : 1) * (hauteurTexte + ESPACE_PX);
|
||||
|
||||
/**
|
||||
* La légende de barres (Graphique) : chaque segment empilé sous son
|
||||
* libellé, rien pour des colonnes juxtaposées, que leurs titres nomment.
|
||||
* Elle ne lit que les données reçues : un graphique qui attend son dessin
|
||||
* la rend déjà.
|
||||
* @returns {Array<{genre: string, libelle: string, signe: null}>}
|
||||
*/
|
||||
export function legendeBarres({ segments = null, colonnes = null }) {
|
||||
return colonnes !== null ? [] : segments.map((s) => ({ genre: s.genre, libelle: s.libelle, signe: null }));
|
||||
}
|
||||
|
||||
/**
|
||||
* La disposition de barres horizontales (§ 12.4, § 12.12, § 12.13) : une
|
||||
* ligne par entrée de lignes, {id, nom, appartenance?, valeurs}, dans
|
||||
|
|
@ -558,8 +592,7 @@ export function dispositionBarres({ largeur, lignes, segments = null, colonnes =
|
|||
const retraitNom = avecPastille ? 2 * RETRAIT_PASTILLE_PX + 2 * POINT_PASTILLE_PX : 0;
|
||||
const largeurNoms = Math.max(0, ...intitules.map((i) => i.largeur)) + retraitNom + (avecPastille ? RETRAIT_PASTILLE_PX : 0);
|
||||
const x0 = largeurNoms + ESPACE_PX;
|
||||
// En tête : le titre de chaque colonne juxtaposée, puis les graduations.
|
||||
const haut = (juxtaposees ? 2 : 1) * (hauteurTexte + ESPACE_PX);
|
||||
const haut = enTeteBarres(juxtaposees, hauteurTexte);
|
||||
// À droite : les libellés des segments, au pire tous sur une même ligne,
|
||||
// puis le plus long texte secondaire.
|
||||
const etiquettes = juxtaposees ? [] : parts.map((s) => s.libelle);
|
||||
|
|
@ -674,9 +707,9 @@ export function dispositionBarres({ largeur, lignes, segments = null, colonnes =
|
|||
colonnes: juxtaposees ? colonnesDisposees : [],
|
||||
axes: colonnesDisposees,
|
||||
etiquettesSegments,
|
||||
legende: juxtaposees ? [] : parts.map((s) => ({ genre: s.genre, libelle: s.libelle, signe: null })),
|
||||
legende: legendeBarres({ segments, colonnes }),
|
||||
haut,
|
||||
hauteurTotale: haut + corps,
|
||||
hauteurTotale: hauteurBarres({ n: lignes.length, juxtaposees, hauteurTexte }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -690,6 +723,26 @@ export function cibleLigne(y, disposition) {
|
|||
return Math.min(n - 1, Math.max(0, indice));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le cadre d'une grille (dispositionGrille) : ce que ses seuls intitulés
|
||||
* décident, mesurés par mesurer. x0, la largeur des intitulés de ligne et
|
||||
* d'un espace ; cote et dessinable, la case que la largeur restante tient
|
||||
* (grille, graphique.js) ; tournes, vrai quand un intitulé de colonne
|
||||
* dépasse la case, et y0, la hauteur de l'en-tête : la hauteur d'un texte,
|
||||
* ou du plus long intitulé de colonne tourné, et un espace ; largeur et
|
||||
* hauteur, le dessin entier. Les comptes des cases n'y entrent pas : un
|
||||
* graphique qui attend son dessin réserve cette hauteur, et sait déjà s'il
|
||||
* cède la place.
|
||||
*/
|
||||
export function cadreGrille({ largeur, lignes, colonnes, mesurer, hauteurTexte, coteMax = Number.POSITIVE_INFINITY }) {
|
||||
exiger(Array.isArray(lignes) && Array.isArray(colonnes), 'grille : lignes et colonnes attendues', TypeError);
|
||||
const x0 = plusLarge(lignes.map((l) => l.libelle), mesurer) + ESPACE_PX;
|
||||
const g = grille(lignes.length, colonnes.length, Math.max(0, largeur - x0), hauteurTexte, coteMax);
|
||||
const tournes = colonnes.some((c) => mesurer(c.libelle) > g.cote);
|
||||
const y0 = (tournes ? plusLarge(colonnes.map((c) => c.libelle), mesurer) : hauteurTexte) + ESPACE_PX;
|
||||
return { x0, y0, cote: g.cote, dessinable: g.dessinable, tournes, largeur: x0 + g.largeur, hauteur: y0 + g.hauteur };
|
||||
}
|
||||
|
||||
/**
|
||||
* La disposition d'une grille (§ 12.5, § 12.11, D33, D35) : lignes ×
|
||||
* colonnes de cases carrées, chacune {libelle}, valeurs les comptes en ordre
|
||||
|
|
@ -701,7 +754,10 @@ export function cibleLigne(y, disposition) {
|
|||
* « — ». Sous diagonale, une case de même ligne et colonne est vide et
|
||||
* neutre. Une case porte sa bordure, bord, un trait d'un pixel au bord de
|
||||
* la case, et son remplissage, fond, en retrait de RETRAIT_CASE_PX dans
|
||||
* elle. dessinable faux quand les cases ne tiennent pas dans la largeur
|
||||
* elle. bordures, le chemin de toutes les bordures, un sous-chemin fermé
|
||||
* par case dans l'ordre des cases, chacun le carré de son bord : un seul
|
||||
* <path> trace ce que traceraient autant de <rect>, sans un élément par
|
||||
* case. dessinable faux quand les cases ne tiennent pas dans la largeur
|
||||
* (grille, graphique.js) : la vue cède la place. Les intitulés de colonne
|
||||
* se tournent d'un quart de tour quand l'un d'eux dépasse la case. ecrire(v)
|
||||
* écrit un compte.
|
||||
|
|
@ -721,29 +777,25 @@ export function dispositionGrille({
|
|||
const nl = lignes.length;
|
||||
const nc = colonnes.length;
|
||||
exiger(Array.isArray(valeurs) && valeurs.length === nl * nc, `grille : ${nl * nc} valeurs attendues`);
|
||||
const largeurIntitules = plusLarge(lignes.map((l) => l.libelle), mesurer) + ESPACE_PX;
|
||||
const g = grille(nl, nc, Math.max(0, largeur - largeurIntitules), hauteurTexte, coteMax);
|
||||
const tournes = colonnes.some((c) => mesurer(c.libelle) > g.cote);
|
||||
const enTete = (tournes ? plusLarge(colonnes.map((c) => c.libelle), mesurer) : hauteurTexte) + ESPACE_PX;
|
||||
const x0 = largeurIntitules;
|
||||
const y0 = enTete;
|
||||
const cadre = cadreGrille({ largeur, lignes, colonnes, mesurer, hauteurTexte, coteMax });
|
||||
const { x0, y0, tournes, cote } = cadre;
|
||||
const cases = valeurs.map((v, index) => {
|
||||
const ligne = Math.floor(index / nc);
|
||||
const colonne = index % nc;
|
||||
const surDiagonale = diagonale && ligne === colonne;
|
||||
const n = surDiagonale || v === null ? null : niveau(v);
|
||||
const entree = n === null ? null : NIVEAUX_GRAPHIQUE[n];
|
||||
const x = x0 + colonne * g.cote;
|
||||
const y = y0 + ligne * g.cote;
|
||||
const x = x0 + colonne * cote;
|
||||
const y = y0 + ligne * cote;
|
||||
return {
|
||||
index,
|
||||
ligne,
|
||||
colonne,
|
||||
x,
|
||||
y,
|
||||
cote: g.cote,
|
||||
bord: { x: x + 0.5, y: y + 0.5, cote: Math.max(0, g.cote - 1) },
|
||||
fond: { x: x + RETRAIT_CASE_PX, y: y + RETRAIT_CASE_PX, cote: Math.max(0, g.cote - 2 * RETRAIT_CASE_PX) },
|
||||
cote,
|
||||
bord: { x: x + 0.5, y: y + 0.5, cote: Math.max(0, cote - 1) },
|
||||
fond: { x: x + RETRAIT_CASE_PX, y: y + RETRAIT_CASE_PX, cote: Math.max(0, cote - 2 * RETRAIT_CASE_PX) },
|
||||
diagonale: surDiagonale,
|
||||
niveau: n,
|
||||
jeton: entree?.jeton ?? null,
|
||||
|
|
@ -753,17 +805,18 @@ export function dispositionGrille({
|
|||
};
|
||||
});
|
||||
return {
|
||||
dessinable: g.dessinable,
|
||||
cote: g.cote,
|
||||
dessinable: cadre.dessinable,
|
||||
cote,
|
||||
x0,
|
||||
y0,
|
||||
largeur: x0 + g.largeur,
|
||||
hauteur: y0 + g.hauteur,
|
||||
largeur: cadre.largeur,
|
||||
hauteur: cadre.hauteur,
|
||||
nl,
|
||||
nc,
|
||||
intitulesLignes: lignes.map((l, i) => ({ texte: l.libelle, x: x0 - ESPACE_PX, y: y0 + (i + 0.5) * g.cote })),
|
||||
intitulesColonnes: colonnes.map((c, j) => ({ texte: c.libelle, x: x0 + (j + 0.5) * g.cote, y: y0 - ESPACE_PX, tourne: tournes })),
|
||||
intitulesLignes: lignes.map((l, i) => ({ texte: l.libelle, x: x0 - ESPACE_PX, y: y0 + (i + 0.5) * cote })),
|
||||
intitulesColonnes: colonnes.map((c, j) => ({ texte: c.libelle, x: x0 + (j + 0.5) * cote, y: y0 - ESPACE_PX, tourne: tournes })),
|
||||
cases,
|
||||
bordures: cases.map(({ bord }) => `M${bord.x} ${bord.y}h${bord.cote}v${bord.cote}h${-bord.cote}z`).join(''),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -87,8 +87,10 @@ function empreinteDuTexte(texte) {
|
|||
// drapeau siegesAttribues sous lequel elle l'a été. Un placement de
|
||||
// proposition ne se modifie jamais en place : une commande en fait un neuf,
|
||||
// ou modifie une copie (structuredClone) avant toute lecture. Chaque
|
||||
// placement se hache ainsi une fois, et les propositions qu'un geste garde,
|
||||
// ou qu'une datation recopie, retrouvent la leur sans la recalculer.
|
||||
// placement se hache ainsi une fois. Une datation, qui recopie la
|
||||
// proposition sans copier son placement, retrouve la sienne ; la copie
|
||||
// qu'un geste fait d'un placement qu'il garde reçoit celle de l'original
|
||||
// (heriterEmpreintes).
|
||||
const EMPREINTES = new WeakMap();
|
||||
|
||||
/**
|
||||
|
|
@ -109,6 +111,64 @@ export function empreinteDe(p) {
|
|||
return empreinte;
|
||||
}
|
||||
|
||||
// Vrai quand les listes a et b portent les mêmes valeurs dans le même
|
||||
// ordre, comparées par ===.
|
||||
function memeListe(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Vrai quand les placements a et b, chacun de la forme du schéma — une
|
||||
// entrée par tour, { sieges, reserve } —, portent les mêmes listes : leurs
|
||||
// textes canoniques sont alors égaux sous un même drapeau siegesAttribues,
|
||||
// puisque texteDuPlacement ne lit que ces deux champs. La comparaison lit
|
||||
// chaque occupant une fois et n'alloue rien.
|
||||
function memePlacement(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let tour = 0; tour < a.length; tour += 1) {
|
||||
const x = a[tour];
|
||||
const y = b[tour];
|
||||
if (!memeListe(x.reserve, y.reserve) || x.sieges.length !== y.sieges.length) return false;
|
||||
for (let table = 0; table < x.sieges.length; table += 1) {
|
||||
if (!memeListe(x.sieges[table], y.sieges[table])) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reporte les empreintes déjà calculées des propositions precedentes sur
|
||||
* leurs copies dans suivantes, sans rien hacher. Une proposition de
|
||||
* suivantes reçoit l'empreinte de la proposition de precedentes de même
|
||||
* identifiant quand celle-ci en a une (EMPREINTES) et que leurs
|
||||
* placements portent les mêmes listes (memePlacement) ; une proposition
|
||||
* dont le placement diffère, même sous la même identité, ne reçoit rien
|
||||
* et se hachera à sa prochaine clé. Le drapeau siegesAttribues suit
|
||||
* l'empreinte reportée : sous un autre drapeau, empreinteDe la recalcule.
|
||||
* Ne modifie aucune proposition. Un geste copie la charge entière
|
||||
* (structuredClone) : sans ce report, chaque geste hacherait de nouveau
|
||||
* chaque placement qu'il garde.
|
||||
*
|
||||
* @param {{id: number, placement: Object[]}[]} precedentes
|
||||
* @param {{id: number, placement: Object[]}[]} suivantes propositions
|
||||
* qui suivent le schéma, comme celles d'une charge examinée
|
||||
*/
|
||||
export function heriterEmpreintes(precedentes, suivantes) {
|
||||
const parId = new Map(precedentes.map((p) => [p.id, p]));
|
||||
for (const q of suivantes) {
|
||||
const p = parId.get(q.id);
|
||||
if (p === undefined || p.placement === q.placement || EMPREINTES.has(q.placement)) continue;
|
||||
const connue = EMPREINTES.get(p.placement);
|
||||
if (connue !== undefined && memePlacement(p.placement, q.placement)) EMPREINTES.set(q.placement, connue);
|
||||
}
|
||||
}
|
||||
|
||||
// L'identité d'une proposition ou d'une entrée hors empreinte : son
|
||||
// identifiant, sa graine, son compte d'arrêt, son historique et son
|
||||
// horodatage, vide quand x n'en porte pas, joints par « | ».
|
||||
const identiteDe = (x) => `${x.id}|${x.graine}|${x.arret}|${x.historique}|${x.horodatage ?? ''}`;
|
||||
|
||||
/**
|
||||
* La clé d'une proposition dans les courses en mémoire : son identifiant,
|
||||
* sa graine, son compte d'arrêt, son historique, son horodatage — vide
|
||||
|
|
@ -124,7 +184,7 @@ export function empreinteDe(p) {
|
|||
*/
|
||||
export function cleDeCourse(x) {
|
||||
const empreinte = Object.hasOwn(x, 'empreinte') ? x.empreinte : empreinteDe(x);
|
||||
return `${x.id}|${x.graine}|${x.arret}|${x.historique}|${x.horodatage ?? ''}|${empreinte}`;
|
||||
return `${identiteDe(x)}|${empreinte}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -232,14 +292,24 @@ export function avecCourses(courses, entrees) {
|
|||
/**
|
||||
* Les entrées de courses pour les propositions données, dans leur ordre :
|
||||
* celle de la clé de chacune, quand courses la connaît. C'est ce que le
|
||||
* compagnon d'une charge porte.
|
||||
* compagnon d'une charge porte. Seule une proposition dont l'identité hors
|
||||
* empreinte est celle d'une entrée de courses peut avoir la sienne : la
|
||||
* clé, et donc l'empreinte, ne se calcule que pour elle.
|
||||
*
|
||||
* @param {Map<string, Object>} courses
|
||||
* @param {Object[]} propositions
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
export function entreesPour(courses, propositions) {
|
||||
return propositions.map((p) => courses.get(cleDeCourse(p))).filter((entree) => entree !== undefined);
|
||||
if (courses.size === 0) return [];
|
||||
const identites = new Set();
|
||||
for (const entree of courses.values()) identites.add(identiteDe(entree));
|
||||
const entrees = [];
|
||||
for (const p of propositions) {
|
||||
const entree = identites.has(identiteDe(p)) ? courses.get(cleDeCourse(p)) : undefined;
|
||||
if (entree !== undefined) entrees.push(entree);
|
||||
}
|
||||
return entrees;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ const ENTREES = [
|
|||
['data-demonstration', 'accueil : une démonstration, par sa clé', null],
|
||||
['data-derive', 'événement : un placement en dérive, par son identifiant', null],
|
||||
['data-dernier-changement', 'qualité : la phrase du dernier changement de la performance du calcul', ['']],
|
||||
['data-dessin-differe', 'qualité : le <svg> d’un graphique dont les marques attendent d’approcher de la zone visible, posé sans valeur', null],
|
||||
['data-devient', 'import : ce que devient une ligne', ['']],
|
||||
['data-donnee', 'diagnostic : une donnée nommée dans une phrase', ['']],
|
||||
['data-dossier', 'accueil : le dossier de travail montré', ['']],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue