chore: stage modifs V2 pre-cherry-pick
This commit is contained in:
4
.dropboxignore
Normal file
4
.dropboxignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
.nitro
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -170,10 +209,12 @@ async function sendMessage() {
|
|||||||
body: { question },
|
body: { question },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const suggestedHashtags = detectHashtagsFromQuery(question)
|
||||||
const assistantMsg: ChatMessage = {
|
const assistantMsg: ChatMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: res.reponse_texte,
|
content: res.reponse_texte,
|
||||||
fiches: res.fiches_recommandees || [],
|
fiches: res.fiches_recommandees || [],
|
||||||
|
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
|
||||||
}
|
}
|
||||||
messages.value.push(assistantMsg)
|
messages.value.push(assistantMsg)
|
||||||
|
|
||||||
|
|||||||
697
pages/index.vue
697
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
|
|
||||||
v-if="dataSource === 'seed'"
|
|
||||||
class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
|
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
|
||||||
>
|
|
||||||
Mode dev — données seed
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── VUE DESKTOP : Métropole pleine largeur + DOM-TOM row en bas ── -->
|
|
||||||
<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 Métropole — pleine largeur -->
|
<!-- Onglets desktop -->
|
||||||
<div class="flex flex-col flex-1 overflow-hidden">
|
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<button
|
||||||
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="desktopMapView === 'metropole'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="desktopMapView = 'metropole'"
|
||||||
|
>Métropolitain</button>
|
||||||
|
<button
|
||||||
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="desktopMapView === 'outremer'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="desktopMapView = 'outremer'"
|
||||||
|
>Outre-mer</button>
|
||||||
|
<button
|
||||||
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="desktopMapView === 'graphe'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="desktopMapView = 'graphe'"
|
||||||
|
>Vue graphique</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carte Métropole desktop -->
|
||||||
|
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
||||||
<div class="relative flex-1" style="min-height: 200px;">
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user