You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Bokeh图例在Python回调后异常问题排查

问题

实现了一个Bokeh图表,包含两个核心功能:

  • 用circle()绘制散点图,通过legend_group实现按组着色和可交互图例(已设置click_policy=hide,点击图例项可隐藏对应分组的点)
  • 带Python回调的RangeSlider用于过滤数据

初始状态一切正常,但拖动滑块过滤数据后,图例出现异常:

  1. 仍有多个分组可见时,图例项随机消失
  2. 剩余图例项交互异常:分组数量少的时候只剩1项;分组多的时候点击一项会灰显多个,但实际只隐藏一组对应的点
  3. 滑块回到初始范围后图例恢复正常,且检查Legend对象发现所有LegendItem的visible属性均为true

相关实现代码

添加散点的方法

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

设置图例的方法

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

滑块及回调实现

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

原因分析

  1. 图例生成逻辑冲突:你同时使用了legend_group和CDSView+GroupFilter拆分每个分组的glyph。Bokeh会为每个glyph生成重复图例项,初始时所有数据存在,Bokeh自动合并重复项;但过滤数据后,部分glyph对应的分组在新数据中不存在,合并逻辑失效,导致图例项消失或交互异常。

  2. 滑块回调的数据源替换问题:回调中直接替换self._source.data的方式,会让数据源的_LABEL_FIELD字段唯一值集合改变,而legend_group依赖的初始化时生成的labels列表没有同步更新,导致图例与当前数据的分组不匹配。

解决办法

调整图例生成方式

二选一使用legend_group或GroupFilter,避免逻辑冲突:

  • 方案一:用单个glyph+legend_group自动生成图例
    去掉循环生成分组glyph的逻辑,靠legend_group自动按分组生成图例:

    def _add_circles(self):
        palette = self._select_palette()
        labels = self._data[self._LABEL_FIELD].unique().tolist()
    
        # 单个circle调用,自动按分组生成图例
        glyph = self._figure.circle(
            source=self._source,
            x="x",
            y="y",
            color=factor_cmap(self._LABEL_FIELD, palette, labels),
            legend_group=self._LABEL_FIELD,
        )
    
        # outliers可通过图例点击手动隐藏,或在数据源预处理时过滤
    
  • 方案二:手动设置图例标签(保留GroupFilter)
    不用legend_group,给每个分组glyph手动设置legend_label,确保每个图例项对应独立glyph:

    def _add_circles(self):
        palette = self._select_palette()
        labels = self._data[self._LABEL_FIELD].unique().tolist()
        color_map = {label: palette[i] for i, label in enumerate(labels)}
    
        for cluster in self._clusters:
            glyph = self._figure.circle(
                source=self._source,
                x="x",
                y="y",
                color=color_map[cluster.label],
                legend_label=cluster.label,  # 手动指定图例标签
                view=CDSView(
                    filter=GroupFilter(
                        column_name=self._LABEL_FIELD, group=cluster.label
                    ),
                ),
            )
    
            if cluster.label == OUTLIERS_LABEL:
                glyph.visible = False
    

优化滑块回调逻辑

不要直接替换整个数据源,而是通过更新过滤条件实现数据筛选,保持原始数据源结构和标签集合不变:

def _year_slider(self) -> RangeSlider:
    min_year: int = self._data[self._YEAR_COLUMN].min()
    max_year: int = self._data[self._YEAR_COLUMN].max()

    # 初始化年份过滤条件
    self._year_filter = GroupFilter(column_name=self._YEAR_COLUMN, group=min_year)
    # 组合年份过滤与分组过滤(如果需要)
    self._combined_view = CDSView(filter=AndFilter([self._year_filter, ...]))

    def callback(attr, old, new):
        # 更新过滤条件,而非替换数据源
        self._year_filter = CustomJSFilter(code="""
            return data.year.map(y => y >= new[0] && y <= new[1]);
        """, args=dict(data=self._source.data, new=new))
        self._source.change.emit()  # 触发视图更新

    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

核心是保持数据源结构稳定,通过过滤条件动态筛选数据,避免图例与数据的分组匹配关系被破坏。

内容的提问来源于stack exchange,提问作者Carsten

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.04 08:24:57