在 Bokeh 中自定义手势工具(GestureTool):以拖拽手绘工具为例的完整实现指南
2026/9/13 23:29:38 网站建设 项目流程

在 Bokeh 中自定义手势工具(GestureTool):以拖拽手绘工具为例的完整实现指南

【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh

导读

Bokeh 允许开发者通过自定义扩展(Custom Extension)把全新的交互能力注入浏览器端画布。本文以官方示例 examples/advanced/extensions/tool.py 为骨架,完整讲解如何用 Python + TypeScript 编写一个自定义的GestureTool子类,实现在绘图画布上按住鼠标(或触屏手指)拖拽即可"手绘"线条的交互工具。读完本文,你将掌握 Bokeh 工具系统的分类与生命周期、手势事件(pan)在 Python 端与 TypeScript 端的对接方式,以及屏幕坐标与数据坐标的换算方法,从而能够独立开发任意自定义交互工具。

Bokeh 工具系统概览:自定义工具从哪里接入

在动手写代码之前,需要先理解 Bokeh 内置工具的分类。在 src/bokeh/models/tools.py 的模块文档中,Bokeh 明确将工具交互分为五类:

  • Pan/Drag(平移/拖拽)
  • Click/Tap(点击/轻触)
  • Scroll/Pinch(滚动/捏合)
  • Actions(动作,如保存、重置)
  • Inspectors(检查器,如 HoverTool,被动报告信息)

其中前三类统称为手势工具(gesture tools),同一时刻每个手势只能有一个工具处于激活状态,激活的工具在工具栏上会有高亮标识;Actions 是即时的或模态的操作,Inspectors 则是可以始终激活的被动工具。

从源码结构看,这套分类在 TypeScript 端对应bokehjs/src/lib/models/tools/下的三个目录:gestures/(手势工具)、actions/(动作工具)、inspectors/(检查器工具)。自定义工具时,只需选择对应基类继承即可——本文要做的拖拽手绘工具属于手势工具,因此直接继承GestureTool

示例目标:一个可拖拽手绘的自定义工具

本示例要实现的工具名为DrawTool,其行为是:

  1. 在画布上按下鼠标/手指时,清空已绘制的数据;
  2. 拖拽过程中,把鼠标经过的每个点追加进数据源;
  3. 松开时结束本次绘制。

绘制结果通过一个普通的line字形实时渲染出来,最终效果是"按住拖拽即可在画布上随意涂鸦"。

官方文档 docs/bokeh/source/docs/user_guide/advanced/extensions/tool.rst 通过.. bokeh-plot::指令直接渲染并展示该示例,属于 Bokeh 扩展能力用户指南中的典型范例。

完整的示例代码

整个扩展由两部分组成:内嵌在 Python 字符串里的 TypeScript 代码(浏览器端逻辑),以及一个继承Tool的 Python 模型(服务端/文档端定义)。完整代码见 examples/advanced/extensions/tool.py,核心结构如下:

from bokeh.core.properties import Instance from bokeh.models import ColumnDataSource, Tool from bokeh.plotting import figure, show from bokeh.util.compiler import TypeScript CODE = """...TypeScript 代码...""" class DrawTool(Tool): __implementation__ = TypeScript(CODE) source = Instance(ColumnDataSource) source = ColumnDataSource(data=dict(x=[], y=[])) plot = figure(x_range=(0,10), y_range=(0,10), title="Click and drag to draw", background_fill_color="#efefef", tools="") plot.add_tools(DrawTool(source=source)) plot.line('x', 'y', line_width=3, source=source) show(plot)

要点拆解:

  • __implementation__ = TypeScript(CODE):这是 Bokeh 自定义扩展的标准接入点。TypeScript类来自 src/bokeh/util/compiler.py,它把内嵌的 TypeScript 源码在运行时编译为浏览器可执行的 JavaScript,无需单独的构建流程;
  • source = Instance(ColumnDataSource):声明自定义工具的一个属性,类型为ColumnDataSource实例。这个属性在 Python 端与 TypeScript 端通过p.Ref(ColumnDataSource)对应,实现两端共享同一个数据源;
  • plot.add_tools(DrawTool(source=source)):把工具挂载到 figure 上。注意这里tools="",即不启用任何内置工具,避免干扰手绘交互;
  • plot.line('x', 'y', line_width=3, source=source):用一个line字形消费数据源中的点,拖拽产生的点实时连线呈现。

浏览器端实现:DrawToolView 的手势生命周期

