Bokeh 自定义扩展实战:用 Surface3d 封装 vis.js 实现 3D 曲面可视化
【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh
本文以 Bokeh 仓库中的 surface3d 示例 为主线,系统讲解如何通过 Bokeh 的**自定义扩展(Custom Extensions)**机制,把第三方 JavaScript 库(vis.js Graph3d)封装为可在 Python 侧直接实例化的 Bokeh 模型,并通过 Bokeh Server 的ColumnDataSource与周期性回调实现数据驱动的实时 3D 渲染。读完本文,你将掌握自定义扩展的 Python/TypeScript 双端模型结构、属性定义与信号同步机制,并能独立编写、运行自己的扩展型 Bokeh Server 应用。
示例背景:为什么需要自定义扩展
Bokeh 内置了大量绘图能力,但"任何 Web 工具、组件或框架"都不可能全部原生内置。官方在 templates/index.html 中明确指出:借助自定义扩展 + Bokeh Server,可以轻松把 Python 数据分析工具(NumPy、SciPy、Pandas 等)与几乎任意的 Web 库连接起来。
surface3d 正是这一理念的样板:它包装了 vis.js 的 Graph3d 组件,做出一张不断被 Server 端回调刷新的 3D 曲面图,并且支持鼠标拖拽旋转、滚轮/捏合缩放。在 examples/server/app/README.md 的示例总览表中,它的定位被概括为"通过 Bokeh 自定义扩展包装第三方 JavaScript 库的动态 3D 图"。
环境准备与启动
官方 README 强调:运行本示例不需要任何额外安装的包或步骤——因为 Bokeh 本身、NumPy(用于计算曲面数据)都属于既有依赖,vis.js 库则在应用模板中通过 CDN 加载(见下文"模板与静态资源")。
在仓库根目录下进入示例父目录examples/server/app,直接执行:
bokeh serve --show surface3d--show会在启动 Server 后自动在浏览器新标签页打开应用。这与 examples/server/app/README.md 中描述的统一运行方式一致:bokeh serve --show <脚本名或目录名>。若希望指定端口或地址,可追加--port等标准 Server 参数。该示例同样被 tests/examples.yaml 登记在测试清单中(注明其因无Bokeh.index无法计算 bbox 而走特殊检查路径),说明它是仓库内被 CI 覆盖的正式示例之一。
扩展的 Python 侧:surface3d.py
自定义扩展的核心是成对出现的模型:Python 侧定义属性与默认值,TypeScript 侧定义浏览器渲染行为。先看 surface3d.py。
继承LayoutDOM:让扩展可参与 Bokeh 布局
class Surface3d(LayoutDOM): __implementation__ = "surface3d.ts"源码注释给出选基类的通用法则:
- 想让扩展拥有 DOM 视图、可被放入 Bokeh 布局 → 继承
LayoutDOM(本示例所选); - 想创建自定义工具 → 继承
Tool; - 想创建自定义字形 → 继承
Glyph。
特殊的类属性__implementation__指向实现浏览器侧逻辑的 TypeScript 文件。从 Bokeh 的编译机制看(见 src/bokeh/util/compiler.py),__implementation__支持多种写法:内联的TypeScript("""...""")/JavaScript("""...""")字符串,或通过FromFile读取.ts、.js、.css、.less独立文件(扩展名决定了语言类型)。本示例直接赋文件路径字符串,等价于从文件读取实现。
属性声明:Python 与浏览器的"自动通信协议"
Bokeh 属性(Property)是类属性,定义模型字段及其类型,由框架在 Python 与浏览器之间自动序列化传输,并附带类型校验。Surface3d 声明了四组属性:
data_source = Instance(ColumnDataSource) # 可被 Server 端 Python 代码更新 x = String() # 指定 ColumnDataSource 中用作 x 的列名 y = String() z = String() options = Dict(String, Any, default=DEFAULTS) # 透传给 vis.js Graph3d 的选项字典值得注意的设计点:
data_source使用Instance(ColumnDataSource),使其可以持有真实数据列,并在 Server 端被更新后自动向浏览器推送变更事件;x/y/z是列名字符串而非数值,数据本身存在ColumnDataSource中,模型只声明"取哪一列",实现了解耦;options是Dict(String, Any),Any允许任意 JSON 值,从而把 vis.js Graph3d 的全部可配置项开放给 Python 使用者。
默认选项DEFAULTS
Python 侧DEFAULTS字典(与 TypeScript 侧的OPTIONS常量一一对应,注释要求二者保持一致):
DEFAULTS = { 'width': '600px', 'height': '600px', 'style': 'surface', 'showPerspective': True, 'showGrid': True, 'keepAspectRatio': True, 'verticalRatio': 1.0, 'legendLabel': 'stuff', 'cameraPosition': { 'horizontal': -0.35, 'vertical': 0.22, 'distance': 1.8, }, }含义速览:width/height固定组件尺寸(源码注释特别说明尺寸在options中固定,若进一步改造可做成响应式);style为曲面绘制风格;showPerspective开启透视投影;showGrid显示网格;keepAspectRatio保持宽高比;verticalRatio垂直方向比例;legendLabel图例标签;cameraPosition设置初始相机位置(水平角、仰角、距离)。这些选项可在实例化时通过options属性覆盖。
扩展的 TypeScript 侧:surface3d.ts
浏览器侧的实现在 surface3d.ts 中,由四个部分组成,结构与 Python 模型严格对应。
1. 外部库类型声明
由于仓库不内置 vis.js 类型定义,文件顶部用declare namespace vis手工声明了Graph3d与DataSet的最小类型接口,便于 TypeScript 编译,并注明其"假设 vis.js 已被加载(例如在自定义应用模板中)"。
2. View 子类:负责渲染
export class Surface3dView extends LayoutDOMView { private _graph: vis.Graph3d override render(): void { super.render() this._graph = new vis.Graph3d(this.shadow_el, this.get_data(), to_object(this.model.options)) } ... }- 注释解释了 Bokeh 视图的通用约定:View 默认创建
<div>元素(即@el),多数视图会忽略它改画 HTML canvas,而这里直接利用该<div>(shadow DOM 中的shadow_el)挂载 Graph3d; child_models返回空数组,表明该布局项没有子 Bokeh 模型。
3. 信号连接:数据变更自动重绘
override connect_signals(): void { super.connect_signals() this.connect(this.model.data_source.change, () => this._graph.setData(this.get_data())) }这是"Python 改数据 → 浏览器自动更新"的关键一环:监听data_source的change信号,触发时把最新数据转换为 vis.js 的DataSet并调用setData重绘。
4. 数据适配:Bokeh → vis.js
get_data(): vis.DataSet { const data = new vis.DataSet() const source = this.model.data_source for (let i = 0; i < source.get_length()!; i++) { data.add({ x: source.get(this.model.x)[i], y: source.get(this.model.y)[i], z: source.get(this.model.z)[i], }) } return data }其职责单一:遍历ColumnDataSource每一行,按x/y/z属性指定的列名取出数值,组装成{x, y, z}对象加入DataSet——即源码注释所说"把 Bokeh 数据源适配为 vis.js DataSet 格式"。
5. 模型子类与属性注册
export class Surface3d extends LayoutDOM { static __name__ = "Surface3d" // 必须与 Python 类名一致(TS 编译时会自动填充) static { this.prototype.default_view = Surface3dView this.define<Surface3d.Props>(({Unknown, Str, Dict, Ref}) => ({ x: [ Str ], y: [ Str ], z: [ Str ], data_source: [ Ref(ColumnDataSource) ], options: [ Dict(Unknown), OPTIONS ], })) } }要点:
__name__必须与 Python 类名精确一致,否则模型无法序列化/反序列化(源码注释特别提醒避免手写拼错);default_view把模型与 View 绑定;define块注册的属性与 Python 侧一一对应,类型对应关系为String → Str、Dict(String, Any) → Dict(Unknown)、Instance(ColumnDataSource) → Ref(ColumnDataSource);在 JS 类型系统尚不丰富的场景可用Unknown作为通配类型。
数据驱动与动画:main.py 的完整链路
main.py 把以上模型组装成可运行的应用,展示了"Python 计算 → ColumnDataSource → 浏览器 3D 图"的完整数据流。
生成曲面网格数据
x = np.arange(0, 300, 20) y = np.arange(0, 300, 20) xx, yy = np.meshgrid(x, y) xx = xx.ravel() yy = yy.ravel() def compute(t): value = np.sin(xx/50 + t/10) * np.cos(yy/50 + t/10) * 50 + 50 return dict(x=xx, y=yy, z=value)在 0~300 范围内每 20 取一个采样点,形成 15×15 的网格;compute(t)以时间参数t驱动正弦/余弦乘积,得到随时间变化的波浪曲面。
实例化扩展并挂载到文档
source = ColumnDataSource(data=compute(0)) surface = Surface3d(x="x", y="y", z="z", data_source=source) curdoc().add_root(surface) curdoc().title = "Surface3d"注意Surface3d的x/y/z传入的是列名字符串,与 Python 侧属性定义呼应。
周期性回调驱动动画
from bokeh.driving import count @count() def update(t): source.data = compute(t) curdoc().add_periodic_callback(update, 100)count()来自 src/bokeh/driving.py,是一个"驱动函数"装饰器:每次调用回调时自动注入递增的计数值作为t,从而让compute(t)持续演化;curdoc().add_periodic_callback(update, 100)每100 毫秒触发一次update(实现见 src/bokeh/document/document.py),每次执行source.data = compute(t);- 该赋值触发
ColumnDataSource的change信号,进而驱动 TypeScript 侧connect_signals中注册的回调 →setData→ 3D 图无刷新重绘。
这就是"Server 端 Python 每 100ms 算一帧、浏览器端同步重绘"的完整闭环。
模板与静态资源:如何加载第三方库
templates/index.html 是一个继承 Bokeh 默认模板({% extends base %})的自定义应用模板,承担两项任务:
- 加载 vis.js:在
preamble块中引入 CDN 脚本<script src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.16.1/vis.min.js"></script>这正是
surface3d.ts中new vis.Graph3d(...)能全局使用vis的前提;源码注释也说明"未来 Bokeh 模型将能自动指定并加载外部脚本",当前阶段需要模板手动引入; - 页面说明文案:在
contents块中输出标题与两段说明,并通过{{ super() }}保留 Bokeh 默认内容(即渲染出的 3D 图)。文中还预告了交互方式——拖拽旋转、滚动/捏合缩放。
同目录的__init__.py为空文件,作用是让examples/server/app/surface3d成为可导入的 Python 包,保证main.py中from .surface3d import Surface3d的相对导入成立。
扩展机制总结与可复用模板
把整个示例抽象成"自定义扩展四件套",即可复用到任意第三方 JS 库:
| 文件 | 角色 | 关键内容 |
|---|---|---|
surface3d.py | Python 模型 | 继承LayoutDOM,声明__implementation__与 Bokeh 属性 |
surface3d.ts | 浏览器实现 | LayoutDOMView子类渲染、connect_signals监听数据变更、模型属性注册 |
main.py | 应用逻辑 | 构造数据源、实例化扩展、add_periodic_callback驱动动画 |
templates/index.html | 应用模板 | 加载第三方库脚本、定制页面文案 |
写作自定义扩展时的通用要点(均有源码佐证):
- 基类选择决定能力边界:布局型用
LayoutDOM,工具型用Tool,字形型用Glyph(见 surface3d.py 注释); - Python 与 TypeScript 的类名必须一致,属性需两侧成对声明;
- 通过
connect_signals监听ColumnDataSource.change,即可在 Server 端任意更新数据而无需触碰 DOM; __implementation__支持内联代码与外部文件两种形态,.ts/.js/.css/.less均可作为实现文件(见 src/bokeh/util/compiler.py)。
借助这套机制,Bokeh 生态之外的 Web 可视化库(尤其是各类 3D、地图、富交互组件)都能被封装成 Python 友好的 Bokeh 模型,与 NumPy 计算、ColumnDataSource数据流和 Server 端回调无缝衔接——这正是 surface3d 示例希望传递的核心价值。
【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考