The Toyplot Tutorial

Getting Started

Welcome! This tutorial will introduce you to the basics of working with Toyplot.

Note: this tutorial was created in a Jupyter notebook and assumes that you're following-along in a notebook of your own. If you aren't using a notebook, you should read the user guide section on :ref:rendering for some important information on how to display your figures.

To begin, we're going to import numpy (so we can create data to use for our figures), and the main toyplot module:


In [1]:
import numpy
import toyplot

For our first figure, let's create a simple set of X and Y coordinates that we can plot:


In [2]:
x = numpy.linspace(0, 10)
y = x ** 2

Now, let's put Toyplot to work ...


In [3]:
canvas = toyplot.Canvas(width=300, height=300)
axes = canvas.axes()
mark = axes.plot(x, y)


0510050100

Short as it is, this example demonstrates several key features of Toyplot:

  • Toyplot figures are beautiful, scalable, embeddable, and interactive. For example:
    • Mouse over the figure, and note the cursor coordinates that update in the upper-right-hand corner.
    • Carefully place the cursor on top of the plotted line, and open a context menu - in the popup that appears, you can choose to save the figure data to a CSV file from your browser.
    • Note that Toyplot produces a clean, aesthetically pleasing figure that is crisp at any scale and free of chartjunk "out-of-the-box".
  • Every Toyplot figure begins with a :class:canvas <toyplot.canvas.Canvas> - a drawing area upon which the caller adds marks. Note that the width and height of the canvas have been specified in CSS pixels, which are always equal to 1/96th of an inch, and converted to device units when the canvas is :ref:rendered <rendering>.
  • A Toyplot figure typically contains one-or-more sets of :class:axes <toyplot.axes.Cartesian> which map a data domain to a range of canvas pixels.
  • Marks are added to axes using factory functions provided by the axes. In this example, the :meth:plot <toyplot.axes.Cartesian.plot> function adds a :class:plot <toyplot.mark.Plot> mark using the supplied coordinates. Note that the axes have been automatically sized to the data's domain.

Styles

Let's say that you wanted to alter the above figure to make the plotted line blue and dashed. To do so, simply override the default style information when creating the plot:


In [4]:
canvas = toyplot.Canvas(width=300, height=300)
axes = canvas.axes()
mark = axes.plot(x, y, style={"stroke":"blue", "stroke-dasharray":"2, 2"})


0510050100

In this case, you can see that the style information is a dictionary of key-value properties that alter how a mark is rendered. To avoid reinventing the wheel, Toyplot uses Cascading Style Sheets (CSS) to specify styles. If you're familiar with web development, you already know CSS. If not, this tutorial will cover many of most useful CSS properties for Toyplot as we go, and there are many learning resources for CSS online.

Every mark you add to a figure will have at least one (and possibly more than one) set of styles that control its appearance.

Plotting

Let's continue with the previous example. As a shortcut, you can omit the X coordinates when using the :func:plot <toyplot.axes.Cartesian.plot> command, and a set of coordinates in the range $[0, M)$ will be provided (compare the following X axis with the previous two plots to see the difference):


In [5]:
canvas = toyplot.Canvas(width=300, height=300)
axes = canvas.axes()
mark = axes.plot(y)


01020304050050100

If you add multiple plots, each automatically receives a different color:


In [6]:
x = numpy.linspace(0, 10, 100)
y1 = numpy.sin(x)
y2 = numpy.cos(x)
y3 = numpy.sin(x) + numpy.cos(x)

In [7]:
canvas = toyplot.Canvas(width=600, height=300)
axes = canvas.axes()
mark1 = axes.plot(x, y1)
mark2 = axes.plot(x, y2)
mark3 = axes.plot(x, y3)


0510-101

As we've already seen, we can use the "stroke" style to override the default color of each plot; in addition, the "stroke-width" and "stroke-opacity" styles are useful properties for (de)emphasizing individual plots:


In [8]:
canvas = toyplot.Canvas(width=600, height=300)
axes = canvas.axes()
mark1 = axes.plot(x, y1, style={"stroke-width":1, "stroke-opacity":0.6})
mark2 = axes.plot(x, y2, style={"stroke-width":1, "stroke-opacity":0.6})
mark3 = axes.plot(x, y3, style={"stroke":"blue"})


0510-101

Palettes

