<div x-data="googleMap('DEMO_MAP_ID')" x-init="init()" class="flex gap-4 h-[600px]">
<div class="w-1/3 bg-white shadow rounded-lg overflow-hidden flex flex-col">
<div class="flex gap-2">
<button @click="switchStoreType('optique')" :class="storeType === 'optique' ? 'bg-blue-500 text-white' : 'bg-gray-100'" class="px-3 py-1 rounded">
Optique
</button>
<button @click="switchStoreType('acoustique')" :class="storeType === 'acoustique' ? 'bg-blue-500 text-white' : 'bg-gray-100'" class="px-3 py-1 rounded">
Acoustique
</button>
</div>
<div>
<div class="p-4 border-b">
<h2 class="font-medium text-lg">Magasins à proximité</h2>
</div>
<div class="divide-y overflow-y-auto flex-1">
<template x-for="store in paginatedStores">
<div class="p-4 hover:bg-gray-50 cursor-pointer" @click="centerOnStore(store)">
<h3 class="font-medium" x-text="store[0].name"></h3>
<p class="text-sm text-gray-600" x-text="store[0].address + ', ' + store[0].zip + ' ' + store[0].city"></p>
<p class="text-sm text-gray-500 mt-1" x-text="formatDistance(store.distance)"></p>
</div>
</template>
</div>
</div>
<div class="p-4 border-t flex justify-between items-center">
<button @click="previousPage()" :disabled="currentPage === 1" class="px-3 py-1 bg-gray-100 rounded disabled:opacity-50">
Précédent
</button>
<span x-text="`Page ${currentPage}/${totalPages}`"></span>
<button @click="nextPage()" :disabled="currentPage === totalPages" class="px-3 py-1 bg-gray-100 rounded disabled:opacity-50">
Suivant
</button>
</div>
</div>
<div class="flex-1 relative">
<div id="DEMO_MAP_ID" class="absolute inset-0"></div>
<template x-if="loading">
<div class="absolute inset-0 bg-white/80 flex items-center justify-center">
<div class="loading-spinner"></div>
</div>
</template>
</div>
</div>
<script src="https://maps.googleapis.com/maps/api/js?key=&libraries=geometry,marker"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('googleMap', (mapId) => ({
storeType: 'optique',
allStores: [],
_initialized: false,
map: null,
markers: [],
stores: [],
sortedStores: [],
currentInfoWindow: null,
loading: true,
currentPage: 1,
itemsPerPage: 7,
markerCluster: null,
updateTimeout: null,
init() {
if (this._initialized) return;
this._initialized = true;
this.initMap();
},
switchStoreType(type) {
this.storeType = type;
this.loading = true;
// Nettoyage complet
this.clearMarkers();
this.stores = [];
this.sortedStores = [];
// Réinitialisation
this.filterAndDisplayStores();
this.updateStoreDistances();
this.loading = false;
},
filterAndDisplayStores() {
const items = this.allStores.data.items;
const filteredStores = Object.entries(items).filter(([_, storeArray]) => {
const hasAudio = storeArray.some(store => store.is_audio);
return this.storeType === 'acoustique' ? hasAudio : !hasAudio;
});
this.stores = {
success: true,
data: {
items: Object.fromEntries(filteredStores)
}
};
Object.values(this.stores.data.items).forEach(store => {
this.addMarker(store[0]);
});
this.updateVisibleMarkers();
},
get paginatedStores() {
const start = (this.currentPage - 1) * this.itemsPerPage;
return this.sortedStores.slice(start, start + this.itemsPerPage);
},
get totalPages() {
return Math.ceil(this.sortedStores.length / this.itemsPerPage);
},
nextPage() {
if (this.currentPage < this.totalPages) this.currentPage++;
},
previousPage() {
if (this.currentPage > 1) this.currentPage--;
},
async initMap() {
await this.waitForGoogleMaps();
this.map = new google.maps.Map(document.getElementById(mapId), {
center: {
lat: 46.603354,
lng: 1.888334
},
zoom: 6,
mapTypeControl: false,
fullscreenControl: true,
streetViewControl: false
});
this.map.addListener('idle', () => {
console.log(this.map)
if (this.map.manualUpdate) {
this.map.manualUpdate = true;
return;
}
clearTimeout(this.updateTimeout);
this.updateTimeout = setTimeout(() => {
this.updateStoreDistances();
this.currentPage = 1;
}, 300);
});
try {
await this.loadStores();
} finally {
this.loading = false;
}
},
waitForGoogleMaps() {
return new Promise(resolve => {
const check = () => {
if (window.google?.maps) resolve();
else setTimeout(check, 100);
};
check();
});
},
async loadStores() {
try {
const response = await fetch(`${window.location.origin}/js/json/getList.json`);
const data = await response.json();
if (!data.success) return;
this.allStores = data;
this.filterAndDisplayStores();
this.updateStoreDistances();
} catch (error) {
console.error('Erreur chargement stores:', error);
}
},
updateVisibleMarkers() {
const algorithmOptions = new markerClusterer.SuperClusterAlgorithm({
radius: 500,
maxZoom: 15,
minZoom: 3,
});
// Créer les clusters
this.markerCluster = new markerClusterer.MarkerClusterer({
map: this.map,
markers: this.markers,
algorithm: algorithmOptions,
renderer: {
render: ({
count,
position
}) =>
new google.maps.Marker({
position,
label: {
text: String(count),
color: "white",
fontSize: "14px",
fontWeight: "bold"
},
icon: {
path: google.maps.SymbolPath.CIRCLE,
fillColor: "#000000",
fillOpacity: 1,
strokeWeight: 0,
scale: count < 10 ? 20 : count < 50 ? 24 : 28
}
})
}
});
},
updateStoreDistances() {
const center = this.map.getCenter();
const centerPoint = new google.maps.LatLng(center.lat(), center.lng());
const stores = Object.values(this.stores.data.items);
this.sortedStores = stores.map(store => ({
...store,
distance: google.maps.geometry.spherical.computeDistanceBetween(
centerPoint,
new google.maps.LatLng(store[0].lat, store[0].lng)
)
})).sort((a, b) => a.distance - b.distance);
},
formatDistance(meters) {
return meters < 1000 ?
`${Math.round(meters)}m` :
`${(meters / 1000).toFixed(1)}km`;
},
clearMarkers() {
console.log("clearMarkers this.markerCluster", this.markerCluster)
console.log("clearMarkers this.markerCluster", this.markerCluster)
if (this.markerCluster) {
this.markerCluster.setMap(null);
this.markerCluster.removeMarkers(this.markers)
this.markerCluster.clearMarkers(true)
this.markerCluster.clearMarkers()
this.markerCluster = null;
}
this.markers.forEach(marker => {
marker.setMap(null);
google.maps.event.clearInstanceListeners(marker);
});
this.markers = [];
},
centerOnStore(store) {
if (this.currentInfoWindow) {
this.currentInfoWindow.close();
}
const lat = parseFloat(store[0].lat);
const lng = parseFloat(store[0].lng);
this.map.manualUpdate = true;
this.map.setCenter({
lat,
lng
});
this.map.setZoom(15);
const marker = this.markers.find(m =>
m.getPosition().lat() === lat &&
m.getPosition().lng() === lng
);
if (marker && this.currentInfoWindow) {
this.currentInfoWindow.open(this.map, marker);
}
},
addMarker(store) {
const lat = parseFloat(store.lat);
const lng = parseFloat(store.lng);
if (isNaN(lat) || isNaN(lng)) {
console.error('Coordonnées invalides:', store);
return;
}
try {
const marker = new google.maps.Marker({
position: {
lat,
lng
},
title: store.name,
map: null
});
const infowindow = new google.maps.InfoWindow({
content: `
<div class="p-2">
<h3 class="font-medium">${store.name}</h3>
<p class="text-sm">${store.address}, ${store.zip} ${store.city}</p>
</div>
`
});
marker.addListener('click', () => {
if (this.currentInfoWindow) {
this.currentInfoWindow.close();
}
this.currentInfoWindow = infowindow;
infowindow.open(this.map, marker);
});
this.markers.push(marker);
} catch (error) {
console.error('Erreur création marker:', error);
}
},
}));
});
</script>
{# components/google-map/google-map.twig #}
<div x-data="googleMap('{{ mapId }}')" x-init="init()" class="flex gap-4 h-[600px]">
<div class="w-1/3 bg-white shadow rounded-lg overflow-hidden flex flex-col">
{# Switcher #}
<div class="flex gap-2">
<button @click="switchStoreType('optique')"
:class="storeType === 'optique' ? 'bg-blue-500 text-white' : 'bg-gray-100'"
class="px-3 py-1 rounded">
Optique
</button>
<button @click="switchStoreType('acoustique')"
:class="storeType === 'acoustique' ? 'bg-blue-500 text-white' : 'bg-gray-100'"
class="px-3 py-1 rounded">
Acoustique
</button>
</div>
{# Magasin #}
<div>
<div class="p-4 border-b">
<h2 class="font-medium text-lg">Magasins à proximité</h2>
</div>
<div class="divide-y overflow-y-auto flex-1">
<template x-for="store in paginatedStores">
<div class="p-4 hover:bg-gray-50 cursor-pointer"
@click="centerOnStore(store)">
<h3 class="font-medium" x-text="store[0].name"></h3>
<p class="text-sm text-gray-600"
x-text="store[0].address + ', ' + store[0].zip + ' ' + store[0].city"></p>
<p class="text-sm text-gray-500 mt-1" x-text="formatDistance(store.distance)"></p>
</div>
</template>
</div>
</div>
{# Pagination #}
<div class="p-4 border-t flex justify-between items-center">
<button @click="previousPage()"
:disabled="currentPage === 1"
class="px-3 py-1 bg-gray-100 rounded disabled:opacity-50">
Précédent
</button>
<span x-text="`Page ${currentPage}/${totalPages}`"></span>
<button @click="nextPage()"
:disabled="currentPage === totalPages"
class="px-3 py-1 bg-gray-100 rounded disabled:opacity-50">
Suivant
</button>
</div>
</div>
<div class="flex-1 relative">
<div id="{{ mapId }}" class="absolute inset-0"></div>
<template x-if="loading">
<div class="absolute inset-0 bg-white/80 flex items-center justify-center">
<div class="loading-spinner"></div>
</div>
</template>
</div>
</div>
<script src="https://maps.googleapis.com/maps/api/js?key={{ mapKey }}&libraries=geometry,marker"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('googleMap', (mapId) => ({
storeType: 'optique',
allStores: [],
_initialized: false,
map: null,
markers: [],
stores: [],
sortedStores: [],
currentInfoWindow: null,
loading: true,
currentPage: 1,
itemsPerPage: 7,
markerCluster: null,
updateTimeout: null,
init() {
if (this._initialized) return;
this._initialized = true;
this.initMap();
},
switchStoreType(type) {
this.storeType = type;
this.loading = true;
// Nettoyage complet
this.clearMarkers();
this.stores = [];
this.sortedStores = [];
// Réinitialisation
this.filterAndDisplayStores();
this.updateStoreDistances();
this.loading = false;
},
filterAndDisplayStores() {
const items = this.allStores.data.items;
const filteredStores = Object.entries(items).filter(([_, storeArray]) => {
const hasAudio = storeArray.some(store => store.is_audio);
return this.storeType === 'acoustique' ? hasAudio : !hasAudio;
});
this.stores = {
success: true,
data: {
items: Object.fromEntries(filteredStores)
}
};
Object.values(this.stores.data.items).forEach(store => {
this.addMarker(store[0]);
});
this.updateVisibleMarkers();
},
get paginatedStores() {
const start = (this.currentPage - 1) * this.itemsPerPage;
return this.sortedStores.slice(start, start + this.itemsPerPage);
},
get totalPages() {
return Math.ceil(this.sortedStores.length / this.itemsPerPage);
},
nextPage() {
if (this.currentPage < this.totalPages) this.currentPage++;
},
previousPage() {
if (this.currentPage > 1) this.currentPage--;
},
async initMap() {
await this.waitForGoogleMaps();
this.map = new google.maps.Map(document.getElementById(mapId), {
center: {lat: 46.603354, lng: 1.888334},
zoom: 6,
mapTypeControl: false,
fullscreenControl: true,
streetViewControl: false
});
this.map.addListener('idle', () => {
console.log(this.map)
if (this.map.manualUpdate) {
this.map.manualUpdate = true;
return;
}
clearTimeout(this.updateTimeout);
this.updateTimeout = setTimeout(() => {
this.updateStoreDistances();
this.currentPage = 1;
}, 300);
});
try {
await this.loadStores();
} finally {
this.loading = false;
}
},
waitForGoogleMaps() {
return new Promise(resolve => {
const check = () => {
if (window.google?.maps) resolve();
else setTimeout(check, 100);
};
check();
});
},
async loadStores() {
try {
const response = await fetch(`${window.location.origin}/js/json/getList.json`);
const data = await response.json();
if (!data.success) return;
this.allStores = data;
this.filterAndDisplayStores();
this.updateStoreDistances();
} catch (error) {
console.error('Erreur chargement stores:', error);
}
},
updateVisibleMarkers() {
const algorithmOptions = new markerClusterer.SuperClusterAlgorithm({
radius: 500,
maxZoom: 15,
minZoom: 3,
});
// Créer les clusters
this.markerCluster = new markerClusterer.MarkerClusterer({
map: this.map,
markers: this.markers,
algorithm: algorithmOptions,
renderer: {
render: ({count, position}) =>
new google.maps.Marker({
position,
label: {text: String(count), color: "white", fontSize: "14px", fontWeight: "bold"},
icon: {
path: google.maps.SymbolPath.CIRCLE,
fillColor: "#000000",
fillOpacity: 1,
strokeWeight: 0,
scale: count < 10 ? 20 : count < 50 ? 24 : 28
}
})
}
});
},
updateStoreDistances() {
const center = this.map.getCenter();
const centerPoint = new google.maps.LatLng(center.lat(), center.lng());
const stores = Object.values(this.stores.data.items);
this.sortedStores = stores.map(store => ({
...store,
distance: google.maps.geometry.spherical.computeDistanceBetween(
centerPoint,
new google.maps.LatLng(store[0].lat, store[0].lng)
)
})).sort((a, b) => a.distance - b.distance);
},
formatDistance(meters) {
return meters < 1000 ?
`${Math.round(meters)}m` :
`${(meters / 1000).toFixed(1)}km`;
},
clearMarkers() {
console.log("clearMarkers this.markerCluster", this.markerCluster)
console.log("clearMarkers this.markerCluster", this.markerCluster)
if (this.markerCluster) {
this.markerCluster.setMap(null);
this.markerCluster.removeMarkers(this.markers)
this.markerCluster.clearMarkers(true)
this.markerCluster.clearMarkers()
this.markerCluster = null;
}
this.markers.forEach(marker => {
marker.setMap(null);
google.maps.event.clearInstanceListeners(marker);
});
this.markers = [];
},
centerOnStore(store) {
if (this.currentInfoWindow) {
this.currentInfoWindow.close();
}
const lat = parseFloat(store[0].lat);
const lng = parseFloat(store[0].lng);
this.map.manualUpdate = true;
this.map.setCenter({lat, lng});
this.map.setZoom(15);
const marker = this.markers.find(m =>
m.getPosition().lat() === lat &&
m.getPosition().lng() === lng
);
if (marker && this.currentInfoWindow) {
this.currentInfoWindow.open(this.map, marker);
}
},
addMarker(store) {
const lat = parseFloat(store.lat);
const lng = parseFloat(store.lng);
if (isNaN(lat) || isNaN(lng)) {
console.error('Coordonnées invalides:', store);
return;
}
try {
const marker = new google.maps.Marker({
position: {lat, lng},
title: store.name,
map: null
});
const infowindow = new google.maps.InfoWindow({
content: `
<div class="p-2">
<h3 class="font-medium">${store.name}</h3>
<p class="text-sm">${store.address}, ${store.zip} ${store.city}</p>
</div>
`
});
marker.addListener('click', () => {
if (this.currentInfoWindow) {
this.currentInfoWindow.close();
}
this.currentInfoWindow = infowindow;
infowindow.open(this.map, marker);
});
this.markers.push(marker);
} catch (error) {
console.error('Erreur création marker:', error);
}
},
}));
})
;
</script>
{
"mapId": "DEMO_MAP_ID",
"loading": false
}
No notes defined.