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 { Earthquake } from "@prismaclient";
import { getRelativeDate } from "@utils/formatters";
import GeologicalEvent from "@appTypes/Event";
export default function Earthquakes() {
const [selectedEventId, setSelectedEventId] = useState("");
@ -18,14 +19,20 @@ export default function Earthquakes() {
const earthquakeEvents = useMemo(
() =>
data && data.earthquakes
? data.earthquakes.map((x: Earthquake) => ({
id: x.id,
? data.earthquakes
.map(
(x: Earthquake): GeologicalEvent => ({
id: x.code,
title: `Earthquake in ${x.code.split("-")[2]}`,
magnitude: x.magnitude,
longitude: x.longitude,
latitude: x.latitude,
text1: "",
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]
);

View File

@ -1,4 +1,5 @@
"use client";
import { useMemo } from "react";
import { useState } from "react";
import useSWR from "swr";
@ -6,18 +7,41 @@ import useSWR from "swr";
import Sidebar from "@/components/Sidebar";
import Map from "@components/Map";
import { fetcher } from "@utils/axiosHelpers";
import { Observatory } from "@prismaclient";
import { getRelativeDate } from "@utils/formatters";
import GeologicalEvent from "@appTypes/Event";
export default function Observatories() {
const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState("");
// todo properly integrate loading
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 (
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
<div className="flex-grow h-full">
<Map
events={data ? data.observatories : []}
events={observatoryEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}
@ -29,7 +53,7 @@ export default function Observatories() {
logTitle="Observatory Mapping"
logSubtitle="Record and search observatories - time/date set-up, location, scientists and recent earthquakes"
recentsTitle="Observatory Events"
events={data ? data.observatories : []}
events={observatoryEvents}
selectedEventId={selectedEventId}
setSelectedEventId={setSelectedEventId}
hoveredEventId={hoveredEventId}

View File

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

View File

@ -1,18 +1,18 @@
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 Event from "@appTypes/Event";
import GeologicalEvent from "@appTypes/Event";
import getMagnitudeColor from "@utils/getMagnitudeColour";
interface SidebarProps {
logTitle: string;
logSubtitle: string;
recentsTitle: string;
events: Event[];
selectedEventId: Event["id"];
events: GeologicalEvent[];
selectedEventId: GeologicalEvent["id"];
setSelectedEventId: Dispatch<SetStateAction<string>>;
hoveredEventId: Event["id"];
hoveredEventId: GeologicalEvent["id"];
setHoveredEventId: Dispatch<SetStateAction<string>>;
button1Name: string;
button2Name: string;
@ -48,6 +48,20 @@ export default function Sidebar({
button1Name,
button2Name,
}: 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 (
<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">
@ -66,13 +80,14 @@ export default function Sidebar({
</Link>
</div>
<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 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">
{events.map((event) => (
<button
key={event.title}
key={event.id}
data-event-id={event.id}
className={`w-full border ${hoveredEventId === event.id ? "bg-neutral-100" : "bg-white"} ${
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`}
@ -83,8 +98,8 @@ export default function Sidebar({
onMouseLeave={() => setHoveredEventId("")}
>
<div className="flex-1">
<p className="text-sm font-medium text-neutral-800 truncate">{event.title}</p>
<p className="text-xs text-neutral-500 mt-1 truncate">{event.text2}</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 line-clamp-1">{event.text2}</p>
</div>
{event.magnitude ? <MagnitudeNumber magnitude={event.magnitude} /> : <></>}
</button>

View File

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