Files
nav-carte/components/GrapheMots.vue
T
Jules NenyandClaude Sonnet dfbac7dd12 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
2026-10-08 16:56:18 +02:00

315 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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>