feat(codev): M4 - matching 3 modes + boutons UI + animation force
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SVG D3 -->
|
<!-- SVG D3 -->
|
||||||
<svg v-else ref="svgEl" class="codev-svg" />
|
<svg v-else ref="svgEl" class="codev-svg">
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="arrow-solution"
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
refX="18"
|
||||||
|
refY="5"
|
||||||
|
markerWidth="6"
|
||||||
|
markerHeight="6"
|
||||||
|
orient="auto-start-reverse"
|
||||||
|
>
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#22c55e" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -82,9 +96,9 @@ function buildLinks(nodes: SimNode[]): SimLink[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function linkColor(mode: string): string {
|
function linkColor(mode: string): string {
|
||||||
if (mode === 'solution') return '#1B4436'
|
if (mode === 'solution') return '#22c55e'
|
||||||
if (mode === 'alliance') return '#3b82f6'
|
if (mode === 'alliance') return '#f97316'
|
||||||
if (mode === 'surprise') return '#a855f7'
|
if (mode === 'surprise') return '#3b82f6'
|
||||||
return '#ccc'
|
return '#ccc'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,8 +146,6 @@ function rebuildLinks() {
|
|||||||
currentLinks = buildLinks(currentNodes)
|
currentLinks = buildLinks(currentNodes)
|
||||||
if (!gLinks || !simulation) return
|
if (!gLinks || !simulation) return
|
||||||
|
|
||||||
const r = nodeRadius.value
|
|
||||||
|
|
||||||
const linkSel = gLinks
|
const linkSel = gLinks
|
||||||
.selectAll<SVGLineElement, SimLink>('line')
|
.selectAll<SVGLineElement, SimLink>('line')
|
||||||
.data(currentLinks, (d: SimLink) => {
|
.data(currentLinks, (d: SimLink) => {
|
||||||
@@ -149,7 +161,7 @@ function rebuildLinks() {
|
|||||||
.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)
|
||||||
.attr('marker-end', null)
|
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rendu complet ──────────────────────────────────────────────────────────
|
// ── Rendu complet ──────────────────────────────────────────────────────────
|
||||||
@@ -173,6 +185,7 @@ function render() {
|
|||||||
.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)
|
||||||
|
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
|
||||||
|
|
||||||
// Noeuds = groupe <g> par personne
|
// Noeuds = groupe <g> par personne
|
||||||
const nodeGroups = gNodes!
|
const nodeGroups = gNodes!
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<p class="carto-subtitle">
|
<p class="carto-subtitle">
|
||||||
<template v-if="pending">Chargement...</template>
|
<template v-if="pending">Chargement...</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} — clique sur un nom pour voir le detail
|
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -23,9 +23,52 @@
|
|||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
||||||
<!-- Boutons matching : arrivent en M4 -->
|
<!-- Bandeau info mode actif -->
|
||||||
<div class="matching-controls placeholder">
|
<div v-if="mode !== 'none'" class="mode-banner">
|
||||||
<p>Boutons matching - arrivent bientot (M4)</p>
|
<span>
|
||||||
|
Mode {{ MODE_LABELS[mode] }} actif -
|
||||||
|
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
||||||
|
</span>
|
||||||
|
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Boutons matching -->
|
||||||
|
<div class="matching-controls">
|
||||||
|
<button
|
||||||
|
:class="{ active: mode === 'solution' }"
|
||||||
|
style="--mode-color: #22c55e"
|
||||||
|
@click="setMode('solution')"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Solution
|
||||||
|
<span class="hint">besoin - offre</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: mode === 'alliance' }"
|
||||||
|
style="--mode-color: #f97316"
|
||||||
|
@click="setMode('alliance')"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Alliance
|
||||||
|
<span class="hint">besoins partages</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: mode === 'surprise' }"
|
||||||
|
style="--mode-color: #3b82f6"
|
||||||
|
@click="setMode('surprise')"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Surprise
|
||||||
|
<span class="hint">offres partagees</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="mode !== 'none'"
|
||||||
|
class="reset"
|
||||||
|
@click="setMode('none')"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -33,18 +76,32 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
||||||
|
import { computeMatches } from '~/utils/codev/matching'
|
||||||
|
|
||||||
useHead({ title: 'Carto — Co-developpement' })
|
useHead({ title: 'Carto - Co-developpement' })
|
||||||
|
|
||||||
const { data, pending } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
|
const { data, pending } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
|
||||||
const fiches = computed(() => data.value?.list ?? [])
|
const fiches = computed(() => data.value?.list ?? [])
|
||||||
|
|
||||||
// En M3 : pas de matching, tableau vide. M4 remplira ces refs.
|
|
||||||
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 MODE_LABELS: Record<string, string> = {
|
||||||
|
solution: 'Solution',
|
||||||
|
alliance: 'Alliance',
|
||||||
|
surprise: 'Surprise',
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(newMode: 'none' | 'solution' | 'alliance' | 'surprise') {
|
||||||
|
mode.value = newMode
|
||||||
|
if (newMode === 'none') {
|
||||||
|
matches.value = []
|
||||||
|
} else {
|
||||||
|
matches.value = computeMatches(fiches.value, newMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onSelectFiche(id: number) {
|
function onSelectFiche(id: number) {
|
||||||
// M3 : simple navigation vers la fiche (ou futur drawer en M4)
|
|
||||||
navigateTo(`/codev/fiche?id=${id}`)
|
navigateTo(`/codev/fiche?id=${id}`)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -96,17 +153,107 @@ function onSelectFiche(id: number) {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Placeholder matching (M4) ── */
|
/* ── Bandeau mode actif ── */
|
||||||
|
|
||||||
.matching-controls.placeholder {
|
.mode-banner {
|
||||||
text-align: center;
|
display: flex;
|
||||||
padding: 0.75rem;
|
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 {
|
.banner-clear {
|
||||||
color: #999;
|
font-size: 0.8rem;
|
||||||
font-size: 0.85rem;
|
font-weight: 600;
|
||||||
margin: 0;
|
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 ── */
|
/* ── Mobile ── */
|
||||||
|
|||||||
97
utils/codev/matching.ts
Normal file
97
utils/codev/matching.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string>, b: Set<string>): 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user