手势工具的核心逻辑在 TypeScript 端的View类中。本示例的DrawToolView继承自GestureToolView,后者定义于 bokehjs/src/lib/models/tools/gestures/gesture_tool.ts,其关键能力是提供了get plot_view(),让视图可以访问所在的绘图画布视图。

DrawToolView重写了三个生命周期方法,这三个方法与 Bokeh 手势事件流一一对应:

// 拖拽开始时执行 _pan_start(_e: PanEvent): void { this.model.source.data = {x: [], y: []} } // 后续每次鼠标/手指移动时执行 _pan(e: PanEvent): void { const {frame} = this.plot_view const {sx, sy} = e if (!frame.bbox.contains(sx, sy)) return const x = frame.x_scale.invert(sx) const y = frame.y_scale.invert(sy) const {source} = this.model source.get_array("x").push(x) source.get_array("y").push(y) source.change.emit() } // 拖拽结束时执行 _pan_end(_e: PanEvent): void {}

这段代码揭示了 Bokeh 手势工具的内部机制:

坐标换算(核心):事件对象e携带的是屏幕像素坐标sx/sy,而绘图需要的是数据坐标。frame.x_scale.invert(sx)通过坐标轴的 scale 对象把屏幕坐标反算为数据坐标——frame来自plot_view,即 Cartesian 框架视图;frame.bbox.contains(sx, sy)先做边界检查,确保只记录画布框架内的点。

边界约束frame.bbox.contains(sx, sy)保证拖出绘图区域(比如拖到坐标轴或标题上)时不会产生越界数据点。

数据驱动渲染:每次移动都把新点push进数据源的x/y数组,随后调用source.change.emit()发出变更信号,Bokeh 的响应式系统会据此重绘绑定的line字形,形成实时手绘效果。

生命周期对称性_pan_start清空旧数据、_pan追加新数据、_pan_end收尾,三个钩子保证了一次完整手势的干净闭环。

从底层看,这三个方法正是 bokehjs/src/lib/models/tools/tool.ts 中ToolView声明的可选钩子_pan_start?_pan?_pan_end?。而事件的分发由 bokehjs/src/lib/core/ui_events.ts 中的pan:startpanpan:end三个 UI 信号驱动:UI 事件系统把浏览器拖拽事件转换为PanEvent,再按工具声明的事件类型路由到对应工具的_pan_start/_pan/_pan_end方法(参见ui_events.ts中信号到方法名的映射逻辑)。这也解释了为什么DrawToolView无需自己监听 DOM 事件——Bokeh 已经统一完成。

浏览器端实现:DrawTool 模型声明

DrawToolView之外,TypeScript 端还需要定义DrawTool模型类,它继承自GestureTool

export class DrawTool extends GestureTool { declare properties: DrawTool.Props declare __view_type__: DrawToolView tool_name = "Draw Tool" tool_icon = "bk-tool-icon-lasso-select" event_type = "pan" as "pan" default_order = 12 static { this.prototype.default_view = DrawToolView this.define<DrawTool.Props>(({Ref}) => ({ source: [ Ref(ColumnDataSource) ], })) } }

逐个字段说明其含义与取值约束:

字段作用说明
tool_name工具栏提示与菜单中显示的名称任意可读字符串,这里为 "Draw Tool"
tool_icon工具栏按钮图标(CSS 类名,不含点号)复用内置图标bk-tool-icon-lasso-select,也可以提供自定义 CSS
event_type声明工具消费的事件类型必须取 ui_events.ts 中EventType联合类型之一:"pan" \| "pinch" \| "rotate" \| "move" \| "tap" \| "doubletap" \| "press" \| "pressup" \| "scroll"。本工具用"pan",因此浏览器端才走_pan_*钩子
default_order多个手势工具共享同一手势时的激活优先级GestureTool抽象类的要求来看(gesture_tool.ts),每个手势工具都必须实现default_orderevent_type两个抽象成员;数字越小优先级越高
source自定义属性,与 Python 端Instance(ColumnDataSource)对应static块中通过this.define声明,类型为p.Property<ColumnDataSource>

注意static { ... }块中的两条关键语句:

  • this.prototype.default_view = DrawToolView:把模型与视图绑定,Bokeh 在渲染时会根据模型自动实例化对应的 View;
  • this.define<DrawTool.Props>(({Ref}) => ({source: [Ref(ColumnDataSource)]})):用 Bokeh 的属性系统声明source属性,Ref表示这是一个到其他模型的引用类型。这样该属性才能享受响应式绑定、序列化等基础设施。

