import numpy as np
import pandas as pd
from bokeh.io import output_notebook, show
from bokeh.models import ColumnDataSource
from bokeh.plotting import figure
import os

os.environ["BOKEH_MINIFIED"] = "false"
output_notebook()
Loading BokehJS ...

Bokeh-JavaScriptコード#

Bokehの描画とインタラクティブ機能はブラウザで実行されるJavaScriptライブラリによって実現されています。Pythonでチャートを生成する際、ユーザーが作成したJavaScriptプログラムを追加することで、チャートのインタラクティブ機能を強化できます。このセクションでは、そのようなJavaScriptプログラムの作成方法を紹介します。ブラウザでJavaScriptプログラムをデバッグしやすくするため、Bokehを読み込む前に環境変数BOKEH_MINIFIED"false"に設定することをお勧めします。これにより、output_notebook()を実行すると非圧縮版のJavaScriptライブラリが読み込まれます。JavaScriptプログラムをデバッグする際、より詳細なコード情報を確認できます。

import os
from bokeh.io import output_notebook

os.environ["BOKEH_MINIFIED"] = "false"
output_notebook()

Notebookで以下のJavaScriptプログラムを実行し、出力テキストにColumnDataSourceが含まれていれば、非圧縮版JavaScriptライブラリが正しく読み込まれています。プログラムではBokeh.Models(name)を使用してnameという名前のモデルクラスを取得し、そのクラス名を表示しています。

%%javascript
let ColumnDataSource = Bokeh.Models["ColumnDataSource"];
element.append(ColumnDataSource.name)

JavaScriptプログラムのデバッグ#

まず、ブラウザでJavaScriptプログラムをデバッグする方法を簡単に紹介します。JavaScriptでチャートオブジェクトを簡単に取得できるようにするため、チャート作成時にidパラメータでチャートIDを指定できます。また、描画要素作成時にはnameパラメータでその名前を指定できます。

from bokeh.models import ColumnDataSource

source = ColumnDataSource(data=dict(x=[1, 2, 3], y=[1, 3, 2]))
fig = figure(height=250)
fig._id = "my_fig"
line = fig.line("x", "y", source=source, name="my_line")
show(fig)

Notebookで上記のプログラムを実行後、ブラウザで[Ctrl+Shift+I]ショートカットを押してデバッグウィンドウを開きます。Consoleタブに切り替え、以下のJavaScriptプログラムを実行します。Consoleに折れ線のX-Y軸座標データが表示されます。プログラムで使用する変数はグローバル変数なので、Consoleでfiglinesourceなどの変数名を使って内容を確認できます。

fig = Bokeh.index["my_fig"].model; //❶
line = fig.select_one("my_line");  //❷
source = line.data_source;         //❸
console.log(source.data);

❶JavaScriptでは、各チャートはBokeh.index内のPlotViewオブジェクトに対応し、そのmodelプロパティはPythonのfigに対応するオブジェクトです。❷fig.select_one()メソッドを呼び出してnameプロパティが"my_line"のモデルオブジェクトを取得します。これはPythonのlineオブジェクトに対応し、GlyphRendererタイプです。❸そのdata_sourceプロパティを通じて、Pythonのsourceに対応するColumnDataSourceオブジェクトを取得します。この例からわかるように、BokehはJavaScript内でPythonと同じオブジェクト構造を作成します。デバッグウィンドウのスクリーンショットは次のグラフの通りです。

js_on_event()コールバックコード#

BokehのモデルクラスModeljs_on_change()js_on_event()メソッドを定義しています。js_on_change()メソッドを使用すると、JavaScriptでモデルオブジェクトに属性値変更時のコールバックコードを追加できます。一方、js_on_event()はモデルオブジェクトにインタラクションイベントのコールバックコードを追加できます。コールバックコードではcb_objを通じてイベント関連情報を取得できます。すべてのイベントクラスはbokeh.eventsモジュールで定義されており、cb_objの属性名はこれらのイベントオブジェクトの属性名と同じです。例えば、以下にマウスダブルクリックイベントDoubleTapのドキュメントを表示します。ドキュメントから、cb_objにはx, y, sx, syの4つの属性があり、x, yはデータ座標系での座標、sx, syはスクリーン座標系でのピクセル座標であることがわかります。

from bokeh import events

print(events.DoubleTap.__doc__)
Announce a double-tap or double-click event on a Bokeh plot.

