Bokeh图例在Python回调后异常问题排查
问题
实现了一个Bokeh图表,包含两个核心功能:
- 用
circle()绘制散点图,通过legend_group实现按组着色和可交互图例(已设置click_policy=hide,点击图例项可隐藏对应分组的点) - 带Python回调的RangeSlider用于过滤数据
初始状态一切正常,但拖动滑块过滤数据后,图例出现异常:
- 仍有多个分组可见时,图例项随机消失
- 剩余图例项交互异常:分组数量少的时候只剩1项;分组多的时候点击一项会灰显多个,但实际只隐藏一组对应的点
- 滑块回到初始范围后图例恢复正常,且检查
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
原因分析
图例生成逻辑冲突:你同时使用了
legend_group和CDSView+GroupFilter拆分每个分组的glyph。Bokeh会为每个glyph生成重复图例项,初始时所有数据存在,Bokeh自动合并重复项;但过滤数据后,部分glyph对应的分组在新数据中不存在,合并逻辑失效,导致图例项消失或交互异常。滑块回调的数据源替换问题:回调中直接替换
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
相关产品推荐
相关产品推荐

