Files
nav-carte/pages/index.vue
Jules Neny fa32552864 feat(nav): restructure cartes + fixes UI
- pages/index.vue : restaurée Carte 1 entraide (NocoDB, 481L)
- pages/agences.vue : Carte 2 réseaux bifurcation + chatbot outre-mer
- app.vue : renommé "Agences Inspirantes" → "Réseaux AEP" (desktop + mobile)
- nuxt.config.ts : leaflet CSS global + cacheDir hors Dropbox
- NavMapV2.vue : double rAF pour init Leaflet après layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 23:57:34 +02:00

482 lines
19 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 : Métropole pleine largeur + DOM-TOM row en bas -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Carte Métropole pleine largeur -->
<div class="flex flex-col flex-1 overflow-hidden">
<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>
<!-- Bandeau DOM-TOM row horizontale pleine largeur, hauteur fixe -->
<div
class="shrink-0"
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
>
<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>
</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>
</div>
<!-- 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" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
</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)"
>
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
</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"
/>
</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 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')
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)
}
const mobileSearch = ref<string>((route.query.q as string) ?? '')
const navMapRef = ref<any>(null)
const navMapMobileRef = ref<any>(null)
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 })
}
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
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
ficheModalId.value = id
ficheModalOpen.value = true
}
}
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 })
}
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])
}
}
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')
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) {
result = result.filter((o) => {
const orgFns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
return fonctions.value.some((fn) => orgFns.includes(fn))
})
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
})
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
})
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>