6 Commits

Author SHA1 Message Date
Jules Neny
ad9e7db43c feat(aep-v2): restore V2 cascade composants récupérés depuis vault history
- Récupérés depuis commit vault b700612^ (état pré-chirurgie git)
- FicheFamilleModal.vue (284L) — PV2-5g
- FicheModalV2.vue (341L) + NavMapV2.vue (243L) — PV2-5
- HashtagFilter.vue (97L) + IntentionBanner.vue (76L) — PV2-5
- GraphView.vue (860L) — PV2-5b+5e+5f+5g complet
- ChatbotPlaceholder.vue (423L) — version chatbot-v2
- pages/index.vue (517L) — carte unifiée 3 onglets
- types/structure-v2.ts, assets/css/v2-bifurcation.css
- server/api/chatbot-v2.post.ts, server/utils/vectorSearch.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:23:36 +02:00
Jules Neny
825b0ddeb2 feat(codev): M5 phase 1 - mode demo factice + build local OK 2026-05-06 16:11:34 +02:00
Jules Neny
d345d7f6f9 feat(codev): M4 - matching 3 modes + boutons UI + animation force
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:07:20 +02:00
Jules Neny
3347b3f859 feat(codev): M3 - CodevGraph D3 force-directed + page carto affichage
- Install d3@^7.9.0 (absent du projet, requis pour force simulation)
- components/codev/CodevGraph.vue : simulation forceLink/forceManyBody/forceCenter/forceCollide, drag D3, pastilles offre (vert) + besoin (orange), tooltip SVG natif, ResizeObserver, watch matches/mode pret pour M4, placeholder si 0 fiches
- pages/codev/carto.vue : useFetch /api/codev/fiches, mount CodevGraph, refs matches+mode vides (M4 les remplira)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:03:28 +02:00
Jules Neny
9c4f4b8e87 feat(codev): M2 - lock screen + fiche form + middleware auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:59:26 +02:00
Jules Neny
5103942698 feat(codev): M1 - NocoDB table schema + 3 endpoints API + runtimeConfig 2026-05-06 15:56:19 +02:00
27 changed files with 5050 additions and 387 deletions

View File

@@ -0,0 +1,23 @@
/* Palette familles V2 - variables locales, ne pas toucher --nav-* */
:root {
--bifurc-color-f1: #a85d3e; /* Réemploi & filières - terracotta */
--bifurc-color-f2: #c4a472; /* Frugalité & low-tech - terre crue */
--bifurc-color-f3: #d4a017; /* Architecture sociale - ocre */
--bifurc-color-f4: #5a7a4a; /* Collectifs & AMO - vert mousse */
--bifurc-color-f5: #3d6a8c; /* Urbanisme transition - bleu profond */
--bifurc-badge-f6: #6b3fa0; /* Recherche politique - violet */
--bifurc-badge-cr: #2d8a6b; /* Centre ressources - vert foncé */
--bifurc-badge-mm: #c44a2f; /* Mouvement manifeste - rouge brique */
--bifurc-badge-cp: #1a3a6b; /* Contre-pouvoir - bleu nuit */
--bifurc-banner-bg: #faf8f5;
--bifurc-banner-border: #e0d8cc;
--bifurc-banner-text: #2c2416;
}
.bifurc-pin-f1 { background: var(--bifurc-color-f1); }
.bifurc-pin-f2 { background: var(--bifurc-color-f2); }
.bifurc-pin-f3 { background: var(--bifurc-color-f3); }
.bifurc-pin-f4 { background: var(--bifurc-color-f4); }
.bifurc-pin-f5 { background: var(--bifurc-color-f5); }

View File

@@ -52,18 +52,9 @@
<div class="chatbot-body-inner" ref="messagesContainer">
<!-- Onboarding -->
<div v-if="messages.length === 0" class="onboarding-bubble">
<p>Ce chatbot fonctionne sur un serveur européen souverain
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
<p>Pour m'aider à te répondre efficacement,
formule ta requête ainsi :</p>
<ul>
<li>• Besoin : [ce que tu cherches]</li>
<li>• Thématique : [juridique / technique / économique / ...]</li>
<li>• Lieu : [région ou ville]</li>
</ul>
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
employeur, besoin conseil juridique droit du travail,
Île-de-France."</p>
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p>
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR - serveur européen souverain, zéro rétention.</p>
</div>
<!-- Messages -->
@@ -72,7 +63,7 @@ employeur, besoin conseil juridique droit du travail,
<div v-else class="assistant-bubble">
<p>{{ msg.content }}</p>
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
<p class="fiches-title">Fiches recommandées :</p>
<p class="fiches-title">Fiches recommandees :</p>
<a
v-for="fiche in msg.fiches"
:key="fiche.id"
@@ -83,6 +74,21 @@ employeur, besoin conseil juridique droit du travail,
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
</a>
</div>
<div v-if="msg.suggestedHashtags && msg.suggestedHashtags.length" style="margin-top: 8px;">
<p style="font-size: 0.7rem; color: var(--nav-text-muted); margin-bottom: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;">Filtrer par :</p>
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
<span
v-for="tag in msg.suggestedHashtags"
:key="tag"
style="
padding: 2px 8px; border-radius: 9999px; font-size: 0.7rem; cursor: pointer;
background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid var(--nav-bg-alt);
transition: all 0.15s;
"
@click="emit('applyHashtag', tag)"
>{{ tag }}</span>
</div>
</div>
</div>
</template>
@@ -132,10 +138,12 @@ interface ChatMessage {
role: 'user' | 'assistant'
content: string
fiches?: FicheReco[]
suggestedHashtags?: string[]
}
const emit = defineEmits<{
'highlightOrgs': [ids: (number | string)[]]
'applyHashtag': [tag: string]
}>()
const isExpanded = ref(false)
@@ -145,6 +153,37 @@ const loading = ref(false)
const errorMsg = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
// Detection hashtags depuis la question posee
const HASHTAG_KEYWORDS: Record<string, string[]> = {
'#reemploi-structurel': ['reemploi', 'materiaux recuperes', 'deconstruction', 'reemploi structurel'],
'#reemploi-second-oeuvre': ['revetement', 'second oeuvre', 'reemploi'],
'#biosource-geosource': ['biosource', 'geosource', 'paille', 'terre', 'chanvre', 'lin', 'biosource'],
'#low-tech-experimentation': ['low-tech', 'low tech', 'technique simple', 'autonomie', 'lowtech'],
'#chantier-ecole': ['formation', 'chantier ecole', 'chantier-ecole', 'apprendre', 'auto-construction', 'autoconstruction'],
'#sobriete-energetique': ['sobriete', 'energie', 'renovation energetique', 'isolation', 'chauffage', 'economie energie'],
'#mal-logement-precarite': ['mal-logement', 'precarite', 'sans-abri', 'logement social', 'squat', 'mal logement'],
'#tiers-lieux-friches': ['friche', 'tiers-lieu', 'tiers lieu', 'espace intermediaire', 'temporaire', 'reconversion'],
'#accompagnement-cooperatif': ['cooperative', 'accompagnement', 'cooperation', 'collectif', 'mutualisation'],
'#transition-energetique-territoriale': ['territoire', 'transition', 'energetique', 'local', 'region', 'transition energetique'],
'#communs-fonciers': ['communs', 'foncier', 'anti-speculatif', 'community land trust', 'commun foncier'],
'#hack-juridique': ['juridique', 'montage', 'structure legale', 'sci', 'cooperative', 'statut'],
'#retrofit-strates': ['retrofit', 'renovation lourde', 'sur-isolation', 'rehaussement'],
'#phytoconstruction': ['plantes', 'vegetal', 'arbre', 'construction vivante', 'phyto'],
}
function detectHashtagsFromQuery(query: string): string[] {
const q = query.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
const detected: string[] = []
for (const [hashtag, keywords] of Object.entries(HASHTAG_KEYWORDS)) {
if (keywords.some(kw => q.includes(kw))) {
detected.push(hashtag)
}
}
return detected.slice(0, 3)
}
function toggleExpand() {
isExpanded.value = !isExpanded.value
}
@@ -170,10 +209,12 @@ async function sendMessage() {
body: { question },
})
const suggestedHashtags = detectHashtagsFromQuery(question)
const assistantMsg: ChatMessage = {
role: 'assistant',
content: res.reponse_texte,
fiches: res.fiches_recommandees || [],
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
}
messages.value.push(assistantMsg)

View File

@@ -0,0 +1,284 @@
<template>
<Teleport to="body">
<!-- Backdrop -->
<Transition name="backdrop">
<div
v-if="modelValue && familleId != null"
class="fixed inset-0 z-[1400]"
style="background: rgba(26,34,56,0.55);"
@click="close"
aria-hidden="true"
/>
</Transition>
<!-- Modal -->
<Transition name="modal">
<div
v-if="modelValue && familleId != null"
class="fixed z-[1401] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
style="
width: min(720px, 94vw);
max-height: 88vh;
background: var(--nav-bg);
border-radius: 16px;
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
overflow: hidden;
"
role="dialog"
aria-modal="true"
:aria-label="familleLabel"
@keydown.esc="close"
>
<!-- Header : background couleur famille -->
<div
class="flex items-center justify-between px-5 py-4 shrink-0"
:style="`background: ${familleColor}; color: white;`"
>
<div class="flex items-center gap-3">
<div
class="w-3 h-3 rounded-full shrink-0"
style="background: white;"
/>
<h2 class="text-lg font-bold" style="color: white;">{{ familleLabel }}</h2>
</div>
<button
@click="close"
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
style="background: rgba(255,255,255,0.18); color: white;"
aria-label="Fermer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<!-- Body scrollable -->
<div class="flex-1 overflow-y-auto px-5 py-5">
<!-- Description longue editoriale -->
<p
class="text-sm leading-relaxed mb-5"
style="color: var(--nav-text); white-space: pre-wrap;"
>{{ familleDescription }}</p>
<!-- Separateur -->
<div style="height: 1px; background: var(--nav-bg-alt); margin-bottom: 16px;" />
<!-- Mode fusion : Principal+Secondaire melanges (peu de secondaires) -->
<template v-if="!showSplit">
<h3
class="text-xs font-bold uppercase tracking-wide mb-3"
style="color: var(--nav-text-muted);"
>{{ allStructures.length }} structure{{ allStructures.length > 1 ? 's' : '' }}</h3>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
<li
v-for="s in allStructures"
:key="s.id"
@click="selectStructure(s.id)"
class="structure-row"
style="
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 6px;
cursor: pointer; transition: background 0.1s;
"
>
<span
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
:title="`Famille principale : ${FAMILLE_LABELS[s.famille_principale] ?? ''}`"
/>
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
</li>
</ul>
</template>
<!-- Mode split : Principal / Secondaire separes -->
<template v-else>
<div v-if="principalStructures.length" class="mb-5">
<h3
class="text-xs font-bold uppercase tracking-wide mb-3"
style="color: var(--nav-text-muted);"
>Principal ({{ principalStructures.length }})</h3>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
<li
v-for="s in principalStructures"
:key="s.id"
@click="selectStructure(s.id)"
class="structure-row"
style="
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 6px;
cursor: pointer; transition: background 0.1s;
"
>
<span
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
/>
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
</li>
</ul>
</div>
<div v-if="secondaireStructures.length">
<h3
class="text-xs font-bold uppercase tracking-wide mb-3"
style="color: var(--nav-text-muted);"
>Secondaire ({{ secondaireStructures.length }})</h3>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
<li
v-for="s in secondaireStructures"
:key="s.id"
@click="selectStructure(s.id)"
class="structure-row"
style="
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 6px;
cursor: pointer; transition: background 0.1s;
"
>
<span
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
:title="`Famille principale : ${FAMILLE_LABELS[s.famille_principale] ?? ''}`"
/>
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
</li>
</ul>
</div>
</template>
</div>
<!-- Footer -->
<div
class="px-5 py-3 shrink-0 text-xs"
style="border-top: 1px solid var(--nav-bg-alt); color: var(--nav-text-muted); background: var(--nav-surface);"
>
Click sur une structure pour ouvrir sa fiche
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
const FAMILLE_LABELS: Record<number, string> = {
1: 'Reemploi & filieres',
2: 'Frugalite & low-tech',
3: 'Architecture sociale',
4: 'Collectifs & AMO',
5: 'Urbanisme de transition',
6: 'Recherche-action',
}
const FAMILLE_DESCRIPTIONS: Record<number, string> = {
1: "Structures dont le geste premier est de travailler avec la matiere existante : deconstruction selective, plateformes de redistribution, filieres biosourcees et geosourcees.",
2: "Pratiques qui partent du principe qu'on peut faire mieux avec moins. Renovation profonde, materiaux locaux, sobriete choisie.",
3: "Structures dont le terrain premier est le mal-logement, la precarite, l'hospitalite. Architecture comme reponse a l'urgence sociale.",
4: "Structures qui accompagnent les projets collectifs : cooperatives d'habitat, ecovillages, accompagnement vers l'autogestion ou la renovation.",
5: "Demarches a l'echelle du territoire : villes en transition, PLU alternatifs, coalitions territoriales.",
6: "Recherche-action et production de contre-savoirs (Forensic Architecture, Rural Studio, PEROU, Centrala). Badge transversal aux familles.",
}
const props = defineProps<{
modelValue: boolean
familleId: number | null
data: ReseauxBifurcationData | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'select-structure': [id: string]
}>()
function close() {
emit('update:modelValue', false)
}
function selectStructure(id: string) {
// Fermer d'abord pour eviter superposition de modales
emit('update:modelValue', false)
emit('select-structure', id)
}
// Fermeture Esc globale
onMounted(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.modelValue) close()
}
window.addEventListener('keydown', handler)
onUnmounted(() => window.removeEventListener('keydown', handler))
})
const familleColor = computed(() =>
FAMILLE_COLORS[props.familleId ?? 0] ?? '#888'
)
const familleLabel = computed(() =>
FAMILLE_LABELS[props.familleId ?? 0] ?? ''
)
const familleDescription = computed(() =>
FAMILLE_DESCRIPTIONS[props.familleId ?? 0] ?? ''
)
const principalStructures = computed<StructureV2[]>(() => {
if (!props.data || props.familleId == null) return []
return props.data.structures
.filter(s => s.famille_principale === props.familleId)
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
})
const secondaireStructures = computed<StructureV2[]>(() => {
if (!props.data || props.familleId == null) return []
return props.data.structures
.filter(s =>
s.famille_principale !== props.familleId
&& (s.familles_secondaires ?? []).includes(props.familleId as number)
)
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
})
const allStructures = computed<StructureV2[]>(() => {
return [...principalStructures.value, ...secondaireStructures.value]
})
// Heuristique : si > 3 secondaires, separer en sections distinctes
const showSplit = computed(() => secondaireStructures.value.length > 3)
</script>
<style scoped>
.structure-row:hover {
background: var(--nav-bg-alt);
}
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
.modal-enter-active, .modal-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.modal-enter-from, .modal-leave-to {
opacity: 0;
transform: translate(-50%, -52%);
}
@media (prefers-reduced-motion: reduce) {
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
.modal-enter-active, .modal-leave-active { transition: none; }
}
</style>

341
components/FicheModalV2.vue Normal file
View File

