feat(reseaux): /agences branché sur VueFiches ; recherche, tags, famille et carte= dans l'URL (B11-M6)

Hashtags désormais en OU dans leur dimension (ET avant), ET avec la famille.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
This commit is contained in:
Jules Neny
2026-10-02 17:57:27 +02:00
co-authored by Claude Opus 5.5
parent 2e82d412ed
commit a98342c5f1
2 changed files with 160 additions and 547 deletions
+45 -10
View File
@@ -21,10 +21,14 @@ const FAMILLE_COLORS: Record<number, string> = {
const props = defineProps<{
structures: StructureV2[]
selectedId?: string | null
/** Fiche survolée dans la liste (B11) : épingle cerclée de safran, sans recentrer. */
survolId?: string | null
}>()
const emit = defineEmits<{
'select-structure': [id: string]
/** Survol d'une épingle (B11) : la liste allume la carte-fiche. */
survol: [id: string | null]
}>()
const mapContainer = ref<HTMLElement | null>(null)
@@ -32,17 +36,18 @@ let mapInstance: Map | null = null
let clusterGroup: any = null
const markers = new Map<string, Marker>()
let tileLayerInstance: any = null
let resizeObserver: ResizeObserver | null = null
function getFamilleColor(famille: number): string {
return FAMILLE_COLORS[famille] ?? '#888888'
}
function createPinIcon(famille: number, isSelected = false): DivIcon {
function createPinIcon(famille: number, isSelected = false, isSurvol = false): DivIcon {
const L = (window as any).L
const bg = getFamilleColor(famille)
const size = isSelected ? 20 : 14
const border = isSelected ? 'white' : 'rgba(255,255,255,0.7)'
const shadow = isSelected ? `0 0 0 4px ${bg}55` : 'none'
const size = isSelected || isSurvol ? 20 : 14
const border = isSurvol ? '#f5b342' : isSelected ? 'white' : 'rgba(255,255,255,0.7)'
const shadow = isSurvol ? '0 0 0 4px rgba(245,179,66,0.6)' : isSelected ? `0 0 0 4px ${bg}55` : 'none'
return L.divIcon({
className: '',
@@ -76,6 +81,7 @@ async function initMap() {
;(window as any).L = L
// @ts-ignore
await import('leaflet.markercluster')
if (!mapContainer.value?.isConnected) return // page quittée pendant le chargement (ex. ?random=1)
const MarkerClusterGroup = L.MarkerClusterGroup
mapInstance = L.map(mapContainer.value, {
@@ -121,6 +127,14 @@ async function initMap() {
mapInstance.addLayer(clusterGroup)
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(s: StructureV2): DivIcon {
return createPinIcon(s.famille_principale, s.id === props.selectedId, s.id === props.survolId)
}
function updateMarkers(L?: any) {
@@ -136,10 +150,7 @@ function updateMarkers(L?: any) {
)
structuresWithCoords.forEach((structure) => {
const isSelected = structure.id === props.selectedId
const icon = createPinIcon(structure.famille_principale, isSelected)
const marker = leaflet.marker([structure.latitude!, structure.longitude!], { icon })
const marker = leaflet.marker([structure.latitude!, structure.longitude!], { icon: iconeDe(structure), title: structure.nom })
const hashtagsHtml = structure.hashtags.slice(0, 2)
.map(h => `<span style="font-size:10px;color:var(--nav-text-muted);">${h}</span>`)
@@ -165,6 +176,8 @@ function updateMarkers(L?: any) {
`, { maxWidth: 260 })
marker.on('click', () => emit('select-structure', structure.id))
marker.on('mouseover', () => emit('survol', structure.id))
marker.on('mouseout', () => emit('survol', null))
markers.set(structure.id, marker)
clusterGroup.addLayer(marker)
@@ -193,14 +206,14 @@ watch(
const oldMarker = markers.get(oldId)
const oldStructure = props.structures.find(s => s.id === oldId)
if (oldMarker && oldStructure) {
oldMarker.setIcon(createPinIcon(oldStructure.famille_principale, false))
oldMarker.setIcon(iconeDe(oldStructure))
}
}
if (newId != null) {
const newMarker = markers.get(newId)
const newStructure = props.structures.find(s => s.id === newId)
if (newMarker && newStructure) {
newMarker.setIcon(createPinIcon(newStructure.famille_principale, true))
newMarker.setIcon(iconeDe(newStructure))
const latLng = newMarker.getLatLng()
mapInstance.panTo(latLng, { animate: true })
}
@@ -208,6 +221,27 @@ watch(
}
)
// Survol depuis la liste : épingle cerclée de 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 s = props.structures.find(x => x.id === id)
if (m && s) m.setIcon(iconeDe(s))
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' : ''
}
function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L
@@ -232,6 +266,7 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('nav-v2-select', onNavV2Select as EventListener)
themeObserver?.disconnect()
resizeObserver?.disconnect()
if (mapInstance) {
mapInstance.remove()
mapInstance = null
+108 -530
View File
@@ -1,433 +1,71 @@
<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) -->
<!-- Masquée en vue « Toutes les fiches » (B3-M3, décision Jules axe 2) :
le panneau EST la liste (+ ses propres chips hashtags), la sidebar
ferait doublon. IntentionBanner reste monté (Teleport, insensible au
v-show de son conteneur). -->
<div
v-show="desktopMapView !== 'fiches'"
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%;"
>
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein écran) -->
<IntentionBanner />
<!-- Filtres familles + hashtags -->
<HashtagFilter
:allHashtags="allHashtags"
:selectedHashtags="selectedHashtags"
:selectedFamille="selectedFamille"
@update:selectedHashtags="selectedHashtags = $event"
@update:selectedFamille="selectedFamille = $event"
/>
<!-- 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 = ''"
<!-- ═══════════════════════════════════════ VUE FICHES (B11) : Carte · Mixte · Fiches -->
<VueFiches
:vf="vf"
intention="120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture."
libelle-recherche="Rechercher une structure…"
:pending="pending"
:selected-id="selectedId"
@select="(f) => onSelectStructure(String(f.id))"
>
<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>
<ChipGroup v-if="structure.hashtags.length" class="mt-1" :aria-label="`Hashtags de ${structure.nom}`">
<Chip
v-for="tag in structure.hashtags.slice(0, 2)"
:key="tag"
as="span"
variant="fonction"
:label="tag"
/>
</ChipGroup>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative">
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- 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 === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('graphe')"
>Vue graphique</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;">
<template #carte>
<!-- Métropole -->
<div v-show="vf.etat.sousVue === 'metropole'" class="relative flex-1 min-h-0">
<ClientOnly>
<NavMapV2
ref="navMapRef"
:structures="metropoleStructures"
:selectedId="selectedId"
:survolId="vf.survolId == null ? null : String(vf.survolId)"
@select-structure="onSelectStructure"
@survol="(id) => (vf.survolId = id)"
/>
<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>
<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="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
<!-- Carte Outre-mer desktop -->
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
<!-- Outre-mer : pas de structure V2 en DOM-TOM pour l'instant (composant gardé vide) -->
<div v-show="vf.etat.sousVue === 'outremer'" class="flex-1 min-h-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<OutremerMap :orgs="[]" :selectedId="null" @select-org="() => {}" />
<template #fallback>
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
Chargement…
</div>
<div class="flex items-center justify-center h-48 text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
</template>
</ClientOnly>
</div>
<!-- Vue graphique desktop -->
<div v-show="desktopMapView === 'graphe'" class="flex-1 overflow-hidden flex flex-col">
<div class="flex-1 overflow-hidden relative">
<!-- Vue graphique -->
<div v-show="vf.etat.sousVue === 'graphe'" class="relative flex-1 min-h-0 overflow-hidden">
<ClientOnly>
<GraphView
:data="bifurcationData"
:allHashtags="allHashtags"
:active="desktopMapView === 'graphe'"
:active="vf.etat.sousVue === 'graphe' && vf.vueAffichee !== 'fiches'"
@select-structure="onSelectStructure"
/>
<template #fallback>
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">
Chargement du graphe...
</div>
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">Chargement du graphe…</div>
</template>
</ClientOnly>
</div>
<!-- Chatbot sous la carte (ordinateur) -->
<div v-if="vf.etat.sousVue !== 'outremer'" class="hidden lg:block shrink-0">
<ChatbotPlaceholder
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
@applyHashtag="(tag) => vf.ajouterFiltre('tags', sansDiese(tag))"
/>
</div>
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — la sidebar est
masquée dans cette vue (décision Jules axe 2), le panneau EST
la liste, avec ses propres chips hashtags sur le même état. -->
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
<FichesPanel
:fiches="allStructuresMini"
:hashtags="allHashtags"
:selected="selectedHashtags"
:query="search"
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
@update:selected="selectedHashtags = $event"
@open="(fiche) => onSelectStructure(String(fiche.id))"
/>
</div>
</div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique/Fiches + sheet swipable ── -->
<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 === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('graphe')"
>Graphe</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 mobile Métropole -->
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
<ClientOnly>
<NavMapV2
ref="navMapMobileRef"
:structures="metropoleStructures"
:selectedId="selectedId"
@select-structure="onSelectStructureMobile"
/>
<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>
</VueFiches>
<!-- Carte mobile Outre-mer -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
Chargement…
</div>
</template>
</ClientOnly>
</div>
<!-- Vue graphique mobile -->
<div v-show="mobileMapView === 'graphe'" class="absolute inset-0 overflow-hidden" style="background: var(--nav-bg);">
<ClientOnly>
<GraphView
:data="bifurcationData"
:allHashtags="allHashtags"
:active="mobileMapView === 'graphe'"
@select-structure="onSelectStructureMobile"
/>
<template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
Chargement du graphe…
</div>
</template>
</ClientOnly>
</div>
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
pas de carte dessous donc pas de sheet demi-hauteur (même logique que
la vue graphe qui masque déjà le MobileSheet). Fix 2026-09-25 : la
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="allStructuresMini"
:hashtags="allHashtags"
:selected="selectedHashtags"
:query="search"
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
@update:selected="selectedHashtags = $event"
@open="(fiche) => onSelectStructureMobile(String(fiche.id))"
/>
</div>
<!-- Bottom sheet swipable (masqué en vue graphique et en vue fiches) -->
<ClientOnly v-if="mobileMapView !== 'graphe' && mobileMapView !== 'fiches'">
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- 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);">
<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;">
<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…"
class="mobile-search-input"
autocomplete="off"
/>
<button
v-if="search"
type="button"
class="mobile-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>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="mt-1 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;"
>Effacer les filtres</button>
</div>
<!-- Liste fiches mobile -->
<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 }} structure{{ 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="structure in filtered"
:key="structure.id"
class="block rounded-lg p-3 transition-all cursor-pointer"
:style="selectedId === structure.id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};`
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
@click="onSelectStructureMobile(structure.id)"
>
<div class="flex items-start justify-between gap-2">
<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>
</div>
</div>
</MobileSheet>
</ClientOnly>
</div>
</main>
<!-- ═══════════════════════════════════════ MODAL FICHE V2 (desktop) -->
<!-- ═══════════════════════════════════════ MODAL FICHE V2 -->
<FicheModalV2
v-model="ficheModalOpen"
:structureId="ficheModalId"
@@ -437,8 +75,9 @@
<!-- ═══════════════════════════════════════ 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="
class="lg:hidden fixed right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
:style="`
bottom: ${basFlottants}px;
height: 48px;
background: var(--nav-primary);
opacity: 0.92;
@@ -447,7 +86,7 @@
font-family: var(--nav-font);
font-size: 0.875rem;
font-weight: 600;
"
`"
aria-label="Ouvrir l'assistant Chatbot"
@click="chatbotOpen = true"
>
@@ -467,6 +106,7 @@
<button
class="reseaux-info-btn"
type="button"
:style="vf.largeur === 'telephone' ? `bottom: ${basFlottants}px;` : undefined"
@click="missionOpen = true"
aria-label="À propos des réseaux AEP cartographiés"
title="À propos de cette carte"
@@ -490,7 +130,7 @@
Cette carte rassemble <strong>120 réseaux, collectifs et agences</strong> qui pratiquent une architecture engagée — écologique, politique, biorégionale. Ce ne sont pas seulement des agences «&nbsp;vertes&nbsp;»&nbsp;: ce sont celles et ceux qui assument des positions, refusent des projets, expérimentent des modèles de gouvernance, mettent leurs ressources et leurs savoirs en commun.
</p>
<p class="mission-text">
Six familles structurent la cartographie&nbsp;: militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe (3<sup>e</sup> onglet) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être&nbsp;: <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
Six familles structurent la cartographie&nbsp;: militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe («&nbsp;Vue graphique&nbsp;» dans le sélecteur de carte) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être&nbsp;: <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
</p>
</MissionPopup>
@@ -499,73 +139,8 @@
<script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
// ── Couleurs familles ──────────────────────────────────────────────────────
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
function familleColor(f: number): string {
return FAMILLE_COLORS[f] ?? '#888'
}
// ── État UI ────────────────────────────────────────────────────────────────
type MapView = 'metropole' | 'outremer' | 'graphe' | 'fiches'
const route = useRoute()
const router = useRouter()
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<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const missionOpen = ref(false)
// Sync URL — B3-M3 : pose/retire ?vue=fiches uniquement (agences.vue n'avait
// aucune synchronisation URL avant B3 pour search/famille/hashtags/vue ; hors
// périmètre de cette mission de l'étendre, seul ?vue=fiches est ajouté ici).
function setDesktopView(v: MapView) {
desktopMapView.value = v
syncViewUrl()
}
function setMobileView(v: MapView) {
mobileMapView.value = v
syncViewUrl()
}
function syncViewUrl() {
const q: Record<string, string> = { ...(route.query as Record<string, string>) }
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') {
q.vue = 'fiches'
} else {
delete q.vue
}
router.replace({ query: Object.keys(q).length ? q : undefined })
}
onMounted(() => {
try {
if (!localStorage.getItem('aep_reseaux_seen')) {
missionOpen.value = true
}
} catch {}
})
// Filtres
const search = ref('')
const selectedFamille = ref<number | null>(null)
const selectedHashtags = ref<string[]>([])
// Refs cartes
const navMapRef = ref<any>(null)
const navMapMobileRef = ref<any>(null)
import type { ConfigVueFiches, ValeurFiltre } from '~/utils/vueFiches'
import { sansDiese } from '~/composables/useFicheAdapter'
// ── Données V2 - JSON statique ─────────────────────────────────────────────
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
@@ -583,89 +158,92 @@ onMounted(async () => {
const structures = computed<StructureV2[]>(() => bifurcationData.value?.structures ?? [])
// Tous les hashtags uniques triés
// Tous les hashtags uniques triés (le graphe les attend avec leur « # »)
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 = []
// ── Vue fiches (B11) : l'état vit dans l'URL (tags=, famille=, carte=…) ────
// Familles : 1-5 = famille principale ou secondaire, 6 = badge « recherche politique »
const FAMILLES: ValeurFiltre[] = [
{ valeur: '1', libelle: 'Réemploi' },
{ valeur: '2', libelle: 'Frugalité' },
{ valeur: '3', libelle: 'Social' },
{ valeur: '4', libelle: 'Collectifs' },
{ valeur: '5', libelle: 'Urbanisme' },
{ valeur: '6', libelle: 'Recherche' },
]
const PAYS: Record<string, string> = {
FR: 'France', BE: 'Belgique', DE: 'Allemagne', UK: 'Royaume-Uni', ES: 'Espagne', IT: 'Italie',
PT: 'Portugal', CH: 'Suisse', AT: 'Autriche', DK: 'Danemark', PL: 'Pologne', NL: 'Pays-Bas',
SE: 'Suède', USA: 'États-Unis', INT: 'International',
}
// Structures métropole (pays != DOM-TOM, et avec coordonnées)
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
/** Valeurs d'une étiquette, de la plus fréquente à la plus rare (ordre stable des chips et des groupes). */
function parFrequence(cle: (s: StructureV2) => string[], libelle: (v: string) => string): ValeurFiltre[] {
const n = new Map<string, number>()
structures.value.forEach(s => cle(s).forEach(v => n.set(v, (n.get(v) ?? 0) + 1)))
return [...n.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'fr'))
.map(([v]) => ({ valeur: v, libelle: libelle(v) }))
}
const config = computed<ConfigVueFiches>(() => ({
dimensions: [
{
cle: 'tags', libelle: 'Hashtag', variante: 'fonction',
valeurs: parFrequence(s => s.hashtags.map(sansDiese), v => `#${v}`),
},
{ cle: 'famille', libelle: 'Famille', variante: 'echelle', valeurs: FAMILLES },
],
tris: ['pertinence', 'nom'],
groupes: [
{ cle: 'famille', libelle: 'Famille', dimension: 'famille', sans: 'Sans famille' },
{ cle: 'pays', libelle: 'Pays', dimension: 'pays', sans: 'Pays inconnu', valeurs: parFrequence(s => [s.pays], v => PAYS[v] ?? v) },
],
etats: ['carte', 'mixte', 'fiches'],
sousVues: {
parametre: 'carte',
defaut: 'metropole',
valeurs: [
{ valeur: 'metropole', libelle: 'Métropole' },
{ valeur: 'outremer', libelle: 'Outre-mer' },
{ valeur: 'graphe', libelle: 'Vue graphique' },
],
},
}))
// Panneau « Toutes les fiches » (B3-M3) — mêmes structures que `filtered`,
// sans filtrer par géolocalisation (adaptateur mutualisé avec `/`).
const { toMini } = useFicheAdapter()
const allStructuresMini = computed(() => filtered.value.map((s) => toMini(s)))
const fichesMini = computed(() => structures.value.map((s) => toMini(s)))
const vf = useVueFiches(fichesMini, config)
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
// OutremerMap attend le format Org legacy - on passe un tableau vide
const outremerOrgsLegacy = computed(() => [])
const selectedIdLegacyNum = computed(() => null)
// La carte ne montre que le résultat courant (recherche + filtres)
const metropoleStructures = computed<StructureV2[]>(() => structures.value.filter((s) => vf.idsResultat.has(s.id)))
// ── Sélection : FicheModalV2 à toutes les largeurs (comportement d'avant B11) ──
const selectedId = ref<string | null>(null)
const ficheModalOpen = ref(false)
const ficheModalId = ref<string | null>(null)
// ── Sélection ─────────────────────────────────────────────────────────────
function onSelectStructure(id: string) {
selectedId.value = selectedId.value === id ? null : id
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
const ordinateur = typeof window !== 'undefined' && window.innerWidth >= 1024
selectedId.value = ordinateur && selectedId.value === id ? null : id
ficheModalId.value = id
ficheModalOpen.value = true
}
}
function onSelectStructureMobile(id: string) {
selectedId.value = id
ficheModalId.value = id
ficheModalOpen.value = true
}
// ── 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_reseaux_seen')) missionOpen.value = true
} catch {}
})
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
</script>
@@ -692,6 +270,6 @@ useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
.reseaux-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
@media (min-width: 1024px) {
.reseaux-info-btn { bottom: 16px; left: 340px; }
.reseaux-info-btn { bottom: 10px; left: auto; right: 52px; }
}
</style>