Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Bokeh 交互式可视化完整教程

循序渐进,从问题出发,掌握Python交互式可视化

学习路径建议

初学者路径

  1. 第一章:快速入门 → 了解 Bokeh 基础
  2. 第二章:核心概念 → 掌握 ColumnDataSource 和 Figure
  3. 第三章:基础图表类型 → 学会常用图表
  4. 第五章:布局系统 → 组合多个图表
  5. 第六章:交互功能 → 添加交互性

进阶者路径

  1. 第四章:数据管理高级技巧 → 高级数据操作
  2. 第七章:Bokeh Server → 构建应用
  3. 第八章:输出选项 → 导出与嵌入
  4. 第九章:性能优化 → 大数据可视化
  5. 第十章:实战案例 → 综合应用

特定需求路径

  • 论文配图:第一章 → 第三章 → 第八章
  • 数据探索:第一章 → 第二章 → 第六章 → 第十章
  • Web 应用:第一章 → 第七章 → 第八章
  • 大数据可视化:第四章 → 第九章

关于本教程

本教程基于 Bokeh 3.x 版本编写,旨在为初学者和有经验的使用者提供一份精确、简练、清晰的教程。通过循序渐进的方式,从实际问题出发,帮助读者掌握 Python 交互式可视化的核心技能。

第一章:快速入门

本章目标:用一个最小但完整的例子跑通 Bokeh,理解它适合解决什么问题,并知道下一步该学习哪些概念。

延伸阅读:本章只介绍 Bokeh 的基本用法。ColumnDataSource 的深入讲解见第二章 2.2FigureGlyph 的关系见第二章 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,并在浏览器中打开它。你可以:

  • 使用滚轮缩放图表
  • 拖拽平移查看不同区域
  • 使用框选缩放查看局部范围
  • 点击重置按钮恢复初始视图
  • 点击保存按钮下载当前图表为图片

这段代码可以拆成三步理解:

  1. output_file("sales.html"):指定输出目标是一个 HTML 文件
  2. figure(...):创建一张图,并配置标题、坐标轴、尺寸和工具栏
  3. p.line(...)p.scatter(...):向图中添加具体图形
  4. show(p):保存并打开图表

这就是 Bokeh 最常见的工作流:

准备数据 → 创建 Figure → 添加 Glyph → 输出或展示图表。

其中 Figure 是整张图的容器,linescatter 添加的是具体图形元素,也就是 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 HTMLoutput_file() / save() / show()分享单个交互图表生成独立 HTML,交互主要在浏览器端完成
Notebookoutput_notebook()教学、探索分析图表直接显示在 Notebook 单元格中
Bokeh Serverbokeh serve app.py --showPython 回调、实时更新、状态同步浏览器和 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 ExpressAPI 简洁,上手快

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.iobokeh.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.iobokeh.embed
  • 需要实时应用和服务端状态:学习 Bokeh Server

三层接口的更完整说明见第二章 2.4

1.9 本章小结

本章你已经完成了 Bokeh 的第一轮入门:

  • 知道 Bokeh 的核心定位是 Python 驱动的 Web 交互可视化
  • 安装并验证了 Bokeh 环境
  • figure() 创建了第一张交互式图表
  • 使用 output_file()show() 生成并打开 HTML 文件
  • 初步接触了 ColumnDataSourceHoverTool
  • 了解了 Bokeh 与 Matplotlib、Seaborn、Plotly 等工具的差异
  • 知道了 Bokeh 的常用接口层次

接下来建议阅读第二章:核心概念。理解 ColumnDataSourceFigureGlyphDocument 之后,你会更容易掌握后续的交互、布局和 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。
先问自己两个问题:

  1. 我现在运行的是 standalone / Notebook,还是 Bokeh Server?
  2. 我使用的是前端交互(例如 HoverToolCustomJS),还是 Python 回调?

很多问题其实不是代码错了,而是运行方式和代码模型不匹配

下一步建议

建议按下面顺序继续:

  1. 阅读第二章:核心概念,重点理解 ColumnDataSource
  2. 阅读第八章:输出选项,建立更完整的运行模式认识
  3. 准备学习 Python 回调前,再阅读第七章:Bokeh Server 进阶

