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} +

+ {t('plan.evidence.personne')}{#if evidence.nom !== null}{evidence.nom}{/if} + +

+ {/if}