Отчёт с тремя кривыми в понедельник, панель с десятком метрик в среду, и каждую правку разработчик пересобирает руками. Matplotlib рисует статичную картинку, а исследователю нужно тыкнуть в точку, выделить подозрительный участок и потянуть ползунок года. Библиотека Bokeh из экосистемы PyData закрывает разрыв: вычисления остаются в Python, а отрисовка идёт в браузере, где график становится живым объектом. Актуальная версия 3.10.1 требует Python 3.12 и новее. Разберём каркас от первого графика до серверного приложения с виджетами и потоковыми обновлениями. По пути пройдём типовые ловушки: застрявший холст на полумиллионе точек, раздутые файлы выгрузок и панель, которая работает у автора, но не открывается у коллеги.

Архитектура Bokeh разделяет вычисления на Python и отрисовку в браузере

Bokeh стоит на двух ногах. Python-сторона готовит значения, описывает фигуры и инструменты, а JavaScript-библиотека BokehJS, написанная на TypeScript, рисует результат на холсте в браузере через WebGL там, где точек много. Между ногами ходит JSON: Python превращает описание графика в документ, который страница понимает без единого стороннего плагина.

Документ в терминах Bokeh это целостное описание: фигуры, источники, инструменты, виджеты и связи между ними. Автономный файл несёт документ целиком, серверный остаётся пустышкой до соединения, и понимание этой разницы экономит неделю недоумений при первых шагах.

Такое разделение диктует главное правило инструмента. Любой пересчёт, который не требует исходных массивов, лучше отдать браузеру: там он пройдёт мгновенно и не будет гонять трафик. Если же при движении ползунка нужно пересчитать модель или сходить в базу, без серверной части уже не обойтись, и Bokeh предлагает для этого собственный сервер.

Первый график умещается в пять строк:

from bokeh.plotting import figure, show

p = figure(width=600, height=400, title="Продажи по месяцам")
p.line(months, sales, line_width=2)
show(p)

Функция show открывает системный браузер с собранным HTML, а save пишет файл на диск для вставки в отчёт или письмо. Для ноутбука Jupyter вывод идёт прямо под ячейкой, достаточно импортировать output_notebook один раз в начале сессии. Всё это работает без запущенного сервера: документ самодостаточен, значения зашиты внутрь файла.

Отрисовкой занимаются глифы, отдельные рисовальщики: line соединяет точки кривой, scatter рассыпает маркеры, vbar и hbar ставят столбики, patch заливает области. Один вызов это один слой фигуры, слои кладутся друг на друга в порядке вызова, и сложная диаграмма собирается из простых, как из кубиков.

p.line(months, plan, line_width=2, color="grey", legend_label="план")
p.vbar(months, top=fact, width=0.6, fill_color="#4c78a8")
p.scatter(months, returns, size=9, marker="triangle")

Легенда строится автоматически по подписям слоёв, щелчок по её элементу приглушает остальные, а столбики с отрицательными значениями просто уходят вниз от нуля, без ручных сдвигов базовой линии.

ColumnDataSource связывает значения с точками и позволяет делить их между графиками

Списки координат удобны до поры, но настоящая сила появляется с ColumnDataSource, центральной таблицей значений графика. Источник хранит именованные столбцы, глифы ссылаются на них по имени, и к каждой точке можно прикрепить сколько угодно сопутствующих полей: наименование, цех, отклонение от плана.

from bokeh.models import ColumnDataSource, HoverTool

src = ColumnDataSource(dict(
    x=days,
    temp=temps,
    city=cities,
))

p = figure(width=700, height=400)
p.scatter(x="x", y="temp", size=8, source=src,
          fill_color="steelblue", alpha=0.7)

p.add_tools(HoverTool(tooltips=[
    ("город", "@city"),
    ("температура", "@temp{0.0} °C"),
]))

Обратите внимание на кавычки вокруг имён в аргументах: глиф получает не массив, а ссылку на столбец, и значения подставляются при отрисовке. Всплывающая подсказка HoverTool читает поля той же таблицы, формат {0.0} округляет число, а подпись с градусами собирается прямо в шаблоне.