第二章:核心概念

前置知识:建议你先完成第一章

延伸阅读

本章只回答四个问题:

  1. Bokeh 为什么强调 ColumnDataSource
  2. FigureGlyphDocument 分别是什么
  3. Python 代码为什么能在浏览器里变成交互图表
  4. 选择、悬停、更新这些交互是怎么工作的

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"],
}

你可以把它当成一张表:

xylabel
14A
25B
36C

重要约束:每一列长度必须一致。
因为第 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 的数值计算
  • 复杂分组聚合
  • 复杂业务逻辑加工

更稳妥的流程通常是:

  1. 先用 pandas / numpy 把数据处理好
  2. 再转换为 ColumnDataSource
  3. 最后在 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 FigurePlot

在日常使用里,你几乎总是通过 figure() 创建图表。
它返回的是一个配置好的绘图对象,适合绝大多数场景。

你可以把 Figure 理解为:

一个“带常用配置的 Plot”,让你更方便地添加图形和交互。


2.5 Glyph:真正被画出来的东西

Glyph 表示一种图形语义,比如:

  • circle
  • line
  • vbar
  • patch
  • text

例如:

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
它把三件事绑定在一起:

  1. 用哪份数据:data_source
  2. 用哪种图形:glyph
  3. 用什么状态样式:选中、未选中、悬停

你可以把它理解为:

Renderer = 数据 + 图形定义 + 渲染状态


2.6 视觉属性:图是怎么“长成这样”的

Bokeh 的视觉属性大体可以分为三类:

2.6.1 填充属性

p.circle(
    "x", "y", source=source,
    fill_color="steelblue",
    fill_alpha=0.6,
)

常见属性:

  • fill_color
  • fill_alpha

2.6.2 线条属性

p.circle(
    "x", "y", source=source,
    line_color="black",
    line_width=2,
    line_alpha=0.8,
    line_dash="dashed",
)

常见属性:

  • line_color
  • line_width
  • line_alpha
  • line_dash

2.6.3 文本属性

p.text(
    "x", "y", text="label", source=source,
    text_font_size="12pt",
    text_color="black",
    text_align="center",
)

常见属性:

  • text_font
  • text_font_size
  • text_color
  • text_align
  • text_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 的作用可以概括为三点:

  1. 组织对象关系:哪些图、哪些控件属于同一个页面
  2. 支持序列化:把 Python 对象转换成浏览器可理解的数据结构
  3. 支持同步:在 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 的很多高级能力,本质上都是“数据源 + 图形对象 + 文档同步”的组合。

本章和后续章节的关系

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 会认为:

  1. 数据源发生变化
  2. 依赖这份数据的 renderer 需要更新
  3. 浏览器端收到变更后重绘

这就是为什么 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,并返回 renderer
  • HoverTool 使用数据列生成提示
  • show() 触发文档输出与浏览器渲染

如果你能清楚说出这五步分别做了什么,本章的核心就已经掌握了。


2.13 本章总结

请记住下面这些结论:

1. ColumnDataSource 是 Bokeh 的标准数据中心

它让图形、悬停、选择、联动、更新都围绕同一份数据工作。

2. Figure 是画布,Glyph 是图形

一个图表通常由多个 glyph 叠加而成。

3. GlyphRenderer 负责把数据和图形绑定起来

它连接 data_sourceglyph 和不同交互状态。

4. Document 是整页 Bokeh 对象的容器

它负责组织、序列化和同步。

5. 真正渲染图表的是浏览器中的 BokehJS

Python 端定义模型,BokehJS 负责显示与交互。

6. 学习路径建议

建议你按这个顺序掌握 Bokeh:

  1. figure() 和常见 glyph
  2. ColumnDataSource
  3. HoverTool、选择状态、联动
  4. bokeh.models 的精细控制
  5. Bokeh Server

2.14 下一章预告

理解了本章以后,你已经掌握了 Bokeh 的最小核心模型。
下一章可以进一步学习:

  • 常见图表类型如何组织
  • 不同 glyph 的使用场景
  • 如何从“会画图”过渡到“会设计图表”

