0

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?

manoelpqueiroz
  • 575
  • 1
  • 7
  • 17

2 Answers2

1

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

mosc9575
  • 5,618
  • 2
  • 9
  • 32
  • That looks much cleaner than my clumsy implementation! Only minor adjustment is when all `MultiChoice` options are cleared all elements should be visible (i.e., reset the filter), but as of now its behaviour makes the plot be shown as a blank. – manoelpqueiroz Nov 02 '21 at 13:44
  • Also, how did you know you could get the `.data` attribute of the GeoJSON? – manoelpqueiroz Nov 02 '21 at 13:54
  • My source is mostly the [bokhe JS callbacks documentation](https://docs.bokeh.org/en/latest/docs/user_guide/interaction/callbacks.html#customjs-for-tools). There are some interessting examples and you can find this little trick there. – mosc9575 Nov 02 '21 at 16:31
  • That's where I was looking to see if I could use bits of their examples. But only now I've noted my mistake: I didn't see that [`GeoJSONDataSource`](https://kutt.it/PRko0X) objects inherit from `ColumnDataSource`. Anyway, thanks for the help. – manoelpqueiroz Nov 02 '21 at 23:17
0

Basically the solution I've managed to find involves creating a copy of the GeoJSON structure to serve as the "original" so anytime the filter updates, the values will be compared against this "original" to ensure:

  • Added filters are reflected with additional data points;
  • Clearing the filters recreate the original structure as a whole.

Along with the splice function of JSONs, we're able to update the JSON inplace:

gs = GeoJSONDataSource(geojson=inter_f.to_json())

# gs_org = gs creates a shallow copy, not ideal
gs_org = GeoJSONDataSource(geojson=inter_f.to_json())

cjs = CustomJS(args={'choice':mc, 'map':gs, 'map_org':gs_org, 'max_filter':len(ops)},
               code=tjs)

tjs will be a string variable with the following JS code:

var gjs = JSON.parse(map_org.geojson);
var n_instances = Object.keys(gjs.features).length;

var txt_mc = choice.value;

if (txt_mc == "") {
    map.geojson = map_org.geojson;
} else {
    var n_filter = String(txt_mc).split(",").length;

    if (n_filter == max_filter) {
        map.geojson = map_org.geojson;
    } else {
        var deleters = [];
        for (var i = 0; i < n_instances; i++){
            if (!txt_mc.includes(gjs.features[i].properties.type)){
                deleters.push(i);
            }
        }
        deleters.reverse();
        for (var i = 0; i < deleters.length; i++){
            gjs.features.splice(deleters[i], 1);
        }
        map.geojson = JSON.stringify(gjs);
    }
}

map.change.emit();

I'm pretty sure it's performing poorly (I have absolutely no knowledge of JavaScript), so if anyone is willing to propose an optimised solution, I'm all ears.

manoelpqueiroz
  • 575
  • 1
  • 7
  • 17