@@ -0,0 +1,341 @@
<template>
<Teleport to="body">
<!-- Backdrop -->
<Transition name="backdrop">
<div
v-if="modelValue && structureId != null"
class="fixed inset-0 z-[1500]"
style="background: rgba(26,34,56,0.55);"
@click="close"
aria-hidden="true"
/>
</Transition>
<!-- Modal -->
<Transition name="modal">
<div
v-if="modelValue && structureId != null && structure"
class="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
style="
width: min(780px, 94vw);
max-height: 90vh;
background: var(--nav-bg);
border-radius: 16px;
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
overflow: hidden;
"
role="dialog"
aria-modal="true"
:aria-label="structure?.nom ?? 'Fiche structure'"
@keydown.esc="close"
>
<!-- Header modal -->
<div
class="flex items-center justify-between px-5 py-3 shrink-0"
:style="`border-bottom: 3px solid ${familleColor}; background: var(--nav-surface);`"
>
<div class="flex items-center gap-3">
<!-- Pastille famille -->
<div
class="w-3 h-3 rounded-full shrink-0"
:style="`background: ${familleColor};`"
/>
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">
{{ familleLabel }}
</span>
<!-- Badges -->
<div class="flex gap-1.5">
<span
v-if="structure.badges.centre_ressources"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #2d8a6b22; color: #2d8a6b;"
>Centre ressources</span>
<span
v-if="structure.badges.mouvement_manifeste"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #c44a2f22; color: #c44a2f;"
>Manifeste</span>
<span
v-if="structure.badges.contre_pouvoir_spatial"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #1a3a6b22; color: #1a3a6b;"
>Contre-pouvoir</span>
<span
v-if="structure.badges.f6_recherche_politique"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #6b3fa022; color: #6b3fa0;"
>Recherche pol.</span>
</div>
</div>
<div class="flex items-center gap-2">
<a
v-if="structure.url"
:href="structure.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text);"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Site web
</a>
<button
@click="close"
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
aria-label="Fermer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<!-- Contenu scrollable -->
<div class="flex-1 overflow-y-auto px-5 py-5">
<!-- Nom + meta -->
<div class="mb-4">
<h2 class="text-xl font-bold mb-1" style="color: var(--nav-text);">{{ structure.nom }}</h2>
<div class="flex flex-wrap gap-2 text-sm" style="color: var(--nav-text-muted);">
<span>{{ structure.type_principal }}</span>
<span>·</span>
<span>{{ structure.ville }}, {{ structure.pays }}</span>
</div>
</div>
<!-- Hashtags -->
<div v-if="structure.hashtags.length" class="flex flex-wrap gap-1.5 mb-4">
<span
v-for="tag in structure.hashtags"
:key="tag"
class="px-2 py-0.5 rounded-full text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
<!-- Description courte -->
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">{{ structure.description_courte }}</p>
<!-- Description longue (expandable) -->
<div v-if="structure.description_longue" class="mb-4">
<div
class="text-sm leading-relaxed"
style="color: var(--nav-text); white-space: pre-wrap;"
:style="showFullDesc ? '' : 'max-height: 120px; overflow: hidden;'"
>{{ structure.description_longue }}</div>
<button
@click="showFullDesc = !showFullDesc"
class="mt-2 text-xs underline"
style="color: var(--nav-text-muted);"
>{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}</button>
</div>
<!-- Pensées rattachées -->
<div v-if="structure.pensees && structure.pensees.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Pensées rattachées</h3>
<div class="flex flex-wrap gap-1.5">
<span
v-for="pensee in structure.pensees"
:key="pensee.id"
class="px-2 py-0.5 rounded text-xs"
:style="pensee.confiance === 'ia_suggested'
? 'background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px dashed var(--nav-bg-alt);'
: 'background: var(--nav-bg-alt); color: var(--nav-text);'"
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
>
{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" class="ml-1 opacity-60">~</span>
</span>
</div>
</div>
<!-- Projets emblématiques -->
<div v-if="projetsStructure.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Projets emblématiques</h3>
<div class="space-y-2">
<div
v-for="projet in projetsStructure.slice(0, 5)"
:key="projet.id"
class="rounded-lg p-3"
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
>
<div class="flex items-start justify-between gap-2">
<span class="font-medium text-sm" style="color: var(--nav-text);">{{ projet.nom }}</span>
<span v-if="projet.annee" class="text-xs shrink-0" style="color: var(--nav-text-muted);">{{ projet.annee }}</span>
</div>
<div v-if="projet.lieu" class="text-xs mt-0.5" style="color: var(--nav-text-muted);">{{ projet.lieu }}</div>
<p class="text-xs mt-1 leading-relaxed" style="color: var(--nav-text-muted);">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
<a
v-if="projet.url"
:href="projet.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs mt-1 inline-block"
style="color: var(--nav-text-muted); text-decoration: underline;"
>En savoir plus </a>
</div>
</div>
</div>
<!-- Structures voisines (graphe) -->
<div v-if="structuresVoisines.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Structures liées</h3>
<div class="flex flex-wrap gap-2">
<button
v-for="voisine in structuresVoisines.slice(0, 6)"
:key="voisine.id"
class="px-2 py-1 rounded text-xs transition-colors hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid transparent;"
@click="emit('update:structureId', voisine.id)"
>{{ voisine.nom }}</button>
</div>
</div>
<!-- Sources -->
<div v-if="structure.sources && structure.sources.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Sources</h3>
<div class="space-y-1">
<div v-for="(source, i) in structure.sources" :key="i" class="flex items-center gap-2">
<span
class="px-1.5 py-0.5 rounded text-xs shrink-0"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ source.type }}</span>
<a
v-if="source.url"
:href="source.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs underline truncate"
style="color: var(--nav-text);"
>{{ source.titre }}</a>
<span v-else class="text-xs" style="color: var(--nav-text);">{{ source.titre }}</span>
</div>
</div>
</div>
<!-- CTAs -->
<div class="flex gap-3 pt-2" style="border-top: 1px solid var(--nav-bg-alt);">
<a
href="/contribuer"
class="text-xs underline"
style="color: var(--nav-text-muted);"
>Signaler une erreur</a>
<a
href="/contribuer"
class="text-xs underline"
style="color: var(--nav-text-muted);"
>Réclamer cette fiche</a>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2, ProjetEmblematique } from '~/types/structure-v2'
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
}
const FAMILLE_LABELS: Record<number, string> = {
1: 'Réemploi & filières',
2: 'Frugalité & low-tech',
3: 'Architecture sociale',
4: 'Collectifs & AMO',
5: 'Urbanisme de transition',
}
const props = defineProps<{
modelValue: boolean
structureId: string | null
data: ReseauxBifurcationData | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:structureId': [id: string]
}>()
const showFullDesc = ref(false)
function close() {
emit('update:modelValue', false)
showFullDesc.value = false
}
// Fermeture Esc globale
onMounted(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.modelValue) close()
}
window.addEventListener('keydown', handler)
onUnmounted(() => window.removeEventListener('keydown', handler))
})
// Remettre showFullDesc a false a chaque changement de fiche
watch(() => props.structureId, () => {
showFullDesc.value = false
})
const structure = computed<StructureV2 | null>(() => {
if (!props.data || !props.structureId) return null
return props.data.structures.find(s => s.id === props.structureId) ?? null
})
const familleColor = computed(() =>
FAMILLE_COLORS[structure.value?.famille_principale ?? 1] ?? '#888'
)
const familleLabel = computed(() =>
FAMILLE_LABELS[structure.value?.famille_principale ?? 1] ?? ''
)
const projetsStructure = computed<ProjetEmblematique[]>(() => {
if (!props.data || !props.structureId) return []
return props.data.projets?.filter(p => p.structure_parent === props.structureId) ?? []
})
const structuresVoisines = computed<StructureV2[]>(() => {
if (!props.data || !props.structureId) return []
const edges = props.data.graphe?.edges ?? []
const voisineIds = edges
.filter(e => e.source === props.structureId || e.target === props.structureId)
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
.map(e => e.source === props.structureId ? e.target : e.source)
.slice(0, 8)
return voisineIds
.map(id => props.data!.structures.find(s => s.id === id))
.filter(Boolean) as StructureV2[]
})
</script>
<style scoped>
/* Backdrop */
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
/* Modal */
.modal-enter-active, .modal-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.modal-enter-from, .modal-leave-to {
opacity: 0;
transform: translate(-50%, -52%);
}
@media (prefers-reduced-motion: reduce) {
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
.modal-enter-active, .modal-leave-active { transition: none; }
}
</style>

860
components/GraphView.vue Normal file
View File

