|
|
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); |
|
|
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'] }); |
|
|
|
|
|
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.forEach(coord => { |
|
|
lons.push(coord[0]); |
|
|
lats.push(coord[1]); |
|
|
}); |
|
|
lons.push(null); |
|
|
lats.push(null); |
|
|
|
|
|
}; |
|
|
|
|
|
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]); |
|
|
} 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)], |
|
|
lon: [current.map(eq => eq.longitude)], |
|
|
text: [current.map(eq => `Mag: ${eq.mag}<br>Location: ${eq.place}<br>Time: ${eq.full_time}<br>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; |
|
|
} |
|
|
|
|
|
updateMap(currentIndex); |
|
|
}; |
|
|
|
|
|
const reset = () => { |
|
|
stop(); |
|
|
setCurrentIndex(0); |
|
|
updateMap(-1); |
|
|
}; |
|
|
|
|
|
const togglePlates = () => { |
|
|
setShowPlates(!showPlates); |
|
|
if (plotRef.current) { |
|
|
Plotly.restyle(plotRef.current, { visible: !showPlates }, [1]); |
|
|
if (!showPlates && (!plotRef.current.data[1].lat || plotRef.current.data[1].lat.length === 0)) { |
|
|
loadPlates(); |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
useEffect(() => { |
|
|
initMap(); |
|
|
return () => animationRef.current && clearTimeout(animationRef.current); |
|
|
}, []); |
|
|
|
|
|
useEffect(() => { |
|
|
if (earthquakeData.length > 0 && !isPlaying) updateMap(currentIndex); |
|
|
}, [earthquakeData, currentIndex]); |
|
|
|
|
|
useEffect(() => { |
|
|
if (isPlaying && currentIndex < earthquakeData.length) { |
|
|
|
|
|
const currentTime = new Date(earthquakeData[currentIndex]?.full_time); |
|
|
const currentHour = new Date(currentTime.getFullYear(), currentTime.getMonth(), currentTime.getDate(), currentTime.getHours()); |
|
|
|
|
|
|
|
|
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', |
|
|
|
|
|
}, |
|
|
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 ( |
|
|
<div style={styles.container}> |
|
|
<h1 style={{textAlign: 'center', color: '#2c3e50', marginBottom: '40px', fontSize: '2.5rem', fontWeight: '600'}}> |
|
|
Dynamic Earthquake Dashboard |
|
|
</h1> |
|
|
|
|
|
<div style={styles.card}> |
|
|
<h2 style={{marginBottom: '20px', color: '#343a40', fontSize: '1.5rem'}}>Date Range</h2> |
|
|
<div style={{display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '12px'}}> |
|
|
<input |
|
|
type="date" |
|
|
value={startDate} |
|
|
onChange={(e) => setStartDate(e.target.value)} |
|
|
style={styles.input} |
|
|
/> |
|
|
<input |
|
|
type="date" |
|
|
value={endDate} |
|
|
onChange={(e) => setEndDate(e.target.value)} |
|
|
style={styles.input} |
|
|
/> |
|
|
<button |
|
|
onClick={fetchData} |
|
|
disabled={loading} |
|
|
style={{ |
|
|
...styles.btn, |
|
|
backgroundColor: loading ? '#6c757d' : '#007bff', |
|
|
transform: loading ? 'none' : 'translateY(-1px)', |
|
|
cursor: loading ? 'not-allowed' : 'pointer' |
|
|
}} |
|
|
> |
|
|
{loading ? 'Loading...' : 'Fetch Data'} |
|
|
</button> |
|
|
</div> |
|
|
{error && <div style={styles.error}>{error}</div>} |
|
|
</div> |
|
|
|
|
|
{earthquakeData.length > 0 && ( |
|
|
<div style={styles.card}> |
|
|
<h2 style={{marginBottom: '20px', color: '#343a40', fontSize: '1.5rem'}}> |
|
|
Controls |
|
|
</h2> |
|
|
<div style={{display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '12px', marginBottom: '16px'}}> |
|
|
<button |
|
|
onClick={animate} |
|
|
disabled={isPlaying} |
|
|
style={{ |
|
|
...styles.btn, |
|
|
backgroundColor: '#28a745', |
|
|
opacity: isPlaying ? 0.6 : 1, |
|
|
cursor: isPlaying ? 'not-allowed' : 'pointer' |
|
|
}} |
|
|
> |
|
|
{isPlaying ? 'Playing...' : 'Start'} |
|
|
</button> |
|
|
<button |
|
|
onClick={stop} |
|
|
disabled={!isPlaying} |
|
|
style={{ |
|
|
...styles.btn, |
|
|
backgroundColor: '#dc3545', |
|
|
opacity: !isPlaying ? 0.6 : 1, |
|
|
cursor: !isPlaying ? 'not-allowed' : 'pointer' |
|
|
}} |
|
|
> |
|
|
Stop |
|
|
</button> |
|
|
<button |
|
|
onClick={reset} |
|
|
disabled={isPlaying} |
|
|
style={{ |
|
|
...styles.btn, |
|
|
backgroundColor: '#6c757d', |
|
|
opacity: isPlaying ? 0.6 : 1, |
|
|
cursor: isPlaying ? 'not-allowed' : 'pointer' |
|
|
}} |
|
|
> |
|
|
Reset |
|
|
</button> |
|
|
<button |
|
|
onClick={togglePlates} |
|
|
style={{ |
|
|
...styles.btn, |
|
|
backgroundColor: showPlates ? '#17a2b8' : '#6c757d' |
|
|
}} |
|
|
> |
|
|
{showPlates ? 'Hide' : 'Show'} Plates |
|
|
</button> |
|
|
<button |
|
|
onClick={() => setAnimationSpeed(prev => prev >= 8 ? 1 : prev * 2)} |
|
|
style={{ |
|
|
...styles.btn, |
|
|
backgroundColor: '#ffc107', |
|
|
color: '#212529' |
|
|
}} |
|
|
> |
|
|
Speed: {animationSpeed}x |
|
|
</button> |
|
|
</div> |
|
|
<div style={{fontSize: '14px', color: '#6c757d', fontWeight: '500'}}> |
|
|
Showing: <span style={{fontWeight: 'bold', color: '#495057'}}>{Math.min(currentIndex + 1, |
|
|
earthquakeData.length)}</span> of <span style={{fontWeight: 'bold', color: '#495057'}}>{earthquakeData.length}</span> earthquakes found |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
<div style={styles.plotCard}> |
|
|
<div style={{ |
|
|
display: 'flex', |
|
|
flexDirection: 'column', |
|
|
alignItems: 'center', |
|
|
marginBottom: '24px' |
|
|
}}> |
|
|
<div style={{ |
|
|
marginBottom: '12px', |
|
|
fontSize: '16px', |
|
|
fontWeight: '500', |
|
|
color: '#495057' |
|
|
}}> |
|
|
Current Earthquake Time: |
|
|
</div> |
|
|
<div style={styles.timeDisplay}> |
|
|
{earthquakeData[currentIndex]?.full_time || 'No data available'} |
|
|
</div> |
|
|
</div> |
|
|
<div ref={plotRef} style={{width: '100%'}}></div> {/* If you want React to "store" a DOM element inside a ref, you must attach it with the ref attribute. */} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default EarthquakeApp; |