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