feat(donut): texte en arc de cercle + 12 dimensions du plancher social

Limites planétaires + dimensions sociales : labels qui épousent vraiment la
courbure de l'anneau au lieu d'être posés en ligne droite tangente. Implémenté
avec SVG <textPath> + un <path> caché par label.

Petit piège : pour qu'un label en bas du donut reste lisible (pas écrit à
l'envers ou de droite à gauche), il faut INVERSER la direction du path selon
la moitié.

  Helper arcLabelPaths() :
    - Moitié haute (angles ~0°)   : path va de θ-δ vers θ+δ, sweep=1
    - Moitié basse (angles ~180°) : path va de θ+δ vers θ-δ, sweep=0
  → la direction du chemin au point central du label est toujours "horizontale
    à gauche-droite" du point de vue d'un lecteur fixe.

Ajouts :
  - 12 dimensions du plancher social Raworth (Alimentation, Santé, Éducation,
    Revenu & travail, Eau, Énergie, Liens sociaux, Logement, Égalité de genre,
    Équité sociale, Voix politique, Paix & justice) placées en arc entre le
    centre et l'anneau intérieur (rayon 67)
  - R_CENTER passé de 56 à 50 pour libérer 6 unités au profit des labels
    sociaux sans toucher au "LE DONUT / X projets" au centre
  - Suppression des textes centrés "PLAFOND ÉCOLOGIQUE" et "PLANCHER SOCIAL"
    sur les anneaux (redondants avec les labels qui les détaillent)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 22:26:00 +02:00