@@ -0,0 +1,860 @@
<template>
<div class="graph-view" style="width: 100%; height: 100%; position: relative; background: var(--nav-bg);">
<!-- Canvas SVG pour D3 (zone centrale, marge droite pour sidebar) -->
<svg
ref="svgRef"
:style="{
width: sidebarOpen ? 'calc(100% - 200px)' : 'calc(100% - 40px)',
height: '100%',
transition: 'width 0.2s ease',
}"
></svg>
<!-- Sidebar droite (repliable) - 3 sections : AFFICHER / HASHTAGS / MODE D'EMPLOI -->
<aside
:style="{
position: 'absolute', top: '0', right: '0', bottom: '0',
width: sidebarOpen ? '200px' : '40px',
background: 'var(--nav-surface)',
borderLeft: '1px solid var(--nav-bg-alt)',
display: 'flex', flexDirection: 'column',
transition: 'width 0.2s ease',
zIndex: 10,
}"
>
<!-- Toggle (toujours visible) -->
<button
@click="sidebarOpen = !sidebarOpen"
:title="sidebarOpen ? 'Replier la sidebar' : 'Deplier la sidebar'"
style="
width: 100%; height: 36px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: var(--nav-bg-alt); border: none; cursor: pointer;
color: var(--nav-text-muted); font-size: 0.78rem; font-weight: 700;
border-bottom: 1px solid var(--nav-bg-alt);
"
>{{ sidebarOpen ? '>>' : '<<' }}</button>
<!-- Mode replie : label vertical -->
<div
v-if="!sidebarOpen"
style="
flex: 1; display: flex; align-items: center; justify-content: center;
writing-mode: vertical-rl; transform: rotate(180deg);
font-size: 0.7rem; font-weight: 700; color: var(--nav-text-muted);
letter-spacing: 0.12em; text-transform: uppercase;
"
>HASHTAGS ({{ activeHashtags.length }}/{{ props.allHashtags.length }})</div>
<!-- Mode deplie : 3 sections empilees -->
<template v-if="sidebarOpen">
<div style="flex: 1; overflow-y: auto; display: flex; flex-direction: column;">
<!-- SECTION 1 : AFFICHER (toggles familles / pratiques) -->
<div style="padding: 10px 12px; flex-shrink: 0;">
<div style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px;">Afficher</div>
<label
:style="{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '7px 10px', marginBottom: '4px',
borderRadius: '6px', cursor: 'pointer',
fontSize: '0.82rem', fontWeight: 600,
background: showFamilles ? 'var(--nav-bg-alt)' : 'transparent',
color: showFamilles ? 'var(--nav-text)' : 'var(--nav-text-muted)',
transition: 'all 0.12s',
}"
>
<input type="checkbox" v-model="showFamilles" style="cursor: pointer; width: 14px; height: 14px;" />
<span>Familles</span>
</label>
<label
:style="{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '7px 10px',
borderRadius: '6px', cursor: 'pointer',
fontSize: '0.82rem', fontWeight: 600,
background: showPratiques ? 'var(--nav-bg-alt)' : 'transparent',
color: showPratiques ? 'var(--nav-text)' : 'var(--nav-text-muted)',
transition: 'all 0.12s',
}"
>
<input type="checkbox" v-model="showPratiques" style="cursor: pointer; width: 14px; height: 14px;" />
<span>Pratiques</span>
</label>
</div>
<!-- SECTION 2 : HASHTAGS (chips groupees) -->
<div style="border-top: 1px solid var(--nav-bg-alt); margin-top: 0; padding: 10px 12px 8px; flex-shrink: 0;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px;">
<span style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em;">Hashtags</span>
<span style="font-size: 0.68rem; color: var(--nav-text-muted);">{{ activeHashtags.length }} actif{{ activeHashtags.length > 1 ? 's' : '' }}</span>
</div>
<button
v-if="activeHashtags.length"
@click="activeHashtags = []"
style="margin-bottom: 6px; font-size: 0.68rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
>Tout effacer</button>
</div>
<div style="flex: 1; overflow-y: auto; padding: 0 10px 10px;">
<div
v-for="group in hashtagsByFamille"
:key="group.famille"
style="margin-bottom: 10px;"
>
<div
:style="{
fontSize: '0.65rem', fontWeight: 700,
color: group.color, textTransform: 'uppercase',
letterSpacing: '0.06em', marginBottom: '4px',
paddingLeft: '2px',
}"
>{{ group.label }}</div>
<div style="display: flex; flex-wrap: wrap; gap: 3px;">
<span
v-for="tag in group.tags"
:key="tag"
style="padding: 2px 7px; border-radius: 9999px; font-size: 0.66rem; cursor: pointer; transition: all 0.12s;"
:style="activeHashtags.includes(tag)
? `background: ${group.color}; color: white; font-weight: 600;`
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleHashtag(tag)"
>{{ tag }}</span>
</div>
</div>
</div>
<!-- SECTION 3 : MODE D'EMPLOI -->
<div style="border-top: 1px solid var(--nav-bg-alt); padding: 10px 12px; flex-shrink: 0;">
<div style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px;">Mode d'emploi</div>
<div style="font-size: 0.7rem; color: var(--nav-text-muted); line-height: 1.5;">
La carte croise des familles editoriales avec des pratiques (hashtags). Coche les couches a afficher, filtre par hashtag, clique sur un noeud pour en savoir plus.
</div>
</div>
</div>
</template>
</aside>
<!-- Tooltip -->
<div ref="tooltipRef" style="
position: absolute; pointer-events: none;
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
border-radius: 6px; padding: 8px 12px; font-size: 0.78rem;
color: var(--nav-text); max-width: 220px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
opacity: 0; transition: opacity 0.15s; z-index: 100;
"></div>
<!-- Popover unifie (famille OU hashtag) -->
<div
v-if="popover.open"
:style="{
position: 'absolute',
left: popover.x + 'px',
top: popover.y + 'px',
background: 'var(--nav-surface)',
border: '1px solid var(--nav-bg-alt)',
borderRadius: '8px',
padding: '12px 14px',
maxWidth: '280px',
boxShadow: '0 6px 18px rgba(0,0,0,0.18)',
zIndex: 50,
}"
@click.stop
>
<button
@click="closePopover"
style="
position: absolute; top: 4px; right: 6px;
background: none; border: none; cursor: pointer;
font-size: 1rem; color: var(--nav-text-muted); padding: 2px 6px;
line-height: 1;
"
title="Fermer"
>x</button>
<div
:style="{
fontWeight: 700, fontSize: '0.92rem',
color: popover.color, marginBottom: '6px',
paddingRight: '14px',
}"
>{{ popover.title }}</div>
<!-- Body famille : description + compteur + 6 structures + bouton "Voir toutes" -->
<div v-if="popover.kind === 'famille'">
<div style="font-size: 0.78rem; line-height: 1.45; color: var(--nav-text); margin-bottom: 10px;">
{{ popover.body }}
</div>
<div style="font-size: 0.72rem; color: var(--nav-text-muted); margin-bottom: 6px;">
{{ popover.structures.length }} structure{{ popover.structures.length > 1 ? 's' : '' }} dans cette famille
</div>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px;">
<li
v-for="s in popover.structures.slice(0, 6)"
:key="s.id"
@click="selectStructureFromPopover(s.id)"
style="
font-size: 0.78rem; color: var(--nav-text);
padding: 4px 6px; border-radius: 4px;
cursor: pointer; transition: background 0.1s;
display: flex; align-items: center; gap: 6px;
"
@mouseenter="(e: any) => e.currentTarget.style.background = 'var(--nav-bg-alt)'"
@mouseleave="(e: any) => e.currentTarget.style.background = 'transparent'"
>
<span
style="width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;"
:style="`background: ${popover.color};`"
/>
<span>{{ s.nom }}</span>
</li>
</ul>
<button
v-if="popover.familleId != null"
@click="openFicheFamilleFromPopover"
style="
margin-top: 10px; width: 100%;
padding: 7px 10px; border-radius: 6px;
background: var(--nav-bg-alt); border: none;
font-size: 0.75rem; font-weight: 600; cursor: pointer;
color: var(--nav-text); transition: opacity 0.12s;
text-align: left;
"
@mouseenter="(e: any) => e.currentTarget.style.opacity = '0.7'"
@mouseleave="(e: any) => e.currentTarget.style.opacity = '1'"
>Voir toutes les {{ popover.structures.length }} pratiques -&gt;</button>
</div>
<!-- Body hashtag : ligne generique + compteur + liste structures cliquables -->
<div v-else-if="popover.kind === 'hashtag'">
<div
style="
font-size: 0.72rem; color: var(--nav-text-muted);
font-style: italic; margin-bottom: 8px; line-height: 1.4;
"
>Pratique transversale - portee par {{ popover.structures.length }} structure{{ popover.structures.length > 1 ? 's' : '' }} de {{ popover.famillesCount }} famille{{ popover.famillesCount > 1 ? 's' : '' }}</div>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px;">
<li
v-for="s in popover.structures.slice(0, 6)"
:key="s.id"
@click="selectStructureFromPopover(s.id)"
style="
font-size: 0.78rem; color: var(--nav-text);
padding: 4px 6px; border-radius: 4px;
cursor: pointer; transition: background 0.1s;
"
@mouseenter="(e: any) => e.currentTarget.style.background = 'var(--nav-bg-alt)'"
@mouseleave="(e: any) => e.currentTarget.style.background = 'transparent'"
>{{ s.nom }}</li>
</ul>
<div
v-if="popover.structures.length > 6"
style="font-size: 0.7rem; color: var(--nav-text-muted); margin-top: 6px; padding-left: 6px;"
>+ {{ popover.structures.length - 6 }} autre{{ popover.structures.length - 6 > 1 ? 's' : '' }}</div>
</div>
</div>
<!-- Fiche famille modale -->
<FicheFamilleModal
v-model="ficheFamilleOpen"
:famille-id="ficheFamilleId"
:data="props.data"
@select-structure="(id) => emit('select-structure', id)"
/>
</div>
</template>
<script setup lang="ts">
import type { ReseauxBifurcationData } from '~/types/structure-v2'
const props = defineProps<{
data: ReseauxBifurcationData | null
allHashtags: string[]
active?: boolean
}>()
const emit = defineEmits<{
'select-structure': [id: string]
}>()
const svgRef = ref<SVGElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null)
// Hashtag filter
const activeHashtags = ref<string[]>([])
const sidebarOpen = ref(true)
// Layers superposables (remplace viewMode exclusif PV2-5e)
const showFamilles = ref(true)
const showPratiques = ref(false)
function toggleHashtag(tag: string) {
activeHashtags.value = activeHashtags.value.includes(tag)
? activeHashtags.value.filter(t => t !== tag)
: [...activeHashtags.value, tag]
}
// Popover unifie (famille | hashtag)
type PopoverState = {
open: boolean
kind: 'famille' | 'hashtag' | null
x: number
y: number
title: string
body: string
color: string
structures: { id: string; nom: string }[]
familleId: number | null
famillesCount: number
}
const popover = ref<PopoverState>({
open: false,
kind: null,
x: 0,
y: 0,
title: '',
body: '',
color: '#000',
structures: [],
familleId: null,
famillesCount: 0,
})
// Fiche famille modale
const ficheFamilleOpen = ref(false)
const ficheFamilleId = ref<number | null>(null)
function closePopover() {
popover.value.open = false
popover.value.kind = null
}
function selectStructureFromPopover(id: string) {
closePopover()
emit('select-structure', id)
}
function openFicheFamilleFromPopover() {
if (popover.value.familleId == null) return
ficheFamilleId.value = popover.value.familleId
ficheFamilleOpen.value = true
closePopover()
}
// Mapping hashtag -> famille majoritaire
// En cas d'egalite : prendre la famille la plus petite (visibilite minoritaires)
const tagToFamille = computed<Record<string, number>>(() => {
if (!props.data) return {}
const counts: Record<string, Record<number, number>> = {}
props.data.structures.forEach(s => {
s.hashtags.forEach(tag => {
if (!counts[tag]) counts[tag] = {}
counts[tag][s.famille_principale] = (counts[tag][s.famille_principale] ?? 0) + 1
})
})
const familleSize: Record<number, number> = {}
props.data.structures.forEach(s => {
familleSize[s.famille_principale] = (familleSize[s.famille_principale] ?? 0) + 1
})
const out: Record<string, number> = {}
for (const tag in counts) {
const entries = Object.entries(counts[tag])
entries.sort((a, b) => {
const diff = (b[1] as number) - (a[1] as number)
if (diff !== 0) return diff
return (familleSize[Number(a[0])] ?? 0) - (familleSize[Number(b[0])] ?? 0)
})
out[tag] = Number(entries[0][0])
}
return out
})
const hashtagsByFamille = computed(() => {
if (!props.data) return []
const map = tagToFamille.value
const groups: Record<number, string[]> = {}
props.allHashtags.forEach(tag => {
const fam = map[tag]
if (fam == null) return
if (!groups[fam]) groups[fam] = []
groups[fam].push(tag)
})
return [1, 2, 3, 4, 5, 6]
.filter(famId => groups[famId]?.length)
.map(famId => ({
famille: famId,
label: FAMILLE_LABELS[famId],
color: FAMILLE_COLORS[famId],
tags: groups[famId].sort(),
}))
})
// Structures portant un hashtag donne (pour popover)
function structuresForHashtag(tag: string): { id: string; nom: string }[] {
if (!props.data) return []
return props.data.structures
.filter(s => s.hashtags.includes(tag))
.map(s => ({ id: s.id, nom: s.nom }))
}
// IDs de structures correspondant aux hashtags actifs
const filteredStructureIds = computed(() => {
if (!props.data || !activeHashtags.value.length) return null
const ids = new Set(
props.data.structures
.filter(s => activeHashtags.value.every(h => s.hashtags.includes(h)))
.map(s => s.id)
)
return ids
})
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
const FAMILLE_LABELS: Record<number, string> = {
1: 'Reemploi',
2: 'Frugalite',
3: 'Social',
4: 'Collectifs',
5: 'Urbanisme',
6: 'Recherche',
}
const FAMILLE_DESCRIPTIONS: Record<number, string> = {
1: "Structures dont le geste premier est de travailler avec la matiere existante : deconstruction selective, plateformes de redistribution, filieres biosourcees et geosourcees.",
2: "Pratiques qui partent du principe qu'on peut faire mieux avec moins. Renovation profonde, materiaux locaux, sobriete choisie.",
3: "Structures dont le terrain premier est le mal-logement, la precarite, l'hospitalite. Architecture comme reponse a l'urgence sociale.",
4: "Structures qui accompagnent les projets collectifs : cooperatives d'habitat, ecovillages, accompagnement vers l'autogestion ou la renovation.",
5: "Demarches a l'echelle du territoire : villes en transition, PLU alternatifs, coalitions territoriales.",
6: "Recherche-action et production de contre-savoirs (Forensic Architecture, Rural Studio, PEROU, Centrala). Badge transversal aux familles.",
}
let simulation: any = null
let d3NodeSelection: any = null
let d3LinkSelection: any = null
async function initGraph() {
if (!svgRef.value || !props.data) return
const d3 = await import('d3')
const svgEl = svgRef.value
const width = svgEl.clientWidth || 800
const height = svgEl.clientHeight || 600
// Nettoyer
d3.select(svgEl).selectAll('*').remove()
closePopover()
const svg = d3.select(svgEl)
.attr('viewBox', `0 0 ${width} ${height}`)
// Click sur le SVG vide -> fermer popover
svg.on('click', (event: any) => {
if (event.target === svgEl) closePopover()
})
// Groupe principal avec zoom
const g = svg.append('g')
const zoomBehavior = d3.zoom<SVGElement, unknown>()
.scaleExtent([0.2, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform)
closePopover()
})
svg.call(zoomBehavior as any)
const { allNodes, links } = buildNodesLinks(width, height)
// Simulation force-directed
if (simulation) simulation.stop()
// Adapter la charge selon le nombre de noeuds (mode "tout coche" = plus de repulsion)
const heavyMode = showPratiques.value && allNodes.length > 150
simulation = d3.forceSimulation(allNodes)
.force('link', d3.forceLink(links).id((d: any) => d.id)
.distance((d: any) => {
if (d.type === 'practice') return 90
return d.type === 'primary' ? 80 : 120
})
.strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(heavyMode ? -80 : -120))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius((d: any) => d.r + 4))
// Rendu liens
d3LinkSelection = g.append('g').selectAll('line')
.data(links)
.join('line')
.attr('stroke', (d: any) => {
if (d.type === 'practice') return 'rgba(150,150,150,0.25)'
return d.type === 'primary' ? 'rgba(150,150,150,0.45)' : 'rgba(150,150,150,0.35)'
})
.attr('stroke-width', 1.5)
.attr('stroke-dasharray', null)
// Rendu noeuds (groupes g)
d3NodeSelection = g.append('g').selectAll('g')
.data(allNodes)
.join('g')
.style('cursor', (d: any) => {
if (d.type === 'structure') return 'pointer'
if (d.type === 'family') return 'pointer'
if (d.type === 'hashtag') return 'pointer'
return 'default'
})
.call(
d3.drag<any, any>()
.on('start', (event: any, d: any) => {
if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
closePopover()
})
.on('drag', (event: any, d: any) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event: any, d: any) => {
if (!event.active) simulation.alphaTarget(0)
if (d.type !== 'family') {
d.fx = null
d.fy = null
}
})
)
.on('click', (event: any, d: any) => {
event.stopPropagation()
if (d.type === 'structure') {
emit('select-structure', d.id)
} else if (d.type === 'family') {
openFamillePopover(d, event, svgEl)
} else if (d.type === 'hashtag') {
openHashtagPopover(d, event, svgEl)
}
})
// Cercles
d3NodeSelection.append('circle')
.attr('r', (d: any) => d.r)
.attr('fill', (d: any) => {
if (d.type === 'family') return d.color
if (d.type === 'hashtag') return d.fill
return d.color + 'cc'
})
.attr('stroke', (d: any) => {
if (d.type === 'family') return 'white'
if (d.type === 'hashtag') return d.stroke
return d.color
})
.attr('stroke-width', (d: any) => {
if (d.type === 'family') return 3
if (d.type === 'hashtag') return 2
return 1.5
})
// Labels familles
d3NodeSelection.filter((d: any) => d.type === 'family')
.append('text')
.text((d: any) => d.label)
.attr('text-anchor', 'middle')
.attr('dy', '0.35em')
.attr('font-size', '11px')
.attr('font-weight', '700')
.attr('fill', 'white')
.style('pointer-events', 'none')
// Labels hashtags : texte noir sur fond clair, tronque a 12 caracteres
d3NodeSelection.filter((d: any) => d.type === 'hashtag')
.append('text')
.text((d: any) => {
const raw = d.label as string
return raw.length > 12 ? raw.slice(0, 12) + '...' : raw
})
.attr('text-anchor', 'middle')
.attr('dy', '0.35em')
.attr('font-size', '9px')
.attr('font-weight', '600')
.attr('fill', '#2a2a2a')
.style('pointer-events', 'none')
// Tooltip hover pour structures
d3NodeSelection.filter((d: any) => d.type === 'structure')
.on('mouseenter', (_event: any, d: any) => {
if (!tooltipRef.value) return
tooltipRef.value.style.opacity = '1'
tooltipRef.value.innerHTML = `<strong>${d.label}</strong><br><span style="opacity:0.6;font-size:0.7rem;">${FAMILLE_LABELS[d.famille] ?? ''}</span>`
})
.on('mousemove', (event: any) => {
if (!tooltipRef.value || !svgEl) return
const rect = (svgEl as HTMLElement).getBoundingClientRect()
tooltipRef.value.style.left = (event.clientX - rect.left + 12) + 'px'
tooltipRef.value.style.top = (event.clientY - rect.top - 10) + 'px'
})
.on('mouseleave', () => {
if (tooltipRef.value) tooltipRef.value.style.opacity = '0'
})
// Tick - mise a jour positions
simulation.on('tick', () => {
d3LinkSelection
.attr('x1', (d: any) => d.source.x)
.attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x)
.attr('y2', (d: any) => d.target.y)
d3NodeSelection.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
// Surlignage selon hashtags actifs
applyHashtagFilter()
})
}
function buildNodesLinks(width: number, height: number) {
const allNodes: any[] = []
const links: any[] = []
if (!props.data) return { allNodes, links }
const tagFamilleMap = tagToFamille.value
// Noeuds structures (toujours presents)
const structureNodes = props.data.structures.map(s => ({
id: s.id,
type: 'structure',
label: s.nom,
famille: s.famille_principale,
familles_secondaires: s.familles_secondaires ?? [],
hashtags: s.hashtags,
color: FAMILLE_COLORS[s.famille_principale] ?? '#888',
r: 8,
}))
allNodes.push(...structureNodes)
// Layer Familles : 6 noeuds famille fixes en etoile + liens primaires/secondaires
if (showFamilles.value) {
const familyNodes = [1, 2, 3, 4, 5, 6].map(id => ({
id: `family-${id}`,
type: 'family',
familleId: id,
label: FAMILLE_LABELS[id],
color: FAMILLE_COLORS[id],
r: 32,
x: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
y: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
fx: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
fy: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
}))
allNodes.push(...familyNodes)
structureNodes.forEach(s => {
links.push({
source: s.id,
target: `family-${s.famille}`,
type: 'primary',
strength: 0.55,
})
;(s.familles_secondaires as number[]).forEach((f: number) => {
links.push({
source: s.id,
target: `family-${f}`,
type: 'secondary',
strength: 0.45,
})
})
})
}
// Layer Pratiques : noeuds hashtag + liens structure -> hashtag
if (showPratiques.value) {
const uniqueTags = new Set<string>()
props.data.structures.forEach(s => s.hashtags.forEach(t => uniqueTags.add(t)))
const tagsArr = Array.from(uniqueTags).sort()
// Si seul layer Pratiques actif : disposition radiale comme reference
// Si superpose avec Familles : laisser la simulation placer
const radius = Math.min(width, height) * 0.32
const hashtagNodes = tagsArr.map((tag, i) => {
const famId = tagFamilleMap[tag]
const strokeColor = famId != null ? FAMILLE_COLORS[famId] : '#888'
const node: any = {
id: `hashtag-${tag}`,
type: 'hashtag',
label: tag.startsWith('#') ? tag.slice(1) : tag,
tag,
fill: 'var(--nav-bg-alt)',
stroke: strokeColor,
color: strokeColor,
r: 22,
}
if (!showFamilles.value) {
const angle = (i / tagsArr.length) * Math.PI * 2
node.x = width / 2 + Math.cos(angle) * radius
node.y = height / 2 + Math.sin(angle) * radius
}
return node
})
allNodes.push(...hashtagNodes)
structureNodes.forEach(s => {
s.hashtags.forEach(tag => {
if (uniqueTags.has(tag)) {
links.push({
source: s.id,
target: `hashtag-${tag}`,
type: 'practice',
strength: 0.3,
})
}
})
})
}
return { allNodes, links }
}
function clampPopoverPosition(rect: DOMRect, evtX: number, evtY: number, w = 280, h = 180) {
const margin = 12
let x = evtX - rect.left + 14
let y = evtY - rect.top + 10
if (x + w > rect.width - margin) {
x = Math.max(margin, rect.width - w - margin)
}
if (y + h > rect.height - margin) {
y = Math.max(margin, rect.height - h - margin)
}
return { x, y }
}
function structuresForFamille(famId: number): { id: string; nom: string }[] {
if (!props.data) return []
return props.data.structures
.filter(s =>
s.famille_principale === famId
|| (s.familles_secondaires ?? []).includes(famId)
)
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
.map(s => ({ id: s.id, nom: s.nom }))
}
function openFamillePopover(d: any, event: any, svgEl: SVGElement) {
const rect = (svgEl as HTMLElement).getBoundingClientRect()
const famId = d.familleId as number
const desc = FAMILLE_DESCRIPTIONS[famId] ?? ''
const structures = structuresForFamille(famId)
const { x, y } = clampPopoverPosition(rect, event.clientX, event.clientY, 280, 280)
popover.value = {
open: true,
kind: 'famille',
x,
y,
title: FAMILLE_LABELS[famId] ?? '',
body: desc,
color: FAMILLE_COLORS[famId] ?? '#000',
structures,
familleId: famId,
famillesCount: 0,
}
}
function openHashtagPopover(d: any, event: any, svgEl: SVGElement) {
const rect = (svgEl as HTMLElement).getBoundingClientRect()
const tag = d.tag as string
const structures = structuresForHashtag(tag)
const famId = tagToFamille.value[tag]
const color = famId != null ? FAMILLE_COLORS[famId] : '#444'
// Compter les familles distinctes parmi les porteuses (famille_principale)
const famSet = new Set<number>()
if (props.data) {
props.data.structures
.filter(s => s.hashtags.includes(tag))
.forEach(s => famSet.add(s.famille_principale))
}
const { x, y } = clampPopoverPosition(rect, event.clientX, event.clientY, 280, 220)
popover.value = {
open: true,
kind: 'hashtag',
x,
y,
title: tag.startsWith('#') ? tag : '#' + tag,
body: '',
color,
structures,
familleId: null,
famillesCount: famSet.size,
}
}
function applyHashtagFilter() {
if (!d3NodeSelection || !d3LinkSelection) return
if (filteredStructureIds.value) {
const ids = filteredStructureIds.value
d3NodeSelection.filter((d: any) => d.type === 'structure').select('circle')
.attr('opacity', (d: any) => ids.has(d.id) ? 1 : 0.1)
d3LinkSelection.attr('opacity', (d: any) => {
const srcId = typeof d.source === 'object' ? d.source.id : d.source
const tgtId = typeof d.target === 'object' ? d.target.id : d.target
return ids.has(srcId) || ids.has(tgtId) ? 1 : 0.05
})
} else {
d3NodeSelection.select('circle').attr('opacity', 1)
d3LinkSelection.attr('opacity', 1)
}
}
// Declencher quand l'onglet devient visible
watch(() => props.active, (val) => {
if (val && import.meta.client && props.data) {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
}
})
// Relancer si les donnees arrivent apres l'activation
watch(() => props.data, (val) => {
if (val && props.active && import.meta.client) {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
}
})
// Re-appliquer le filtre visuel sans rebuild complet
watch(activeHashtags, () => {
applyHashtagFilter()
if (simulation) simulation.alpha(0.01).restart()
}, { deep: true })
// Watchers layers : rebuild simulation
watch([showFamilles, showPratiques], () => {
closePopover()
if (import.meta.client && props.data && props.active) {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
}
})
// Toggle sidebar : largeur SVG change -> reinit graphe apres transition CSS
watch(sidebarOpen, () => {
if (!import.meta.client || !props.active || !props.data) return
setTimeout(() => {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
}, 220)
})
onMounted(async () => {
if (import.meta.client && props.data && props.active) {
await nextTick()
initGraph()
}
})
onUnmounted(() => {
if (simulation) simulation.stop()
})
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
<!-- Filtres famille -->
<div style="margin-bottom: 6px;">
<span class="filter-label">FAMILLES</span>
<div class="chips-row">
<span
v-for="fam in FAMILLES"
:key="fam.id"
class="chip"
:style="selectedFamille === fam.id
? `background: ${fam.color}; color: white; font-weight: 600; border: 2px solid ${fam.color};`
: `background: var(--nav-bg-alt); color: ${fam.color}; border: 2px solid ${fam.color}; font-weight: 600;`"
@click="toggleFamille(fam.id)"
>{{ fam.shortLabel }}</span>
</div>
</div>
<!-- Filtres hashtags avec toggle -->
<div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<span class="filter-label">HASHTAGS</span>
<button
@click="hashtagsVisible = !hashtagsVisible"
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
>{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
</div>
<div v-if="hashtagsVisible" class="chips-row">
<span
v-for="tag in props.allHashtags"
:key="tag"
class="chip chip-small"
:style="selectedHashtags.includes(tag)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleHashtag(tag)"
>{{ tag }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const FAMILLES = [
{ id: 1, shortLabel: 'Réemploi', color: '#a85d3e' },
{ id: 2, shortLabel: 'Frugalité', color: '#c4a472' },
{ id: 3, shortLabel: 'Social', color: '#d4a017' },
{ id: 4, shortLabel: 'Collectifs', color: '#5a7a4a' },
{ id: 5, shortLabel: 'Urbanisme', color: '#3d6a8c' },
{ id: 6, shortLabel: 'Recherche', color: '#6b3fa0' },
]
const props = defineProps<{
allHashtags: string[]
selectedHashtags: string[]
selectedFamille: number | null
}>()
const emit = defineEmits<{
'update:selectedHashtags': [v: string[]]
'update:selectedFamille': [v: number | null]
}>()
const hashtagsVisible = ref(false)
function toggleFamille(id: number) {
emit('update:selectedFamille', props.selectedFamille === id ? null : id)
}
function toggleHashtag(tag: string) {
const current = props.selectedHashtags
emit('update:selectedHashtags',
current.includes(tag) ? current.filter(t => t !== tag) : [...current, tag]
)
}
</script>
<style scoped>
.filter-label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--nav-text-muted);
display: block;
margin-bottom: 4px;
}
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
.chip {
cursor: pointer;
padding: 3px 10px;
border-radius: 9999px;
font-size: 0.75rem;
transition: all 0.15s;
user-select: none;
}
.chip-small { font-size: 0.7rem; padding: 2px 8px; }
</style>

