438 lines
12 KiB
TypeScript
438 lines
12 KiB
TypeScript
"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 DiscoverMapMarker = {
|
|
id: string;
|
|
slug: string;
|
|
businessName: string;
|
|
city: string;
|
|
province: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
};
|
|
|
|
type DisplayMarker = DiscoverMapMarker & {
|
|
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: DiscoverMapMarker[]): 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<number>();
|
|
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: `<span class="map-home-cluster-badge ${toneClass}">${count}</span>`,
|
|
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;
|
|
}
|
|
|
|
map.fitBounds(
|
|
markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
|
|
{
|
|
padding: [40, 40],
|
|
maxZoom: 14
|
|
}
|
|
);
|
|
}, [map, markers]);
|
|
|
|
return null;
|
|
}
|
|
|
|
function RegisterMapInstance({ mapRef }: { mapRef: MutableRefObject<LeafletMap | null> }) {
|
|
const map = useMap();
|
|
|
|
useEffect(() => {
|
|
mapRef.current = map;
|
|
|
|
return () => {
|
|
if (mapRef.current === map) {
|
|
mapRef.current = null;
|
|
}
|
|
};
|
|
}, [map, mapRef]);
|
|
|
|
return null;
|
|
}
|
|
|
|
function ClusteredMarkerLayer({
|
|
markers,
|
|
selectedMarkerId,
|
|
onSelectMarker
|
|
}: {
|
|
markers: DisplayMarker[];
|
|
selectedMarkerId?: string;
|
|
onSelectMarker: (makerId: string) => void;
|
|
}) {
|
|
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];
|
|
const isSelected = marker.id === selectedMarkerId;
|
|
|
|
return (
|
|
<CircleMarker
|
|
key={marker.id}
|
|
center={[marker.displayLatitude, marker.displayLongitude]}
|
|
radius={isSelected ? marker.radius + 4 : marker.radius}
|
|
pathOptions={{
|
|
color: "#ffffff",
|
|
weight: isSelected ? 3 : 2,
|
|
fillColor: isSelected ? "#4f89ff" : "#10358d",
|
|
fillOpacity: 0.98
|
|
}}
|
|
eventHandlers={{
|
|
click: () => onSelectMarker(marker.id)
|
|
}}
|
|
>
|
|
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
|
|
<div className="map-home-popup-card">
|
|
<strong>{marker.businessName}</strong>
|
|
<span>{marker.city}, {marker.province}</span>
|
|
<a href={`/makers/${marker.slug}`}>Ver maker</a>
|
|
</div>
|
|
</Popup>
|
|
</CircleMarker>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Marker
|
|
key={group.key}
|
|
position={[group.latitude, group.longitude]}
|
|
icon={buildClusterIcon(group.count)}
|
|
eventHandlers={{
|
|
click: () => {
|
|
map.fitBounds(
|
|
group.markers.map((marker) => [marker.displayLatitude, marker.displayLongitude] as [number, number]),
|
|
{
|
|
padding: [48, 48],
|
|
maxZoom: 15,
|
|
animate: true
|
|
}
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<Popup className="map-home-popup" closeButton={false} offset={[0, -8]}>
|
|
<div className="map-home-popup-card">
|
|
<strong>{group.count} makers en esta zona</strong>
|
|
<span>Toca el grupo para acercar y separarlos.</span>
|
|
</div>
|
|
</Popup>
|
|
</Marker>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function InteractiveDiscoverMap({
|
|
markers,
|
|
selectedMarkerId,
|
|
onSelectMarker,
|
|
onApplyVisibleArea,
|
|
focusRequest
|
|
}: {
|
|
markers: DiscoverMapMarker[];
|
|
selectedMarkerId?: string;
|
|
onSelectMarker: (makerId: string) => void;
|
|
onApplyVisibleArea: (makerIds: string[]) => void;
|
|
focusRequest: number;
|
|
}) {
|
|
const mapRef = useRef<LeafletMap | null>(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
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyVisibleArea = () => {
|
|
const map = mapRef.current;
|
|
if (!map) {
|
|
onApplyVisibleArea(markers.map((marker) => marker.id));
|
|
return;
|
|
}
|
|
|
|
const bounds = map.getBounds();
|
|
const visibleIds = displayMarkers
|
|
.filter((marker) => bounds.contains([marker.displayLatitude, marker.displayLongitude]))
|
|
.map((marker) => marker.id);
|
|
|
|
onApplyVisibleArea(visibleIds);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (focusRequest > 0) {
|
|
focusMarkers();
|
|
}
|
|
}, [focusRequest]);
|
|
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map || !selectedMarkerId) {
|
|
return;
|
|
}
|
|
|
|
const selectedMarker = displayMarkers.find((marker) => marker.id === selectedMarkerId);
|
|
if (!selectedMarker) {
|
|
return;
|
|
}
|
|
|
|
map.flyTo([selectedMarker.displayLatitude, selectedMarker.displayLongitude], Math.max(map.getZoom(), 13), {
|
|
animate: true,
|
|
duration: 0.6
|
|
});
|
|
}, [selectedMarkerId, displayMarkers]);
|
|
|
|
return (
|
|
<section className="discover-map-stage">
|
|
<div className="discover-map-surface">
|
|
<MapContainer center={cordobaCenter} zoom={13} scrollWheelZoom className="discover-leaflet">
|
|
<RegisterMapInstance mapRef={mapRef} />
|
|
|
|
<TileLayer
|
|
attribution="© OpenStreetMap contributors © CARTO"
|
|
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
|
/>
|
|
|
|
<FitMapToMarkers markers={displayMarkers} />
|
|
|
|
<Circle
|
|
center={cordobaCenter}
|
|
radius={userApproximationRadius}
|
|
pathOptions={{
|
|
color: "#2d7cff",
|
|
weight: 2,
|
|
fillColor: "#2d7cff",
|
|
fillOpacity: 0.14
|
|
}}
|
|
/>
|
|
|
|
<CircleMarker
|
|
center={cordobaCenter}
|
|
radius={8}
|
|
pathOptions={{
|
|
color: "#ffffff",
|
|
weight: 2,
|
|
fillColor: "#2e7cff",
|
|
fillOpacity: 1
|
|
}}
|
|
/>
|
|
|
|
<ClusteredMarkerLayer
|
|
markers={displayMarkers}
|
|
selectedMarkerId={selectedMarkerId}
|
|
onSelectMarker={onSelectMarker}
|
|
/>
|
|
</MapContainer>
|
|
</div>
|
|
|
|
<div className="discover-map-overlay" aria-hidden="false">
|
|
<div className="discover-map-side-tools">
|
|
<button className="discover-map-control" type="button" aria-label="Recentrar mapa" onClick={focusMarkers}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="5.4" />
|
|
<path d="M12 2v3" />
|
|
<path d="M12 19v3" />
|
|
<path d="M2 12h3" />
|
|
<path d="M19 12h3" />
|
|
</svg>
|
|
</button>
|
|
|
|
<button className="discover-map-control" type="button" aria-label="Capas del mapa">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="m12 4 8 4-8 4-8-4 8-4Z" />
|
|
<path d="m4 12 8 4 8-4" />
|
|
<path d="m4 16 8 4 8-4" />
|
|
</svg>
|
|
</button>
|
|
|
|
<button className="discover-map-radar" type="button" aria-label="Radar maker">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="2.2" />
|
|
<path d="M12 5a7 7 0 0 1 7 7" />
|
|
<path d="M12 2a10 10 0 0 1 10 10" />
|
|
<path d="M5 12a7 7 0 0 1 7-7" />
|
|
</svg>
|
|
<span>Radar Maker</span>
|
|
</button>
|
|
</div>
|
|
|
|
<button className="discover-map-cta" type="button" onClick={applyVisibleArea}>
|
|
Buscar en esta zona
|
|
</button>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|