#!/usr/bin/env python # coding: utf-8 # **NOTE: GitHub doesn't render the GIF animations in this notebook. View this locally or in nbviewer for best results.** # # [![Nbviewer](https://img.shields.io/badge/render-nbviewer-lightgrey?logo=jupyter)](https://nbviewer.jupyter.org/github/stefmolin/python-data-viz-workshop/blob/main/slides/2-animations.ipynb) [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/stefmolin/python-data-viz-workshop/main?urlpath=lab/tree/slides/2-animations.ipynb) [![View slides in browser](https://img.shields.io/badge/view-slides-orange?logo=reveal.js&logoColor=white)](https://stefaniemolin.com/python-data-viz-workshop/#/section-2) # # --- # # Section 2: Moving Beyond Static Visualizations # # Static visualizations are limited in how much information they can show. To move beyond these limitations, we can create animated and/or interactive visualizations. Animations make it possible for our visualizations to tell a story through movement of the plot components (e.g., bars, points, lines). Interactivity makes it possible to explore the data visually by hiding and displaying information based on user interest. In this section, we will focus on creating animated visualizations using Matplotlib before moving on to create interactive visualizations in the next section. # #
# King penguin backlit by setting sun #
A whole new world awaits...
#
# ## Learning Path # # 1. **Animating cumulative values over time** # 2. Animating distributions over time # 3. Animating geospatial data with HoloViz # ## Animating cumulative values over time # # In the previous section, we made a couple of visualizations to help us understand the number of Stack Overflow questions per library and how it changed over time. However, each of these came with some limitations. # We made a bar plot that captured the total number of questions per library, but it couldn't show us the growth in pandas questions over time (or how the growth rate changed over time): # #
# bar plot #
# We also made an area plot showing the number of questions per day over time for the top 4 libraries, but by limiting the libraries shown we lost some information: # #
# area plot #
# Both of these visualizations gave us insight into the dataset. For example, we could see that pandas has by far the largest number of questions and has been growing at a faster rate than the other libraries. While this comes from studying the plots, an animation would make this much more obvious and, at the same time, capture the exponential growth in pandas questions that helped pandas overtake both Matplotlib and NumPy in cumulative questions. # Let's use Matplotlib to create an animated bar plot of cumulative questions over time to show this. We will do so in the following steps: # 1. Create a dataset of cumulative questions per library over time. # 2. Import the `FuncAnimation` class. # 3. Write a function for generating the initial plot. # 4. Write a function for generating annotations and plot text. # 5. Define the plot update function. # 6. Bind arguments to the update function. # 7. Animate the plot. # #### 1. Create a dataset of cumulative questions per library over time. # We will start by reading in our Stack Overflow dataset, but this time, we will calculate the total number of questions per month and then calculate the cumulative value over time: # In[1]: import pandas as pd questions_per_library = pd.read_csv( '../data/stackoverflow.zip', parse_dates=True, index_col='creation_date' ).loc[:,'pandas':'bokeh'].resample('1M').sum().cumsum().reindex( pd.date_range('2008-08', '2021-10', freq='M') ).fillna(0) questions_per_library.tail() # *Source: [Stack Exchange Network](https://api.stackexchange.com/docs/search)* # #### 2. Import the `FuncAnimation` class. # To create animations with Matplotlib, we will be using the `FuncAnimation` class, so let's import it now: # In[2]: from matplotlib.animation import FuncAnimation # At a minimum, we will need to provide the following when instantiating a `FuncAnimation` object: # - The `Figure` object to draw on. # - A function to call at each frame to update the plot. # In the next few steps, we will work on the logic for these. # #### 3. Write a function for generating the initial plot. # Since we are required to pass in a `Figure` object and bake all the plot update logic into a function, we will start by building up an initial plot. Here, we create a bar plot with bars of width 0, so that they don't show up for now. The y-axis is set up so that the libraries with the most questions overall are at the top: # In[3]: import matplotlib.pyplot as plt from matplotlib import ticker from utils import despine def bar_plot(data): fig, ax = plt.subplots(figsize=(6, 4), layout='constrained') sort_order = data.last('1M').squeeze().sort_values().index bars = ax.barh(sort_order, [0] * data.shape[1], label=sort_order) ax.set_xlabel('total questions', fontweight='bold') ax.set_xlim(0, 250_000) ax.xaxis.set_major_formatter(ticker.EngFormatter()) ax.xaxis.set_tick_params(labelsize=11) ax.yaxis.set_tick_params(labelsize=11) despine(ax) return fig, ax # This gives us a plot that we can update: # In[4]: import matplotlib_inline from utils import mpl_svg_config matplotlib_inline.backend_inline.set_matplotlib_formats( 'svg', **mpl_svg_config('section-2') ) bar_plot(questions_per_library) # #### 4. Write a function for generating annotations and plot text. # # We will also need to initialize annotations for each of the bars and some text to show the date in the animation (month and year): # In[5]: def generate_plot_text(ax): annotations = [ ax.annotate( '', xy=(0, bar.get_y() + bar.get_height() / 2), ha='left', va='center' ) for bar in ax.patches ] time_text = ax.text( 0.9, 0.1, '', transform=ax.transAxes, fontsize=15, ha='center', va='center' ) return annotations, time_text # *Tip: We are passing in `transform=ax.transAxes` when we place our time text in order to specify the location in terms of the `Axes` object's coordinates instead of basing it off the data in the plot so that it is easier to place.* # #### 5. Define the plot update function. # # Next, we will make our plot update function. This will be called at each frame. We will extract that frame's data (the cumulative questions for that month), and then update the width of each of the bars. In addition, we will annotate the bars if their widths are greater than 0. At every frame, we will also need to update our time annotation (`time_text`): # In[6]: def update(frame, *, ax, df, annotations, time_text): data = df.loc[frame, :] # update bars for rect, text in zip(ax.patches, annotations): col = rect.get_label() if data[col]: rect.set_width(data[col]) text.set_x(data[col]) text.set_text(f' {data[col]:,.0f}') # update time time_text.set_text(frame.strftime('%b\n%Y')) # *Tip: The asterisk in the function signature requires all arguments after it to be passed in by name. This makes sure that we explicitly define the components for the animation when calling the function. Read more on this syntax [here](https://www.python.org/dev/peps/pep-3102/).* # #### 6. Bind arguments to the update function. # # The last step before creating our animation is to create a function that will assemble everything we need to pass to `FuncAnimation`. Note that our `update()` function requires multiple parameters, but we would be passing in the same values every time (since we would only change the value for `frame`). To make this simpler, we create a [partial function](https://docs.python.org/3/library/functools.html#functools.partial), which **binds** values to each of those arguments so that we only have to pass in `frame` when we call the partial. This is essentially a [closure](https://www.programiz.com/python-programming/closure), where `bar_plot_init()` is the enclosing function and `update()` is the nested function, which we defined in the previous code block for readability: # In[7]: from functools import partial def bar_plot_init(questions_per_library): fig, ax = bar_plot(questions_per_library) annotations, time_text = generate_plot_text(ax) bar_plot_update = partial( update, ax=ax, df=questions_per_library, annotations=annotations, time_text=time_text ) return fig, bar_plot_update # #### 7. Animate the plot. # Finally, we are ready to create our animation. We start by calling the `bar_plot_init()` function from the previous code block to generate the `Figure` object and partial function for the update of the plot. Then, we pass in the `Figure` object and update function when initializing our `FuncAnimation` object. We also specify the `frames` argument as the index of our DataFrame (the dates) and that the animation shouldn't repeat because we will save it as an MP4 video: # In[8]: fig, update_func = bar_plot_init(questions_per_library) ani = FuncAnimation( fig, update_func, frames=questions_per_library.index, repeat=False ) ani.save( '../media/stackoverflow_questions.mp4', writer='ffmpeg', fps=10, bitrate=100, dpi=300 ) plt.close() # **Important**: The `FuncAnimation` object **must** be assigned to a variable when creating it; otherwise, without any references to it, Python will garbage collect it – ending the animation. For more information on garbage collection in Python, check out [this](https://stackify.com/python-garbage-collection/) article. # Now, let's view the animation we just saved as an MP4 file: # In[9]: from IPython import display display.Video( '../media/stackoverflow_questions.mp4', width=600, height=400, embed=True, html_attributes='controls muted autoplay' ) # ## Learning Path # # 1. Animating cumulative values over time # 2. **Animating distributions over time** # 3. Animating geospatial data with HoloViz # ## Animating distributions over time # # As with the previous example, the histograms of daily Manhattan subway entries in 2018 (from the first section of the workshop) don't tell the whole story of the dataset because the distributions changed drastically in 2020 and 2021: # #
# Histograms of daily Manhattan subway entries in 2018 #
# We will make an animated version of these histograms that enables us to see the distributions changing over time. Note that this example will have two key differences from the previous one. The first is that we will be animating subplots rather than a single plot, and the second is that we will use a technique called **blitting** to only update the portion of the subplots that has changed. This requires that we return the [*artists*](https://matplotlib.org/stable/tutorials/intermediate/artists.html) that need to be redrawn in the plot update function. # To make this visualization, we will work through these steps: # # 1. Create a dataset of daily subway entries. # 2. Determine the bin ranges for the histograms. # 3. Write a function for generating the initial histogram subplots. # 4. Write a function for generating an annotation for the time period. # 5. Define the plot update function. # 6. Bind arguments for the update function. # 7. Animate the plot. # #### 1. Create a dataset of daily subway entries. # As we did previously, we will read in the subway dataset, which contains the total entries and exits per day per borough: # In[10]: subway = pd.read_csv( '../data/NYC_subway_daily.csv', parse_dates=['Datetime'], index_col=['Borough', 'Datetime'] ) subway_daily = subway.unstack(0) subway_daily.head() # *Source: The above dataset was resampled from [this](https://www.kaggle.com/eddeng/nyc-subway-traffic-data-20172021?select=NYC_subway_traffic_2017-2021.csv) dataset provided by Kaggle user [Edden](https://www.kaggle.com/eddeng).* # For this visualization, we will just be working with the entries in Manhattan: # In[11]: manhattan_entries = subway_daily['Entries']['M'] # #### 2. Determine the bin ranges for the histograms. # Before we can set up the subplots, we have to calculate the bin ranges for the histograms so that our animation is smooth. NumPy provides the `histogram()` function, which gives us both the number of data points in each bin and the bin ranges, respectively. We will also be using this function to update the histograms during the animation: # In[12]: import numpy as np count_per_bin, bin_ranges = np.histogram(manhattan_entries, bins=30) # #### 3. Write a function for generating the initial histogram subplots. # # Next, we will handle the logic for building our initial histogram, packaging it in a function: # In[13]: def subway_histogram(data, bins, date_range): _, bin_ranges = np.histogram(data, bins=bins) weekday_mask = data.index.weekday < 5 configs = [ {'label': 'Weekend', 'mask': ~weekday_mask, 'ymax': 60}, {'label': 'Weekday', 'mask': weekday_mask, 'ymax': 120} ] fig, axes = plt.subplots(1, 2, figsize=(6, 3), sharex=True, layout='constrained') for ax, config in zip(axes, configs): _, _, config['hist'] = ax.hist( data[config['mask']].loc[date_range], bin_ranges, ec='black' ) ax.xaxis.set_major_formatter(ticker.EngFormatter()) ax.set( xlim=(0, None), ylim=(0, config['ymax']), xlabel=f'{config["label"]} Entries' ) despine(ax) axes[0].set_ylabel('Frequency') fig.suptitle('Histogram of Daily Subway Entries in Manhattan') return fig, axes, bin_ranges, configs # Notice that our plot this time starts out with data already – this is because we want to show the change in the distribution of daily entries in the last year: # In[14]: _ = subway_histogram(manhattan_entries, bins=30, date_range='2017') # #### 4. Write a function for generating an annotation for the time period. # # We will once again include some text that indicates the time period as the animation runs. This is similar to what we had in the previous example: # In[15]: def add_time_text(ax): time_text = ax.text( 0.15, 0.9, '', transform=ax.transAxes, fontsize=12, ha='center', va='center' ) return time_text # #### 5. Define the plot update function. # # Now, we will create our update function. This time, we have to update both subplots and return any artists that need to be redrawn since we are going to use blitting: # In[16]: def update(frame, *, data, configs, time_text, bin_ranges): artists = [] time = frame.strftime('%b\n%Y') if time != time_text.get_text(): time_text.set_text(time) artists.append(time_text) for config in configs: time_frame_mask = \ (data.index > frame - pd.Timedelta(days=365)) & (data.index <= frame) counts, _ = np.histogram( data[time_frame_mask & config['mask']], bin_ranges ) for count, rect in zip(counts, config['hist'].patches): if count != rect.get_height(): rect.set_height(count) artists.append(rect) return artists # #### 6. Bind arguments for the update function. # # As our final step before generating the animation, we bind our arguments to the update function using a partial function: # In[17]: def histogram_init(data, bins, initial_date_range): fig, axes, bin_ranges, configs = subway_histogram(data, bins, initial_date_range) update_func = partial( update, data=data, configs=configs, time_text=add_time_text(axes[0]), bin_ranges=bin_ranges ) return fig, update_func # #### 7. Animate the plot. # Finally, we will animate the plot using `FuncAnimation` like before. Notice that this time we are passing in `blit=True`, so that only the artists that we returned in the `update()` function are redrawn. We are specifying to make updates for each day in the data starting on August 1, 2019: # In[18]: fig, update_func = histogram_init( manhattan_entries, bins=30, initial_date_range=slice('2017', '2019-07') ) ani = FuncAnimation( fig, update_func, frames=manhattan_entries['2019-08':'2021'].index, repeat=False, blit=True ) ani.save( '../media/subway_entries_subplots.mp4', writer='ffmpeg', fps=30, bitrate=500, dpi=300 ) plt.close() # *Tip: We are using a `slice` object to pass a date range for pandas to use with `loc[]`. More information on `slice()` can be found [here](https://docs.python.org/3/library/functions.html?highlight=slice#slice).* # Our animation makes it easy to see the change in the distributions over time: # In[19]: from IPython import display display.Video( '../media/subway_entries_subplots.mp4', width=600, height=300, embed=True, html_attributes='controls muted autoplay' ) # ### Exercise 2.1 # # ##### Modify the animation of daily subway entries to show both the weekday and weekend histograms on the same subplot (you only need one now). Don't forget to change the transparency of the bars to be able to visualize the overlap. # ### Solution # We start by reading in the dataset: # In[20]: import pandas as pd manhattan_entries = pd.read_csv( '../data/NYC_subway_daily.csv', parse_dates=['Datetime'], index_col=['Borough', 'Datetime'] ).unstack(0)['Entries']['M'] manhattan_entries.head() # Next, we need to handle our imports: # In[21]: from functools import partial from matplotlib.animation import FuncAnimation import matplotlib.pyplot as plt from matplotlib import ticker import numpy as np from utils import despine # We can make this animation with the following changes to the original code: # # 1. Modify the `subway_histogram()` function to account for bar color and transparency, as well as plotting everything on a single `Axes` object. # 2. Move the time text further to the left. # 3. Modify the `histogram_init()` function to account for a single `Axes` object. # 4. Generate and save the animation to a new file. # ##### 1. Modify the `subway_histogram()` function to account for bar color and transparency, as well as plotting everything on a single `Axes` object. # In[22]: def subway_histogram(data, bins, date_range): _, bin_ranges = np.histogram(data, bins=bins) weekday_mask = data.index.weekday < 5 configs = [ # CHANGE: add bar color to config {'label': 'Weekend', 'mask': ~weekday_mask, 'color': 'green'}, {'label': 'Weekday', 'mask': weekday_mask, 'color': 'blue'} ] fig, ax = plt.subplots(figsize=(6, 3), layout='constrained') # CHANGE: single Axes for config in configs: _, _, config['hist'] = ax.hist( data[config['mask']].loc[date_range], bin_ranges, ec='black', facecolor=config['color'], alpha=0.5, label=config['label'] ) # CHANGES: ^ color the bar and ^ add transparency ax.xaxis.set_major_formatter(ticker.EngFormatter()) despine(ax) # CHANGES: update formatting and add legend ax.set( xlim=(0, None), ylim=(0, 120), xlabel='Entries', ylabel='Frequency', title='Histogram of Daily Subway Entries in Manhattan' ) ax.legend(loc='upper center', bbox_to_anchor=(0.5, 1), ncols=2, frameon=False) return fig, ax, bin_ranges, configs # ##### 2. Move the time text further to the left. # In[23]: def add_time_text(ax): time_text = ax.text( 0.075, 0.9, '', transform=ax.transAxes, fontsize=12, ha='center', va='center' ) return time_text # Note that we don't need to change the `update()` function for this exercise: # In[24]: def update(frame, *, data, configs, time_text, bin_ranges): artists = [] time = frame.strftime('%b\n%Y') if time != time_text.get_text(): time_text.set_text(time) artists.append(time_text) for config in configs: time_frame_mask = \ (data.index > frame - pd.Timedelta(days=365)) & (data.index <= frame) counts, _ = np.histogram( data[time_frame_mask & config['mask']], bin_ranges ) for count, rect in zip(counts, config['hist'].patches): if count != rect.get_height(): rect.set_height(count) artists.append(rect) return artists # ##### 3. Modify the `histogram_init()` function to account for a single `Axes` object. # In[25]: def histogram_init(data, bins, initial_date_range): fig, ax, bin_ranges, configs = subway_histogram( data, bins, initial_date_range ) # CHANGE: rename variable `ax` update_func = partial( update, data=data, configs=configs, time_text=add_time_text(ax), # CHANGE: pass in `ax` bin_ranges=bin_ranges ) return fig, update_func # ##### 4. Generate and save the animation to a new file. # In[26]: fig, update_func = histogram_init( manhattan_entries, bins=30, initial_date_range=slice('2017', '2019-07') ) ani = FuncAnimation( fig, update_func, frames=manhattan_entries['2019-08':'2021'].index, repeat=False, blit=True ) ani.save( '../media/subway_entries_exercise.mp4', # CHANGE: new filename writer='ffmpeg', fps=30, bitrate=500, dpi=300 ) plt.close() # The new animation looks like this: # In[27]: from IPython import display display.Video( '../media/subway_entries_exercise.mp4', width=600, height=300, embed=True, html_attributes='controls muted autoplay' ) # ## Learning Path # # 1. Animating cumulative values over time # 2. Animating distributions over time # 3. **Animating geospatial data with HoloViz** # ## Animating geospatial data with HoloViz # # [HoloViz](https://holoviz.org/) provides multiple high-level tools that aim to simplify data visualization in Python. For this example, we will be looking at [HoloViews](https://holoviews.org/) and [GeoViews](https://geoviews.org/), which extends HoloViews for use with geographic data. HoloViews abstracts away some of the plotting logic, removing boilerplate code and making it possible to easily switch backends (e.g., switch from Matplotlib to Bokeh for JavaScript-powered, interactive plotting). To wrap up our discussion on animation, we will use GeoViews to create an animation of earthquakes per month in 2020 on a map of the world. # To make this visualization, we will work through the following steps: # 1. Use GeoPandas to read in our data. # 2. Handle HoloViz imports and set up the Matplotlib backend. # 3. Define a function for plotting earthquakes on a map using GeoViews. # 4. Create a mapping of frames to plots using HoloViews. # 5. Animate the plot. # #### 1. Use GeoPandas to read in our data. # Our dataset is in GeoJSON format, so the best way to read it in will be to use [GeoPandas](https://geopandas.org/), which is a library that makes working with geospatial data in Python easier. It builds on top of pandas, so we don't have to learn any additional syntax for this example. # #
# Sleeping panda at Berlin zoo #
# Here, we import GeoPandas and then use the `read_file()` function to read the earthquakes GeoJSON data into a `GeoDataFrame` object: # In[28]: import geopandas as gpd earthquakes = gpd.read_file('../data/earthquakes.geojson').assign( time=lambda x: pd.to_datetime(x.time, unit='ms'), month=lambda x: x.time.dt.month )[['geometry', 'mag', 'time', 'month']] earthquakes.shape # Our data looks like this: # In[29]: earthquakes.head() # *Source: [USGS API](https://earthquake.usgs.gov/fdsnws/event/1/)* # #### 2. Handle HoloViz imports and set up the Matplotlib backend. # # Since our earthquakes dataset contains geometries, we will use GeoViews in addition to HoloViews to create our animation. For this example, we will be using the [Matplotlib backend](http://holoviews.org/user_guide/Plotting_with_Matplotlib.html): # In[30]: import geoviews as gv import geoviews.feature as gf import holoviews as hv gv.extension('matplotlib') # #### 3. Define a function for plotting earthquakes on a map using GeoViews. # # Next, we will write a function to plot each earthquake as a point on the world map. Since our dataset has geometries, we can use that information to plot them and then color each point by the earthquake magnitude. Note that, since earthquakes are measured on a logarithmic scale, some magnitudes are negative: # In[31]: import calendar def plot_earthquakes(data, month_num): points = gv.Points( data.query(f'month == {month_num}'), kdims=['longitude', 'latitude'], # key dimensions (for coordinates in this case) vdims=['mag'] # value dimensions (for modifying the plot in this case) ).redim.range(mag=(-2, 10), latitude=(-90, 90)) # create an overlay by combining Cartopy features and the points with * overlay = gf.land * gf.coastline * gf.borders * points return overlay.opts( gv.opts.Points(color='mag', cmap='fire_r', colorbar=True, alpha=0.75), gv.opts.Overlay( global_extent=False, title=calendar.month_name[month_num], fontscale=2 ) ) # Our function returns an `Overlay` of earthquakes (represented as `Points`) on a map of the world. Under the hood GeoViews is using [Cartopy](https://scitools.org.uk/cartopy/docs/latest/) to create the map: # In[32]: plot_earthquakes(earthquakes, 1).opts( fig_inches=(6, 3), aspect=2, fig_size=250, fig_bounds=(0.07, 0.05, 0.87, 0.95) ) # *Tip: One thing that makes working with geospatial data difficult is handling [projections](https://en.wikipedia.org/wiki/Map_projection). When working with datasets that use different projections, GeoViews can help align them – check out their tutorial [here](https://geoviews.org/user_guide/Projections.html).* # #### 4. Create a mapping of frames to plots using HoloViews. # We will create a `HoloMap` of the frames to include in our animation. This maps the frame to the plot that should be rendered at that frame: # In[33]: frames = { month_num: plot_earthquakes(earthquakes, month_num) for month_num in range(1, 13) } holomap = hv.HoloMap(frames) # #### 5. Animate the plot. # Now, we will output our `HoloMap` as a GIF animation, which may take a while to run: # In[34]: hv.output( holomap.opts( fig_inches=(6, 3), aspect=2, fig_size=250, fig_bounds=(0.07, 0.05, 0.87, 0.95) ), holomap='gif', fps=5 ) # To save the animation to a file, run the following code: # # ```python # hv.save( # holomap.opts( # fig_inches=(6, 3), aspect=2, fig_size=250, # fig_bounds=(0.07, 0.05, 0.87, 0.95) # ), 'earthquakes.gif', fps=5 # ) # ``` # ### Exercise 2.2 # # ##### Modify the earthquake animation to show earthquakes per day in April 2020. # ### Solution # We start by reading in the dataset: # In[35]: import geopandas as gpd import pandas as pd earthquakes = gpd.read_file('../data/earthquakes.geojson').assign( time=lambda x: pd.to_datetime(x.time, unit='ms'), month=lambda x: x.time.dt.month )[['geometry', 'mag', 'time', 'month']] earthquakes.head() # Next, we handle our plotting imports: # In[36]: import geoviews as gv import geoviews.feature as gf import holoviews as hv gv.extension('matplotlib') # We can make this animation as follows: # # 1. Modify the `plot_earthquakes()` function to filter by date instead of month and use the date for the title. # 2. Generate frames per day in April, and create a `HoloMap` object. # 3. Output the result. # ##### 1. Modify the `plot_earthquakes()` function to filter by date instead of month and use the date for the title. # In[37]: def plot_earthquakes(data, date): points = gv.Points( # CHANGE: filter `data` by `date` data.query(f'time.dt.strftime("%Y-%m-%d") == "{date}"'), kdims=['longitude', 'latitude'], vdims=['mag'] ).redim.range(mag=(-2, 10), latitude=(-90, 90)) overlay = gf.land * gf.coastline * gf.borders * points return overlay.opts( gv.opts.Points(color='mag', cmap='fire_r', colorbar=True, alpha=0.75), gv.opts.Overlay( global_extent=False, title=f'{date:%B %d, %Y}', fontscale=2 ) # CHANGE: title each frame with the date ^ ) # ##### 2. Generate frames per day in April, and create a `HoloMap` object. # In[38]: import datetime as dt frames = { day: plot_earthquakes(earthquakes, dt.date(2020, 4, day)) for day in range(1, 31) } holomap = hv.HoloMap(frames) # ##### 3. Output the result. # In[39]: hv.output( holomap.opts( fig_inches=(6, 3), aspect=2, fig_size=250, fig_bounds=(0.07, 0.05, 0.87, 0.95) ), holomap='gif', fps=5 ) # ## Additional resources # # - `matplotlib.animation` [API overview](https://matplotlib.org/stable/api/animation_api.html) # - `FuncAnimation` [documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.animation.FuncAnimation.html) # - Matplotlib animation [examples](https://matplotlib.org/stable/api/animation_api.html#examples) # - Matplotlib's list of [3rd-party animation libraries](https://matplotlib.org/stable/thirdpartypackages/index.html#animations) # - Using HoloViews with the [Matplotlib backend](http://holoviews.org/user_guide/Plotting_with_Matplotlib.html) # ## Section 2 Complete 🎉 # # Kangaroo