View File

@@ -0,0 +1,76 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="visible"
class="intention-overlay"
style="
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(20, 18, 14, 0.85);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
"
@click.self="dismiss"
>
<div style="
max-width: 540px;
width: 100%;
background: #faf8f5;
border-radius: 8px;
padding: 32px;
color: #2c2416;
">
<p style="font-size: 1rem; line-height: 1.7; margin: 0 0 12px 0;">
Cette carte recense les réseaux, collectifs, agences et projets des
pensées écologiques deviennent des pratiques d'architecture et d'habiter.
</p>
<p style="font-size: 0.875rem; line-height: 1.6; opacity: 0.75; margin: 0 0 24px 0;">
Elle ne prétend pas à l'exhaustivité. Elle est un geste politique :
rendre visible ce qui se transforme, comment, par qui, où.
5 familles et des hashtags vous permettent d'explorer.
</p>
<button
@click="dismiss"
style="
background: #2c2416;
color: #faf8f5;
border: none;
border-radius: 4px;
padding: 10px 24px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
width: 100%;
"
>Explorer la carte</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const visible = ref(false)
onMounted(() => {
if (typeof localStorage !== 'undefined' && !localStorage.getItem('aep_intention_seen')) {
visible.value = true
}
})
function dismiss() {
visible.value = false
if (typeof localStorage !== 'undefined') {
localStorage.setItem('aep_intention_seen', '1')
}
}
</script>
<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

243
components/NavMapV2.vue Normal file
View File