继续阅读:第三章:常见图表类型

第三章:基础图表类型

前置知识:建议你先完成第一章第二章,尤其要理解 figureColumnDataSourceHoverTool 的基本用法。
延伸阅读:本章里用到的 dodge()factor_cmap()linear_cmap()cumsum() 等转换工具,会在第四章中系统展开;多个图表如何组合,请见第五章;交互工具的详细配置见第六章

这一章回答一个最实际的问题:

面对一组数据,你应该先画什么图?

很多初学者学习可视化时,会把注意力集中在“Bokeh 有哪些 API”。
但真正重要的是先想清楚:

  • 你想看的是趋势比较分布,还是关系
  • 你的数据是连续变量类别数据,还是空间数据
  • 你是想做快速探索,还是要做可以交付的图表

Bokeh 的优势不在“图表种类最多”,而在于:

  • 你可以直接用 Python 组织数据
  • 图表天然支持缩放、平移、悬停
  • 后续可以平滑升级到联动、布局、嵌入网页、Bokeh Server

所以本章不会把所有图形都堆给你,而是围绕常见需求,给出:

  1. 图表选择建议
  2. 当前 Bokeh 3.x 仍然稳妥的写法
  3. 什么时候该用、什么时候不该用
  4. 容易踩坑的地方

图表选择指南

图表类型决策树

先看一个实用版决策树:

