feat(v2-graphe): sidebar hashtags droite repliable + chatbot integre
- 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
This commit is contained in:
435
components/GraphView.vue
Normal file
435
components/GraphView.vue
Normal file
@@ -0,0 +1,435 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user