From 901095a1ef0f1521038018cdf98d4e99c8cc70aa Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 11 Oct 2026 00:58:07 -0400 Subject: [PATCH] [IMP] plan and diagnostic: grouped toolbar, card panels, bounded tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../diagnostic/Diagnostic.navigateur.test.js | 141 ++++++++++++- src/interface/diagnostic/Diagnostic.svelte | 190 ++++++++++++------ src/interface/diagnostic/phrases.js | 35 ++++ src/interface/diagnostic/phrases.test.js | 63 +++++- src/interface/libelles/diagnostic.js | 4 + src/interface/libelles/plan.js | 3 + src/interface/plan/BandeauPlan.svelte | 25 ++- src/interface/plan/BarreVue.svelte | 152 ++++++++++---- src/interface/plan/Infobulle.svelte | 44 ++-- src/interface/plan/Legende.svelte | 13 +- src/interface/plan/ListesTravail.svelte | 186 +++++++++++------ src/interface/plan/MenuPersonne.svelte | 11 +- src/interface/plan/PanneauPlace.svelte | 129 ++++++++---- src/interface/plan/PanneauTable.svelte | 169 +++++++++++----- .../plan/Panneaux.navigateur.test.js | 64 ++++++ src/interface/plan/Plan.svelte | 13 +- src/interface/plan/RefusPlace.svelte | 67 ++++-- src/interface/plan/Reserve.svelte | 85 ++++++-- src/interface/plan/SectionPlan.svelte | 6 + src/interface/plan/vue_plan.js | 39 ++++ src/interface/plan/vue_plan.test.js | 65 ++++++ 21 files changed, 1185 insertions(+), 319 deletions(-) diff --git a/src/interface/diagnostic/Diagnostic.navigateur.test.js b/src/interface/diagnostic/Diagnostic.navigateur.test.js index d3ad40e..9059c28 100644 --- a/src/interface/diagnostic/Diagnostic.navigateur.test.js +++ b/src/interface/diagnostic/Diagnostic.navigateur.test.js @@ -31,9 +31,10 @@ import { VERSION } from '../../version.genere.js'; import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw'; import { fixerCadre } from '../../../test/navigateur/entrees.js'; import { monterApp } from '../../../test/navigateur/montage.js'; -import { visible } from '../../../test/navigateur/rendu.js'; +import { composantes, visible } from '../../../test/navigateur/rendu.js'; +import { teintesAppartenances } from '../appartenances.js'; import { TABLES_INTERFACE } from '../libelles.js'; -import { enonces, numeroDeCharge } from './phrases.js'; +import { cartesDiagnostic, enonces, numeroDeCharge } from './phrases.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); const V1 = VERSION.affichee; @@ -131,11 +132,16 @@ const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim(); const charge = (m) => m.seance.etat().charge; const revision = (m) => m.seance.etat().evenement.revision; -// Chaque énoncé rendu sous la racine : sa clé, son appartenance, son texte, -// et son remède, null quand il n'en porte pas. +// L'intitulé de la liste qui porte un énoncé : la liste d'une carte est +// nommée par son titre (aria-labelledby). +const intituleDe = (li) => document.getElementById(li.closest('ul').getAttribute('aria-labelledby')); + +// Chaque énoncé rendu sous la racine : sa clé, son appartenance — la donnée +// du titre de sa carte —, son texte, et son remède, null quand il n'en porte +// pas. function lus(racine) { return [...racine.querySelectorAll('[data-enonce]')].map((li) => { - const donnee = li.querySelector('[data-donnee]'); + const donnee = intituleDe(li).querySelector('[data-donnee]'); const remede = li.querySelector('[data-remede]'); return { cle: li.dataset.enonce, @@ -159,6 +165,51 @@ function attendus(chargeLue, { avecRemede }) { })); } + +// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde. +function jeton(nom) { + const sonde = document.createElement('span'); + sonde.style.color = `var(${nom})`; + document.body.append(sonde); + const couleur = composantes(getComputedStyle(sonde).color); + sonde.remove(); + return couleur; +} + +// La carte qui porte un élément : son plus proche ancêtre peint du fond de +// la carte, bordé d'un filet et arrondi. +function carteDe(element) { + const fond = jeton('--couleur-carte-fond'); + for (let porteur = element.parentElement; porteur !== null; porteur = porteur.parentElement) { + const style = getComputedStyle(porteur); + const peinte = composantes(style.backgroundColor).join() === fond.join(); + if (peinte && parseFloat(style.borderTopWidth) > 0 && parseFloat(style.borderTopLeftRadius) > 0) return porteur; + } + return null; +} + +// Les cartes attendues : genre, groupe et clés de chaque carte, du moteur +// sollicité une seconde fois. +function cartesAttendues(chargeLue) { + const diagnostic = diagnostiquer(configurationDepuisCharge(chargeLue)); + const liste = enonces(diagnostic, { numeroDe: numeroDeCharge(chargeLue), tours: chargeLue.evenement.tours }); + return cartesDiagnostic(liste).map(({ genre, groupe, enonces: dedans }) => ({ genre, groupe, cles: dedans.map(({ cle }) => cle) })); +} + +// Les cartes rendues sous la racine, lues comme cartesAttendues : chaque +// liste d'énoncés et son titre ; groupe, la donnée du titre. +function cartesLues(racine) { + return [...racine.querySelectorAll('ul')] + .filter((ul) => ul.querySelector('[data-enonce]') !== null) + .map((ul) => { + const donnee = document.getElementById(ul.getAttribute('aria-labelledby')).querySelector('[data-donnee]'); + return { + groupe: donnee === null ? null : texteDe(donnee), + cles: [...ul.querySelectorAll('[data-enonce]')].map((li) => li.dataset.enonce), + }; + }); +} + // --- Les épreuves ------------------------------------------------------------------ describe('le diagnostic se lit sans rien demander (§ 2.1 étape 8, § 5.6)', () => { @@ -312,6 +363,86 @@ describe('la configuration refusée (§ 5.6, § 5.9)', () => { }); }); + +describe('le diagnostic en cartes (§ 5.6, § 12.7)', () => { + test('toutes en conflit : la section pose une carte par appartenance, titrée de sa pastille, de la teinte de son rang ; le panneau compact titre chaque groupe de même', async () => { + const m = await ouvrirTexte(TEXTE_TOUTES_EN_CONFLIT, 'toutes_en_conflit'); + const rangs = teintesAppartenances(charge(m).participants); + const attendues = cartesAttendues(charge(m)); + const appartenances = attendues.filter(({ genre }) => genre === 'appartenance'); + expect(appartenances.length).toBe(2); + const sansGenre = attendues.map(({ groupe, cles }) => ({ groupe, cles })); + + for (const [nom, racine] of [ + ['panneau', () => panneau(m)], + ['section', () => (m.ui.allerA('diagnostic'), section(m))], + ]) { + await attendre(() => expect(cartesLues(racine())).toEqual(sansGenre)); + const listes = [...racine().querySelectorAll('ul')].filter((ul) => ul.querySelector('[data-enonce]') !== null); + for (const { groupe } of appartenances) { + const ul = listes.find((liste) => document.getElementById(liste.getAttribute('aria-labelledby')).textContent.includes(groupe)); + const titre = document.getElementById(ul.getAttribute('aria-labelledby')); + expect(/^H[2-6]$/.test(titre.tagName), `${nom} ${groupe} : un intitulé`).toBe(true); + const donnee = titre.querySelector('[data-donnee]'); + expect(texteDe(donnee)).toBe(groupe); + // La pastille : un point de la marque du rang, le nom de l'encre de la page. + const point = donnee.querySelector('[aria-hidden="true"]'); + expect(point, `${nom} ${groupe} : le point de la pastille`).not.toBeNull(); + const rang = rangs.get(groupe); + expect(composantes(getComputedStyle(point).backgroundColor)).toEqual(jeton(`--couleur-appartenance-${rang}-marque`)); + expect(visible(donnee)).toBe(true); + } + if (nom === 'section') { + // Chaque liste a sa propre carte. + const cartes = listes.map(carteDe); + expect(cartes.every((carte) => carte !== null)).toBe(true); + expect(new Set(cartes).size).toBe(listes.length); + } + } + }); +}); + +describe('le rang de teinte d’une carte se compte sur tous les participants, exclus compris (§ 12.7)', () => { + test('une personne exclue dont l’appartenance se range avant les autres décale les deux cartes aux rangs 2 et 3', async () => { + // La charge « toutes en conflit » et une treizième personne, exclue, + // d'une appartenance qui se range avant les deux autres : le moteur ne + // la voit pas, les collisions restent celles des deux groupes. + const chargeAvecExclue = analyser(TEXTE_TOUTES_EN_CONFLIT).charge; + chargeAvecExclue.participants.push({ + id: 13, + nom: 'Varech', + prenom: null, + appartenance: 'Atelier des Alisiers', + courriel: null, + titrePressenti: null, + notes: null, + exclu: true, + }); + chargeAvecExclue.prochainsIds = { ...chargeAvecExclue.prochainsIds, participant: 14 }; + const m = await ouvrirCharge(chargeAvecExclue); + const attendues = cartesAttendues(charge(m)).filter(({ genre }) => genre === 'appartenance'); + expect(attendues.map(({ groupe }) => groupe).sort()).toEqual(['Cercle des Hérons', 'Club des Merles']); + + for (const [nom, racine] of [ + ['panneau', () => panneau(m)], + ['section', () => (m.ui.allerA('diagnostic'), section(m))], + ]) { + await attendre(() => expect(racine().querySelectorAll('[data-donnee]').length).toBe(2)); + for (const [groupe, rang] of [ + ['Cercle des Hérons', 2], + ['Club des Merles', 3], + ]) { + const donnee = [...racine().querySelectorAll('[data-donnee]')].find((d) => texteDe(d) === groupe); + expect(donnee, `${nom} ${groupe} : la pastille`).not.toBeUndefined(); + const point = donnee.querySelector('[aria-hidden="true"]'); + expect(composantes(getComputedStyle(point).backgroundColor), `${nom} ${groupe} : la marque du rang ${rang}`).toEqual( + jeton(`--couleur-appartenance-${rang}-marque`), + ); + } + } + }); +}); + describe('le bandeau du plan énonce le diagnostic (§ 7.5)', () => { // Les entrées attendues du bandeau, du moteur sollicité une seconde fois : // la phrase, collisionPartout, puis chaque plancher de groupe. diff --git a/src/interface/diagnostic/Diagnostic.svelte b/src/interface/diagnostic/Diagnostic.svelte index 0db3b4d..ba0d44a 100644 --- a/src/interface/diagnostic/Diagnostic.svelte +++ b/src/interface/diagnostic/Diagnostic.svelte @@ -9,29 +9,40 @@ // fait que traduire. Regarder n'écrit jamais (§ 8.4) : il se lit en // lecture comme sur un plan bloqué. // + // Les énoncés se regroupent par cartesDiagnostic, sans changer d'ordre : + // la séparation vidée de son sens, une carte par appartenance en + // collision, puis le reste de l'événement. Chaque groupe a son intitulé, + // qui nomme sa liste (aria-labelledby) : celui d'une appartenance est sa + // pastille, de la teinte que teintesAppartenances donne à son rang sur + // toute la liste des participants, exclus compris, et porte la donnée + // [data-donnee] ; les autres, un intertitre. + // // compact : la forme du panneau latéral du plan, que SectionPlan monte - // dans [data-panneau-diagnostic] — son titre et les énoncés seuls. Sinon, la section, - // [data-section="diagnostic"] : chaque énoncé suivi du texte de son - // remède et, quand une commande le réalise, de la Commande qui l'exécute - // par la séance ; un refus de la séance — mode lecture, plan bloqué — - // s'affiche à côté du bouton, avec son propre remède. + // dans [data-panneau-diagnostic] — une carte titrée, dont les groupes se + // suivent sous un filet, et les énoncés seuls. Sinon, la section, + // [data-section="diagnostic"] : une carte par groupe, chaque énoncé suivi + // du texte de son remède et, quand une commande le réalise, de la Commande + // qui l'exécute par la séance ; un refus de la séance — mode lecture, plan + // bloqué — s'affiche à côté du bouton, avec son propre remède. // // Une configuration que le moteur refuse n'a pas de diagnostic : son // code, traduit, se pose à sa place, jamais le message de l'erreur. - // L'appartenance d'un groupe est une donnée : elle occupe son propre - // nœud, avant le texte. import { getContext } from 'svelte'; import { diagnostiquerCharge } from '../../application/diagnostic.js'; + import { teintesAppartenances } from '../appartenances.js'; + import Carte from '../primitives/Carte.svelte'; import { actionRemede } from '../primitives/commande.js'; import Commande from '../primitives/Commande.svelte'; + import Pastille from '../primitives/Pastille.svelte'; import Refus from '../primitives/Refus.svelte'; - import { enonces, numeroDeCharge } from './phrases.js'; + import { cartesDiagnostic, enonces, numeroDeCharge } from './phrases.js'; let { compact = false } = $props(); const t = getContext('t'); const seance = getContext('seance'); const ui = getContext('ui') ?? null; + const uid = $props.id(); const charge = $derived($seance.charge); const resultat = $derived(charge === null ? null : diagnostiquerCharge(charge)); @@ -40,53 +51,71 @@ ? [] : enonces(resultat.diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }), ); + const cartes = $derived(cartesDiagnostic(liste)); + // Le rang de teinte de chaque appartenance, sur tous les participants. + const rangs = $derived(charge === null ? new Map() : teintesAppartenances(charge.participants)); + // L'intitulé d'un groupe est un h3 dans la section, un h4 sous le titre + // du panneau. + const balise = $derived(compact ? 'h4' : 'h3'); // L'action d'un remède que réalise une commande ; null pour un remède de // texte seul. const actionDe = (remede) => (remede?.commande === undefined ? null : actionRemede(remede, { seance, ui })); -{#snippet contenu()} - {#if resultat !== null && resultat.erreur !== null} -

