尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Bokeh 自定义控件扩展实战:用 TypeScript 为绘图添加双端滑块 IonRangeSlider

Bokeh 自定义控件扩展实战:用 TypeScript 为绘图添加双端滑块 IonRangeSlider Bokeh 自定义控件扩展实战用 TypeScript 为绘图添加双端滑块 IonRangeSlider【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh本指南以 Bokeh 官方示例examples/advanced/extensions/widget.py与ion_range_slider.ts为完整素材系统讲解如何通过「Python 模型 BokehJS TypeScript 视图」的扩展机制为绘图页面添加一个双端double-ended滑块控件。读完本文你将掌握 Bokeh 扩展控件的完整开发链路Python 侧属性定义、__implementation__内联实现、外部 JS/CSS 依赖加载以及 BokehJS 侧 View 的渲染与事件回调并能基于此思路开发自己的自定义控件。扩展示例要解决什么问题Bokeh 内置的Slider是单端滑块只能控制一个数值。而在实际交互场景中常常需要同时控制一个「区间」——例如本例中通过双端滑块控制折线图的 x 轴显示范围从 0.01 到 0.99 之间截取一段同时用一个普通 Bokeh Slider 控制曲线的幂次power实现「功率 区间」的联动缩放。widget.rst文档docs/bokeh/source/docs/user_guide/advanced/extensions/widget.rst展示的正是这一场景它把第三方的 ion.rangeSlider jQuery 插件封装成 Bokeh 模型让外部 JavaScript 库能以标准 Bokeh 属性/信号的方式参与数据联动。整个示例由两部分构成Python 脚本examples/advanced/extensions/widget.py —— 定义扩展模型并搭建应用TypeScript 实现examples/advanced/extensions/ion_range_slider.ts —— 负责浏览器端的渲染与交互。第一步在 Python 中定义扩展模型自定义控件与普通 Bokeh 模型一样是一个继承bokeh.models.widgets.input_widget.InputWidget的类。关键在于三个特殊类属性类属性作用__implementation__浏览器端实现的源码可以是内联的 JS/TS 字符串也可以是实现文件的名字本例为ion_range_slider.ts__javascript__需要从 CDN 额外加载的 JavaScript 依赖列表jQuery 与 ion.rangeSlider__css__需要加载的外部 CSS 列表在 TS 侧通过ImportedStyleSheet引入模型类的完整定义以下是widget.py中IonRangeSlider类的完整代码from bokeh.core.properties import Bool, Float, Tuple from bokeh.io import show from bokeh.layouts import column from bokeh.models import ColumnDataSource, CustomJS, InputWidget, Slider from bokeh.plotting import figure class IonRangeSlider(InputWidget): # The special class attribute __implementation__ should contain a string # of JavaScript or TypeScript code that implements the web browser # side of the custom extension model or a string name of a file with the implementation. __implementation__ ion_range_slider.ts __javascript__ [ https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js, https://cdnjs.cloudflare.com/ajax/libs/ion-rangeslider/2.1.4/js/ion.rangeSlider.js, ] # Below are all the properties for this model. Bokeh properties are # class attributes that define the fields (and their types) that can be # communicated automatically between Python and the browser. Properties # also support type validation. disable Bool(defaultTrue, help Enable or disable the slider. ) grid Bool(defaultTrue, help Show or hide the grid beneath the slider. ) start Float(default0, help The minimum allowable value. ) end Float(default1, help The maximum allowable value. ) range Tuple(Float, Float, help The start and end values for the range. ) step Float(default0.1, help The step between consecutive values. )代码注释中已经点明了扩展机制的核心__implementation__存放「浏览器端」的 JavaScript 或 TypeScript 代码字符串或指向实现文件的文件名而下面定义的每个 Bokeh property都会被自动在 Python 与浏览器之间同步并带类型校验。属性如何被序列化与校验这些属性之所以能「自动在 Python 与浏览器之间通信」依赖的是 Bokeh 底层属性系统。从 src/bokeh/core/has_props.py 可以看到HasProps是「具有声明式、类型化、可序列化属性」的所有对象的基类而 src/bokeh/models/widgets/inputs.py 中InputWidget基类自带title标签与description文本或富 HTML Tooltip两个属性因此自定义控件无需重复定义标题字段。值得一提的是widget.py中把disable的默认值设为Truedisabled而实际使用示例时滑块默认处于可用状态。从 src/bokeh/core/has_props.py 的模型注册逻辑看带有__implementation__或__javascript__、__css__的类会被_default_resolver识别为扩展模型clear_extensions()依据这三个属性判断并清理扩展这也从源码层面印证了扩展模型的判定标准。第二步用 TypeScript 实现浏览器端 View扩展控件需要「真正渲染东西」因此必须提供一个 View。TS 侧的思路是Python 里继承了哪个基类TS 里就继承对应的 View。本例中 Python 继承InputWidget所以 TS 侧同时导入并继承InputWidget与InputWidgetView// The core/properties module has all the property types import * as p from core/properties // HTML construction and manipulation functions import {div, input, StyleSheetLike, ImportedStyleSheet} from core/dom // We will subclass in JavaScript from the same class that was subclassed // from in Python import {InputWidget, InputWidgetView} from models/widgets/input_widget declare function jQuery(...args: any[]): any export type SliderData {from: number, to: number}加载外部样式ion.rangeSlider需要自己的 CSS因此 View 通过重写stylesheets()方法在父类样式的基础上追加两段远程样式表export class IonRangeSliderView extends InputWidgetView { declare model: IonRangeSlider override stylesheets(): StyleSheetLike[] { return [ ...super.stylesheets(), new ImportedStyleSheet(https://cdnjs.cloudflare.com/ajax/libs/ion-rangeslider/2.1.4/css/ion.rangeSlider.css), new ImportedStyleSheet(https://cdnjs.cloudflare.com/ajax/libs/ion-rangeslider/2.1.4/css/ion.rangeSlider.skinFlat.min.css), ] }这里体现了 BokehJS 渲染的两层依赖组织JavaScript 脚本依赖由 Python 侧的__javascript__声明在打包时注入样式依赖则由 View 的stylesheets()动态加载两者职责清晰。渲染输入控件InputWidgetView在 bokehjs/src/lib/models/widgets/input_widget.ts 中定义了标准的渲染流程render()会先构建标题title_el然后调用抽象的_render_input()获取输入元素最后拼装成input_group放入 shadow DOM。因此子类只需实现_render_input()并覆写render()private value_el?: HTMLInputElement protected _render_input(): HTMLElement { this.input_el input({type: text}) return div({style: {width: 100%}}, this.input_el) } override render(): void { // BokehJS Views create div elements by default, accessible as this.el. // Many Bokeh views ignore this default div, and instead do things // like draw to the HTML canvas. In this case though, we change the // contents of the div, based on the current slider value. super.render() if (this.model.title ! null) { this.value_el input({type: text, class: bk-input, readonly: true, style: {marginBottom: 5px}}) this.group_el.appendChild(this.value_el) } // Set up parameters const max this.model.end const min this.model.start const [from, to] this.model.range ?? [max, min] const opts { type: double, grid: this.model.grid, min, max, from, to, step: this.model.step ?? (max - min)/50, disable: this.model.disabled, onChange: (data: SliderData) this.slide(data), onFinish: (data: SliderData) this.slidestop(data), } jQuery(this.input_el).ionRangeSlider(opts) if (this.value_el ! null) this.value_el.value ${from} - ${to} }关键点逐一说明_render_input()返回一个包裹着input元素的div这会被挂到group_el上render()调用super.render()完成标题、描述等基础渲染后若模型有title再追加一个只读文本框用于实时显示「from - to」区间ion.rangeSlider 的双端模式由type: double开启min/max/from/to/step/grid/disable全部映射到 Python 侧定义的属性第三方库通过jQuery(this.input_el).ionRangeSlider(opts)初始化this.input_el正是_render_input()创建的那个 DOM 节点。把用户交互写回模型滑块拖动时ion.rangeSlider 触发onChange/onFinish回调。扩展 View 把最新的from/to值写回模型属性this.model.range这是触发 Python 侧js_on_change回调的关键一步slidestop(_data: SliderData): void { } slide({from, to}: SliderData): void { if (this.value_el ! null) this.value_el.value ${from} - ${to} this.model.range [from, to] }slide()既更新了只读文本框又把区间值赋给model.range。Bokeh 的信号机制会检测到属性变化从而触发用户在 Python 端绑定的js_on_change(range, ...)回调实现「浏览器控件 → 模型属性 → Python 侧逻辑」的闭环。模型类与属性声明TS 侧同样需要一个与 Python 类一一对应的模型类通过static初始化块完成 View 关联与属性定义export namespace IonRangeSlider { export type Attrs p.AttrsOfProps export type Props InputWidget.Props { range: p.Property[number, number] | null start: p.Propertynumber end: p.Propertynumber step: p.Propertynumber | null grid: p.Propertyboolean } } export interface IonRangeSlider extends IonRangeSlider.Attrs {} export class IonRangeSlider extends InputWidget { declare properties: IonRangeSlider.Props declare __view_type__: IonRangeSliderView constructor(attrs?: PartialIonRangeSlider.Attrs) { super(attrs) } static { // If there is an associated view, this is boilerplate. this.prototype.default_view IonRangeSliderView // The this.define block adds corresponding properties to the JS model. These // should basically line up 1-1 with the Python model class. Most property // types have counterparts, e.g. bokeh.core.properties.String will be // String in the JS implementation. Where the JS type system is not yet // as rich, you can use p.Any as a wildcard property type. this.defineIonRangeSlider.Props(({Bool, Float, Tuple, Nullable}) ({ range: [ Nullable(Tuple(Float, Float)), null ], start: [ Float, 0 ], end: [ Float, 1 ], step: [ Nullable(Float), 0.1 ], grid: [ Bool, true ], })) } }这里体现了扩展开发的一条铁律Python 与 TypeScript 的属性定义必须一一对应。p.Float对应Floatp.Tuple(Float, Float)对应Tuple(Float, Float)当 JS 类型系统没有完全对应的类型时可以使用p.Any作为通配符源码注释中的原话。注意range与step在 Python 侧默认值非空、在 TS 侧声明为Nullable并默认null通过??运算符在渲染时回退到合理默认值如step默认取(max - min)/50。第三步把扩展与绘图联动起来扩展模型定义好之后使用方式与内置控件完全一致。widget.py构建了一条折线y 随 x 变化并用两个回调分别驱动「功率」与「区间」x [x*0.005 for x in range(2, 198)] y x source ColumnDataSource(datadict(xx, yy)) plot figure(width400, height400) plot.line(x, y, sourcesource, line_width3, line_alpha0.6, color#ed5565) callback_single CustomJS(argsdict(sourcesource), code const f cb_obj.value const x source.data.x const y Array.from(x, (x) Math.pow(x, f)) source.data {x, y} ) callback_ion CustomJS(argsdict(sourcesource), code const {data} source const f cb_obj.range const pow (Math.log(data.y[100]) / Math.log(data.x[100])) const delta (f[1] - f[0]) / data.x.length const x Array.from(data.x, (x, i) delta*i f[0]) const y Array.from(x, (x) Math.pow(x, pow)) source.data {x, y} ) slider Slider(start0, end5, step0.1, value1, titleBokeh Slider - Power) slider.js_on_change(value, callback_single) ion_range_slider IonRangeSlider(start0.01, end0.99, step0.01, range(min(x), max(x)), titleIon Range Slider - Range) ion_range_slider.js_on_change(range, callback_ion) show(column(plot, slider, ion_range_slider))联动逻辑拆解功率滑块内置 Slidercallback_single读取cb_obj.value滑块的当前值f把 x 序列重算为x**f实现整条曲线的幂次变换区间滑块自定义 IonRangeSlidercallback_ion从cb_obj.range取出[from, to]二元组结合原数据的幂次关系pow log(y[100]) / log(x[100])反推当前曲线形状再把 x 均匀重采样到新区间[f[0], f[1]]实现 x 轴显示范围的缩放由于扩展控件是InputWidget子类它自然支持js_on_change并且两个回调都用CustomJS(argsdict(sourcesource), ...)操作共享的ColumnDataSource因此不需要 Bokeh Server 也能在纯静态 HTML 中完成全部交互。编译、依赖与运行机制编译链路扩展的 TS 代码并非手工编译进页面而是由 Bokeh 在构建文档或输出 HTML 时自动处理。Python 侧的编译入口是 src/bokeh/util/compiler.pynodejs_compile()会把源码与bokehjs/js/compiler.js交给 Node.js 编译该文件要求 Node.js ≥ 18.0.0见源码第 101 行并通过Inline、JavaScript、TypeScript、Less等实现类支持内联字符串或文件路径两种写法——本例的__implementation__ ion_range_slider.ts属于「文件名」形式。依赖注入__javascript__中声明的 jQuery 与 ion.rangeSlider 会在打包阶段被合并进扩展的 JS bundle保证浏览器端执行jQuery(...)时依赖已经就绪src/bokeh/embed/bundle.py 在处理模型收集时同样会把带__implementation__的扩展类排除在标准模型之外单独走扩展打包路径避免与内置模型冲突。运行示例在安装好 Bokeh 的 Python 环境中直接运行python examples/advanced/extensions/widget.pyBokeh 会自动打开浏览器页面中从上到下依次是折线图、Bokeh Slider控制功率、IonRangeSlider控制 x 区间。由于该示例依赖 CDN 上的 jQuery 与 ion.rangeSlider运行环境需要能够访问外网。注意在 tests/examples.yaml 中examples/advanced/extensions/*的测试配置明确skip: [widget.py]——这通常是因为示例依赖外部 CDN 资源、不适合在离线 CI 环境中运行并不代表示例本身有问题。自定义控件开发要点小结对照本示例与源码可以总结出开发 Bokeh 自定义控件widget extension的通用步骤Python 侧继承InputWidget或更合适的控件基类用 Bokeh properties 声明全部可同步属性并设置__implementation__内联代码或文件名与__javascript__外部脚本BokehJS 侧同时导出两个类——继承对应*View的 View 类重写stylesheets()、_render_input()、render()等钩子并在交互回调中写回this.model以及继承对应*的模型类static块中设置default_view并用this.define声明与 Python 一一对应的属性接线像使用内置控件一样实例化扩展模型通过js_on_change或 Server 回调把属性变化接入业务逻辑类型不匹配时Python 与 TS 属性类型保持严格对应必要时用Nullable与??处理可选值或使用p.Any兜底。这套「Python 声明式模型 BokehJS View」的扩展架构正是 Bokeh 把任意第三方前端库如 ion.rangeSlider、d3 等接入数据可视化生态的标准途径掌握它之后无论是双端滑块、日期区间选择器还是复杂的自定义渲染控件都可以用同样的模式实现。【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表