Files
Makers3D/apps/web/app/components/InteractiveMakerMap.tsx

355 lines
9.5 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 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<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;
}
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 (
<CircleMarker
key={marker.id}
center={[marker.displayLatitude, marker.displayLongitude]}
radius={marker.radius}
pathOptions={{
color: "#ffffff",
weight: 2,
fillColor: "#10358d",
fillOpacity: 0.95
}}
>
<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>
);
})}
</>
);
}
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;
}
export default function InteractiveMakerMap({ markers }: { markers: MakerMarker[] }) {
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
}
);
};
return (
<section className="map-home-map">
<div className="map-home-realmap">
<MapContainer
center={cordobaCenter}
zoom={13}
scrollWheelZoom
className="map-home-leaflet"
>
<RegisterMapInstance mapRef={mapRef} />
<TileLayer
attribution='&copy; OpenStreetMap contributors &copy; CARTO'
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
/>
<FitMapToMarkers markers={displayMarkers} />
<Circle
center={cordobaCenter}
radius={userApproximationRadius}
pathOptions={{
color: "#1d67ff",
weight: 2,
fillColor: "#2e7cff",
fillOpacity: 0.1
}}
/>
<CircleMarker
center={cordobaCenter}
radius={8}
pathOptions={{
color: "#ffffff",
weight: 2,
fillColor: "#2e7cff",
fillOpacity: 1
}}
/>
<ClusteredMarkerLayer markers={displayMarkers} />
</MapContainer>
</div>
<div className="map-home-map-overlay" aria-hidden="false">
<button className="map-home-crosshair" 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="map-home-area-cta" type="button" onClick={focusMarkers}>
Buscar en esta area
</button>
</div>
</section>
);
}