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[] fiches: CodevFiche[]
matches?: CodevMatch[] matches?: CodevMatch[]
mode?: 'none' | 'solution' | 'alliance' | 'surprise' mode?: 'none' | 'solution' | 'alliance' | 'surprise'
showLabels?: boolean
}>(), { }>(), {
matches: () => [], matches: () => [],
mode: 'none', mode: 'none',
showLabels: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -146,18 +148,15 @@ function rebuildLinks() {
currentLinks = buildLinks(currentNodes) currentLinks = buildLinks(currentNodes)
if (!gLinks || !simulation) return if (!gLinks || !simulation) return
const linkSel = gLinks // .join() moderne D3 pour garantir le re-rendu complet
gLinks
.selectAll<SVGLineElement, SimLink>('line') .selectAll<SVGLineElement, SimLink>('line')
.data(currentLinks, (d: SimLink) => { .data(currentLinks)
const s = d.source as SimNode .join(
const t = d.target as SimNode enter => enter.append('line'),
return `${s.id}-${t.id}-${d.mode}` update => update,
}) exit => exit.remove()
)
linkSel.exit().remove()
linkSel.enter()
.append('line')
.attr('stroke', d => linkColor(d.mode)) .attr('stroke', d => linkColor(d.mode))
.attr('stroke-width', d => 1 + d.score * 3) .attr('stroke-width', d => 1 + d.score * 3)
.attr('stroke-opacity', 0.7) .attr('stroke-opacity', 0.7)
@@ -223,12 +222,12 @@ function render() {
.attr('stroke', '#fff') .attr('stroke', '#fff')
.attr('stroke-width', 1.5) .attr('stroke-width', 1.5)
// Pastille besoin (bas-droite, orange) // Pastille besoin (bas-droite, bleu)
nodeGroups.append('circle') nodeGroups.append('circle')
.attr('r', 6) .attr('r', 6)
.attr('cx', r * 0.65) .attr('cx', r * 0.65)
.attr('cy', r * 0.65) .attr('cy', r * 0.65)
.attr('fill', '#f97316') .attr('fill', '#3b82f6')
.attr('stroke', '#fff') .attr('stroke', '#fff')
.attr('stroke-width', 1.5) .attr('stroke-width', 1.5)
@@ -236,6 +235,57 @@ function render() {
nodeGroups.append('title') nodeGroups.append('title')
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`) .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
simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes) simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes)
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks) .force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
@@ -245,6 +295,8 @@ function render() {
.force('charge', d3.forceManyBody<SimNode>().strength(-400)) .force('charge', d3.forceManyBody<SimNode>().strength(-400))
.force('center', d3.forceCenter(width.value / 2, height.value / 2)) .force('center', d3.forceCenter(width.value / 2, height.value / 2))
.force('collide', d3.forceCollide<SimNode>().radius(r + 12)) .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) .alphaDecay(0.02)
.on('tick', tick) .on('tick', tick)
@@ -254,16 +306,21 @@ function render() {
} }
function tick() { function tick() {
const r = nodeRadius.value
if (!gLinks || !gNodes) return if (!gLinks || !gNodes) return
gLinks.selectAll<SVGLineElement, SimLink>('line') gLinks.selectAll<SVGLineElement, SimLink>('line')
.attr('x1', d => (d.source as SimNode).x ?? 0) .attr('x1', d => Math.max(r, Math.min(width.value - r, (d.source as SimNode).x ?? 0)))
.attr('y1', d => (d.source as SimNode).y ?? 0) .attr('y1', d => Math.max(r, Math.min(height.value - r, (d.source as SimNode).y ?? 0)))
.attr('x2', d => (d.target as SimNode).x ?? 0) .attr('x2', d => Math.max(r, Math.min(width.value - r, (d.target as SimNode).x ?? 0)))
.attr('y2', d => (d.target as SimNode).y ?? 0) .attr('y2', d => Math.max(r, Math.min(height.value - r, (d.target as SimNode).y ?? 0)))
gNodes.selectAll<SVGGElement, SimNode>('g.node') 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) ───────────────────────────────────── // ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
@@ -271,13 +328,21 @@ function tick() {
watch(() => [props.matches, props.mode] as const, () => { watch(() => [props.matches, props.mode] as const, () => {
if (!simulation) return if (!simulation) return
rebuildLinks() rebuildLinks()
simulation.force('link', d3.forceLink<SimNode, SimLink>(currentLinks) const newForce = d3.forceLink<SimNode, SimLink>(currentLinks)
.id(d => d.id) .id(d => String(d.id))
.distance(120) .distance(120)
.strength(0.3)) .strength(0.5)
simulation.alpha(0.5).restart() simulation.force('link', newForce)
simulation.alpha(0.8).restart()
}, { deep: true }) }, { 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 fiches (re-render si nouvelles fiches) ───────────────────────────
watch(() => props.fiches, () => { watch(() => props.fiches, () => {

View File

@@ -11,11 +11,22 @@
</p> </p>
</header> </header>
<div class="show-labels-bar">
<button
type="button"
:class="{ active: showLabels }"
@click="showLabels = !showLabels"
>
{{ showLabels ? 'Masquer besoins/offres' : 'Montrer besoins/offres' }}
</button>
</div>
<ClientOnly> <ClientOnly>
<CodevGraph <CodevGraph
:fiches="fiches" :fiches="fiches"
:matches="matches" :matches="matches"
:mode="mode" :mode="mode"
:show-labels="showLabels"
@select-fiche="onSelectFiche" @select-fiche="onSelectFiche"
/> />
<template #fallback> <template #fallback>
@@ -71,6 +82,11 @@
</button> </button>
</div> </div>
<!-- FAB ajouter une fiche -->
<NuxtLink to="/codev/fiche" class="fab-add" title="Ajouter ma fiche" aria-label="Ajouter une fiche">
+
</NuxtLink>
</div> </div>
</template> </template>
@@ -85,6 +101,7 @@ const fiches = computed(() => data.value?.list ?? [])
const matches = ref<CodevMatch[]>([]) const matches = ref<CodevMatch[]>([])
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none') const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
const showLabels = ref(false)
const MODE_LABELS: Record<string, string> = { const MODE_LABELS: Record<string, string> = {
solution: 'Solution', 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 ── */ /* ── Mobile ── */
@media (max-width: 600px) { @media (max-width: 600px) {

View File

@@ -4,7 +4,7 @@
<!-- En-tête --> <!-- En-tête -->
<div class="fiche-header"> <div class="fiche-header">
<h1>Ma fiche</h1> <h1>{{ isEdit ? 'Modifier ma fiche' : 'Ma fiche' }}</h1>
<p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p> <p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p>
</div> </div>
@@ -105,7 +105,7 @@
<!-- Bouton --> <!-- Bouton -->
<button type="submit" class="submit-btn" :disabled="loading"> <button type="submit" class="submit-btn" :disabled="loading">
{{ loading ? 'Envoi en cours...' : 'Ajouter ma fiche' }} {{ isEdit ? (loading ? 'Modification...' : 'Enregistrer les modifications') : (loading ? 'Envoi en cours...' : 'Ajouter ma fiche') }}
</button> </button>
</form> </form>
@@ -115,12 +115,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute()
const editId = computed(() => route.query.id ? Number(route.query.id) : null)
const isEdit = computed(() => editId.value !== null)
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' }) const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
const error = ref('') const error = ref('')
const loading = ref(false) const loading = ref(false)
const activeTip = ref<'besoin' | 'offre' | null>(null) const activeTip = ref<'besoin' | 'offre' | null>(null)
useHead({ title: 'Ma fiche — Co-développement' }) useHead({ title: computed(() => isEdit.value ? 'Modifier ma fiche — Co-développement' : 'Ma fiche — Co-développement') })
onMounted(async () => {
if (!isEdit.value) return
try {
const fiche = await $fetch<any>(`/api/codev/fiches/${editId.value}`)
form.value.nom = fiche.nom
form.value.besoin = fiche.besoin
form.value.offre = fiche.offre
form.value.hashtagsRaw = fiche.hashtags.join(', ')
} catch {
error.value = 'Impossible de charger la fiche, elle a peut-etre ete supprimee.'
}
})
function toggleTip(field: 'besoin' | 'offre') { function toggleTip(field: 'besoin' | 'offre') {
activeTip.value = activeTip.value === field ? null : field activeTip.value = activeTip.value === field ? null : field
@@ -136,15 +153,18 @@ async function submit() {
.filter(Boolean) .filter(Boolean)
.slice(0, 3) .slice(0, 3)
await $fetch('/api/codev/fiches', { const payload = {
method: 'POST',
body: {
nom: form.value.nom, nom: form.value.nom,
besoin: form.value.besoin, besoin: form.value.besoin,
offre: form.value.offre, offre: form.value.offre,
hashtags, hashtags,
}, }
})
if (isEdit.value) {
await $fetch(`/api/codev/fiches/${editId.value}`, { method: 'PATCH', body: payload })
} else {
await $fetch('/api/codev/fiches', { method: 'POST', body: payload })
}
await navigateTo('/codev/carto') await navigateTo('/codev/carto')
} catch (e: any) { } catch (e: any) {
error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie' error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie'

View File

@@ -0,0 +1,34 @@
import type { CodevFiche } from '~/types/codev'
export default defineEventHandler(async (event): Promise<CodevFiche> => {
const config = useRuntimeConfig()
const tableId = config.codevTableId
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
const id = getRouterParam(event, 'id')
if (!tableId || !id) {
throw createError({ statusCode: 400, message: 'Parametre manquant' })
}
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
const r: any = await $fetch(url, {
headers: { 'xc-token': config.nocodbToken },
}).catch(() => null)
if (!r) {
throw createError({ statusCode: 404, message: 'Fiche introuvable' })
}
return {
id: r.Id ?? r.id,
nom: r.nom || '',
besoin: r.besoin || '',
offre: r.offre || '',
hashtags: (r.hashtags || '')
.split(',')
.map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean),
created_at: r.created_at || r.CreatedAt || '',
}
})

View File

@@ -0,0 +1,59 @@
import { z } from 'zod'
const PatchSchema = z.object({
nom: z.string().min(2).max(50).trim(),
besoin: z.string().min(5).max(300).trim(),
offre: z.string().min(5).max(300).trim(),
hashtags: z.array(z.string().max(30)).max(3).default([]),
})
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const tableId = config.codevTableId
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
const id = getRouterParam(event, 'id')
const body = await readBody(event)
if (!tableId || !id) {
throw createError({ statusCode: 400, message: 'Parametre manquant' })
}
const parsed = PatchSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation echouee',
data: parsed.error.flatten().fieldErrors,
})
}
const payload = {
nom: parsed.data.nom,
besoin: parsed.data.besoin,
offre: parsed.data.offre,
hashtags: parsed.data.hashtags
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean)
.slice(0, 3)
.join(','),
}
// NocoDB v1 PATCH par Id
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
try {
await $fetch(url, {
method: 'PATCH',
headers: {
'xc-token': config.nocodbToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
} catch (e: any) {
console.error('[codev/fiches.patch] NocoDB patch error:', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur' })
}
return { status: 200, ok: true }
})