import urllib.parse
import geopandas as gpd
import pandas as pd
# these are the rough bounds of the earthquake and its aftershocks
= 27, 29
minlatitude, maxlatitude = 84, 87
minlongitude, maxlongitude
# limiting the search to April and May 2015, and only those >= 5 magnitude
= "https://earthquake.usgs.gov/fdsnws/event/1/query.geojson"
CATALOG_URL = {
params "starttime": "2015-04-01",
"endtime": "2015-05-31",
"maxlatitude": maxlatitude,
"minlatitude": minlatitude,
"maxlongitude": maxlongitude,
"minlongitude": minlongitude,
"minmagnitude": 5,
"eventtype": "earthquake",
"orderby": "time",
}= ["mag", "time", "geometry"]
columns
= f"{CATALOG_URL}?{urllib.parse.urlencode(params)}"
url = gpd.read_file(url, columns=columns)
quakes
# get the national boundary of Nepal
= "https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/refs/heads/main/releaseData/gbOpen/NPL/ADM0/geoBoundaries-NPL-ADM0.geojson"
boundary_url = gpd.read_file(boundary_url)
nepal
# ensure both dataframes are in the same CRS
= quakes.to_crs(nepal.crs)
quakes
# filter earthquakes to those within Nepal's boundaries
= quakes[quakes.within(nepal.iloc[0]['geometry'])]
quakes
# sort from earliest to latest
# localize the datetime to Nepal's datetime
= quakes.sort_values("time", ascending=True, ignore_index=True)
quakes "time"] = pd.to_datetime(quakes["time"], unit="ms").dt.tz_localize(
quakes[="Asia/Kathmandu",
tz
)
# for working with matplotlib, add separate latitude/longitude columns
= quakes.join(
quakes =dict(y="latitude", x="longitude"))
quakes.get_coordinates().rename(columns )
I have been looking at generating animated plots using Matplotlib, with the official documentation providing an excellent example for basic animations.
However, I couldn’t find any good examples of generating animations with legends for size of the marker in a scatter plot. I had been looking to animate the time and magnitude of the 25th April 2015 earthquake of Nepal along with its foreshocks and aftershocks.
In this post, I will explain how I managed to achieve that (with help from some excellent references online, of course).
Getting the earthquake data
The first step is of course to the get the data about the earthquakes. This is easily available using the USGS Catalog. I used geopandas
to directly read a GeoDataFrame
from the catalog’s API, but I won’t go into a lot of details here; the code below should be self explanatory.
One important thing to note is that the catalog API doesn’t support filtering by country. It only supports filtering by latitutde and longitude bounds (west, south, east, north). After this, I used the within
method of geopandas
to limit the results to earthquakes that occured only in Nepal. The within
method requires a Polygon object. For this, I directly downloaded a GeoJSON file of the administrative boundaries from the geoBoundaries project on GitHub.
Now, lets look at what this data looks like, and then do a simple visualisation with geopandas
. We have 30 earthquakes >= 5 magnitude.
quakes.head()
mag | time | geometry | longitude | latitude | |
---|---|---|---|---|---|
0 | 7.8 | 2015-04-25 06:11:25.950000+05:45 | POINT Z (84.7314 28.2305 8.22) | 84.7314 | 28.2305 |
1 | 6.1 | 2015-04-25 06:15:22.910000+05:45 | POINT Z (85.5398 27.6285 10) | 85.5398 | 27.6285 |
2 | 5.6 | 2015-04-25 06:18:10.870000+05:45 | POINT Z (86.0213 27.6857 10) | 86.0213 | 27.6857 |
3 | 5.4 | 2015-04-25 06:20:40.340000+05:45 | POINT Z (84.492 28.2046 10) | 84.4920 | 28.2046 |
4 | 5.1 | 2015-04-25 06:22:02.750000+05:45 | POINT Z (85.1141 27.8006 10) | 85.1141 | 27.8006 |
import matplotlib.pyplot as plt
= plt.subplots(figsize=(6,4))
fig, ax ="white", edgecolor="black", ax=ax)
nepal.plot(color=ax, markersize='mag', color="red")
quakes.plot(ax plt.show()
Of course, some of these earthquakes might not be foreshocks or aftershocks, but that shouldn’t matter for the purpose of this post. The main goal is to animate the scatter plot.
Generating the animated plot
My goals for the plot were to:
- animate the time the earthquake occurred,
- marker sizes indicating the earthquake’s magnitude,
- add a legend for the magnitude.
Marker size for magnitudes
Earthquake magnitudes are logarithmic in nature; for example, a magnitude 7 earthquake is 10 times stronger than a magnitude 6 one. To visualise this, I have used the following formula to calculate the marker size for the earthquakes.
\(marker\_size = base\_size \times 10^{(magnitude - base\_magnitude)}\)
= 10
min_marker_size = 5
min_magnitude "marker_size"] = min_marker_size * (10 ** (quakes["mag"] - min_magnitude))
quakes[
quakes.head()
mag | time | geometry | longitude | latitude | marker_size | |
---|---|---|---|---|---|---|
0 | 7.8 | 2015-04-25 06:11:25.950000+05:45 | POINT Z (84.7314 28.2305 8.22) | 84.7314 | 28.2305 | 6309.573445 |
1 | 6.1 | 2015-04-25 06:15:22.910000+05:45 | POINT Z (85.5398 27.6285 10) | 85.5398 | 27.6285 | 125.892541 |
2 | 5.6 | 2015-04-25 06:18:10.870000+05:45 | POINT Z (86.0213 27.6857 10) | 86.0213 | 27.6857 | 39.810717 |
3 | 5.4 | 2015-04-25 06:20:40.340000+05:45 | POINT Z (84.492 28.2046 10) | 84.4920 | 28.2046 | 25.118864 |
4 | 5.1 | 2015-04-25 06:22:02.750000+05:45 | POINT Z (85.1141 27.8006 10) | 85.1141 | 27.8006 | 12.589254 |
A basic animated scatter plot
Generating a simple animated scatter plot in Matplotlib is quite easy. Let’s do that to start with.
import contextily as cx
import matplotlib.animation as animation
import matplotlib.pyplot as plt
import numpy as np
= plt.subplots(layout="constrained", figsize=(6,5))
fig, ax set(xlim=[minlongitude, maxlongitude], ylim=[minlatitude, maxlatitude])
ax."The April 2015 Nepal earthquake and its aftershocks")
ax.set_title(
# add basemap
=quakes.crs, zoom=8, source="CartoDB.Voyager")
cx.add_basemap(ax, crs
# the first point in the animation
= dict(alpha=0.5, linewidth=0, color="red")
points_opts = ax.scatter(
points "longitude"].iloc[0],
quakes["latitude"].iloc[0],
quakes[=quakes["marker_size"].iloc[0],
s**points_opts
)
# the first label
# labels show the time of the earthquake
= dict(ha="center", va="center", fontsize=15, color="black", transform=ax.transAxes)
label_opts = ax.text(
label 0.25,
0.95,
"time"].iloc[0].strftime("%Y-%m-%d %H:%M"),
quakes[**label_opts
)
# loop through each point in the data and draw a marker and legend for that data point
def update(frame):
# for each frame, update the data stored on each artist.
= quakes["longitude"].iloc[:frame]
x = quakes["latitude"].iloc[:frame]
y
# update the scatter plot:
= np.stack([x, y]).T
data
points.set_offsets(data)
# set the size of the earthquakes
"marker_size"].iloc[:frame])
points.set_sizes(quakes[
# update the label
"time"].iloc[frame - 1].strftime("%Y-%m-%d %H:%M"))
label.set_text(quakes[
return (points,)
# animate by looping through all datapoints.
= animation.FuncAnimation(
ani =fig, func=update, frames=len(quakes), interval=600
fig
)
# To save the animation using Pillow as a gif
= animation.PillowWriter(fps=10, bitrate=1800)
writer "animated-scatter-v1.gif", writer=writer)
ani.save(
plt.ioff plt.close()
This gives us a nice animated scatter plot.
But the legend is missing, without which the marker sizes don’t have much meaning. I found adding the correct legend to be the most difficult part of this process because there aren’t many working examples of this on the WWW.
Custom legend
The solution that I ended up using is to add a custom legend that doesn’t completely correspond with the data being plotted. This is because I wanted to show the magnitude legend from the get go even when only a small part of the data has been plotted initially. I used the example in the official documentation as a starting point.
I want the legend to have 4 different sizes: 5, 6, 7, and 7.8 (the strongest earthquake). My first attempt was to generate the marker sizes as I did above and generate the legend. I added a Line2D object for each marker size, and use the collection of those Line2D objects to create the legend.
from matplotlib.lines import Line2D
# the four magnitudes in the legend
= np.array([5, 6, 7, quakes['mag'].max()])
mags = min_marker_size * (10 ** (mags - min_magnitude))
marker_sizes
def get_legend_elements(marker_sizes):
# marker size for each magnitude
= dict(marker="o", color="w", markerfacecolor="red", alpha=0.5)
marker_opts = [
legend_elements
Line2D(0],
[
[i],=mags[i],
label=s,
markersize**marker_opts
)for i, s in enumerate(marker_sizes)
]return legend_elements
= plt.subplots()
fig, ax = dict(title="Magnitude", labelspacing=1, frameon=False)
legend_opts =get_legend_elements(marker_sizes), **legend_opts, loc='center') ax.legend(handles
That did’t work as exepcted as the marker sizes were too big and didn’t align with the marker sizes on the scatter plot. I arrived at the solution after a lot of digging around and trial and error.
Looking at the scatterplot
documentation, it turns out that the marker size (s
) is points ** 2. However, the marker size (markersize
) in Line2D is only in points This means the marker size for the legend should be square roots of the marker size for the scatter plot. Perhaps I should have realised this much sooner.
Below is the 2nd version of the legend; and the sizes correspond with those in the plot.
Second attempt
= plt.subplots(layout="constrained", figsize=(7,5))
fig, ax set(xlim=[minlongitude, maxlongitude], ylim=[minlatitude, maxlatitude])
ax."The April 2015 Nepal earthquake and its aftershocks")
ax.set_title(=quakes.crs, zoom=8, source="CartoDB.Voyager")
cx.add_basemap(ax, crs
= ax.scatter(
points "longitude"].iloc[0],
quakes["latitude"].iloc[0],
quakes[=quakes["marker_size"].iloc[0],
s**points_opts
)
= ax.text(
label 0.25,
0.95,
"time"].iloc[0].strftime("%Y-%m-%d %H:%M"),
quakes[**label_opts
)
ax.legend(=get_legend_elements(marker_sizes),
handles="center left",
loc="Magnitude",
title=1,
labelspacing=False,
frameon=(1.05, 0.5), # place the legend to the right
bbox_to_anchor
)
= animation.FuncAnimation(
ani =fig, func=update, frames=len(quakes), interval=600
fig
)
# To save the animation using Pillow as a gif
= animation.PillowWriter(fps=15, bitrate=1800)
writer "animated-scatter-v2.gif", writer=writer)
ani.save(
plt.ioff plt.close()
Finally, this gave me the animated plot that I wanted to create.
.