Attributes:
    sx (float) : x-coordinate of the event in *screen* space
    sy (float) : y-coordinate of the event in *screen* space
    x (float) : x-coordinate of the event in *data* space
    y (float) : y-coordinate of the event in *data* space

以下の例では、ユーザーがマウスで曲線を描画できます。チャート上でマウスをダブルクリックすると曲線の描画が開始され、再度ダブルクリックするまでマウスの移動軌跡が記録されます。プログラムでは、ダブルクリックイベントDoubleTapとマウス移動イベントMouseMoveのコールバックコードを使用し、データソースに座標点を追加することでマウス描画を実現しています。❶sourceはマウス軌跡を保存するデータソースで、そのtags属性には軌跡の色リストと現在の描画状態が保存されています。複数の図形要素がこのデータソースに接続されています。❷point_sourceデータソースはマウスの現在位置を保存するために使用され、散布図要素がこのデータソースに接続されています。ダブルクリックイベントとマウス移動イベントのコールバックコードはどちらもcallbackです。

イベントの関連情報はcb_objに保存されています。例えば、event_name属性にはイベント名が、xy属性にはデータ座標系でのマウス座標が保存されています。❹マウスダブルクリック時、event_name属性が’doubletap’の場合、現在の描画状態が’normal’であれば曲線描画を開始します。データソースのxsys列に新しいリストを追加し、colors列に新しい色値を追加します。また、カーソルを表すpointer描画オブジェクトを表示し、現在の描画状態を’draw’に設定します。マウスダブルクリック時、現在の描画状態が’draw’であれば曲線描画を終了します。この時、カーソルを非表示にし、描画状態を’normal’に設定します。❺event_name属性が’mousemove’の場合、カーソルデータソースの座標を更新します。現在の描画状態が’draw’であれば、マウスの現在座標をデータソースpoint_sourcexsys列の最後のリストに追加します。最後に、両データソースのchange.emit()メソッドを呼び出してチャートを再描画します。

from bokeh.models import CustomJS
from bokeh.events import MouseMove, DoubleTap
from bokeh.palettes import Category20_20

fig = figure(
    active_drag=None,
    x_range=(-10, 10),
    y_range=(-10, 10),
    frame_width=500,
    frame_height=300,
)
source = ColumnDataSource(
    data=dict(xs=[], ys=[], colors=[]), tags=[dict(colors=Category20_20, mode="normal")]
)  # ❶
pointer_source = ColumnDataSource(data=dict(x=[0], y=[0]))  # ❷

lines = fig.multi_line(
    "xs", "ys", line_color="colors", source=source, line_width=2
)  # ❶
pointer = fig.scatter(
    "x", "y", source=pointer_source, visible=False, marker="cross", size=10, color="red"
)  # ❷

callback = CustomJS(  # ❸
    args=dict(source=source, pointer=pointer),
    code="""
let data = source.data;
let info = source.tags[0];

if(cb_obj.event_name == 'doubletap'){
    if(info.mode == 'normal'){ //❹
        let colors = info.colors;
        data.xs.push([]);
        data.ys.push([]);
        data.colors.push(colors[data.xs.length % colors.length]);
        pointer.visible = true;
        pointer.data_source.change.emit();
        info.mode = 'draw';
    }
    else{
        info.mode = 'normal';
        pointer.visible = false;
    }
}
else if(cb_obj.event_name == 'mousemove'){ //❺
    pointer.data_source.data["x"] = [cb_obj.x];
    pointer.data_source.data["y"] = [cb_obj.y];
    if(info.mode == 'draw'){
        let xs = data.xs;
        let ys = data.ys;
        xs[xs.length-1].push(cb_obj.x);
        ys[ys.length-1].push(cb_obj.y);
        source.change.emit();
        pointer.data_source.change.emit();
    }
}
""",
)

fig.js_on_event(DoubleTap, callback)
fig.js_on_event(MouseMove, callback)
show(fig)

データトランスフォーマー#

データ可視化では、データが直接表示に適さない場合がよくあります。この場合、データを適切に変換する必要があります。このデータ変換はPythonで計算することも、データトランスフォーマーを使用してJavaScriptで計算することもできます。例えば、数値を色に変換したり、データにジッターを追加して描画点が重ならないようにするなどの演算はJavaScriptで実装できます。以下の例では、dodge("y", 1)はデータソースsource"y"列の値に1を加えた後、赤い曲線のY軸座標値として使用します。

from bokeh.transform import dodge

