wip: snapshot V2 cascade onglet 2 (sauvegarde avant chirurgie git-hygiene)
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
}"
|
||||
></svg>
|
||||
|
||||
<!-- Sidebar hashtags droite (repliable) -->
|
||||
<!-- Sidebar droite (repliable) - 3 sections : AFFICHER / HASHTAGS / MODE D'EMPLOI -->
|
||||
<aside
|
||||
:style="{
|
||||
position: 'absolute', top: '0', right: '0', bottom: '0',
|
||||
@@ -46,46 +46,92 @@
|
||||
"
|
||||
>HASHTAGS ({{ activeHashtags.length }}/{{ props.allHashtags.length }})</div>
|
||||
|
||||
<!-- Mode deplie : header + liste groupee -->
|
||||
<!-- Mode deplie : 3 sections empilees -->
|
||||
<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; display: flex; flex-direction: column;">
|
||||
|
||||
<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
|
||||
<!-- SECTION 1 : AFFICHER (toggles familles / pratiques) -->
|
||||
<div style="padding: 10px 12px; flex-shrink: 0;">
|
||||
<div style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px;">Afficher</div>
|
||||
<label
|
||||
:style="{
|
||||
fontSize: '0.65rem', fontWeight: 700,
|
||||
color: group.color, textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em', marginBottom: '4px',
|
||||
paddingLeft: '2px',
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '7px 10px', marginBottom: '4px',
|
||||
borderRadius: '6px', cursor: 'pointer',
|
||||
fontSize: '0.82rem', fontWeight: 600,
|
||||
background: showFamilles ? 'var(--nav-bg-alt)' : 'transparent',
|
||||
color: showFamilles ? 'var(--nav-text)' : 'var(--nav-text-muted)',
|
||||
transition: 'all 0.12s',
|
||||
}"
|
||||
>{{ 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>
|
||||
>
|
||||
<input type="checkbox" v-model="showFamilles" style="cursor: pointer; width: 14px; height: 14px;" />
|
||||
<span>Familles</span>
|
||||
</label>
|
||||
<label
|
||||
:style="{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '7px 10px',
|
||||
borderRadius: '6px', cursor: 'pointer',
|
||||
fontSize: '0.82rem', fontWeight: 600,
|
||||
background: showPratiques ? 'var(--nav-bg-alt)' : 'transparent',
|
||||
color: showPratiques ? 'var(--nav-text)' : 'var(--nav-text-muted)',
|
||||
transition: 'all 0.12s',
|
||||
}"
|
||||
>
|
||||
<input type="checkbox" v-model="showPratiques" style="cursor: pointer; width: 14px; height: 14px;" />
|
||||
<span>Pratiques</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 2 : HASHTAGS (chips groupees) -->
|
||||
<div style="border-top: 1px solid var(--nav-bg-alt); margin-top: 0; padding: 10px 12px 8px; flex-shrink: 0;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px;">
|
||||
<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-bottom: 6px; 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: 0 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>
|
||||
|
||||
<!-- SECTION 3 : MODE D'EMPLOI -->
|
||||
<div style="border-top: 1px solid var(--nav-bg-alt); padding: 10px 12px; flex-shrink: 0;">
|
||||
<div style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px;">Mode d'emploi</div>
|
||||
<div style="font-size: 0.7rem; color: var(--nav-text-muted); line-height: 1.5;">
|
||||
La carte croise des familles editoriales avec des pratiques (hashtags). Coche les couches a afficher, filtre par hashtag, clique sur un noeud pour en savoir plus.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
@@ -98,6 +144,123 @@
|
||||
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>
|
||||
|
||||
<!-- Popover unifie (famille OU hashtag) -->
|
||||
<div
|
||||
v-if="popover.open"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
left: popover.x + 'px',
|
||||
top: popover.y + 'px',
|
||||
background: 'var(--nav-surface)',
|
||||
border: '1px solid var(--nav-bg-alt)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 14px',
|
||||
maxWidth: '280px',
|
||||
boxShadow: '0 6px 18px rgba(0,0,0,0.18)',
|
||||
zIndex: 50,
|
||||
}"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
@click="closePopover"
|
||||
style="
|
||||
position: absolute; top: 4px; right: 6px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
font-size: 1rem; color: var(--nav-text-muted); padding: 2px 6px;
|
||||
line-height: 1;
|
||||
"
|
||||
title="Fermer"
|
||||
>x</button>
|
||||
<div
|
||||
:style="{
|
||||
fontWeight: 700, fontSize: '0.92rem',
|
||||
color: popover.color, marginBottom: '6px',
|
||||
paddingRight: '14px',
|
||||
}"
|
||||
>{{ popover.title }}</div>
|
||||
|
||||
<!-- Body famille : description + compteur + 6 structures + bouton "Voir toutes" -->
|
||||
<div v-if="popover.kind === 'famille'">
|
||||
<div style="font-size: 0.78rem; line-height: 1.45; color: var(--nav-text); margin-bottom: 10px;">
|
||||
{{ popover.body }}
|
||||
</div>
|
||||
<div style="font-size: 0.72rem; color: var(--nav-text-muted); margin-bottom: 6px;">
|
||||
{{ popover.structures.length }} structure{{ popover.structures.length > 1 ? 's' : '' }} dans cette famille
|
||||
</div>
|
||||
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px;">
|
||||
<li
|
||||
v-for="s in popover.structures.slice(0, 6)"
|
||||
:key="s.id"
|
||||
@click="selectStructureFromPopover(s.id)"
|
||||
style="
|
||||
font-size: 0.78rem; color: var(--nav-text);
|
||||
padding: 4px 6px; border-radius: 4px;
|
||||
cursor: pointer; transition: background 0.1s;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
"
|
||||
@mouseenter="(e: any) => e.currentTarget.style.background = 'var(--nav-bg-alt)'"
|
||||
@mouseleave="(e: any) => e.currentTarget.style.background = 'transparent'"
|
||||
>
|
||||
<span
|
||||
style="width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;"
|
||||
:style="`background: ${popover.color};`"
|
||||
/>
|
||||
<span>{{ s.nom }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
v-if="popover.familleId != null"
|
||||
@click="openFicheFamilleFromPopover"
|
||||
style="
|
||||
margin-top: 10px; width: 100%;
|
||||
padding: 7px 10px; border-radius: 6px;
|
||||
background: var(--nav-bg-alt); border: none;
|
||||
font-size: 0.75rem; font-weight: 600; cursor: pointer;
|
||||
color: var(--nav-text); transition: opacity 0.12s;
|
||||
text-align: left;
|
||||
"
|
||||
@mouseenter="(e: any) => e.currentTarget.style.opacity = '0.7'"
|
||||
@mouseleave="(e: any) => e.currentTarget.style.opacity = '1'"
|
||||
>Voir toutes les {{ popover.structures.length }} pratiques -></button>
|
||||
</div>
|
||||
|
||||
<!-- Body hashtag : ligne generique + compteur + liste structures cliquables -->
|
||||
<div v-else-if="popover.kind === 'hashtag'">
|
||||
<div
|
||||
style="
|
||||
font-size: 0.72rem; color: var(--nav-text-muted);
|
||||
font-style: italic; margin-bottom: 8px; line-height: 1.4;
|
||||
"
|
||||
>Pratique transversale - portee par {{ popover.structures.length }} structure{{ popover.structures.length > 1 ? 's' : '' }} de {{ popover.famillesCount }} famille{{ popover.famillesCount > 1 ? 's' : '' }}</div>
|
||||
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px;">
|
||||
<li
|
||||
v-for="s in popover.structures.slice(0, 6)"
|
||||
:key="s.id"
|
||||
@click="selectStructureFromPopover(s.id)"
|
||||
style="
|
||||
font-size: 0.78rem; color: var(--nav-text);
|
||||
padding: 4px 6px; border-radius: 4px;
|
||||
cursor: pointer; transition: background 0.1s;
|
||||
"
|
||||
@mouseenter="(e: any) => e.currentTarget.style.background = 'var(--nav-bg-alt)'"
|
||||
@mouseleave="(e: any) => e.currentTarget.style.background = 'transparent'"
|
||||
>{{ s.nom }}</li>
|
||||
</ul>
|
||||
<div
|
||||
v-if="popover.structures.length > 6"
|
||||
style="font-size: 0.7rem; color: var(--nav-text-muted); margin-top: 6px; padding-left: 6px;"
|
||||
>+ {{ popover.structures.length - 6 }} autre{{ popover.structures.length - 6 > 1 ? 's' : '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fiche famille modale -->
|
||||
<FicheFamilleModal
|
||||
v-model="ficheFamilleOpen"
|
||||
:famille-id="ficheFamilleId"
|
||||
:data="props.data"
|
||||
@select-structure="(id) => emit('select-structure', id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -121,17 +284,68 @@ const tooltipRef = ref<HTMLElement | null>(null)
|
||||
const activeHashtags = ref<string[]>([])
|
||||
const sidebarOpen = ref(true)
|
||||
|
||||
// Layers superposables (remplace viewMode exclusif PV2-5e)
|
||||
const showFamilles = ref(true)
|
||||
const showPratiques = ref(false)
|
||||
|
||||
function toggleHashtag(tag: string) {
|
||||
activeHashtags.value = activeHashtags.value.includes(tag)
|
||||
? activeHashtags.value.filter(t => t !== tag)
|
||||
: [...activeHashtags.value, tag]
|
||||
}
|
||||
|
||||
// Popover unifie (famille | hashtag)
|
||||
type PopoverState = {
|
||||
open: boolean
|
||||
kind: 'famille' | 'hashtag' | null
|
||||
x: number
|
||||
y: number
|
||||
title: string
|
||||
body: string
|
||||
color: string
|
||||
structures: { id: string; nom: string }[]
|
||||
familleId: number | null
|
||||
famillesCount: number
|
||||
}
|
||||
|
||||
const popover = ref<PopoverState>({
|
||||
open: false,
|
||||
kind: null,
|
||||
x: 0,
|
||||
y: 0,
|
||||
title: '',
|
||||
body: '',
|
||||
color: '#000',
|
||||
structures: [],
|
||||
familleId: null,
|
||||
famillesCount: 0,
|
||||
})
|
||||
|
||||
// Fiche famille modale
|
||||
const ficheFamilleOpen = ref(false)
|
||||
const ficheFamilleId = ref<number | null>(null)
|
||||
|
||||
function closePopover() {
|
||||
popover.value.open = false
|
||||
popover.value.kind = null
|
||||
}
|
||||
|
||||
function selectStructureFromPopover(id: string) {
|
||||
closePopover()
|
||||
emit('select-structure', id)
|
||||
}
|
||||
|
||||
function openFicheFamilleFromPopover() {
|
||||
if (popover.value.familleId == null) return
|
||||
ficheFamilleId.value = popover.value.familleId
|
||||
ficheFamilleOpen.value = true
|
||||
closePopover()
|
||||
}
|
||||
|
||||
// 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 tagToFamille = computed<Record<string, number>>(() => {
|
||||
if (!props.data) return {}
|
||||
const counts: Record<string, Record<number, number>> = {}
|
||||
props.data.structures.forEach(s => {
|
||||
s.hashtags.forEach(tag => {
|
||||
@@ -139,32 +353,33 @@ const hashtagsByFamille = computed(() => {
|
||||
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> = {}
|
||||
const out: 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])
|
||||
out[tag] = Number(entries[0][0])
|
||||
}
|
||||
// 3. Grouper les hashtags par famille
|
||||
return out
|
||||
})
|
||||
|
||||
const hashtagsByFamille = computed(() => {
|
||||
if (!props.data) return []
|
||||
const map = tagToFamille.value
|
||||
const groups: Record<number, string[]> = {}
|
||||
props.allHashtags.forEach(tag => {
|
||||
const fam = tagToFamille[tag]
|
||||
const fam = map[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 => ({
|
||||
@@ -175,6 +390,14 @@ const hashtagsByFamille = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// Structures portant un hashtag donne (pour popover)
|
||||
function structuresForHashtag(tag: string): { id: string; nom: string }[] {
|
||||
if (!props.data) return []
|
||||
return props.data.structures
|
||||
.filter(s => s.hashtags.includes(tag))
|
||||
.map(s => ({ id: s.id, nom: s.nom }))
|
||||
}
|
||||
|
||||
// IDs de structures correspondant aux hashtags actifs
|
||||
const filteredStructureIds = computed(() => {
|
||||
if (!props.data || !activeHashtags.value.length) return null
|
||||
@@ -204,6 +427,15 @@ const FAMILLE_LABELS: Record<number, string> = {
|
||||
6: 'Recherche',
|
||||
}
|
||||
|
||||
const FAMILLE_DESCRIPTIONS: Record<number, string> = {
|
||||
1: "Structures dont le geste premier est de travailler avec la matiere existante : deconstruction selective, plateformes de redistribution, filieres biosourcees et geosourcees.",
|
||||
2: "Pratiques qui partent du principe qu'on peut faire mieux avec moins. Renovation profonde, materiaux locaux, sobriete choisie.",
|
||||
3: "Structures dont le terrain premier est le mal-logement, la precarite, l'hospitalite. Architecture comme reponse a l'urgence sociale.",
|
||||
4: "Structures qui accompagnent les projets collectifs : cooperatives d'habitat, ecovillages, accompagnement vers l'autogestion ou la renovation.",
|
||||
5: "Demarches a l'echelle du territoire : villes en transition, PLU alternatifs, coalitions territoriales.",
|
||||
6: "Recherche-action et production de contre-savoirs (Forensic Architecture, Rural Studio, PEROU, Centrala). Badge transversal aux familles.",
|
||||
}
|
||||
|
||||
let simulation: any = null
|
||||
let d3NodeSelection: any = null
|
||||
let d3LinkSelection: any = null
|
||||
@@ -219,74 +451,43 @@ async function initGraph() {
|
||||
|
||||
// Nettoyer
|
||||
d3.select(svgEl).selectAll('*').remove()
|
||||
closePopover()
|
||||
|
||||
const svg = d3.select(svgEl)
|
||||
.attr('viewBox', `0 0 ${width} ${height}`)
|
||||
|
||||
// Click sur le SVG vide -> fermer popover
|
||||
svg.on('click', (event: any) => {
|
||||
if (event.target === svgEl) closePopover()
|
||||
})
|
||||
|
||||
// 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))
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform)
|
||||
closePopover()
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
const { allNodes, links } = buildNodesLinks(width, height)
|
||||
|
||||
// Simulation force-directed
|
||||
if (simulation) simulation.stop()
|
||||
|
||||
// Adapter la charge selon le nombre de noeuds (mode "tout coche" = plus de repulsion)
|
||||
const heavyMode = showPratiques.value && allNodes.length > 150
|
||||
|
||||
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('link', d3.forceLink(links).id((d: any) => d.id)
|
||||
.distance((d: any) => {
|
||||
if (d.type === 'practice') return 90
|
||||
return d.type === 'primary' ? 80 : 120
|
||||
})
|
||||
.strength((d: any) => d.strength ?? 0.5))
|
||||
.force('charge', d3.forceManyBody().strength(heavyMode ? -80 : -120))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius((d: any) => d.r + 4))
|
||||
|
||||
@@ -294,7 +495,10 @@ async function initGraph() {
|
||||
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', (d: any) => {
|
||||
if (d.type === 'practice') return 'rgba(150,150,150,0.25)'
|
||||
return d.type === 'primary' ? 'rgba(150,150,150,0.45)' : 'rgba(150,150,150,0.35)'
|
||||
})
|
||||
.attr('stroke-width', 1.5)
|
||||
.attr('stroke-dasharray', null)
|
||||
|
||||
@@ -302,13 +506,19 @@ async function initGraph() {
|
||||
d3NodeSelection = g.append('g').selectAll('g')
|
||||
.data(allNodes)
|
||||
.join('g')
|
||||
.style('cursor', (d: any) => d.type === 'structure' ? 'pointer' : 'default')
|
||||
.style('cursor', (d: any) => {
|
||||
if (d.type === 'structure') return 'pointer'
|
||||
if (d.type === 'family') return 'pointer'
|
||||
if (d.type === 'hashtag') return 'pointer'
|
||||
return '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
|
||||
closePopover()
|
||||
})
|
||||
.on('drag', (event: any, d: any) => {
|
||||
d.fx = event.x
|
||||
@@ -322,16 +532,35 @@ async function initGraph() {
|
||||
}
|
||||
})
|
||||
)
|
||||
.on('click', (_event: any, d: any) => {
|
||||
if (d.type === 'structure') emit('select-structure', d.id)
|
||||
.on('click', (event: any, d: any) => {
|
||||
event.stopPropagation()
|
||||
if (d.type === 'structure') {
|
||||
emit('select-structure', d.id)
|
||||
} else if (d.type === 'family') {
|
||||
openFamillePopover(d, event, svgEl)
|
||||
} else if (d.type === 'hashtag') {
|
||||
openHashtagPopover(d, event, svgEl)
|
||||
}
|
||||
})
|
||||
|
||||
// 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)
|
||||
.attr('fill', (d: any) => {
|
||||
if (d.type === 'family') return d.color
|
||||
if (d.type === 'hashtag') return d.fill
|
||||
return d.color + 'cc'
|
||||
})
|
||||
.attr('stroke', (d: any) => {
|
||||
if (d.type === 'family') return 'white'
|
||||
if (d.type === 'hashtag') return d.stroke
|
||||
return d.color
|
||||
})
|
||||
.attr('stroke-width', (d: any) => {
|
||||
if (d.type === 'family') return 3
|
||||
if (d.type === 'hashtag') return 2
|
||||
return 1.5
|
||||
})
|
||||
|
||||
// Labels familles
|
||||
d3NodeSelection.filter((d: any) => d.type === 'family')
|
||||
@@ -344,6 +573,20 @@ async function initGraph() {
|
||||
.attr('fill', 'white')
|
||||
.style('pointer-events', 'none')
|
||||
|
||||
// Labels hashtags : texte noir sur fond clair, tronque a 12 caracteres
|
||||
d3NodeSelection.filter((d: any) => d.type === 'hashtag')
|
||||
.append('text')
|
||||
.text((d: any) => {
|
||||
const raw = d.label as string
|
||||
return raw.length > 12 ? raw.slice(0, 12) + '...' : raw
|
||||
})
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', '0.35em')
|
||||
.attr('font-size', '9px')
|
||||
.attr('font-weight', '600')
|
||||
.attr('fill', '#2a2a2a')
|
||||
.style('pointer-events', 'none')
|
||||
|
||||
// Tooltip hover pour structures
|
||||
d3NodeSelection.filter((d: any) => d.type === 'structure')
|
||||
.on('mouseenter', (_event: any, d: any) => {
|
||||
@@ -376,6 +619,181 @@ async function initGraph() {
|
||||
})
|
||||
}
|
||||
|
||||
function buildNodesLinks(width: number, height: number) {
|
||||
const allNodes: any[] = []
|
||||
const links: any[] = []
|
||||
|
||||
if (!props.data) return { allNodes, links }
|
||||
|
||||
const tagFamilleMap = tagToFamille.value
|
||||
|
||||
// Noeuds structures (toujours presents)
|
||||
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,
|
||||
}))
|
||||
allNodes.push(...structureNodes)
|
||||
|
||||
// Layer Familles : 6 noeuds famille fixes en etoile + liens primaires/secondaires
|
||||
if (showFamilles.value) {
|
||||
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,
|
||||
}))
|
||||
allNodes.push(...familyNodes)
|
||||
|
||||
structureNodes.forEach(s => {
|
||||
links.push({
|
||||
source: s.id,
|
||||
target: `family-${s.famille}`,
|
||||
type: 'primary',
|
||||
strength: 0.55,
|
||||
})
|
||||
;(s.familles_secondaires as number[]).forEach((f: number) => {
|
||||
links.push({
|
||||
source: s.id,
|
||||
target: `family-${f}`,
|
||||
type: 'secondary',
|
||||
strength: 0.45,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Layer Pratiques : noeuds hashtag + liens structure -> hashtag
|
||||
if (showPratiques.value) {
|
||||
const uniqueTags = new Set<string>()
|
||||
props.data.structures.forEach(s => s.hashtags.forEach(t => uniqueTags.add(t)))
|
||||
const tagsArr = Array.from(uniqueTags).sort()
|
||||
|
||||
// Si seul layer Pratiques actif : disposition radiale comme reference
|
||||
// Si superpose avec Familles : laisser la simulation placer
|
||||
const radius = Math.min(width, height) * 0.32
|
||||
const hashtagNodes = tagsArr.map((tag, i) => {
|
||||
const famId = tagFamilleMap[tag]
|
||||
const strokeColor = famId != null ? FAMILLE_COLORS[famId] : '#888'
|
||||
const node: any = {
|
||||
id: `hashtag-${tag}`,
|
||||
type: 'hashtag',
|
||||
label: tag.startsWith('#') ? tag.slice(1) : tag,
|
||||
tag,
|
||||
fill: 'var(--nav-bg-alt)',
|
||||
stroke: strokeColor,
|
||||
color: strokeColor,
|
||||
r: 22,
|
||||
}
|
||||
if (!showFamilles.value) {
|
||||
const angle = (i / tagsArr.length) * Math.PI * 2
|
||||
node.x = width / 2 + Math.cos(angle) * radius
|
||||
node.y = height / 2 + Math.sin(angle) * radius
|
||||
}
|
||||
return node
|
||||
})
|
||||
allNodes.push(...hashtagNodes)
|
||||
|
||||
structureNodes.forEach(s => {
|
||||
s.hashtags.forEach(tag => {
|
||||
if (uniqueTags.has(tag)) {
|
||||
links.push({
|
||||
source: s.id,
|
||||
target: `hashtag-${tag}`,
|
||||
type: 'practice',
|
||||
strength: 0.3,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return { allNodes, links }
|
||||
}
|
||||
|
||||
function clampPopoverPosition(rect: DOMRect, evtX: number, evtY: number, w = 280, h = 180) {
|
||||
const margin = 12
|
||||
let x = evtX - rect.left + 14
|
||||
let y = evtY - rect.top + 10
|
||||
if (x + w > rect.width - margin) {
|
||||
x = Math.max(margin, rect.width - w - margin)
|
||||
}
|
||||
if (y + h > rect.height - margin) {
|
||||
y = Math.max(margin, rect.height - h - margin)
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function structuresForFamille(famId: number): { id: string; nom: string }[] {
|
||||
if (!props.data) return []
|
||||
return props.data.structures
|
||||
.filter(s =>
|
||||
s.famille_principale === famId
|
||||
|| (s.familles_secondaires ?? []).includes(famId)
|
||||
)
|
||||
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
|
||||
.map(s => ({ id: s.id, nom: s.nom }))
|
||||
}
|
||||
|
||||
function openFamillePopover(d: any, event: any, svgEl: SVGElement) {
|
||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||
const famId = d.familleId as number
|
||||
const desc = FAMILLE_DESCRIPTIONS[famId] ?? ''
|
||||
const structures = structuresForFamille(famId)
|
||||
const { x, y } = clampPopoverPosition(rect, event.clientX, event.clientY, 280, 280)
|
||||
popover.value = {
|
||||
open: true,
|
||||
kind: 'famille',
|
||||
x,
|
||||
y,
|
||||
title: FAMILLE_LABELS[famId] ?? '',
|
||||
body: desc,
|
||||
color: FAMILLE_COLORS[famId] ?? '#000',
|
||||
structures,
|
||||
familleId: famId,
|
||||
famillesCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function openHashtagPopover(d: any, event: any, svgEl: SVGElement) {
|
||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||
const tag = d.tag as string
|
||||
const structures = structuresForHashtag(tag)
|
||||
const famId = tagToFamille.value[tag]
|
||||
const color = famId != null ? FAMILLE_COLORS[famId] : '#444'
|
||||
// Compter les familles distinctes parmi les porteuses (famille_principale)
|
||||
const famSet = new Set<number>()
|
||||
if (props.data) {
|
||||
props.data.structures
|
||||
.filter(s => s.hashtags.includes(tag))
|
||||
.forEach(s => famSet.add(s.famille_principale))
|
||||
}
|
||||
const { x, y } = clampPopoverPosition(rect, event.clientX, event.clientY, 280, 220)
|
||||
popover.value = {
|
||||
open: true,
|
||||
kind: 'hashtag',
|
||||
x,
|
||||
y,
|
||||
title: tag.startsWith('#') ? tag : '#' + tag,
|
||||
body: '',
|
||||
color,
|
||||
structures,
|
||||
familleId: null,
|
||||
famillesCount: famSet.size,
|
||||
}
|
||||
}
|
||||
|
||||
function applyHashtagFilter() {
|
||||
if (!d3NodeSelection || !d3LinkSelection) return
|
||||
if (filteredStructureIds.value) {
|
||||
@@ -384,7 +802,8 @@ function applyHashtagFilter() {
|
||||
.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
|
||||
const tgtId = typeof d.target === 'object' ? d.target.id : d.target
|
||||
return ids.has(srcId) || ids.has(tgtId) ? 1 : 0.05
|
||||
})
|
||||
} else {
|
||||
d3NodeSelection.select('circle').attr('opacity', 1)
|
||||
@@ -392,16 +811,14 @@ function applyHashtagFilter() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// Declencher quand l'onglet devient visible
|
||||
watch(() => props.active, (val) => {
|
||||
if (val && import.meta.client && props.data) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||
}
|
||||
})
|
||||
|
||||
// Relancer si les données arrivent après l'activation
|
||||
// Relancer si les donnees arrivent apres l'activation
|
||||
watch(() => props.data, (val) => {
|
||||
if (val && props.active && import.meta.client) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||
@@ -414,6 +831,14 @@ watch(activeHashtags, () => {
|
||||
if (simulation) simulation.alpha(0.01).restart()
|
||||
}, { deep: true })
|
||||
|
||||
// Watchers layers : rebuild simulation
|
||||
watch([showFamilles, showPratiques], () => {
|
||||
closePopover()
|
||||
if (import.meta.client && props.data && props.active) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||
}
|
||||
})
|
||||
|
||||
// Toggle sidebar : largeur SVG change -> reinit graphe apres transition CSS
|
||||
watch(sidebarOpen, () => {
|
||||
if (!import.meta.client || !props.active || !props.data) return
|
||||
|
||||
Reference in New Issue
Block a user