[ADD] interface: plan view — zoom, framing, fit, scale, side panels
The drawing becomes a plan the operator can look around. One transform on
the drawing group holds the view, so the screen and the drawing share the
formula of src/geometrie; the wheel zooms about the pointer without
scrolling the page, « + », « − » and « Ajuster » bound the scale, and a
resize keeps the view inside its limits. Only a deliberate gesture saves
the framing, deferred, into reglages_locaux.json — in read mode and on a
blocked plan too. The side panels take their first form.
Checked: 2368 node and 378 browser tests.
--- FR ---
[ADD] interface : vue du plan — zoom, cadrage, ajuster, échelle
Le dessin devient un plan que l'opérateur parcourt. Une seule
transformation du groupe de dessin tient la vue : l'écran et le dessin
partagent la formule de src/geometrie ; la molette zoome autour du
pointeur sans faire défiler la page, « + », « − » et « Ajuster » bornent
l'échelle, et un redimensionnement garde la vue dans ses limites. Seul un
geste voulu enregistre le cadrage, en différé, dans reglages_locaux.json
— en lecture et sur un plan bloqué aussi. Les panneaux latéraux prennent
leur première forme.
Vérifié : 2368 node et 378 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 09:09:45 -04:00
|
|
|
|
// © 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);
|
|
|
|
|
|
}
|
[FIX] iteration 3 review: seat targets, gestures, warnings, settings
The wheel no longer zooms during a gesture, so a drop lands under the
ghost. A seat is a target the size of its pitch. A floor drop refuses to
remove an every-tour reservation. A seat reservation the retained plan
does not hold is flagged at the seat, with its swap as remedy. DERIVE names
the person; capacities have an upper bound; Generate waits for a pending
setting; a worker that fails to load shows a refusal; § 5.3 constraints
get their switches. Commands are not re-entrant and a double click does not
confirm. Checked: 2755 node, 550 browser tests, from the index alone.
--- FR ---
[FIX] revue de l'itération 3 : cibles, gestes, avertissements, réglages
La molette ne zoome plus pendant un geste : le dépôt tombe sous le fantôme.
Un siège est une cible de la taille de son pas. Un dépôt sur le sol refuse
de retirer une réservation de tous les tours. Une réservation à un siège
que le retenu ne tient pas est relevée au siège, l'échange en remède. La
dérive nomme la personne ; les capacités ont une borne ; Générer attend un
réglage en cours ; un travailleur qui ne se charge pas donne un refus ; le
§ 5.3 a ses bascules. Les commandes ne sont pas ré-entrantes et un double
clic ne confirme pas. Vérifié : 2755 node, 550 navigateur, index seul.
Assisted-by: Claude Opus 5.5
2026-10-07 17:50:35 -04:00
|
|
|
|
|
[ADD] plan: one hue and pattern per conflict rule, legend, appartenance
The plan drew every conflict in one hue, so a table that seats colleagues
together looked like one where people meet again. Each active rule now has
its own reserved hue and pattern: same appartenance hatched one way, met
again hatched the other, back at a table dotted; rules on one table
overlap, the badge keeps the total. A legend from the view bar shows each
active rule's sample. A table's tooltip names the appartenance that brings
a pair together. The hues are validated by computation in both themes and
the spec (7.5, 12.7) follows. Checked: 2848 node, 580 browser tests.
--- FR ---
[ADD] plan : une teinte et une trame par règle, légende, appartenance
Le plan dessinait tout conflit d'une seule teinte : une table qui réunit
des collègues ressemblait à une table où des gens se revoient. Chaque
règle active a sa teinte réservée et sa trame : même appartenance hachurée
dans un sens, voisins revus dans l'autre, table revue en pointillé ; les
règles d'une table se superposent, la pastille garde le total. Une légende
dans la barre de vue montre l'échantillon de chaque règle active.
L'infobulle nomme l'appartenance qui réunit une paire. Teintes validées
par calcul dans les deux thèmes ; spec § 7.5, § 12.7. Vérifié : 2848, 580.
Assisted-by: Claude Opus 5.5
2026-10-08 04:33:43 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* La fonction appartenanceDe de l'infobulle (detailConflits) : l'identifiant
|
|
|
|
|
|
* d'un participant de la charge vers son appartenance, la chaîne même que la
|
|
|
|
|
|
* liste porte, ou null — sans appartenance, ou pour un identifiant que la
|
|
|
|
|
|
* charge n'a pas.
|
|
|
|
|
|
* @returns {(id: number) => string|null}
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function appartenanceDeCharge(charge) {
|
|
|
|
|
|
const appartenances = new Map(charge.participants.map(({ id, appartenance }) => [id, appartenance]));
|
|
|
|
|
|
return (id) => appartenances.get(id) ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
[FIX] iteration 3 review: seat targets, gestures, warnings, settings
The wheel no longer zooms during a gesture, so a drop lands under the
ghost. A seat is a target the size of its pitch. A floor drop refuses to
remove an every-tour reservation. A seat reservation the retained plan
does not hold is flagged at the seat, with its swap as remedy. DERIVE names
the person; capacities have an upper bound; Generate waits for a pending
setting; a worker that fails to load shows a refusal; § 5.3 constraints
get their switches. Commands are not re-entrant and a double click does not
confirm. Checked: 2755 node, 550 browser tests, from the index alone.
--- FR ---
[FIX] revue de l'itération 3 : cibles, gestes, avertissements, réglages
La molette ne zoome plus pendant un geste : le dépôt tombe sous le fantôme.
Un siège est une cible de la taille de son pas. Un dépôt sur le sol refuse
de retirer une réservation de tous les tours. Une réservation à un siège
que le retenu ne tient pas est relevée au siège, l'échange en remède. La
dérive nomme la personne ; les capacités ont une borne ; Générer attend un
réglage en cours ; un travailleur qui ne se charge pas donne un refus ; le
§ 5.3 a ses bascules. Les commandes ne sont pas ré-entrantes et un double
clic ne confirme pas. Vérifié : 2755 node, 550 navigateur, index seul.
Assisted-by: Claude Opus 5.5
2026-10-07 17:50:35 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* 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)}`);
|
|
|
|
|
|
}
|
[IMP] plan and diagnostic: grouped toolbar, card panels, bounded tooltip
The plan's chrome mixed view controls with panels of the same weight.
The toolbar groups round, zoom and views; side panels, the reserve and
the work lists become cards. A seat tooltip, like a refusal on a seat,
stays inside the plan frame and flips side rather than cover the next
panel. The diagnostic groups its statements into one card per colliding
membership, in the same order. The drawing keeps its colours and shapes.
Checked: node 231, browser 137, 6 mutations caught.
--- FR ---
[IMP] plan et diagnostic : barre groupée, panneaux en cartes, infobulle
L'habillage du plan mêlait commandes de vue et panneaux de même poids.
La barre groupe tour, zoom et vues ; panneaux, réserve et listes de
travail deviennent des cartes. L'infobulle d'un siège, comme un refus
sur une place, reste dans le cadre du plan et change de côté plutôt que
de couvrir le panneau voisin. Le diagnostic groupe ses énoncés en une
carte par appartenance en collision, sans changer l'ordre. Le dessin
garde couleurs et formes. Vérifié : node 231, navigateur 137.
Assisted-by: Claude Opus 5.5
2026-10-11 00:58:07 -04:00
|
|
|
|
|
|
|
|
|
|
// La position d'une bulle sur un axe : debut, le bord de départ voulu ;
|
|
|
|
|
|
// repli, le bord où finit la bulle passée de l'autre côté ; taille, sa
|
|
|
|
|
|
// longueur ; de et a, les bords de la zone. Le côté choisi est ensuite
|
|
|
|
|
|
// borné à [de, a - taille] : une ancre avant de — une table coupée par le
|
|
|
|
|
|
// bord du cadre — ramène la bulle sur de ; une bulle plus longue que la
|
|
|
|
|
|
// zone se cale sur de.
|
|
|
|
|
|
function placerSurAxe(debut, repli, taille, de, a) {
|
|
|
|
|
|
const fin = Math.max(de, a - taille);
|
|
|
|
|
|
const choisi = debut + taille <= a ? debut : repli - taille >= de ? repli - taille : fin;
|
|
|
|
|
|
return Math.min(Math.max(choisi, de), fin);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* La position d'une bulle posée sur le plan — l'infobulle d'une table, le
|
|
|
|
|
|
* refus posé sur une place — dans la zone qui la contient, en px du
|
|
|
|
|
|
* conteneur : à son ancre quand elle y tient, à droite et dessous ; passée
|
|
|
|
|
|
* à gauche, son bord droit sur xRepli, quand elle déborde à droite ;
|
|
|
|
|
|
* au-dessus, son bord bas sur y, quand elle déborde dessous ; calée sur le
|
|
|
|
|
|
* bord opposé de la zone quand aucun côté ne suffit, jamais avant le bord
|
|
|
|
|
|
* gauche ni le bord haut : une ancre au-dessus ou à gauche de la zone ramène
|
|
|
|
|
|
* la bulle sur ce bord. Une taille nulle — pas encore mesurée — tient
|
|
|
|
|
|
* toujours : la bulle reste à son ancre quand celle-ci est dans la zone.
|
|
|
|
|
|
* Aucun argument n'est modifié.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {{x: number, y: number, xRepli?: number}} ancre xRepli vaut x par défaut
|
|
|
|
|
|
* @param {{largeur: number, hauteur: number}} taille
|
|
|
|
|
|
* @param {{gauche: number, haut: number, largeur: number, hauteur: number}} zone
|
|
|
|
|
|
* @returns {{x: number, y: number}} le coin haut gauche de la bulle
|
|
|
|
|
|
* @throws {TypeError} pour un nombre non fini
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function placerBulle({ x, y, xRepli = x }, { largeur, hauteur }, zone) {
|
|
|
|
|
|
const nombres = [x, y, xRepli, largeur, hauteur, zone.gauche, zone.haut, zone.largeur, zone.hauteur];
|
|
|
|
|
|
if (!nombres.every(Number.isFinite)) throw new TypeError(`bulle : nombre non fini : ${JSON.stringify(nombres)}`);
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: placerSurAxe(x, xRepli, largeur, zone.gauche, zone.gauche + zone.largeur),
|
|
|
|
|
|
y: placerSurAxe(y, y, hauteur, zone.haut, zone.haut + zone.hauteur),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|