你要回答什么问题?
│
├─ 看两个变量之间有没有关系
│  └─ 散点图(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:把“漂亮”放在“准确”前面

颜色、透明度、渐变、阴影都只是辅助。
可视化优先级永远是:

  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 官方推荐的思路是:

  1. 先用 numpy.histogram() 计算分箱
  2. 再用 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 太多:噪声被放大
  • 初步探索时:先试 2040
  • 对比多个图时:尽量保持一致的分箱规则

什么时候不用直方图

不建议用直方图的情况:

  • 样本量太少
  • 你更关心分组差异,而不是总体分布
  • 你想强调中位数、四分位数、异常值

替代方案:

  • 箱线图
  • 小提琴图(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() 很方便

它帮你省掉了两类工作:

  1. 自己构造图的节点和边数据源
  2. 自己手工指定布局提供器

这使得你可以快速把已有的 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”,而是下面这些更重要的判断:

  1. 先根据分析目标选图,再考虑 Bokeh 写法
  2. Bokeh 3.x 中很多图形都适合以 ColumnDataSource 为中心组织数据
  3. 散点、折线、柱状图是最常用的三大基础图
  4. 分布类问题优先想到直方图、箱线图、误差条
  5. 构成类问题优先想到堆叠图,饼图和环形图应谨慎使用
  6. 等高线、地图、网络图虽然更“高级”,但也更依赖数据结构是否匹配
  7. 交互不是装饰,而是帮助你更快读图的工具

接下来建议阅读:

如果你已经能根据数据特征快速想到“应该先画哪种图”,那么这章的目标就达到了。

第四章:数据转换与处理

前置知识:本章需要了解 ColumnDataSource(第二章 2.2)和基础图表绘制(第三章)。

延伸阅读:本章聚焦“数据如何进入图、如何映射到视觉属性、如何按条件筛选”。如果你更关心图表为什么会卡、何时该降采样、何时该考虑 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 本章与性能优化的边界

到这里,你已经接触了三类非常重要的“数据到视觉”的工具:

  1. 映射linear_cmap()factor_cmap()transform()
    适合把数值或类别映射到颜色、大小等视觉属性。
  2. 位置变换dodge()jitter()stack()
    适合在不改原始数据语义的前提下调整显示位置。
  3. 筛选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 是对当前 Bokeh Document 生效的全局主题设置。在 Bokeh Server 中最直观;如果你只是生成单个 standalone HTML,也可以理解为“给当前输出文档设置统一样式”。如果你只想改一个图,不一定要上升到主题层,直接设置 p.titlep.axisp.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_widthstretch_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_changeon_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 Servercurdoc()),纯前端 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 过滤
“我想调参数看效果”参数控制SliderTextInput 控件
“数据在实时产生”实时监控periodic_callbackstream()
“对比两组数据的差异”对比分析多数据源叠加、差异计算
“既要概览,又能看细节”主从联动 + 过滤钻取组合使用
“监控的同时可以调参”实时监控 + 参数控制组合使用

模式组合建议

概览型仪表盘 → 主从联动 + 过滤钻取
监控型仪表盘 → 实时监控 + 对比分析
分析型仪表盘 → 参数控制 + 过滤钻取
决策型仪表盘 → 对比分析 + 主从联动

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
选择高亮< 100msCustomJS + 共享数据源
过滤更新< 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:17:1+≥ 4.5:1
大文字对比度3:14.5:1+≥ 3:1
数据点与背景3:14.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 进阶

与此同时,如果你在实现交互时遇到下面几类问题,也可以回看对应章节:

  • 数据结构不清楚:回看第二章
  • 多图排布混乱:回看第五章
  • 需要导出 HTML / Notebook / Web 嵌入:回看第八章
  • 交互变卡、数据量太大:回看第九章

6.12 常见坑

坑 1:把 Python 回调当成 standalone HTML 的能力

这是最常见的误解。on_change()on_event() 这类 Python 回调只有在 Bokeh Server 环境里才会执行。
如果你是用 show()save() 导出单个 HTML 文件,那么应该优先使用:

  • CustomJS
  • js_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:把所有交互都塞进一个页面

交互越多,不一定越好。
如果一个页面同时有太多:

  • 滑块
  • 下拉框
  • 图例控制
  • 过滤器
  • 多图联动
  • 实时刷新

用户反而会更难理解。更稳妥的做法通常是:

  1. 先保留最核心的一个交互路径
  2. 再逐步增加辅助控件
  3. 必要时拆成多个视图或标签页

延伸阅读:本章的 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.pyshow()save() 生成静态 HTML 时,Python 端回调不会执行;静态 HTML 交互应使用 CustomJS

交叉引用

  • 如果你还不熟悉 ColumnDataSourceDocumentcurdoc() 的关系,建议先回看第二章
  • 如果你只是想做浏览器端交互而不需要 Python 回调,建议先看第六章中的 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 的场景对比,请回看第六章

第八章:输出选项

前置知识:本章需要了解基础图表绘制(第一章)和 Figure 对象(第二章 2.3)。

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 HTMLoutput_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 框架中。但在写代码之前,必须先分清两类嵌入:

  1. 嵌入 standalone 文档
    • 不需要 Bokeh Server
    • 适合 components()json_item()file_html()
    • 只能使用浏览器端交互和 CustomJS
  2. 嵌入 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)

这里要注意两点:

  1. server_document() 嵌入的是一个 正在运行的 Bokeh Server 应用
  2. 页面里的交互会通过 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版本HTMLPNGSVGJupyterLab备注
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 本章小结

本章你应该掌握四件事:

  1. 先分运行模式,再选输出接口
    • standalone:output_file()save()file_html()components()
    • Notebook:output_notebook() + show()
    • Server:server_document() / server_session() + bokeh serve
  2. components()file_html()server_document() 并不是同一类东西
    • file_html() 生成完整页面
    • components() 返回可嵌入片段
    • server_document() 嵌入的是活的 Server 应用
  3. PNG / SVG 导出是额外能力,不是默认内置能力
    • 需要 Selenium
    • 需要浏览器和对应驱动
  4. 是否需要 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”当作默认答案,而是按更稳妥的决策顺序来组织内容:

  1. 先识别瓶颈到底发生在哪一层
  2. 优先减少数据量和传输量
  3. 再考虑增量更新、聚合和降采样
  4. 最后再评估 WebGL 或更高阶工具(如 Datashader)

交叉引用

  • 如果你主要在处理 ColumnDataSource 的更新方式,请先回看第二章 2.3
  • 如果你遇到的是滑块、筛选器、实时监控导致的卡顿,请结合第六章第七章一起看。
  • 如果你还不确定图表应该如何输出或嵌入,请先回看第八章,因为 standalone HTML、Notebook、Bokeh Server 的性能瓶颈并不完全相同。