parent 362b3c5712
commit 986241dc85
+94 -35
View File
@@ -58,7 +58,8 @@ const R_CEILING_OUTER = 225
const R_CEILING_INNER = 200 const R_CEILING_INNER = 200
const R_FLOOR_OUTER = 100 const R_FLOOR_OUTER = 100
const R_FLOOR_INNER = 78 const R_FLOOR_INNER = 78
const R_CENTER = 56 const R_SOCIAL_LABEL = 67 // étiquettes des dimensions du plancher social (intérieur)
const R_CENTER = 50
/** 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 = [
@@ -73,6 +74,65 @@ const PLANETARY_BOUNDARIES = [
"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. */
const SOCIAL_FOUNDATIONS = [
'Alimentation',
'Santé',
'Éducation',
'Revenu & travail',
'Eau',
'Énergie',
'Liens sociaux',
'Logement',
'Égalité de genre',
'Équité sociale',
'Voix politique',
'Paix & justice',
]
/**
* 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.
*
* 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).
*
* - 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)
*
* @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)
*/
function arcLabelPaths(labels: readonly string[], radius: number, idPrefix: string, halfSpanDeg: number) {
const n = labels.length
const stepDeg = 360 / n
return labels.map((label, 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
const fromDeg = isTopHalf ? startDeg : endDeg
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)}`
return { label, pathId: `${idPrefix}-${i}`, d, key: i }
})
}
/** deg → rad avec offset -90° (0° en haut). */ /** deg → rad avec offset -90° (0° en haut). */
function rad(deg: number): number { function rad(deg: number): number {
return (deg - 90) * Math.PI / 180 return (deg - 90) * Math.PI / 180
@@ -139,27 +199,11 @@ const categoryLabels = computed(() => {
}) })
}) })
/** Étiquettes des 9 limites planétaires, autour de l'anneau extérieur. /** Paths d'arc pour les 9 limites planétaires (anneau extérieur). */
* const planetaryArcs = computed(() => arcLabelPaths(PLANETARY_BOUNDARIES, R_PLANET_LABEL, 'pb', 18))
* Texte tangent à la circonférence + flip 180° dans la moitié inférieure
* pour rester lisible (sinon le label serait à l'envers en bas du donut). /** Paths d'arc pour les 12 dimensions du plancher social (anneau intérieur). */
* const socialArcs = computed(() => arcLabelPaths(SOCIAL_FOUNDATIONS, R_SOCIAL_LABEL, 'sf', 14))
* - angle 0 (haut) : rotation 0 → texte horizontal lisible
* - angle 90 (droite) : rotation 90 → texte vertical lisible de haut en bas
* - angle 180 (bas) : rotation 180+180 = 0 → texte horizontal lisible
* - angle 270 (gauche) : rotation 270 → texte vertical lisible de bas en haut
*/
const planetaryLabels = computed(() => {
const n = PLANETARY_BOUNDARIES.length
const step = 360 / n
return PLANETARY_BOUNDARIES.map((label, i) => {
const angle = i * step + step / 2
const [x, y] = pt(angle, R_PLANET_LABEL)
let rotation = angle
if (angle > 90 && angle < 270) rotation += 180
return { label, x, y, rotation, key: i }
})
})
interface DotView { interface DotView {
project: ProjectListRow project: ProjectListRow
@@ -274,22 +318,31 @@ 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 -->
<path
v-for="p in planetaryArcs" :key="p.pathId"
:id="p.pathId" :d="p.d" fill="none"
/>
<path
v-for="s in socialArcs" :key="s.pathId"
:id="s.pathId" :d="s.d" fill="none"
/>
</defs> </defs>
<!-- 1) ZONE D'OVERSHOOT (au-delà du plafond) --> <!-- 1) ZONE D'OVERSHOOT (au-delà du plafond) -->
<!-- fond cream clair pour soutenir les labels limites planétaires --> <!-- fond cream clair pour soutenir les labels limites planétaires -->
<circle r="270" fill="#F5F2EA" /> <circle r="270" fill="#F5F2EA" />
<!-- 2) Étiquettes des 9 LIMITES PLANÉTAIRES, autour de l'anneau extérieur --> <!-- 2) Étiquettes des 9 LIMITES PLANÉTAIRES, le long de leur arc -->
<g> <g>
<g <text
v-for="p in planetaryLabels" :key="p.key" v-for="p in planetaryArcs" :key="`pb-text-${p.key}`"
:transform="`translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) rotate(${p.rotation.toFixed(2)})`" font-size="8" fill="#16A34A" font-weight="600"
> >
<text text-anchor="middle" font-size="8" fill="#22C55E" font-weight="600"> <textPath :href="`#${p.pathId}`" startOffset="50%" text-anchor="middle">
{{ p.label }} {{ p.label }}
</text> </textPath>
</g> </text>
</g> </g>
<!-- 3) Anneau extérieur PLAFOND ÉCOLOGIQUE (gris strié) --> <!-- 3) Anneau extérieur PLAFOND ÉCOLOGIQUE (gris strié) -->
@@ -311,7 +364,7 @@ function gotoProject(p: ProjectListRow) {
<circle :r="R_FLOOR_OUTER" fill="url(#ring-stripes)" /> <circle :r="R_FLOOR_OUTER" fill="url(#ring-stripes)" />
<circle :r="R_FLOOR_INNER" fill="#FAFAF6" /> <circle :r="R_FLOOR_INNER" fill="#FAFAF6" />
<!-- 6) Séparateurs radiaux dotted entre secteurs --> <!-- 6) Séparateurs radiaux entre secteurs -->
<g stroke="#FFFFFF" stroke-width="1.5"> <g stroke="#FFFFFF" stroke-width="1.5">
<line <line
v-for="d in wedgeDividers" :key="d.key" v-for="d in wedgeDividers" :key="d.key"
@@ -332,11 +385,17 @@ function gotoProject(p: ProjectListRow) {
>{{ l.cat.name }}</text> >{{ l.cat.name }}</text>
</g> </g>
<!-- 8) Labels des anneaux --> <!-- 8) Étiquettes des 12 DIMENSIONS DU PLANCHER SOCIAL, le long de leur arc -->
<text x="0" :y="-(R_CEILING_OUTER + 30)" text-anchor="middle" font-size="9" fill="#16A34A" font-weight="700" <g>
style="letter-spacing:1.5px">PLAFOND ÉCOLOGIQUE</text> <text
<text x="0" :y="-(R_FLOOR_INNER - 5)" text-anchor="middle" font-size="8" fill="#EA580C" font-weight="700" v-for="s in socialArcs" :key="`sf-text-${s.key}`"
style="letter-spacing:1.5px">PLANCHER SOCIAL</text> font-size="6" fill="#EA580C" font-weight="600"
>
<textPath :href="`#${s.pathId}`" startOffset="50%" text-anchor="middle">
{{ s.label }}
</textPath>
</text>
</g>
<!-- 9) Points-projets : la classe `dot-circle` corrige le transform-origin --> <!-- 9) Points-projets : la classe `dot-circle` corrige le transform-origin -->
<g> <g>