Files
Makers3D/apps/web/app/components/InteractiveDiscoverMap.tsx
T

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="&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: "#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>
);
}