fix(codev): boundaries D3 + matching rebuildLinks + couleurs + bulles toggle + FAB +

This commit is contained in:
Jules Neny
2026-05-06 17:49:56 +02:00
parent 4ed0a87106
commit e7c7d302ea
5 changed files with 282 additions and 34 deletions

View File

@@ -37,9 +37,11 @@ const props = withDefaults(defineProps<{
fiches: CodevFiche[]
matches?: CodevMatch[]
mode?: 'none' | 'solution' | 'alliance' | 'surprise'
showLabels?: boolean
}>(), {
matches: () => [],
mode: 'none',
showLabels: false,
})
const emit = defineEmits<{
@@ -146,18 +148,15 @@ function rebuildLinks() {
currentLinks = buildLinks(currentNodes)
if (!gLinks || !simulation) return
const linkSel = gLinks
// .join() moderne D3 pour garantir le re-rendu complet
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')
.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)
@@ -223,12 +222,12 @@ function render() {
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
// Pastille besoin (bas-droite, orange)
// Pastille besoin (bas-droite, bleu)
nodeGroups.append('circle')
.attr('r', 6)
.attr('cx', r * 0.65)
.attr('cy', r * 0.65)
.attr('fill', '#f97316')
.attr('fill', '#3b82f6')
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
@@ -236,6 +235,57 @@ function render() {
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)
@@ -245,6 +295,8 @@ function render() {
.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)
@@ -254,16 +306,21 @@ function render() {
}
function tick() {
const r = nodeRadius.value
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)
.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 => `translate(${d.x ?? 0},${d.y ?? 0})`)
.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) ─────────────────────────────────────
@@ -271,13 +328,21 @@ function tick() {
watch(() => [props.matches, props.mode] as const, () => {
if (!simulation) return
rebuildLinks()
simulation.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
.id(d => d.id)
const newForce = d3.forceLink<SimNode, SimLink>(currentLinks)
.id(d => String(d.id))
.distance(120)
.strength(0.3))
simulation.alpha(0.5).restart()
.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, () => {