Before proceeding, let's take a moment to look at how the default color for a mark is assigned. When we add multiple marks to a set of axes, each mark gets a different color. These default colors are all drawn from a :class:palette <toyplot.color.Palette> - an ordered collection of RGBA colors. For example, here's Toyplot's default palette:


In [9]:
import toyplot.color
toyplot.color.Palette()


Out[9]:

Note: Like canvases, palettes are automatically rendered in Jupyter notebooks, in this case as a collection of color swatches.

You should observe that the order of colors in the palette match the order of the colors that were assigned to our plots as they were added to their axes. You could create a custom palette by passing a sequence of colors to the :class:toyplot.color.Palette constructor, but Toyplot already comes with a builtin collection of high-quality palettes from Color Brewer, which we will use in the examples that follow.

For more detail on colors in Toyplot, see the :ref:color section of the user guide.

Filled Regions

You can use :meth:fill<toyplot.axes.Cartesian.fill> to display a region bounded by two sets of Y coordinates. This can be a handy way to visualize data distributions:


In [10]:
numpy.random.seed(1234)
observations = numpy.random.normal(size=(50, 50))

x = numpy.linspace(0, 1, len(observations))
y1 = numpy.min(observations, axis=1)
y2 = numpy.max(observations, axis=1)

In [11]:
canvas = toyplot.Canvas(width=400, height=300)
axes = canvas.axes()
mark = axes.fill(x, y1, y2)


0.00.51.0-4-2024

Use the "fill" style (not to be confused with the fill command) to control the color of the shaded region. You might also want to change the fill-opacity or add a stroke using styles:


In [12]:
canvas = toyplot.Canvas(width=400, height=300)
axes = canvas.axes()
mark = axes.fill(x, y1, y2, style={"fill":"steelblue", "fill-opacity":0.5, "stroke":toyplot.color.near_black})


0.00.51.0-4-2024

If you omit one of the boundaries it will default to $y = 0$:


In [13]:
canvas = toyplot.Canvas(width=400, height=300)
axes = canvas.axes()
mark = axes.fill(x, y2)


0.00.51.00123

As with plots, if you omit the X coordinates, they will default to the range $[0, M)$:


In [14]:
canvas = toyplot.Canvas(width=400, height=300)
axes = canvas.axes()
mark = axes.fill(y2)


010203040500123

Toyplot also makes it easy to define multiple sets of boundaries, by passing an $M \times N$ matrix as input, where $M$ is the number of observations, and $N$ is the number of boundaries:


In [15]:
boundaries = numpy.column_stack(
    (numpy.min(observations, axis=1),
     numpy.percentile(observations, 25, axis=1),
     numpy.percentile(observations, 50, axis=1),
     numpy.percentile(observations, 75, axis=1),
     numpy.max(observations, axis=1)))

In [16]:
canvas = toyplot.Canvas(width=400, height=300)
axes = canvas.axes()
mark = axes.fill(boundaries)


01020304050-4-2024

This introduces an important new concept: you can think of fill (and other types of) marks as containers for collections of series, where in this case, $N$ boundaries define $N-1$ series.

This distinction is important because we can control the styles of individual series, not just the mark as a whole. So, if we want to override the default colors for the fill regions, we can do it using the mark's global "fill" style (with a contrasting stroke to display the boundaries between series):


In [17]:
canvas = toyplot.Canvas(width=400, height=300)
axes = canvas.axes()
mark = axes.fill(boundaries, style={"fill":"steelblue", "stroke":"white"})


01020304050-4-2024

... or we can do it using the "fill" argument:


In [18]:
canvas = toyplot.Canvas(width=400, height=300)
axes = canvas.axes()
mark = axes.fill(boundaries, fill="steelblue", style={"stroke":"white"})


01020304050-4-2024

The advantage of the latter is that the "fill" argument can specify a single color value as we've seen, or a sequence of color values, one-per-series. And, you can combine those per-series fill values with global styles in intuitive ways:


In [19]:
fill = ["red", "green", "blue", "yellow"]

In [20]:
canvas = toyplot.Canvas(width=400, height=300)
axes = canvas.axes()
mark = axes.fill(boundaries, fill=fill, style={"stroke":toyplot.color.near_black})


01020304050-4-2024

The "opacity" and "title" arguments can also be specified on a per-series basis (hover the mouse over the fill regions in the following figure to see the title as a popup):


