Compare commits
6 Commits
feat/aep-p
...
feat/aep-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5878c56888 | ||
|
|
755d1ef9ae | ||
|
|
682d5d337e | ||
|
|
914759a815 | ||
|
|
a6fff9a950 | ||
|
|
358cb55d50 |
18
app.vue
18
app.vue
@@ -107,9 +107,9 @@
|
|||||||
>
|
>
|
||||||
Signaler
|
Signaler
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<!-- Proposer une ressource -->
|
<!-- Proposer une ressource (cible contextuelle selon la carte active) -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/contribuer"
|
:to="proposeTarget"
|
||||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
>
|
>
|
||||||
@@ -136,9 +136,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Mobile : contribuer icône -->
|
<!-- Mobile : contribuer icône (cible contextuelle) -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/contribuer"
|
:to="proposeTarget"
|
||||||
class="sm:hidden p-2 rounded-lg"
|
class="sm:hidden p-2 rounded-lg"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
title="Contribuer une fiche"
|
title="Contribuer une fiche"
|
||||||
@@ -253,6 +253,16 @@ function clearHeaderSearch() {
|
|||||||
function goRandom() {
|
function goRandom() {
|
||||||
router.push({ path: '/', query: { random: '1' } })
|
router.push({ path: '/', query: { random: '1' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cible contextuelle du bouton Proposer ────────────────────────────────
|
||||||
|
// Sur l'onglet pratiques regeneratives, route vers /proposer-pratique.
|
||||||
|
// Sur l'onglet ecosysteme AEP (et toute autre route), route vers /contribuer.
|
||||||
|
const proposeTarget = computed(() => {
|
||||||
|
if (route.path.startsWith('/pratiques-regeneratives') || route.path.startsWith('/pratique/')) {
|
||||||
|
return '/proposer-pratique'
|
||||||
|
}
|
||||||
|
return '/contribuer'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -461,10 +461,12 @@ const jaugePct = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── FAB mobile soutenir ─────────────────────────────────────────────────── */
|
/* ── FAB mobile soutenir ─────────────────────────────────────────────────── */
|
||||||
|
/* Stack vertical avec le FAB Chatbot a droite (evite l'overlap avec les chips
|
||||||
|
sidebar mobile sur viewport intermediaire ~880px - bug E2E M3) */
|
||||||
.fab-soutenir {
|
.fab-soutenir {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 68px; /* au-dessus du FAB chatbot à 24px du bas + 48px de hauteur */
|
bottom: 84px; /* au-dessus du FAB chatbot a bottom-6 (24px) + 48px de hauteur + 12px gap */
|
||||||
left: 16px;
|
right: 16px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|||||||
@@ -52,18 +52,9 @@
|
|||||||
<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>Ce chatbot fonctionne sur un serveur européen souverain
|
<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>
|
||||||
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
|
||||||
<p>Pour m'aider à te répondre efficacement,
|
<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>
|
||||||
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 -->
|
||||||
@@ -72,7 +63,7 @@ employeur, besoin conseil juridique droit du travail,
|
|||||||
<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 recommandées :</p>
|
<p class="fiches-title">Fiches recommandees :</p>
|
||||||
<a
|
<a
|
||||||
v-for="fiche in msg.fiches"
|
v-for="fiche in msg.fiches"
|
||||||
:key="fiche.id"
|
:key="fiche.id"
|
||||||
@@ -83,6 +74,21 @@ employeur, besoin conseil juridique droit du travail,
|
|||||||
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
<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>
|
||||||
|
|
||||||
@@ -132,10 +138,12 @@ 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)
|
||||||
@@ -145,6 +153,37 @@ 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
|
||||||
}
|
}
|
||||||
@@ -165,15 +204,17 @@ async function sendMessage() {
|
|||||||
const res = await $fetch<{
|
const res = await $fetch<{
|
||||||
reponse_texte: string
|
reponse_texte: string
|
||||||
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
||||||
}>('/api/chatbot', {
|
}>('/api/chatbot-v2', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-bold text-sm" style="color: var(--nav-text);">Chatbot</span>
|
<span class="font-bold text-sm" style="color: var(--nav-text);">{{ title }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||||
<!-- Message onboarding (avant la première question) -->
|
<!-- Message onboarding (avant la première question) -->
|
||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
|
<slot name="onboarding">
|
||||||
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
||||||
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||||
<p>Pour m'aider à te répondre efficacement,
|
<p>Pour m'aider à te répondre efficacement,
|
||||||
@@ -81,6 +82,7 @@ formule ta requête ainsi :</p>
|
|||||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
||||||
employeur, besoin conseil juridique droit du travail,
|
employeur, besoin conseil juridique droit du travail,
|
||||||
Île-de-France."</p>
|
Île-de-France."</p>
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
@@ -100,7 +102,7 @@ employeur, besoin conseil juridique droit du travail,
|
|||||||
<a
|
<a
|
||||||
v-for="fiche in msg.fiches"
|
v-for="fiche in msg.fiches"
|
||||||
:key="fiche.id"
|
:key="fiche.id"
|
||||||
:href="`/fiche/${fiche.id}`"
|
:href="`${ficheBasePath}/${fiche.id}`"
|
||||||
class="fiche-card"
|
class="fiche-card"
|
||||||
>
|
>
|
||||||
<span class="fiche-nom">{{ fiche.nom }}</span>
|
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||||
@@ -176,9 +178,16 @@ interface ChatMessage {
|
|||||||
fiches?: FicheReco[]
|
fiches?: FicheReco[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
}>()
|
endpoint?: string
|
||||||
|
title?: string
|
||||||
|
ficheBasePath?: string
|
||||||
|
}>(), {
|
||||||
|
endpoint: '/api/chatbot',
|
||||||
|
title: 'Chatbot',
|
||||||
|
ficheBasePath: '/fiche',
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
@@ -225,7 +234,7 @@ async function sendMessage() {
|
|||||||
const res = await $fetch<{
|
const res = await $fetch<{
|
||||||
reponse_texte: string
|
reponse_texte: string
|
||||||
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
||||||
}>('/api/chatbot', {
|
}>(props.endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { question },
|
body: { question },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ async function initMap() {
|
|||||||
updateMarkers(L)
|
updateMarkers(L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vue initiale (centre Europe + zoom 4) - sauvegardee pour reset
|
||||||
|
const INITIAL_CENTER: [number, number] = [50.0, 10.0]
|
||||||
|
const INITIAL_ZOOM = 4
|
||||||
|
|
||||||
|
let initialFitDone = false
|
||||||
|
|
||||||
function updateMarkers(L?: any) {
|
function updateMarkers(L?: any) {
|
||||||
if (!mapInstance || !clusterGroup) return
|
if (!mapInstance || !clusterGroup) return
|
||||||
const leaflet = L || (window as any).L
|
const leaflet = L || (window as any).L
|
||||||
@@ -158,6 +164,29 @@ function updateMarkers(L?: any) {
|
|||||||
markers.set(org.id, marker)
|
markers.set(org.id, marker)
|
||||||
clusterGroup.addLayer(marker)
|
clusterGroup.addLayer(marker)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Bug E2E L3 : recadrer la carte sur les resultats filtres
|
||||||
|
// Conditions : 1+ resultat, et au moins 1 marker hors viewport actuel.
|
||||||
|
// On evite de recadrer au tout premier rendu (laisser la vue initiale).
|
||||||
|
if (orgsWithCoords.length > 0 && initialFitDone) {
|
||||||
|
try {
|
||||||
|
const bounds = leaflet.latLngBounds(
|
||||||
|
orgsWithCoords.map((o) => [o.lat!, o.lng!])
|
||||||
|
)
|
||||||
|
// On recadre uniquement si la liste filtree est restreinte
|
||||||
|
// (evite un recadrage permanent quand toutes les fiches sont la).
|
||||||
|
if (orgsWithCoords.length <= 15) {
|
||||||
|
mapInstance.fitBounds(bounds, {
|
||||||
|
padding: [40, 40],
|
||||||
|
maxZoom: 10,
|
||||||
|
animate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[EuropeMap] fitBounds echoue:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialFitDone = true
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
435
components/GraphView.vue
Normal file
435
components/GraphView.vue
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
<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 hashtags droite (repliable) -->
|
||||||
|
<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 : header + liste groupee -->
|
||||||
|
<template v-if="sidebarOpen">
|
||||||
|
<div style="padding: 8px 12px; border-bottom: 1px solid var(--nav-bg-alt); flex-shrink: 0;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
|
||||||
|
<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-top: 4px; 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: 6px 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>
|
||||||
|
</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>
|
||||||
|
</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)
|
||||||
|
|
||||||
|
function toggleHashtag(tag: string) {
|
||||||
|
activeHashtags.value = activeHashtags.value.includes(tag)
|
||||||
|
? activeHashtags.value.filter(t => t !== tag)
|
||||||
|
: [...activeHashtags.value, tag]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping hashtag -> famille majoritaire
|
||||||
|
// En cas d'egalite : prendre la famille la plus petite (visibilite minoritaires)
|
||||||
|
const hashtagsByFamille = computed(() => {
|
||||||
|
if (!props.data) return []
|
||||||
|
// 1. Pour chaque hashtag, compter les structures par famille
|
||||||
|
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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// 2. Pour chaque hashtag, trouver la famille majoritaire (egalite -> + petite famille)
|
||||||
|
// Pour preferer la famille la moins peuplee globalement, calculer la taille de chaque famille.
|
||||||
|
const familleSize: Record<number, number> = {}
|
||||||
|
props.data.structures.forEach(s => {
|
||||||
|
familleSize[s.famille_principale] = (familleSize[s.famille_principale] ?? 0) + 1
|
||||||
|
})
|
||||||
|
const tagToFamille: 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
|
||||||
|
// egalite : famille avec moins de structures gagne
|
||||||
|
return (familleSize[Number(a[0])] ?? 0) - (familleSize[Number(b[0])] ?? 0)
|
||||||
|
})
|
||||||
|
tagToFamille[tag] = Number(entries[0][0])
|
||||||
|
}
|
||||||
|
// 3. Grouper les hashtags par famille
|
||||||
|
const groups: Record<number, string[]> = {}
|
||||||
|
props.allHashtags.forEach(tag => {
|
||||||
|
const fam = tagToFamille[tag]
|
||||||
|
if (fam == null) return
|
||||||
|
if (!groups[fam]) groups[fam] = []
|
||||||
|
groups[fam].push(tag)
|
||||||
|
})
|
||||||
|
// 4. Sortie ordonnee selon ID de famille
|
||||||
|
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(),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
const svg = d3.select(svgEl)
|
||||||
|
.attr('viewBox', `0 0 ${width} ${height}`)
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
|
||||||
|
svg.call(zoomBehavior as any)
|
||||||
|
|
||||||
|
// Noeuds familles (centres fixes en etoile)
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Noeuds structures
|
||||||
|
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,
|
||||||
|
x: undefined as number | undefined,
|
||||||
|
y: undefined as number | undefined,
|
||||||
|
fx: undefined as number | null | undefined,
|
||||||
|
fy: undefined as number | null | undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const allNodes: any[] = [...familyNodes, ...structureNodes]
|
||||||
|
|
||||||
|
// Liens structures -> familles
|
||||||
|
const links: any[] = []
|
||||||
|
structureNodes.forEach(s => {
|
||||||
|
links.push({
|
||||||
|
source: s.id,
|
||||||
|
target: `family-${s.famille}`,
|
||||||
|
type: 'primary',
|
||||||
|
strength: 0.55,
|
||||||
|
})
|
||||||
|
s.familles_secondaires.forEach((f: number) => {
|
||||||
|
links.push({
|
||||||
|
source: s.id,
|
||||||
|
target: `family-${f}`,
|
||||||
|
type: 'secondary',
|
||||||
|
strength: 0.45,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulation force-directed
|
||||||
|
if (simulation) simulation.stop()
|
||||||
|
simulation = d3.forceSimulation(allNodes)
|
||||||
|
.force('link', d3.forceLink(links).id((d: any) => d.id).distance((d: any) => d.type === 'primary' ? 80 : 120).strength((d: any) => d.strength ?? 0.5))
|
||||||
|
.force('charge', d3.forceManyBody().strength(-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) => 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) => d.type === 'structure' ? 'pointer' : '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
|
||||||
|
})
|
||||||
|
.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) => {
|
||||||
|
if (d.type === 'structure') emit('select-structure', d.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cercles
|
||||||
|
d3NodeSelection.append('circle')
|
||||||
|
.attr('r', (d: any) => d.r)
|
||||||
|
.attr('fill', (d: any) => d.type === 'family' ? d.color : d.color + 'cc')
|
||||||
|
.attr('stroke', (d: any) => d.type === 'family' ? 'white' : d.color)
|
||||||
|
.attr('stroke-width', (d: any) => d.type === 'family' ? 3 : 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')
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
return ids.has(srcId) ? 1 : 0.05
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
d3NodeSelection.select('circle').attr('opacity', 1)
|
||||||
|
d3LinkSelection.attr('opacity', 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déclencher quand l'onglet devient visible
|
||||||
|
// Double rAF : nextTick met à jour le vdom, les 2 frames garantissent que
|
||||||
|
// le browser a calculé le layout et que clientWidth/clientHeight != 0
|
||||||
|
watch(() => props.active, (val) => {
|
||||||
|
if (val && import.meta.client && props.data) {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Relancer si les données arrivent après 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 })
|
||||||
|
|
||||||
|
// 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>
|
||||||
@@ -128,6 +128,8 @@ async function initMap() {
|
|||||||
updateMarkers(L)
|
updateMarkers(L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let initialFitDone = false
|
||||||
|
|
||||||
function updateMarkers(L?: any) {
|
function updateMarkers(L?: any) {
|
||||||
if (!mapInstance || !clusterGroup) return
|
if (!mapInstance || !clusterGroup) return
|
||||||
const leaflet = L || (window as any).L
|
const leaflet = L || (window as any).L
|
||||||
@@ -168,6 +170,25 @@ function updateMarkers(L?: any) {
|
|||||||
markers.set(org.Id, marker)
|
markers.set(org.Id, marker)
|
||||||
clusterGroup.addLayer(marker)
|
clusterGroup.addLayer(marker)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Bug E2E L3 : recadrer la carte sur les resultats filtres
|
||||||
|
if (orgsWithCoords.length > 0 && initialFitDone) {
|
||||||
|
try {
|
||||||
|
const bounds = leaflet.latLngBounds(
|
||||||
|
orgsWithCoords.map((o: any) => [o.latitude!, o.longitude!])
|
||||||
|
)
|
||||||
|
if (orgsWithCoords.length <= 15) {
|
||||||
|
mapInstance.fitBounds(bounds, {
|
||||||
|
padding: [40, 40],
|
||||||
|
maxZoom: 10,
|
||||||
|
animate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[NavMap] fitBounds echoue:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialFitDone = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Réagir aux changements de filtres (liste d'orgs)
|
// Réagir aux changements de filtres (liste d'orgs)
|
||||||
|
|||||||
@@ -136,6 +136,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════ CTA PROPOSER -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 px-4 py-3 border-t"
|
||||||
|
style="border-color: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<NuxtLink
|
||||||
|
to="/contribuer"
|
||||||
|
class="sidebar-cta-link"
|
||||||
|
>
|
||||||
|
+ Proposer une fiche
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
@@ -254,4 +266,24 @@ function orgFonctions(org: Org): string[] {
|
|||||||
color: var(--nav-text);
|
color: var(--nav-text);
|
||||||
background: var(--nav-bg-alt);
|
background: var(--nav-bg-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-cta-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--nav-primary-solid);
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-cta-link:hover {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -72,6 +72,21 @@ const { data: org, pending, error } = await useFetch<Org>(`/api/fiche/${orgId}`,
|
|||||||
key: `fiche-${orgId}`,
|
key: `fiche-${orgId}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Fallback Pratiques regeneratives (bug E2E L1) ─────────────────────
|
||||||
|
// Si /api/fiche/:id echoue, on regarde si l'id correspond a une pratique
|
||||||
|
// regenerative et on redirige automatiquement vers /pratique/:id.
|
||||||
|
if (error.value) {
|
||||||
|
try {
|
||||||
|
const pratiquesRes = await $fetch<{ list: { id: number }[] }>('/api/pratiques')
|
||||||
|
const numericId = Number(orgId)
|
||||||
|
if (!isNaN(numericId) && pratiquesRes.list?.some((p) => p.id === numericId)) {
|
||||||
|
await navigateTo(`/pratique/${numericId}`, { replace: true })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// pas de fallback dispo, on garde l'erreur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Commentaires — tick de rafraîchissement ───────────────────────────
|
// ── Commentaires — tick de rafraîchissement ───────────────────────────
|
||||||
const commentRefreshTick = ref(0)
|
const commentRefreshTick = ref(0)
|
||||||
|
|
||||||
|
|||||||
695
pages/index.vue
695
pages/index.vue
@@ -1,56 +1,144 @@
|
|||||||
<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:flex w-80 shrink-0 flex-col overflow-hidden">
|
<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%;">
|
||||||
<NavSidebar
|
|
||||||
:search="search"
|
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
|
||||||
:modeValue="territoireMode"
|
<IntentionBanner />
|
||||||
:echelle="echelle"
|
|
||||||
:fonctions="fonctions"
|
<!-- Filtres familles + hashtags -->
|
||||||
:territoire="territoire"
|
<HashtagFilter
|
||||||
:echelleCount="echelleCount"
|
:allHashtags="allHashtags"
|
||||||
:fonctionCount="fonctionCount"
|
:selectedHashtags="selectedHashtags"
|
||||||
:territoireCount="territoireCount"
|
:selectedFamille="selectedFamille"
|
||||||
:resultCount="filtered.length"
|
@update:selectedHashtags="selectedHashtags = $event"
|
||||||
:orgs="filtered"
|
@update:selectedFamille="selectedFamille = $event"
|
||||||
: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">
|
||||||
|
|
||||||
<!-- Indicateur source dev -->
|
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
|
||||||
<div
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||||
v-if="dataSource === 'seed'"
|
<!-- Onglets desktop -->
|
||||||
class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
|
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
<button
|
||||||
>
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
Mode dev — données seed
|
: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>
|
</div>
|
||||||
|
|
||||||
<!-- ── VUE DESKTOP : Métropole pleine largeur + DOM-TOM row en bas ── -->
|
<!-- Carte Métropole desktop -->
|
||||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
||||||
<!-- Carte Métropole — pleine largeur -->
|
|
||||||
<div class="flex flex-col flex-1 overflow-hidden">
|
|
||||||
<div class="relative flex-1" style="min-height: 200px;">
|
<div class="relative flex-1" style="min-height: 200px;">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<NavMap
|
<NavMapV2
|
||||||
ref="navMapRef"
|
ref="navMapRef"
|
||||||
:orgs="metropoleOrgs"
|
:structures="metropoleStructures"
|
||||||
:selectedId="selectedId"
|
:selectedId="selectedId"
|
||||||
@select-org="onSelectOrg"
|
@select-structure="onSelectStructure"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div
|
<div
|
||||||
@@ -62,35 +150,53 @@
|
|||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
<ChatbotPlaceholder
|
||||||
|
@highlightOrgs="() => {}"
|
||||||
|
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bandeau DOM-TOM — row horizontale pleine largeur, hauteur fixe -->
|
<!-- Carte Outre-mer desktop -->
|
||||||
<div
|
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
|
||||||
class="shrink-0"
|
|
||||||
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
|
|
||||||
>
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<OutremerMap
|
<OutremerMap
|
||||||
:orgs="outremerOrgs"
|
:orgs="outremerOrgsLegacy"
|
||||||
:selectedId="selectedId"
|
:selectedId="selectedIdLegacyNum"
|
||||||
@select-org="onSelectOrg"
|
@select-org="() => {}"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div
|
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
|
||||||
class="flex items-center justify-center h-full text-sm"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>
|
|
||||||
Chargement…
|
Chargement…
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + 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"
|
||||||
@@ -109,34 +215,30 @@
|
|||||||
</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>
|
||||||
<NavMap
|
<NavMapV2
|
||||||
ref="navMapMobileRef"
|
ref="navMapMobileRef"
|
||||||
:orgs="metropoleOrgs"
|
:structures="metropoleStructures"
|
||||||
:selectedId="selectedId"
|
:selectedId="selectedId"
|
||||||
@select-org="onSelectOrgMobile"
|
@select-structure="onSelectStructureMobile"
|
||||||
/>
|
/>
|
||||||
<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);">
|
||||||
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 Outre-mer (scroll vertical, pleine largeur) -->
|
<!-- Carte mobile Outre-mer -->
|
||||||
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<OutremerMap
|
<OutremerMap
|
||||||
:orgs="outremerOrgs"
|
:orgs="outremerOrgsLegacy"
|
||||||
:selectedId="selectedId"
|
:selectedId="selectedIdLegacyNum"
|
||||||
@select-org="onSelectOrgMobile"
|
@select-org="() => {}"
|
||||||
/>
|
/>
|
||||||
<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);">
|
||||||
@@ -146,81 +248,65 @@
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
|
<!-- Bottom sheet swipable -->
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||||
<!-- Barre recherche -->
|
<!-- Bandeau intention mobile -->
|
||||||
|
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
|
||||||
|
<p class="text-xs leading-relaxed" style="color: var(--bifurc-banner-text, #2c2416); margin: 0;">
|
||||||
|
120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres hashtags mobile -->
|
||||||
|
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<HashtagFilter
|
||||||
|
:allHashtags="allHashtags"
|
||||||
|
:selectedHashtags="selectedHashtags"
|
||||||
|
:selectedFamille="selectedFamille"
|
||||||
|
@update:selectedHashtags="selectedHashtags = $event"
|
||||||
|
@update:selectedFamille="selectedFamille = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barre recherche mobile -->
|
||||||
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
<label class="mobile-search-label" aria-label="Rechercher une organisation">
|
<label class="mobile-search-label" aria-label="Rechercher une structure">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
<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="mobileSearch"
|
v-model="search"
|
||||||
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="mobileSearch"
|
v-if="search"
|
||||||
type="button"
|
type="button"
|
||||||
class="mobile-search-clear"
|
class="mobile-search-clear"
|
||||||
aria-label="Effacer"
|
aria-label="Effacer"
|
||||||
@click.stop="mobileSearch = ''; onSearch('')"
|
@click.stop="search = ''"
|
||||||
>
|
>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
<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-2 text-xs"
|
class="mt-1 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>
|
||||||
|
|
||||||
<!-- Compteur + Liste fiches -->
|
<!-- Liste fiches mobile -->
|
||||||
<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 }} résultat{{ filtered.length > 1 ? 's' : '' }}
|
{{ filtered.length }} structure{{ 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…
|
||||||
@@ -233,46 +319,36 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div
|
<div
|
||||||
v-for="org in filtered"
|
v-for="structure in filtered"
|
||||||
:key="org.Id"
|
:key="structure.id"
|
||||||
class="block rounded-lg p-3 transition-all cursor-pointer"
|
class="block rounded-lg p-3 transition-all cursor-pointer"
|
||||||
:style="selectedId === org.Id
|
:style="selectedId === structure.id
|
||||||
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
|
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};`
|
||||||
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
||||||
@click="onSelectOrgMobile(org.Id)"
|
@click="onSelectStructureMobile(structure.id)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
|
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="org.echelle"
|
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
|
||||||
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
|
:style="`background: ${familleColor(structure.famille_principale)};`"
|
||||||
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 (desktop) -->
|
<!-- ═══════════════════════════════════════ MODAL FICHE V2 (desktop) -->
|
||||||
<FicheModal
|
<FicheModalV2
|
||||||
v-model="ficheModalOpen"
|
v-model="ficheModalOpen"
|
||||||
:orgId="ficheModalId"
|
:structureId="ficheModalId"
|
||||||
|
:data="bifurcationData"
|
||||||
|
@update:structureId="ficheModalId = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||||
@@ -301,268 +377,141 @@
|
|||||||
<ChatbotSheet
|
<ChatbotSheet
|
||||||
:modelValue="chatbotOpen"
|
:modelValue="chatbotOpen"
|
||||||
@update:modelValue="chatbotOpen = $event"
|
@update:modelValue="chatbotOpen = $event"
|
||||||
@highlightOrgs="onHighlightOrgs"
|
@highlightOrgs="() => {}"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Org } from '~/types/org'
|
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
|
||||||
|
|
||||||
// ── URL query params sync ─────────────────────────────────────────────────
|
// ── Couleurs familles ──────────────────────────────────────────────────────
|
||||||
const route = useRoute()
|
const FAMILLE_COLORS: Record<number, string> = {
|
||||||
const router = useRouter()
|
1: '#a85d3e',
|
||||||
|
2: '#c4a472',
|
||||||
const search = ref<string>((route.query.q as string) ?? '')
|
3: '#d4a017',
|
||||||
const echelle = ref<string[]>(
|
4: '#5a7a4a',
|
||||||
route.query.echelle
|
5: '#3d6a8c',
|
||||||
? (route.query.echelle as string).split(',').filter(Boolean)
|
6: '#6b3fa0',
|
||||||
: []
|
|
||||||
)
|
|
||||||
const fonctions = ref<string[]>(
|
|
||||||
route.query.fonctions
|
|
||||||
? (route.query.fonctions as string).split(',').filter(Boolean)
|
|
||||||
: []
|
|
||||||
)
|
|
||||||
const territoire = ref<string | null>((route.query.territoire as string) ?? null)
|
|
||||||
const territoireMode = ref<string>(
|
|
||||||
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
|
|
||||||
)
|
|
||||||
|
|
||||||
const selectedId = ref<number | null>(null)
|
|
||||||
const chatbotOpen = ref(false)
|
|
||||||
const ficheModalOpen = ref(false)
|
|
||||||
const ficheModalId = ref<number | null>(null)
|
|
||||||
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
|
||||||
// Surlignage temporaire (5 sec) suite à une réponse chatbot
|
|
||||||
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
|
|
||||||
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
const prevSelectedId = ref<number | null>(null)
|
|
||||||
|
|
||||||
function onHighlightOrgs(ids: (number | string)[]) {
|
|
||||||
if (!ids.length) return
|
|
||||||
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
|
|
||||||
if (isNaN(firstId)) return
|
|
||||||
|
|
||||||
// Sauvegarde la sélection courante
|
|
||||||
prevSelectedId.value = selectedId.value
|
|
||||||
selectedId.value = firstId
|
|
||||||
|
|
||||||
if (highlightTimer) clearTimeout(highlightTimer)
|
|
||||||
highlightTimer = setTimeout(() => {
|
|
||||||
// Restaure la sélection précédente (ou null)
|
|
||||||
selectedId.value = prevSelectedId.value
|
|
||||||
prevSelectedId.value = null
|
|
||||||
highlightTimer = null
|
|
||||||
}, 5000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ref locale barre de recherche mobile (synchronisée avec search via onSearch)
|
function familleColor(f: number): string {
|
||||||
const mobileSearch = ref<string>((route.query.q as string) ?? '')
|
return FAMILLE_COLORS[f] ?? '#888'
|
||||||
|
}
|
||||||
|
|
||||||
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
|
// ── État UI ────────────────────────────────────────────────────────────────
|
||||||
|
const selectedId = ref<string | null>(null)
|
||||||
|
const hoveredId = ref<string | null>(null)
|
||||||
|
const ficheModalOpen = ref(false)
|
||||||
|
const ficheModalId = ref<string | null>(null)
|
||||||
|
const chatbotOpen = ref(false)
|
||||||
|
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||||
|
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
|
||||||
|
|
||||||
|
// Filtres
|
||||||
|
const search = ref('')
|
||||||
|
const selectedFamille = ref<number | null>(null)
|
||||||
|
const selectedHashtags = ref<string[]>([])
|
||||||
|
|
||||||
|
// Refs cartes
|
||||||
const navMapRef = ref<any>(null)
|
const navMapRef = ref<any>(null)
|
||||||
const navMapMobileRef = ref<any>(null)
|
const navMapMobileRef = ref<any>(null)
|
||||||
|
|
||||||
// Sync URL <-> état filtres
|
// ── Données V2 - JSON statique ─────────────────────────────────────────────
|
||||||
function syncUrl() {
|
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
|
||||||
const q: Record<string, string> = {}
|
const pending = ref(true)
|
||||||
if (search.value) q.q = search.value
|
|
||||||
if (echelle.value.length) q.echelle = echelle.value.join(',')
|
onMounted(async () => {
|
||||||
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
try {
|
||||||
if (territoire.value) q.territoire = territoire.value
|
bifurcationData.value = await $fetch<ReseauxBifurcationData>('/data/reseaux-bifurcation.json')
|
||||||
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
} catch (e) {
|
||||||
router.replace({ query: Object.keys(q).length ? q : undefined })
|
console.error('Erreur chargement reseaux-bifurcation.json', e)
|
||||||
|
} finally {
|
||||||
|
pending.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const structures = computed<StructureV2[]>(() => bifurcationData.value?.structures ?? [])
|
||||||
|
|
||||||
|
// Tous les hashtags uniques triés
|
||||||
|
const allHashtags = computed<string[]>(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
structures.value.forEach(s => s.hashtags.forEach(h => set.add(h)))
|
||||||
|
return Array.from(set).sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Filtrage ───────────────────────────────────────────────────────────────
|
||||||
|
const filtered = computed<StructureV2[]>(() => {
|
||||||
|
let result = structures.value
|
||||||
|
|
||||||
|
// Filtre texte
|
||||||
|
if (search.value.trim()) {
|
||||||
|
const q = search.value.toLowerCase()
|
||||||
|
result = result.filter(
|
||||||
|
s =>
|
||||||
|
s.nom.toLowerCase().includes(q) ||
|
||||||
|
s.ville.toLowerCase().includes(q) ||
|
||||||
|
s.description_courte.toLowerCase().includes(q) ||
|
||||||
|
s.hashtags.some(h => h.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre famille - F6 = badge_f6_recherche_politique, pas famille_principale
|
||||||
|
if (selectedFamille.value !== null) {
|
||||||
|
if (selectedFamille.value === 6) {
|
||||||
|
result = result.filter(s => (s.badges as any)?.f6_recherche_politique === true)
|
||||||
|
} else {
|
||||||
|
result = result.filter(
|
||||||
|
s => s.famille_principale === selectedFamille.value ||
|
||||||
|
(s.familles_secondaires ?? []).includes(selectedFamille.value!)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre hashtags (AND logique si plusieurs)
|
||||||
|
if (selectedHashtags.value.length) {
|
||||||
|
result = result.filter(
|
||||||
|
s => selectedHashtags.value.every(h => s.hashtags.includes(h))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(
|
||||||
|
() => !!search.value || selectedFamille.value !== null || selectedHashtags.value.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
search.value = ''
|
||||||
|
selectedFamille.value = null
|
||||||
|
selectedHashtags.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
|
// Structures métropole (pays != DOM-TOM, et avec coordonnées)
|
||||||
function storeFiltersForBack() {
|
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
|
||||||
if (typeof window === 'undefined') return
|
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
|
||||||
const q: Record<string, string> = {}
|
|
||||||
if (search.value) q.q = search.value
|
|
||||||
if (echelle.value.length) q.echelle = echelle.value.join(',')
|
|
||||||
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
|
||||||
if (territoire.value) q.territoire = territoire.value
|
|
||||||
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
|
||||||
const qs = new URLSearchParams(q).toString()
|
|
||||||
sessionStorage.setItem('nav_back_filters', qs)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
|
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
|
||||||
function onMode(v: string) { territoireMode.value = v; syncUrl(); storeFiltersForBack() }
|
// OutremerMap attend le format Org legacy - on passe un tableau vide
|
||||||
function onEchelle(v: string[]) { echelle.value = v; syncUrl(); storeFiltersForBack() }
|
const outremerOrgsLegacy = computed(() => [])
|
||||||
function onFonctions(v: string[]) { fonctions.value = v; syncUrl(); storeFiltersForBack() }
|
const selectedIdLegacyNum = computed(() => null)
|
||||||
function onTerritoire(v: string | null) { territoire.value = v; syncUrl(); storeFiltersForBack() }
|
|
||||||
|
|
||||||
function onSelectOrg(id: number) {
|
// ── 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tap card mobile → ouvre la fiche détaillée
|
function onSelectStructureMobile(id: string) {
|
||||||
function onSelectOrgMobile(id: number) {
|
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
storeFiltersForBack()
|
ficheModalId.value = id
|
||||||
router.push(`/fiche/${id}`)
|
ficheModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function onHoverOrg(id: number | null) {
|
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
|
||||||
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>
|
||||||
|
|||||||
@@ -26,10 +26,37 @@
|
|||||||
<!-- ═══════════════════════════════════════ 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 : Europe pleine largeur + DOM-TOM row en bas ── -->
|
<!-- ── VUE DESKTOP : Onglets Europe / Outre-mer + carte pleine hauteur ── -->
|
||||||
<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">
|
||||||
<!-- Carte Europe — pleine largeur -->
|
|
||||||
<div class="flex flex-col flex-1 overflow-hidden">
|
<!-- Onglets Europe / Outre-mer (desktop) -->
|
||||||
|
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="desktopMapView === 'europe'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="desktopMapView = 'europe'"
|
||||||
|
>
|
||||||
|
Europe
|
||||||
|
<span class="ml-1 text-xs opacity-70">({{ europeOrgs.length }})</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 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
|
||||||
|
<span class="ml-1 text-xs opacity-70">({{ outremerOrgs.length }})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carte Europe (pleine hauteur) -->
|
||||||
|
<div v-show="desktopMapView === 'europe'" class="flex flex-col flex-1 overflow-hidden">
|
||||||
<div class="relative flex-1" style="min-height: 200px;">
|
<div class="relative flex-1" style="min-height: 200px;">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<EuropeMap
|
<EuropeMap
|
||||||
@@ -48,13 +75,31 @@
|
|||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
<ChatbotPlaceholder
|
||||||
|
endpoint="/api/chatbot-pratiques"
|
||||||
|
title="Chatbot Pratiques régé"
|
||||||
|
placeholder="Pose une question sur les pratiques régénératives…"
|
||||||
|
ficheBasePath="/pratique"
|
||||||
|
@highlightOrgs="onHighlightOrgs"
|
||||||
|
>
|
||||||
|
<template #onboarding>
|
||||||
|
<p>Ce chatbot interroge la base des pratiques régénératives
|
||||||
|
(Mistral FR, serveur européen souverain, zéro rétention).</p>
|
||||||
|
<p>Pour t'aider à trouver les pratiques pertinentes,
|
||||||
|
formule ta requête ainsi :</p>
|
||||||
|
<ul>
|
||||||
|
<li>• Besoin : [matériaux biosourcés / réemploi / posture politique...]</li>
|
||||||
|
<li>• Type : [agence / coopérative / collectif / réseau...]</li>
|
||||||
|
<li>• Lieu : [pays ou région]</li>
|
||||||
|
</ul>
|
||||||
|
<p class="example">Exemple : "Je cherche une coopérative qui travaille
|
||||||
|
le réemploi de matériaux en Belgique."</p>
|
||||||
|
</template>
|
||||||
|
</ChatbotPlaceholder>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bandeau DOM-TOM — row horizontale pleine largeur, hauteur fixe -->
|
<!-- Carte Outre-mer (pleine hauteur, scroll) -->
|
||||||
<div
|
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
|
||||||
class="shrink-0"
|
|
||||||
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
|
|
||||||
>
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<OutremerMapPratiques
|
<OutremerMapPratiques
|
||||||
:orgs="outremerOrgs"
|
:orgs="outremerOrgs"
|
||||||
@@ -63,7 +108,7 @@
|
|||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center h-full text-sm"
|
class="flex items-center justify-center h-48 text-sm"
|
||||||
style="color: var(--nav-text-muted);"
|
style="color: var(--nav-text-muted);"
|
||||||
>
|
>
|
||||||
Chargement…
|
Chargement…
|
||||||
@@ -166,15 +211,17 @@
|
|||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">CRITÈRES</span>
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">CRITÈRES</span>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<span
|
<button
|
||||||
v-for="c in CRITERES"
|
v-for="c in CRITERES"
|
||||||
:key="c.id"
|
:key="c.id"
|
||||||
|
type="button"
|
||||||
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
:style="criteres.includes(c.id)
|
:style="criteres.includes(c.id)
|
||||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
:aria-pressed="criteres.includes(c.id)"
|
||||||
@click="toggleCritere(c.id)"
|
@click="toggleCritere(c.id)"
|
||||||
>{{ c.label }}</span>
|
>{{ c.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,15 +229,17 @@
|
|||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">TYPE</span>
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">TYPE</span>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<span
|
<button
|
||||||
v-for="t in TYPES_ENTITE"
|
v-for="t in TYPES_ENTITE"
|
||||||
:key="t"
|
:key="t"
|
||||||
|
type="button"
|
||||||
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
:style="typesEntite.includes(t)
|
:style="typesEntite.includes(t)
|
||||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
:aria-pressed="typesEntite.includes(t)"
|
||||||
@click="toggleType(t)"
|
@click="toggleType(t)"
|
||||||
>{{ TYPES_ENTITE_LABELS[t] ?? t }}</span>
|
>{{ TYPES_ENTITE_LABELS[t] ?? t }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -254,22 +303,21 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) — désactivé V1 -->
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||||
<button
|
<button
|
||||||
v-if="false"
|
|
||||||
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||||
style="
|
style="
|
||||||
height: 48px;
|
height: 48px;
|
||||||
background: var(--nav-primary);
|
background: var(--nav-primary);
|
||||||
opacity: 0.5;
|
opacity: 0.92;
|
||||||
color: var(--nav-text-on-primary);
|
color: var(--nav-text-on-primary);
|
||||||
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||||
font-family: var(--nav-font);
|
font-family: var(--nav-font);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
"
|
"
|
||||||
aria-label="Chatbot (bientôt disponible)"
|
aria-label="Ouvrir l'assistant Chatbot"
|
||||||
disabled
|
@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">
|
<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"/>
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
@@ -277,6 +325,30 @@
|
|||||||
<span>Chatbot</span>
|
<span>Chatbot</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
|
||||||
|
<ChatbotSheet
|
||||||
|
:modelValue="chatbotOpen"
|
||||||
|
endpoint="/api/chatbot-pratiques"
|
||||||
|
title="Chatbot Pratiques régé"
|
||||||
|
ficheBasePath="/pratique"
|
||||||
|
@update:modelValue="chatbotOpen = $event"
|
||||||
|
@highlightOrgs="onHighlightOrgs"
|
||||||
|
>
|
||||||
|
<template #onboarding>
|
||||||
|
<p>Ce chatbot interroge la base des pratiques régénératives
|
||||||
|
(Mistral FR, serveur européen souverain, zéro rétention).</p>
|
||||||
|
<p>Pour t'aider à trouver les pratiques pertinentes,
|
||||||
|
formule ta requête ainsi :</p>
|
||||||
|
<ul>
|
||||||
|
<li>• Besoin : [matériaux biosourcés / réemploi / posture politique...]</li>
|
||||||
|
<li>• Type : [agence / coopérative / collectif / réseau...]</li>
|
||||||
|
<li>• Lieu : [pays ou région]</li>
|
||||||
|
</ul>
|
||||||
|
<p class="example">Exemple : "Je cherche une coopérative qui travaille
|
||||||
|
le réemploi de matériaux en Belgique."</p>
|
||||||
|
</template>
|
||||||
|
</ChatbotSheet>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -307,6 +379,28 @@ const pays = ref<string[]>(
|
|||||||
|
|
||||||
const selectedId = ref<number | null>(null)
|
const selectedId = ref<number | null>(null)
|
||||||
const mobileMapView = ref<'europe' | 'outremer'>('europe')
|
const mobileMapView = ref<'europe' | 'outremer'>('europe')
|
||||||
|
const desktopMapView = ref<'europe' | 'outremer'>('europe')
|
||||||
|
const chatbotOpen = ref(false)
|
||||||
|
|
||||||
|
// Surlignage temporaire (5 sec) suite a une reponse chatbot
|
||||||
|
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const prevSelectedId = ref<number | null>(null)
|
||||||
|
|
||||||
|
function onHighlightOrgs(ids: (number | string)[]) {
|
||||||
|
if (!ids.length) return
|
||||||
|
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
|
||||||
|
if (isNaN(firstId)) return
|
||||||
|
|
||||||
|
prevSelectedId.value = selectedId.value
|
||||||
|
selectedId.value = firstId
|
||||||
|
|
||||||
|
if (highlightTimer) clearTimeout(highlightTimer)
|
||||||
|
highlightTimer = setTimeout(() => {
|
||||||
|
selectedId.value = prevSelectedId.value
|
||||||
|
prevSelectedId.value = null
|
||||||
|
highlightTimer = null
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
// Refs vers les instances EuropeMap
|
// Refs vers les instances EuropeMap
|
||||||
const europeMapRef = ref<any>(null)
|
const europeMapRef = ref<any>(null)
|
||||||
@@ -367,6 +461,7 @@ const hasActiveFilters = computed(() =>
|
|||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
search.value = ''
|
search.value = ''
|
||||||
|
mobileSearch.value = ''
|
||||||
criteres.value = []
|
criteres.value = []
|
||||||
typesEntite.value = []
|
typesEntite.value = []
|
||||||
pays.value = []
|
pays.value = []
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="contribuer-inner">
|
<div class="contribuer-inner">
|
||||||
<!-- Retour -->
|
<!-- Retour -->
|
||||||
<NuxtLink to="/pratiques-regeneratives" class="back-link">
|
<NuxtLink to="/pratiques-regeneratives" class="back-link">
|
||||||
← Retour à la carte
|
← Retour aux pratiques régénératives
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- En-tête -->
|
<!-- En-tête -->
|
||||||
|
|||||||
127
scripts/vectorize-v2.cjs
Normal file
127
scripts/vectorize-v2.cjs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// scripts/vectorize-v2.js
|
||||||
|
// Usage : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
|
||||||
|
// Génère : server/data/embeddings-v2.json
|
||||||
|
//
|
||||||
|
// SETUP AVANT DEPLOY :
|
||||||
|
// cd nav-carte && MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
|
||||||
|
// Coût estimé : ~0.10 EUR pour 120 fiches
|
||||||
|
//
|
||||||
|
// Prérequis : Node >= 18 (fetch natif disponible)
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY
|
||||||
|
if (!MISTRAL_API_KEY) {
|
||||||
|
console.error('Erreur : MISTRAL_API_KEY manquante')
|
||||||
|
console.error('Usage : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataPath = path.join(process.cwd(), 'public', 'data', 'reseaux-bifurcation.json')
|
||||||
|
const outPath = path.join(process.cwd(), 'server', 'data', 'embeddings-v2.json')
|
||||||
|
|
||||||
|
// Créer server/data/ si absent
|
||||||
|
const outDir = path.dirname(outPath)
|
||||||
|
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true })
|
||||||
|
|
||||||
|
const rawData = fs.readFileSync(dataPath, 'utf-8')
|
||||||
|
const data = JSON.parse(rawData)
|
||||||
|
const structures = data.structures
|
||||||
|
|
||||||
|
if (!Array.isArray(structures) || structures.length === 0) {
|
||||||
|
console.error('Erreur : aucune structure trouvée dans reseaux-bifurcation.json')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function embedBatch(texts) {
|
||||||
|
const res = await fetch('https://api.mistral.ai/v1/embeddings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${MISTRAL_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'mistral-embed',
|
||||||
|
input: texts
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
throw new Error(`Mistral API error ${res.status}: ${err}`)
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
return json.data.map(d => d.embedding)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildText(s) {
|
||||||
|
const parts = [
|
||||||
|
s.nom,
|
||||||
|
s.description_courte ?? '',
|
||||||
|
(s.description_longue ?? '').slice(0, 800),
|
||||||
|
(s.hashtags ?? []).join(' '),
|
||||||
|
(s.sources ?? []).map(src => src.titre).join(' '),
|
||||||
|
(s.pensees ?? []).map(p => p.label).join(' ')
|
||||||
|
]
|
||||||
|
return parts.filter(Boolean).join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const embeddings = []
|
||||||
|
const BATCH_SIZE = 8 // Mistral embed : rate limit prudent
|
||||||
|
|
||||||
|
console.log(`Vectorisation de ${structures.length} structures (modele : mistral-embed)...`)
|
||||||
|
console.log(`Sortie : ${outPath}`)
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
for (let i = 0; i < structures.length; i += BATCH_SIZE) {
|
||||||
|
const batch = structures.slice(i, i + BATCH_SIZE)
|
||||||
|
const texts = batch.map(buildText)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const batchEmbeddings = await embedBatch(texts)
|
||||||
|
batch.forEach((s, j) => {
|
||||||
|
embeddings.push({
|
||||||
|
fiche_id: s.id,
|
||||||
|
nom: s.nom,
|
||||||
|
famille: s.famille_principale,
|
||||||
|
hashtags: s.hashtags ?? [],
|
||||||
|
embedding: batchEmbeddings[j],
|
||||||
|
text_preview: texts[j].slice(0, 300)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const batchNum = Math.floor(i / BATCH_SIZE) + 1
|
||||||
|
const totalBatches = Math.ceil(structures.length / BATCH_SIZE)
|
||||||
|
console.log(` Batch ${batchNum}/${totalBatches} OK (${batch.length} fiches)`)
|
||||||
|
// Pause rate limit entre batches
|
||||||
|
await new Promise(r => setTimeout(r, 200))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` Erreur batch ${i}-${i + BATCH_SIZE}:`, err.message)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
meta: {
|
||||||
|
total: embeddings.length,
|
||||||
|
model: 'mistral-embed',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
source: 'reseaux-bifurcation.json'
|
||||||
|
},
|
||||||
|
embeddings
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(outPath, JSON.stringify(output, null, 2), 'utf-8')
|
||||||
|
|
||||||
|
const sizeKb = Math.round(fs.statSync(outPath).size / 1024)
|
||||||
|
console.log()
|
||||||
|
console.log(`Done : ${embeddings.length} embeddings -> ${outPath}`)
|
||||||
|
console.log(`Taille : ${sizeKb} KB`)
|
||||||
|
console.log()
|
||||||
|
console.log('Prochaine etape : deployer le fichier sur le VPS avec les autres assets.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Erreur fatale :', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
304
server/api/chatbot-pratiques.post.ts
Normal file
304
server/api/chatbot-pratiques.post.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/chatbot-pratiques
|
||||||
|
*
|
||||||
|
* Chatbot semantique sur la base des pratiques regeneratives (JSON statique).
|
||||||
|
* Adapte du endpoint /api/chatbot (ecosysteme AEP NocoDB).
|
||||||
|
*
|
||||||
|
* Flow :
|
||||||
|
* 1. Rate limit : 10 req/IP/jour (JSON fichier, SHA-256)
|
||||||
|
* 2. Circuit breaker : budget 20 EUR/mois partage avec /api/chatbot
|
||||||
|
* 3. Lit public/data/pratiques-regeneratives.json (52 fiches V1)
|
||||||
|
* 4. Score keyword puis top 20 fiches en contexte
|
||||||
|
* 5. Appel Mistral Small avec prompt systeme adapte aux pratiques
|
||||||
|
* 6. Parse JSON -> { reponse_texte, fiches_recommandees }
|
||||||
|
* 7. Log stats_usage
|
||||||
|
*
|
||||||
|
* Reponse 200 : { reponse_texte, fiches_recommandees: [{ id, nom, explication }] }
|
||||||
|
* Reponse 429 : rate limit depasse
|
||||||
|
* Reponse 503 : budget IA epuise
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
||||||
|
import { CRITERES, TYPES_ENTITE_LABELS, PAYS_LABELS } from '~/types/pratique'
|
||||||
|
import type { Pratique } from '~/types/pratique'
|
||||||
|
|
||||||
|
interface FicheReco {
|
||||||
|
id: number
|
||||||
|
nom: string
|
||||||
|
explication: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MistralResponse {
|
||||||
|
reponse_texte: string
|
||||||
|
fiches_recommandees: FicheReco[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt systeme dedie aux pratiques regeneratives.
|
||||||
|
// Difference avec /api/chatbot : on parle de pratiques, criteres rege (8 axes),
|
||||||
|
// types d'entites (agence, cooperative, collectif...), perimetre Europe + DOM-TOM.
|
||||||
|
const SYSTEM_PROMPT = `Tu es un assistant engage au service de la transition ecologique des pratiques architecturales. Tu accedes a la base AEP - Pratiques regeneratives, qui referencee les acteurs concrets de l'architecture regenerative en Europe et dans les DOM-TOM (agences, cooperatives, collectifs, reseaux, associations, plateformes, recherche).
|
||||||
|
|
||||||
|
CRITERES DE REGENERATION (8 axes utilises pour decrire chaque pratique) :
|
||||||
|
1. Materiaux (biosources, geosources, reemploi)
|
||||||
|
2. Filieres (locales, courtes, paysannes)
|
||||||
|
3. Posture (ethique, engagement politique, refus)
|
||||||
|
4. Process (collaboratif, participatif, lent)
|
||||||
|
5. Politique (lobbying, plaidoyer, contre-expertise)
|
||||||
|
6. Modele economique (cooperatif, low-tech, soutenable)
|
||||||
|
7. Vivant (biodiversite, sols, eau)
|
||||||
|
8. Transmission (formation, partage, pedagogie)
|
||||||
|
|
||||||
|
REGLES ABSOLUES :
|
||||||
|
1. Tu ne peux recommander QUE des pratiques presentes dans le contexte ci-dessous.
|
||||||
|
2. Ne jamais inventer une pratique absente du contexte.
|
||||||
|
3. Cite chaque pratique recommandee par son nom exact et son identifiant id.
|
||||||
|
4. Si le contexte ne contient aucune pratique pertinente, dis-le honnetement.
|
||||||
|
5. Reponses concises (200 mots max). Si l'usager demande explicitement plus de detail, tu peux developper.
|
||||||
|
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou apres.
|
||||||
|
7. Si la question est hors du champ architecture / ecologie / regeneration / territoire, recadre poliment.
|
||||||
|
|
||||||
|
FORMAT DE SORTIE :
|
||||||
|
{
|
||||||
|
"reponse_texte": "Ta reponse en prose (max 200 mots)",
|
||||||
|
"fiches_recommandees": [
|
||||||
|
{ "id": 12, "explication": "Pourquoi cette pratique repond a la question (1-2 phrases max)" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
CONTEXTE - Pratiques regeneratives disponibles :
|
||||||
|
{{FICHES_JSON}}`
|
||||||
|
|
||||||
|
function scorePratique(p: Pratique, keywords: string[]): number {
|
||||||
|
if (keywords.length === 0) return 1
|
||||||
|
const critereLabels = (p.criteres ?? [])
|
||||||
|
.map((cId) => CRITERES.find((c) => c.id === cId)?.label ?? '')
|
||||||
|
.join(' ')
|
||||||
|
const haystack = [
|
||||||
|
p.nom,
|
||||||
|
p.description,
|
||||||
|
p.ville,
|
||||||
|
p.type ? (TYPES_ENTITE_LABELS[p.type] ?? p.type) : '',
|
||||||
|
p.pays ? (PAYS_LABELS[p.pays] ?? p.pays) : '',
|
||||||
|
critereLabels,
|
||||||
|
(p.tags ?? []).join(' '),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
return keywords.reduce((score, kw) => score + (haystack.includes(kw) ? 1 : 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractKeywords(question: string): string[] {
|
||||||
|
return question
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\sàâäéèêëîïôùûüç-]/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((w) => w.length >= 3)
|
||||||
|
.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPratiques(): Pratique[] {
|
||||||
|
try {
|
||||||
|
const jsonPath = resolve(process.cwd(), 'public/data/pratiques-regeneratives.json')
|
||||||
|
const raw = readFileSync(jsonPath, 'utf-8')
|
||||||
|
return JSON.parse(raw) as Pratique[]
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[chatbot-pratiques] Erreur lecture JSON:', (e as Error).message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logUsage(params: {
|
||||||
|
nocodbUrl: string
|
||||||
|
nocodbToken: string
|
||||||
|
statsTableId: string
|
||||||
|
tokensIn: number
|
||||||
|
tokensOut: number
|
||||||
|
coutEur: number
|
||||||
|
}) {
|
||||||
|
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params
|
||||||
|
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||||
|
try {
|
||||||
|
await $fetch(logUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'mistral-small-latest',
|
||||||
|
endpoint: 'chatbot-pratiques',
|
||||||
|
tokens_in: tokensIn,
|
||||||
|
tokens_out: tokensOut,
|
||||||
|
cout_eur: coutEur,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
orga_id: null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[chatbot-pratiques] Log stats_usage echoue (non bloquant):', (e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
// 1. IP (proxy-aware)
|
||||||
|
const ip =
|
||||||
|
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||||
|
event.node.req.socket?.remoteAddress ||
|
||||||
|
'0.0.0.0'
|
||||||
|
|
||||||
|
// 2. Rate limit : 10 req/IP/jour (compteur dedie chatbot-pratiques)
|
||||||
|
const allowed = checkRateLimitJson(ip, 'chatbot-pratiques', 10)
|
||||||
|
if (!allowed) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: 'Limite de 10 questions par jour atteinte.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Lire le 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.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Circuit breaker budget partage
|
||||||
|
const statsTableId = process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc'
|
||||||
|
const budget = await checkBudget({
|
||||||
|
nocodbUrl: config.nocodbUrl as string,
|
||||||
|
nocodbToken: config.nocodbToken as string,
|
||||||
|
statsTableId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (budget.blocked) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 503,
|
||||||
|
statusMessage: 'Budget IA mensuel epuise - reouverture le 1er du mois prochain.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Charger pratiques + scoring
|
||||||
|
const allPratiques = loadPratiques()
|
||||||
|
if (allPratiques.length === 0) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 503,
|
||||||
|
statusMessage: 'Donnees pratiques indisponibles.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywords = extractKeywords(question)
|
||||||
|
|
||||||
|
const scored = allPratiques
|
||||||
|
.map((p) => ({ pratique: p, score: scorePratique(p, keywords) }))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((x) => x.pratique)
|
||||||
|
|
||||||
|
const fichesContext = scored.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
nom: p.nom,
|
||||||
|
type: p.type ? (TYPES_ENTITE_LABELS[p.type] ?? p.type) : '',
|
||||||
|
pays: p.pays ? (PAYS_LABELS[p.pays] ?? p.pays) : '',
|
||||||
|
ville: p.ville ?? '',
|
||||||
|
criteres: (p.criteres ?? [])
|
||||||
|
.map((cId) => CRITERES.find((c) => c.id === cId)?.label ?? '')
|
||||||
|
.filter(Boolean),
|
||||||
|
description: (p.description ?? '').slice(0, 250),
|
||||||
|
tags: (p.tags ?? []).slice(0, 5),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const systemPrompt = SYSTEM_PROMPT.replace(
|
||||||
|
'{{FICHES_JSON}}',
|
||||||
|
JSON.stringify(fichesContext, null, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 6. Appel Mistral Small
|
||||||
|
const mistralApiKey = config.mistralApiKey as string
|
||||||
|
|
||||||
|
if (!mistralApiKey) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Cle API Mistral manquante.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let mistralRaw: string
|
||||||
|
let tokensIn = 0
|
||||||
|
let tokensOut = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mistralRes = await $fetch<{
|
||||||
|
choices: { message: { content: string } }[]
|
||||||
|
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||||
|
}>('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 ?? '{}'
|
||||||
|
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
|
||||||
|
tokensOut = mistralRes.usage?.completion_tokens ?? 0
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[chatbot-pratiques] Erreur Mistral Small:', e?.message ?? e)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
statusMessage: 'Erreur appel IA - reessaie dans quelques instants.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Parse JSON
|
||||||
|
let parsed: MistralResponse
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(mistralRaw)
|
||||||
|
parsed = {
|
||||||
|
reponse_texte: raw.reponse_texte ?? "Je n'ai pas pu analyser ta demande.",
|
||||||
|
fiches_recommandees: (raw.fiches_recommandees ?? []).map((f: any) => {
|
||||||
|
const p = scored.find((x) => x.id === f.id)
|
||||||
|
return {
|
||||||
|
id: f.id,
|
||||||
|
nom: p?.nom ?? f.nom ?? `Fiche #${f.id}`,
|
||||||
|
explication: f.explication ?? '',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
parsed = {
|
||||||
|
reponse_texte: "Je n'ai pas pu analyser ta demande correctement.",
|
||||||
|
fiches_recommandees: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Log usage (non bloquant)
|
||||||
|
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut)
|
||||||
|
logUsage({
|
||||||
|
nocodbUrl: config.nocodbUrl as string,
|
||||||
|
nocodbToken: config.nocodbToken as string,
|
||||||
|
statsTableId,
|
||||||
|
tokensIn,
|
||||||
|
tokensOut,
|
||||||
|
coutEur,
|
||||||
|
})
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user