Search code examples
javascriptpythoncallbackbokeh

How to filter a Bokeh visual based on GeoJSON data?


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?


EDIT

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?


Solution

  • The solution below

    1. simply resets the data which is shown and
    2. copies the data selected by the MultiChoice in the source
    3. emit the changes
    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

    Toggle rects