x = np.linspace(0, 2 * np.pi, 100)
y = np.sin(x)
source = ColumnDataSource(data=dict(x=x, y=y))
fig = figure(frame_width=400, frame_height=250)
fig.line("x", "y", source=source)
line = fig.line("x", dodge("y", 1), color="red", source=source)
show(fig)

トランスフォーマーを使用する場合、図形オブジェクトの属性yはデータソースの列名を表す文字列ではなく、データ変換を記述する辞書になります。これはデータソースのy列に対してDodge変換を行うことを示します。Dodgeはデータトランスフォーマーのモデルクラスで、dodge()はトランスフォーマーを作成しやすくするヘルパー関数です。

line.glyph.y
Field(field='y', transform=Dodge(id='p1215', ...), units=Unspecified)

複数の点の座標が完全に同じ場合、散布図では点の分布状態を正しく表示できません。この場合、jitter()トランスフォーマーを使用して点の座標にジッターを追加できます。例えば、以下のプログラムではnp.round()を使用して乱数を四捨五入しているため、これらのランダムな点は次のグラフ(左)に示すような等間隔グリッド上に分布します。jitter()を使用して点のX軸座標とY軸座標に一定のジッターノイズを追加すると、次のグラフ(右)に示すような分布を明確に観察できます。

from bokeh.transform import jitter
from bokeh.layouts import row

x, y = np.round(np.random.normal(scale=0.2, size=(2, 500)), 1)

source = ColumnDataSource(data=dict(x=x, y=y))
fig1 = figure(frame_width=250, frame_height=250)
fig1.scatter("x", "y", source=source, alpha=0.3)
fig2 = figure(frame_width=250, frame_height=250)
fig2.scatter(
    jitter("x", 0.1, distribution="normal"),
    jitter("y", 0.1, distribution="normal"),
    source=source,
    alpha=0.3,
)
layout = row(fig1, fig2)
show(layout)

以下の例では、データソースのデータを使用して散布点の塗りつぶし色(fill_color)を制御し、その結果は次のグラフ(左)の通りです。❶linear_cmp()は数値を色に変換するトランスフォーマーを作成します。列名以外の3つのパラメータは、色リスト、最小値、最大値です。データソースのz列の数値をカラーマップViridisの色に変換します。❷散布点のfill_color属性にカラートランスフォーマーを設定します。❸カラーバーColorBarオブジェクトを使用すると、数値と色の関係を表示できます。そのcolor_mapperはカラーマッピングを表すColorMapperオブジェクトです。

from bokeh.transform import linear_cmap
from bokeh.palettes import Viridis
from bokeh.models import ColorBar

x, y = np.random.normal(scale=0.2, size=(2, 500))
z = (x**2 + y**2) ** 0.5

source = ColumnDataSource(data=dict(x=x, y=y, z=z))
fig_cmap1 = figure(frame_width=400, frame_height=300)
cmap = linear_cmap("z", Viridis[256], z.min(), z.max())  # ❶
c = fig_cmap1.scatter(
    "x", "y", fill_color=cmap, line_color=None, source=source, alpha=1, size=6
)  # ❷
colorbar = ColorBar(
    color_mapper=cmap["transform"],  # ❸
    label_standoff=12,
    border_line_color=None,
    location=(0, 0),
)
fig_cmap1.add_layout(colorbar, "right")

上記のlinear_cmap()は連続的な数値を連続的に変化する色に変換します。一方、factor_cmap()はカテゴリデータを色に変換でき、その結果は次のグラフ(右)の通りです。列名以外の2つのパラメータは、カラーマップとカテゴリ候補値リストです。カテゴリ値をカラーマップ内の対応するインデックスの色に変換します。❶iris.csvデータファイルを読み込む際、species列のデータタイプをカテゴリタイプに設定します。❷カテゴリタイプの列はcat.categoriesですべてのカテゴリ値を取得できます。この方法を使用すると、1つの散布図オブジェクトで複数の異なるカテゴリの分布状況を表示できます。

from bokeh.transform import factor_cmap
from bokeh.palettes import Category10

df = pd.read_csv("data/iris.csv", dtype={"species": "category"})  # ❶

source = ColumnDataSource(data=df)
fig_cmap2 = figure(frame_width=400, frame_height=300)
cmap = factor_cmap("species", Category10[10], df.species.cat.categories)  # ❷
c = fig_cmap2.scatter(
    "sepal_length",
    "sepal_width",
    fill_color=cmap,
    legend_field="species",
    line_color=None,
    source=source,
    alpha=1,
    size=6,
)