Python 端模型:如何与 TypeScript 对接

Python 端的DrawTool非常简单:

class DrawTool(Tool): __implementation__ = TypeScript(CODE) source = Instance(ColumnDataSource)
  • 继承自Tool而非GestureTool:这是 Bokeh 扩展的惯例——Python 侧的工具基类统一使用Tool,真正的"手势"语义由 TypeScript 侧继承GestureTool来表达;
  • __implementation__:Bokeh 自定义扩展的注册入口,TypeScript(CODE)指示编译器把内嵌 TypeScript 源码编译进最终资源;
  • source属性:在 Python 端声明为Instance(ColumnDataSource),与 TypeScript 端的define声明一一对应。Python 端负责创建/持有数据源,TypeScript 端在交互时读写该数据源,两端共享同一对象。

这种"Python 定义属性、TypeScript 实现行为"的配对模式,是所有 Bokeh 自定义工具、字形、注释扩展的共同结构。

与内置手势工具的对照:这不是特例,而是通用模式

DrawTool的实现方式并非 Bokeh 专门为扩展开放的特例,内置手势工具本身就用同一套模式编写。以几个典型实现为例:

  • bokehjs/src/lib/models/tools/gestures/box_select_tool.ts:BoxSelectToolView同样重写_pan_start(记录起点)、_pan(更新选框)、_pan_end(提交选择);
  • bokehjs/src/lib/models/tools/gestures/lasso_select_tool.ts:套索选择工具在_pan中累积轨迹点,与DrawTool的绘图逻辑几乎同构;
  • bokehjs/src/lib/models/tools/gestures/pan_tool.ts:平移工具在_pan中按拖拽增量更新坐标范围。

这意味着:阅读 bokehjs/src/lib/models/tools/gestures/ 目录下的任意一个内置手势工具,都能直接复用到自定义扩展的写作中;反过来,学会了DrawTool这一套_pan_*钩子,也就理解了 Bokeh 全部拖拽类交互工具的底层原理。

运行方式与实操验证

该示例采用内嵌编译的扩展形式,直接运行即可(本地需已安装当前仓库对应的 Bokeh 开发环境):

python examples/advanced/extensions/tool.py

运行后会弹出(或由 notebook 环境展示)一个标题为 "Click and drag to draw" 的绘图窗口:按住鼠标在灰色画布(background_fill_color="#efefef")上拖拽,即可实时绘制出粗线条(line_width=3);松开后再次按下拖拽,会清空上一次的痕迹重新绘制。

可以自行验证的调整点:

  • plot.add_tools(DrawTool(source=source))改为plot.add_tools(DrawTool(source=source), "reset")等,观察自定义工具与内置工具在工具栏上的共存;
  • 修改default_order = 12,再同时添加一个BoxSelectTool,观察激活优先级的变化;
  • _pan中移除frame.bbox.contains(sx, sy)边界检查,拖出绘图区观察坐标越界行为。

扩展方向:从手绘工具到更复杂的交互

DrawTool虽小,却是完整的扩展范式,可以沿以下方向继续扩展:

  • 更多事件类型:把event_type改为"tap""press",重写_tap等钩子即可实现点击打点、按压缩放等交互;
  • 多数据源/多属性:仿照source的声明方式,用Instance/Ref增加第二个数据源或配置属性(如线条颜色、线宽);
  • 覆盖层(overlay)ToolView支持通过overlays返回渲染器,可用于像框选工具那样绘制临时视觉反馈;
  • 自定义图标:修改tool_icon为自定义 CSS 类,或在 Python 端通过IconLike属性注入图标资源。

若想了解同一扩展体系下的其他形态,可继续阅读同目录下的 ticking.rst(自定义坐标轴刻度)、widget.rst(自定义 UI 组件)与 wrapping.rst(将第三方 JS 库封装为扩展)。

小结

本文以官方示例DrawTool为线索,完整走通了 Bokeh 自定义手势工具从声明到运行的整条链路:Python 端用Tool基类 +__implementation__注册 TypeScript 实现并声明共享属性;TypeScript 端用GestureTool/GestureToolView提供模型与视图,通过_pan_start/_pan/_pan_end三个钩子接入 Bokeh 统一的手势事件分发(ui_events.ts),并用frame.x_scale.invert完成屏幕坐标到数据坐标的换算,最后通过数据源变更驱动line字形实时渲染。这套模式与 Bokeh 内置的框选、套索、平移工具完全一致,掌握了它,你就掌握了 Bokeh 全部拖拽类交互的扩展能力。

【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询