Compare commits
13 Commits
feat/aep-v
...
03127b1648
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03127b1648 | ||
|
|
0099627da4 | ||
|
|
f9960bf8ea | ||
|
|
05bbcc2a02 | ||
|
|
f518318d60 | ||
|
|
0598536244 | ||
|
|
b951fe0b8d | ||
|
|
c8311ce1fb | ||
|
|
142e5cf787 | ||
|
|
606b9f0a47 | ||
|
|
6f7d2450de | ||
|
|
e7c7d302ea | ||
|
|
4ed0a87106 |
20
app.vue
20
app.vue
@@ -39,8 +39,21 @@
|
|||||||
class="nav-tab"
|
class="nav-tab"
|
||||||
:class="{ 'nav-tab--active': route.path === '/agences' }"
|
:class="{ 'nav-tab--active': route.path === '/agences' }"
|
||||||
>
|
>
|
||||||
Agences Inspirantes
|
Réseaux AEP
|
||||||
<span class="nav-tab-badge">en construction</span>
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/trouver-du-taf"
|
||||||
|
class="nav-tab"
|
||||||
|
:class="{ 'nav-tab--active': route.path === '/trouver-du-taf' }"
|
||||||
|
>
|
||||||
|
Jobs
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/codev"
|
||||||
|
class="nav-tab"
|
||||||
|
:class="{ 'nav-tab--active': route.path.startsWith('/codev') }"
|
||||||
|
>
|
||||||
|
Codev
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/rag"
|
to="/rag"
|
||||||
@@ -165,8 +178,9 @@
|
|||||||
@click="hamburgerOpen = false"
|
@click="hamburgerOpen = false"
|
||||||
>
|
>
|
||||||
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
||||||
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Agences Inspirantes</NuxtLink>
|
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Réseaux AEP</NuxtLink>
|
||||||
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
|
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
|
||||||
|
<NuxtLink to="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
||||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||||
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
||||||
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
/* 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); }
|
|
||||||
@@ -52,9 +52,18 @@
|
|||||||
<div class="chatbot-body-inner" ref="messagesContainer">
|
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||||
<!-- Onboarding -->
|
<!-- Onboarding -->
|
||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
<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>Ce chatbot fonctionne sur un serveur européen souverain
|
||||||
<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>
|
(Mistral FR, zéro rétention), conçu sobre en énergie.</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
@@ -63,7 +72,7 @@
|
|||||||
<div v-else class="assistant-bubble">
|
<div v-else class="assistant-bubble">
|
||||||
<p>{{ msg.content }}</p>
|
<p>{{ msg.content }}</p>
|
||||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
||||||
<p class="fiches-title">Fiches recommandees :</p>
|
<p class="fiches-title">Fiches recommandées :</p>
|
||||||
<a
|
<a
|
||||||
v-for="fiche in msg.fiches"
|
v-for="fiche in msg.fiches"
|
||||||
:key="fiche.id"
|
:key="fiche.id"
|
||||||
@@ -74,21 +83,6 @@
|
|||||||
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -138,12 +132,10 @@ interface ChatMessage {
|
|||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
fiches?: FicheReco[]
|
fiches?: FicheReco[]
|
||||||
suggestedHashtags?: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'highlightOrgs': [ids: (number | string)[]]
|
'highlightOrgs': [ids: (number | string)[]]
|
||||||
'applyHashtag': [tag: string]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isExpanded = ref(false)
|
const isExpanded = ref(false)
|
||||||
@@ -153,37 +145,6 @@ const loading = ref(false)
|
|||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const messagesContainer = ref<HTMLElement | null>(null)
|
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() {
|
function toggleExpand() {
|
||||||
isExpanded.value = !isExpanded.value
|
isExpanded.value = !isExpanded.value
|
||||||
}
|
}
|
||||||
@@ -209,12 +170,10 @@ async function sendMessage() {
|
|||||||
body: { question },
|
body: { question },
|
||||||
})
|
})
|
||||||
|
|
||||||
const suggestedHashtags = detectHashtagsFromQuery(question)
|
|
||||||
const assistantMsg: ChatMessage = {
|
const assistantMsg: ChatMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: res.reponse_texte,
|
content: res.reponse_texte,
|
||||||
fiches: res.fiches_recommandees || [],
|
fiches: res.fiches_recommandees || [],
|
||||||
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
|
|
||||||
}
|
}
|
||||||
messages.value.push(assistantMsg)
|
messages.value.push(assistantMsg)
|
||||||
|
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,860 +0,0 @@
|
|||||||
<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 -></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>
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<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 où 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>
|
|
||||||
@@ -221,7 +221,12 @@ function updateTileTheme(dark: boolean) {
|
|||||||
let themeObserver: MutationObserver | null = null
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Double rAF : laisser le browser calculer la hauteur du conteneur avant Leaflet
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
initMap()
|
initMap()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Observer les changements de classe dark sur <html>
|
// Observer les changements de classe dark sur <html>
|
||||||
themeObserver = new MutationObserver(() => {
|
themeObserver = new MutationObserver(() => {
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -125,8 +125,8 @@
|
|||||||
<span
|
<span
|
||||||
v-for="fn in orgFonctions(org)"
|
v-for="fn in orgFonctions(org)"
|
||||||
:key="fn"
|
:key="fn"
|
||||||
class="px-1.5 py-0.5 rounded text-xs"
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt); letter-spacing: 0.01em;"
|
||||||
>{{ fn }}</span>
|
>{{ fn }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ const props = withDefaults(defineProps<{
|
|||||||
fiches: CodevFiche[]
|
fiches: CodevFiche[]
|
||||||
matches?: CodevMatch[]
|
matches?: CodevMatch[]
|
||||||
mode?: 'none' | 'solution' | 'alliance' | 'surprise'
|
mode?: 'none' | 'solution' | 'alliance' | 'surprise'
|
||||||
|
showLabels?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
matches: () => [],
|
matches: () => [],
|
||||||
mode: 'none',
|
mode: 'none',
|
||||||
|
showLabels: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -146,18 +148,15 @@ function rebuildLinks() {
|
|||||||
currentLinks = buildLinks(currentNodes)
|
currentLinks = buildLinks(currentNodes)
|
||||||
if (!gLinks || !simulation) return
|
if (!gLinks || !simulation) return
|
||||||
|
|
||||||
const linkSel = gLinks
|
// .join() moderne D3 pour garantir le re-rendu complet
|
||||||
|
gLinks
|
||||||
.selectAll<SVGLineElement, SimLink>('line')
|
.selectAll<SVGLineElement, SimLink>('line')
|
||||||
.data(currentLinks, (d: SimLink) => {
|
.data(currentLinks)
|
||||||
const s = d.source as SimNode
|
.join(
|
||||||
const t = d.target as SimNode
|
enter => enter.append('line'),
|
||||||
return `${s.id}-${t.id}-${d.mode}`
|
update => update,
|
||||||
})
|
exit => exit.remove()
|
||||||
|
)
|
||||||
linkSel.exit().remove()
|
|
||||||
|
|
||||||
linkSel.enter()
|
|
||||||
.append('line')
|
|
||||||
.attr('stroke', d => linkColor(d.mode))
|
.attr('stroke', d => linkColor(d.mode))
|
||||||
.attr('stroke-width', d => 1 + d.score * 3)
|
.attr('stroke-width', d => 1 + d.score * 3)
|
||||||
.attr('stroke-opacity', 0.7)
|
.attr('stroke-opacity', 0.7)
|
||||||
@@ -223,12 +222,12 @@ function render() {
|
|||||||
.attr('stroke', '#fff')
|
.attr('stroke', '#fff')
|
||||||
.attr('stroke-width', 1.5)
|
.attr('stroke-width', 1.5)
|
||||||
|
|
||||||
// Pastille besoin (bas-droite, orange)
|
// Pastille besoin (bas-droite, bleu)
|
||||||
nodeGroups.append('circle')
|
nodeGroups.append('circle')
|
||||||
.attr('r', 6)
|
.attr('r', 6)
|
||||||
.attr('cx', r * 0.65)
|
.attr('cx', r * 0.65)
|
||||||
.attr('cy', r * 0.65)
|
.attr('cy', r * 0.65)
|
||||||
.attr('fill', '#f97316')
|
.attr('fill', '#3b82f6')
|
||||||
.attr('stroke', '#fff')
|
.attr('stroke', '#fff')
|
||||||
.attr('stroke-width', 1.5)
|
.attr('stroke-width', 1.5)
|
||||||
|
|
||||||
@@ -236,6 +235,57 @@ function render() {
|
|||||||
nodeGroups.append('title')
|
nodeGroups.append('title')
|
||||||
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`)
|
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`)
|
||||||
|
|
||||||
|
// Groupe label bulle (affiche si showLabels)
|
||||||
|
const labelGroups = nodeGroups.append('g')
|
||||||
|
.attr('class', 'label-bubble')
|
||||||
|
.attr('visibility', props.showLabels ? 'visible' : 'hidden')
|
||||||
|
|
||||||
|
// Fond bulle besoin (dessous du noeud)
|
||||||
|
labelGroups.append('rect')
|
||||||
|
.attr('class', 'bubble-besoin-bg')
|
||||||
|
.attr('x', -(r + 50))
|
||||||
|
.attr('y', r + 4)
|
||||||
|
.attr('width', 100)
|
||||||
|
.attr('height', 28)
|
||||||
|
.attr('rx', 6)
|
||||||
|
.attr('fill', '#eff6ff')
|
||||||
|
.attr('stroke', '#3b82f6')
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
|
||||||
|
// Texte besoin
|
||||||
|
labelGroups.append('text')
|
||||||
|
.attr('class', 'bubble-besoin-txt')
|
||||||
|
.attr('x', -(r) + 50)
|
||||||
|
.attr('y', r + 22)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', 9)
|
||||||
|
.attr('fill', '#1e40af')
|
||||||
|
.attr('pointer-events', 'none')
|
||||||
|
.text(d => truncate(d.besoin, 18))
|
||||||
|
|
||||||
|
// Fond bulle offre (dessus du noeud)
|
||||||
|
labelGroups.append('rect')
|
||||||
|
.attr('class', 'bubble-offre-bg')
|
||||||
|
.attr('x', -(r + 50))
|
||||||
|
.attr('y', -(r + 32))
|
||||||
|
.attr('width', 100)
|
||||||
|
.attr('height', 28)
|
||||||
|
.attr('rx', 6)
|
||||||
|
.attr('fill', '#f0fdf4')
|
||||||
|
.attr('stroke', '#22c55e')
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
|
||||||
|
// Texte offre
|
||||||
|
labelGroups.append('text')
|
||||||
|
.attr('class', 'bubble-offre-txt')
|
||||||
|
.attr('x', -(r) + 50)
|
||||||
|
.attr('y', -(r + 14))
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', 9)
|
||||||
|
.attr('fill', '#166534')
|
||||||
|
.attr('pointer-events', 'none')
|
||||||
|
.text(d => truncate(d.offre, 18))
|
||||||
|
|
||||||
// Simulation
|
// Simulation
|
||||||
simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes)
|
simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes)
|
||||||
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
|
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
|
||||||
@@ -245,6 +295,8 @@ function render() {
|
|||||||
.force('charge', d3.forceManyBody<SimNode>().strength(-400))
|
.force('charge', d3.forceManyBody<SimNode>().strength(-400))
|
||||||
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
||||||
.force('collide', d3.forceCollide<SimNode>().radius(r + 12))
|
.force('collide', d3.forceCollide<SimNode>().radius(r + 12))
|
||||||
|
.force('x', d3.forceX(width.value / 2).strength(0.05))
|
||||||
|
.force('y', d3.forceY(height.value / 2).strength(0.05))
|
||||||
.alphaDecay(0.02)
|
.alphaDecay(0.02)
|
||||||
.on('tick', tick)
|
.on('tick', tick)
|
||||||
|
|
||||||
@@ -254,16 +306,21 @@ function render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
|
const r = nodeRadius.value
|
||||||
if (!gLinks || !gNodes) return
|
if (!gLinks || !gNodes) return
|
||||||
|
|
||||||
gLinks.selectAll<SVGLineElement, SimLink>('line')
|
gLinks.selectAll<SVGLineElement, SimLink>('line')
|
||||||
.attr('x1', d => (d.source as SimNode).x ?? 0)
|
.attr('x1', d => Math.max(r, Math.min(width.value - r, (d.source as SimNode).x ?? 0)))
|
||||||
.attr('y1', d => (d.source as SimNode).y ?? 0)
|
.attr('y1', d => Math.max(r, Math.min(height.value - r, (d.source as SimNode).y ?? 0)))
|
||||||
.attr('x2', d => (d.target as SimNode).x ?? 0)
|
.attr('x2', d => Math.max(r, Math.min(width.value - r, (d.target as SimNode).x ?? 0)))
|
||||||
.attr('y2', d => (d.target as SimNode).y ?? 0)
|
.attr('y2', d => Math.max(r, Math.min(height.value - r, (d.target as SimNode).y ?? 0)))
|
||||||
|
|
||||||
gNodes.selectAll<SVGGElement, SimNode>('g.node')
|
gNodes.selectAll<SVGGElement, SimNode>('g.node')
|
||||||
.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`)
|
.attr('transform', d => {
|
||||||
|
const x = Math.max(r, Math.min(width.value - r, d.x ?? 0))
|
||||||
|
const y = Math.max(r, Math.min(height.value - r, d.y ?? 0))
|
||||||
|
return `translate(${x},${y})`
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
|
// ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
|
||||||
@@ -271,13 +328,21 @@ function tick() {
|
|||||||
watch(() => [props.matches, props.mode] as const, () => {
|
watch(() => [props.matches, props.mode] as const, () => {
|
||||||
if (!simulation) return
|
if (!simulation) return
|
||||||
rebuildLinks()
|
rebuildLinks()
|
||||||
simulation.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
|
const newForce = d3.forceLink<SimNode, SimLink>(currentLinks)
|
||||||
.id(d => d.id)
|
.id(d => String(d.id))
|
||||||
.distance(120)
|
.distance(120)
|
||||||
.strength(0.3))
|
.strength(0.5)
|
||||||
simulation.alpha(0.5).restart()
|
simulation.force('link', newForce)
|
||||||
|
simulation.alpha(0.8).restart()
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
// ── Watch showLabels ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
watch(() => props.showLabels, (val) => {
|
||||||
|
if (!svgEl.value) return
|
||||||
|
d3.select(svgEl.value).selectAll('.label-bubble').attr('visibility', val ? 'visible' : 'hidden')
|
||||||
|
})
|
||||||
|
|
||||||
// ── Watch fiches (re-render si nouvelles fiches) ───────────────────────────
|
// ── Watch fiches (re-render si nouvelles fiches) ───────────────────────────
|
||||||
|
|
||||||
watch(() => props.fiches, () => {
|
watch(() => props.fiches, () => {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxtjs/tailwindcss'],
|
modules: ['@nuxtjs/tailwindcss'],
|
||||||
css: ['~/assets/css/main.css'],
|
css: [
|
||||||
|
'~/assets/css/main.css',
|
||||||
|
'leaflet/dist/leaflet.css',
|
||||||
|
'leaflet.markercluster/dist/MarkerCluster.css',
|
||||||
|
'leaflet.markercluster/dist/MarkerCluster.Default.css',
|
||||||
|
],
|
||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
nocodbUrl: process.env.NOCODB_URL,
|
nocodbUrl: process.env.NOCODB_URL,
|
||||||
@@ -17,16 +22,17 @@ export default defineNuxtConfig({
|
|||||||
codevTableId: '', // NUXT_CODEV_TABLE_ID
|
codevTableId: '', // NUXT_CODEV_TABLE_ID
|
||||||
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
|
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
|
||||||
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
|
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
|
||||||
|
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
|
||||||
},
|
},
|
||||||
|
|
||||||
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
||||||
ssr: true,
|
ssr: true,
|
||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
|
cacheDir: 'C:/Users/jules/AppData/Local/nav-carte-vite-cache',
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['leaflet', 'leaflet.markercluster'],
|
include: ['leaflet', 'leaflet.markercluster', 'd3'],
|
||||||
},
|
},
|
||||||
// Éviter l'import SSR de Leaflet qui utilise window
|
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: [],
|
noExternal: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,39 +1,517 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
|
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||||
<div class="text-center max-w-md px-6">
|
|
||||||
|
<!-- ═══════════════════════════════════════ 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
|
<div
|
||||||
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
|
v-for="structure in filtered"
|
||||||
style="background: var(--nav-bg-alt);"
|
: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"
|
||||||
>
|
>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
|
<div class="flex items-start justify-between gap-1.5">
|
||||||
<rect x="3" y="3" width="7" height="7"/>
|
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
|
||||||
<rect x="14" y="3" width="7" height="7"/>
|
<span
|
||||||
<rect x="14" y="14" width="7" height="7"/>
|
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
|
||||||
<rect x="3" y="14" width="7" height="7"/>
|
:style="`background: ${familleColor(structure.famille_principale)};`"
|
||||||
</svg>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">Agences Inspirantes</h1>
|
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
|
||||||
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
|
<div v-if="structure.hashtags.length" class="mt-1 flex flex-wrap gap-1">
|
||||||
Cette section répertoriera les agences d'architecture qui incarnent une pratique engagée — écologie politique, auto-construction, architectures vernaculaires, sobriété.
|
<span
|
||||||
</p>
|
v-for="tag in structure.hashtags.slice(0, 2)"
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
|
:key="tag"
|
||||||
Bientôt disponible
|
class="text-xs"
|
||||||
</p>
|
style="color: var(--nav-text-muted);"
|
||||||
<NuxtLink
|
>{{ tag }}</span>
|
||||||
to="/"
|
</div>
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
|
</div>
|
||||||
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
||||||
|
<main class="flex-1 flex flex-col overflow-hidden relative">
|
||||||
|
|
||||||
|
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
|
||||||
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg: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>
|
||||||
|
<NavMapV2
|
||||||
|
ref="navMapRef"
|
||||||
|
:structures="metropoleStructures"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-structure="onSelectStructure"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="w-full h-full flex items-center justify-center"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
Chargement de la carte…
|
||||||
<line x1="19" y1="12" x2="5" y2="12"/>
|
|
||||||
<polyline points="12 19 5 12 12 5"/>
|
|
||||||
</svg>
|
|
||||||
Retour à l'écosystème
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
<ChatbotPlaceholder
|
||||||
|
@highlightOrgs="() => {}"
|
||||||
|
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carte Outre-mer desktop -->
|
||||||
|
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
|
||||||
|
<ClientOnly>
|
||||||
|
<OutremerMap
|
||||||
|
: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);">
|
||||||
|
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 + 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"
|
||||||
|
:style="mobileMapView === 'metropole'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="mobileMapView = 'metropole'"
|
||||||
|
>Métropolitain</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="mobileMapView === 'outremer'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="mobileMapView = 'outremer'"
|
||||||
|
>Outre-mer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||||
|
<!-- Carte mobile Métropole -->
|
||||||
|
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
|
||||||
|
<ClientOnly>
|
||||||
|
<NavMapV2
|
||||||
|
ref="navMapMobileRef"
|
||||||
|
:structures="metropoleStructures"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@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);">
|
||||||
|
Chargement de la carte…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carte mobile Outre-mer -->
|
||||||
|
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||||
|
<ClientOnly>
|
||||||
|
<OutremerMap
|
||||||
|
:orgs="outremerOrgsLegacy"
|
||||||
|
:selectedId="selectedIdLegacyNum"
|
||||||
|
@select-org="() => {}"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom sheet swipable -->
|
||||||
|
<ClientOnly>
|
||||||
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||||
|
<!-- 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 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="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher…"
|
||||||
|
class="mobile-search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="search"
|
||||||
|
type="button"
|
||||||
|
class="mobile-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>
|
||||||
|
<button
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
@click="resetFilters"
|
||||||
|
class="mt-1 text-xs"
|
||||||
|
style="color: var(--nav-text-muted); text-decoration: underline;"
|
||||||
|
>Effacer les filtres</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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 }} 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…
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
||||||
|
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
||||||
|
Effacer les filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="structure in filtered"
|
||||||
|
:key="structure.id"
|
||||||
|
class="block rounded-lg p-3 transition-all cursor-pointer"
|
||||||
|
: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="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);">{{ 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileSheet>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ MODAL FICHE V2 (desktop) -->
|
||||||
|
<FicheModalV2
|
||||||
|
v-model="ficheModalOpen"
|
||||||
|
:structureId="ficheModalId"
|
||||||
|
:data="bifurcationData"
|
||||||
|
@update:structureId="ficheModalId = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||||
|
<button
|
||||||
|
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||||
|
style="
|
||||||
|
height: 48px;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
opacity: 0.92;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
"
|
||||||
|
aria-label="Ouvrir l'assistant Chatbot"
|
||||||
|
@click="chatbotOpen = true"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Chatbot</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
|
||||||
|
<ChatbotSheet
|
||||||
|
:modelValue="chatbotOpen"
|
||||||
|
@update:modelValue="chatbotOpen = $event"
|
||||||
|
@highlightOrgs="() => {}"
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
useHead({ title: 'Agences Inspirantes — AEP (bientôt disponible)' })
|
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
|
||||||
|
|
||||||
|
// ── Couleurs familles ──────────────────────────────────────────────────────
|
||||||
|
const FAMILLE_COLORS: Record<number, string> = {
|
||||||
|
1: '#a85d3e',
|
||||||
|
2: '#c4a472',
|
||||||
|
3: '#d4a017',
|
||||||
|
4: '#5a7a4a',
|
||||||
|
5: '#3d6a8c',
|
||||||
|
6: '#6b3fa0',
|
||||||
|
}
|
||||||
|
|
||||||
|
function familleColor(f: number): string {
|
||||||
|
return FAMILLE_COLORS[f] ?? '#888'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── É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)
|
||||||
|
|
||||||
|
// ── 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 = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// ── Sélection ─────────────────────────────────────────────────────────────
|
||||||
|
function onSelectStructure(id: string) {
|
||||||
|
selectedId.value = selectedId.value === id ? null : id
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||||
|
ficheModalId.value = id
|
||||||
|
ficheModalOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectStructureMobile(id: string) {
|
||||||
|
selectedId.value = id
|
||||||
|
ficheModalId.value = id
|
||||||
|
ficheModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,13 +9,31 @@
|
|||||||
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
|
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
|
<NuxtLink to="/codev/qr" class="qr-link" title="QR Code">[ QR ]</NuxtLink>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="codev-tabs">
|
||||||
|
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
|
||||||
|
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tab === 'carto'">
|
||||||
|
<div class="show-labels-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ active: showLabels }"
|
||||||
|
@click="showLabels = !showLabels"
|
||||||
|
>
|
||||||
|
{{ showLabels ? 'Masquer besoins/offres' : 'Montrer besoins/offres' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<CodevGraph
|
<CodevGraph
|
||||||
:fiches="fiches"
|
:fiches="fiches"
|
||||||
:matches="matches"
|
:matches="matches"
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
|
:show-labels="showLabels"
|
||||||
@select-fiche="onSelectFiche"
|
@select-fiche="onSelectFiche"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
@@ -41,7 +59,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Solution
|
Solution
|
||||||
<span class="hint">besoin - offre</span>
|
<span class="hint">besoin - compétence</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="{ active: mode === 'alliance' }"
|
:class="{ active: mode === 'alliance' }"
|
||||||
@@ -50,16 +68,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Alliance
|
Alliance
|
||||||
<span class="hint">besoins partages</span>
|
<span class="hint">besoins partagés</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>
|
||||||
<button
|
<button
|
||||||
v-if="mode !== 'none'"
|
v-if="mode !== 'none'"
|
||||||
@@ -70,6 +79,66 @@
|
|||||||
Effacer
|
Effacer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
|
||||||
|
|
||||||
|
<div v-if="fiches.length === 0" class="list-empty">
|
||||||
|
Aucune fiche. <NuxtLink to="/codev/fiche">Ajouter la mienne</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="annuaire-scroll">
|
||||||
|
<table class="annuaire-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-nom">Prénom</th>
|
||||||
|
<th class="col-besoin">Besoin</th>
|
||||||
|
<th class="col-offre">Ce que j'offre</th>
|
||||||
|
<th v-if="isAdmin" class="col-actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="f in fiches" :key="f.id" @click="navigateTo(`/codev/fiche?id=${f.id}`)" class="annuaire-row">
|
||||||
|
<td class="col-nom">{{ f.nom }}</td>
|
||||||
|
<td class="col-besoin">{{ f.besoin }}</td>
|
||||||
|
<td class="col-offre">{{ f.offre }}</td>
|
||||||
|
<td v-if="isAdmin" class="col-actions">
|
||||||
|
<button @click.stop="deleteFiche(f.id)" class="delete-btn" type="button" title="Supprimer">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="annuaire-hint">Clique sur une ligne pour modifier la fiche</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FAB ajouter une fiche -->
|
||||||
|
<NuxtLink to="/codev/fiche" class="fab-add" title="Ajouter ma fiche" aria-label="Ajouter une fiche">
|
||||||
|
+
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<Transition name="sheet">
|
||||||
|
<div v-if="selectedFiche" class="bottom-sheet" @click.self="selectedFiche = null">
|
||||||
|
<div class="sheet-content">
|
||||||
|
<div class="sheet-handle"></div>
|
||||||
|
<div class="sheet-name">{{ selectedFiche.nom }}</div>
|
||||||
|
<div class="sheet-section">
|
||||||
|
<span class="sheet-label">Besoin</span>
|
||||||
|
<p class="sheet-text">{{ selectedFiche.besoin }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-section">
|
||||||
|
<span class="sheet-label">Ce que j'apporte</span>
|
||||||
|
<p class="sheet-text">{{ selectedFiche.offre }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-tags" v-if="selectedFiche.hashtags.length">
|
||||||
|
<span v-for="t in selectedFiche.hashtags" :key="t" class="sheet-tag">#{{ t }}</span>
|
||||||
|
</div>
|
||||||
|
<NuxtLink :to="`/codev/fiche?id=${selectedFiche.id}`" class="sheet-edit-btn">Modifier cette fiche</NuxtLink>
|
||||||
|
<button class="sheet-close" @click="selectedFiche = null" type="button">Fermer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -80,11 +149,24 @@ import { computeMatches } from '~/utils/codev/matching'
|
|||||||
|
|
||||||
useHead({ title: 'Carto - Co-developpement' })
|
useHead({ title: 'Carto - Co-developpement' })
|
||||||
|
|
||||||
const { data, pending } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
|
const { data, pending, refresh } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
|
||||||
const fiches = computed(() => data.value?.list ?? [])
|
const fiches = computed(() => data.value?.list ?? [])
|
||||||
|
|
||||||
const matches = ref<CodevMatch[]>([])
|
const matches = ref<CodevMatch[]>([])
|
||||||
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
|
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
|
||||||
|
const showLabels = ref(false)
|
||||||
|
const tab = ref<'carto' | 'annuaire'>('carto')
|
||||||
|
const selectedFiche = ref<CodevFiche | null>(null)
|
||||||
|
const isMobileView = typeof window !== 'undefined' ? window.innerWidth < 600 : false
|
||||||
|
|
||||||
|
const isAdmin = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const r = await $fetch<{ admin: boolean }>('/api/codev/me')
|
||||||
|
isAdmin.value = r.admin
|
||||||
|
} catch { isAdmin.value = false }
|
||||||
|
})
|
||||||
|
|
||||||
const MODE_LABELS: Record<string, string> = {
|
const MODE_LABELS: Record<string, string> = {
|
||||||
solution: 'Solution',
|
solution: 'Solution',
|
||||||
@@ -102,8 +184,18 @@ function setMode(newMode: 'none' | 'solution' | 'alliance' | 'surprise') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSelectFiche(id: number) {
|
function onSelectFiche(id: number) {
|
||||||
|
if (isMobileView) {
|
||||||
|
selectedFiche.value = fiches.value.find(f => f.id === id) ?? null
|
||||||
|
} else {
|
||||||
navigateTo(`/codev/fiche?id=${id}`)
|
navigateTo(`/codev/fiche?id=${id}`)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFiche(id: number) {
|
||||||
|
if (!confirm('Supprimer la fiche ?')) return
|
||||||
|
await $fetch(`/api/codev/fiches/${id}`, { method: 'DELETE' })
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -256,6 +348,194 @@ function onSelectFiche(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Toggle besoins/offres ── */
|
||||||
|
|
||||||
|
.show-labels-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-labels-bar button {
|
||||||
|
border: 1px solid #d0d4dc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: white;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #374151;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-labels-bar button.active {
|
||||||
|
background: #1B4436;
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FAB ajouter ── */
|
||||||
|
|
||||||
|
.fab-add {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 16px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1B4436;
|
||||||
|
color: white;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 300;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
||||||
|
z-index: 100;
|
||||||
|
transition: transform 0.15s, opacity 0.15s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-add:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tabs ── */
|
||||||
|
|
||||||
|
.codev-tabs { display: flex; gap: 4px; background: #f3f4f6; border-radius: 10px; padding: 4px; }
|
||||||
|
.codev-tabs button { flex: 1; padding: 8px 4px; border: none; border-radius: 7px; background: transparent; font-size: 0.875rem; font-weight: 500; cursor: pointer; color: #6b7280; transition: all 0.15s; }
|
||||||
|
.codev-tabs button.active { background: white; color: #1a1a2e; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||||
|
|
||||||
|
/* ── List view ── */
|
||||||
|
|
||||||
|
.list-view { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
|
||||||
|
.list-card { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.list-card-name { font-weight: 700; font-size: 0.95rem; color: #1a1a2e; }
|
||||||
|
.list-card-text { font-size: 0.875rem; color: #4b5563; margin: 0; line-height: 1.5; }
|
||||||
|
.list-card-link { font-size: 0.8rem; color: #1B4436; text-decoration: none; align-self: flex-end; }
|
||||||
|
.list-empty { text-align: center; color: #6b7280; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* ── Bottom sheet ── */
|
||||||
|
|
||||||
|
.bottom-sheet { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 200; display: flex; align-items: flex-end; }
|
||||||
|
.sheet-content { background: white; border-radius: 16px 16px 0 0; padding: 16px 20px 32px; width: 100%; display: flex; flex-direction: column; gap: 12px; max-height: 80vh; overflow-y: auto; }
|
||||||
|
.sheet-handle { width: 36px; height: 4px; background: #d1d5db; border-radius: 2px; align-self: center; margin-bottom: 4px; }
|
||||||
|
.sheet-name { font-size: 1.1rem; font-weight: 700; color: #1a1a2e; }
|
||||||
|
.sheet-section { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.sheet-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
|
||||||
|
.sheet-text { font-size: 0.9rem; color: #374151; margin: 0; line-height: 1.5; }
|
||||||
|
.sheet-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.sheet-tag { font-size: 0.75rem; background: #f3f4f6; color: #374151; padding: 2px 8px; border-radius: 12px; }
|
||||||
|
.sheet-edit-btn { display: block; text-align: center; background: #1B4436; color: white; border-radius: 8px; padding: 12px; text-decoration: none; font-weight: 600; }
|
||||||
|
.sheet-close { background: transparent; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px; color: #6b7280; cursor: pointer; font-size: 0.875rem; }
|
||||||
|
.sheet-enter-active, .sheet-leave-active { transition: opacity 0.2s; }
|
||||||
|
.sheet-enter-from, .sheet-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
/* ── QR link ── */
|
||||||
|
|
||||||
|
.qr-link {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-decoration: none;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
.qr-link:hover { color: #6b7280; }
|
||||||
|
|
||||||
|
/* ── Annuaire ── */
|
||||||
|
|
||||||
|
.annuaire-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-table thead tr {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-table th {
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #6b7280;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-table td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-row:hover { background: #f9fafb; }
|
||||||
|
.annuaire-row:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
.col-nom {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 80px;
|
||||||
|
border-right: 2px solid #e5e7eb;
|
||||||
|
box-shadow: 2px 0 6px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-row:hover .col-nom { background: #f9fafb; }
|
||||||
|
thead tr .col-nom { background: #f9fafb; z-index: 3; }
|
||||||
|
|
||||||
|
.col-besoin { min-width: 200px; max-width: 260px; }
|
||||||
|
.col-offre { min-width: 200px; max-width: 260px; }
|
||||||
|
|
||||||
|
.annuaire-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-actions { width: 40px; text-align: center; }
|
||||||
|
.delete-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.delete-btn:hover { background: #fef2f2; }
|
||||||
|
|
||||||
/* ── Mobile ── */
|
/* ── Mobile ── */
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
<p class="subtitle">10 personnes fictives. Clique sur un mode pour voir les matchs.</p>
|
<p class="subtitle">10 personnes fictives. Clique sur un mode pour voir les matchs.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="codev-tabs">
|
||||||
|
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
|
||||||
|
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tab === 'carto'">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<CodevGraph
|
<CodevGraph
|
||||||
:fiches="fiches"
|
:fiches="fiches"
|
||||||
@@ -27,7 +33,7 @@
|
|||||||
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 3 boutons matching identiques a carto.vue -->
|
<!-- Boutons matching -->
|
||||||
<div class="matching-controls">
|
<div class="matching-controls">
|
||||||
<button
|
<button
|
||||||
:class="{ active: mode === 'solution' }"
|
:class="{ active: mode === 'solution' }"
|
||||||
@@ -47,15 +53,6 @@
|
|||||||
Alliance
|
Alliance
|
||||||
<span class="hint">besoins partages</span>
|
<span class="hint">besoins partages</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
:class="{ active: mode === 'surprise' }"
|
|
||||||
style="--mode-color: #3b82f6"
|
|
||||||
@click="setMode('surprise')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Surprise
|
|
||||||
<span class="hint">offres partagees</span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
v-if="mode !== 'none'"
|
v-if="mode !== 'none'"
|
||||||
class="reset"
|
class="reset"
|
||||||
@@ -65,6 +62,28 @@
|
|||||||
Effacer
|
Effacer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
|
||||||
|
<div class="annuaire-scroll">
|
||||||
|
<table class="annuaire-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-nom">Prénom</th>
|
||||||
|
<th class="col-besoin">Besoin</th>
|
||||||
|
<th class="col-offre">Ce que j'offre</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="f in fiches" :key="f.id" class="annuaire-row">
|
||||||
|
<td class="col-nom">{{ f.nom }}</td>
|
||||||
|
<td class="col-besoin">{{ f.besoin }}</td>
|
||||||
|
<td class="col-offre">{{ f.offre }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -73,98 +92,95 @@
|
|||||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
||||||
import { computeMatches } from '~/utils/codev/matching'
|
import { computeMatches } from '~/utils/codev/matching'
|
||||||
|
|
||||||
// 10 fiches factices - hashtags alignes pour demontrer les 3 modes :
|
const tab = ref<'carto' | 'annuaire'>('carto')
|
||||||
|
|
||||||
|
// 10 fiches sans hashtags — textes enrichis pour que scoreDirect discrimine bien les 3 modes :
|
||||||
//
|
//
|
||||||
// Solution : Lea(besoin coaching) -> Maya(offre coaching)
|
// Solution (scoreDirect besoinA vs offreB) :
|
||||||
// Sami(besoin formation+vente) -> Ines(offre vente+formation)
|
// Sami(besoin vendre formation) -> Ines(offre vente formations) ✓
|
||||||
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation+tiers-lieu)
|
// Nael(besoin site web formation) -> Sami(offre developpement web) ✓
|
||||||
|
// Eva(besoin coaching vente) -> Ines(offre vente formations) ✓
|
||||||
|
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation tiers-lieux) ✓
|
||||||
//
|
//
|
||||||
// Alliance : Lea + Maya (hashtag coaching commun dans besoins)
|
// Alliance (besoins similaires) :
|
||||||
// Sami + Kenji (hashtag formation+vente dans besoins)
|
// Lea + Maya (coaching, lancer, offre) ✓
|
||||||
// Tom + Zoe (hashtag tiers-lieu dans besoins)
|
// Tom + Zoe (tiers-lieu, co-creer) ✓
|
||||||
|
// Sami + Kenji (vendre, formations) ✓
|
||||||
//
|
//
|
||||||
// Surprise : Lea + Zoe (hashtag facilitation dans offres)
|
// Surprise (offres similaires) :
|
||||||
// Tom + Roman (hashtag archi dans offres)
|
// Lea + Zoe (facilitation, groupes) ✓
|
||||||
|
// Tom + Roman (architecture) ✓
|
||||||
|
// Ines + Nael (marketing, formations) ✓
|
||||||
|
|
||||||
const FICHES_DEMO: CodevFiche[] = [
|
const FICHES_DEMO: CodevFiche[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1, nom: 'Lea',
|
||||||
nom: 'Lea',
|
besoin: 'Structurer et lancer mon offre de coaching professionnel cet automne',
|
||||||
besoin: 'Structurer mon offre de coaching pour la lancer en septembre',
|
offre: 'Facilitation de groupes et animation de cercles de parole',
|
||||||
offre: 'Animation de groupes, facilitation de cercles de parole',
|
hashtags: [],
|
||||||
hashtags: ['coaching', 'facilitation'],
|
|
||||||
created_at: '2026-05-08T10:00:00Z',
|
created_at: '2026-05-08T10:00:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2, nom: 'Sami',
|
||||||
nom: 'Sami',
|
besoin: 'Vendre ma formation en ligne et attirer mes premiers clients',
|
||||||
besoin: 'Comprendre comment vendre une formation en ligne',
|
offre: 'Developpement web sur mesure, creation de sites et applications',
|
||||||
offre: 'Developpement web, sites Astro et Nuxt',
|
hashtags: [],
|
||||||
hashtags: ['formation', 'vente'],
|
|
||||||
created_at: '2026-05-08T10:01:00Z',
|
created_at: '2026-05-08T10:01:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3, nom: 'Ines',
|
||||||
nom: 'Ines',
|
besoin: 'Ameliorer la facilitation de mes ateliers collaboratifs',
|
||||||
besoin: 'Aide pour la facilitation de mes ateliers ecriture',
|
offre: 'Vente de formations en ligne et marketing pour formateurs',
|
||||||
offre: 'Vente de formations en ligne, marketing direct',
|
hashtags: [],
|
||||||
hashtags: ['vente', 'formation'],
|
|
||||||
created_at: '2026-05-08T10:02:00Z',
|
created_at: '2026-05-08T10:02:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4, nom: 'Tom',
|
||||||
nom: 'Tom',
|
besoin: 'Trouver des associes pour co-creer un tiers-lieu rural',
|
||||||
besoin: 'Trouver un associe pour un projet de tiers-lieu',
|
offre: 'Architecture bioclimatique et eco-construction pour tiers-lieux',
|
||||||
offre: 'Architecture eco-responsable, conception bioclimatique',
|
hashtags: [],
|
||||||
hashtags: ['tiers-lieu', 'archi'],
|
|
||||||
created_at: '2026-05-08T10:03:00Z',
|
created_at: '2026-05-08T10:03:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5, nom: 'Maya',
|
||||||
nom: 'Maya',
|
besoin: 'Creer et lancer mon offre de coaching en transition professionnelle',
|
||||||
besoin: 'Structurer mon offre de coaching freelance',
|
offre: 'Accompagnement coaching de carriere et transitions professionnelles',
|
||||||
offre: 'Coaching de carriere, accompagnement transition pro',
|
hashtags: [],
|
||||||
hashtags: ['coaching', 'carriere'],
|
|
||||||
created_at: '2026-05-08T10:04:00Z',
|
created_at: '2026-05-08T10:04:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6, nom: 'Kenji',
|
||||||
nom: 'Kenji',
|
besoin: 'Apprendre a vendre mes formations sans pression commerciale',
|
||||||
besoin: 'Apprendre a vendre mes formations sans me sentir vendeur',
|
offre: 'Photographie professionnelle et direction artistique editoriale',
|
||||||
offre: 'Photographie, direction artistique de projets editoriaux',
|
hashtags: [],
|
||||||
hashtags: ['formation', 'vente'],
|
|
||||||
created_at: '2026-05-08T10:05:00Z',
|
created_at: '2026-05-08T10:05:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7, nom: 'Zoe',
|
||||||
nom: 'Zoe',
|
besoin: 'Co-creer un tiers-lieu avec des porteurs de projet alignes',
|
||||||
besoin: 'Trouver des associes pour mon projet de tiers-lieu rural',
|
offre: 'Facilitation de collectifs et animation en intelligence collective',
|
||||||
offre: 'Animation et facilitation de collectifs, intelligence collective',
|
hashtags: [],
|
||||||
hashtags: ['tiers-lieu', 'facilitation'],
|
|
||||||
created_at: '2026-05-08T10:06:00Z',
|
created_at: '2026-05-08T10:06:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 8,
|
id: 8, nom: 'Nael',
|
||||||
nom: 'Nael',
|
besoin: 'Creer un site web pour presenter et vendre ma formation',
|
||||||
besoin: 'Construire un site web pour ma formation',
|
offre: 'Strategie marketing digital et lancement de produits en ligne',
|
||||||
offre: 'Strategie marketing, lancement de produits digitaux',
|
hashtags: [],
|
||||||
hashtags: ['web', 'strategie'],
|
|
||||||
created_at: '2026-05-08T10:07:00Z',
|
created_at: '2026-05-08T10:07:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 9,
|
id: 9, nom: 'Eva',
|
||||||
nom: 'Eva',
|
besoin: 'Lancer mon coaching avec une page de vente qui convertit',
|
||||||
besoin: 'Lancer mon offre de coaching avec une page de vente',
|
offre: 'Ecriture longue forme, articles de fond et tribunes editoriales',
|
||||||
offre: 'Ecriture longue forme, articles essais et tribunes',
|
hashtags: [],
|
||||||
hashtags: ['coaching', 'ecriture'],
|
|
||||||
created_at: '2026-05-08T10:08:00Z',
|
created_at: '2026-05-08T10:08:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 10,
|
id: 10, nom: 'Roman',
|
||||||
nom: 'Roman',
|
besoin: 'Ecrire de meilleurs articles pour mon blog et ma newsletter',
|
||||||
besoin: 'Ameliorer mes articles de blog sur la renovation',
|
offre: 'Architecture technique et plans pour renovation energetique',
|
||||||
offre: 'Architecture, plans techniques pour renovation energetique',
|
hashtags: [],
|
||||||
hashtags: ['archi', 'reno'],
|
|
||||||
created_at: '2026-05-08T10:09:00Z',
|
created_at: '2026-05-08T10:09:00Z',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -186,7 +202,7 @@ function setMode(newMode: typeof mode.value) {
|
|||||||
if (newMode === 'none') {
|
if (newMode === 'none') {
|
||||||
matches.value = []
|
matches.value = []
|
||||||
} else {
|
} else {
|
||||||
matches.value = computeMatches(fiches.value, newMode)
|
matches.value = computeMatches(fiches.value, newMode, 0.12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
<!-- En-tête -->
|
<!-- En-tête -->
|
||||||
<div class="fiche-header">
|
<div class="fiche-header">
|
||||||
<h1>Ma fiche</h1>
|
<NuxtLink to="/codev/carto" class="back-link">← Retour à la carte</NuxtLink>
|
||||||
|
<h1>{{ isEdit ? 'Modifier ma fiche' : 'Ma fiche' }}</h1>
|
||||||
<p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p>
|
<p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,9 +106,13 @@
|
|||||||
|
|
||||||
<!-- Bouton -->
|
<!-- Bouton -->
|
||||||
<button type="submit" class="submit-btn" :disabled="loading">
|
<button type="submit" class="submit-btn" :disabled="loading">
|
||||||
{{ loading ? 'Envoi en cours...' : 'Ajouter ma fiche' }}
|
{{ isEdit ? (loading ? 'Modification...' : 'Enregistrer les modifications') : (loading ? 'Envoi en cours...' : 'Ajouter ma fiche') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<NuxtLink to="/codev/carto" class="skip-link">
|
||||||
|
Voir la carte sans créer de fiche →
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -115,12 +120,29 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const editId = computed(() => route.query.id ? Number(route.query.id) : null)
|
||||||
|
const isEdit = computed(() => editId.value !== null)
|
||||||
|
|
||||||
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
|
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const activeTip = ref<'besoin' | 'offre' | null>(null)
|
const activeTip = ref<'besoin' | 'offre' | null>(null)
|
||||||
|
|
||||||
useHead({ title: 'Ma fiche — Co-développement' })
|
useHead({ title: computed(() => isEdit.value ? 'Modifier ma fiche — Co-développement' : 'Ma fiche — Co-développement') })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!isEdit.value) return
|
||||||
|
try {
|
||||||
|
const fiche = await $fetch<any>(`/api/codev/fiches/${editId.value}`)
|
||||||
|
form.value.nom = fiche.nom
|
||||||
|
form.value.besoin = fiche.besoin
|
||||||
|
form.value.offre = fiche.offre
|
||||||
|
form.value.hashtagsRaw = fiche.hashtags.join(', ')
|
||||||
|
} catch {
|
||||||
|
error.value = 'Impossible de charger la fiche, elle a peut-etre ete supprimee.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function toggleTip(field: 'besoin' | 'offre') {
|
function toggleTip(field: 'besoin' | 'offre') {
|
||||||
activeTip.value = activeTip.value === field ? null : field
|
activeTip.value = activeTip.value === field ? null : field
|
||||||
@@ -136,15 +158,18 @@ async function submit() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
|
|
||||||
await $fetch('/api/codev/fiches', {
|
const payload = {
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
nom: form.value.nom,
|
nom: form.value.nom,
|
||||||
besoin: form.value.besoin,
|
besoin: form.value.besoin,
|
||||||
offre: form.value.offre,
|
offre: form.value.offre,
|
||||||
hashtags,
|
hashtags,
|
||||||
},
|
}
|
||||||
})
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
await $fetch(`/api/codev/fiches/${editId.value}`, { method: 'PATCH', body: payload })
|
||||||
|
} else {
|
||||||
|
await $fetch('/api/codev/fiches', { method: 'POST', body: payload })
|
||||||
|
}
|
||||||
await navigateTo('/codev/carto')
|
await navigateTo('/codev/carto')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie'
|
error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie'
|
||||||
@@ -173,6 +198,18 @@ async function submit() {
|
|||||||
|
|
||||||
/* ── En-tête ── */
|
/* ── En-tête ── */
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--nav-primary-solid, #1B4436);
|
||||||
|
}
|
||||||
|
|
||||||
.fiche-header h1 {
|
.fiche-header h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -357,6 +394,17 @@ async function submit() {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
color: var(--nav-text-muted, #9ca3af);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.skip-link:hover { color: var(--nav-text, #1a1a2e); }
|
||||||
|
|
||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
|||||||
94
pages/codev/qr.vue
Normal file
94
pages/codev/qr.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div class="qr-page">
|
||||||
|
<div class="qr-card">
|
||||||
|
<h1>Co-développement</h1>
|
||||||
|
<p class="qr-subtitle">Scanne pour rejoindre la session</p>
|
||||||
|
|
||||||
|
<img
|
||||||
|
:src="`https://api.qrserver.com/v1/create-qr-code/?size=280x280&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
|
||||||
|
alt="QR code aep.trans-former.fr/codev"
|
||||||
|
class="qr-img"
|
||||||
|
width="280"
|
||||||
|
height="280"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="qr-url">{{ APP_URL }}</p>
|
||||||
|
<p class="qr-password">Mot de passe : <strong>merci</strong></p>
|
||||||
|
|
||||||
|
<a :href="`https://api.qrserver.com/v1/create-qr-code/?size=600x600&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
|
||||||
|
download="codev-qr.png"
|
||||||
|
class="qr-download"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Télécharger le QR code
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const APP_URL = 'https://aep.trans-former.fr/codev'
|
||||||
|
useHead({ title: 'QR Code — Co-développement' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qr-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg, #fafafa);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.qr-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 360px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.qr-card h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.qr-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.qr-img {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.qr-url {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.qr-password {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #374151;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.qr-download {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #1B4436;
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.qr-download:hover { opacity: 0.88; }
|
||||||
|
</style>
|
||||||
672
pages/index.vue
672
pages/index.vue
@@ -1,111 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (>= 1024px) -->
|
<!-- ═══════════════════════════════════════ 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%;">
|
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
|
||||||
|
<NavSidebar
|
||||||
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
|
:search="search"
|
||||||
<IntentionBanner />
|
:modeValue="territoireMode"
|
||||||
|
:echelle="echelle"
|
||||||
<!-- Filtres familles + hashtags -->
|
:fonctions="fonctions"
|
||||||
<HashtagFilter
|
:territoire="territoire"
|
||||||
:allHashtags="allHashtags"
|
:echelleCount="echelleCount"
|
||||||
:selectedHashtags="selectedHashtags"
|
:fonctionCount="fonctionCount"
|
||||||
:selectedFamille="selectedFamille"
|
:territoireCount="territoireCount"
|
||||||
@update:selectedHashtags="selectedHashtags = $event"
|
:resultCount="filtered.length"
|
||||||
@update:selectedFamille="selectedFamille = $event"
|
: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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
||||||
<main class="flex-1 flex flex-col overflow-hidden relative">
|
<main class="flex-1 flex flex-col overflow-hidden relative">
|
||||||
|
|
||||||
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
|
<!-- 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 : Onglets Métropole / Outre-mer ── -->
|
||||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||||
<!-- Onglets desktop -->
|
<!-- Barre onglets desktop -->
|
||||||
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
<button
|
<button
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
@@ -121,82 +58,47 @@
|
|||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="desktopMapView = 'outremer'"
|
@click="desktopMapView = 'outremer'"
|
||||||
>Outre-mer</button>
|
>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>
|
</div>
|
||||||
|
|
||||||
<!-- Carte Métropole desktop -->
|
<!-- Carte Métropole desktop -->
|
||||||
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
||||||
<div class="relative flex-1" style="min-height: 200px;">
|
<div class="relative flex-1" style="min-height: 200px;">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<NavMapV2
|
<NavMap
|
||||||
ref="navMapRef"
|
ref="navMapRef"
|
||||||
:structures="metropoleStructures"
|
:orgs="metropoleOrgs"
|
||||||
:selectedId="selectedId"
|
:selectedId="selectedId"
|
||||||
@select-structure="onSelectStructure"
|
@select-org="onSelectOrg"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div
|
<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>
|
||||||
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>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
<ChatbotPlaceholder
|
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||||
@highlightOrgs="() => {}"
|
|
||||||
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Carte Outre-mer desktop -->
|
<!-- Carte Outre-mer desktop -->
|
||||||
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
|
<div v-show="desktopMapView === 'outremer'" class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<OutremerMap
|
<OutremerMap
|
||||||
:orgs="outremerOrgsLegacy"
|
:orgs="outremerOrgs"
|
||||||
:selectedId="selectedIdLegacyNum"
|
:selectedId="selectedId"
|
||||||
@select-org="() => {}"
|
@select-org="onSelectOrg"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<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>
|
||||||
Chargement…
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||||
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + sheet swipable ── -->
|
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
||||||
|
|
||||||
|
<!-- Onglets Métropolitain / Outre-mer -->
|
||||||
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
@@ -215,30 +117,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||||
<!-- Carte mobile Métropole -->
|
|
||||||
|
<!-- Carte Métropole -->
|
||||||
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
|
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<NavMapV2
|
<NavMap
|
||||||
ref="navMapMobileRef"
|
ref="navMapMobileRef"
|
||||||
:structures="metropoleStructures"
|
:orgs="metropoleOrgs"
|
||||||
:selectedId="selectedId"
|
:selectedId="selectedId"
|
||||||
@select-structure="onSelectStructureMobile"
|
@select-org="onSelectOrgMobile"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<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…
|
Chargement de la carte…
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Carte mobile Outre-mer -->
|
<!-- Carte Outre-mer (scroll vertical, pleine largeur) -->
|
||||||
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<OutremerMap
|
<OutremerMap
|
||||||
:orgs="outremerOrgsLegacy"
|
:orgs="outremerOrgs"
|
||||||
:selectedId="selectedIdLegacyNum"
|
:selectedId="selectedId"
|
||||||
@select-org="() => {}"
|
@select-org="onSelectOrgMobile"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||||
@@ -248,65 +154,81 @@
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom sheet swipable -->
|
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||||
<!-- Bandeau intention mobile -->
|
<!-- Barre recherche -->
|
||||||
<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);">
|
<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 structure">
|
<label class="mobile-search-label" aria-label="Rechercher une organisation">
|
||||||
<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;">
|
<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"/>
|
<circle cx="11" cy="11" r="8"/>
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
v-model="search"
|
v-model="mobileSearch"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Rechercher…"
|
placeholder="Rechercher…"
|
||||||
class="mobile-search-input"
|
class="mobile-search-input"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
@input="onSearch(mobileSearch)"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="search"
|
v-if="mobileSearch"
|
||||||
type="button"
|
type="button"
|
||||||
class="mobile-search-clear"
|
class="mobile-search-clear"
|
||||||
aria-label="Effacer"
|
aria-label="Effacer"
|
||||||
@click.stop="search = ''"
|
@click.stop="mobileSearch = ''; onSearch('')"
|
||||||
>
|
>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
<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"/>
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</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
|
<button
|
||||||
v-if="hasActiveFilters"
|
v-if="hasActiveFilters"
|
||||||
@click="resetFilters"
|
@click="resetFilters"
|
||||||
class="mt-1 text-xs"
|
class="mt-2 text-xs"
|
||||||
style="color: var(--nav-text-muted); text-decoration: underline;"
|
style="color: var(--nav-text-muted); text-decoration: underline;"
|
||||||
>Effacer les filtres</button>
|
>✕ Effacer les filtres</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Liste fiches mobile -->
|
<!-- Compteur + Liste fiches -->
|
||||||
<div class="px-3 py-2">
|
<div class="px-3 py-2">
|
||||||
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
||||||
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
|
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
||||||
Chargement des fiches…
|
Chargement des fiches…
|
||||||
@@ -319,36 +241,46 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div
|
<div
|
||||||
v-for="structure in filtered"
|
v-for="org in filtered"
|
||||||
:key="structure.id"
|
:key="org.Id"
|
||||||
class="block rounded-lg p-3 transition-all cursor-pointer"
|
class="block rounded-lg p-3 transition-all cursor-pointer"
|
||||||
:style="selectedId === structure.id
|
:style="selectedId === org.Id
|
||||||
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};`
|
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
|
||||||
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
||||||
@click="onSelectStructureMobile(structure.id)"
|
@click="onSelectOrgMobile(org.Id)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
|
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
|
||||||
<span
|
<span
|
||||||
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
|
v-if="org.echelle"
|
||||||
:style="`background: ${familleColor(structure.famille_principale)};`"
|
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 }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} · {{ structure.ville }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MobileSheet>
|
</MobileSheet>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ MODAL FICHE V2 (desktop) -->
|
<!-- ═══════════════════════════════════════ MODAL FICHE (desktop) -->
|
||||||
<FicheModalV2
|
<FicheModal
|
||||||
v-model="ficheModalOpen"
|
v-model="ficheModalOpen"
|
||||||
:structureId="ficheModalId"
|
:orgId="ficheModalId"
|
||||||
:data="bifurcationData"
|
|
||||||
@update:structureId="ficheModalId = $event"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||||
@@ -377,141 +309,269 @@
|
|||||||
<ChatbotSheet
|
<ChatbotSheet
|
||||||
:modelValue="chatbotOpen"
|
:modelValue="chatbotOpen"
|
||||||
@update:modelValue="chatbotOpen = $event"
|
@update:modelValue="chatbotOpen = $event"
|
||||||
@highlightOrgs="() => {}"
|
@highlightOrgs="onHighlightOrgs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
|
import type { Org } from '~/types/org'
|
||||||
|
|
||||||
// ── Couleurs familles ──────────────────────────────────────────────────────
|
// ── URL query params sync ─────────────────────────────────────────────────
|
||||||
const FAMILLE_COLORS: Record<number, string> = {
|
const route = useRoute()
|
||||||
1: '#a85d3e',
|
const router = useRouter()
|
||||||
2: '#c4a472',
|
|
||||||
3: '#d4a017',
|
|
||||||
4: '#5a7a4a',
|
|
||||||
5: '#3d6a8c',
|
|
||||||
6: '#6b3fa0',
|
|
||||||
}
|
|
||||||
|
|
||||||
function familleColor(f: number): string {
|
const search = ref<string>((route.query.q as string) ?? '')
|
||||||
return FAMILLE_COLORS[f] ?? '#888'
|
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'
|
||||||
|
)
|
||||||
|
|
||||||
// ── État UI ────────────────────────────────────────────────────────────────
|
const desktopMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<number | null>(null)
|
||||||
const hoveredId = ref<string | null>(null)
|
|
||||||
const ficheModalOpen = ref(false)
|
|
||||||
const ficheModalId = ref<string | null>(null)
|
|
||||||
const chatbotOpen = ref(false)
|
const chatbotOpen = ref(false)
|
||||||
|
const ficheModalOpen = ref(false)
|
||||||
|
const ficheModalId = ref<number | null>(null)
|
||||||
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||||
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('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)
|
||||||
|
|
||||||
// Filtres
|
function onHighlightOrgs(ids: (number | string)[]) {
|
||||||
const search = ref('')
|
if (!ids.length) return
|
||||||
const selectedFamille = ref<number | null>(null)
|
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
|
||||||
const selectedHashtags = ref<string[]>([])
|
if (isNaN(firstId)) return
|
||||||
|
|
||||||
// Refs cartes
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ref locale barre de recherche mobile (synchronisée avec search via onSearch)
|
||||||
|
const mobileSearch = ref<string>((route.query.q as string) ?? '')
|
||||||
|
|
||||||
|
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
|
||||||
const navMapRef = ref<any>(null)
|
const navMapRef = ref<any>(null)
|
||||||
const navMapMobileRef = ref<any>(null)
|
const navMapMobileRef = ref<any>(null)
|
||||||
|
|
||||||
// ── Données V2 - JSON statique ─────────────────────────────────────────────
|
// Sync URL <-> état filtres
|
||||||
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
|
function syncUrl() {
|
||||||
const pending = ref(true)
|
const q: Record<string, string> = {}
|
||||||
|
if (search.value) q.q = search.value
|
||||||
onMounted(async () => {
|
if (echelle.value.length) q.echelle = echelle.value.join(',')
|
||||||
try {
|
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
||||||
bifurcationData.value = await $fetch<ReseauxBifurcationData>('/data/reseaux-bifurcation.json')
|
if (territoire.value) q.territoire = territoire.value
|
||||||
} catch (e) {
|
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
||||||
console.error('Erreur chargement reseaux-bifurcation.json', e)
|
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||||
} 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
|
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
|
||||||
if (selectedFamille.value !== null) {
|
function storeFiltersForBack() {
|
||||||
if (selectedFamille.value === 6) {
|
if (typeof window === 'undefined') return
|
||||||
result = result.filter(s => (s.badges as any)?.f6_recherche_politique === true)
|
const q: Record<string, string> = {}
|
||||||
} else {
|
if (search.value) q.q = search.value
|
||||||
result = result.filter(
|
if (echelle.value.length) q.echelle = echelle.value.join(',')
|
||||||
s => s.famille_principale === selectedFamille.value ||
|
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
||||||
(s.familles_secondaires ?? []).includes(selectedFamille.value!)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtre hashtags (AND logique si plusieurs)
|
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
if (selectedHashtags.value.length) {
|
function onMode(v: string) { territoireMode.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
result = result.filter(
|
function onEchelle(v: string[]) { echelle.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
s => selectedHashtags.value.every(h => s.hashtags.includes(h))
|
function onFonctions(v: string[]) { fonctions.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
)
|
function onTerritoire(v: string | null) { territoire.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
function onSelectOrg(id: number) {
|
||||||
})
|
|
||||||
|
|
||||||
const hasActiveFilters = computed(
|
|
||||||
() => !!search.value || selectedFamille.value !== null || selectedHashtags.value.length > 0
|
|
||||||
)
|
|
||||||
|
|
||||||
function resetFilters() {
|
|
||||||
search.value = ''
|
|
||||||
selectedFamille.value = null
|
|
||||||
selectedHashtags.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
// ── Sélection ─────────────────────────────────────────────────────────────
|
|
||||||
function onSelectStructure(id: string) {
|
|
||||||
selectedId.value = selectedId.value === id ? null : id
|
selectedId.value = selectedId.value === id ? null : id
|
||||||
|
// Desktop : ouvrir le modal fiche
|
||||||
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||||
ficheModalId.value = id
|
ficheModalId.value = id
|
||||||
ficheModalOpen.value = true
|
ficheModalOpen.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectStructureMobile(id: string) {
|
// Tap card mobile → ouvre la fiche détaillée
|
||||||
|
function onSelectOrgMobile(id: number) {
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
ficheModalId.value = id
|
storeFiltersForBack()
|
||||||
ficheModalOpen.value = true
|
router.push(`/fiche/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
|
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' })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -15,10 +15,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const expected = config.codevPassword || 'merci'
|
const expected = config.codevPassword || 'merci'
|
||||||
|
|
||||||
if (parsed.data.password.trim().toLowerCase() !== expected.trim().toLowerCase()) {
|
const isAdmin = parsed.data.password.trim().toLowerCase() === (config.codevAdminPassword || 'admin2026').trim().toLowerCase()
|
||||||
|
const isUser = parsed.data.password.trim().toLowerCase() === expected.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!isAdmin && !isUser) {
|
||||||
throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' })
|
throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cookie session (user + admin)
|
||||||
setCookie(event, 'codev_session', 'ok', {
|
setCookie(event, 'codev_session', 'ok', {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
@@ -27,5 +31,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
path: '/',
|
path: '/',
|
||||||
})
|
})
|
||||||
|
|
||||||
return { status: 200, ok: true }
|
// Cookie admin si mot de passe admin
|
||||||
|
if (isAdmin) {
|
||||||
|
setCookie(event, 'codev_admin', 'ok', {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: 60 * 60 * 24, // 24h
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 200, ok: true, admin: isAdmin }
|
||||||
})
|
})
|
||||||
|
|||||||
25
server/api/codev/fiches/[id].delete.ts
Normal file
25
server/api/codev/fiches/[id].delete.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// Vérif cookie admin
|
||||||
|
const adminCookie = getCookie(event, 'codev_admin')
|
||||||
|
if (adminCookie !== 'ok') {
|
||||||
|
throw createError({ statusCode: 403, statusMessage: 'Accès refusé' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const tableId = config.codevTableId
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
if (!tableId || !id) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Parametre manquant' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await $fetch(`${config.nocodbUrl}/api/v2/tables/${tableId}/records`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ Id: Number(id) }),
|
||||||
|
}).catch(() => {
|
||||||
|
throw createError({ statusCode: 502, statusMessage: 'Erreur suppression' })
|
||||||
|
})
|
||||||
|
|
||||||
|
return { status: 200, ok: true }
|
||||||
|
})
|
||||||
34
server/api/codev/fiches/[id].get.ts
Normal file
34
server/api/codev/fiches/[id].get.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { CodevFiche } from '~/types/codev'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<CodevFiche> => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const tableId = config.codevTableId
|
||||||
|
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
if (!tableId || !id) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Parametre manquant' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
|
||||||
|
|
||||||
|
const r: any = await $fetch(url, {
|
||||||
|
headers: { 'xc-token': config.nocodbToken },
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
if (!r) {
|
||||||
|
throw createError({ statusCode: 404, message: 'Fiche introuvable' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.Id ?? r.id,
|
||||||
|
nom: r.nom || '',
|
||||||
|
besoin: r.besoin || '',
|
||||||
|
offre: r.offre || '',
|
||||||
|
hashtags: (r.hashtags || '')
|
||||||
|
.split(',')
|
||||||
|
.map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
|
||||||
|
.filter(Boolean),
|
||||||
|
created_at: r.created_at || r.CreatedAt || '',
|
||||||
|
}
|
||||||
|
})
|
||||||
59
server/api/codev/fiches/[id].patch.ts
Normal file
59
server/api/codev/fiches/[id].patch.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const PatchSchema = z.object({
|
||||||
|
nom: z.string().min(2).max(50).trim(),
|
||||||
|
besoin: z.string().min(5).max(300).trim(),
|
||||||
|
offre: z.string().min(5).max(300).trim(),
|
||||||
|
hashtags: z.array(z.string().max(30)).max(3).default([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const tableId = config.codevTableId
|
||||||
|
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
if (!tableId || !id) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Parametre manquant' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = PatchSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 422,
|
||||||
|
statusMessage: 'Validation echouee',
|
||||||
|
data: parsed.error.flatten().fieldErrors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
nom: parsed.data.nom,
|
||||||
|
besoin: parsed.data.besoin,
|
||||||
|
offre: parsed.data.offre,
|
||||||
|
hashtags: parsed.data.hashtags
|
||||||
|
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 3)
|
||||||
|
.join(','),
|
||||||
|
}
|
||||||
|
|
||||||
|
// NocoDB v1 PATCH par Id
|
||||||
|
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(url, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'xc-token': config.nocodbToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[codev/fiches.patch] NocoDB patch error:', e?.message ?? e)
|
||||||
|
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 200, ok: true }
|
||||||
|
})
|
||||||
5
server/api/codev/me.get.ts
Normal file
5
server/api/codev/me.get.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const admin = getCookie(event, 'codev_admin') === 'ok'
|
||||||
|
const session = getCookie(event, 'codev_session') === 'ok'
|
||||||
|
return { admin, session }
|
||||||
|
})
|
||||||
@@ -8,8 +8,9 @@ export default defineEventHandler((event) => {
|
|||||||
// Seulement les routes sous /codev/
|
// Seulement les routes sous /codev/
|
||||||
if (!path.startsWith('/codev/')) return
|
if (!path.startsWith('/codev/')) return
|
||||||
|
|
||||||
// Routes publiques : /codev/demo (et sous-routes éventuelles)
|
// Routes publiques : /codev/demo et /codev/qr (et sous-routes éventuelles)
|
||||||
if (path === '/codev/demo' || path.startsWith('/codev/demo/')) return
|
if (path === '/codev/demo' || path.startsWith('/codev/demo/')) return
|
||||||
|
if (path === '/codev/qr' || path.startsWith('/codev/qr/')) return
|
||||||
|
|
||||||
// Vérification cookie
|
// Vérification cookie
|
||||||
const session = getCookie(event, 'codev_session')
|
const session = getCookie(event, 'codev_session')
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,15 +41,21 @@ function score(textA: string, hashtagsA: string[], textB: string, hashtagsB: str
|
|||||||
return jaccard(tokenize(textA), tokenize(textB))
|
return jaccard(tokenize(textA), tokenize(textB))
|
||||||
}
|
}
|
||||||
|
|
||||||
const THRESHOLD = 0.15
|
// scoreDirect tokenise TOUJOURS les textes, ignore les hashtags
|
||||||
|
// Utilise pour matchSolution : besoin vs offre doivent etre compares par leur contenu reel
|
||||||
|
function scoreDirect(textA: string, textB: string): number {
|
||||||
|
return jaccard(tokenize(textA), tokenize(textB))
|
||||||
|
}
|
||||||
|
|
||||||
export function matchSolution(fiches: CodevFiche[]): CodevMatch[] {
|
export function matchSolution(fiches: CodevFiche[], threshold = 0.18): CodevMatch[] {
|
||||||
const matches: CodevMatch[] = []
|
const matches: CodevMatch[] = []
|
||||||
for (const a of fiches) {
|
for (const a of fiches) {
|
||||||
for (const b of fiches) {
|
for (const b of fiches) {
|
||||||
if (a.id === b.id) continue
|
if (a.id === b.id) continue
|
||||||
const s = score(a.besoin, a.hashtags, b.offre, b.hashtags)
|
// Solution : on compare le TEXTE besoin de A avec le TEXTE offre de B
|
||||||
if (s >= THRESHOLD) {
|
// On ignore les hashtags pour differencier besoin et offre
|
||||||
|
const s = scoreDirect(a.besoin, b.offre)
|
||||||
|
if (s >= threshold) {
|
||||||
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' })
|
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,13 +63,14 @@ export function matchSolution(fiches: CodevFiche[]): CodevMatch[] {
|
|||||||
return matches
|
return matches
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchAlliance(fiches: CodevFiche[]): CodevMatch[] {
|
export function matchAlliance(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
|
||||||
const matches: CodevMatch[] = []
|
const matches: CodevMatch[] = []
|
||||||
for (let i = 0; i < fiches.length; i++) {
|
for (let i = 0; i < fiches.length; i++) {
|
||||||
for (let j = i + 1; j < fiches.length; j++) {
|
for (let j = i + 1; j < fiches.length; j++) {
|
||||||
const a = fiches[i], b = fiches[j]
|
const a = fiches[i], b = fiches[j]
|
||||||
|
// Alliance : besoins similaires — on compare hashtags si presents, sinon textes
|
||||||
const s = score(a.besoin, a.hashtags, b.besoin, b.hashtags)
|
const s = score(a.besoin, a.hashtags, b.besoin, b.hashtags)
|
||||||
if (s >= THRESHOLD) {
|
if (s >= threshold) {
|
||||||
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'alliance' })
|
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'alliance' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,13 +78,14 @@ export function matchAlliance(fiches: CodevFiche[]): CodevMatch[] {
|
|||||||
return matches
|
return matches
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchSurprise(fiches: CodevFiche[]): CodevMatch[] {
|
export function matchSurprise(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
|
||||||
const matches: CodevMatch[] = []
|
const matches: CodevMatch[] = []
|
||||||
for (let i = 0; i < fiches.length; i++) {
|
for (let i = 0; i < fiches.length; i++) {
|
||||||
for (let j = i + 1; j < fiches.length; j++) {
|
for (let j = i + 1; j < fiches.length; j++) {
|
||||||
const a = fiches[i], b = fiches[j]
|
const a = fiches[i], b = fiches[j]
|
||||||
|
// Surprise : offres similaires
|
||||||
const s = score(a.offre, a.hashtags, b.offre, b.hashtags)
|
const s = score(a.offre, a.hashtags, b.offre, b.hashtags)
|
||||||
if (s >= THRESHOLD) {
|
if (s >= threshold) {
|
||||||
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'surprise' })
|
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'surprise' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,10 +96,11 @@ export function matchSurprise(fiches: CodevFiche[]): CodevMatch[] {
|
|||||||
export function computeMatches(
|
export function computeMatches(
|
||||||
fiches: CodevFiche[],
|
fiches: CodevFiche[],
|
||||||
mode: 'solution' | 'alliance' | 'surprise',
|
mode: 'solution' | 'alliance' | 'surprise',
|
||||||
|
threshold?: number,
|
||||||
): CodevMatch[] {
|
): CodevMatch[] {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'solution': return matchSolution(fiches)
|
case 'solution': return matchSolution(fiches, threshold)
|
||||||
case 'alliance': return matchAlliance(fiches)
|
case 'alliance': return matchAlliance(fiches, threshold)
|
||||||
case 'surprise': return matchSurprise(fiches)
|
case 'surprise': return matchSurprise(fiches, threshold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user