Вторая суперспособность источника в разделении. Один и тот же объект можно отдать двум фигурам, и выделение точек рамкой на одном графике мгновенно подсветит те же записи на втором. Это называется связанным выделением, и для исследовательского разбора аномалий приём экономит часы: не нужно сверять глазами, кто именно выпал из общей массы.

Источник умеет не только отдавать, но и принимать обновления. Метод patch заменяет отдельные ячейки, stream дописывает строки, а присваивание полю data меняет таблицу целиком. В автономном файле обновления запускают колбэки на JavaScript, в серверном те же методы вызываются из Python, и разница для кода исчезает.

Инструменты взаимодействия от зума до выделения входят в фигуру по умолчанию

Каждая фигура приходит с панелью инструментов, и набор настраивается параметром tools. Стандартный арсенал выглядит так:

  1. pan двигает область просмотра по полю графика;
  2. wheel_zoom приближает и отдаляет картинку колесом мыши;
  3. box_select выделяет точки прямоугольной рамкой;
  4. lasso обводит произвольный контур выделения;
  5. tap подсвечивает одиночный маркер;
  6. hover показывает всплывающую карточку со значениями полей;
  7. reset возвращает исходный масштаб одним щелчком.

Инструменты активируются щелчком на панели, активный по умолчанию задаётся списком active_drag и active_scroll. Для плотных диаграмм полезен box_zoom с наложением, а crosshair в паре с hover даёт удобное чтение двух осей. Панель можно спрятать или сместить в угол, чтобы не занимала место на скриншоте отчёта.

Свободное место в панели занимают собственные кнопки: CustomJSHover подставляет в подсказку результат вычисления, BoxSelectTool с наложением рисует на поле графика статистику по выделенному, а TapTool умеет открывать адрес по щелчку на точку, превращая график в навигатор по записям.

Оси и сетка тоже остаются полноправными объектами: у оси настраивается формат чисел, шрифт, границы, у сетки цвет и шаг линий, у фигуры фон и рамка. Логарифмическая ось включается одним аргументом y_axis_type, для денежных метрик готов форматирование с разделителями тысяч, а дата-время отображается человеческими подписями без плясок с приведением типов.

Виджеты и обратные вызовы превращают график в мини приложение

Дальше статики лежит страна виджетов: ползунки, списки, кнопки, числовые поля и таблицы значений. Виджет меняет состояние, а график слушает и перестраивается. Простейшая связка работает целиком в браузере через CustomJS: Python пишет в файл JavaScript-функцию, которая пересчитывает значения на лету.

from bokeh.layouts import column
from bokeh.models import Slider, CustomJS

s = Slider(start=1, end=10, value=1, step=1, title="Порядок фильтра")
cb = CustomJS(args=dict(src=src), code="""
    const k = cb_obj.value;
    src.data['y'] = src.data['base'].map(v => v * k);
    src.change.emit();
""")
s.js_on_change("value", cb)

show(column(s, p))

Функция получает источники как аргументы, меняет столбец и уведомляет график. Такой файл самодостаточен: его можно положить на любой хостинг статики, и интерактив сохранится, потому что пересчёт идёт в браузере. Для сложной математики, которой нет в нескольких строках JavaScript, этот путь перестаёт радовать, и тогда зовут сервер.

Набор виджетов закрывает типовую панель целиком: Select и MultiSelect выбирают из списков, CheckboxButtonGroup даёт переключатели, DatePicker работает с датами, DataTable сортирует и редактирует таблицу значений, Div вставляет собственную разметку. Все они понимают и JavaScript-колбэки, и серверные, поэтому прототип доращивается до приложения без переписывания.

Bokeh Server держит состояние на стороне Python и обновляет картинку

Серверная часть меняет баланс: документ живёт в процессе Python, а браузер держит с ним постоянное соединение. Колбэки пишутся на родном языке, без единой строки JavaScript, а при изменении источника сервер сам проталкивает обновления всем открытым вкладкам. Запуск до смешного прост: bokeh serve app.py поднимает приложение на порту 5006.