9.1 优化前的决策顺序

在真正开始“优化”之前,建议先按下面的顺序判断:

第一步:先问自己,慢在哪里?

现象更可能的瓶颈优先检查
Python 代码本身执行很慢数据处理 / 回调逻辑pandas / numpy / 查询逻辑
页面初次打开很慢文档体积过大、序列化成本高发送的数据列数、点数、布局复杂度
图表能打开,但缩放/平移/刷选卡顿浏览器渲染压力大glyph 类型、点数、透明度、工具数量
Bokeh Server 中交互延迟明显Python 回调频繁或网络传输多on_change()add_periodic_callback()
实时监控越跑越卡数据持续累积stream()rollover、历史窗口长度

第二步:按这个优先级优化

  1. 减少数据量

    • 只传当前真正需要显示的数据
    • 先聚合、再绘图
    • 先降采样、再考虑样式
  2. 减少更新量

    • stream() / patch() 替代整表替换
    • 避免每次交互都重算和重传全部数据
  3. 减少绘制复杂度

    • 简化 glyph
    • 减少透明叠加、文本、图例和复杂工具
    • 拆成多个联动图,而不是一张图塞全部信息
  4. 最后再尝试 WebGL

    • WebGL 更适合大量散点、折线等场景
    • 不是所有 glyph 和场景都一定更快
    • 不是“性能问题的一键修复”
  5. 数据规模再上一个数量级时,考虑更高阶方案

    • Datashader
    • 服务端聚合
    • 预计算结果
    • 分层加载

第三步:确认你的运行模式

性能优化必须结合运行模式来判断:

运行模式常见瓶颈
standalone HTML首次加载体积、浏览器渲染
Notebook / JupyterLab前端扩展环境、单元格内渲染
Bokeh ServerPython 回调、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 对比

