Files
nav-carte/components/NavMap.vue
Jules Neny 05bbcc2a02 fix(nav): Réseaux AEP + Leaflet CSS global + double rAF NavMap + chips V2
- app.vue : "Agences Inspirantes" → "Réseaux AEP" (desktop + mobile)
- nuxt.config.ts : Leaflet/MarkerCluster CSS global + Vite cacheDir AppData
- NavMap.vue : double requestAnimationFrame avant initMap (même fix NavMapV2)
- NavSidebar.vue : tags → style chip rounded-full comme V2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 00:32:50 +02:00

247 lines
7.4 KiB
Vue

<template>
<div class="relative w-full h-full">
<div ref="mapContainer" class="w-full h-full rounded-none" />
</div>
</template>
<script setup lang="ts">
import type { Map, Marker, DivIcon } from 'leaflet'
interface Org {
Id: number
nom: string
latitude?: number | null
longitude?: number | null
echelle?: string
tags_fonction?: string
territoire?: string
localisation_ville?: string
url?: string
prioritaire?: boolean
}
const props = defineProps<{
orgs: Org[]
selectedId?: number | null
}>()
const emit = defineEmits<{
'select-org': [id: number]
}>()
const mapContainer = ref<HTMLElement | null>(null)
let mapInstance: Map | null = null
let clusterGroup: any = null
const markers = new Map<number, Marker>()
let tileLayerInstance: any = null
// Créer une DivIcon pour les pins personnalisés
function createPinIcon(isPrioritaire: boolean, isSelected = false): DivIcon {
const L = (window as any).L
const bg = isPrioritaire ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
const border = isPrioritaire ? '#1a2238' : '#ffffff'
const size = isSelected ? 18 : 14
const shadow = isSelected ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
return L.divIcon({
className: '',
html: `<div style="
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: ${bg};
border: 2px solid ${border};
box-shadow: ${shadow};
transition: all 0.2s;
"></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 4)],
})
}
async function initMap() {
if (!mapContainer.value) return
const Lmod = await import('leaflet')
const L: any = (Lmod as any).default || Lmod
await import('leaflet/dist/leaflet.css')
// @ts-ignore
await import('leaflet.markercluster/dist/MarkerCluster.css')
// @ts-ignore
await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
// Installer L globalement AVANT le plugin (markercluster lit window.L au load)
;(window as any).L = L
// @ts-ignore — étend L.MarkerClusterGroup en side effect
await import('leaflet.markercluster')
const MarkerClusterGroup = L.MarkerClusterGroup
mapInstance = L.map(mapContainer.value, {
center: [46.6, 2.3],
zoom: 6,
zoomControl: true,
attributionControl: true,
maxBounds: [[41.0, -5.5], [51.5, 10.0]],
maxBoundsViscosity: 1.0,
minZoom: 5,
maxZoom: 18,
})
// Fond de carte CartoDB Positron (light ou dark selon theme)
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance = L.tileLayer(tileUrl, {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 19,
})
tileLayerInstance.addTo(mapInstance!)
// Cluster dès 15+ pins
clusterGroup = new MarkerClusterGroup({
disableClusteringAtZoom: 14,
maxClusterRadius: 50,
showCoverageOnHover: false,
iconCreateFunction: (cluster: any) => {
const count = cluster.getChildCount()
return L.divIcon({
html: `<div style="
width: 36px; height: 36px; border-radius: 50%;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 13px;
border: 2px solid white;
font-family: var(--nav-font);
">${count}</div>`,
className: '',
iconSize: [36, 36],
iconAnchor: [18, 18],
})
},
})
mapInstance.addLayer(clusterGroup)
updateMarkers(L)
}
function updateMarkers(L?: any) {
if (!mapInstance || !clusterGroup) return
const leaflet = L || (window as any).L
if (!leaflet) return
// Clear existing
clusterGroup.clearLayers()
markers.clear()
const orgsWithCoords = props.orgs.filter(
(o) => o.latitude != null && o.longitude != null
)
orgsWithCoords.forEach((org) => {
const isSelected = org.Id === props.selectedId
const icon = createPinIcon(!!org.prioritaire, isSelected)
const marker = leaflet.marker([org.latitude!, org.longitude!], { icon })
const fonctions = org.tags_fonction
? org.tags_fonction.split(',').map((f: string) => f.trim()).filter(Boolean).slice(0, 2).join(', ')
: ''
marker.bindPopup(`
<div style="font-family: var(--nav-font); min-width: 180px; padding: 4px 0;">
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 4px;">${org.nom}</div>
${org.echelle ? `<div style="font-size: 11px; color: var(--nav-text-muted);">${org.echelle}${org.localisation_ville ? ' · ' + org.localisation_ville : ''}</div>` : ''}
${fonctions ? `<div style="font-size: 11px; color: var(--nav-text-muted); margin-top: 2px;">${fonctions}</div>` : ''}
<a href="/fiche/${org.Id}" style="
display: inline-block; margin-top: 8px; font-size: 12px;
color: var(--nav-primary-solid); text-decoration: underline;
">Voir la fiche →</a>
</div>
`, { maxWidth: 240 })
marker.on('click', () => emit('select-org', org.Id))
markers.set(org.Id, marker)
clusterGroup.addLayer(marker)
})
}
// Réagir aux changements de filtres (liste d'orgs)
watch(
() => props.orgs,
() => updateMarkers(),
{ deep: false }
)
// Réagir à la sélection
watch(
() => props.selectedId,
(newId, oldId) => {
if (!mapInstance) return
const leaflet = (window as any).L
if (!leaflet) return
// Remettre l'ancien marker à la normale
if (oldId != null) {
const oldMarker = markers.get(oldId)
const oldOrg = props.orgs.find(o => o.Id === oldId)
if (oldMarker && oldOrg) {
oldMarker.setIcon(createPinIcon(!!oldOrg.prioritaire, false))
}
}
// Mettre en avant le nouveau marker
if (newId != null) {
const newMarker = markers.get(newId)
const newOrg = props.orgs.find(o => o.Id === newId)
if (newMarker && newOrg) {
newMarker.setIcon(createPinIcon(!!newOrg.prioritaire, true))
// Centrer si visible
const latLng = newMarker.getLatLng()
mapInstance.panTo(latLng, { animate: true })
}
}
}
)
// Watcher dark mode — switch tuile CartoDB light_all ↔ dark_all
function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L
if (!L) return
const url = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance.setUrl(url)
}
let themeObserver: MutationObserver | null = null
onMounted(() => {
// Double rAF : laisser le browser calculer la hauteur du conteneur avant Leaflet
requestAnimationFrame(() => {
requestAnimationFrame(() => {
initMap()
})
})
// Observer les changements de classe dark sur <html>
themeObserver = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark')
updateTileTheme(dark)
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
onUnmounted(() => {
themeObserver?.disconnect()
if (mapInstance) {
mapInstance.remove()
mapInstance = null
}
})
</script>