"use client"; import { useEffect, useRef, useState } from "react"; import type { MutableRefObject } from "react"; import { divIcon, type DivIcon, type Map as LeafletMap } from "leaflet"; import { Circle, CircleMarker, MapContainer, Marker, Popup, TileLayer, useMap, useMapEvents } from "react-leaflet"; type MakerMarker = { id: string; slug: string; businessName: string; city: string; province: string; latitude: number; longitude: number; }; type DisplayMarker = MakerMarker & { displayLatitude: number; displayLongitude: number; radius: number; }; type ClusterGroup = { key: string; count: number; latitude: number; longitude: number; markers: DisplayMarker[]; }; const cordobaCenter: [number, number] = [-31.4201, -64.1888]; const userApproximationRadius = 900; function hashValue(input: string, seed: number) { let value = seed; for (let index = 0; index < input.length; index += 1) { value = (value * 33 + input.charCodeAt(index) + index) % 1000003; } return value; } function toOffset(value: number, spread: number) { return ((value % 1000) / 999 - 0.5) * spread; } function toDisplayMarkers(markers: MakerMarker[]): DisplayMarker[] { return markers.map((marker) => { const latSeed = hashValue(marker.id, 17); const lngSeed = hashValue(marker.slug, 29); const radiusSeed = hashValue(marker.businessName, 43); return { ...marker, displayLatitude: marker.latitude + toOffset(latSeed, 0.0072), displayLongitude: marker.longitude + toOffset(lngSeed, 0.0094), radius: 8 + (radiusSeed % 4) }; }); } function getClusterRadius(zoom: number) { if (zoom >= 15) { return 0; } if (zoom >= 14) { return 34; } if (zoom >= 13) { return 42; } if (zoom >= 12) { return 50; } if (zoom >= 11) { return 58; } return 66; } function buildClusterGroups(markers: DisplayMarker[], map: LeafletMap, zoom: number) { const clusterRadius = getClusterRadius(zoom); if (clusterRadius === 0) { return markers.map((marker) => ({ key: marker.id, count: 1, latitude: marker.displayLatitude, longitude: marker.displayLongitude, markers: [marker] })); } const projectedMarkers = markers.map((marker) => ({ marker, point: map.project([marker.displayLatitude, marker.displayLongitude], zoom) })); const visited = new Set(); const groups: ClusterGroup[] = []; for (let index = 0; index < projectedMarkers.length; index += 1) { if (visited.has(index)) { continue; } const queue = [index]; const memberIndexes: number[] = []; visited.add(index); while (queue.length > 0) { const currentIndex = queue.shift() as number; const currentMarker = projectedMarkers[currentIndex]; memberIndexes.push(currentIndex); for (let candidateIndex = 0; candidateIndex < projectedMarkers.length; candidateIndex += 1) { if (visited.has(candidateIndex)) { continue; } const candidateMarker = projectedMarkers[candidateIndex]; const deltaX = currentMarker.point.x - candidateMarker.point.x; const deltaY = currentMarker.point.y - candidateMarker.point.y; if (Math.hypot(deltaX, deltaY) <= clusterRadius) { visited.add(candidateIndex); queue.push(candidateIndex); } } } const bucket = memberIndexes.map((memberIndex) => projectedMarkers[memberIndex].marker); const latitude = bucket.reduce((sum, marker) => sum + marker.displayLatitude, 0) / bucket.length; const longitude = bucket.reduce((sum, marker) => sum + marker.displayLongitude, 0) / bucket.length; groups.push({ key: bucket.map((marker) => marker.id).sort().join(":"), count: bucket.length, latitude, longitude, markers: bucket }); } return groups; } function buildClusterIcon(count: number): DivIcon { const size = count >= 10 ? 60 : count >= 5 ? 54 : 48; const toneClass = count >= 10 ? "is-large" : count >= 5 ? "is-medium" : "is-small"; return divIcon({ className: "map-home-cluster-icon-shell", html: `${count}`, iconSize: [size, size], iconAnchor: [size / 2, size / 2] }); } function FitMapToMarkers({ markers }: { markers: DisplayMarker[] }) { const map = useMap(); useEffect(() => { if (markers.length === 0) { map.setView(cordobaCenter, 12); return; } const bounds = markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]); map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 }); }, [map, markers]); return null; } function ClusteredMarkerLayer({ markers }: { markers: DisplayMarker[] }) { const map = useMap(); const [zoom, setZoom] = useState(() => map.getZoom()); useMapEvents({ zoomend() { setZoom(map.getZoom()); } }); const groups = buildClusterGroups(markers, map, zoom); return ( <> {groups.map((group) => { if (group.count === 1) { const marker = group.markers[0]; return (
{marker.businessName} {marker.city}, {marker.province} Ver maker
); } return ( { map.fitBounds( group.markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]), { padding: [48, 48], maxZoom: 15, animate: true } ); } }} >
{group.count} makers en esta zona Toca el grupo para acercar y separarlos.
); })} ); } function RegisterMapInstance({ mapRef }: { mapRef: MutableRefObject }) { const map = useMap(); useEffect(() => { mapRef.current = map; return () => { if (mapRef.current === map) { mapRef.current = null; } }; }, [map, mapRef]); return null; } export default function InteractiveMakerMap({ markers }: { markers: MakerMarker[] }) { const mapRef = useRef(null); const displayMarkers = toDisplayMarkers(markers); const focusMarkers = () => { const map = mapRef.current; if (!map) { return; } if (markers.length === 0) { map.setView(cordobaCenter, 12, { animate: true }); return; } map.fitBounds( displayMarkers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]), { padding: [40, 40], maxZoom: 14, animate: true } ); }; return (
); }