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); + }); +});