// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // La vue du plan côté interface (§ 7.2, § 8.5, § 13.3), sans DOM : ce que // Plan.svelte décide de sa vue — à l'ouverture, après un changement de // cadre, à la molette et aux boutons de zoom —, composé des seules // fonctions de src/geometrie/vue.js. Le cadre est un paramètre, relu par // l'appelant au moment du geste ; aucune fonction ne lit le DOM. // // Une vue ne se calcule que dans un cadre où kAjuste rend un nombre : un // cadre dont la largeur ou la hauteur ne dépasse pas 2 × MARGE_AJUSTEMENT_PX, // nul compris — un plan monté caché —, ne fixe aucune borne, et bornesZoom // lèverait sur null. Chaque fonction fait ce contrôle avant de borner. import { nomAffiche } from '../../application/personnes.js'; import { englobant, geometrieTable } from '../../geometrie/tables.js'; import { ajuster, borner, bornesZoom, facteurMolette, kAjuste, zoomerSous } from '../../geometrie/vue.js'; /** Facteur de zoom des boutons de zoom, appliqué autour du centre du cadre. */ export const FACTEUR_BOUTON = 1.25; /** * Le Cadre du contrat depuis un DOMRect : gauche ← left, haut ← top, * largeur ← width, hauteur ← height. Plan.svelte, les gestes et les * épreuves n'en lisent pas d'autre. * @param {{left: number, top: number, width: number, height: number}} rect * @returns {{gauche: number, haut: number, largeur: number, hauteur: number}} */ export function cadreDepuisRect(rect) { return { gauche: rect.left, haut: rect.top, largeur: rect.width, hauteur: rect.height }; } /** * Vue à l'ouverture : le cadrage mémorisé, son k ramené dans les bornes du * cadre (borner) ; sans cadrage, l'ajustement. Null dès que kAjuste vaut * null, cadrage mémorisé ou non. Le cadrage reçu n'est pas modifié. * @param {{k: number, tx: number, ty: number}|null} cadrage * @param {{minX: number, minY: number, maxX: number, maxY: number}|null} englobantPlan * @param {{gauche: number, haut: number, largeur: number, hauteur: number}} cadre * @returns {{k: number, tx: number, ty: number}|null} */ export function vueInitiale(cadrage, englobantPlan, cadre) { const k = kAjuste(englobantPlan, cadre); if (k === null) return null; if (cadrage === null) return ajuster(englobantPlan, cadre); return borner(cadrage, cadre, bornesZoom(k)); } /** * Vue après un changement de cadre : k ajusté recalculé sur le nouveau * cadre, k ramené dans ses bornes, le point de dessin au centre du cadre y * restant. Quand kAjuste vaut null, la vue reçue elle-même, inchangée. * @returns {{k: number, tx: number, ty: number}} */ export function vueApresCadre(vue, englobantPlan, cadre) { const k = kAjuste(englobantPlan, cadre); if (k === null) return vue; return borner(vue, cadre, bornesZoom(k)); } /** * Vue après un cran de molette : zoom sous le pointeur (clientX, clientY), * du facteur de facteurMolette(deltaY, deltaMode), aux bornes du cadre. * Null quand il n'y a rien à zoomer : vue null, ou cadre où kAjuste vaut null. * @param {{k: number, tx: number, ty: number}|null} vue * @param {{clientX: number, clientY: number, deltaY: number, deltaMode: number}} evenement * @returns {{k: number, tx: number, ty: number}|null} */ export function zoomerMolette(vue, englobantPlan, cadre, { clientX, clientY, deltaY, deltaMode }) { const k = vue === null ? null : kAjuste(englobantPlan, cadre); if (k === null) return null; return zoomerSous(vue, cadre, clientX, clientY, facteurMolette(deltaY, deltaMode), bornesZoom(k)); } /** * Vue après « Zoom + » (sens 1) ou « Zoom moins » (sens -1) : zoom de FACTEUR_BOUTON, * ou de son inverse, autour du centre du cadre, aux bornes du cadre. Null * quand il n'y a rien à zoomer, comme zoomerMolette. RangeError pour un * autre sens. * @param {1|-1} sens * @returns {{k: number, tx: number, ty: number}|null} */ export function zoomerBouton(vue, englobantPlan, cadre, sens) { if (sens !== 1 && sens !== -1) throw new RangeError(`sens du zoom : 1 ou -1 attendu, reçu ${String(sens)}`); const k = vue === null ? null : kAjuste(englobantPlan, cadre); if (k === null) return null; const facteur = sens === 1 ? FACTEUR_BOUTON : 1 / FACTEUR_BOUTON; const x = cadre.gauche + cadre.largeur / 2; const y = cadre.haut + cadre.hauteur / 2; return zoomerSous(vue, cadre, x, y, facteur, bornesZoom(k)); } /** * L'englobant des emprises des tables d'un état des places (EtatPlaces, * A5), chacune à sa capacité courante, comme le modèle de dessin les pose * (geometrieTable) ; null sans table. Il ne dépend ni du tour ni de k. * @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} */ export function englobantDuTour(etat) { return englobant(etat.tables.map((table) => geometrieTable(table, table.capacite))); } /** * Les tours que le sélecteur offre : 1 à tours, croissants. * @param {number} tours entier ≥ 1 * @returns {number[]} */ export function toursOfferts(tours) { return Array.from({ length: tours }, (_, rang) => rang + 1); } /** * Le tour que montre le plan : le tour demandé, ramené au dernier quand le * nombre de tours a baissé depuis la demande — etatPlaces lève au-delà. * @param {number} tour entier ≥ 1 @param {number} tours entier ≥ 1 * @returns {number} */ export function tourAffiche(tour, tours) { return Math.min(tour, tours); } /** * La fonction nomDe du modèle de dessin : l'identifiant d'un participant de * la charge vers son nom affiché (nomAffiche, F1). * @returns {(id: number) => string} */ export function nomDeCharge(charge) { const noms = new Map(charge.participants.map((participant) => [participant.id, nomAffiche(participant)])); return (id) => noms.get(id); } /** * Ce que la barre de vue dit du placement que le plan montre (§ 2.1, étape * 11 ; § 5.8), lu sur l'état des places du tour : le placement retenu, les * seules réservations — ou un retenu qui ne se lit pas, sous lequel le plan * montre les réservations —, une proposition dont les gestes posent des * réservations (avant la retenue), ou une proposition en vue seule (après). * Quand une proposition est demandée (ui.proposition), la barre offre de ne * plus la montrer : revenir au retenu quand il existe, aux réservations * sinon ; une proposition demandée qu'un tour ne porte pas laisse le plan * sur sa source réelle, et la commande reste offerte. * * @param {{source: string, proposition: number|null, cible: string|null, refus: Object|null}} etat * EtatPlaces du tour affiché * @param {number|null} demandee la proposition demandée au plan * @param {boolean} retenu la charge porte un placement retenu * @returns {{libelle: {cle: string, details: Object}, revenir: string|null}} * revenir : la clé du libellé de la commande, null sans proposition demandée * @throws {TypeError} pour une source hors du contrat */ export function sourceMontree(etat, demandee, retenu) { const revenir = demandee === null ? null : retenu ? 'plan.source.revenirRetenu' : 'plan.source.revenirReservations'; const avec = (cle, details = {}) => ({ libelle: { cle, details }, revenir }); if (etat.source === 'retenu') return avec('plan.source.retenu'); if (etat.source === 'proposition') { const cle = etat.cible === 'reservations' ? 'plan.source.propositionReservations' : 'plan.source.propositionVueSeule'; return avec(cle, { proposition: etat.proposition }); } if (etat.source === 'reservations') { return avec(etat.refus?.code === 'RETENU_FAUTIF' ? 'plan.source.retenuFautif' : 'plan.source.reservations'); } throw new TypeError(`source de l’état des places hors du contrat : ${JSON.stringify(etat.source)}`); }