diff --git a/scripts/engendrer_livrables.js b/scripts/engendrer_livrables.js
index 9637f22..adf9a49 100644
--- a/scripts/engendrer_livrables.js
+++ b/scripts/engendrer_livrables.js
@@ -28,11 +28,13 @@
// plus grande à égalité ; une table d'une autre capacité porte la sienne
// en surcharge (§ 6.1) ;
// - les tables rondes, rangées en grille dans l'ordre de la configuration,
-// rangée par rangée, leurs rangées espacées de 300 cm ; la grille a la
-// plus petite largeur dont le carré contient toutes les tables, et seule
-// sa dernière rangée est incomplète. Ses colonnes s'écartent assez pour
-// que le bloc de noms d'une table, de toute sa largeur, tienne entre deux
-// colonnes : aucune liste ne passe sur une autre table (§ 7.4) ;
+// rangée par rangée ; la grille a la plus petite largeur dont le carré
+// contient toutes les tables, et seule sa dernière rangée est incomplète.
+// Son pas est ecartsGrille (src/geometrie/tables.js), celui des tables que
+// l'opérateur crée : ses colonnes s'écartent assez pour que le bloc de
+// noms d'une table, de toute sa largeur, tienne entre deux colonnes —
+// aucune liste ne passe sur une autre table (§ 7.4) —, ses rangées de
+// 300 cm au moins ;
// - les contraintes de la configuration, les sièges non attribués, la
// génération par défaut de la recherche ;
// - chaque réservation, celle d'un animateur, au siège 1 de sa table pour
@@ -49,8 +51,7 @@ import { decoder } from '../src/csv/encodage.js';
import { SEPARATEUR, ecrireCsv, exporterParticipants } from '../src/csv/export.js';
import { decouper } from '../src/csv/lecture.js';
import { CATALOGUE } from '../src/demo/catalogue.js';
-import { constante } from '../src/geometrie/constantes.js';
-import { ECART_LISTE, geometrieTable } from '../src/geometrie/tables.js';
+import { ecartsGrille } from '../src/geometrie/tables.js';
import { serialiser, serialiserCharge } from '../src/stockage/canonique.js';
import { analyser, creerCharge } from '../src/stockage/document.js';
import { VERSION } from '../src/version.genere.js';
@@ -70,9 +71,6 @@ const DEMONSTRATIONS_EN_CSV = ['petite', 'grande'];
// Séparateur de la variante windows-1252 (§ 10.3).
const VIRGULE = ',';
-// Écart entre les centres de deux rangées de la grille, en cm.
-const ECART_RANGEES_CM = 300;
-
// Le siège de l'animateur, et le titre qu'il porte.
const SIEGE_ANIMATEUR = 1;
const ANIMATION = 'animation';
@@ -100,21 +98,6 @@ function colonnesDe(nombre) {
return colonnes;
}
-// Écart entre les centres de deux colonnes de la grille, en cm entiers : le
-// bloc de noms d'une table commence à ECART_LISTE de son emprise et s'étend
-// sur LARGEUR_BLOC_NOMS_CM, où toute ligne s'abrège ; il s'achève à
-// ECART_LISTE de l'emprise de la colonne suivante, pour la plus grande
-// emprise des capacités données. Aucune emprise, d'aucune rangée, ne
-// rencontre ainsi la bande du bloc.
-function ecartColonnes(capacites) {
- const emprise = Math.max(
- ...capacites.map(
- (places) => geometrieTable({ id: 0, numero: 0, forme: 'ronde', position: { x: 0, y: 0 } }, places).rayonEmprise,
- ),
- );
- return Math.ceil(2 * emprise + 2 * ECART_LISTE + constante('LARGEUR_BLOC_NOMS_CM'));
-}
-
// Plus grand identifiant d'une liste d'enregistrements ; 0 pour une liste
// vide.
const plusGrandId = (enregistrements) => enregistrements.reduce((plus, { id }) => Math.max(plus, id), 0);
@@ -153,7 +136,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) {
const animateurs = animateursDe(reservations, cle);
const reserves = new Set(animateurs.values());
const colonnes = colonnesDe(tables.length);
- const ecart = ecartColonnes(tables.map(({ capacite }) => capacite));
+ const pas = ecartsGrille(tables.map(({ capacite }) => ({ forme: 'ronde', places: capacite })));
const { separerAppartenances, nouveauxVoisins, nouvelleTable, varierAppartenances } = contraintes;
return {
...neuve,
@@ -174,7 +157,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) {
numero,
sieges: capacite === siegesParDefaut ? null : capacite,
forme: 'ronde',
- position: { x: (rang % colonnes) * ecart, y: Math.floor(rang / colonnes) * ECART_RANGEES_CM },
+ position: { x: (rang % colonnes) * pas.colonnes, y: Math.floor(rang / colonnes) * pas.rangees },
})),
reservations: [...animateurs].map(([table, participant]) => ({
participant,
diff --git a/src/application/avertissements.js b/src/application/avertissements.js
index f4385c3..f0c72b2 100644
--- a/src/application/avertissements.js
+++ b/src/application/avertissements.js
@@ -17,6 +17,7 @@ import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { verifierInvariants } from '../moteur/verification.js';
import { configurationDepuisCharge } from '../stockage/document.js';
import { examiner, planDepuisFichier } from '../stockage/placements.js';
+import { nomAffiche } from './personnes.js';
const avertissement = (code, details) => ({ code, details });
@@ -111,11 +112,13 @@ export function invariantViole(charge, examen = examiner(charge)) {
* raisons d'examiner(apres) — derives, retenu.raisons — qu'examiner(avant)
* ne portait pas, comparées champ pour champ ; les propositions par
* identifiant, puis le retenu, sous id 'retenu' ;
- * RESERVATION_NON_HONOREE {participant, table, tour} chaque violation
- * RESERVATION de verifierInvariants(instance d'après,
+ * RESERVATION_NON_HONOREE {participant, table, tour, personne, numero}
+ * chaque violation RESERVATION de verifierInvariants(instance d'après,
* planDepuisFichier(apres.retenu)) absente du même calcul sur avant, qui
* n'en compte aucune quand son retenu est nul ou fautif, ou que sa
- * configuration ne se normalisait pas.
+ * configuration ne se normalisait pas ; personne est le nom affiché du
+ * participant, numero celui de la table, lus sur la charge d'après, qui
+ * porte l'une et l'autre puisque l'instance qui les viole en vient.
*
* @param {import('../stockage/types.js').Charge} avant
* @param {import('../stockage/types.js').Charge} apres
@@ -151,7 +154,10 @@ export function avertissementsApres(avant, apres) {
const tenues = reservations(avant, examenAvant, instanceAvant);
for (const { participant, table, tour } of reservations(apres, examenApres, instance)) {
const dejaLa = tenues.some((connue) => connue.participant === participant && connue.table === table && connue.tour === tour);
- if (!dejaLa) rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour }));
+ if (dejaLa) continue;
+ const personne = nomAffiche(apres.participants.find(({ id }) => id === participant));
+ const { numero } = apres.tables.find(({ id }) => id === table);
+ rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour, personne, numero }));
}
return rendus;
}
diff --git a/src/application/avertissements.test.js b/src/application/avertissements.test.js
index e87d02b..353cde6 100644
--- a/src/application/avertissements.test.js
+++ b/src/application/avertissements.test.js
@@ -226,15 +226,26 @@ describe('avertissements : la dérive que le geste ajoute (§ 9, § 8.9)', () =>
});
describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8, § 14.12)", () => {
- test('une réservation posée après la retenue, que le retenu ne tient pas : RESERVATION_NON_HONOREE {participant, table, tour}', () => {
+ test('une réservation posée après la retenue, que le retenu ne tient pas : RESERVATION_NON_HONOREE {participant, table, tour, personne, numero}', () => {
const avant = petiteSoiree();
const apres = avec(avant, reserverGrisaille);
assert.deepEqual(avertissementsApres(avant, apres), [
- { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
+ { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
]);
assert.deepEqual(avertissementsApres(apres, avec(apres, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
});
+ test('la personne se nomme par son nom affiché, et la table par son numéro, distinct de son identifiant', () => {
+ const avant = avec(petiteSoiree(), (charge) => {
+ charge.tables[1].numero = 7;
+ charge.participants[1].prenom = 'Aubépine';
+ });
+ const apres = avec(avant, reserverGrisaille);
+ assert.deepEqual(avertissementsApres(avant, apres), [
+ { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Aubépine Grisaille', numero: 7 } },
+ ]);
+ });
+
test("retenir une proposition qui ne tient pas une réservation : le retenu nul d'avant n'en comptait aucune", () => {
const avant = avec(petiteSoiree({ retenu: false }), reserverGrisaille);
const apres = avec(avant, (charge) => {
@@ -242,7 +253,7 @@ describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8,
charge.evenement.etat = 'retenu';
});
assert.deepEqual(avertissementsApres(avant, apres), [
- { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
+ { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
]);
});
@@ -250,7 +261,7 @@ describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8,
const avant = avec(avec(petiteSoiree(), reserverGrisaille), rendreFautif);
const apres = avec(avant, (charge) => (charge.retenu.placement[0].reserve = []));
assert.deepEqual(avertissementsApres(avant, apres), [
- { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
+ { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
]);
});
diff --git a/src/application/commandes_mobilier.js b/src/application/commandes_mobilier.js
index e8648e4..eaa31bb 100644
--- a/src/application/commandes_mobilier.js
+++ b/src/application/commandes_mobilier.js
@@ -91,8 +91,9 @@ function exigerCapacite(charge, id, sieges, origine) {
/**
* creerTable {forme} (§ 4, § 6.1) : une table qui suit le défaut, d'identifiant
* prochainsIds.table, numérotée après la plus grande, posée sur la première
- * case libre (caseLibre). Sous un retenu lisible, elle y entre avec sa
- * capacité et une liste vide à chaque tour.
+ * case libre (caseLibre) de la grille des démonstrations, au pas que donnent
+ * les tables posées, chacune à sa capacité, et la nouvelle. Sous un retenu
+ * lisible, elle y entre avec sa capacité et une liste vide à chaque tour.
*/
function creerTable(charge, { forme } = {}) {
exigerModifiable(charge);
@@ -100,7 +101,8 @@ function creerTable(charge, { forme } = {}) {
const id = charge.prochainsIds.table;
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' });
const numero = Math.max(0, ...charge.tables.map((table) => table.numero)) + 1;
- const position = caseLibre(charge.tables.map((table) => table.position));
+ const posees = charge.tables.map((table) => ({ position: table.position, forme: table.forme, places: capacite(charge, table) }));
+ const position = caseLibre(posees, { forme, places: charge.evenement.siegesParDefaut });
const suivante = structuredClone(charge);
suivante.tables.push({ id, numero, sieges: null, forme, position });
suivante.prochainsIds.table = id + 1;
diff --git a/src/application/commandes_mobilier.test.js b/src/application/commandes_mobilier.test.js
index 7049fa5..35b76e8 100644
--- a/src/application/commandes_mobilier.test.js
+++ b/src/application/commandes_mobilier.test.js
@@ -11,7 +11,10 @@
import assert from 'node:assert/strict';
import { creerFichiersSimules } from '../../test/fichiers_simules.js';
import { describe, test } from '../../test/lanceur.js';
-import { caseLibre } from '../geometrie/tables.js';
+import { texteLivre } from '../demo/livrees.js';
+import { constante } from '../geometrie/constantes.js';
+import { caseLibre, geometrieTable } from '../geometrie/tables.js';
+import { TAILLE_TEXTE_LISTE_CM } from '../geometrie/texte.js';
import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { serialiser } from '../stockage/canonique.js';
@@ -118,6 +121,45 @@ function seNormalise(charge) {
}
}
+// Hauteur de ligne d'une liste que suppose l'épreuve des bandes : 1,25 corps,
+// au-dessus de la hauteur d'encre du gabarit sur les polices courantes ; le
+// dessin la mesure au navigateur.
+const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM;
+
+// Les fautes « une liste passe sur une autre table » d'une charge : pour
+// chaque paire ordonnée de tables, la bande du bloc de noms de la première —
+// LARGEUR_BLOC_NOMS_CM de large depuis origineListe, capacité + 1 lignes de
+// haut, en-tête d'une table gelée compris — recoupe le carré d'emprise de la
+// seconde, bords exclus. Avec avec, seules les paires où figure la table
+// d'identifiant avec sont examinées. Rend les fautes et le nombre de paires
+// examinées.
+function fautesBandes(charge, { avec = null } = {}) {
+ const largeur = constante('LARGEUR_BLOC_NOMS_CM');
+ const geometries = charge.tables.map((table) => ({ g: geometrieTable(table, capacite(charge, table)), places: capacite(charge, table) }));
+ const fautes = [];
+ let paires = 0;
+ for (const { g, places } of geometries) {
+ const bande = {
+ gauche: g.origineListe.x,
+ droite: g.origineListe.x + largeur,
+ haut: g.origineListe.y,
+ bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE,
+ };
+ for (const { g: autre } of geometries) {
+ if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue;
+ paires += 1;
+ const e = autre.rayonEmprise;
+ const recoupe =
+ autre.centre.x + e > bande.gauche &&
+ autre.centre.x - e < bande.droite &&
+ autre.centre.y + e > bande.haut &&
+ autre.centre.y - e < bande.bas;
+ if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`);
+ }
+ }
+ return { fautes, paires };
+}
+
// Une commande acceptée, sur une copie gelée de la charge : la charge rendue
// se relit, et se normalise quand la charge reçue se normalisait.
function accepter(nom, charge, args) {
@@ -222,6 +264,56 @@ describe('mobilier : le registre (§ 8.2)', () => {
});
describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, § 9)', () => {
+ test('une table créée se pose sur la grille des démonstrations : le pas tient compte des emprises et du bloc de noms, aucune liste ne passe sur une autre table', () => {
+ let charge = chargeDe();
+ for (let i = 0; i < 5; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
+ // Rondes de 8 : colonnes à 813 cm, rangées à 300 (ecartsGrille).
+ assert.deepEqual(
+ charge.tables.map(({ position }) => [position.x, position.y]),
+ [[0, 0], [813, 0], [0, 300], [813, 300], [1626, 0]],
+ );
+ // La bande du bloc de noms de chaque table, de toute sa largeur, ne
+ // recoupe l'emprise d'aucune autre.
+ assert.deepEqual(fautesBandes(charge), { fautes: [], paires: 5 * 4 });
+ // Une table passée à 16 places : le pas devient 966 × 430 ; toute case
+ // à moins d'un pas d'une table posée, sur les deux axes, est prise, la
+ // suivante est (1932, 430) : la table créée ne porte sa liste sur aucune
+ // table, et aucune liste ne passe sur elle. La table 5 agrandie reçoit
+ // la liste de sa voisine, ce que la création ne change pas.
+ charge = accepter('changerPlacesTable', charge, { id: 5, sieges: 16, origine: 'champ' }).charge;
+ charge = accepter('creerTable', charge, { forme: 'carree' }).charge;
+ assert.deepEqual(charge.tables.at(-1).position, { x: 1932, y: 430 });
+ assert.deepEqual(fautesBandes(charge, { avec: charge.tables.at(-1).id }), { fautes: [], paires: 2 * 5 });
+ });
+
+ test('dans la grande démonstration, la table 1 passée à 16 places : la table créée au pas 966 × 430 évite toute liste, (2898, 1720)', () => {
+ // La case (4830, 0) est à 765 cm de la table (4065, 0), dont la liste,
+ // de 4221,4 à 4721,4, couvrirait la table nouvelle, de 4709,6 à 4950,4 :
+ // elle est prise, comme toute case à moins d'un pas d'une table posée.
+ // La rangée 1720 est à 220 cm de la dernière rangée, de trois tables en
+ // 0, 813 et 1626 : la case (2898, 1720) est la première libre. Seules
+ // les paires où figure la table créée sont examinées : la table 1
+ // agrandie porte sa liste sur sa voisine, ce que la création ne change
+ // pas.
+ let charge = analyser(texteLivre('grande')).charge;
+ const premiere = charge.tables.find(({ numero }) => numero === 1);
+ assert.deepEqual(premiere.position, { x: 0, y: 0 });
+ charge = accepter('changerPlacesTable', charge, { id: premiere.id, sieges: 16, origine: 'champ' }).charge;
+ charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
+ assert.deepEqual(charge.tables.at(-1).position, { x: 2898, y: 1720 });
+ const creee = charge.tables.at(-1).id;
+ assert.deepEqual(fautesBandes(charge, { avec: creee }), { fautes: [], paires: 2 * 33 });
+ });
+
+ test('créée dans la grande démonstration, une table se pose sur la case suivante de sa grille, (2439, 1500)', () => {
+ // 33 rondes de 8 sur six colonnes à 813 cm et des rangées à 300 cm ; la
+ // trente-quatrième case, rang 33, est la colonne 3 de la rangée 5.
+ const grande = analyser(texteLivre('grande')).charge;
+ assert.equal(grande.tables.length, 33);
+ const { charge } = accepter('creerTable', grande, { forme: 'ronde' });
+ assert.deepEqual(charge.tables.at(-1).position, { x: 2439, y: 1500 });
+ });
+
test("les identifiants ne se réutilisent jamais ; le numéro suit le plus grand, et le libellé nomme le numéro", () => {
let charge = chargeDe();
for (let i = 0; i < 3; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
@@ -234,7 +326,10 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, §
numero: 3,
sieges: null,
forme: 'carree',
- position: caseLibre(charge.tables.map(({ position }) => position)),
+ position: caseLibre(
+ charge.tables.map((table) => ({ position: table.position, forme: table.forme, places: capacite(charge, table) })),
+ { forme: 'carree', places: charge.evenement.siegesParDefaut },
+ ),
});
assert.equal(cree.charge.prochainsIds.table, 5);
const deplace = accepter('deplacerTable', cree.charge, { id: 4, position: { x: 612.4, y: -0.4 } });
@@ -272,6 +367,20 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, §
assert.equal(capacite(dix, dix.tables[0]), 10);
});
+ test('une table créée se pose au pas de sa propre capacité, le défaut : sous un défaut de 16, à côté d’une ronde de 8, en (966, 0) et non en (813, 0)', () => {
+ // Une ronde de 8 posée en (0, 0) : la grille a deux colonnes, et la case
+ // (0, 0) est prise. Le pas se calcule sur la plus grande emprise, celle
+ // de la table nouvelle à 16 places : 966 cm entre colonnes, là où deux
+ // rondes de 8 n'en demandent que 813.
+ const charge = chargeDe({ tables: [{ id: 1, sieges: 8 }], defaut: 16 });
+ charge.tables[0].position = { x: 0, y: 0 };
+ const { charge: apres } = accepter('creerTable', charge, { forme: 'ronde' });
+ const creee = apres.tables.at(-1);
+ assert.equal(capacite(apres, creee), 16);
+ assert.deepEqual(creee.position, { x: 966, y: 0 });
+ assert.deepEqual(fautesBandes(apres), { fautes: [], paires: 2 });
+ });
+
test('une table créée après la retenue entre au retenu, vide à chaque tour ; les propositions dérivent TABLE_AJOUTEE', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 6 });
const listes = [[[1, 2, 3], [4, 5, 6]], [[1, 4, 5], [2, 3, 6]]];
diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js
index b3ad8d2..fe602a0 100644
--- a/src/application/libelles.test.js
+++ b/src/application/libelles.test.js
@@ -593,6 +593,17 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => {
);
});
+ test('RESERVATION_NON_HONOREE nomme la personne et le numéro de la table quand ils sont donnés, sinon leurs identifiants', () => {
+ assert.equal(
+ libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1, personne: 'Iris Ombrelle', numero: 3 }),
+ "Le placement retenu n'honore pas la réservation de Iris Ombrelle à la table 3 au tour 1 : il la place ailleurs.",
+ );
+ assert.equal(
+ libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1 }),
+ "Le placement retenu n'honore pas la réservation de la personne n° 4 à la table d'identifiant 5 au tour 1 : il la place ailleurs.",
+ );
+ });
+
test('le séparateur introuvable nomme la ligne et la cause ; le guillemet ouvert tombe dès la ligne 2', () => {
const nombre = libelle('SEPARATEUR_INTROUVABLE', { separateur: ';', ligne: 3, cause: 'NOMBRE_DE_CHAMPS' });
assert.match(nombre, /la ligne 3 n'a pas le nombre de champs de l'en-tête/);
diff --git a/src/application/libelles/seance.js b/src/application/libelles/seance.js
index 2d543ca..c26849a 100644
--- a/src/application/libelles/seance.js
+++ b/src/application/libelles/seance.js
@@ -114,11 +114,14 @@ export const TABLE = new Map([
);
},
],
+ // La personne se nomme par personne, et la table par numero, quand les
+ // détails les portent (avertissementsApres les lit sur la charge) ; une
+ // violation d'INVARIANT_VIOLE, qui ne les porte pas, garde les identifiants.
[
'RESERVATION_NON_HONOREE',
- ({ participant, table, tour }) =>
- `Le placement retenu n'honore pas la réservation de la personne n° ${participant} ` +
- `à la table d'identifiant ${table} au tour ${tour} : il la place ailleurs.`,
+ ({ participant, table, tour, personne = null, numero = null }) =>
+ `Le placement retenu n'honore pas la réservation de ${personne ?? `la personne n° ${participant}`} ` +
+ `à la ${numero === null ? `table d'identifiant ${table}` : `table ${numero}`} au tour ${tour} : il la place ailleurs.`,
],
[
'INVARIANT_VIOLE',
diff --git a/src/geometrie/tables.js b/src/geometrie/tables.js
index e418136..483ad38 100644
--- a/src/geometrie/tables.js
+++ b/src/geometrie/tables.js
@@ -7,7 +7,9 @@
// dessin pose les chaises par geometrieTable et la désignation d'un siège
// (designation.js) lit les mêmes chaises : une seule implémentation, que le
// dessin et le dépôt partagent. Aucune table ne tourne : la géométrie ne
-// porte aucun angle.
+// porte aucun angle. La grille où se posent les tables des démonstrations et
+// celles que l'opérateur crée a un seul pas, ecartsGrille.
+import { constante } from './constantes.js';
/**
* @typedef {Object} GeometrieTable en cm
@@ -40,8 +42,9 @@ export const ECART_POIGNEE = 8;
* 26 cm d'elle, plus la moitié de son trait ; le bloc commence au-delà, et
* aucune marque ne couvre la liste. */
export const ECART_LISTE = 36;
-/** Distance entre deux cases de la grille des tables créées, en cm. */
-export const PAS_GRILLE = 300;
+/** Écart minimal entre les centres de deux rangées de la grille des tables,
+ * en cm. */
+export const ECART_RANGEES = 300;
// Accroissement du rayon d'une ronde pour une place de plus, au-dessus du
// plancher : le périmètre croît de LARGEUR_PAR_PLACE, le rayon de 60 / 2π.
@@ -204,26 +207,63 @@ export function englobant(geometries) {
return { minX, minY, maxX, maxY };
}
+/**
+ * Le pas de la grille des tables, en cm entiers, pour la plus grande emprise
+ * E des tables données, chacune de sa forme et de son nombre de places :
+ * entre deux colonnes, ⌈2E + 2 × ECART_LISTE + LARGEUR_BLOC_NOMS_CM⌉ — le bloc
+ * de noms d'une table, à ECART_LISTE de son emprise et large de
+ * LARGEUR_BLOC_NOMS_CM, où toute ligne s'abrège, s'achève à ECART_LISTE de
+ * l'emprise de la colonne suivante : aucune liste ne passe sur une autre
+ * table de la grille, d'aucune rangée ; entre deux rangées,
+ * max(ECART_RANGEES, ⌈2E + ECART_LISTE⌉) — deux emprises voisines restent à
+ * ECART_LISTE l'une de l'autre, la marge du carré de focus.
+ *
+ * @param {Array<{forme: 'ronde'|'carree', places: number}>} tables au moins une
+ * @returns {{colonnes: number, rangees: number}}
+ */
+export function ecartsGrille(tables) {
+ const emprise = Math.max(
+ ...tables.map(({ forme, places }) => geometrieTable({ id: 0, numero: 0, forme, position: { x: 0, y: 0 } }, places).rayonEmprise),
+ );
+ return {
+ colonnes: Math.ceil(2 * emprise + 2 * ECART_LISTE + constante('LARGEUR_BLOC_NOMS_CM')),
+ rangees: Math.max(ECART_RANGEES, Math.ceil(2 * emprise + ECART_LISTE)),
+ };
+}
+
/**
* Position d'une table créée : la première case libre, ligne par ligne, d'une
- * grille de PAS_GRILLE cm à ⌈√(T + 1)⌉ colonnes, T = centres.length, partant
- * de (0, 0) ; une case est prise quand un centre est à PAS_GRILLE / 2 ou
- * moins. centres : les positions de charge.tables, que l'appelant passe sans
- * construire de géométrie.
+ * grille à ⌈√(T + 1)⌉ colonnes, T = tables.length, partant de (0, 0), au pas
+ * d'ecartsGrille des tables posées et de la nouvelle — le pas des
+ * démonstrations (scripts/engendrer_livrables.js). Une case est prise quand
+ * une table posée est à moins d'un pas de son centre sur chacun des deux
+ * axes, bornes exclues. Une table à un pas de colonne ou plus a son emprise
+ * hors de la bande du bloc de noms de la table nouvelle, et la sienne
+ * s'achève avant l'emprise nouvelle, à ECART_LISTE au moins, quelle que soit
+ * la rangée : c'est le calcul d'ecartsGrille, fait sur la plus grande
+ * emprise des deux. Une table à un pas de rangée ou plus a son emprise à
+ * ECART_LISTE au moins de l'emprise nouvelle. Ainsi une table déplacée hors
+ * de la grille, ou une grille dont le pas a crû depuis qu'elle a été posée,
+ * ne reçoit pas la liste de la table nouvelle et ne lui porte pas la sienne
+ * à travers une colonne.
*
- * La recherche s'arrête : un centre est à PAS_GRILLE / 2 ou moins d'au plus
- * deux cases, le plus petit disque qui contient trois cases ayant un rayon
- * d'au moins PAS_GRILLE / √2 ; l'une des 2T + 1 premières cases est donc
- * libre.
+ * La recherche s'arrête : un intervalle ouvert de largeur 2 × pas contient
+ * au plus deux multiples du pas, et une table prend donc au plus quatre
+ * cases ; l'une des 4T + 1 premières cases est libre.
*
- * @param {Array<{x: number, y: number}>} centres
+ * @param {Array<{position: {x: number, y: number}, forme: 'ronde'|'carree', places: number}>} tables
+ * les tables posées, chacune à sa capacité courante
+ * @param {{forme: 'ronde'|'carree', places: number}} nouvelle la table à poser
* @returns {{x: number, y: number}}
*/
-export function caseLibre(centres) {
- const colonnes = Math.ceil(Math.sqrt(centres.length + 1));
+export function caseLibre(tables, nouvelle) {
+ const { colonnes: pasX, rangees: pasY } = ecartsGrille([...tables, nouvelle]);
+ const colonnes = Math.ceil(Math.sqrt(tables.length + 1));
for (let rang = 0; ; rang += 1) {
- const candidate = { x: (rang % colonnes) * PAS_GRILLE, y: Math.floor(rang / colonnes) * PAS_GRILLE };
- const prise = centres.some(({ x, y }) => Math.hypot(x - candidate.x, y - candidate.y) <= PAS_GRILLE / 2);
+ const candidate = { x: (rang % colonnes) * pasX, y: Math.floor(rang / colonnes) * pasY };
+ const prise = tables.some(
+ ({ position: { x, y } }) => Math.abs(x - candidate.x) < pasX && Math.abs(y - candidate.y) < pasY,
+ );
if (!prise) return candidate;
}
}
diff --git a/src/geometrie/tables.test.js b/src/geometrie/tables.test.js
index befc550..2f34a4b 100644
--- a/src/geometrie/tables.test.js
+++ b/src/geometrie/tables.test.js
@@ -8,18 +8,21 @@
// attendues se calculent à la main dans le commentaire qui les précède.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
+import { constante } from './constantes.js';
import { surLeSol } from './designation.js';
+import { TAILLE_TEXTE_LISTE_CM } from './texte.js';
import {
ECART_LISTE,
ECART_POIGNEE,
+ ECART_RANGEES,
LARGEUR_PAR_PLACE,
- PAS_GRILLE,
PLANCHER_DIAMETRE,
RAYON_CHAISE,
RAYON_POIGNEE,
caseLibre,
cote,
diametre,
+ ecartsGrille,
englobant,
geometrieTable,
placesSousPoignee,
@@ -42,6 +45,48 @@ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
// Une table du fichier d'état : identifiant, numéro, forme et centre.
const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } });
+// Hauteur de ligne d'une liste que suppose l'épreuve des bandes : 1,25 corps,
+// au-dessus de la hauteur d'encre du gabarit sur les polices courantes. Le
+// dessin la mesure au navigateur ; le module de géométrie ne la connaît pas.
+const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM;
+
+// Les fautes « une liste passe sur une autre table » d'un ensemble de tables
+// posées {position, forme, places} : pour chaque paire ordonnée, la bande du
+// bloc de noms de la première — LARGEUR_BLOC_NOMS_CM de large depuis
+// origineListe, places + 1 lignes de haut, en-tête d'une table gelée
+// compris — recoupe le carré d'emprise de la seconde, bords exclus. Avec
+// avec, seules les paires où figure la table de ce rang, compté depuis 1,
+// sont examinées. Rend les fautes et le nombre de paires examinées.
+function fautesBandes(posees, { avec = null } = {}) {
+ const largeur = constante('LARGEUR_BLOC_NOMS_CM');
+ const geometries = posees.map(({ position, forme, places }, rang) => ({
+ g: geometrieTable(table(rang + 1, forme, position.x, position.y), places),
+ places,
+ }));
+ const fautes = [];
+ let paires = 0;
+ for (const { g, places } of geometries) {
+ const bande = {
+ gauche: g.origineListe.x,
+ droite: g.origineListe.x + largeur,
+ haut: g.origineListe.y,
+ bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE,
+ };
+ for (const { g: autre } of geometries) {
+ if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue;
+ paires += 1;
+ const e = autre.rayonEmprise;
+ const recoupe =
+ autre.centre.x + e > bande.gauche &&
+ autre.centre.x - e < bande.droite &&
+ autre.centre.y + e > bande.haut &&
+ autre.centre.y - e < bande.bas;
+ if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`);
+ }
+ }
+ return { fautes, paires };
+}
+
describe('constantes', () => {
test('les valeurs du contrat', () => {
assert.equal(PLANCHER_DIAMETRE, 70);
@@ -50,7 +95,7 @@ describe('constantes', () => {
assert.equal(RAYON_POIGNEE, 15);
assert.equal(ECART_POIGNEE, 8);
assert.equal(ECART_LISTE, 36);
- assert.equal(PAS_GRILLE, 300);
+ assert.equal(ECART_RANGEES, 300);
});
});
@@ -289,27 +334,105 @@ describe('englobant', () => {
});
});
-describe('caseLibre', () => {
- test('sans table, l’origine', () => {
- assert.deepEqual(caseLibre([]), { x: 0, y: 0 });
+describe('ecartsGrille : le pas d\u{2019}une grille de tables (§ 7.4, § 7.7)', () => {
+ // colonnes = ⌈2 × emprise + 2 × ECART_LISTE + LARGEUR_BLOC_NOMS_CM⌉ ;
+ // rangées = max(ECART_RANGEES, ⌈2 × emprise + ECART_LISTE⌉), l'emprise la
+ // plus grande des tables données. LARGEUR_BLOC_NOMS_CM vaut 500.
+ test('la largeur du bloc de noms est celle du registre', () => {
+ assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 500);
});
- test('deux centres en (0, 0) et (300, 0) : deux colonnes, la case suivante est (0, 300)', () => {
- assert.deepEqual(caseLibre([{ x: 0, y: 0 }, { x: 300, y: 0 }]), { x: 0, y: 300 });
+ test('ronde de 3 : emprise 79, colonnes 158 + 72 + 500 = 730, rangées 300', () => {
+ assert.deepEqual(ecartsGrille([{ forme: 'ronde', places: 3 }]), { colonnes: 730, rangees: 300 });
});
- test('un centre en (150, 0) prend les deux premières cases', () => {
- assert.deepEqual(caseLibre([{ x: 150, y: 0 }]), { x: 0, y: 300 });
+ test('ronde de 8 : emprise 120,39, colonnes ⌈812,79⌉ = 813, rangées 300', () => {
+ // 2 × 120,3944 + 72 + 500 = 812,789 → 813 ; 2 × 120,3944 + 36 = 276,8 → 300.
+ assert.deepEqual(ecartsGrille([{ forme: 'ronde', places: 8 }]), { colonnes: 813, rangees: 300 });
});
- test('un centre à plus de PAS_GRILLE / 2 laisse la case libre', () => {
- assert.deepEqual(caseLibre([{ x: 150.001, y: 0 }]), { x: 0, y: 0 });
+ test('carrée de 8 : côté 120, emprise 104, colonnes 780, rangées 300', () => {
+ assert.deepEqual(ecartsGrille([{ forme: 'carree', places: 8 }]), { colonnes: 780, rangees: 300 });
});
- test('quatre centres sur 2 × 2 : trois colonnes, la case suivante est (600, 0)', () => {
- const centres = [{ x: 0, y: 0 }, { x: 300, y: 0 }, { x: 0, y: 300 }, { x: 300, y: 300 }];
- assert.deepEqual(caseLibre(centres.slice(0, 3)), { x: 300, y: 300 });
- assert.deepEqual(caseLibre(centres), { x: 600, y: 0 });
+ test('la plus grande emprise décide : une ronde de 16 parmi des rondes de 8 → colonnes 966, rangées 430', () => {
+ // diamètre 16 × 60 / π = 305,577 ; emprise 152,789 + 44 = 196,789 ;
+ // 393,578 + 572 = 965,578 → 966 ; 393,578 + 36 = 429,578 → 430.
+ const tables = [{ forme: 'ronde', places: 8 }, { forme: 'ronde', places: 16 }, { forme: 'carree', places: 8 }];
+ assert.deepEqual(ecartsGrille(tables), { colonnes: 966, rangees: 430 });
+ });
+});
+
+describe('caseLibre : la place d\u{2019}une table créée, sur la grille des démonstrations', () => {
+ const huit = { forme: 'ronde', places: 8 };
+ const posee = (x, y, autres = huit) => ({ position: { x, y }, ...autres });
+
+ test('sans table, l\u{2019}origine', () => {
+ assert.deepEqual(caseLibre([], huit), { x: 0, y: 0 });
+ });
+
+ test('deux rondes de 8 en (0, 0) et (813, 0) : deux colonnes, la case suivante est (0, 300)', () => {
+ assert.deepEqual(caseLibre([posee(0, 0), posee(813, 0)], huit), { x: 0, y: 300 });
+ });
+
+ test('un centre à moins d\u{2019}un pas de colonne, (406,6 ; 0) ou (812,9 ; 0), prend les deux premières cases ; à 813, la première est libre', () => {
+ // À 406,6 de (0, 0), le bloc de la table nouvelle, de 156,4 à 656,4,
+ // couvrirait l'emprise posée, de 286,2 à 527 : la case est refusée.
+ assert.deepEqual(caseLibre([posee(406.6, 0)], huit), { x: 0, y: 300 });
+ assert.deepEqual(caseLibre([posee(812.9, 0)], huit), { x: 0, y: 300 });
+ assert.deepEqual(caseLibre([posee(813, 0)], huit), { x: 0, y: 0 });
+ });
+
+ test('un centre à moins d\u{2019}un pas de rangée, (0 ; 150,1) ou (0 ; 299,9), prend la case (0, 0) ; à 300, elle est libre', () => {
+ assert.deepEqual(caseLibre([posee(0, 150.1)], huit), { x: 813, y: 0 });
+ assert.deepEqual(caseLibre([posee(0, 299.9)], huit), { x: 813, y: 0 });
+ assert.deepEqual(caseLibre([posee(0, 300)], huit), { x: 0, y: 0 });
+ });
+
+ test('une table déplacée en (330, 0) prend aussi (813, 0) : la case suivante est (0, 300), et aucun bloc ne passe sur l\u{2019}autre table', () => {
+ // En (813, 0), à 483 cm, la liste de la table déplacée, de 486,4 à
+ // 986,4, couvrirait la table nouvelle, de 692,6 à 933,4.
+ const posees = [posee(330, 0)];
+ const position = caseLibre(posees, huit);
+ assert.deepEqual(position, { x: 0, y: 300 });
+ assert.deepEqual(fautesBandes([...posees, posee(position.x, position.y)]), { fautes: [], paires: 2 });
+ });
+
+ test('l\u{2019}épreuve des bandes voit la faute : une ronde de 8 en (330, 0) et une autre en (813, 0)', () => {
+ assert.equal(fautesBandes([posee(330, 0), posee(813, 0)]).fautes.length, 1);
+ });
+
+ test('une ronde de 8 agrandie à 16 au milieu de rondes de 8 : la table créée au nouveau pas ne porte ni ne subit aucune liste', () => {
+ // Vingt rondes de 8 sur cinq colonnes au pas 813 × 300, la première
+ // passée à 16 places : le pas devient 966 × 430, sur cinq colonnes. La
+ // case (3864, 0) est à 612 cm de la table (3252, 0), dont la liste, de
+ // 3408,4 à 3908,4, couvrirait la table nouvelle, de 3743,6 à 3984,4 :
+ // elle est prise, comme toute case à moins d'un pas d'une table posée,
+ // jusqu'à la rangée 1720, à 820 cm de la dernière. Seules les paires où
+ // figure la table nouvelle sont examinées : la table agrandie porte sa
+ // liste sur ses voisines, ce que la création ne change pas.
+ const posees = Array.from({ length: 20 }, (_, rang) => posee((rang % 5) * 813, Math.floor(rang / 5) * 300));
+ posees[0] = posee(0, 0, { forme: 'ronde', places: 16 });
+ const position = caseLibre(posees, huit);
+ assert.deepEqual(position, { x: 0, y: 1720 });
+ assert.deepEqual(fautesBandes([...posees, posee(position.x, position.y)], { avec: 21 }), { fautes: [], paires: 2 * 20 });
+ });
+
+ test('le pas suit la table à créer et les tables posées : une ronde de 16 posée écarte la case suivante à 966', () => {
+ assert.deepEqual(caseLibre([posee(0, 0, { forme: 'ronde', places: 16 })], huit), { x: 966, y: 0 });
+ assert.deepEqual(caseLibre([posee(0, 0)], { forme: 'ronde', places: 16 }), { x: 966, y: 0 });
+ });
+
+ test('trente-trois rondes de 8 créées une à une : les cases de la grille des démonstrations, et aucun bloc de noms ne passe sur une autre table', () => {
+ const posees = [];
+ for (let n = 0; n < 33; n += 1) posees.push(posee(...Object.values(caseLibre(posees, huit))));
+ assert.equal(posees.length, 33);
+ assert.equal(new Set(posees.map(({ position: { x, y } }) => `${x},${y}`)).size, 33);
+ for (const { position } of posees) {
+ assert.equal(position.x % 813, 0, `x ${position.x}`);
+ assert.equal(position.y % 300, 0, `y ${position.y}`);
+ }
+ assert.deepEqual(fautesBandes(posees), { fautes: [], paires: 33 * 32 });
});
});
diff --git a/src/interface/libelles/plan.js b/src/interface/libelles/plan.js
index dbedf2f..45f2a37 100644
--- a/src/interface/libelles/plan.js
+++ b/src/interface/libelles/plan.js
@@ -156,8 +156,16 @@ export const TABLE = new Map([
['plan.menu.deplacerReservation', 'Déplacer sa réservation'],
['plan.menu.exclure', 'Exclure'],
['plan.menu.reintegrer', 'Réintégrer'],
+ ['plan.menu.mettreEnEvidence', 'Mettre en évidence'],
+ ['plan.menu.oterEvidence', 'Ôter la mise en évidence'],
['plan.menu.ouvrirFiche', 'Ouvrir la fiche'],
+ // --- La mise en évidence d'une personne (§ 7.1, § 8.4) ----------------------
+ // Le nom de la personne mise en évidence est une donnée dans son nœud.
+ ['plan.evidence.personne', 'Mise en évidence : '],
+ ['plan.evidence.oter', 'Ôter la mise en évidence'],
+ ['plan.evidence.voir', 'Voir sur le plan'],
+
// --- Les listes de travail (§ 5.8, § 4.1, § 4.2) ---------------------------
// Le libellé d'un titre, le titre pressenti et le nom d'une personne sont
// des données, chacun dans son nœud, entre ces textes.
diff --git a/src/interface/participants/Grille.svelte b/src/interface/participants/Grille.svelte
index 4bdf346..270a21e 100644
--- a/src/interface/participants/Grille.svelte
+++ b/src/interface/participants/Grille.svelte
@@ -21,6 +21,9 @@
// lieu, avec son remède : celui de la séance, lu dans etat().refus avant
// d'ouvrir l'édition, ou celui de la commande, la saisie gardée.
//
+ // « Voir sur le plan » met la personne en évidence et ouvre le plan, au
+ // tour montré (§ 7.1, § 8.4) : un geste de regard, qui n'écrit rien.
+ //
// L'en-tête trie : un clic sur une colonne la trie croissante, un second
// décroissante ; le sens se lit en texte et dans aria-sort. Un collage sur
// la grille, hors d'une cellule en édition, ouvre l'aperçu d'import (§
@@ -38,6 +41,11 @@
const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui');
+
+ function voirSurLePlan(id) {
+ ui.mettreEnEvidence(id);
+ ui.allerA('plan');
+ }
const uid = $props.id();
const geste = creerGeste(seance);
@@ -327,6 +335,9 @@
+
{#if personne.exclu}
ui.basculerPleinEcran()}
>{t('plan.vue.pleinEcran')}
+ {#if evidence !== null}
+