- Install d3@^7.9.0 (absent du projet, requis pour force simulation) - components/codev/CodevGraph.vue : simulation forceLink/forceManyBody/forceCenter/forceCollide, drag D3, pastilles offre (vert) + besoin (orange), tooltip SVG natif, ResizeObserver, watch matches/mode pret pour M4, placeholder si 0 fiches - pages/codev/carto.vue : useFetch /api/codev/fiches, mount CodevGraph, refs matches+mode vides (M4 les remplira) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
373 lines
11 KiB
Vue
373 lines
11 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" />
|
|
|
|
</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'
|
|
}>(), {
|
|
matches: () => [],
|
|
mode: 'none',
|
|
})
|
|
|
|
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 '#1B4436'
|
|
if (mode === 'alliance') return '#3b82f6'
|
|
if (mode === 'surprise') return '#a855f7'
|
|
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
|
|
|
|
const r = nodeRadius.value
|
|
|
|
const linkSel = gLinks
|
|
.selectAll<SVGLineElement, SimLink>('line')
|
|
.data(currentLinks, (d: SimLink) => {
|
|
const s = d.source as SimNode
|
|
const t = d.target as SimNode
|
|
return `${s.id}-${t.id}-${d.mode}`
|
|
})
|
|
|
|
linkSel.exit().remove()
|
|
|
|
linkSel.enter()
|
|
.append('line')
|
|
.attr('stroke', d => linkColor(d.mode))
|
|
.attr('stroke-width', d => 1 + d.score * 3)
|
|
.attr('stroke-opacity', 0.7)
|
|
.attr('marker-end', 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)
|
|
|
|
// 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, orange)
|
|
nodeGroups.append('circle')
|
|
.attr('r', 6)
|
|
.attr('cx', r * 0.65)
|
|
.attr('cy', r * 0.65)
|
|
.attr('fill', '#f97316')
|
|
.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}`)
|
|
|
|
// 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))
|
|
.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() {
|
|
if (!gLinks || !gNodes) return
|
|
|
|
gLinks.selectAll<SVGLineElement, SimLink>('line')
|
|
.attr('x1', d => (d.source as SimNode).x ?? 0)
|
|
.attr('y1', d => (d.source as SimNode).y ?? 0)
|
|
.attr('x2', d => (d.target as SimNode).x ?? 0)
|
|
.attr('y2', d => (d.target as SimNode).y ?? 0)
|
|
|
|
gNodes.selectAll<SVGGElement, SimNode>('g.node')
|
|
.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`)
|
|
}
|
|
|
|
// ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
|
|
|
|
watch(() => [props.matches, props.mode] as const, () => {
|
|
if (!simulation) return
|
|
rebuildLinks()
|
|
simulation.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
|
|
.id(d => d.id)
|
|
.distance(120)
|
|
.strength(0.3))
|
|
simulation.alpha(0.5).restart()
|
|
}, { deep: true })
|
|
|
|
// ── 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>
|