diff --git a/components/CartePensees.vue b/components/CartePensees.vue index c3c2329..3123994 100644 --- a/components/CartePensees.vue +++ b/components/CartePensees.vue @@ -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(null) const tooltipRef = ref(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(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() + 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 = `${ecole.label}
${ecole.description}` + 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 = `Influence
Passerelles : ${link.auteurs_passerelle.join(', ')}` + 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() - 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((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((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() .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 }) diff --git a/pages/media.vue b/pages/media.vue index 70f7e76..789c450 100644 --- a/pages/media.vue +++ b/pages/media.vue @@ -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 { diff --git a/public/data/auteurs-pensees.json b/public/data/auteurs-pensees.json index 42a143b..0615ca7 100644 --- a/public/data/auteurs-pensees.json +++ b/public/data/auteurs-pensees.json @@ -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"] },