Visualizing Math: A Guide to Creating Times Table Animations with Python

Times Table Header Image

Explore the mesmerizing patterns of times tables with Python! In this blog post, we present an alternative implementation to the famous Mathologer's video where beautiful patterns emerge from times tables. Burkard highlights how stunning patterns arise from these tables and utilizes Wolfram Mathematica to demonstrate these patterns in greater detail. This blog post aims to showcase a similar implementation using Python.

For those unfamiliar with the concept, it is recommended to view the video that served as inspiration for this post. It provides a comprehensive explanation of what Times Tables are.

This post provides an alternative approach to generate similar animations to the ones seen in the video, using Python and its libraries such as Matplotlib and IPython.

The following animations were created using Python and its supporting libraries.

times-table-2-100

Bringing this captivating video to life with Python is made easy with the help of a Jupyter Notebook. Explore the examples and discover the beauty of Times Tables for yourself by following along. With all the necessary dependencies already installed, the Jupyter Notebook is the perfect platform for experimentation and hands-on learning

In this post, multiple examples are provided, including:

Requirements:

In order to produce these images and animations, the code should be split in 4 parts:

Initialization

It is recommended to place all imports at the beginning of the document for better organization and maintenance. This also helps to clearly identify the required tools. In this instance, there are both general purpose imports and those specific to Jupyter. To utilize the code outside of Jupyter, it may need to be modified to remove Jupyter-specific components. This ensures the code can run as a standalone script.

# General Purpose
import colorsys
from typing import Optional, List

from matplotlib import animation, rc
from matplotlib import pyplot as plt
from matplotlib.collections import LineCollection
from matplotlib.lines import Line2D
import numpy as np
import numpy.typing as npt

# Jupyter Specifics
from ipywidgets.widgets import interact, IntSlider, FloatSlider, Layout

%matplotlib inline
rc('animation', html='html5')

Basic Functions

With all the necessary imports in place, it's time to define a few functions that will bring this project to life. These functions include:

  1. A function to calculate the points around a circle
  2. A function to generate each of the lines
  3. A function to plot the labels and points on the circle
  4. A function to plot the lines on the circle

The first function, named points_around_circle, uses polar coordinates to determine a specified number of points around a circle of radius 1. The use of numpy in this calculation enhances its performance.

def points_around_circle(number: int = 100) -> npt.NDArray[np.float64]:
    theta = np.linspace(0, 2 * np.pi, number, endpoint=False)
    xs, ys = np.cos(theta), np.sin(theta)
    return np.stack((xs, ys))

The second function pertains to the generation of lines. Given a list of points, this function generates a new line.

def generate_lines_from_points(
    points: npt.NDArray[np.float64], factor: float
) -> npt.NDArray[np.float64]:
    _, size = points.shape
    index = (np.arange(size) * factor) % size

    line_starts = points.T
    line_ends = line_starts[index.astype(int)]

    return np.stack((line_ends, line_starts), axis=1)

However, the line in numpy format is not directly plotable. To translate the numpy array into a data structure compatible with matplotlib, a LineCollection is used. The function generate_line_collection not only generates the line collection, but also specifies the color based on the HSV format. This capability will come in handy when creating the final animation.

def generate_line_collection(
    points: npt.NDArray[np.float64], factor: float, color: Optional[float] = None
) -> LineCollection:
    lines = generate_lines_from_points(points, factor)
    color_ = colorsys.hsv_to_rgb(color, 1.0, 0.8) if color else None
    return LineCollection(lines, color=color_)

Static Version

With all the required functions defined, plotting a static version of the data is straightforward. Simply generate the axis object and call the functions in the appropriate order, and the image will be generated. This approach is useful for quickly experimenting with a fixed number of factors and points.

def plot_static(factor: float, number_of_points: int) -> None:
    points = points_around_circle(number=number_of_points)
    lines = generate_line_collection(points, factor)

    plt.figure(figsize=(10, 10))
    ax = plt.gca()
    ax.axis("off")
    ax.annotate(f"Points: {number_of_points}", (0.8, 0.9))
    ax.annotate(f"Factor: {factor}", (0.8, 1))
    ax.plot(*points, "-ko", markevery=1)
    ax.add_collection(lines)

factor = 2
points = 100
plot_static(factor, points)

times-table-2-100

Parametric Version

