Linking Plots

In [1]:
import numpy as np
import holoviews as hv
from holoviews import opts

hv.extension('bokeh')

When working with the bokeh backend in HoloViews complex interactivity can be achieved using very little code, whether that is shared axes, which zoom and pan together or shared datasources, which allow for linked cross-filtering. Separately it is possible to create custom interactions by attaching LinkedStreams to a plot and thereby triggering events on interactions with the plot. The Streams based interactivity affords a lot of flexibility to declare custom interactivity on a plot, however it always requires a live Python kernel to be connected either via the notebook or bokeh server. The Link classes described in this user guide however allow declaring interactions which do not require a live server, opening up the possibility of declaring complex interactions in a plot that can be exported to a static HTML file.

What is a Link?

A Link defines some connection between a source and target object in their visualization. It is quite similar to a Stream as it allows defining callbacks in response to some change or event on the source object, however, unlike a Stream, it does not transfer data between the browser and a Python process. Instead a Link directly causes some action to occur on the target, for JS based backends this usually means that a corresponding JS callback will effect some change on the target in response to a change on the source.

One of the simplest examples of a Link is the DataLink which links the data from two sources as long as they match in length, e.g. below we create two elements with data of the same length. By declaring a DataLink between the two we can ensure they are linked and can be selected together:

In [2]:
from holoviews.plotting.links import DataLink

scatter1 = hv.Scatter(np.arange(100))
scatter2 = hv.Scatter(np.arange(100)[::-1], 'x2', 'y2')

dlink = DataLink(scatter1, scatter2)

(scatter1 + scatter2).opts(
    opts.Scatter(tools=['box_select', 'lasso_select']))
Out[2]:

If we want to display the elements subsequently without linking them we can call the unlink method:

In [3]:
dlink.unlink()

(scatter1 + scatter2)
Out[3]:

Another example of a link is the RangeToolLink which adds a RangeTool to the source plot which is linked to the axis range on the target plot. In this way the source plot can be used as an overview of the full data while the target plot provides a more detailed view of a subset of the data:

In [4]:
from holoviews.plotting.links import RangeToolLink

data = np.random.randn(1000).cumsum()

source = hv.Curve(data).opts(width=800, height=125, axiswise=True, default_tools=[])
target = hv.Curve(data).opts(width=800, labelled=['y'], toolbar=None)

rtlink = RangeToolLink(source, target)

(target + source).opts(merge_tools=False).cols(1)
Out[4]:

A Link consists of two components the Link itself and a LinkCallback which provides the actual implementation behind the Link. In order to demonstrate writing a Link we'll start with a fairly straightforward example, linking an HLine or VLine to the mean value of a selection on a Scatter element. To express this we declare a MeanLineLink class subclassing from the Link baseclass and declare ClassSelector parameters for the source and target with the appropriate types to perform some basic validation. Additionally we declare a column parameter to specify which column to compute the mean on.

In [5]:
import param
from holoviews.plotting.links import Link

class MeanLineLink(Link):
    
    column = param.String(default='x', doc="""
        The column to compute the mean on.""")
    
    _requires_target = True

Now we have the Link class we need to write the implementation in the form of a LinkCallback, which in the case of bokeh will be translated into a CustomJS callback. A LinkCallback should declare the source_model we want to listen to events on and a target_model, declaring which model should be altered in response. To find out which models we can attach the Link to we can create a Plot instance and look at the plot.handles, e.g. here we create a ScatterPlot and can see it has a 'cds', which represents the ColumnDataSource.

In [6]:
renderer = hv.renderer('bokeh')

plot = renderer.get_plot(hv.Scatter([]))

plot.handles.keys()
Out[6]:
dict_keys(['xaxis', 'x_range', 'yaxis', 'y_range', 'plot', 'previous_id', 'source', 'cds', 'selected', 'glyph', 'glyph_renderer'])

In this case we are interested in the 'cds' handle, but we still have to tell it which events should trigger the callback. Bokeh callbacks can be grouped into two types, model property changes and events. For more detail on these two types of callbacks see the Bokeh user guide.

For this example we want to respond to changes to the ColumnDataSource.selected property. We can declare this in the on_source_changes class atttribute on our callback. So now that we have declared which model we want to listen to events on and which events we want to listen to, we have to declare the model on the target we want to change in response.

We can once again look at the handles on the plot corresponding to the HLine element:

In [7]:
plot = renderer.get_plot(hv.HLine(0))
plot.handles.keys()
Out[7]:
dict_keys(['xaxis', 'x_range', 'yaxis', 'y_range', 'plot', 'previous_id', 'source', 'cds', 'selected', 'glyph'])

We now want to change the glyph, which defines the position of the HLine, so we declare the target_model as 'glyph'. Having defined both the source and target model and the events we can finally start writing the JS callback that should be triggered. To declare it we simply define the source_code class attribute. To understand how to write this code we need to understand how the source and target models, we have declared, can be referenced from within the callback.

The source_model will be made available by prefixing it with source_, while the target model is made available with the prefix target_. This means that the ColumnDataSource on the source can be referenced as source_source, while the glyph on the target can be referenced as target_glyph.

Finally, any parameters other than the source and target on the Link will also be made available inside the callback, which means we can reference the appropriate column in the ColumnDataSource to compute the mean value along a particular axis.

Once we know how to reference the bokeh models and Link parameters we can access their properties to compute the mean value of the current selection on the source ColumnDataSource and set the target_glyph.position to that value.

A LinkCallback may also define a validate method to validate that the Link parameters and plots are compatible, e.g. in this case we can validate that the column is actually present in the source_plot ColumnDataSource.

In [8]:
from holoviews.plotting.bokeh.callbacks import LinkCallback

class MeanLineCallback(LinkCallback):

    source_model = 'selected'
    source_handles = ['cds']
    on_source_changes = ['indices']

    target_model = 'glyph'
    
    source_code = """
        var inds = source_selected.indices
        var d = source_cds.data
        var vm = 0
        if (inds.length == 0)
            return
        for (var i = 0; i < inds.length; i++)
            vm += d[column][inds[i]]
        vm /= inds.length
        target_glyph.location = vm
    """
    
    def validate(self):
        assert self.link.column in self.source_plot.handles['cds'].data

Finally we need to register the MeanLineLinkCallback with the MeanLineLink using the register_callback classmethod:

In [9]:
MeanLineLink.register_callback('bokeh', MeanLineCallback)

Now the newly declared Link is ready to use, we'll create a Scatter element along with an HLine and VLine element and link each one:

In [10]:
options = opts.Scatter(
    selection_fill_color='firebrick', alpha=0.4, line_color='black', size=8,
    tools=['lasso_select', 'box_select'], width=500, height=500,
    active_tools=['lasso_select']
)
scatter = hv.Scatter(np.random.randn(500, 2)).opts(options)
vline = hv.VLine(scatter['x'].mean()).opts(color='black')
hline = hv.HLine(scatter['y'].mean()).opts(color='black')

MeanLineLink(scatter, vline, column='x')
MeanLineLink(scatter, hline, column='y')

scatter * hline * vline
Out[10]:

Using the 'box_select' and 'lasso_select' tools will now update the position of the HLine and VLine.


Right click to download this notebook from GitHub.