fix(donut): hover anti-jitter + couleurs catégories + limites planétaires

1. Jitter au survol (bug visible) :
   `transform: scale(1.5)` en SVG prend pour origine le (0,0) du viewBox,
   pas le centre du <circle>. Le scale "déplaçait" donc le cercle vers le centre
   de la viewport → la souris n'était plus dessus → mouseleave → retour →
   mouseenter → re-déplacé. Boucle infinie de tremblement.

   Fix : classe `.dot-circle` avec
       transform-box: fill-box;
       transform-origin: center;
   Le scale prend désormais pour pivot le centre de la forme. Stable.

2. Couleurs des secteurs thématiques (par la maquette d'origine) :
   Réintroduction des wedges colorés dans la zone sûre, opacité 10 % sur
   le fill + 20 % sur le stroke. Reste sobre, garde la lisibilité.

3. Limites planétaires (de Kate Raworth) :
   Les 9 limites planétaires (Changement climatique, Acidification des
   océans, Biodiversité, Eau douce, Cycles N&P, Pollution chimique,
   Pollution de l'air, Couche d'ozone, Usage des sols) sont placées en
   étiquettes tangentes autour de l'anneau extérieur, avec flip 180° en
   moitié basse pour rester lisibles.

Cosmétique :
   - viewBox 560×560 (au lieu de 520) pour caser les labels extérieurs
   - Labels catégorie déplacés AU MILIEU de leur wedge (sur la zone sûre)
     plutôt qu'à l'extérieur (où ils chevauchaient les limites planétaires)
   - Séparateurs radiaux passés en blanc pour mieux contraster avec les
     wedges colorés

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 22:10:37 +02:00
parent 80193e5bc0
commit 8dd9cffaed
+137 -23
View File
@@ -52,13 +52,27 @@ async function load() {
} }
onMounted(load) onMounted(load)
// --- Géométrie (en unités du viewBox -260..260) --- // --- Géométrie (en unités du viewBox) ---
const R_CEILING_OUTER = 230 const R_PLANET_LABEL = 252 // étiquettes des limites planétaires (extérieur)
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_CENTER = 56
/** 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",
]
/** 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
@@ -75,6 +89,33 @@ function strHash(s: string): number {
return Math.abs(h) return Math.abs(h)
} }
/** Construit le path SVG d'un anneau-secteur (donut wedge) entre 2 rayons. */
function wedgePath(startDeg: number, endDeg: number, rInner: number, rOuter: number): string {
const [x1, y1] = pt(startDeg, rOuter)
const [x2, y2] = pt(endDeg, rOuter)
const [x3, y3] = pt(endDeg, rInner)
const [x4, y4] = pt(startDeg, rInner)
const largeArc = endDeg - startDeg > 180 ? 1 : 0
return [
`M ${x1.toFixed(2)} ${y1.toFixed(2)}`,
`A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`,
`L ${x3.toFixed(2)} ${y3.toFixed(2)}`,
`A ${rInner} ${rInner} 0 ${largeArc} 0 ${x4.toFixed(2)} ${y4.toFixed(2)}`,
'Z',
].join(' ')
}
/** Secteurs colorés (tint subtil) pour chaque catégorie, dans la zone sûre. */
const thematicWedges = computed(() => {
const n = categories.value.length || 6
const step = 360 / n
return categories.value.map((cat, i) => ({
cat,
path: wedgePath(i * step, (i + 1) * step, R_FLOOR_OUTER, R_CEILING_INNER),
key: cat.id,
}))
})
/** Lignes radiales de séparation des secteurs (entre plancher et plafond). */ /** Lignes radiales de séparation des secteurs (entre plancher et plafond). */
const wedgeDividers = computed(() => { const wedgeDividers = computed(() => {
const n = categories.value.length || 6 const n = categories.value.length || 6
@@ -87,17 +128,39 @@ const wedgeDividers = computed(() => {
}) })
}) })
/** Position du label catégorie (à l'extérieur du plafond). */ /** Position du label catégorie (juste sous l'anneau écologique, sur la zone sûre). */
const categoryLabels = computed(() => { const categoryLabels = computed(() => {
const n = categories.value.length || 6 const n = categories.value.length || 6
const step = 360 / n const step = 360 / n
return categories.value.map((cat, i) => { return categories.value.map((cat, i) => {
const center = i * step + step / 2 const center = i * step + step / 2
const [x, y] = pt(center, R_CEILING_OUTER + 18) const [x, y] = pt(center, (R_CEILING_INNER + R_FLOOR_OUTER) / 2)
return { cat, x, y } return { cat, x, y }
}) })
}) })
/** Étiquettes des 9 limites planétaires, autour de l'anneau extérieur.
*
* 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).
*
* - 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
x: number x: number
@@ -204,49 +267,78 @@ function gotoProject(p: ProjectListRow) {
<div v-else class="mt-10 grid grid-cols-1 lg:grid-cols-5 gap-10 items-start"> <div v-else class="mt-10 grid grid-cols-1 lg:grid-cols-5 gap-10 items-start">
<!-- Donut SVG (3/5 sur grands écrans) --> <!-- Donut SVG (3/5 sur grands écrans) -->
<div class="lg:col-span-3 flex flex-col items-center"> <div class="lg:col-span-3 flex flex-col items-center">
<svg viewBox="-260 -260 520 520" class="w-full max-w-[600px] aspect-square"> <svg viewBox="-280 -280 560 560" class="w-full max-w-[640px] aspect-square">
<defs> <defs>
<!-- Anneaux gris légèrement striés -->
<pattern id="ring-stripes" patternUnits="userSpaceOnUse" width="6" height="6" patternTransform="rotate(45)"> <pattern id="ring-stripes" patternUnits="userSpaceOnUse" width="6" height="6" patternTransform="rotate(45)">
<rect width="6" height="6" fill="#E5E7EB" /> <rect width="6" height="6" fill="#ECECE7" />
<line x1="0" y1="0" x2="0" y2="6" stroke="#D1D5DB" stroke-width="1.2" /> <line x1="0" y1="0" x2="0" y2="6" stroke="#D1D5DB" stroke-width="0.9" />
</pattern> </pattern>
</defs> </defs>
<!-- 1) Anneau extérieur PLAFOND ÉCOLOGIQUE (gris) --> <!-- 1) ZONE D'OVERSHOOT (au-delà du plafond) -->
<!-- fond cream clair pour soutenir les labels limites planétaires -->
<circle r="270" fill="#F5F2EA" />
<!-- 2) Étiquettes des 9 LIMITES PLANÉTAIRES, autour de l'anneau extérieur -->
<g>
<g
v-for="p in planetaryLabels" :key="p.key"
:transform="`translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) rotate(${p.rotation.toFixed(2)})`"
>
<text text-anchor="middle" font-size="8" fill="#22C55E" font-weight="600">
{{ p.label }}
</text>
</g>
</g>
<!-- 3) Anneau extérieur PLAFOND ÉCOLOGIQUE (gris strié) -->
<circle :r="R_CEILING_OUTER" fill="url(#ring-stripes)" /> <circle :r="R_CEILING_OUTER" fill="url(#ring-stripes)" />
<circle :r="R_CEILING_INNER" fill="#FAFAF6" /> <circle :r="R_CEILING_INNER" fill="#FAFAF6" />
<!-- 2) Anneau intérieur PLANCHER SOCIAL (gris) --> <!-- 4) Secteurs thématiques colorés (tint subtil, sur la zone sûre) -->
<g>
<path
v-for="w in thematicWedges" :key="w.key"
:d="w.path"
:fill="w.cat.color_hex + '1A'"
:stroke="w.cat.color_hex + '33'"
stroke-width="0.5"
/>
</g>
<!-- 5) Anneau intérieur PLANCHER SOCIAL (gris strié) -->
<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" />
<!-- 3) Séparateurs radiaux entre secteurs (dotted) --> <!-- 6) Séparateurs radiaux dotted entre secteurs -->
<g stroke="#D1D5DB" stroke-width="0.8" stroke-dasharray="2 3"> <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"
:x1="d.x1" :y1="d.y1" :x2="d.x2" :y2="d.y2" :x1="d.x1" :y1="d.y1" :x2="d.x2" :y2="d.y2"
/> />
</g> </g>
<!-- 4) Étiquettes catégories autour du plafond --> <!-- 7) Étiquettes catégories au milieu de leur wedge -->
<g> <g>
<text <text
v-for="l in categoryLabels" :key="`label-${l.cat.id}`" v-for="l in categoryLabels" :key="`label-${l.cat.id}`"
:x="l.x" :y="l.y" :x="l.x" :y="l.y"
text-anchor="middle" text-anchor="middle"
font-size="10" font-size="9"
:fill="l.cat.color_hex" :fill="l.cat.color_hex"
font-weight="600" font-weight="700"
opacity="0.85"
>{{ l.cat.name }}</text> >{{ l.cat.name }}</text>
</g> </g>
<!-- 5) Labels des anneaux --> <!-- 8) Labels des anneaux -->
<text x="0" :y="-(R_CEILING_OUTER + 4)" text-anchor="middle" font-size="9" fill="#22C55E" font-weight="700" <text x="0" :y="-(R_CEILING_OUTER + 30)" text-anchor="middle" font-size="9" fill="#16A34A" font-weight="700"
style="letter-spacing:1px">PLAFOND ÉCOLOGIQUE</text> style="letter-spacing:1.5px">PLAFOND ÉCOLOGIQUE</text>
<text x="0" :y="-(R_FLOOR_INNER - 5)" text-anchor="middle" font-size="8" fill="#F97316" font-weight="700" <text x="0" :y="-(R_FLOOR_INNER - 5)" text-anchor="middle" font-size="8" fill="#EA580C" font-weight="700"
style="letter-spacing:1px">PLANCHER SOCIAL</text> style="letter-spacing:1.5px">PLANCHER SOCIAL</text>
<!-- 6) Points-projets --> <!-- 9) Points-projets : la classe `dot-circle` corrige le transform-origin -->
<g> <g>
<circle <circle
v-for="d in dots" :key="d.project.id" v-for="d in dots" :key="d.project.id"
@@ -254,8 +346,8 @@ function gotoProject(p: ProjectListRow) {
:fill="d.isHollow ? '#FAFAF6' : d.color" :fill="d.isHollow ? '#FAFAF6' : d.color"
:stroke="d.color" :stroke="d.color"
:stroke-width="d.isHollow ? 2 : 1.5" :stroke-width="d.isHollow ? 2 : 1.5"
class="cursor-pointer transition-transform hover:scale-150 origin-center" class="dot-circle"
:class="hoveredProject?.id === d.project.id ? 'scale-150' : ''" :class="hoveredProject?.id === d.project.id ? 'is-hover' : ''"
@mouseenter="hoveredProject = d.project" @mouseenter="hoveredProject = d.project"
@mouseleave="hoveredProject = null" @mouseleave="hoveredProject = null"
@click="gotoProject(d.project)" @click="gotoProject(d.project)"
@@ -264,7 +356,7 @@ function gotoProject(p: ProjectListRow) {
</circle> </circle>
</g> </g>
<!-- 7) Centre --> <!-- 10) Centre -->
<circle :r="R_CENTER" fill="#FFFFFF" stroke="#E5E7EB" /> <circle :r="R_CENTER" fill="#FFFFFF" stroke="#E5E7EB" />
<text x="0" y="-4" text-anchor="middle" font-size="13" fill="#111827" font-weight="700" <text x="0" y="-4" text-anchor="middle" font-size="13" fill="#111827" font-weight="700"
style="letter-spacing:2px">LE DONUT</text> style="letter-spacing:2px">LE DONUT</text>
@@ -360,4 +452,26 @@ function gotoProject(p: ProjectListRow) {
.fade-enter-from, .fade-leave-to { .fade-enter-from, .fade-leave-to {
opacity: 0; opacity: 0;
} }
/* Fix anti-jitter sur les points-projets.
*
* Sans ces 2 propriétés, `transform: scale()` en SVG prend pour origine le (0,0)
* du viewBox -> le cercle scalé est déplacé vers le centre, la souris n'est plus
* dessus, mouseleave -> retour à la position d'origine -> mouseenter -> re-déplacé.
* Boucle infinie de jitter.
*
* `transform-box: fill-box` change le référentiel à la bounding box de la forme,
* et `transform-origin: center` met l'origine au centre de cette box.
* Le scale se fait alors "sur place".
*/
.dot-circle {
cursor: pointer;
transform-box: fill-box;
transform-origin: center;
transition: transform 0.15s ease;
}
.dot-circle:hover,
.dot-circle.is-hover {
transform: scale(1.6);
}
</style> </style>