@@ -153,6 +470,7 @@
}
.surface {
+ position: relative;
display: flex;
flex: 1 1 auto;
flex-direction: column;
@@ -177,5 +495,6 @@
padding: 0;
overflow: hidden;
touch-action: none;
+ user-select: none;
}
diff --git a/src/interface/plan/RefusPlace.svelte b/src/interface/plan/RefusPlace.svelte
new file mode 100644
index 0000000..bb9778d
--- /dev/null
+++ b/src/interface/plan/RefusPlace.svelte
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+ {t(libelle.cle, libelle.details)}
+
+ {#if pose.refus !== null}
+
+ {/if}
+ {#each pose.avertissements as avertissement, rang (rang)}
+
{t(avertissement.code, avertissement.details)}
+ {/each}
+
+
+
+
diff --git a/src/interface/plan/depot.js b/src/interface/plan/depot.js
new file mode 100644
index 0000000..fdedcc3
--- /dev/null
+++ b/src/interface/plan/depot.js
@@ -0,0 +1,109 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Le dépôt d'une personne glissée sur le plan (§ 5.8, § 5.9, § 7.1, § 7.3,
+// § 14.10), pur : la place se désigne par la géométrie des tables — celles
+// mêmes que le dessin pose —, jamais dans le DOM, et la commande se lit sur
+// la cible de l'état des places (EtatPlaces.cible, A5), jamais sur la
+// présence d'un retenu. L'interface ne choisit rien : la cible dit ce que
+// le geste modifie, et la commande juge la pose et la capacité.
+import { designer, surLeSol as horsDesTables } from '../../geometrie/designation.js';
+
+// La chaise d'une table de l'état des places, ou undefined.
+function chaiseDe(etatPlaces, { table, siege }) {
+ return etatPlaces.tables.find(({ id }) => id === table)?.chaises[siege - 1];
+}
+
+// Le premier siège libre d'une table au tour affiché, ou null : sous le
+// retenu, une chaise que personne n'occupe ; sous les réservations, une
+// chaise qu'aucune réservation ne tient — l'occupant non réservé d'une
+// proposition montrée ne la prend pas.
+function premierLibre(etatPlaces, table, libre) {
+ const chaise = etatPlaces.tables.find(({ id }) => id === table).chaises.find(libre);
+ return chaise === undefined ? null : chaise.siege;
+}
+
+/**
+ * La commande d'un dépôt de personne, lue sur etatPlaces.cible (contrat,
+ * « Source ») :
+ * - 'reservations' — avant la retenue, ou à un tour que le retenu ne porte
+ * pas — : une personne que sa chaise d'origine tient réservée déplace sa
+ * réservation (deplacerReservation), qui garde portée et tour ; une
+ * autre reçoit une réservation de portée « tour » au tour affiché
+ * (reserver) ; sur le sol, la réservation glissée part
+ * (retirerReservation), et rien pour une personne non réservée. Un siège
+ * qu'occupe une personne non réservée de la proposition montrée vaut un
+ * siège libre : la proposition reste identique, et dérive ;
+ * - 'retenu' : un siège libre → deplacerDansRetenu, un siège occupé par une
+ * autre → echangerDansRetenu, le sol → retirerDuTour ;
+ * - null — proposition montrée après la retenue, retenu fautif — : un dépôt
+ * sur un siège, une table ou le sol rend { refus: etatPlaces.refus }, et
+ * rien n'est exécuté.
+ * En régime table — cible {table} —, le premier siège libre de la table au
+ * tour affiché, sinon la table sans siège (siege: null), dont la commande
+ * juge la capacité. Null quand le dépôt manque — entre deux chaises, sur un
+ * plateau, sur la chaise d'origine — : la personne revient.
+ *
+ * @param {Object} options
+ * @param {Object} options.etatPlaces EtatPlaces du tour affiché
+ * @param {number} options.tour le tour affiché
+ * @param {number} options.personne l'identifiant de la personne glissée
+ * @param {{table: number, siege: number}|null} options.depuis sa chaise d'origine
+ * @param {{table: number, siege?: number}|null} options.cible le rendu de designer
+ * @param {boolean} options.surLeSol le rendu de surLeSol
+ * @returns {{commande: string, arguments: Object}|{refus: Object}|null}
+ */
+export function commandeDeDepot({ etatPlaces, tour, personne, depuis, cible, surLeSol }) {
+ if (cible === null && !surLeSol) return null;
+ if (etatPlaces.cible === null) return { refus: etatPlaces.refus };
+ const surSiege = cible !== null && cible.siege !== undefined;
+ if (surSiege && depuis !== null && cible.table === depuis.table && cible.siege === depuis.siege) return null;
+
+ if (etatPlaces.cible === 'retenu') {
+ if (cible === null) return { commande: 'retirerDuTour', arguments: { tour, participant: personne } };
+ const siege = surSiege ? cible.siege : premierLibre(etatPlaces, cible.table, ({ occupant }) => occupant === null);
+ const occupant = siege === null ? null : chaiseDe(etatPlaces, { table: cible.table, siege }).occupant;
+ if (occupant !== null && occupant !== personne) {
+ return { commande: 'echangerDansRetenu', arguments: { tour, a: personne, b: occupant } };
+ }
+ return { commande: 'deplacerDansRetenu', arguments: { tour, participant: personne, table: cible.table, siege } };
+ }
+
+ const origine = depuis === null ? undefined : chaiseDe(etatPlaces, depuis);
+ const tenue = origine?.reservation?.participant === personne ? origine.reservation : null;
+ if (cible === null) {
+ if (tenue === null) return null;
+ return { commande: 'retirerReservation', arguments: { participant: personne, table: depuis.table, portee: tenue.portee, tour: tenue.tour } };
+ }
+ const siege = surSiege ? cible.siege : premierLibre(etatPlaces, cible.table, ({ reservation }) => reservation === null);
+ if (tenue !== null) {
+ return {
+ commande: 'deplacerReservation',
+ arguments: {
+ participant: personne,
+ de: { table: depuis.table, portee: tenue.portee, tour: tenue.tour },
+ vers: { table: cible.table, siege },
+ },
+ };
+ }
+ return { commande: 'reserver', arguments: { participant: personne, table: cible.table, siege, portee: 'tour', tour } };
+}
+
+/**
+ * Le dépôt au point de dessin : la place que désignent designer et surLeSol
+ * sur les géométries du modèle de dessin, au zoom k, et la commande de
+ * commandeDeDepot. place est la place où poser un refus : la place
+ * désignée — chaise ou table —, sinon la chaise d'origine.
+ *
+ * @param {Object} options ceux de commandeDeDepot, cible et surLeSol exceptés, plus :
+ * @param {{x: number, y: number}} options.point cm
+ * @param {Object[]} options.geometries TableDessin.geometrie de chaque table, dans l'ordre du dessin
+ * @param {number} options.k
+ * @returns {{resultat: ReturnType
, place: Object|null}}
+ */
+export function deposer({ etatPlaces, tour, personne, depuis, point, geometries, k }) {
+ const cible = designer(point, geometries, k);
+ const sol = cible === null && horsDesTables(point, geometries);
+ const resultat = commandeDeDepot({ etatPlaces, tour, personne, depuis, cible, surLeSol: sol });
+ return { resultat, place: cible ?? depuis };
+}
diff --git a/src/interface/plan/depot.test.js b/src/interface/plan/depot.test.js
new file mode 100644
index 0000000..d2f3797
--- /dev/null
+++ b/src/interface/plan/depot.test.js
@@ -0,0 +1,264 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Épreuves du dépôt d'une personne glissée (§ 5.8, § 5.9, § 7.1, § 7.3,
+// § 14.10), sans DOM : la commande se lit sur la cible de l'état des places
+// (EtatPlaces.cible, A5), jamais sur la présence d'un retenu ; la place se
+// désigne par la géométrie des tables. Les charges d'épreuve se construisent
+// par les commandes de l'application et l'état des places se calcule par
+// etatPlaces : la même implémentation que celle du plan (§ 19.1). Les noms
+// sont inventés.
+import assert from 'node:assert/strict';
+import { describe, test } from '../../../test/lanceur.js';
+import { appliquerCommande } from '../../application/commandes.js';
+import { etatPlaces } from '../../application/places.js';
+import { geometrieTable } from '../../geometrie/tables.js';
+import { creerCharge } from '../../stockage/document.js';
+import { commandeDeDepot, deposer } from './depot.js';
+
+const NOMS = [
+ ['Ombrelle', 'Iris'],
+ ['Pervenche', 'Théo'],
+ ['Lacasse', 'Ondine'],
+ ['Grisaille', null],
+ ['Tourmaline', 'Basile'],
+ ['Arrosoir', 'Capucine'],
+ ['Cerfeuil', 'Aurèle'],
+];
+
+// Sept personnes, trois rondes de trois places, deux tours, et une
+// proposition à sièges non attribués : au tour 1, la table 1 est pleine
+// (1, 2, 3), la table 2 assied 4 et 5, la table 3 assied 6 et 7 ; au
+// tour 2, 1, 4, 6 ; 2, 5, 7 ; 3.
+function chargeDepot() {
+ const charge = creerCharge({ id: 'evt-depot', nom: 'Club des Merles', siegesParDefaut: 3, tours: 2 });
+ charge.participants = NOMS.map(([nom, prenom], rang) => ({
+ id: rang + 1,
+ nom,
+ prenom,
+ appartenance: null,
+ courriel: null,
+ titrePressenti: null,
+ notes: null,
+ exclu: false,
+ }));
+ charge.tables = [1, 2, 3].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * (id - 1), y: 0 } }));
+ charge.prochainsIds = { participant: 8, table: 4, proposition: 2 };
+ charge.propositions = [
+ {
+ id: 1,
+ graine: 1,
+ arret: 40,
+ historique: 1000,
+ produitVersion: 'epreuve',
+ siegesAttribues: false,
+ tables: [1, 2, 3],
+ capacites: [3, 3, 3],
+ tours: 2,
+ participants: [1, 2, 3, 4, 5, 6, 7],
+ placement: [
+ { sieges: [[1, 2, 3], [4, 5], [6, 7]], reserve: [] },
+ { sieges: [[1, 4, 6], [2, 5, 7], [3]], reserve: [] },
+ ],
+ },
+ ];
+ charge.evenement.etat = 'propose';
+ return charge;
+}
+
+const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
+const retenue = (charge) => executer(charge, 'retenirProposition', { id: 1 });
+
+// Le dépôt de la personne assise à depuis, lu sur l'état des places du tour.
+function depot(etat, depuis, cible, surLeSol = false) {
+ const table = etat.tables.find(({ id }) => id === depuis.table);
+ const personne = table.chaises[depuis.siege - 1].occupant;
+ assert.notEqual(personne, null, `une personne est assise à ${depuis.table}:${depuis.siege}`);
+ return commandeDeDepot({ etatPlaces: etat, tour: etat.tour, personne, depuis, cible, surLeSol });
+}
+
+describe('commandeDeDepot sous la cible « reservations » : avant la retenue (§ 5.8)', () => {
+ const avant = () => etatPlaces(chargeDepot(), { tour: 1, proposition: 1 });
+
+ test('l’état des places le dit : une proposition montrée avant la retenue a pour cible les réservations', () => {
+ const etat = avant();
+ assert.equal(etat.source, 'proposition');
+ assert.equal(etat.cible, 'reservations');
+ });
+
+ test('une personne non réservée sur un siège libre → reserver au tour affiché, portée « tour »', () => {
+ assert.deepEqual(depot(avant(), { table: 2, siege: 1 }, { table: 3, siege: 3 }), {
+ commande: 'reserver',
+ arguments: { participant: 4, table: 3, siege: 3, portee: 'tour', tour: 1 },
+ });
+ });
+
+ test('sur un siège qu’occupe une personne non réservée de la proposition → reserver, comme sur un siège libre', () => {
+ assert.deepEqual(depot(avant(), { table: 2, siege: 1 }, { table: 3, siege: 1 }), {
+ commande: 'reserver',
+ arguments: { participant: 4, table: 3, siege: 1, portee: 'tour', tour: 1 },
+ });
+ });
+
+ test('une personne réservée → deplacerReservation, la réservation désignée par sa table, sa portée et son tour', () => {
+ const charge = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 });
+ const etat = etatPlaces(charge, { tour: 1, proposition: 1 });
+ assert.deepEqual(depot(etat, { table: 1, siege: 1 }, { table: 3, siege: 3 }), {
+ commande: 'deplacerReservation',
+ arguments: { participant: 1, de: { table: 1, portee: 'tour', tour: 1 }, vers: { table: 3, siege: 3 } },
+ });
+ const tous = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null });
+ assert.deepEqual(depot(etatPlaces(tous, { tour: 2, proposition: 1 }), { table: 1, siege: 1 }, { table: 2, siege: 3 }), {
+ commande: 'deplacerReservation',
+ arguments: { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 2, siege: 3 } },
+ });
+ });
+
+ test('sur le sol → retirerReservation pour une personne réservée ; null pour une personne qui ne l’est pas', () => {
+ const charge = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 });
+ const etat = etatPlaces(charge, { tour: 1, proposition: 1 });
+ assert.deepEqual(depot(etat, { table: 1, siege: 1 }, null, true), {
+ commande: 'retirerReservation',
+ arguments: { participant: 1, table: 1, portee: 'tour', tour: 1 },
+ });
+ assert.equal(depot(etat, { table: 2, siege: 1 }, null, true), null);
+ });
+
+ test('régime table → le premier siège qu’aucune réservation ne tient ; table pleine de réservations → la même commande, siege: null', () => {
+ assert.deepEqual(depot(avant(), { table: 2, siege: 1 }, { table: 3 }), {
+ commande: 'reserver',
+ arguments: { participant: 4, table: 3, siege: 1, portee: 'tour', tour: 1 },
+ });
+ let charge = chargeDepot();
+ for (const [participant, siege] of [[1, 1], [2, 2], [3, 3]]) {
+ charge = executer(charge, 'reserver', { participant, table: 1, siege, portee: 'tour', tour: 1 });
+ }
+ assert.deepEqual(depot(etatPlaces(charge, { tour: 1, proposition: 1 }), { table: 2, siege: 1 }, { table: 1 }), {
+ commande: 'reserver',
+ arguments: { participant: 4, table: 1, siege: null, portee: 'tour', tour: 1 },
+ });
+ });
+
+ test('un tour que le retenu ne porte pas : la cible est « reservations », la commande reserver, quoiqu’un retenu existe', () => {
+ const charge = executer(retenue(chargeDepot()), 'changerTours', { tours: '3' });
+ const etat = etatPlaces(charge, { tour: 3 });
+ assert.notEqual(charge.retenu, null);
+ assert.equal(etat.cible, 'reservations');
+ const personne = etat.reserve[0];
+ assert.equal(personne, 1);
+ const rendu = commandeDeDepot({ etatPlaces: etat, tour: 3, personne, depuis: null, cible: { table: 2, siege: 2 }, surLeSol: false });
+ assert.deepEqual(rendu, { commande: 'reserver', arguments: { participant: 1, table: 2, siege: 2, portee: 'tour', tour: 3 } });
+ });
+});
+
+describe('commandeDeDepot sous la cible « retenu » (§ 5.8, § 7.1)', () => {
+ const apres = () => etatPlaces(retenue(chargeDepot()), { tour: 1 });
+
+ test('siège libre → deplacerDansRetenu ; siège occupé → echangerDansRetenu ; sol → retirerDuTour', () => {
+ const etat = apres();
+ assert.equal(etat.cible, 'retenu');
+ assert.deepEqual(depot(etat, { table: 2, siege: 1 }, { table: 3, siege: 3 }), {
+ commande: 'deplacerDansRetenu',
+ arguments: { tour: 1, participant: 4, table: 3, siege: 3 },
+ });
+ assert.deepEqual(depot(etat, { table: 2, siege: 1 }, { table: 3, siege: 1 }), {
+ commande: 'echangerDansRetenu',
+ arguments: { tour: 1, a: 4, b: 6 },
+ });
+ assert.deepEqual(depot(etat, { table: 2, siege: 1 }, null, true), {
+ commande: 'retirerDuTour',
+ arguments: { tour: 1, participant: 4 },
+ });
+ });
+
+ test('régime table → le premier siège libre au tour affiché ; table pleine → la même commande, siege: null', () => {
+ const etat = apres();
+ assert.deepEqual(depot(etat, { table: 2, siege: 1 }, { table: 3 }), {
+ commande: 'deplacerDansRetenu',
+ arguments: { tour: 1, participant: 4, table: 3, siege: 3 },
+ });
+ assert.deepEqual(depot(etat, { table: 2, siege: 1 }, { table: 1 }), {
+ commande: 'deplacerDansRetenu',
+ arguments: { tour: 1, participant: 4, table: 1, siege: null },
+ });
+ });
+
+ test('la commande rendue s’exécute : la personne change de table au tour affiché seulement', () => {
+ const charge = retenue(chargeDepot());
+ const etat = etatPlaces(charge, { tour: 1 });
+ const { commande, arguments: args } = depot(etat, { table: 2, siege: 1 }, { table: 3, siege: 3 });
+ const suivante = executer(charge, commande, args);
+ assert.deepEqual(suivante.retenu.placement[0].sieges, [[1, 2, 3], [5], [4, 6, 7]]);
+ assert.deepEqual(suivante.retenu.placement[1], charge.retenu.placement[1]);
+ });
+});
+
+describe('commandeDeDepot : ce qui ne désigne rien, et la cible null (contrat, « Source »)', () => {
+ test('entre deux chaises, ou sur le plateau : null, la personne revient', () => {
+ for (const etat of [etatPlaces(chargeDepot(), { tour: 1, proposition: 1 }), etatPlaces(retenue(chargeDepot()), { tour: 1 })]) {
+ assert.equal(depot(etat, { table: 2, siege: 1 }, null, false), null);
+ }
+ });
+
+ test('relâchée sur sa propre chaise : null', () => {
+ assert.equal(depot(etatPlaces(retenue(chargeDepot()), { tour: 1 }), { table: 2, siege: 1 }, { table: 2, siege: 1 }), null);
+ });
+
+ test('une proposition montrée après la retenue → { refus } PROPOSITION_MONTREE, remède afficherTour, sur un siège, une table comme sur le sol', () => {
+ const etat = etatPlaces(retenue(chargeDepot()), { tour: 1, proposition: 1 });
+ assert.equal(etat.cible, null);
+ const attendu = {
+ refus: { code: 'PROPOSITION_MONTREE', details: { proposition: 1, remede: { geste: 'afficherTour', tour: 1, proposition: null } } },
+ };
+ assert.deepEqual(depot(etat, { table: 2, siege: 1 }, { table: 3, siege: 3 }), attendu);
+ assert.deepEqual(depot(etat, { table: 2, siege: 1 }, { table: 3 }), attendu);
+ assert.deepEqual(depot(etat, { table: 2, siege: 1 }, null, true), attendu);
+ });
+
+ test('un retenu fautif → { refus } RETENU_FAUTIF', () => {
+ const charge = retenue(chargeDepot());
+ charge.retenu.placement[0].sieges[1].push(1);
+ const etat = etatPlaces(charge, { tour: 1 });
+ assert.equal(etat.cible, null);
+ assert.equal(etat.refus.code, 'RETENU_FAUTIF');
+ const rendu = commandeDeDepot({ etatPlaces: etat, tour: 1, personne: 4, depuis: { table: 2, siege: 1 }, cible: { table: 3, siege: 3 }, surLeSol: false });
+ assert.deepEqual(rendu, { refus: etat.refus });
+ });
+});
+
+describe('deposer : la place se désigne par la géométrie (§ 7.3, § 14.10)', () => {
+ const geometries = (etat) => etat.tables.map((table) => geometrieTable(table, table.capacite));
+
+ test('au centre d’une chaise libre d’une autre table → la commande nomme cette table et ce siège, et la place est ce siège', () => {
+ const etat = etatPlaces(retenue(chargeDepot()), { tour: 1 });
+ const g = geometries(etat);
+ assert.equal(g.length, 3);
+ const chaise = g[2].chaises[2];
+ const rendu = deposer({ etatPlaces: etat, tour: 1, personne: 4, depuis: { table: 2, siege: 1 }, point: { x: chaise.x, y: chaise.y }, geometries: g, k: 1 });
+ assert.deepEqual(rendu, {
+ resultat: { commande: 'deplacerDansRetenu', arguments: { tour: 1, participant: 4, table: 3, siege: 3 } },
+ place: { table: 3, siege: 3 },
+ });
+ });
+
+ test('sur le plateau : rien, la place est celle d’où vient la personne ; loin des tables : le sol', () => {
+ const etat = etatPlaces(retenue(chargeDepot()), { tour: 1 });
+ const g = geometries(etat);
+ const commun = { etatPlaces: etat, tour: 1, personne: 4, depuis: { table: 2, siege: 1 }, geometries: g, k: 1 };
+ assert.deepEqual(deposer({ ...commun, point: g[2].centre }), { resultat: null, place: { table: 2, siege: 1 } });
+ assert.deepEqual(deposer({ ...commun, point: { x: 150, y: 400 } }), {
+ resultat: { commande: 'retirerDuTour', arguments: { tour: 1, participant: 4 } },
+ place: { table: 2, siege: 1 },
+ });
+ });
+
+ test('au zoom où la table est la cible, le point au centre désigne la table, et la place est la table', () => {
+ const etat = etatPlaces(retenue(chargeDepot()), { tour: 1 });
+ const g = geometries(etat);
+ const rendu = deposer({ etatPlaces: etat, tour: 1, personne: 4, depuis: { table: 2, siege: 1 }, point: g[0].centre, geometries: g, k: 0.1 });
+ assert.deepEqual(rendu, {
+ resultat: { commande: 'deplacerDansRetenu', arguments: { tour: 1, participant: 4, table: 1, siege: null } },
+ place: { table: 1 },
+ });
+ });
+});
diff --git a/src/interface/plan/ecouteurs.test.js b/src/interface/plan/ecouteurs.test.js
new file mode 100644
index 0000000..94c6a24
--- /dev/null
+++ b/src/interface/plan/ecouteurs.test.js
@@ -0,0 +1,116 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Garde des sources du plan (§ 7.3) : un seul écouteur, sur la racine, que
+// Plan.svelte pose par addEventListener ; aucun élément SVG d'un composant
+// de src/interface/plan/ ne déclare d'écouteur dans son gabarit — ni
+// attribut on…, ni directive on:. Les contrôles HTML hors du