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

第十章:实战案例

前置知识:本章综合运用了以下知识点: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 进阶第八章:输出选项第九章:性能优化