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_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. */
const PLANETARY_BOUNDARIES = [
'Changement climatique',
'Acidification des océans',
'Pollution chimique',
'Cycles azote & phosphore',
'Usage des sols',
'Eau douce',
'Biodiversité',
"Pollution de l'air",
"Couche d'ozone",
const PLANETARY_BOUNDARIES: readonly Label[] = [
['Changement climatique'],
['Acidification des océans'],
['Pollution chimique'],
['Cycles azote & phosphore'],
['Usage des sols'],
['Eau douce'],
['Biodiversité'],
["Pollution de l'air"],
["Couche d'ozone"],
]
/** Les 12 dimensions du plancher social de Kate Raworth, placées entre le centre et l'anneau intérieur. */
const SOCIAL_FOUNDATIONS = [
'Alimentation',
'Santé',
'Éducation',
'Revenu & travail',
'Eau',
'Énergie',
'Liens sociaux',
'Logement',
'Égalité de genre',
'Équité sociale',
'Voix politique',
'Paix & justice',
/** Les 12 dimensions du plancher social de Raworth. Beaucoup sont sur 2 lignes
* car leur largeur dépasse l'arc disponible à R_SOCIAL_LABEL. */
const SOCIAL_FOUNDATIONS: readonly Label[] = [
['Alimentation'],
['Santé'],
['Éducation'],
['Revenu', 'travail'],
['Eau'],
['Énergie'],
['Liens', 'sociaux'],
['Logement'],
['Égalité', 'de genre'],
['Équité', 'sociale'],
['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>).
* Le texte épouse alors la courbure de l'anneau au lieu d'être posé en ligne droite.
* Génère pour chaque libel un (ou plusieurs) petit arc(s) de cercle.
* 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é
* du cercle pour que les labels du bas restent lisibles à l'endroit. Sans ça,
* les labels du bas seraient écrits à l'envers ou inversés (lus de droite à gauche).
* Lisibilité haut/bas : on inverse le sens du path (CW vs CCW) selon la moitié
* du cercle. Sans ça, les labels du bas seraient écrits à l'envers.
*
* - Moitié haute (angles proches de 0°) : path va de θ-δ à θ+δ, sweep=1 (CW visuellement)
* → direction RIGHT au sommet → texte "left of right" = UP = OUTSIDE
* - Moitié basse (angles proches de 180°) : path va de θ+δ à θ-δ, sweep=0 (CCW visuellement)
* → direction RIGHT au bas → texte "left of right" = UP = vers le CENTRE
* (= lisible à l'endroit pour un lecteur fixe en face de l'écran)
* - Moitié haute : path va de θ-δ à θ+δ, sweep=1 (CW visuellement)
* → direction tangente vers la droite au sommet, "above the path" = OUTSIDE
* - Moitié basse : path va de θ+δ à θ-δ, sweep=0 (CCW visuellement)
* → direction tangente vers la droite au bas (du POV du lecteur fixe en face)
*
* @param labels Liste des libellés
* @param radius Rayon de l'arc de chaque label
* @param idPrefix Préfixe pour générer les `id` des paths (chaque path doit être unique en DOM)
* @param halfSpanDeg Demi-largeur angulaire du path (= halfSpan ≈ 90 % du segment alloué au label)
* Multi-lignes : chaque ligne a son propre arc à un rayon décalé. Pour qu'une
* ligne donnée apparaisse toujours "au-dessus" visuellement (que ce soit en
* haut ou en bas du donut), on inverse aussi le décalage radial selon la moitié :
*
* - 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 stepDeg = 360 / n
return labels.map((label, i) => {
return labels.map((entry, i) => {
const centerDeg = i * stepDeg + stepDeg / 2
const startDeg = 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 isTopHalf = normalized <= 90 || normalized >= 270
@@ -130,11 +155,26 @@ function arcLabelPaths(labels: readonly string[], radius: number, idPrefix: stri
const toDeg = isTopHalf ? endDeg : startDeg
const sweep = isTopHalf ? 1 : 0
const [x1, y1] = pt(fromDeg, radius)
const [x2, y2] = pt(toDeg, radius)
const d = `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${radius} ${radius} 0 0 ${sweep} ${x2.toFixed(2)} ${y2.toFixed(2)}`
const nLines = entry.length
const halfSpan = (nLines - 1) / 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). */
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 {
project: ProjectListRow
x: number
@@ -324,14 +368,15 @@ function gotoProject(p: ProjectListRow) {
<rect width="6" height="6" fill="#ECECE7" />
<line x1="0" y1="0" x2="0" y2="6" stroke="#D1D5DB" stroke-width="0.9" />
</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
v-for="p in planetaryArcs" :key="p.pathId"
:id="p.pathId" :d="p.d" fill="none"
v-for="ln in planetaryArcLines" :key="ln.pathId"
:id="ln.pathId" :d="ln.d" fill="none"
/>
<path
v-for="s in socialArcs" :key="s.pathId"
:id="s.pathId" :d="s.d" fill="none"
v-for="ln in socialArcLines" :key="ln.pathId"
:id="ln.pathId" :d="ln.d" fill="none"
/>
</defs>
@@ -342,11 +387,11 @@ function gotoProject(p: ProjectListRow) {
<!-- 2) Étiquettes des 9 LIMITES PLANÉTAIRES, le long de leur arc -->
<g>
<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"
>
<textPath :href="`#${p.pathId}`" startOffset="50%" text-anchor="middle">
{{ p.label }}
<textPath :href="`#${ln.pathId}`" startOffset="50%" text-anchor="middle">
{{ ln.text }}
</textPath>
</text>
</g>
@@ -392,13 +437,15 @@ function gotoProject(p: ProjectListRow) {
</g>
<!-- 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>
<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"
>
<textPath :href="`#${s.pathId}`" startOffset="50%" text-anchor="middle">
{{ s.label }}
<textPath :href="`#${ln.pathId}`" startOffset="50%" text-anchor="middle">
{{ ln.text }}
</textPath>
</text>
</g>