Sorted events in date order and improved Map fitting

This commit is contained in:
Tim Howitz 2025-05-27 14:10:41 +01:00
parent 3d2b71c768
commit 9124274603
6 changed files with 99 additions and 62 deletions

View File

@ -8,6 +8,7 @@ import Sidebar from "@components/Sidebar";
import { createPoster } from "@utils/axiosHelpers"; import { createPoster } from "@utils/axiosHelpers";
import { Earthquake } from "@prismaclient"; import { Earthquake } from "@prismaclient";
import { getRelativeDate } from "@utils/formatters"; import { getRelativeDate } from "@utils/formatters";
import GeologicalEvent from "@appTypes/Event";
export default function Earthquakes() { export default function Earthquakes() {
const [selectedEventId, setSelectedEventId] = useState(""); const [selectedEventId, setSelectedEventId] = useState("");
@ -18,14 +19,20 @@ export default function Earthquakes() {
const earthquakeEvents = useMemo( const earthquakeEvents = useMemo(
() => () =>
data && data.earthquakes data && data.earthquakes
? data.earthquakes.map((x: Earthquake) => ({ ? data.earthquakes
id: x.id, .map(
(x: Earthquake): GeologicalEvent => ({
id: x.code,
title: `Earthquake in ${x.code.split("-")[2]}`, title: `Earthquake in ${x.code.split("-")[2]}`,
magnitude: x.magnitude, magnitude: x.magnitude,
longitude: x.longitude, longitude: x.longitude,
latitude: x.latitude, latitude: x.latitude,
text1: "",
text2: getRelativeDate(x.date), text2: getRelativeDate(x.date),
})) date: x.date,
})
)
.sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime()) // Remove Date conversion
: [], : [],
[data] [data]
); );

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import { useMemo } from "react";
import { useState } from "react"; import { useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
@ -6,18 +7,41 @@ import useSWR from "swr";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import Map from "@components/Map"; import Map from "@components/Map";
import { fetcher } from "@utils/axiosHelpers"; import { fetcher } from "@utils/axiosHelpers";
import { Observatory } from "@prismaclient";
import { getRelativeDate } from "@utils/formatters";
import GeologicalEvent from "@appTypes/Event";
export default function Observatories() { export default function Observatories() {
const [selectedEventId, setSelectedEventId] = useState(""); const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState(""); const [hoveredEventId, setHoveredEventId] = useState("");
// todo properly integrate loading
const { data, error, isLoading } = useSWR("/api/observatories", fetcher); const { data, error, isLoading } = useSWR("/api/observatories", fetcher);
// todo add in earthquake events
const observatoryEvents = useMemo(
() =>
data && data.observatories
? data.observatories
.map(
(x: Observatory): GeologicalEvent => ({
id: x.id.toString(),
title: `New Observatory - ${x.name}`,
longitude: x.longitude,
latitude: x.latitude,
text1: "",
text2: getRelativeDate(x.dateEstablished),
date: x.dateEstablished,
})
)
.sort((a: GeologicalEvent, b: GeologicalEvent) => new Date(b.date).getTime() - new Date(a.date).getTime()) // Remove Date conversion
: [],
[data]
);
return ( return (
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden"> <div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
<div className="flex-grow h-full"> <div className="flex-grow h-full">
<Map <Map
events={data ? data.observatories : []} events={observatoryEvents}
selectedEventId={selectedEventId} selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId} setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId} hoveredEventId={hoveredEventId}
@ -29,7 +53,7 @@ export default function Observatories() {
logTitle="Observatory Mapping" logTitle="Observatory Mapping"
logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes" logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes"
recentsTitle="Observatory Events" recentsTitle="Observatory Events"
events={data ? data.observatories : []} events={observatoryEvents}
selectedEventId={selectedEventId} selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId} setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId} hoveredEventId={hoveredEventId}

View File

@ -1,25 +1,22 @@
import "mapbox-gl/dist/mapbox-gl.css"; import "mapbox-gl/dist/mapbox-gl.css";
import mapboxgl, { LngLatBounds } from "mapbox-gl"; import mapboxgl, { LngLatBounds } from "mapbox-gl";
import { userAgentFromString } from "next/server";
import React, { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from "react"; import React, { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { GiObservatory } from "react-icons/gi"; import { GiObservatory } from "react-icons/gi";
import Event from "@appTypes/Event"; import GeologicalEvent from "@appTypes/Event";
import getMagnitudeColor from "@utils/getMagnitudeColour"; import getMagnitudeColor from "@utils/getMagnitudeColour";
interface MapComponentProps { interface MapComponentProps {
events: Event[]; events: GeologicalEvent[];
selectedEventId: Event["id"]; selectedEventId: GeologicalEvent["id"];
setSelectedEventId: Dispatch<SetStateAction<string>>; setSelectedEventId: Dispatch<SetStateAction<string>>;
hoveredEventId: Event["id"]; hoveredEventId: GeologicalEvent["id"];
setHoveredEventId: Dispatch<SetStateAction<string>>; setHoveredEventId: Dispatch<SetStateAction<string>>;
mapType: String; mapType: String;
} }
// Map component with location-style pulsing dots, animations, and tooltips
function MapComponent({ function MapComponent({
events, events,
selectedEventId, selectedEventId,
@ -32,9 +29,9 @@ function MapComponent({
const markers = useRef<{ [key: string]: mapboxgl.Marker }>({}); const markers = useRef<{ [key: string]: mapboxgl.Marker }>({});
const [mapBounds, setMapBounds] = useState<LngLatBounds>(); const [mapBounds, setMapBounds] = useState<LngLatBounds>();
const fitToBounds = useCallback((bounds: LngLatBounds) => { const fitToBounds = useCallback((bounds: LngLatBounds, padding: number = 150) => {
if (map.current && bounds) { if (map.current && bounds) {
map.current!.fitBounds(bounds, { padding: 150, maxZoom: 10 }); map.current!.fitBounds(bounds, { padding, maxZoom: 10, zoom: 1 });
} }
}, []); }, []);
@ -49,7 +46,7 @@ function MapComponent({
Object.values(markers.current).forEach((marker) => marker.getPopup()?.remove()); Object.values(markers.current).forEach((marker) => marker.getPopup()?.remove());
}, []); }, []);
const flyToEvent = useCallback((event: Event) => { const flyToEvent = useCallback((event: GeologicalEvent) => {
if (map.current) { if (map.current) {
map.current.flyTo({ map.current.flyTo({
center: [event.longitude, event.latitude], center: [event.longitude, event.latitude],
@ -76,15 +73,13 @@ function MapComponent({
} }
map.current.on("load", () => { map.current.on("load", () => {
// Fit map to bounds
const bounds = new mapboxgl.LngLatBounds(); const bounds = new mapboxgl.LngLatBounds();
events.forEach((event) => { events.forEach((event) => {
bounds.extend([event.longitude, event.latitude]); bounds.extend([event.longitude, event.latitude]);
}); });
fitToBounds(bounds); fitToBounds(bounds, 0);
setMapBounds(bounds); setMapBounds(bounds);
// Add markers with location pulse
events.forEach((event) => { events.forEach((event) => {
const quakeElement = document.createElement("div"); const quakeElement = document.createElement("div");
const dotElement = document.createElement("div"); const dotElement = document.createElement("div");
@ -92,37 +87,31 @@ function MapComponent({
if (event.magnitude) { if (event.magnitude) {
const color = getMagnitudeColor(event.magnitude); const color = getMagnitudeColor(event.magnitude);
quakeElement.style.width = "50px";
// Create marker container
quakeElement.style.width = "50px"; // Increased size to accommodate pulse
quakeElement.style.height = "50px"; quakeElement.style.height = "50px";
quakeElement.style.position = "absolute"; quakeElement.style.position = "absolute";
quakeElement.style.display = "flex"; quakeElement.style.display = "flex";
quakeElement.style.alignItems = "center"; quakeElement.style.alignItems = "center";
quakeElement.style.justifyContent = "center"; quakeElement.style.justifyContent = "center";
// Central dot
dotElement.style.width = "10px"; dotElement.style.width = "10px";
dotElement.style.height = "10px"; dotElement.style.height = "10px";
dotElement.style.backgroundColor = color; dotElement.style.backgroundColor = color;
dotElement.style.borderRadius = "50%"; dotElement.style.borderRadius = "50%";
dotElement.style.position = "absolute"; dotElement.style.position = "absolute";
dotElement.style.zIndex = "2"; // Ensure dot is above pulse dotElement.style.zIndex = "2";
// Pulsing ring
pulseElement.className = "location-pulse"; pulseElement.className = "location-pulse";
pulseElement.style.width = "20px"; // Initial size pulseElement.style.width = "20px";
pulseElement.style.height = "20px"; pulseElement.style.height = "20px";
pulseElement.style.backgroundColor = `${color}80`; // Color with 50% opacity (hex alpha) pulseElement.style.backgroundColor = `${color}80`;
pulseElement.style.borderRadius = "50%"; pulseElement.style.borderRadius = "50%";
pulseElement.style.position = "absolute"; pulseElement.style.position = "absolute";
pulseElement.style.zIndex = "1"; pulseElement.style.zIndex = "1";
} }
// Observatory marker
//<GiObservatory />
const observatoryElement = document.createElement("div"); const observatoryElement = document.createElement("div");
const root = createRoot(observatoryElement); // `createRoot` is now the standard API const root = createRoot(observatoryElement);
root.render(<GiObservatory className="text-blue-600 text-2xl drop-shadow-lg" />); root.render(<GiObservatory className="text-blue-600 text-2xl drop-shadow-lg" />);
quakeElement.appendChild(pulseElement); quakeElement.appendChild(pulseElement);
@ -143,9 +132,8 @@ function MapComponent({
marker.setPopup(popup); marker.setPopup(popup);
markers.current[event.id] = marker; markers.current[event.id] = marker;
// Add hover events
const markerDomElement = marker.getElement(); const markerDomElement = marker.getElement();
markerDomElement.style.cursor = "pointer"; // Optional: indicate interactivity markerDomElement.style.cursor = "pointer";
markerDomElement.addEventListener("mouseenter", () => setHoveredEventId(event.id)); markerDomElement.addEventListener("mouseenter", () => setHoveredEventId(event.id));
markerDomElement.addEventListener("mouseleave", () => setHoveredEventId("")); markerDomElement.addEventListener("mouseleave", () => setHoveredEventId(""));
@ -153,11 +141,19 @@ function MapComponent({
setSelectedEventId((prevEventId) => (prevEventId === event.id ? "" : event.id)) setSelectedEventId((prevEventId) => (prevEventId === event.id ? "" : event.id))
); );
// Cleanup event listeners on unmount markerDomElement.dataset.listenersAdded = "true";
markerDomElement.dataset.listenersAdded = "true"; // Mark for cleanup
}); });
}); });
// 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) => { map.current.on("error", (e) => {
console.error("Mapbox error:", e); console.error("Mapbox error:", e);
}); });
@ -170,22 +166,17 @@ function MapComponent({
useEffect(() => { useEffect(() => {
const event = events.find((x) => x.id === selectedEventId); const event = events.find((x) => x.id === selectedEventId);
if (event) flyToEvent(event); if (event) flyToEvent(event);
else if (!selectedEventId) { else if (!selectedEventId && mapBounds) {
if (mapBounds) fitToBounds(mapBounds); fitToBounds(mapBounds, 0);
} }
}, [events, selectedEventId, mapBounds, fitToBounds, clearAllPopups, flyToEvent]); }, [events, selectedEventId, mapBounds, fitToBounds, flyToEvent]);
useEffect(() => { useEffect(() => {
// Clear all popups first
clearAllPopups(); clearAllPopups();
// Handle both events if they exist and are different
if (hoveredEventId && selectedEventId && hoveredEventId !== selectedEventId) { if (hoveredEventId && selectedEventId && hoveredEventId !== selectedEventId) {
showPopup(hoveredEventId); showPopup(hoveredEventId);
showPopup(selectedEventId); showPopup(selectedEventId);
} } else if (hoveredEventId || selectedEventId) {
// Handle single event case (either hovered or selected)
else if (hoveredEventId || selectedEventId) {
showPopup(hoveredEventId || selectedEventId); showPopup(hoveredEventId || selectedEventId);
} }
}, [hoveredEventId, selectedEventId, clearAllPopups, showPopup]); }, [hoveredEventId, selectedEventId, clearAllPopups, showPopup]);
@ -197,7 +188,6 @@ function MapComponent({
); );
} }
// CSS for location-style pulsing animation
const pulseStyles = ` const pulseStyles = `
.location-pulse { .location-pulse {
animation: locationPulse 2s infinite; animation: locationPulse 2s infinite;

View File

@ -1,18 +1,18 @@
import Link from "next/link"; import Link from "next/link";
import React, { Dispatch, SetStateAction } from "react"; import React, { Dispatch, SetStateAction, useEffect, useRef } from "react";
import { TbHexagon } from "react-icons/tb"; import { TbHexagon } from "react-icons/tb";
import Event from "@appTypes/Event"; import GeologicalEvent from "@appTypes/Event";
import getMagnitudeColor from "@utils/getMagnitudeColour"; import getMagnitudeColor from "@utils/getMagnitudeColour";
interface SidebarProps { interface SidebarProps {
logTitle: string; logTitle: string;
logSubtitle: string; logSubtitle: string;
recentsTitle: string; recentsTitle: string;
events: Event[]; events: GeologicalEvent[];
selectedEventId: Event["id"]; selectedEventId: GeologicalEvent["id"];
setSelectedEventId: Dispatch<SetStateAction<string>>; setSelectedEventId: Dispatch<SetStateAction<string>>;
hoveredEventId: Event["id"]; hoveredEventId: GeologicalEvent["id"];
setHoveredEventId: Dispatch<SetStateAction<string>>; setHoveredEventId: Dispatch<SetStateAction<string>>;
button1Name: string; button1Name: string;
button2Name: string; button2Name: string;
@ -48,6 +48,20 @@ export default function Sidebar({
button1Name, button1Name,
button2Name, button2Name,
}: SidebarProps) { }: SidebarProps) {
const eventsContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (selectedEventId && eventsContainerRef.current) {
const selectedEventElement = eventsContainerRef.current.querySelector(`[data-event-id="${selectedEventId}"]`);
if (selectedEventElement) {
selectedEventElement.scrollIntoView({
block: "center",
behavior: "smooth",
});
}
}
}, [selectedEventId]);
return ( return (
<div className="flex flex-col h-full w-80 bg-gradient-to-b from-neutral-100 to-neutral-50 shadow-lg"> <div className="flex flex-col h-full w-80 bg-gradient-to-b from-neutral-100 to-neutral-50 shadow-lg">
<div className="py-6 flex flex-col h-full"> <div className="py-6 flex flex-col h-full">
@ -66,13 +80,14 @@ export default function Sidebar({
</Link> </Link>
</div> </div>
<div className="px-6 pt-6"> <div className="px-6 pt-6">
<h2 className="text-xl font-bold text-neutral-800 mb-2">{recentsTitle}</h2> <h2 className="text-xl font-bold text-neutral-800 mb-4">{recentsTitle}</h2>
</div> </div>
<div className="flex-1 px-6 overflow-y-auto pt-2"> <div className="flex-1 px-6 overflow-y-auto" ref={eventsContainerRef}>
<div className="space-y-3"> <div className="space-y-3">
{events.map((event) => ( {events.map((event) => (
<button <button
key={event.title} key={event.id}
data-event-id={event.id}
className={`w-full border ${hoveredEventId === event.id ? "bg-neutral-100" : "bg-white"} ${ className={`w-full border ${hoveredEventId === event.id ? "bg-neutral-100" : "bg-white"} ${
selectedEventId === event.id ? "border-neutral-800" : "border-neutral-200" selectedEventId === event.id ? "border-neutral-800" : "border-neutral-200"
} rounded-lg p-4 flex items-center gap-4 hover:bg-neutral-100 transition-colors duration-150 shadow-sm text-left`} } rounded-lg p-4 flex items-center gap-4 hover:bg-neutral-100 transition-colors duration-150 shadow-sm text-left`}
@ -83,8 +98,8 @@ export default function Sidebar({
onMouseLeave={() => setHoveredEventId("")} onMouseLeave={() => setHoveredEventId("")}
> >
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-neutral-800 truncate">{event.title}</p> <p className="text-sm font-medium text-neutral-800 line-clamp-1">{event.title}</p>
<p className="text-xs text-neutral-500 mt-1 truncate">{event.text2}</p> <p className="text-xs text-neutral-500 mt-1 line-clamp-1">{event.text2}</p>
</div> </div>
{event.magnitude ? <MagnitudeNumber magnitude={event.magnitude} /> : <></>} {event.magnitude ? <MagnitudeNumber magnitude={event.magnitude} /> : <></>}
</button> </button>

View File

@ -1,5 +1,6 @@
interface Event { interface GeologicalEvent {
id: string; id: string;
date: Date;
title: string; title: string;
magnitude?: number; magnitude?: number;
longitude: number; longitude: number;
@ -8,4 +9,4 @@ interface Event {
text2: string; text2: string;
} }
export default Event; export default GeologicalEvent;