diff --git a/components/codev/CodevGraph.vue b/components/codev/CodevGraph.vue
index 5d2347c..cc4acc8 100644
--- a/components/codev/CodevGraph.vue
+++ b/components/codev/CodevGraph.vue
@@ -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('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(currentNodes)
.force('link', d3.forceLink(currentLinks)
@@ -245,6 +295,8 @@ function render() {
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
.force('collide', d3.forceCollide().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('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('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(currentLinks)
- .id(d => d.id)
+ const newForce = d3.forceLink(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, () => {
diff --git a/pages/codev/carto.vue b/pages/codev/carto.vue
index 88e11db..bb17163 100644
--- a/pages/codev/carto.vue
+++ b/pages/codev/carto.vue
@@ -11,11 +11,22 @@
+
+
+
+
@@ -71,6 +82,11 @@
+
+
+ +
+
+
@@ -85,6 +101,7 @@ const fiches = computed(() => data.value?.list ?? [])
const matches = ref([])
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
+const showLabels = ref(false)
const MODE_LABELS: Record = {
solution: 'Solution',
@@ -256,6 +273,59 @@ function onSelectFiche(id: number) {
}
}
+/* ── Toggle besoins/offres ── */
+
+.show-labels-bar {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 8px;
+}
+
+.show-labels-bar button {
+ border: 1px solid #d0d4dc;
+ border-radius: 8px;
+ padding: 8px 16px;
+ background: white;
+ font-size: 13px;
+ cursor: pointer;
+ color: #374151;
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+}
+
+.show-labels-bar button.active {
+ background: #1B4436;
+ color: white;
+ border-color: transparent;
+}
+
+/* ── FAB ajouter ── */
+
+.fab-add {
+ position: fixed;
+ bottom: 80px;
+ right: 16px;
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: #1B4436;
+ color: white;
+ font-size: 28px;
+ font-weight: 300;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-decoration: none;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
+ z-index: 100;
+ transition: transform 0.15s, opacity 0.15s;
+ line-height: 1;
+}
+
+.fab-add:hover {
+ transform: scale(1.08);
+ opacity: 0.92;
+}
+
/* ── Mobile ── */
@media (max-width: 600px) {
diff --git a/pages/codev/fiche.vue b/pages/codev/fiche.vue
index 58367dd..64d03be 100644
--- a/pages/codev/fiche.vue
+++ b/pages/codev/fiche.vue
@@ -4,7 +4,7 @@
@@ -105,7 +105,7 @@
@@ -115,12 +115,29 @@