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 @@

+
+ +
+ @@ -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 @@
-

Ma fiche

+

{{ isEdit ? 'Modifier ma fiche' : 'Ma fiche' }}

3 lignes pour te présenter. Le reste se passe entre nous.

@@ -105,7 +105,7 @@ @@ -115,12 +115,29 @@