前述の各種変換器のJavaScriptプログラムは既にBokehに含まれており、手動でJavaScriptプログラムを記述する必要はありません。CustomJSTransformクラスを使用すると、カスタムコードの変換器を作成できます。CustomJSTransformオブジェクトでは、funcv_func属性が計算用のJavaScriptコードを保持します。funcのコードでは、変数xに現在変換が必要な単一の値が保持され、コードは変換後の値を返します。v_funcのコードでは、xsに変換が必要な配列全体が保持され、コードは変換後の配列を返します。以下の例では、❶CustomJSTransformオブジェクトを作成し、関数\(f(x) = \sin(x) \cdot \sin(10 x)\)の値を計算します。この変換器はデータソースの列データを処理するために使用されるため、func属性のコード定義を省略できます。❷transform()を使用してCustomJSTransformオブジェクトをxという名前の列に適用します。結果は次のグラフ(左)のようになります。

layout_cmap = row(fig_cmap1, fig_cmap2)
show(layout_cmap)
from bokeh.transform import transform
from bokeh.models import CustomJSTransform

x = np.linspace(0, 2 * np.pi, 1000)

source = ColumnDataSource(data=dict(x=x))
fig_cjs1 = figure(frame_width=400, frame_height=250)

transform_sin = CustomJSTransform(  # ❶
    func="return Math.sin(x) * Math.sin(10*x);",
    v_func="return xs.map(x=>Math.sin(x) * Math.sin(10*x));",
)

fig_cjs1.line("x", transform("x", transform_sin), source=source);  # ❷

上記の例では、v_func属性のコードの入力xsはデータソースの特定の列データのみです。CustomJSTransformオブジェクトのargs属性を設定することで、データソースのすべての列データに直接アクセスできます。args属性の使用方法はCustomJSと同じです。以下の例では、sourceモデルオブジェクトをargs属性に渡し、v_funcコード内でsource変数を通じてデータにアクセスします。プログラムはx列とy列の平方和の平方根を返し、この戻り値が各散布点のサイズを決定します。結果は次のグラフ(右)のようになります。

x, y = np.random.normal(scale=0.2, size=(2, 500))
source = ColumnDataSource(data=dict(x=x, y=y))
fig_cjs2 = figure(frame_width=400, frame_height=300)

transform_dist = CustomJSTransform(
    args=dict(source=source),
    v_func="""
        let data = source.data;
        let ys = data.y;
        return xs.map((x, i) => {
            let y = ys[i];
            return 10 * Math.sqrt(x*x + y*y);
        })
    """,
)

c = fig_cjs2.scatter(
    "x",
    "y",
    size=transform("x", transform_dist),
    line_color=None,
    source=source,
    alpha=1,
)
layout_cjs = row(fig_cjs1, fig_cjs2)
show(layout_cjs)

データソースフィルター#

JavaScriptではデータソースのデータをフィルタリングすることもできます。以下の例では、ドロップダウン選択ボックスを使用して散布点の分類を選択できます。❶ドロップダウン選択ボックスを作成し、その候補値リスト属性optionsはデータテーブルのspecies列の選択可能な値であり、現在の値を候補リストの最初の要素に設定します。❷GroupFilterオブジェクトは、データソース内の特定の分類のデータをフィルタリングするために使用されます。属性column_nameはデータソースの列名であり、属性groupはその列のフィルタリング値です。column_name列の値がgroup属性と等しいすべてのデータをフィルタリングします。フィルターはビューオブジェクトCDSViewfilters属性に配置され、このビューのsource属性はフィルタリングされるデータソースです。❹フィルターが正しく機能するためには、図形を作成する際にviewパラメータでビューオブジェクトを指定する必要があります。❺ドロップダウン選択ボックスの値が変更されると、callback内のコードが実行されます。❻このコードでは、cb_objはコールバックコードをトリガーするオブジェクト、つまりドロップダウン選択ボックスを表すオブジェクトです。cb_obj.valueはドロップダウン選択ボックスの現在の値であり、この値を使用してフィルターのgroup属性を設定します。❼最後に、データソースのchange.emit()を呼び出してチャートの表示を更新する必要があります。結果は次のグラフ(左)のようになります。

from bokeh.layouts import column
from bokeh.models import CDSView, GroupFilter, Select

