Files
nav-carte/components/CartePensees.vue

44 lines
17 KiB
Vue

<template> <div style="width: 100%; height: 100%; position: relative; background: #f5f3f0;"> <svg ref="svgRef" style="width: 100%; height: 100%;"></svg> <div ref="tooltipRef" style=" position: absolute; pointer-events: none; background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); border-radius: 6px; padding: 8px 12px; font-size: 0.78rem; color: var(--nav-text); max-width: 240px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); opacity: 0; transition: opacity 0.15s; z-index: 100; "></div> </div> </template> <script setup lang="ts"> interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number } interface LivreRag { slug: string; titre: string; annee: number; couches: string[] } interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; ingere: boolean; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string; bio_courte_provisoire?: 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]; 'select-ecole': [id: string] }>() const svgRef = ref<SVGElement | null>(null) const tooltipRef = ref<HTMLElement | null>(null) let simulation: 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 svgEl = svgRef.value const W = svgEl.clientWidth || 900 const H = svgEl.clientHeight || 600 d3.select(svgEl).selectAll('*').remove() const svg = d3.select(svgEl).attr('viewBox', `0 0 ${W} ${H}`) const g = svg.append('g') svg.call(d3.zoom<SVGElement, unknown>().scaleExtent([0.3, 4]).on('zoom', (e) => g.attr('transform', e.transform)) as any) const ecoleMap = new Map<string, EcoleData>(props.data.ecoles.map(e => [e.id, e])) // Positions fixes des ecoles (base pour forces D3) 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 }) }) // ---- 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) ---- // Pre-positionner chaque auteur pres de son ecole + jitter aleatoire pour eviter le rush initial vers la droite const auteurNodes: any[] = props.data.auteurs.map(a => { const ecole = ecoleMap.get(a.ecole_principale) const jitter = () => (Math.random() - 0.5) * 80 return { id: a.id, type: 'auteur', nom: a.nom, dates: a.dates, bio_courte: a.bio_courte, bio_provisoire: a.bio_courte_provisoire ?? '', ingere: a.ingere, ecole_principale: a.ecole_principale, color: ecole?.color ?? '#888', r: 11, x: W * (ecole?.x_hint ?? 0.5) + jitter(), y: H * (ecole?.y_hint ?? 0.5) + jitter(), } }) // Liens appartenance auteur -> ecole (vers centroid fixe) const links: any[] = [] props.data.auteurs.forEach(a => { 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 }) }) }) // 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, })) // Rayon proportionnel au nombre d'auteurs de l'ecole const ecoleAuteurCounts = new Map<string, number>() props.data.ecoles.forEach(e => ecoleAuteurCounts.set(e.id, 0)) props.data.auteurs.forEach(a => ecoleAuteurCounts.set(a.ecole_principale, (ecoleAuteurCounts.get(a.ecole_principale) ?? 0) + 1)) const ecoleRadius = (count: number) => Math.max(16, Math.min(36, 13 + count * 1.5)) const allNodes = [...ecoleFixedNodes, ...auteurNodes] if (simulation) simulation.stop() // Phase 8.D : sim ajustee pour 171 auteurs (vs 28 v2.1, densite 6x) simulation = d3.forceSimulation(allNodes) .force('link', d3.forceLink(links).id((d: any) => d.id).distance(120).strength((d: any) => d.strength ?? 0.5)) .force('charge', d3.forceManyBody().strength(-70)) .force('center', d3.forceCenter(W / 2, H / 2).strength(0.02)) .force('collision', d3.forceCollide().radius((d: any) => d.type === 'ecole-fixed' ? ecoleRadius(ecoleAuteurCounts.get(d.ecoleId) ?? 0) + 4 : 12)) .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.15)) .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.15)) // ---- NOEUDS ECOLES visibles (couche 3.5) ---- // Cercles proportionnels au count d'auteurs, fixes aux centroids Bonpote, cliquables const gEcoles = g.append('g').attr('class', 'ecoles-nodes') ecoleFixedNodes.forEach(eNode => { const ecole = ecoleMap.get(eNode.ecoleId) if (!ecole) return const count = ecoleAuteurCounts.get(eNode.ecoleId) ?? 0 const r = ecoleRadius(count) gEcoles.append('circle') .attr('cx', eNode.fx).attr('cy', eNode.fy).attr('r', r) .attr('fill', ecole.color).attr('fill-opacity', 0.82).attr('stroke', ecole.color).attr('stroke-width', 2) .attr('class', 'ecole-node').style('cursor', 'pointer') .on('mouseenter', (e: any) => { if (!tooltipRef.value) return tooltipRef.value.innerHTML = `<strong>${ecole.label}</strong> <span style="opacity:0.6;font-size:0.7rem;">${count} auteur${count > 1 ? 's' : ''}</span><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' }) .on('click', (e: any) => { e.stopPropagation(); emit('select-ecole', eNode.ecoleId) })
// ---- TITRES ECOLES visibles en permanence (sous-mission 1) ----
const labelText = ecole.label
const words = labelText.split(' ')
const fontSize = Math.max(12, r * 0.45)
// Si le label est long (>2 mots ou >12 chars), on split en 2 tspans
if (words.length > 2 || labelText.length > 12) {
const mid = Math.ceil(words.length / 2)
const line1 = words.slice(0, mid).join(' ')
const line2 = words.slice(mid).join(' ')
const textEl = gEcoles.append('text')
.attr('x', eNode.fx)
.attr('y', eNode.fy)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('pointer-events', 'none')
.style('font-weight', '700')
.style('font-size', `${fontSize}px`)
.style('fill', '#ffffff')
.style('text-shadow', '0 1px 3px rgba(0,0,0,0.5)')
.style('user-select', 'none')
textEl.append('tspan')
.attr('x', eNode.fx)
.attr('dy', `-${fontSize * 0.6}px`)
.text(line1)
textEl.append('tspan')
.attr('x', eNode.fx)
.attr('dy', `${fontSize * 1.2}px`)
.text(line2)
} else {
gEcoles.append('text')
.attr('x', eNode.fx)
.attr('y', eNode.fy)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('pointer-events', 'none')
.style('font-weight', '700')
.style('font-size', `${fontSize}px`)
.style('fill', '#ffffff')
.style('user-select', 'none')
.text(labelText)
}
}) // ---- 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) // ---- 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', (d: any) => d.ingere ? 'pointer' : 'default') .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); d.fx = null; d.fy = null })) .on('click', (e: any, d: any) => { if (!d.ingere) return e.stopPropagation() emit('select-auteur', d.id) }) // Phase 8.D : grisage conditionnel auteurs non-ingeres (ingere:false) d3NodeSel.append('circle') .attr('r', (d: any) => d.r) .attr('fill', (d: any) => d.ingere ? (d.color + 'cc') : '#bbbbbb') .attr('stroke', (d: any) => d.ingere ? d.color : '#999999') .attr('stroke-width', 1.5) .attr('opacity', (d: any) => d.ingere ? 1 : 0.35) // ---- 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)) .style('pointer-events', 'none') .style('opacity', (d: any) => d.ingere ? 1 : 0.3) .style('fill', (d: any) => d.ingere ? '#1a1a1a' : '#777777') d3NodeSel .on('mouseenter', (e: any, d: any) => { if (!tooltipRef.value) return let tooltipHtml = '' if (d.ingere) { const rawBio = d.bio_courte || '' const bio = rawBio.length > 90 ? rawBio.slice(0, 87) + '...' : rawBio tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio || 'Dans le RAG ATIS.'}</span>` } else { tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.65;font-size:0.72rem;font-style:italic;">Présent dans Bonpote, pas encore ingéré dans le RAG ATIS.</span>` } tooltipRef.value.innerHTML = tooltipHtml 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.on('tick', () => { 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})`) }) } watch(() => props.active, (val) => { if (val && import.meta.client && props.data) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) }) watch(() => props.data, (val) => { if (val && props.active && import.meta.client) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) }) onMounted(async () => { if (import.meta.client && props.data && props.active) { await nextTick(); initGraph() } }) onUnmounted(() => { if (simulation) simulation.stop() }) function triggerResize() { if (simulation) { simulation.alpha(0.3).restart() } else if (import.meta.client && props.data && props.active) { initGraph() } } defineExpose({ triggerResize }) </script> <style> /* ---- 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 : non-blurre Phase 8.F (revert Phase 8.D) ---- */ /* Blur retire ; les cellules colorees Bonpote-aligned suffisent visuellement. */ .ecole-node { transition: opacity 0.15s, r 0.15s; } .ecole-node:hover { opacity: 0.75; } </style>