Bokeh 交互式可视化完整教程
循序渐进,从问题出发,掌握Python交互式可视化
学习路径建议
初学者路径
- 第一章:快速入门 → 了解 Bokeh 基础
- 第二章:核心概念 → 掌握 ColumnDataSource 和 Figure
- 第三章:基础图表类型 → 学会常用图表
- 第五章:布局系统 → 组合多个图表
- 第六章:交互功能 → 添加交互性
进阶者路径
- 第四章:数据管理高级技巧 → 高级数据操作
- 第七章:Bokeh Server → 构建应用
- 第八章:输出选项 → 导出与嵌入
- 第九章:性能优化 → 大数据可视化
- 第十章:实战案例 → 综合应用
特定需求路径
- 论文配图:第一章 → 第三章 → 第八章
- 数据探索:第一章 → 第二章 → 第六章 → 第十章
- Web 应用:第一章 → 第七章 → 第八章
- 大数据可视化:第四章 → 第九章
关于本教程
本教程基于 Bokeh 3.x 版本编写,旨在为初学者和有经验的使用者提供一份精确、简练、清晰的教程。通过循序渐进的方式,从实际问题出发,帮助读者掌握 Python 交互式可视化的核心技能。
第一章:快速入门
本章目标:用一个最小但完整的例子跑通 Bokeh,理解它适合解决什么问题,并知道下一步该学习哪些概念。
延伸阅读:本章只介绍 Bokeh 的基本用法。
ColumnDataSource的深入讲解见第二章 2.2;Figure与Glyph的关系见第二章 2.3;三层接口的详细说明见第二章 2.4;输出选项的完整讨论见第八章;如果你想先建立对不同运行方式的整体认识,也可以先浏览第八章 8.1和第七章 7.1。
1.1 从一个需求开始
假设你有一组销售数据,想要展示每天的销售额变化。用 Matplotlib 可以很快画出一张静态图:
import matplotlib.pyplot as plt
days = [1, 2, 3, 4, 5]
sales = [100, 150, 120, 180, 200]
plt.plot(days, sales)
plt.savefig("sales.png")
这类图片很适合放进论文、报告或幻灯片。但如果你的需求变成下面这样,静态图片就不够方便了:
- 想在浏览器里缩放和平移,查看局部细节
- 想把鼠标悬停在点上,显示具体数值
- 想生成一个可以直接分享的 HTML 文件
- 想把图表嵌入现有 Web 页面或数据应用
- 想后续扩展为可筛选、可联动、可实时更新的仪表板
这就是 Bokeh 的典型使用场景:
用 Python 构建原生 Web 交互图表,并输出到浏览器、HTML 文件、Notebook 或 Web 应用中。
需要注意的是,Matplotlib 也有交互式后端,并不是“完全不能交互”。Bokeh 的优势在于:它以浏览器和 Web 交互为核心设计,生成的是由 BokehJS 渲染的交互式图表,更适合分享、嵌入和构建数据应用。
1.2 安装与环境
系统要求
本教程面向 Bokeh 3.x 编写。建议使用:
- Python 3.10 或更高版本
- 推荐 Python 3.11+,以获得更好的生态兼容性和运行性能
- 一个现代浏览器,例如 Chrome、Firefox、Edge 或 Safari
基础安装
如果你使用 pip:
pip install bokeh
验证安装:
python -c "import bokeh; print(bokeh.__version__)"
如果你使用 conda:
conda install bokeh
可选依赖
Bokeh 的基础绘图不需要安装额外依赖。但有些功能需要额外包,例如导出 PNG/SVG、使用 Notebook 小组件或处理更复杂的应用场景。
常见选择:
# 安装 Bokeh 的常用可选依赖
pip install "bokeh[all]"
# 如果你需要在 Jupyter 中使用更完整的 Bokeh 小组件支持
pip install jupyter_bokeh
提示:如果你只是想在 Notebook 中显示普通 Bokeh 图表,通常先从
output_notebook()开始即可;如果遇到 JupyterLab 显示或小组件相关问题,再安装jupyter_bokeh。
1.3 第一个 Bokeh 图表
下面用同一组销售数据生成一个交互式 HTML 图表:
from bokeh.plotting import figure, output_file, show
days = [1, 2, 3, 4, 5]
sales = [100, 150, 120, 180, 200]
output_file("sales.html", title="销售趋势")
p = figure(
title="销售趋势",
x_axis_label="日期",
y_axis_label="销售额",
width=700,
height=400,
tools="pan,wheel_zoom,box_zoom,reset,save",
active_scroll="wheel_zoom",
)
p.line(days, sales, line_width=2, legend_label="销售额")
p.scatter(days, sales, size=8, legend_label="销售额")
show(p)
运行后,Bokeh 会生成 sales.html,并在浏览器中打开它。你可以:
- 使用滚轮缩放图表
- 拖拽平移查看不同区域
- 使用框选缩放查看局部范围
- 点击重置按钮恢复初始视图
- 点击保存按钮下载当前图表为图片
这段代码可以拆成三步理解:
output_file("sales.html"):指定输出目标是一个 HTML 文件figure(...):创建一张图,并配置标题、坐标轴、尺寸和工具栏p.line(...)、p.scatter(...):向图中添加具体图形show(p):保存并打开图表
这就是 Bokeh 最常见的工作流:
准备数据 → 创建 Figure → 添加 Glyph → 输出或展示图表。
其中 Figure 是整张图的容器,line 和 scatter 添加的是具体图形元素,也就是 Bokeh 中常说的 Glyph。这些概念会在第二章展开。
1.4 加上悬停提示
交互图表最常见的需求之一是:鼠标移动到数据点上时显示更多信息。
这时推荐使用 ColumnDataSource。它是 Bokeh 中最重要的数据结构之一,可以把数据组织成“按列存储的表”。
from bokeh.models import ColumnDataSource, HoverTool
from bokeh.plotting import figure, output_file, show
source = ColumnDataSource(data={
"day": [1, 2, 3, 4, 5],
"sales": [100, 150, 120, 180, 200],
"note": ["周一", "周二", "周三", "周四", "周五"],
})
output_file("sales_with_hover.html", title="销售趋势")
p = figure(
title="销售趋势",
x_axis_label="日期",
y_axis_label="销售额",
width=700,
height=400,
tools="pan,wheel_zoom,box_zoom,reset,save",
active_scroll="wheel_zoom",
)
p.line("day", "sales", source=source, line_width=2)
p.scatter("day", "sales", source=source, size=8)
hover = HoverTool(tooltips=[
("日期", "@day"),
("销售额", "@sales"),
("说明", "@note"),
])
p.add_tools(hover)
show(p)
注意这段代码中的两个变化:
- 图形方法里不再直接写列表,而是写列名,例如
"day"和"sales" HoverTool通过@day、@sales、@note读取同一行中的字段
如果你后续要做筛选、联动、回调或实时更新,ColumnDataSource 会变得非常重要。现在只需要先记住一句话:
简单图表可以直接传列表;一旦涉及交互和多字段数据,优先使用
ColumnDataSource。
1.5 在 Jupyter 中使用
如果你在 Jupyter Notebook 或 JupyterLab 中学习 Bokeh,可以把输出目标切换到 Notebook:
from bokeh.io import output_notebook, show
from bokeh.plotting import figure
output_notebook()
p = figure(title="Notebook 中的 Bokeh 图表", width=600, height=350)
p.line([1, 2, 3, 4, 5], [100, 150, 120, 180, 200], line_width=2)
p.scatter([1, 2, 3, 4, 5], [100, 150, 120, 180, 200], size=8)
show(p)
常见输出方式如下:
| 输出方式 | 适用场景 | 特点 |
|---|---|---|
output_notebook() | Jupyter 开发和教学 | 图表直接显示在 Notebook 单元格中 |
output_file() | 生成独立 HTML 文件 | 适合本地查看、分享和归档 |
save() | 程序化保存 | 只保存文件,不自动打开浏览器 |
show() | 快速预览 | 根据当前输出目标显示图表 |
components() | 嵌入 Web 页面 | 返回可嵌入网页的 HTML/JavaScript 片段 |
更完整的输出方式会在第八章介绍。
1.6 先建立一个概念:Bokeh 的四种运行模式
很多初学者一开始最容易混淆的,不是 figure() 或 HoverTool,而是:
同样是 Bokeh 代码,为什么有的可以直接生成 HTML,有的必须用
bokeh serve运行?
你可以先把 Bokeh 的使用方式分成四类:
| 运行模式 | 典型入口 | 适用场景 | 关键特点 |
|---|---|---|---|
| standalone HTML | output_file() / save() / show() | 分享单个交互图表 | 生成独立 HTML,交互主要在浏览器端完成 |
| Notebook | output_notebook() | 教学、探索分析 | 图表直接显示在 Notebook 单元格中 |
| Bokeh Server | bokeh serve app.py --show | Python 回调、实时更新、状态同步 | 浏览器和 Python 进程持续通信 |
| embed 到现有 Web 页面 | components()、file_html()、server_document() | Flask、Django、已有前端页面 | 适合把 Bokeh 接入更大的 Web 系统 |
初学阶段你最常用的是前两种:
- standalone HTML
- Notebook
它们的共同点是:
你写 Python 代码生成图表对象,Bokeh 再把这些对象转换成浏览器可以理解的内容,由 BokehJS 在前端渲染和交互。
而 Bokeh Server 的不同点在于:
- 浏览器中的交互事件会继续传回 Python
- Python 回调函数可以重新计算数据并推回前端
- 因此它更适合筛选器、实时刷新、复杂业务逻辑
你现在可以先记住一个简单判断:
- 只想生成交互图表文件 → 先用 standalone HTML
- 在 Notebook 里学习和试验 → 用 Notebook
- 需要 Python 回调或实时更新 → 用 Bokeh Server
- 需要嵌入现有网站 → 学第八章的 embed 方式
这一点非常重要,因为它会直接影响后面章节中代码应该如何运行:
- 第六章的
CustomJS示例很多可以直接用于 standalone HTML - 第七章的 Python 回调示例必须通过
bokeh serve运行 - 第八章会系统讲解 standalone、Notebook、embed 等输出方式的差异
1.7 Bokeh 适合什么,不适合什么
Bokeh 不是要替代所有可视化工具。不同库有不同的优势。
| 需求场景 | 推荐工具 | 理由 |
|---|---|---|
| 论文配图、静态报告 | Matplotlib | 出版质量高,控制细节丰富 |
| 快速统计图表 | Seaborn | 默认样式美观,适合统计分析 |
| 交互式 Web 图表 | Bokeh | 原生浏览器交互,输出方式灵活 |
| 仪表板和数据应用 | Bokeh Server | 支持 Python 回调、状态同步和实时更新 |
| 快速探索性分析 | Plotly Express | API 简洁,上手快 |
Bokeh 的主要优势包括:
- 使用 Python 编写图表逻辑
- 输出 HTML,便于分享和嵌入
- 浏览器端由 BokehJS 渲染,交互体验自然
- 与 NumPy、Pandas 等 Python 数据生态配合良好
- 既能画单张图,也能扩展为完整的 Bokeh Server 应用
- 支持多种输出方式,包括 HTML、Notebook、Web 组件和图片导出
如果你的目标是“一张精致的静态出版图”,Matplotlib 可能更合适;如果你的目标是“可以在浏览器中探索的数据图表”,Bokeh 就非常适合。
1.8 Bokeh 的三层接口
Bokeh 提供多个层次的接口。快速入门阶段不需要全部掌握,但你应该知道它们各自解决什么问题。
第一层:bokeh.plotting
这是最常用的高级绘图接口,适合大部分日常图表。
from bokeh.plotting import figure, show
p = figure(title="plotting 接口示例")
p.line([1, 2, 3], [4, 5, 6], line_width=2)
p.scatter([1, 2, 3], [4, 5, 6], size=8)
show(p)
你在本章前面使用的 figure()、p.line()、p.scatter() 都属于这一层。
第二层:bokeh.models
这一层提供更细粒度的模型对象,例如数据源、工具、坐标轴、范围、颜色映射、回调等。你通常会把它和 bokeh.plotting 一起使用。
from bokeh.models import ColumnDataSource, HoverTool
from bokeh.plotting import figure, show
source = ColumnDataSource(data={
"x": [1, 2, 3],
"y": [4, 5, 6],
"label": ["A", "B", "C"],
})
p = figure(title="models 接口示例")
p.scatter("x", "y", source=source, size=12)
p.add_tools(HoverTool(tooltips=[
("标签", "@label"),
("x", "@x"),
("y", "@y"),
]))
show(p)
这类写法会在交互、联动和数据更新中频繁出现。
第三层:bokeh.io、bokeh.embed 和相关输出接口
这一层主要控制图表如何输出、保存或嵌入。
from bokeh.embed import components
from bokeh.plotting import figure
p = figure(title="嵌入示例")
p.line([1, 2, 3], [4, 5, 6])
script, div = components(p)
components(p) 会返回一段 <script> 和一个 <div>,方便你把 Bokeh 图表嵌入到 Flask、Django 或其他 Web 页面中。
选择建议:
- 日常画图:优先使用
bokeh.plotting - 需要悬停、筛选、联动、回调:结合使用
bokeh.models - 需要控制 HTML、Notebook、导出或嵌入:学习
bokeh.io和bokeh.embed - 需要实时应用和服务端状态:学习 Bokeh Server
三层接口的更完整说明见第二章 2.4。
1.9 本章小结
本章你已经完成了 Bokeh 的第一轮入门:
- 知道 Bokeh 的核心定位是 Python 驱动的 Web 交互可视化
- 安装并验证了 Bokeh 环境
- 用
figure()创建了第一张交互式图表 - 使用
output_file()和show()生成并打开 HTML 文件 - 初步接触了
ColumnDataSource和HoverTool - 了解了 Bokeh 与 Matplotlib、Seaborn、Plotly 等工具的差异
- 知道了 Bokeh 的常用接口层次
接下来建议阅读第二章:核心概念。理解 ColumnDataSource、Figure、Glyph 和 Document 之后,你会更容易掌握后续的交互、布局和 Bokeh Server。
1.10 常见坑
坑 1:把 standalone HTML 和 Bokeh Server 混为一谈
这是初学者最常见的问题。
如果你写的是:
output_file()/save()/show()output_notebook()
那么你通常得到的是 standalone 文档。这类图表可以有缩放、平移、悬停等浏览器端交互,但 Python 回调不会继续运行。
如果你写的是:
curdoc()on_change()add_periodic_callback()
那么通常说明你已经进入 Bokeh Server 场景,需要使用 bokeh serve 运行。关于这一点,见第七章。
坑 2:一开始就忽略 ColumnDataSource
简单示例里直接传列表没有问题,但只要你需要下面这些能力:
- 悬停显示更多字段
- 多个图共享数据
- 做选择联动
- 做数据更新
就应该尽早切换到 ColumnDataSource。它会在第二章详细展开。
坑 3:以为 show() 就等于“部署完成”
show() 更像是“快速预览”:
- 在脚本里,通常会打开浏览器
- 在 Notebook 里,通常会直接内联显示
但这不等于你已经掌握了分享、嵌入、导出、集成到 Web 框架中的方法。相关内容请继续看第八章。
坑 4:还没区分输出方式,就急着查交互为什么不工作
如果你发现“图显示出来了,但某些回调没反应”,先不要急着怀疑 API。
先问自己两个问题:
- 我现在运行的是 standalone / Notebook,还是 Bokeh Server?
- 我使用的是前端交互(例如
HoverTool、CustomJS),还是 Python 回调?
很多问题其实不是代码错了,而是运行方式和代码模型不匹配。
下一步建议
建议按下面顺序继续:
- 阅读第二章:核心概念,重点理解
ColumnDataSource - 阅读第八章:输出选项,建立更完整的运行模式认识
- 准备学习 Python 回调前,再阅读第七章:Bokeh Server 进阶
第二章:核心概念
前置知识:建议你先完成第一章。
延伸阅读:
本章只回答四个问题:
- Bokeh 为什么强调
ColumnDataSource Figure、Glyph、Document分别是什么- Python 代码为什么能在浏览器里变成交互图表
- 选择、悬停、更新这些交互是怎么工作的
2.1 一张图是怎么组成的?
先看最常见的一段代码:
from bokeh.plotting import figure, show
p = figure(title="示例")
p.circle([1, 2, 3], [4, 5, 6], size=12)
show(p)
这段代码背后其实包含了几层对象:
Figure:整张图的容器Glyph:具体怎么画,比如圆、线、柱子DataSource:图表使用的数据Document:把整套对象组织起来,交给 Bokeh 输出或同步BokehJS:浏览器端负责渲染和交互的引擎
你可以先把它记成一句话:
Bokeh 会把 Python 中的图表对象组织成一个文档,再交给浏览器中的 BokehJS 渲染。
2.2 为什么推荐 ColumnDataSource
2.2.1 直接传数组可以画图,但不适合复杂交互
最简单的写法是直接传列表:
from bokeh.plotting import figure, show
p = figure()
p.circle([1, 2, 3], [10, 20, 30], size=10)
show(p)
这样写很适合快速试验,但一旦你想做下面这些事,就会开始不方便:
- 悬停时显示更多字段
- 用某一列控制颜色或大小
- 多个图共享同一份数据
- 在 Bokeh Server 中动态更新数据
- 做选择联动
2.2.2 ColumnDataSource 是 Bokeh 的标准数据入口
from bokeh.models import ColumnDataSource, HoverTool
from bokeh.plotting import figure, show
source = ColumnDataSource(data={
"product": ["A", "B", "C"],
"sales": [100, 200, 150],
"price": [10, 20, 15],
})
p = figure(title="销量与价格")
p.circle("sales", "price", source=source, size=14)
p.add_tools(HoverTool(tooltips=[
("产品", "@product"),
("销量", "@sales"),
("价格", "@price"),
]))
show(p)
这里的关键变化是:
- 图形不再写死列表,而是写列名:
"sales"、"price" HoverTool可以通过@product、@sales读取同一行数据- 同一个
source可以被多个图、多个工具、多个回调共享
2.2.3 把它理解成“按列存储的表”
ColumnDataSource 的核心结构就是一个字典:
source.data = {
"x": [1, 2, 3],
"y": [4, 5, 6],
"label": ["A", "B", "C"],
}
你可以把它当成一张表:
| x | y | label |
|---|---|---|
| 1 | 4 | A |
| 2 | 5 | B |
| 3 | 6 | C |
重要约束:每一列长度必须一致。
因为第 i 行的数据会被当成同一个数据点的完整属性。
2.3 ColumnDataSource 的常见操作
在继续往下看之前,先记住一个经验:
只要图表需要悬停、多图联动、筛选、回调或增量更新,就尽量尽早切到
ColumnDataSource。
因为后面第四章:数据转换与处理里的过滤器、映射和视图,和第六章:交互功能场景实战里的联动、回调、刷选,几乎都默认你已经把数据放进了 ColumnDataSource。
2.3.1 创建方式
from bokeh.models import ColumnDataSource
import pandas as pd
# 方式 1:直接传字典
source = ColumnDataSource(data={
"x": [1, 2, 3],
"y": [4, 5, 6],
})
# 方式 2:从 DataFrame 创建
df = pd.DataFrame({
"x": [1, 2, 3],
"y": [4, 5, 6],
})
source = ColumnDataSource(df)
2.3.2 读取和整体替换
print(source.data)
print(source.data["x"])
source.data = {
"x": [10, 20],
"y": [30, 40],
}
当你执行整体替换时,Bokeh 会把这视为一次完整的数据更新。
2.3.3 增量更新:stream() 和 patch()
# 追加新行
source.stream({
"x": [4],
"y": [7],
})
# 局部修改
source.patch({
"y": [(0, 100)],
})
适用场景:
stream():实时追加数据,例如传感器、日志、时间序列patch():只改局部值,减少更新量
2.3.4 选择状态
selected = source.selected.indices
print(selected)
source.selected.indices = [1, 3]
source.selected.indices = []
这套选择状态是 Bokeh 联动机制的重要基础。
多个图如果共享同一个 ColumnDataSource,选择状态也会共享。
2.3.5 ColumnDataSource 的常见坑
这是初学者最容易卡住的一组问题。很多“图不显示”“悬停取不到值”“回调没反应”的根源,其实都在这里。
坑 1:各列长度不一致
这是最常见的问题。
source = ColumnDataSource(data={
"x": [1, 2, 3],
"y": [4, 5], # 错误:长度不一致
})
为什么不行:
ColumnDataSource本质上是“按列存储的一张表”- 第
i行的数据会被当成一个完整数据点 - 所以每一列都必须能对齐到同样的行数
排查方式:
- 打印
source.data - 分别检查每一列长度
- 一旦你从 DataFrame、字典、回调中动态拼装数据,这一步尤其要做
坑 2:字段名写错,但代码表面上看不出来
source = ColumnDataSource(data={
"sales": [100, 200, 150],
"price": [10, 20, 15],
})
p.circle("sale", "price", source=source) # "sale" 拼错了
这里最难受的地方在于:
- Python 端代码可能看起来没什么问题
- 但浏览器里图形不会按预期显示
HoverTool、颜色映射、回调里也可能因为列名拼错而失效
建议:
- 先打印
source.column_names - 在 tooltip、glyph 字段、transform 字段、回调代码里统一复用同一套列名
- 字段一多时,优先把列名集中定义,减少手写字符串的次数
坑 3:在 DataFrame 和 ColumnDataSource 之间切换时忽略了索引
import pandas as pd
from bokeh.models import ColumnDataSource
df = pd.DataFrame({
"x": [1, 2, 3],
"y": [4, 5, 6],
})
source = ColumnDataSource(df)
这类写法通常很方便,但你要注意:
- DataFrame 的索引可能会被带进数据源
- 分组、聚合、重置索引之后,列名可能和你预期的不一样
- 时间索引、分层索引在转换后尤其容易让字段名变复杂
经验做法:
- 转成
ColumnDataSource前先看一眼df.columns - 必要时先
reset_index() - 在图里真正引用字段前,先检查
source.column_names
如果你后面要做分组聚合和过滤,建议同时阅读第四章。
坑 4:直接改 source.data["x"],却没有触发前端更新
在 Python 端,最稳妥的方式通常是整体替换:
source.data = {
"x": [10, 20, 30],
"y": [40, 50, 60],
}
在浏览器端 CustomJS 回调里,如果你是直接修改已有数组,通常还需要手动通知:
source.data["x"][0] = 999
source.change.emit()
可以这样理解:
- Python 端:整体赋值最清晰,Bokeh 也最容易判断发生了更新
- 浏览器端
CustomJS:如果你直接改内部数组,经常要调用source.change.emit()
这一点和第六章:交互功能场景实战里的 CustomJS 回调是连在一起的。
坑 5:把 ColumnDataSource 当成通用数据处理工具
ColumnDataSource 非常适合:
- 给 glyph 提供数据
- 给 HoverTool、选择联动、回调共享数据
- 做
stream()/patch()这类前后端同步友好的更新
但它不适合替代:
- pandas 的数据清洗
- numpy 的数值计算
- 复杂分组聚合
- 复杂业务逻辑加工
更稳妥的流程通常是:
- 先用 pandas / numpy 把数据处理好
- 再转换为
ColumnDataSource - 最后在 Bokeh 中做展示、映射和交互
一句话总结:
ColumnDataSource是可视化层的数据容器,不是完整的数据分析层。
2.4 Figure:图表的画布
Figure 是你最常接触的对象。它负责承载:
- 坐标轴
- 网格
- 标题
- 工具栏
- 图形渲染器
- 范围设置
2.4.1 创建 Figure
from bokeh.plotting import figure
p = figure(
title="销售趋势",
width=700,
height=400,
x_axis_label="月份",
y_axis_label="销售额",
tools="pan,wheel_zoom,box_zoom,reset,save",
)
2.4.2 范围控制
from bokeh.models import Range1d, DataRange1d
from bokeh.plotting import figure
# 固定范围
p = figure(x_range=Range1d(0, 10), y_range=Range1d(0, 100))
# 自动范围
p = figure(x_range=DataRange1d(), y_range=DataRange1d())
经验上可以这样选:
- 范围固定:适合你明确知道边界的图
- 自动范围:适合探索性分析或数据经常变化的图
2.4.3 Figure 和 Plot
在日常使用里,你几乎总是通过 figure() 创建图表。
它返回的是一个配置好的绘图对象,适合绝大多数场景。
你可以把 Figure 理解为:
一个“带常用配置的 Plot”,让你更方便地添加图形和交互。
2.5 Glyph:真正被画出来的东西
Glyph 表示一种图形语义,比如:
circlelinevbarpatchtext
例如:
from bokeh.plotting import figure, show
p = figure()
p.line([1, 2, 3, 4], [2, 5, 3, 6], line_width=2)
p.circle([1, 2, 3, 4], [2, 5, 3, 6], size=10)
show(p)
这段代码里其实创建了两个渲染层:
- 一层画线
- 一层画点
2.5.1 常见 Glyph 类型
| 类型 | 常见方法 | 用途 |
|---|---|---|
| 点 | circle, square, triangle | 散点、标记 |
| 线 | line, step, multi_line | 趋势、轨迹 |
| 柱 | vbar, hbar, quad | 分类比较 |
| 面 | patch, varea, harea | 区域表达 |
| 文本 | text | 标注 |
| 图像 | image, image_rgba | 栅格图像 |
2.5.2 Glyph 和 Renderer 的关系
当你写:
renderer = p.circle("x", "y", source=source, size=12)
返回值其实不是单纯的圆,而是一个 GlyphRenderer。
它把三件事绑定在一起:
- 用哪份数据:
data_source - 用哪种图形:
glyph - 用什么状态样式:选中、未选中、悬停
你可以把它理解为:
Renderer = 数据 + 图形定义 + 渲染状态
2.6 视觉属性:图是怎么“长成这样”的
Bokeh 的视觉属性大体可以分为三类:
2.6.1 填充属性
p.circle(
"x", "y", source=source,
fill_color="steelblue",
fill_alpha=0.6,
)
常见属性:
fill_colorfill_alpha
2.6.2 线条属性
p.circle(
"x", "y", source=source,
line_color="black",
line_width=2,
line_alpha=0.8,
line_dash="dashed",
)
常见属性:
line_colorline_widthline_alphaline_dash
2.6.3 文本属性
p.text(
"x", "y", text="label", source=source,
text_font_size="12pt",
text_color="black",
text_align="center",
)
常见属性:
text_fonttext_font_sizetext_colortext_aligntext_baseline
2.6.4 颜色的常见写法
fill_color="red"
fill_color="#FF5733"
fill_color=(255, 87, 51)
fill_color="color_column"
其中 "color_column" 很重要,它表示颜色来自数据列,而不是固定值。
2.7 选择、未选择与悬停状态
Bokeh 的交互并不只是“能点一下”,而是内建了不同状态的视觉反馈。
renderer = p.circle(
"x", "y", source=source, size=15,
fill_color="blue",
selection_fill_color="red",
nonselection_fill_color="gray",
nonselection_fill_alpha=0.2,
)
也可以更明确地指定不同状态的图形:
from bokeh.models import Circle
renderer.selection_glyph = Circle(fill_color="red", size=18)
renderer.nonselection_glyph = Circle(fill_color="lightgray", fill_alpha=0.2)
renderer.hover_glyph = Circle(fill_color="orange", size=18)
这一机制非常适合做:
- 多图联动
- 高亮选中点
- 弱化未选中数据
- 配合
HoverTool做分析
2.8 Document:把所有对象组织起来
2.8.1 什么是 Document
Document 是 Bokeh 的顶层容器。
一个文档里可以包含:
- 一个或多个图表
- 布局容器
- 控件
- 数据源
- 回调逻辑
from bokeh.document import Document
from bokeh.plotting import figure
doc = Document()
p = figure()
p.circle([1, 2, 3], [4, 5, 6])
doc.add_root(p)
2.8.2 为什么需要它
Document 的作用可以概括为三点:
- 组织对象关系:哪些图、哪些控件属于同一个页面
- 支持序列化:把 Python 对象转换成浏览器可理解的数据结构
- 支持同步:在 Bokeh Server 模式下,把变更推给前端
你可以把它理解成:
Document不是“图的一部分”,而是“整页 Bokeh 应用的容器”。
2.9 Bokeh 的三层使用方式
从日常开发角度,Bokeh 可以分成三层:
2.9.1 bokeh.plotting:最常用
这是你最常写的一层:
from bokeh.plotting import figure, show
p = figure()
p.line([1, 2, 3], [4, 5, 6])
show(p)
特点:
- 上手快
- 代码短
- 适合大多数常规图表
2.9.2 bokeh.models:需要精细控制时使用
from bokeh.models import Range1d, HoverTool
from bokeh.plotting import figure
p = figure(x_range=Range1d(0, 100))
p.add_tools(HoverTool(tooltips=[("x", "@x"), ("y", "@y")]))
适合:
- 精细配置轴、范围、工具、标注
- 控制数据源、选择状态、过滤器
- 处理复杂布局或交互
2.9.3 bokeh.server:需要实时交互时使用
运行方式:下面这个例子属于 Bokeh Server 应用。请将代码保存为
app.py后使用bokeh serve app.py --show运行。直接执行python app.py或用show()导出静态 HTML 时,Python 回调不会生效。更完整的运行模型、部署方式和会话机制,见第七章:Bokeh Server 进阶。
from bokeh.io import curdoc
from bokeh.layouts import column
from bokeh.models import ColumnDataSource, Slider
from bokeh.plotting import figure
source = ColumnDataSource(data=dict(x=[1, 2, 3], y=[1, 4, 9]))
p = figure(title="动态示例")
p.line("x", "y", source=source, line_width=2)
slider = Slider(start=1, end=5, value=2, step=0.1, title="指数")
def update(attr, old, new):
exponent = slider.value
x = list(range(1, 20))
y = [v ** exponent for v in x]
source.data = dict(x=x, y=y)
slider.on_change("value", update)
curdoc().add_root(column(p, slider))
适合:
- 控件驱动数据更新
- 周期性推送新数据
- 多用户会话
- 真正的 Python 端回调
2.10 本章小结
这一章最重要的收获,不是记住了多少 API,而是建立了 Bokeh 的对象模型:
Figure是图表容器Glyph决定“画什么”ColumnDataSource决定“数据从哪里来”Document负责把整套对象组织起来并交给前端BokehJS在浏览器里完成渲染和交互
如果你觉得后面的交互、布局、Server 一下子变复杂了,可以先回到这一章重新确认一句话:
Bokeh 的很多高级能力,本质上都是“数据源 + 图形对象 + 文档同步”的组合。
本章和后续章节的关系
- 如果你接下来最想学“该画什么图”,读第三章:基础图表类型
- 如果你接下来最想学“怎么做颜色映射、过滤、分组和变换”,读第四章:数据转换与处理
- 如果你最关心“悬停、选择、回调、联动”,读第六章:交互功能场景实战
- 如果你最关心“为什么 Python 回调只有在服务里才生效”,读第七章:Bokeh Server 进阶
2.11 常见坑
坑 1:把 ColumnDataSource 当成“可有可无”的包装层
在简单示例里,直接传列表当然能画图。
但一旦进入:
HoverTool- 多字段 tooltip
- 颜色/大小映射
- 多图联动
CustomJS- Bokeh Server 回调
你迟早还是要回到 ColumnDataSource。
所以更实际的学习策略是:尽早习惯用它来组织数据。
坑 2:混淆“静态 HTML 交互”和“Python 回调交互”
这是 Bokeh 初学者最容易误解的一点:
show()/save()输出的 standalone HTML,可以有浏览器端交互- 但 Python 回调不会在静态 HTML 里自动运行
- 真正的 Python 回调需要
bokeh serve
如果你发现“控件能显示,但 Python 函数没执行”,优先回看本章 2.9.3 和第七章。
坑 3:只记 API,不建立对象关系
如果只记“散点图用什么函数、悬停怎么写、滑块怎么绑”,后面很容易越学越碎。
更有效的方式是始终把每个问题放回这几个核心对象里理解:
- 数据在
ColumnDataSource - 图形在 glyph / renderer
- 页面状态在
Document - 浏览器端由
BokehJS接手
这样你后面读布局、交互、输出、Server,会顺很多。
2.9.4 如何选择
可以直接按下面的原则判断:
- 先用
bokeh.plotting - 发现不够用时,再引入
bokeh.models - 需要 Python 实时回调时,再用
bokeh.server
对大多数教程代码来说,plotting + models 已经够用了。
2.10 从 Python 到浏览器:Bokeh 是怎么工作的
这是本章最重要的理解之一。
2.10.1 整体流程
Python 代码
↓
创建 Bokeh 模型对象
↓
组装成 Document
↓
序列化为 JSON
↓
交给浏览器中的 BokehJS
↓
BokehJS 创建对应视图并渲染到 Canvas
也就是说,浏览器并不直接运行你的 Python 绘图代码。
浏览器拿到的是 Bokeh 模型的描述数据,真正负责显示的是 BokehJS。
2.10.2 BokehJS 是什么
BokehJS 是运行在浏览器中的前端渲染引擎。它负责:
- 解析模型数据
- 创建前端视图对象
- 在 Canvas 上绘制图形
- 处理鼠标、键盘、触摸等事件
- 根据变更重绘图表
你可以把前后端关系理解为:
- Python 端:定义“图表是什么”
- BokehJS:负责“把它画出来并响应交互”
2.11 渲染与更新的最小心智模型
你不需要记住全部内部细节,但最好记住这条链路:
2.11.1 首次渲染
Figure / Glyph / DataSource
↓
Document
↓
JSON
↓
BokehJS
↓
Canvas 渲染
2.11.2 用户交互
例如悬停、框选、缩放:
- 浏览器端工具先响应
- 必要时更新前端状态
- 如果是 Server 应用,变更还可以同步回 Python
2.11.3 数据更新
如果你修改:
source.data = {
"x": [1, 2, 3],
"y": [2, 4, 8],
}
那么 Bokeh 会认为:
- 数据源发生变化
- 依赖这份数据的 renderer 需要更新
- 浏览器端收到变更后重绘
这就是为什么 ColumnDataSource 是 Bokeh 更新机制的核心。
2.12 一个串起全部概念的例子
下面这段代码把本章主要对象串在一起:
from bokeh.models import ColumnDataSource, HoverTool
from bokeh.plotting import figure, show
source = ColumnDataSource(data={
"x": [1, 2, 3, 4, 5],
"y": [2, 5, 8, 2, 7],
"label": ["A", "B", "C", "D", "E"],
"color": ["#4C78A8", "#F58518", "#E45756", "#72B7B2", "#54A24B"],
})
p = figure(
title="核心概念示例",
width=700,
height=400,
tools="pan,wheel_zoom,box_select,reset",
)
renderer = p.circle(
"x", "y",
source=source,
size=16,
fill_color="color",
line_color="white",
line_width=2,
selection_fill_color="red",
nonselection_fill_alpha=0.25,
)
p.add_tools(HoverTool(tooltips=[
("标签", "@label"),
("坐标", "(@x, @y)"),
]))
show(p)
这段代码里:
source提供数据figure()创建画布circle()添加 glyph,并返回 rendererHoverTool使用数据列生成提示show()触发文档输出与浏览器渲染
如果你能清楚说出这五步分别做了什么,本章的核心就已经掌握了。
2.13 本章总结
请记住下面这些结论:
1. ColumnDataSource 是 Bokeh 的标准数据中心
它让图形、悬停、选择、联动、更新都围绕同一份数据工作。
2. Figure 是画布,Glyph 是图形
一个图表通常由多个 glyph 叠加而成。
3. GlyphRenderer 负责把数据和图形绑定起来
它连接 data_source、glyph 和不同交互状态。
4. Document 是整页 Bokeh 对象的容器
它负责组织、序列化和同步。
5. 真正渲染图表的是浏览器中的 BokehJS
Python 端定义模型,BokehJS 负责显示与交互。
6. 学习路径建议
建议你按这个顺序掌握 Bokeh:
figure()和常见 glyphColumnDataSourceHoverTool、选择状态、联动bokeh.models的精细控制Bokeh Server
2.14 下一章预告
理解了本章以后,你已经掌握了 Bokeh 的最小核心模型。
下一章可以进一步学习:
- 常见图表类型如何组织
- 不同 glyph 的使用场景
- 如何从“会画图”过渡到“会设计图表”
继续阅读:第三章:常见图表类型
第三章:基础图表类型
前置知识:建议你先完成第一章和第二章,尤其要理解
figure、ColumnDataSource、HoverTool的基本用法。
延伸阅读:本章里用到的dodge()、factor_cmap()、linear_cmap()、cumsum()等转换工具,会在第四章中系统展开;多个图表如何组合,请见第五章;交互工具的详细配置见第六章。
这一章回答一个最实际的问题:
面对一组数据,你应该先画什么图?
很多初学者学习可视化时,会把注意力集中在“Bokeh 有哪些 API”。
但真正重要的是先想清楚:
- 你想看的是趋势、比较、分布,还是关系
- 你的数据是连续变量、类别数据,还是空间数据
- 你是想做快速探索,还是要做可以交付的图表
Bokeh 的优势不在“图表种类最多”,而在于:
- 你可以直接用 Python 组织数据
- 图表天然支持缩放、平移、悬停
- 后续可以平滑升级到联动、布局、嵌入网页、Bokeh Server
所以本章不会把所有图形都堆给你,而是围绕常见需求,给出:
- 图表选择建议
- 当前 Bokeh 3.x 仍然稳妥的写法
- 什么时候该用、什么时候不该用
- 容易踩坑的地方
图表选择指南
图表类型决策树
先看一个实用版决策树:
你要回答什么问题?
│
├─ 看两个变量之间有没有关系
│ └─ 散点图(3.1)
│
├─ 看一个指标如何随时间或顺序变化
│ ├─ 折线图(3.2)
│ └─ 阶梯图(3.2)
│
├─ 比较几个类别谁大谁小
│ ├─ 柱状图(3.3)
│ └─ 分组柱状图 / 堆叠柱状图(3.3)
│
├─ 看数值分布
│ ├─ 直方图(3.4)
│ ├─ 箱线图(3.8)
│ └─ 误差条图(3.9)
│
├─ 看整体如何构成
│ ├─ 堆叠柱状图(3.3)
│ ├─ 面积图 / 堆叠面积图(3.6)
│ └─ 饼图 / 环形图(3.5,谨慎使用)
│
├─ 看二维矩阵或强度分布
│ ├─ 热力图(3.7)
│ └─ 等高线图(3.10)
│
├─ 看地理空间位置
│ └─ 地理数据可视化(3.11)
│
└─ 看节点与连接关系
└─ 网络图(3.12)
按分析目的选择
| 分析目的 | 推荐图表 | 原因 |
|---|---|---|
| 看相关性 | 散点图 | 最直观呈现两个变量关系 |
| 看趋势 | 折线图 | 时间序列最常用 |
| 看类别比较 | 柱状图 | 长度比较最准确 |
| 看分布形态 | 直方图、箱线图 | 分别适合“频数分布”和“统计摘要” |
| 看构成变化 | 堆叠柱状图、堆叠面积图 | 能同时看总量与构成 |
| 看局部高低区域 | 热力图、等高线图 | 适合二维强度场 |
| 看空间位置 | 地图 | 经纬度/地理对象不能硬塞进普通坐标图 |
| 看连接结构 | 网络图 | 突出节点和边 |
大数据量图表选择建议
可视化不是“数据越多越好”,而是“让模式更容易被看见”。
| 数据量级 | 更稳妥的图表 | 建议 |
|---|---|---|
| 数十到数百 | 散点图、柱状图、折线图 | 直接画即可 |
| 数千到数万 | 散点图、折线图、热力图 | 优先简化样式、减少透明叠加 |
| 数万到数十万 | 热力图、聚合图、降采样折线 | 不要迷信点点点全部都画出来 |
| 更大规模 | Datashader / 预聚合 / 服务端处理 | 超出基础教程范畴 |
经验法则:
- 数据点太多时,先考虑聚合,再考虑美化
- 类别太多时,先考虑排序、筛选、分组
- 一个图解决不了的问题,往往应该拆成多个图联动
图表组合建议
实际工作里,下面几种组合非常常见:
| 目标 | 推荐组合 |
|---|---|
| 先看趋势,再看异常点 | 折线图 + 散点图 |
| 先看总体分布,再看分组差异 | 直方图 + 箱线图 |
| 同时看总量和构成 | 堆叠柱状图 + 明细表 |
| 看空间位置和属性 | 地图 + HoverTool |
| 看关系和聚类 | 散点图 + 颜色/大小编码 |
常见错误
错误 1:类别比较却用饼图
饼图不是不能用,但它不适合精确比较多个接近的比例。
只要类别一多,读者就很难比较扇区面积和角度。
更稳妥的选择通常是:
- 少量类别:柱状图
- 看构成变化:堆叠柱状图
- 看占比同时要排序:水平柱状图
错误 2:时间序列却用柱状图硬画
如果数据本质上有连续顺序,尤其是时间,优先考虑:
- 折线图
- 阶梯图
- 面积图
柱状图更适合离散类别比较。
错误 3:点太多还坚持画散点图
当散点已经严重重叠时,你看到的不是“更多信息”,而是“更厚的墨水”。
这时应该考虑:
- 降低透明度
- 改成热力图 / 六边形分箱
- 做抽样或聚合
错误 4:把“漂亮”放在“准确”前面
颜色、透明度、渐变、阴影都只是辅助。
可视化优先级永远是:
- 数据表达正确
- 结构清晰
- 交互有帮助
- 最后再谈视觉风格
3.1 散点图:从简单到复杂
散点图最适合回答:
- 两个变量有没有关系?
- 有没有离群点?
- 有没有聚类?
- 某个第三变量能不能用颜色或大小编码出来?
最简单的散点图
在 Bokeh 3.x 里,推荐优先使用 scatter()。
它可以统一处理不同 marker 类型,写法也更一致。
from bokeh.plotting import figure, show
x = [1, 2, 3, 4, 5]
y = [4, 7, 6, 9, 13]
p = figure(
title="基础散点图",
width=600,
height=400,
tools="pan,wheel_zoom,box_zoom,reset,save"
)
p.scatter(x, y, size=12, marker="circle", alpha=0.7)
show(p)
使用 ColumnDataSource 和悬停提示
一旦你希望加标签、颜色映射、工具提示,就应该尽早切到 ColumnDataSource。
from bokeh.models import ColumnDataSource, HoverTool
from bokeh.plotting import figure, show
source = ColumnDataSource(data={
"x": [1, 2, 3, 4, 5],
"y": [4, 7, 6, 9, 13],
"name": ["A", "B", "C", "D", "E"],
"group": ["g1", "g1", "g2", "g2", "g2"],
})
p = figure(title="带悬停信息的散点图", width=600, height=400)
p.scatter("x", "y", source=source, size=12, marker="circle", alpha=0.8)
hover = HoverTool(tooltips=[
("名称", "@name"),
("x", "@x"),
("y", "@y"),
("分组", "@group"),
])
p.add_tools(hover)
show(p)
用颜色和大小表达更多维度
散点图非常适合做“二维位置 + 第三维颜色 + 第四维大小”的表达。
from bokeh.models import ColumnDataSource, ColorBar
from bokeh.palettes import Viridis256
from bokeh.plotting import figure, show
from bokeh.transform import linear_cmap
source = ColumnDataSource(data={
"x": [1, 2, 3, 4, 5],
"y": [2, 5, 8, 4, 7],
"value": [10, 30, 50, 70, 90],
"size": [8, 12, 16, 20, 24],
})
mapper = linear_cmap(
field_name="value",
palette=Viridis256,
low=10,
high=90,
)
p = figure(title="颜色和大小编码", width=600, height=400)
r = p.scatter(
"x", "y",
source=source,
size="size",
marker="circle",
fill_color=mapper,
line_color="black",
alpha=0.8,
)
color_bar = ColorBar(color_mapper=mapper["transform"], width=8)
p.add_layout(color_bar, "right")
show(p)
不同 marker 的选择
scatter() 的一个优势,是可以通过 marker 参数切换标记类型。
from bokeh.plotting import figure, show
p = figure(title="不同 marker", width=700, height=300, x_range=(0, 6), y_range=(0, 2))
markers = ["circle", "square", "triangle", "diamond", "hex"]
for i, marker in enumerate(markers, start=1):
p.scatter([i], [1], size=18, marker=marker, legend_label=marker)
p.legend.location = "top_left"
show(p)
什么时候不用散点图
以下情况不建议直接用散点图:
- 点非常多,已经重叠成一团
- 一个轴其实是类别轴,不是真正的连续变量
- 你真正想展示的是分布,不是个体点
这时更好的替代是:
- 热力图
- 抖动图(jitter)
- 箱线图
- 直方图
实战建议
- 探索性分析时:先画散点图
- 点数上万时:先降低透明度,再考虑聚合
- 想强调个别点:单独叠加第二层 glyph
- 想要可读 tooltip:尽量把字段放进
ColumnDataSource
3.2 折线图与多系列
折线图最适合表现有顺序的数据,尤其是:
- 时间序列
- 序列位置
- 累积过程
- 连续变化趋势
单条折线
from bokeh.plotting import figure, show
days = [1, 2, 3, 4, 5, 6, 7]
sales = [120, 132, 128, 145, 160, 158, 172]
p = figure(title="每日销售趋势", width=700, height=400)
p.line(days, sales, line_width=3, color="#1f77b4")
p.scatter(days, sales, size=8, marker="circle", color="#1f77b4")
show(p)
一个常见做法是:
- 用
line()表示整体趋势 - 再叠加一层
scatter()强调每个观测点
阶梯图
当数据不是连续变化,而是“状态在相邻时段保持不变,到某个点才跳变”时,step() 往往比 line() 更准确。
from bokeh.plotting import figure, show
x = [1, 2, 3, 4, 5]
y = [10, 10, 15, 15, 20]
p = figure(title="阶梯图", width=600, height=350)
p.step(x, y, mode="after", line_width=3)
show(p)
mode 可选:
"before""after""center"
多条折线
如果系列数量不多,最直接的方法是连续调用多次 line()。
from bokeh.plotting import figure, show
days = [1, 2, 3, 4, 5]
product_a = [100, 120, 110, 135, 150]
product_b = [90, 105, 130, 128, 142]
p = figure(title="多系列折线图", width=700, height=400)
p.line(days, product_a, line_width=3, color="#1f77b4", legend_label="产品 A")
p.line(days, product_b, line_width=3, color="#ff7f0e", legend_label="产品 B")
p.scatter(days, product_a, size=7, color="#1f77b4", marker="circle")
p.scatter(days, product_b, size=7, color="#ff7f0e", marker="square")
p.legend.location = "top_left"
p.legend.click_policy = "hide"
show(p)
click_policy = "hide" 是一个非常实用的交互细节:
当系列较多时,用户可以临时隐藏某一条线。
一次性绘制多条线:multi_line()
如果你的数据天然是“多条线构成的集合”,multi_line() 更合适。
但它的数据结构会更特别:需要“列表的列表”。
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, show
source = ColumnDataSource(data={
"xs": [[1, 2, 3, 4], [1, 2, 3, 4]],
"ys": [[2, 4, 3, 5], [1, 3, 2, 4]],
"color": ["#1f77b4", "#ff7f0e"],
})
p = figure(title="multi_line 示例", width=650, height=350)
p.multi_line(xs="xs", ys="ys", line_color="color", line_width=3, source=source)
show(p)
适合场景:
- 一批结构一致的轨迹
- 批量绘制很多条线
- 和数据管道天然对应
缺失值的处理
Bokeh 对折线图的一个细节很重要:
- 如果要制造“断线”,应使用
NaN - 不要依赖
None来表达缺失段
在很多分析场景里,这能帮助你明确区分:
- 真实值为 0
- 数据缺失
- 应该断开绘制
什么时候不用折线图
不适合用折线图的情况包括:
- x 轴没有真实顺序,只是一组类别
- 你想突出每一类的独立比较,而不是连续变化
- 系列太多,线条缠在一起难以辨认
替代方案:
- 柱状图
- 小多图(small multiples)
- 热力图
- 交互筛选
3.3 柱状图
柱状图最适合做类别比较。
它的核心优势不是“好看”,而是人眼最擅长比较长度。
基础柱状图
from bokeh.plotting import figure, show
fruits = ["苹果", "梨", "香蕉", "葡萄"]
counts = [12, 18, 9, 15]
p = figure(x_range=fruits, title="基础柱状图", width=600, height=400)
p.vbar(x=fruits, top=counts, width=0.7)
show(p)
注意这里的 x_range=fruits:
- 这会创建一个类别轴
- 本质上等价于使用
FactorRange
排序比默认顺序更重要
很多柱状图一旦排序,信息量会立刻上升。
from bokeh.plotting import figure, show
data = [("苹果", 12), ("梨", 18), ("香蕉", 9), ("葡萄", 15)]
data = sorted(data, key=lambda item: item[1], reverse=True)
fruits = [item[0] for item in data]
counts = [item[1] for item in data]
p = figure(x_range=fruits, title="按数值排序的柱状图", width=600, height=400)
p.vbar(x=fruits, top=counts, width=0.7)
show(p)
分组柱状图:dodge()
当你想比较“同一类别下的多个系列”时,可以用 dodge() 做视觉偏移。
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, show
from bokeh.transform import dodge
quarters = ["Q1", "Q2", "Q3", "Q4"]
source = ColumnDataSource(data={
"quarter": quarters,
"2023": [120, 135, 150, 170],
"2024": [130, 145, 162, 182],
})
p = figure(x_range=quarters, title="分组柱状图", width=700, height=400)
p.vbar(x=dodge("quarter", -0.2, range=p.x_range), top="2023", width=0.35,
source=source, color="#1f77b4", legend_label="2023")
p.vbar(x=dodge("quarter", 0.2, range=p.x_range), top="2024", width=0.35,
source=source, color="#ff7f0e", legend_label="2024")
p.legend.location = "top_left"
show(p)
堆叠柱状图:vbar_stack()
如果你除了比较总量,还想看构成,可以用堆叠柱状图。
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, show
source = ColumnDataSource(data={
"month": ["1月", "2月", "3月"],
"线上": [30, 45, 50],
"门店": [20, 25, 35],
"代理": [10, 15, 12],
})
categories = ["线上", "门店", "代理"]
colors = ["#1f77b4", "#ff7f0e", "#2ca02c"]
p = figure(x_range=source.data["month"], title="堆叠柱状图", width=700, height=400)
p.vbar_stack(categories, x="month", width=0.7, color=colors, source=source, legend_label=categories)
p.legend.location = "top_left"
p.legend.click_policy = "hide"
show(p)
水平柱状图
当类别名很长、数量较多时,水平柱状图通常更易读。
from bokeh.plotting import figure, show
categories = ["华东大区", "华南大区", "华北大区", "西南大区"]
values = [82, 76, 69, 58]
p = figure(y_range=categories, title="水平柱状图", width=700, height=350)
p.hbar(y=categories, right=values, height=0.6)
show(p)
何时用堆叠,何时用分组
这是一个特别常见的问题:
- 想精确比较每个系列:用分组柱状图
- 想看总量 + 构成:用堆叠柱状图
如果两个目标都很强,最稳妥的做法不是硬塞在一张图里,而是拆成两张图。
柱状图的常见建议
- 类别很多时,优先排序
- 标签过长时,优先水平布局
- 类别超过十几个时,考虑分页、筛选或分组
- 不要在柱状图里用太多颜色,除非颜色本身有数据含义
3.4 直方图
直方图回答的问题是:
- 数据集中在哪些区间?
- 分布是偏左、偏右,还是接近对称?
- 有没有长尾、双峰、多峰?
它和柱状图最大的不同是:
- 柱状图比较类别
- 直方图比较数值区间的频数或密度
基础直方图
Bokeh 官方推荐的思路是:
- 先用
numpy.histogram()计算分箱 - 再用
quad()画出来
import numpy as np
from bokeh.plotting import figure, show
data = np.random.normal(loc=0, scale=1, size=1000)
hist, edges = np.histogram(data, bins=20)
p = figure(title="基础直方图", width=700, height=400)
p.quad(
top=hist,
bottom=0,
left=edges[:-1],
right=edges[1:],
fill_color="#4e79a7",
line_color="white",
alpha=0.8,
)
show(p)
叠加概率密度曲线
如果你想让读者同时看到“分箱频数”和“整体形态”,可以在直方图上叠加一条密度曲线。
import numpy as np
from scipy.stats import gaussian_kde
from bokeh.plotting import figure, show
data = np.random.normal(loc=0, scale=1, size=1000)
hist, edges = np.histogram(data, bins=30, density=True)
x = np.linspace(data.min(), data.max(), 300)
kde = gaussian_kde(data)
p = figure(title="直方图 + KDE", width=700, height=400)
p.quad(
top=hist,
bottom=0,
left=edges[:-1],
right=edges[1:],
fill_alpha=0.5,
fill_color="skyblue",
line_color="white",
)
p.line(x, kde(x), line_width=3, color="firebrick")
show(p)
如何选 bins
很多“看起来奇怪的直方图”,问题不在数据,而在分箱。
经验上可以这样记:
- bins 太少:信息被抹平
- bins 太多:噪声被放大
- 初步探索时:先试
20到40 - 对比多个图时:尽量保持一致的分箱规则
什么时候不用直方图
不建议用直方图的情况:
- 样本量太少
- 你更关心分组差异,而不是总体分布
- 你想强调中位数、四分位数、异常值
替代方案:
- 箱线图
- 小提琴图(Bokeh 基础教程不展开)
- KDE 曲线
- 分组密度图
3.5 饼图与环形图
这一节先讲一个很重要的事实:
Bokeh 没有内置的 pie / donut 专用 API。
当前官方文档的做法,是使用:
wedge()画饼图annular_wedge()画环形图
饼图的基础写法
import math
import pandas as pd
from bokeh.models import ColumnDataSource
from bokeh.palettes import Category20c
from bokeh.plotting import figure, show
from bokeh.transform import cumsum
data = pd.Series({
"A类": 35,
"B类": 25,
"C类": 20,
"D类": 15,
"E类": 5,
}).reset_index(name="value").rename(columns={"index": "category"})
data["angle"] = data["value"] / data["value"].sum() * 2 * math.pi
data["color"] = Category20c[len(data)]
source = ColumnDataSource(data)
p = figure(title="饼图", width=500, height=400, toolbar_location=None, tools="hover",
tooltips="@category: @value", x_range=(-1.2, 1.2))
p.wedge(
x=0, y=1, radius=0.8,
start_angle=cumsum("angle", include_zero=True),
end_angle=cumsum("angle"),
line_color="white",
fill_color="color",
legend_field="category",
source=source,
)
p.axis.visible = False
p.grid.grid_line_color = None
show(p)
环形图的写法
import math
import pandas as pd
from bokeh.models import ColumnDataSource
from bokeh.palettes import Category20c
from bokeh.plotting import figure, show
from bokeh.transform import cumsum
data = pd.Series({
"搜索": 45,
"直访": 20,
"广告": 18,
"社媒": 12,
"其他": 5,
}).reset_index(name="value").rename(columns={"index": "channel"})
data["angle"] = data["value"] / data["value"].sum() * 2 * math.pi
data["color"] = Category20c[len(data)]
source = ColumnDataSource(data)
p = figure(title="环形图", width=500, height=400, toolbar_location=None, tools="hover",
tooltips="@channel: @value", x_range=(-1.2, 1.2))
p.annular_wedge(
x=0, y=1,
inner_radius=0.35,
outer_radius=0.7,
start_angle=cumsum("angle", include_zero=True),
end_angle=cumsum("angle"),
fill_color="color",
line_color="white",
legend_field="channel",
source=source,
)
p.axis.visible = False
p.grid.grid_line_color = None
show(p)
什么时候可以用饼图
饼图不是绝对不能用,但要克制:
可以考虑用的情况:
- 类别很少,通常不超过 5 个
- 你只是想表达“整体由几部分组成”
- 不追求精确比较
不建议用的情况:
- 类别较多
- 很多比例接近
- 还想同时比较多个时间点或多个组别
在这些情况下,更推荐:
- 排序柱状图
- 堆叠柱状图
- 100% 堆叠柱状图
3.6 面积图
面积图常用于表达:
- 随时间变化的累计量
- 一段区间内的变化范围
- 多个组成部分的总量变化
基础面积图:varea()
from bokeh.plotting import figure, show
x = [1, 2, 3, 4, 5]
y = [3, 5, 4, 7, 6]
p = figure(title="基础面积图", width=700, height=350)
p.varea(x=x, y1=0, y2=y, fill_alpha=0.5, color="#1f77b4")
p.line(x, y, line_width=2, color="#1f77b4")
show(p)
这里本质上是填充了:
- 下边界
y1=0 - 上边界
y2=y
区间带状图
面积图还有一个很实用的用途:表示上下界之间的区间。
from bokeh.plotting import figure, show
x = [1, 2, 3, 4, 5]
lower = [10, 12, 11, 14, 15]
upper = [14, 16, 15, 18, 20]
mean = [12, 14, 13, 16, 18]
p = figure(title="区间带状图", width=700, height=350)
p.varea(x=x, y1=lower, y2=upper, fill_alpha=0.3, color="gray")
p.line(x, mean, line_width=3, color="firebrick")
show(p)
这种形式常见于:
- 置信区间
- 预测区间
- 误差带
堆叠面积图:varea_stack()
如果你想看多个部分如何组成总量,可以用堆叠面积图。
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, show
source = ColumnDataSource(data={
"x": [1, 2, 3, 4, 5],
"A": [2, 3, 4, 3, 5],
"B": [1, 2, 2, 3, 2],
"C": [3, 2, 1, 2, 3],
})
stackers = ["A", "B", "C"]
colors = ["#4e79a7", "#f28e2b", "#59a14f"]
p = figure(title="堆叠面积图", width=700, height=350)
p.varea_stack(stackers, x="x", color=colors, alpha=0.7, legend_label=stackers, source=source)
p.legend.location = "top_left"
show(p)
面积图的注意事项
面积图很容易“看起来丰富”,但也很容易误导。
适合用面积图的情况:
- 你关心总量变化
- 数据有连续顺序
- 系列数量不多
不适合的情况:
- 只是比较独立类别
- 系列很多,导致叠加难以分辨
- 你真正关心的是每条线的精确比较
这种时候,折线图通常更清晰。
3.7 热力图
热力图适合表达:
- 二维矩阵中的强弱关系
- 分类 × 分类 的交叉统计
- 空间格点中的数值高低
先说一个实用判断:
- 如果你的数据是“分类 × 分类”,通常用
rect() - 如果你的数据本质是二维数组/栅格,通常用
image() - 这一章先讲最常见的分类热力图
分类热力图:rect() + 颜色映射
from bokeh.models import ColumnDataSource, ColorBar
from bokeh.palettes import Viridis256
from bokeh.plotting import figure, show
from bokeh.transform import linear_cmap
days = ["周一", "周二", "周三", "周四", "周五"]
hours = ["9点", "10点", "11点", "12点"]
source = ColumnDataSource(data={
"day": ["周一", "周二", "周三", "周四", "周五",
"周一", "周二", "周三", "周四", "周五",
"周一", "周二", "周三", "周四", "周五",
"周一", "周二", "周三", "周四", "周五"],
"hour": ["9点"] * 5 + ["10点"] * 5 + ["11点"] * 5 + ["12点"] * 5,
"value": [12, 18, 15, 10, 8, 22, 26, 24, 18, 14, 30, 35, 28, 25, 20, 16, 20, 18, 15, 12],
})
mapper = linear_cmap("value", Viridis256, low=8, high=35)
p = figure(
title="分类热力图",
x_range=days,
y_range=list(reversed(hours)),
width=700,
height=350,
toolbar_location="above",
)
p.rect(
x="day",
y="hour",
width=1,
height=1,
source=source,
line_color="white",
fill_color=mapper,
)
color_bar = ColorBar(color_mapper=mapper["transform"], width=8)
p.add_layout(color_bar, "right")
show(p)
为什么热力图适合“密集概览”
热力图的优点是:
- 适合同时展示很多格子
- 对模式识别友好
- 非常适合做概览和异常定位
例如上面的图,你很容易一眼看出:
- 哪个时间段整体更高
- 哪一列或哪一行明显偏高
- 是否存在局部极值
何时改用 image()
如果你的数据是规则二维数组,比如:
- 温度场
- 地形高度
- 仿真结果矩阵
- 图像像素数据
那么更适合使用 image(),因为那是官方针对栅格数据的图像 glyph。
但如果你的目标是“分类交叉表”,rect() 更直观,也更容易加 tooltip。
热力图的注意事项
- 颜色映射要有明确的上下界
- 不要同时叠加太多文本标签
- 颜色过于花哨会削弱模式识别
- 想表达精确值时,热力图应配合 hover,而不是在每个格子里塞数字
3.8 箱线图
箱线图适合快速比较不同组的分布特征:
- 中位数
- 四分位数
- 须线范围
- 异常值
Bokeh 没有“一键箱线图”高层接口,但官方推荐的做法很明确:
- 用
vbar()画箱体 - 用
Whisker画须线 - 用
scatter()画异常值
一个基础箱线图示例
import pandas as pd
from bokeh.models import ColumnDataSource, Whisker
from bokeh.plotting import figure, show
df = pd.DataFrame({
"group": ["A"] * 8 + ["B"] * 8,
"value": [5, 7, 8, 8, 9, 10, 12, 20, 4, 5, 6, 6, 7, 8, 9, 14],
})
groups = []
q1s = []
q2s = []
q3s = []
lowers = []
uppers = []
outlier_x = []
outlier_y = []
for group, sub in df.groupby("group"):
q1 = sub["value"].quantile(0.25)
q2 = sub["value"].quantile(0.50)
q3 = sub["value"].quantile(0.75)
iqr = q3 - q1
lower = max(sub["value"].min(), q1 - 1.5 * iqr)
upper = min(sub["value"].max(), q3 + 1.5 * iqr)
groups.append(group)
q1s.append(q1)
q2s.append(q2)
q3s.append(q3)
lowers.append(lower)
uppers.append(upper)
outliers = sub[(sub["value"] < lower) | (sub["value"] > upper)]["value"]
outlier_x.extend([group] * len(outliers))
outlier_y.extend(outliers.tolist())
source = ColumnDataSource(data={
"group": groups,
"q1": q1s,
"q2": q2s,
"q3": q3s,
"lower": lowers,
"upper": uppers,
})
p = figure(x_range=groups, title="箱线图", width=650, height=400)
# 箱体
p.vbar(x="group", width=0.6, top="q3", bottom="q2", source=source, fill_color="#a6cee3", line_color="black")
p.vbar(x="group", width=0.6, top="q2", bottom="q1", source=source, fill_color="#1f78b4", line_color="black")
# 中位数线
p.segment(x0=groups, y0=q2s, x1=groups, y1=q2s, line_width=3, color="black")
# 须线
whisker = Whisker(source=source, base="group", upper="upper", lower="lower")
p.add_layout(whisker)
# 异常值
p.scatter(outlier_x, outlier_y, marker="circle", size=8, color="firebrick", alpha=0.8)
show(p)
箱线图适合什么问题
它最适合回答:
- 哪个组的中位数更高?
- 哪个组波动更大?
- 哪个组异常值更多?
- 不同组的分布是否偏斜?
箱线图不擅长什么
箱线图是统计摘要,不展示原始形状细节。
它不适合回答:
- 分布是否双峰?
- 两组密度曲线形状是否不同?
- 个体点的聚集模式如何?
这时可以考虑:
- 直方图
- KDE
- 抖动散点
- SinaPlot / beeswarm(本教程不展开)
3.9 误差条图
误差条图常见于:
- 实验结果展示
- 估计值 + 不确定性
- 平均值 + 标准误 / 标准差 / 置信区间
在 Bokeh 中,最方便的做法是:
- 用
line()、scatter()或vbar()画主体 - 用
Whisker添加误差条
误差条基础示例
from bokeh.models import ColumnDataSource, Whisker
from bokeh.plotting import figure, show
source = ColumnDataSource(data={
"x": [1, 2, 3, 4, 5],
"mean": [10, 12, 15, 14, 18],
"lower": [9, 11, 13, 12, 16],
"upper": [11, 13, 17, 16, 20],
})
p = figure(title="误差条图", width=650, height=400)
p.line("x", "mean", source=source, line_width=2)
p.scatter("x", "mean", source=source, size=10, marker="circle")
whisker = Whisker(source=source, base="x", lower="lower", upper="upper")
p.add_layout(whisker)
show(p)
和面积误差带的区别
误差条和误差带都能表达不确定性,但适合场景不同:
- 点比较稀疏:误差条更好
- 连续时间序列:误差带更好
- 重点看每个观测点:误差条
- 重点看整体范围:面积带
注意事项
- 一定要明确误差条表示什么:标准差、标准误、置信区间?
- 如果只是“画出来像误差条”,却没说明统计含义,图会很容易被误解
- 误差条太多时,可能会互相遮挡,此时可以考虑分面或减少系列数
3.10 等高线图
这一节需要特别更新一个认知:
在当前 Bokeh 3.x 中,已经有 figure.contour()。
所以如果你要画真正的等高线图,不应该再用 image() 假装等高线。image() 适合显示栅格图像;contour() 才是等值线 / 等值区域的原生接口。
基础等高线图
import numpy as np
from bokeh.plotting import figure, show
x = np.linspace(-3, 3, 100)
y = np.linspace(-3, 3, 100)
xx, yy = np.meshgrid(x, y)
z = np.sin(xx) * np.cos(yy)
p = figure(title="等高线图", width=500, height=450)
p.contour(
x=x,
y=y,
z=z,
levels=10,
line_color="black",
fill_color=["#440154", "#482777", "#3e4989", "#31688e", "#26828e",
"#1f9e89", "#35b779", "#6ece58", "#b5de2b", "#fde725"],
)
show(p)
什么时候用等高线图
适合场景:
- 二维标量场
- 地形高度
- 温度、压力、浓度等空间分布
- 优化目标函数的形态观察
什么时候不用等高线图
不适合的情况:
- 数据本质是类别矩阵
- 你只有少量离散点,而没有规则网格
- 你真正想表达的是像素/栅格,而不是等值线
这时更适合:
- 热力图
image()- 散点图 + 插值(视情况而定)
3.11 地理数据可视化
地理可视化和普通二维图最大的区别是:
它不仅仅是“x 和 y 的值”,还涉及:
- 坐标参考系
- 经纬度与 Web Mercator
- 地图底图来源
- GeoJSON 等地理对象
底图地图:add_tile()
当前官方文档中,Bokeh 推荐通过 add_tile() 使用 XYZ 瓦片服务。
你可以直接传入 tile provider 名称字符串。
from bokeh.plotting import figure, show
p = figure(
title="底图地图示例",
width=700,
height=500,
x_axis_type="mercator",
y_axis_type="mercator",
)
p.add_tile("CartoDB Positron")
show(p)
一个带点位的地图示例
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, show
source = ColumnDataSource(data={
"x": [12958000, 12980000, 13000000],
"y": [4850000, 4865000, 4880000],
"name": ["点 A", "点 B", "点 C"],
})
p = figure(
title="地图上的点位",
width=700,
height=500,
x_axis_type="mercator",
y_axis_type="mercator",
tools="pan,wheel_zoom,reset,save,hover",
tooltips=[("名称", "@name")],
)
p.add_tile("CartoDB Positron")
p.scatter("x", "y", source=source, size=12, marker="circle", color="firebrick", alpha=0.8)
show(p)
重要:坐标必须匹配投影
如果你用的是底图瓦片,最常见的坑就是:
- 底图使用 Web Mercator
- 你的点数据还是经纬度
这会导致点“跑偏”甚至完全不显示。
所以要记住:
x_axis_type="mercator"/y_axis_type="mercator"时- 你的点位也应该先转换到 Web Mercator
GeoJSON 数据
如果你的数据本身是地理对象,如:
- 行政区边界
- 路线
- 多边形区域
那么应优先考虑 GeoJSONDataSource。
它可以直接承接地理数据结构,而不是手工拆成很多列。
什么时候用地图
只有当地理位置本身有意义时,才值得上地图。
如果你只是想比较几个城市的数值大小,很多时候:
- 排序柱状图
- 点图
- 小表格
反而比地图更清晰。
3.12 网络图可视化
网络图适合表达:
- 节点与连接关系
- 社交网络
- 依赖关系
- 路由拓扑
- 知识图谱中的局部结构
在 Bokeh 里,网络图底层基于 GraphRenderer。
而在教程实践里,最方便的入口通常是 from_networkx()。
基础网络图示例
import networkx as nx
from bokeh.plotting import figure, from_networkx, show
G = nx.karate_club_graph()
p = figure(title="NetworkX + Bokeh 网络图", width=700, height=500,
x_range=(-2, 2), y_range=(-2, 2),
tools="pan,wheel_zoom,reset,save")
graph = from_networkx(G, nx.spring_layout, scale=1.5, center=(0, 0))
# 设置节点和边样式
graph.node_renderer.glyph.size = 12
graph.node_renderer.glyph.fill_color = "#1f77b4"
graph.edge_renderer.glyph.line_alpha = 0.4
graph.edge_renderer.glyph.line_width = 1.5
p.renderers.append(graph)
show(p)
为什么 from_networkx() 很方便
它帮你省掉了两类工作:
- 自己构造图的节点和边数据源
- 自己手工指定布局提供器
这使得你可以快速把已有的 NetworkX 图对象可视化出来。
可以继续做什么
网络图在 Bokeh 中还可以继续扩展:
- 给节点加 hover 信息
- 给节点按属性着色
- 调整 selection / inspection policy
- 自定义布局
- 自定义 node / edge glyph
网络图的局限与建议
网络图很容易“看起来很炫”,但也很容易“什么都看不清”。
所以建议:
- 节点少时适合展示结构
- 节点多时应先做筛选、聚类、分层
- 不要试图把整个大图一次性全画出来
对于较复杂网络,布局和筛选策略往往比“画图 API”更重要。
3.13 图表类型对比与选择
这一节把前面的内容收束成一张“查表”。
全部图表类型对比总表
| 图表类型 | Bokeh 常用方法 | 适合数据 | 最适合回答的问题 | 注意事项 |
|---|---|---|---|---|
| 散点图 | scatter() | 两个连续变量 | 有没有关系、离群点、聚类 | 点多会重叠 |
| 折线图 | line() | 有顺序的连续数据 | 趋势如何变化 | 不适合纯类别比较 |
| 阶梯图 | step() | 离散阶段变化 | 状态何时跳变 | 只适合阶梯式变化 |
| 柱状图 | vbar() / hbar() | 类别 + 数值 | 谁大谁小 | 类别太多会拥挤 |
| 分组柱状图 | dodge() + vbar() | 分组类别数据 | 同类下多个系列比较 | 系列太多难读 |
| 堆叠柱状图 | vbar_stack() | 构成数据 | 总量和构成如何变化 | 不利于精确比较子项 |
| 直方图 | quad() + np.histogram() | 单变量连续数据 | 分布形态如何 | bins 选择很关键 |
| 饼图 | wedge() | 少量构成数据 | 整体由几部分组成 | 不适合精确比较 |
| 环形图 | annular_wedge() | 少量构成数据 | 构成展示 | 和饼图局限相似 |
| 面积图 | varea() | 连续序列 | 总量/区间如何变化 | 容易掩盖精确比较 |
| 堆叠面积图 | varea_stack() | 多部分连续构成 | 总量与构成 | 系列多时难读 |
| 热力图 | rect() + 颜色映射 | 二维矩阵/交叉表 | 哪些区域更高更低 | 适合模式识别,不适合精读 |
| 箱线图 | vbar() + Whisker | 分组分布 | 中位数、离散程度、异常值 | 不展示完整分布形态 |
| 误差条图 | Whisker | 估计值 + 不确定性 | 不确定性有多大 | 需说明误差含义 |
| 等高线图 | contour() | 二维标量场 | 等值线/区域结构如何 | 需规则网格更合适 |
| 地图 | add_tile()、GeoJSONDataSource | 空间数据 | 数据在地理上如何分布 | 注意投影转换 |
| 网络图 | from_networkx() | 节点边关系 | 结构如何连接 | 图太大时可读性差 |
按数据特征快速选择
只有一个数值变量
优先考虑:
- 直方图
- 箱线图
如果你更想看“频数分布”,选直方图。
如果你更想看“统计摘要”,选箱线图。
一个类别变量 + 一个数值变量
优先考虑:
- 柱状图
- 水平柱状图
- 箱线图(如果每个类别下有一批样本)
两个连续变量
优先考虑:
- 散点图
- 折线图(前提是有顺序)
连续时间序列
优先考虑:
- 折线图
- 阶梯图
- 面积图
二维矩阵
优先考虑:
- 热力图
- 等高线图
image()(若是栅格图像)
地理位置数据
优先考虑:
- 地图底图 + 点位
- GeoJSON 面对象可视化
图结构数据
优先考虑:
- 网络图
图表选择速查口诀
看关系,用散点;
看趋势,用折线;
看跳变,用阶梯;
比大小,用柱状;
看分布,直方箱;
看构成,堆叠优先,饼环慎用;
看强弱,用热力;
看场分布,用等高;
看位置,用地图;
看连接,用网络。
本章小结
这一章你应该掌握的,不只是“会调哪些 API”,而是下面这些更重要的判断:
- 先根据分析目标选图,再考虑 Bokeh 写法
- Bokeh 3.x 中很多图形都适合以
ColumnDataSource为中心组织数据 - 散点、折线、柱状图是最常用的三大基础图
- 分布类问题优先想到直方图、箱线图、误差条
- 构成类问题优先想到堆叠图,饼图和环形图应谨慎使用
- 等高线、地图、网络图虽然更“高级”,但也更依赖数据结构是否匹配
- 交互不是装饰,而是帮助你更快读图的工具
接下来建议阅读:
- 第四章:数据变换与数据管理
你会系统掌握dodge()、颜色映射、数据组织方式等内容。 - 第五章:布局与样式
你会学会如何把多个基础图组合成真正可交付的可视化页面。 - 第六章:交互功能
你会进一步理解 Hover、选择、联动和工具栏配置。
如果你已经能根据数据特征快速想到“应该先画哪种图”,那么这章的目标就达到了。
第四章:数据转换与处理
延伸阅读:本章聚焦“数据如何进入图、如何映射到视觉属性、如何按条件筛选”。如果你更关心图表为什么会卡、何时该降采样、何时该考虑 WebGL 或 Datashader,请继续阅读第九章。
4.1 为什么需要数据转换?
场景:你有100个数据点,想根据数值大小设置颜色。
笨办法:手动计算每个颜色
colors = []
for v in values:
if v < 30:
colors.append('red')
elif v < 70:
colors.append('yellow')
else:
colors.append('green')
Bokeh方式:使用transform
from bokeh.transform import linear_cmap
p.circle('x', 'y', source=source,
fill_color=linear_cmap('value', 'RdYlGn10', low=0, high=100))
4.2 颜色映射
线性映射(linear_cmap):
from bokeh.transform import linear_cmap
from bokeh.palettes import Viridis256
# 连续数值映射到颜色
mapper = linear_cmap('value', Viridis256, low=0, high=100)
p.circle('x', 'y', source=source, fill_color=mapper)
分类映射(factor_cmap):
from bokeh.transform import factor_cmap
# 类别映射到颜色
factors = ['A', 'B', 'C']
mapper = factor_cmap('category', 'Category10', factors)
p.vbar(x='category', top='value', source=source, fill_color=mapper)
字段映射(transform):
from bokeh.transform import transform
# 通用映射函数
mapper = transform('value', LinearColorMapper(palette='Viridis256', low=0, high=100))
4.3 位置变换
dodge - 偏移:
from bokeh.transform import dodge
# 用于分组柱状图
p.vbar(x=dodge('category', -0.2, range=p.x_range), top='value1', source=source)
p.vbar(x=dodge('category', 0.2, range=p.x_range), top='value2', source=source)
jitter - 抖动:
from bokeh.transform import jitter
# 避免点重叠
p.circle(x=jitter('category', width=0.3, range=p.x_range), y='value', source=source)
stack - 堆叠:
from bokeh.transform import stack
# 堆叠柱状图
p.vbar(x='category', top=stack('value1', 'value2', 'value3'), source=source)
4.4 数据过滤器
IndexFilter - 索引过滤:
from bokeh.models import IndexFilter
# 只显示索引0, 2, 4的数据
view = CDSView(filter=IndexFilter([0, 2, 4]))
p.circle('x', 'y', source=source, view=view)
BooleanFilter - 布尔过滤:
from bokeh.models import BooleanFilter
# 只显示值大于50的点
booleans = [v > 50 for v in source.data['value']]
view = CDSView(filter=BooleanFilter(booleans))
p.circle('x', 'y', source=source, view=view)
GroupFilter - 分组过滤:
from bokeh.models import GroupFilter, CDSView
# 只显示category为'A'的数据
view = CDSView(filter=GroupFilter(column_name='category', group='A'))
p.circle('x', 'y', source=source, view=view)
组合过滤器:
from bokeh.models import IntersectionFilter
# 多条件组合
view = CDSView(filter=IntersectionFilter(filters=[
GroupFilter(column_name='category', group='A'),
BooleanFilter([v > 50 for v in source.data['value']])
]))
4.5 自定义表达式
from bokeh.models import CustomJSExpr
# 使用JavaScript表达式计算新列
expr = CustomJSExpr(args=dict(source=source), code='''
return source.data['value'].map(v => v * 2)
''')
source.data['doubled'] = expr
4.6 GroupBy 数据聚合
import pandas as pd
from bokeh.models import ColumnDataSource
# 创建 DataFrame
df = pd.DataFrame({
'category': ['A', 'A', 'B', 'B', 'C'],
'value': [10, 20, 30, 40, 50]
})
# 从 GroupBy 创建 ColumnDataSource
group = df.groupby('category')
source = ColumnDataSource(group)
# source 包含以下列:
# - category_mean, category_std, etc.
4.7 本章与性能优化的边界
到这里,你已经接触了三类非常重要的“数据到视觉”的工具:
- 映射:
linear_cmap()、factor_cmap()、transform()
适合把数值或类别映射到颜色、大小等视觉属性。 - 位置变换:
dodge()、jitter()、stack()
适合在不改原始数据语义的前提下调整显示位置。 - 筛选:
CDSView+ 各类 filter
适合控制“哪些数据被画出来”。
但要注意:这些工具的目标主要是让已有数据更适合可视化表达,不是替代 pandas / NumPy 的通用数据处理流程,也不是完整的性能优化方案。
如果你遇到的是下面这些问题:
- 点太多,浏览器渲染明显变慢
- 每次交互都要传很多数据
- 实时更新时整张图频繁重绘
- 想知道什么时候该降采样、聚合、用
stream()/patch() - 想评估 WebGL 是否真的适合你的场景
请转到第九章:性能优化。
那里会按“先减少数据量,再考虑渲染后端”的顺序系统展开,而不是把 WebGL 当成默认答案。
4.8 本章小结
本章你已经掌握了 Bokeh 中最常见的数据转换思路:
- 用
linear_cmap()、factor_cmap()把数据映射到颜色 - 用
dodge()、jitter()、stack()调整图形位置 - 用
CDSView和过滤器控制显示的数据子集 - 了解哪些转换适合放在 Bokeh 中做,哪些更适合提前在 pandas / NumPy 中处理
如果你准备继续学习,推荐按下面的顺序前进:
4.9 常见坑
坑 1:把 transform 当成通用数据处理工具
linear_cmap()、factor_cmap()、dodge() 这些函数更适合“为了画图而做的转换”。
如果你需要复杂清洗、分组、聚合、时间对齐,通常应该先在 pandas / NumPy 中完成,再交给 Bokeh。
坑 2:过滤条件写对了,但 source 本身的数据就不一致
很多“为什么过滤后图不显示”的问题,根源并不是 filter 本身,而是 ColumnDataSource 中各列长度不一致,或者列名写错。
这类问题请回看第二章中 ColumnDataSource 的基本约束。
坑 3:过早把问题归结为性能
如果当前问题只是“颜色映射不对”或“分类轴位置不对”,不要立刻跳到 WebGL、降采样这类方案。
先确认:
- 数据列名是否正确
low/high是否覆盖了数据范围x_range/y_range是否与变换方式匹配CDSView的过滤条件是否真的命中了数据
坑 4:在 Bokeh 中做了太多业务逻辑
Bokeh 很擅长把数据变成图,但不适合承担整套业务数据处理流水线。
经验上更稳妥的分工是:
- pandas / NumPy:清洗、聚合、衍生字段
- Bokeh:映射、筛选、交互、渲染
第五章:布局系统与样式定制
前置知识:本章需要掌握基础图表绘制(第三章)和 ColumnDataSource(第二章 2.2)。
运行方式说明:本章大多数示例既可以在普通 Python 脚本中配合
show()运行,也可以在 Notebook 中运行;涉及curdoc().theme的示例更适合放在 Bokeh Server 或当前活动 Document 的上下文中理解。关于运行模式的整体区别,可先回看第一章 1.5和第八章 8.1。延伸阅读:布局系统是构建交互式仪表板的基础,结合第六章的交互功能可以创建完整的分析应用。主题和样式的更多参考见附录。
5.1 为什么需要布局系统?
场景:你想在一个页面展示4个图表。
直觉做法:创建4个HTML文件,手动拼接。
Bokeh方式:使用布局函数
from bokeh.layouts import row, column, gridplot
layout = gridplot([[p1, p2], [p3, p4]])
show(layout)
5.2 基础布局
row - 水平排列:
from bokeh.layouts import row
layout = row(p1, p2, p3) # 水平排列
layout = row(p1, p2, sizing_mode="stretch_width") # 自适应宽度
column - 垂直排列:
from bokeh.layouts import column
layout = column(p1, p2, p3) # 垂直排列
layout = column(p1, p2, width=800) # 固定宽度
嵌套布局:
# 复杂布局
layout = column(
row(p1, p2),
row(p3, p4)
)
5.3 网格布局
gridplot - 网格排列:
from bokeh.layouts import gridplot
# 2x2网格
grid = gridplot([[p1, p2], [p3, p4]])
# 自动填充None
grid = gridplot([[p1, p2], [p3, None]]) # 右下角为空
# 统一工具栏
grid = gridplot([[p1, p2], [p3, p4]], toolbar_location='right')
# 合并工具栏
grid = gridplot([[p1, p2], [p3, p4]], merge_tools=True)
响应式网格:
grid = gridplot([[p1, p2], [p3, p4]],
sizing_mode="stretch_both", # 拉伸填充
toolbar_location=None,
)
5.4 标签页布局
from bokeh.models import Tabs, Panel
tab1 = Panel(child=p1, title="图表1")
tab2 = Panel(child=p2, title="图表2")
tab3 = Panel(child=p3, title="图表3")
tabs = Tabs(tabs=[tab1, tab2, tab3])
show(tabs)
5.5 样式定制
主题:
说明:
curdoc().theme是对当前 BokehDocument生效的全局主题设置。在 Bokeh Server 中最直观;如果你只是生成单个 standalone HTML,也可以理解为“给当前输出文档设置统一样式”。如果你只想改一个图,不一定要上升到主题层,直接设置p.title、p.axis、p.grid等属性通常更简单。输出方式的区别可参考第八章。
from bokeh.themes import Theme
from bokeh.io import curdoc
# 内置主题
curdoc().theme = "dark_minimal"
curdoc().theme = "caliber"
curdoc().theme = "night_sky"
# 自定义主题
theme = Theme(json={
'attrs': {
'Figure': {
'background_fill_color': '#f5f5f5',
'outline_line_color': 'gray',
},
'Axis': {
'axis_line_color': 'gray',
'major_tick_line_color': 'gray',
}
}
})
curdoc().theme = theme
标题样式:
p.title.text = "图表标题"
p.title.text_font_size = "20pt"
p.title.text_color = "navy"
p.title.align = "center"
p.title.background_fill_color = "#f0f0f0"
坐标轴样式:
# 轴标签
p.xaxis.axis_label = "X轴"
p.yaxis.axis_label = "Y轴"
p.xaxis.axis_label_text_font_size = "14pt"
# 刻度
p.xaxis.major_tick_line_color = "red"
p.xaxis.minor_tick_line_color = "gray"
p.xaxis.major_label_text_font_size = "10pt"
# 格式化
from bokeh.models import NumeralTickFormatter
p.yaxis.formatter = NumeralTickFormatter(format="0,0") # 千分位
p.yaxis.formatter = NumeralTickFormatter(format="$0.00") # 货币
网格线样式:
p.grid.grid_line_color = "gray"
p.grid.grid_line_alpha = 0.3
p.grid.grid_line_dash = "dashed"
# 只显示Y轴网格
p.xgrid.grid_line_color = None
图例样式:
p.legend.location = "top_right"
p.legend.orientation = "vertical" # 或 "horizontal"
p.legend.background_fill_color = "#f0f0f0"
p.legend.background_fill_alpha = 0.8
p.legend.border_line_color = "gray"
p.legend.label_text_font_size = "12pt"
# 图例交互
p.legend.click_policy = "hide" # 点击隐藏
p.legend.click_policy = "mute" # 点击静音(变淡)
工具栏样式:
p.toolbar_location = "above" # above, below, left, right, None
p.toolbar.logo = None # 隐藏logo
p.toolbar.autohide = True # 自动隐藏
5.6 响应式布局
使用 sizing_mode:
from bokeh.layouts import row
from bokeh.plotting import figure
p1 = figure(sizing_mode="stretch_width", height=300)
p2 = figure(sizing_mode="stretch_width", height=300)
layout = row(p1, p2, sizing_mode="stretch_width")
# sizing_mode 选项:
# - 'fixed':固定尺寸
# - 'stretch_width':拉伸宽度
# - 'stretch_height':拉伸高度
# - 'stretch_both':拉伸宽度和高度
# - 'scale_width':按比例缩放宽度
# - 'scale_height':按比例缩放高度
# - 'scale_both':按比例缩放宽度和高度
5.7 高级主题定制
创建自定义主题文件:
attrs:
Figure:
background_fill_color: '#f5f5f5'
outline_line_color: null
toolbar_location: above
Axis:
axis_line_color: '#DDDDDD'
major_tick_line_color: '#DDDDDD'
minor_tick_line_color: null
axis_label_text_font_size: '12pt'
major_label_text_font_size: '10pt'
Grid:
grid_line_color: '#EEEEEE'
Legend:
background_fill_color: '#FFFFFF'
border_line_color: '#DDDDDD'
Title:
text_font_size: '16pt'
text_color: '#333333'
应用主题文件:
from bokeh.themes import Theme
from bokeh.io import curdoc
theme = Theme(filename='theme.yaml')
curdoc().theme = theme
动态切换主题:
from bokeh.models import Select
def change_theme(attr, old, new):
curdoc().theme = new
theme_select = Select(
title="主题",
options=["caliber", "dark_minimal", "night_sky", "contrast"],
value="caliber"
)
theme_select.on_change('value', change_theme)
5.8 本章小结
本章你已经掌握了 Bokeh 中把多个图和控件组织成一个页面的基本方法:
- 知道何时使用
row()、column()、gridplot() - 了解了标签页布局的基本写法
- 学会通过标题、坐标轴、网格、图例、工具栏来调整视觉样式
- 初步理解了
sizing_mode对响应式布局的重要性 - 了解了主题适合“统一风格”,逐属性设置适合“局部精调”
如果你下一步想让页面“动起来”,建议继续阅读第六章:交互功能场景实战;如果你想把这些布局输出到 HTML、Notebook 或嵌入现有网页,可以接着看第八章:输出选项。
5.9 常见坑
坑 1:布局能显示,但尺寸很奇怪
最常见原因是图表本身和外层布局同时设置了不同的 sizing_mode,导致拉伸策略冲突。排查时建议:
- 先全部用固定宽高验证布局结构
- 再逐步引入
stretch_width或stretch_both - 优先让“外层布局控制伸缩,内层图表只设置必要高度”
如果你对尺寸控制还不熟,可以结合第八章一起看,因为不同输出环境对响应式布局的表现也会有差异。
坑 2:主题没有生效,或者效果和预期不一样
常见原因包括:
- 你修改的是单个图表属性,但同时又设置了全局主题
- 你在不同
Document上设置了主题 - 你本来只想局部微调,却用了全局主题
经验上可以这样选:
- 统一页面风格:优先用主题
- 只改一个图的局部样式:优先直接设置属性
- 需要动态切换主题:更适合在 Bokeh Server 应用里做
坑 3:图例、工具栏、控件一多,页面马上显得拥挤
这通常不是某个 API 的问题,而是信息层级没有整理好。可以考虑:
- 把次要图放到
Tabs - 合并多个图的工具栏
- 用交互筛选替代一次性展示全部内容
- 把“说明性文字”放到图外,而不是全塞进标题和图例
这类问题会在第六章里变得更明显,因为交互控件一多,布局设计就会直接影响可用性。
坑 4:布局已经复杂到像应用,但代码仍然像一次性脚本
当你开始同时管理多个图、多个控件、共享数据源和主题时,代码就已经接近“小型应用”了。这时建议:
- 把“数据准备”“图表创建”“布局组织”拆成不同函数
- 提前统一颜色、字体、图例位置等样式约定
- 明确哪些组件共享数据源,哪些只是并排展示
如果你的页面已经需要 Python 回调、状态同步和周期更新,说明你很可能应该转到第七章:Bokeh Server 进阶。
第六章:交互功能场景实战
前置知识:本章需要掌握 ColumnDataSource(第二章 2.2)、Figure 工具配置(第二章 2.3)和基础图表(第三章)。
6.1 场景一:悬停查看详情
需求:鼠标悬停在数据点上,显示详细信息。
实现:
from bokeh.models import HoverTool, ColumnDataSource
source = ColumnDataSource(data={
'x': [1, 2, 3, 4, 5],
'y': [2, 5, 8, 2, 7],
'product': ['A', 'B', 'C', 'D', 'E'],
'sales': [100, 200, 150, 80, 250],
})
p = figure(tools="", width=600, height=400)
p.circle('x', 'y', source=source, size=15)
# 添加悬停工具
hover = HoverTool(tooltips=[
("产品", "@product"),
("销量", "@sales{0,0}"), # 千分位格式
("坐标", "(@x, @y)"),
])
p.add_tools(hover)
高级悬停模板:
hover = HoverTool(tooltips="""
<div style="background: #333; color: white; padding: 10px; border-radius: 5px;">
<h3>@product</h3>
<p>销量: @sales{0,0}</p>
<p>占比: @percent{0.0%}</p>
</div>
""")
6.2 场景二:选择联动
需求:在图表A中选择数据点,图表B高亮显示对应数据。
实现:
from bokeh.models import ColumnDataSource
from bokeh.layouts import row
# 共享数据源
source = ColumnDataSource(data={
'x': [1, 2, 3, 4, 5],
'y': [2, 5, 8, 2, 7],
'category': ['A', 'B', 'C', 'D', 'E'],
})
# 图表1
p1 = figure(tools="box_select,lasso_select", width=400, height=400)
p1.circle('x', 'y', source=source, size=15,
selection_color="red", nonselection_color="gray")
# 图表2 - 使用相同数据源
p2 = figure(width=400, height=400)
p2.circle('x', 'y', source=source, size=15,
selection_color="red", nonselection_color="gray")
# 选择自动联动(因为共享数据源)
show(row(p1, p2))
6.3 场景三:动态控制
需求:通过滑块控制显示的数据范围。
CustomJS实现(纯前端):
from bokeh.models import Slider, CustomJS
source = ColumnDataSource(data={
'x': list(range(100)),
'y': [i**0.5 for i in range(100)],
})
p = figure(width=600, height=400)
p.line('x', 'y', source=source)
slider = Slider(start=0, end=99, value=99, title="显示点数")
# JavaScript回调
slider.js_on_change('value', CustomJS(args=dict(source=source), code="""
const data = source.data;
const n = cb_obj.value;
source.change.emit();
"""))
show(column(slider, p))
6.4 场景四:点击事件
需求:点击数据点,显示详细信息面板。
实现:
from bokeh.models import TapTool, CustomJS
source = ColumnDataSource(data={
'x': [1, 2, 3, 4, 5],
'y': [2, 5, 8, 2, 7],
'name': ['A', 'B', 'C', 'D', 'E'],
'info': ['详情A', '详情B', '详情C', '详情D', '详情E'],
})
p = figure(tools="tap", width=600, height=400)
renderers = p.circle('x', 'y', source=source, size=20)
# 点击回调
tap = p.select(type=TapTool)
tap.callback = CustomJS(args=dict(source=source), code="""
const indices = source.selected.indices;
if (indices.length > 0) {
const idx = indices[0];
alert('选中: ' + source.data['name'][idx] + '\\n' + source.data['info'][idx]);
}
""")
6.5 工具详解
工具分类:
| 类别 | 工具 | 说明 |
|---|---|---|
| 导航 | PanTool, WheelZoomTool, BoxZoomTool | 平移缩放 |
| 选择 | BoxSelectTool, LassoSelectTool, PolySelectTool | 区域选择 |
| 检查 | HoverTool, CrosshairTool, InspectTool | 数据检查 |
| 编辑 | PointDrawTool, BoxEditTool, PolyDrawTool | 数据编辑 |
| 操作 | ResetTool, SaveTool, UndoTool, RedoTool | 操作工具 |
添加工具:
# 方式1:字符串
p = figure(tools="pan,wheel_zoom,box_zoom,reset,save")
# 方式2:工具对象
from bokeh.models import HoverTool, BoxSelectTool
p = figure(tools=[HoverTool(), BoxSelectTool()])
# 方式3:动态添加
p.add_tools(HoverTool(tooltips=[("x", "@x")]))
配置工具:
from bokeh.models import BoxSelectTool
p = figure(tools="box_select")
box_select = p.select(type=BoxSelectTool)
box_select.dimensions = "width" # 只能水平选择
box_select.select_every_mousemove = False
6.6 链接轴与链接刷选
链接轴(共享坐标轴):
# 共享x轴
p1 = figure(width=400, height=300)
p2 = figure(width=400, height=300, x_range=p1.x_range) # 共享x轴
# 现在缩放p1,p2会同步
链接刷选(共享数据源):
source = ColumnDataSource(data={...})
p1 = figure()
p1.circle('x', 'y', source=source)
p2 = figure()
p2.circle('a', 'b', source=source) # 同一数据源
# 选择任一图表,另一个同步高亮
6.7 CustomJS 回调
基础 CustomJS 回调:
from bokeh.models import CustomJS, Slider
slider = Slider(start=0, end=10, value=1, title="Size")
# 定义 JavaScript 回调
callback = CustomJS(args=dict(source=source), code="""
const data = source.data;
const size = cb_obj.value;
data['size'] = data['size'].map(() => size);
source.change.emit();
""")
slider.js_on_change('value', callback)
⚠️ 重要提示:Python 回调(
on_change、on_event)只能在 Bokeh Server 中运行。在show()或save()模式下使用 Python 回调不会报错,但回调函数不会执行。如果需要纯前端交互,请使用 CustomJS。Bokeh Server 的完整用法见第七章。
Python 回调(Bokeh Server):
运行要求:下面的
on_change()示例必须放在 Bokeh Server 应用中执行,例如保存为app.py后运行bokeh serve app.py --show。如果只是用show()/save()生成 standalone HTML,请改用上面的CustomJS/js_on_change()写法。关于 Bokeh Server 应用结构、curdoc()和部署方式,详见第七章;如果你的回调主要是在更新ColumnDataSource,也建议回看第二章 2.3的数据更新方式。
from bokeh.models import Slider
def update_data(attr, old, new):
# Python 回调函数
source.data = compute_new_data(new)
slider = Slider(start=0, end=10, value=1)
slider.on_change('value', update_data)
6.8 自定义工具
from bokeh.models import CustomAction
custom_action = CustomAction(
name="Custom Action",
callback=CustomJS(code="console.log('Custom action triggered')")
)
p.add_tools(custom_action)
6.9 交互设计模式
在实际项目中,交互功能往往不是孤立使用的,而是按照一定的设计模式组合,以满足特定的分析需求。本节介绍五种常见的交互设计模式及其在 Bokeh 中的实现方式。
以下模式共享相同的基础导入,不再在每个示例中重复:
# 所有模式的公共模板
from bokeh.models import ColumnDataSource, CustomJS, HoverTool
from bokeh.plotting import figure, show
from bokeh.layouts import row, column
from bokeh.io import output_notebook
import numpy as np
output_notebook()
主从联动模式(Master-Detail)
概念:用户在一个“主“视图中选择数据子集,“从“视图立即更新以展示选中数据的详细信息。这是仪表盘中最为经典的交互模式之一。
适用场景:
- 概览-详情浏览(如地图上选区域,旁边显示该区域详细数据)
- 列表-详情展示(如点击表格行,右侧展示完整信息)
核心代码——关键在于两个数据源 + selected 事件回调,将选中行复制到从数据源:
# 两个数据源:主视图全量数据,从视图初始为空
master_source = ColumnDataSource(data={
'x': np.random.randn(200), 'y': np.random.randn(200),
'sales': np.random.randint(50, 500, 200),
'profit': np.random.uniform(0.05, 0.35, 200),
})
detail_source = ColumnDataSource(data={'sales': [], 'profit': []})
# 主视图(启用 box_select / lasso_select 工具)
master_plot = figure(width=500, height=400, title="主视图:框选数据",
tools="box_select,lasso_select,reset,wheel_zoom")
master_plot.circle('x', 'y', source=master_source, size=8, alpha=0.6,
selection_color='orange', nonselection_alpha=0.2)
# 从视图
detail_plot = figure(width=400, height=400, title="从视图:选中数据详情")
detail_plot.circle('sales', 'profit', source=detail_source, size=10, color='orange')
# ★ 核心:选中事件回调——将选中索引的数据复制到 detail_source
master_source.selected.js_on_change('indices', CustomJS(
args=dict(master=master_source, detail=detail_source),
code="""
const indices = master.selected.indices;
const d = detail.data;
d['sales'] = [];
d['profit'] = [];
for (let i = 0; i < indices.length; i++) {
const idx = indices[i];
d['sales'].push(master.data['sales'][idx]);
d['profit'].push(master.data['profit'][idx]);
}
detail.change.emit();
"""
))
show(row(master_plot, detail_plot))
过滤钻取模式(Filter & Drill-down)
概念:通过级联过滤器,逐层缩小数据范围,最终定位到感兴趣的数据子集。类似于数据库查询中的 WHERE 子句逐步叠加。
适用场景:
- 多维度数据分析(如地区→城市→门店逐级下钻)
- 过滤条件之间存在依赖关系(级联动)
核心代码——关键在于 Select 控件级联 + CustomJS 过滤逻辑:
from bokeh.models import Select
# 层级数据
regions_cities = {
'华东': ['上海', '杭州', '南京'],
'华北': ['北京', '天津', '石家庄'],
}
products = ['手机', '电脑', '平板']
# 构造扁平数据 + 全量/过滤 两个数据源
data = {'region': [], 'city': [], 'product': [], 'sales': []}
for region, cities in regions_cities.items():
for city in cities:
for product in products:
data['region'].append(region)
data['city'].append(city)
data['product'].append(product)
data['sales'].append(np.random.randint(100, 1000))
full_source = ColumnDataSource(data=data)
filtered_source = ColumnDataSource(data={k: list(v) for k, v in data.items()})
# 三个级联 Select
region_select = Select(title="地区:", value='全部',
options=['全部'] + list(regions_cities.keys()))
city_select = Select(title="城市:", value='全部',
options=['全部'] + sum(regions_cities.values(), []))
product_select = Select(title="产品:", value='全部', options=['全部'] + products)
# 图表
p = figure(width=700, height=350, x_range=products, tools="hover,wheel_zoom,reset")
p.vbar(x='product', top='sales', source=filtered_source, width=0.5)
# ★ 核心:级联过滤回调——根据三个 Select 的值过滤数据,并动态更新城市选项
filter_callback = CustomJS(
args=dict(full=full_source, filtered=filtered_source,
region_sel=region_select, city_sel=city_select,
product_sel=product_select, regions_cities=regions_cities),
code="""
const region = region_sel.value;
const product = product_sel.value;
// 级联:更新城市下拉框选项
let cities = ['全部'];
if (region !== '全部' && regions_cities[region]) {
cities = cities.concat(regions_cities[region]);
} else {
cities = cities.concat(Object.values(regions_cities).flat());
}
city_sel.options = cities;
if (!cities.includes(city_sel.value)) city_sel.value = '全部';
const city = city_sel.value;
// 过滤数据
const fd = filtered.data, sd = full.data;
for (const key of Object.keys(fd)) fd[key] = [];
for (let i = 0; i < sd['region'].length; i++) {
if ((region === '全部' || sd['region'][i] === region) &&
(city === '全部' || sd['city'][i] === city) &&
(product === '全部' || sd['product'][i] === product)) {
for (const key of Object.keys(fd)) fd[key].push(sd[key][i]);
}
}
filtered.change.emit();
"""
)
for sel in [region_select, city_select, product_select]:
sel.js_on_change('value', filter_callback)
show(column(region_select, city_select, product_select, p))
参数控制模式(Parameter Control)
概念:通过滑块、输入框等控件,让用户动态调整可视化参数(如大小、透明度、颜色范围),图表实时响应。这是最简洁的模式——回调仅修改 renderer.glyph 的属性。
适用场景:
- 参数敏感性分析(如调整阈值观察分类结果变化)
- 自定义视图外观(如调整点大小、透明度)
核心代码——仅两行回调,直接修改 glyph 属性:
from bokeh.models import Slider, ColorBar
from bokeh.transform import linear_cmap
from bokeh.palettes import Viridis256
source = ColumnDataSource(data={
'x': np.random.randn(300), 'y': np.random.randn(300),
'value': np.random.uniform(0, 100, 300),
})
mapper = linear_cmap('value', Viridis256, low=0, high=100)
p = figure(width=600, height=400, tools="wheel_zoom,pan,reset,hover")
renderer = p.circle('x', 'y', source=source, size=8, alpha=0.6, color=mapper)
point_size = Slider(start=2, end=30, value=8, step=1, title="点大小")
alpha_slider = Slider(start=0.1, end=1.0, value=0.6, step=0.05, title="透明度")
# ★ 核心:直接修改 renderer.glyph 属性,无需重绘数据
point_size.js_on_change('value', CustomJS(
args=dict(renderer=renderer), code="renderer.glyph.size = cb_obj.value;"))
alpha_slider.js_on_change('value', CustomJS(
args=dict(renderer=renderer), code="renderer.glyph.alpha = cb_obj.value;"))
show(column(row(point_size, alpha_slider), p))
实时监控模式(Real-time Monitoring)
概念:数据通过定时更新或流式传输不断涌入,图表自动刷新以展示最新状态。此模式必须使用 Bokeh Server(curdoc()),纯前端 show() 无法运行定时器。
适用场景:
- 系统监控(CPU、内存、网络流量)
- 传感器数据或业务指标实时看板
核心代码——需要 bokeh serve 运行,关键在于 periodic_callback + source.stream():
# 文件名:realtime_monitor.py
# 运行:bokeh serve realtime_monitor.py --show
from bokeh.plotting import figure, curdoc
from bokeh.models import ColumnDataSource
from bokeh.layouts import column
from datetime import datetime
MAX_POINTS = 100
source = ColumnDataSource(data={'time': [], 'value': [], 'value2': [], 'mean': []})
p = figure(width=800, height=400, title="实时监控面板",
x_axis_type="datetime", tools="pan,wheel_zoom,reset,hover,crosshair")
p.line('time', 'value', source=source, color='steelblue', line_width=2, legend_label="指标A")
p.line('time', 'value2', source=source, color='coral', line_width=2, legend_label="指标B")
p.line('time', 'mean', source=source, color='green', line_dash='dashed', legend_label="移动平均")
p.legend.location = "top_left"
counter = [0]
def update():
"""★ 核心:定时回调——用 stream() 追加数据,自动滚动"""
counter[0] += 1
t = counter[0] * 0.1
now = datetime.now()
new_val = 50 + 20 * np.sin(t) + np.random.randn() * 5
source.stream({'time': [now], 'value': [new_val],
'value2': [new_val * 0.8 + 10]}, MAX_POINTS)
# 移动平均
values = source.data['value']
mean_val = np.mean(values[-min(10, len(values)):])
means = list(source.data['mean']) + [mean_val]
source.data['mean'] = means[-MAX_POINTS:]
curdoc().add_root(column(p))
curdoc().add_periodic_callback(update, 500) # 每 500ms 执行一次
curdoc().title = "实时监控"
注意:此模式需要 Bokeh Server 支持,不能在纯 Notebook 中以
show()方式运行。
对比分析模式(Comparison)
概念:将两组或多组数据并排或叠加展示,支持交互式选择对比的维度和范围。
适用场景:
- 同比/环比分析(如今年 vs 去年)
- A/B 测试结果对比
核心代码——关键在于 Select 控件切换数据集 + 差异计算回调:
from bokeh.models import Select
months = ['1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月']
# 多个数据集(通过 Select 切换展示哪个)
datasets = {
'2023年': np.random.uniform(80, 150, 12).tolist(),
'2024年': np.random.uniform(100, 180, 12).tolist(),
'2024年目标': [150] * 12,
}
x_indices = list(range(12))
source_a = ColumnDataSource(data={'x': x_indices, 'y': datasets['2023年']})
source_b = ColumnDataSource(data={'x': x_indices, 'y': datasets['2024年']})
source_diff = ColumnDataSource(data={'x': x_indices, 'y': []})
sel_a = Select(title="数据集 A:", value="2023年", options=list(datasets.keys()))
sel_b = Select(title="数据集 B:", value="2024年", options=list(datasets.keys()))
p = figure(width=700, height=350, title="对比分析", x_range=months)
p.line('x', 'y', source=source_a, line_width=2.5, color='steelblue', legend_label="A")
p.line('x', 'y', source=source_b, line_width=2.5, color='coral', line_dash='dashed', legend_label="B")
p_diff = figure(width=700, height=180, title="差异(B - A)", x_range=months)
p_diff.vbar('x', top='y', source=source_diff, width=0.6, color='gray', alpha=0.7)
# ★ 核心:切换数据集时重新计算差异
compare_callback = CustomJS(
args=dict(datasets=datasets, source_a=source_a, source_b=source_b,
source_diff=source_diff, sel_a=sel_a, sel_b=sel_b),
code="""
const a = datasets[sel_a.value], b = datasets[sel_b.value];
source_a.data['y'] = a;
source_b.data['y'] = b;
source_diff.data['y'] = a.map((v, i) => b[i] - v);
source_a.change.emit();
source_b.change.emit();
source_diff.change.emit();
"""
)
sel_a.js_on_change('value', compare_callback)
sel_b.js_on_change('value', compare_callback)
show(column(row(sel_a, sel_b), p, p_diff))
模式选择指南
根据实际需求选择合适的交互模式,可以参考以下决策表:
| 需求描述 | 推荐模式 | 辅助模式 | Bokeh 关键能力 |
|---|---|---|---|
| “选中一个,看到细节” | 主从联动 | — | selected 事件、共享数据源 |
| “一步步缩小范围” | 过滤钻取 | — | Select 控件、CustomJS 过滤 |
| “我想调参数看效果” | 参数控制 | — | Slider、TextInput 控件 |
| “数据在实时产生” | 实时监控 | — | periodic_callback、stream() |
| “对比两组数据的差异” | 对比分析 | — | 多数据源叠加、差异计算 |
| “既要概览,又能看细节” | 主从联动 + 过滤钻取 | — | 组合使用 |
| “监控的同时可以调参” | 实时监控 + 参数控制 | — | 组合使用 |
模式组合建议:
概览型仪表盘 → 主从联动 + 过滤钻取
监控型仪表盘 → 实时监控 + 对比分析
分析型仪表盘 → 参数控制 + 过滤钻取
决策型仪表盘 → 对比分析 + 主从联动
6.10 交互体验最佳实践
优秀的交互不仅在于功能实现,更在于用户体验的细节打磨。本节从六个方面总结交互设计的最佳实践。
响应速度优化
交互的响应速度直接影响用户体验。以下是保持交互流畅的关键策略:
1. 使用 CustomJS 替代 Python 回调(适用于纯数据过滤和样式变更)
from bokeh.models import Slider, CustomJS, ColumnDataSource
from bokeh.plotting import figure, show
from bokeh.io import output_notebook
output_notebook()
source = ColumnDataSource(data={
'x': list(range(100)),
'y': [i ** 0.5 for i in range(100)],
})
p = figure(width=600, height=350)
renderer = p.circle('x', 'y', source=source, size=8)
slider = Slider(start=1, end=20, value=8, step=1, title="点大小")
# ✅ 推荐:使用 CustomJS 在前端直接修改 glyph 属性
slider.js_on_change('value', CustomJS(
args=dict(renderer=renderer),
code="""
renderer.glyph.size = {value: cb_obj.value};
"""
))
show(column(slider, p))
2. 数据分片加载
import numpy as np
from bokeh.models import ColumnDataSource
N_TOTAL = 100000
N_DISPLAY = 5000
# 只加载要显示的数据子集
all_data = {
'x': np.random.randn(N_TOTAL),
'y': np.random.randn(N_TOTAL),
}
display_indices = np.random.choice(N_TOTAL, N_DISPLAY, replace=False)
display_source = ColumnDataSource(data={
'x': all_data['x'][display_indices],
'y': all_data['y'][display_indices],
})
3. 避免不必要的数据重绘
from bokeh.models import CustomJS
# ✅ 推荐:只修改需要变化的属性
update_callback = CustomJS(args=dict(source=source), code="""
source.data['highlight'] = source.data['value'].map(
v => v > threshold ? 1 : 0
);
source.patch({
highlight: [/* 只发送变化的部分 */]
});
""")
# ❌ 不推荐:每次都替换全部数据
# source.data = new_full_data // 触发完整重绘
响应速度参考标准:
| 操作类型 | 建议响应时间 | 实现策略 |
|---|---|---|
| 悬停提示 | < 50ms | 内置 HoverTool |
| 选择高亮 | < 100ms | CustomJS + 共享数据源 |
| 过滤更新 | < 300ms | 前端 CustomJS 过滤 |
| 参数调整 | < 200ms | 直接修改 glyph 属性 |
| 全量刷新 | < 1s | 后端计算 + 数据替换 |
状态反馈
用户执行操作后,应提供清晰的视觉反馈,告知用户操作已被接收和处理。
1. 加载指示器
from bokeh.models import Div, CustomJS
loading_div = Div(text="", width=200, height=30)
loading_callback = CustomJS(
args=dict(loading_div=loading_div),
code="""
loading_div.text = '<span style="color: #666;">⏳ 数据加载中...</span>';
setTimeout(function() {
loading_div.text = '<span style="color: green;">✅ 加载完成</span>';
setTimeout(function() {
loading_div.text = '';
}, 3000);
}, 500);
"""
)
2. 选择状态计数
from bokeh.models import ColumnDataSource, Div, CustomJS
source = ColumnDataSource(data={
'x': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
'y': [2, 5, 3, 8, 1, 6, 4, 9, 7, 2],
})
status_div = Div(text="已选中: 0 项", width=200, height=30)
source.selected.js_on_change('indices', CustomJS(
args=dict(source=source, status_div=status_div),
code="""
const count = source.selected.indices.length;
const total = source.data['x'].length;
status_div.text = '已选中: ' + count + ' / ' + total + ' 项';
"""
))
3. 操作确认提示
from bokeh.models import Button, Div, CustomJS
confirm_div = Div(text="", width=300, height=30)
reset_button = Button(label="重置筛选", button_type="warning")
reset_button.js_on_click(CustomJS(
args=dict(confirm_div=confirm_div),
code="""
confirm_div.text = '<span style="color: orange;">⚠️ 筛选条件已重置</span>';
setTimeout(function() { confirm_div.text = ''; }, 2000);
"""
))
渐进式披露
避免一次性展示过多信息,根据用户的操作逐步展示更多细节。
1. 分层信息展示
from bokeh.models import HoverTool, CustomJS, ColumnDataSource, Div
from bokeh.plotting import figure, show
from bokeh.layouts import column
from bokeh.io import output_notebook
output_notebook()
source = ColumnDataSource(data={
'x': [1, 2, 3, 4, 5],
'y': [2, 5, 8, 2, 7],
'name': ['产品A', '产品B', '产品C', '产品D', '产品E'],
'summary': ['销量: 100', '销量: 200', '销量: 150',
'销量: 80', '销量: 250'],
'detail': ['华东区领先,月增5%', '全国热销,库存紧张',
'新品推广期', '清仓处理中', '年度爆款,同比+30%'],
})
# 第一层:悬停显示摘要
hover = HoverTool(tooltips=[
("名称", "@name"),
("概要", "@summary"),
])
# 第二层:点击显示完整详情
detail_div = Div(
text="<i>点击数据点查看详细信息</i>",
width=400, height=60,
)
p = figure(tools=[hover, "tap", "wheel_zoom", "reset"],
width=500, height=350)
p.circle('x', 'y', source=source, size=15, color='steelblue')
source.selected.js_on_change('indices', CustomJS(
args=dict(source=source, detail_div=detail_div),
code="""
const indices = source.selected.indices;
if (indices.length > 0) {
const idx = indices[0];
detail_div.text = '<b>' + source.data['name'][idx] + '</b><br>'
+ source.data['summary'][idx] + '<br>'
+ '<span style="color: #666;">'
+ source.data['detail'][idx] + '</span>';
}
"""
))
show(column(p, detail_div))
2. 可折叠面板
from bokeh.models import CheckboxGroup, CustomJS
from bokeh.layouts import column
from bokeh.io import output_notebook, show
output_notebook()
# 使用 CheckboxGroup 控制信息层的显示
info_toggle = CheckboxGroup(
labels=["显示数据表格", "显示统计信息", "显示趋势线"],
active=[0],
)
info_toggle.js_on_change('active', CustomJS(
args=dict(toggle=info_toggle),
code="""
// 根据勾选项控制对应元素的可见性
const active = toggle.active;
console.log('当前激活项:', active);
"""
))
show(column(info_toggle))
一致性
在同一仪表盘或应用中保持交互模式的一致性,降低用户学习成本。
一致性检查清单:
| 维度 | 一致性要求 | 示例 |
|---|---|---|
| 颜色语义 | 同一类别始终使用相同颜色 | 销售额始终为蓝色,利润始终为绿色 |
| 选择行为 | 选择操作方式统一 | 统一使用点击选择或框选 |
| 提示格式 | HoverTool 格式统一 | 所有图表的提示框使用相同的排版 |
| 导航方式 | 缩放和平移行为一致 | 所有图表共享缩放范围或独立缩放明确标注 |
| 控件风格 | 控件外观和排列统一 | 所有滑块从左到右排列,标签在上方 |
统一 HoverTool 格式的示例:
from bokeh.models import HoverTool
# 定义统一的提示框模板
def create_standard_hover(extra_fields=None):
"""创建标准格式的 HoverTool"""
tooltips = [
("名称", "@name"),
("数值", "@value{0,0.00}"),
("占比", "@percent{0.0%}"),
]
if extra_fields:
tooltips.extend(extra_fields)
return HoverTool(tooltips=tooltips)
# 所有图表使用相同格式
hover1 = create_standard_hover([("地区", "@region")])
hover2 = create_standard_hover([("时间", "@date{%Y-%m}")])
统一颜色映射的示例:
from bokeh.transform import factor_cmap
from bokeh.palettes import Category10
# 定义全局颜色映射
CATEGORY_COLORS = {
'电子产品': Category10[10][0],
'服装': Category10[10][1],
'食品': Category10[10][2],
'图书': Category10[10][3],
'家居': Category10[10][4],
}
# 在多个图表中复用同一映射
color_mapper = factor_cmap(
'category',
palette=list(CATEGORY_COLORS.values()),
factors=list(CATEGORY_COLORS.keys()),
)
交互复杂度控制
并非所有场景都需要复杂的交互。根据用户技术水平和使用频率选择合适的交互复杂度。
复杂度分级:
| 级别 | 交互形式 | 适用场景 | 用户要求 |
|---|---|---|---|
| L0 - 静态 | 无交互,仅展示 | 报告、截图导出 | 无 |
| L1 - 浏览 | 悬停、缩放、平移 | 日常查看、演示 | 基础 |
| L2 - 选择 | 点选、框选、联动 | 数据探索 | 中等 |
| L3 - 控制 | 滑块、过滤器、参数 | 深度分析 | 较高 |
| L4 - 编辑 | 绘制、标注、修改 | 数据标注、设计 | 专业 |
建议:
- 面向非技术用户:最高到 L1-L2
- 面向分析师:L2-L3
- 面向数据科学家:L3-L4
- 演示场景:使用 L0-L1,确保稳定性
移动端适配
移动设备上的交互需要特别考虑触摸操作和屏幕尺寸。
1. 触摸友好的交互元素
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource
source = ColumnDataSource(data={'x': [1, 2, 3], 'y': [4, 5, 6]})
# 移动端适配:增大点击区域
p = figure(
width=400, height=350,
tools="tap,pan,wheel_zoom",
sizing_mode="stretch_width", # 自适应宽度
)
# 使用较大的数据点和字体
p.circle('x', 'y', source=source, size=25, alpha=0.7) # 更大的点击目标
p.title.text_font_size = "16pt"
p.axis.axis_label_text_font_size = "12pt"
p.axis.major_label_text_font_size = "10pt"
2. 响应式布局
from bokeh.layouts import gridplot
from bokeh.models import Div
# 使用 sizing_mode 实现响应式
title = Div(
text="<h2>响应式仪表盘</h2>",
sizing_mode="stretch_width",
)
p1 = figure(width=400, height=300, sizing_mode="scale_width")
p2 = figure(width=400, height=300, sizing_mode="scale_width")
# gridplot 支持自动换行
grid = gridplot(
[[p1, p2]],
sizing_mode="stretch_width",
)
# 移动端提示
mobile_hint = Div(
text="<small>📱 双指缩放 | 单指平移 | 点击选择</small>",
sizing_mode="stretch_width",
)
3. 简化移动端工具栏
from bokeh.plotting import figure
# 移动端精简工具
p_mobile = figure(
tools="tap,pan,wheel_zoom,reset", # 只保留核心工具
toolbar_location="below", # 工具栏放底部,方便拇指操作
)
# 隐藏不常用的工具
p_mobile.toolbar.logo = None # 移除 logo 节省空间
6.11 无障碍访问指导
数据可视化应尽可能让所有人都能使用,包括视觉障碍、色觉缺陷和运动障碍的用户。本节介绍如何在 Bokeh 中实现无障碍访问支持。
键盘导航
确保图表可以通过键盘进行基本操作,这对于无法使用鼠标的用户至关重要。
实现思路:
Bokeh 的图表渲染在 HTML Canvas 上,Canvas 本身不支持键盘焦点导航。因此需要通过额外的 HTML 元素和事件监听来补充键盘交互能力。
from bokeh.models import CustomJS, ColumnDataSource, Div, Button
from bokeh.plotting import figure, show
from bokeh.layouts import column
from bokeh.io import output_notebook
output_notebook()
source = ColumnDataSource(data={
'x': [1, 2, 3, 4, 5],
'y': [2, 5, 8, 2, 7],
'name': ['产品A', '产品B', '产品C', '产品D', '产品E'],
'value': [100, 200, 150, 80, 250],
})
p = figure(width=600, height=400, title="键盘导航示例")
renderers = p.circle('x', 'y', source=source, size=15, color='steelblue')
# 键盘焦点信息面板
focus_info = Div(
text="<i>使用下方按钮或键盘方向键浏览数据点</i>",
width=400, height=40,
)
# 当前焦点索引(通过 Div 的 text 属性在 JS 中传递状态)
focus_state = Div(text="0", visible=False)
# 上一个/下一个按钮
prev_btn = Button(label="← 上一个", button_type="primary")
next_btn = Button(label="下一个 →", button_type="primary")
prev_btn.js_on_click(CustomJS(
args=dict(source=source, focus_info=focus_info, focus_state=focus_state),
code="""
let idx = parseInt(focus_state.text) || 0;
idx = (idx - 1 + source.data['x'].length) % source.data['x'].length;
focus_state.text = String(idx);
focus_info.text = '<b>' + source.data['name'][idx] + '</b>: '
+ '值 = ' + source.data['value'][idx];
// 高亮当前数据点
const n = source.data['x'].length;
const colors = [];
const sizes = [];
for (let i = 0; i < n; i++) {
colors.push(i === idx ? 'red' : 'steelblue');
sizes.push(i === idx ? 25 : 15);
}
source.data['_focus_color'] = colors;
source.data['_focus_size'] = sizes;
source.change.emit();
"""
))
next_btn.js_on_click(CustomJS(
args=dict(source=source, focus_info=focus_info, focus_state=focus_state),
code="""
let idx = parseInt(focus_state.text) || 0;
idx = (idx + 1) % source.data['x'].length;
focus_state.text = String(idx);
focus_info.text = '<b>' + source.data['name'][idx] + '</b>: '
+ '值 = ' + source.data['value'][idx];
// 高亮当前数据点
const n = source.data['x'].length;
const colors = [];
const sizes = [];
for (let i = 0; i < n; i++) {
colors.push(i === idx ? 'red' : 'steelblue');
sizes.push(i === idx ? 25 : 15);
}
source.data['_focus_color'] = colors;
source.data['_focus_size'] = sizes;
source.change.emit();
"""
))
show(column(focus_info, prev_btn, next_btn, p))
键盘导航最佳实践:
- 提供明确的焦点指示(高亮当前项)
- 支持 Tab 键在控件间切换
- 使用方向键在数据点间移动
- Enter/Space 触发选择操作
- Escape 取消或返回上级
色盲友好配色
约 8% 的男性和 0.5% 的女性存在色觉缺陷。使用色盲友好的配色方案是可视化无障碍的基本要求。
Bokeh 内置的色盲友好调色板:
from bokeh.palettes import colorblind
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource
from bokeh.io import output_notebook
output_notebook()
# Bokeh 提供了专门的色盲友好调色板
# colorblind 调色板: ['Colorblind'][8]
print("色盲友好调色板:", colorblind['Colorblind'][8])
# 使用色盲友好调色板
categories = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
values = [25, 40, 30, 55, 35, 45, 20, 50]
source = ColumnDataSource(data={
'x': categories,
'top': values,
'color': colorblind['Colorblind'][8],
})
p = figure(width=600, height=400, title="色盲友好配色示例",
x_range=categories, tools="hover")
p.vbar(x='x', top='top', source=source, width=0.6,
color='color', alpha=0.8)
show(p)
推荐的色盲友好配色策略:
from bokeh.palettes import Viridis256, Cividis256, colorblind
import colorsys
# 策略1:使用感知均匀的连续调色板
# Viridis 和 Cividis 对色觉缺陷用户友好
sequential_palette = Viridis256 # 推荐用于连续数据
diverging_safe = Cividis256 # 推荐用于所有类型色觉缺陷
# 策略2:使用 Bokeh 内置的色盲友好分类调色板
categorical_safe = colorblind['Colorblind'][8]
# 策略3:自定义色盲友好配色
# 以下配色经过验证,在三种主要色觉缺陷下均可区分
COLORBLIND_SAFE_6 = [
'#0072B2', # 蓝色
'#E69F00', # 橙色
'#009E73', # 绿色
'#D55E00', # 朱红色
'#CC79A7', # 粉紫色
'#56B4E9', # 天蓝色
'#F0E442', # 黄色
'#000000', # 黑色
]
# 策略4:除了颜色,额外使用形状或图案区分
# 在散点图中同时使用颜色和标记形状
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource
markers = ['circle', 'square', 'triangle', 'diamond']
验证配色方案的色盲安全性:
from bokeh.palettes import Category10, colorblind
from bokeh.plotting import figure, show
from bokeh.layouts import row
from bokeh.models import ColumnDataSource
from bokeh.io import output_notebook
output_notebook()
# 对比展示:普通调色板 vs 色盲友好调色板
categories = ['类别A', '类别B', '类别C', '类别D']
values = [30, 50, 20, 40]
# 普通调色板
source1 = ColumnDataSource(data={
'x': categories, 'top': values,
'color': Category10[4],
})
# 色盲友好调色板
source2 = ColumnDataSource(data={
'x': categories, 'top': values,
'color': colorblind['Colorblind'][4],
})
p1 = figure(width=350, height=300, title="普通调色板",
x_range=categories, tools="hover")
p1.vbar(x='x', top='top', source=source1, width=0.6, color='color')
p2 = figure(width=350, height=300, title="色盲友好调色板",
x_range=categories, tools="hover")
p2.vbar(x='x', top='top', source=source2, width=0.6, color='color')
show(row(p1, p2))
屏幕阅读器支持
为图表添加 ARIA 标签和描述,使屏幕阅读器能够向用户传达图表内容。
1. 使用 Div 添加图表描述
from bokeh.models import Div, ColumnDataSource
from bokeh.plotting import figure, show
from bokeh.layouts import column
from bokeh.io import output_notebook
output_notebook()
source = ColumnDataSource(data={
'x': [1, 2, 3, 4, 5],
'y': [2, 5, 8, 2, 7],
'name': ['产品A', '产品B', '产品C', '产品D', '产品E'],
})
p = figure(width=600, height=400, title="月度销售趋势")
p.line('x', 'y', source=source, line_width=2, color='steelblue')
p.circle('x', 'y', source=source, size=10, color='steelblue')
# 为屏幕阅读器提供详细的图表描述
chart_description = Div(text="""
<div role="img" aria-label="月度销售趋势折线图"
aria-describedby="chart-desc">
<p id="chart-desc" class="sr-only">
折线图显示5个产品的月度销售趋势。
X轴为产品编号(产品A到产品E),
Y轴为销售额。
产品C的销售额最高(8),
产品D的销售额最低(2)。
整体趋势为先升后降再回升。
</p>
</div>
""", visible=True)
# 辅助样式:屏幕阅读器可见但视觉隐藏
sr_style = Div(text="""
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
""")
show(column(sr_style, chart_description, p))
2. 动态更新 ARIA 描述
from bokeh.models import CustomJS, Div
# 当数据更新时,同步更新图表的文字描述
aria_div = Div(text="", width=600, height=30)
update_aria = CustomJS(
args=dict(source=source, aria_div=aria_div),
code="""
const x = source.data['x'];
const y = source.data['y'];
const max_idx = y.indexOf(Math.max(...y));
const min_idx = y.indexOf(Math.min(...y));
aria_div.text = '图表概要: 共 ' + x.length + ' 个数据点, '
+ '最大值 ' + Math.max(...y).toFixed(1)
+ ' (第' + (max_idx + 1) + '项), '
+ '最小值 ' + Math.min(...y).toFixed(1)
+ ' (第' + (min_idx + 1) + '项).';
"""
)
source.js_on_change('data', update_aria)
替代文本
为图表提供文字替代方案,当图表无法显示或用户使用辅助技术时,仍然可以获取数据信息。
1. 基础替代文本
from bokeh.models import Div, ColumnDataSource, DataTable, TableColumn
from bokeh.plotting import figure, show
from bokeh.layouts import column, tabs
from bokeh.io import output_notebook
output_notebook()
source = ColumnDataSource(data={
'category': ['A', 'B', 'C', 'D', 'E'],
'value': [25, 40, 30, 55, 35],
'description': [
'市场份额25%,同比增长5%',
'市场份额40%,同比下降2%',
'市场份额30%,同比增长8%',
'市场份额55%,同比增长12%',
'市场份额35%,同比持平',
],
})
# 图表视图
p = figure(width=500, height=350, title="市场份额分布",
x_range=source.data['category'])
p.vbar(x='category', top='value', source=source, width=0.5,
color='steelblue', alpha=0.7)
# 文字替代:数据表格
columns = [
TableColumn(field="category", title="类别"),
TableColumn(field="value", title="数值"),
TableColumn(field="description", title="说明"),
]
data_table = DataTable(source=source, columns=columns,
width=500, height=200)
# 文字摘要
summary = Div(text="""
<h3>数据摘要</h3>
<p>本图表展示5个类别的市场份额分布。</p>
<ul>
<li>最高:类别D(55%)</li>
<li>最低:类别A(25%)</li>
<li>平均值:37%</li>
</ul>
<p>总体趋势:类别D领先,类别A和B相对较低。</p>
""", width=500)
show(column(p, data_table, summary))
2. 自动生成数据摘要
from bokeh.models import CustomJS, Div
def create_data_summary_div(source, x_field, y_field, title="图表摘要"):
"""创建自动更新的数据摘要 Div"""
return Div(text="", width=600, height=80)
summary_div = create_data_summary_div(source, 'category', 'value')
# 使用 CustomJS 自动计算并展示摘要
summary_callback = CustomJS(
args=dict(source=source, summary_div=summary_div, title="市场份额分布"),
code="""
const values = source.data['value'];
const categories = source.data['category'];
const max_val = Math.max(...values);
const min_val = Math.min(...values);
const avg_val = values.reduce((a, b) => a + b, 0) / values.length;
const max_idx = values.indexOf(max_val);
const min_idx = values.indexOf(min_val);
summary_div.text = '<strong>' + title + ' 数据摘要:</strong>'
+ '共 ' + values.length + ' 个类别,'
+ '最高为 ' + categories[max_idx] + '(' + max_val.toFixed(1) + '),'
+ '最低为 ' + categories[min_idx] + '(' + min_val.toFixed(1) + '),'
+ '平均值为 ' + avg_val.toFixed(1) + '。';
"""
)
source.js_on_change('data', summary_callback)
高对比度模式
为需要高对比度的用户提供增强的视觉方案,确保在低视力或强光环境下仍然可读。
1. 高对比度配色方案
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource
from bokeh.io import output_notebook
output_notebook()
# 高对比度配色
HIGH_CONTRAST = {
'background': '#000000', # 纯黑背景
'foreground': '#FFFFFF', # 纯白前景
'colors': [
'#FFFFFF', # 白色
'#FFFF00', # 黄色
'#00FFFF', # 青色
'#FF00FF', # 品红
'#00FF00', # 绿色
'#FF6600', # 橙色
],
'grid': '#444444',
'axis': '#CCCCCC',
}
source = ColumnDataSource(data={
'x': [1, 2, 3, 4, 5],
'y': [2, 5, 8, 2, 7],
})
p = figure(
width=600, height=400,
title="高对比度模式",
background_fill_color=HIGH_CONTRAST['background'],
border_fill_color=HIGH_CONTRAST['background'],
)
# 设置高对比度样式
p.title.text_color = HIGH_CONTRAST['foreground']
p.title.text_font_size = "16pt"
p.xaxis.axis_line_color = HIGH_CONTRAST['axis']
p.yaxis.axis_line_color = HIGH_CONTRAST['axis']
p.xaxis.major_label_text_color = HIGH_CONTRAST['foreground']
p.yaxis.major_label_text_color = HIGH_CONTRAST['foreground']
p.xaxis.axis_label_text_color = HIGH_CONTRAST['foreground']
p.yaxis.axis_label_text_color = HIGH_CONTRAST['foreground']
p.xgrid.grid_line_color = HIGH_CONTRAST['grid']
p.ygrid.grid_line_color = HIGH_CONTRAST['grid']
p.outline_line_color = HIGH_CONTRAST['axis']
# 使用明亮颜色的数据点
p.circle('x', 'y', source=source, size=15,
color=HIGH_CONTRAST['colors'][0],
line_color=HIGH_CONTRAST['colors'][1],
line_width=2)
p.line('x', 'y', source=source, line_width=3,
color=HIGH_CONTRAST['colors'][1])
show(p)
2. 通过控件切换对比度模式
from bokeh.models import Select, CustomJS, ColumnDataSource
from bokeh.plotting import figure, show
from bokeh.layouts import column
from bokeh.io import output_notebook
output_notebook()
source = ColumnDataSource(data={
'x': [1, 2, 3, 4, 5],
'y': [2, 5, 8, 2, 7],
})
p = figure(width=600, height=400, title="可切换对比度模式")
renderer = p.circle('x', 'y', source=source, size=15,
color='steelblue', line_color='white', line_width=1)
line = p.line('x', 'y', source=source, line_width=2, color='steelblue')
# 对比度模式选择
contrast_select = Select(
title="显示模式:",
value="normal",
options=[
("normal", "标准模式"),
("high_contrast", "高对比度"),
("dark", "深色模式"),
],
)
contrast_select.js_on_change('value', CustomJS(
args=dict(p=p, renderer=renderer, line=line),
code="""
const mode = cb_obj.value;
if (mode === 'high_contrast') {
p.background_fill_color = '#000000';
p.border_fill_color = '#000000';
p.title.text_color = '#FFFFFF';
p.xaxis.major_label_text_color = '#FFFFFF';
p.yaxis.major_label_text_color = '#FFFFFF';
p.xaxis.axis_line_color = '#CCCCCC';
p.yaxis.axis_line_color = '#CCCCCC';
p.xgrid.grid_line_color = '#444444';
p.ygrid.grid_line_color = '#444444';
p.outline_line_color = '#CCCCCC';
renderer.glyph.fill_color = {value: '#FFFF00'};
renderer.glyph.line_color = {value: '#FFFFFF'};
line.glyph.line_color = {value: '#FFFF00'};
} else if (mode === 'dark') {
p.background_fill_color = '#1a1a2e';
p.border_fill_color = '#16213e';
p.title.text_color = '#e0e0e0';
p.xaxis.major_label_text_color = '#cccccc';
p.yaxis.major_label_text_color = '#cccccc';
p.xaxis.axis_line_color = '#555555';
p.yaxis.axis_line_color = '#555555';
p.xgrid.grid_line_color = '#333333';
p.ygrid.grid_line_color = '#333333';
p.outline_line_color = '#555555';
renderer.glyph.fill_color = {value: '#00d4ff'};
renderer.glyph.line_color = {value: '#e0e0e0'};
line.glyph.line_color = {value: '#00d4ff'};
} else {
p.background_fill_color = '#ffffff';
p.border_fill_color = '#ffffff';
p.title.text_color = '#333333';
p.xaxis.major_label_text_color = '#666666';
p.yaxis.major_label_text_color = '#666666';
p.xaxis.axis_line_color = '#cccccc';
p.yaxis.axis_line_color = '#cccccc';
p.xgrid.grid_line_color = '#eeeeee';
p.ygrid.grid_line_color = '#eeeeee';
p.outline_line_color = '#cccccc';
renderer.glyph.fill_color = {value: 'steelblue'};
renderer.glyph.line_color = {value: 'white'};
line.glyph.line_color = {value: 'steelblue'};
}
p.change.emit();
"""
))
show(column(contrast_select, p))
高对比度设计要点:
| 要素 | 标准模式 | 高对比度模式 | WCAG AA 标准 |
|---|---|---|---|
| 正文文字对比度 | 4.5:1 | 7:1+ | ≥ 4.5:1 |
| 大文字对比度 | 3:1 | 4.5:1+ | ≥ 3:1 |
| 数据点与背景 | 3:1 | 4.5:1+ | ≥ 3:1 |
| 网格线可见性 | 低对比 | 中等对比 | 不强制 |
| 交互元素边框 | 可选 | 必须明显 | ≥ 3:1 |
WCAG(Web Content Accessibility Guidelines) 是 Web 无障碍的国际标准。上述对比度要求来自 WCAG 2.1 AA 级别。对于数据可视化,建议至少满足 AA 级别。
6.11 本章小结
本章你已经接触了 Bokeh 中最核心的一组交互能力:
- 使用
HoverTool提供悬停提示 - 通过共享
ColumnDataSource实现链接刷选和多图联动 - 使用
CustomJS在浏览器端实现轻量交互 - 理解了 Python 回调与 Bokeh Server 的关系
- 看到了主从联动、过滤钻取、参数控制、实时监控、对比分析等常见交互模式
如果你发现自己已经不满足于“浏览器端小交互”,而是需要:
- 访问数据库或文件系统
- 执行真正的 Python 计算
- 做周期性刷新
- 管理多用户会话状态
那么下一步就应该进入第七章:Bokeh Server 进阶。
与此同时,如果你在实现交互时遇到下面几类问题,也可以回看对应章节:
6.12 常见坑
坑 1:把 Python 回调当成 standalone HTML 的能力
这是最常见的误解。on_change()、on_event() 这类 Python 回调只有在 Bokeh Server 环境里才会执行。
如果你是用 show()、save() 导出单个 HTML 文件,那么应该优先使用:
CustomJSjs_on_change()js_on_event()
坑 2:修改了 source.data,却没有触发前端更新
在 Python 端,给 source.data = {...} 整体赋值通常会触发更新。
但在 JavaScript 端,如果你是直接修改已有数组内容,通常还需要:
source.change.emit()
如果你对 ColumnDataSource 的结构和更新语义还不熟,建议回看第二章 2.3。
坑 3:联动没生效,其实是因为没有共享同一个数据源
很多“链接刷选失败”的根本原因不是工具配置错了,而是两个图并没有真正共享同一个 ColumnDataSource。
只要数据源不同:
- 选择状态不会同步
- 高亮不会自动联动
selected.indices也不会共享
坑 4:交互写得太复杂,却没有先区分运行模式
在开始写交互前,最好先回答一个问题:
你要的是 standalone 文档,还是 Bokeh Server 应用?
经验上可以这样判断:
- 只是 hover、筛选、前端联动:优先
CustomJS - 需要 Python 计算、周期更新、服务端状态:使用 Bokeh Server
这个判断会直接影响你后面选什么 API,也能避免大量返工。
坑 5:把所有交互都塞进一个页面
交互越多,不一定越好。
如果一个页面同时有太多:
- 滑块
- 下拉框
- 图例控制
- 过滤器
- 多图联动
- 实时刷新
用户反而会更难理解。更稳妥的做法通常是:
- 先保留最核心的一个交互路径
- 再逐步增加辅助控件
- 必要时拆成多个视图或标签页
延伸阅读:本章的 Python 回调(6.7 节中的
on_change)需要 Bokeh Server 支持,详见第七章。交互功能的性能优化技巧见第九章;如果你需要把交互结果输出到 Notebook、HTML 或网页中,请继续阅读第八章。
第七章:Bokeh Server进阶
前置知识:本章需要了解 ColumnDataSource(第二章 2.2)和 CustomJS 回调(第六章 6.7)。Bokeh 三层架构的概念见第二章 2.2.6。
7.1 为什么需要Bokeh Server?
CustomJS的局限:
- 只能执行JavaScript,无法调用Python库
- 无法访问数据库、文件系统
- 无法进行复杂计算
Bokeh Server的优势:
- 回调函数用Python编写
- 可以访问任何Python库
- 支持实时数据更新
- 支持多用户会话
运行方式提醒:本章中的 Python 回调示例必须通过
bokeh serve运行。直接使用python app.py、show()或save()生成静态 HTML 时,Python 端回调不会执行;静态 HTML 交互应使用CustomJS。交叉引用:
7.2 第一个Bokeh Server应用
创建文件app.py:
这是一个 Bokeh Server 应用,请保存为
app.py后使用bokeh serve app.py --show运行。
# app.py
from bokeh.io import curdoc
from bokeh.plotting import figure
from bokeh.models import Slider, ColumnDataSource
from bokeh.layouts import column
import numpy as np
# 数据
x = np.linspace(0, 10, 100)
source = ColumnDataSource(data={'x': x, 'y': np.sin(x)})
# 图表
p = figure(title="正弦波", width=600, height=400)
p.line('x', 'y', source=source, line_width=2)
# 滑块
freq = Slider(title="频率", start=0.1, end=5, value=1, step=0.1)
# 回调
def update(attr, old, new):
new_y = np.sin(freq.value * x)
source.data = {'x': x, 'y': new_y}
freq.on_change('value', update)
# 布局
curdoc().add_root(column(freq, p))
curdoc().title = "正弦波演示"
运行:
bokeh serve app.py --show
7.3 Widgets小部件详解
数值输入类:
from bokeh.models import Slider, RangeSlider, Spinner
# 滑块
slider = Slider(title="数值", start=0, end=100, value=50, step=1)
# 范围滑块
range_slider = RangeSlider(title="范围", start=0, end=100, value=(20, 80))
# 数字输入框
spinner = Spinner(title="数量", low=0, high=1000, value=100, step=10)
选择类:
from bokeh.models import Select, MultiSelect, CheckboxGroup, RadioGroup
# 下拉选择
select = Select(title="类别", options=["A", "B", "C"], value="A")
# 多选下拉
multi_select = MultiSelect(title="多选", options=["A", "B", "C"], value=["A"])
# 复选框
checkbox = CheckboxGroup(labels=["选项1", "选项2", "选项3"], active=[0])
# 单选按钮
radio = RadioGroup(labels=["选项1", "选项2", "选项3"], active=0)
按钮类:
from bokeh.models import Button, Toggle, Dropdown
# 按钮
button = Button(label="点击")
# 开关按钮
toggle = Toggle(label="开关", active=False)
# 下拉按钮
dropdown = Dropdown(label="菜单", menu=[("选项1", "1"), ("选项2", "2")])
文本类:
from bokeh.models import TextInput, TextAreaInput, Div
# 文本输入
text_input = TextInput(title="输入", value="")
# 多行文本
text_area = TextAreaInput(title="描述", value="")
# 文本显示
div = Div(text="<h2>标题</h2><p>段落内容</p>")
日期类:
from bokeh.models import DatePicker, DateRangeSlider
from datetime import date
# 日期选择
date_picker = DatePicker(title="日期", value=date.today())
# 日期范围
date_range = DateRangeSlider(
title="日期范围",
start=date(2020, 1, 1),
end=date.today(),
value=(date(2023, 1, 1), date.today())
)
7.4 回调机制
on_change - 属性变化回调:
def callback(attr, old, new):
print(f"属性 {attr} 从 {old} 变为 {new}")
slider.on_change('value', callback)
on_click - 点击回调:
def callback():
print("按钮被点击")
button.on_click(callback)
on_event - 事件回调:
from bokeh.events import DoubleTap, Press
def callback(event):
print(f"双击位置: ({event.x}, {event.y})")
p.on_event(DoubleTap, callback)
选择回调:
def callback(attr, old, new):
indices = source.selected.indices
print(f"选中索引: {indices}")
source.selected.on_change('indices', callback)
7.5 实时数据更新
periodic_callback - 定时更新:
import random
from bokeh.io import curdoc
source = ColumnDataSource(data={'x': [], 'y': []})
p = figure()
p.line('x', 'y', source=source)
counter = [0]
def update():
counter[0] += 1
new_data = {'x': [counter[0]], 'y': [random.random()]}
source.stream(new_data, rollover=100) # 保留最近100个点
curdoc().add_root(p)
curdoc().add_periodic_callback(update, 100) # 每100ms更新
stream - 流式添加:
# 添加数据,保留最近N个
source.stream(new_data, rollover=100)
# 添加数据,无限制
source.stream(new_data)
patch - 局部更新:
# 更新特定位置的值
source.patch({
'y': [(0, new_value), (2, another_value)] # 更新索引0和2
})
7.6 完整示例:交互式仪表板
# dashboard.py
from bokeh.plotting import figure, curdoc
from bokeh.models import (
ColumnDataSource, Select, Slider, DataTable,
TableColumn, Div, HoverTool
)
from bokeh.layouts import column, row, gridplot
import pandas as pd
import numpy as np
# 模拟数据
np.random.seed(42)
n = 1000
df = pd.DataFrame({
'date': pd.date_range('2023-01-01', periods=n, freq='D'),
'category': np.random.choice(['A', 'B', 'C'], n),
'value': np.random.randn(n).cumsum(),
'volume': np.random.randint(100, 1000, n),
})
df['month'] = df['date'].dt.strftime('%Y-%m')
# 数据源
source = ColumnDataSource(df)
# 标题
title = Div(text="<h1>销售数据分析仪表板</h1>", width=800)
# 控件
category_select = Select(title="类别", options=['All'] + list(df['category'].unique()), value='All')
month_slider = Slider(title="显示天数", start=30, end=n, value=n, step=30)
# 主图表
p_main = figure(x_axis_type="datetime", width=800, height=400, title="趋势图")
p_main.line('date', 'value', source=source, line_width=2)
p_main.add_tools(HoverTool(tooltips=[("日期", "@date{%F}"), ("值", "@value{0.00}")],
formatters={'@date': 'datetime'}))
# 分布图
p_dist = figure(width=400, height=400, title="分布")
hist, edges = np.histogram(df['value'], bins=30)
p_dist.quad(top=hist, bottom=0, left=edges[:-1], right=edges[1:],
fill_color="skyblue", line_color="white")
# 数据表格
columns = [
TableColumn(field="date", title="日期", formatter=DateFormatter()),
TableColumn(field="category", title="类别"),
TableColumn(field="value", title="值"),
TableColumn(field="volume", title="销量"),
]
table = DataTable(source=source, columns=columns, width=800, height=200)
# 回调
def update_data(attr, old, new):
# 过滤类别
cat = category_select.value
days = month_slider.value
filtered = df.copy()
if cat != 'All':
filtered = filtered[filtered['category'] == cat]
filtered = filtered.tail(days)
source.data = ColumnDataSource.from_df(filtered)
category_select.on_change('value', update_data)
month_slider.on_change('value', update_data)
# 布局
controls = column(category_select, month_slider)
layout = column(
title,
row(controls, p_main),
row(p_dist, table)
)
curdoc().add_root(layout)
curdoc().title = "销售仪表板"
运行:
bokeh serve dashboard.py --show
7.7 应用模板结构
在继续看部署之前,建议先把这一节和第五章连起来理解:
- 第五章解决的是“页面怎么排版、控件和图表怎么组织”
- 本章解决的是“这些控件变化后,Python 代码如何真正响应”
- 第九章会继续讨论“这些回调和实时更新在数据量变大时如何优化”
如果你发现自己在仪表板代码里同时处理布局、数据过滤、回调、部署,通常说明已经进入了 Bokeh Server 的核心使用场景。
推荐的项目结构:
myapp/
├── main.py # 主应用文件
├── templates/
│ └── index.html # 自定义模板(可选)
├── static/
│ ├── css/
│ └── js/
├── data/
│ └── data.csv
└── requirements.txt
main.py 示例:
from bokeh.plotting import figure, curdoc
from bokeh.models import ColumnDataSource
# 初始化数据
source = ColumnDataSource(data=dict(x=[], y=[]))
# 创建图表
p = figure(title="实时数据")
p.line('x', 'y', source=source)
# 添加到文档
curdoc().add_root(p)
curdoc().title = "My App"
7.8 生产环境部署
方式1:使用 Nginx 反向代理
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:5006;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
方式2:使用 Docker
FROM python:3.11
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 5006
CMD ["bokeh", "serve", "--allow-websocket-origin=*", "."]
启动命令:
# 开发环境
bokeh serve myapp.py --show
# 生产环境
bokeh serve myapp.py \
--allow-websocket-origin=example.com \
--port=5006 \
--num-procs=4
7.9 高级部署方案
使用 Gunicorn + Supervisor:
# 安装 Gunicorn
pip install gunicorn
# 启动命令
gunicorn -w 4 -b 0.0.0.0:5006 \
-k bokeh.server.gunicorn.bokeh_worker.BokehWorker \
myapp:application
Supervisor 配置:
[program:bokeh_app]
command=/path/to/venv/bin/gunicorn -w 4 -b 127.0.0.1:5006 -k bokeh.server.gunicorn.bokeh_worker.BokehWorker myapp:application
directory=/path/to/app
user=www-data
autostart=true
autorestart=true
stderr_logfile=/var/log/bokeh/bokeh.err.log
stdout_logfile=/var/log/bokeh/bokeh.out.log
使用 Systemd:
[Unit]
Description=Bokeh Server Application
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/path/to/app
ExecStart=/path/to/venv/bin/bokeh serve --port=5006 --allow-websocket-origin=example.com
Restart=always
[Install]
WantedBy=multi-user.target
使用 Kubernetes:
apiVersion: apps/v1
kind: Deployment
metadata:
name: bokeh-app
spec:
replicas: 3
selector:
matchLabels:
app: bokeh
template:
metadata:
labels:
app: bokeh
spec:
containers:
- name: bokeh
image: my-bokeh-app:latest
ports:
- containerPort: 5006
env:
- name: BOKEH_ALLOW_WS_ORIGIN
value: "example.com"
---
apiVersion: v1
kind: Service
metadata:
name: bokeh-service
spec:
selector:
app: bokeh
ports:
- port: 80
targetPort: 5006
type: LoadBalancer
7.10 安全最佳实践
WebSocket安全配置:
# 限制WebSocket连接的来源
bokeh serve app.py \
--allow-websocket-origin=example.com \
--allow-websocket-origin=www.example.com
CORS设置:
# 在应用中配置CORS
from bokeh.server.server import Server
server = Server({'/app': app},
allow_websocket_origin=["example.com"],
port=5006)
身份验证集成:
# 使用Flask-Login进行身份验证
from flask_login import login_required
@app.route('/bokeh-app')
@login_required
def bokeh_app():
script = server_document('http://localhost:5006/app')
return render_template('app.html', script=script)
敏感数据保护:
# 使用环境变量存储敏感信息
import os
DB_PASSWORD = os.environ.get('DB_PASSWORD')
API_KEY = os.environ.get('API_KEY')
# 不要在代码中硬编码敏感信息
7.11 生产环境部署清单
部署前检查:
- 设置正确的
--allow-websocket-origin - 配置HTTPS证书
- 设置进程管理(systemd/supervisor)
- 配置反向代理(Nginx/Apache)
- 设置日志记录
- 配置监控和告警
环境配置:
# 生产环境启动命令
bokeh serve app.py \
--port=5006 \
--allow-websocket-origin=example.com \
--num-procs=4 \
--log-level=warning \
--address=127.0.0.1
性能调优:
# 使用数据流控制
source.stream(new_data, rollover=1000)
# 启用WebGL(大数据集)
p = figure(output_backend="webgl")
# 使用异步回调
curdoc().add_periodic_callback(update, 1000)
监控配置:
# 使用systemd监控
sudo systemctl enable bokeh-app
sudo systemctl status bokeh-app
# 查看日志
journalctl -u bokeh-app -f
Docker部署示例:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5006
HEALTHCHECK CMD curl -f http://localhost:5006/ || exit 1
CMD ["bokeh", "serve", "app.py", \
"--port=5006", \
"--allow-websocket-origin=*", \
"--num-procs=2"]
7.12 本章小结
这一章的重点不是“会写一个滑块回调”这么简单,而是理解 Bokeh Server 让浏览器里的交互继续连接到 Python。
到这里,你应该已经建立了下面这条主线:
- standalone HTML 适合浏览器端交互和
CustomJS - 一旦需要 Python 回调、周期更新、访问数据库或文件系统,就进入 Bokeh Server 场景
- Bokeh Server 应用本质上是围绕
curdoc()、数据源、控件和回调组织起来的一个 Python Web 应用 - 部署时不仅要考虑“能跑起来”,还要考虑 WebSocket、反向代理、安全、进程管理和资源限制
可以把这一章和前后章节连起来看:
- 本章前半部分是在承接第六章:当
CustomJS不够时,就进入 Bokeh Server - 本章中的
ColumnDataSource更新、stream()、patch()又和第二章是连在一起的 - 本章的部署、嵌入和访问方式,还要结合第八章理解
- 本章里的实时回调、频率控制和数据规模问题,会在第九章进一步展开
7.13 常见坑
坑 1:把 Bokeh Server 当成“高级版 show()”
Bokeh Server 不是简单把 show() 换成 bokeh serve。
一旦进入 Server 模式,你就同时进入了这些问题域:
- 会话状态
- WebSocket 同步
- 回调执行频率
- 多用户访问
- 部署与安全
所以不要把它当成“只是让 Python 回调能跑”的小补丁,而要把它当成一个真正的应用运行环境。
坑 2:所有问题都用 Python 回调解决
很多交互其实根本不需要 Server,例如:
- 改点大小
- 简单过滤显示
- 前端联动
- tooltip 展示
这些场景通常直接用 CustomJS 更轻量,也更容易部署。
经验上可以这样判断:
- 只改前端展示:优先
CustomJS - 需要 Python 参与计算或访问后端资源:使用 Bokeh Server
如果你不确定该怎么选,建议回看第六章。
坑 3:回调能跑,但数据更新方式不对
很多 Server 应用变卡,不是因为 Server 本身“太慢”,而是因为:
- 每次都整体替换
source.data - 定时器频率过高
- 明明只改一个值,却重传整份数据
- 保留了过长的历史数据窗口
更稳妥的思路通常是:
- 追加数据用
stream() - 局部修改用
patch() - 控制
rollover - 把耗时计算和高频 UI 更新分开
这部分建议和第九章一起看。
坑 4:本地能跑,部署后交互失效
这通常不是图表代码本身的问题,而是部署链路的问题。最常见的原因包括:
--allow-websocket-origin没配对- 反向代理没正确转发 WebSocket
- 页面是 HTTPS,但 Bokeh Server 仍然走 HTTP
- 多进程配置和会话状态设计不匹配
所以生产环境部署时,应该把“图表功能正确”和“访问链路正确”分开排查。
坑 5:应用代码越写越大,却没有及时拆结构
一个 Bokeh Server 文件一开始可能只有几十行,但很快就会同时包含:
- 数据加载
- 图表创建
- 控件定义
- 回调逻辑
- 布局组织
- 部署配置假设
如果不及时拆分,后面会很难维护。
建议尽早把代码拆成几类函数:
- 数据准备函数
- 图表创建函数
- 控件创建函数
- 回调绑定函数
- 页面组装函数
这样在继续扩展到多页面、多个数据源或复杂部署时,成本会低很多。
延伸阅读:Bokeh Server 应用的部署和安全见本章 7.10-7.11 节。Server 应用的性能优化见第九章。输出选项见第八章;如果你还想回到前端交互和
CustomJS的场景对比,请回看第六章。
第八章:输出选项
Bokeh提供了多种输出方式,让你的可视化作品能够在不同场景中展示和使用。本章将详细介绍各种输出选项的特点、配置方法和适用场景。
在进入具体 API 之前,先抓住一个总原则:
- standalone 文档:不需要 Bokeh Server,适合
output_file()、save()、file_html()、components()、Notebook 内联展示 - Bokeh Server 应用:需要
bokeh serve运行,适合server_document()、server_session()这类“嵌入一个活的 Python 应用”的场景 - 静态导出:PNG / SVG 适合报告、论文、幻灯片,但会失去 Python 端交互能力
如果你还不熟悉 Bokeh 的运行模式,建议先回看第一章 1.5和第七章。
8.1 输出格式概览
8.1.1 先按“运行模式”理解输出
| 运行模式 | 是否需要 Bokeh Server | 常见 API | 适用场景 |
|---|---|---|---|
| Standalone HTML | 否 | output_file()、save()、file_html() | 分享单图、保存本地 HTML、导出离线文档 |
| Notebook 内联 | 否 | output_notebook()、show() | Jupyter 教学、探索式分析 |
| Standalone 嵌入 | 否 | components()、json_item() | 嵌入 Flask / Django / 任意网页模板 |
| Server 嵌入 | 是 | server_document()、server_session() | 嵌入需要 Python 回调的交互应用 |
| 静态图片 | 否(但需要浏览器驱动) | export_png()、export_svg() | 论文、汇报、印刷、快照导出 |
8.1.2 格式对比
| 输出格式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| HTML | 独立分享、Web嵌入 | 交互完整、易于分享 | 需要浏览器、文件较大 |
| PNG | 报告、演示文稿 | 通用性强、易于查看 | 静态图片、失去交互性 |
| SVG | 出版印刷、矢量编辑 | 无损缩放、可编辑 | 部分交互丢失 |
| Notebook | 数据分析、教学 | 内联展示、便于记录 | 依赖Jupyter环境 |
| 组件 | Web应用集成 | 灵活嵌入、可定制 | 需要Web开发知识 |
| Server 应用嵌入 | Web 应用集成 | 保留 Python 回调、状态同步 | 需要 Bokeh Server 进程 |
8.1.3 选择决策指南
需要输出图表?
│
├── 只是想保存或分享一份交互式结果?
│ └── standalone HTML:output_file / save / file_html
│
├── 想在 Jupyter 中直接显示?
│ └── output_notebook + show
│
├── 想嵌入现有网页?
│ ├── 不需要 Python 回调 → components / json_item
│ └── 需要 Python 回调 → server_document / server_session
│
└── 需要静态图片?
├── 屏幕展示 / 报告 → PNG
└── 印刷 / 矢量编辑 → SVG
8.1.4 components()、file_html()、server_document() 怎么选
这是最容易混淆的三个接口,可以先这样记:
| 接口 | 是否需要 Bokeh Server | 返回内容 | 适合什么 |
|---|---|---|---|
file_html() | 否 | 一整份 HTML 字符串 | 想自己生成完整 HTML 页面 |
components() | 否 | <script> + <div> 片段 | 想把 standalone 图表嵌入模板 |
server_document() | 是 | 指向 Bokeh Server 应用的 <script> | 想把“活的” Bokeh Server 应用嵌入网页 |
你可以把它们理解成:
file_html():整页导出components():页面局部嵌入server_document():嵌入远端运行中的 Server 应用
后面在 8.6 节会分别展开。
8.2 HTML 输出
HTML 是最常用的输出格式,生成的文件包含完整的浏览器端交互功能。
运行方式说明:本节讨论的
output_file()、save()、file_html()都属于 standalone 输出。它们不需要bokeh serve,但也不能承载 Python 回调。若你需要 Python 回调,请跳到第七章和本章 8.6 节的server_document()。
基础用法
from bokeh.plotting import figure, output_file, save
# 创建图表
p = figure(title="销售趋势", width=800, height=400)
p.line([1, 2, 3, 4, 5], [100, 150, 120, 180, 200], line_width=2)
p.circle([1, 2, 3, 4, 5], [100, 150, 120, 180, 200], size=8)
# 输出为HTML文件
output_file("sales_chart.html")
save(p)
高级配置
from bokeh.plotting import figure, output_file, save
from bokeh.resources import CDN, INLINE
# 使用CDN资源(推荐,文件更小)
output_file("chart_cdn.html",
title="我的图表",
mode="cdn" # 使用CDN加载BokehJS
)
# 使用内联资源(自包含文件)
output_file("chart_inline.html",
title="我的图表",
mode="inline" # 将BokehJS内联到HTML中
)
# 自定义资源
from bokeh.resources import Resources
custom_resources = Resources(mode="server", root_url="http://myserver.com/static")
output_file("chart_custom.html", resources=custom_resources)
自包含HTML文件
当你需要生成一个完全自包含的HTML文件时(比如通过邮件发送),使用内联模式:
from bokeh.plotting import figure, output_file, save
from bokeh.resources import INLINE
# 创建图表
p = figure(title="自包含图表")
p.circle([1, 2, 3], [4, 5, 6])
# 生成自包含HTML(所有JavaScript都内联在文件中)
output_file("self_contained.html", mode="inline")
save(p)
# 文件可以离线查看,但文件较大
多图表输出
from bokeh.plotting import figure, output_file, save
from bokeh.layouts import column, row
# 创建多个图表
p1 = figure(title="图表1", width=400, height=300)
p1.circle([1, 2, 3], [4, 5, 6])
p2 = figure(title="图表2", width=400, height=300)
p2.line([1, 2, 3], [6, 5, 4])
p3 = figure(title="图表3", width=400, height=300)
p3.vbar(x=[1, 2, 3], top=[4, 5, 6], width=0.5)
# 布局并输出
layout = row(column(p1, p2), p3)
output_file("multiple_charts.html")
save(layout)
8.3 PNG 导出
PNG导出需要额外的依赖,适合生成静态报告图片。
依赖安装
方式1:使用 conda(推荐)
# Firefox 路线:安装 Selenium、Firefox 和 geckodriver
conda install selenium firefox geckodriver -c conda-forge
# Chrome 路线:安装 Selenium 和 ChromeDriver
# 注意:ChromeDriver 版本必须与本机 Chrome/Chromium 主版本匹配
conda install selenium python-chromedriver-binary -c conda-forge
方式2:使用pip
# 安装Selenium
pip install selenium
# 下载并配置WebDriver
# Firefox: 下载geckodriver并添加到PATH
# Chrome: 下载ChromeDriver并添加到PATH
基础导出
from bokeh.plotting import figure
from bokeh.io import export_png
# 创建图表
p = figure(title="导出示例", width=800, height=400)
p.line([1, 2, 3, 4, 5], [2, 5, 8, 2, 7], line_width=2)
# 导出为PNG
export_png(p, filename="chart.png")
# 默认分辨率:屏幕分辨率
# 可以通过webdriver参数指定使用Chrome或Firefox
from selenium import webdriver
driver = webdriver.Chrome() # 或 webdriver.Firefox()
export_png(p, filename="chart.png", webdriver=driver)
driver.quit()
高分辨率导出
from bokeh.plotting import figure
from bokeh.io import export_png
from bokeh.models import Plot
from bokeh.layouts import column
# 创建高分辨率图表
p = figure(title="高分辨率图表",
width=1600, # 2倍宽度
height=800, # 2倍高度
sizing_mode="scale_width"
)
# 调整字体大小以适应高分辨率
p.title.text_font_size = "24pt"
p.xaxis.major_label_text_font_size = "14pt"
p.yaxis.major_label_text_font_size = "14pt"
p.line([1, 2, 3, 4, 5], [2, 5, 8, 2, 7], line_width=4)
# 导出
export_png(p, filename="high_res_chart.png")
批量导出
from bokeh.plotting import figure
from bokeh.io import export_png
import os
# 确保输出目录存在
os.makedirs("charts", exist_ok=True)
# 创建多个图表并导出
datasets = [
("sales", [100, 150, 120, 180, 200]),
("profit", [20, 30, 25, 40, 35]),
("customers", [50, 60, 55, 70, 65])
]
for name, data in datasets:
p = figure(title=f"{name.title()} 数据", width=800, height=400)
p.line(range(len(data)), data, line_width=2)
p.circle(range(len(data)), data, size=8)
# 导出到charts目录
export_png(p, filename=f"charts/{name}_chart.png")
print(f"已导出: charts/{name}_chart.png")
print("所有图表导出完成!")
8.4 SVG 导出
SVG是矢量格式,适合出版印刷和进一步编辑。
基础导出
from bokeh.plotting import figure
from bokeh.io import export_svg
# 创建图表,指定SVG后端
p = figure(title="SVG导出", width=800, height=400,
output_backend="svg") # 重要:指定SVG渲染
p.circle([1, 2, 3, 4, 5], [2, 5, 8, 2, 7], size=10, color="navy")
p.line([1, 2, 3, 4, 5], [2, 5, 8, 2, 7], line_width=2)
# 导出SVG
export_svg(p, filename="chart.svg")
SVG vs Canvas 渲染
from bokeh.plotting import figure, show
# Canvas渲染(默认,适合大数据集)
p_canvas = figure(title="Canvas渲染", output_backend="canvas")
p_canvas.circle(range(10000), [i**0.5 for i in range(10000)], size=1)
# SVG渲染(适合出版,支持矢量编辑)
p_svg = figure(title="SVG渲染", output_backend="svg")
p_svg.circle([1, 2, 3], [4, 5, 6], size=10)
# 根据需求选择渲染后端
# 大数据集:使用canvas
# 需要导出SVG:使用svg
# 需要最佳性能:使用webgl
8.5 Jupyter Notebook 输出
Bokeh 与 Jupyter Notebook / JupyterLab 可以很好地集成,支持内联交互式图表。
交叉引用:如果你只想快速在 Notebook 里显示图表,可先看第一章 1.5;如果你需要排查 JupyterLab 显示问题,可结合常见问题与故障排查一起看。
经典 Jupyter Notebook
from bokeh.plotting import figure, show
from bokeh.io import output_notebook
# 初始化Notebook输出
output_notebook()
# 创建图表
p = figure(title="Notebook内联图表", width=600, height=400)
p.circle([1, 2, 3, 4, 5], [2, 5, 8, 2, 7], size=10)
# 显示图表(直接在Notebook中显示)
show(p)
JupyterLab 配置
安装扩展:
# 使用pip
pip install jupyter_bokeh
# 使用conda
conda install jupyter_bokeh -c conda-forge
使用方法:
from bokeh.plotting import figure, show
from bokeh.io import output_notebook
# JupyterLab中同样使用output_notebook
output_notebook()
# 创建图表
p = figure(title="JupyterLab图表", width=600, height=400)
p.line([1, 2, 3], [4, 5, 6])
show(p)
Notebook 高级技巧
from bokeh.plotting import figure, show
from bokeh.io import output_notebook
from bokeh.layouts import column, row
from bokeh.models import Slider, CustomJS
output_notebook()
# 在一个单元格中显示多个图表
p1 = figure(title="图表1", width=300, height=200)
p1.circle([1, 2, 3], [4, 5, 6])
p2 = figure(title="图表2", width=300, height=200)
p2.line([1, 2, 3], [6, 5, 4])
# 水平排列显示
show(row(p1, p2))
# 在Notebook中使用交互控件
slider = Slider(start=0, end=10, value=1, title="数值")
slider.js_on_change('value', CustomJS(code="""
console.log('滑块值变为: ' + this.value)
"""))
show(slider)
Notebook 常见问题
问题1:图表不显示
# 解决方案:确保调用output_notebook()
from bokeh.io import output_notebook
output_notebook() # 必须在show()之前调用
问题2:JupyterLab中无响应
# 解决方案:安装jupyter_bokeh扩展
pip install jupyter_bokeh
# 然后重启JupyterLab
8.6 嵌入 Web 框架
Bokeh 可以轻松嵌入到 Flask、Django 等 Web 框架中。但在写代码之前,必须先分清两类嵌入:
- 嵌入 standalone 文档
- 不需要 Bokeh Server
- 适合
components()、json_item()、file_html() - 只能使用浏览器端交互和
CustomJS
- 嵌入 Bokeh Server 应用
- 需要单独运行
bokeh serve - 适合
server_document()、server_session() - 支持 Python 回调、会话状态和实时更新
- 需要单独运行
如果你不确定该选哪种,优先问自己一句:这张图是否需要 Python 在后台持续参与交互?
- 如果答案是否定的,通常用
components()就够了 - 如果答案是肯定的,就应该考虑
server_document()+ Bokeh Server
8.6.1 使用 components() 生成 standalone 组件
from bokeh.plotting import figure
from bokeh.embed import components
# 创建图表
p = figure(title="嵌入示例", width=600, height=400)
p.circle([1, 2, 3, 4, 5], [2, 5, 8, 2, 7])
# 生成脚本和div标签
script, div = components(p)
print("JavaScript脚本:")
print(script[:200] + "...") # 脚本很长,只显示开头
print("\nHTML div:")
print(div)
# 返回的是元组 (script, div)
# script: 包含所有必要的JavaScript代码
# div: 包含图表的HTML容器
适用判断:
- 你已经有一个 Flask / Django / FastAPI 模板页面
- 你只想把一张或几张 Bokeh 图表嵌进去
- 这些图表不依赖 Python 回调持续运行
如果你需要嵌入的是 Bokeh Server 应用,请直接看后面的 8.6.4 节。
8.6.2 嵌入 Flask 应用
app.py:
from flask import Flask, render_template
from bokeh.plotting import figure
from bokeh.embed import components
app = Flask(__name__)
@app.route('/')
def index():
# 创建图表
p = figure(title="Flask中的Bokeh图表", width=800, height=400)
p.line([1, 2, 3, 4, 5], [2, 5, 8, 2, 7], line_width=2)
# 生成组件
script, div = components(p)
return render_template('index.html', script=script, div=div)
if __name__ == '__main__':
app.run(debug=True)
templates/index.html:
<!DOCTYPE html>
<html>
<head>
<title>Bokeh Flask示例</title>
<link rel="stylesheet" href="https://cdn.bokeh.org/bokeh/release/bokeh-3.x.x.min.css">
</head>
<body>
<h1>销售数据图表</h1>
<!-- 图表容器 -->
{{ div | safe }}
<!-- Bokeh脚本 -->
{{ script | safe }}
</body>
</html>
8.6.3 嵌入 Django 应用
views.py:
from django.shortcuts import render
from bokeh.plotting import figure
from bokeh.embed import components
def chart_view(request):
# 创建图表
p = figure(title="Django中的Bokeh图表", width=800, height=400)
p.circle([1, 2, 3, 4, 5], [2, 5, 8, 2, 7], size=10)
# 生成组件
script, div = components(p)
context = {
'script': script,
'div': div
}
return render(request, 'chart.html', context)
templates/chart.html:
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>Bokeh Django示例</title>
<link rel="stylesheet" href="https://cdn.bokeh.org/bokeh/release/bokeh-3.x.x.min.css">
</head>
<body>
{{ div|safe }}
{{ script|safe }}
</body>
</html>
8.6.4 嵌入 Bokeh Server 应用
当你需要把 支持 Python 回调 的 Bokeh 应用嵌入现有网站时,就不能再用 components()。这时应该先运行 Bokeh Server,再通过 server_document() 获取嵌入脚本。
from flask import Flask, render_template
from bokeh.embed import server_document
app = Flask(__name__)
@app.route('/')
def index():
# 获取 Bokeh Server 应用的嵌入脚本
# 假设 Bokeh Server 运行在 http://localhost:5006/myapp
script = server_document('http://localhost:5006/myapp')
return render_template('index.html', script=script)
这里要注意两点:
server_document()嵌入的是一个 正在运行的 Bokeh Server 应用- 页面里的交互会通过 WebSocket 与服务器上的 Python 代码通信
因此,components() 与 server_document() 的根本区别不是“返回值长什么样”,而是:
components()嵌入的是 静态生成的 standalone 文档server_document()嵌入的是 动态运行的 Server 应用
如果你对 Bokeh Server 还不熟,建议先回看第七章。
8.7 批量导出与自动化
自动化导出脚本
"""
批量导出图表脚本
用于生成报告所需的图表图片
"""
import os
from datetime import datetime
from bokeh.plotting import figure
from bokeh.io import export_png, export_svg
from bokeh.layouts import column, row
def create_sales_chart(data):
"""创建销售图表"""
p = figure(title="销售趋势", width=800, height=400)
p.line(range(len(data)), data, line_width=2, color="navy")
p.circle(range(len(data)), data, size=8, color="navy")
p.xaxis.axis_label = "月份"
p.yaxis.axis_label = "销售额"
return p
def create_comparison_chart(data1, data2):
"""创建对比图表"""
p = figure(title="销售对比", width=800, height=400)
p.line(range(len(data1)), data1, line_width=2, color="blue", legend_label="产品A")
p.line(range(len(data2)), data2, line_width=2, color="red", legend_label="产品B")
p.legend.location = "top_left"
return p
def export_charts(output_dir="exported_charts", formats=["png", "svg"]):
"""导出所有图表"""
# 创建输出目录
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = os.path.join(output_dir, timestamp)
os.makedirs(output_path, exist_ok=True)
# 示例数据
sales_data = [100, 150, 120, 180, 200, 175, 220]
product_a = [100, 120, 140, 160, 180]
product_b = [80, 100, 130, 150, 170]
# 创建图表
charts = {
"sales_trend": create_sales_chart(sales_data),
"comparison": create_comparison_chart(product_a, product_b)
}
# 导出图表
for name, chart in charts.items():
if "png" in formats:
png_path = os.path.join(output_path, f"{name}.png")
export_png(chart, filename=png_path)
print(f"已导出PNG: {png_path}")
if "svg" in formats:
chart.output_backend = "svg" # 设置SVG渲染
svg_path = os.path.join(output_path, f"{name}.svg")
export_svg(chart, filename=svg_path)
print(f"已导出SVG: {svg_path}")
print(f"\n所有图表已导出到: {output_path}")
return output_path
if __name__ == "__main__":
export_charts()
定时导出任务
"""
定时导出图表任务
可以配合cron或Windows任务计划使用
"""
import schedule
import time
from datetime import datetime
from export_script import export_charts
def scheduled_export():
"""定时导出任务"""
print(f"开始定时导出任务: {datetime.now()}")
try:
output_path = export_charts()
print(f"导出成功: {output_path}")
except Exception as e:
print(f"导出失败: {e}")
# 每天早上8点执行
schedule.every().day.at("08:00").do(scheduled_export)
# 或者每小时执行
schedule.every().hour.do(scheduled_export)
print("定时任务已启动...")
while True:
schedule.run_pending()
time.sleep(60)
8.8 版本兼容性说明
Bokeh 版本兼容性
| Bokeh版本 | HTML | PNG | SVG | JupyterLab | 备注 |
|---|---|---|---|---|---|
| 3.x | ✅ | ✅ | ✅ | ✅ | 当前稳定版本 |
| 2.x | ✅ | ✅ | ✅ | ⚠️ | 需要旧版扩展 |
| 1.x | ✅ | ✅ | ⚠️ | ❌ | 部分功能有限 |
依赖库版本要求
# requirements.txt 示例
bokeh>=3.0.0
selenium>=4.0.0
jupyter_bokeh>=3.0.0 # JupyterLab需要
# WebDriver版本需要与浏览器匹配
# Chrome: 查看 https://chromedriver.chromium.org/
# Firefox: 查看 https://github.com/mozilla/geckodriver/releases
常见兼容性问题
问题1:导出 PNG 时 WebDriver 不可用或版本不匹配
常见错误信息:
RuntimeError: Neither firefox and geckodriver nor chrome and chromedriver are available
WebDriverException: Message: 'chromedriver' executable needs to be in PATH
推荐优先使用 conda-forge 安装一组匹配的浏览器与驱动:
# Firefox 路线
conda install selenium firefox geckodriver -c conda-forge
# Chrome 路线
conda install selenium python-chromedriver-binary -c conda-forge
如果你手动安装 ChromeDriver,需要确保 ChromeDriver 与 Chrome/Chromium 的主版本匹配,并把驱动放到 PATH 中。使用 Selenium 4 手动指定驱动路径时,应使用 Service:
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
driver = webdriver.Chrome(service=Service("/path/to/chromedriver"))
Bokeh 的
export_png()/export_svg()通常不需要你自己创建webdriver对象;上面的 Selenium 代码只用于解释现代 Selenium 4 的驱动路径写法。
问题2:JupyterLab 中图表不显示
# 安装或升级 jupyter_bokeh
pip install --upgrade jupyter_bokeh
# JupyterLab 4.x 建议使用 jupyter_bokeh 4.x 或更新版本
pip install --upgrade "jupyter_bokeh>=4.0"
安装或升级后,重启 JupyterLab 再运行:
from bokeh.io import output_notebook, show
from bokeh.plotting import figure
output_notebook()
p = figure()
p.scatter([1, 2, 3], [4, 5, 6])
show(p)
JupyterLab 3/4 通常不需要手动执行
jupyter lab build。只有在旧版 JupyterLab 或本地扩展构建失败时,才考虑清理并重建。
8.9 本章小结
本章你应该掌握四件事:
- 先分运行模式,再选输出接口
- standalone:
output_file()、save()、file_html()、components() - Notebook:
output_notebook()+show() - Server:
server_document()/server_session()+bokeh serve
- standalone:
components()、file_html()、server_document()并不是同一类东西file_html()生成完整页面components()返回可嵌入片段server_document()嵌入的是活的 Server 应用
- PNG / SVG 导出是额外能力,不是默认内置能力
- 需要 Selenium
- 需要浏览器和对应驱动
- 是否需要 Python 回调,是决定输出方案的关键分界线
8.10 常见坑
- 把 standalone 文档和 Server 应用混为一谈:
save()/show()生成的 HTML 不能执行 Python 回调。 - 误把
components()当成 Server 嵌入方案:它只能嵌入 standalone 文档。 - 导出 PNG/SVG 时忽略浏览器驱动依赖:先确认 Selenium、浏览器、驱动三者都可用。
- 在 Jupyter 中只执行
show(),没先output_notebook():这会导致图表不显示或行为异常。 - 一上来就追求“所有格式都支持”:先明确你的交付目标,是分享交互图、嵌入网页,还是导出静态图片。
下一步:了解了输出选项后,下一章我们将学习性能优化,重点是如何判断瓶颈、选择降采样/聚合/WebGL 等策略,而不是把所有性能问题都归结为渲染后端。
延伸阅读:
第九章:性能优化
前置知识:本章需要掌握 ColumnDataSource(第二章 2.2)、基础图表绘制(第三章)以及交互与 Bokeh Server 的基本使用(分别见第六章和第七章)。
当处理大数据集或复杂可视化时,性能优化变得至关重要。
不过,Bokeh 性能问题很少只来自“渲染慢”这一件事,它通常可能出现在:
- Python 端数据处理过慢
- 发送到浏览器的数据量过大
- 浏览器首次渲染或交互重绘成本过高
- Python 回调过于频繁
- Notebook / Bokeh Server 的运行方式不匹配
因此,本章不把“开启 WebGL”当作默认答案,而是按更稳妥的决策顺序来组织内容:
- 先识别瓶颈到底发生在哪一层
- 优先减少数据量和传输量
- 再考虑增量更新、聚合和降采样
- 最后再评估 WebGL 或更高阶工具(如 Datashader)
交叉引用:
9.1 优化前的决策顺序
在真正开始“优化”之前,建议先按下面的顺序判断:
第一步:先问自己,慢在哪里?
| 现象 | 更可能的瓶颈 | 优先检查 |
|---|---|---|
| Python 代码本身执行很慢 | 数据处理 / 回调逻辑 | pandas / numpy / 查询逻辑 |
| 页面初次打开很慢 | 文档体积过大、序列化成本高 | 发送的数据列数、点数、布局复杂度 |
| 图表能打开,但缩放/平移/刷选卡顿 | 浏览器渲染压力大 | glyph 类型、点数、透明度、工具数量 |
| Bokeh Server 中交互延迟明显 | Python 回调频繁或网络传输多 | on_change()、add_periodic_callback() |
| 实时监控越跑越卡 | 数据持续累积 | stream()、rollover、历史窗口长度 |
第二步:按这个优先级优化
-
减少数据量
- 只传当前真正需要显示的数据
- 先聚合、再绘图
- 先降采样、再考虑样式
-
减少更新量
- 用
stream()/patch()替代整表替换 - 避免每次交互都重算和重传全部数据
- 用
-
减少绘制复杂度
- 简化 glyph
- 减少透明叠加、文本、图例和复杂工具
- 拆成多个联动图,而不是一张图塞全部信息
-
最后再尝试 WebGL
- WebGL 更适合大量散点、折线等场景
- 不是所有 glyph 和场景都一定更快
- 不是“性能问题的一键修复”
-
数据规模再上一个数量级时,考虑更高阶方案
- Datashader
- 服务端聚合
- 预计算结果
- 分层加载
第三步:确认你的运行模式
性能优化必须结合运行模式来判断:
| 运行模式 | 常见瓶颈 |
|---|---|
| standalone HTML | 首次加载体积、浏览器渲染 |
| Notebook / JupyterLab | 前端扩展环境、单元格内渲染 |
| Bokeh Server | Python 回调、WebSocket 同步、会话更新 |
| 嵌入 Web 页面 | 静态资源加载、页面集成方式 |
这一点和第八章中的输出方式选择直接相关。
9.2 性能分析方法
识别性能瓶颈
在优化之前,首先需要识别性能瓶颈所在:
import time
from bokeh.plotting import figure, show
def measure_performance(func):
"""性能测量装饰器"""
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"{func.__name__} 执行时间: {end_time - start_time:.4f}秒")
return result
return wrapper
@measure_performance
def create_large_plot(n_points=100000):
"""创建大数据集图表"""
import numpy as np
x = np.random.randn(n_points)
y = np.random.randn(n_points)
p = figure(title=f"{n_points}个数据点")
p.circle(x, y, size=2)
return p
# 测试性能
p = create_large_plot(100000)
性能监控工具
import psutil
import os
from functools import wraps
def monitor_resources(func):
"""监控资源使用"""
@wraps(func)
def wrapper(*args, **kwargs):
process = psutil.Process(os.getpid())
# 记录开始状态
start_memory = process.memory_info().rss / 1024 / 1024 # MB
start_time = time.time()
# 执行函数
result = func(*args, **kwargs)
# 记录结束状态
end_memory = process.memory_info().rss / 1024 / 1024
end_time = time.time()
print(f"执行时间: {end_time - start_time:.2f}秒")
print(f"内存使用: {end_memory:.2f}MB (增加: {end_memory - start_memory:.2f}MB)")
return result
return wrapper
@monitor_resources
def process_data(n_points):
"""处理大数据"""
import numpy as np
data = np.random.randn(n_points, 10)
return data.sum(axis=1)
# 使用
result = process_data(1000000)
9.3 渲染性能优化
WebGL 渲染
WebGL 是浏览器中的硬件加速图形后端,在部分场景下可以明显提升性能,尤其常见于:
- 大量散点
- 大量折线段
- 浏览器端绘制压力明显大于 Python 端处理压力
但要注意,WebGL 不是默认最优解:
- 它并不替代降采样、聚合和数据裁剪
- 某些 glyph、文本、导出或交互行为不一定更理想
- 兼容性、视觉效果和调试体验也可能与 Canvas 不同
如果你还没有先做“减少点数 / 减少列数 / 减少重绘范围”,建议先完成这些步骤,再尝试 WebGL。
from bokeh.plotting import figure
import numpy as np
# 创建大数据集
n = 100000
x = np.random.randn(n)
y = np.random.randn(n)
# 使用Canvas渲染(默认)
p_canvas = figure(title="Canvas渲染", width=600, height=400,
output_backend="canvas")
p_canvas.circle(x, y, size=2, alpha=0.1)
# 使用WebGL渲染(适合大数据集)
p_webgl = figure(title="WebGL渲染", width=600, height=400,
output_backend="webgl")
p_webgl.circle(x, y, size=2, alpha=0.1)
# 注意:这里只是说明 WebGL 的典型用法,不代表它总是更快
show(p_webgl)
Canvas vs WebGL 对比
| 特性 | Canvas | WebGL |
|---|---|---|
| 适用场景 | 中小数据集(<10k点) | 大数据集(>10k点) |
| 渲染速度 | 较慢 | 较快(硬件加速) |
| 功能支持 | 完整 | 部分功能受限 |
| 兼容性 | 所有浏览器 | 现代浏览器 |
| 内存使用 | 较低 | 较高 |
渲染优化最佳实践
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource
# 1. 减少不必要的视觉元素
p = figure(
title="优化图表",
width=800,
height=400,
tools="pan,wheel_zoom,reset", # 只保留必要工具
toolbar_location=None # 隐藏工具栏
)
# 2. 使用简单的标记
source = ColumnDataSource(data={
'x': range(10000),
'y': [i**0.5 for i in range(10000)]
})
# 简单的圆形标记通常比复杂标记更快
p.circle(
'x', 'y', source=source,
size=2,
alpha=0.5,
color="navy"
)
# 3. 避免不必要的交互
p.toolbar.autohide = True
9.5 增量更新优先于全量替换
在 Bokeh 中,很多“越交互越卡”的问题,不是因为数据绝对太大,而是因为每次都在完整替换 source.data。
什么时候优先考虑增量更新?
- 实时监控面板
- 追加式时间序列
- 只改局部值的控制面板
- Bokeh Server 周期回调
推荐方式
from bokeh.models import ColumnDataSource
source = ColumnDataSource(data={'x': [1, 2, 3], 'y': [2, 4, 6]})
# 追加新点
source.stream({'x': [4], 'y': [8]}, rollover=200)
# 只更新局部值
source.patch({'y': [(1, 5)]})
不推荐方式
# 每次都整体替换,尤其在高频回调里成本更高
source.data = {
'x': new_x,
'y': new_y,
}
当然,整体替换不是“错误”,而是要看场景:
- 数据结构整体发生变化:可以整体替换
- 只是追加几个点:优先
stream() - 只是修改个别位置:优先
patch()
9.6 Bokeh 特有的性能瓶颈
除了普通 Python 程序常见的 CPU / 内存问题,Bokeh 还有几类很典型的性能瓶颈:
1. 文档体积过大
症状:
- 页面首次打开很慢
- Notebook 单元格输出很大
- Bokeh Server 初次加载耗时长
常见原因:
- 传了太多列
- 传了太多历史数据
- 一个页面里堆了太多图和控件
- 每个图都带了完整 tooltip 字段
2. 浏览器重绘成本高
症状:
- 图表能打开,但缩放、平移、刷选卡顿
- 选中高亮后页面明显延迟
常见原因:
- 点数太多
- 文本、透明叠加、复杂 marker 太多
- 多图联动同时重绘
3. Bokeh Server 回调频率过高
症状:
- 滑块拖动时明显掉帧
- 实时面板越跑越慢
- 服务器 CPU 持续升高
常见原因:
- 每次
on_change()都做重计算 add_periodic_callback()频率过高- 每次回调都发送整份数据到前端
4. 输出模式和需求不匹配
症状:
- 只是展示数据,却用了 Server
- 只是简单筛选,却写了复杂 Python 回调
- notebook 和 standalone、server 混用后调试困难
建议:
- 纯前端交互优先
CustomJS - 需要 Python 逻辑再使用 Bokeh Server
- 需要分享静态交互页面时优先 standalone HTML
9.7 实用排查清单
优化时建议按下面顺序排查,而不是一上来就改后端或启用 WebGL:
- 当前图表真的需要显示这么多点吗?
- 能否先聚合、分箱或降采样?
-
ColumnDataSource是否只保留了必要列? - 是否在高频回调里反复整体替换
source.data? - 是否给每张图都配置了过多工具、图例和悬停字段?
- 是否误把应该用
CustomJS的前端交互写成了 Python 回调? - 当前瓶颈到底在 Python、网络传输,还是浏览器渲染?
- 是否真的有必要启用 WebGL?
- 数据量是否已经到了应该考虑 Datashader 的级别?
9.8 本章小结
本章的核心不是“怎么把所有性能技巧都用上”,而是学会按正确顺序优化:
- 先识别瓶颈
- 先减少数据量,再减少更新量
- 优先聚合、降采样、增量更新
- 再考虑简化绘制和 WebGL
- 超大规模数据再考虑 Datashader 或更高层方案
如果你接下来主要要优化的是:
9.9 常见坑
- 看到卡顿就立刻启用 WebGL,而没有先减少数据量
- 在 Bokeh Server 中高频整体替换
source.data - 在 tooltip 中塞入大量其实不需要显示的字段
- 用散点图硬画百万级点,而不是先聚合或栅格化
- 混淆 standalone HTML、Notebook、Bokeh Server 三种运行模式
- 只测 Python 执行时间,却忽略浏览器渲染和网络传输成本
更实用的经验顺序通常是:
- 先减少点数
- 再简化视觉编码
- 再减少工具和联动
- 最后再试
output_backend="webgl"
如果你是在做实时监控或 Bokeh Server 应用,还要结合第七章中的 stream()、patch() 和回调频率一起优化。
9.4 数据优化策略
数据降采样
当数据量过大时,降采样通常比“直接把所有点都画出来”更有效。
对于趋势图、监控图、长时间序列,降采样往往是第一选择,而不是 WebGL。
交叉引用:如果你这里处理的是实时更新数据,而不是一次性大数据,请先回看第二章 2.3.3中的
stream()/patch(),以及第七章 7.5 的实时更新模式。
当数据量过大时,降采样可以显著提升性能:
import numpy as np
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource
def downsample_data(x, y, max_points=5000):
"""
降采样数据,保留数据特征
参数:
x, y: 原始数据
max_points: 最大点数
返回:
降采样后的数据
"""
if len(x) <= max_points:
return x, y
# 计算采样步长
step = len(x) // max_points
# 均匀采样
indices = np.arange(0, len(x), step)[:max_points]
return x[indices], y[indices]
# 创建大数据集
n_points = 1000000
x_original = np.linspace(0, 10 * np.pi, n_points)
y_original = np.sin(x_original) + np.random.randn(n_points) * 0.1
# 降采样
x_sampled, y_sampled = downsample_data(x_original, y_original, max_points=5000)
print(f"原始数据: {len(x_original)} 点")
print(f"降采样后: {len(x_sampled)} 点")
# 绘制降采样后的数据
p = figure(title="降采样示例", width=800, height=400)
p.line(x_sampled, y_sampled, line_width=1)
show(p)
智能降采样算法
def lttb_downsample(x, y, target_points):
"""
Largest Triangle Three Buckets (LTTB) 算法
保持数据视觉特征的降采样方法
"""
if len(x) <= target_points:
return x, y
# 始终保留第一个和最后一个点
sampled_x = [x[0]]
sampled_y = [y[0]]
# 计算每个桶的大小
bucket_size = (len(x) - 2) / (target_points - 2)
for i in range(1, target_points - 1):
# 当前桶的范围
bucket_start = int((i - 1) * bucket_size) + 1
bucket_end = int(i * bucket_size) + 1
next_bucket_start = int(i * bucket_size) + 1
next_bucket_end = int((i + 1) * bucket_size) + 1
# 找到下一个桶中面积最大的点
max_area = -1
best_idx = next_bucket_start
for j in range(next_bucket_start, min(next_bucket_end, len(x))):
area = abs(
(x[bucket_start] - x[j]) * (y[i-1] - y[bucket_start]) -
(x[bucket_start] - x[i-1]) * (y[j] - y[bucket_start])
)
if area > max_area:
max_area = area
best_idx = j
sampled_x.append(x[best_idx])
sampled_y.append(y[best_idx])
# 保留最后一个点
sampled_x.append(x[-1])
sampled_y.append(y[-1])
return np.array(sampled_x), np.array(sampled_y)
# 使用LTTB降采样
x_lttb, y_lttb = lttb_downsample(x_original, y_original, 1000)
print(f"LTTB降采样: {len(x_lttb)} 点")
数据聚合
对于分类数据或矩阵数据,聚合通常比“直接把明细全画出来”更稳妥。
如果你发现散点已经重叠成一团,或者柱状图类别过多,优先考虑:
- 按组聚合
- 分箱统计
- 预先计算区间摘要
- 改用热力图、直方图、箱线图等更适合聚合表达的图形
这和第三章里的“图表选择”是一体两面:很多性能问题,本质上其实是图表选择问题。
对于分类数据,使用聚合可以减少数据点数量:
import pandas as pd
import numpy as np
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource
# 创建大量数据
np.random.seed(42)
n = 100000
df = pd.DataFrame({
'category': np.random.choice(['A', 'B', 'C', 'D', 'E'], n),
'value': np.random.randn(n) * 100
})
# 聚合数据
aggregated = df.groupby('category')['value'].agg(['mean', 'std', 'count']).reset_index()
print("聚合后的数据:")
print(aggregated)
# 绘制聚合后的图表
source = ColumnDataSource(aggregated)
p = figure(x_range=list(aggregated['category']), title="聚合数据", height=400)
p.vbar(x='category', top='mean', source=source, width=0.5, color="navy")
show(p)
9.4 内存优化
内存使用监控
import psutil
import os
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource
import numpy as np
def get_memory_usage():
"""获取当前内存使用量(MB)"""
process = psutil.Process(os.getpid())
return process.memory_info().rss / 1024 / 1024
print(f"初始内存: {get_memory_usage():.2f} MB")
# 创建大数据集
n = 1000000
x = np.random.randn(n)
y = np.random.randn(n)
print(f"创建数据后: {get_memory_usage():.2f} MB")
# 创建ColumnDataSource
source = ColumnDataSource(data={'x': x, 'y': y})
print(f"创建数据源后: {get_memory_usage():.2f} MB")
# 创建图表
p = figure(title="内存优化示例", width=800, height=400)
p.circle('x', 'y', source=source, size=1, alpha=0.1)
print(f"创建图表后: {get_memory_usage():.2f} MB")
内存优化技巧
import numpy as np
from bokeh.models import ColumnDataSource
# 技巧1:使用适当的数据类型
# 使用float32代替float64可以节省一半内存
x = np.random.randn(1000000).astype(np.float32) # 节省内存
y = np.random.randn(1000000).astype(np.float32)
# 技巧2:避免不必要的数据复制
source = ColumnDataSource(data={'x': x, 'y': y})
# 不要这样做:source.data = {'x': x.copy(), 'y': y.copy()}
# 技巧3:及时释放不需要的数据
large_data = np.random.randn(10000000)
# 处理完后释放
del large_data
# 技巧4:使用流式更新而不是完全替换
source.stream({'x': [1.0], 'y': [2.0]}) # 追加数据
source.patch({'x': [(0, 1.5)]}) # 局部更新
内存泄漏检测
import gc
from bokeh.models import ColumnDataSource
def check_memory_leaks():
"""检查内存泄漏"""
# 强制垃圾回收
gc.collect()
# 检查未引用的对象
print(f"未回收的对象: {len(gc.garbage)}")
# 检查数据源引用
sources = [obj for obj in gc.get_objects() if isinstance(obj, ColumnDataSource)]
print(f"活跃的ColumnDataSource数量: {len(sources)}")
# 定期检查
check_memory_leaks()
9.5 网络优化
数据压缩
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource
import numpy as np
# 压缩大数据集
def create_compressed_source(x, y, compression_ratio=10):
"""创建压缩的数据源"""
if len(x) > compression_ratio:
step = len(x) // compression_ratio
x_compressed = x[::step]
y_compressed = y[::step]
else:
x_compressed = x
y_compressed = y
return ColumnDataSource(data={'x': x_compressed, 'y': y_compressed})
# 使用压缩数据源
n = 100000
x = np.linspace(0, 10, n)
y = np.sin(x)
source = create_compressed_source(x, y, compression_ratio=1000)
p = figure(title="压缩数据", width=800, height=400)
p.line('x', 'y', source=source)
show(p)
增量更新优化
from bokeh.plotting import figure, curdoc
from bokeh.models import ColumnDataSource
import numpy as np
# 创建数据源
source = ColumnDataSource(data={'x': [], 'y': []})
p = figure(title="实时数据", width=800, height=400)
p.line('x', 'y', source=source)
# 使用增量更新而不是完全替换
def update_data():
"""增量更新数据"""
# 只添加新数据
new_x = [len(source.data['x'])]
new_y = [np.random.randn()]
# 使用stream追加数据(高效)
source.stream({'x': new_x, 'y': new_y}, rollover=100) # 只保留最近100个点
# 定时更新
curdoc().add_periodic_callback(update_data, 100) # 每100ms更新
curdoc().add_root(p)
缓存策略
from functools import lru_cache
import numpy as np
@lru_cache(maxsize=32)
def compute_expensive_operation(n_points):
"""缓存耗时的计算"""
print(f"计算 {n_points} 个点...")
x = np.linspace(0, 10 * np.pi, n_points)
y = np.sin(x) * np.exp(-x / 10)
return x, y
# 第一次调用会计算
x1, y1 = compute_expensive_operation(10000)
# 第二次调用使用缓存(瞬间返回)
x2, y2 = compute_expensive_operation(10000)
print("缓存生效:第二次调用无需重新计算")
9.6 性能检查清单
渲染性能检查
- 使用WebGL渲染大数据集(>10k点)
- 限制同时显示的数据点数量
- 减少不必要的视觉元素(网格线、标签等)
- 使用简单的标记形状(圆形比复杂形状快)
- 设置适当的透明度(alpha < 1 可能影响性能)
内存使用检查
- 使用适当的数据类型(float32 vs float64)
- 及时释放不需要的数据
- 使用流式更新(stream/patch)而不是完全替换
- 监控内存使用,防止内存泄漏
- 设置数据保留限制(rollover参数)
网络传输检查
- 压缩大数据集后再传输
- 使用增量更新减少数据传输量
- 启用数据压缩(Bokeh Server自动处理)
- 优化数据序列化格式
- 使用CDN加载BokehJS
代码优化检查
- 避免在回调中执行耗时操作
- 使用异步处理长时间任务
- 缓存重复计算结果
- 优化数据结构和算法
- 减少不必要的对象创建
9.7 常见性能问题排查
问题1:图表渲染缓慢
症状:图表加载时间长,交互卡顿
可能原因:
- 数据点过多
- 未使用WebGL渲染
- 视觉元素过于复杂
解决方案:
# 方案1:启用WebGL
p = figure(output_backend="webgl")
# 方案2:降采样数据
def downsample(x, y, max_points=10000):
if len(x) > max_points:
step = len(x) // max_points
return x[::step], y[::step]
return x, y
# 方案3:简化视觉元素
p.circle(x, y, size=2, alpha=0.1, color="navy") # 简单样式
问题2:内存占用过高
症状:应用内存持续增长,最终崩溃
可能原因:
- 数据未及时释放
- 内存泄漏
- 数据类型不当
解决方案:
# 方案1:使用适当的数据类型
import numpy as np
x = np.array(data, dtype=np.float32) # 使用float32
# 方案2:及时释放数据
del large_array
import gc
gc.collect()
# 方案3:使用流式更新
source.stream(new_data, rollover=1000) # 限制保留的数据量
问题3:交互响应延迟
症状:缩放、平移操作不流畅
可能原因:
- 回调函数耗时过长
- 数据更新频率过高
- 网络延迟
解决方案:
# 方案1:优化回调函数
def update(attr, old, new):
# 避免在回调中执行耗时操作
# 使用异步处理或延迟执行
pass
# 方案2:降低更新频率
curdoc().add_periodic_callback(update, 500) # 500ms而不是100ms
# 方案3:使用防抖动
from bokeh.models import CustomJS
slider.js_on_change('value', CustomJS(code="""
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
// 实际处理逻辑
}, 300);
"""))
问题4:Bokeh Server响应慢
症状:服务器处理请求时间长
可能原因:
- 并发用户过多
- 服务器资源不足
- 应用代码效率低
解决方案:
# 方案1:增加工作进程
bokeh serve app.py --num-procs=4
# 方案2:使用负载均衡
# 配置Nginx或其他负载均衡器
# 方案3:优化应用代码
# 使用性能分析工具识别瓶颈
9.8 性能基准测试
"""
Bokeh性能基准测试脚本
用于测量不同场景下的性能表现
"""
import time
import numpy as np
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource
def benchmark_scatter(n_points, use_webgl=False):
"""散点图性能测试"""
x = np.random.randn(n_points)
y = np.random.randn(n_points)
backend = "webgl" if use_webgl else "canvas"
start = time.time()
p = figure(title=f"散点图 {n_points}点 ({backend})",
width=800, height=400, output_backend=backend)
p.circle(x, y, size=2, alpha=0.5)
creation_time = time.time() - start
print(f"{n_points}点散点图创建时间 ({backend}): {creation_time:.4f}秒")
return p
def benchmark_line(n_points):
"""折线图性能测试"""
x = np.linspace(0, 10 * np.pi, n_points)
y = np.sin(x)
start = time.time()
p = figure(title=f"折线图 {n_points}点", width=800, height=400)
p.line(x, y, line_width=1)
creation_time = time.time() - start
print(f"{n_points}点折线图创建时间: {creation_time:.4f}秒")
return p
def run_benchmarks():
"""运行所有基准测试"""
print("=== Bokeh 性能基准测试 ===\n")
# 测试不同数据量
test_sizes = [1000, 10000, 100000, 500000]
for n in test_sizes:
print(f"\n--- {n} 数据点 ---")
# Canvas渲染
benchmark_scatter(n, use_webgl=False)
# WebGL渲染(如果数据量足够大)
if n >= 10000:
benchmark_scatter(n, use_webgl=True)
# 折线图
benchmark_line(n)
if __name__ == "__main__":
run_benchmarks()
下一步:掌握了性能优化技巧后,下一章我们将通过实战案例来综合运用所学知识。
延伸阅读:WebGL 渲染也可在第三章和第四章 4.7中找到简要介绍。Bokeh Server 的实时数据更新见第七章。
第十章:实战案例
前置知识:本章综合运用了以下知识点:ColumnDataSource 与选择状态(第二章 2.2-2.6)、基础图表(第三章)、布局系统(第五章)、交互功能(第六章)、Bokeh Server(第七章)。
10.1 案例一:股票分析仪表板
案例说明:本案例构建一个股票分析仪表板,综合演示以下知识点:
- K 线图与成交量图:使用
segment()+vbar()组合绘制(第三章 3.3 柱状图的扩展应用) - 时间轴配置:
x_axis_type="datetime"(第二章 2.3 Figure 配置) - 共享 X 轴:K 线图和成交量图通过
x_range=p_candle.x_range联动(第六章 6.6 链接轴) - 悬停工具:
HoverTool的日期格式化@date{%F}(第六章 6.1) - Bokeh Server:使用
curdoc().add_root()添加布局(第七章 7.2)
运行方式:保存为 stock_dashboard.py,执行 bokeh serve stock_dashboard.py。
# stock_dashboard.py
from bokeh.io import curdoc
from bokeh.plotting import figure
from bokeh.models import (
ColumnDataSource, Select, DateRangeSlider, HoverTool,
CrosshairTool, Span, Label, NumeralTickFormatter
)
from bokeh.layouts import column, row
from bokeh.palettes import Category10
import pandas as pd
import numpy as np
# 模拟股票数据
np.random.seed(42)
dates = pd.date_range('2022-01-01', '2023-12-31', freq='D')
n = len(dates)
base_price = 100
prices = base_price + np.random.randn(n).cumsum()
volumes = np.random.randint(1000000, 10000000, n)
df = pd.DataFrame({
'date': dates,
'open': prices + np.random.randn(n) * 2,
'high': prices + np.abs(np.random.randn(n)) * 3,
'low': prices - np.abs(np.random.randn(n)) * 3,
'close': prices + np.random.randn(n) * 2,
'volume': volumes,
})
df['ma5'] = df['close'].rolling(5).mean()
df['ma20'] = df['close'].rolling(20).mean()
source = ColumnDataSource(df)
# K线图
p_candle = figure(x_axis_type="datetime", width=900, height=400,
title="K线图", tools="pan,wheel_zoom,box_zoom,reset")
# 绘制K线
inc = df.close > df.open
dec = df.open > df.close
p_candle.segment('date', 'high', 'date', 'low', source=source, color="black")
p_candle.vbar('date', width=0.5, bottom='open', top='close', source=source,
fill_color="red", line_color="black") # 简化版,实际需区分涨跌
# 均线
p_candle.line('date', 'ma5', source=source, line_color="blue", line_width=1, legend_label="MA5")
p_candle.line('date', 'ma20', source=source, line_color="orange", line_width=1, legend_label="MA20")
# 成交量图
p_vol = figure(x_axis_type="datetime", width=900, height=150,
title="成交量", x_range=p_candle.x_range)
p_vol.vbar('date', top='volume', source=source, width=0.5, fill_color="skyblue")
# 悬停工具
hover = HoverTool(tooltips=[
("日期", "@date{%F}"),
("开盘", "@open{0.00}"),
("最高", "@high{0.00}"),
("最低", "@low{0.00}"),
("收盘", "@close{0.00}"),
("成交量", "@volume{0,0}"),
], formatters={'@date': 'datetime'})
p_candle.add_tools(hover)
# 十字光标
crosshair = CrosshairTool(dimensions="both")
p_candle.add_tools(crosshair)
# 布局
layout = column(p_candle, p_vol)
curdoc().add_root(layout)
curdoc().title = "股票分析"
10.2 案例二:实时服务器监控
案例说明:本案例构建一个实时服务器监控面板,综合演示以下知识点:
- 实时数据更新:
curdoc().add_periodic_callback()定时刷新(第七章 7.6 定时回调) - 滚动时序图:通过更新
source.data实现动态折线图(第二章 2.2 ColumnDataSource 数据更新) - 状态卡片:使用
Div组件动态更新 HTML 内容(第七章 7.3 Widget) - 网格布局:
gridplot()排列多个监控图表(第五章 5.2 布局系统) - 范围控制:
p.x_range.start/end实现时间轴自动滚动
运行方式:保存为 monitor.py,执行 bokeh serve monitor.py。
# monitor.py
from bokeh.io import curdoc
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, Div
from bokeh.layouts import column, row, gridplot
from collections import deque
import random
import time
# 配置
MAX_POINTS = 100
UPDATE_INTERVAL = 500 # ms
# 数据存储
times = deque(maxlen=MAX_POINTS)
cpu_data = deque(maxlen=MAX_POINTS)
mem_data = deque(maxlen=MAX_POINTS)
net_data = deque(maxlen=MAX_POINTS)
# 数据源
cpu_source = ColumnDataSource(data={'x': [], 'y': []})
mem_source = ColumnDataSource(data={'x': [], 'y': []})
net_source = ColumnDataSource(data={'x': [], 'y': []})
# 状态卡片
cpu_card = Div(text="<div style='background:#2196F3;padding:20px;border-radius:10px;width:150px;'><h3 style='color:white;margin:0;'>CPU</h3><h1 style='color:white;margin:5px 0;'>0%</h1></div>")
mem_card = Div(text="<div style='background:#4CAF50;padding:20px;border-radius:10px;width:150px;'><h3 style='color:white;margin:0;'>内存</h3><h1 style='color:white;margin:5px 0;'>0%</h1></div>")
net_card = Div(text="<div style='background:#FF9800;padding:20px;border-radius:10px;width:150px;'><h3 style='color:white;margin:0;'>网络</h3><h1 style='color:white;margin:5px 0;'>0 MB/s</h1></div>")
# 图表
cpu_plot = figure(width=400, height=250, title="CPU使用率", tools="pan,wheel_zoom")
cpu_plot.line('x', 'y', source=cpu_source, line_width=2, line_color="#2196F3")
cpu_plot.y_range.start = 0
cpu_plot.y_range.end = 100
mem_plot = figure(width=400, height=250, title="内存使用率", tools="pan,wheel_zoom")
mem_plot.line('x', 'y', source=mem_source, line_width=2, line_color="#4CAF50")
mem_plot.y_range.start = 0
mem_plot.y_range.end = 100
net_plot = figure(width=400, height=250, title="网络流量", tools="pan,wheel_zoom")
net_plot.line('x', 'y', source=net_source, line_width=2, line_color="#FF9800")
# 更新函数
start_time = time.time()
def update():
t = time.time() - start_time
# 模拟数据
cpu = random.uniform(20, 80)
mem = random.uniform(40, 70)
net = random.uniform(1, 50)
# 更新数据
times.append(t)
cpu_data.append(cpu)
mem_data.append(mem)
net_data.append(net)
# 更新数据源
cpu_source.data = {'x': list(times), 'y': list(cpu_data)}
mem_source.data = {'x': list(times), 'y': list(mem_data)}
net_source.data = {'x': list(times), 'y': list(net_data)}
# 更新卡片
cpu_card.text = f"<div style='background:#2196F3;padding:20px;border-radius:10px;width:150px;'><h3 style='color:white;margin:0;'>CPU</h3><h1 style='color:white;margin:5px 0;'>{cpu:.1f}%</h1></div>"
mem_card.text = f"<div style='background:#4CAF50;padding:20px;border-radius:10px;width:150px;'><h3 style='color:white;margin:0;'>内存</h3><h1 style='color:white;margin:5px 0;'>{mem:.1f}%</h1></div>"
net_card.text = f"<div style='background:#FF9800;padding:20px;border-radius:10px;width:150px;'><h3 style='color:white;margin:0;'>网络</h3><h1 style='color:white;margin:5px 0;'>{net:.1f} MB/s</h1></div>"
# 滚动x轴
if t > MAX_POINTS:
for p in [cpu_plot, mem_plot, net_plot]:
p.x_range.start = t - MAX_POINTS
p.x_range.end = t
# 布局
layout = column(
row(cpu_card, mem_card, net_card),
gridplot([[cpu_plot, mem_plot], [net_plot, None]])
)
curdoc().add_root(layout)
curdoc().title = "服务器监控"
curdoc().add_periodic_callback(update, UPDATE_INTERVAL)
10.3 案例三:地理数据可视化
案例说明:本案例展示地理数据可视化,综合演示以下知识点:
- 瓦片地图:使用
add_tile()加载底图(第三章 3.11 地理可视化) - 坐标转换:经纬度转 Web 墨卡托投影
- 气泡大小映射:先把人口缩放为适合显示的
size列,再映射到点大小 - 悬停工具:显示城市名、人口、GDP 等信息(第六章 6.1)
运行方式:保存为 geo_map.py,执行 bokeh serve geo_map.py --show。
运行方式说明:本例使用
curdoc(),属于 Bokeh Server 应用。请用bokeh serve运行,而不是直接python geo_map.py或show()导出静态 HTML。
# geo_map.py
from bokeh.io import curdoc
from bokeh.models import ColumnDataSource, HoverTool
from bokeh.plotting import figure
import pandas as pd
import numpy as np
import xyzservices.providers as xyz
# 经纬度转 Web 墨卡托
def lonlat_to_web_mercator(lon, lat):
k = 6378137
x = lon * (k * np.pi / 180.0)
y = np.log(np.tan((90 + lat) * np.pi / 360.0)) * k
return x, y
# 模拟城市数据
cities = pd.DataFrame({
'name': ['北京', '上海', '广州', '深圳', '成都', '杭州', '武汉', '西安'],
'lon': [116.4, 121.5, 113.3, 114.1, 104.1, 120.2, 114.3, 108.9],
'lat': [39.9, 31.2, 23.1, 22.5, 30.7, 30.3, 30.6, 34.3],
'population': [2154, 2424, 1530, 1303, 1634, 1036, 1121, 1295],
'gdp': [36102, 38701, 25019, 27670, 17013, 15373, 15616, 10020],
})
# 转换坐标
cities['x'], cities['y'] = lonlat_to_web_mercator(cities['lon'], cities['lat'])
# 将人口缩放为更合理的气泡大小(像素)
cities['size'] = cities['population'] / 120
source = ColumnDataSource(cities)
p = figure(
x_axis_type="mercator",
y_axis_type="mercator",
width=800,
height=600,
title="中国主要城市分布",
)
# Bokeh 3.x 推荐直接给 add_tile() 传入 xyzservices provider
p.add_tile(xyz.CartoDB.Positron)
# 绘制城市点
p.circle(
'x',
'y',
source=source,
size='size',
fill_color="red",
fill_alpha=0.6,
line_color="black",
)
# 悬停
hover = HoverTool(tooltips=[
("城市", "@name"),
("人口", "@population{0,0}万"),
("GDP", "@gdp{0,0}亿"),
])
p.add_tools(hover)
# 设置地图范围(中国区域)
p.x_range.start = 10000000
p.x_range.end = 15000000
p.y_range.start = 2000000
p.y_range.end = 7000000
curdoc().add_root(p)
curdoc().title = "城市分布图"
10.4 本章小结
本章通过三个综合案例,把前面章节中的核心能力串了起来:
- 股票分析仪表板:强调时间序列、共享坐标轴、悬停提示和多图组合,可与第三章、第五章、第六章对照阅读。
- 实时服务器监控:强调
ColumnDataSource更新、curdoc().add_periodic_callback()和 Bokeh Server 运行模型,建议结合第二章和第七章一起理解。 - 地理数据可视化:强调 Web Mercator 坐标、
add_tile()、悬停提示和点位映射,可回看第三章中图表选择与第八章中的嵌入/分享方式。
如果你准备把案例改造成自己的项目,推荐按下面顺序进行:
- 先明确数据结构,确保字段命名和类型稳定。
- 再决定图表类型、布局方式和交互模式。
- 如果需要 Python 回调、实时更新或多控件协同,再切换到 Bokeh Server。
- 如果数据量增大,再参考第九章做降采样、增量更新或服务端处理。
10.5 常见坑
常见坑 1:把案例直接当成生产代码
本章案例以“教学演示”为主,很多数据是模拟的,部分逻辑也做了简化。
例如股票案例中的 K 线配色、城市案例中的气泡大小缩放、监控案例中的随机数据,都需要按你的真实业务进一步改造。
常见坑 2:忽略运行方式
本章三个案例都使用了 curdoc(),属于 Bokeh Server 应用。
如果直接运行 python xxx.py,或者只调用 show() / save(),Python 回调和定时更新都不会生效。运行方式请统一使用:
bokeh serve stock_dashboard.py --showbokeh serve monitor.py --showbokeh serve geo_map.py --show
如果你只需要独立 HTML,请回看第八章中的 standalone 输出方式,并把 Python 回调改写为 CustomJS。
常见坑 3:案例之间重复造轮子
这三个案例其实复用了很多共同模式:
- 共享
ColumnDataSource - 多图组合布局
- HoverTool / CrosshairTool 等交互工具
- Bokeh Server 文档模型
建议你在自己的项目里提炼公共函数,而不是每个案例都从头复制粘贴。
例如可以统一封装数据加载、样式配置、HoverTool 模板和布局函数,这样后续维护会更轻松。
常见坑 4:一开始就追求“大全套”
实战项目里最常见的问题不是“功能太少”,而是“第一次就把筛选、联动、导出、部署、性能优化全部堆上去”。
更稳妥的做法是:
- 先做一个最小可运行版本。
- 确认数据和图表表达正确。
- 再逐步增加交互、布局、输出和性能优化。
这样也更符合本教程前九章的学习路径。
延伸阅读:建议先完成前置章节的学习再阅读本章案例。如遇到问题可参考故障排查。如果你准备把案例继续扩展为完整应用,建议顺序阅读第六章:交互功能场景实战 → 第七章:Bokeh Server 进阶 → 第八章:输出选项 → 第九章:性能优化。
快速参考卡片
创建图表
from bokeh.plotting import figure, show
p = figure(title="标题", width=800, height=600)
p.circle([1, 2, 3], [4, 5, 6])
show(p)
数据源
from bokeh.models import ColumnDataSource
source = ColumnDataSource(data={'x': [...], 'y': [...]})
p.circle('x', 'y', source=source)
布局
from bokeh.layouts import row, column, gridplot
layout = row(p1, p2)
layout = column(p1, p2)
layout = gridplot([[p1, p2], [p3, p4]])
交互
from bokeh.models import HoverTool
hover = HoverTool(tooltips=[('名称', '@name')])
p.add_tools(hover)
输出
from bokeh.io import output_file, save, export_png
output_file("plot.html")
save(p)
export_png(p, filename="plot.png")
Bokeh Server
from bokeh.io import curdoc
curdoc().add_root(layout)
curdoc().title = "My App"
运行方式:将代码保存为 main.py,然后执行 bokeh serve --show main.py。
常见问题与故障排查
本节汇总了使用 Bokeh 过程中最常遇到的问题及其解决方案,并提供了系统化的调试方法。
安装与环境问题
问题1:安装失败
症状:pip install bokeh 报错或安装后无法导入
可能原因:
- Python 版本不兼容
- 依赖库冲突
- 网络问题导致下载中断
- 虚拟环境配置问题
解决方案:
# 检查 Python 版本(Bokeh 3.x 需要 Python 3.9+)
python --version
# 升级 pip 并安装
pip install --upgrade pip
pip install bokeh -i https://pypi.tuna.tsinghua.edu.cn/simple
# 如果有依赖冲突,尝试全新安装
pip install --force-reinstall bokeh
# 使用 conda 安装(推荐,自动处理依赖)
conda install bokeh -c conda-forge
# 验证安装
python -c "import bokeh; print(bokeh.__version__)"
问题2:JupyterLab 扩展问题
症状:JupyterLab 中图表不显示或显示空白
可能原因:
jupyter_bokeh未安装或版本过旧- JupyterLab 与
jupyter_bokeh版本不兼容 - Notebook 环境未正确初始化
- 旧版 JupyterLab 扩展缓存异常
解决方案:
# 安装或升级 jupyter_bokeh
pip install --upgrade jupyter_bokeh
# 如果你使用 JupyterLab 4.x,建议搭配 jupyter_bokeh 4.x 或更高版本
pip install "jupyter_bokeh>=4.0"
然后重启 JupyterLab,并在执行 show() 之前确认已经运行:
from bokeh.io import output_notebook
output_notebook()
说明:JupyterLab 3/4 通常不需要手动执行
jupyter lab build。只有在较旧的 JupyterLab 环境或扩展缓存异常时,才考虑执行jupyter lab clean && jupyter lab build。
验证扩展是否正常:
from bokeh.io import output_notebook, show
from bokeh.plotting import figure
output_notebook()
p = figure()
p.circle([1, 2, 3], [4, 5, 6])
show(p)
问题3:Selenium / WebDriver 配置问题
症状:export_png() 或 export_svg() 报错
常见错误信息:
RuntimeError: Neither firefox and geckodriver nor chrome and chromedriver are available
解决方案:
# 推荐:使用 conda 一键安装
conda install selenium geckodriver firefox -c conda-forge
# 或使用 pip + 手动安装 WebDriver
pip install selenium
brew install chromedriver # macOS ChromeDriver
brew install geckodriver # macOS geckodriver(Firefox)
手动指定 WebDriver 路径:
from bokeh.io import export_png
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options()
options.headless = True
driver = webdriver.Chrome(options=options)
p = figure()
p.circle([1, 2, 3], [4, 5, 6])
export_png(p, filename="chart.png", webdriver=driver)
driver.quit()
问题4:虚拟环境问题
解决方案:使用 python -m venv bokeh_env 或 conda create -n bokeh_env python=3.11 创建虚拟环境后 pip install bokeh。确保 which bokeh 指向虚拟环境中的 bokeh。
显示与渲染问题
问题5:图表不显示
症状:运行代码后没有显示图表
可能原因:
- 忘记调用
show(p)或output_notebook() - Jupyter 扩展未安装
- 输出模式冲突
解决方案:
from bokeh.io import output_notebook, output_file, show, save
# 在 Jupyter 中
output_notebook()
show(p)
# 在脚本中
output_file("chart.html")
show(p)
# 如果 show() 不工作,用 save() 替代
save(p)
print("图表已保存到 chart.html")
注意:
output_file()和output_notebook()不要同时使用。在 VS Code 中如果仍不显示,可使用save(p)后用浏览器打开。
问题6:图表显示空白
症状:Figure 画布出现了,但看不到数据
检查清单:
# 1. 确认数据不为空
source = ColumnDataSource(data={'x': [1, 2, 3], 'y': [4, 5, 6]})
print(f"数据行数: {len(source)}")
# 2. 检查坐标轴范围
p = figure()
p.circle([1, 2, 3], [4, 5, 6])
print(f"X 范围: {p.x_range.start} ~ {p.x_range.end}")
# 如果范围不对,手动设置
p.x_range.start = 0
p.x_range.end = 4
# 3. 检查 NaN 值
import numpy as np
data = [1, 2, np.nan, 4]
print(f"NaN 数量: {sum(np.isnan(data))}")
# 4. 确认 glyph 参数正确
p.circle('x', 'y', source=source) # 需要 x, y 和 source 三个参数
问题7:图表尺寸异常
症状:图表太大、太小或响应式布局不工作
sizing_mode 选项一览:
| 模式 | 说明 |
|---|---|
"fixed" | 固定尺寸(默认) |
"stretch_width" | 宽度随容器拉伸 |
"stretch_height" | 高度随容器拉伸 |
"stretch_both" | 宽高都拉伸 |
"scale_width" | 等比缩放,宽度填满容器 |
"scale_height" | 等比缩放,高度填满容器 |
"scale_both" | 等比缩放,宽高都填满 |
# 固定尺寸
p = figure(width=800, height=400)
# 响式(推荐用于 Web 嵌入)
p = figure(sizing_mode="stretch_width", height=400)
# 布局也支持 sizing_mode
from bokeh.layouts import row
layout = row(p1, p2, sizing_mode="stretch_width")
问题8:工具栏不显示
# toolbar_location: "above", "below", "left", "right", None
p = figure(toolbar_location="above", tools="pan,wheel_zoom,box_zoom,reset,save")
p.toolbar.logo = None # 隐藏 Bokeh logo
p.toolbar.autohide = True # 鼠标不在图表上时自动隐藏
问题9:颜色显示错误
Bokeh 支持的颜色格式:
| 格式 | 示例 |
|---|---|
| CSS 颜色名称 | "red" |
| 十六进制 | "#FF5733" |
| RGB 元组(0~255) | (255, 87, 51) |
| RGBA 元组 | (255, 87, 51, 0.5) |
常见错误:Bokeh 不支持 matplotlib 的
(0.5, 0.3, 0.1)格式(0~1 的 RGB),请使用 0~255 的 RGB 或十六进制。
from bokeh.transform import linear_cmap
from bokeh.palettes import Viridis256, Category20
# 确保 low 和 high 覆盖数据范围
color_mapper = linear_cmap('value', Viridis256,
low=min(data['value']), high=max(data['value']))
# 超过 10 种颜色时用 Category20
colors = Category20[20][:15]
问题10:图例重叠
# 方式1:调整位置
p.legend.location = "top_left"
# 方式2:移到图表外部
p.add_layout(p.legend[0], 'right')
# 方式3:点击图例隐藏/显示数据
p.legend.click_policy = "hide" # 或 "mute"(变淡)
# 附加:设置图例样式
p.legend.background_fill_alpha = 0.5
p.legend.border_line_color = None
p.legend.label_text_font_size = "10pt"
数据相关问题
问题11:中文显示问题
症状:图表中的中文显示为方框或乱码
解决方案:
# 方法1:设置具体字体
p.title.text_font = "SimHei"
p.xaxis.axis_label_text_font = "SimHei"
p.yaxis.axis_label_text_font = "SimHei"
# 常用中文字体:
# Windows: "SimHei"(黑体), "Microsoft YaHei"(微软雅黑)
# macOS: "PingFang SC", "Heiti SC"
# Linux: "WenQuanYi Micro Hei"
# 方法2:使用主题全局设置
from bokeh.themes import Theme
theme = Theme(json={
'attrs': {
'Figure': {
'title_text_font': 'PingFang SC'
},
'Axis': {
'axis_label_text_font': 'PingFang SC',
'major_label_text_font': 'PingFang SC'
},
'Legend': {
'label_text_font': 'PingFang SC'
}
}
})
from bokeh.io import curdoc
curdoc().theme = theme
# 方法3:查看系统可用字体
import matplotlib.font_manager as fm
fonts = [f.name for f in fm.fontManager.ttflist]
chinese_fonts = [f for f in fonts if any(
keyword in f for keyword in ['Hei', 'Song', 'Ming', 'Ya', 'Ping', 'Yuan', 'Fang']
)]
print("可用中文字体:", chinese_fonts)
问题12:ColumnDataSource 错误
症状:ValueError: expected an element of either String or Dict(String, Either(String, Seq(String))) 等数据相关报错
常见错误与解决:
from bokeh.models import ColumnDataSource
import pandas as pd
# 错误1:列长度不一致
source = ColumnDataSource(data={
'x': [1, 2, 3],
'y': [4, 5, 6], # ✅ 所有列长度必须相同
})
# 错误2:引用不存在的列
print(source.column_names) # 先确认列名
p.circle('x', 'y', source=source)
# 错误3:DataFrame 中有 NaN
df = pd.DataFrame({'x': [1, 2, None], 'y': [4, 5, 6]})
df = df.dropna() # ✅ 填充或删除 NaN
# 错误4:更新数据方式不对
source.data['x'] = [10, 20] # ❌ 直接修改不会触发更新
source.data = {'x': [10, 20]} # ✅ 整体赋值触发更新
source.stream({'x': [4], 'y': [7]}) # ✅ 追加数据
source.patch({'x': [(0, 100)]}) # ✅ 局部更新(索引0改为100)
问题13:日期轴格式问题
症状:日期显示为时间戳数字,或格式不正确
解决方案:
from bokeh.plotting import figure
from bokeh.models import DatetimeTickFormatter, HoverTool
import pandas as pd
# 方法1:使用 x_axis_type="datetime"
dates = pd.date_range('2024-01-01', periods=12, freq='M')
values = np.random.randn(12).cumsum()
p = figure(x_axis_type="datetime", width=800, height=400)
p.line(dates, values, line_width=2)
# 方法2:自定义日期格式
p.xaxis.formatter = DatetimeTickFormatter(
days=["%Y-%m-%d"], months=["%Y-%m"],
hours=["%H:%M"], minutes=["%H:%M"],
)
# 方法3:HoverTool 中的日期格式
hover = HoverTool(tooltips=[
("日期", "@date{%F}"),
("值", "@value{0.00}"),
], formatters={'@date': 'datetime'})
# 方法4:确保传入 datetime 类型
dates = pd.to_datetime(['2024-01-01', '2024-02-01', '2024-03-01']) # ✅
问题14:分类轴问题
症状:类别顺序错误、标签重叠
解决方案:使用 x_range 参数控制类别顺序:p = figure(x_range=['C', 'A', 'B'])。标签重叠时设置 p.xaxis.major_label_orientation = "vertical" 或 math.pi / 4。注意分类数据必须配合 x_range 使用,不能直接当数值轴用。
问题15:NaN / None 值处理
症状:图表部分数据缺失或渲染异常
解决方案:使用 df.dropna() 删除或 df.fillna(0) / df.interpolate() 填充。注意 Bokeh 折线遇到 NaN 会断开,这是预期行为;如需连续可先过滤:mask = ~np.isnan(y); p.line(x[mask], y[mask])。
Bokeh Server 问题
问题16:Bokeh Server 无法启动
症状:运行 bokeh serve app.py 报错
可能原因:
- 端口被占用
- 文件路径错误
- 代码语法错误
解决方案:
# 检查并释放端口
lsof -i :5006 # macOS/Linux
kill -9 $(lsof -t -i:5006)
# 先用最直接的方式验证应用能否启动
bokeh serve app.py --show
# 使用其他端口
bokeh serve app.py --port 5007 --show
# 查看详细错误信息
bokeh serve app.py --log-level debug
# 以目录方式运行(推荐)
mkdir myapp && mv app.py myapp/main.py
bokeh serve myapp --show
注意:如果你的示例里使用了
curdoc()、on_change()、add_periodic_callback()等 Python 回调,必须通过bokeh serve启动;直接运行python app.py或在静态show()/save()模式下,这些回调都不会生效。
问题17:回调不触发
症状:滑块、按钮等控件的回调函数没有被执行
可能原因:
- 回调函数签名错误
on_change监听的属性名不对- 在静态 HTML 中使用了 Python 回调
- Bokeh Server 未正确启动
解决方案:
from bokeh.io import curdoc
from bokeh.models import Slider, Button, CustomJS
from bokeh.plotting import figure
# 问题1:on_change 回调签名必须接受三个参数
# ❌ 错误
def update(new_value):
pass
# ✅ 正确
def update(attr, old, new):
print(f"属性 {attr} 从 {old} 变为 {new}")
slider = Slider(start=0, end=10, value=1)
slider.on_change('value', update)
# 问题2:on_click 回调签名
def on_button_click():
print("clicked")
button = Button(label="Click")
button.on_click(on_button_click)
# 问题3:静态 HTML 不支持 Python 回调
# ❌ 在 show() / save() 中使用 on_change → 不会执行
# ✅ 静态 HTML 只能用 CustomJS
slider.js_on_change('value', CustomJS(code="""
console.log('新值: ' + cb_obj.value);
"""))
# 问题4:确保 curdoc() 正确使用
# 下面这类代码需要在 `bokeh serve app.py --show` 启动的应用里运行
curdoc().add_root(layout) # 添加到文档
# 不要再调用 show()
问题18:WebSocket 连接失败
症状:浏览器控制台报 WebSocket 错误,图表无法交互
常见错误信息:
WebSocket connection to 'ws://localhost:5006/app/ws' failed
解决方案:
# 原因1:WebSocket 源未允许
bokeh serve app.py --allow-websocket-origin=localhost:5006
bokeh serve app.py --allow-websocket-origin=example.com
# 允许所有源(仅开发环境)
bokeh serve app.py --allow-websocket-origin=*
# 原因2:Nginx 反向代理需要 WebSocket 配置
location / {
proxy_pass http://127.0.0.1:5006;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
proxy_read_timeout 86400;
}
# 原因3:SSL/HTTPS 混合内容 → Bokeh Server 也需要 HTTPS 或用反向代理
# 原因4:防火墙阻断 → sudo ufw allow 5006
问题19:多用户会话冲突
症状:多个用户同时访问时数据互相干扰
解决方案:
# ❌ 错误:使用全局变量(所有会话共享)
global_source = ColumnDataSource(data={'x': [1, 2, 3]})
def update(attr, old, new):
global_source.data = ... # 会影响所有用户!
# ✅ 正确:在 make_document 函数中创建局部对象
def make_document(doc):
source = ColumnDataSource(data={'x': [1, 2, 3]})
p = figure()
p.circle('x', 'y', source=source)
def update(attr, old, new):
source.data = ... # 只影响当前会话
slider = Slider(start=0, end=10, value=1)
slider.on_change('value', update)
doc.add_root(column(slider, p))
# 启动方式
from bokeh.server.server import Server
server = Server({'/': make_document})
server.start()
问题20:部署后样式丢失
症状:部署到生产环境后图表样式异常或 BokehJS 加载失败
解决方案:CDN 不可访问时,使用内联资源模式:output_file("chart.html", resources=INLINE)。同时确保静态文件目录权限正确(chmod -R 755),并在 Nginx 中配置 CORS:add_header Access-Control-Allow-Origin *;。
导出与输出问题
问题21:大数据集性能问题
症状:图表渲染缓慢或浏览器崩溃
快速修复:启用 WebGL 渲染:p = figure(output_backend="webgl")
关于性能优化的完整讨论(WebGL、数据采样、降采样算法、Datashader 等),详见第九章。
问题22:导出 PNG 失败
症状:export_png() 报错或超时
解决方案:
from bokeh.io import export_png
from bokeh.plotting import figure
import selenium
driver = selenium.webdriver.Chrome()
driver.set_page_load_timeout(30) # 增加超时时间
p = figure()
p.circle([1, 2, 3], [4, 5, 6])
try:
export_png(p, filename="chart.png", webdriver=driver, timeout=30)
except Exception as e:
print(f"导出失败: {e}")
finally:
driver.quit()
# 高分辨率导出:使用 2 倍尺寸
p = figure(width=1600, height=800)
export_png(p, filename="chart_hires.png")
问题23:SVG 导出限制
- 需要先设置
p = figure(output_backend="svg"),然后调用export_svg(p, filename="chart.svg") - 不支持 WebGL 渲染的内容
- 部分交互工具(如
lasso_select)在 SVG 中不工作 - 数据量大时 SVG 文件可能很大,建议使用高分辨率 PNG 替代
问题24:HTML 文件过大
症状:生成的 HTML 文件非常大(>10MB)
解决方案:使用 CDN 模式(推荐):output_file("chart.html", mode="cdn"),文件仅几 KB(查看时需要网络)。如需离线使用可改用 mode="inline"(2~5 MB)。对于大数据量场景,建议使用 Bokeh Server 或 AjaxDataSource 按需加载。
调试技巧
通用调试策略
1. 使用 --log-level debug 启动 Bokeh Server
bokeh serve app.py --log-level debug
2. 检查 Bokeh 对象
p = figure()
source = ColumnDataSource(data={'x': [1, 2, 3], 'y': [4, 5, 6]})
r = p.circle('x', 'y', source=source)
print(type(r)) # GlyphRenderer
print(source.column_names) # ['x', 'y']
print(len(source)) # 3
3. 使用浏览器开发者工具(F12 → Console 标签查看 BokehJS 错误)
错误信息速查
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
ValueError: expected an element of ... | 参数类型错误 | 检查参数类型是否匹配 API 文档 |
RuntimeError: Neither firefox/geckodriver nor chrome/chromedriver available | 缺少 WebDriver | 安装 Selenium 和浏览器驱动 |
WebSocket connection to 'ws://...' failed | WebSocket 连接被拒 | 添加 --allow-websocket-origin |
ValueError: ColumnDataSource's columns must be of the same length | 列长度不一致 | 确保所有数据列长度相同 |
RuntimeError: Models must be owned by only a single document | 对象被多个文档引用 | 每个文档创建独立的对象 |
ERROR:tornado.application:Uncaught exception GET /ws | 服务端代码异常 | 检查回调函数中的错误 |
常见调试场景
回调函数中报错但不显示详细信息:
import traceback
def update(attr, old, new):
try:
source.data = compute_data(new)
except Exception as e:
print(f"回调错误: {e}")
traceback.print_exc()
图表显示但交互无反应:检查是否在 Bokeh Server 中运行(Python 回调需要)、回调是否正确绑定、浏览器控制台是否有 JS 错误。
布局不生效:给组件添加背景色辅助调试,确认 layout.children 数量正确。
附录:API速查
常用模块
| 模块 | 用途 |
|---|---|
bokeh.plotting | 高级绘图接口 |
bokeh.models | 低级模型和组件 |
bokeh.io | 输入输出控制 |
bokeh.layouts | 布局管理 |
bokeh.transform | 数据转换 |
bokeh.palettes | 调色板 |
figure.add_tile() / xyzservices | 地图瓦片 |
Figure常用方法
| 方法 | 用途 | 典型场景 |
|---|---|---|
circle() | 散点图 | 相关性分析 |
line() | 折线图 | 时间序列 |
vbar() / hbar() | 柱状图 | 类别比较 |
varea() | 面积图 | 累积趋势 |
quad() | 矩形 | 直方图 |
image() | 热力图 | 相关矩阵 |
text() | 文本标注 | 标签 |
wedge() | 扇形 | 饼图 |
常用工具
| 工具 | 作用 |
|---|---|
PanTool | 拖拽平移 |
WheelZoomTool | 滚轮缩放 |
BoxZoomTool | 框选缩放 |
BoxSelectTool | 框选 |
LassoSelectTool | 套索选择 |
HoverTool | 悬停提示 |
CrosshairTool | 十字光标 |
ResetTool | 重置视图 |
SaveTool | 保存图片 |
Widgets小部件
| 控件 | 用途 | 常用属性 |
|---|---|---|
Slider | 数值滑块 | start, end, value, step |
RangeSlider | 范围滑块 | start, end, value |
Select | 下拉选择 | options, value |
MultiSelect | 多选下拉 | options, value |
Button | 按钮 | label |
Toggle | 开关按钮 | label, active |
TextInput | 文本输入 | value, placeholder |
DataTable | 数据表格 | source, columns |
DatePicker | 日期选择 | value, min_date, max_date |
回调类型
| 回调 | 用途 | 示例 |
|---|---|---|
on_change('value', fn) | 属性变化 | 滑块值改变 |
on_click(fn) | 点击事件 | 按钮点击 |
selected.on_change('indices', fn) | 选择变化 | 框选数据 |
add_periodic_callback(fn, ms) | 定时执行 | 实时更新 |
调色板
| 调色板 | 类型 | 用途 |
|---|---|---|
Viridis256 | 连续 | 数值映射 |
Plasma256 | 连续 | 数值映射 |
Category10 | 分类 | 10类以内 |
Category20 | 分类 | 20类以内 |
RdYlGn | 发散 | 正负值 |
Greys256 | 灰度 | 单色 |
数据源操作
| 方法 | 用途 |
|---|---|
ColumnDataSource(data) | 创建数据源 |
source.data | 获取/设置数据 |
source.stream(new_data) | 流式添加 |
source.patch(updates) | 局部更新 |
source.selected.indices | 获取选中索引 |
学习路径建议
入门阶段
- 掌握Figure创建和基本Glyph
- 理解ColumnDataSource的作用
- 学会使用output_file和show
进阶阶段
- 熟练使用HoverTool和选择工具
- 掌握布局系统(row, column, gridplot)
- 学会样式定制和主题
高级阶段
- 掌握Bokeh Server应用开发
- 学会Python回调和实时更新
- 理解CustomJS与Python回调的选择
实战阶段
- 结合真实数据构建仪表板
- 学习部署和性能优化
- 探索地理可视化、网络图等高级主题
学习资源
官方资源
教程与文章
视频教程
相关工具
- Panel - 基于 Bokeh 的仪表板框架
- Holoviews - 高级数据可视化接口
- Datashader - 大数据可视化
本教程基于Bokeh 3.x版本