feat(media): iteration 2 carte - Voronoi bg + collapso fusion + lisibilite + liens influence

This commit is contained in:
Jules Neny
2026-05-12 12:12:05 +02:00
parent 1b1e373bea
commit c14a1ee01f
3 changed files with 226 additions and 60 deletions

View File

@@ -17,18 +17,43 @@ interface LivreRag { slug: string; titre: string; annee: number; couches: string
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
// Liens d'influence inter-ecoles (Phase 7 - matrice de filiation)
const LINKS_INFLUENCE = [
// filiations directes
{ source: 'eco-anarchisme', target: 'technocritique', auteurs_passerelle: ['Bookchin', 'Illich'], type: 'filiation' },
{ source: 'eco-anarchisme', target: 'decroissance', auteurs_passerelle: ['Latouche', 'Kropotkine'], type: 'filiation' },
{ source: 'ecosocialisme', target: 'decroissance', auteurs_passerelle: ['Saito', 'Gorz'], type: 'filiation' },
{ source: 'ecosocialisme', target: 'ecologies-decoloniales', auteurs_passerelle: ['Klein', 'Ferdinand'], type: 'filiation' },
{ source: 'ecofeminismes', target: 'ecologies-decoloniales', auteurs_passerelle: ['Shiva', 'Ouassak'], type: 'filiation' },
{ source: 'ecofeminismes', target: 'pensees-vivant', auteurs_passerelle: ['Haraway', 'Despret'], type: 'filiation' },
{ source: 'technocritique', target: 'decroissance', auteurs_passerelle: ['Ellul', 'Latouche'], type: 'filiation' },
{ source: 'decroissance', target: 'pensees-vivant', auteurs_passerelle: ['Servigne', 'Despret'], type: 'filiation' },
{ source: 'pensees-vivant', target: 'ethiques-environnementales', auteurs_passerelle: ['Naess', 'Latour'], type: 'filiation' },
{ source: 'ecosocialisme', target: 'eco-anarchisme', auteurs_passerelle: ['Gorz', 'Graeber'], type: 'filiation' },
// liens de critique (toutes les ecoles progressistes vs cap-vert / ecofascismes)
{ source: 'ecosocialisme', target: 'capitalisme-vert', auteurs_passerelle: ['Klein', 'Malm'], type: 'critique' },
{ source: 'decroissance', target: 'capitalisme-vert', auteurs_passerelle: ['Latouche', 'Meadows'], type: 'critique' },
{ source: 'eco-anarchisme', target: 'capitalisme-vert', auteurs_passerelle: ['Bookchin'], type: 'critique' },
{ source: 'ethiques-environnementales', target: 'ecofascismes', auteurs_passerelle: ['Naess'], type: 'critique' },
{ source: 'capitalisme-vert', target: 'ecofascismes', auteurs_passerelle: [], type: 'critique' },
]
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
const emit = defineEmits<{ 'select-auteur': [id: string] }>()
const svgRef = ref<SVGElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null)
let simulation: any = null
let d3NodeSel: any = null
let d3LinkSel: any = null
let d3InfluenceSel: any = null
let d3NodeSel: any = null
let d3EdgeLabelSel: any = null
async function initGraph() {
if (!svgRef.value || !props.data) return
const d3 = await import('d3')
const { Delaunay } = await import('d3-delaunay')
const svgEl = svgRef.value
const W = svgEl.clientWidth || 900
const H = svgEl.clientHeight || 600
@@ -41,86 +66,189 @@ async function initGraph() {
const ecoleMap = new Map<string, EcoleData>(props.data.ecoles.map(e => [e.id, e]))
const ecoleNodes: any[] = props.data.ecoles.map(e => ({
id: `ecole-${e.id}`, type: 'ecole', ecoleId: e.id, label: e.label, color: e.color, r: 38,
x: W * e.x_hint, y: H * e.y_hint, fx: W * e.x_hint, fy: H * e.y_hint,
}))
// Positions fixes des ecoles (base pour Voronoi)
const ecolePositions = new Map<string, { tx: number; ty: number }>()
props.data.ecoles.forEach(e => {
ecolePositions.set(e.id, { tx: W * e.x_hint, ty: H * e.y_hint })
})
// ---- VORONOI BACKGROUND (couche 1) ----
const ecolesArr = props.data.ecoles
const points: [number, number][] = ecolesArr.map(e => [W * e.x_hint, H * e.y_hint])
const delaunay = Delaunay.from(points)
const voronoi = delaunay.voronoi([0, 0, W, H])
// Groupe Voronoi (fond, couche 1)
const gVoronoi = g.append('g').attr('class', 'voronoi-bg')
ecolesArr.forEach((ecole, i) => {
const cellPath = voronoi.renderCell(i)
const poly = voronoi.cellPolygon(i)
gVoronoi.append('path')
.attr('d', cellPath)
.attr('fill', ecole.color)
.attr('fill-opacity', 0.48)
.attr('class', 'voronoi-cell')
.attr('data-ecole', ecole.id)
.on('mouseenter', (e: any) => {
if (!tooltipRef.value) return
tooltipRef.value.innerHTML = `<strong>${ecole.label}</strong><br><span style="opacity:0.75;font-size:0.72rem;">${ecole.description}</span>`
tooltipRef.value.style.opacity = '1'
})
.on('mousemove', (e: any) => {
if (!tooltipRef.value || !svgEl) return
const rect = (svgEl as HTMLElement).getBoundingClientRect()
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
})
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
// Label ecole dans la cellule (centroid du polygone)
if (poly && poly.length > 0) {
const centroid = d3.polygonCentroid(poly as [number, number][])
if (centroid && !isNaN(centroid[0]) && !isNaN(centroid[1])) {
const words = ecole.label.split(' ')
const labelEl = gVoronoi.append('text')
.attr('class', 'voronoi-cell-label')
.attr('x', centroid[0])
.attr('y', centroid[1])
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('pointer-events', 'none')
.style('user-select', 'none')
if (words.length <= 2) {
labelEl.text(ecole.label)
} else {
const mid = Math.ceil(words.length / 2)
labelEl.append('tspan').attr('x', centroid[0]).attr('dy', '-0.55em').text(words.slice(0, mid).join(' '))
labelEl.append('tspan').attr('x', centroid[0]).attr('dy', '1.1em').text(words.slice(mid).join(' '))
}
}
}
})
// ---- LIENS D'INFLUENCE INTER-ECOLES (couche 3) ----
const gInfluence = g.append('g').attr('class', 'links-influence')
LINKS_INFLUENCE.forEach(link => {
const src = ecolePositions.get(link.source)
const tgt = ecolePositions.get(link.target)
if (!src || !tgt) return
const isCritique = link.type === 'critique'
const lineEl = gInfluence.append('line')
.attr('class', 'influence-link')
.attr('x1', src.tx).attr('y1', src.ty)
.attr('x2', tgt.tx).attr('y2', tgt.ty)
.attr('stroke', isCritique ? '#d99' : '#9aa')
.attr('stroke-width', 1)
.attr('stroke-dasharray', isCritique ? '4,3' : '6,4')
.attr('stroke-opacity', isCritique ? 0.2 : 0.22)
if (link.auteurs_passerelle && link.auteurs_passerelle.length > 0) {
lineEl
.on('mouseenter', (e: any) => {
if (!tooltipRef.value) return
tooltipRef.value.innerHTML = `<strong>Influence</strong><br><span style="opacity:0.8;font-size:0.72rem;">Passerelles : ${link.auteurs_passerelle.join(', ')}</span>`
tooltipRef.value.style.opacity = '1'
})
.on('mousemove', (e: any) => {
if (!tooltipRef.value || !svgEl) return
const rect = (svgEl as HTMLElement).getBoundingClientRect()
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
})
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
}
})
// ---- SIMULATION D3 (auteurs) ----
const auteurNodes: any[] = props.data.auteurs.map(a => ({
id: a.id, type: 'auteur', nom: a.nom, dates: a.dates, bio_courte: a.bio_courte,
ecole_principale: a.ecole_principale,
color: ecoleMap.get(a.ecole_principale)?.color ?? '#888', r: 11,
}))
const allNodes = [...ecoleNodes, ...auteurNodes]
// Liens appartenance auteur -> ecole (vers centroid fixe)
const links: any[] = []
props.data.auteurs.forEach(a => {
links.push({ source: a.id, target: `ecole-${a.ecole_principale}`, strength: 0.65 })
a.ecoles.filter(e => e !== a.ecole_principale).forEach(e => links.push({ source: a.id, target: `ecole-${e}`, strength: 0.25 }))
links.push({ source: a.id, target: a.ecole_principale, strength: 0.65, isSubcourant: false })
a.ecoles.filter(e => e !== a.ecole_principale).forEach(e => {
links.push({ source: a.id, target: e, strength: 0.25, isSubcourant: true })
})
})
// Precalculer les positions cibles des ecoles pour ancrer les auteurs proches
const ecolePositions = new Map<string, { tx: number; ty: number }>()
ecoleNodes.forEach(e => { ecolePositions.set(e.ecoleId, { tx: e.x, ty: e.y }) })
// Nodes fictifs fixes pour les ecoles (cibles des liens appartenance)
const ecoleFixedNodes: any[] = props.data.ecoles.map(e => ({
id: e.id, type: 'ecole-fixed', ecoleId: e.id,
x: W * e.x_hint, y: H * e.y_hint,
fx: W * e.x_hint, fy: H * e.y_hint,
}))
const allNodes = [...ecoleFixedNodes, ...auteurNodes]
if (simulation) simulation.stop()
simulation = d3.forceSimulation(allNodes)
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(130).strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(-50))
.force('center', d3.forceCenter(W / 2, H / 2))
.force('collision', d3.forceCollide().radius((d: any) => d.r + 5))
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(110).strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(-45))
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.02))
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'auteur' ? 14 : 0))
.force('forceX', d3.forceX<any>((d: any) => {
if (d.type === 'auteur') {
const pos = ecolePositions.get(d.ecole_principale)
return pos ? pos.tx : W / 2
}
return W / 2
}).strength(0.08))
}).strength(0.12))
.force('forceY', d3.forceY<any>((d: any) => {
if (d.type === 'auteur') {
const pos = ecolePositions.get(d.ecole_principale)
return pos ? pos.ty : H / 2
}
return H / 2
}).strength(0.08))
}).strength(0.12))
d3LinkSel = g.append('g').selectAll('line').data(links).join('line')
.attr('stroke', 'rgba(150,150,150,0.3)').attr('stroke-width', 1.2)
// ---- LIENS APPARTENANCE (couche 4) ----
const gLinks = g.append('g').attr('class', 'links-appartenance')
d3LinkSel = gLinks.selectAll('line').data(links).join('line')
.attr('stroke', 'rgba(150,150,150,0.28)').attr('stroke-width', 1.2)
d3NodeSel = g.append('g').selectAll('g').data(allNodes).join('g')
.style('cursor', (d: any) => d.type === 'auteur' ? 'pointer' : 'default')
// ---- EDGE LABELS - sous-courants (couche 4b) ----
// Afficher label "decroissance" sur lien Servigne (sous-courant specifique - option C)
const subcourantLinks = links.filter((l: any) => l.isSubcourant)
d3EdgeLabelSel = gLinks.selectAll('text.pensees-edge-label')
.data(subcourantLinks)
.join('text')
.attr('class', 'pensees-edge-label')
// ---- NODES AUTEURS (couche 5) ----
const gAuteurs = g.append('g').attr('class', 'auteurs')
d3NodeSel = gAuteurs.selectAll('g').data(auteurNodes).join('g')
.style('cursor', 'pointer')
.call(d3.drag<any, any>()
.on('start', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
.on('drag', (e: any, d: any) => { d.fx = e.x; d.fy = e.y })
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); if (d.type !== 'ecole') { d.fx = null; d.fy = null } }))
.on('click', (e: any, d: any) => { e.stopPropagation(); if (d.type === 'auteur') emit('select-auteur', d.id) })
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null }))
.on('click', (e: any, d: any) => { e.stopPropagation(); emit('select-auteur', d.id) })
d3NodeSel.append('circle')
.attr('r', (d: any) => d.r)
.attr('fill', (d: any) => d.type === 'ecole' ? d.color : d.color + 'cc')
.attr('stroke', (d: any) => d.type === 'ecole' ? 'rgba(255,255,255,0.6)' : d.color)
.attr('stroke-width', (d: any) => d.type === 'ecole' ? 3 : 1.5)
.attr('fill', (d: any) => d.color + 'cc')
.attr('stroke', (d: any) => d.color)
.attr('stroke-width', 1.5)
d3NodeSel.filter((d: any) => d.type === 'ecole').append('text')
.attr('text-anchor', 'middle').attr('dy', '0.35em').attr('font-size', '10px').attr('font-weight', '700').attr('fill', '#1a1a1a')
.style('pointer-events', 'none')
.each(function(d: any) {
const el = d3.select(this as any)
const words: string[] = d.label.split(' ')
if (words.length <= 2) { el.text(d.label) } else {
const mid = Math.ceil(words.length / 2)
el.append('tspan').attr('x', 0).attr('dy', '-0.6em').text(words.slice(0, mid).join(' '))
el.append('tspan').attr('x', 0).attr('dy', '1.2em').text(words.slice(mid).join(' '))
}
})
d3NodeSel.filter((d: any) => d.type === 'auteur').append('text')
// ---- LABELS AUTEURS (couche 6 - fix 7.1 : drop-shadow blanc) ----
d3NodeSel.append('text')
.attr('class', 'pensees-auteur-label')
.text((d: any) => d.nom.split(' ').pop() ?? d.nom)
.attr('text-anchor', 'middle').attr('dy', (d: any) => -(d.r + 4)).attr('font-size', '9px').attr('font-weight', '500')
.attr('text-anchor', 'middle')
.attr('dy', (d: any) => -(d.r + 4))
.style('pointer-events', 'none')
d3NodeSel.filter((d: any) => d.type === 'auteur')
d3NodeSel
.on('mouseenter', (e: any, d: any) => {
if (!tooltipRef.value) return
const bio = d.bio_courte.length > 90 ? d.bio_courte.slice(0, 87) + '...' : d.bio_courte
@@ -136,8 +264,19 @@ async function initGraph() {
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
simulation.on('tick', () => {
d3LinkSel.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
d3LinkSel
.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y)
// Edge labels positions (milieu du lien)
d3EdgeLabelSel
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
.attr('y', (d: any) => (d.source.y + d.target.y) / 2)
.text((d: any) => {
const targetId = typeof d.target === 'object' ? d.target.id : d.target
return targetId
})
d3NodeSel.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
})
}
@@ -147,7 +286,6 @@ watch(() => props.data, (val) => { if (val && props.active && import.meta.client
onMounted(async () => { if (import.meta.client && props.data && props.active) { await nextTick(); initGraph() } })
onUnmounted(() => { if (simulation) simulation.stop() })
// Expose pour reset D3 apres resize du conteneur
function triggerResize() {
if (simulation) {
simulation.alpha(0.3).restart()
@@ -159,5 +297,41 @@ defineExpose({ triggerResize })
</script>
<style>
.pensees-auteur-label { fill: var(--nav-text); opacity: 1; paint-order: stroke; stroke: var(--nav-bg); stroke-width: 2px; stroke-linejoin: round; user-select: none; font-weight: 600; }
/* ---- Labels auteurs : fix 7.1 drop-shadow blanc pour lisibilite sur pastel ---- */
.pensees-auteur-label {
fill: #1a1a1a;
font-weight: 600;
font-size: 10px;
filter: drop-shadow(0 0 2.5px rgba(255,255,255,0.95));
user-select: none;
}
/* ---- Labels edge sous-courants (option C : seulement les liens secondaires) ---- */
.pensees-edge-label {
fill: #555;
font-size: 8.5px;
font-style: italic;
opacity: 0.7;
text-anchor: middle;
dominant-baseline: middle;
user-select: none;
pointer-events: none;
}
/* ---- Voronoi cellules ---- */
.voronoi-cell {
stroke: rgba(255,255,255,0.4);
stroke-width: 1.5px;
cursor: default;
}
/* ---- Labels ecoles dans cellules Voronoi ---- */
.voronoi-cell-label {
fill: rgba(40,40,40,0.52);
font-size: 17px;
font-weight: 700;
letter-spacing: 0.3px;
pointer-events: none;
user-select: none;
}
</style>

View File

@@ -239,8 +239,8 @@ useHead({ title: 'AEP - Media - Carte FRACAS Bonpote' })
gap: 6px;
padding: 4px 12px;
background: var(--nav-bg);
border-top: 1px solid var(--nav-bg-alt);
border-bottom: 1px solid var(--nav-bg-alt);
border-top: 1px solid rgba(180, 170, 160, 0.22);
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
min-height: 38px;
}
@@ -285,7 +285,7 @@ useHead({ title: 'AEP - Media - Carte FRACAS Bonpote' })
overflow: hidden;
position: relative;
transition: flex-basis 0.3s ease, height 0.3s ease, opacity 0.2s ease;
border-top: 1px solid var(--nav-bg-alt);
border-top: 1px solid rgba(180, 170, 160, 0.28);
}
.chatbot-split {

View File

@@ -5,7 +5,7 @@
"corpus_ingere": 141,
"auteurs_count": 28,
"livres_count": 64,
"ecoles_count": 11,
"ecoles_count": 10,
"note_doublons_en_fr": "3 livres avec version EN aussi indexee dans le RAG pour cross-language queries : carson-mer-autour-de-nous-fr/EN, graeber-wengrow-aurore-fr/EN, saito-capital-anthropocene/EN. JSON conserve la version FR.",
"note_v2_1": "Phase 6 refonte Bonpote-aligned : 11 ecoles (fusion Marxismes-ecologiques -> Ecosocialisme, Marx+Saito migres), palette pastel, positions x_hint/y_hint Bonpote-aligned, labels affiches renommes (eco-anarchisme -> Eco-anarchisme, technocritique, ethique-env singulier)",
"updated": "2026-05-12"
@@ -30,7 +30,7 @@
{
"id": "decroissance",
"label": "Décroissance",
"description": "Critique radicale de la croissance économique comme horizon. Pour une réduction volontaire de la production et de la consommation.",
"description": "Critique radicale de la croissance économique comme horizon. Pour une réduction volontaire de la production et de la consommation. Inclut la collapsologie (Servigne, Diamond, Randers) comme sous-courant.",
"color": "#ecc09c",
"x_hint": 0.42,
"y_hint": 0.45
@@ -75,14 +75,6 @@
"x_hint": 0.45,
"y_hint": 0.70
},
{
"id": "collapsologie",
"label": "Collapsologie",
"description": "Étude interdisciplinaire de l'effondrement de la civilisation industrielle et des voies de résilience. Articule sciences du vivant, géopolitique et psychologie de la transition.",
"color": "#a0afc0",
"x_hint": 0.68,
"y_hint": 0.42
},
{
"id": "capitalisme-vert",
"label": "Capitalisme vert",
@@ -287,8 +279,8 @@
"id": "pablo-servigne",
"nom": "Pablo Servigne",
"dates": "1978-",
"ecoles": ["collapsologie"],
"ecole_principale": "collapsologie",
"ecoles": ["decroissance"],
"ecole_principale": "decroissance",
"livres_rag": [
{ "slug": "servigne-effondrer", "titre": "Comment tout peut s'effondrer", "annee": 2015, "couches": ["fond", "structure"] },
{ "slug": "servigne-autre-fin-du-monde", "titre": "Une autre fin du monde est possible", "annee": 2018, "couches": ["fond", "structure"] },