225 lines
6.5 KiB
TypeScript

import "mapbox-gl/dist/mapbox-gl.css";
import mapboxgl, { LngLatBounds } from "mapbox-gl";
import React, { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { GiObservatory } from "react-icons/gi";
import GeologicalEvent from "@appTypes/GeologicalEvent";
import getMagnitudeColor from "@utils/getMagnitudeColour";
interface MapComponentProps {
events: GeologicalEvent[];
selectedEventId: GeologicalEvent["id"];
setSelectedEventId: Dispatch<SetStateAction<string>>;
hoveredEventId: GeologicalEvent["id"];
setHoveredEventId: Dispatch<SetStateAction<string>>;
mapType: String;
}
function MapComponent({
events,
selectedEventId,
setSelectedEventId,
hoveredEventId,
setHoveredEventId,
mapType,
}: MapComponentProps) {
const map = useRef<mapboxgl.Map | null>(null);
const markers = useRef<{ [key: string]: mapboxgl.Marker }>({});
const [mapBounds, setMapBounds] = useState<LngLatBounds>();
const fitToBounds = useCallback((bounds: LngLatBounds, padding: number = 150) => {
if (map.current && bounds) {
map.current!.fitBounds(bounds, { padding, maxZoom: 10, zoom: 1 });
}
}, []);
const showPopup = useCallback((eventId: string) => {
const marker = markers.current[eventId];
if (marker && map.current) {
marker.getPopup()?.addTo(map.current);
}
}, []);
const clearAllPopups = useCallback(() => {
Object.values(markers.current).forEach((marker) => marker.getPopup()?.remove());
}, []);
const flyToEvent = useCallback((event: GeologicalEvent) => {
if (map.current) {
map.current.flyTo({
center: [event.longitude, event.latitude],
zoom: 4,
speed: 1.5,
curve: 1.42,
});
}
}, []);
useEffect(() => {
mapboxgl.accessToken = "pk.eyJ1IjoidGltaG93aXR6IiwiYSI6ImNtOGtjcXA5bDA3Ym4ya3NnOWxjbjlxZG8ifQ.6u_KgXEdLTakz910QRAorQ";
try {
map.current = new mapboxgl.Map({
container: "map-container",
style: "mapbox://styles/mapbox/light-v10",
center: [0, 0],
zoom: 1,
});
} catch (error) {
console.error("Map initialization failed:", error);
return;
}
map.current.on("load", () => {
const bounds = new mapboxgl.LngLatBounds();
events.forEach((event) => {
bounds.extend([event.longitude, event.latitude]);
});
fitToBounds(bounds, 0);
setMapBounds(bounds);
events.forEach((event) => {
const quakeElement = document.createElement("div");
const dotElement = document.createElement("div");
const pulseElement = document.createElement("div");
if (event.magnitude) {
const color = getMagnitudeColor(event.magnitude);
quakeElement.style.width = "50px";
quakeElement.style.height = "50px";
quakeElement.style.position = "absolute";
quakeElement.style.display = "flex";
quakeElement.style.alignItems = "center";
quakeElement.style.justifyContent = "center";
dotElement.style.width = "10px";
dotElement.style.height = "10px";
dotElement.style.backgroundColor = color;
dotElement.style.borderRadius = "50%";
dotElement.style.position = "absolute";
dotElement.style.zIndex = "2";
pulseElement.className = "location-pulse";
pulseElement.style.width = "20px";
pulseElement.style.height = "20px";
pulseElement.style.backgroundColor = `${color}80`;
pulseElement.style.borderRadius = "50%";
pulseElement.style.position = "absolute";
pulseElement.style.zIndex = "1";
}
const observatoryElement = document.createElement("div");
const root = createRoot(observatoryElement);
root.render(
<GiObservatory
className={`text-2xl drop-shadow-lg ${event.isFunctional === false ? "text-gray-400" : "text-blue-600"}`}
/>
);
quakeElement.appendChild(pulseElement);
quakeElement.appendChild(dotElement);
const marker = new mapboxgl.Marker({ element: mapType === "observatories" ? observatoryElement : quakeElement })
.setLngLat([event.longitude, event.latitude])
.addTo(map.current!);
const popup = new mapboxgl.Popup({ offset: 25, closeButton: false, anchor: "bottom" }).setHTML(`
<div>
<h3>${event.title}</h3>
${mapType !== "observatories" ? `<p style="margin-bottom:3px;">${event.code}` : null}
${mapType === "observatories" ? `<p>${event.text1}</p>` : `<p>Magnitude: ${event.magnitude}</p>`}
<p>${event.text2}</p>
</div>
`);
marker.setPopup(popup);
markers.current[event.id] = marker;
const markerDomElement = marker.getElement();
markerDomElement.style.cursor = "pointer";
markerDomElement.addEventListener("mouseenter", () => setHoveredEventId(event.id));
markerDomElement.addEventListener("mouseleave", () => setHoveredEventId(""));
markerDomElement.addEventListener("click", () =>
setSelectedEventId((prevEventId) => (prevEventId === event.id ? "" : event.id))
);
markerDomElement.dataset.listenersAdded = "true";
});
});
// Add map click handler to deselect when clicking outside markers
map.current.on("click", (e) => {
const target = e.originalEvent.target as HTMLElement;
// Check if the click target is not a marker element
if (!target.closest(".mapboxgl-marker")) {
setSelectedEventId("");
}
});
map.current.on("error", (e) => {
console.error("Mapbox error:", e);
});
return () => {
map.current?.remove();
};
}, [events, setSelectedEventId, setHoveredEventId, fitToBounds]);
useEffect(() => {
const event = events.find((x) => x.id === selectedEventId);
if (event) flyToEvent(event);
else if (!selectedEventId && mapBounds) {
fitToBounds(mapBounds, 0);
}
}, [events, selectedEventId, mapBounds, fitToBounds, flyToEvent]);
useEffect(() => {
clearAllPopups();
if (hoveredEventId && selectedEventId && hoveredEventId !== selectedEventId) {
showPopup(hoveredEventId);
showPopup(selectedEventId);
} else if (hoveredEventId || selectedEventId) {
showPopup(hoveredEventId || selectedEventId);
}
}, [hoveredEventId, selectedEventId, clearAllPopups, showPopup]);
return (
<div className="w-full h-full">
<div id="map-container" className="w-full h-full" />
</div>
);
}
const pulseStyles = `
.location-pulse {
animation: locationPulse 2s infinite;
}
@keyframes locationPulse {
0% {
transform: scale(0.5);
opacity: 0.8;
}
70% {
transform: scale(2);
opacity: 0;
}
100% {
transform: scale(2);
opacity: 0;
}
}
`;
export default function Map(props: MapComponentProps) {
return (
<>
<style>{pulseStyles}</style>
<MapComponent {...props} />
</>
);
}