M1 - Chips a11y : converti les <span> chips mobile (criteres, types, echelle, fonctions) en <button type=button> avec aria-pressed pour support clavier et lecteurs d'ecran (sidebar desktop deja en boutons). M2 - Effacer les filtres ne vide pas la search : resetFilters() reset maintenant aussi mobileSearch dans pratiques-regeneratives.vue et index.vue. M3 - FAB Soutenir overlap chip Agence : repositionne le FAB Soutenir en stack vertical avec le FAB Chatbot (right: 16px, bottom: 84px) au lieu de left: 16px, bottom: 68px. Evite l'overlap avec les chips de la bottom-sheet sur viewport intermediaire. L1 - /fiche/[id] introuvable pour pratiques : ajoute un fallback dans pages/fiche/[id].vue qui detecte si l'id correspond a une pratique regenerative et redirige vers /pratique/[id] (navigateTo replace). L2 - Label retour incorrect sur /proposer-pratique : harmonise en 'Retour aux pratiques regeneratives'. L3 - Map ne fitBounds pas apres filtre : EuropeMap et NavMap appellent maintenant fitBounds(bounds, padding 40px, maxZoom 10) quand la liste filtree contient 1 a 15 markers. Saute le tout premier rendu pour preserver la vue initiale.
254 lines
7.3 KiB
Vue
254 lines
7.3 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 Pratique {
|
|
id: number
|
|
nom: string
|
|
lat?: number | null
|
|
lng?: number | null
|
|
pays?: string
|
|
ville?: string
|
|
type?: string
|
|
score?: number
|
|
}
|
|
|
|
const props = defineProps<{
|
|
orgs: Pratique[]
|
|
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
|
|
|
|
function createPinIcon(score: number, isSelected = false): DivIcon {
|
|
const L = (window as any).L
|
|
// Couleur selon score (1-5) : du pale au vif
|
|
const bg = score >= 4 ? '#f5b342' : score >= 3 ? 'rgba(26,34,56,0.75)' : 'rgba(26,34,56,0.5)'
|
|
const border = isSelected ? '#f5b342' : '#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')
|
|
|
|
;(window as any).L = L
|
|
// @ts-ignore
|
|
await import('leaflet.markercluster')
|
|
const MarkerClusterGroup = L.MarkerClusterGroup
|
|
|
|
mapInstance = L.map(mapContainer.value, {
|
|
center: [50.0, 10.0],
|
|
zoom: 4,
|
|
zoomControl: true,
|
|
attributionControl: true,
|
|
maxBounds: [[30.0, -15.0], [72.0, 40.0]],
|
|
maxBoundsViscosity: 0.8,
|
|
minZoom: 3,
|
|
maxZoom: 18,
|
|
})
|
|
|
|
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!)
|
|
|
|
clusterGroup = new MarkerClusterGroup({
|
|
disableClusteringAtZoom: 12,
|
|
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)
|
|
}
|
|
|
|
// Vue initiale (centre Europe + zoom 4) - sauvegardee pour reset
|
|
const INITIAL_CENTER: [number, number] = [50.0, 10.0]
|
|
const INITIAL_ZOOM = 4
|
|
|
|
let initialFitDone = false
|
|
|
|
function updateMarkers(L?: any) {
|
|
if (!mapInstance || !clusterGroup) return
|
|
const leaflet = L || (window as any).L
|
|
if (!leaflet) return
|
|
|
|
clusterGroup.clearLayers()
|
|
markers.clear()
|
|
|
|
const orgsWithCoords = props.orgs.filter(
|
|
(o) => o.lat != null && o.lng != null
|
|
)
|
|
|
|
orgsWithCoords.forEach((org) => {
|
|
const isSelected = org.id === props.selectedId
|
|
const icon = createPinIcon(org.score ?? 1, isSelected)
|
|
|
|
const marker = leaflet.marker([org.lat!, org.lng!], { icon })
|
|
|
|
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.pays ? `<div style="font-size: 11px; color: var(--nav-text-muted);">${org.pays}${org.ville ? ' · ' + org.ville : ''}</div>` : ''}
|
|
${org.type ? `<div style="font-size: 11px; color: var(--nav-text-muted); margin-top: 2px;">${org.type}</div>` : ''}
|
|
<a href="/pratique/${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)
|
|
})
|
|
|
|
// Bug E2E L3 : recadrer la carte sur les resultats filtres
|
|
// Conditions : 1+ resultat, et au moins 1 marker hors viewport actuel.
|
|
// On evite de recadrer au tout premier rendu (laisser la vue initiale).
|
|
if (orgsWithCoords.length > 0 && initialFitDone) {
|
|
try {
|
|
const bounds = leaflet.latLngBounds(
|
|
orgsWithCoords.map((o) => [o.lat!, o.lng!])
|
|
)
|
|
// On recadre uniquement si la liste filtree est restreinte
|
|
// (evite un recadrage permanent quand toutes les fiches sont la).
|
|
if (orgsWithCoords.length <= 15) {
|
|
mapInstance.fitBounds(bounds, {
|
|
padding: [40, 40],
|
|
maxZoom: 10,
|
|
animate: true,
|
|
})
|
|
}
|
|
} catch (e) {
|
|
console.warn('[EuropeMap] fitBounds echoue:', e)
|
|
}
|
|
}
|
|
initialFitDone = true
|
|
}
|
|
|
|
watch(
|
|
() => props.orgs,
|
|
() => updateMarkers(),
|
|
{ deep: false }
|
|
)
|
|
|
|
watch(
|
|
() => props.selectedId,
|
|
(newId, oldId) => {
|
|
if (!mapInstance) return
|
|
const leaflet = (window as any).L
|
|
if (!leaflet) return
|
|
|
|
if (oldId != null) {
|
|
const oldMarker = markers.get(oldId)
|
|
const oldOrg = props.orgs.find(o => o.id === oldId)
|
|
if (oldMarker && oldOrg) {
|
|
oldMarker.setIcon(createPinIcon(oldOrg.score ?? 1, false))
|
|
}
|
|
}
|
|
if (newId != null) {
|
|
const newMarker = markers.get(newId)
|
|
const newOrg = props.orgs.find(o => o.id === newId)
|
|
if (newMarker && newOrg) {
|
|
newMarker.setIcon(createPinIcon(newOrg.score ?? 1, true))
|
|
const latLng = newMarker.getLatLng()
|
|
mapInstance.panTo(latLng, { animate: true })
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
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(() => {
|
|
initMap()
|
|
|
|
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>
|