def test_js_on_change_executes(self, bokeh_model_page): group = RadioButtonGroup(labels=LABELS, css_classes=["foo"]) group.js_on_click(CustomJS(code=RECORD("active", "cb_obj.active"))) page = bokeh_model_page(group) el = page.driver.find_element_by_css_selector('.foo .bk-btn:nth-child(3)') el.click() results = page.results assert results['active'] == 2 el = page.driver.find_element_by_css_selector('.foo .bk-btn:nth-child(1)') el.click() results = page.results assert results['active'] == 0 assert page.has_no_console_errors()
def test_js_on_change_executes(self, bokeh_model_page: BokehModelPage) -> None: group = RadioButtonGroup(labels=LABELS) group.js_on_click(CustomJS(code=RECORD("active", "cb_obj.active"))) page = bokeh_model_page(group) el = find_element_for(page.driver, group, ".bk-btn:nth-child(3)") el.click() results = page.results assert results['active'] == 2 el = find_element_for(page.driver, group, ".bk-btn:nth-child(1)") el.click() results = page.results assert results['active'] == 0 assert page.has_no_console_errors()
def test_js_on_change_executes(self, bokeh_model_page): group = RadioButtonGroup(labels=LABELS, css_classes=["foo"]) group.js_on_click(CustomJS(code=RECORD("active", "cb_obj.active"))) page = bokeh_model_page(group) el = page.driver.find_element_by_css_selector( 'div.foo div label input[value="2"]') el = el.find_element_by_xpath('..') el.click() results = page.results assert results['active'] == 2 el = page.driver.find_element_by_css_selector( 'div.foo div label input[value="0"]') el = el.find_element_by_xpath('..') el.click() results = page.results assert results['active'] == 0 assert page.has_no_console_errors()
code= "console.log('radio_group: active=' + this.active, this.toString())")) checkbox_button_group = CheckboxButtonGroup( labels=["Option 1", "Option 2", "Option 3"], active=[0, 1]) checkbox_button_group.js_on_click( CustomJS( code= "console.log('checkbox_button_group: active=' + this.active, this.toString())" )) radio_button_group = RadioButtonGroup( labels=["Option 1", "Option 2", "Option 3"], active=0) radio_button_group.js_on_click( CustomJS( code= "console.log('radio_button_group: active=' + this.active, this.toString())" )) widget_box = Column(children=[ button, button_disabled, toggle_inactive, toggle_active, dropdown, dropdown_disabled, dropdown_split, checkbox_group, radio_group, checkbox_button_group, radio_button_group,
def plot_data(self, plotdata): self.init_plotter(plotdata) p = figure(plot_width=1500, plot_height=500, title=self.plotType.name, x_axis_type="datetime") #TODO add color , hide and delete button ################### mmt block ##################### #+----------------+--------------+----------------+# #| mmtButtonGroup | mmtBoxNum | mmtBoxActivity |# #+----------------+--------------+----------------+# #################################################### #+----------------+----------------+ # #|+--boxHeader---+|+--boxHeader---+| # #|| boxTitle ||| boxTitle || # #|| mmtLegendNum ||| mmtLegendNum || # #|+--------------+|+--------------+| # #| legend_1 | legend_1 | # #| legend_2 | legend_2 | # #| ... | ... | # #+----------------+----------------+ # #################################################### # ROW_2 :: mmt block ################################## maxBoxNum = 5 maxLegendNum = 5 ## mmt block header mmtButtonGroup = RadioButtonGroup(labels=ZmPlotter.mmtLabels) boxNum = ["number" for i in range(maxBoxNum)] boxActivity = ["activity" for i in range(maxBoxNum)] mmtBoxNum = RadioButtonGroup(labels=boxNum, active=0, visible=False) mmtBoxActivity = RadioButtonGroup(labels=boxActivity, active=0, visible=False) mmtLegendBlockHead = row(mmtButtonGroup, mmtBoxNum, mmtBoxActivity) ## mmt box array bgColor = "black" defaultBoxColor = "white" selectedBoxColor = "red" boxHeaderW = 100 mmtLegendH = 20 mmtLegendW = 400 #TODO add button beside the box title for color-div and remove-option of the box mmtLegendBoxArr = self.setup_mmt_legendboxArr(maxBoxNum, maxLegendNum) mmtModelPlotArr = self.plot_mmt(maxBoxNum, p) ## mmt block mmtLegendBlock = column(mmtLegendBlockHead, mmtLegendBoxArr) mmtButtonGroup.js_on_click( CustomJS(args=dict(mmtLegendBlock=mmtLegendBlock, mmtLabels=ZmPlotter.mmtLabels, height=mmtLegendH, width=boxHeaderW, maxBoxNum=maxBoxNum, selColor=selectedBoxColor, defaultColor=defaultBoxColor), code=""" if(cb_obj.active == 'None') return; var blockHeader = (mmtLegendBlock.children)[0].children; var curBoxNum = blockHeader[1]; var activeBox = blockHeader[2]; var boxArr = (mmtLegendBlock.children)[1].children; if(activeBox.active > 0){ var oldBoxTitle = (((boxArr[activeBox.active-1].children)[0]).children)[0]; oldBoxTitle.button_type = 'default'; oldBoxTitle.background=defaultColor; } if(curBoxNum.active < maxBoxNum){ activeBox.active = ++curBoxNum.active; var boxTitle = (((boxArr[activeBox.active-1].children)[0]).children)[0]; boxTitle.label = mmtLabels[cb_obj.active]+'_'+curBoxNum.active; boxTitle.visible = true; boxTitle.height = height; boxTitle.width = width; boxTitle.button_type = 'success'; boxTitle.background=selColor; }else{ activeBox.active = 0; } mmtLegendBlock.change.emit(); cb_obj.active = 'None'; """)) for boxIndex in range(maxBoxNum): legendBox = (mmtLegendBoxArr.children)[boxIndex] legendArr = legendBox.children legendBoxTitle = (legendArr[0].children)[0] legendBoxTitle.js_on_click( CustomJS(args=dict(mmtLegendBlock=mmtLegendBlock, boxIndex=boxIndex, selColor=selectedBoxColor, defaultColor=defaultBoxColor), code=""" var blockHeader = (mmtLegendBlock.children)[0].children; var curBoxNum = blockHeader[1]; var activeBox = blockHeader[2]; var boxArr = (mmtLegendBlock.children)[1].children; if(activeBox.active > 0){ var oldBoxTitle = (((boxArr[activeBox.active-1].children)[0]).children)[0]; oldBoxTitle.button_type = 'default'; oldBoxTitle.background=defaultColor; } activeBox.active = boxIndex + 1; var boxTitle = (((boxArr[boxIndex].children)[0]).children)[0]; boxTitle.button_type = 'success'; boxTitle.background = selColor; mmtLegendBlock.change.emit(); """)) for legendIndex in range(1, maxLegendNum): legend = legendArr[legendIndex] # TODO legend.js_on_click to delete model ####################################### legend.js_on_click( CustomJS(args=dict(mmtBlockHead=mmtLegendBlockHead, mmtBoxArr=mmtLegendBoxArr, bgColor=bgColor, boxIndex=legendIndex), code=""" """)) # ROW_1 :: p + legends ####################################################################### items = [] for name, model in self.modelDict.items(): renderer = self.plot_model(p, model) item = LegendItem(label=name, renderers=[renderer]) renderer_cb = CustomJS(args=dict( mmtLegendBlock=mmtLegendBlock, mmtModelArr=mmtModelPlotArr['mmtModelArr'], mmtPlotArr=mmtModelPlotArr['mmtPlotArr'], height=mmtLegendH, width=mmtLegendW, maxLegendNum=maxLegendNum, legendName=name, model=model.get_val_cds(), legendColor=renderer.glyph.line_color), code=""" var blockHeader = (mmtLegendBlock.children)[0].children; var activeBox = blockHeader[2]; var boxArr = (mmtLegendBlock.children)[1].children; var legendArr = boxArr[activeBox.active-1].children; var curLegendNum = (legendArr[0].children)[1]; if(activeBox.active > 0 && curLegendNum.active < maxLegendNum){ for(var i=1; i<=curLegendNum.active; i++){ if(legendArr[i].label == legendName) return; } curLegendNum.active++; legendArr[curLegendNum.active].label = legendName; legendArr[curLegendNum.active].visible = true; legendArr[curLegendNum.active].height = height; //legendArr[curLegendNum.active].width_policy = "fit"; legendArr[curLegendNum.active].width = width; legendArr[curLegendNum.active].background = legendColor; mmtLegendBlock.change.emit(); //update mmtplot var legendY = model['y']; var mmtModel = mmtModelArr[activeBox.active-1]; var mmtPlot = mmtPlotArr[activeBox.active-1]; var yArr = (mmtModel.data)['y']; for (var i=0; i<yArr.length; i++){ yArr[i] = (yArr[i]*(curLegendNum.active-1)+legendY[i])/curLegendNum.active; } mmtModel.change.emit(); mmtPlot.visible = true; mmtPlot.change.emit(); cb_obj.visible = false; } """) renderer.js_on_change('muted', renderer_cb) items.append(item) self.setup_axis(p) p.toolbar.autohide = True legend = self.setup_legends(p, items) # LAYOUT :: ROW_1, ROW_2 ######################### layout = column(p, mmtLegendBlock) # tab pages #tab1 = Panel(child=fig1, title="sine") #tab2 = Panel(child=fig2, title="cos") #tabs = Tabs(tabs=[ tab1, tab2 ]) # output return self.do_export(layout, p)
def __make_daybyday_interactive_timeline( df: pd.DataFrame, *, geo_df: geopandas.GeoDataFrame, value_col: str, transform_df_func: Callable[[pd.DataFrame], pd.DataFrame] = None, stage: Union[DiseaseStage, Literal[Select.ALL]] = Select.ALL, count: Union[Counting, Literal[Select.ALL]] = Select.ALL, out_file_basename: str, subplot_title_prefix: str, plot_aspect_ratio: float = None, cmap=None, n_cbar_buckets: int = None, n_buckets_btwn_major_ticks: int = None, n_minor_ticks_btwn_major_ticks: int = None, per_capita_denominator: int = None, x_range: Tuple[float, float], y_range: Tuple[float, float], min_visible_y_range: float, should_make_video: bool, ) -> InfoForAutoload: """Create the bokeh interactive timeline plot(s) This function takes the given DataFrame, which must contain COVID data for locations on different dates, and a GeoDataFrame, which contains the long/lat coords for those locations, and creates an interactive choropleth of the COVID data over time. :param df: The COVID data DataFrame :type df: pd.DataFrame :param geo_df: The geometry GeoDataFrame for the locations in `df` :type geo_df: geopandas.GeoDataFrame :param value_col: The column of `df` containing the values to plot in the choropleth; should be something like "Case_Counts" or "Case_Diff_From_Prev_Day" :type value_col: str :param stage: The DiseaseStage to plot, defaults to Select.ALL. If ALL, then all stages are plotted and are stacked vertically. :type stage: Union[DiseaseStage, Literal[Select.ALL]], optional :param count: The Counting to plot, defaults to Select.ALL. If ALL, then all count types are plotted and are stacked horizontally. :type count: Union[Counting, Literal[Select.ALL]], optional :param out_file_basename: The basename of the file to save the interactive plots to (there are two components, the JS script and the HTML <div>) :type out_file_basename: str :param subplot_title_prefix: What the first part of the subplot title should be; probably a function of `value_col` (if value_col is "Case_Counts" then this param might be "Cases" or "# of Cases") :type subplot_title_prefix: str :param x_range: The range of the x-axis as (min, max) :type x_range: Tuple[float, float] :param y_range: The range of the y-axis as (min, max) :type y_range: Tuple[float, float] :param min_visible_y_range: The minimum height (in axis units) of the y-axis; it will not be possible to zoom in farther than this on the choropleth. :type min_visible_y_range: float :param should_make_video: Optionally run through the timeline day by day, capture a screenshot for each day, and then stitch the screenshots into a video. The video shows the same info as the interactive plots, but not interactively. This easily takes 20x as long as just making the graphs themselves, so use with caution. :type should_make_video: bool :param transform_df_func: This function expects data in a certain format, and does a bunch of preprocessing (expected to be common) before plotting. This gives you a chance to do any customization on the postprocessed df before it's plotted. Defaults to None, in which case no additional transformation is performed. :type transform_df_func: Callable[[pd.DataFrame], pd.DataFrame], optional :param plot_aspect_ratio: The aspect ratio of the plot as width/height; if set, the aspect ratio will be fixed to this. Defaults to None, in which case the aspect ratio is determined from the x_range and y_range arguments :type plot_aspect_ratio: float, optional :param cmap: The colormap to use as either a matplotlib-compatible colormap or a list of hex strings (e.g., ["#ae8f1c", ...]). Defaults to None in which case a reasonable default is used. :type cmap: Matplotlib-compatible colormap or List[str], optional :param n_cbar_buckets: How many colorbar buckets to use. Has little effect if the colormap is continuous, but always works in conjunction with n_buckets_btwn_major_ticks to determine the number of major ticks. Defaults to 6. :type n_cbar_buckets: int, optional :param n_buckets_btwn_major_ticks: How many buckets are to lie between colorbar major ticks, determining how many major ticks are drawn. Defaults to 1. :type n_buckets_btwn_major_ticks: int, optional :param n_minor_ticks_btwn_major_ticks: How many minor ticks to draw between colorbar major ticks. Defaults to 8 (which means each pair of major ticks has 10 ticks total). :type n_minor_ticks_btwn_major_ticks: int, optional :param per_capita_denominator: When describing per-capita numbers, what to use as the denominator (e.g., cases per 100,000 people). If None, it is automatically computed per plot to be appropriately scaled for the data. :type per_capita_denominator: int, optional :raises ValueError: [description] :return: The two pieces of info required to make a Bokeh autoloading HTML+JS plot: the HTML div to be inserted somewhere in the HTML body, and the JS file that will load the plot into that div. :rtype: InfoForAutoload """ Counting.verify(count, allow_select=True) DiseaseStage.verify(stage, allow_select=True) # The date as a string, so that bokeh can use it as a column name STRING_DATE_COL = "String_Date_" # A column whose sole purpose is to be a (the same) date associated with each # location FAKE_DATE_COL = "Fake_Date_" # The column we'll actually use for the colors; it's computed from value_col COLOR_COL = "Color_" # Under no circumstances may you change this date format # It's not just a pretty date representation; it actually has to match up with the # date strings computed in JS DATE_FMT = r"%Y-%m-%d" ID_COLS = [ REGION_NAME_COL, Columns.DATE, Columns.STAGE, Columns.COUNT_TYPE, ] if cmap is None: cmap = cmocean.cm.matter if n_cbar_buckets is None: n_cbar_buckets = 6 if n_buckets_btwn_major_ticks is None: n_buckets_btwn_major_ticks = 1 if n_minor_ticks_btwn_major_ticks is None: n_minor_ticks_btwn_major_ticks = 8 n_cbar_major_ticks = n_cbar_buckets // n_buckets_btwn_major_ticks + 1 try: color_list = [ # Convert matplotlib colormap to bokeh (list of hex strings) # https://stackoverflow.com/a/49934218 RGB(*rgb).to_hex() for i, rgb in enumerate((255 * cmap(range(256))).astype("int")) ] except TypeError: color_list = cmap color_list: List[BokehColor] if stage is Select.ALL: stage_list = list(DiseaseStage) else: stage_list = [stage] if count is Select.ALL: count_list = list(Counting) else: count_list = [count] stage_list: List[DiseaseStage] count_list: List[Counting] stage_count_list: List[Tuple[DiseaseStage, Counting]] = list( itertools.product(stage_list, count_list)) df = df.copy() # Unadjust dates (see SaveFormats._adjust_dates) normalized_dates = df[Columns.DATE].dt.normalize() is_at_midnight = df[Columns.DATE] == normalized_dates df.loc[is_at_midnight, Columns.DATE] -= pd.Timedelta(days=1) df.loc[~is_at_midnight, Columns.DATE] = normalized_dates[~is_at_midnight] min_date, max_date = df[Columns.DATE].agg(["min", "max"]) dates: List[pd.Timestamp] = pd.date_range(start=min_date, end=max_date, freq="D") max_date_str = max_date.strftime(DATE_FMT) # Get day-by-day case diffs per location, date, stage, count-type # Make sure data exists for every date for every state so that the entire country is # plotted each day; fill missing data with 0 (missing really *is* as good as 0) # enums will be replaced by their name (kind of important) id_cols_product: pd.MultiIndex = pd.MultiIndex.from_product( [ df[REGION_NAME_COL].unique(), dates, [s.name for s in DiseaseStage], [c.name for c in Counting], ], names=ID_COLS, ) df = (id_cols_product.to_frame(index=False).merge( df, how="left", on=ID_COLS, ).sort_values(ID_COLS)) df[STRING_DATE_COL] = df[Columns.DATE].dt.strftime(DATE_FMT) df[Columns.CASE_COUNT] = df[Columns.CASE_COUNT].fillna(0) if transform_df_func is not None: df = transform_df_func(df) df = geo_df.merge(df, how="inner", on=REGION_NAME_COL)[[ REGION_NAME_COL, Columns.DATE, STRING_DATE_COL, Columns.STAGE, Columns.COUNT_TYPE, value_col, ]] dates: List[pd.Timestamp] = [ pd.Timestamp(d) for d in df[Columns.DATE].unique() ] values_mins_maxs = (df[df[value_col] > 0].groupby( [Columns.STAGE, Columns.COUNT_TYPE])[value_col].agg(["min", "max"])) vmins: pd.Series = values_mins_maxs["min"] vmaxs: pd.Series = values_mins_maxs["max"] pow10s_series: pd.Series = vmaxs.map( lambda x: int(10**(-np.floor(np.log10(x))))) # _pow_10s_series_dict = {} # for stage in DiseaseStage: # _pow_10s_series_dict.update( # { # (stage.name, Counting.TOTAL_CASES.name): 100000, # (stage.name, Counting.PER_CAPITA.name): 10000, # } # ) # pow10s_series = pd.Series(_pow_10s_series_dict) vmins: dict = vmins.to_dict() vmaxs: dict = vmaxs.to_dict() for stage in DiseaseStage: _value_key = (stage.name, Counting.PER_CAPITA.name) if per_capita_denominator is None: _max_pow10 = pow10s_series.loc[(slice(None), Counting.PER_CAPITA.name)].max() else: _max_pow10 = per_capita_denominator vmins[_value_key] *= _max_pow10 vmaxs[_value_key] *= _max_pow10 pow10s_series[_value_key] = _max_pow10 percap_pow10s: pd.Series = df.apply( lambda row: pow10s_series[ (row[Columns.STAGE], row[Columns.COUNT_TYPE])], axis=1, ) _per_cap_rows = df[Columns.COUNT_TYPE] == Counting.PER_CAPITA.name df.loc[_per_cap_rows, value_col] *= percap_pow10s.loc[_per_cap_rows] # Ideally we wouldn't have to pivot, and we could do a JIT join of state longs/lats # after filtering the data. Unfortunately this is not possible, and a long data # format leads to duplication of the very large long/lat lists; pivoting is how we # avoid that. (This seems to be one downside of bokeh when compared to plotly) df = (df.pivot_table( index=[REGION_NAME_COL, Columns.STAGE, Columns.COUNT_TYPE], columns=STRING_DATE_COL, values=value_col, aggfunc="first", ).reset_index().merge( geo_df[[REGION_NAME_COL, LONG_COL, LAT_COL]], how="inner", on=REGION_NAME_COL, )) # All three oclumns are just initial values; they'll change with the date slider df[value_col] = df[max_date_str] df[FAKE_DATE_COL] = max_date_str df[COLOR_COL] = np.where(df[value_col] > 0, df[value_col], "NaN") # Technically takes a df but we don't need the index bokeh_data_source = ColumnDataSource( {k: v.tolist() for k, v in df.to_dict(orient="series").items()}) filters = [[ GroupFilter(column_name=Columns.STAGE, group=stage.name), GroupFilter(column_name=Columns.COUNT_TYPE, group=count.name), ] for stage, count in stage_count_list] figures = [] for subplot_index, (stage, count) in enumerate(stage_count_list): # fig = bplotting.figure() # ax: plt.Axes = fig.add_subplot( # len(stage_list), len(count_list), subplot_index # ) # # Add timestamp to top right axis # if subplot_index == 2: # ax.text( # 1.25, # Coords are arbitrary magic numbers # 1.23, # f"Last updated {NOW_STR}", # horizontalalignment="right", # fontsize="small", # transform=ax.transAxes, # ) view = CDSView(source=bokeh_data_source, filters=filters[subplot_index]) vmin = vmins[(stage.name, count.name)] vmax = vmaxs[(stage.name, count.name)] # Compute and set axes titles if stage is DiseaseStage.CONFIRMED: fig_stage_name = "Cases" elif stage is DiseaseStage.DEATH: fig_stage_name = "Deaths" else: raise ValueError fig_title_components: List[str] = [] if subplot_title_prefix is not None: fig_title_components.append(subplot_title_prefix) fig_title_components.append(fig_stage_name) if count is Counting.PER_CAPITA: _per_cap_denom = pow10s_series[(stage.name, count.name)] fig_title_components.append(f"Per {_per_cap_denom:,d} people") formatter = PrintfTickFormatter(format=r"%2.3f") label_standoff = 12 tooltip_fmt = "{0.000}" else: formatter = NumeralTickFormatter(format="0.0a") label_standoff = 10 tooltip_fmt = "{0}" color_mapper = LogColorMapper( color_list, low=vmin, high=vmax, nan_color="#f2f2f2", ) fig_title = " ".join(fig_title_components) if plot_aspect_ratio is None: if x_range is None or y_range is None: raise ValueError("Must provide both `x_range` and `y_range`" + " when `plot_aspect_ratio` is None") plot_aspect_ratio = (x_range[1] - x_range[0]) / (y_range[1] - y_range[0]) # Create figure object p = bplotting.figure( title=fig_title, title_location="above", tools=[ HoverTool( tooltips=[ ("Date", f"@{{{FAKE_DATE_COL}}}"), ("State", f"@{{{REGION_NAME_COL}}}"), ("Count", f"@{{{value_col}}}{tooltip_fmt}"), ], toggleable=False, ), PanTool(), BoxZoomTool(match_aspect=True), ZoomInTool(), ZoomOutTool(), ResetTool(), ], active_drag=None, aspect_ratio=plot_aspect_ratio, output_backend="webgl", lod_factor=4, lod_interval=400, lod_threshold=1000, lod_timeout=300, ) p.xgrid.grid_line_color = None p.ygrid.grid_line_color = None # Finally, add the actual choropleth data we care about p.patches( LONG_COL, LAT_COL, source=bokeh_data_source, view=view, fill_color={ "field": COLOR_COL, "transform": color_mapper }, line_color="black", line_width=0.25, fill_alpha=1, ) # Add evenly spaced ticks and their labels to the colorbar # First major, then minor # Adapted from https://stackoverflow.com/a/50314773 bucket_size = (vmax / vmin)**(1 / n_cbar_buckets) tick_dist = bucket_size**n_buckets_btwn_major_ticks # Simple log scale math major_tick_locs = ( vmin * (tick_dist**np.arange(0, n_cbar_major_ticks)) # * (bucket_size ** 0.5) # Use this if centering ticks on buckets ) # Get minor locs by linearly interpolating between major ticks minor_tick_locs = [] for major_tick_index, this_major_tick in enumerate( major_tick_locs[:-1]): next_major_tick = major_tick_locs[major_tick_index + 1] # Get minor ticks as numbers in range [this_major_tick, next_major_tick] # and exclude the major ticks themselves (once we've used them to # compute the minor tick locs) minor_tick_locs.extend( np.linspace( this_major_tick, next_major_tick, n_minor_ticks_btwn_major_ticks + 2, )[1:-1]) color_bar = ColorBar( color_mapper=color_mapper, ticker=FixedTicker(ticks=major_tick_locs, minor_ticks=minor_tick_locs), formatter=formatter, label_standoff=label_standoff, major_tick_out=0, major_tick_in=13, major_tick_line_color="white", major_tick_line_width=1, minor_tick_out=0, minor_tick_in=5, minor_tick_line_color="white", minor_tick_line_width=1, location=(0, 0), border_line_color=None, bar_line_color=None, orientation="vertical", ) p.add_layout(color_bar, "right") p.hover.point_policy = "follow_mouse" # Bokeh axes (and most other things) are splattable p.axis.visible = False figures.append(p) # Make all figs pan and zoom together by setting their axes equal to each other # Also fix the plots' aspect ratios figs_iter = iter(np.ravel(figures)) anchor_fig = next(figs_iter) if x_range is not None and y_range is not None: data_aspect_ratio = (x_range[1] - x_range[0]) / (y_range[1] - y_range[0]) else: data_aspect_ratio = plot_aspect_ratio if x_range is not None: anchor_fig.x_range = Range1d( *x_range, bounds="auto", min_interval=min_visible_y_range * data_aspect_ratio, ) if y_range is not None: anchor_fig.y_range = Range1d(*y_range, bounds="auto", min_interval=min_visible_y_range) for fig in figs_iter: fig.x_range = anchor_fig.x_range fig.y_range = anchor_fig.y_range # 2x2 grid (for now) gp = gridplot( figures, ncols=len(count_list), sizing_mode="scale_both", toolbar_location="above", ) plot_layout = [gp] # Ok, pause # Now we're going into a whole other thing: we're doing all the JS logic behind a # date slider that changes which date is shown on the graphs. The structure of the # data is one column per date, one row per location, and a few extra columns to # store the data the graph will use. When we adjust the date of the slider, we copy # the relevant column of the df into the columns the graphs are looking at. # That's the easy part; the hard part is handling the "play button" functionality, # whereby the user can click one button and the date slider will periodically # advance itself. That requires a fair bit of logic to schedule and cancel the # timers and make it all feel right. # Create unique ID for the JS playback info object for this plot (since it'll be on # the webpage with other plots, and their playback info isn't shared) _THIS_PLOT_ID = uuid.uuid4().hex __TIMER = "'timer'" __IS_ACTIVE = "'isActive'" __SELECTED_INDEX = "'selectedIndex'" __BASE_INTERVAL_MS = "'BASE_INTERVAL'" # Time (in MS) btwn frames when speed==1 __TIMER_START_DATE = "'startDate'" __TIMER_ELAPSED_TIME_MS = "'elapsedTimeMS'" __TIMER_ELAPSED_TIME_PROPORTION = "'elapsedTimeProportion'" __SPEEDS_KEY = "'SPEEDS'" __PLAYBACK_INFO = f"window._playbackInfo_{_THIS_PLOT_ID}" _PBI_TIMER = f"{__PLAYBACK_INFO}[{__TIMER}]" _PBI_IS_ACTIVE = f"{__PLAYBACK_INFO}[{__IS_ACTIVE}]" _PBI_SELECTED_INDEX = f"{__PLAYBACK_INFO}[{__SELECTED_INDEX}]" _PBI_TIMER_START_DATE = f"{__PLAYBACK_INFO}[{__TIMER_START_DATE}]" _PBI_TIMER_ELAPSED_TIME_MS = f"{__PLAYBACK_INFO}[{__TIMER_ELAPSED_TIME_MS}]" _PBI_TIMER_ELAPSED_TIME_PROPORTION = ( f"{__PLAYBACK_INFO}[{__TIMER_ELAPSED_TIME_PROPORTION}]") _PBI_BASE_INTERVAL = f"{__PLAYBACK_INFO}[{__BASE_INTERVAL_MS}]" _PBI_SPEEDS = f"{__PLAYBACK_INFO}[{__SPEEDS_KEY}]" _PBI_CURR_INTERVAL_MS = ( f"{_PBI_BASE_INTERVAL} / {_PBI_SPEEDS}[{_PBI_SELECTED_INDEX}]") _SPEED_OPTIONS = [0.25, 0.5, 1.0, 2.0] _DEFAULT_SPEED = 1.0 _DEFAULT_SELECTED_INDEX = _SPEED_OPTIONS.index(_DEFAULT_SPEED) _SETUP_WINDOW_PLAYBACK_INFO = f""" if (typeof({__PLAYBACK_INFO}) === 'undefined') {{ {__PLAYBACK_INFO} = {{ {__TIMER}: null, {__IS_ACTIVE}: false, {__SELECTED_INDEX}: {_DEFAULT_SELECTED_INDEX}, {__TIMER_START_DATE}: null, {__TIMER_ELAPSED_TIME_MS}: 0, {__TIMER_ELAPSED_TIME_PROPORTION}: 0, {__BASE_INTERVAL_MS}: 1000, {__SPEEDS_KEY}: {_SPEED_OPTIONS} }}; }} """ _DEFFUN_INCR_DATE = f""" // See this link for why this works (it's an undocumented feature?) // https://discourse.bokeh.org/t/5254 // Tl;dr we need this to automatically update the hover as the play button plays // Without this, the hover tooltip only updates when we jiggle the mouse // slightly let prev_val = null; source.inspect.connect(v => prev_val = v); function updateDate() {{ {_PBI_TIMER_START_DATE} = new Date(); {_PBI_TIMER_ELAPSED_TIME_MS} = 0 if (dateSlider.value < maxDate) {{ dateSlider.value += 86400000; }} if (dateSlider.value >= maxDate) {{ console.log(dateSlider.value, maxDate) console.log('reached end') clearInterval({_PBI_TIMER}); {_PBI_IS_ACTIVE} = false; playPauseButton.active = false; playPauseButton.change.emit(); playPauseButton.label = 'Restart'; }} dateSlider.change.emit(); // This is pt. 2 of the prev_val/inspect stuff above if (prev_val !== null) {{ source.inspect.emit(prev_val); }} }} """ _DO_START_TIMER = f""" function startLoopTimer() {{ updateDate(); if ({_PBI_IS_ACTIVE}) {{ {_PBI_TIMER} = setInterval(updateDate, {_PBI_CURR_INTERVAL_MS}) }} }} {_PBI_TIMER_START_DATE} = new Date(); // Should never be <0 or >1 but I am being very defensive here const proportionRemaining = 1 - ( {_PBI_TIMER_ELAPSED_TIME_PROPORTION} <= 0 ? 0 : {_PBI_TIMER_ELAPSED_TIME_PROPORTION} >= 1 ? 1 : {_PBI_TIMER_ELAPSED_TIME_PROPORTION} ); const remainingTimeMS = ( {_PBI_CURR_INTERVAL_MS} * proportionRemaining ); const initialInterval = ( {_PBI_TIMER_ELAPSED_TIME_MS} === 0 ? 0 : remainingTimeMS ); {_PBI_TIMER} = setTimeout( startLoopTimer, initialInterval ); """ _DO_STOP_TIMER = f""" const now = new Date(); {_PBI_TIMER_ELAPSED_TIME_MS} += ( now.getTime() - {_PBI_TIMER_START_DATE}.getTime() ); {_PBI_TIMER_ELAPSED_TIME_PROPORTION} = ( {_PBI_TIMER_ELAPSED_TIME_MS} / {_PBI_CURR_INTERVAL_MS} ); clearInterval({_PBI_TIMER}); """ update_on_date_change_callback = CustomJS( args={"source": bokeh_data_source}, code=f""" {_SETUP_WINDOW_PLAYBACK_INFO} const sliderValue = cb_obj.value; const sliderDate = new Date(sliderValue) // Ugh, actually requiring the date to be YYYY-MM-DD (matching DATE_FMT) const dateStr = sliderDate.toISOString().split('T')[0] const data = source.data; {_PBI_TIMER_ELAPSED_TIME_MS} = 0 if (typeof(data[dateStr]) !== 'undefined') {{ data['{value_col}'] = data[dateStr] const valueCol = data['{value_col}']; const colorCol = data['{COLOR_COL}']; const fakeDateCol = data['{FAKE_DATE_COL}'] for (var i = 0; i < data['{value_col}'].length; i++) {{ const value = valueCol[i] if (value == 0) {{ colorCol[i] = 'NaN'; }} else {{ colorCol[i] = value; }} fakeDateCol[i] = dateStr; }} source.change.emit(); }} """, ) # Taking day-over-day diffs means the min slider day is one more than the min data # date (might be off by 1 if not using day over diffs but in practice not an issue) min_slider_date = min_date + pd.Timedelta(days=1) date_slider = DateSlider( start=min_slider_date, end=max_date, value=max_date, step=1, sizing_mode="stretch_width", width_policy="fit", ) date_slider.js_on_change("value", update_on_date_change_callback) play_pause_button = Toggle( label="Start playing", button_type="success", active=False, sizing_mode="stretch_width", ) animate_playback_callback = CustomJS( args={ "source": bokeh_data_source, "dateSlider": date_slider, "playPauseButton": play_pause_button, "maxDate": max_date, "minDate": min_slider_date, }, code=f""" {_SETUP_WINDOW_PLAYBACK_INFO} {_DEFFUN_INCR_DATE} if (dateSlider.value >= maxDate) {{ if (playPauseButton.active) {{ dateSlider.value = minDate; dateSlider.change.emit(); // Hack to get timer to wait after date slider wraps; any positive // number works but the smaller the better {_PBI_TIMER_ELAPSED_TIME_MS} = 1; }} }} const active = cb_obj.active; {_PBI_IS_ACTIVE} = active; if (active) {{ playPauseButton.label = 'Playing – Click/tap to pause' {_DO_START_TIMER} }} else {{ playPauseButton.label = 'Paused – Click/tap to play' {_DO_STOP_TIMER} }} """, ) play_pause_button.js_on_click(animate_playback_callback) change_playback_speed_callback = CustomJS( args={ "source": bokeh_data_source, "dateSlider": date_slider, "playPauseButton": play_pause_button, "maxDate": max_date, }, code=f""" {_SETUP_WINDOW_PLAYBACK_INFO} {_DEFFUN_INCR_DATE} // Must stop timer before handling changing the speed, as stopping the timer // saves values based on the current (unchaged) speed selection if ({_PBI_TIMER} !== null) {{ {_DO_STOP_TIMER} }} const selectedIndex = cb_obj.active; {_PBI_SELECTED_INDEX} = selectedIndex; if ({_PBI_IS_ACTIVE}) {{ {_DO_START_TIMER} }} else {{ {_PBI_TIMER_ELAPSED_TIME_MS} = 0 }} console.log({__PLAYBACK_INFO}) """, ) playback_speed_radio = RadioButtonGroup( labels=[f"{speed:.2g}x speed" for speed in _SPEED_OPTIONS], active=_DEFAULT_SELECTED_INDEX, sizing_mode="stretch_width", ) playback_speed_radio.js_on_click(change_playback_speed_callback) plot_layout.append( layout_column( [ date_slider, layout_row( [play_pause_button, playback_speed_radio], height_policy="min", ), ], width_policy="fit", height_policy="min", )) plot_layout = layout_column(plot_layout, sizing_mode="scale_both") # grid = gridplot(figures, ncols=len(count_list), sizing_mode="stretch_both") # Create the autoloading bokeh plot info (HTML + JS) js_path = str(Path(out_file_basename + "_autoload").with_suffix(".js")) tag_html_path = str( Path(out_file_basename + "_div_tag").with_suffix(".html")) js_code, tag_code = autoload_static(plot_layout, CDN, js_path) tag_uuid = re.search(r'id="([^"]+)"', tag_code).group(1) tag_code = re.sub(r'src="([^"]+)"', f'src="\\1?uuid={tag_uuid}"', tag_code) with open(Paths.DOCS / js_path, "w") as f_js, open(Paths.DOCS / tag_html_path, "w") as f_html: f_js.write(js_code) f_html.write(tag_code) # Create the video by creating stills of the graphs for each date and then stitching # the images into a video if should_make_video: save_dir: Path = PNG_SAVE_ROOT_DIR / out_file_basename save_dir.mkdir(parents=True, exist_ok=True) STILL_WIDTH = 1500 STILL_HEIGHT = int(np.ceil(STILL_WIDTH / plot_aspect_ratio) * 1.05) # Unclear why *1.05 is necessary gp.height = STILL_HEIGHT gp.width = STILL_WIDTH gp.sizing_mode = "fixed" orig_title = anchor_fig.title.text for date in dates: date_str = date.strftime(DATE_FMT) anchor_fig.title = Title(text=f"{orig_title} {date_str}") for p in figures: p.title = Title(text=p.title.text, text_font_size="20px") # Just a reimplementation of the JS code in the date slider's callback data = bokeh_data_source.data data[value_col] = data[date_str] for i, value in enumerate(data[value_col]): if value == 0: data[COLOR_COL][i] = "NaN" else: data[COLOR_COL][i] = value data[FAKE_DATE_COL][i] = date_str save_path: Path = (save_dir / date_str).with_suffix(".png") export_png(gp, filename=save_path) resize_to_even_dims(save_path, pad_bottom=0.08) if date == max(dates): poster_path: Path = ( PNG_SAVE_ROOT_DIR / (out_file_basename + "_poster")).with_suffix(".png") poster_path.write_bytes(save_path.read_bytes()) make_video(save_dir, out_file_basename, 0.9) print(f"Did interactive {out_file_basename}") return (js_code, tag_code)
------------------------------------------------------------------------------------------------ """ # Get the callback script used for many of the widgets with open(javascript_path + 'callback_map_widgets.js', 'r') as f: callback_widgets = f.read() # Level radio buttons radio_labels = ["Play \u25B6", "Step \u23ef", "Pause \u23f8"] radioGroup_play_controls = RadioButtonGroup(labels=radio_labels, active=2, name='radioGroup_play_controls') radioGroup_play_controls.js_on_click( CustomJS(args={ 'event': 'radioGroup_play_controls', 'ext_datafiles': ext_datafiles, 'mpoly': p_map_mpoly, 'source_map': source_map, 'p_map': p_map, }, code=callback_widgets)) # %% Make date range slider date_range_slider = DateRangeSlider( value=((latest_data_date - pd.DateOffset(months=1)), (latest_data_date)), start=(oldest_date_date), end=(latest_data_date), name='date_range_slider') date_range_slider.js_on_change( "value", CustomJS(args={ 'event': 'date_range_slider',
def interactive_map(wi_data): pop = cov.population_data[cov.population_data['STNAME'] == 'Wisconsin'] pop['geoid'] = pop['COUNTY'].apply(lambda x: int(f"55{x:0>3}")) pop = pop[['geoid', 'POPESTIMATE2019']] my_palette = tuple(reversed(reds)) #my_palette = palette today = max(wi_data['date']) five_ago = datetime.datetime( *[int(x) for x in today.split("-")]) - datetime.timedelta(5) five_ago_date = f"{five_ago.year}-{int(five_ago.month):02}-{int(five_ago.day):02}" recent_data = wi_data[wi_data['date'] >= five_ago_date] county_list = [] for county in list(set(recent_data['name'])): county_df = recent_data[recent_data['name'] == county] county_meta = county_df[['geo', 'name', 'date']].iloc[-1] county_df = county_df.mean() county_df = county_df.append(county_meta) county_list.append(county_df) now_data = pd.DataFrame(county_list) wisconsin = gpd.read_file( 'resources/shapefiles/Wisconsin/County_Boundaries_24K.shp') wisconsin['geoid'] = wisconsin['COUNTY_FIP'].apply( lambda x: int(f"55{x:0>3}")) wi_df = wisconsin.merge(now_data, right_on='geoid', left_on='geoid') wi_df = wi_df[[ 'name', 'geoid', 'date', 'positive', 'pos_new', 'deaths', 'dth_new', 'SHAPEAREA', 'SHAPELEN', 'geometry' ]] covid_wi = wi_df.merge(pop, right_on='geoid', left_on='geoid') covid_wi['cases_per_10k'] = round( covid_wi['pos_new'] / (covid_wi['POPESTIMATE2019'] / 10000), 1) covid_wi['deaths_per_10k'] = round( covid_wi['dth_new'] / (covid_wi['POPESTIMATE2019'] / 10000), 2) covid_wi['display'] = covid_wi['cases_per_10k'] geosource = GeoJSONDataSource(geojson=covid_wi.to_json()) color_mapper = LinearColorMapper(palette=my_palette) # Create figure object. p = figure(title=None, plot_height=875, plot_width=800, x_axis_location=None, y_axis_location=None, toolbar_location=None) p.grid.grid_line_color = None p.outline_line_width = 0 # Add patch renderer to figure. states = p.patches('xs', 'ys', source=geosource, fill_color={ 'field': 'display', 'transform': color_mapper }, line_color='black', line_width=0.5, fill_alpha=0.8) TOOLTIPS = """ <div> <div> <span style="color: #000000; font-weight: 700; font-size:20px;">@name</span><br> <span style="color: #0066ff; font-weight: 700; font-size:16px;">Cases: </span><span style="font-size:16px;">@pos_new (@cases_per_10k{0.0} per 10k)</span><br> <span style="color: #0066ff; font-weight: 700; font-size:16px;">Deaths: </span><span style="font-size:16px;">@dth_new (@deaths_per_10k{0.00} per 10k)</span><br> </div> </div> """ # Create hover tool p.add_tools(HoverTool(renderers=[states], tooltips=TOOLTIPS)) LABELS = ["Cases", "Deaths"] radio_button_group = RadioButtonGroup(labels=LABELS, active=0, min_width=50, width_policy="min") radio_button_group.js_on_click( CustomJS(args=dict(p=p, source=geosource), code=""" var radio_value = cb_obj.active; var data = JSON.parse(source.geojson); var f = data["features"]; if (radio_value == 0) { f.map(function (d) { d.properties.display = d.properties.cases_per_10k; }) } else if (radio_value == 1) { f.map(function (d) { d.properties.display = d.properties.deaths_per_10k; }) } data["features"] = f; source.geojson = JSON.stringify(data); source.change.emit(); """)) output_file('docs/assets/img/wi_interactive.html') show(Column(p, radio_button_group))
def triadEffortPlot(args): """ Plot concatenated pickled data from triadEffortData """ from .stats import unpickleAll # Initializing bokeh is an expensive operation and this module is imported # alot, so only do it when necessary. from bokeh.palettes import Set3 from bokeh.plotting import figure from bokeh.models import RadioButtonGroup, CustomJS, Slope from bokeh.embed import json_item from bokeh.layouts import column p = figure( plot_width=1000, plot_height=500, sizing_mode='scale_both', x_range=(0, 1), y_range=(0, 1), output_backend="webgl", ) data = list(unpickleAll(sys.stdin.buffer)) colors = Set3[len(data)] lines = dict() for o, color in zip(data, colors): name = o['layout'].name assert name not in lines lines[name] = p.line(o['x'], o['y'], line_width=1, color=color, legend_label=name, name=name) # color: base1 slope = Slope(gradient=1, y_intercept=0, line_color='#93a1a1', line_dash='dashed', line_width=1) p.add_layout(slope) setPlotStyle(p) for axis, size, font in ((p.xaxis, '1em', 'IBM Plex Sans'), (p.yaxis, '1em', 'IBM Plex Sans')): axis.major_label_text_font_size = size axis.major_label_text_font = font LABELS = ["All", "Standard", "Usable"] visible = { 0: list(lines.keys()), 1: ['ar-asmo663', 'ar-linux', 'ar-osx'], 2: ['ar-lulua', 'ar-ergoarabic', 'ar-malas', 'ar-linux', 'ar-osx'], } ranges = { 0: [(0, 1), (0, 1)], 1: [(0, 0.5), (0, 0.4)], 2: [(0, 0.5), (0, 0.4)], } presets = RadioButtonGroup(labels=LABELS, active=0) # Set visibility and x/yranges on click. Not sure if there’s a more pythonic way. presets.js_on_click( CustomJS(args=dict(lines=lines, plot=p, visible=visible, ranges=ranges), code=""" for (const [k, line] of Object.entries (lines)) { line.visible = visible[this.active].includes (k); } const xrange = plot.x_range; xrange.start = ranges[this.active][0][0]; xrange.end = ranges[this.active][0][1]; const yrange = plot.y_range; yrange.start = ranges[this.active][1][0]; yrange.end = ranges[this.active][1][1]; """)) json.dump(json_item(column(p, presets)), sys.stdout) return 0