feat(outils): Partir de soi en vue fiches, graphe des sept mots (B6-M2)

Le 3e sous-onglet de /outils remplace le rendu provisoire par VueFiches
(état Mixte : liste des mots, graphe SVG statique, fiche d'un mot avec ses
ressources). L'en-tête se compacte sur cet onglet, la vue prend la hauteur.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
This commit is contained in:
Jules Neny
2026-10-08 16:56:18 +02:00
co-authored by Claude Sonnet
parent 11f24c7925
commit dfbac7dd12
3 changed files with 588 additions and 138 deletions
+314
View File
@@ -0,0 +1,314 @@
<!--
GrapheMots — B6-M2. Le graphe des sept mots de « Partir de soi » : un nœud
central, les mots en couronne (ordre de la slide, sens horaire depuis le
haut), et autour de chaque mot ses ressources en petits nœuds-feuilles.
Vocabulaire du motif maillage (DESIGN-SYSTEM) : nœuds pleins, arêtes fines,
un nœud central mis en avant. Statique et déterministe : pas de simulation,
rien ne bouge ; la mise en page est une fonction pure de la taille mesurée.
Il vit dans le slot #carte de VueFiches et ne connaît que `vf` :
clic / Entrée sur un mot ouvre sa fiche (`vf.ouvrirFiche`), le mot ouvert
(`vf.ficheId`) et le mot survolé dans la liste (`vf.survolId`) s'allument,
les mots hors du résultat (`vf.idsResultat`) s'estompent sans disparaître.
Les feuilles ne sont pas dans l'ordre de tabulation (on y accède par leur
mot) ; leur libellé apparaît au survol.
-->
<template>
<div ref="racine" class="gm">
<svg
class="gm__svg"
:viewBox="`0 0 ${taille.w} ${taille.h}`"
preserveAspectRatio="xMidYMid meet"
role="group"
aria-labelledby="gm-titre"
>
<title id="gm-titre">Graphe des sept mots de « Partir de soi » et des ressources de chacun</title>
<!-- Arêtes : centre → mot, mot → feuilles -->
<g class="gm__aretes" aria-hidden="true">
<template v-for="n in noeuds" :key="`a-${n.id}`">
<line
:x1="centre.x" :y1="centre.y" :x2="n.x" :y2="n.y"
class="gm__arete" :class="{ 'gm__arete--vif': allume(n.id), 'gm__arete--hors': hors(n.id) }"
/>
<line
v-for="f in n.feuilles" :key="`a-${n.id}-${f.id}`"
:x1="n.x" :y1="n.y" :x2="f.x" :y2="f.y"
class="gm__arete gm__arete--feuille" :class="{ 'gm__arete--vif': allume(n.id), 'gm__arete--hors': hors(n.id) }"
/>
</template>
</g>
<!-- Nœud central -->
<g class="gm__centre" aria-hidden="true">
<circle :cx="centre.x" :cy="centre.y" :r="RAYON_CENTRE" class="gm__centre-rond" />
<text :x="centre.x" :y="centre.y - 7" class="gm__centre-texte" text-anchor="middle">Partir</text>
<text :x="centre.x" :y="centre.y + 8" class="gm__centre-texte" text-anchor="middle">de soi</text>
</g>
<!-- Feuilles (ressources) -->
<g v-for="n in noeuds" :key="`f-${n.id}`" class="gm__feuilles" :class="{ 'gm__feuilles--hors': hors(n.id) }">
<circle
v-for="f in n.feuilles" :key="f.id"
:cx="f.x" :cy="f.y" r="5.5"
class="gm__feuille" :class="{ 'gm__feuille--vive': allume(n.id) || survolFeuille === `${n.id}/${f.id}` }"
@click="vf.ouvrirFiche(n.id)"
@mouseenter="survolerFeuille(n.id, f.id)"
@mouseleave="survolerFeuille(null, null)"
>
<title>{{ f.titre }}</title>
</circle>
</g>
<!-- Mots -->
<g
v-for="n in noeuds"
:key="n.id"
class="gm__mot"
:class="{ 'gm__mot--actif': allume(n.id), 'gm__mot--hors': hors(n.id), 'gm__mot--vide': n.vide }"
role="button"
tabindex="0"
:aria-label="n.aria"
:aria-pressed="actif(n.id)"
@click="vf.ouvrirFiche(n.id)"
@keydown.enter.prevent="vf.ouvrirFiche(n.id)"
@keydown.space.prevent="vf.ouvrirFiche(n.id)"
@mouseenter="vf.survolId = n.id"
@mouseleave="vf.survolId = null"
@focus="vf.survolId = n.id"
@blur="vf.survolId = null"
>
<rect
:x="n.x - n.l / 2" :y="n.y - n.h / 2" :width="n.l" :height="n.h" :rx="n.h / 2"
class="gm__pilule"
/>
<text :x="n.x" :y="n.y" text-anchor="middle" class="gm__libelle" aria-hidden="true">
<tspan v-for="(ligne, i) in n.lignes" :key="i" :x="n.x" :y="n.y + (i - (n.lignes.length - 1) / 2) * INTERLIGNE + 4.5">{{ ligne }}</tspan>
</text>
</g>
<!-- Libellé de la feuille survolée, au-dessus de tout -->
<g v-if="bulle" class="gm__bulle" aria-hidden="true">
<rect :x="bulle.x - bulle.l / 2" :y="bulle.y - 11" :width="bulle.l" height="22" rx="6" class="gm__bulle-fond" />
<text :x="bulle.x" :y="bulle.y + 4" text-anchor="middle" class="gm__bulle-texte">{{ bulle.texte }}</text>
</g>
</svg>
</div>
</template>
<script setup lang="ts">
import type { VueFichesEtat } from '~/composables/useVueFiches'
const props = defineProps<{
vf: VueFichesEtat
mots: { id: string; mot: string; ressources: { id: string; titre: string }[]; vide?: boolean }[]
}>()
const RAYON_CENTRE = 36
const INTERLIGNE = 15
const CARACTERES_LIGNE = 16
const PORTEE_FEUILLES = 46
const MARGE = 10
// ── Taille : mesurée au montage (le rendu serveur part d'une taille plausible) ──
const racine = ref<HTMLElement | null>(null)
const taille = reactive({ w: 720, h: 600 })
onMounted(() => {
const el = racine.value
if (!el || typeof ResizeObserver === 'undefined') return
const maj = (w: number, h: number) => {
if (w > 40 && h > 40) { taille.w = Math.round(w); taille.h = Math.round(h) }
}
maj(el.clientWidth, el.clientHeight)
const ro = new ResizeObserver((entrees) => {
const r = entrees[0]?.contentRect
if (r) maj(r.width, r.height)
})
ro.observe(el)
onBeforeUnmount(() => ro.disconnect())
})
// ── Mise en page : fonction pure de (taille, mots) ──
/** Une ligne si ça tient, sinon deux lignes équilibrées (« naviguer / avec soi », pas « naviguer avec / soi »). */
function couper(texte: string): string[] {
if (texte.length <= CARACTERES_LIGNE) return [texte]
const mots = texte.split(' ')
let meilleur: string[] = [texte]
let pire = Infinity
for (let i = 1; i < mots.length; i++) {
const l = [mots.slice(0, i).join(' '), mots.slice(i).join(' ')]
const m = Math.max(l[0].length, l[1].length)
if (m < pire) { pire = m; meilleur = l }
}
return meilleur
}
function tronquer(t: string, max = 44): string {
return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t
}
const centre = computed(() => ({ x: taille.w / 2, y: taille.h / 2 }))
const noeuds = computed(() => {
const n = props.mots.length
const { w, h } = taille
const base = props.mots.map((m) => {
const lignes = couper(m.mot)
return { m, lignes, l: Math.max(...lignes.map((x) => x.length)) * 6.9 + 24, h: lignes.length * INTERLIGNE + 11 }
})
const lMax = Math.max(...base.map((b) => b.l))
const hMax = Math.max(...base.map((b) => b.h))
const rx = Math.max(48, w / 2 - lMax / 2 - PORTEE_FEUILLES - MARGE)
const ry = Math.max(70, h / 2 - hMax / 2 - PORTEE_FEUILLES - MARGE)
const cx = centre.value.x
const cy = centre.value.y
// Colonne étroite : la couronne ne tient plus (les mots du bas se chevaucheraient).
// Les mots s'étagent alors en zigzag autour du centre, une rangée chacun, dans le
// même sens de lecture (haut, puis la droite en descendant, puis la gauche en remontant),
// et leurs ressources se rangent SOUS le mot.
const etroit = rx < lMax * 1.3
let place: { x: number; y: number }[]
if (etroit) {
const droite = Math.ceil((n - 1) / 2)
const rangsDroite = Array.from({ length: droite }, (_, i) => 1 + 2 * i)
const rangsGauche = Array.from({ length: n }, (_, i) => i).filter((r) => r > 0 && !rangsDroite.includes(r)).reverse()
const rang = (i: number) => (i === 0 ? 0 : i <= droite ? rangsDroite[i - 1] : rangsGauche[i - droite - 1])
const y0 = hMax / 2 + MARGE
const y1 = h - hMax / 2 - MARGE - 24 // de la place sous le dernier mot pour ses feuilles
const pas = n > 1 ? (y1 - y0) / (n - 1) : 0
const dx = Math.max(0, w / 2 - lMax / 2 - 6)
place = base.map((_, i) => ({ x: i === 0 ? cx : i <= droite ? cx + dx : cx - dx, y: y0 + rang(i) * pas }))
} else {
place = base.map((_, i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / n // depuis le haut, sens horaire
return { x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a) }
})
}
return base.map((b, i) => {
const { x, y } = place[i]
const k = b.m.ressources.length
let feuilles: { id: string; titre: string; x: number; y: number }[]
if (etroit) {
const ecart = k > 1 ? Math.min(17, (b.l - 20) / (k - 1)) : 0
feuilles = b.m.ressources.map((r, j) => ({
id: r.id,
titre: r.titre,
x: x + (j - (k - 1) / 2) * ecart,
y: y + b.h / 2 + 13 + (j % 2 ? 9 : 0),
}))
} else {
// direction « vers l'extérieur » réelle (l'ellipse n'est pas un cercle)
const dir = Math.atan2(y - cy, x - cx)
const pas = Math.min(0.55, 2.3 / Math.max(k, 1))
const dist = Math.abs(Math.cos(dir)) * (b.l / 2) + Math.abs(Math.sin(dir)) * (b.h / 2) + 20
feuilles = b.m.ressources.map((r, j) => {
const t = dir + (j - (k - 1) / 2) * pas
const d = dist + (j % 2 ? 12 : 0)
return {
id: r.id,
titre: r.titre,
x: Math.min(w - 8, Math.max(8, x + d * Math.cos(t))),
y: Math.min(h - 8, Math.max(8, y + d * Math.sin(t))),
}
})
}
return {
id: b.m.id,
x, y, l: b.l, h: b.h, lignes: b.lignes,
vide: !!b.m.vide || k === 0,
aria: `${b.m.mot}, ${k === 0 ? 'aucune ressource pour l’instant' : k === 1 ? '1 ressource' : `${k} ressources`}`,
feuilles,
}
})
})
// ── États (tout vient de vf) ──
const actif = (id: string) => String(props.vf.ficheId ?? '') === id
const survol = (id: string) => props.vf.survolId != null && String(props.vf.survolId) === id
const allume = (id: string) => actif(id) || survol(id)
const hors = (id: string) => !props.vf.idsResultat.has(id)
const survolFeuille = ref<string | null>(null)
function survolerFeuille(mot: string | null, feuille: string | null) {
survolFeuille.value = mot && feuille ? `${mot}/${feuille}` : null
props.vf.survolId = mot
}
const bulle = computed(() => {
if (!survolFeuille.value) return null
const [mot, feuille] = survolFeuille.value.split('/')
const f = noeuds.value.find((n) => n.id === mot)?.feuilles.find((x) => x.id === feuille)
if (!f) return null
const texte = tronquer(f.titre)
const l = texte.length * 6.3 + 16
const x = Math.min(taille.w - l / 2 - 4, Math.max(l / 2 + 4, f.x))
const y = f.y < 30 ? f.y + 24 : f.y - 18
return { x, y, l, texte }
})
</script>
<style scoped>
.gm {
position: relative;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
background: var(--bg-alt);
}
.gm__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
font-family: inherit;
}
/* Arêtes fines */
.gm__arete { stroke: var(--nav-primary-solid); stroke-width: 1; stroke-opacity: 0.35; }
.gm__arete--feuille { stroke-opacity: 0.28; }
.gm__arete--vif { stroke-width: 2; stroke-opacity: 0.8; }
.gm__arete--hors { stroke-opacity: 0.12; }
/* Nœud central, mis en avant */
.gm__centre-rond { fill: var(--nav-primary-solid); stroke: var(--accent); stroke-width: 3; }
.gm__centre-texte { fill: var(--nav-text-on-primary); font-size: 12px; font-weight: 700; }
/* Feuilles : petits nœuds pleins */
.gm__feuille {
fill: var(--nav-primary-solid);
stroke: var(--bg-alt);
stroke-width: 1.5;
cursor: pointer;
transition: fill 120ms ease-out;
}
.gm__feuille--vive { fill: var(--accent); stroke: var(--nav-primary-solid); }
.gm__feuilles--hors { opacity: 0.3; }
/* Mots : pastilles qui portent leur libellé */
.gm__mot { cursor: pointer; outline: none; transition: opacity 120ms ease-out; }
.gm__pilule {
fill: var(--surface);
stroke: var(--nav-primary-solid);
stroke-width: 2;
transition: fill 120ms ease-out, stroke 120ms ease-out;
}
.gm__libelle { fill: var(--text); font-size: 12.5px; font-weight: 600; pointer-events: none; }
.gm__mot--vide .gm__pilule { stroke-dasharray: 5 4; }
.gm__mot--actif .gm__pilule { fill: var(--accent); stroke: var(--nav-primary-solid); }
.gm__mot--actif .gm__libelle { fill: var(--on-accent); }
.gm__mot--hors { opacity: 0.4; }
.gm__mot:focus-visible .gm__pilule { stroke: var(--focus-ring); stroke-width: 4; }
/* Libellé de feuille */
.gm__bulle { pointer-events: none; }
.gm__bulle-fond { fill: var(--surface); stroke: var(--nav-primary-solid); stroke-width: 1; }
.gm__bulle-texte { fill: var(--text); font-size: 11.5px; font-weight: 500; }
@media (prefers-reduced-motion: reduce) {
.gm__feuille, .gm__mot, .gm__pilule { transition: none; }
}
</style>