From 1ea572468dc5df29370d27efb66fdd908dd75dba Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 02:32:35 -0400 Subject: [PATCH] [ADD] geometry: plan view, truncation, scale bar, measured constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan needs one formula between the screen and the drawing, shared by the drawing, the seat designation and the drop of a drag. src/geometrie/vue holds the view as k px/cm plus a translation, written in a single transform attribute, and converts pointer positions without reading the DOM. Text truncation, the graphic scale and the register of measured constants live beside it; the layer guard now requires src/geometrie and keeps it deterministic and away from window. Checked: 1619 node tests. --- FR --- [ADD] géométrie : vue du plan, troncature, échelle, constantes mesurées Le plan exige une seule formule entre l'écran et le dessin, que partagent le dessin, la désignation d'un siège et le dépôt d'un glissement. src/geometrie/vue tient la vue — k px/cm et une translation — écrite dans un seul attribut transform, et convertit les positions du pointeur sans lire le DOM. La troncature du texte, l'échelle graphique et le registre des constantes mesurées vivent à côté ; la garde des couches exige désormais src/geometrie, déterministe et sans window. Vérifié : 1619 épreuves node. Assisted-by: Claude Opus 5.5 --- src/geometrie/constantes.js | 158 ++++++++++++++ src/geometrie/constantes.test.js | 96 +++++++++ src/geometrie/echelle.js | 40 ++++ src/geometrie/echelle.test.js | 55 +++++ src/geometrie/texte.js | 75 +++++++ src/geometrie/texte.test.js | 92 ++++++++ src/geometrie/vue.js | 218 +++++++++++++++++++ src/geometrie/vue.test.js | 348 +++++++++++++++++++++++++++++++ test/arborescence.test.js | 105 ++++++++-- 9 files changed, 1172 insertions(+), 15 deletions(-) create mode 100644 src/geometrie/constantes.js create mode 100644 src/geometrie/constantes.test.js create mode 100644 src/geometrie/echelle.js create mode 100644 src/geometrie/echelle.test.js create mode 100644 src/geometrie/texte.js create mode 100644 src/geometrie/texte.test.js create mode 100644 src/geometrie/vue.js create mode 100644 src/geometrie/vue.test.js diff --git a/src/geometrie/constantes.js b/src/geometrie/constantes.js new file mode 100644 index 0000000..7875d16 --- /dev/null +++ b/src/geometrie/constantes.js @@ -0,0 +1,158 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le registre des constantes mesurées (§ 14.1, § 19.10). Toute dimension qui +// dépend du rendu ou du geste se relève, jamais ne se déduit : chaque entrée +// dit ce qu'elle mesure, pourquoi elle existe, sur quelle configuration elle +// se relève, qui la relève et comment. Tant que son propriétaire ne l'a pas +// relevée, une constante porte sa valeur de départ et se déclare défaut de +// travail ; il écrit ici la valeur relevée, passe le statut à « mesuree » et +// complète la configuration. Le banc de mesure lit ce tableau pour en +// engendrer la région de la documentation (§ 19.10). + +/** + * @typedef {Object} Constante + * @property {string} nom + * @property {number|null} valeur null pour une constante relevée à l'exécution + * @property {string} unite + * @property {string} mesure ce qu'elle mesure + * @property {string} raison pourquoi elle existe + * @property {string} configuration sur quoi elle se relève : machine, cadre, configuration + * @property {'banc'|'node'|'manuel'|'execution'} proprietaire + * @property {'mesuree'|'defautDeTravail'} statut + * @property {string} protocole comment le propriétaire la relève, et où il l'écrit + */ + +const PROPRIETAIRES = new Set(['banc', 'node', 'manuel', 'execution']); +const STATUTS = new Set(['mesuree', 'defautDeTravail']); + +/** @type {ReadonlyArray>} */ +export const CONSTANTES = Object.freeze( + [ + { + nom: 'SEUIL_GLISSER_PX', + valeur: 4, + unite: 'px', + mesure: "déplacement du pointeur, entre pointerdown et pointerup, au-delà duquel un appui devient un glissement", + raison: "un clic délibéré bouge de quelques pixels : sous ce seuil, il resterait un clic et ne déplacerait rien (§ 8.4)", + configuration: 'pavé tactile de la machine de référence', + proprietaire: 'manuel', + statut: 'defautDeTravail', + protocole: + 'vingt clics délibérés au pavé tactile de la machine de référence ; le plus grand déplacement entre ' + + 'pointerdown et pointerup, × 1,5, écrit dans ce registre', + }, + { + nom: 'DUREE_PRESSION_LONGUE_MS', + valeur: 500, + unite: 'ms', + mesure: "durée d'un appui immobile au-delà de laquelle il ouvre le menu d'une personne", + raison: "un appui bref ne doit jamais ouvrir le menu, ni une pression voulue se faire attendre (§ 7.3)", + configuration: 'écran tactile de la machine de référence', + proprietaire: 'manuel', + statut: 'defautDeTravail', + protocole: + "à l'écran tactile de la machine de référence, la plus courte durée qu'aucun appui bref ne déclenche, " + + 'sur dix essais, écrite dans ce registre', + }, + { + nom: 'SEUIL_SIEGES_LISIBLES', + valeur: 16, + unite: 'places', + mesure: "plus grand nombre de places d'une table dont la liste de noms reste lisible", + raison: "au-delà, le dessin d'une table cesse d'être lisible, et l'application le signale (§ 6.2)", + configuration: 'banc de mesure, cadre de 1 366 × 700 px', + proprietaire: 'banc', + statut: 'defautDeTravail', + protocole: + 'banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de places dont la liste, à la hauteur ' + + "de ligne mesurée, tient dans la hauteur du cadre au zoom ajusté d'une table seule ; écrit dans ce registre", + }, + { + nom: 'K_LISTES', + valeur: 0.3, + unite: 'px/cm', + mesure: 'échelle sous laquelle le plan retire les listes de noms', + raison: "sous elle, dessiner les listes coûte plus d'une image par déplacement de vue (§ 17, point 6)", + configuration: 'banc de mesure, grande démonstration, 33 tables avec listes', + proprietaire: 'banc', + statut: 'defautDeTravail', + protocole: + 'banc de mesure : temps par image pendant un déplacement de vue sur la grande démonstration, 33 tables ' + + 'avec listes, à plusieurs k ; le plus petit k qui tient le critère de temps par image, écrit dans ce registre', + }, + { + nom: 'LARGEUR_BLOC_NOMS_CM', + valeur: 180, + unite: 'cm', + mesure: "largeur, dans le dessin, du bloc de noms d'une table, où un nom trop long s'abrège", + raison: 'un nom tient sur une ligne du plan sans recouvrir la table voisine (§ 7.4)', + configuration: 'grande démonstration au zoom par défaut', + proprietaire: 'manuel', + statut: 'defautDeTravail', + protocole: + "relecture de la grande démonstration au zoom par défaut par une personne qui n'a pas écrit le " + + 'logiciel (§ 19.11) ; la largeur retenue, écrite dans ce registre', + }, + { + nom: 'HAUTEUR_LIGNE', + valeur: null, + unite: 'cm', + mesure: "hauteur d'une ligne d'une liste de noms, dans le dessin", + raison: 'deux lignes ne se chevauchent pas, quels que soient les accents et les jambages (§ 7.4)', + configuration: 'police calculée des listes, relevée à chaque montage du plan', + proprietaire: 'execution', + statut: 'mesuree', + protocole: + 'mesurée à chaque montage du plan : measureText du gabarit « Ôjgq Æ W » sur la police calculée des ' + + "listes ; aucune valeur n'est écrite dans ce registre", + }, + ].map((entree) => Object.freeze(entree)), +); + +// Champs textuels d'une entrée, qui ne peuvent être vides. +const CHAMPS_TEXTE = ['unite', 'mesure', 'raison', 'configuration']; + +const estTexte = (valeur) => typeof valeur === 'string' && valeur.length > 0; + +/** + * La valeur de la constante nommée ; null pour une constante relevée à + * l'exécution. Lève RangeError sur un nom absent du registre. + * @param {string} nom + * @returns {number|null} + */ +export function constante(nom) { + const entree = CONSTANTES.find((c) => c.nom === nom); + if (entree === undefined) throw new RangeError(`constante inconnue : ${String(nom)}`); + return entree.valeur; +} + +/** + * Les fautes d'un registre, [] quand chaque entrée est complète : une par + * champ fautif, dans l'ordre des entrées, puis dans l'ordre des champs du + * type Constante. Un nom vide, ou déjà porté par une entrée précédente, est + * une faute ; la valeur est un nombre fini, et null pour une constante + * relevée à l'exécution seulement ; les champs textuels ne sont pas vides ; + * propriétaire et statut prennent une valeur de leur liste. + * @param {ReadonlyArray} liste + * @returns {Array<{nom: string, champ: string}>} + */ +export function verifierRegistre(liste) { + const vus = new Set(); + const fautes = []; + for (const entree of liste) { + const nom = entree.nom; + const faute = (champ) => fautes.push({ nom, champ }); + if (!estTexte(nom) || vus.has(nom)) faute('nom'); + vus.add(nom); + const execution = entree.proprietaire === 'execution'; + if (execution ? entree.valeur !== null : !Number.isFinite(entree.valeur)) faute('valeur'); + for (const champ of CHAMPS_TEXTE) { + if (!estTexte(entree[champ])) faute(champ); + } + if (!PROPRIETAIRES.has(entree.proprietaire)) faute('proprietaire'); + if (!STATUTS.has(entree.statut)) faute('statut'); + if (!estTexte(entree.protocole)) faute('protocole'); + } + return fautes; +} diff --git a/src/geometrie/constantes.test.js b/src/geometrie/constantes.test.js new file mode 100644 index 0000000..add9c51 --- /dev/null +++ b/src/geometrie/constantes.test.js @@ -0,0 +1,96 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du registre des constantes mesurées (§ 14.1, § 19.10) : les six +// entrées du contrat de données, chacune complète, et la vérification qui +// nomme l'entrée et le champ d'une faute. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { CONSTANTES, constante, verifierRegistre } from './constantes.js'; + +// Le contrat de données : nom, valeur de départ, unité, propriétaire. +const CONTRAT = [ + ['SEUIL_GLISSER_PX', 4, 'px', 'manuel'], + ['DUREE_PRESSION_LONGUE_MS', 500, 'ms', 'manuel'], + ['SEUIL_SIEGES_LISIBLES', 16, 'places', 'banc'], + ['K_LISTES', 0.3, 'px/cm', 'banc'], + ['LARGEUR_BLOC_NOMS_CM', 180, 'cm', 'manuel'], + ['HAUTEUR_LIGNE', null, 'cm', 'execution'], +]; + +// Une copie du registre, entrée par entrée, que l'épreuve peut altérer. +const copie = () => CONSTANTES.map((entree) => ({ ...entree })); + +describe('constantes : le registre (§ 14.1, § 19.10)', () => { + test('le registre porte les six entrées du contrat, dans son ordre, chacune complète', () => { + assert.equal(CONSTANTES.length, 6); + assert.deepEqual( + CONSTANTES.map(({ nom, valeur, unite, proprietaire }) => [nom, valeur, unite, proprietaire]), + CONTRAT, + ); + assert.deepEqual(verifierRegistre(CONSTANTES), []); + for (const entree of CONSTANTES) { + assert.ok(entree.protocole.length > 0 && entree.mesure.length > 0 && entree.raison.length > 0, entree.nom); + } + }); + + test('tant que son propriétaire ne l\'a pas relevée, une constante se déclare défaut de travail', () => { + const statuts = CONSTANTES.map(({ nom, statut }) => [nom, statut]); + assert.deepEqual(statuts.slice(0, 5), CONTRAT.slice(0, 5).map(([nom]) => [nom, 'defautDeTravail'])); + }); + + test('constante rend la valeur par le nom ; un nom inconnu lève', () => { + assert.equal(constante('SEUIL_GLISSER_PX'), 4); + assert.equal(constante('K_LISTES'), 0.3); + assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 180); + assert.equal(constante('HAUTEUR_LIGNE'), null); + assert.throws(() => constante('INCONNUE'), RangeError); + assert.throws(() => constante('nom'), RangeError); + }); + + test('le registre et ses entrées sont figés', () => { + assert.ok(Object.isFrozen(CONSTANTES)); + assert.ok(CONSTANTES.every((entree) => Object.isFrozen(entree))); + }); + + test("verifierRegistre nomme l'entrée et le champ d'une faute : propriétaire, configuration ou statut perdu", () => { + for (const champ of ['proprietaire', 'configuration', 'statut']) { + const liste = copie(); + delete liste[2][champ]; + assert.deepEqual(verifierRegistre(liste), [{ nom: 'SEUIL_SIEGES_LISIBLES', champ }], champ); + } + const vide = copie(); + vide[0].configuration = ''; + vide[4].proprietaire = 'personne'; + vide[1].statut = 'mesure'; + assert.deepEqual(verifierRegistre(vide), [ + { nom: 'SEUIL_GLISSER_PX', champ: 'configuration' }, + { nom: 'DUREE_PRESSION_LONGUE_MS', champ: 'statut' }, + { nom: 'LARGEUR_BLOC_NOMS_CM', champ: 'proprietaire' }, + ]); + }); + + test("verifierRegistre relève aussi une valeur absente hors de l'exécution, un texte vide, un nom répété", () => { + const liste = copie(); + liste[0].valeur = null; + liste[1].valeur = Number.NaN; + liste[2].protocole = ''; + liste[3].unite = undefined; + liste[4].mesure = 3; + liste[5].raison = ''; + liste.push({ ...liste[3], unite: 'px/cm' }); + assert.deepEqual(verifierRegistre(liste), [ + { nom: 'SEUIL_GLISSER_PX', champ: 'valeur' }, + { nom: 'DUREE_PRESSION_LONGUE_MS', champ: 'valeur' }, + { nom: 'SEUIL_SIEGES_LISIBLES', champ: 'protocole' }, + { nom: 'K_LISTES', champ: 'unite' }, + { nom: 'LARGEUR_BLOC_NOMS_CM', champ: 'mesure' }, + { nom: 'HAUTEUR_LIGNE', champ: 'raison' }, + { nom: 'K_LISTES', champ: 'nom' }, + ]); + assert.deepEqual(verifierRegistre([{ ...liste[0], nom: '', valeur: 4 }]), [{ nom: '', champ: 'nom' }]); + // Une constante relevée à l'exécution n'a pas de valeur dans le registre : + // un nombre y serait le relevé d'une autre machine. + assert.deepEqual(verifierRegistre([{ ...CONSTANTES[5], valeur: 21.5 }]), [{ nom: 'HAUTEUR_LIGNE', champ: 'valeur' }]); + }); +}); diff --git a/src/geometrie/echelle.js b/src/geometrie/echelle.js new file mode 100644 index 0000000..b7a93c7 --- /dev/null +++ b/src/geometrie/echelle.js @@ -0,0 +1,40 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'échelle graphique du plan (§ 7.7) : une barre d'une longueur ronde, que le +// lecteur reporte sur le dessin. Une barre reste juste quand une impression +// change le facteur d'échelle, là où un rapport écrit en chiffres deviendrait +// faux. + +// Mantisses des longueurs rondes, de la plus grande à la plus petite. +const MANTISSES = [5, 2, 1]; + +// m × 10ⁿ, au plus près : une puissance négative divise par 10⁻ⁿ, que +// l'arithmétique flottante représente exactement, plutôt que de multiplier +// par 10ⁿ, qu'elle arrondit. +const ronde = (m, n) => (n >= 0 ? m * 10 ** n : m / 10 ** -n); + +/** + * La plus grande longueur ronde — 1, 2 ou 5 × 10ⁿ cm, n entier de signe + * quelconque — dont la barre tient dans largeurMaxPx à l'échelle k, et la + * largeur de cette barre, longueurCm × k. La recherche part de la décade + * au-dessus de largeurMaxPx / k et descend, mantisse par mantisse, jusqu'à la + * première longueur qui tient : l'arrondi du logarithme ne fait rien manquer. + * Lève RangeError quand k ou largeurMaxPx n'est pas un nombre fini > 0. + * @param {number} k px/cm + * @param {number} largeurMaxPx + * @returns {{longueurCm: number, largeurPx: number}} + */ +export function echelleGraphique(k, largeurMaxPx) { + for (const [nom, valeur] of [['k', k], ['largeur', largeurMaxPx]]) { + if (typeof valeur !== 'number' || !Number.isFinite(valeur) || valeur <= 0) { + throw new RangeError(`${nom} : nombre fini > 0 attendu, reçu ${String(valeur)}`); + } + } + for (let n = Math.floor(Math.log10(largeurMaxPx / k)) + 1; ; n -= 1) { + for (const m of MANTISSES) { + const longueurCm = ronde(m, n); + if (longueurCm * k <= largeurMaxPx) return { longueurCm, largeurPx: longueurCm * k }; + } + } +} diff --git a/src/geometrie/echelle.test.js b/src/geometrie/echelle.test.js new file mode 100644 index 0000000..f0f269d --- /dev/null +++ b/src/geometrie/echelle.test.js @@ -0,0 +1,55 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de l'échelle graphique (§ 7.7) : la plus grande longueur ronde, 1, +// 2 ou 5 × 10ⁿ cm, dont la barre tient dans la largeur donnée à l'échelle k. +// Les cas chiffrés sont écrits en clair ; le balayage de k vérifie chaque +// résultat par la définition, non par une seconde implémentation. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { echelleGraphique } from './echelle.js'; + +// Vrai quand longueur vaut 1, 2 ou 5 × 10ⁿ, n entier de signe quelconque, +// à l'arrondi des nombres flottants près. +function estRonde(longueur) { + const puissance = 10 ** Math.floor(Math.log10(longueur) + 1e-12); + const mantisse = longueur / puissance; + return [1, 2, 5].some((m) => Math.abs(mantisse - m) < 1e-9); +} + +// La valeur ronde qui suit longueur : 1 → 2, 2 → 5, 5 → 10. +function rondeSuivante(longueur) { + const puissance = 10 ** Math.floor(Math.log10(longueur) + 1e-12); + const mantisse = Math.round(longueur / puissance); + return (mantisse === 1 ? 2 : mantisse === 2 ? 5 : 10) * puissance; +} + +describe("échelle : l'échelle graphique (§ 7.7)", () => { + test('k = 1, 0,19 et 10 dans 200 px', () => { + assert.deepEqual(echelleGraphique(1, 200), { longueurCm: 200, largeurPx: 200 }); + const etroite = echelleGraphique(0.19, 200); + assert.equal(etroite.longueurCm, 1000); + assert.ok(Math.abs(etroite.largeurPx - 190) < 1e-9, `${etroite.largeurPx}`); + assert.deepEqual(echelleGraphique(10, 200), { longueurCm: 20, largeurPx: 200 }); + assert.deepEqual(echelleGraphique(10, 5), { longueurCm: 0.5, largeurPx: 5 }); + }); + + test('pour chaque k de 0,05 à 10, raison 1,07 : longueur ronde, barre dans 200 px, la ronde suivante dépasserait', () => { + let comptes = 0; + for (let k = 0.05; k <= 10; k *= 1.07) { + const { longueurCm, largeurPx } = echelleGraphique(k, 200); + assert.ok(estRonde(longueurCm), `k = ${k} : ${longueurCm}`); + assert.ok(Math.abs(largeurPx - longueurCm * k) < 1e-9, `k = ${k}`); + assert.ok(largeurPx <= 200, `k = ${k} : ${largeurPx}`); + assert.ok(rondeSuivante(longueurCm) * k > 200, `k = ${k} : ${longueurCm}`); + comptes += 1; + } + assert.equal(comptes, 79); + }); + + test("k ou largeur qui n'est pas un nombre fini positif est refusé", () => { + for (const [k, largeur] of [[0, 200], [-1, 200], [Number.NaN, 200], [Number.POSITIVE_INFINITY, 200], [1, 0], [1, -5], [1, Number.NaN], [null, 200]]) { + assert.throws(() => echelleGraphique(k, largeur), RangeError, `${k}, ${largeur}`); + } + }); +}); diff --git a/src/geometrie/texte.js b/src/geometrie/texte.js new file mode 100644 index 0000000..55ea83d --- /dev/null +++ b/src/geometrie/texte.js @@ -0,0 +1,75 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le texte du plan (§ 7.4) : la troncature d'un nom dans la largeur d'un bloc +// de noms, et les ordonnées des lignes d'une liste, chaque ligne étant un +// à décalage explicite. Aucune fonction ne lit le DOM : la largeur +// d'une chaîne vient d'une mesure injectée, getComputedTextLength au +// navigateur, sur la police rendue (§ 14.1). + +/** + * Gabarit dont se mesure la hauteur de ligne d'une liste : capitales + * accentuées et jambages, jamais une chaîne moyenne (§ 7.4). + */ +export const GABARIT_HAUTEUR_LIGNE = 'Ôjgq Æ W'; + +/** + * Corps du texte des listes de noms, en cm dans le dessin : les étiquettes ne + * sont pas à l'échelle du mobilier (§ 7.7), et ce corps fixe leur taille dans + * le dessin, que le zoom agrandit avec le reste. + */ +export const TAILLE_TEXTE_LISTE_CM = 18; + +// Vrai pour la première, ou la seconde, moitié d'une paire de substitution. +const estPremiereMoitie = (code) => code >= 0xd800 && code <= 0xdbff; +const estSecondeMoitie = (code) => code >= 0xdc00 && code <= 0xdfff; + +/** + * Le texte entier s'il tient dans largeurMax ; sinon le plus long préfixe + * qui, suivi de la marque, y tient ; '' quand la marque seule n'y tient pas ; + * '' reste ''. Le résultat ne dépasse jamais largeurMax. + * + * La recherche avance par dichotomie sur le nombre d'unités de code du + * préfixe, la largeur d'un préfixe croissant avec sa longueur. Une coupe qui + * tomberait entre les deux moitiés d'une paire de substitution recule d'une + * unité : le préfixe ne finit jamais sur une moitié de lettre. + * + * @param {string} texte + * @param {number} largeurMax + * @param {function(string): number} mesurer largeur d'une chaîne, dans l'unité de largeurMax + * @param {string} marque ce qui suit un préfixe tronqué + * @returns {string} + */ +export function tronquer(texte, largeurMax, mesurer, marque) { + if (texte === '' || mesurer(texte) <= largeurMax) return texte; + if (!(mesurer(marque) <= largeurMax)) return ''; + // Fin d'un préfixe de n unités, ramenée hors d'une paire de substitution. + const coupe = (n) => + n > 0 && estPremiereMoitie(texte.charCodeAt(n - 1)) && estSecondeMoitie(texte.charCodeAt(n)) ? n - 1 : n; + const avecMarque = (n) => texte.slice(0, coupe(n)) + marque; + // Invariant : le préfixe de bas unités tient, suivi de la marque ; celui de + // haut unités ne tient pas, le texte entier, suivi de la marque, compris. + let bas = 0; + let haut = texte.length; + while (haut - bas > 1) { + const milieu = bas + Math.floor((haut - bas) / 2); + if (mesurer(avecMarque(milieu)) <= largeurMax) bas = milieu; + else haut = milieu; + } + return avecMarque(bas); +} + +/** + * Ordonnées des n lignes d'une liste, la première à 0, chacune d'une hauteur + * de ligne sous la précédente. Lève RangeError sur un n qui n'est pas un + * entier ≥ 0. + * @param {number} n + * @param {number} hauteurLigne + * @returns {number[]} + */ +export function decalagesLignes(n, hauteurLigne) { + if (!Number.isInteger(n) || n < 0) { + throw new RangeError(`nombre de lignes : entier ≥ 0 attendu, reçu ${String(n)}`); + } + return Array.from({ length: n }, (_, i) => i * hauteurLigne); +} diff --git a/src/geometrie/texte.test.js b/src/geometrie/texte.test.js new file mode 100644 index 0000000..714eb5c --- /dev/null +++ b/src/geometrie/texte.test.js @@ -0,0 +1,92 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du texte du plan (§ 7.4, § 14.1) : la troncature avec marque et +// les décalages des lignes d'une liste. La mesure est injectée : ici, une +// somme de largeurs par glyphe écrites dans l'épreuve, où le glyphe large +// coûte plus que la moyenne ; au navigateur, getComputedTextLength. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { GABARIT_HAUTEUR_LIGNE, TAILLE_TEXTE_LISTE_CM, decalagesLignes, tronquer } from './texte.js'; + +const MARQUE = '…'; + +// Largeur d'un glyphe : W 10, Æ 11, i 3, la marque 6, tout autre 6. Une +// moitié de paire de substitution isolée ne dessine rien de mesurable et +// compte 0 : une coupe au milieu d'une paire paraîtrait tenir. +const LARGEURS = new Map([['W', 10], ['Æ', 11], ['i', 3], [MARQUE, 6]]); +const MOITIE = /^[\u{D800}-\u{DFFF}]$/u; +const mesurer = (chaine) => + [...chaine].reduce((somme, glyphe) => somme + (MOITIE.test(glyphe) ? 0 : (LARGEURS.get(glyphe) ?? 6)), 0); + +// Une moitié de paire de substitution isolée, n'importe où dans la chaîne. +const MOITIE_ISOLEE = /[\u{D800}-\u{DFFF}]/u; + +describe('texte : la troncature (§ 7.4, § 14.1)', () => { + test('un texte qui tient reste entier ; sinon le plus long préfixe qui, suivi de la marque, tient', () => { + assert.equal(mesurer('Ombrelle'), 48); + assert.equal(tronquer('Ombrelle', 60, mesurer, MARQUE), 'Ombrelle'); + assert.equal(tronquer('Ombrelle', 48, mesurer, MARQUE), 'Ombrelle'); + assert.equal(tronquer('WWWWWWWW', 60, mesurer, MARQUE), 'WWWWW…'); + assert.equal(mesurer('WWWWW…'), 56); + assert.equal(tronquer('ÆÆÆÆÆÆ', 40, mesurer, MARQUE), 'ÆÆÆ…'); + assert.equal(tronquer('Ombrelle', 47, mesurer, MARQUE), 'Ombrel…'); + }); + + test("la marque seule qui ne tient pas rend '' ; un texte entier qui tient sans marque reste ; '' reste ''", () => { + assert.equal(tronquer('Ombrelle', 4, mesurer, MARQUE), ''); + assert.equal(tronquer('i', 4, mesurer, MARQUE), 'i'); + assert.equal(tronquer('', 0, mesurer, MARQUE), ''); + assert.equal(tronquer('', 100, mesurer, MARQUE), ''); + assert.equal(tronquer('Ombrelle', 6, mesurer, MARQUE), '…'); + }); + + test("une lettre hors du plan de base n'est jamais coupée entre ses deux unités UTF-16", () => { + const texte = '\u{1D49C}\u{1D49C}\u{1D49C}\u{1D49C}'; + assert.equal(texte.length, 8); + assert.equal(tronquer(texte, 20, mesurer, MARQUE), '\u{1D49C}\u{1D49C}…'); + let comptes = 0; + for (let largeur = 0; largeur <= 30; largeur += 1) { + const rendu = tronquer(texte, largeur, mesurer, MARQUE); + assert.ok(!MOITIE_ISOLEE.test(rendu), `largeur ${largeur} : ${JSON.stringify(rendu)}`); + comptes += 1; + } + assert.equal(comptes, 31); + assert.equal(tronquer('Iris \u{1D49C}mbrelle', 44, mesurer, MARQUE), 'Iris \u{1D49C}…'); + }); + + test('le résultat ne dépasse jamais la largeur, et garde le plus long préfixe admis', () => { + const textes = ['Théo Pervenche', 'WWWWWWWWWWWW', 'ÆÆÆÆ iii WWW', 'Ondine Lacasse \u{1D49C}\u{1D49C} Grisaille', 'Ôjgq', 'x'.repeat(200)]; + let comptes = 0; + for (const texte of textes) { + for (let largeur = 0; largeur <= 130; largeur += 1) { + const rendu = tronquer(texte, largeur, mesurer, MARQUE); + assert.ok(mesurer(rendu) <= largeur, `${texte} dans ${largeur} : ${rendu}`); + if (rendu !== texte && rendu !== '') { + // Le préfixe d'un glyphe de plus, suivi de la marque, ne tiendrait pas. + const prefixe = [...rendu.slice(0, -MARQUE.length)]; + const suivant = [...texte][prefixe.length]; + assert.ok(texte.startsWith(prefixe.join('')), `${texte} dans ${largeur}`); + assert.ok(mesurer(prefixe.join('') + suivant + MARQUE) > largeur, `${texte} dans ${largeur} : ${rendu}`); + } + comptes += 1; + } + } + assert.equal(comptes, 6 * 131); + }); +}); + +describe("texte : les lignes d'une liste (§ 7.4)", () => { + test('les ordonnées des n lignes partent de 0 et avancent de la hauteur de ligne', () => { + assert.deepEqual(decalagesLignes(3, 21.5), [0, 21.5, 43]); + assert.deepEqual(decalagesLignes(1, 21.5), [0]); + assert.deepEqual(decalagesLignes(0, 21.5), []); + assert.throws(() => decalagesLignes(-1, 21.5), RangeError); + assert.throws(() => decalagesLignes(1.5, 21.5), RangeError); + }); + + test('le gabarit de hauteur porte capitales accentuées et jambages ; le corps des listes vaut 18 cm', () => { + assert.equal(GABARIT_HAUTEUR_LIGNE, 'Ôjgq Æ W'); + assert.equal(TAILLE_TEXTE_LISTE_CM, 18); + }); +}); diff --git a/src/geometrie/vue.js b/src/geometrie/vue.js new file mode 100644 index 0000000..c9ab959 --- /dev/null +++ b/src/geometrie/vue.js @@ -0,0 +1,218 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La vue du plan (§ 7.2) : trois nombres, k en px/cm, tx et ty en px CSS, que +// le dessin écrit dans un seul attribut, transform="translate(tx,ty) scale(k)", +// sur le groupe [data-vue] d'un sans viewBox. Toute +// conversion entre l'écran et le dessin passe par ce module : le dessin, la +// désignation d'un siège et le dépôt d'un glissement lisent la même formule. +// Aucune fonction ne lit le DOM : le cadre, rectangle du à l'écran, est +// un paramètre que l'appelant relit au pointerdown. + +/** @typedef {{k: number, tx: number, ty: number}} Vue */ +/** @typedef {{gauche: number, haut: number, largeur: number, hauteur: number}} Cadre */ +/** @typedef {{x: number, y: number}} Point */ +/** @typedef {{minX: number, minY: number, maxX: number, maxY: number}} Englobant */ + +/** Borne haute du zoom, en px/cm (§ 7.2). */ +export const K_MAX = 10; +/** k_min = FACTEUR_K_MIN × k_ajusté ; aucune borne basse absolue (§ 7.2). */ +export const FACTEUR_K_MIN = 0.5; +/** k ajusté d'un plan sans table, en px/cm : sans englobant, rien ne fixe d'échelle. */ +export const K_PLAN_VIDE = 1; +/** Marge laissée de chaque côté du cadre par l'ajustement, en px. */ +export const MARGE_AJUSTEMENT_PX = 16; +/** Facteur de zoom d'un cran de molette. */ +export const FACTEUR_CRAN = 1.1; + +// Longueur d'un cran de molette, par valeur de WheelEvent.deltaMode : en +// pixels, en lignes, en pages. +const CRAN_PAR_MODE = [100, 3, 1]; + +/** + * Point de dessin, en cm, sous le point écran (xEcran, yEcran), en px CSS de + * la fenêtre : la formule du § 7.2, cadre soustrait avant la translation, la + * division par k en dernier. + * @param {Vue} vue @param {Cadre} cadre + * @returns {Point} + */ +export function dessinDepuisEcran(vue, cadre, xEcran, yEcran) { + return { x: (xEcran - cadre.gauche - vue.tx) / vue.k, y: (yEcran - cadre.haut - vue.ty) / vue.k }; +} + +/** + * Point écran, en px CSS de la fenêtre, du point de dessin (x, y) en cm : la + * réciproque de dessinDepuisEcran. + * @param {Vue} vue @param {Cadre} cadre + * @returns {Point} + */ +export function ecranDepuisDessin(vue, cadre, x, y) { + return { x: cadre.gauche + vue.tx + vue.k * x, y: cadre.haut + vue.ty + vue.k * y }; +} + +/** + * Le k qui fait tenir l'englobant dans le cadre moins ses marges, plafonné à + * K_MAX ; K_PLAN_VIDE sans englobant. Null quand la largeur ou la hauteur du + * cadre ne dépasse pas 2 × MARGE_AJUSTEMENT_PX, plan vide compris : aucun k + * positif n'y fait rien tenir, et un cadre de taille nulle est celui d'un plan + * monté caché. Un englobant plat dans une direction n'y contraint rien, et un + * englobant réduit à un point prend K_MAX. Tout k rendu est fini et > 0. + * @param {Englobant|null} englobant @param {Cadre} cadre + * @returns {number|null} + */ +export function kAjuste(englobant, cadre) { + const utileX = cadre.largeur - 2 * MARGE_AJUSTEMENT_PX; + const utileY = cadre.hauteur - 2 * MARGE_AJUSTEMENT_PX; + if (!(utileX > 0 && utileY > 0)) return null; + if (englobant === null) return K_PLAN_VIDE; + const etendueX = englobant.maxX - englobant.minX; + const etendueY = englobant.maxY - englobant.minY; + // Une étendue nulle donne Infinity, que le plafond ramène à K_MAX. + const kX = etendueX > 0 ? utileX / etendueX : Number.POSITIVE_INFINITY; + const kY = etendueY > 0 ? utileY / etendueY : Number.POSITIVE_INFINITY; + return Math.min(K_MAX, kX, kY); +} + +/** + * La vue ajustée : k = kAjuste, centre de l'englobant au centre du cadre ; un + * plan vide met l'origine du dessin au centre. Null quand kAjuste l'est : + * l'appelant garde la vue en place. + * @param {Englobant|null} englobant @param {Cadre} cadre + * @returns {Vue|null} + */ +export function ajuster(englobant, cadre) { + const k = kAjuste(englobant, cadre); + if (k === null) return null; + const cx = englobant === null ? 0 : (englobant.minX + englobant.maxX) / 2; + const cy = englobant === null ? 0 : (englobant.minY + englobant.maxY) / 2; + return { k, tx: cadre.largeur / 2 - k * cx, ty: cadre.hauteur / 2 - k * cy }; +} + +/** + * Bornes du zoom : kMin = FACTEUR_K_MIN × kAjuste, kMax = K_MAX. Lève + * RangeError sur un kAjuste qui n'est pas un nombre fini > 0, null compris : + * 0,5 × null vaudrait 0, et la borne basse disparaîtrait sans bruit. + * @param {number} kAjuste + * @returns {{kMin: number, kMax: number}} + */ +export function bornesZoom(kAjuste) { + if (typeof kAjuste !== 'number' || !Number.isFinite(kAjuste) || kAjuste <= 0) { + throw new RangeError(`k ajusté : nombre fini > 0 attendu, reçu ${String(kAjuste)}`); + } + return { kMin: FACTEUR_K_MIN * kAjuste, kMax: K_MAX }; +} + +// k ramené dans les bornes. +const dansBornes = (k, bornes) => Math.min(bornes.kMax, Math.max(bornes.kMin, k)); + +// La vue d'échelle k dont le point écran (xCadre, yCadre), relatif au coin du +// cadre, montre le même point de dessin que dans vue. +function versK(vue, xCadre, yCadre, k) { + const x = (xCadre - vue.tx) / vue.k; + const y = (yCadre - vue.ty) / vue.k; + return { k, tx: xCadre - k * x, ty: yCadre - k * y }; +} + +/** + * La vue, son k ramené dans les bornes, le point de dessin au centre du cadre + * restant au centre ; inchangée quand k y est déjà. Un cadrage mémorisé, + * restitué dans un autre cadre, y passe. + * @param {Vue} vue @param {Cadre} cadre @param {{kMin: number, kMax: number}} bornes + * @returns {Vue} + */ +export function borner(vue, cadre, bornes) { + const k = dansBornes(vue.k, bornes); + if (k === vue.k) return { k: vue.k, tx: vue.tx, ty: vue.ty }; + return versK(vue, cadre.largeur / 2, cadre.hauteur / 2, k); +} + +/** + * Zoom d'un facteur sous le point écran (xEcran, yEcran) : k devient + * k × facteur ramené dans les bornes, puis la translation se calcule sur ce k + * borné, de sorte que le point de dessin sous le pointeur y reste, à une borne + * comprise. Lève RangeError sur un facteur qui n'est pas un nombre fini > 0. + * @param {Vue} vue @param {Cadre} cadre @param {number} facteur + * @param {{kMin: number, kMax: number}} bornes + * @returns {Vue} + */ +export function zoomerSous(vue, cadre, xEcran, yEcran, facteur, bornes) { + if (!Number.isFinite(facteur) || facteur <= 0) { + throw new RangeError(`facteur de zoom : nombre fini > 0 attendu, reçu ${String(facteur)}`); + } + return versK(vue, xEcran - cadre.gauche, yEcran - cadre.haut, dansBornes(vue.k * facteur, bornes)); +} + +/** + * Position d'une table glissée, en cm : positionInitiale + (pointeur − + * pointeurInitial) / k. Le calcul se fait en écart : la table garde le + * décalage où le pointeur l'a saisie, et un mouvement nul la laisse en place. + * @param {Point} positionInitiale cm @param {Point} pointeurInitial px + * @param {Point} pointeur px @param {number} k + * @returns {Point} + */ +export function deplacer(positionInitiale, pointeurInitial, pointeur, k) { + return { + x: positionInitiale.x + (pointeur.x - pointeurInitial.x) / k, + y: positionInitiale.y + (pointeur.y - pointeurInitial.y) / k, + }; +} + +/** + * Vue glissée par le sol : tx, ty + (pointeur − pointeurInitial), en px ; k + * inchangé. + * @param {Vue} vueInitiale @param {Point} pointeurInitial @param {Point} pointeur + * @returns {Vue} + */ +export function deplacerVue(vueInitiale, pointeurInitial, pointeur) { + return { + k: vueInitiale.k, + tx: vueInitiale.tx + (pointeur.x - pointeurInitial.x), + ty: vueInitiale.ty + (pointeur.y - pointeurInitial.y), + }; +} + +/** + * Facteur de zoom d'un événement de molette : FACTEUR_CRAN puissance le + * nombre de crans, un cran valant 100 px, 3 lignes ou une page selon deltaMode + * (0, 1, 2) ; > 1 vers le haut (deltaY négatif), 1 sans mouvement. Lève + * RangeError sur un autre deltaMode. + * @param {number} deltaY @param {number} deltaMode + * @returns {number} + */ +export function facteurMolette(deltaY, deltaMode) { + const cran = CRAN_PAR_MODE[deltaMode]; + if (!Number.isInteger(deltaMode) || cran === undefined) { + throw new RangeError(`deltaMode : 0, 1 ou 2 attendu, reçu ${String(deltaMode)}`); + } + return FACTEUR_CRAN ** (-deltaY / cran); +} + +/** + * Valeur de l'attribut transform du groupe [data-vue] : chaque nombre écrit + * par String, que Number relit à l'identique. + * @param {Vue} vue + * @returns {string} + */ +export function transformDe(vue) { + return `translate(${String(vue.tx)},${String(vue.ty)}) scale(${String(vue.k)})`; +} + +// Un nombre tel que String l'écrit : signe, chiffres, partie décimale, +// exposant. +const NOMBRE = String.raw`-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?`; +const FORME_TRANSFORM = new RegExp(`^translate\\((${NOMBRE}),(${NOMBRE})\\) scale\\((${NOMBRE})\\)$`); + +/** + * La vue que décrit un attribut transform de la forme qu'écrit transformDe. + * Lève SyntaxError sur toute autre forme, et sur un k qui n'est pas > 0. + * @param {string} texte + * @returns {Vue} + */ +export function vueDepuisTransform(texte) { + const lu = FORME_TRANSFORM.exec(texte); + const k = lu === null ? Number.NaN : Number(lu[3]); + if (lu === null || !(k > 0) || !Number.isFinite(k)) { + throw new SyntaxError(`transform de vue attendu, reçu ${JSON.stringify(texte)}`); + } + return { k, tx: Number(lu[1]), ty: Number(lu[2]) }; +} diff --git a/src/geometrie/vue.test.js b/src/geometrie/vue.test.js new file mode 100644 index 0000000..0f2363a --- /dev/null +++ b/src/geometrie/vue.test.js @@ -0,0 +1,348 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la vue du plan (§ 7.2, § 14.10) : la conversion écran ↔ dessin, +// l'ajustement, les bornes relatives du zoom, le zoom sous le curseur, le +// glissement en écart, la molette et l'attribut transform. Chaque valeur +// attendue est écrite en clair ou se déduit, à la main, de la formule du +// § 7.2 ; les formules fausses qu'une épreuve doit refuser sont écrites ici, +// et montrées rouges sur les mêmes cas. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { FLUX, creerPcg32 } from '../demo/prng.js'; +import { + FACTEUR_CRAN, + FACTEUR_K_MIN, + K_MAX, + K_PLAN_VIDE, + MARGE_AJUSTEMENT_PX, + ajuster, + borner, + bornesZoom, + deplacer, + deplacerVue, + dessinDepuisEcran, + ecranDepuisDessin, + facteurMolette, + kAjuste, + transformDe, + vueDepuisTransform, + zoomerSous, +} from './vue.js'; + +const PRECISION = 1e-9; + +// Vrai quand a et b diffèrent de moins de PRECISION, relativement à leur +// grandeur quand elle dépasse 1. +const proches = (a, b) => Math.abs(a - b) <= PRECISION * Math.max(1, Math.abs(a), Math.abs(b)); + +function egauxPoints(recu, attendu, message) { + assert.ok( + proches(recu.x, attendu.x) && proches(recu.y, attendu.y), + `${message} : (${recu.x}, ${recu.y}) au lieu de (${attendu.x}, ${attendu.y})`, + ); +} + +function egalesVues(recue, attendue, message) { + assert.ok( + proches(recue.k, attendue.k) && proches(recue.tx, attendue.tx) && proches(recue.ty, attendue.ty), + `${message} : ${JSON.stringify(recue)} au lieu de ${JSON.stringify(attendue)}`, + ); +} + +// Un englobant carré de côté c cm, coin haut gauche à l'origine. +const carre = (c) => ({ minX: 0, minY: 0, maxX: c, maxY: c }); + +describe('vue : conversion écran ↔ dessin (§ 7.2)', () => { + const VUE = { k: 2, tx: 10, ty: 20 }; + const CADRE = { gauche: 100, haut: 50, largeur: 800, hauteur: 600 }; + + // Cas écrits à la main : [vue, cadre, point écran, point de dessin]. + const CAS = [ + [VUE, CADRE, { x: 150, y: 90 }, { x: 20, y: 10 }], + [{ k: 0.19, tx: -50, ty: 30 }, { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 }, { x: 67, y: 279 }, { x: 80 / 0.19, y: 38 / 0.19 }], + [{ k: 10, tx: 0, ty: 0 }, { gauche: 37, haut: 211, largeur: 300, hauteur: 900 }, { x: 37, y: 211 }, { x: 0, y: 0 }], + ]; + + // Écarts d'une conversion aux cas écrits à la main, un par cas faux. + const ecarts = (conversion) => + CAS.filter(([vue, cadre, ecran, dessin]) => { + const recu = conversion(vue, cadre, ecran.x, ecran.y); + return !(proches(recu.x, dessin.x) && proches(recu.y, dessin.y)); + }); + + test('le point écran (150, 90) est le point de dessin (20, 10), et réciproquement', () => { + egauxPoints(dessinDepuisEcran(VUE, CADRE, 150, 90), { x: 20, y: 10 }, 'dessin'); + egauxPoints(ecranDepuisDessin(VUE, CADRE, 20, 10), { x: 150, y: 90 }, 'écran'); + assert.deepEqual(ecarts(dessinDepuisEcran), []); + }); + + test('trois formules fausses tombent sur les mêmes cas : cadre oublié, division avant la translation, répartition dans un viewBox', () => { + const cadreOublie = (vue, cadre, x, y) => ({ x: (x - vue.tx) / vue.k, y: (y - vue.ty) / vue.k }); + const diviseAvant = (vue, cadre, x, y) => ({ + x: (x - cadre.gauche) / vue.k - vue.tx, + y: (y - cadre.haut) / vue.k - vue.ty, + }); + // Un viewBox carré, de la largeur visible du dessin : le pointeur s'y + // répartit en proportion du cadre, juste en x, faux en y dès que le cadre + // n'est pas carré. + const viewBox = (vue, cadre, x, y) => { + const cote = cadre.largeur / vue.k; + return { + x: -vue.tx / vue.k + ((x - cadre.gauche) / cadre.largeur) * cote, + y: -vue.ty / vue.k + ((y - cadre.haut) / cadre.hauteur) * cote, + }; + }; + for (const [nom, faux] of [['cadre oublié', cadreOublie], ['division avant', diviseAvant], ['viewBox', viewBox]]) { + assert.ok(ecarts(faux).length > 0, nom); + } + }); + + test("l'aller-retour écran → dessin → écran rend le point, sur 25 points, quatre k et un cadre décalé", () => { + const cadre = { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 }; + let comptes = 0; + for (const k of [0.1, 0.19, 1, 10]) { + const vue = { k, tx: -123.25, ty: 47.5 }; + for (let i = 0; i < 5; i += 1) { + for (let j = 0; j < 5; j += 1) { + const ecran = { x: cadre.gauche + (i * cadre.largeur) / 4, y: cadre.haut + (j * cadre.hauteur) / 4 }; + const dessin = dessinDepuisEcran(vue, cadre, ecran.x, ecran.y); + egauxPoints(ecranDepuisDessin(vue, cadre, dessin.x, dessin.y), ecran, `k = ${k}, (${i}, ${j})`); + comptes += 1; + } + } + } + assert.equal(comptes, 100); + }); +}); + +describe("vue : l'ajustement et les bornes relatives du zoom (§ 7.2, § 14.10)", () => { + test('un plan de 36 m dans 1 366 × 700 s\'ajuste sous 0,2 px/cm, et k_min vaut 0,5 × k_ajusté sur deux plans', () => { + const cadre = { gauche: 0, haut: 0, largeur: 1366, hauteur: 700 }; + const grand = kAjuste(carre(3600), cadre); + assert.ok(proches(grand, (700 - 32) / 3600), `k ajusté ${grand}`); + assert.ok(grand < 0.2); + assert.equal(bornesZoom(grand).kMin, 0.5 * grand); + assert.ok(bornesZoom(grand).kMin < 0.2); + const petit = kAjuste(carre(400), cadre); + assert.ok(proches(petit, 668 / 400), `k ajusté ${petit}`); + assert.equal(bornesZoom(petit).kMin, 0.5 * petit); + assert.equal(bornesZoom(petit).kMax, 10); + assert.equal(FACTEUR_K_MIN, 0.5); + assert.equal(K_MAX, 10); + assert.equal(MARGE_AJUSTEMENT_PX, 16); + }); + + test('k_max vaut 10 : un zoom avant de facteur 2 depuis k = 9 rend 10', () => { + const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 }; + const vue = zoomerSous({ k: 9, tx: 0, ty: 0 }, cadre, 400, 300, 2, bornesZoom(1)); + assert.equal(vue.k, 10); + }); + + test("ajuster centre l'englobant au k ajusté ; le plan vide prend 1 px/cm, l'origine au centre", () => { + const cadre = { gauche: 0, haut: 0, largeur: 1366, hauteur: 700 }; + const vue = ajuster(carre(3600), cadre); + assert.ok(proches(vue.k, 668 / 3600)); + egauxPoints(ecranDepuisDessin(vue, cadre, 1800, 1800), { x: 683, y: 350 }, 'centre'); + const decale = { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 }; + egauxPoints(ecranDepuisDessin(ajuster(carre(3600), decale), decale, 1800, 1800), { x: 37 + 683, y: 211 + 350 }, 'centre, cadre décalé'); + + const vide = { gauche: 5, haut: 7, largeur: 800, hauteur: 600 }; + assert.equal(K_PLAN_VIDE, 1); + assert.equal(kAjuste(null, vide), 1); + assert.deepEqual(ajuster(null, vide), { k: 1, tx: 400, ty: 300 }); + assert.deepEqual(bornesZoom(kAjuste(null, vide)), { kMin: 0.5, kMax: 10 }); + }); + + test("un englobant de 10 × 10 cm s'ajuste à k = 10, plafonné ; un englobant réduit à un point aussi", () => { + const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 }; + assert.equal(kAjuste(carre(10), cadre), 10); + assert.equal(ajuster(carre(10), cadre).k, 10); + assert.equal(kAjuste({ minX: 300, minY: 300, maxX: 300, maxY: 300 }, cadre), 10); + const ligne = ajuster({ minX: 0, minY: 300, maxX: 3000, maxY: 300 }, cadre); + assert.ok(proches(ligne.k, 768 / 3000)); + egauxPoints(ecranDepuisDessin(ligne, cadre, 1500, 300), { x: 400, y: 300 }, 'centre'); + }); + + test("un cadre nul ou plus petit que ses marges ne s'ajuste pas : null, plan vide compris ; 33 px de large suffisent", () => { + for (const [largeur, hauteur] of [[0, 600], [800, 0], [0, 0], [20, 20], [32, 600], [800, 32]]) { + const cadre = { gauche: 0, haut: 0, largeur, hauteur }; + assert.equal(kAjuste(carre(3600), cadre), null, `${largeur} × ${hauteur}`); + assert.equal(kAjuste(null, cadre), null, `${largeur} × ${hauteur}, plan vide`); + assert.equal(ajuster(carre(3600), cadre), null, `${largeur} × ${hauteur}`); + assert.equal(ajuster(null, cadre), null, `${largeur} × ${hauteur}, plan vide`); + } + const etroit = { gauche: 0, haut: 0, largeur: 33, hauteur: 600 }; + assert.ok(proches(kAjuste(carre(3600), etroit), 1 / 3600)); + }); + + test('bornesZoom refuse un k ajusté qui n\'est pas un nombre fini positif', () => { + for (const k of [null, undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY, '1']) { + assert.throws(() => bornesZoom(k), RangeError, String(k)); + } + }); + + test('200 englobants et cadres tirés, nuls et vides compris : chaque nombre rendu est fini, chaque k > 0, ou le résultat vaut null', () => { + const GRAINE = 20260; + const alea = creerPcg32(GRAINE, FLUX.DEMO); + let nuls = 0; + let rendus = 0; + for (let i = 0; i < 200; i += 1) { + const nature = i % 5; + const cadre = { + gauche: alea.borne(2000) - 1000, + haut: alea.borne(2000) - 1000, + largeur: nature === 0 ? alea.borne(33) : alea.borne(3000), + hauteur: nature === 1 ? 0 : alea.borne(3000), + }; + let englobant = null; + if (nature >= 2) { + const minX = alea.borne(20000) - 10000; + const minY = alea.borne(20000) - 10000; + const plat = nature === 2; + englobant = { + minX, + minY, + maxX: minX + (plat ? 0 : alea.borne(10000) / 7), + maxY: minY + (plat ? 0 : alea.borne(10000) / 3), + }; + } + const k = kAjuste(englobant, cadre); + const vue = ajuster(englobant, cadre); + if (k === null) { + assert.equal(vue, null, `tirage ${i}`); + nuls += 1; + continue; + } + rendus += 1; + assert.ok(Number.isFinite(k) && k > 0 && k <= K_MAX, `tirage ${i} : k ${k}`); + assert.ok([vue.k, vue.tx, vue.ty].every(Number.isFinite) && vue.k > 0, `tirage ${i} : ${JSON.stringify(vue)}`); + const bornes = bornesZoom(k); + assert.ok(Number.isFinite(bornes.kMin) && bornes.kMin > 0, `tirage ${i}`); + } + assert.ok(nuls > 0 && rendus > 0, `${nuls} nuls, ${rendus} rendus`); + }); + + test('un cadrage hors des bornes est ramené dans elles, le centre du cadre fixe', () => { + const cadre = { gauche: 30, haut: 40, largeur: 800, hauteur: 600 }; + const k = kAjuste({ minX: 0, minY: 0, maxX: 1000, maxY: 1420 }, cadre); + assert.ok(proches(k, 0.4)); + const bornes = bornesZoom(k); + const memorise = { k: 0.1, tx: -25, ty: 60 }; + const centre = dessinDepuisEcran(memorise, cadre, 30 + 400, 40 + 300); + const ramene = borner(memorise, cadre, bornes); + assert.ok(proches(ramene.k, 0.2), `k ${ramene.k}`); + egauxPoints(dessinDepuisEcran(ramene, cadre, 30 + 400, 40 + 300), centre, 'centre'); + const trop = borner({ k: 25, tx: 3, ty: 4 }, cadre, bornes); + assert.equal(trop.k, 10); + assert.deepEqual(borner({ k: 1, tx: 3, ty: 4 }, cadre, bornes), { k: 1, tx: 3, ty: 4 }); + }); +}); + +describe('vue : le zoom sous le curseur (§ 7.2)', () => { + const CADRE = { gauche: 37, haut: 211, largeur: 800, hauteur: 600 }; + const DEPART = { k: 1, tx: 50, ty: 40 }; + const BORNES = { kMin: 0.2, kMax: 10 }; + const POINTS = [ + { x: 37 + 400, y: 211 + 300 }, + { x: 37 + 799, y: 211 + 599 }, + { x: 37, y: 211 }, + ]; + + // Les cas où le point de dessin sous le pointeur bouge, pour une fonction + // de zoom donnée. + const fuites = (zoomer) => + [1.1, 1 / 1.1, 100, 0.001].flatMap((facteur) => + POINTS.filter((p) => { + const avant = dessinDepuisEcran(DEPART, CADRE, p.x, p.y); + const apres = dessinDepuisEcran(zoomer(DEPART, CADRE, p.x, p.y, facteur, BORNES), CADRE, p.x, p.y); + return !(proches(avant.x, apres.x) && proches(avant.y, apres.y)); + }), + ); + + test('le point de dessin sous le pointeur y reste, au centre comme au coin, aux bornes comprises', () => { + assert.deepEqual(fuites(zoomerSous), []); + assert.equal(zoomerSous(DEPART, CADRE, 437, 511, 100, BORNES).k, 10); + assert.equal(zoomerSous(DEPART, CADRE, 437, 511, 0.001, BORNES).k, 0.2); + assert.ok(proches(zoomerSous(DEPART, CADRE, 437, 511, 1.1, BORNES).k, 1.1)); + }); + + test("deux zooms faux tombent : autour de l'origine du cadre, et le bornage appliqué après coup", () => { + const borne = (k) => Math.min(BORNES.kMax, Math.max(BORNES.kMin, k)); + const autourDeLOrigine = (vue, cadre, x, y, facteur, bornes) => ({ k: borne(vue.k * facteur), tx: vue.tx, ty: vue.ty }); + const borneApres = (vue, cadre, x, y, facteur) => { + const k = vue.k * facteur; + const p = dessinDepuisEcran(vue, cadre, x, y); + return { k: borne(k), tx: x - cadre.gauche - k * p.x, ty: y - cadre.haut - k * p.y }; + }; + assert.ok(fuites(autourDeLOrigine).length > 0); + assert.ok(fuites(borneApres).length > 0); + }); + + test('un facteur qui n\'est pas un nombre fini positif est refusé', () => { + for (const facteur of [0, -1.1, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws(() => zoomerSous(DEPART, CADRE, 437, 511, facteur, BORNES), RangeError, String(facteur)); + } + }); +}); + +describe('vue : le glissement se calcule en écart (§ 7.2)', () => { + test("une table saisie à 37 px de son centre suit l'écart du pointeur, divisé par k", () => { + const initiale = { x: 300, y: 300 }; + const pointeurInitial = { x: 437, y: 260 }; + assert.deepEqual(deplacer(initiale, pointeurInitial, { x: 477, y: 240 }, 2), { x: 320, y: 290 }); + assert.deepEqual(deplacer(initiale, pointeurInitial, pointeurInitial, 2), initiale); + }); + + test("la mutation qui met l'origine de la table sous le pointeur tombe", () => { + // Vue où le centre de la table, (300, 300) cm, est à l'écran (400, 260) : + // le pointeur saisi en (437, 260) est à 37 px à droite du centre. + const vue = { k: 2, tx: -200, ty: -340 }; + const cadre = { gauche: 0, haut: 0, largeur: 800, hauteur: 600 }; + egauxPoints(ecranDepuisDessin(vue, cadre, 300, 300), { x: 400, y: 260 }, 'centre'); + const sousLePointeur = (initiale, pointeurInitial, pointeur) => dessinDepuisEcran(vue, cadre, pointeur.x, pointeur.y); + const initiale = { x: 300, y: 300 }; + const pointeurInitial = { x: 437, y: 260 }; + assert.notDeepEqual(sousLePointeur(initiale, pointeurInitial, pointeurInitial, 2), initiale); + assert.notDeepEqual(sousLePointeur(initiale, pointeurInitial, { x: 477, y: 240 }, 2), { x: 320, y: 290 }); + }); + + test("glisser le sol ajoute l'écart du pointeur, en px, et garde k", () => { + assert.deepEqual(deplacerVue({ k: 0.5, tx: 10, ty: -20 }, { x: 100, y: 100 }, { x: 130, y: 85 }), { k: 0.5, tx: 40, ty: -35 }); + }); +}); + +describe("vue : la molette et l'attribut transform", () => { + test('un cran vaut 100 px, 3 lignes ou une page ; vers le haut, le facteur dépasse 1', () => { + assert.equal(FACTEUR_CRAN, 1.1); + assert.ok(proches(facteurMolette(-100, 0), 1.1)); + assert.ok(proches(facteurMolette(100, 0), 1 / 1.1)); + assert.ok(proches(facteurMolette(-3, 1), 1.1)); + assert.ok(proches(facteurMolette(1, 2), 1 / 1.1)); + assert.ok(proches(facteurMolette(-250, 0), 1.1 ** 2.5)); + assert.equal(facteurMolette(0, 0), 1); + assert.throws(() => facteurMolette(-100, 3), RangeError); + }); + + test('transformDe et vueDepuisTransform font l\'aller-retour ; une autre forme lève', () => { + const vues = [ + { k: 2, tx: 10, ty: 20 }, + { k: 1 / 3600, tx: -683.5, ty: 0.1 + 0.2 }, + { k: 10, tx: 1e-7, ty: -2.5e21 }, + ]; + assert.equal(transformDe(vues[0]), 'translate(10,20) scale(2)'); + for (const vue of vues) assert.deepEqual(vueDepuisTransform(transformDe(vue)), vue); + for (const texte of [ + '', + 'translate(10,20)', + 'scale(2) translate(10,20)', + 'translate(10 20) scale(2)', + 'translate(10,20) scale(0)', + 'translate(10,20) scale(-2)', + 'translate(a,20) scale(2)', + 'translate(10,20) scale(2) rotate(3)', + ]) { + assert.throws(() => vueDepuisTransform(texte), SyntaxError, texte); + } + }); +}); diff --git a/test/arborescence.test.js b/test/arborescence.test.js index 70ff45a..d13ef42 100644 --- a/test/arborescence.test.js +++ b/test/arborescence.test.js @@ -1,9 +1,9 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Gardes de l'arborescence, lues dans le texte des sources : le moteur, le -// générateur de démonstrations, le stockage et l'analyseur CSV n'appellent -// aucune source non reproductible (§ 14.7), et ne parcourent les clés d'un +// Gardes de l'arborescence, lues dans le texte des sources : le moteur, la +// géométrie du plan, le générateur de démonstrations, le stockage et +// l'analyseur CSV n'appellent aucune source non reproductible (§ 14.7), et ne parcourent les clés d'un // objet que par exception nommée (§ 15.5, point 4) ; le graphe d'imports de // chaque couche ne rejoint que ce que le § 13.4 lui permet ; chaque paquet // importé est déclaré dans devDependencies ; une valeur du contrat de données @@ -138,10 +138,10 @@ const PARCOURS_DE_CLES_ADMIS = [ ]; // Arbres dont les modules ne lisent aucune source non reproductible (§ 14.7) : -// le moteur et le générateur de démonstrations, puis le stockage et -// l'analyseur CSV, qui reçoivent l'horloge et l'aléa de l'application, en -// paramètre. -const ARBRES_DETERMINISTES = ['src/moteur', 'src/demo', 'src/stockage', 'src/csv']; +// le moteur, la géométrie du plan et le générateur de démonstrations, puis le +// stockage et l'analyseur CSV, qui reçoivent l'horloge et l'aléa de +// l'application, en paramètre. +const ARBRES_DETERMINISTES = ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv']; // Vrai quand le motif se lit dans le texte à partir de l'indice donné, et non // plus loin : une copie collante du motif, ses drapeaux g et y retirés. @@ -196,6 +196,7 @@ function releverDeterminisme(racine, admis = PARCOURS_DE_CLES_ADMIS) { // pour éprouver le refus d'un balayage vide. const MODULES_VIDES = Object.freeze({ 'src/moteur/a.js': '', + 'src/geometrie/a.js': '', 'src/demo/a.js': '', 'src/stockage/a.js': '', 'src/csv/a.js': '', @@ -210,7 +211,7 @@ const sansArbre = (fichiers, arbre) => const releverSansException = (racine) => releverDeterminisme(racine, []); describe('arborescence : déterminisme (§ 14.7)', () => { - test("ni src/moteur, ni src/demo, ni src/stockage, ni src/csv ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => { + test("ni src/moteur, ni src/geometrie, ni src/demo, ni src/stockage, ni src/csv ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => { assert.deepEqual(releverDeterminisme(RACINE), []); }); @@ -315,7 +316,7 @@ describe('arborescence : déterminisme (§ 14.7)', () => { 'src/demo/tirage.cjs:1 Date.now', 'src/demo/tirage.mjs:1 Math.random', ]); - for (const arbre of ['src/moteur', 'src/demo', 'src/stockage', 'src/csv']) { + for (const arbre of ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv']) { const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' }; assert.throws( () => avecArbre(sansModule, releverSansException), @@ -325,6 +326,24 @@ describe('arborescence : déterminisme (§ 14.7)', () => { } }); + test('src/geometrie se relève comme le moteur, à toute profondeur, hors épreuves', () => { + const fichiers = { + ...MODULES_VIDES, + 'src/geometrie/vue.js': [ + 'export const a = Math.random();', + 'export const b = (o) => Object.keys(o).map((cle) => o[cle]);', + 'export const c = Math.min(1, 2) + Math.floor(2.5);', + ].join('\n'), + 'src/geometrie/sous/texte.js': 'for (const champ in ligne) t.push(champ);\n', + 'src/geometrie/vue.test.js': 'export const d = Math.random() + Object.keys(o).length;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverSansException), [ + 'src/geometrie/sous/texte.js:1 for…in', + 'src/geometrie/vue.js:1 Math.random', + 'src/geometrie/vue.js:2 Object.keys', + ]); + }); + test('src/stockage et src/csv se relèvent comme le moteur et le générateur, à toute profondeur, hors épreuves', () => { const fichiers = { ...MODULES_VIDES, @@ -470,7 +489,9 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/; // l'application. // // Le moteur et la géométrie ne connaissent ni l'écran, ni les fichiers, ni ce -// qui les commande. Le stockage lit et écrit par le système de fichiers qu'il +// qui les commande. La géométrie est une couche à part, plus bas que le +// moteur : des fonctions pures du plan, qui n'importent ni le moteur, ni +// l'analyseur CSV, ni le générateur de démonstrations. Le stockage lit et écrit par le système de fichiers qu'il // reçoit ; seules ses deux implémentations de plateforme touchent le // navigateur, par l'OPFS sous web et par le pont window.gtt sous Electron. // L'analyseur CSV lit le modèle du stockage. L'application câble le moteur, @@ -478,12 +499,19 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/; // l'interface. const COUCHES = [ { - arbres: ['src/moteur', 'src/geometrie'], + arbres: ['src/moteur'], requis: ['src/moteur'], exterieures: ['interface', 'application', 'stockage'], paquets: PAQUETS_DE_PLATEFORME, navigateur: [], }, + { + arbres: ['src/geometrie'], + requis: ['src/geometrie'], + exterieures: ['interface', 'application', 'stockage', 'moteur', 'csv', 'demo'], + paquets: PAQUETS_DE_PLATEFORME, + navigateur: [], + }, { arbres: ['src/stockage'], requis: ['src/stockage'], @@ -815,10 +843,10 @@ describe('arborescence : frontière des couches (§ 13.4)', () => { 'src/moteur/a.js:26 import ../application/seance.js', 'src/moteur/b.js:4 import ../interface/App.svelte', 'src/moteur/sous/c.js:1 import ../../interface/x.js', + 'src/moteur/a.js → src/commun/aide.js:2 window.', 'src/geometrie/echelle.js:1 window.', 'src/geometrie/echelle.js:3 import ../application/commandes.js', 'src/geometrie/echelle.js:4 import ../application/seance.js', - 'src/moteur/a.js → src/commun/aide.js:2 window.', 'src/geometrie/echelle.js → src/commun/racine.js:1 window.', 'src/stockage/fichiers.js:1 import @capacitor/core', ]); @@ -989,10 +1017,57 @@ describe('arborescence : frontière des couches (§ 13.4)', () => { ]); }); - test('src/geometrie et src/application peuvent manquer ; un src/moteur, un src/stockage ou un src/csv sans module fait échouer la garde', () => { + test("src/geometrie n'importe ni le moteur, ni le CSV, ni les démonstrations : une couche à part, plus bas que le moteur", () => { + const fichiers = { + ...MODULES_VIDES, + 'src/geometrie/vue.js': [ + "import { mesurer } from '../moteur/indicateurs.js';", + "import { decouper } from '../csv/lecture.js';", + "import { creerPcg32 } from '../demo/prng.js';", + "import { tronquer } from './texte.js';", + ].join('\n'), + 'src/geometrie/texte.js': 'export const tronquer = (t) => t;\n', + 'src/moteur/indicateurs.js': 'export const mesurer = () => 0;\n', + 'src/csv/lecture.js': 'export const decouper = () => [];\n', + 'src/demo/prng.js': 'export const creerPcg32 = () => null;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/geometrie/vue.js:1 import ../moteur/indicateurs.js', + 'src/geometrie/vue.js:2 import ../csv/lecture.js', + 'src/geometrie/vue.js:3 import ../demo/prng.js', + ]); + }); + + test("src/geometrie n'importe ni l'interface, ni l'application, ni le stockage, ni un paquet de plateforme", () => { + const fichiers = { + ...MODULES_VIDES, + 'src/geometrie/vue.js': [ + "import { x } from '../interface/x.js';", + "import { y } from '../application/y.js';", + "import { lire } from '../stockage/document.js';", + "import { mount } from 'svelte';", + "import { writable } from 'svelte/store';", + "import { app } from 'electron';", + "import { Capacitor } from '@capacitor/core';", + "import { tronquer } from './texte.js';", + ].join('\n'), + 'src/geometrie/texte.js': 'export const tronquer = (t) => t;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/geometrie/vue.js:1 import ../interface/x.js', + 'src/geometrie/vue.js:2 import ../application/y.js', + 'src/geometrie/vue.js:3 import ../stockage/document.js', + 'src/geometrie/vue.js:4 import svelte', + 'src/geometrie/vue.js:5 import svelte/store', + 'src/geometrie/vue.js:6 import electron', + 'src/geometrie/vue.js:7 import @capacitor/core', + ]); + }); + + test('src/application peut manquer ; un src/moteur, un src/geometrie, un src/stockage ou un src/csv sans module fait échouer la garde', () => { assert.deepEqual(avecArbre(MODULES_VIDES, releverFrontiere), []); - for (const arbre of ['src/moteur', 'src/stockage', 'src/csv']) { - const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '', 'src/geometrie/echelle.js': '' }; + for (const arbre of ['src/moteur', 'src/geometrie', 'src/stockage', 'src/csv']) { + const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' }; assert.throws( () => avecArbre(sansModule, releverFrontiere), new RegExp(`${arbre} ne fournit aucun module`),