2025-03-23 15:24:10 +00:00
|
|
|
import "mapbox-gl/dist/mapbox-gl.css";
|
2025-05-04 16:04:44 +01:00
|
|
|
|
|
|
|
|
import mapboxgl, { LngLatBounds } from "mapbox-gl";
|
|
|
|
|
import React, { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
|
2025-03-31 14:22:57 +01:00
|
|
|
import { createRoot } from "react-dom/client";
|
2025-05-04 16:04:44 +01:00
|
|
|
import { GiObservatory } from "react-icons/gi";
|
|
|
|
|
|
2025-06-01 14:18:32 +01:00
|
|
|
import GeologicalEvent from "@appTypes/GeologicalEvent";
|
2025-05-04 16:04:44 +01:00
|
|
|
import getMagnitudeColor from "@utils/getMagnitudeColour";
|
2025-03-23 15:24:10 +00:00
|
|
|
|
|
|
|
|
interface MapComponentProps {
|
2025-05-27 14:10:41 +01:00
|
|
|
events: GeologicalEvent[];
|
|
|
|
|
selectedEventId: GeologicalEvent["id"];
|
2025-05-04 16:04:44 +01:00
|
|
|
setSelectedEventId: Dispatch<SetStateAction<string>>;
|
2025-05-27 14:10:41 +01:00
|
|
|
hoveredEventId: GeologicalEvent["id"];
|
2025-05-04 16:04:44 +01:00
|
|
|
setHoveredEventId: Dispatch<SetStateAction<string>>;
|
|
|
|
|
mapType: String;
|
2025-03-23 15:24:10 +00:00
|
|
|
}
|
|
|
|
|
|
2025-05-04 16:04:44 +01:00
|
|
|
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>();
|
|
|
|
|
|
2025-05-27 14:10:41 +01:00
|
|
|
const fitToBounds = useCallback((bounds: LngLatBounds, padding: number = 150) => {
|
2025-05-04 16:04:44 +01:00
|
|
|
if (map.current && bounds) {
|
2025-05-27 14:10:41 +01:00
|
|
|
map.current!.fitBounds(bounds, { padding, maxZoom: 10, zoom: 1 });
|
2025-05-04 16:04:44 +01:00
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-05-27 14:10:41 +01:00
|
|
|
const flyToEvent = useCallback((event: GeologicalEvent) => {
|
2025-05-04 16:04:44 +01:00
|
|
|
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]);
|
|
|
|
|
});
|
2025-05-27 14:10:41 +01:00
|
|
|
fitToBounds(bounds, 0);
|
2025-05-04 16:04:44 +01:00
|
|
|
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);
|
2025-05-27 14:10:41 +01:00
|
|
|
quakeElement.style.width = "50px";
|
2025-05-04 16:04:44 +01:00
|
|
|
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";
|
2025-05-27 14:10:41 +01:00
|
|
|
dotElement.style.zIndex = "2";
|
2025-05-04 16:04:44 +01:00
|
|
|
|
|
|
|
|
pulseElement.className = "location-pulse";
|
2025-05-27 14:10:41 +01:00
|
|
|
pulseElement.style.width = "20px";
|
2025-05-04 16:04:44 +01:00
|
|
|
pulseElement.style.height = "20px";
|
2025-05-27 14:10:41 +01:00
|
|
|
pulseElement.style.backgroundColor = `${color}80`;
|
2025-05-04 16:04:44 +01:00
|
|
|
pulseElement.style.borderRadius = "50%";
|
|
|
|
|
pulseElement.style.position = "absolute";
|
|
|
|
|
pulseElement.style.zIndex = "1";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const observatoryElement = document.createElement("div");
|
2025-06-01 14:18:32 +01:00
|
|
|
const root = createRoot(observatoryElement);
|
|
|
|
|
root.render(
|
|
|
|
|
<GiObservatory
|
|
|
|
|
className={`text-2xl drop-shadow-lg ${event.isFunctional === false ? "text-gray-400" : "text-blue-600"}`}
|
|
|
|
|
/>
|
|
|
|
|
);
|
2025-05-04 16:04:44 +01:00
|
|
|
|
|
|
|
|
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(`
|
2025-03-23 15:24:10 +00:00
|
|
|
<div>
|
|
|
|
|
<h3>${event.title}</h3>
|
2025-06-01 14:18:32 +01:00
|
|
|
${mapType !== "observatories" ? `<p style="margin-bottom:3px;">${event.code}` : null}
|
2025-05-04 16:04:44 +01:00
|
|
|
${mapType === "observatories" ? `<p>${event.text1}</p>` : `<p>Magnitude: ${event.magnitude}</p>`}
|
2025-03-23 15:24:10 +00:00
|
|
|
<p>${event.text2}</p>
|
|
|
|
|
</div>
|
|
|
|
|
`);
|
|
|
|
|
|
2025-05-04 16:04:44 +01:00
|
|
|
marker.setPopup(popup);
|
|
|
|
|
markers.current[event.id] = marker;
|
|
|
|
|
|
|
|
|
|
const markerDomElement = marker.getElement();
|
2025-05-27 14:10:41 +01:00
|
|
|
markerDomElement.style.cursor = "pointer";
|
2025-05-04 16:04:44 +01:00
|
|
|
|
|
|
|
|
markerDomElement.addEventListener("mouseenter", () => setHoveredEventId(event.id));
|
|
|
|
|
markerDomElement.addEventListener("mouseleave", () => setHoveredEventId(""));
|
|
|
|
|
markerDomElement.addEventListener("click", () =>
|
|
|
|
|
setSelectedEventId((prevEventId) => (prevEventId === event.id ? "" : event.id))
|
|
|
|
|
);
|
|
|
|
|
|
2025-05-27 14:10:41 +01:00
|
|
|
markerDomElement.dataset.listenersAdded = "true";
|
2025-05-04 16:04:44 +01:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-27 14:10:41 +01:00
|
|
|
// 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("");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-04 16:04:44 +01:00
|
|
|
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);
|
2025-05-27 14:10:41 +01:00
|
|
|
else if (!selectedEventId && mapBounds) {
|
|
|
|
|
fitToBounds(mapBounds, 0);
|
2025-05-04 16:04:44 +01:00
|
|
|
}
|
2025-05-27 14:10:41 +01:00
|
|
|
}, [events, selectedEventId, mapBounds, fitToBounds, flyToEvent]);
|
2025-05-04 16:04:44 +01:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
clearAllPopups();
|
|
|
|
|
if (hoveredEventId && selectedEventId && hoveredEventId !== selectedEventId) {
|
|
|
|
|
showPopup(hoveredEventId);
|
|
|
|
|
showPopup(selectedEventId);
|
2025-05-27 14:10:41 +01:00
|
|
|
} else if (hoveredEventId || selectedEventId) {
|
2025-05-04 16:04:44 +01:00
|
|
|
showPopup(hoveredEventId || selectedEventId);
|
|
|
|
|
}
|
|
|
|
|
}, [hoveredEventId, selectedEventId, clearAllPopups, showPopup]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="w-full h-full">
|
|
|
|
|
<div id="map-container" className="w-full h-full" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2025-03-23 15:24:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-05-04 16:04:44 +01:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<style>{pulseStyles}</style>
|
|
|
|
|
<MapComponent {...props} />
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|