Bokeh(ボケ)は、Pythonで書いたグラフをWebブラウザ上で拡大・移動・ホバー表示できるようにするデータ可視化ライブラリです。出力はHTMLとJavaScript(BokehJS)なので、グラフをHTMLファイル1つで配ったり、スライダーで条件を変えるダッシュボードをWebアプリとして動かしたりできます。2026年10月時点の最新版は3.10.1(2026年10月13日公開)で、Python 3.12以上が必要です。この記事では、インストールから最初のグラフ、ColumnDataSource、検索の多い2軸グラフ(右側に2本目のY軸を持つグラフ)、Bokehサーバー、FastAPIへの組み込みまでを、3.10.1で実行して確かめたコードで説明します。
まとめ:Bokehの使い方と2軸グラフの要点
- Bokehは操作できるグラフをブラウザに出すPythonライブラリ。写真用語の「bokeh(ボケ)」と綴りは同じだが別物。
- インストールは
pip install bokeh。3.10系はPython 3.12以上が必要で、Python 3.10・3.11ではエラーにならず3.9.2が入る。3.9.0からpandasは自動では入らない。 - 2軸グラフは、
extra_y_rangesに名前付きの範囲を登録し、同じ名前をLinearAxisと右軸側の系列のy_range_nameに指定して作る。 - 範囲を自動に任せると左軸が右の系列まで含んで潰れる。範囲を固定するか、
DataRange1d(renderers=[...])で系列ごとに自動調整させる。 - ブラウザ内で済む操作は
CustomJS、Pythonの処理が要る操作はbokeh serveのBokehサーバーで動かす。3.10.0からはFastAPIなどのASGIアプリにもマウントできる。
Bokehとは:写真用語の「ボケ」との違いと向いている用途
「bokeh」で検索すると、上位は写真のぼけ(背景をぼかす表現)の解説が占めます。この記事で扱うのは同じ綴りのPythonライブラリです。Pythonで figure にグラフを描くと、ブラウザ側のJavaScriptライブラリBokehJSが描画を担当し、マウスで拡大・移動・値の確認ができる状態で表示されます。
GitHubのリポジトリ(bokeh/bokeh)は2012年3月に作られ、PyPIで確認できる最古の版は2013年10月の0.2で、作者はContinuum Analytics(現Anaconda)と記録されています。現在は非営利団体NumFOCUSの支援を受けるコミュニティ開発のプロジェクトで、ライセンスはBSD-3-Clauseです。商用のシステムに組み込んで使えます。
向いているのは、分析結果をブラウザで触れる形で渡したい場面です。散布図の点にマウスを当てて値を読む、複数のグラフで同じ点を同時に選択する、スライダーで条件を変えて再描画する、といった操作をPythonだけで組めます。論文やスライド用の静止画だけが目的ならMatplotlibを選んでください。Bokehで画像を書き出すにはブラウザの準備が要ります。Qtのデスクトップアプリで毎秒何十回も描き直す用途も、ブラウザを介さないPyQtGraphのほうが向いています。
最新バージョン3.10.1と動作要件
PyPIの公開情報から、直近の版と要件を並べます。既存の記事やコードとの差になりやすいのは、3.10.0で必要なPythonが3.12以上へ上がったことと、3.9.0でpandasが必須の依存から外れたことです。
| 版 | 公開日(PyPI) | 必要なPython | pandas |
|---|---|---|---|
| 3.9.0 | 2026年3月11日 | 3.10以上 | 任意 |
| 3.9.2 | 2026年7月25日 | 3.10以上 | 任意 |
| 3.10.0 | 2026年8月18日 | 3.12以上 | 任意 |
| 3.10.1 | 2026年10月13日 | 3.12以上 | 任意 |
次のメジャー版4.0は開発版(4.0.0.dev8、2026年10月16日)の段階で、安定版の公開日は決まっていません。3.10.2の候補版(3.10.2rc1)も10月26日に出ています。
3.9.0:pandasが必須の依存から外れた変更
公式のリリースノートには、3.9.0の変更として「Removed pandas as a required dependency」とあります。3.7.0でデータフレームを扱うNarwhalsが依存に加わり、ColumnDataSource はpandasのほかPolarsやPyArrowのデータフレームも受け取れます。3.10.1を新しい仮想環境に入れても、入るのはnumpy・narwhals・tornado・jinja2・pillow・pyyaml・packaging・xyzservicesで、pandasは入りません。サンプルデータも別パッケージになり、from bokeh.sampledata.iris import flowers は RuntimeError: The separate bokeh_sampledata is needed in order to use the sampledata module. で止まります。チュートリアルを試すときは pip install bokeh_sampledata を先に実行してください。
3.10系:ASGI対応・Playwright出力・3.10.1の修正
3.10.0では、FastAPIやStarletteなどのASGIアプリでBokehアプリを動かす BokehASGI、PNG・SVG出力の手段としてのPlaywright、pip install "bokeh[all]" などの追加依存グループ(extra・export・sampledata・all)が加わりました。3.10.1は不具合修正のみの版で、WebGL描画の精度、diverging_palette() が要求した数の色を返さない問題、Playwrightでの画像出力のスレッド処理などが直っています。
インストールと版の確認
仮想環境を作ってからpipで入れ、bokeh info で実際に入った版とPython・Tornadoの版を確認します。
python3 -m venv .venv
source .venv/bin/activate
pip install bokeh
bokeh info
Anacondaなら conda install bokeh、uvなら uv add bokeh でも入ります。DataFrameを使うなら pip install pandas を別に実行してください。
Python 3.10・3.11で3.9.2が入る理由
3.10系の requires-python は >=3.12 です。Python 3.11の仮想環境で pip install bokeh を実行すると、エラーは出ずに要件を満たす最新の3.9.2が入ります。BokehASGI など3.10の機能を使うコードが ModuleNotFoundError で動かないときは、まず python --version を確認します。
Panel・HoloViewsと一緒に入れるときの版の組み合わせ
Bokehの上に作られたダッシュボード用ライブラリPanelは、2026年10月23日公開の1.10.0で依存を bokeh<3.11.0,>=3.10.0 に変え、Python 3.12以上を必須にしました。1つ前の1.9.4は bokeh<3.10.0 に固定していたため、Pythonの版によって入る組み合わせが分かれます(uvの依存解決で確認)。
| Python | pip install panel | 入るBokeh |
|---|---|---|
| 3.12以上 | Panel 1.10.0 | 3.10.1 |
| 3.10・3.11 | Panel 1.9.4 | 3.9.2 |
Panelを依存に持つHoloViews 1.23.2も、Python 3.12以上ではPanel 1.10.0とBokeh 3.10.1の組み合わせになります。PanelはBokehのマイナー版ごとに上限を付けるので、Bokeh 3.11が出てもPanelが対応するまでは3.10系に留まります。requirements.txt でBokehだけ先に上げると依存解決に失敗するので、Panelの版と合わせて上げてください。
最初のグラフ:figure・output_file・save
figure() で描画領域を作り、line() や scatter() でグラフを重ね、save() でHTMLに書き出します。show() を使うと、保存したうえで既定のブラウザで開きます。
from bokeh.plotting import figure, output_file, save
x = [1, 2, 3, 4, 5]
y = [6, 7, 2, 4, 5]
p = figure(title="最初のグラフ", x_axis_label="x", y_axis_label="y",
width=600, height=350)
p.line(x, y, legend_label="売上", line_width=2)
p.scatter(x, y, size=8)
output_file("first.html")
save(p)
Python 3.12・Bokeh 3.10.1の環境で生成した first.html は7,669バイトでした。グラフ本体のJavaScriptは含まれず、https://cdn.bokeh.org/bokeh/release/bokeh-3.10.1.min.js を読みに行きます。インターネットに出られない環境ではグラフが表示されないので、from bokeh.resources import INLINE を読み込んで save(p, resources=INLINE) とします。JavaScriptを埋め込む分、このグラフでは約1.4MBに増えました。Jupyter Notebookでは、先頭で from bokeh.io import output_notebook と output_notebook() を実行しておくと、show(p) の結果がセルの下に表示されます。
ColumnDataSourceとホバーツールによるデータ共有
ColumnDataSource は、列名をキーにしたデータの入れ物です。複数のグラフに同じ source を渡すと、片方で点を選択したとき、もう片方の同じ行も選択状態になります。ホバー時に出す値も @列名 で同じ入れ物から引けます。
from bokeh.layouts import row
from bokeh.models import ColumnDataSource, HoverTool
from bokeh.plotting import figure, output_file, save
source = ColumnDataSource(data=dict(
month=["1月", "2月", "3月", "4月"],
sales=[120, 150, 90, 180],
visits=[3000, 3600, 2500, 4100],
))
p1 = figure(x_range=source.data["month"], title="売上", width=350, height=300,
tools="tap,reset")
p1.vbar(x="month", top="sales", width=0.8, source=source)
p1.add_tools(HoverTool(tooltips=[("月", "@month"), ("売上", "@sales")]))
p2 = figure(title="訪問数と売上", width=350, height=300, tools="tap,reset")
p2.scatter(x="visits", y="sales", size=12, source=source)
output_file("cds.html")
save(row(p1, p2))
PandasやPolarsのDataFrameを ColumnDataSource(df) に渡すと、インデックスに相当する列が追加されます。通常は index ですが、pandasの名前付きインデックスではその名前になり、既存の列名によっては level_0 になります。1つの ColumnDataSource を別々に保存する2つのグラフで使い回すと、2回目の save() が RuntimeError: Models must be owned by only a single document で止まります。別ファイルに出すグラフにはデータソースを作り直して渡してください。
2軸グラフの作り方:extra_y_rangesとLinearAxis
気温と降水量のように単位の違う2系列を1枚に重ねるときは、右側に2本目のY軸を足します。手順は3つです。(1) extra_y_ranges に名前付きの範囲を登録する、(2) 同じ名前を y_range_name に指定した LinearAxis を右に置く、(3) 右軸で描く系列にも同じ y_range_name を指定する。公式ユーザーガイドのTwin axesの節も同じ構成で、X軸側は extra_x_ranges と x_range_name で同じように増やせます。
棒グラフと折れ線を右軸・左軸に分ける基本形
from bokeh.models import ColumnDataSource, HoverTool, LinearAxis, Range1d
from bokeh.plotting import figure, output_file, save
src = ColumnDataSource(data=dict(
month=[1, 2, 3, 4, 5, 6],
temp=[5.2, 6.1, 9.4, 14.3, 18.8, 21.9],
rain=[52, 56, 118, 125, 138, 168],
))
p = figure(title="気温と降水量", width=600, height=350,
y_range=Range1d(0, 30), y_axis_label="気温(℃)")
p.extra_y_ranges = {"rain": Range1d(0, 240)}
p.add_layout(LinearAxis(y_range_name="rain", axis_label="降水量(mm)"), "right")
bars = p.vbar(x="month", top="rain", width=0.6, alpha=0.4, source=src,
y_range_name="rain", legend_label="降水量")
p.line(x="month", y="temp", source=src, line_width=2, color="firebrick",
legend_label="気温")
# 棒に重ねたときに気温と降水量を1つのツールチップで出す
p.add_tools(HoverTool(renderers=[bars], mode="vline",
tooltips=[("月", "@month"), ("気温", "@temp{0.0} ℃"),
("降水量", "@rain mm")]))
output_file("twin.html")
save(p)
(3) を忘れると、棒グラフが左軸(0〜30)の目盛りで描かれて上に突き抜けます。HoverTool に renderers を指定しないと、棒と折れ線の両方が反応して同じ内容のツールチップが2つ縦に並びました。上のように片方の系列(ここでは棒)にだけ付け、同じ ColumnDataSource から両方の列を引くと1つにまとまります。{0.0} は表示書式で、付けないと気温が「14.300」のように小数3桁で出ます。3本目の軸が要るときは、extra_y_ranges に別の名前を足して LinearAxis をもう1本 "right" に置けば、外側に並びます。
DataRange1dのrenderersによる系列別の範囲自動調整
範囲を Range1d で固定せず自動に任せたい場合、既定の DataRange1d には落とし穴があります。DataRange1d は特に指定しないと図の中のすべての系列を対象に範囲を決めるため、左軸も右軸の系列(降水量)まで含んでしまいます。上の気温と降水量で両軸とも既定のままにすると、3.10.1では左右とも -8.4〜176.4 になり、気温の折れ線が底に張り付きました(ヘッドレスのChromiumで描画後の範囲を取得して確認)。
対策は、DataRange1dの renderers 属性で、範囲を決める対象の系列を軸ごとに指定することです。
from bokeh.models import ColumnDataSource, DataRange1d, LinearAxis
from bokeh.plotting import figure
# 基本形で保存したデータソースを別の文書に流用しない
src = ColumnDataSource(data=dict(src.data))
p = figure(width=600, height=350)
temp_line = p.line(x="month", y="temp", source=src, line_width=2)
rain_bars = p.vbar(x="month", top="rain", width=0.6, source=src,
y_range_name="rain")
p.y_range.renderers = [temp_line] # 左軸は気温だけを見る
p.y_range.start = 0
p.extra_y_ranges = {"rain": DataRange1d(renderers=[rain_bars], start=0)}
p.add_layout(LinearAxis(y_range_name="rain"), "right")
この指定で、左軸は0〜22.99、右軸は0〜176.4になりました。start=0 を付けたのは、棒グラフの下端が0のとき、自動の余白で範囲が -8.4 まで下がるのを防ぐためです。
左右の目盛りとグリッド線の位置合わせ
figure() が作るグリッド線は左軸と下軸の2本分だけで、右軸の目盛りはグリッド線と無関係に決まります。そのため右軸の目盛りが横線とずれて見えます。Bokehの開発者も公式フォーラムで、左右の「きりのよい」目盛りが一致する保証は無く、自動では揃えられないと説明しています。
揃えるには、両軸の範囲を同じ比率で固定し、目盛りの間隔も同じ比率にします。左軸0〜30を5刻み、右軸0〜240を40刻みにすると、どちらも7本の目盛りになり、右軸の目盛りが左軸のグリッド線にそろいます。
from bokeh.models import Range1d, SingleIntervalTicker
# 直前の自動調整例から続けても左右を同じ比率にする
p.y_range = Range1d(0, 30)
p.extra_y_ranges["rain"] = Range1d(0, 240)
# 左 0〜30 を5刻み、右 0〜240 を40刻み(どちらも6等分)
p.yaxis[0].ticker = SingleIntervalTicker(interval=5)
p.yaxis[1].ticker = SingleIntervalTicker(interval=40)
p.yaxis は左軸・右軸の順に並びます。この方法は拡大・移動すると比率が崩れるので、目盛りの一致が要る資料用のグラフでは、ツールバーから pan と wheel_zoom を外しておくのが確実です。
extra_y_scalesによる右軸の対数目盛り設定
アクセス数と売上のように桁が大きく違う系列は、右軸だけを対数にできます。extra_y_scales に範囲と同じ名前で LogScale を登録し、軸を LogAxis にします。範囲の下限に0は使えません。
from bokeh.models import ColumnDataSource, LogAxis, LogScale, Range1d
from bokeh.plotting import figure
# 基本形のsrcからデータをコピーして別のグラフを作る
p = figure(width=600, height=350, y_range=Range1d(0, 30))
p.line(x="month", y="temp",
source=ColumnDataSource(data=dict(src.data)), line_width=2)
p.extra_y_ranges = {"log": Range1d(10, 10000)}
p.extra_y_scales = {"log": LogScale()}
p.add_layout(LogAxis(y_range_name="log"), "right")
p.scatter([1, 2, 3, 4, 5, 6], [20, 150, 900, 1200, 4000, 8000],
size=8, y_range_name="log")
3.10.1で描画すると、右軸は10から10の4乗までの対数目盛りになり、左軸の線形目盛り(0〜30)はそのまま残りました。
E-1020によるグラフ非表示の原因と対処
extra_y_ranges への登録を忘れたまま y_range_name="rain" を指定すると、save() の時点で次のエラーがログに出ます。
E-1020 (BAD_EXTRA_RANGE_NAME): An extra range name is configured with a name that does not correspond to any range: y_range_name='rain' [LinearAxis(...)], y_range_name='rain' [GlyphRenderer(...)]
HTMLファイルは書き出されますが、ブラウザで開くと「missing ‘rain’ range」のエラーで止まり、グラフが表示されません。名前の綴り違いでも同じことが起きるので、範囲名は変数に入れて使い回すと防げます。
2段の項目軸(FactorRange)は2軸グラフと別物
「年」と「上期・下期」のように横軸の項目を2段に並べるグラフは、Y軸を2本持つグラフとは仕組みが違います。こちらは FactorRange に項目の組を渡して作ります。
from bokeh.models import FactorRange
factors = [("2025", "上期"), ("2025", "下期"), ("2026", "上期"), ("2026", "下期")]
p = figure(x_range=FactorRange(*factors), width=600, height=300)
p.vbar(x=factors, top=[10, 12, 15, 18], width=0.8)
ウィジェットで操作するグラフ:CustomJSとBokehサーバーの選び分け
スライダーなどでグラフを動かす方法は2つあり、値を変えたときにPythonの処理が要るかどうかで選びます。配列の掛け算程度ならCustomJSでHTML1枚に収め、データベースへの問い合わせや機械学習モデルの推論が要るならBokehサーバーを使います。
CustomJS:サーバー不要でHTML1枚に収める方法
js_on_change にJavaScriptのコードを渡すと、ブラウザ内だけでグラフが更新されます。サーバーが要らないので、HTMLファイルをメールや社内Wikiで配れます。
import numpy as np
from bokeh.layouts import column
from bokeh.models import ColumnDataSource, CustomJS, Slider
from bokeh.plotting import figure, output_file, save
x = np.linspace(0, 10, 200)
source = ColumnDataSource(data=dict(x=x, y=np.sin(x)))
p = figure(width=600, height=300, y_range=(-3, 3))
p.line("x", "y", source=source, line_width=2)
slider = Slider(start=0.1, end=3, value=1, step=0.1, title="振幅")
slider.js_on_change("value", CustomJS(args=dict(source=source), code="""
const a = cb_obj.value
const x = source.data.x
source.data = {x: x, y: x.map(v => a * Math.sin(v))}
"""))
output_file("slider.html")
save(column(slider, p))
bokeh serve:Pythonのコールバックとリアルタイム更新
Bokehサーバー向けのアプリは、curdoc()(現在の文書)にグラフを登録するスクリプトとして書きます。on_change に渡したPython関数が操作ごとにサーバー側で実行され、add_periodic_callback で一定間隔の更新も組めます。
import numpy as np
from bokeh.layouts import column
from bokeh.models import ColumnDataSource, Slider
from bokeh.plotting import curdoc, figure
x = np.linspace(0, 10, 200)
source = ColumnDataSource(data=dict(x=x, y=np.sin(x)))
p = figure(width=600, height=300, y_range=(-3, 3))
p.line("x", "y", source=source, line_width=2)
freq = Slider(start=0.1, end=5, value=1, step=0.1, title="周波数")
def update(attr, old, new):
source.data = dict(x=x, y=np.sin(new * x))
freq.on_change("value", update)
def tick():
# 1秒ごとに波形をずらす(リアルタイム更新の例)
y = np.roll(source.data["y"], -2)
source.data = dict(x=x, y=y)
curdoc().add_periodic_callback(tick, 1000)
curdoc().add_root(column(freq, p))
curdoc().title = "Bokehサーバーの例"
app.py として保存し、bokeh serve --show app.py で起動すると、http://localhost:5006/app がブラウザで開きます。ブラウザとサーバーはWebSocketでつながり、スクリプトは接続(セッション)ごとに実行されます。利用者ごとに別の状態を持てる反面、同時接続が増えるとその分サーバーのメモリを使います。
Webアプリへの組み込み:components・server_document・FastAPI
既存のWebアプリにBokehのグラフを載せる方法は、サーバーが要るかどうかで分かれます。
| 方法 | Bokehサーバー | 向いている場面 |
|---|---|---|
components() |
不要 | 静的なグラフをテンプレートに埋め込む |
server_document() |
別に起動 | bokeh serveのアプリを既存ページに載せる |
BokehASGI(3.10.0〜) |
ASGIアプリに同居 | FastAPI・Starletteと同じプロセスで動かす |
components(p) は、グラフを描く <script> と表示先の <div> を文字列で返します。使用中のBokehと同じ版のBokehJSを先に読み込み、返されたscriptとdivをFlaskやDjangoのテンプレートに差し込みます。
BokehASGIによるFastAPIへのマウント
BokehASGI を使うと、先ほどのapp.pyをFastAPIのアプリに /bokeh としてマウントできます。pip install fastapi "uvicorn[standard]" を実行し、app.pyと同じディレクトリに、公式ドキュメントの例に沿った次のmain.pyを保存します。
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from bokeh.server.asgi import BokehASGI
bokeh_app = BokehASGI({"/": Path("app.py")})
@asynccontextmanager
async def lifespan(site):
# マウントしたアプリにはlifespanイベントが届かないため、ここで起動・停止する
await bokeh_app.core.start()
try:
yield
finally:
await bokeh_app.core.stop()
site = FastAPI(lifespan=lifespan)
site.mount("/bokeh", bokeh_app)
uvicorn main:site で起動すると /bokeh/ でページは返りますが、pip install uvicorn だけの環境ではグラフが表示されません。ログに「No supported WebSocket library detected」と出て、WebSocketの接続(/bokeh/ws)が404になります。"uvicorn[standard]" でWebSocketのライブラリを入れると接続が通ります(3.10.0で確認)。リバースプロキシの内側に置く場合は、Upgrade・Connection に加えてBokehの接続に使う Sec-WebSocket-Protocol も転送し、公開側の Host とブラウザの Origin を保持します。
export_pngによるPNG出力とブラウザの準備
export_png()・export_svg() はブラウザを使って画像を書き出します。SVGはベクター形式で出すため、対象グラフに p.output_backend = "svg" を設定します。Seleniumを使う場合は selenium パッケージとChromeDriver(Firefoxならgeckodriver)が、Playwrightを使う場合は pip install playwright と playwright install chromium が必要です。3.10.0からは backend="playwright" を指定できます。既定の "auto" はSeleniumを先に試し、使えなければPlaywrightを試します。
Plotly・Matplotlib・Streamlitとの使い分け
「python グラフ ライブラリ」で比べられることの多いライブラリを、出力と得意な用途で並べます。
| ライブラリ | 出力 | 得意な用途 |
|---|---|---|
| Bokeh | HTML・JS | グラフ間の連動、サーバー連携の細かい制御 |
| Plotly | HTML・JS | 少ないコードで多種類のグラフ |
| Matplotlib | PNG・PDF・SVG | 論文・資料用の静止画 |
| Seaborn | PNG・PDF・SVG | 統計グラフを短いコードで |
| Streamlit | Webアプリ | 画面全体をPythonで組む |
| PyQtGraph | Qtウィンドウ | デスクトップでの高頻度描画 |
迷いやすいのは、どちらもブラウザで操作できるグラフを出すBokehとPlotlyです。1枚のグラフを早く出したいならPlotlyを選びます。plotly.express なら散布図も箱ひげ図も1行で描けます(使い方はPlotlyの色指定と使い方の記事)。Bokehを選ぶのは、ColumnDataSource で複数のグラフとウィジェットを連動させたい場合と、Pythonのコールバックを持つアプリを既存のFastAPIに同居させたい場合です。Plotlyでアプリまで作るならDashという別フレームワークを使います。社内の指標を部門をまたいで見せるダッシュボードで、Pythonのコードを書く人を置けないなら、BIツール導入支援のようにBIツールで組む選択肢もあります。
大量の点で描画が重くなったときは、figure(output_backend="webgl") でWebGL描画に切り替えます。WebGLで速くなるかはブラウザとGPUの対応に左右されるため、実際に表示して操作の応答を確かめてください。WebGLでも操作が重い場合は、点を画像に集計してから渡すDatashaderと組み合わせます。
よくある質問
Bokehとは何の意味ですか?写真用語のボケと同じですか?
綴りは写真用語の「bokeh(ボケ)」と同じですが、この記事で扱うのはPythonのデータ可視化ライブラリです。検索結果に写真の解説が多く出るときは「bokeh python」で検索すると、ライブラリの情報に絞れます。
Bokehで2軸グラフを作ると左軸の範囲が広がりすぎるのはなぜですか?
既定の DataRange1d が右軸の系列も含めて範囲を決めるためです。左軸を Range1d で固定するか、p.y_range.renderers に左軸の系列だけを指定します。
pip install bokehを実行したのにpandasが使えないのはなぜですか?
3.9.0からpandasが必須の依存ではなくなったためです。DataFrameを使うなら pip install pandas を別に実行します。サンプルデータを使うなら pip install bokeh_sampledata も必要です。
BokehとPlotlyはどちらを選べばよいですか?
単体のグラフを少ないコードで出すならPlotly、複数のグラフとウィジェットの連動や、FastAPIに同居させるアプリを作るならBokehが向いています。どちらもブラウザで拡大・ホバーができる点は同じです。
Jupyter NotebookでBokehのグラフを表示するには?
from bokeh.io import output_notebook を読み込んで、先頭で output_notebook() を1回実行します。以降は show(p) でセルの下にグラフが表示されます。