451 lines
14 KiB
Vue
451 lines
14 KiB
Vue
<template>
|
|
<div ref="container" class="codev-graph-wrap">
|
|
|
|
<!-- Placeholder si aucune fiche -->
|
|
<div v-if="fiches.length === 0" class="empty-state">
|
|
<p class="empty-msg">Encore personne. Sois la premiere fiche !</p>
|
|
<NuxtLink to="/codev/fiche" class="empty-link">Creer ma fiche →</NuxtLink>
|
|
</div>
|
|
|
|
<!-- SVG D3 -->
|
|
<svg v-else ref="svgEl" class="codev-svg">
|
|
<defs>
|
|
<marker
|
|
id="arrow-solution"
|
|
viewBox="0 0 10 10"
|
|
refX="18"
|
|
refY="5"
|
|
markerWidth="6"
|
|
markerHeight="6"
|
|
orient="auto-start-reverse"
|
|
>
|
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#22c55e" />
|
|
</marker>
|
|
</defs>
|
|
</svg>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import * as d3 from 'd3'
|
|
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
|
|
|
// ── Props / Emits ──────────────────────────────────────────────────────────
|
|
|
|
const props = withDefaults(defineProps<{
|
|
fiches: CodevFiche[]
|
|
matches?: CodevMatch[]
|
|
mode?: 'none' | 'solution' | 'alliance' | 'surprise'
|
|
showLabels?: boolean
|
|
}>(), {
|
|
matches: () => [],
|
|
mode: 'none',
|
|
showLabels: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'select-fiche': [id: number]
|
|
}>()
|
|
|
|
// ── Refs ───────────────────────────────────────────────────────────────────
|
|
|
|
const container = ref<HTMLDivElement | null>(null)
|
|
const svgEl = ref<SVGSVGElement | null>(null)
|
|
const width = ref(800)
|
|
const height = ref(600)
|
|
|
|
// ── State interne ──────────────────────────────────────────────────────────
|
|
|
|
type SimNode = d3.SimulationNodeDatum & { id: number; nom: string; offre: string; besoin: string }
|
|
type SimLink = d3.SimulationLinkDatum<SimNode> & { score: number; mode: string }
|
|
|
|
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
|
let svgRoot: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null
|
|
let gLinks: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
|
let gNodes: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
|
|
|
const isMobile = computed(() => width.value < 600)
|
|
const nodeRadius = computed(() => isMobile.value ? 22 : 28)
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function truncate(str: string, max = 10): string {
|
|
if (!str) return ''
|
|
return str.length > max ? str.slice(0, max - 1) + '…' : str
|
|
}
|
|
|
|
function buildNodes(): SimNode[] {
|
|
return props.fiches.map(f => ({
|
|
id: f.id,
|
|
nom: f.nom,
|
|
offre: f.offre,
|
|
besoin: f.besoin,
|
|
}))
|
|
}
|
|
|
|
function buildLinks(nodes: SimNode[]): SimLink[] {
|
|
if (!props.matches || props.matches.length === 0) return []
|
|
const nodeById = new Map(nodes.map(n => [n.id, n]))
|
|
return props.matches
|
|
.filter(m => nodeById.has(m.fromId) && nodeById.has(m.toId))
|
|
.map(m => ({
|
|
source: nodeById.get(m.fromId)!,
|
|
target: nodeById.get(m.toId)!,
|
|
score: m.score,
|
|
mode: m.mode,
|
|
}))
|
|
}
|
|
|
|
function linkColor(mode: string): string {
|
|
if (mode === 'solution') return '#22c55e'
|
|
if (mode === 'alliance') return '#f97316'
|
|
if (mode === 'surprise') return '#3b82f6'
|
|
return '#ccc'
|
|
}
|
|
|
|
// ── Drag handler ───────────────────────────────────────────────────────────
|
|
|
|
function makeDrag(sim: d3.Simulation<SimNode, SimLink>): d3.DragBehavior<SVGGElement, SimNode, SimNode> {
|
|
return d3.drag<SVGGElement, SimNode>()
|
|
.on('start', (event, d) => {
|
|
if (!event.active) sim.alphaTarget(0.3).restart()
|
|
d.fx = d.x
|
|
d.fy = d.y
|
|
})
|
|
.on('drag', (event, d) => {
|
|
d.fx = event.x
|
|
d.fy = event.y
|
|
})
|
|
.on('end', (event, d) => {
|
|
if (!event.active) sim.alphaTarget(0)
|
|
d.fx = null
|
|
d.fy = null
|
|
})
|
|
}
|
|
|
|
// ── Initialisation SVG ─────────────────────────────────────────────────────
|
|
|
|
function initSvg() {
|
|
if (!svgEl.value) return
|
|
|
|
svgRoot = d3.select(svgEl.value)
|
|
.attr('width', width.value)
|
|
.attr('height', height.value)
|
|
|
|
svgRoot.selectAll('*').remove()
|
|
|
|
gLinks = svgRoot.append('g').attr('class', 'links')
|
|
gNodes = svgRoot.append('g').attr('class', 'nodes')
|
|
}
|
|
|
|
// ── Rebuild liens (hook pour M4) ───────────────────────────────────────────
|
|
|
|
let currentNodes: SimNode[] = []
|
|
let currentLinks: SimLink[] = []
|
|
|
|
function rebuildLinks() {
|
|
currentLinks = buildLinks(currentNodes)
|
|
if (!gLinks || !simulation) return
|
|
|
|
// .join() moderne D3 pour garantir le re-rendu complet
|
|
gLinks
|
|
.selectAll<SVGLineElement, SimLink>('line')
|
|
.data(currentLinks)
|
|
.join(
|
|
enter => enter.append('line'),
|
|
update => update,
|
|
exit => exit.remove()
|
|
)
|
|
.attr('stroke', d => linkColor(d.mode))
|
|
.attr('stroke-width', d => 1 + d.score * 3)
|
|
.attr('stroke-opacity', 0.7)
|
|
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
|
|
}
|
|
|
|
// ── Rendu complet ──────────────────────────────────────────────────────────
|
|
|
|
function render() {
|
|
if (!svgEl.value || props.fiches.length === 0) return
|
|
|
|
initSvg()
|
|
|
|
currentNodes = buildNodes()
|
|
currentLinks = buildLinks(currentNodes)
|
|
|
|
const r = nodeRadius.value
|
|
const fontSize = isMobile.value ? 10 : 12
|
|
|
|
// Liens
|
|
gLinks!
|
|
.selectAll<SVGLineElement, SimLink>('line')
|
|
.data(currentLinks)
|
|
.join('line')
|
|
.attr('stroke', d => linkColor(d.mode))
|
|
.attr('stroke-width', d => 1 + d.score * 3)
|
|
.attr('stroke-opacity', 0.7)
|
|
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
|
|
|
|
// Noeuds = groupe <g> par personne
|
|
const nodeGroups = gNodes!
|
|
.selectAll<SVGGElement, SimNode>('g.node')
|
|
.data(currentNodes, d => String(d.id))
|
|
.join('g')
|
|
.attr('class', 'node')
|
|
.style('cursor', 'pointer')
|
|
.call(makeDrag(simulation!) as any)
|
|
.on('click', (_event, d) => emit('select-fiche', d.id))
|
|
|
|
// Cercle principal
|
|
nodeGroups.append('circle')
|
|
.attr('r', r)
|
|
.attr('fill', '#ffffff')
|
|
.attr('stroke', '#1B4436')
|
|
.attr('stroke-width', 2)
|
|
|
|
// Label nom
|
|
nodeGroups.append('text')
|
|
.attr('text-anchor', 'middle')
|
|
.attr('dominant-baseline', 'central')
|
|
.attr('font-size', fontSize)
|
|
.attr('font-weight', '700')
|
|
.attr('fill', '#1a1a2e')
|
|
.attr('pointer-events', 'none')
|
|
.text(d => truncate(d.nom, 10))
|
|
|
|
// Pastille offre (haut-droite, vert)
|
|
nodeGroups.append('circle')
|
|
.attr('r', 6)
|
|
.attr('cx', r * 0.65)
|
|
.attr('cy', -r * 0.65)
|
|
.attr('fill', '#22c55e')
|
|
.attr('stroke', '#fff')
|
|
.attr('stroke-width', 1.5)
|
|
|
|
// Pastille besoin (bas-droite, bleu)
|
|
nodeGroups.append('circle')
|
|
.attr('r', 6)
|
|
.attr('cx', r * 0.65)
|
|
.attr('cy', r * 0.65)
|
|
.attr('fill', '#3b82f6')
|
|
.attr('stroke', '#fff')
|
|
.attr('stroke-width', 1.5)
|
|
|
|
// Tooltip SVG natif <title>
|
|
nodeGroups.append('title')
|
|
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`)
|
|
|
|
// Groupe label bulle (affiche si showLabels)
|
|
const labelGroups = nodeGroups.append('g')
|
|
.attr('class', 'label-bubble')
|
|
.attr('visibility', props.showLabels ? 'visible' : 'hidden')
|
|
|
|
// Fond bulle besoin (dessous du noeud)
|
|
labelGroups.append('rect')
|
|
.attr('class', 'bubble-besoin-bg')
|
|
.attr('x', -(r + 50))
|
|
.attr('y', r + 4)
|
|
.attr('width', 100)
|
|
.attr('height', 28)
|
|
.attr('rx', 6)
|
|
.attr('fill', '#eff6ff')
|
|
.attr('stroke', '#3b82f6')
|
|
.attr('stroke-width', 1)
|
|
|
|
// Texte besoin
|
|
labelGroups.append('text')
|
|
.attr('class', 'bubble-besoin-txt')
|
|
.attr('x', -(r) + 50)
|
|
.attr('y', r + 22)
|
|
.attr('text-anchor', 'middle')
|
|
.attr('font-size', 9)
|
|
.attr('fill', '#1e40af')
|
|
.attr('pointer-events', 'none')
|
|
.text(d => truncate(d.besoin, 18))
|
|
|
|
// Fond bulle offre (dessus du noeud)
|
|
labelGroups.append('rect')
|
|
.attr('class', 'bubble-offre-bg')
|
|
.attr('x', -(r + 50))
|
|
.attr('y', -(r + 32))
|
|
.attr('width', 100)
|
|
.attr('height', 28)
|
|
.attr('rx', 6)
|
|
.attr('fill', '#f0fdf4')
|
|
.attr('stroke', '#22c55e')
|
|
.attr('stroke-width', 1)
|
|
|
|
// Texte offre
|
|
labelGroups.append('text')
|
|
.attr('class', 'bubble-offre-txt')
|
|
.attr('x', -(r) + 50)
|
|
.attr('y', -(r + 14))
|
|
.attr('text-anchor', 'middle')
|
|
.attr('font-size', 9)
|
|
.attr('fill', '#166534')
|
|
.attr('pointer-events', 'none')
|
|
.text(d => truncate(d.offre, 18))
|
|
|
|
// Simulation
|
|
simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes)
|
|
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
|
|
.id(d => d.id)
|
|
.distance(120)
|
|
.strength(0.3))
|
|
.force('charge', d3.forceManyBody<SimNode>().strength(-400))
|
|
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
|
.force('collide', d3.forceCollide<SimNode>().radius(r + 12))
|
|
.force('x', d3.forceX(width.value / 2).strength(0.05))
|
|
.force('y', d3.forceY(height.value / 2).strength(0.05))
|
|
.alphaDecay(0.02)
|
|
.on('tick', tick)
|
|
|
|
// Re-bind drag avec la nouvelle simulation
|
|
gNodes!.selectAll<SVGGElement, SimNode>('g.node')
|
|
.call(makeDrag(simulation) as any)
|
|
}
|
|
|
|
function tick() {
|
|
const r = nodeRadius.value
|
|
if (!gLinks || !gNodes) return
|
|
|
|
gLinks.selectAll<SVGLineElement, SimLink>('line')
|
|
.attr('x1', d => Math.max(r, Math.min(width.value - r, (d.source as SimNode).x ?? 0)))
|
|
.attr('y1', d => Math.max(r, Math.min(height.value - r, (d.source as SimNode).y ?? 0)))
|
|
.attr('x2', d => Math.max(r, Math.min(width.value - r, (d.target as SimNode).x ?? 0)))
|
|
.attr('y2', d => Math.max(r, Math.min(height.value - r, (d.target as SimNode).y ?? 0)))
|
|
|
|
gNodes.selectAll<SVGGElement, SimNode>('g.node')
|
|
.attr('transform', d => {
|
|
const x = Math.max(r, Math.min(width.value - r, d.x ?? 0))
|
|
const y = Math.max(r, Math.min(height.value - r, d.y ?? 0))
|
|
return `translate(${x},${y})`
|
|
})
|
|
}
|
|
|
|
// ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
|
|
|
|
watch(() => [props.matches, props.mode] as const, () => {
|
|
if (!simulation) return
|
|
rebuildLinks()
|
|
const newForce = d3.forceLink<SimNode, SimLink>(currentLinks)
|
|
.id(d => String(d.id))
|
|
.distance(120)
|
|
.strength(0.5)
|
|
simulation.force('link', newForce)
|
|
simulation.alpha(0.8).restart()
|
|
}, { deep: true })
|
|
|
|
// ── Watch showLabels ──────────────────────────────────────────────────────
|
|
|
|
watch(() => props.showLabels, (val) => {
|
|
if (!svgEl.value) return
|
|
d3.select(svgEl.value).selectAll('.label-bubble').attr('visibility', val ? 'visible' : 'hidden')
|
|
})
|
|
|
|
// ── Watch fiches (re-render si nouvelles fiches) ───────────────────────────
|
|
|
|
watch(() => props.fiches, () => {
|
|
if (simulation) {
|
|
simulation.stop()
|
|
simulation = null
|
|
}
|
|
render()
|
|
}, { deep: true })
|
|
|
|
// ── ResizeObserver ─────────────────────────────────────────────────────────
|
|
|
|
let ro: ResizeObserver | null = null
|
|
|
|
onMounted(() => {
|
|
if (!container.value) return
|
|
width.value = container.value.clientWidth || 800
|
|
height.value = container.value.clientHeight || 600
|
|
|
|
render()
|
|
|
|
ro = new ResizeObserver(() => {
|
|
if (!container.value) return
|
|
width.value = container.value.clientWidth || 800
|
|
height.value = container.value.clientHeight || 600
|
|
if (svgRoot) {
|
|
svgRoot.attr('width', width.value).attr('height', height.value)
|
|
}
|
|
if (simulation) {
|
|
simulation.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
|
simulation.alpha(0.3).restart()
|
|
}
|
|
})
|
|
ro.observe(container.value!)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (simulation) simulation.stop()
|
|
if (ro) ro.disconnect()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.codev-graph-wrap {
|
|
width: 100%;
|
|
height: 70vh;
|
|
min-height: 320px;
|
|
position: relative;
|
|
background: var(--nav-bg, #fafafa);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.codev-svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
}
|
|
|
|
/* ── Etat vide ── */
|
|
|
|
.empty-state {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 1rem;
|
|
padding: 2rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.empty-msg {
|
|
font-size: 1.125rem;
|
|
color: var(--nav-text-muted, #6b7280);
|
|
margin: 0;
|
|
}
|
|
|
|
.empty-link {
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
color: var(--nav-primary-solid, #1B4436);
|
|
text-decoration: none;
|
|
border: 1.5px solid var(--nav-primary-solid, #1B4436);
|
|
border-radius: 8px;
|
|
padding: 0.5rem 1.25rem;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
|
|
.empty-link:hover {
|
|
background: var(--nav-primary-solid, #1B4436);
|
|
color: #fff;
|
|
}
|
|
|
|
/* ── Mobile ── */
|
|
|
|
@media (max-width: 600px) {
|
|
.codev-graph-wrap {
|
|
height: 65vh;
|
|
min-height: 260px;
|
|
border-radius: 8px;
|
|
}
|
|
}
|
|
</style>
|