@@ -0,0 +1,243 @@
<template>
<div class="relative w-full h-full">
<div ref="mapContainer" class="w-full h-full rounded-none" />
</div>
</template>
<script setup lang="ts">
import type { Map, Marker, DivIcon } from 'leaflet'
import type { StructureV2 } from '~/types/structure-v2'
// Couleurs par famille (synchronisées avec v2-bifurcation.css)
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
}
const props = defineProps<{
structures: StructureV2[]
selectedId?: string | null
}>()
const emit = defineEmits<{
'select-structure': [id: string]
}>()
const mapContainer = ref<HTMLElement | null>(null)
let mapInstance: Map | null = null
let clusterGroup: any = null
const markers = new Map<string, Marker>()
let tileLayerInstance: any = null
function getFamilleColor(famille: number): string {
return FAMILLE_COLORS[famille] ?? '#888888'
}
function createPinIcon(famille: number, isSelected = false): DivIcon {
const L = (window as any).L
const bg = getFamilleColor(famille)
const size = isSelected ? 20 : 14
const border = isSelected ? 'white' : 'rgba(255,255,255,0.7)'
const shadow = isSelected ? `0 0 0 4px ${bg}55` : 'none'
return L.divIcon({
className: '',
html: `<div style="
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: ${bg};
border: 2px solid ${border};
box-shadow: ${shadow};
transition: all 0.2s;
"></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 4)],
})
}
async function initMap() {
if (!mapContainer.value) return
const Lmod = await import('leaflet')
const L: any = (Lmod as any).default || Lmod
await import('leaflet/dist/leaflet.css')
// @ts-ignore
await import('leaflet.markercluster/dist/MarkerCluster.css')
// @ts-ignore
await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
// Installer L globalement AVANT le plugin
;(window as any).L = L
// @ts-ignore
await import('leaflet.markercluster')
const MarkerClusterGroup = L.MarkerClusterGroup
mapInstance = L.map(mapContainer.value, {
center: [46.6, 2.3],
zoom: 5,
zoomControl: true,
attributionControl: true,
minZoom: 2,
maxZoom: 18,
})
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance = L.tileLayer(tileUrl, {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 19,
})
tileLayerInstance.addTo(mapInstance!)
clusterGroup = new MarkerClusterGroup({
disableClusteringAtZoom: 14,
maxClusterRadius: 50,
showCoverageOnHover: false,
iconCreateFunction: (cluster: any) => {
const count = cluster.getChildCount()
return L.divIcon({
html: `<div style="
width: 36px; height: 36px; border-radius: 50%;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 13px;
border: 2px solid white;
font-family: var(--nav-font);
">${count}</div>`,
className: '',
iconSize: [36, 36],
iconAnchor: [18, 18],
})
},
})
mapInstance.addLayer(clusterGroup)
updateMarkers(L)
}
function updateMarkers(L?: any) {
if (!mapInstance || !clusterGroup) return
const leaflet = L || (window as any).L
if (!leaflet) return
clusterGroup.clearLayers()
markers.clear()
const structuresWithCoords = props.structures.filter(
(s) => s.latitude != null && s.longitude != null
)
structuresWithCoords.forEach((structure) => {
const isSelected = structure.id === props.selectedId
const icon = createPinIcon(structure.famille_principale, isSelected)
const marker = leaflet.marker([structure.latitude!, structure.longitude!], { icon })
const hashtagsHtml = structure.hashtags.slice(0, 2)
.map(h => `<span style="font-size:10px;color:var(--nav-text-muted);">${h}</span>`)
.join(' ')
marker.bindPopup(`
<div style="font-family: var(--nav-font); min-width: 190px; padding: 4px 0;">
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 2px; font-size: 0.9rem;">${structure.nom}</div>
<div style="font-size: 11px; color: var(--nav-text-muted); margin-bottom: 4px;">${structure.type_principal} · ${structure.ville}, ${structure.pays}</div>
${hashtagsHtml ? `<div style="margin-bottom: 6px;">${hashtagsHtml}</div>` : ''}
<div style="font-size: 11px; color: var(--nav-text); line-height: 1.4; margin-bottom: 8px;">${structure.description_courte.slice(0, 100)}…</div>
<button onclick="document.dispatchEvent(new CustomEvent('nav-v2-select', {detail:'${structure.id}'}))" style="
font-size: 12px;
color: var(--nav-primary-solid);
text-decoration: underline;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: var(--nav-font);
">Voir la fiche →</button>
</div>
`, { maxWidth: 260 })
marker.on('click', () => emit('select-structure', structure.id))
markers.set(structure.id, marker)
clusterGroup.addLayer(marker)
})
}
// Ecouter l'event custom depuis les popups Leaflet
function onNavV2Select(e: CustomEvent) {
emit('select-structure', e.detail)
}
watch(
() => props.structures,
() => updateMarkers(),
{ deep: false }
)
watch(
() => props.selectedId,
(newId, oldId) => {
if (!mapInstance) return
const leaflet = (window as any).L
if (!leaflet) return
if (oldId != null) {
const oldMarker = markers.get(oldId)
const oldStructure = props.structures.find(s => s.id === oldId)
if (oldMarker && oldStructure) {
oldMarker.setIcon(createPinIcon(oldStructure.famille_principale, false))
}
}
if (newId != null) {
const newMarker = markers.get(newId)
const newStructure = props.structures.find(s => s.id === newId)
if (newMarker && newStructure) {
newMarker.setIcon(createPinIcon(newStructure.famille_principale, true))
const latLng = newMarker.getLatLng()
mapInstance.panTo(latLng, { animate: true })
}
}
}
)
function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L
if (!L) return
const url = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance.setUrl(url)
}
let themeObserver: MutationObserver | null = null
onMounted(() => {
initMap()
document.addEventListener('nav-v2-select', onNavV2Select as EventListener)
themeObserver = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark')
updateTileTheme(dark)
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
onUnmounted(() => {
document.removeEventListener('nav-v2-select', onNavV2Select as EventListener)
themeObserver?.disconnect()
if (mapInstance) {
mapInstance.remove()
mapInstance = null
}
})
</script>

View File

@@ -0,0 +1,385 @@
<template>
<div ref="container" class="codev-graph-wrap">
<!-- Placeholder si aucune fiche -->
<div v-if="fiches.length === 0" class="empty-state">
<p class="empty-msg">Encore personne. Sois la premiere fiche !</p>
<NuxtLink to="/codev/fiche" class="empty-link">Creer ma fiche &rarr;</NuxtLink>
</div>
<!-- SVG D3 -->
<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>
</template>
<script setup lang="ts">
import * as d3 from 'd3'
import type { CodevFiche, CodevMatch } from '~/types/codev'
// ── Props / Emits ──────────────────────────────────────────────────────────
const props = withDefaults(defineProps<{
fiches: CodevFiche[]
matches?: CodevMatch[]
mode?: 'none' | 'solution' | 'alliance' | 'surprise'
}>(), {
matches: () => [],
mode: 'none',
})
const emit = defineEmits<{
'select-fiche': [id: number]
}>()
// ── Refs ───────────────────────────────────────────────────────────────────
const container = ref<HTMLDivElement | null>(null)
const svgEl = ref<SVGSVGElement | null>(null)
const width = ref(800)
const height = ref(600)
// ── State interne ──────────────────────────────────────────────────────────
type SimNode = d3.SimulationNodeDatum & { id: number; nom: string; offre: string; besoin: string }
type SimLink = d3.SimulationLinkDatum<SimNode> & { score: number; mode: string }
let simulation: d3.Simulation<SimNode, SimLink> | null = null
let svgRoot: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null
let gLinks: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
let gNodes: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
const isMobile = computed(() => width.value < 600)
const nodeRadius = computed(() => isMobile.value ? 22 : 28)
// ── Helpers ────────────────────────────────────────────────────────────────
function truncate(str: string, max = 10): string {
if (!str) return ''
return str.length > max ? str.slice(0, max - 1) + '…' : str
}
function buildNodes(): SimNode[] {
return props.fiches.map(f => ({
id: f.id,
nom: f.nom,
offre: f.offre,
besoin: f.besoin,
}))
}
function buildLinks(nodes: SimNode[]): SimLink[] {
if (!props.matches || props.matches.length === 0) return []
const nodeById = new Map(nodes.map(n => [n.id, n]))
return props.matches
.filter(m => nodeById.has(m.fromId) && nodeById.has(m.toId))
.map(m => ({
source: nodeById.get(m.fromId)!,
target: nodeById.get(m.toId)!,
score: m.score,
mode: m.mode,
}))
}
function linkColor(mode: string): string {
if (mode === 'solution') return '#22c55e'
if (mode === 'alliance') return '#f97316'
if (mode === 'surprise') return '#3b82f6'
return '#ccc'
}
// ── Drag handler ───────────────────────────────────────────────────────────
function makeDrag(sim: d3.Simulation<SimNode, SimLink>): d3.DragBehavior<SVGGElement, SimNode, SimNode> {
return d3.drag<SVGGElement, SimNode>()
.on('start', (event, d) => {
if (!event.active) sim.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
})
.on('drag', (event, d) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event, d) => {
if (!event.active) sim.alphaTarget(0)
d.fx = null
d.fy = null
})
}
// ── Initialisation SVG ─────────────────────────────────────────────────────
function initSvg() {
if (!svgEl.value) return
svgRoot = d3.select(svgEl.value)
.attr('width', width.value)
.attr('height', height.value)
svgRoot.selectAll('*').remove()
gLinks = svgRoot.append('g').attr('class', 'links')
gNodes = svgRoot.append('g').attr('class', 'nodes')
}
// ── Rebuild liens (hook pour M4) ───────────────────────────────────────────
let currentNodes: SimNode[] = []
let currentLinks: SimLink[] = []
function rebuildLinks() {
currentLinks = buildLinks(currentNodes)
if (!gLinks || !simulation) return
const linkSel = gLinks
.selectAll<SVGLineElement, SimLink>('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')
.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)
}
// ── Rendu complet ──────────────────────────────────────────────────────────
function render() {
if (!svgEl.value || props.fiches.length === 0) return
initSvg()
currentNodes = buildNodes()
currentLinks = buildLinks(currentNodes)
const r = nodeRadius.value
const fontSize = isMobile.value ? 10 : 12
// Liens
gLinks!
.selectAll<SVGLineElement, SimLink>('line')
.data(currentLinks)
.join('line')
.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 <g> par personne
const nodeGroups = gNodes!
.selectAll<SVGGElement, SimNode>('g.node')
.data(currentNodes, d => String(d.id))
.join('g')
.attr('class', 'node')
.style('cursor', 'pointer')
.call(makeDrag(simulation!) as any)
.on('click', (_event, d) => emit('select-fiche', d.id))
// Cercle principal
nodeGroups.append('circle')
.attr('r', r)
.attr('fill', '#ffffff')
.attr('stroke', '#1B4436')
.attr('stroke-width', 2)
// Label nom
nodeGroups.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('font-size', fontSize)
.attr('font-weight', '700')
.attr('fill', '#1a1a2e')
.attr('pointer-events', 'none')
.text(d => truncate(d.nom, 10))
// Pastille offre (haut-droite, vert)
nodeGroups.append('circle')
.attr('r', 6)
.attr('cx', r * 0.65)
.attr('cy', -r * 0.65)
.attr('fill', '#22c55e')
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
// Pastille besoin (bas-droite, orange)
nodeGroups.append('circle')
.attr('r', 6)
.attr('cx', r * 0.65)
.attr('cy', r * 0.65)
.attr('fill', '#f97316')
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
// Tooltip SVG natif <title>
nodeGroups.append('title')
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`)
// Simulation
simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes)
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
.id(d => d.id)
.distance(120)
.strength(0.3))
.force('charge', d3.forceManyBody<SimNode>().strength(-400))
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
.force('collide', d3.forceCollide<SimNode>().radius(r + 12))
.alphaDecay(0.02)
.on('tick', tick)
// Re-bind drag avec la nouvelle simulation
gNodes!.selectAll<SVGGElement, SimNode>('g.node')
.call(makeDrag(simulation) as any)
}
function tick() {
if (!gLinks || !gNodes) return
gLinks.selectAll<SVGLineElement, SimLink>('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)
gNodes.selectAll<SVGGElement, SimNode>('g.node')
.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`)
}
// ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
watch(() => [props.matches, props.mode] as const, () => {
if (!simulation) return
rebuildLinks()
simulation.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
.id(d => d.id)
.distance(120)
.strength(0.3))
simulation.alpha(0.5).restart()
}, { deep: true })
// ── Watch fiches (re-render si nouvelles fiches) ───────────────────────────
watch(() => props.fiches, () => {
if (simulation) {
simulation.stop()
simulation = null
}
render()
}, { deep: true })
// ── ResizeObserver ─────────────────────────────────────────────────────────
let ro: ResizeObserver | null = null
onMounted(() => {
if (!container.value) return
width.value = container.value.clientWidth || 800
height.value = container.value.clientHeight || 600
render()
ro = new ResizeObserver(() => {
if (!container.value) return
width.value = container.value.clientWidth || 800
height.value = container.value.clientHeight || 600
if (svgRoot) {
svgRoot.attr('width', width.value).attr('height', height.value)
}
if (simulation) {
simulation.force('center', d3.forceCenter(width.value / 2, height.value / 2))
simulation.alpha(0.3).restart()
}
})
ro.observe(container.value!)
})
onUnmounted(() => {
if (simulation) simulation.stop()
if (ro) ro.disconnect()
})
</script>
<style scoped>
.codev-graph-wrap {
width: 100%;
height: 70vh;
min-height: 320px;
position: relative;
background: var(--nav-bg, #fafafa);
border-radius: 12px;
overflow: hidden;
}
.codev-svg {
width: 100%;
height: 100%;
display: block;
}
/* ── Etat vide ── */
.empty-state {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
text-align: center;
}
.empty-msg {
font-size: 1.125rem;
color: var(--nav-text-muted, #6b7280);
margin: 0;
}
.empty-link {
font-size: 0.9rem;
font-weight: 600;
color: var(--nav-primary-solid, #1B4436);
text-decoration: none;
border: 1.5px solid var(--nav-primary-solid, #1B4436);
border-radius: 8px;
padding: 0.5rem 1.25rem;
transition: background 0.15s, color 0.15s;
}
.empty-link:hover {
background: var(--nav-primary-solid, #1B4436);
color: #fff;
}
/* ── Mobile ── */
@media (max-width: 600px) {
.codev-graph-wrap {
height: 65vh;
min-height: 260px;
border-radius: 8px;
}
}
</style>

View File

@@ -14,6 +14,9 @@ export default defineNuxtConfig({
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
resendApiKey: process.env.RESEND_API_KEY,
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
codevTableId: '', // NUXT_CODEV_TABLE_ID
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
},
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client

459
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"@headlessui/vue": "^1.7.23",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0",
"ioredis": "^5.3.2",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
@@ -5312,6 +5313,416 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/db0": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
@@ -5425,6 +5836,15 @@
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
"node_modules/delaunator": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -6480,6 +6900,18 @@
"node": ">=16.17.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -6555,6 +6987,15 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ioredis": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
@@ -9480,6 +9921,12 @@
"node": ">=0.10.0"
}
},
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -9595,6 +10042,12 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -9633,6 +10086,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",

View File

@@ -13,6 +13,7 @@
"@headlessui/vue": "^1.7.23",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0",
"ioredis": "^5.3.2",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",

270
pages/codev/carto.vue Normal file
View File

@@ -0,0 +1,270 @@
<template>
<div class="codev-carto">
<header class="carto-header">
<h1>Carto entraide</h1>
<p class="carto-subtitle">
<template v-if="pending">Chargement...</template>
<template v-else>
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
</template>
</p>
</header>
<ClientOnly>
<CodevGraph
:fiches="fiches"
:matches="matches"
:mode="mode"
@select-fiche="onSelectFiche"
/>
<template #fallback>
<div class="graph-fallback">Chargement du graphe...</div>
</template>
</ClientOnly>
<!-- Bandeau info mode actif -->
<div v-if="mode !== 'none'" class="mode-banner">
<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>
</template>
<script setup lang="ts">
import type { CodevFiche, CodevMatch } from '~/types/codev'
import { computeMatches } from '~/utils/codev/matching'
useHead({ title: 'Carto - Co-developpement' })
const { data, pending } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
const fiches = computed(() => data.value?.list ?? [])
const matches = ref<CodevMatch[]>([])
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) {
navigateTo(`/codev/fiche?id=${id}`)
}
</script>
<style scoped>
.codev-carto {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
display: flex;
flex-direction: column;
padding: 1.25rem 1rem 2rem;
gap: 1rem;
max-width: 100%;
box-sizing: border-box;
}
/* ── En-tete ── */
.carto-header {
text-align: center;
padding-bottom: 0.5rem;
}
.carto-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text, #1a1a2e);
margin: 0 0 0.375rem;
}
.carto-subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
margin: 0;
}
/* ── Fallback ── */
.graph-fallback {
width: 100%;
height: 70vh;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted, #6b7280);
font-size: 0.9rem;
background: var(--nav-bg-alt, #f3f4f6);
border-radius: 12px;
}
/* ── Bandeau mode actif ── */
.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;
}
.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 ── */
@media (max-width: 600px) {
.codev-carto {
padding: 1rem 0.75rem 1.5rem;
}
.carto-header h1 {
font-size: 1.25rem;
}
}
</style>

367
pages/codev/demo.vue Normal file
View File

@@ -0,0 +1,367 @@
<template>
<div class="codev-demo">
<header class="demo-header">
<span class="demo-badge">DEMO</span>
<h1>Co-developpement - exemple</h1>
<p class="subtitle">10 personnes fictives. Clique sur un mode pour voir les matchs.</p>
</header>
<ClientOnly>
<CodevGraph
:fiches="fiches"
:matches="matches"
:mode="mode"
/>
<template #fallback>
<div class="graph-fallback">Chargement du graphe...</div>
</template>
</ClientOnly>
<!-- Bandeau info mode actif -->
<div v-if="mode !== 'none'" class="mode-banner">
<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>
<!-- 3 boutons matching identiques a carto.vue -->
<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>
</template>
<script setup lang="ts">
import type { CodevFiche, CodevMatch } from '~/types/codev'
import { computeMatches } from '~/utils/codev/matching'
// 10 fiches factices - hashtags alignes pour demontrer les 3 modes :
//
// Solution : Lea(besoin coaching) -> Maya(offre coaching)
// Sami(besoin formation+vente) -> Ines(offre vente+formation)
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation+tiers-lieu)
//
// Alliance : Lea + Maya (hashtag coaching commun dans besoins)
// Sami + Kenji (hashtag formation+vente dans besoins)
// Tom + Zoe (hashtag tiers-lieu dans besoins)
//
// Surprise : Lea + Zoe (hashtag facilitation dans offres)
// Tom + Roman (hashtag archi dans offres)
const FICHES_DEMO: CodevFiche[] = [
{
id: 1,
nom: 'Lea',
besoin: 'Structurer mon offre de coaching pour la lancer en septembre',
offre: 'Animation de groupes, facilitation de cercles de parole',
hashtags: ['coaching', 'facilitation'],
created_at: '2026-05-08T10:00:00Z',
},
{
id: 2,
nom: 'Sami',
besoin: 'Comprendre comment vendre une formation en ligne',
offre: 'Developpement web, sites Astro et Nuxt',
hashtags: ['formation', 'vente'],
created_at: '2026-05-08T10:01:00Z',
},
{
id: 3,
nom: 'Ines',
besoin: 'Aide pour la facilitation de mes ateliers ecriture',
offre: 'Vente de formations en ligne, marketing direct',
hashtags: ['vente', 'formation'],
created_at: '2026-05-08T10:02:00Z',
},
{
id: 4,
nom: 'Tom',
besoin: 'Trouver un associe pour un projet de tiers-lieu',
offre: 'Architecture eco-responsable, conception bioclimatique',
hashtags: ['tiers-lieu', 'archi'],
created_at: '2026-05-08T10:03:00Z',
},
{
id: 5,
nom: 'Maya',
besoin: 'Structurer mon offre de coaching freelance',
offre: 'Coaching de carriere, accompagnement transition pro',
hashtags: ['coaching', 'carriere'],
created_at: '2026-05-08T10:04:00Z',
},
{
id: 6,
nom: 'Kenji',
besoin: 'Apprendre a vendre mes formations sans me sentir vendeur',
offre: 'Photographie, direction artistique de projets editoriaux',
hashtags: ['formation', 'vente'],
created_at: '2026-05-08T10:05:00Z',
},
{
id: 7,
nom: 'Zoe',
besoin: 'Trouver des associes pour mon projet de tiers-lieu rural',
offre: 'Animation et facilitation de collectifs, intelligence collective',
hashtags: ['tiers-lieu', 'facilitation'],
created_at: '2026-05-08T10:06:00Z',
},
{
id: 8,
nom: 'Nael',
besoin: 'Construire un site web pour ma formation',
offre: 'Strategie marketing, lancement de produits digitaux',
hashtags: ['web', 'strategie'],
created_at: '2026-05-08T10:07:00Z',
},
{
id: 9,
nom: 'Eva',
besoin: 'Lancer mon offre de coaching avec une page de vente',
offre: 'Ecriture longue forme, articles essais et tribunes',
hashtags: ['coaching', 'ecriture'],
created_at: '2026-05-08T10:08:00Z',
},
{
id: 10,
nom: 'Roman',
besoin: 'Ameliorer mes articles de blog sur la renovation',
offre: 'Architecture, plans techniques pour renovation energetique',
hashtags: ['archi', 'reno'],
created_at: '2026-05-08T10:09:00Z',
},
]
const fiches = ref(FICHES_DEMO)
const matches = ref<CodevMatch[]>([])
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
const MODE_LABELS: Record<string, string> = {
solution: 'Solution',
alliance: 'Alliance',
surprise: 'Surprise',
}
useHead({ title: 'Demo - Co-developpement' })
function setMode(newMode: typeof mode.value) {
mode.value = newMode
if (newMode === 'none') {
matches.value = []
} else {
matches.value = computeMatches(fiches.value, newMode)
}
}
</script>
<style scoped>
.codev-demo {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
display: flex;
flex-direction: column;
padding: 1.25rem 1rem 2rem;
gap: 1rem;
max-width: 100%;
box-sizing: border-box;
}
/* ── En-tete ── */
.demo-header {
text-align: center;
padding-bottom: 0.5rem;
}
.demo-badge {
display: inline-block;
background: #f97316;
color: #fff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.demo-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text, #1a1a2e);
margin: 0 0 0.375rem;
}
.subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
margin: 0;
}
/* ── Fallback ── */
.graph-fallback {
width: 100%;
height: 70vh;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted, #6b7280);
font-size: 0.9rem;
background: var(--nav-bg-alt, #f3f4f6);
border-radius: 12px;
}
/* ── Bandeau mode actif ── */
.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;
}
.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 ── */
@media (max-width: 600px) {
.codev-demo {
padding: 1rem 0.75rem 1.5rem;
}
.demo-header h1 {
font-size: 1.25rem;
}
}
</style>