In [21]:
fill = ["blue", "blue", "red", "red"]
opacity = [0.1, 0.2, 0.2, 0.1]
title = ["1st Quartile", "2nd Quartile", "3rd Quartile", "4th Quartile"]

In [22]:
canvas = toyplot.Canvas(width=400, height=300)
axes = canvas.axes()
mark = axes.fill(boundaries, fill=fill, opacity=opacity, title=title, style={"stroke":toyplot.color.near_black})


1st Quartile2nd Quartile3rd Quartile4th Quartile01020304050-4-2024

In the preceding examples you defined the fill regions by explicitly specifying their boundaries ... as an alternative, you can generate fills by specifying the magnitudes (the heights) of each region (note that in this case $N$ heights define $N$ series):


In [23]:
numpy.random.seed(1234)

heights = []
x = numpy.linspace(0, 4 * numpy.pi, 100)
for i in range(10):
    heights.append(numpy.random.uniform(0.1, 1) * (2 + numpy.sin(numpy.random.normal() + (numpy.random.normal() * x))))
heights = numpy.column_stack(heights)

In [24]:
canvas = toyplot.Canvas(width=500, height=300)
axes = canvas.axes()
m = axes.fill(heights, baseline="stacked")


05010004812

If you pass a sequence of scalar values instead of colors to the "fill" argument, the values will be mapped to colors using a linear mapping and a default sequential palette:


In [25]:
fill = numpy.arange(heights.shape[1])

In [26]:
canvas = toyplot.Canvas(width=500, height=300)
axes = canvas.axes()
m = axes.fill(heights, baseline="stacked", fill=fill)


05010004812

Of course, you're free to supply your own palette for the mapping:


In [27]:
palette = toyplot.color.brewer("BlueRed")

In [28]:
canvas = toyplot.Canvas(width=500, height=300)
axes = canvas.axes()
m = axes.fill(heights, baseline="stacked", fill=(fill, palette))


05010004812

... note that the baseline parameter is what signals that the inputs are magnitudes instead of boundaries. You can also change the baseline parameter to create various types of streamgraph:


In [29]:
canvas = toyplot.Canvas(width=500, height=300)
axes = canvas.axes()
m = axes.fill(heights, baseline="symmetric", fill=(fill, palette))


050100-505

In [30]:
canvas = toyplot.Canvas(width=500, height=300)
axes = canvas.axes()
m = axes.fill(heights, baseline="wiggle", fill=(fill, palette))


050100-4048

Barplots

Of course, you can't have a plotting library without :meth:barplots <toyplot.axes.Cartesian.bars> ...


In [31]:
heights = numpy.linspace(1, 10, 10) ** 2

In [32]:
canvas = toyplot.Canvas(width=300, height=300)
axes = canvas.axes()
mark = axes.bars(heights)


0510050100

By default the bars are centered on integer X coordinates in the range $[0, M)$ - but we can specify our own X coordinates to suit:


In [33]:
x = numpy.linspace(-2, 2, 20)
y = 5 - (x ** 2)

canvas = toyplot.Canvas(width=300, height=300)
axes = canvas.axes()
mark = axes.bars(x, y)


-2-1012012345

As a convenience, you can pass the output from :func:numpy.histogram() directly to :meth:toyplot.axes.Cartesian.bars:


In [34]:
numpy.random.seed(1234)
population = numpy.random.normal(size=10000)

In [35]:
canvas = toyplot.Canvas(width=300, height=300)
axes = canvas.axes()
bars = axes.bars(numpy.histogram(population, 20))


-4-2024050010001500

As with fill marks, Toyplot allows you to stack multiple sets of bars by passing an $M \times N$ matrix as input, where $M$ is the number of observations, and $N$ is the number of series:


In [36]:
heights1 = numpy.linspace(1, 10, 10) ** 1.1
heights2 = numpy.linspace(1, 10, 10) ** 1.3
heights3 = numpy.linspace(1, 10, 10) ** 1.4
heights4 = numpy.linspace(1, 10, 10) ** 1.5
heights = numpy.column_stack((heights1, heights2, heights3, heights4))

In [37]:
canvas = toyplot.Canvas(width=300, height=300)
axes = canvas.axes()
mark = axes.bars(heights)


05100306090

As before, we can style the bars globally and use the "fill", "opacity", and "title" arguments to specify constant or per-series behavior:


