6 - Extending the module

This tutorial explains how to extend the plotting module when the plotting functions and/or the customization options are not enough to achieve the desired results.

We are only going to look at extending MatplotlibBackend (or MB), as the procedure to modify other backends is the same.

Here is how the plotting module works:

  • a plotting function is called, receiving one or more symbolic expression, as well as keyword arguments to further customize the output.

  • inside the plotting function, one (or more) data series are instantiated for each symbolic expression. These will generate the numerical data.

  • then, a plot object is instantiated, p, which receives the data series. For each data series, an appropriate renderer is created. A renderer contains the logic to visualize the numerical data on the chart, and keep it updated when a widget-plot is created.

  • When p.show() is executed, a new figure is created and a loop is run over the renderers to add the numerical data to the figure.

The structure of a renderer is described in Renderers.

Let’s say we want to create a new plotting function to fill the area between 2 symbolic expressions. Here is a commented code-block to achieve that goal:

from sympy.abc import x
from sympy import cos, sin, pi
from spb import MB
from spb.series import LineOver1DRangeSeries, BaseSeries
from spb.backends.matplotlib.renderers.renderer import MatplotlibRenderer


class FilledLineSeries(BaseSeries):
    """
    Create a new data series to represent a filled area between 2 lines.
    The base class must be BaseSeries.
    """

    def __init__(self, expr1, expr2, var_start_end, **kwargs):
        """
        Each symbolic expression is treated as a line. Hence, for each
        symbolic expression this class creates a corresponding
        LineOver1DRangeSeries.
    
        Parameters
        ==========
        expr1 : Expr
        expr2 : Expr
        var_start_end : 3-elements tuple
            A tuple of the form (x, min_val, max_val), where x is the
            symbol used on the symbolic expressions.
        **kwargs :
            Keyword arguments to be passed to LineOver1DRangeSeries
        """
    
        # Very important: execute the base class __init__ method.
        super().__init__(**kwargs)
    
        # set a few attributes
        self.label = kwargs.pop("label", str(expr1) + " to " + str(expr2))
        self.series1 = LineOver1DRangeSeries(expr1, var_start_end, **kwargs)
        self.series2 = LineOver1DRangeSeries(expr2, var_start_end, **kwargs)

    def get_data(self):
        """Computes and returns the numerical data.
        """
        x1, y1 = self.series1.get_data()
        x2, y2 = self.series2.get_data()
        return x1, y1, x2, y2

###########################################################################
######################### Rendering logic #################################
###########################################################################

def draw(renderer, data):
    """
    Parameters
    ==========
    renderer : an instance of MatplotlibRenderer
    data : the numerical data to be shown

    Returns
    =======

    handle : 
        A single Matplotlib's handle or a list of handles that
        will be used by the `update` function in case of
        interactive-widget plots.
    """
    # A renderer exposes two attributes:
    # 1. the data series, mainly used to access its attributes
    # 2. the plot object where the numerical data will be added
    p, s = renderer.plot, renderer.series
    # numerical data returned by FilledLineSeries.get_data()
    x1, y1, x2, y2 = data

    # draw the numerical data to the figure
    handle = p._ax.fill_between(x1, y1, y2, **s.rendering_kw)

    # eventually add a legend entry
    if s.show_in_legend:
        proxy_artist = p.Rectangle((0, 0), 1, 1,
            color=handle.get_facecolor(), label=s.label)
        p._legend_handles.append(proxy_artist)

    # return the handle so that it can later be updated with
    # new data.
    # NOTE: in this case only one handle is needed. If the
    # visualization requires more objects, `handle` will be
    # a list of handles.
    return handle

def update(renderer, data, handle):
    """
    Parameters
    ==========
    renderer : an instance of MatplotlibRenderer
    data : the numerical data to be shown
    handle : the handle generated by draw()
    """
    # logic to update the visualization
    raise NotImplementedError


class FilledLineRenderer(MatplotlibRenderer):
    """Create a new renderer to display filled areas between lines.
    """
    draw_update_map = {
        draw: update
    }

# let MatplotlibBackend knows that when a FilledLineSeries is encountered,
# it must be rendered with FilledLineRenderer
MB.renderers_map.update({FilledLineSeries: FilledLineRenderer})

###########################################################################
######################### Plot function logic #############################
###########################################################################

def plot_filled(from_to, x_range, **kwargs):
    show = kwargs.get("show", True)
    backend = kwargs.get("backend", MB)

    # create data series
    series = []
    for (expr1, expr2) in from_to:
        series.append(
            FilledLineSeries(expr1, expr2, x_range, **kwargs)
        )
    # create the plot object
    p = backend(*series, **kwargs)
    if show:
        p.show()
    return p

# example plotting two filled regions
plot_filled([(sin(x), cos(x)), (-sin(x), 0)], (x, -pi, pi), grid=False)

(Source code, png)

../_images/extended_module.png