From d345d7f6f99d9f033a1b54742217b7b4e64b0af2 Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Wed, 6 May 2026 16:07:20 +0200 Subject: [PATCH] feat(codev): M4 - matching 3 modes + boutons UI + animation force Co-Authored-By: Claude Sonnet 4.6 --- components/codev/CodevGraph.vue | 27 +++-- pages/codev/carto.vue | 177 +++++++++++++++++++++++++++++--- utils/codev/matching.ts | 97 +++++++++++++++++ 3 files changed, 279 insertions(+), 22 deletions(-) create mode 100644 utils/codev/matching.ts diff --git a/components/codev/CodevGraph.vue b/components/codev/CodevGraph.vue index 97764d3..5d2347c 100644 --- a/components/codev/CodevGraph.vue +++ b/components/codev/CodevGraph.vue @@ -8,7 +8,21 @@ - + + + + + + + @@ -82,9 +96,9 @@ function buildLinks(nodes: SimNode[]): SimLink[] { } function linkColor(mode: string): string { - if (mode === 'solution') return '#1B4436' - if (mode === 'alliance') return '#3b82f6' - if (mode === 'surprise') return '#a855f7' + if (mode === 'solution') return '#22c55e' + if (mode === 'alliance') return '#f97316' + if (mode === 'surprise') return '#3b82f6' return '#ccc' } @@ -132,8 +146,6 @@ function rebuildLinks() { currentLinks = buildLinks(currentNodes) if (!gLinks || !simulation) return - const r = nodeRadius.value - const linkSel = gLinks .selectAll('line') .data(currentLinks, (d: SimLink) => { @@ -149,7 +161,7 @@ function rebuildLinks() { .attr('stroke', d => linkColor(d.mode)) .attr('stroke-width', d => 1 + d.score * 3) .attr('stroke-opacity', 0.7) - .attr('marker-end', null) + .attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null) } // ── Rendu complet ────────────────────────────────────────────────────────── @@ -173,6 +185,7 @@ function render() { .attr('stroke', d => linkColor(d.mode)) .attr('stroke-width', d => 1 + d.score * 3) .attr('stroke-opacity', 0.7) + .attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null) // Noeuds = groupe par personne const nodeGroups = gNodes! diff --git a/pages/codev/carto.vue b/pages/codev/carto.vue index 4939cd5..88e11db 100644 --- a/pages/codev/carto.vue +++ b/pages/codev/carto.vue @@ -6,7 +6,7 @@

@@ -23,9 +23,52 @@ - -
-

Boutons matching - arrivent bientot (M4)

+ +
+ + Mode {{ MODE_LABELS[mode] }} actif - + {{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}. + + +
+ + +
+ + + +
@@ -33,18 +76,32 @@ @@ -96,17 +153,107 @@ function onSelectFiche(id: number) { border-radius: 12px; } -/* ── Placeholder matching (M4) ── */ +/* ── Bandeau mode actif ── */ -.matching-controls.placeholder { - text-align: center; - padding: 0.75rem; +.mode-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0.875rem; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 8px; + font-size: 0.875rem; + color: #166534; + flex-wrap: wrap; } -.matching-controls.placeholder p { - color: #999; - font-size: 0.85rem; - margin: 0; +.banner-clear { + font-size: 0.8rem; + font-weight: 600; + color: #166534; + background: transparent; + border: 1px solid #166534; + border-radius: 6px; + padding: 0.2rem 0.6rem; + cursor: pointer; + white-space: nowrap; +} + +.banner-clear:hover { + background: #166534; + color: #fff; +} + +/* ── Boutons matching ── */ + +.matching-controls { + position: sticky; + bottom: 0; + display: flex; + gap: 8px; + padding: 12px; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); + border-top: 1px solid #e5e7eb; + margin: 0 -1rem -2rem; +} + +.matching-controls button { + flex: 1; + padding: 12px 8px; + border: 1px solid #d0d4dc; + border-radius: 8px; + background: white; + font-size: 14px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.matching-controls button .hint { + font-size: 11px; + color: #6b7280; + font-weight: normal; +} + +.matching-controls button.active { + background: var(--mode-color, #1B4436); + color: white; + border-color: transparent; +} + +.matching-controls button.active .hint { + color: rgba(255, 255, 255, 0.8); +} + +.matching-controls button.reset { + flex: 0 0 auto; + padding: 12px 16px; + background: #f3f4f6; + border-color: #d0d4dc; + color: #374151; + font-size: 13px; +} + +.matching-controls button.reset:hover { + background: #e5e7eb; +} + +@media (max-width: 500px) { + .matching-controls { + display: grid; + grid-template-columns: repeat(2, 1fr); + margin: 0 -0.75rem -1.5rem; + } + + .matching-controls button.reset { + grid-column: span 2; + } } /* ── Mobile ── */ diff --git a/utils/codev/matching.ts b/utils/codev/matching.ts new file mode 100644 index 0000000..fe3135b --- /dev/null +++ b/utils/codev/matching.ts @@ -0,0 +1,97 @@ +import type { CodevFiche, CodevMatch } from '~/types/codev' + +const STOP_WORDS_FR = new Set([ + 'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'au', 'aux', + 'et', 'ou', 'mais', 'donc', 'car', 'ni', 'or', + 'a', 'en', 'pour', 'par', 'sur', 'avec', 'sans', 'dans', 'sous', + 'je', 'tu', 'il', 'elle', 'on', 'nous', 'vous', 'ils', 'elles', + 'mon', 'ma', 'mes', 'ton', 'ta', 'tes', 'son', 'sa', 'ses', + 'notre', 'nos', 'votre', 'vos', 'leur', 'leurs', + 'ce', 'cet', 'cette', 'ces', 'qui', 'que', 'quoi', 'dont', + 'est', 'sont', 'etre', 'ai', 'as', 'avoir', + 'pas', 'plus', 'moins', 'tres', 'aussi', 'bien', 'tout', 'tous', + 'me', 'te', 'se', 'lui', 'leur', 'y', +]) + +function tokenize(text: string): Set { + if (!text) return new Set() + const tokens = text + .toLowerCase() + .replace(/[.,;:!?()'"\-/]/g, ' ') + .split(/\s+/) + .filter((t) => t.length >= 3 && !STOP_WORDS_FR.has(t)) + return new Set(tokens) +} + +function jaccard(a: Set, b: Set): number { + if (a.size === 0 || b.size === 0) return 0 + let inter = 0 + for (const x of a) if (b.has(x)) inter++ + const union = a.size + b.size - inter + return union === 0 ? 0 : inter / union +} + +function score(textA: string, hashtagsA: string[], textB: string, hashtagsB: string[]): number { + const tagsA = new Set(hashtagsA.map((h) => h.toLowerCase())) + const tagsB = new Set(hashtagsB.map((h) => h.toLowerCase())) + + if (tagsA.size > 0 && tagsB.size > 0) { + return jaccard(tagsA, tagsB) + } + return jaccard(tokenize(textA), tokenize(textB)) +} + +const THRESHOLD = 0.15 + +export function matchSolution(fiches: CodevFiche[]): CodevMatch[] { + const matches: CodevMatch[] = [] + for (const a of fiches) { + for (const b of fiches) { + if (a.id === b.id) continue + const s = score(a.besoin, a.hashtags, b.offre, b.hashtags) + if (s >= THRESHOLD) { + matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' }) + } + } + } + return matches +} + +export function matchAlliance(fiches: CodevFiche[]): CodevMatch[] { + const matches: CodevMatch[] = [] + for (let i = 0; i < fiches.length; i++) { + for (let j = i + 1; j < fiches.length; j++) { + const a = fiches[i], b = fiches[j] + const s = score(a.besoin, a.hashtags, b.besoin, b.hashtags) + if (s >= THRESHOLD) { + matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'alliance' }) + } + } + } + return matches +} + +export function matchSurprise(fiches: CodevFiche[]): CodevMatch[] { + const matches: CodevMatch[] = [] + for (let i = 0; i < fiches.length; i++) { + for (let j = i + 1; j < fiches.length; j++) { + const a = fiches[i], b = fiches[j] + const s = score(a.offre, a.hashtags, b.offre, b.hashtags) + if (s >= THRESHOLD) { + matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'surprise' }) + } + } + } + return matches +} + +export function computeMatches( + fiches: CodevFiche[], + mode: 'solution' | 'alliance' | 'surprise', +): CodevMatch[] { + switch (mode) { + case 'solution': return matchSolution(fiches) + case 'alliance': return matchAlliance(fiches) + case 'surprise': return matchSurprise(fiches) + } +}