One method to update the factor and points variables is to manually change them and re-execute the cell or function. However, Jupyter offers support for interaction, allowing for a more user-friendly approach via Sliders, a built-in user interface of IPython. The function plot_parametric serves the same purpose as plot_static, with the addition of a call to plt.show() at the end to display the image. While the image is still static, the user can adjust the variables by moving the sliders.

def plot_parametric(factor: float = 2, points: int = 100) -> None:
    points = points_around_circle(number=points)
    lines = generate_line_collection(points, factor)

    plt.figure(figsize=(10, 10))
    ax = plt.gca()
    ax.axis("off")
    ax.plot(*points, "-ko", markevery=1)
    ax.add_collection(lines)
    plt.show()


factors = [21, 29, 33, 34, 49, 51, 66, 67, 73, 76, 79, 80, 86, 91, 99]
print("Try these Factors with different number of points:", *factors)

interact(
    plot_parametric,
    factor=FloatSlider(min=0, max=100, step=0.1, value=2, layout=Layout(width="99%")),
    points=IntSlider(min=0, max=300, step=25, value=100, layout=Layout(width="99%")),
)

Animate Construction Line by Line

In the next step, the focus shifts to animations. In this particular animation, the factor and the number of points remain constant while the lines change. This animation replicates the act of drawing these times tables by hand and provides a deeper understanding of the sequence in which the lines are plotted, instead of simply presenting them all together.

In Matplotlib, animations are constructed using the animate function, which returns the objects to be displayed in each frame. As a result, two functions need to be defined in this and the following animations. The first is for the animate API of Matplotlib and the second is to be integrated into the interact function of IPython. In this particular case, the function line_by_line takes a given factor, a specified number of max_points, and an interval. The first two parameters are already familiar from previous functions, while interval represents the delay between frames in milliseconds. This delay is directly related to the animation's frames per second (FPS), which is calculated as FPS = 1000 / delay.

def animate_line_by_line(
    i: int, lines: npt.NDArray[np.float64], ax: plt.Axes
) -> List[Line2D]:
    start_point, end_point = lines[i].T
    line = Line2D(start_point, end_point)
    ax.add_line(line)
    return []


def line_by_line(
    factor: float, max_points: int, interval: int
) -> animation.FuncAnimation:
    points = points_around_circle(number=max_points)
    lines = generate_lines_from_points(points, factor)

    fig = plt.figure(figsize=(10, 10))
    ax = plt.gca()
    ax.axis("off")
    ax.annotate(f"Factor: {factor}", (0.8, 1))
    ax.annotate(f"Interval: {interval}", (0.8, 0.8))
    ax.annotate(f"Points: {max_points}", (0.8, 0.9))
    ax.plot(*points, "-ko", markevery=1)
    anim = animation.FuncAnimation(
        fig,
        animate_line_by_line,
        frames=max_points - 2,
        interval=interval,
        blit=True,
        fargs=(lines, ax),
    )
    plt.close()

    return anim


interact(
    line_by_line,
    factor=FloatSlider(min=0, max=100, step=0.1, value=2, layout=Layout(width="99%")),
    max_points=IntSlider(min=1, max=200, step=1, value=100, layout=Layout(width="99%")),
    interval=IntSlider(min=5, max=500, step=5, value=75, layout=Layout(width="99%")),
)

Animate Construction Point by Point

The next animation focuses on the process of how the figure becomes clearer as the number of points increases. The factor is fixed, and all lines are plotted at once, but with each frame, the number of points increases incrementally from 0 to a specified maximum number of max_points. This perspective provides a different view on the relationship between the number of points and the clarity of the figure.

def animate_point_by_point(
    i: int, ax: plt.Axes, factor: float, interval: int, max_points: int
) -> List[Line2D]:
    points = points_around_circle(number=i + 1)
    lines = generate_line_collection(points, factor)

    ax.cla()
    ax.axis("off")
    ax.set_ylim(-1.2, 1.2)
    ax.set_xlim(-1.2, 1.2)
    ax.annotate(f"Interval: {interval}", (0.8, 0.8))
    ax.annotate(f"Points: {max_points}", (0.8, 0.9))
    ax.annotate(f"Factor: {factor}", (0.8, 1))
    ax.plot(*points, "-ko", markevery=1)
    ax.add_collection(lines)
    return []


def point_by_point(
    factor: float, interval: int, max_points: int
) -> animation.FuncAnimation:
    fig = plt.figure(figsize=(10, 10))
    ax = plt.gca()
    anim = animation.FuncAnimation(
        fig,
        animate_point_by_point,
        frames=max_points,
        interval=interval,
        blit=True,
        fargs=(ax, factor, interval, max_points),
    )
    plt.close()

    return anim


