986241dc85
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>
537 lines
21 KiB
Vue
537 lines
21 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Visualisation Donut — SVG custom, version maquette « Capture d'écran 2026-05-28 205310 ».
|
|
*
|
|
* Anneaux concentriques sobres (pas de couleurs vives sur les secteurs) :
|
|
* - Anneau extérieur gris = "Plafond écologique" (limites planétaires)
|
|
* - Espace sûr cream = zone où l'on veut que vivent les projets
|
|
* - Anneau intérieur gris = "Plancher social" (besoins fondamentaux)
|
|
*
|
|
* Positionnement des projets :
|
|
* - Angle : par catégorie (6 secteurs de 60°), jitter angulaire
|
|
* - Rayon : déterminé par la DIMENSION
|
|
* social -> bande intérieure (proche du plancher)
|
|
* ecologique -> bande extérieure (proche du plafond)
|
|
* transversal -> milieu de la zone sûre
|
|
* puis modulé par le PROGRESS (plus progressé -> plus éloigné du centre,
|
|
* dans les limites de la bande de la dimension)
|
|
* - Projets archivés rendus en POINTS CREUX (stroke only).
|
|
*/
|
|
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { RouterLink, useRouter } from 'vue-router'
|
|
import { api, ApiError } from '@/api/client'
|
|
import type { Category, Dimension, Paginated, ProjectListRow, Wrapped } from '@/types/api'
|
|
import LoadingState from '@/components/LoadingState.vue'
|
|
import ErrorState from '@/components/ErrorState.vue'
|
|
|
|
const router = useRouter()
|
|
const categories = ref<Category[]>([])
|
|
const projects = ref<ProjectListRow[]>([])
|
|
const loading = ref(true)
|
|
const error = ref<string | null>(null)
|
|
const hoveredProject = ref<ProjectListRow | null>(null)
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
// /api/projects sans filtre renvoie déjà tous les non soft-deletés
|
|
// (active + completed + archived) — utile pour les points creux des archives.
|
|
const [cats, projs] = await Promise.all([
|
|
api.get<Wrapped<Category>>('/api/categories'),
|
|
api.get<Paginated<ProjectListRow>>('/api/projects', { query: { per_page: 100 } }),
|
|
])
|
|
categories.value = [...cats.data].sort((a, b) => a.sort_order - b.sort_order)
|
|
projects.value = projs.data
|
|
} catch (e) {
|
|
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
onMounted(load)
|
|
|
|
// --- 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_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. */
|
|
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",
|
|
]
|
|
|
|
/** 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). */
|
|
function rad(deg: number): number {
|
|
return (deg - 90) * Math.PI / 180
|
|
}
|
|
function pt(angleDeg: number, r: number): [number, number] {
|
|
const a = rad(angleDeg)
|
|
return [Math.cos(a) * r, Math.sin(a) * r]
|
|
}
|
|
|
|
/** Hash 32-bit basique pour stabiliser le jitter par projet. */
|
|
function strHash(s: string): number {
|
|
let h = 0
|
|
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0
|
|
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
|
|
const step = 360 / n
|
|
return Array.from({ length: n }, (_, i) => {
|
|
const angle = i * step
|
|
const [x1, y1] = pt(angle, R_FLOOR_OUTER)
|
|
const [x2, y2] = pt(angle, R_CEILING_INNER)
|
|
return { x1, y1, x2, y2, key: i }
|
|
})
|
|
})
|
|
|
|
/** 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_INNER + R_FLOOR_OUTER) / 2)
|
|
return { cat, x, y }
|
|
})
|
|
})
|
|
|
|
/** Paths d'arc pour les 9 limites planétaires (anneau extérieur). */
|
|
const planetaryArcs = computed(() => arcLabelPaths(PLANETARY_BOUNDARIES, R_PLANET_LABEL, 'pb', 18))
|
|
|
|
/** Paths d'arc pour les 12 dimensions du plancher social (anneau intérieur). */
|
|
const socialArcs = computed(() => arcLabelPaths(SOCIAL_FOUNDATIONS, R_SOCIAL_LABEL, 'sf', 14))
|
|
|
|
interface DotView {
|
|
project: ProjectListRow
|
|
x: number
|
|
y: number
|
|
isHollow: boolean
|
|
color: string
|
|
}
|
|
|
|
const dots = computed<DotView[]>(() => {
|
|
if (categories.value.length === 0) return []
|
|
// index catégorie par slug -> position du secteur (0..5)
|
|
const catIndex = new Map<string, number>()
|
|
categories.value.forEach((c, i) => catIndex.set(c.slug, i))
|
|
const n = categories.value.length
|
|
const step = 360 / n
|
|
|
|
// Bands selon la dimension (intérieur/extérieur/milieu).
|
|
const BANDS: Record<Dimension, [number, number]> = {
|
|
social: [R_FLOOR_OUTER + 8, R_FLOOR_OUTER + 40], // 108..140
|
|
ecologique: [R_CEILING_INNER - 40, R_CEILING_INNER - 8], // 160..192
|
|
transversal: [R_FLOOR_OUTER + 42, R_CEILING_INNER - 42], // 142..158
|
|
}
|
|
|
|
// Groupage par catégorie pour répartir angulairement.
|
|
const byCat = new Map<string, ProjectListRow[]>()
|
|
for (const p of projects.value) {
|
|
const list = byCat.get(p.category.slug) ?? []
|
|
list.push(p)
|
|
byCat.set(p.category.slug, list)
|
|
}
|
|
|
|
const out: DotView[] = []
|
|
for (const [slug, list] of byCat.entries()) {
|
|
const i = catIndex.get(slug) ?? 0
|
|
const wedgeStart = i * step + 3
|
|
const wedgeEnd = (i + 1) * step - 3
|
|
const span = wedgeEnd - wedgeStart
|
|
|
|
list.forEach((p, idx) => {
|
|
// Angle : répartition + jitter stable
|
|
const t = (idx + 1) / (list.length + 1)
|
|
const angleJitter = ((strHash(p.slug + 'a') % 100) / 100 - 0.5) * Math.min(6, span / Math.max(2, list.length))
|
|
const angle = wedgeStart + span * t + angleJitter
|
|
|
|
// Rayon : bande de dimension + progress
|
|
const [rMin, rMax] = BANDS[p.category.dimension] ?? BANDS.transversal
|
|
const progressR = rMin + (rMax - rMin) * (p.progress_percent / 100)
|
|
const rJitter = ((strHash(p.slug + 'r') % 100) / 100 - 0.5) * 4
|
|
const r = Math.max(rMin, Math.min(rMax, progressR + rJitter))
|
|
|
|
const [x, y] = pt(angle, r)
|
|
out.push({
|
|
project: p,
|
|
x, y,
|
|
isHollow: p.status === 'archived',
|
|
color: p.category.color_hex,
|
|
})
|
|
})
|
|
}
|
|
return out
|
|
})
|
|
|
|
// Légende — catégories regroupées par dimension avec leur count.
|
|
const projectsByCategorySlug = computed(() => {
|
|
const m: Record<string, number> = {}
|
|
for (const p of projects.value) {
|
|
m[p.category.slug] = (m[p.category.slug] ?? 0) + 1
|
|
}
|
|
return m
|
|
})
|
|
|
|
const DIMENSION_ORDER: Dimension[] = ['social', 'ecologique', 'transversal']
|
|
const DIMENSION_META: Record<Dimension, { label: string; color: string }> = {
|
|
social: { label: 'Plancher social', color: '#F97316' },
|
|
ecologique: { label: 'Plafond écologique', color: '#22C55E' },
|
|
transversal: { label: 'Transversal', color: '#6B7280' },
|
|
}
|
|
|
|
const categoriesByDimension = computed(() => {
|
|
const map: Record<Dimension, Category[]> = { social: [], ecologique: [], transversal: [] }
|
|
for (const c of categories.value) map[c.dimension].push(c)
|
|
return map
|
|
})
|
|
|
|
function gotoProject(p: ProjectListRow) {
|
|
router.push({ name: 'project-detail', params: { slug: p.slug } })
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="px-10 py-8 max-w-7xl">
|
|
<header class="text-center max-w-3xl mx-auto">
|
|
<h1 class="text-3xl font-semibold text-donut-text">Le Donut des Projets Citoyens</h1>
|
|
<p class="text-donut-muted mt-2">
|
|
Inspiré du modèle de l'économie du Donut de Kate Raworth. L'Infolab des projets citoyens
|
|
explore les initiatives qui poussent une vie marseillaise dans l'espace sûr et juste : entre
|
|
plancher social et plafond écologique.
|
|
</p>
|
|
</header>
|
|
|
|
<LoadingState v-if="loading" class="mt-12 justify-center" />
|
|
<ErrorState v-else-if="error" :message="error" :retry="load" class="mt-12" />
|
|
|
|
<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="-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="#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 -->
|
|
<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>
|
|
|
|
<!-- 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, le long de leur arc -->
|
|
<g>
|
|
<text
|
|
v-for="p in planetaryArcs" :key="`pb-text-${p.key}`"
|
|
font-size="8" fill="#16A34A" font-weight="600"
|
|
>
|
|
<textPath :href="`#${p.pathId}`" startOffset="50%" text-anchor="middle">
|
|
{{ p.label }}
|
|
</textPath>
|
|
</text>
|
|
</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" />
|
|
|
|
<!-- 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" />
|
|
|
|
<!-- 6) Séparateurs radiaux 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>
|
|
|
|
<!-- 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="9"
|
|
:fill="l.cat.color_hex"
|
|
font-weight="700"
|
|
opacity="0.85"
|
|
>{{ l.cat.name }}</text>
|
|
</g>
|
|
|
|
<!-- 8) Étiquettes des 12 DIMENSIONS DU PLANCHER SOCIAL, le long de leur arc -->
|
|
<g>
|
|
<text
|
|
v-for="s in socialArcs" :key="`sf-text-${s.key}`"
|
|
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 -->
|
|
<g>
|
|
<circle
|
|
v-for="d in dots" :key="d.project.id"
|
|
:cx="d.x" :cy="d.y" r="6"
|
|
:fill="d.isHollow ? '#FAFAF6' : d.color"
|
|
:stroke="d.color"
|
|
:stroke-width="d.isHollow ? 2 : 1.5"
|
|
class="dot-circle"
|
|
:class="hoveredProject?.id === d.project.id ? 'is-hover' : ''"
|
|
@mouseenter="hoveredProject = d.project"
|
|
@mouseleave="hoveredProject = null"
|
|
@click="gotoProject(d.project)"
|
|
>
|
|
<title>{{ d.project.title }} — {{ d.project.progress_percent }}%</title>
|
|
</circle>
|
|
</g>
|
|
|
|
<!-- 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>
|
|
<text x="0" y="12" text-anchor="middle" font-size="9" fill="#6B7280">
|
|
{{ projects.length }} projet{{ projects.length > 1 ? 's' : '' }}
|
|
</text>
|
|
</svg>
|
|
|
|
<!-- Tooltip projet survolé -->
|
|
<div class="mt-2 h-12 flex items-center justify-center">
|
|
<transition name="fade">
|
|
<RouterLink
|
|
v-if="hoveredProject"
|
|
:to="{ name: 'project-detail', params: { slug: hoveredProject.slug } }"
|
|
class="px-3 py-1.5 rounded-full bg-white border border-donut-border shadow-sm text-sm flex items-center gap-2 hover:shadow-md transition-shadow"
|
|
>
|
|
<span
|
|
class="w-2 h-2 rounded-full"
|
|
:style="{ background: hoveredProject.category.color_hex }"
|
|
/>
|
|
<span class="font-medium">{{ hoveredProject.title }}</span>
|
|
<span class="text-donut-muted text-xs">
|
|
— {{ hoveredProject.progress_percent }}%
|
|
<template v-if="hoveredProject.status === 'archived'"> · archivé</template>
|
|
<template v-else-if="hoveredProject.status === 'completed'"> · réalisé</template>
|
|
</span>
|
|
</RouterLink>
|
|
<div v-else class="text-xs text-donut-muted">
|
|
Survolez un point pour voir le projet · cliquez pour ouvrir
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Légende -->
|
|
<aside class="lg:col-span-2 space-y-6">
|
|
<div class="rounded-xl border border-donut-border bg-donut-surface p-5">
|
|
<h2 class="text-base font-semibold text-donut-text">Comment lire ce donut ?</h2>
|
|
<ul class="mt-3 space-y-2.5 text-sm text-donut-muted leading-relaxed">
|
|
<li>L'<strong class="text-donut-text">anneau intérieur</strong> représente le
|
|
<span class="font-medium" style="color:#F97316">plancher social</span>, qui définit les besoins fondamentaux.</li>
|
|
<li>L'<strong class="text-donut-text">anneau extérieur</strong> représente le
|
|
<span class="font-medium" style="color:#22C55E">plafond écologique</span>, qui définit les limites planétaires.</li>
|
|
<li>L'espace entre les deux est l'<strong class="text-donut-text">espace sûr et juste</strong>.</li>
|
|
<li>Les <strong class="text-donut-text">projets sociaux</strong> sont positionnés vers l'intérieur.</li>
|
|
<li>Les <strong class="text-donut-text">projets écologiques</strong> sont positionnés vers l'extérieur.</li>
|
|
<li>Plus un point est <strong class="text-donut-text">éloigné du centre</strong>, plus le projet est progressé.</li>
|
|
<li>Les <strong class="text-donut-text">points creux</strong> sont les projets historiques.</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div
|
|
v-for="dim in DIMENSION_ORDER"
|
|
:key="dim"
|
|
class="rounded-xl border border-donut-border bg-donut-surface p-5"
|
|
>
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<span class="w-2.5 h-2.5 rounded-full" :style="{ background: DIMENSION_META[dim].color }" />
|
|
<h3 class="text-xs font-semibold uppercase tracking-wider" :style="{ color: DIMENSION_META[dim].color }">
|
|
{{ DIMENSION_META[dim].label }}
|
|
</h3>
|
|
</div>
|
|
<ul v-if="categoriesByDimension[dim].length" class="space-y-2">
|
|
<li
|
|
v-for="c in categoriesByDimension[dim]"
|
|
:key="c.id"
|
|
>
|
|
<RouterLink
|
|
:to="{ name: 'projects', query: { category: c.slug } }"
|
|
class="flex items-center justify-between gap-2 text-sm group"
|
|
>
|
|
<span class="flex items-center gap-2 min-w-0">
|
|
<span class="w-2.5 h-2.5 rounded-full shrink-0" :style="{ background: c.color_hex }" />
|
|
<span class="text-donut-text truncate group-hover:underline">{{ c.name }}</span>
|
|
</span>
|
|
<span class="text-donut-muted tabular-nums shrink-0">
|
|
{{ projectsByCategorySlug[c.slug] ?? 0 }}
|
|
</span>
|
|
</RouterLink>
|
|
</li>
|
|
</ul>
|
|
<p v-else class="text-xs text-donut-muted">Aucune catégorie dans cette dimension.</p>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.fade-enter-active, .fade-leave-active {
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
.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>
|