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