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:
@@ -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 libellé 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user