特性CanvasWebGL
适用场景中小数据集(<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()

交叉引用ColumnDataSource 的更新方式见第二章 2.3.3,实时回调见第七章 7.5

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 本章小结

本章的核心不是“怎么把所有性能技巧都用上”,而是学会按正确顺序优化

  1. 先识别瓶颈
  2. 先减少数据量,再减少更新量
  3. 优先聚合、降采样、增量更新
  4. 再考虑简化绘制和 WebGL
  5. 超大规模数据再考虑 Datashader 或更高层方案

如果你接下来主要要优化的是:

  • 交互回调卡顿:优先回看第六章
  • Bokeh Server 实时更新:优先回看第七章
  • 输出方式导致的问题:优先回看第八章

9.9 常见坑

  • 看到卡顿就立刻启用 WebGL,而没有先减少数据量
  • 在 Bokeh Server 中高频整体替换 source.data
  • 在 tooltip 中塞入大量其实不需要显示的字段
  • 用散点图硬画百万级点,而不是先聚合或栅格化
  • 混淆 standalone HTML、Notebook、Bokeh Server 三种运行模式
  • 只测 Python 执行时间,却忽略浏览器渲染和网络传输成本

更实用的经验顺序通常是:

  1. 先减少点数
  2. 再简化视觉编码
  3. 再减少工具和联动
  4. 最后再试 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:图表渲染缓慢

症状:图表加载时间长,交互卡顿

可能原因

  1. 数据点过多
  2. 未使用WebGL渲染
  3. 视觉元素过于复杂

解决方案

# 方案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. 数据未及时释放
  2. 内存泄漏
  3. 数据类型不当

解决方案

# 方案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. 回调函数耗时过长
  2. 数据更新频率过高
  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. 并发用户过多
  2. 服务器资源不足
  3. 应用代码效率低

解决方案

# 方案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.pyshow() 导出静态 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()、悬停提示和点位映射,可回看第三章中图表选择与第八章中的嵌入/分享方式。

如果你准备把案例改造成自己的项目,推荐按下面顺序进行:

  1. 先明确数据结构,确保字段命名和类型稳定。
  2. 再决定图表类型、布局方式和交互模式。
  3. 如果需要 Python 回调、实时更新或多控件协同,再切换到 Bokeh Server。
  4. 如果数据量增大,再参考第九章做降采样、增量更新或服务端处理。

10.5 常见坑

常见坑 1:把案例直接当成生产代码

本章案例以“教学演示”为主,很多数据是模拟的,部分逻辑也做了简化。
例如股票案例中的 K 线配色、城市案例中的气泡大小缩放、监控案例中的随机数据,都需要按你的真实业务进一步改造。

常见坑 2:忽略运行方式

本章三个案例都使用了 curdoc(),属于 Bokeh Server 应用
如果直接运行 python xxx.py,或者只调用 show() / save(),Python 回调和定时更新都不会生效。运行方式请统一使用:

  • bokeh serve stock_dashboard.py --show
  • bokeh serve monitor.py --show
  • bokeh serve geo_map.py --show

如果你只需要独立 HTML,请回看第八章中的 standalone 输出方式,并把 Python 回调改写为 CustomJS

常见坑 3:案例之间重复造轮子

这三个案例其实复用了很多共同模式:

  • 共享 ColumnDataSource
  • 多图组合布局
  • HoverTool / CrosshairTool 等交互工具
  • Bokeh Server 文档模型

建议你在自己的项目里提炼公共函数,而不是每个案例都从头复制粘贴。
例如可以统一封装数据加载、样式配置、HoverTool 模板和布局函数,这样后续维护会更轻松。

常见坑 4:一开始就追求“大全套”

实战项目里最常见的问题不是“功能太少”,而是“第一次就把筛选、联动、导出、部署、性能优化全部堆上去”。
更稳妥的做法是:

  1. 先做一个最小可运行版本。
  2. 确认数据和图表表达正确。
  3. 再逐步增加交互、布局、输出和性能优化。

这样也更符合本教程前九章的学习路径。


延伸阅读:建议先完成前置章节的学习再阅读本章案例。如遇到问题可参考故障排查。如果你准备把案例继续扩展为完整应用,建议顺序阅读第六章:交互功能场景实战第七章: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 报错或安装后无法导入

可能原因

  1. Python 版本不兼容
  2. 依赖库冲突
  3. 网络问题导致下载中断
  4. 虚拟环境配置问题

解决方案

# 检查 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 中图表不显示或显示空白

可能原因

  1. jupyter_bokeh 未安装或版本过旧
  2. JupyterLab 与 jupyter_bokeh 版本不兼容
  3. Notebook 环境未正确初始化
  4. 旧版 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_envconda create -n bokeh_env python=3.11 创建虚拟环境后 pip install bokeh。确保 which bokeh 指向虚拟环境中的 bokeh。


显示与渲染问题

问题5:图表不显示

症状:运行代码后没有显示图表

可能原因

  1. 忘记调用 show(p)output_notebook()
  2. Jupyter 扩展未安装
  3. 输出模式冲突

解决方案

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 报错

可能原因

  1. 端口被占用
  2. 文件路径错误
  3. 代码语法错误

解决方案

# 检查并释放端口
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:回调不触发

症状:滑块、按钮等控件的回调函数没有被执行

可能原因

  1. 回调函数签名错误
  2. on_change 监听的属性名不对
  3. 在静态 HTML 中使用了 Python 回调
  4. 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://...' failedWebSocket 连接被拒添加 --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获取选中索引

学习路径建议

入门阶段

  1. 掌握Figure创建和基本Glyph
  2. 理解ColumnDataSource的作用
  3. 学会使用output_file和show

进阶阶段

  1. 熟练使用HoverTool和选择工具
  2. 掌握布局系统(row, column, gridplot)
  3. 学会样式定制和主题

高级阶段

  1. 掌握Bokeh Server应用开发
  2. 学会Python回调和实时更新
  3. 理解CustomJS与Python回调的选择

实战阶段

  1. 结合真实数据构建仪表板
  2. 学习部署和性能优化
  3. 探索地理可视化、网络图等高级主题

学习资源

官方资源

教程与文章

视频教程

相关工具


本教程基于Bokeh 3.x版本