interact(
    point_by_point,
    factor=FloatSlider(min=0, max=100, step=0.1, value=2, layout=Layout(width="99%")),
    max_points=IntSlider(min=1, max=200, step=1, value=75, layout=Layout(width="99%")),
    interval=IntSlider(min=100, max=500, step=1, value=200, layout=Layout(width="99%")),
)

Animate Construction Factor by Factor

The animation displays the effect of incrementing the factor, with the number of points fixed and all lines plotted at once. To enhance the visual impact, the factor is increased in increments of 0.1 instead of 1. This results in a smoother animation. This version is monochromatic, with all lines appearing in the same color.

def animate_factor_by_factor(
    i: int, ax: plt.Axes, max_points: int, interval: int
) -> List[Line2D]:
    points = points_around_circle(number=max_points)
    lines = generate_line_collection(points, i / 10)

    ax.cla()
    ax.axis("off")
    ax.set_ylim(-1.2, 1.2)
    ax.set_xlim(-1.2, 1.2)
    ax.annotate(f"Interval: {interval}", (0.8, 0.8))
    ax.annotate(f"Points: {max_points}", (0.8, 0.9))
    ax.annotate(f"Factor: {factor}", (0.8, 1))
    ax.plot(*points, "-ko", markevery=1)
    ax.add_collection(lines)
    return []


def factor_by_factor(
    factor: float, interval: int, max_points: int
) -> animation.FuncAnimation:
    fig = plt.figure(figsize=(10, 10))
    ax = plt.gca()

    frames = int(factor * 10)
    anim = animation.FuncAnimation(
        fig,
        animate_factor_by_factor,
        frames=frames,
        interval=interval,
        blit=True,
        fargs=(ax, max_points, interval),
    )

    plt.close()

    return anim


interact(
    factor_by_factor,
    factor=FloatSlider(min=0, max=100, step=0.1, value=5, layout=Layout(width="99%")),
    max_points=IntSlider(min=1, max=200, step=1, value=100, layout=Layout(width="99%")),
    interval=IntSlider(min=50, max=500, step=25, value=100, layout=Layout(width="99%")),
)

Animate Construction Factor by Factor with Color

The final animation is similar to the previous one, with the difference being the addition of color. To achieve this, the function animate_factor_by_factor_colored is passed an additional parameter, frames, which specifies the total number of frames. The HSV color system is utilized, with fixed saturation and value, while the hue changes from 0 to 1 as the current frame (i) is divided by the total number of frames (frames), resulting in a range from 0 to 1.

def animate_factor_by_factor_colored(
    i: int, ax: plt.Axes, max_points: int, interval: int, frames: int
) -> List[Line2D]:
    points = points_around_circle(number=max_points)
    lines = generate_line_collection(points, i / 10, color=i / frames)

    ax.cla()
    ax.axis("off")
    ax.annotate(f"Interval: {interval}", (0.8, 0.8))
    ax.annotate(f"Points: {max_points}", (0.8, 0.9))
    ax.annotate(f"Factor: {factor}", (0.8, 1))
    ax.plot(*points, "-ko", markevery=1)
    ax.add_collection(lines)
    return []


def factor_by_factor_colored(
    factor: float, interval: int, max_points: int
) -> animation.FuncAnimation:
    fig = plt.figure(figsize=(10, 10))
    ax = plt.gca()

    frames = int(factor * 10)
    anim = animation.FuncAnimation(
        fig,
        animate_factor_by_factor_colored,
        frames=frames,
        interval=interval,
        blit=True,
        fargs=(ax, max_points, interval, frames),
    )

    plt.close()

    return anim


interact(
    factor_by_factor_colored,
    factor=FloatSlider(min=0, max=100, step=0.1, value=5, layout=Layout(width="99%")),
    max_points=IntSlider(min=1, max=200, step=1, value=100, layout=Layout(width="99%")),
    interval=IntSlider(min=50, max=500, step=25, value=100, layout=Layout(width="99%")),
)

Export

The generated animations can be exported as mp4 files by calling the appropriate function, storing the result in a variable and using the following code snippet. The function specific_function should be replaced with the desired animation, such as factor_by_factor_colored or animate_point_by_point, and the corresponding parameters should be included.

anim = specific_function(*args)
filename = "my_animation.mp4"

Writer = animation.writers['ffmpeg']
writer = Writer(fps=30)

anim.save(filename, writer=writer)