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)
|
||||
|
||||
// --- Géométrie (en unités du viewBox -260..260) ---
|
||||
const R_CEILING_OUTER = 230
|
||||
// --- Géométrie (en unités du viewBox) ---
|
||||
const R_PLANET_LABEL = 252 // étiquettes des limites planétaires (extérieur)
|
||||
const R_CEILING_OUTER = 225
|
||||
const R_CEILING_INNER = 200
|
||||
const R_FLOOR_OUTER = 100
|
||||
const R_FLOOR_INNER = 78
|
||||
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). */
|
||||
function rad(deg: number): number {
|
||||
return (deg - 90) * Math.PI / 180
|
||||
@@ -75,6 +89,33 @@ function strHash(s: string): number {
|
||||
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). */
|
||||
const wedgeDividers = computed(() => {
|
||||
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 n = categories.value.length || 6
|
||||
const step = 360 / n
|
||||
return categories.value.map((cat, i) => {
|
||||
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 }
|
||||
})
|
||||
})
|
||||
|
||||
/** É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 {
|
||||
project: ProjectListRow
|
||||
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">
|
||||
<!-- Donut SVG (3/5 sur grands écrans) -->
|
||||
<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>
|
||||
<!-- Anneaux gris légèrement striés -->
|
||||
<pattern id="ring-stripes" patternUnits="userSpaceOnUse" width="6" height="6" patternTransform="rotate(45)">
|
||||
<rect width="6" height="6" fill="#E5E7EB" />
|
||||
<line x1="0" y1="0" x2="0" y2="6" stroke="#D1D5DB" stroke-width="1.2" />
|
||||
<rect width="6" height="6" fill="#ECECE7" />
|
||||
<line x1="0" y1="0" x2="0" y2="6" stroke="#D1D5DB" stroke-width="0.9" />
|
||||
</pattern>
|
||||
</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_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_INNER" fill="#FAFAF6" />
|
||||
|
||||
<!-- 3) Séparateurs radiaux entre secteurs (dotted) -->
|
||||
<g stroke="#D1D5DB" stroke-width="0.8" stroke-dasharray="2 3">
|
||||
<!-- 6) Séparateurs radiaux dotted entre secteurs -->
|
||||
<g stroke="#FFFFFF" stroke-width="1.5">
|
||||
<line
|
||||
v-for="d in wedgeDividers" :key="d.key"
|
||||
:x1="d.x1" :y1="d.y1" :x2="d.x2" :y2="d.y2"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 4) Étiquettes catégories autour du plafond -->
|
||||
<!-- 7) Étiquettes catégories au milieu de leur wedge -->
|
||||
<g>
|
||||
<text
|
||||
v-for="l in categoryLabels" :key="`label-${l.cat.id}`"
|
||||
:x="l.x" :y="l.y"
|
||||
text-anchor="middle"
|
||||
font-size="10"
|
||||
font-size="9"
|
||||
:fill="l.cat.color_hex"
|
||||
font-weight="600"
|
||||
font-weight="700"
|
||||
opacity="0.85"
|
||||
>{{ l.cat.name }}</text>
|
||||
</g>
|
||||
|
||||
<!-- 5) Labels des anneaux -->
|
||||
<text x="0" :y="-(R_CEILING_OUTER + 4)" text-anchor="middle" font-size="9" fill="#22C55E" font-weight="700"
|
||||
style="letter-spacing:1px">PLAFOND ÉCOLOGIQUE</text>
|
||||
<text x="0" :y="-(R_FLOOR_INNER - 5)" text-anchor="middle" font-size="8" fill="#F97316" font-weight="700"
|
||||
style="letter-spacing:1px">PLANCHER SOCIAL</text>
|
||||
<!-- 8) Labels des anneaux -->
|
||||
<text x="0" :y="-(R_CEILING_OUTER + 30)" text-anchor="middle" font-size="9" fill="#16A34A" font-weight="700"
|
||||
style="letter-spacing:1.5px">PLAFOND ÉCOLOGIQUE</text>
|
||||
<text x="0" :y="-(R_FLOOR_INNER - 5)" text-anchor="middle" font-size="8" fill="#EA580C" font-weight="700"
|
||||
style="letter-spacing:1.5px">PLANCHER SOCIAL</text>
|
||||
|
||||
<!-- 6) Points-projets -->
|
||||
<!-- 9) Points-projets : la classe `dot-circle` corrige le transform-origin -->
|
||||
<g>
|
||||
<circle
|
||||
v-for="d in dots" :key="d.project.id"
|
||||
@@ -254,8 +346,8 @@ function gotoProject(p: ProjectListRow) {
|
||||
:fill="d.isHollow ? '#FAFAF6' : d.color"
|
||||
:stroke="d.color"
|
||||
:stroke-width="d.isHollow ? 2 : 1.5"
|
||||
class="cursor-pointer transition-transform hover:scale-150 origin-center"
|
||||
:class="hoveredProject?.id === d.project.id ? 'scale-150' : ''"
|
||||
class="dot-circle"
|
||||
:class="hoveredProject?.id === d.project.id ? 'is-hover' : ''"
|
||||
@mouseenter="hoveredProject = d.project"
|
||||
@mouseleave="hoveredProject = null"
|
||||
@click="gotoProject(d.project)"
|
||||
@@ -264,7 +356,7 @@ function gotoProject(p: ProjectListRow) {
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
<!-- 7) Centre -->
|
||||
<!-- 10) Centre -->
|
||||
<circle :r="R_CENTER" fill="#FFFFFF" stroke="#E5E7EB" />
|
||||
<text x="0" y="-4" text-anchor="middle" font-size="13" fill="#111827" font-weight="700"
|
||||
style="letter-spacing:2px">LE DONUT</text>
|
||||
@@ -360,4 +452,26 @@ function gotoProject(p: ProjectListRow) {
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user