- GraphView : barre hashtags du haut supprimee, sidebar droite 200px groupee par famille (heuristique majoritaire, egalite -> famille la plus petite), toggle >>/<< vers 40px replie - pages/index.vue : ChatbotPlaceholder ajoute sous GraphView en flex-column - watch sidebarOpen : reinit D3 apres transition CSS pour SVG resize
436 lines
15 KiB
Vue
436 lines
15 KiB
Vue
<template>
|
|
<div class="graph-view" style="width: 100%; height: 100%; position: relative; background: var(--nav-bg);">
|
|
<!-- Canvas SVG pour D3 (zone centrale, marge droite pour sidebar) -->
|
|
<svg
|
|
ref="svgRef"
|
|
:style="{
|
|
width: sidebarOpen ? 'calc(100% - 200px)' : 'calc(100% - 40px)',
|
|
height: '100%',
|
|
transition: 'width 0.2s ease',
|
|
}"
|
|
></svg>
|
|
|
|
<!-- Sidebar hashtags droite (repliable) -->
|
|
<aside
|
|
:style="{
|
|
position: 'absolute', top: '0', right: '0', bottom: '0',
|
|
width: sidebarOpen ? '200px' : '40px',
|
|
background: 'var(--nav-surface)',
|
|
borderLeft: '1px solid var(--nav-bg-alt)',
|
|
display: 'flex', flexDirection: 'column',
|
|
transition: 'width 0.2s ease',
|
|
zIndex: 10,
|
|
}"
|
|
>
|
|
<!-- Toggle (toujours visible) -->
|
|
<button
|
|
@click="sidebarOpen = !sidebarOpen"
|
|
:title="sidebarOpen ? 'Replier la sidebar' : 'Deplier la sidebar'"
|
|
style="
|
|
width: 100%; height: 36px; flex-shrink: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: var(--nav-bg-alt); border: none; cursor: pointer;
|
|
color: var(--nav-text-muted); font-size: 0.78rem; font-weight: 700;
|
|
border-bottom: 1px solid var(--nav-bg-alt);
|
|
"
|
|
>{{ sidebarOpen ? '>>' : '<<' }}</button>
|
|
|
|
<!-- Mode replie : label vertical -->
|
|
<div
|
|
v-if="!sidebarOpen"
|
|
style="
|
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
|
writing-mode: vertical-rl; transform: rotate(180deg);
|
|
font-size: 0.7rem; font-weight: 700; color: var(--nav-text-muted);
|
|
letter-spacing: 0.12em; text-transform: uppercase;
|
|
"
|
|
>HASHTAGS ({{ activeHashtags.length }}/{{ props.allHashtags.length }})</div>
|
|
|
|
<!-- Mode deplie : header + liste groupee -->
|
|
<template v-if="sidebarOpen">
|
|
<div style="padding: 8px 12px; border-bottom: 1px solid var(--nav-bg-alt); flex-shrink: 0;">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
|
|
<span style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em;">Hashtags</span>
|
|
<span style="font-size: 0.68rem; color: var(--nav-text-muted);">{{ activeHashtags.length }} actif{{ activeHashtags.length > 1 ? 's' : '' }}</span>
|
|
</div>
|
|
<button
|
|
v-if="activeHashtags.length"
|
|
@click="activeHashtags = []"
|
|
style="margin-top: 4px; font-size: 0.68rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
|
|
>Tout effacer</button>
|
|
</div>
|
|
|
|
<div style="flex: 1; overflow-y: auto; padding: 6px 10px 10px;">
|
|
<div
|
|
v-for="group in hashtagsByFamille"
|
|
:key="group.famille"
|
|
style="margin-bottom: 10px;"
|
|
>
|
|
<div
|
|
:style="{
|
|
fontSize: '0.65rem', fontWeight: 700,
|
|
color: group.color, textTransform: 'uppercase',
|
|
letterSpacing: '0.06em', marginBottom: '4px',
|
|
paddingLeft: '2px',
|
|
}"
|
|
>{{ group.label }}</div>
|
|
<div style="display: flex; flex-wrap: wrap; gap: 3px;">
|
|
<span
|
|
v-for="tag in group.tags"
|
|
:key="tag"
|
|
style="padding: 2px 7px; border-radius: 9999px; font-size: 0.66rem; cursor: pointer; transition: all 0.12s;"
|
|
:style="activeHashtags.includes(tag)
|
|
? `background: ${group.color}; color: white; font-weight: 600;`
|
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
|
@click="toggleHashtag(tag)"
|
|
>{{ tag }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</aside>
|
|
|
|
<!-- Tooltip -->
|
|
<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: 220px; 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">
|
|
import type { ReseauxBifurcationData } from '~/types/structure-v2'
|
|
|
|
const props = defineProps<{
|
|
data: ReseauxBifurcationData | null
|
|
allHashtags: string[]
|
|
active?: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'select-structure': [id: string]
|
|
}>()
|
|
|
|
const svgRef = ref<SVGElement | null>(null)
|
|
const tooltipRef = ref<HTMLElement | null>(null)
|
|
|
|
// Hashtag filter
|
|
const activeHashtags = ref<string[]>([])
|
|
const sidebarOpen = ref(true)
|
|
|
|
function toggleHashtag(tag: string) {
|
|
activeHashtags.value = activeHashtags.value.includes(tag)
|
|
? activeHashtags.value.filter(t => t !== tag)
|
|
: [...activeHashtags.value, tag]
|
|
}
|
|
|
|
// Mapping hashtag -> famille majoritaire
|
|
// En cas d'egalite : prendre la famille la plus petite (visibilite minoritaires)
|
|
const hashtagsByFamille = computed(() => {
|
|
if (!props.data) return []
|
|
// 1. Pour chaque hashtag, compter les structures par famille
|
|
const counts: Record<string, Record<number, number>> = {}
|
|
props.data.structures.forEach(s => {
|
|
s.hashtags.forEach(tag => {
|
|
if (!counts[tag]) counts[tag] = {}
|
|
counts[tag][s.famille_principale] = (counts[tag][s.famille_principale] ?? 0) + 1
|
|
})
|
|
})
|
|
// 2. Pour chaque hashtag, trouver la famille majoritaire (egalite -> + petite famille)
|
|
// Pour preferer la famille la moins peuplee globalement, calculer la taille de chaque famille.
|
|
const familleSize: Record<number, number> = {}
|
|
props.data.structures.forEach(s => {
|
|
familleSize[s.famille_principale] = (familleSize[s.famille_principale] ?? 0) + 1
|
|
})
|
|
const tagToFamille: Record<string, number> = {}
|
|
for (const tag in counts) {
|
|
const entries = Object.entries(counts[tag])
|
|
entries.sort((a, b) => {
|
|
const diff = (b[1] as number) - (a[1] as number)
|
|
if (diff !== 0) return diff
|
|
// egalite : famille avec moins de structures gagne
|
|
return (familleSize[Number(a[0])] ?? 0) - (familleSize[Number(b[0])] ?? 0)
|
|
})
|
|
tagToFamille[tag] = Number(entries[0][0])
|
|
}
|
|
// 3. Grouper les hashtags par famille
|
|
const groups: Record<number, string[]> = {}
|
|
props.allHashtags.forEach(tag => {
|
|
const fam = tagToFamille[tag]
|
|
if (fam == null) return
|
|
if (!groups[fam]) groups[fam] = []
|
|
groups[fam].push(tag)
|
|
})
|
|
// 4. Sortie ordonnee selon ID de famille
|
|
return [1, 2, 3, 4, 5, 6]
|
|
.filter(famId => groups[famId]?.length)
|
|
.map(famId => ({
|
|
famille: famId,
|
|
label: FAMILLE_LABELS[famId],
|
|
color: FAMILLE_COLORS[famId],
|
|
tags: groups[famId].sort(),
|
|
}))
|
|
})
|
|
|
|
// IDs de structures correspondant aux hashtags actifs
|
|
const filteredStructureIds = computed(() => {
|
|
if (!props.data || !activeHashtags.value.length) return null
|
|
const ids = new Set(
|
|
props.data.structures
|
|
.filter(s => activeHashtags.value.every(h => s.hashtags.includes(h)))
|
|
.map(s => s.id)
|
|
)
|
|
return ids
|
|
})
|
|
|
|
const FAMILLE_COLORS: Record<number, string> = {
|
|
1: '#a85d3e',
|
|
2: '#c4a472',
|
|
3: '#d4a017',
|
|
4: '#5a7a4a',
|
|
5: '#3d6a8c',
|
|
6: '#6b3fa0',
|
|
}
|
|
|
|
const FAMILLE_LABELS: Record<number, string> = {
|
|
1: 'Reemploi',
|
|
2: 'Frugalite',
|
|
3: 'Social',
|
|
4: 'Collectifs',
|
|
5: 'Urbanisme',
|
|
6: 'Recherche',
|
|
}
|
|
|
|
let simulation: any = null
|
|
let d3NodeSelection: any = null
|
|
let d3LinkSelection: any = null
|
|
|
|
async function initGraph() {
|
|
if (!svgRef.value || !props.data) return
|
|
|
|
const d3 = await import('d3')
|
|
|
|
const svgEl = svgRef.value
|
|
const width = svgEl.clientWidth || 800
|
|
const height = svgEl.clientHeight || 600
|
|
|
|
// Nettoyer
|
|
d3.select(svgEl).selectAll('*').remove()
|
|
|
|
const svg = d3.select(svgEl)
|
|
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
|
|
// Groupe principal avec zoom
|
|
const g = svg.append('g')
|
|
const zoomBehavior = d3.zoom<SVGElement, unknown>()
|
|
.scaleExtent([0.2, 4])
|
|
.on('zoom', (event) => g.attr('transform', event.transform))
|
|
|
|
svg.call(zoomBehavior as any)
|
|
|
|
// Noeuds familles (centres fixes en etoile)
|
|
const familyNodes = [1, 2, 3, 4, 5, 6].map(id => ({
|
|
id: `family-${id}`,
|
|
type: 'family',
|
|
familleId: id,
|
|
label: FAMILLE_LABELS[id],
|
|
color: FAMILLE_COLORS[id],
|
|
r: 32,
|
|
x: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
|
|
y: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
|
|
fx: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
|
|
fy: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
|
|
}))
|
|
|
|
// Noeuds structures
|
|
const structureNodes = props.data.structures.map(s => ({
|
|
id: s.id,
|
|
type: 'structure',
|
|
label: s.nom,
|
|
famille: s.famille_principale,
|
|
familles_secondaires: s.familles_secondaires ?? [],
|
|
hashtags: s.hashtags,
|
|
color: FAMILLE_COLORS[s.famille_principale] ?? '#888',
|
|
r: 8,
|
|
x: undefined as number | undefined,
|
|
y: undefined as number | undefined,
|
|
fx: undefined as number | null | undefined,
|
|
fy: undefined as number | null | undefined,
|
|
}))
|
|
|
|
const allNodes: any[] = [...familyNodes, ...structureNodes]
|
|
|
|
// Liens structures -> familles
|
|
const links: any[] = []
|
|
structureNodes.forEach(s => {
|
|
links.push({
|
|
source: s.id,
|
|
target: `family-${s.famille}`,
|
|
type: 'primary',
|
|
strength: 0.55,
|
|
})
|
|
s.familles_secondaires.forEach((f: number) => {
|
|
links.push({
|
|
source: s.id,
|
|
target: `family-${f}`,
|
|
type: 'secondary',
|
|
strength: 0.45,
|
|
})
|
|
})
|
|
})
|
|
|
|
// Simulation force-directed
|
|
if (simulation) simulation.stop()
|
|
simulation = d3.forceSimulation(allNodes)
|
|
.force('link', d3.forceLink(links).id((d: any) => d.id).distance((d: any) => d.type === 'primary' ? 80 : 120).strength((d: any) => d.strength ?? 0.5))
|
|
.force('charge', d3.forceManyBody().strength(-120))
|
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
.force('collision', d3.forceCollide().radius((d: any) => d.r + 4))
|
|
|
|
// Rendu liens
|
|
d3LinkSelection = g.append('g').selectAll('line')
|
|
.data(links)
|
|
.join('line')
|
|
.attr('stroke', (d: any) => d.type === 'primary' ? 'rgba(150,150,150,0.45)' : 'rgba(150,150,150,0.35)')
|
|
.attr('stroke-width', 1.5)
|
|
.attr('stroke-dasharray', null)
|
|
|
|
// Rendu noeuds (groupes g)
|
|
d3NodeSelection = g.append('g').selectAll('g')
|
|
.data(allNodes)
|
|
.join('g')
|
|
.style('cursor', (d: any) => d.type === 'structure' ? 'pointer' : 'default')
|
|
.call(
|
|
d3.drag<any, any>()
|
|
.on('start', (event: any, d: any) => {
|
|
if (!event.active) simulation.alphaTarget(0.3).restart()
|
|
d.fx = d.x
|
|
d.fy = d.y
|
|
})
|
|
.on('drag', (event: any, d: any) => {
|
|
d.fx = event.x
|
|
d.fy = event.y
|
|
})
|
|
.on('end', (event: any, d: any) => {
|
|
if (!event.active) simulation.alphaTarget(0)
|
|
if (d.type !== 'family') {
|
|
d.fx = null
|
|
d.fy = null
|
|
}
|
|
})
|
|
)
|
|
.on('click', (_event: any, d: any) => {
|
|
if (d.type === 'structure') emit('select-structure', d.id)
|
|
})
|
|
|
|
// Cercles
|
|
d3NodeSelection.append('circle')
|
|
.attr('r', (d: any) => d.r)
|
|
.attr('fill', (d: any) => d.type === 'family' ? d.color : d.color + 'cc')
|
|
.attr('stroke', (d: any) => d.type === 'family' ? 'white' : d.color)
|
|
.attr('stroke-width', (d: any) => d.type === 'family' ? 3 : 1.5)
|
|
|
|
// Labels familles
|
|
d3NodeSelection.filter((d: any) => d.type === 'family')
|
|
.append('text')
|
|
.text((d: any) => d.label)
|
|
.attr('text-anchor', 'middle')
|
|
.attr('dy', '0.35em')
|
|
.attr('font-size', '11px')
|
|
.attr('font-weight', '700')
|
|
.attr('fill', 'white')
|
|
.style('pointer-events', 'none')
|
|
|
|
// Tooltip hover pour structures
|
|
d3NodeSelection.filter((d: any) => d.type === 'structure')
|
|
.on('mouseenter', (_event: any, d: any) => {
|
|
if (!tooltipRef.value) return
|
|
tooltipRef.value.style.opacity = '1'
|
|
tooltipRef.value.innerHTML = `<strong>${d.label}</strong><br><span style="opacity:0.6;font-size:0.7rem;">${FAMILLE_LABELS[d.famille] ?? ''}</span>`
|
|
})
|
|
.on('mousemove', (event: any) => {
|
|
if (!tooltipRef.value || !svgEl) return
|
|
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
|
tooltipRef.value.style.left = (event.clientX - rect.left + 12) + 'px'
|
|
tooltipRef.value.style.top = (event.clientY - rect.top - 10) + 'px'
|
|
})
|
|
.on('mouseleave', () => {
|
|
if (tooltipRef.value) tooltipRef.value.style.opacity = '0'
|
|
})
|
|
|
|
// Tick - mise a jour positions
|
|
simulation.on('tick', () => {
|
|
d3LinkSelection
|
|
.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)
|
|
|
|
d3NodeSelection.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
|
|
|
// Surlignage selon hashtags actifs
|
|
applyHashtagFilter()
|
|
})
|
|
}
|
|
|
|
function applyHashtagFilter() {
|
|
if (!d3NodeSelection || !d3LinkSelection) return
|
|
if (filteredStructureIds.value) {
|
|
const ids = filteredStructureIds.value
|
|
d3NodeSelection.filter((d: any) => d.type === 'structure').select('circle')
|
|
.attr('opacity', (d: any) => ids.has(d.id) ? 1 : 0.1)
|
|
d3LinkSelection.attr('opacity', (d: any) => {
|
|
const srcId = typeof d.source === 'object' ? d.source.id : d.source
|
|
return ids.has(srcId) ? 1 : 0.05
|
|
})
|
|
} else {
|
|
d3NodeSelection.select('circle').attr('opacity', 1)
|
|
d3LinkSelection.attr('opacity', 1)
|
|
}
|
|
}
|
|
|
|
// Déclencher quand l'onglet devient visible
|
|
// Double rAF : nextTick met à jour le vdom, les 2 frames garantissent que
|
|
// le browser a calculé le layout et que clientWidth/clientHeight != 0
|
|
watch(() => props.active, (val) => {
|
|
if (val && import.meta.client && props.data) {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
|
}
|
|
})
|
|
|
|
// Relancer si les données arrivent après l'activation
|
|
watch(() => props.data, (val) => {
|
|
if (val && props.active && import.meta.client) {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
|
}
|
|
})
|
|
|
|
// Re-appliquer le filtre visuel sans rebuild complet
|
|
watch(activeHashtags, () => {
|
|
applyHashtagFilter()
|
|
if (simulation) simulation.alpha(0.01).restart()
|
|
}, { deep: true })
|
|
|
|
// Toggle sidebar : largeur SVG change -> reinit graphe apres transition CSS
|
|
watch(sidebarOpen, () => {
|
|
if (!import.meta.client || !props.active || !props.data) return
|
|
setTimeout(() => {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
|
}, 220)
|
|
})
|
|
|
|
onMounted(async () => {
|
|
if (import.meta.client && props.data && props.active) {
|
|
await nextTick()
|
|
initGraph()
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (simulation) simulation.stop()
|
|
})
|
|
</script>
|