Thanks for the quick response, changing layout children list is also what I intended, however it doesn’t work as expected. I’m attaching a demo code here.
import os
from typing import Tuple
import pandas as pd
import numpy as np
from bokeh.models.widgets import MultiSelect, RangeSlider, Select, Button, Div, Paragraph, \
DateRangeSlider, DataTable, TableColumn
from bokeh.layouts import column, widgetbox, Spacer, row
from bokeh.models import ColumnDataSource, Column
from functools import partial
from bokeh.io import curdoc
def widget_height(rows: int, row_height: int, height_min: int, height_max: int) -> int:
"""
Sets height of a selection widget
:param rows: how many options to be listed
:param row_height: height for a single option
:param height_min: lower cap of widget height
:param height_max: upper cap of widget height
:return: int value of widget height
"""
if rows * row_height < height_min:
height = height_min
elif rows * row_height > height_max:
height = height_max
else:
height = rows * row_height
return height
def column_type(input_df: pd.DataFrame) -> Tuple[list, list, list]:
"""
Segregate df columns by dtype
Generates 3 lists of column names by column dtype
:param input_df: input dataframe
:return: list of string column, list of number column, list of datetime column
"""
columns = input_df.columns
string_cols = [x for x in columns if (('object' in str(input_df[x].dtype)) or
('category' in str(input_df[x].dtype)) or
('bool' in str(input_df[x].dtype)))]
number_cols = [x for x in columns if(('int' in str(input_df[x].dtype)) or
('float' in str(input_df[x].dtype)))]
datetime_cols = [x for x in columns if 'datetime' in str(input_df[x].dtype)]
return string_cols, number_cols, datetime_cols
def replace_na(input_df: pd.DataFrame) -> pd.DataFrame:
"""Replaces empty cells in a dataframe to meaningful values
For string columns, fill empty as 'N/A'
For number columns, fill empty or infinite as 0
:param input_df: dataframe with (potentially) empty cells
:return: dataframe with empty cells filled up
"""
string_cols, number_cols, datetime_cols = column_type(input_df)
for col in string_cols:
input_df[col].fillna('N/A', inplace=True)
for col in number_cols:
input_df[col].replace(np.nan, 0, inplace=True)
input_df[col].replace(np.inf, 0, inplace=True)
for col in datetime_cols:
input_df[col].replace(np.nan, 0, inplace=True)
return input_df
def generate_wid_table(df: pd.DataFrame, width: int = 800,
height: int = 400, fit: bool = False) -> DataTable:
"""
Converts a DataFrame into a bokeh data_table widget
:param df: input dataframe
:param width: table width
:param height: table height
:param fit: boolean flag of fit_column
:return: bokeh dataTable widget
"""
source_cols = []
for i in df.columns.values:
col = TableColumn(field=i, title=i)
source_cols.append(col)
wid_table = DataTable(source=ColumnDataSource(df), columns=source_cols, margin=[2, 2, 10, 10],
fit_columns=fit, width=width, height=height)
return wid_table
class BokehFilters:
"""
Takes a given dataframe and generates bokeh page of widget components, then applies the
customized filters on dataset to generate a new subset.
Variables that programmers can set: widget height, widget width, debug switch
"""
def __init__(self, df: pd.DataFrame, widget_width: int = 250, row_height: int = 30,
height_min: int = 80, height_max: int = 120, debug_flag: bool = True):
"""
Initializes with given dataframe, outputs widgets on bokeh page layout
:param df: input pandas dataframe
:param widget_width: global var or widget width in final output page
:param row_height: height of a single option in MultiSelect widget
:param height_min: lower cap of MultiSelect widget height
:param height_max: upper cap of MultiSelect widget height
:param debug_flag: switch to turn on/off debug messages
"""
self.DEBUG = debug_flag
self.raw_data = replace_na(df)
self.filtered_data = pd.DataFrame()
self.columns = self.raw_data.columns.to_list()
self.str_cols, self.num_cols, self.dt_cols = column_type(self.raw_data)
self.filter_name_list = []
self.filter_widget_list = []
self.x_btn_list = []
self.header = Div(text='<b>Data Filter</b>',
style={'font-size': '120%', 'color': 'darkgreen'})
self.select_column = Select(title='Choose Column:', value=self.columns[0],
options=self.columns)
self.btn_add = Button(label='Add filter on selected column', button_type='success')
self.btn_add.on_click(self.add_filter_widget)
self.btn_remove = Button(label='Remove filter on selected column', button_type='primary')
self.btn_remove.on_click(self.remove_filter_widget)
self.para_debug = Paragraph(text='', visible=self.DEBUG)
self.placeholder1 = Paragraph(text='')
self.placeholder2 = Paragraph(text='')
self.btn_apply_filter = Button(label='Apply Filters', button_type='success')
self.btn_apply_filter.on_click(self.apply_filters)
self.btn_clear_filter = Button(label='Clear All Filters', button_type='primary')
self.btn_clear_filter.on_click(self.clear_filters)
self.para_dataset_desc = Paragraph(text='')
self.data_table = generate_wid_table(self.filtered_data)
self.space_h = Spacer(height=3, background='gray')
self.widget_width = widget_width
self.row_height = row_height
self.widget_height_min = height_min
self.widget_height_max = height_max
self.page_layout = column(self.header,
self.select_column,
self.btn_add,
self.btn_remove,
self.space_h,
self.para_debug,
row(self.placeholder1, self.placeholder2),
self.btn_apply_filter,
self.btn_clear_filter,
self.para_dataset_desc,
self.data_table,
width=250
# sizing_mode='fixed'
)
def generate_category_filter(self, col_name: str) -> MultiSelect:
"""
Generates a MultiSelect (dropdown menu) widget for string type column
A "SelectAll" options is added to option list and enabled by default, so users don't have
to shift-scroll
:param col_name: dataframe column name to make filter on
:return: one MultiSelect (dropdown menu) widget
"""
option_vals = list(self.raw_data[col_name].unique())
option_vals.sort()
options_count = len(option_vals)
select_widget = MultiSelect(title=col_name,
value=['SelectAll'],
options=['SelectAll'] + option_vals,
name=col_name,
height=widget_height(options_count,
self.row_height,
self.widget_height_min,
self.widget_height_max),
width=self.widget_width)
return select_widget
def generate_range_filter(self, col_name: str) -> RangeSlider:
"""
Generates a RangeSlider widget for number type column
:param col_name: dataframe column name to make filter on
:return: one RangeSlider widget
"""
range_min = self.raw_data[col_name].min()
range_max = self.raw_data[col_name].max()
range_widget = RangeSlider(title=col_name,
name=col_name,
start=range_min,
end=range_max,
value=(range_min, range_max), )
return range_widget
def generate_daterange_filter(self, col_name: str) -> DateRangeSlider:
"""
Generates a DateRangeSlider widget for datetime type column
:param col_name: dataframe column name to make filter on
:return: one DateRangeSlider widget, the returned value of this slider is a tuple of int,
need to use value_as_datetime property instead
"""
date_min = self.raw_data[col_name].min()
date_max = self.raw_data[col_name].max()
daterange_slider = DateRangeSlider(title="Date Range:",
name=col_name,
start=date_min,
end=date_max,
value=(date_min, date_max),
step=1)
return daterange_slider
def generate_x_btn(self, col_name: str) -> Button:
x_btn = Button(label='Remove {}'.format(col_name), button_type='danger')
x_btn.name = col_name
x_btn.on_click(partial(self.remove_x_btn, name=x_btn.name))
self.x_btn_list.append(x_btn)
return x_btn
def remove_x_btn(self, name: str):
for filter_widget in self.filter_widget_list:
if filter_widget.name == name:
self.filter_widget_list.remove(filter_widget)
for x_btn in self.x_btn_list:
if x_btn.name == name:
self.x_btn_list.remove(x_btn)
self.filter_name_list.remove(name)
self.page_layout.children[6].children[0] = Column(children=self.filter_widget_list)
self.page_layout.children[6].children[1] = Column(children=self.x_btn_list)
self.para_debug.text = str(self.filter_name_list)
def add_filter_widget(self):
"""
Updates widget lists and page upon "button_add" click:
Adds the widget into filter_widget_list (by respctive dtype);
Adds the column name into filter_name_list
Finally refreshes page layout
"""
col_name = self.select_column.value
if col_name not in self.filter_name_list:
self.filter_name_list.append(col_name)
if col_name in self.num_cols:
filter_widget = self.generate_range_filter(col_name)
elif col_name in self.dt_cols:
filter_widget = self.generate_daterange_filter(col_name)
else:
filter_widget = self.generate_category_filter(col_name)
self.filter_widget_list.append(filter_widget)
x_btn = self.generate_x_btn(col_name)
self.x_btn_list.append(x_btn)
self.page_layout.children[6].children[0] = Column(children=self.filter_widget_list)
self.page_layout.children[6].children[1] = Column(children=self.x_btn_list)
self.para_debug.text = 'Active Filter(s):' + str(self.filter_name_list)
def remove_filter_widget(self):
"""
Updates widget lists and page upon "button_remove" click:
Removes the widget from filter_widget_list (by respctive dtype);
Removes the column name from filter_name_list
Finally refreshes page layout
"""
col_name = self.select_column.value
for filter_widget in self.filter_widget_list:
if filter_widget.name == col_name:
self.filter_widget_list.remove(filter_widget)
self.filter_name_list.remove(col_name)
else:
continue
self.page_layout.children[6] = Column(children=self.filter_widget_list)
self.para_debug.text = str(self.filter_name_list)
def apply_filters(self):
"""
Updates widget lists and page upon "button_apply" click:
Makes a subset of dataframe by applying user selected filters on raw data
Also updates the description text to brief data shape
"""
df = self.raw_data.copy()
for filter_widget in self.filter_widget_list:
if filter_widget.name in self.num_cols:
df = df[(df[filter_widget.name] >= filter_widget.value[0]) &
(df[filter_widget.name] <= filter_widget.value[1])]
elif filter_widget.name in self.dt_cols:
df = df[(df[filter_widget.name] >= filter_widget.value_as_datetime[0]) &
(df[filter_widget.name] <= filter_widget.value_as_datetime[1])]
else:
if filter_widget.value != ['SelectAll']:
df = df[df[filter_widget.name].isin(filter_widget.value)]
self.filtered_data = df.copy()
self.para_dataset_desc.text = 'Selected {} rows, Total {} rows'.format(
self.filtered_data.shape[0], self.raw_data.shape[0])
self.page_layout.children[-1] = generate_wid_table(self.filtered_data)
def clear_filters(self):
"""
Updates widget lists and page upon "button_clear" click:
Clears filter_widget_lists (by respctive dtype);
Clears filter_name_list
Reset filtered_dataset to empty
Finally refreshes page layout
"""
self.filter_widget_list = []
self.filter_name_list = []
self.filtered_data = pd.DataFrame()
self.page_layout.children[6] = Column(children=self.filter_widget_list)
self.para_debug.text = str(self.filter_name_list)
self.para_dataset_desc.text = 'Selected {} rows, Total {} rows'.format(
self.filtered_data.shape[0], self.raw_data.shape[0])
script_path = os.path.dirname(__file__)
sample_csv = os.path.join(script_path, '..', 'data', 'sample.csv')
sample_df = pd.read_csv(sample_csv, low_memory=False)
filter_object = BokehFilters(sample_df)
curdoc().add_root(filter_object.page_layout)