feat(donut): libellés du plancher social sur 2 lignes (arc multi-niveaux)

Beaucoup de dimensions Raworth dépassent l'arc disponible à R=88 :
« Revenu & travail », « Liens sociaux », « Égalité de genre »,
« Équité sociale », « Voix politique », « Paix & justice ». Posées en
une seule ligne, le texte débordait ou était illisible.

Solution : permettre 2 lignes par libellé, chaque ligne ayant son propre
arc à un rayon décalé. Du coup ces 6 dimensions sont séparées en 2 mots
empilés radialement. Les 6 autres (Alimentation, Santé, Éducation, Eau,
Énergie, Logement) restent sur une seule ligne.

Refactor :
  - Type `Label = readonly string[]` (1..N lignes)
  - PLANETARY_BOUNDARIES et SOCIAL_FOUNDATIONS unifiés au format tableau
    de tableaux (planétaires restent toutes à 1 ligne, sociales mixtes)
  - arcLabelPaths() gère le multi-lignes : une ArcLine par ligne, avec
    décalage radial de ±lineSpacing/2
  - lineSpacing = 9u (légèrement > font-size 8 pour éviter le chevauchement)
  - Inversion du décalage radial selon la moitié du donut pour que la
    ligne "primaire" reste toujours du côté lecteur (haut de l'écran) :
        top : ligne 0 = R+offset (extérieur)
        bot : ligne 0 = R-offset (intérieur)
  - Aplatissement via flatMap pour rendre comme une flat-list dans le
    SVG (pas de `<template>` imbriqué)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 22:34:20 +02:00
parent 1d4910a940
commit 8fc8c21b7e
+103 -56
View File
@@ -66,63 +66,88 @@ const R_FLOOR_INNER = 102 // ↑ (était 78)
const R_SOCIAL_LABEL = 88 // ↑ (était 67) — arc de label = 2π·88/12 ≈ 46u const R_SOCIAL_LABEL = 88 // ↑ (était 67) — arc de label = 2π·88/12 ≈ 46u
const R_CENTER = 55 // ↑ légèrement pour ne pas écraser "LE DONUT" const R_CENTER = 55 // ↑ légèrement pour ne pas écraser "LE DONUT"
/**
* Format : chaque entrée est un tableau de string. Un tableau à 1 élément →
* libellé sur une ligne. À 2 éléments → ligne 1 (primaire) + ligne 2 (suite).
* On garde un type uniforme pour simplifier le helper qui les rend.
*/
type Label = readonly string[]
/** Les 9 limites planétaires de Kate Raworth, placées autour de l'anneau extérieur. */ /** Les 9 limites planétaires de Kate Raworth, placées autour de l'anneau extérieur. */
const PLANETARY_BOUNDARIES = [ const PLANETARY_BOUNDARIES: readonly Label[] = [
'Changement climatique', ['Changement climatique'],
'Acidification des océans', ['Acidification des océans'],
'Pollution chimique', ['Pollution chimique'],
'Cycles azote & phosphore', ['Cycles azote & phosphore'],
'Usage des sols', ['Usage des sols'],
'Eau douce', ['Eau douce'],
'Biodiversité', ['Biodiversité'],
"Pollution de l'air", ["Pollution de l'air"],
"Couche d'ozone", ["Couche d'ozone"],
] ]
/** Les 12 dimensions du plancher social de Kate Raworth, placées entre le centre et l'anneau intérieur. */ /** Les 12 dimensions du plancher social de Raworth. Beaucoup sont sur 2 lignes
const SOCIAL_FOUNDATIONS = [ * car leur largeur dépasse l'arc disponible à R_SOCIAL_LABEL. */
'Alimentation', const SOCIAL_FOUNDATIONS: readonly Label[] = [
'Santé', ['Alimentation'],
'Éducation', ['Santé'],
'Revenu & travail', ['Éducation'],
'Eau', ['Revenu', 'travail'],
'Énergie', ['Eau'],
'Liens sociaux', ['Énergie'],
'Logement', ['Liens', 'sociaux'],
'Égalité de genre', ['Logement'],
'Équité sociale', ['Égalité', 'de genre'],
'Voix politique', ['Équité', 'sociale'],
'Paix & justice', ['Voix', 'politique'],
['Paix', 'justice'],
] ]
interface ArcLine {
pathId: string
d: string
text: string
}
/** /**
* Génère pour chaque label un petit arc de cercle (utilisé par <textPath>). * Génère pour chaque libel un (ou plusieurs) petit arc(s) de cercle.
* Le texte épouse alors la courbure de l'anneau au lieu d'être posé en ligne droite. * Le texte épouse alors la courbure de l'anneau via <textPath>.
* *
* Astuce de lisibilité : on inverse le sens du path (CW vs CCW) selon la moitié * Lisibilité haut/bas : on inverse le sens du path (CW vs CCW) selon la moitié
* du cercle pour que les labels du bas restent lisibles à l'endroit. Sans ça, * du cercle. Sans ça, les labels du bas seraient écrits à l'envers.
* les labels du bas seraient écrits à l'envers ou inversés (lus de droite à gauche).
* *
* - Moitié haute (angles proches de 0°) : path va de θ-δ à θ+δ, sweep=1 (CW visuellement) * - Moitié haute : path va de θ-δ à θ+δ, sweep=1 (CW visuellement)
* → direction RIGHT au sommet → texte "left of right" = UP = OUTSIDE * → direction tangente vers la droite au sommet, "above the path" = OUTSIDE
* - Moitié basse (angles proches de 180°) : path va de θ+δ à θ-δ, sweep=0 (CCW visuellement) * - Moitié basse : path va de θ+δ à θ-δ, sweep=0 (CCW visuellement)
* → direction RIGHT au bas → texte "left of right" = UP = vers le CENTRE * → direction tangente vers la droite au bas (du POV du lecteur fixe en face)
* (= lisible à l'endroit pour un lecteur fixe en face de l'écran)
* *
* @param labels Liste des libellés * Multi-lignes : chaque ligne a son propre arc à un rayon décalé. Pour qu'une
* @param radius Rayon de l'arc de chaque label * ligne donnée apparaisse toujours "au-dessus" visuellement (que ce soit en
* @param idPrefix Préfixe pour générer les `id` des paths (chaque path doit être unique en DOM) * haut ou en bas du donut), on inverse aussi le décalage radial selon la moitié :
* @param halfSpanDeg Demi-largeur angulaire du path (= halfSpan ≈ 90 % du segment alloué au label) *
* - En haut, ligne 0 = R + offset (loin du centre = haut de l'écran)
* - En bas, ligne 0 = R - offset (proche du centre = haut de l'écran)
*
* @param labels Liste des libellés (chacun = tableau de lignes)
* @param radius Rayon central de l'arc
* @param idPrefix Préfixe pour les `id` des paths (unique en DOM)
* @param halfSpanDeg Demi-largeur angulaire de chaque label
* @param lineSpacing Espacement radial entre 2 lignes (en unités viewBox)
*/ */
function arcLabelPaths(labels: readonly string[], radius: number, idPrefix: string, halfSpanDeg: number) { function arcLabelPaths(
labels: readonly Label[],
radius: number,
idPrefix: string,
halfSpanDeg: number,
lineSpacing = 9,
): Array<{ key: number; lines: ArcLine[] }> {
const n = labels.length const n = labels.length
const stepDeg = 360 / n const stepDeg = 360 / n
return labels.map((label, i) => { return labels.map((entry, i) => {
const centerDeg = i * stepDeg + stepDeg / 2 const centerDeg = i * stepDeg + stepDeg / 2
const startDeg = centerDeg - halfSpanDeg const startDeg = centerDeg - halfSpanDeg
const endDeg = centerDeg + halfSpanDeg const endDeg = centerDeg + halfSpanDeg
// Top half ⇨ chemin CW (sweep=1) ; bottom half ⇨ chemin CCW (sweep=0).
const normalized = ((centerDeg % 360) + 360) % 360 const normalized = ((centerDeg % 360) + 360) % 360
const isTopHalf = normalized <= 90 || normalized >= 270 const isTopHalf = normalized <= 90 || normalized >= 270
@@ -130,11 +155,26 @@ function arcLabelPaths(labels: readonly string[], radius: number, idPrefix: stri
const toDeg = isTopHalf ? endDeg : startDeg const toDeg = isTopHalf ? endDeg : startDeg
const sweep = isTopHalf ? 1 : 0 const sweep = isTopHalf ? 1 : 0
const [x1, y1] = pt(fromDeg, radius) const nLines = entry.length
const [x2, y2] = pt(toDeg, radius) const halfSpan = (nLines - 1) / 2
const d = `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${radius} ${radius} 0 0 ${sweep} ${x2.toFixed(2)} ${y2.toFixed(2)}`
return { label, pathId: `${idPrefix}-${i}`, d, key: i } const lines: ArcLine[] = entry.map((text, lineIdx) => {
// offsetIdx : +halfSpan pour la ligne du haut visuel, -halfSpan pour la ligne du bas
const offsetIdx = isTopHalf
? halfSpan - lineIdx // top : ligne 0 au plus éloigné du centre
: lineIdx - halfSpan // bot : ligne 0 au plus proche du centre
const r = radius + offsetIdx * lineSpacing
const [x1, y1] = pt(fromDeg, r)
const [x2, y2] = pt(toDeg, r)
return {
pathId: `${idPrefix}-${i}-${lineIdx}`,
d: `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r.toFixed(2)} ${r.toFixed(2)} 0 0 ${sweep} ${x2.toFixed(2)} ${y2.toFixed(2)}`,
text,
}
})
return { key: i, lines }
}) })
} }
@@ -210,6 +250,10 @@ const planetaryArcs = computed(() => arcLabelPaths(PLANETARY_BOUNDARIES, R_PLANE
/** Paths d'arc pour les 12 dimensions du plancher social (anneau intérieur). */ /** Paths d'arc pour les 12 dimensions du plancher social (anneau intérieur). */
const socialArcs = computed(() => arcLabelPaths(SOCIAL_FOUNDATIONS, R_SOCIAL_LABEL, 'sf', 14)) const socialArcs = computed(() => arcLabelPaths(SOCIAL_FOUNDATIONS, R_SOCIAL_LABEL, 'sf', 14))
/** Aplatissement (label × ligne) pour rendre proprement en flat-list dans le SVG. */
const planetaryArcLines = computed(() => planetaryArcs.value.flatMap(l => l.lines))
const socialArcLines = computed(() => socialArcs.value.flatMap(l => l.lines))
interface DotView { interface DotView {
project: ProjectListRow project: ProjectListRow
x: number x: number
@@ -324,14 +368,15 @@ function gotoProject(p: ProjectListRow) {
<rect width="6" height="6" fill="#ECECE7" /> <rect width="6" height="6" fill="#ECECE7" />
<line x1="0" y1="0" x2="0" y2="6" stroke="#D1D5DB" stroke-width="0.9" /> <line x1="0" y1="0" x2="0" y2="6" stroke="#D1D5DB" stroke-width="0.9" />
</pattern> </pattern>
<!-- Arcs invisibles pour textPath : un par limite planétaire et par dimension sociale --> <!-- Arcs invisibles pour textPath : un par LIGNE de label (1 ligne par limite
planétaire, 1 à 2 lignes par dimension sociale selon la longueur). -->
<path <path
v-for="p in planetaryArcs" :key="p.pathId" v-for="ln in planetaryArcLines" :key="ln.pathId"
:id="p.pathId" :d="p.d" fill="none" :id="ln.pathId" :d="ln.d" fill="none"
/> />
<path <path
v-for="s in socialArcs" :key="s.pathId" v-for="ln in socialArcLines" :key="ln.pathId"
:id="s.pathId" :d="s.d" fill="none" :id="ln.pathId" :d="ln.d" fill="none"
/> />
</defs> </defs>
@@ -342,11 +387,11 @@ function gotoProject(p: ProjectListRow) {
<!-- 2) Étiquettes des 9 LIMITES PLANÉTAIRES, le long de leur arc --> <!-- 2) Étiquettes des 9 LIMITES PLANÉTAIRES, le long de leur arc -->
<g> <g>
<text <text
v-for="p in planetaryArcs" :key="`pb-text-${p.key}`" v-for="ln in planetaryArcLines" :key="`pb-text-${ln.pathId}`"
font-size="8" fill="#16A34A" font-weight="600" font-size="8" fill="#16A34A" font-weight="600"
> >
<textPath :href="`#${p.pathId}`" startOffset="50%" text-anchor="middle"> <textPath :href="`#${ln.pathId}`" startOffset="50%" text-anchor="middle">
{{ p.label }} {{ ln.text }}
</textPath> </textPath>
</text> </text>
</g> </g>
@@ -392,13 +437,15 @@ function gotoProject(p: ProjectListRow) {
</g> </g>
<!-- 8) Étiquettes des 12 DIMENSIONS DU PLANCHER SOCIAL, le long de leur arc --> <!-- 8) Étiquettes des 12 DIMENSIONS DU PLANCHER SOCIAL, le long de leur arc -->
<!-- Libellés longs (« Revenu / travail », « Égalité / de genre », ) sur 2 lignes
radiales : chaque ligne a son propre arc, lineSpacing=9u du helper. -->
<g> <g>
<text <text
v-for="s in socialArcs" :key="`sf-text-${s.key}`" v-for="ln in socialArcLines" :key="`sf-text-${ln.pathId}`"
font-size="8" fill="#EA580C" font-weight="600" font-size="8" fill="#EA580C" font-weight="600"
> >
<textPath :href="`#${s.pathId}`" startOffset="50%" text-anchor="middle"> <textPath :href="`#${ln.pathId}`" startOffset="50%" text-anchor="middle">
{{ s.label }} {{ ln.text }}
</textPath> </textPath>
</text> </text>
</g> </g>