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()
示例#2
0
    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()
示例#4
0
    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()
示例#5
0
文件: buttons.py 项目: zmj2008/bokeh
        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,
示例#6
0
    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)
示例#8
0
------------------------------------------------------------------------------------------------
"""
# 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',
示例#9
0
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))
示例#10
0
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