df = pd.read_csv("data/iris.csv", dtype={"species": "category"})

categories = df.species.cat.categories.tolist()
select = Select(options=categories, value=categories[0])  # ❶
source = ColumnDataSource(data=df)
group_filter = GroupFilter(column_name="species", group=select.value)  # ❷
view = CDSView(filter=group_filter)  # ❸

fig_filter1 = figure(frame_width=400, frame_height=300)
c = fig_filter1.scatter(
    "sepal_length",
    "sepal_width",
    line_color=None,
    source=source,
    view=view,
    alpha=1,
    size=6,
)  # ❹

callback = CustomJS(
    args=dict(filter=group_filter, source=source),
    code="""
    filter.group = cb_obj.value; //❻
    source.change.emit();        //❼
    """,
)

select.js_on_change("value", callback)  # ❺
layout_filter1 = column(select, fig_filter1)

CustomJSFilterオブジェクトを使用すると、カスタムコードでデータソースのデータをフィルタリングできます。そのargscodeパラメータはCustomJSと同じです。その戻り値はデータソースのデータをフィルタリングするために使用され、戻り値のタイプは整数配列またはブール配列であり、その使用方法はNumPy配列のインデックス配列と同じです。以下の例では、❶範囲スライダーコントロールRangeSliderを使用してpetal_length列の範囲を選択します。❷CustomJSFilterオブジェクトが範囲スライダーの値を取得できるように、スライダーオブジェクトをargs属性に渡します。❸カスタムコード内では、source変数を使用してフィルタリングされるデータソースを取得できます。❹petal_length列の各データに対して無名関数を呼び出し、スライダーで指定された範囲内にあるかどうかを判断します。ここでは、Array.from(data)を使用して最初にdataをJavaScriptの配列オブジェクトに変換し、その後map()メソッドを使用してブール配列を取得する必要があることに注意してください。結果は次のグラフ(右)のようになります。

from bokeh.models import CustomJSFilter, RangeSlider

source = ColumnDataSource(data=df)
fig_filter2 = figure(frame_width=400, frame_height=300)
slider = RangeSlider(  # ❶
    start=df.petal_length.min(),
    end=df.petal_length.max(),
    value=(df.petal_length.min(), df.petal_length.max()),
    step=0.1,
    title="petal length",
)

func_filter = CustomJSFilter(  # ❷
    args=dict(slider=slider),
    code="""
    let data = source.data['petal_length']; //❸
    let [start, end] = slider.value;
    let res = Array.from(data).map(v => start <= v && v <= end); //❹
    return res;
    """,
)

view = CDSView(filter=func_filter)

c = fig_filter2.scatter(
    "sepal_length",
    "sepal_width",
    fill_color=linear_cmap(
        "petal_length", Viridis[256], df.petal_length.min(), df.petal_length.max()
    ),
    line_color=None,
    source=source,
    view=view,
    alpha=1,
    size=6,
)

callback = CustomJS(args=dict(source=source), code="source.change.emit();")

slider.js_on_change("value", callback)
layout_filter2 = column(slider, fig_filter2)
layout_filter = row(layout_filter1, layout_filter2)
show(layout_filter)

カスタム軸目盛ラベル#

FuncTickFormatterはJavaScriptコードを使用して軸の目盛ラベルを計算できます。以下の例では、さまざまな可能性のフレーズの確率分布を比較し、その効果は次のグラフのようになります。Y軸の目盛ラベルは各フレーズであり、曲線はフレーズのカーネル密度推定を示します。❶まず、Bokehのデモデータベースからproblyを読み込みます。これはDataFrameオブジェクトであり、各列はフレーズの確率評価です。❷gaussian_kde()を使用して、各列データに基づいてカーネル密度推定でその確率分布を計算します。比較を容易にするために、カーネル密度推定曲線の最大値を2に設定します。

from bokeh.sampledata.perceptions import probly  # ❶
from scipy.stats import gaussian_kde
import numpy as np

x = np.linspace(0, 100, 200)
df_kde = probly.apply(lambda s: gaussian_kde(s)(x)).set_index(x)  # ❷
df_kde = df_kde * (2 / df_kde.max())  # ❸

❹ループ内でdf_kdeの各列データに対応する曲線を作成し、曲線のY軸座標はdodge()を使用して異なる値にオフセットします。❺FixedTickerを使用してY軸目盛の位置を設定します。❻FuncTickFormatterを使用してY軸の目盛ラベルを設定します。その使用方法はCustomJSと同じです。プログラムではlabels変数を使用してY軸目盛位置を表すリストを受け取り、JavaScriptでそのリストを使用して目盛ラベルを取得します。目盛の位置はtick変数で取得できます。