from bokeh.plotting import curdoc

src = ColumnDataSource(dict(x=[], y=[]))
p = figure(width=700, height=400)
p.line(x="x", y="y", source=src)

def tick():
    new = fetch_last_minute()
    src.stream(dict(x=[new.time], y=[new.value]), rollover=3600)

curdoc().add_root(p)
curdoc().add_periodic_callback(tick, 2000)

Метод stream дописывает значения без пересылки всей истории, а параметр rollover держит скользящее окно, чтобы сутки мониторинга не раздули память. Кроме таймера доступны колбэки на изменение полей и виджетов, а при закрытой вкладке соединение просто закрывается, без лишних процессов.

Серверный режим незаменим в трёх случаях: пересчёт требует библиотек Python, нужны свежие значения из базы по ходу работы, у панели несколько пользователей, которым должен достаться один и тот же живой документ. Именно так работают внутренние панели цехов и диспетчерские, где каждое движение видно всем.

Продакшен-обвязка у сервера скромная, но достаточная: число процессов задаётся флагом запуска, статика и соединения идут за обратным прокси, авторизация опирается на подписанные токены сессий из отдельного пакета. Документ привязан к сессии, поэтому обновление кода требует переподключения вкладок, и это честная плата за живое состояние.

Компоновка из строк колонок и вкладок собирает дашборд

Один график редко живёт в одиночку. Bokeh собирает композиции функциями row, column и gridplot, а Tabs раскладывает несколько панелей по вкладкам. В серверном приложении корень добавляется в документ одним вызовом add_root, и раскладка централизуется в одном месте.

from bokeh.layouts import row, column, Tabs, Panel

dash = column(
    row(select, slider),
    row(main, column(hist, box)),
)
curdoc().add_root(dash)

Ширина и высота ячеек подгоняются параметром sizing_mode: stretch_width растягивает графики по ширине окна, fixed держит пиксели для печати, scale_both честно масштабирует всё вместе. Палитры цветов из bokeh.palettes содержат готовые градиенты на любой вкус, а linear_cmap и factor_cmap привязывают цвет к значению или категории, и легенда строится сама.

Большие массивы точек и экспорт картинок на практике

Обычный глиф в браузере уверенно рисует десятки тысяч точек, но миллионы маркеров превращают холст в кашу и тормозят. Для таких объёмов создан datashader: он агрегирует точки в растр по пикселям экрана, и плотность закрашивается цветом на лету при каждом зуме.

import datashader as ds
from datashader import transfer_functions as tf

canvas = ds.Canvas(width=700, height=400)
agg = canvas.points(df, "x", "y")
img = tf.shade(agg, cmap=["white", "steelblue"])

Ядро пайплайна умещается в четыре строки, результат shading подкладывается как изображение, а обёртка dynspread раздувает редкие пиксели, чтобы одинокие точки не терялись на больших полях. Миллион значений листается так же гладко, как тысяча.

Для статического экспорта есть два пути. Функция save пишет автономный HTML со всеми значениями внутри, файл открывается без сети и удобен для рассылки.

from bokeh.io import export_png

export_png(p, filename="panel.png")

Безголовый браузер должен стоять в системе, иначе экспорт упадёт с внятной ошибкой, зато результат одинаков на любой машине. Векторный вариант выручает полиграфию, где растр смотрится рыхло.

Итог для практики. Bokeh выбирают, когда графику нужно не просто смотреть, а трогать: выделять, фильтровать, тянуть ползунки и следить за живыми метриками. Для статичных иллюстраций в статью хватит Matplotlib, для всеобщей красоты и офлайн-вставок есть и другие библиотеки, но связка Python-сервера с быстрым браузерным холстом и общим выделением остаётся фирменной чертой, которая и отличает Bokeh среди библиотек визуализации. Проект родился в 2013 году в недрах Continuum Analytics, нынешней Anaconda, и за это время пережил смену мажорных версий без ломки основ: код времён 1.x после перехода на 3.x правится точечно, а не переписывается.