- Page pages/pensees-ecologiques.vue → pages/media.vue (titre "ATIS Média")
- Labels onglet/menu "Pensées" → "Média" (app.vue, agences, index, filters)
- auteurs-pensees.json reconciled avec 141 docs LightRAG (était 27)
· 28 auteurs (était 18), 64 livres, slugs corrigés (ex: bookchin-ecologie-liberte)
· 12 écoles: 8 familles FRACAS Bonpote + 4 extensions ATIS
· Labels alignés Bonpote: Écologies libertaires (ex eco-anarchisme),
Écologies anti-industrielles (ex technocritique)
· Familles Bonpote ajoutées: Capitalisme vert + Écofascismes
(corpus_status: non_ingere — fidélité carte, critique éditoriale assumée)
V2 Phase 2.3 — corpus réel reflété, alignement Bonpote initial
643 lines
26 KiB
Vue
643 lines
26 KiB
Vue
<template>
|
|
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
|
|
|
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (≥ 1024px) -->
|
|
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
|
|
<NavSidebar
|
|
:search="search"
|
|
:modeValue="territoireMode"
|
|
:echelle="echelle"
|
|
:fonctions="fonctions"
|
|
:territoire="territoire"
|
|
:echelleCount="echelleCount"
|
|
:fonctionCount="fonctionCount"
|
|
:territoireCount="territoireCount"
|
|
:resultCount="filtered.length"
|
|
:orgs="filtered"
|
|
:selectedId="selectedId"
|
|
:hasActiveFilters="hasActiveFilters"
|
|
:pending="pending"
|
|
@update:search="onSearch"
|
|
@update:mode="onMode"
|
|
@update:echelle="onEchelle"
|
|
@update:fonctions="onFonctions"
|
|
@update:territoire="onTerritoire"
|
|
@select-org="onSelectOrg"
|
|
@hover-org="onHoverOrg"
|
|
@reset-filters="resetFilters"
|
|
/>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
|
<main class="flex-1 flex flex-col overflow-hidden relative">
|
|
|
|
<!-- Indicateur source dev -->
|
|
<div
|
|
v-if="dataSource === 'seed'"
|
|
class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
|
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
|
>
|
|
Mode dev — données seed
|
|
</div>
|
|
|
|
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer ── -->
|
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
|
<!-- Barre onglets desktop -->
|
|
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
|
<button
|
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
:style="desktopMapView === 'metropole'
|
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
@click="desktopMapView = 'metropole'"
|
|
>Métropolitain</button>
|
|
<button
|
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
:style="desktopMapView === 'outremer'
|
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
@click="desktopMapView = 'outremer'"
|
|
>Outre-mer</button>
|
|
</div>
|
|
|
|
<!-- Carte Métropole desktop -->
|
|
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
|
<div class="relative flex-1" style="min-height: 200px;">
|
|
<ClientOnly>
|
|
<NavMap
|
|
ref="navMapRef"
|
|
:orgs="metropoleOrgs"
|
|
:selectedId="selectedId"
|
|
@select-org="onSelectOrg"
|
|
/>
|
|
<template #fallback>
|
|
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
|
|
</template>
|
|
</ClientOnly>
|
|
</div>
|
|
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
|
</div>
|
|
|
|
<!-- Carte Outre-mer desktop -->
|
|
<div v-show="desktopMapView === 'outremer'" class="flex-1 flex flex-col overflow-hidden">
|
|
<div class="flex-1 overflow-y-auto">
|
|
<ClientOnly>
|
|
<OutremerMap
|
|
:orgs="outremerOrgs"
|
|
:selectedId="selectedId"
|
|
@select-org="onSelectOrg"
|
|
/>
|
|
<template #fallback>
|
|
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
|
|
</template>
|
|
</ClientOnly>
|
|
</div>
|
|
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
|
|
|
<!-- Onglets Métropolitain / Outre-mer -->
|
|
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
|
<button
|
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
:style="mobileMapView === 'metropole'
|
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
@click="mobileMapView = 'metropole'"
|
|
>Métropolitain</button>
|
|
<button
|
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
:style="mobileMapView === 'outremer'
|
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
@click="mobileMapView = 'outremer'"
|
|
>Outre-mer</button>
|
|
</div>
|
|
|
|
<div class="lg:hidden flex-1 relative overflow-hidden">
|
|
|
|
<!-- Carte Métropole -->
|
|
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
|
|
<ClientOnly>
|
|
<NavMap
|
|
ref="navMapMobileRef"
|
|
:orgs="metropoleOrgs"
|
|
:selectedId="selectedId"
|
|
@select-org="onSelectOrgMobile"
|
|
/>
|
|
<template #fallback>
|
|
<div
|
|
class="w-full h-full flex items-center justify-center"
|
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
>
|
|
Chargement de la carte…
|
|
</div>
|
|
</template>
|
|
</ClientOnly>
|
|
</div>
|
|
|
|
<!-- Carte Outre-mer (scroll vertical, pleine largeur) -->
|
|
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
|
<ClientOnly>
|
|
<OutremerMap
|
|
:orgs="outremerOrgs"
|
|
:selectedId="selectedId"
|
|
@select-org="onSelectOrgMobile"
|
|
/>
|
|
<template #fallback>
|
|
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
|
Chargement…
|
|
</div>
|
|
</template>
|
|
</ClientOnly>
|
|
</div>
|
|
|
|
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
|
|
<ClientOnly>
|
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
|
<!-- Barre recherche -->
|
|
<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">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
|
<circle cx="11" cy="11" r="8"/>
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
</svg>
|
|
<input
|
|
v-model="mobileSearch"
|
|
type="search"
|
|
placeholder="Rechercher…"
|
|
class="mobile-search-input"
|
|
autocomplete="off"
|
|
@input="onSearch(mobileSearch)"
|
|
/>
|
|
<button
|
|
v-if="mobileSearch"
|
|
type="button"
|
|
class="mobile-search-clear"
|
|
aria-label="Effacer"
|
|
@click.stop="mobileSearch = ''; onSearch('')"
|
|
>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</label>
|
|
|
|
<!-- Filtres ÉCHELLE — chips style FONCTION -->
|
|
<div class="mt-2">
|
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
|
|
<div class="flex flex-wrap gap-1">
|
|
<span
|
|
v-for="opt in ECHELLES"
|
|
:key="opt"
|
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
|
:style="echelle.includes(opt)
|
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
|
@click="toggleEchelle(opt)"
|
|
>{{ opt }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filtres FONCTION — chips flex-wrap + toggle collapse -->
|
|
<div class="mt-2">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
|
<span class="text-xs font-bold uppercase tracking-wide" style="color: var(--nav-text-muted);">
|
|
FONCTION
|
|
<span v-if="fonctions.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ fonctions.length }} active{{ fonctions.length > 1 ? 's' : '' }})</span>
|
|
</span>
|
|
<button
|
|
@click="mobileFonctionsOpen = !mobileFonctionsOpen"
|
|
style="font-size: 0.65rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
|
|
>{{ mobileFonctionsOpen || fonctions.length ? (mobileFonctionsOpen ? 'Replier' : 'Afficher') : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
|
|
</div>
|
|
<div v-if="mobileFonctionsOpen || fonctions.length" class="flex flex-wrap gap-1">
|
|
<span
|
|
v-for="fn in FONCTIONS"
|
|
:key="fn"
|
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
|
:style="fonctions.includes(fn)
|
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
|
@click="toggleFonction(fn)"
|
|
>{{ fn }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
v-if="hasActiveFilters"
|
|
@click="resetFilters"
|
|
class="mt-2 text-xs"
|
|
style="color: var(--nav-text-muted); text-decoration: underline;"
|
|
>✕ Effacer les filtres</button>
|
|
</div>
|
|
|
|
<!-- Compteur + Liste fiches -->
|
|
<div class="px-3 py-2">
|
|
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
|
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
|
|
</div>
|
|
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
|
Chargement des fiches…
|
|
</div>
|
|
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
|
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
|
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
|
Effacer les filtres
|
|
</button>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div
|
|
v-for="org in filtered"
|
|
:key="org.Id"
|
|
class="block rounded-lg p-3 transition-all cursor-pointer"
|
|
:style="selectedId === org.Id
|
|
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
|
|
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
|
@click="onSelectOrgMobile(org.Id)"
|
|
>
|
|
<div class="flex items-start justify-between gap-2">
|
|
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
|
|
<span
|
|
v-if="org.echelle"
|
|
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
|
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
>{{ org.echelle }}</span>
|
|
</div>
|
|
<div v-if="fonctionsList(org).length" class="mt-1 flex flex-wrap gap-1">
|
|
<span
|
|
v-for="fn in fonctionsList(org)"
|
|
:key="fn"
|
|
class="px-1.5 py-0.5 rounded text-xs"
|
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
>{{ fn }}</span>
|
|
</div>
|
|
<div v-if="org.localisation_ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
|
|
{{ org.localisation_ville }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</MobileSheet>
|
|
</ClientOnly>
|
|
</div>
|
|
|
|
</main>
|
|
|
|
<!-- ═══════════════════════════════════════ MODAL FICHE (desktop) -->
|
|
<FicheModal
|
|
v-model="ficheModalOpen"
|
|
:orgId="ficheModalId"
|
|
/>
|
|
|
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
|
<button
|
|
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
|
style="
|
|
height: 48px;
|
|
background: var(--nav-primary);
|
|
opacity: 0.92;
|
|
color: var(--nav-text-on-primary);
|
|
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
|
font-family: var(--nav-font);
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
"
|
|
aria-label="Ouvrir l'assistant Chatbot"
|
|
@click="chatbotOpen = true"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
</svg>
|
|
<span>Chatbot</span>
|
|
</button>
|
|
|
|
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
|
|
<ChatbotSheet
|
|
:modelValue="chatbotOpen"
|
|
@update:modelValue="chatbotOpen = $event"
|
|
@highlightOrgs="onHighlightOrgs"
|
|
/>
|
|
|
|
<!-- ═══════════════════════════════════════ POP-UP MISSION ENTRAIDE -->
|
|
<button
|
|
class="mission-info-btn"
|
|
type="button"
|
|
@click="missionOpen = true"
|
|
aria-label="À propos de cette carte d'entraide"
|
|
title="À propos de cette carte"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<MissionPopup
|
|
:modelValue="missionOpen"
|
|
@update:modelValue="missionOpen = $event"
|
|
/>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Org } from '~/types/org'
|
|
|
|
// ── URL query params sync ─────────────────────────────────────────────────
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const search = ref<string>((route.query.q as string) ?? '')
|
|
const echelle = ref<string[]>(
|
|
route.query.echelle
|
|
? (route.query.echelle as string).split(',').filter(Boolean)
|
|
: []
|
|
)
|
|
const fonctions = ref<string[]>(
|
|
route.query.fonctions
|
|
? (route.query.fonctions as string).split(',').filter(Boolean)
|
|
: []
|
|
)
|
|
const territoire = ref<string | null>((route.query.territoire as string) ?? null)
|
|
const territoireMode = ref<string>(
|
|
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
|
|
)
|
|
|
|
const desktopMapView = ref<'metropole' | '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')
|
|
const missionOpen = ref(false)
|
|
const mobileFonctionsOpen = ref(false)
|
|
|
|
onMounted(() => {
|
|
try {
|
|
if (!localStorage.getItem('aep_mission_seen')) {
|
|
missionOpen.value = true
|
|
}
|
|
} catch {}
|
|
})
|
|
// 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)
|
|
const mobileSearch = ref<string>((route.query.q as string) ?? '')
|
|
|
|
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
|
|
const navMapRef = ref<any>(null)
|
|
const navMapMobileRef = ref<any>(null)
|
|
|
|
// Sync URL <-> état filtres
|
|
function syncUrl() {
|
|
const q: Record<string, string> = {}
|
|
if (search.value) q.q = search.value
|
|
if (echelle.value.length) q.echelle = echelle.value.join(',')
|
|
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
|
if (territoire.value) q.territoire = territoire.value
|
|
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
|
router.replace({ query: Object.keys(q).length ? q : undefined })
|
|
}
|
|
|
|
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
|
|
function storeFiltersForBack() {
|
|
if (typeof window === 'undefined') return
|
|
const q: Record<string, string> = {}
|
|
if (search.value) q.q = search.value
|
|
if (echelle.value.length) q.echelle = echelle.value.join(',')
|
|
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
|
if (territoire.value) q.territoire = territoire.value
|
|
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
|
const qs = new URLSearchParams(q).toString()
|
|
sessionStorage.setItem('nav_back_filters', qs)
|
|
}
|
|
|
|
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
|
|
function onMode(v: string) { territoireMode.value = v; syncUrl(); storeFiltersForBack() }
|
|
function onEchelle(v: string[]) { echelle.value = v; syncUrl(); storeFiltersForBack() }
|
|
function onFonctions(v: string[]) { fonctions.value = v; syncUrl(); storeFiltersForBack() }
|
|
function onTerritoire(v: string | null) { territoire.value = v; syncUrl(); storeFiltersForBack() }
|
|
|
|
function onSelectOrg(id: number) {
|
|
selectedId.value = selectedId.value === id ? null : id
|
|
// Desktop : ouvrir le modal fiche
|
|
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
|
ficheModalId.value = id
|
|
ficheModalOpen.value = true
|
|
}
|
|
}
|
|
|
|
// Tap card mobile → ouvre la fiche détaillée
|
|
function onSelectOrgMobile(id: number) {
|
|
selectedId.value = id
|
|
storeFiltersForBack()
|
|
router.push(`/fiche/${id}`)
|
|
}
|
|
|
|
function onHoverOrg(id: number | null) {
|
|
if (id !== null) selectedId.value = id
|
|
}
|
|
|
|
const hasActiveFilters = computed(() =>
|
|
!!search.value || echelle.value.length > 0 || fonctions.value.length > 0 || !!territoire.value
|
|
)
|
|
|
|
function resetFilters() {
|
|
search.value = ''
|
|
echelle.value = []
|
|
fonctions.value = []
|
|
territoire.value = null
|
|
router.replace({ query: undefined })
|
|
}
|
|
|
|
// Tagging compact mobile — toggle direct
|
|
function toggleEchelle(opt: string) {
|
|
if (echelle.value.includes(opt)) {
|
|
onEchelle(echelle.value.filter(v => v !== opt))
|
|
} else {
|
|
onEchelle([...echelle.value, opt])
|
|
}
|
|
}
|
|
|
|
function toggleFonction(fn: string) {
|
|
if (fonctions.value.includes(fn)) {
|
|
onFonctions(fonctions.value.filter(f => f !== fn))
|
|
} else {
|
|
onFonctions([...fonctions.value, fn])
|
|
}
|
|
}
|
|
|
|
// Sync recherche depuis app.vue top nav (via URL ?q=)
|
|
watch(() => route.query.q, (v) => {
|
|
search.value = (v as string) ?? ''
|
|
})
|
|
|
|
// ── Données ───────────────────────────────────────────────────────────────
|
|
const { data, pending, error } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
|
|
|
|
const orgs = computed<Org[]>(() => data.value?.list ?? [])
|
|
const dataSource = computed(() => data.value?.source ?? 'nocodb')
|
|
|
|
// Fiche aléatoire — réagit au ?random=1
|
|
watch(() => route.query.random, (v) => {
|
|
if (v === '1' && orgs.value.length > 0) {
|
|
const randomOrg = orgs.value[Math.floor(Math.random() * orgs.value.length)]
|
|
router.replace({ path: `/fiche/${randomOrg.Id}` })
|
|
}
|
|
})
|
|
|
|
// ── Filtrage côté client ──────────────────────────────────────────────────
|
|
const filtered = computed<Org[]>(() => {
|
|
let result = orgs.value
|
|
|
|
if (search.value.trim()) {
|
|
const q = search.value.toLowerCase()
|
|
result = result.filter(
|
|
(o) =>
|
|
o.nom?.toLowerCase().includes(q) ||
|
|
o.localisation_ville?.toLowerCase().includes(q)
|
|
)
|
|
}
|
|
|
|
if (echelle.value.length) {
|
|
result = result.filter((o) => o.echelle && echelle.value.includes(o.echelle))
|
|
}
|
|
|
|
if (fonctions.value.length) {
|
|
// Garde les orgs qui matchent au moins 1 fonction sélectionnée
|
|
result = result.filter((o) => {
|
|
const orgFns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
|
return fonctions.value.some((fn) => orgFns.includes(fn))
|
|
})
|
|
// Tri par score pondéré : priorité 1 (1er cliqué) = poids le plus fort
|
|
const n = fonctions.value.length
|
|
const score = (o: Org) =>
|
|
fonctions.value.reduce((s, fn, i) => {
|
|
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
|
return s + (fns.includes(fn) ? (n - i) : 0)
|
|
}, 0)
|
|
result = [...result].sort((a, b) => score(b) - score(a))
|
|
}
|
|
|
|
if (territoire.value) {
|
|
result = result.filter((o) => o.territoire === territoire.value)
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
|
|
const DOM_TOM_LIST = DOM_TOM
|
|
|
|
const metropoleOrgs = computed<Org[]>(() =>
|
|
filtered.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire))
|
|
)
|
|
|
|
const outremerOrgs = computed<Org[]>(() => {
|
|
if (territoire.value && DOM_TOM.includes(territoire.value)) {
|
|
return filtered.value.filter(o => o.territoire === territoire.value)
|
|
}
|
|
return filtered.value.filter(o => o.territoire && DOM_TOM.includes(o.territoire))
|
|
})
|
|
|
|
const outremerCountByDom = computed<Record<string, number>>(() => {
|
|
const counts: Record<string, number> = {}
|
|
DOM_TOM.forEach(d => { counts[d] = 0 })
|
|
filtered.value.forEach(o => {
|
|
if (o.territoire && DOM_TOM.includes(o.territoire)) {
|
|
counts[o.territoire] = (counts[o.territoire] ?? 0) + 1
|
|
}
|
|
})
|
|
return counts
|
|
})
|
|
|
|
// ── Compteurs ─────────────────────────────────────────────────────────────
|
|
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
|
const ECHELLE_LABELS: Record<string, string> = { National: 'Nat', Régional: 'Rég', Local: 'Loc' }
|
|
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale'] as const
|
|
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
|
|
|
|
const echelleCount = computed<Record<string, number>>(() => {
|
|
const counts: Record<string, number> = {}
|
|
ECHELLES.forEach((e) => { counts[e] = 0 })
|
|
orgs.value.forEach((o) => { if (o.echelle) counts[o.echelle] = (counts[o.echelle] ?? 0) + 1 })
|
|
return counts
|
|
})
|
|
|
|
const fonctionCount = computed<Record<string, number>>(() => {
|
|
const counts: Record<string, number> = {}
|
|
FONCTIONS.forEach((f) => { counts[f] = 0 })
|
|
orgs.value.forEach((o) => {
|
|
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
|
fns.forEach((fn) => { counts[fn] = (counts[fn] ?? 0) + 1 })
|
|
})
|
|
return counts
|
|
})
|
|
|
|
const territoireCount = computed<Record<string, number>>(() => {
|
|
const counts: Record<string, number> = {}
|
|
TERRITOIRES.forEach((t) => { counts[t] = 0 })
|
|
orgs.value.forEach((o) => { if (o.territoire) counts[o.territoire] = (counts[o.territoire] ?? 0) + 1 })
|
|
counts['Métropole'] = orgs.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire)).length
|
|
return counts
|
|
})
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
function fonctionsList(org: Org): string[] {
|
|
return (org.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean).slice(0, 3)
|
|
}
|
|
|
|
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.mission-info-btn {
|
|
position: fixed;
|
|
bottom: 24px;
|
|
left: 16px;
|
|
z-index: 1000;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
border: none;
|
|
background: var(--nav-surface);
|
|
color: var(--nav-text-muted);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 2px 12px rgba(26,34,56,0.18);
|
|
cursor: pointer;
|
|
transition: opacity 0.15s, transform 0.1s;
|
|
}
|
|
.mission-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
|
|
|
@media (min-width: 1024px) {
|
|
.mission-info-btn { bottom: 16px; left: 340px; }
|
|
}
|
|
</style>
|