In [38]:
fill = ["red", "green", "blue", "yellow"]
title = ["Series 1", "Series 2", "Series 3", "Series 4"]
style = {"stroke":toyplot.color.near_black}

In [39]:
canvas = toyplot.Canvas(width=300, height=300)
axes = canvas.axes()
bars = axes.bars(heights, fill=fill, title=title, style=style)


Series 1Series 1Series 1Series 1Series 1Series 1Series 1Series 1Series 1Series 1Series 2Series 2Series 2Series 2Series 2Series 2Series 2Series 2Series 2Series 2Series 3Series 3Series 3Series 3Series 3Series 3Series 3Series 3Series 3Series 3Series 4Series 4Series 4Series 4Series 4Series 4Series 4Series 4Series 4Series 405100306090

However, with bars we can take these concepts even further to specify per-datum quantities. That is, the fill, opacity, and title arguments can accept data that will apply to every individual bar in the plot. For the following example, we generate a per-datum set of random values to map to the fill color, and also use them as the bar titles (hover over the bars to see the titles):


In [40]:
fill = numpy.random.random(heights.shape)
colormap = toyplot.color.diverging("BlueRed")

In [41]:
canvas = toyplot.Canvas(width=300, height=300)
axes = canvas.axes()
bars = axes.bars(heights, fill=(fill, colormap), title=fill, style=style)


0.671180366490.1335326337740.5541205739780.2517039739570.9205897480110.7759288665580.1347204372830.4825836850710.08188993451240.3926659033060.8883088878670.9901663250710.9124180775430.1981534929710.9520176342640.8283304454050.3773021026340.1720444573530.002482950217810.4628787394180.5424529654880.9359602075270.6052180652360.163638000510.5212729044720.02115913445260.7154891795510.5053269667880.9288410165950.06828732321960.9450110629430.3526752535930.4021094467720.8678650687380.5422079096990.7801711435650.9609649388790.1872860863710.6640783903610.35655134502805100306090

Scatterplots

Next on our whirlwind tour of marks is the :meth:scatterplot <toyplot.axes.Cartesian.scatterplot>:


In [42]:
x = numpy.linspace(0, 2 * numpy.pi)
y1 = numpy.sin(x)
y2 = numpy.cos(x)

In [43]:
canvas = toyplot.Canvas(width=500, height=300)
axes = canvas.axes()
mark = axes.scatterplot(x, y1)


0246-1.0-0.50.00.51.0

As you might expect, you can omit the X coordinates for a scatterplot, and they will fall in to the range $[0, M)$:


In [44]:
canvas = toyplot.Canvas(width=500, height=300)
axes = canvas.axes()
mark = axes.scatterplot(y1)


01020304050-1.0-0.50.00.51.0

And as we've seen before, you can pass multiple series in a single call:


In [45]:
series = numpy.column_stack((y1, y2))

canvas = toyplot.Canvas(width=500, height=300)
axes = canvas.axes()
mark = axes.scatterplot(series)


01020304050-1.0-0.50.00.51.0

And as expected, you can control attributes like fill, size, and opacity on a global, per-series, or per-datum basis (note that the size is treated as an approximation of the area of an individual datum):


In [46]:
fill = numpy.random.random(series.shape)
palette = toyplot.color.brewer("Oranges")
size = [16, 81]

canvas = toyplot.Canvas(width=500, height=300)
axes = canvas.axes()
mark = axes.scatterplot(series, fill=(fill, palette), size=size)


01020304050-1.0-0.50.00.51.0

Markers

You can choose from a variety of marker shapes for scatterplots and line plots, also specified either globally, per-series, or per-datum, and specify styles for the markers:


In [47]:
mstyle={"stroke":toyplot.color.near_black}
canvas = toyplot.Canvas(width=500, height=300)
axes = canvas.axes()
mark = axes.scatterplot(series, size=50, marker=["^", "o"], mstyle=mstyle)


01020304050-1.0-0.50.00.51.0

In addition to the basic marker shapes, you can create your own by adding text labels, also with their own styles:


In [48]:
marker = [{"shape":"o", "label":"1"}, {"shape":"o", "label":"2"}]
mlstyle = {"fill":"white"}

