- auteurs-pensees.json: 11 ecoles (suppression marxismes-ecologiques, fusion Marx+Saito->ecosocialisme), palette pastel, positions x_hint/y_hint Bonpote-aligned - CartePensees.vue: texte ecole blanc->#1a1a1a, background #f5f3f0, linkDistance 130, charge -50, forceX/forceY ajoutescode pour ancrer auteurs pres de leur ecole principale - app.vue: onglet desktop RAG->MEDIA sans badge, menu mobile to=/rag->to=/media avec active state conditionnel
164 lines
7.7 KiB
Vue
164 lines
7.7 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; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
|
|
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
|
|
|
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
|
|
|
|
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]))
|
|
|
|
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,
|
|
}))
|
|
|
|
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]
|
|
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 }))
|
|
})
|
|
|
|
// 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 }) })
|
|
|
|
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('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))
|
|
.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))
|
|
|
|
d3LinkSel = g.append('g').selectAll('line').data(links).join('line')
|
|
.attr('stroke', 'rgba(150,150,150,0.3)').attr('stroke-width', 1.2)
|
|
|
|
d3NodeSel = g.append('g').selectAll('g').data(allNodes).join('g')
|
|
.style('cursor', (d: any) => d.type === 'auteur' ? '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); 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) })
|
|
|
|
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)
|
|
|
|
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')
|
|
.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')
|
|
.style('pointer-events', 'none')
|
|
|
|
d3NodeSel.filter((d: any) => d.type === 'auteur')
|
|
.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
|
|
tooltipRef.value.innerHTML = `<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}</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.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)
|
|
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() })
|
|
|
|
// Expose pour reset D3 apres resize du conteneur
|
|
function triggerResize() {
|
|
if (simulation) {
|
|
simulation.alpha(0.3).restart()
|
|
} else if (import.meta.client && props.data && props.active) {
|
|
initGraph()
|
|
}
|
|
}
|
|
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; }
|
|
</style>
|