Files
donut-gestprojet/frontend/src/pages/DonutPage.vue
T
Arnaud 986241dc85 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>
2026-05-28 22:26:00 +02:00

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>