In [49]:
canvas = toyplot.Canvas(width=500, height=300)
axes = canvas.axes()
mark = axes.scatterplot(series, size=100, marker=marker, mstyle=mstyle, mlstyle=mlstyle)


111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222201020304050-1.0-0.50.00.51.0

See the user guide section on :ref:markers for additional detail on the available marker shapes, styles, and how to create your own custom markers.

Plots Revisited

Now that we've seen how bar, fill, and scatter plots can accept multiple series' worth of data in a single call, let's revisit our earlier line plots, and see that the same is true:


In [50]:
x = numpy.linspace(0, 10, 100)
y1 = numpy.sin(x)
y2 = numpy.cos(x)
y3 = numpy.sin(x) + numpy.cos(x)
series = numpy.column_stack((y1, y2, y3))

In [51]:
canvas = toyplot.Canvas(width=600, height=300)
axes = canvas.axes()
mark = axes.plot(x, series)


0510-101

Axes

So far, we've created a default set of axes in each of the preceeding examples, and called methods on the axes to add marks to the canvas. The reason we explicitly create axes in Toyplot (instead of simply adding marks to the canvas directly) is that it allows us to have multiple axes on a single canvas:


In [52]:
canvas = toyplot.Canvas(600, 300)
axes = canvas.axes(grid=(1, 2, 0))
mark = axes.plot(x, y1)
axes = canvas.axes(grid=(1, 2, 1))
mark = axes.plot(x, y2)


0510-1.0-0.50.00.51.00510-1.0-0.50.00.51.0

In addition to positioning axes on the canvas, there are many properties that control their behavior. For example, Toyplot axes include builtin support for labels:


In [53]:
canvas = toyplot.Canvas(300, 300)
axes = canvas.axes(label="Toyplot User Growth", xlabel="Days", ylabel="Users")
mark = axes.plot(x, 40 + x ** 2)


0510Days50100150UsersToyplot User Growth

Similarly, we can specify minimum and maximum values for each axis - for example, if we wanted the previous figure to include $y = 0$:


In [54]:
canvas = toyplot.Canvas(300, 300)
axes = canvas.axes(label="Toyplot User Growth", xlabel="Days", ylabel="Users", ymin=0)
mark = axes.plot(x, 40 + x ** 2)


0510Days050100150UsersToyplot User Growth

We can also specify logarithmic scales for axes:


In [55]:
x = numpy.linspace(-1000, 1000)

In [56]:
toyplot.plot(x, x, marker="o", xscale="linear", yscale="log", width=500);


-1000-50005001000-10 ³-10 ²-10 ¹-10 ⁰010 ⁰10 ¹10 ²10 ³

There are many more properties that control axes positioning and behavior - for more details, see :ref:canvas-layout and :ref:cartesian-axes in the user guide.

Animation

Toyplot can also create animated figures, by recording changes to a figure over time. Assume you've setup the following scatterplot:


In [57]:
x = numpy.random.normal(size=100)
y = numpy.random.normal(size=len(x))

In [58]:
canvas = toyplot.Canvas(300, 300)
axes = canvas.axes()
mark = axes.scatterplot(x, y, size=100)


-202-2-1012

Suppose we want to show the order in which the samples were drawn from some distribution. We could use the fill parameter to map each sample's index to a color, but an animation can be more intuitive. We can use :meth:toyplot.canvas.Canvas.animate to add a sequence of animation frames to the canvas. We pass the number of frames and a callback function as arguments, and the callback function will be called once per frame with a single :class:frame <toyplot.canvas.AnimationFrame> argument. The callback uses the frame object to retrieve information about the frame and record any changes that should be made to the canvas at that frame. In the example below, we set the opacity of each scatterplot datum to 5% in the first frame, then change them back to 100% over the course of the animation:


In [59]:
canvas = toyplot.Canvas(300, 300)
axes = canvas.axes()
mark = axes.scatterplot(x, y, size=100)

def callback(frame):
    if frame.index() == 0:
        for i in range(len(x)):
            frame.set_datum_style(mark, 0, i, style={"opacity":0.1})
    else:
        frame.set_datum_style(mark, 0, frame.index() - 1, style={"opacity":1.0})
canvas.animate(len(x) + 1, callback)


-202-2-1012

Let's try animating something other than a datum style - in the following example, we add a text mark to the canvas, and use it to display information about the frame:


