I'm using some shapefiles in Bokeh and after following the rationale behind this tutorial, one functionality I'd like to add to my plot is the possibility of filtering values based on a single or multiple selections on a MultiChoice widget:
import geopandas as gpd
from shapely.geometry import Polygon
from bokeh.io import show
from bokeh.models import (GeoJSONDataSource, HoverTool, BoxZoomTool,
MultiChoice)
from bokeh.models.callbacks import CustomJS
from bokeh.layouts import column
from bokeh.plotting import figure
# Creating a GeoDataFrame as an example
data = {
'type':['alpha', 'beta', 'gaga', 'alpha'],
'age':['young', 'adult', 'really old', 'Methuselah'],
'favourite_food':['banana', 'fish & chips', 'cookieeeeees!', 'pies'],
'geometry':[Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
Polygon([(2, 2), (2, 3), (3, 3), (3, 2)]),
Polygon([(4, 4), (4, 3), (3, 3), (3, 4)]),
Polygon([(1, 3), (2, 3), (2, 4), (1, 4)])]}
df = gpd.GeoDataFrame(data)
# Creating the Bokeh data source
gs = GeoJSONDataSource(geojson=df.to_json())
# Our MultiChoice widget accepts the values in the "type" column
ops = ['alpha', 'beta', 'gaga']
mc = MultiChoice(title='Type', options=ops)
# The frame of the Bokeh figure
f = figure(title='Three little squares',
toolbar_location='below',
tools='pan, wheel_zoom, reset, save',
match_aspect=True)
# The GeoJSON data added to the figure
plot_area = f.patches('xs', 'ys', source=gs, line_color=None, line_width=0.25,
fill_alpha=1, fill_color='blue')
# Additional tools (may be relevant for the case in question)
f.add_tools(
HoverTool(renderers=[plot_area], tooltips=[
('Type', '@type'),
('Age', '@age'),
('Favourite Food', '@favourite_food')]),
BoxZoomTool(match_aspect=True))
# EDIT: creating the callback
tjs = '''
var gjs = JSON.parse(map.geojson);
var n_instances = Object.keys(gjs.features).length;
var txt_mc = choice.value;
if (txt_mc == "") {
alert("empty string means no filter!");
} else {
var n_filter = String(txt_mc).split(",").length;
if (n_filter == max_filter) {
alert("all values are selected: reset filter!");
} else {
var keepers = [];
for (var i = 0; i < n_instances; i++){
if (txt_mc.includes(gjs.features[i].properties.type)){
keepers.push(i);
}
}
alert("Objects to be kept visible: " + String(keepers));
}
}
'''
cjs = CustomJS(args={'choice':mc, 'map':gs, 'max_filter':len(ops)}, code=tjs)
mc.js_on_change('value', cjs)
show(column(mc, f))
However, I'm completely oblivious as to how I should write the custom JavaScript callback to a GeoJSONDataSource
, or if this is even possible, given that other examples I've found here in SO deal with ColumnDataSource
objects instead, like here, and Bokeh's tutorial seems to favour static filters.
Is it possible to dynamically filter the data? If so, how should the callback be structured?
I've managed to at least build the callback logic to bind objects to the values selected on the MultiChoice
, via the variables tjs
and cjs
. However, the last piece of the puzzle remains: because gs
is not a ColumnDataSource
, I'm not able to use CDSView
along with CustomJSFilter
to do the job, like the article did ("Slider Tool" section). Any ideas?
The solution below
callback = CustomJS(args=dict(gs=gs, gs_org=gs_org, mc=mc),
code="""
const show = gs.data;
var base = gs_org.data;
var array = ['xs', 'ys', 'type', 'age', 'favourite_food']
// clear all old data
for(let element of array)
show[element] = []
// set default if no label is selected
if(mc.value.length==0)
mc.value = ['alpha', 'beta', 'gaga']
// loop trough all values in MultiChoice
for(let i=0; i<mc.value.length;i++){
let value = mc.value[i]
var idx = base['type'].indexOf(value);
// search for multiple indexes for "value"
while(idx!=-1){
// set new data
for(let element of array)
show[element].push(base[element][idx])
idx = base['type'].indexOf(value, idx + 1);
}
}
gs.change.emit()
"""
)
mc.js_on_change('value', callback)
Output