Search code examples
pythonsliderfilteringlegendbokeh

Bokeh legend breaks on Python callback


I have implemented a Bokeh figure with

  1. a scatter plot using circle() with a legend_group for colouring and for creating an interactive legend.
  2. a range slider with a Python callback that filters the data

I have set click_policy=hide for the legend so that points (circles) from a specific group are hidden when I click on a legend item.

All of that works fine initially. When I move the slider, however, the legend "breaks":

  1. Legend items disappear randomly, even though multiple groups from above are still visible
  2. The legend items that remain behave erroneously. With few initial total groups, only one item remains. With many initial groups, several tend to remain, but when I click on one of them, multiple are greyed out, while only one of the corresponding circle groups are hidden.

This is what the initial plot looks like:

Plot with full range

When I move the slider (at the bottom of the plot), some groups are correctly filtered out from the plot, but only one legend item remains, while multiple groups are still visible in the plot.

Plot after moving the slider

The code is embedded into an object-oriented implementation. This is the method that adds the circles to the figure:

    def _add_circles(self):
        palette = self._select_palette()
        labels = self._data[self._LABEL_FIELD].unique().tolist()

        for cluster in self._clusters:
            glyph = self._figure.circle(
                source=self._source,
                x="x",
                y="y",
                color=factor_cmap(self._LABEL_FIELD, palette, labels),
                legend_group=self._LABEL_FIELD,
                view=CDSView(
                    filter=GroupFilter(
                        column_name=self._LABEL_FIELD, group=cluster.label
                    ),
                ),
            )

            if cluster.label == OUTLIERS_LABEL:
                glyph.visible = False

This method sets up the legend:

    def _setup_legend(self, legend_location: str = "right", click_policy: str = "hide"):
        legend = self._figure.legend[0]

        legend.label_text_font_size = "6px"
        legend.spacing = 0
        legend.location = legend_location
        legend.click_policy = click_policy

The slider with callback is added like this:

    def _year_slider(self) -> RangeSlider:
        def callback(attr, old, new):  # noqa: unused-argument
            self._source.data = self._data.loc[
                self._data.year.between(new[0], new[1])
            ].to_dict(orient="list")

        min_year: int = self._data[self._YEAR_COLUMN].min()
        max_year: int = self._data[self._YEAR_COLUMN].max()

        slider = RangeSlider(
            start=min_year,
            end=max_year,
            value=(min_year, max_year),
            width=self._figure.frame_width,
        )
        slider.on_change("value_throttled", callback)
        return slider

When I move the slider back to its original span, all legend items are displayed correctly again.

The Legend object still contains all the LegendItem objects after moving the slider. All of them still have the visible property set to true.

The question is: why the legend does not display all its items? How is this related to the slider and/or the callback the slider uses?


Solution

  • It looks like using legend_label=cluster.label (where cluster.label is a string literal) instead of legend_group="year" fixes the issue.

    This is indicated in the documentation about interactive legends:

    The features of interactive legends currently only work on the basic legend labels described above. Legends that are created by specifying a column to automatically group do not yet support interactive features.