In [60]:
canvas = toyplot.Canvas(300, 300)
axes = canvas.axes()
mark = axes.scatterplot(x, y, size=100)
text = canvas.text(150, 20, "")

def callback(frame):
    frame.set_datum_text(text, 0, 0, str(frame))
    if frame.index() == 0:
        for i in range(len(x)):
            frame.set_datum_style(mark, 0, i, style={"opacity":0.05})
    else:
        frame.set_datum_style(mark, 0, frame.index() - 1, style={"opacity":1.0})
canvas.animate(len(x) + 1, callback)


-202-2-1012

Note from this example that each frame has a zero-based frame index, along with begin and end times, which are measured in seconds. If you look closely, you'll see that the difference in begin and end times is 0.03 seconds for each frame, which corresponds to a default 30 frames per second. If we want to control the framerate, we can pass a (frames, framerate) tuple when we call :meth:toyplot.canvas.Canvas.animate (note that the playback is slower, and the times for the frames are changed):


In [61]:
canvas = toyplot.Canvas(300, 300)
axes = canvas.axes()
mark = axes.scatterplot(x, y, size=100)
text = canvas.text(150, 20, "")

def callback(frame):
    frame.set_datum_text(text, 0, 0, str(frame))
    if frame.index() == 0:
        for i in range(len(x)):
            frame.set_datum_style(mark, 0, i, style={"opacity":0.05})
    else:
        frame.set_datum_style(mark, 0, frame.index() - 1, style={"opacity":1.0})
canvas.animate((len(x) + 1, 10), callback)


-202-2-1012

Sometimes the callback approach to animation is awkward, particularly if you simply have a one-time "event" that needs to happen in the middle of the animation. In this case, you can use :meth:toyplot.canvas.Canvas.time to record changes for individual frames:


In [62]:
canvas = toyplot.Canvas(300, 300)
axes = canvas.axes()
mark = axes.scatterplot(x, y, size=100)
text = canvas.text(150, 20, "", style={"text-anchor":"middle"})
text2 = canvas.text(150, 50, "Halfway There!", style={"font-size":"16px", "font-weight":"bold", "fill":"blue", "text-anchor":"middle"})

def callback(frame):
    frame.set_datum_text(text, 0, 0, str(frame))
    if frame.index() == 0:
        for i in range(len(x)):
            frame.set_datum_style(mark, 0, i, style={"opacity":0.05})
    else:
        frame.set_datum_style(mark, 0, frame.index() - 1, style={"opacity":1.0})
canvas.animate((len(x) + 1), callback)
canvas.time(0, 0.1).set_datum_style(text2, 0, 0, {"opacity":0.0})
canvas.time(1.5, 5.1).set_datum_style(text2, 0, 0, {"opacity":1.0})


-202-2-1012Halfway There!

Note that when you combine :meth:toyplot.canvas.Canvas.animate and :meth:toyplot.canvas.Canvas.time, you don't have to force the "frames" to line-up ... you can record events in any order and at any point in time, whether there are existing frames at those times or not. In fact, you could call :meth:toyplot.canvas.Canvas.animate multiple times, if you wanted to animate events happening at different rates:


In [63]:
canvas = toyplot.Canvas(100, 100, style={"background-color":"ivory"})
t1 = canvas.text(50, 33, "1 hz", style={"text-anchor":"middle"})
t2 = canvas.text(50, 66, "2 hz", style={"text-anchor":"middle"})
def blink(mark):
    def callback(frame):
        frame.set_datum_style(mark, 0, 0, {"opacity":frame.index() % 2.0})
    return callback
canvas.animate((10, 2), blink(t1))
canvas.animate((20, 4), blink(t2))


1 hz2 hz

Using transition properties in your styles can smooth-out the animation by avoiding instantaneous changes:


In [64]:
canvas = toyplot.Canvas(100, 100, style={"background-color":"ivory"})
t1 = canvas.text(50, 33, "1 hz", style={"text-anchor":"middle", "transition":"opacity 0.5s"})
t2 = canvas.text(50, 66, "2 hz", style={"text-anchor":"middle", "transition":"opacity 0.25s"})
def blink(mark):
    def callback(frame):
        frame.set_datum_style(mark, 0, 0, {"opacity":frame.index() % 2.0})
    return callback
canvas.animate((10, 2), blink(t1))
canvas.animate((20, 4), blink(t2))


1 hz2 hz

In [ ]: