gestion_table_tournante_libre/src/geometrie/tables.js

336 lines
16 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La géométrie d'une table (§ 7.1, § 7.7), en centimètres de dessin, l'axe y
// vers le bas : plateau tiré du nombre de places, chaises numérotées depuis le
// haut dans le sens horaire, emprise, poignée et origine du bloc de noms. Le
// 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. 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';
import { TAILLE_TEXTE_LISTE_CM } from './texte.js';
/**
* @typedef {Object} GeometrieTable en cm
* @property {number} id
* @property {number} numero
* @property {'ronde'|'carree'} forme
* @property {{x: number, y: number}} centre
* @property {number} demiTaille rayon du plateau (ronde) ou demi-côté (carrée)
* @property {number} pas distance entre deux sièges voisins, au bord du plateau
* @property {Array<{siege: number, x: number, y: number, rayon: number}>} chaises sièges 1 à places
* @property {{x: number, y: number}} poignee centre du disque de la poignée
* @property {number} rayonCible rayon du disque, centré sur une chaise, où un point désigne
* son siège en régime sièges : max(RAYON_CHAISE, pas / 2)
* @property {number} rayonEmprise demiTaille + 2 × RAYON_CHAISE : rayon du disque d'emprise
* (ronde) ou demi-côté du carré d'emprise (carrée)
* @property {{x: number, y: number}} origineListe coin haut gauche du bloc de noms
*/
/** Diamètre minimal d'une table ronde, côté minimal d'une carrée, en cm :
* réglage par défaut, non norme (§ 7.7). */
export const PLANCHER_DIAMETRE = 70;
/** Longueur de bord par convive, en cm (§ 7.7). */
export const LARGEUR_PAR_PLACE = 60;
/** Rayon d'une chaise, en cm. */
export const RAYON_CHAISE = 22;
/** Rayon du disque de la poignée, en cm. */
export const RAYON_POIGNEE = 15;
/** Distance entre l'emprise et le bord de la poignée, en cm. */
export const ECART_POIGNEE = 8;
/** Distance entre l'emprise et le bloc de noms, en cm. Le carré du focus
* que l'interface pose autour de l'emprise, ou d'une chaise, s'en écarte
* de ECART_FOCUS (src/interface/plan/modele.js), plus la moitié de son
* trait, en deçà de cette distance : le bloc commence au-delà, et aucune
* marque ne couvre la liste. */
export const ECART_LISTE = 36;
/** Écart minimal entre les centres de deux rangées de la grille des tables,
* en cm. */
export const ECART_RANGEES = 300;
/** Hauteur d'une ligne de liste que la géométrie suppose, en cm : 1,25
* corps, au-dessus de la hauteur d'encre du gabarit sur les polices
* courantes. Le dessin mesure la sienne au navigateur (HAUTEUR_LIGNE, du
* registre) ; un calcul pur qui juge où descend une liste lit celle-ci. */
export const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM;
// 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π.
const RAYON_PAR_PLACE = LARGEUR_PAR_PLACE / (2 * Math.PI);
// Les quatre côtés d'une carrée, dans l'ordre où ils reçoivent les places —
// haut, droit, bas, gauche — : le coin où commence le côté dans le sens
// horaire, en demi-côtés depuis le centre, la direction de parcours et la
// normale sortante.
const COTES = [
{ depart: { x: -1, y: -1 }, direction: { x: 1, y: 0 }, normale: { x: 0, y: -1 } },
{ depart: { x: 1, y: -1 }, direction: { x: 0, y: 1 }, normale: { x: 1, y: 0 } },
{ depart: { x: 1, y: 1 }, direction: { x: -1, y: 0 }, normale: { x: 0, y: 1 } },
{ depart: { x: -1, y: 1 }, direction: { x: 0, y: -1 }, normale: { x: -1, y: 0 } },
];
// Lève RangeError sur un nombre de places qui n'est pas un entier ≥ 1 : la
// géométrie d'une table se calcule sans valeur de repli.
function exigerPlaces(places) {
if (!Number.isInteger(places) || places < 1) {
throw new RangeError(`nombre de places entier ≥ 1 attendu, reçu ${JSON.stringify(places)}`);
}
}
/**
* Diamètre d'une table ronde (§ 7.7) : max(70, places × 60 / π). Le format 1
* ne porte aucun diamètre saisi : la formule dessine toute table, et le pas
* qu'en tire geometrieTable ne connaît qu'elle.
*
* @param {number} places entier ≥ 1
* @returns {number} cm
*/
export function diametre(places) {
exigerPlaces(places);
return Math.max(PLANCHER_DIAMETRE, (places * LARGEUR_PAR_PLACE) / Math.PI);
}
/**
* Côté d'une table carrée : max(70, ⌈places / 4⌉ × 60) — le côté le plus
* garni porte ⌈places / 4⌉ sièges.
*
* @param {number} places entier ≥ 1
* @returns {number} cm
*/
export function cote(places) {
exigerPlaces(places);
return Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE);
}
// Chaises d'une ronde : sur le cercle de rayon demiTaille + RAYON_CHAISE,
// siège s à l'angle −π/2 + 2π(s − 1)/places, le siège 1 en haut et la
// numérotation dans le sens horaire à l'écran (y vers le bas).
function chaisesRonde(centre, demiTaille, places) {
const rayon = demiTaille + RAYON_CHAISE;
return Array.from({ length: places }, (_, i) => {
const angle = -Math.PI / 2 + (2 * Math.PI * i) / places;
return { siege: i + 1, x: centre.x + rayon * Math.cos(angle), y: centre.y + rayon * Math.sin(angle), rayon: RAYON_CHAISE };
});
}
// Nombre de sièges de chaque côté d'une carrée, dans l'ordre de COTES :
// ⌊places / 4⌋ chacun, et une place en surplus à chacun des premiers côtés.
const siegesParCote = (places) => COTES.map((_, i) => Math.floor(places / 4) + (i < places % 4 ? 1 : 0));
// Chaises d'une carrée : sur chaque côté, n sièges espacés de côté / n, le
// premier à côté / 2n du coin de départ, leur centre à RAYON_CHAISE au-delà
// du bord ; le siège 1 est le premier du côté haut.
function chaisesCarree(centre, demiTaille, places) {
const chaises = [];
siegesParCote(places).forEach((nombre, i) => {
const { depart, direction, normale } = COTES[i];
for (let j = 0; j < nombre; j += 1) {
const avance = ((j + 0.5) * 2 * demiTaille) / nombre;
chaises.push({
siege: chaises.length + 1,
x: centre.x + depart.x * demiTaille + direction.x * avance + normale.x * RAYON_CHAISE,
y: centre.y + depart.y * demiTaille + direction.y * avance + normale.y * RAYON_CHAISE,
rayon: RAYON_CHAISE,
});
}
});
return chaises;
}
/**
* Géométrie d'une table, en cm (contrat de données, GeometrieTable).
*
* Le pas d'une ronde est π × diamètre / places, calculé sous la forme
* max(60, 70π / places) qui lui est égale : elle rend 60 exactement dès que
* le périmètre borne la table, là où le produit rend 59,999… pour certains
* nombres de places et ferait basculer le régime des cibles (designation.js)
* sous le seuil. Le pas d'une carrée est son côté divisé par le nombre de
* sièges du côté le plus garni.
*
* La cible d'un siège est un disque de rayon max(RAYON_CHAISE, pas / 2)
* centré sur sa chaise : au zoom où le régime des cibles passe aux sièges,
* k × pas ≥ 24 px (designation.js), elle mesure au moins 24 px à l'écran.
* Deux chaises d'une même table sont à un pas au moins l'une de l'autre :
* deux cibles se touchent au plus, sans se recouvrir.
*
* La poignée est un disque de rayon RAYON_POIGNEE sur la diagonale à +45°
* (vers le bas à droite), son bord à ECART_POIGNEE de l'emprise : son centre
* est à rayonEmprise + 23 cm du centre d'une ronde, à rayonEmprise × √2 + 23
* cm de celui d'une carrée, au-delà du coin de son emprise.
*
* @param {{id: number, numero: number, forme: 'ronde'|'carree', position: {x: number, y: number}}} table
* l'enregistrement de charge.tables ; position est le centre
* @param {number} places la capacité courante, entier ≥ 1
* @returns {GeometrieTable}
* @throws {RangeError} pour une forme inconnue ou un nombre de places invalide
*/
export function geometrieTable(table, places) {
const { id, numero, forme } = table;
if (forme !== 'ronde' && forme !== 'carree') {
throw new RangeError(`forme de table inconnue : ${JSON.stringify(forme)}`);
}
const centre = { x: table.position.x, y: table.position.y };
const ronde = forme === 'ronde';
const demiTaille = (ronde ? diametre(places) : cote(places)) / 2;
const pas = ronde
? Math.max(LARGEUR_PAR_PLACE, (PLANCHER_DIAMETRE * Math.PI) / places)
: (2 * demiTaille) / Math.ceil(places / 4);
const chaises = ronde ? chaisesRonde(centre, demiTaille, places) : chaisesCarree(centre, demiTaille, places);
const rayonEmprise = demiTaille + 2 * RAYON_CHAISE;
const distancePoignee = (ronde ? rayonEmprise : rayonEmprise * Math.SQRT2) + ECART_POIGNEE + RAYON_POIGNEE;
const decalage = distancePoignee / Math.SQRT2;
return {
id,
numero,
forme,
centre,
demiTaille,
pas,
chaises,
rayonCible: Math.max(RAYON_CHAISE, pas / 2),
poignee: { x: centre.x + decalage, y: centre.y + decalage },
rayonEmprise,
origineListe: { x: centre.x + rayonEmprise + ECART_LISTE, y: centre.y - demiTaille },
};
}
/**
* Rectangle englobant de ce que le dessin pose de chaque table et qui se
* pointe : le carré de demi-côté rayonEmprise centré sur elle — il
* circonscrit le disque d'une ronde et égale l'emprise d'une carrée —, le
* carré circonscrit à la cible de chaque siège, qui déborde l'emprise de
* pas / 2 − RAYON_CHAISE, et celui du disque de la poignée, posée au-delà du
* coin d'une carrée. L'ajustement (vue.js) le fait tenir dans le cadre :
* toute cible et toute poignée y restent visibles.
*
* @param {GeometrieTable[]} geometries
* @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} null sans table
*/
export function englobant(geometries) {
if (geometries.length === 0) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const etendre = ({ x, y }, demi) => {
minX = Math.min(minX, x - demi);
minY = Math.min(minY, y - demi);
maxX = Math.max(maxX, x + demi);
maxY = Math.max(maxY, y + demi);
};
for (const { centre, rayonEmprise, chaises, rayonCible, poignee } of geometries) {
etendre(centre, rayonEmprise);
for (const chaise of chaises) etendre(chaise, rayonCible);
etendre(poignee, RAYON_POIGNEE);
}
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 à ⌈√(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, à travers une colonne,
* 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. Le pas de rangée ne sépare que les emprises :
* une liste plus haute que lui, celle d'une table de beaucoup de places,
* descend sur une table posée hors de la grille dessous, dans la bande de
* son bloc ; listeSurTable juge ce recouvrement.
*
* 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<{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(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) * 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;
}
}
/**
* Vrai quand le bloc de noms de la table geometrie, à places places, recoupe
* l'emprise de la table autre, bords exclus : la bande du bloc, large de
* LARGEUR_BLOC_NOMS_CM depuis origineListe, haute de places + 1 lignes de
* HAUTEUR_LIGNE_SUPPOSEE — l'en-tête d'une table gelée compris —, contre le
* carré de demi-côté rayonEmprise centré sur autre, qui circonscrit le
* disque d'une ronde. La seule mesure de ce recouvrement : le mobilier en
* tire LISTE_SUR_VOISINE.
*
* @param {GeometrieTable} geometrie la table dont la liste se juge
* @param {number} places sa capacité courante
* @param {GeometrieTable} autre
* @returns {boolean}
*/
export function listeSurTable(geometrie, places, autre) {
const gauche = geometrie.origineListe.x;
const haut = geometrie.origineListe.y;
const droite = gauche + constante('LARGEUR_BLOC_NOMS_CM');
const bas = haut + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE;
const { centre, rayonEmprise: e } = autre;
return centre.x + e > gauche && centre.x - e < droite && centre.y + e > haut && centre.y - e < bas;
}
/**
* Nombre de places sous la poignée : placesInitiales + round(ecartRadial ×
* 2π / 60), jamais sous 2. L'écart se compte depuis le début du geste
* (§ 7.2) ; un demi-pas exact s'arrondit vers le haut (Math.round). Lève
* RangeError sur des places initiales qui ne sont pas un entier ≥ 1, ou sur
* un écart non fini : le résultat serait NaN, ou un nombre non entier.
*
* @param {number} placesInitiales les places au début du geste
* @param {number} ecartRadial cm, projeté sur l'axe de la poignée, positif
* en s'éloignant du centre
* @returns {number} entier ≥ 2
*/
export function placesSousPoignee(placesInitiales, ecartRadial) {
exigerPlaces(placesInitiales);
if (typeof ecartRadial !== 'number' || !Number.isFinite(ecartRadial)) {
throw new RangeError(`écart radial : nombre fini attendu, reçu ${String(ecartRadial)}`);
}
return Math.max(2, placesInitiales + Math.round(ecartRadial / RAYON_PAR_PLACE));
}