{t('diagnostic.nonCalcule')}

- - {:else if liste.length > 0} - +{/snippet} + +{#snippet erreur()} +

{t('diagnostic.nonCalcule')}

+ {/snippet} {#if compact} -
-

{t('coquille.section.diagnostic')}

- {@render contenu()} -
+
+

{t('coquille.section.diagnostic')}

+ {#if resultat !== null && resultat.erreur !== null} +
{@render erreur()}
+ {:else if cartes.length > 0} +
+ {#each cartes as carte, rang (rang)} +
{@render groupe(carte, rang)}
+ {/each} +
+ {/if} +
{:else}

{t('coquille.section.diagnostic')}

- {@render contenu()} + {#if resultat !== null && resultat.erreur !== null} + {@render erreur()} + {:else if cartes.length > 0} +
+ {#each cartes as carte, rang (rang)} + {@render groupe(carte, rang)} + {/each} +
+ {/if}
{/if} @@ -94,7 +123,7 @@ .diagnostic { display: flex; flex-direction: column; - gap: var(--espace-2); + gap: var(--espace-3); min-width: 0; color: var(--couleur-page-texte); font-size: var(--taille-texte); @@ -105,9 +134,30 @@ font-size: var(--taille-titre); } - h3 { - margin: 0; + .cartes { + display: flex; + flex-direction: column; + gap: var(--espace-3); + } + + /* L'intitulé d'un groupe : la pastille de son appartenance, ou un + intertitre en petites capitales d'encre secondaire. */ + .intitule { + margin: 0 0 var(--espace-2); font-size: var(--taille-texte); + font-weight: bold; + } + + .intertitre { + color: var(--couleur-page-texte-secondaire); + font-size: var(--taille-intertitre); + letter-spacing: var(--interlettre-capitales); + text-transform: uppercase; + } + + .donnee { + display: flex; + min-width: 0; } ul { @@ -119,10 +169,6 @@ list-style: none; } - .compact ul { - gap: var(--espace-2); - } - li { display: flex; flex-direction: column; @@ -134,17 +180,43 @@ margin: 0; } - .enonce { - display: flex; - flex-wrap: wrap; - gap: var(--espace-2); - } - - .donnee { - font-weight: 700; - } - .remede { color: var(--couleur-page-texte-secondaire); } + + /* Le panneau compact : une carte titrée ; ses groupes se suivent, chacun + sous un filet. */ + .compact { + gap: 0; + border: 1px solid var(--couleur-carte-filet); + border-radius: var(--rayon-3); + background-color: var(--couleur-carte-fond); + box-shadow: var(--ombre-carte); + } + + .titre-panneau { + margin: 0; + padding: var(--espace-3) var(--espace-4); + border-bottom: 1px solid var(--couleur-carte-filet); + font-size: var(--taille-titre-carte); + } + + .compact .corps { + padding: var(--espace-3) var(--espace-4); + } + + .compact .groupes { + display: flex; + flex-direction: column; + gap: var(--espace-3); + } + + .compact .groupe + .groupe { + padding-top: var(--espace-3); + border-top: 1px solid var(--couleur-carte-filet); + } + + .compact ul { + gap: var(--espace-2); + } diff --git a/src/interface/diagnostic/phrases.js b/src/interface/diagnostic/phrases.js index fde613d..66eb457 100644 --- a/src/interface/diagnostic/phrases.js +++ b/src/interface/diagnostic/phrases.js @@ -190,3 +190,38 @@ export function numeroDeCharge(charge) { return numero; }; } + +// Les énoncés de la séparation vidée de son sens, que porte la carte de la +// séparation. +const CLES_SEPARATION = new Set(['diagnostic.uneSeuleAppartenance', 'diagnostic.collisionPartout']); + +// Le genre et le groupe de la carte d'un énoncé. +function carteDe({ cle, details }) { + if (details.groupe !== undefined) return { genre: 'appartenance', groupe: details.groupe }; + return { genre: CLES_SEPARATION.has(cle) ? 'separation' : 'ensemble', groupe: null }; +} + +/** + * Les cartes du diagnostic (§ 5.6) : les énoncés regroupés, sans en changer + * l'ordre, par suites d'énoncés voisins de même carte — la séparation vidée + * de son sens ('separation'), une appartenance, celle de details.groupe + * ('appartenance'), ou le reste de l'événement ('ensemble'). Dans l'ordre + * d'enonces, la séparation vient d'abord, puis une carte par appartenance + * en collision — ses collisions et son excédent —, puis l'événement : les + * cartes mises bout à bout redonnent la liste reçue, énoncé pour énoncé, les + * mêmes objets. Ne modifie pas la liste. + * + * @param {Array<{cle: string, details: Object}>} liste celle que rend enonces + * @returns {Array<{genre: 'separation'|'appartenance'|'ensemble', groupe: string|null, + * enonces: Array}>} + */ +export function cartesDiagnostic(liste) { + const cartes = []; + for (const enonce of liste) { + const { genre, groupe } = carteDe(enonce); + const derniere = cartes.at(-1); + if (derniere !== undefined && derniere.genre === genre && derniere.groupe === groupe) derniere.enonces.push(enonce); + else cartes.push({ genre, groupe, enonces: [enonce] }); + } + return cartes; +} diff --git a/src/interface/diagnostic/phrases.test.js b/src/interface/diagnostic/phrases.test.js index 3b1897e..10d61bc 100644 --- a/src/interface/diagnostic/phrases.test.js +++ b/src/interface/diagnostic/phrases.test.js @@ -16,7 +16,7 @@ import { texteLivre } from '../../demo/livrees.js'; import { diagnostiquer } from '../../moteur/diagnostic.js'; import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; import { TABLES_INTERFACE } from '../libelles.js'; -import { enonces, numeroDeCharge } from './phrases.js'; +import { cartesDiagnostic, enonces, numeroDeCharge } from './phrases.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); const TIRET = '\u{2014}'; @@ -492,3 +492,64 @@ describe('enonces : aux bords', () => { assert.deepEqual(diagnostic, avant); }); }); + +describe('cartesDiagnostic : une carte par appartenance (§ 5.6, § 12.7)', () => { + // La forme lisible d'une suite de cartes : genre, groupe, clés. + const formes = (cartes) => cartes.map(({ genre, groupe, enonces: liste }) => [genre, groupe, cles(liste)]); + + test('deux appartenances en collision partout : la séparation, une carte par groupe, puis le reste de l\u{2019}événement', () => { + const { diagnostic, liste } = lire(CONFIGURATIONS.toutesEnConflit()); + assert.equal(diagnostic.collisionPartout, true); + const cartes = cartesDiagnostic(liste); + const groupes = diagnostic.collisions.map(({ groupe }) => groupe); + assert.equal(groupes.length, 2); + const reste = cles(liste).filter((cle) => !['diagnostic.collisionPartout', 'diagnostic.collisions', 'diagnostic.excedent'].includes(cle)); + assert.ok(reste.length > 0); + assert.deepEqual(formes(cartes), [ + ['separation', null, ['diagnostic.collisionPartout']], + ...groupes.map((groupe) => [ + 'appartenance', + groupe, + cles(liste.filter(({ details }) => details.groupe === groupe)), + ]), + ['ensemble', null, reste], + ]); + }); + + test('l\u{2019}excédent suit les collisions de son groupe, dans la même carte', () => { + const { liste } = lire(CONFIGURATIONS.excedent()); + const [carte] = cartesDiagnostic(liste).filter(({ genre }) => genre === 'appartenance'); + assert.deepEqual([carte.groupe, cles(carte.enonces)], ['Grisaille', ['diagnostic.collisions', 'diagnostic.excedent']]); + }); + + test('l\u{2019}ordre des énoncés est celui d\u{2019}enonces, carte après carte, et chaque énoncé est le même objet', () => { + for (const [nom, fabrique] of Object.entries(CONFIGURATIONS)) { + const { liste } = lire(fabrique()); + const cartes = cartesDiagnostic(liste); + const aplati = cartes.flatMap((carte) => carte.enonces); + assert.equal(aplati.length, liste.length, nom); + aplati.forEach((enonce, rang) => assert.equal(enonce, liste[rang], `${nom} : énoncé ${rang}`)); + // Deux cartes voisines ne se confondent pas. + for (let rang = 1; rang < cartes.length; rang += 1) { + const [a, b] = [cartes[rang - 1], cartes[rang]]; + assert.ok(a.genre !== b.genre || a.groupe !== b.groupe, `${nom} : cartes ${rang - 1} et ${rang}`); + } + for (const carte of cartes) assert.ok(carte.enonces.length > 0, nom); + } + }); + + test('sans appartenance, une seule carte, celle de l\u{2019}événement ; aucun énoncé, aucune carte ; la liste reçue n\u{2019}est pas modifiée', () => { + const { liste } = lire(CONFIGURATIONS.manque()); + const copie = structuredClone(liste); + assert.deepEqual(formes(cartesDiagnostic(liste)), [['ensemble', null, cles(liste)]]); + assert.deepEqual(liste, copie); + assert.deepEqual(cartesDiagnostic([]), []); + }); + + test('une seule appartenance : sa phrase ouvre la carte de la séparation', () => { + const { liste } = lire(CONFIGURATIONS.uneSeule()); + const [premiere] = cartesDiagnostic(liste); + assert.equal(premiere.genre, 'separation'); + assert.equal(premiere.enonces[0].cle, 'diagnostic.uneSeuleAppartenance'); + }); +}); diff --git a/src/interface/libelles/diagnostic.js b/src/interface/libelles/diagnostic.js index 62f961d..d4e050e 100644 --- a/src/interface/libelles/diagnostic.js +++ b/src/interface/libelles/diagnostic.js @@ -17,6 +17,10 @@ export const TABLE = new Map([ // --- Le cadre de l'écran --------------------------------------------------- ['diagnostic.enonces', 'Ce que la configuration impose'], ['diagnostic.nonCalcule', 'La configuration ne se normalise pas : le diagnostic ne se calcule pas.'], + // Les intitulés des cartes que ne nomme pas une appartenance + // (cartesDiagnostic) : la séparation vidée de son sens, puis le reste. + ['diagnostic.carte.separation', 'Séparation des appartenances'], + ['diagnostic.carte.ensemble', "Tout l'événement"], // --- La séparation vidée de son sens (§ 7.5, § 15.4) ----------------------- [ diff --git a/src/interface/libelles/plan.js b/src/interface/libelles/plan.js index c8db7ef..cd9f39a 100644 --- a/src/interface/libelles/plan.js +++ b/src/interface/libelles/plan.js @@ -83,6 +83,9 @@ export const TABLE = new Map([ ['plan.vue.ajuster', 'Ajuster'], ['plan.vue.listes', 'Listes de noms'], ['plan.vue.pleinEcran', 'Plein écran'], + // Les noms des groupes de la barre de vue (role="group"). + ['plan.vue.groupeZoom', 'Zoom'], + ['plan.vue.groupeAffichage', 'Affichage'], ['plan.vue.dessin', 'Plan de salle'], // Le placement que le plan montre, et la commande qui quitte une // proposition montrée. diff --git a/src/interface/plan/BandeauPlan.svelte b/src/interface/plan/BandeauPlan.svelte index 3fa6300..727e147 100644 --- a/src/interface/plan/BandeauPlan.svelte +++ b/src/interface/plan/BandeauPlan.svelte @@ -9,7 +9,12 @@ // d'un plancher de groupe est une donnée : elle occupe son propre nœud, // avant le texte. Le bandeau d'application garde la version, la // provenance et le mode. + // + // Il se lit comme un avis : fond, filet et trait de gauche de l'avis, et + // son signe, le triangle, de la marque de l'avis ; le texte garde l'encre + // de la page. import { getContext } from 'svelte'; + import Icone from '../primitives/Icone.svelte'; let { bandeau } = $props(); @@ -18,7 +23,7 @@ {#if bandeau.length > 0}
-
    +
      {#each bandeau as entree, rang (rang)}
    • {#if entree.details.groupe !== undefined}{entree.details.groupe}{/if} .bandeau-plan { + display: flex; + align-items: flex-start; + gap: var(--espace-2); padding: var(--espace-2) var(--espace-3); - border-left: 4px solid var(--couleur-avis); - background: var(--couleur-page-fond); + border: 1px solid var(--couleur-avis-filet); + border-left: 3px solid var(--couleur-avis); + border-radius: var(--rayon-2); + background: var(--couleur-avis-fond); color: var(--couleur-page-texte); font-size: var(--taille-texte); } + /* Le signe : la hauteur d'une ligne, centré sur elle. */ + .signe { + display: inline-flex; + flex: none; + align-items: center; + height: calc(var(--police-interligne) * 1em); + color: var(--couleur-avis-marque); + } + ul { margin: 0; padding: 0; diff --git a/src/interface/plan/BarreVue.svelte b/src/interface/plan/BarreVue.svelte index b156f29..0dd0b3b 100644 --- a/src/interface/plan/BarreVue.svelte +++ b/src/interface/plan/BarreVue.svelte @@ -25,6 +25,14 @@ // cet état : ouvrir la légende n'écrit rien. Le plein écran est la dernière // commande de vue ; une mise en évidence pose après lui son nom et « ôter ». // + // La barre se lit en groupes, de gauche à droite : le placement montré, le + // tour affiché, le zoom — ses deux boutons et « Ajuster » —, puis + // l'affichage — listes, légende, plein écran —, chacun un role="group" + // nommé, aux boutons accolés dans un même filet ; la mise en évidence + // ferme la barre. Une bascule active, ou la légende dévoilée, se peint de + // la teinte de l'action et garde sa forme distincte : trait intérieur + // épais, texte souligné. + // // source : ce que la barre dit du placement montré, que rend sourceMontree // (vue_plan.js) — {libelle, revenir} — : la barre le nomme, [data-source], // et, quand une proposition est demandée, offre de ne plus la montrer @@ -42,53 +50,78 @@ diff --git a/src/interface/plan/Legende.svelte b/src/interface/plan/Legende.svelte index 70654aa..de4f576 100644 --- a/src/interface/plan/Legende.svelte +++ b/src/interface/plan/Legende.svelte @@ -60,12 +60,15 @@ diff --git a/src/interface/plan/MenuPersonne.svelte b/src/interface/plan/MenuPersonne.svelte index 60b3c3d..11ef0e2 100644 --- a/src/interface/plan/MenuPersonne.svelte +++ b/src/interface/plan/MenuPersonne.svelte @@ -109,12 +109,15 @@ {/if} diff --git a/src/interface/plan/PanneauTable.svelte b/src/interface/plan/PanneauTable.svelte index 751bf93..9e64314 100644 --- a/src/interface/plan/PanneauTable.svelte +++ b/src/interface/plan/PanneauTable.svelte @@ -115,73 +115,109 @@ {#if etatTable !== undefined} -
      +

      {t(nommee.cle, nommee.details)}

      -

      {t('plan.panneauTable.titres', comptes)}

      - {#if etatTable.gelee} -

      {t('plan.liste.gelee')}

      - {/if} -

      {t('plan.panneauTable.sieges')}

      -
        - {#each lignes as parties, rang (rang)} -
      • - +
      • + {/each} +
      + {#if choix !== null} +

      + {t(choix.genre === 'personne' ? 'plan.choix.personne' : 'plan.choix.reservation')}{nomDe(choix.participant)} - {#each parties as { partie, texte } (partie)}{texte}{/each} - -

    • - {/each} -
    - {#if choix !== null} -

    - {t(choix.genre === 'personne' ? 'plan.choix.personne' : 'plan.choix.reservation')}{nomDe(choix.participant)} -

    - {:else} -

    {t('plan.choix.inviter')}

    - {/if} - {#if etat.siegesAttribues} +

    + {:else} +

    {t('plan.choix.inviter')}

    + {/if} + {#if etat.siegesAttribues} + + {/if} - {/if} - -
    - - +
    + + +
    {/if} diff --git a/src/interface/plan/Reserve.svelte b/src/interface/plan/Reserve.svelte index 2bf3968..008342e 100644 --- a/src/interface/plan/Reserve.svelte +++ b/src/interface/plan/Reserve.svelte @@ -44,38 +44,60 @@ -
    +

    {t('plan.reserve.titre')}

    {#if charge !== null} -

    {t('plan.reserve.tour', { tour })}

    - {#if reserve.length === 0} -

    {t('plan.reserve.vide')}

    - {:else} -
      - {#each reserve as id (id)} -
    • - -
    • - {/each} -
    - {/if} +
    +

    {t('plan.reserve.tour', { tour })}

    + {#if reserve.length === 0} +

    {t('plan.reserve.vide')}

    + {:else} +
      + {#each reserve as id (id)} +
    • + +
    • + {/each} +
    + {/if} +
    {/if}
    diff --git a/src/interface/plan/SectionPlan.svelte b/src/interface/plan/SectionPlan.svelte index ad20b19..6bbd28d 100644 --- a/src/interface/plan/SectionPlan.svelte +++ b/src/interface/plan/SectionPlan.svelte @@ -16,6 +16,10 @@ // Le plan se remonte pour chaque événement : un autre événement repart de // sa propre vue, mémorisée ou ajustée. // + // Les panneaux sont des cartes, empilées dans une colonne qui défile seule ; + // la colonne garde un léger retrait, pour que l'ombre des cartes ne soit + // pas rognée par son défilement. + // // En plein écran, le titre et les panneaux s'effacent : le plan prend la // surface que le cadre de mode lui laisse sous le bandeau (§ 18.6), et le // cadre l'entoure toujours (§ 8.4). @@ -99,7 +103,9 @@ flex: 0 0 18rem; flex-direction: column; gap: var(--espace-3); + box-sizing: border-box; min-height: 0; + padding: 0 0.125rem var(--espace-1); overflow-y: auto; } diff --git a/src/interface/plan/vue_plan.js b/src/interface/plan/vue_plan.js index 8e05e9f..736786a 100644 --- a/src/interface/plan/vue_plan.js +++ b/src/interface/plan/vue_plan.js @@ -172,3 +172,42 @@ export function sourceMontree(etat, demandee, retenu) { } throw new TypeError(`source de l’état des places hors du contrat : ${JSON.stringify(etat.source)}`); } + +// 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), + }; +} diff --git a/src/interface/plan/vue_plan.test.js b/src/interface/plan/vue_plan.test.js index d127604..dcc8455 100644 --- a/src/interface/plan/vue_plan.test.js +++ b/src/interface/plan/vue_plan.test.js @@ -28,6 +28,7 @@ import { cadreDepuisRect, englobantDuTour, nomDeCharge, + placerBulle, sourceMontree, tourAffiche, toursOfferts, @@ -304,3 +305,67 @@ describe('sourceMontree : ce que la barre de vue dit du placement montré (§ 2. assert.throws(() => sourceMontree(etat('autre'), null, false), TypeError); }); }); + +describe('placerBulle : une bulle reste dans la zone du plan (§ 7.1, § 7.3)', () => { + // Une zone de 600 × 400 px dont le coin est à (20, 10) : bords droit 620 + // et bas 410. + const ZONE = { gauche: 20, haut: 10, largeur: 600, hauteur: 400 }; + + test('une bulle qui tient à droite et dessous reste à son ancre', () => { + assert.deepEqual(placerBulle({ x: 100, y: 50, xRepli: 60 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 100, y: 50 }); + // Au ras des bords droit et bas, elle tient encore. + assert.deepEqual(placerBulle({ x: 420, y: 310 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 420, y: 310 }); + }); + + test('qui déborde à droite, elle passe à gauche de xRepli, son bord droit sur lui', () => { + assert.deepEqual(placerBulle({ x: 500, y: 50, xRepli: 440 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 240, y: 50 }); + // Sans xRepli, le repli se fait autour de x. + assert.deepEqual(placerBulle({ x: 500, y: 50 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 300, y: 50 }); + }); + + test('qui déborde dessous, elle passe au-dessus de son ancre, son bord bas sur elle', () => { + assert.deepEqual(placerBulle({ x: 100, y: 380 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 100, y: 280 }); + }); + + test('sans place d’un côté ni de l’autre, elle se cale sur le bord opposé, jamais avant le début de la zone', () => { + // 200 px à droite de 500 débordent ; à gauche de 150, il manque 30 px. + assert.deepEqual(placerBulle({ x: 500, y: 50, xRepli: 150 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 420, y: 50 }); + // Plus large que la zone : calée au bord gauche. + assert.deepEqual(placerBulle({ x: 500, y: 50, xRepli: 150 }, { largeur: 700, hauteur: 100 }, ZONE), { x: 20, y: 50 }); + // Plus haute que la zone, sous une ancre basse : calée au bord haut. + assert.deepEqual(placerBulle({ x: 100, y: 300 }, { largeur: 200, hauteur: 500 }, ZONE), { x: 100, y: 10 }); + }); + + test('une ancre avant le bord gauche ou le bord haut ramène la bulle sur ce bord, même quand elle tient', () => { + // L'ancre d'une table coupée par le haut du cadre : au-dessus de la zone. + assert.deepEqual(placerBulle({ x: 100, y: -50 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 100, y: 10 }); + // Au-dessus et à gauche : calée dans le coin haut gauche. + assert.deepEqual(placerBulle({ x: -40, y: -50, xRepli: -90 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 20, y: 10 }); + // Une taille encore inconnue ne la laisse pas non plus hors de la zone. + assert.deepEqual(placerBulle({ x: -40, y: -50 }, { largeur: 0, hauteur: 0 }, ZONE), { x: 20, y: 10 }); + }); + + test('une ancre après le bord droit ou le bord bas ramène la bulle dans la zone, son bord sur ce bord', () => { + // À droite de la zone, son repli encore après le bord : le bord droit + // de la bulle sur celui de la zone, 620 − 50. + assert.deepEqual(placerBulle({ x: 700, y: 50, xRepli: 690 }, { largeur: 50, hauteur: 100 }, ZONE), { x: 570, y: 50 }); + // Sous la zone, 100 px au-dessus de l'ancre ne suffisent pas : le bord + // bas de la bulle sur celui de la zone, 410 − 100. + assert.deepEqual(placerBulle({ x: 100, y: 450 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 100, y: 310 }); + }); + + test('une taille encore inconnue — zéro, avant la première mesure — laisse la bulle à son ancre', () => { + assert.deepEqual(placerBulle({ x: 615, y: 405, xRepli: 580 }, { largeur: 0, hauteur: 0 }, ZONE), { x: 615, y: 405 }); + }); + + test('les arguments ne sont pas modifiés ; un nombre non fini lève', () => { + const ancre = { x: 500, y: 380, xRepli: 440 }; + const taille = { largeur: 200, hauteur: 100 }; + const zone = { ...ZONE }; + placerBulle(ancre, taille, zone); + assert.deepEqual([ancre, taille, zone], [{ x: 500, y: 380, xRepli: 440 }, { largeur: 200, hauteur: 100 }, ZONE]); + assert.throws(() => placerBulle({ x: Number.NaN, y: 0 }, taille, zone), TypeError); + assert.throws(() => placerBulle({ x: 0, y: 0 }, { largeur: Infinity, hauteur: 0 }, zone), TypeError); + assert.throws(() => placerBulle({ x: 0, y: 0 }, taille, { ...ZONE, hauteur: undefined }), TypeError); + }); +});