367
pages/codev/fiche.vue Normal file
View File

@@ -0,0 +1,367 @@
<template>
<div class="fiche-page">
<div class="fiche-inner">
<!-- En-tête -->
<div class="fiche-header">
<h1>Ma fiche</h1>
<p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p>
</div>
<!-- Formulaire -->
<form class="fiche-form" @submit.prevent="submit" novalidate>
<!-- Nom -->
<div class="field-group">
<label for="nom">
Prénom <span class="required">*</span>
</label>
<input
id="nom"
v-model="form.nom"
type="text"
placeholder="Ex : Camille"
required
minlength="2"
maxlength="50"
:disabled="loading"
/>
</div>
<!-- Besoin -->
<div class="field-group">
<div class="label-row">
<label for="besoin">
Mon besoin actuel <span class="required">*</span>
</label>
<button type="button" class="tooltip-trigger" @click="toggleTip('besoin')" aria-label="C'est quoi un besoin ?">?</button>
</div>
<details v-if="activeTip === 'besoin'" class="tooltip-block" open>
<summary class="sr-only">Aide</summary>
<p>Un besoin, c'est ce qui te manque pour avancer. Ca peut etre concret (un coup de main sur un dossier) ou plus large (clarifier ou tu vas). Pas grave si c'est flou - la rencontre IRL aide a le preciser.</p>
</details>
<textarea
id="besoin"
v-model="form.besoin"
rows="3"
placeholder="Ex : J'ai besoin d'aide pour structurer mon offre de prestation"
required
minlength="5"
maxlength="300"
:disabled="loading"
/>
<span class="char-count" :class="{ 'char-warn': form.besoin.length > 260 }">
{{ form.besoin.length }}/300
</span>
</div>
<!-- Offre -->
<div class="field-group">
<div class="label-row">
<label for="offre">
Ce que j'offre a la communaute <span class="required">*</span>
</label>
<button type="button" class="tooltip-trigger" @click="toggleTip('offre')" aria-label="C'est quoi une offre ?">?</button>
</div>
<details v-if="activeTip === 'offre'" class="tooltip-block" open>
<summary class="sr-only">Aide</summary>
<p>Une offre, c'est une competence, une experience ou une qualite que tu peux partager. Ce que les autres viennent chercher chez toi naturellement.</p>
</details>
<textarea
id="offre"
v-model="form.offre"
rows="3"
placeholder="Ex : Je peux partager mon expérience en facilitation de groupe"
required
minlength="5"
maxlength="300"
:disabled="loading"
/>
<span class="char-count" :class="{ 'char-warn': form.offre.length > 260 }">
{{ form.offre.length }}/300
</span>
</div>
<!-- Hashtags -->
<div class="field-group">
<label for="hashtags">
Mots-clés
<span class="label-hint">(optionnel, 3 max, séparés par des virgules)</span>
</label>
<input
id="hashtags"
v-model="form.hashtagsRaw"
type="text"
placeholder="Ex : business, écriture, écologie"
maxlength="120"
:disabled="loading"
/>
</div>
<!-- Erreur serveur -->
<div v-if="error" class="server-error" role="alert">
{{ error }}
</div>
<!-- Bouton -->
<button type="submit" class="submit-btn" :disabled="loading">
{{ loading ? 'Envoi en cours...' : 'Ajouter ma fiche' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
const error = ref('')
const loading = ref(false)
const activeTip = ref<'besoin' | 'offre' | null>(null)
useHead({ title: 'Ma fiche — Co-développement' })
function toggleTip(field: 'besoin' | 'offre') {
activeTip.value = activeTip.value === field ? null : field
}
async function submit() {
error.value = ''
loading.value = true
try {
const hashtags = form.value.hashtagsRaw
.split(',')
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean)
.slice(0, 3)
await $fetch('/api/codev/fiches', {
method: 'POST',
body: {
nom: form.value.nom,
besoin: form.value.besoin,
offre: form.value.offre,
hashtags,
},
})
await navigateTo('/codev/carto')
} catch (e: any) {
error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie'
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* ── Layout ── */
.fiche-page {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
padding: 1.5rem 1rem 4rem;
}
.fiche-inner {
max-width: 480px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
/* ── En-tête ── */
.fiche-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text, #1a1a2e);
margin: 0 0 0.375rem;
}
.fiche-lead {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
margin: 0;
line-height: 1.5;
}
/* ── Formulaire ── */
.fiche-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Champ ── */
.field-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-group label {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text, #1a1a2e);
}
.label-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.label-hint {
font-weight: 400;
font-size: 0.8rem;
color: var(--nav-text-muted, #6b7280);
margin-left: 0.25rem;
}
.required {
color: #c0392b;
}
.field-group input[type="text"],
.field-group input[type="password"],
.field-group textarea {
width: 100%;
padding: 0.75rem 0.875rem;
border: 1px solid var(--border-color, #d0d4dc);
border-radius: 8px;
font-size: 1rem;
color: var(--nav-text, #1a1a2e);
background: var(--nav-surface, #ffffff);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.field-group input:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--nav-primary-solid, #1B4436);
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
}
.field-group input:disabled,
.field-group textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.field-group textarea {
resize: vertical;
min-height: 80px;
}
.char-count {
font-size: 0.75rem;
color: var(--nav-text-muted, #6b7280);
text-align: right;
}
.char-warn {
color: #e67e22;
}
/* ── Tooltip ── */
.tooltip-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: var(--nav-surface, #ffffff);
border: 1px solid var(--border-color, #d0d4dc);
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
color: var(--nav-text-muted, #6b7280);
cursor: pointer;
padding: 0;
line-height: 1;
flex-shrink: 0;
transition: border-color 0.15s, color 0.15s;
}
.tooltip-trigger:hover {
border-color: var(--nav-primary-solid, #1B4436);
color: var(--nav-primary-solid, #1B4436);
}
.tooltip-block {
background: var(--nav-surface, #ffffff);
border: 1px solid var(--border-color, #d0d4dc);
border-radius: 8px;
padding: 0.75rem 0.875rem;
font-size: 0.85rem;
color: var(--nav-text-muted, #6b7280);
line-height: 1.5;
}
.tooltip-block p {
margin: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* ── Erreur serveur ── */
.server-error {
padding: 0.75rem 0.875rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
}
/* ── Bouton ── */
.submit-btn {
width: 100%;
padding: 0.875rem 1rem;
background: var(--nav-primary-solid, #1B4436);
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
margin-top: 0.25rem;
}
.submit-btn:hover:not(:disabled) {
opacity: 0.88;
}
.submit-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Responsive ── */
@media (max-width: 480px) {
.fiche-page {
padding: 1.25rem 1rem 3rem;
}
}
</style>

217
pages/codev/index.vue Normal file
View File

@@ -0,0 +1,217 @@
<template>
<div class="lock-page">
<div class="lock-inner">
<div class="lock-header">
<h1>Co-développement</h1>
<p class="lock-subtitle">Entraide entre pairs</p>
<p class="lock-intro">Cet espace est un cercle. Pour entrer, il y a un mot.</p>
</div>
<form class="lock-form" @submit.prevent="submit" novalidate>
<div class="field-group">
<input
id="password"
v-model="password"
type="password"
placeholder="Mot de passe"
autocomplete="current-password"
required
:disabled="loading"
class="lock-input"
/>
</div>
<div v-if="error" class="lock-error" role="alert">
{{ error }}
</div>
<button type="submit" class="lock-btn" :disabled="loading || !password">
{{ loading ? 'Vérification...' : 'Entrer' }}
</button>
</form>
<div class="lock-footer">
<NuxtLink to="/codev/demo" class="demo-link">Voir l'exemple &rarr;</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const password = ref('')
const error = ref('')
const loading = ref(false)
useHead({ title: 'Co-développement Entraide entre pairs' })
async function submit() {
error.value = ''
loading.value = true
try {
await $fetch('/api/codev/auth', {
method: 'POST',
body: { password: password.value },
})
await navigateTo('/codev/fiche')
} catch (e: any) {
error.value = e?.statusMessage || 'Mauvais mot de passe'
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* ── Layout ── */
.lock-page {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
}
.lock-inner {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 2rem;
}
/* ── En-tête ── */
.lock-header {
text-align: center;
}
.lock-header h1 {
font-size: 1.75rem;
font-weight: 700;
color: var(--nav-text, #1a1a2e);
margin: 0 0 0.375rem;
}
.lock-subtitle {
font-size: 1rem;
color: var(--nav-text-muted, #6b7280);
margin: 0 0 1rem;
}
.lock-intro {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
line-height: 1.5;
margin: 0;
font-style: italic;
}
/* ── Formulaire ── */
.lock-form {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.field-group {
display: flex;
flex-direction: column;
}
.lock-input {
width: 100%;
padding: 0.875rem 1rem;
border: 1px solid var(--border-color, #d0d4dc);
border-radius: 8px;
font-size: 1rem;
color: var(--nav-text, #1a1a2e);
background: var(--nav-surface, #ffffff);
font-family: inherit;
text-align: center;
letter-spacing: 0.1em;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.lock-input:focus {
outline: none;
border-color: var(--nav-primary-solid, #1B4436);
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
}
.lock-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Erreur ── */
.lock-error {
padding: 0.625rem 0.875rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
text-align: center;
}
/* ── Bouton ── */
.lock-btn {
width: 100%;
padding: 0.875rem 1rem;
background: var(--nav-primary-solid, #1B4436);
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
.lock-btn:hover:not(:disabled) {
opacity: 0.88;
}
.lock-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Pied de page ── */
.lock-footer {
text-align: center;
}
.demo-link {
font-size: 0.875rem;
color: var(--nav-text-muted, #6b7280);
text-decoration: none;
transition: color 0.15s;
}
.demo-link:hover {
color: var(--nav-primary-solid, #1B4436);
}
/* ── Responsive ── */
@media (max-width: 480px) {
.lock-page {
padding: 1.25rem 1rem 2.5rem;
align-items: flex-start;
padding-top: 3rem;
}
}
</style>

View File

@@ -1,56 +1,144 @@
<template>
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- SIDEBAR DESKTOP ( 1024px) -->
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
<NavSidebar
:search="search"
:modeValue="territoireMode"
:echelle="echelle"
:fonctions="fonctions"
:territoire="territoire"
:echelleCount="echelleCount"
:fonctionCount="fonctionCount"
:territoireCount="territoireCount"
:resultCount="filtered.length"
:orgs="filtered"
:selectedId="selectedId"
:hasActiveFilters="hasActiveFilters"
:pending="pending"
@update:search="onSearch"
@update:mode="onMode"
@update:echelle="onEchelle"
@update:fonctions="onFonctions"
@update:territoire="onTerritoire"
@select-org="onSelectOrg"
@hover-org="onHoverOrg"
@reset-filters="resetFilters"
<!-- SIDEBAR DESKTOP (>= 1024px) -->
<div class="hidden lg:block overflow-y-auto" style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;">
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
<IntentionBanner />
<!-- Filtres familles + hashtags -->
<HashtagFilter
:allHashtags="allHashtags"
:selectedHashtags="selectedHashtags"
:selectedFamille="selectedFamille"
@update:selectedHashtags="selectedHashtags = $event"
@update:selectedFamille="selectedFamille = $event"
/>
<!-- Separateur -->
<div style="height: 1px; background: var(--nav-bg-alt);"></div>
<!-- Barre de recherche -->
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="sidebar-search-label" aria-label="Rechercher une structure">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sidebar-search-icon">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="search"
type="search"
placeholder="Rechercher une structure..."
class="sidebar-search-input"
autocomplete="off"
/>
<button
v-if="search"
type="button"
class="sidebar-search-clear"
aria-label="Effacer"
@click.stop="search = ''"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
<!-- Header compteur + reset -->
<div class="flex items-center justify-between px-4 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
</span>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
>Effacer les filtres</button>
</div>
<!-- Liste fiches (sidebar entiere scroll - pas de scroll interne) -->
<div class="px-3 py-2 space-y-1.5">
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement...
</div>
<div v-else-if="filtered.length === 0" class="text-center py-8">
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
</div>
<div
v-for="structure in filtered"
:key="structure.id"
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
:style="selectedId === structure.id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)}; padding-left: 9px;`
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
@click="onSelectStructure(structure.id)"
@mouseenter="hoveredId = structure.id"
@mouseleave="hoveredId = null"
>
<div class="flex items-start justify-between gap-1.5">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
<span
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
:style="`background: ${familleColor(structure.famille_principale)};`"
/>
</div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
<div v-if="structure.hashtags.length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="tag in structure.hashtags.slice(0, 2)"
:key="tag"
class="text-xs"
style="color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative">
<!-- Indicateur source dev -->
<div
v-if="dataSource === 'seed'"
class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
style="background: var(--nav-accent); color: var(--nav-text);"
>
Mode dev données seed
</div>
<!-- VUE DESKTOP : Métropole pleine largeur + DOM-TOM row en bas -->
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Carte Métropole pleine largeur -->
<div class="flex flex-col flex-1 overflow-hidden">
<!-- Onglets desktop -->
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'metropole'"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'"
>Outre-mer</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'graphe'"
>Vue graphique</button>
</div>
<!-- Carte Métropole desktop -->
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;">
<ClientOnly>
<NavMap
<NavMapV2
ref="navMapRef"
:orgs="metropoleOrgs"
:structures="metropoleStructures"
:selectedId="selectedId"
@select-org="onSelectOrg"
@select-structure="onSelectStructure"
/>
<template #fallback>
<div
@@ -62,35 +150,53 @@
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
<ChatbotPlaceholder
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
<!-- Bandeau DOM-TOM row horizontale pleine largeur, hauteur fixe -->
<div
class="shrink-0"
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
>
<!-- Carte Outre-mer desktop -->
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectOrg"
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<template #fallback>
<div
class="flex items-center justify-center h-full text-sm"
style="color: var(--nav-text-muted);"
>
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
Chargement…
</div>
</template>
</ClientOnly>
</div>
<!-- Vue graphique desktop -->
<div v-show="desktopMapView === 'graphe'" class="flex-1 overflow-hidden flex flex-col">
<div class="flex-1 overflow-hidden relative">
<ClientOnly>
<GraphView
:data="bifurcationData"
:allHashtags="allHashtags"
:active="desktopMapView === 'graphe'"
@select-structure="onSelectStructure"
/>
<template #fallback>
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">
Chargement du graphe...
</div>
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
</div>
<!-- VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable -->
<!-- Onglets Métropolitain / Outre-mer -->
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + sheet swipable ── -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
@@ -109,34 +215,30 @@
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
<!-- Carte Métropole -->
<!-- Carte mobile Métropole -->
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
<ClientOnly>
<NavMap
<NavMapV2
ref="navMapMobileRef"
:orgs="metropoleOrgs"
:structures="metropoleStructures"
:selectedId="selectedId"
@select-org="onSelectOrgMobile"
@select-structure="onSelectStructureMobile"
/>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">
Chargement de la carte…
</div>
</template>
</ClientOnly>
</div>
<!-- Carte Outre-mer (scroll vertical, pleine largeur) -->
<!-- Carte mobile Outre-mer -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectOrgMobile"
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
@@ -146,81 +248,65 @@
</ClientOnly>
</div>
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
<!-- Bottom sheet swipable -->
<ClientOnly>
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Barre recherche -->
<!-- Bandeau intention mobile -->
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
<p class="text-xs leading-relaxed" style="color: var(--bifurc-banner-text, #2c2416); margin: 0;">
120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture.
</p>
</div>
<!-- Filtres hashtags mobile -->
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<HashtagFilter
:allHashtags="allHashtags"
:selectedHashtags="selectedHashtags"
:selectedFamille="selectedFamille"
@update:selectedHashtags="selectedHashtags = $event"
@update:selectedFamille="selectedFamille = $event"
/>
</div>
<!-- Barre recherche mobile -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="mobile-search-label" aria-label="Rechercher une organisation">
<label class="mobile-search-label" aria-label="Rechercher une structure">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="mobileSearch"
v-model="search"
type="search"
placeholder="Rechercher…"
class="mobile-search-input"
autocomplete="off"
@input="onSearch(mobileSearch)"
/>
<button
v-if="mobileSearch"
v-if="search"
type="button"
class="mobile-search-clear"
aria-label="Effacer"
@click.stop="mobileSearch = ''; onSearch('')"
@click.stop="search = ''"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
<!-- Filtres ÉCHELLE chips style FONCTION -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
<div class="flex flex-wrap gap-1">
<span
v-for="opt in ECHELLES"
:key="opt"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="echelle.includes(opt)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleEchelle(opt)"
>{{ opt }}</span>
</div>
</div>
<!-- Filtres FONCTION chips flex-wrap -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
<div class="flex flex-wrap gap-1">
<span
v-for="fn in FONCTIONS"
:key="fn"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="fonctions.includes(fn)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleFonction(fn)"
>{{ fn }}</span>
</div>
</div>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="mt-2 text-xs"
class="mt-1 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;"
> Effacer les filtres</button>
>Effacer les filtres</button>
</div>
<!-- Compteur + Liste fiches -->
<!-- Liste fiches mobile -->
<div class="px-3 py-2">
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
</div>
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement des fiches
@@ -233,46 +319,36 @@
</div>
<div class="space-y-2">
<div
v-for="org in filtered"
:key="org.Id"
v-for="structure in filtered"
:key="structure.id"
class="block rounded-lg p-3 transition-all cursor-pointer"
:style="selectedId === org.Id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
:style="selectedId === structure.id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};`
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
@click="onSelectOrgMobile(org.Id)"
@click="onSelectStructureMobile(structure.id)"
>
<div class="flex items-start justify-between gap-2">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
<span
v-if="org.echelle"
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ org.echelle }}</span>
</div>
<div v-if="fonctionsList(org).length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="fn in fonctionsList(org)"
:key="fn"
class="px-1.5 py-0.5 rounded text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ fn }}</span>
</div>
<div v-if="org.localisation_ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
{{ org.localisation_ville }}
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
:style="`background: ${familleColor(structure.famille_principale)};`"
/>
</div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} · {{ structure.ville }}</div>
</div>
</div>
</div>
</MobileSheet>
</ClientOnly>
</div>
</main>
<!-- MODAL FICHE (desktop) -->
<FicheModal
<!-- MODAL FICHE V2 (desktop) -->
<FicheModalV2
v-model="ficheModalOpen"
:orgId="ficheModalId"
:structureId="ficheModalId"
:data="bifurcationData"
@update:structureId="ficheModalId = $event"
/>
<!-- BOUTON CHATBOT FLOTTANT (mobile) -->
@@ -301,268 +377,141 @@
<ChatbotSheet
:modelValue="chatbotOpen"
@update:modelValue="chatbotOpen = $event"
@highlightOrgs="onHighlightOrgs"
@highlightOrgs="() => {}"
/>
</div>
</template>
<script setup lang="ts">
import type { Org } from '~/types/org'
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
// ── URL query params sync ─────────────────────────────────────────────────
const route = useRoute()
const router = useRouter()
const search = ref<string>((route.query.q as string) ?? '')
const echelle = ref<string[]>(
route.query.echelle
? (route.query.echelle as string).split(',').filter(Boolean)
: []
)
const fonctions = ref<string[]>(
route.query.fonctions
? (route.query.fonctions as string).split(',').filter(Boolean)
: []
)
const territoire = ref<string | null>((route.query.territoire as string) ?? null)
const territoireMode = ref<string>(
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
)
const selectedId = ref<number | null>(null)
const chatbotOpen = ref(false)
const ficheModalOpen = ref(false)
const ficheModalId = ref<number | null>(null)
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
// Surlignage temporaire (5 sec) suite à une réponse chatbot
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
let highlightTimer: ReturnType<typeof setTimeout> | null = null
const prevSelectedId = ref<number | null>(null)
function onHighlightOrgs(ids: (number | string)[]) {
if (!ids.length) return
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
if (isNaN(firstId)) return
// Sauvegarde la sélection courante
prevSelectedId.value = selectedId.value
selectedId.value = firstId
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
// Restaure la sélection précédente (ou null)
selectedId.value = prevSelectedId.value
prevSelectedId.value = null
highlightTimer = null
}, 5000)
// ── Couleurs familles ──────────────────────────────────────────────────────
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
// Ref locale barre de recherche mobile (synchronisée avec search via onSearch)
const mobileSearch = ref<string>((route.query.q as string) ?? '')
function familleColor(f: number): string {
return FAMILLE_COLORS[f] ?? '#888'
}
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
// ── État UI ────────────────────────────────────────────────────────────────
const selectedId = ref<string | null>(null)
const hoveredId = ref<string | null>(null)
const ficheModalOpen = ref(false)
const ficheModalId = ref<string | null>(null)
const chatbotOpen = ref(false)
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
// Filtres
const search = ref('')
const selectedFamille = ref<number | null>(null)
const selectedHashtags = ref<string[]>([])
// Refs cartes
const navMapRef = ref<any>(null)
const navMapMobileRef = ref<any>(null)
// Sync URL <-> état filtres
function syncUrl() {
const q: Record<string, string> = {}
if (search.value) q.q = search.value
if (echelle.value.length) q.echelle = echelle.value.join(',')
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
if (territoire.value) q.territoire = territoire.value
if (territoireMode.value === 'outremer') q.mode = 'outremer'
router.replace({ query: Object.keys(q).length ? q : undefined })
// ── Données V2 - JSON statique ─────────────────────────────────────────────
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
const pending = ref(true)
onMounted(async () => {
try {
bifurcationData.value = await $fetch<ReseauxBifurcationData>('/data/reseaux-bifurcation.json')
} catch (e) {
console.error('Erreur chargement reseaux-bifurcation.json', e)
} finally {
pending.value = false
}
})
const structures = computed<StructureV2[]>(() => bifurcationData.value?.structures ?? [])
// Tous les hashtags uniques triés
const allHashtags = computed<string[]>(() => {
const set = new Set<string>()
structures.value.forEach(s => s.hashtags.forEach(h => set.add(h)))
return Array.from(set).sort()
})
// ── Filtrage ───────────────────────────────────────────────────────────────
const filtered = computed<StructureV2[]>(() => {
let result = structures.value
// Filtre texte
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
s =>
s.nom.toLowerCase().includes(q) ||
s.ville.toLowerCase().includes(q) ||
s.description_courte.toLowerCase().includes(q) ||
s.hashtags.some(h => h.toLowerCase().includes(q))
)
}
// Filtre famille - F6 = badge_f6_recherche_politique, pas famille_principale
if (selectedFamille.value !== null) {
if (selectedFamille.value === 6) {
result = result.filter(s => (s.badges as any)?.f6_recherche_politique === true)
} else {
result = result.filter(
s => s.famille_principale === selectedFamille.value ||
(s.familles_secondaires ?? []).includes(selectedFamille.value!)
)
}
}
// Filtre hashtags (AND logique si plusieurs)
if (selectedHashtags.value.length) {
result = result.filter(
s => selectedHashtags.value.every(h => s.hashtags.includes(h))
)
}
return result
})
const hasActiveFilters = computed(
() => !!search.value || selectedFamille.value !== null || selectedHashtags.value.length > 0
)
function resetFilters() {
search.value = ''
selectedFamille.value = null
selectedHashtags.value = []
}
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
function storeFiltersForBack() {
if (typeof window === 'undefined') return
const q: Record<string, string> = {}
if (search.value) q.q = search.value
if (echelle.value.length) q.echelle = echelle.value.join(',')
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
if (territoire.value) q.territoire = territoire.value
if (territoireMode.value === 'outremer') q.mode = 'outremer'
const qs = new URLSearchParams(q).toString()
sessionStorage.setItem('nav_back_filters', qs)
}
// Structures métropole (pays != DOM-TOM, et avec coordonnées)
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
function onMode(v: string) { territoireMode.value = v; syncUrl(); storeFiltersForBack() }
function onEchelle(v: string[]) { echelle.value = v; syncUrl(); storeFiltersForBack() }
function onFonctions(v: string[]) { fonctions.value = v; syncUrl(); storeFiltersForBack() }
function onTerritoire(v: string | null) { territoire.value = v; syncUrl(); storeFiltersForBack() }
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
// OutremerMap attend le format Org legacy - on passe un tableau vide
const outremerOrgsLegacy = computed(() => [])
const selectedIdLegacyNum = computed(() => null)
function onSelectOrg(id: number) {
// ── Sélection ─────────────────────────────────────────────────────────────
function onSelectStructure(id: string) {
selectedId.value = selectedId.value === id ? null : id
// Desktop : ouvrir le modal fiche
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
ficheModalId.value = id
ficheModalOpen.value = true
}
}
// Tap card mobile → ouvre la fiche détaillée
function onSelectOrgMobile(id: number) {
function onSelectStructureMobile(id: string) {
selectedId.value = id
storeFiltersForBack()
router.push(`/fiche/${id}`)
ficheModalId.value = id
ficheModalOpen.value = true
}
function onHoverOrg(id: number | null) {
if (id !== null) selectedId.value = id
}
const hasActiveFilters = computed(() =>
!!search.value || echelle.value.length > 0 || fonctions.value.length > 0 || !!territoire.value
)
function resetFilters() {
search.value = ''
echelle.value = []
fonctions.value = []
territoire.value = null
router.replace({ query: undefined })
}
// Tagging compact mobile — toggle direct
function toggleEchelle(opt: string) {
if (echelle.value.includes(opt)) {
onEchelle(echelle.value.filter(v => v !== opt))
} else {
onEchelle([...echelle.value, opt])
}
}
function toggleFonction(fn: string) {
if (fonctions.value.includes(fn)) {
onFonctions(fonctions.value.filter(f => f !== fn))
} else {
onFonctions([...fonctions.value, fn])
}
}
// Sync recherche depuis app.vue top nav (via URL ?q=)
watch(() => route.query.q, (v) => {
search.value = (v as string) ?? ''
})
// ── Données ───────────────────────────────────────────────────────────────
const { data, pending, error } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
const orgs = computed<Org[]>(() => data.value?.list ?? [])
const dataSource = computed(() => data.value?.source ?? 'nocodb')
// Fiche aléatoire — réagit au ?random=1
watch(() => route.query.random, (v) => {
if (v === '1' && orgs.value.length > 0) {
const randomOrg = orgs.value[Math.floor(Math.random() * orgs.value.length)]
router.replace({ path: `/fiche/${randomOrg.Id}` })
}
})
// ── Filtrage côté client ──────────────────────────────────────────────────
const filtered = computed<Org[]>(() => {
let result = orgs.value
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
(o) =>
o.nom?.toLowerCase().includes(q) ||
o.localisation_ville?.toLowerCase().includes(q)
)
}
if (echelle.value.length) {
result = result.filter((o) => o.echelle && echelle.value.includes(o.echelle))
}
if (fonctions.value.length) {
// Garde les orgs qui matchent au moins 1 fonction sélectionnée
result = result.filter((o) => {
const orgFns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
return fonctions.value.some((fn) => orgFns.includes(fn))
})
// Tri par score pondéré : priorité 1 (1er cliqué) = poids le plus fort
const n = fonctions.value.length
const score = (o: Org) =>
fonctions.value.reduce((s, fn, i) => {
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
return s + (fns.includes(fn) ? (n - i) : 0)
}, 0)
result = [...result].sort((a, b) => score(b) - score(a))
}
if (territoire.value) {
result = result.filter((o) => o.territoire === territoire.value)
}
return result
})
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const DOM_TOM_LIST = DOM_TOM
const metropoleOrgs = computed<Org[]>(() =>
filtered.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire))
)
const outremerOrgs = computed<Org[]>(() => {
if (territoire.value && DOM_TOM.includes(territoire.value)) {
return filtered.value.filter(o => o.territoire === territoire.value)
}
return filtered.value.filter(o => o.territoire && DOM_TOM.includes(o.territoire))
})
const outremerCountByDom = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
DOM_TOM.forEach(d => { counts[d] = 0 })
filtered.value.forEach(o => {
if (o.territoire && DOM_TOM.includes(o.territoire)) {
counts[o.territoire] = (counts[o.territoire] ?? 0) + 1
}
})
return counts
})
// ── Compteurs ─────────────────────────────────────────────────────────────
const ECHELLES = ['National', 'Régional', 'Local'] as const
const ECHELLE_LABELS: Record<string, string> = { National: 'Nat', Régional: 'Rég', Local: 'Loc' }
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale'] as const
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const echelleCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
ECHELLES.forEach((e) => { counts[e] = 0 })
orgs.value.forEach((o) => { if (o.echelle) counts[o.echelle] = (counts[o.echelle] ?? 0) + 1 })
return counts
})
const fonctionCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
FONCTIONS.forEach((f) => { counts[f] = 0 })
orgs.value.forEach((o) => {
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
fns.forEach((fn) => { counts[fn] = (counts[fn] ?? 0) + 1 })
})
return counts
})
const territoireCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
TERRITOIRES.forEach((t) => { counts[t] = 0 })
orgs.value.forEach((o) => { if (o.territoire) counts[o.territoire] = (counts[o.territoire] ?? 0) + 1 })
counts['Métropole'] = orgs.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire)).length
return counts
})
// ── Helpers ───────────────────────────────────────────────────────────────
function fonctionsList(org: Org): string[] {
return (org.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean).slice(0, 3)
}
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
</script>

View File

@@ -0,0 +1,39 @@
/**
* GET /api/admin/rag-info
*
* Retourne le statut du système RAG (v1 + v2) pour la page /admin/rag-status
*/
import { existsSync, readFileSync } from 'fs'
import { resolve } from 'path'
export default defineEventHandler(async (_event) => {
// Statut V2 : compter les embeddings
let v2Count = 0
let v2Date: string | null = null
let v2Model: string | null = null
try {
// Chercher depuis process.cwd() (racine du projet Nuxt)
const embPath = resolve(process.cwd(), 'server', 'data', 'embeddings-v2.json')
if (existsSync(embPath)) {
const data = JSON.parse(readFileSync(embPath, 'utf-8'))
v2Count = data.embeddings?.length ?? 0
v2Date = data.meta?.date ?? null
v2Model = data.meta?.model ?? null
}
} catch (e: any) {
console.warn('[rag-info] Erreur lecture embeddings-v2.json :', e?.message ?? e)
}
return {
v2_embeddings_count: v2Count,
v2_ready: v2Count > 0,
v2_model: v2Model ?? 'mistral-embed',
v2_generated_date: v2Date ?? null,
v1_enabled: process.env.RAG_V1_ENABLED !== 'false',
v1_deprecation_date: process.env.RAG_V1_DEPRECATION_DATE ?? 'non défini',
model_chat: 'mistral-small-latest',
setup_command: 'MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js'
}
})

View File

@@ -0,0 +1,194 @@
/**
* POST /api/chatbot-v2
*
* Chatbot V2 - Embedding-based search sur structures bifurcation
* Coexiste avec /api/chatbot (keyword NocoDB) pendant la transition.
*
* SETUP AVANT DEPLOY :
* cd nav-carte && MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
* Coût estimé : ~0.10 EUR pour 120 fiches
*
* Flow :
* 1. Rate limit (réutilise checkRateLimitJson, 10 req/IP/jour)
* 2. Embed la query via Mistral Embed (mistral-embed)
* 3. Top-5 cosine similarity sur embeddings-v2.json
* 4. Si embeddings absents : réponse graceful (v2_ready: false)
* 5. Construit contexte RAG depuis les fiches candidates
* 6. Génère réponse Mistral Small (json_object)
* 7. Retourne { reponse_texte, fiches_recommandees, sources, v2_ready }
*
* Variables d'env :
* MISTRAL_API_KEY - Clé Mistral (partagée avec chatbot v1)
* RAG_V1_ENABLED - true/false (défaut: true) - coexistence pendant transition
* RAG_V1_DEPRECATION_DATE - Date prévue deprecation v1 (ex: 2026-05-18)
*/
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { loadEmbeddingsV2, topKSearch } from '~/server/utils/vectorSearch'
// ── System prompt V2 ───────────────────────────────────────────────────────────
const SYSTEM_PROMPT_V2 = `Tu es un assistant pour la carte des réseaux de bifurcation en architecture (projet AEP).
Tu réponds aux questions sur les structures, les pratiques, les pensées écologiques.
Règles :
- Cite chaque structure par son nom exact et son fiche_id
- Indique la famille (1-5) entre parenthèses après chaque nom
- Reste sobre et descriptif - pas militant agressif
- Tirets longs interdits : utilise des - ou des ;
- Max 200 mots par réponse
- Si hors-scope (pas archi/habiter/écologie), redirige poliment vers la carte
- Retourne UNIQUEMENT un JSON valide, sans texte avant ou après
Familles :
1 - Réemploi et filières
2 - Frugalité et low-tech
3 - Architecture sociale et précarités
4 - Collectifs, écolieux et AMO
5 - Urbanisme de transition et territoires
FORMAT DE SORTIE :
{
"reponse_texte": "Ta réponse en prose (max 200 mots)",
"fiches_recommandees": [
{ "fiche_id": "f1-rotor", "nom": "Rotor", "explication": "1-2 phrases pourquoi cette fiche" }
]
}
CONTEXTE - Structures disponibles :
{{CONTEXTE_RAG}}`
// ── Handler ────────────────────────────────────────────────────────────────────
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
// 1. Rate limit
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
const allowed = checkRateLimitJson(ip, 'chatbot-v2', 10)
if (!allowed) {
throw createError({
statusCode: 429,
statusMessage: 'Limite de 10 questions par jour atteinte.'
})
}
// 2. Validation body
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
if (!question || question.length < 3) {
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
}
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) {
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
}
// 3. Charger embeddings V2 (lazy, cachés en mémoire)
const embeddingsV2 = loadEmbeddingsV2()
// Graceful fallback si le script vectorize-v2.js n'a pas encore été lancé
if (embeddingsV2.length === 0) {
return {
reponse_texte: "La base vectorielle V2 est en cours de préparation. Merci d'utiliser le chatbot classique en attendant.",
fiches_recommandees: [],
sources: [],
v2_ready: false
}
}
// 4. Embed la query via Mistral Embed
let queryEmbedding: number[]
try {
const embedRes = await $fetch<{ data: { embedding: number[] }[] }>(
'https://api.mistral.ai/v1/embeddings',
{
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'mistral-embed',
inputs: [question]
})
}
)
queryEmbedding = embedRes.data[0].embedding
} catch (e: any) {
console.error('[chatbot-v2] Erreur embedding Mistral :', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur embedding Mistral.' })
}
// 5. Top-5 cosine similarity
const v2Results = topKSearch(embeddingsV2, queryEmbedding, 5)
// 6. Contexte RAG
const candidatesContext = v2Results.map(r => ({
fiche_id: r.fiche_id,
nom: r.nom,
famille: r.famille,
hashtags: r.hashtags,
score: r.score,
preview: r.text_preview
}))
const contextStr = candidatesContext
.map(c => `[${c.fiche_id}] ${c.nom} (famille ${c.famille}, score: ${c.score.toFixed(2)})\n${c.preview}`)
.join('\n\n---\n\n')
const systemPrompt = SYSTEM_PROMPT_V2.replace('{{CONTEXTE_RAG}}', contextStr)
// 7. Mistral Small - génération réponse
let mistralRaw: string
try {
const mistralRes = await $fetch<{
choices: { message: { content: string } }[]
}>('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 600,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question }
]
})
})
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
} catch (e: any) {
console.error('[chatbot-v2] Erreur Mistral Small :', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur appel Mistral Small.' })
}
// 8. Parse JSON
let parsed: { reponse_texte: string; fiches_recommandees: any[] }
try {
parsed = JSON.parse(mistralRaw)
if (!parsed.reponse_texte) throw new Error('reponse_texte absent')
} catch {
parsed = {
reponse_texte: "Impossible d'analyser la réponse.",
fiches_recommandees: []
}
}
return {
reponse_texte: parsed.reponse_texte,
fiches_recommandees: parsed.fiches_recommandees ?? [],
sources: candidatesContext,
v2_ready: true
}
})

View File

@@ -0,0 +1,31 @@
import { z } from 'zod'
const AuthSchema = z.object({
password: z.string().min(1).max(100),
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const parsed = AuthSchema.safeParse(body)
if (!parsed.success) {
throw createError({ statusCode: 422, statusMessage: 'Mot de passe invalide' })
}
const config = useRuntimeConfig()
const expected = config.codevPassword || 'merci'
if (parsed.data.password.trim().toLowerCase() !== expected.trim().toLowerCase()) {
throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' })
}
setCookie(event, 'codev_session', 'ok', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24, // 24h
path: '/',
})
return { status: 200, ok: true }
})

View File

@@ -0,0 +1,31 @@
import type { CodevFiche } from '~/types/codev'
export default defineEventHandler(async (event): Promise<{ list: CodevFiche[] }> => {
const config = useRuntimeConfig()
const tableId = config.codevTableId
if (!tableId) {
throw createError({ statusCode: 500, message: 'codevTableId non configuré' })
}
const url = `${config.nocodbUrl}/api/v2/tables/${tableId}/records?sort=created_at&limit=200`
const data: any = await $fetch(url, {
headers: { 'xc-token': config.nocodbToken },
}).catch(() => ({ list: [] }))
// Mapper chaque record NocoDB vers CodevFiche
const list: CodevFiche[] = (data?.list ?? []).map((r: any) => ({
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 || new Date().toISOString(),
}))
return { list }
})

View File

@@ -0,0 +1,63 @@
import { z } from 'zod'
const FicheSchema = 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 body = await readBody(event)
const parsed = FicheSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation échouée',
data: parsed.error.flatten().fieldErrors,
})
}
const config = useRuntimeConfig()
const tableId = config.codevTableId
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
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(','),
created_at: new Date().toISOString(),
}
// NocoDB v1 endpoint pour INSERT (cf. submit/index.post.ts pour le pattern)
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}`
let inserted: any
try {
inserted = await $fetch(insertUrl, {
method: 'POST',
headers: {
'xc-token': config.nocodbToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
} catch (e: any) {
console.error('[codev/fiches.post] NocoDB insert error:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur serveur, réessaie',
})
}
return {
status: 201,
id: inserted?.Id ?? inserted?.id ?? null,
}
})

View File

@@ -0,0 +1,20 @@
// Middleware server Nuxt — protection des routes /codev/fiche et /codev/carto
// Laisse passer /codev (lock screen), /codev/demo et toutes les routes /api/*
export default defineEventHandler((event) => {
const url = getRequestURL(event)
const path = url.pathname
// Seulement les routes sous /codev/
if (!path.startsWith('/codev/')) return
// Routes publiques : /codev/demo (et sous-routes éventuelles)
if (path === '/codev/demo' || path.startsWith('/codev/demo/')) return
// Vérification cookie
const session = getCookie(event, 'codev_session')
if (session === 'ok') return
// Non authentifié -> redirect vers /codev (lock screen)
return sendRedirect(event, '/codev', 302)
})

View File

@@ -0,0 +1,96 @@
/**
* Recherche vectorielle sur les embeddings V2
* Cosine similarity + top-K
*
* Utilisé par : server/api/chatbot-v2.post.ts
* Données : server/data/embeddings-v2.json (généré par scripts/vectorize-v2.js)
*/
import { readFileSync, existsSync } from 'fs'
import { fileURLToPath } from 'url'
import { resolve, dirname } from 'path'
// ── Types ──────────────────────────────────────────────────────────────────────
export interface EmbeddingEntry {
fiche_id: string
nom: string
famille: number
hashtags: string[]
embedding: number[]
text_preview: string
}
export interface SearchResult {
fiche_id: string
nom: string
famille: number
hashtags: string[]
score: number
text_preview: string
}
// ── Cosine similarity ──────────────────────────────────────────────────────────
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) return 0
let dot = 0, normA = 0, normB = 0
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
const denom = Math.sqrt(normA) * Math.sqrt(normB)
return denom === 0 ? 0 : dot / denom
}
// ── Top-K search ───────────────────────────────────────────────────────────────
export function topKSearch(
embeddings: EmbeddingEntry[],
queryEmbedding: number[],
k: number = 5
): SearchResult[] {
return embeddings
.map(e => ({
fiche_id: e.fiche_id,
nom: e.nom,
famille: e.famille,
hashtags: e.hashtags,
score: cosineSimilarity(e.embedding, queryEmbedding),
text_preview: e.text_preview
}))
.sort((a, b) => b.score - a.score)
.slice(0, k)
}
// ── Chargement lazy des embeddings (cache module-level) ────────────────────────
let _embeddingsV2: EmbeddingEntry[] | null = null
export function loadEmbeddingsV2(): EmbeddingEntry[] {
if (_embeddingsV2 !== null) return _embeddingsV2
try {
// Résolution du chemin depuis server/utils/ vers server/data/
const currentDir = dirname(fileURLToPath(import.meta.url))
const embPath = resolve(currentDir, '..', 'data', 'embeddings-v2.json')
if (!existsSync(embPath)) {
console.warn('[vectorSearch] embeddings-v2.json absent - V2 vector search désactivé')
console.warn('[vectorSearch] Lancer : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js')
_embeddingsV2 = []
return []
}
const raw = readFileSync(embPath, 'utf-8')
const data = JSON.parse(raw)
_embeddingsV2 = data.embeddings ?? []
console.log(`[vectorSearch] ${_embeddingsV2!.length} embeddings V2 chargés (${data.meta?.model ?? 'unknown'})`)
return _embeddingsV2!
} catch (e: any) {
console.warn('[vectorSearch] Erreur chargement embeddings-v2.json :', e?.message ?? e)
_embeddingsV2 = []
return []
}
}

18
types/codev.ts Normal file
View File

@@ -0,0 +1,18 @@
export interface CodevFiche {
id: number
nom: string
besoin: string
offre: string
hashtags: string[] // parsé depuis CSV NocoDB
created_at: string // ISO
}
export interface CodevMatch {
fromId: number
toId: number
score: number // 0-1
mode: 'solution' | 'alliance' | 'surprise'
// solution : fromId.besoin matche toId.offre (orienté)
// alliance : symétrique sur besoin
// surprise : symétrique sur offre
}

91
types/structure-v2.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Types V2 - Carte des réseaux de bifurcation
* Source : public/data/reseaux-bifurcation.json
*/
export interface StructureV2 {
id: string
nom: string
url: string
pays: string
ville: string
famille_principale: 1 | 2 | 3 | 4 | 5
familles_secondaires?: number[]
hashtags: string[]
type_principal: string
badges: {
centre_ressources: boolean
mouvement_manifeste: boolean
contre_pouvoir_spatial: boolean
f6_recherche_politique: boolean
}
description_courte: string
description_longue: string
pensees: { id: string; label: string; confiance: string }[]
sources: { type: string; titre: string; url: string }[]
already_in_v1: boolean
eligible_v2: boolean
// Geocoords (ajoutés par géocodage - peut être null)
latitude?: number | null
longitude?: number | null
}
export interface ReseauxBifurcationData {
version: string
meta: {
total_structures: number
total_projets_emblematiques: number
total_edges_graphe: number
familles: { id: number; label: string; color: string }[]
hashtags_officiels: string[]
}
structures: StructureV2[]
projets: ProjetEmblematique[]
graphe: { edges: GrapheEdge[] }
}
export interface ProjetEmblematique {
id: string
nom: string
structure_parent: string
annee?: number
lieu?: string
geocoords?: { lat: number; lng: number } | null
description: string
url?: string | null
tags: string[]
}
export interface GrapheEdge {
source: string
target: string
types: string[]
score: number
evidence: string
}
// Mapping StructureV2 vers le format attendu par NavMap (interface Org)
// NavMap attend { Id, latitude, longitude, nom, ... }
export function structureToMapOrg(s: StructureV2, index: number): {
Id: number
nom: string
latitude?: number | null
longitude?: number | null
prioritaire?: boolean
famille_principale?: number
hashtags?: string[]
type_principal?: string
description_courte?: string
} {
return {
Id: index,
nom: s.nom,
latitude: s.latitude,
longitude: s.longitude,
prioritaire: s.badges?.centre_ressources || s.badges?.mouvement_manifeste || s.badges?.contre_pouvoir_spatial,
famille_principale: s.famille_principale,
hashtags: s.hashtags,
type_principal: s.type_principal,
description_courte: s.description_courte,
}
}

97
utils/codev/matching.ts Normal file
View 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)
}
}