diff --git a/docs/best_practices.md b/docs/best_practices.md
index a1770df..d71bdf1 100644
--- a/docs/best_practices.md
+++ b/docs/best_practices.md
@@ -170,3 +170,13 @@ No 'max_frames' specified; using the default 50 / 100 frames. Pass `-1` to use a
## 🧩 Use `processes=False` for rendering HoloViews objects
This is done automatically! However, in case there's an edge case, note that the kdims/vdims don't seem to carry over properly to the subprocesses when rendering HoloViews objects. It might complain that it can't find the desired dimensions.
+
+## 📚 Use `threads_per_worker` if flickering
+
+Matplotlib is not always thread-safe, so if you're seeing flickering, set `threads_per_worker=1`.
+
+```python
+from streamjoy import stream
+
+stream(..., threads_per_worker=1)
+```
\ No newline at end of file
diff --git a/docs/example_recipes/air_temperature.md b/docs/example_recipes/air_temperature.md
index 1049ddd..ec4b9f7 100644
--- a/docs/example_recipes/air_temperature.md
+++ b/docs/example_recipes/air_temperature.md
@@ -15,6 +15,7 @@ Highlights:
import xarray as xr
import streamjoy.xarray
-ds = xr.tutorial.open_dataset("air_temperature")
-ds.streamjoy("air_temperature.mp4")
+if __name__ == "__main__":
+ ds = xr.tutorial.open_dataset("air_temperature")
+ ds.streamjoy("air_temperature.mp4")
```
diff --git a/docs/example_recipes/co2_timeseries.md b/docs/example_recipes/co2_timeseries.md
new file mode 100644
index 0000000..5b5863e
--- /dev/null
+++ b/docs/example_recipes/co2_timeseries.md
@@ -0,0 +1,158 @@
+# CO2 timeseries
+
+
+
+Shows the yearly CO2 measurements from the Mauna Loa Observatory in Hawaii.
+
+The data is sourced from the [datasets/co2-ppm-daily](https://github.com/datasets/co2-ppm-daily/blob/master/co2-ppm-daily-flow.py).
+
+Highlights:
+
+- Uses `wrap_matplotlib` to automatically handle saving and closing the figure.
+- Uses a custom `renderer` function to create each frame of the animation.
+- Uses `Paused` to pause the animation at notable dates.
+
+```python
+import pandas as pd
+import matplotlib.pyplot as plt
+from matplotlib.ticker import AutoMinorLocator
+from streamjoy import stream, wrap_matplotlib, Paused
+
+URL = "https://raw.githubusercontent.com/datasets/co2-ppm-daily/master/data/co2-ppm-daily.csv"
+NOTABLE_YEARS = {
+ 1958: "Mauna Loa measurements begin",
+ 1979: "1st World Climate Conference",
+ 1997: "Kyoto Protocol",
+ 2005: "exceeded 380 ppm",
+ 2010: "exceeded 390 ppm",
+ 2013: "exceeded 400 ppm",
+ 2015: "Paris Agreement",
+}
+
+
+@wrap_matplotlib()
+def renderer(df):
+ plt.style.use("dark_background")
+
+ fig, ax = plt.subplots(figsize=(7, 5))
+ fig.patch.set_facecolor("#1b1e23")
+ ax.set_facecolor("#1b1e23")
+ ax.set_frame_on(False)
+ ax.axis("off")
+ ax.set_title(
+ "CO2 Yearly Max",
+ fontsize=20,
+ loc="left",
+ fontname="Courier New",
+ color="lightgrey",
+ )
+
+ # draw line
+ df.plot(
+ y="value",
+ color="lightgrey", # Line color
+ legend=False,
+ ax=ax,
+ )
+
+ # max date
+ max_date = df["value"].idxmax()
+ max_co2 = df["value"].max()
+ ax.text(
+ 0.0,
+ 0.92,
+ f"{max_co2:.0f} ppm",
+ va="bottom",
+ ha="left",
+ transform=ax.transAxes,
+ fontsize=25,
+ color="lightgrey",
+ )
+ ax.text(
+ 0.0,
+ 0.91,
+ f"Peaked in {max_date.year}",
+ va="top",
+ ha="left",
+ transform=ax.transAxes,
+ fontsize=12,
+ color="lightgrey",
+ fontname="Courier New",
+ )
+
+ # draw end point
+ date = df.index[-1]
+ co2 = df["value"].values[-1]
+ diff = df["diff"].fillna(0).values[-1]
+ diff = f"+{diff:.0f}" if diff >= 0 else f"{diff:.0f}"
+ ax.scatter(date, co2, color="red", zorder=999)
+ ax.annotate(
+ f"{diff} ppm",
+ (date, co2),
+ textcoords="offset points",
+ xytext=(-10, 5),
+ fontsize=12,
+ ha="right",
+ va="bottom",
+ color="lightgrey",
+ )
+
+ # draw source label
+ ax.text(
+ 0.0,
+ 0.03,
+ f"Source: {URL}",
+ va="bottom",
+ ha="left",
+ transform=ax.transAxes,
+ fontsize=8,
+ color="lightgrey",
+ )
+
+ # properly tighten layout
+ plt.subplots_adjust(bottom=0, top=0.9, right=0.9, left=0.05)
+
+ # pause at notable years
+ year = date.year
+ if year in NOTABLE_YEARS:
+ ax.annotate(
+ f"{NOTABLE_YEARS[year]} - {year}",
+ (date, co2),
+ textcoords="offset points",
+ xytext=(-10, 3),
+ fontsize=10.5,
+ ha="right",
+ va="top",
+ color="lightgrey",
+ fontname="Courier New",
+ )
+ return Paused(ax, 2.8)
+ else:
+ ax.annotate(
+ year,
+ (date, co2),
+ textcoords="offset points",
+ xytext=(-10, 3),
+ fontsize=10.5,
+ ha="right",
+ va="top",
+ color="lightgrey",
+ fontname="Courier New",
+ )
+ return ax
+
+
+if __name__ == "__main__":
+ df = (
+ pd.read_csv(URL, parse_dates=True, index_col="date")
+ .resample("1YE")
+ .max()
+ .interpolate()
+ .assign(
+ diff=lambda df: df["value"].diff(),
+ )
+ )
+ stream(df, renderer=renderer, max_frames=-1, threads_per_worker=1).write("co2_emissions.mp4")
+```
\ No newline at end of file
diff --git a/docs/example_recipes/temperature_anomaly.md b/docs/example_recipes/temperature_anomaly.md
new file mode 100644
index 0000000..cc80916
--- /dev/null
+++ b/docs/example_recipes/temperature_anomaly.md
@@ -0,0 +1,153 @@
+# Temperature anomaly
+
+
+
+Shows the global temperature anomaly from 1995 to 2024 using the HadCRUT5 dataset. The video pauses at notable dates.
+
+Highlights:
+
+- Uses `wrap_matplotlib` to automatically handle saving and closing the figure.
+- Uses a custom `renderer` function to create each frame of the animation.
+- Uses `Paused` to pause the animation at notable dates.
+
+```python
+import pandas as pd
+import matplotlib.pyplot as plt
+from streamjoy import stream, wrap_matplotlib, Paused
+
+URL = "https://climexp.knmi.nl/data/ihadcrut5_global.dat"
+NOTABLE_DATES = {
+ "1997-12": "Kyoto Protocol adopted",
+ "2005-01": "Exceeded 380 ppm",
+ "2010-01": "Exceeded 390 ppm",
+ "2013-05": "Exceeded 400 ppm",
+ "2015-12": "Paris Agreement signed",
+ "2016-01": "CO2 permanently over 400 ppm",
+}
+
+
+@wrap_matplotlib()
+def renderer(df):
+ plt.style.use("dark_background") # Setting the style for dark mode
+
+ fig, ax = plt.subplots()
+ fig.patch.set_facecolor("#1b1e23")
+ ax.set_facecolor("#1b1e23")
+ ax.set_frame_on(False)
+ ax.axis("off")
+
+ # Set title
+ year = df["year"].iloc[-1]
+ ax.set_title(
+ f"Global Temperature Anomaly {year} [HadCRUT5]",
+ fontsize=15,
+ loc="left",
+ fontname="Courier New",
+ color="lightgrey",
+ )
+
+ # draw line
+ df.groupby("year")["anom"].plot(
+ y="anom", color="lightgrey", legend=False, ax=ax, lw=0.5
+ )
+
+ # add source text at bottom right
+ ax.text(
+ 0.01,
+ 0.05,
+ f"Source: {URL}",
+ va="bottom",
+ ha="left",
+ transform=ax.transAxes,
+ fontsize=8,
+ color="lightgrey",
+ fontname="Courier New",
+ )
+
+ # draw end point
+ jday = df.index.values[-1]
+ anom = df["anom"].values[-1]
+ ax.scatter(jday, anom, color="red", zorder=999)
+ anom_label = f"+{anom:.1f} K" if anom > 0 else f"{anom:.1f} K"
+ ax.annotate(
+ anom_label,
+ (jday, anom),
+ textcoords="offset points",
+ xytext=(-10, 5),
+ fontsize=12,
+ ha="right",
+ va="bottom",
+ color="lightgrey",
+ )
+
+ # draw yearly labels
+ for year, df_year in df.reset_index().groupby("year").last().iloc[-5:].iterrows():
+ if df_year["month"] != 12:
+ continue
+ ax.annotate(
+ year,
+ (df_year["jday"], df_year["anom"]),
+ fontsize=12,
+ ha="left",
+ va="center",
+ color="lightgrey",
+ fontname="Courier New",
+ )
+
+ plt.subplots_adjust(bottom=0, top=0.9, left=0.05)
+
+ month = df["date"].iloc[-1].strftime("%b")
+ ax.annotate(
+ month,
+ (jday, anom),
+ textcoords="offset points",
+ xytext=(-10, 3),
+ fontsize=12,
+ ha="right",
+ va="top",
+ color="lightgrey",
+ fontname="Courier New",
+ )
+ date_string = df["date"].iloc[-1].strftime("%Y-%m")
+ if date_string in NOTABLE_DATES:
+ ax.annotate(
+ f"{NOTABLE_DATES[date_string]}",
+ xy=(0, 1),
+ xycoords="axes fraction",
+ xytext=(0, -5),
+ textcoords="offset points",
+ fontsize=12,
+ ha="left",
+ va="top",
+ color="lightgrey",
+ fontname="Courier New",
+ )
+ return Paused(fig, 3)
+ return fig
+
+
+df = (
+ pd.read_csv(
+ URL,
+ comment="#",
+ header=None,
+ sep="\s+",
+ na_values=[-999.9],
+ )
+ .rename(columns={0: "year"})
+ .melt(id_vars="year", var_name="month", value_name="anom")
+)
+df.index = pd.to_datetime(
+ df["year"].astype(str) + df["month"].astype(str), format="%Y%m"
+)
+df = df.sort_index()["1995":"2024"]
+df["jday"] = df.index.dayofyear
+df = df.rename_axis("date").reset_index().set_index("jday")
+df_list = [df[:i] for i in range(1, len(df) + 1)]
+
+stream(df_list, renderer=renderer, threads_per_worker=1).write(
+ "temperature_anomaly.mp4"
+)
+```
\ No newline at end of file
diff --git a/mkdocs.yml b/mkdocs.yml
index 68d4da0..0a7fa61 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -67,6 +67,8 @@ nav:
- Example recipes:
- Air temperature: example_recipes/air_temperature.md
- Sine wave: example_recipes/sine_wave.md
+ - CO2 timeseries: example_recipes/co2_timeseries.md
+ - Temperature anomaly: example_recipes/temperature_anomaly.md
- Sea ice: example_recipes/sea_ice.md
- OISST globe: example_recipes/oisst_globe.md
- Gender gapminder: example_recipes/gender_gapminder.md
diff --git a/streamjoy/_utils.py b/streamjoy/_utils.py
index c5a2db2..30752e3 100644
--- a/streamjoy/_utils.py
+++ b/streamjoy/_utils.py
@@ -296,5 +296,5 @@ def imread_with_pause(
) -> np.ndarray | Paused:
imread_kwargs = dict(extension=extension, plugin=plugin)
if isinstance(uri, Paused):
- return Paused(iio.imread(uri.output, **imread_kwargs), uri.seconds).squeeze()
+ return Paused(iio.imread(uri.output, **imread_kwargs).squeeze(), uri.seconds)
return iio.imread(uri, **imread_kwargs).squeeze()
diff --git a/streamjoy/models.py b/streamjoy/models.py
index 49f35f6..66f69bd 100644
--- a/streamjoy/models.py
+++ b/streamjoy/models.py
@@ -17,7 +17,7 @@ class Paused(param.Parameterized):
output = param.Parameter(doc="The output to pause for.")
- seconds = param.Integer(doc="The number of seconds to pause for.")
+ seconds = param.Number(doc="The number of seconds to pause for.")
def __init__(self, output: Any, seconds: int, **params):
self.output = output
diff --git a/streamjoy/streams.py b/streamjoy/streams.py
index 7bd794e..1e5c17a 100644
--- a/streamjoy/streams.py
+++ b/streamjoy/streams.py
@@ -1249,8 +1249,11 @@ def _write_images(
self._prepend_intro(buf, intro_frame, **write_kwargs)
- for image in images:
+ for i, image in enumerate(images):
image = _utils.get_result(image)
+ if isinstance(image, Paused):
+ duration[i] = image.seconds * 1000
+ image = image.output
buf.write(image[:, :, :3], **write_kwargs)
del image
diff --git a/tests/conftest.py b/tests/conftest.py
index 7d7f692..05adf04 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -42,6 +42,7 @@ def client():
@pytest.fixture(autouse=True, scope="session")
def default_config():
+ config["fps"] = 1
config["max_frames"] = 3
config["max_files"] = 2
config["ending_pause"] = 0
diff --git a/tests/test_streams.py b/tests/test_streams.py
index e84d8ac..2926f46 100644
--- a/tests/test_streams.py
+++ b/tests/test_streams.py
@@ -1,7 +1,9 @@
import pytest
from imageio.v3 import improps
+from streamjoy.models import Paused
from streamjoy.streams import GifStream, Mp4Stream
+from streamjoy.wrappers import wrap_matplotlib
class AbstractTestMediaStream:
@@ -50,8 +52,26 @@ class TestGifStream(AbstractTestMediaStream):
def stream_cls(self):
return GifStream
+ def test_paused(self, stream_cls, df):
+ @wrap_matplotlib()
+ def renderer(df, groupby=None): # TODO: fix bug groupby not needed
+ return Paused(df.plot(), seconds=2)
+
+ buf = stream_cls.from_pandas(df, renderer=renderer).write()
+ props = improps(buf)
+ assert props.n_images == 3
+
class TestMp4Stream(AbstractTestMediaStream):
@pytest.fixture(scope="class")
def stream_cls(self):
return Mp4Stream
+
+ def test_paused(self, stream_cls, df):
+ @wrap_matplotlib()
+ def renderer(df, groupby=None): # TODO: fix bug groupby not needed
+ return Paused(df.plot(), seconds=2)
+
+ buf = stream_cls.from_pandas(df, renderer=renderer).write()
+ props = improps(buf)
+ assert props.n_images == 9