Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
297 lines
9.2 KiB
Vue
297 lines
9.2 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">
|
|
const baseTiles = useBaseTiles()
|
|
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
|
|
/** Fiche survolée dans la liste (B11) : épingle safran, sans recentrer. */
|
|
survolId?: number | null
|
|
/** « Voir sur la carte » (B12) : zoome jusqu'à l'épingle, sortie de son cluster ; `n` change à chaque demande. */
|
|
centrage?: { id: number; n: number } | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'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)
|
|
let mapInstance: Map | null = null
|
|
let clusterGroup: any = null
|
|
const markers = new Map<number, Marker>()
|
|
let tileLayerInstance: any = null
|
|
let resizeObserver: ResizeObserver | null = null
|
|
|
|
// Créer une DivIcon pour les pins personnalisés
|
|
function createPinIcon(isPrioritaire: boolean, isSelected = false, isSurvol = false): DivIcon {
|
|
const L = (window as any).L
|
|
const bg = isSurvol || isPrioritaire ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
|
|
const border = isPrioritaire || isSurvol ? '#1a2238' : '#ffffff'
|
|
const size = isSelected || isSurvol ? 18 : 14
|
|
const shadow = isSelected || isSurvol ? '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')
|
|
if (!mapContainer.value?.isConnected) return // page quittée pendant le chargement (ex. ?random=1)
|
|
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 = baseTiles.url(isDark)
|
|
|
|
tileLayerInstance = L.tileLayer(tileUrl, {
|
|
attribution: baseTiles.attribution,
|
|
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)
|
|
|
|
// 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) {
|
|
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 marker = leaflet.marker([org.latitude!, org.longitude!], { icon: iconeDe(org), title: org.nom })
|
|
|
|
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))
|
|
marker.on('mouseover', () => emit('survol', org.Id))
|
|
marker.on('mouseout', () => emit('survol', null))
|
|
|
|
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(iconeDe(oldOrg))
|
|
}
|
|
}
|
|
// 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(iconeDe(newOrg))
|
|
// Centrer si visible
|
|
const latLng = newMarker.getLatLng()
|
|
mapInstance.panTo(latLng, { animate: true })
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
// 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
|
|
function updateTileTheme(dark: boolean) {
|
|
if (!mapInstance || !tileLayerInstance) return
|
|
const L = (window as any).L
|
|
if (!L) return
|
|
const url = baseTiles.url(dark)
|
|
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()
|
|
resizeObserver?.disconnect()
|
|
if (mapInstance) {
|
|
mapInstance.remove()
|
|
mapInstance = null
|
|
}
|
|
})
|
|
|
|
// « Voir sur la carte » (B12) : la carte vient peut-être de s'afficher (Fiches → Mixte) ;
|
|
// on attend sa taille réelle, puis on sort l'épingle de son cluster.
|
|
watch(
|
|
() => props.centrage?.n,
|
|
() => {
|
|
const c = props.centrage
|
|
if (!c) return
|
|
setTimeout(() => {
|
|
if (!mapInstance || !clusterGroup) return
|
|
mapInstance.invalidateSize({ pan: false })
|
|
const m = markers.get(c.id)
|
|
if (m) clusterGroup.zoomToShowLayer(m, () => mapInstance?.panTo(m.getLatLng(), { animate: true }))
|
|
}, 260)
|
|
},
|
|
)
|
|
</script>
|