import React, { useState, useRef, useEffect } from 'react';
import Plotly from 'plotly.js-dist';
const EarthquakeApp = () => {
const [startDate, setStartDate] = useState('2025-08-20');
const [endDate, setEndDate] = useState('2025-08-21');
const [earthquakeData, setEarthquakeData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [isPlaying, setIsPlaying] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [showPlates, setShowPlates] = useState(true);
const [animationSpeed, setAnimationSpeed] = useState(1); // 1x, 2x, 4x, 8x
const plotRef = useRef(null);
const animationRef = useRef(null);
const fetchData = async () => {
setLoading(true);
setError('');
try {
const response = await fetch('/api/proxy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
start_date: startDate,
end_date: endDate
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
}
const data = await response.json();
const sortedData = (data.data || []).sort((a, b) =>
new Date(a.full_time) - new Date(b.full_time)
);
setEarthquakeData(sortedData);
setCurrentIndex(0);
setIsPlaying(false);
if (sortedData.length === 0) {
setError('No earthquake data found for the selected date range');
}
} catch (err) {
setError(`Failed to fetch data: ${err.message}`);
} finally {
setLoading(false);
}
};
const initMap = async () => {
if (!plotRef.current) return;
const traces = [
{
type: 'scattergeo',
mode: 'markers',
lat: [], lon: [], text: [],
marker: { size: [], color: 'red', opacity: 0.7, line: { color: 'darkred', width: 1 } },
name: 'Earthquakes'
},
{
type: 'scattergeo',
mode: 'lines',
lat: [], lon: [],
line: { color: 'blue', width: 2, dash: 'dash' },
name: 'Tectonic Plates',
visible: showPlates
}
];
const layout = {
title: {
text: 'Earthquake Animation - World Map',
font: { size: 18, color: '#333' },
x: 0.5
},
geo: {
projection: { type: 'natural earth' },
showland: true, landcolor: 'lightgray',
showocean: true, oceancolor: 'lightblue',
coastlinecolor: 'gray', showframe: false
},
height: 600,
margin: { l: 40, r: 40, t: 60, b: 40 },
showlegend: true,
legend: {
x: 0,
y: 1.2,
bgcolor: 'rgba(255,255,255,0.9)',
}
};
await Plotly.newPlot(plotRef.current, traces, layout, { responsive: true, displayModeBar: true, modeBarButtonsToRemove: ['pan2d', 'select2d', 'lasso2d', 'autoScale2d'] }); // { responsive: true } it ensures that the map resizes correctly
if (showPlates) await loadPlates();
};
const loadPlates = async () => {
try {
const response = await fetch('https://raw.githubusercontent.com/fraxen/tectonicplates/master/GeoJSON/PB2002_boundaries.json');
const plateData = await response.json();
const [lats, lons] = [[], []];
plateData.features.forEach(feature => {
const processCoords = (coords) => { // coords is an array of coordinates [[lon, lat], [lon, lat], ...]
coords.forEach(coord => {
lons.push(coord[0]);
lats.push(coord[1]);
});
lons.push(null);
lats.push(null); // After finishing one line (or segment), we push null to separate line segments in Plotly.
// In Plotly, arrays of coordinates with null values indicate a break in the line.
};
if (feature.geometry.type === 'LineString') {
processCoords(feature.geometry.coordinates);
} else if (feature.geometry.type === 'MultiLineString') {
feature.geometry.coordinates.forEach(processCoords);
}
});
Plotly.restyle(plotRef.current, { lat: [lats], lon: [lons] }, [1]); //[1] tells Plotly to update the second trace (tectonic plates)
} catch (error) {
console.error('Failed to load plates:', error);
}
};
const updateMap = (index) => {
if (!plotRef.current || !earthquakeData.length) return;
const current = earthquakeData.slice(0, index + 1);
const update = {
lat: [current.map(eq => eq.latitude)], // Loops over an array and returns a new array with the results of the callback function (forEach does not return a new element)
lon: [current.map(eq => eq.longitude)],
text: [current.map(eq => `Mag: ${eq.mag}
Location: ${eq.place}
Time: ${eq.full_time}
Depth: ${eq.depth} km`)],
'marker.size': [current.map(eq => Math.max(4, eq.mag * 3))]
};
Plotly.restyle(plotRef.current, update, [0]);
};
const animate = () => {
if (!earthquakeData.length || isPlaying) return;
setIsPlaying(true);
setCurrentIndex(0);
};
const stop = () => {
setIsPlaying(false);
if (animationRef.current) {
clearTimeout(animationRef.current);
animationRef.current = null;
}
// Update map to show only earthquakes up to current index
updateMap(currentIndex);
};
const reset = () => {
stop();
setCurrentIndex(0);
updateMap(-1);
};
const togglePlates = () => {
setShowPlates(!showPlates);
if (plotRef.current) {
Plotly.restyle(plotRef.current, { visible: !showPlates }, [1]); // visible: it shows or hide the tectonic plates
if (!showPlates && (!plotRef.current.data[1].lat || plotRef.current.data[1].lat.length === 0)) {
loadPlates();
}
}
};
useEffect(() => {
initMap();
return () => animationRef.current && clearTimeout(animationRef.current);
}, []); // Initialize map and clear animation on unmount
useEffect(() => { // shows the first earthquake on the map
if (earthquakeData.length > 0 && !isPlaying) updateMap(currentIndex);
}, [earthquakeData, currentIndex]);
useEffect(() => {
if (isPlaying && currentIndex < earthquakeData.length) {
// Group earthquakes by hour
const currentTime = new Date(earthquakeData[currentIndex]?.full_time);
const currentHour = new Date(currentTime.getFullYear(), currentTime.getMonth(), currentTime.getDate(), currentTime.getHours());
// Find all earthquakes in the same hour
let nextIndex = currentIndex;
while (nextIndex < earthquakeData.length) {
const eqTime = new Date(earthquakeData[nextIndex].full_time);
const eqHour = new Date(eqTime.getFullYear(), eqTime.getMonth(), eqTime.getDate(), eqTime.getHours());
if (eqHour.getTime() === currentHour.getTime()) {
nextIndex++;
} else {
break;
}
}
updateMap(nextIndex - 1);
if (nextIndex < earthquakeData.length) {
animationRef.current = setTimeout(() => {
setCurrentIndex(nextIndex);
}, 500 / animationSpeed);
} else {
setIsPlaying(false);
}
}
}, [currentIndex, isPlaying, earthquakeData, animationSpeed]);
const styles = {
container: {
minHeight: '100vh',
backgroundColor: '#f8f9fa',
padding: '20px',
font: 'Avenir sans-serif',
// fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
},
card: {
backgroundColor: 'white',
padding: '24px',
borderRadius: '12px',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
marginBottom: '24px',
maxWidth: '1000px',
margin: '0 auto 24px auto',
border: '1px solid #e9ecef'
},
plotCard: {
backgroundColor: 'white',
padding: '24px',
borderRadius: '12px',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
margin: '0 auto',
maxWidth: '1000px',
border: '1px solid #e9ecef'
},
input: {
padding: '10px 12px',
border: '2px solid #e9ecef',
borderRadius: '6px',
marginRight: '12px',
fontSize: '14px',
transition: 'border-color 0.2s',
outline: 'none'
},
btn: {
padding: '10px 18px',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
marginRight: '12px',
color: 'white',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
},
error: {
marginTop: '16px',
padding: '12px 16px',
backgroundColor: '#f8d7da',
color: '#721c24',
border: '1px solid #f5c6cb',
borderRadius: '6px',
fontSize: '14px'
},
timeDisplay: {
backgroundColor: '#f8f9fa',
border: '2px solid #dee2e6',
padding: '12px 20px',
borderRadius: '8px',
fontSize: '16px',
fontWeight: '500',
color: '#495057',
boxShadow: '0 2px 4px rgba(0,0,0,0.05)'
}
};
return (