280 lines
7.8 KiB
Vue
280 lines
7.8 KiB
Vue
<template>
|
|
<div class="outremer-accordion">
|
|
<div
|
|
v-for="dom in DOM_TOM"
|
|
:key="dom.name"
|
|
class="outremer-item"
|
|
>
|
|
<button
|
|
class="outremer-header"
|
|
@click="toggle(dom.name)"
|
|
:aria-expanded="openDom === dom.name"
|
|
>
|
|
<span class="outremer-title">{{ dom.name }}</span>
|
|
<span class="outremer-meta">
|
|
<span class="outremer-count-badge" :style="orgCounts[dom.name] === 0 ? 'opacity:0.4' : ''">
|
|
{{ orgCounts[dom.name] ?? 0 }} fiche{{ (orgCounts[dom.name] ?? 0) > 1 ? 's' : '' }}
|
|
</span>
|
|
<svg
|
|
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
|
aria-hidden="true"
|
|
class="outremer-chevron"
|
|
:class="{ 'outremer-chevron--open': openDom === dom.name }"
|
|
>
|
|
<polyline points="6 9 12 15 18 9"/>
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
|
|
<div
|
|
v-show="openDom === dom.name"
|
|
class="outremer-map-container"
|
|
>
|
|
<div :ref="el => { if (el) mapRefs[dom.name] = el as HTMLElement }" class="outremer-map" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Map as LeafletMap, TileLayer } from 'leaflet'
|
|
|
|
interface Org {
|
|
Id: number
|
|
nom: string
|
|
latitude?: number | null
|
|
longitude?: number | null
|
|
territoire?: string
|
|
echelle?: string
|
|
tags_fonction?: string
|
|
localisation_ville?: string
|
|
prioritaire?: boolean
|
|
}
|
|
|
|
const DOM_TOM = [
|
|
{ name: 'Guadeloupe', center: [16.25, -61.58] as [number, number], zoom: 9 },
|
|
{ name: 'Martinique', center: [14.65, -61.02] as [number, number], zoom: 9 },
|
|
{ name: 'Guyane', center: [4.0, -53.0] as [number, number], zoom: 6 },
|
|
{ name: 'La Réunion', center: [-21.11, 55.53] as [number, number], zoom: 9 },
|
|
{ name: 'Mayotte', center: [-12.83, 45.16] as [number, number], zoom: 10 },
|
|
]
|
|
|
|
const props = defineProps<{
|
|
orgs: Org[]
|
|
selectedId?: number | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'select-org': [id: number]
|
|
}>()
|
|
|
|
const mapRefs: Record<string, HTMLElement> = {}
|
|
const mapInstances: Record<string, LeafletMap> = {}
|
|
const tileLayers: Record<string, TileLayer> = {}
|
|
|
|
const openDom = ref<string | null>(null)
|
|
|
|
const orgCounts = computed<Record<string, number>>(() => {
|
|
const counts: Record<string, number> = {}
|
|
DOM_TOM.forEach(d => { counts[d.name] = 0 })
|
|
props.orgs.forEach(o => {
|
|
if (o.territoire && counts[o.territoire] !== undefined) {
|
|
counts[o.territoire]++
|
|
}
|
|
})
|
|
return counts
|
|
})
|
|
|
|
function toggle(name: string) {
|
|
openDom.value = openDom.value === name ? null : name
|
|
nextTick(() => {
|
|
if (openDom.value === name && !mapInstances[name]) {
|
|
initSingleMap(name)
|
|
} else if (openDom.value === name) {
|
|
mapInstances[name]?.invalidateSize()
|
|
}
|
|
})
|
|
}
|
|
|
|
function createPinIcon(L: any, isPrioritaire: boolean, isSelected = false) {
|
|
const bg = isPrioritaire ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
|
|
const border = isPrioritaire ? '#1a2238' : '#ffffff'
|
|
const size = isSelected ? 16 : 12
|
|
return L.divIcon({
|
|
className: '',
|
|
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${bg};border:2px solid ${border};"></div>`,
|
|
iconSize: [size, size],
|
|
iconAnchor: [size / 2, size / 2],
|
|
popupAnchor: [0, -(size / 2 + 4)],
|
|
})
|
|
}
|
|
|
|
function getTileUrl(dark: boolean) {
|
|
return 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'
|
|
}
|
|
|
|
async function initSingleMap(domName: string) {
|
|
const dom = DOM_TOM.find(d => d.name === domName)
|
|
if (!dom) return
|
|
const Lmod = await import('leaflet')
|
|
const L: any = (Lmod as any).default || Lmod
|
|
await import('leaflet/dist/leaflet.css')
|
|
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
|
const el = mapRefs[domName]
|
|
if (!el) return
|
|
const map = L.map(el, {
|
|
center: dom.center, zoom: dom.zoom,
|
|
zoomControl: false, attributionControl: false,
|
|
dragging: true, scrollWheelZoom: true, doubleClickZoom: true,
|
|
touchZoom: true, keyboard: false,
|
|
})
|
|
const tileLayer = L.tileLayer(getTileUrl(isDark), {
|
|
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
|
|
})
|
|
tileLayer.addTo(map)
|
|
tileLayers[domName] = tileLayer as unknown as TileLayer
|
|
mapInstances[domName] = map as unknown as LeafletMap
|
|
renderPins(L, domName)
|
|
}
|
|
|
|
function updateTheme(dark: boolean) {
|
|
const url = getTileUrl(dark)
|
|
Object.values(tileLayers).forEach(tl => {
|
|
(tl as any).setUrl(url)
|
|
})
|
|
}
|
|
|
|
function renderPins(L: any, domName: string) {
|
|
const map = mapInstances[domName] as any
|
|
if (!map) return
|
|
|
|
if (map._navMarkers) {
|
|
map._navMarkers.forEach((m: any) => m.remove())
|
|
}
|
|
map._navMarkers = []
|
|
|
|
const domOrgs = props.orgs.filter(o => o.territoire === domName && o.latitude != null && o.longitude != null)
|
|
domOrgs.forEach(org => {
|
|
const icon = createPinIcon(L, !!org.prioritaire, org.Id === props.selectedId)
|
|
const marker = L.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:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:160px;padding:4px 0;">
|
|
<div style="font-weight:700;color:#1a2238;margin-bottom:2px;">${org.nom}</div>
|
|
${org.echelle ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);">${org.echelle}${org.localisation_ville ? ' · ' + org.localisation_ville : ''}</div>` : ''}
|
|
${fonctions ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);margin-top:2px;">${fonctions}</div>` : ''}
|
|
<a href="/fiche/${org.Id}" style="display:inline-block;margin-top:8px;font-size:12px;color:#1a2238;text-decoration:underline;">Voir la fiche →</a>
|
|
</div>
|
|
`, { maxWidth: 200 })
|
|
|
|
marker.on('click', () => emit('select-org', org.Id))
|
|
marker.addTo(map)
|
|
map._navMarkers.push(marker)
|
|
})
|
|
}
|
|
|
|
watch(() => props.orgs, () => {
|
|
DOM_TOM.forEach(dom => {
|
|
if (mapInstances[dom.name]) {
|
|
import('leaflet').then(L => renderPins(L, dom.name))
|
|
}
|
|
})
|
|
}, { deep: false })
|
|
|
|
watch(() => props.selectedId, () => {
|
|
DOM_TOM.forEach(dom => {
|
|
if (mapInstances[dom.name]) {
|
|
import('leaflet').then(L => renderPins(L, dom.name))
|
|
}
|
|
})
|
|
})
|
|
|
|
let themeObserver: MutationObserver | null = null
|
|
|
|
onMounted(() => {
|
|
themeObserver = new MutationObserver(() => {
|
|
const dark = document.documentElement.classList.contains('dark')
|
|
updateTheme(dark)
|
|
})
|
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
themeObserver?.disconnect()
|
|
Object.values(mapInstances).forEach(m => (m as any).remove())
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.outremer-accordion {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
}
|
|
|
|
.outremer-item {
|
|
border-bottom: 1px solid var(--nav-bg-alt);
|
|
}
|
|
|
|
.outremer-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
background: var(--nav-surface);
|
|
border: none;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.outremer-header:hover {
|
|
background: var(--nav-bg-alt);
|
|
}
|
|
|
|
.outremer-title {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--nav-text);
|
|
}
|
|
|
|
.outremer-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.outremer-count-badge {
|
|
font-size: 0.75rem;
|
|
color: var(--nav-text-muted);
|
|
}
|
|
|
|
.outremer-chevron {
|
|
color: var(--nav-text-muted);
|
|
transition: transform 0.2s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.outremer-chevron--open {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.outremer-map-container {
|
|
height: 220px;
|
|
border-top: 1px solid var(--nav-bg-alt);
|
|
}
|
|
|
|
.outremer-map {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
</style>
|