from bokeh.models import FixedTicker, CustomJSTickFormatter
from bokeh.palettes import Category20_20

source = ColumnDataSource(df_kde)
fig_tick = figure()

for i, col in enumerate(df_kde.columns):
    fig_tick.line(
        "index",
        dodge(col, i),
        source=source,
        color=Category20_20[i],
        line_width=2,
        alpha=0.6,
    )  # ❹

fig_tick.yaxis.ticker = FixedTicker(ticks=np.arange(df_kde.shape[1]))  # ❺
fig_tick.yaxis.ticker.tags = ["x", "y", "z"]
fig_tick.yaxis.formatter = CustomJSTickFormatter(
    args=dict(labels=list(df_kde.columns)), code="return labels[tick];"
)  # ❻
show(fig_tick)

インタラクティブツール#

チャートのツールバーには、JavaScriptコールバックコードを実行できる3つのツールがあります:CustomActionTapToolHoverToolCustomActionツールボタンがクリックされるとコールバックコードが実行され、TapToolツールを使用するとチャート内の図形をクリックしたときにコールバックコードが実行され、HoverToolを使用するとマウスが図形上に移動したときにコールバックコードが実行されます。以下の例では、HoverToolツールを使用して中国の各州の概要を表示し、その効果は次のグラフのようになります。カーソルが州の上にホバーすると、右側の情報欄に州の情報がリアルタイムで表示されます。

❶ファイルchina_simple.jsonは、中国の各州の形状を保存する地理空間データ(GeoJSON)ファイルです。BokehのGeoJSONDataSourceデータソースはGeoJSONテキストを直接処理できるため、ここでは直接このファイルを読み込みます。❷ファイルchina_info.jsonは各州の概要を保持するJSONファイルです。json.load()を使用してこのファイルを読み込み、辞書オブジェクトprovince_infoを取得します。この辞書のキーは州名であり、値は概要テキストです。

patches()を使用して各州の形状を表示し、塗りつぶし色はデータソースのpopulation列を使用して計算します。❹Div要素を使用して概要テキストを表示し、そのtags属性を使用してすべての州の概要province_infoを保存します。Bokehは自動的にPythonの辞書オブジェクトをJavaScriptの対応するオブジェクトに変換します。❺HoverToolツールオブジェクトを作成します。属性renderersはこのツールのコールバックコードをトリガーできる図形オブジェクトのリストです。

コールバックコードでは、❻cb_data.index.indicesはマウスがホバーしている図形に対応するインデックスリストです。このインデックスリストが空でない場合は、その最初のインデックスを取得し、関連情報を表示します。❼データソースでは、name列に各州の名前が保存されており、マウスがホバーしている州の名前を取得した後、❽州の概要辞書infoで対応する概要テキストを見つけ、div_info.text属性を更新してインターフェースに概要を表示します。

import json
from bokeh.models import GeoJSONDataSource, Div, CustomJS, HoverTool
from bokeh.layouts import row
from bokeh.transform import linear_cmap
from bokeh.palettes import Viridis

with open("data/china_simple.json", encoding="utf-8") as f:  # ❶
    geojson_string = f.read()

with open("data/china_info.json", encoding="utf-8") as f:  # ❷
    province_info = json.load(f)

source = GeoJSONDataSource(geojson=geojson_string)
fig = figure(match_aspect=True, aspect_scale=1, frame_width=500, frame_height=500)
patches = fig.patches(
    "xs",
    "ys",
    source=source,
    line_color="black",  # ❸
    fill_color=linear_cmap("population", Viridis[256], low=None, high=None),
)

div_info = Div(width=300, tags=[province_info])  # ❹

callback = CustomJS(
    args=dict(source=source, div_info=div_info),
    code="""
    let indices = cb_data.index.indices; //❻
    let info = div_info.tags[0];

    if(indices.length > 0){
        let name = source.data['name'][indices[0]]; //❼
        let text = info[name]; //❽
        div_info.text = `<h2>${name}</h2><p>${text}</p>`; 
    }
    """,
)

hover = HoverTool(tooltips=None, callback=callback, renderers=[patches])  # ❺
fig.tools.append(hover)
layout = row(fig, div_info)
show(layout)