feat(entraide): / branché sur VueFiches ; NavSidebar, FichesPanel, MobileSheet et onglets remplacés (B11-M5)
Survol fiche ↔ épingle (cluster compris), invalidateSize au redimensionnement, titre accessible des épingles, ?random=1 fiable au chargement direct. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5a4c7af79c
commit
2e82d412ed
+46
-11
@@ -24,10 +24,14 @@ interface Org {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
orgs: Org[]
|
orgs: Org[]
|
||||||
selectedId?: number | null
|
selectedId?: number | null
|
||||||
|
/** Fiche survolée dans la liste (B11) : épingle safran, sans recentrer. */
|
||||||
|
survolId?: number | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'select-org': [id: number]
|
'select-org': [id: number]
|
||||||
|
/** Survol d'une épingle (B11) : la liste allume la carte-fiche. */
|
||||||
|
survol: [id: number | null]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const mapContainer = ref<HTMLElement | null>(null)
|
const mapContainer = ref<HTMLElement | null>(null)
|
||||||
@@ -35,14 +39,15 @@ let mapInstance: Map | null = null
|
|||||||
let clusterGroup: any = null
|
let clusterGroup: any = null
|
||||||
const markers = new Map<number, Marker>()
|
const markers = new Map<number, Marker>()
|
||||||
let tileLayerInstance: any = null
|
let tileLayerInstance: any = null
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
// Créer une DivIcon pour les pins personnalisés
|
// Créer une DivIcon pour les pins personnalisés
|
||||||
function createPinIcon(isPrioritaire: boolean, isSelected = false): DivIcon {
|
function createPinIcon(isPrioritaire: boolean, isSelected = false, isSurvol = false): DivIcon {
|
||||||
const L = (window as any).L
|
const L = (window as any).L
|
||||||
const bg = isPrioritaire ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
|
const bg = isSurvol || isPrioritaire ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
|
||||||
const border = isPrioritaire ? '#1a2238' : '#ffffff'
|
const border = isPrioritaire || isSurvol ? '#1a2238' : '#ffffff'
|
||||||
const size = isSelected ? 18 : 14
|
const size = isSelected || isSurvol ? 18 : 14
|
||||||
const shadow = isSelected ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
|
const shadow = isSelected || isSurvol ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
|
||||||
|
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
@@ -76,6 +81,7 @@ async function initMap() {
|
|||||||
;(window as any).L = L
|
;(window as any).L = L
|
||||||
// @ts-ignore — étend L.MarkerClusterGroup en side effect
|
// @ts-ignore — étend L.MarkerClusterGroup en side effect
|
||||||
await import('leaflet.markercluster')
|
await import('leaflet.markercluster')
|
||||||
|
if (!mapContainer.value?.isConnected) return // page quittée pendant le chargement (ex. ?random=1)
|
||||||
const MarkerClusterGroup = L.MarkerClusterGroup
|
const MarkerClusterGroup = L.MarkerClusterGroup
|
||||||
|
|
||||||
mapInstance = L.map(mapContainer.value, {
|
mapInstance = L.map(mapContainer.value, {
|
||||||
@@ -125,6 +131,14 @@ async function initMap() {
|
|||||||
|
|
||||||
mapInstance.addLayer(clusterGroup)
|
mapInstance.addLayer(clusterGroup)
|
||||||
updateMarkers(L)
|
updateMarkers(L)
|
||||||
|
|
||||||
|
// B11 : la carte change de largeur sans que la fenêtre bouge (Mixte ↔ Carte ↔ Fiches)
|
||||||
|
resizeObserver = new ResizeObserver(() => mapInstance?.invalidateSize({ pan: false }))
|
||||||
|
resizeObserver.observe(mapContainer.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconeDe(org: Org): DivIcon {
|
||||||
|
return createPinIcon(!!org.prioritaire, org.Id === props.selectedId, org.Id === props.survolId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMarkers(L?: any) {
|
function updateMarkers(L?: any) {
|
||||||
@@ -141,10 +155,7 @@ function updateMarkers(L?: any) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
orgsWithCoords.forEach((org) => {
|
orgsWithCoords.forEach((org) => {
|
||||||
const isSelected = org.Id === props.selectedId
|
const marker = leaflet.marker([org.latitude!, org.longitude!], { icon: iconeDe(org), title: org.nom })
|
||||||
const icon = createPinIcon(!!org.prioritaire, isSelected)
|
|
||||||
|
|
||||||
const marker = leaflet.marker([org.latitude!, org.longitude!], { icon })
|
|
||||||
|
|
||||||
const fonctions = org.tags_fonction
|
const fonctions = org.tags_fonction
|
||||||
? org.tags_fonction.split(',').map((f: string) => f.trim()).filter(Boolean).slice(0, 2).join(', ')
|
? org.tags_fonction.split(',').map((f: string) => f.trim()).filter(Boolean).slice(0, 2).join(', ')
|
||||||
@@ -163,6 +174,8 @@ function updateMarkers(L?: any) {
|
|||||||
`, { maxWidth: 240 })
|
`, { maxWidth: 240 })
|
||||||
|
|
||||||
marker.on('click', () => emit('select-org', org.Id))
|
marker.on('click', () => emit('select-org', org.Id))
|
||||||
|
marker.on('mouseover', () => emit('survol', org.Id))
|
||||||
|
marker.on('mouseout', () => emit('survol', null))
|
||||||
|
|
||||||
markers.set(org.Id, marker)
|
markers.set(org.Id, marker)
|
||||||
clusterGroup.addLayer(marker)
|
clusterGroup.addLayer(marker)
|
||||||
@@ -189,7 +202,7 @@ watch(
|
|||||||
const oldMarker = markers.get(oldId)
|
const oldMarker = markers.get(oldId)
|
||||||
const oldOrg = props.orgs.find(o => o.Id === oldId)
|
const oldOrg = props.orgs.find(o => o.Id === oldId)
|
||||||
if (oldMarker && oldOrg) {
|
if (oldMarker && oldOrg) {
|
||||||
oldMarker.setIcon(createPinIcon(!!oldOrg.prioritaire, false))
|
oldMarker.setIcon(iconeDe(oldOrg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Mettre en avant le nouveau marker
|
// Mettre en avant le nouveau marker
|
||||||
@@ -197,7 +210,7 @@ watch(
|
|||||||
const newMarker = markers.get(newId)
|
const newMarker = markers.get(newId)
|
||||||
const newOrg = props.orgs.find(o => o.Id === newId)
|
const newOrg = props.orgs.find(o => o.Id === newId)
|
||||||
if (newMarker && newOrg) {
|
if (newMarker && newOrg) {
|
||||||
newMarker.setIcon(createPinIcon(!!newOrg.prioritaire, true))
|
newMarker.setIcon(iconeDe(newOrg))
|
||||||
// Centrer si visible
|
// Centrer si visible
|
||||||
const latLng = newMarker.getLatLng()
|
const latLng = newMarker.getLatLng()
|
||||||
mapInstance.panTo(latLng, { animate: true })
|
mapInstance.panTo(latLng, { animate: true })
|
||||||
@@ -206,6 +219,27 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Survol depuis la liste : épingle safran, sans recentrer la carte
|
||||||
|
watch(
|
||||||
|
() => props.survolId,
|
||||||
|
(newId, oldId) => {
|
||||||
|
for (const id of [oldId, newId]) {
|
||||||
|
if (id == null) continue
|
||||||
|
const m = markers.get(id)
|
||||||
|
const o = props.orgs.find(x => x.Id === id)
|
||||||
|
if (m && o) m.setIcon(iconeDe(o))
|
||||||
|
allumerCluster(m, id === newId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Épingle regroupée dans un cluster : c'est le cluster visible qui s'allume
|
||||||
|
function allumerCluster(m: Marker | undefined, on: boolean) {
|
||||||
|
const parent = m && clusterGroup?.getVisibleParent(m)
|
||||||
|
const el = parent && parent !== m ? (parent as any)._icon?.firstElementChild as HTMLElement | undefined : undefined
|
||||||
|
if (el) el.style.boxShadow = on ? '0 0 0 4px #f5b342' : ''
|
||||||
|
}
|
||||||
|
|
||||||
// Watcher dark mode — switch tuile CartoDB light_all ↔ dark_all
|
// Watcher dark mode — switch tuile CartoDB light_all ↔ dark_all
|
||||||
function updateTileTheme(dark: boolean) {
|
function updateTileTheme(dark: boolean) {
|
||||||
if (!mapInstance || !tileLayerInstance) return
|
if (!mapInstance || !tileLayerInstance) return
|
||||||
@@ -235,6 +269,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
themeObserver?.disconnect()
|
themeObserver?.disconnect()
|
||||||
|
resizeObserver?.disconnect()
|
||||||
if (mapInstance) {
|
if (mapInstance) {
|
||||||
mapInstance.remove()
|
mapInstance.remove()
|
||||||
mapInstance = null
|
mapInstance = null
|
||||||
|
|||||||
+120
-571
@@ -1,344 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
<div class="relative h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (≥ 1024px) -->
|
<!-- Indicateur source dev -->
|
||||||
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
|
<div
|
||||||
<NavSidebar
|
v-if="dataSource === 'seed'"
|
||||||
:search="search"
|
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1002] px-2 py-1 rounded text-xs pointer-events-none"
|
||||||
:modeValue="territoireMode"
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
:echelle="echelle"
|
>
|
||||||
:fonctions="fonctions"
|
Mode dev — données seed
|
||||||
: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>
|
</div>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
<!-- ═══════════════════════════════════════ VUE FICHES (B11) : Carte · Mixte · Fiches -->
|
||||||
<main class="flex-1 flex flex-col overflow-hidden relative">
|
<VueFiches
|
||||||
|
:vf="vf"
|
||||||
<!-- Indicateur source dev -->
|
intention="Les structures d'entraide du métier, y compris celles sans adresse sur la carte."
|
||||||
<div
|
libelle-recherche="Rechercher une structure…"
|
||||||
v-if="dataSource === 'seed'"
|
:pending="pending"
|
||||||
class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
|
:selected-id="selectedId"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
@select="(f) => onSelectOrg(Number(f.id))"
|
||||||
>
|
>
|
||||||
Mode dev — données seed
|
<template #carte>
|
||||||
</div>
|
<!-- Métropole -->
|
||||||
|
<div v-show="vf.etat.sousVue === 'metropole'" class="relative flex-1 min-h-0">
|
||||||
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer / Toutes les fiches ── -->
|
|
||||||
<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="setDesktopView('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="setDesktopView('outremer')"
|
|
||||||
>Outre-mer</button>
|
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'fiches'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setDesktopView('fiches')"
|
|
||||||
>Toutes les fiches</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>
|
|
||||||
|
|
||||||
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — remplace la carte,
|
|
||||||
fiches sans géoloc incluses (gain principal de cette mission) -->
|
|
||||||
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="shrink-0 px-4 py-2"
|
|
||||||
style="border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface);"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="text-xs font-medium underline"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
@click="setDesktopView('metropole')"
|
|
||||||
>← Carte</button>
|
|
||||||
</div>
|
|
||||||
<FichesPanel
|
|
||||||
:fiches="allFichesMini"
|
|
||||||
:hashtags="[...FONCTIONS]"
|
|
||||||
:selected="fonctions"
|
|
||||||
:query="search"
|
|
||||||
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
|
|
||||||
@update:selected="onFonctions"
|
|
||||||
@open="(fiche) => onSelectOrg(Number(fiche.id))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
|
||||||
|
|
||||||
<!-- Onglets Métropolitain / Outre-mer / Toutes les fiches -->
|
|
||||||
<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="setMobileView('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="setMobileView('outremer')"
|
|
||||||
>Outre-mer</button>
|
|
||||||
<button
|
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="mobileMapView === 'fiches'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setMobileView('fiches')"
|
|
||||||
>Fiches</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
|
||||||
|
|
||||||
<!-- Carte Métropole -->
|
|
||||||
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<NavMap
|
<NavMap
|
||||||
ref="navMapMobileRef"
|
|
||||||
:orgs="metropoleOrgs"
|
:orgs="metropoleOrgs"
|
||||||
:selectedId="selectedId"
|
:selectedId="selectedId"
|
||||||
@select-org="onSelectOrgMobile"
|
:survolId="survolNum"
|
||||||
|
@select-org="onSelectOrg"
|
||||||
|
@survol="(id) => (vf.survolId = id)"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div
|
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
|
||||||
class="w-full h-full flex items-center justify-center"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
||||||
>
|
|
||||||
Chargement de la carte…
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Carte Outre-mer (scroll vertical, pleine largeur) -->
|
<!-- Outre-mer -->
|
||||||
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
<div v-show="vf.etat.sousVue === 'outremer'" class="flex-1 min-h-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<OutremerMap
|
<OutremerMap
|
||||||
:orgs="outremerOrgs"
|
:orgs="outremerOrgs"
|
||||||
:selectedId="selectedId"
|
:selectedId="selectedId"
|
||||||
@select-org="onSelectOrgMobile"
|
@select-org="onSelectOrg"
|
||||||
/>
|
/>
|
||||||
<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 text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
|
||||||
Chargement…
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
|
<!-- Chatbot sous la carte (ordinateur) -->
|
||||||
pas de carte dessous donc pas de sheet demi-hauteur (même logique que
|
<div class="hidden lg:block shrink-0">
|
||||||
la vue graphe qui masque aussi le MobileSheet). Fix 2026-09-25 : la
|
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||||
version précédente empilait un second MobileSheet (état half par
|
|
||||||
défaut) au-dessus d'un fond neutre plein écran, laissant ~45% de
|
|
||||||
hauteur vide au-dessus du panneau. -->
|
|
||||||
<div
|
|
||||||
v-show="mobileMapView === 'fiches'"
|
|
||||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
|
||||||
style="background: var(--nav-bg-alt);"
|
|
||||||
>
|
|
||||||
<FichesPanel
|
|
||||||
:fiches="allFichesMini"
|
|
||||||
:hashtags="[...FONCTIONS]"
|
|
||||||
:selected="fonctions"
|
|
||||||
:query="search"
|
|
||||||
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
|
|
||||||
@update:selected="onFonctions"
|
|
||||||
@open="(fiche) => onSelectOrgMobile(Number(fiche.id))"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</VueFiches>
|
||||||
|
|
||||||
<!-- Bottom sheet swipable (Métropole et Outre-mer uniquement) -->
|
<!-- ═══════════════════════════════════════ MODAL FICHE (ordinateur) -->
|
||||||
<ClientOnly v-if="mobileMapView !== 'fiches'">
|
|
||||||
<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 — Chip variant echelle (B3-M1 : alignées sur EchelleFilter.vue,
|
|
||||||
dupliquées ici car la sheet mobile ne passe pas par NavSidebar) -->
|
|
||||||
<div class="mt-2">
|
|
||||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
|
|
||||||
<ChipGroup aria-label="Filtrer par échelle">
|
|
||||||
<Chip
|
|
||||||
v-for="opt in ECHELLES"
|
|
||||||
:key="opt"
|
|
||||||
:label="opt"
|
|
||||||
variant="echelle"
|
|
||||||
:active="echelle.includes(opt)"
|
|
||||||
@toggle="toggleEchelle(opt)"
|
|
||||||
/>
|
|
||||||
</ChipGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filtres FONCTION — Chip variant fonction (B3-M1, idem) -->
|
|
||||||
<div class="mt-2">
|
|
||||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
|
|
||||||
<ChipGroup aria-label="Filtrer par fonction">
|
|
||||||
<Chip
|
|
||||||
v-for="fn in FONCTIONS"
|
|
||||||
:key="fn"
|
|
||||||
:label="fn"
|
|
||||||
variant="fonction"
|
|
||||||
:active="fonctions.includes(fn)"
|
|
||||||
:count="fonctions.includes(fn) ? (fonctions.indexOf(fn) + 1) : (fonctionCount[fn] ?? 0)"
|
|
||||||
@toggle="toggleFonction(fn)"
|
|
||||||
/>
|
|
||||||
</ChipGroup>
|
|
||||||
</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
|
<FicheModal
|
||||||
v-model="ficheModalOpen"
|
v-model="ficheModalOpen"
|
||||||
:orgId="ficheModalId"
|
:orgId="ficheModalId"
|
||||||
@@ -346,8 +65,9 @@
|
|||||||
|
|
||||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||||
<button
|
<button
|
||||||
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 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||||
style="
|
:style="`
|
||||||
|
bottom: ${basFlottants}px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
background: var(--nav-primary);
|
background: var(--nav-primary);
|
||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
@@ -356,7 +76,7 @@
|
|||||||
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="Ouvrir l'assistant Chatbot"
|
aria-label="Ouvrir l'assistant Chatbot"
|
||||||
@click="chatbotOpen = true"
|
@click="chatbotOpen = true"
|
||||||
>
|
>
|
||||||
@@ -377,6 +97,7 @@
|
|||||||
<button
|
<button
|
||||||
class="mission-info-btn"
|
class="mission-info-btn"
|
||||||
type="button"
|
type="button"
|
||||||
|
:style="vf.largeur === 'telephone' ? `bottom: ${basFlottants}px;` : undefined"
|
||||||
@click="missionOpen = true"
|
@click="missionOpen = true"
|
||||||
aria-label="À propos de cette carte d'entraide"
|
aria-label="À propos de cette carte d'entraide"
|
||||||
title="À propos de cette carte"
|
title="À propos de cette carte"
|
||||||
@@ -399,46 +120,80 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Org } from '~/types/org'
|
import type { Org } from '~/types/org'
|
||||||
|
import type { ConfigVueFiches } from '~/utils/vueFiches'
|
||||||
|
import { DOM_TOM } from '~/composables/useFicheAdapter'
|
||||||
|
|
||||||
// ── URL query params sync ─────────────────────────────────────────────────
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const search = ref<string>((route.query.q as string) ?? '')
|
// ── Données ───────────────────────────────────────────────────────────────
|
||||||
const echelle = ref<string[]>(
|
const { data, pending } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
|
||||||
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'
|
|
||||||
)
|
|
||||||
|
|
||||||
type MapView = 'metropole' | 'outremer' | 'fiches'
|
const orgs = computed<Org[]>(() => data.value?.list ?? [])
|
||||||
|
const dataSource = computed(() => data.value?.source ?? 'nocodb')
|
||||||
|
|
||||||
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
// `territoire=` (lien existant) : pré-filtre de page, hors de la vue fiches
|
||||||
|
const orgsTerritoire = computed<Org[]>(() => {
|
||||||
|
const t = route.query.territoire
|
||||||
|
return typeof t === 'string' && t ? orgs.value.filter((o) => o.territoire === t) : orgs.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Vue fiches (B11) : l'état vit dans l'URL ──────────────────────────────
|
||||||
|
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale']
|
||||||
|
const ECHELLES = ['National', 'Régional', 'Local']
|
||||||
|
const enValeurs = (l: string[]) => l.map((v) => ({ valeur: v, libelle: v }))
|
||||||
|
|
||||||
|
const config: ConfigVueFiches = {
|
||||||
|
dimensions: [
|
||||||
|
{ cle: 'fonctions', libelle: 'Fonction', variante: 'fonction', valeurs: enValeurs(FONCTIONS) },
|
||||||
|
{ cle: 'echelle', libelle: 'Échelle', variante: 'echelle', valeurs: enValeurs(ECHELLES) },
|
||||||
|
],
|
||||||
|
tris: ['pertinence', 'nom', 'recent'],
|
||||||
|
groupes: [
|
||||||
|
{ cle: 'fonction', libelle: 'Fonction', dimension: 'fonctions', sans: 'Sans fonction' },
|
||||||
|
{ cle: 'echelle', libelle: 'Échelle', dimension: 'echelle', sans: 'Sans échelle' },
|
||||||
|
{ cle: 'territoire', libelle: 'Territoire', dimension: 'territoire', sans: 'Sans territoire', valeurs: enValeurs(['Métropole', ...DOM_TOM]) },
|
||||||
|
],
|
||||||
|
etats: ['carte', 'mixte', 'fiches'],
|
||||||
|
sousVues: {
|
||||||
|
parametre: 'mode',
|
||||||
|
defaut: 'metropole',
|
||||||
|
valeurs: [{ valeur: 'metropole', libelle: 'Métropole' }, { valeur: 'outremer', libelle: 'Outre-mer' }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const { toMini } = useFicheAdapter()
|
||||||
|
const fichesMini = computed(() => orgsTerritoire.value.map((o) => toMini(o)))
|
||||||
|
const vf = useVueFiches(fichesMini, config)
|
||||||
|
|
||||||
|
// La carte ne montre que le résultat courant (recherche + filtres)
|
||||||
|
const orgsFiltres = computed<Org[]>(() => orgsTerritoire.value.filter((o) => vf.idsResultat.has(String(o.Id))))
|
||||||
|
const metropoleOrgs = computed<Org[]>(() => orgsFiltres.value.filter((o) => !o.territoire || !DOM_TOM.includes(o.territoire)))
|
||||||
|
const outremerOrgs = computed<Org[]>(() => orgsFiltres.value.filter((o) => o.territoire && DOM_TOM.includes(o.territoire)))
|
||||||
|
const survolNum = computed(() => (vf.survolId == null ? null : Number(vf.survolId)))
|
||||||
|
|
||||||
|
// ── Sélection ─────────────────────────────────────────────────────────────
|
||||||
const selectedId = ref<number | null>(null)
|
const selectedId = ref<number | null>(null)
|
||||||
const chatbotOpen = ref(false)
|
|
||||||
const ficheModalOpen = ref(false)
|
const ficheModalOpen = ref(false)
|
||||||
const ficheModalId = ref<number | null>(null)
|
const ficheModalId = ref<number | null>(null)
|
||||||
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
|
||||||
const missionOpen = ref(false)
|
|
||||||
|
|
||||||
onMounted(() => {
|
// Ordinateur : FicheModal ; téléphone et tablette : page /fiche/:id (comportement d'avant B11)
|
||||||
|
function onSelectOrg(id: number) {
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||||
|
selectedId.value = selectedId.value === id ? null : id
|
||||||
|
ficheModalId.value = id
|
||||||
|
ficheModalOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedId.value = id
|
||||||
try {
|
try {
|
||||||
if (!localStorage.getItem('aep_mission_seen')) {
|
// Le bouton retour de /fiche/:id revient à la même vue, filtres compris
|
||||||
missionOpen.value = true
|
sessionStorage.setItem('nav_back_filters', new URLSearchParams(route.query as Record<string, string>).toString())
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
router.push(`/fiche/${id}`)
|
||||||
// Surlignage temporaire (5 sec) suite à une réponse chatbot
|
}
|
||||||
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
|
|
||||||
|
// Surlignage temporaire (5 s) suite à une réponse chatbot
|
||||||
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const prevSelectedId = ref<number | null>(null)
|
const prevSelectedId = ref<number | null>(null)
|
||||||
|
|
||||||
@@ -446,243 +201,37 @@ function onHighlightOrgs(ids: (number | string)[]) {
|
|||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
|
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
|
||||||
if (isNaN(firstId)) return
|
if (isNaN(firstId)) return
|
||||||
|
|
||||||
// Sauvegarde la sélection courante
|
|
||||||
prevSelectedId.value = selectedId.value
|
prevSelectedId.value = selectedId.value
|
||||||
selectedId.value = firstId
|
selectedId.value = firstId
|
||||||
|
|
||||||
if (highlightTimer) clearTimeout(highlightTimer)
|
if (highlightTimer) clearTimeout(highlightTimer)
|
||||||
highlightTimer = setTimeout(() => {
|
highlightTimer = setTimeout(() => {
|
||||||
// Restaure la sélection précédente (ou null)
|
|
||||||
selectedId.value = prevSelectedId.value
|
selectedId.value = prevSelectedId.value
|
||||||
prevSelectedId.value = null
|
prevSelectedId.value = null
|
||||||
highlightTimer = null
|
highlightTimer = null
|
||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ref locale barre de recherche mobile (synchronisée avec search via onSearch)
|
// Fiche aléatoire — ?random=1, au chargement comme en navigation interne
|
||||||
const mobileSearch = ref<string>((route.query.q as string) ?? '')
|
if (import.meta.client) {
|
||||||
|
watch([() => route.query.random, () => orgs.value.length], ([v, n]) => {
|
||||||
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
|
if (v === '1' && n > 0) {
|
||||||
const navMapRef = ref<any>(null)
|
const randomOrg = orgs.value[Math.floor(Math.random() * n)]
|
||||||
const navMapMobileRef = ref<any>(null)
|
router.replace({ path: `/fiche/${randomOrg.Id}` })
|
||||||
|
|
||||||
// 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'
|
|
||||||
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') q.vue = 'fiches'
|
|
||||||
router.replace({ query: Object.keys(q).length ? q : undefined })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Changement de vue (carte/fiches) — passe par syncUrl pour poser/retirer ?vue=fiches
|
|
||||||
function setDesktopView(v: MapView) {
|
|
||||||
desktopMapView.value = v
|
|
||||||
syncUrl()
|
|
||||||
}
|
|
||||||
function setMobileView(v: MapView) {
|
|
||||||
mobileMapView.value = v
|
|
||||||
syncUrl()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
})
|
|
||||||
|
|
||||||
// Panneau « Toutes les fiches » (B3-M3) — mêmes fiches que `filtered`, sans
|
|
||||||
// filtrer par géolocalisation : c'est le gain principal de cette mission,
|
|
||||||
// les fiches sans latitude/longitude (invisibles sur la carte) y apparaissent.
|
|
||||||
const { toMini } = useFicheAdapter()
|
|
||||||
const allFichesMini = computed(() => filtered.value.map((org) => toMini(org)))
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
})
|
}, { immediate: true })
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Boutons flottants (téléphone) : au-dessus du pied « Voir les N fiches » en état Carte ──
|
||||||
|
const chatbotOpen = ref(false)
|
||||||
|
const missionOpen = ref(false)
|
||||||
|
const basFlottants = computed(() => (vf.largeur === 'telephone' && vf.vueAffichee === 'carte' ? 76 : 24))
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
if (!localStorage.getItem('aep_mission_seen')) missionOpen.value = true
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -708,6 +257,6 @@ useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale'
|
|||||||
.mission-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
.mission-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.mission-info-btn { bottom: 16px; left: 340px; }
|
.mission-info-btn { bottom: 10px; left: auto; right: 52px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user