def test_js_on_click_executes(self, bokeh_model_page) -> None: button = Toggle(css_classes=['foo']) button.js_on_click(CustomJS(code=RECORD("value", "cb_obj.active"))) page = bokeh_model_page(button) button = page.driver.find_element_by_css_selector('.foo .bk-btn') button.click() results = page.results assert results == {'value': True} button = page.driver.find_element_by_css_selector('.foo .bk-btn') button.click() results = page.results assert results == {'value': False} button = page.driver.find_element_by_css_selector('.foo .bk-btn') button.click() results = page.results assert results == {'value': True} assert page.has_no_console_errors()
def test_js_on_click_executes(self, bokeh_model_page: BokehModelPage) -> None: button = Toggle() button.js_on_click(CustomJS(code=RECORD("value", "cb_obj.active"))) page = bokeh_model_page(button) button_el = find_element_for(page.driver, button, ".bk-btn") button_el.click() results = page.results assert results == {'value': True} button_el = find_element_for(page.driver, button, ".bk-btn") button_el.click() results = page.results assert results == {'value': False} button_el = find_element_for(page.driver, button, ".bk-btn") button_el.click() results = page.results assert results == {'value': True} assert page.has_no_console_errors()
def test_js_on_click_executes(self, bokeh_model_page): button = Toggle(css_classes=['foo']) button.js_on_click(CustomJS(code=RECORD("value", "cb_obj.active"))) page = bokeh_model_page(button) button = page.driver.find_element_by_css_selector('.foo .bk-btn') button.click() results = page.results assert results == {'value': True} button = page.driver.find_element_by_css_selector('.foo .bk-btn') button.click() results = page.results assert results == {'value': False} button = page.driver.find_element_by_css_selector('.foo .bk-btn') button.click() results = page.results assert results == {'value': True} assert page.has_no_console_errors()
# Spinner GUI spinner = Spinner(title="Size", low=0, high=4, step=0.1, value=1, width=300) # spinner.js_link('value', points.glyph, 'radius') # Dropdown menu GUI menu = [("Item 1", "item_1"), ("Item 2", "item_2"), None, ("Item 3", "item_3")] dropdown = Dropdown(label="Dropdown button", button_type="warning", menu=menu) dropdown.js_on_event( "menu_item_click", CustomJS(code="console.log('dropdown: ' + this.item, this.toString())")) # Toggle button GUI toggle = Toggle(label="Button", button_type="success") toggle.js_on_click( CustomJS(code=""" console.log('toggle: active=' + this.active, this.toString()) """)) # choice menu GUI OPTIONS = [str(i) for i in range(20)] multi_choice = MultiChoice(value=["foo", "baz"], options=OPTIONS) multi_choice.js_on_change( "value", CustomJS(code=""" console.log('multi_choice: value=' + this.value, this.toString()) """)) # # SELECT menu GUI # selectoptions = ["Postive tested on Covid-19 virus", "Negative tested on Covid-19 virus", "Show both"] # resultSelect = Select(title="What to show", options=selectoptions)
class RoiBrowser(object): def __init__( self, global_map_cells=None, global_map_values=None, # these two args first for bw comp roi_tessellations=None, roi_map_values=None, roi_meta_tessellation=None, **kwargs): self.global_map_cells = global_map_cells self.global_map_values = global_map_values self.points = None if roi_meta_tessellation is None else roi_meta_tessellation.points tessellation = lambda a: None if a is None else a.tessellation self.roi_model = RoiCollection( global_tessellation=tessellation(global_map_cells), roi_tessellations=roi_tessellations, roi_meta_tessellation=tessellation(roi_meta_tessellation), **kwargs) self.roi_map_values = roi_map_values self.first_active_roi = 0 # options for view elements self.full_fov_figure_kwargs = dict(toolbar_location='above', toolbar_sticky=False, match_aspect=True, tools='pan, wheel_zoom, reset') self.zooming_in_figure_kwargs = dict(toolbar_location=None, active_drag=None, match_aspect=True, tools='pan, wheel_zoom, reset') self.scalar_map_2d_kwargs = {} self.points_kwargs = dict(color='r', alpha=.1) self.trajectories_kwargs = dict(color='r', line_width=.5, line_alpha=.5, loc_alpha=.1, loc_size=6) self.slider_kwargs = dict(title='roi') self.roi_controller = RoiController(self.roi_model, fill_color=None) x, y = self.points['x'].values, self.points['y'].values self.roi_controller.xlim = [x.min(), x.max()] self.roi_controller.ylim = [y.min(), y.max()] @property def values(self): # for bw compatibility during code transition warnings.warn('deprecated attribute: values', RuntimeWarning) return self.global_map_cells @property def points(self): if self._points is None: if self.global_map_cells is not None: self._points = self.global_map_cells.points return self._points @points.setter def points(self, pts): self._points = pts @property def cells(self): warnings.warn('deprecated attribute: cells', RuntimeWarning) return self.global_map_cells @property def roi_tessellations(self): return self.roi_model.roi_tessellations def full_fov(self): ctrl = self.roi_controller full_fov_fig = figure(**self.full_fov_figure_kwargs) full_fov_fig.add_tools(BoxZoomTool(match_aspect=True)) if self.global_map_values is not None: scalar_map_2d(self.global_map_cells, self.global_map_values, figure=full_fov_fig, **self.scalar_map_2d_kwargs) elif False: _min, _max = zip(*[(values.min(), values.max()) for values in self.roi_map_values]) clim = (min(_min), max(_max)) for cells, values in zip(self.roi_tessellations, self.roi_map_values): scalar_map_2d(cells, values, figure=full_fov_fig, clim=clim, **self.scalar_map_2d_kwargs) plot_points(self.points, figure=full_fov_fig, **self.points_kwargs) full_fov_fig.patches(**ctrl.patches_kwargs) full_fov_fig.line(**ctrl.line_kwargs) ctrl.unset_active('all') ctrl.set_active(self.first_active_roi) self.roi_view_full_fov = full_fov_fig return full_fov_fig def zooming_in(self): ctrl = self.roi_controller roi_bb = self.roi_model.bounding_box(margin=.1)[self.first_active_roi] xlim = roi_bb[[0, 2]].tolist() ylim = roi_bb[[1, 3]].tolist() xlim, ylim = match_aspect(xlim, ylim) zooming_in_fig = figure(**self.zooming_in_figure_kwargs) zooming_in_fig.add_tools(BoxZoomTool(match_aspect=True)) if self.global_map_values is not None: scalar_map_2d(self.global_map_cells, self.global_map_values, figure=zooming_in_fig, **self.scalar_map_2d_kwargs) else: for cells, values in zip(self.roi_tessellations, self.roi_map_values): scalar_map_2d(cells, values, figure=zooming_in_fig, **self.scalar_map_2d_kwargs) traj_handles = plot_trajectories(self.points, figure=zooming_in_fig, **self.trajectories_kwargs) self.trajectory_handles = traj_handles[0::2] self.location_handles = traj_handles[1::2] zooming_in_fig.x_range = Range1d(*xlim) zooming_in_fig.y_range = Range1d(*ylim) self.roi_view_zooming_in = zooming_in_fig return zooming_in_fig def slider(self): ctrl = self.roi_controller zooming_in_fig = self.roi_view_zooming_in slider = Slider(start=1, end=len(self.roi_model), step=1, value=self.first_active_roi + 1, **self.slider_kwargs) slider.js_on_change('value_throttled', ctrl.js_callback(zooming_in_fig)) self.roi_view_slider = slider return slider def visibility_button1(self): self.trajectory_visibility_button = Toggle(label='Hide lines', button_type='success') assert not self.trajectory_handles[1:] def set_visibility(multiline): return CustomJS(args=dict(multiline=multiline), code=""" multiline.visible=!multiline.visible; if (multiline.visible) { this.label='Hide lines'; } else { this.label='Show lines'; } """) self.trajectory_visibility_button.js_on_click( set_visibility(self.trajectory_handles[0])) return self.trajectory_visibility_button def visibility_button2(self): self.location_visibility_button = Toggle(label='Hide points', button_type='success') assert not self.location_handles[1:] def set_visibility(multiline): return CustomJS(args=dict(multiline=multiline), code=""" multiline.visible=!multiline.visible; if (multiline.visible) { this.label='Hide points'; } else { this.label='Show points'; } """) self.location_visibility_button.js_on_click( set_visibility(self.location_handles[0])) return self.location_visibility_button def make_default_view(self): full_fov_map = self.full_fov() zooming_in_map = self.zooming_in() visibility_button1 = self.visibility_button1() visibility_button2 = self.visibility_button2() if 1 < len(self.roi_model): slider = self.slider() self.roi_view = row( zooming_in_map, column(slider, full_fov_map, row(visibility_button1, visibility_button2), sizing_mode='scale_width')) else: self.roi_view = row( zooming_in_map, column(full_fov_map, sizing_mode='scale_width')) def show(self): show(self.roi_view)
button = Button(label="Button (enabled) - has click event", button_type="primary") button.js_on_click( CustomJS(code="console.log('button: click ', this.toString())")) button_disabled = Button(label="Button (disabled) - no click event", button_type="primary", disabled=True) button_disabled.js_on_click( CustomJS(code="console.log('button(disabled): click ', this.toString())")) toggle_inactive = Toggle(label="Toggle button (initially inactive)", button_type="success") toggle_inactive.js_on_click( CustomJS( code= "console.log('toggle(inactive): active=' + this.active, this.toString())" )) toggle_active = Toggle(label="Toggle button (initially active)", button_type="success", active=True) toggle_active.js_on_click( CustomJS( code= "console.log('toggle(active): active=' + this.active, this.toString())" )) menu = [("Item 1", "item_1_value"), ("Item 2", "item_2_value"), None, ("Item 3", "item_3_value")]
HoverTool(renderers=[points_render], tooltips=[('Var1', '@var1'), ('Var2', '@var2'), ('Var3', '@var3')])) filter_list = {} for var in ['var1', 'var2', 'var3']: min_ = 0 max_ = 100 slider = RangeSlider(start=min_, end=max_, step=0.1, value=(min_, max_), title=f'{var} range') toggle = Toggle(label="Inactive", button_type="danger", aspect_ratio=3) toggle.js_on_click(toggle_callback(toggle)) filter_list[var] = Filter(var, slider, toggle) def update_plot(attrname, old, new): mask = [True] * len(gdf) for key, filter in filter_list.items(): if filter.toggle_.active: mask = mask & (gdf[key] >= filter.slider_.value[0]) & ( gdf[key] <= filter.slider_.value[1]) test_view.filters[0] = BooleanFilter(booleans=mask) for _, filter in filter_list.items(): filter.slider_.on_change('value', update_plot) filter.toggle_.on_change('active', update_plot)
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)
def lidar2js(path, ncfile, jsfile): fname_nc = join(path,ncfile) if isfile(fname_nc): if Debug: print "Found file: ", ncfile ds = Dataset(fname_nc,'r',format="NETCDF3_CLASSIC") t_raw = ds.variables["time"] z = ds.variables["alt1"][:] bsc532 = ds.variables["bsc532"][:] bsc1064 = ds.variables["bsc1064"][:] zb = ds.variables["zb"][:] zpbl = ds.variables["zpbl"][:] t = num2date(t_raw[:], units = t_raw.units) tm = t_raw[:] ds.close() else: if Debug: print "Not found file: ", ncfile return "No Data" DX = t[1]-t[0] Dx_sec = DX.total_seconds() Nx = len(t) Nx0 = int(max_days*86400.0/Dx_sec) if Nx0<Nx: Nx=Nx0 tmin = t[-Nx] tmax = t[-1]+DX twidth = 1000.0*(tmax-tmin).total_seconds() zmin = 0 zmax = z[-1] my_cmap,my_norm,my_rgb = build_palette4(levs1,levs2,levs3,levs4) img532_raw = my_norm(bsc532[-Nx:,:]) img1064_raw = my_norm(bsc1064[-Nx:,:]) data1 = {'zbase': zb[-Nx:], 'zpbl': zpbl[-Nx:], 'tcenter': t[-Nx:] + DX/2, } data2 = { 'image532': [np.array(img532_raw.filled().T, dtype=np.int8)], 'image1064': [np.array(img1064_raw.filled().T, dtype=np.int8)], } data3 = { 'profile532': bsc532[-1,:], 'range': z } src = ColumnDataSource(data=data1) src_img = ColumnDataSource(data=data2) src_z = ColumnDataSource(data=data3) color_mapper = LinearColorMapper(palette=[rgb2hex(item) for item in my_rgb], low=0, high=my_cmap.N ) plot = figure(x_range=(tmin,tmax), y_range=(zmin,zmax), title="Attenuated Backscatter coefficient [/sr /km]", toolbar_location="above", tools = "pan,wheel_zoom,box_zoom,reset,save", active_scroll=None, active_drag=None, active_inspect=None, # toolbar_sticky=False, y_axis_label = 'Height [km]', plot_width=900, plot_height=350, ) plot.toolbar.logo=None plot2 = figure(title="Last Profile at 532 nm - {}".format(t[-1]), tools = "pan,wheel_zoom,box_zoom,reset,save", y_axis_label = 'Height [km]', x_axis_label = 'Attenuated Backscatter coefficient [/sr /km]', # y_axis_type="log", active_inspect=None, plot_width=900, plot_height=350 ) plot2.toolbar.logo=None plot2.line(x='profile532', y='range', source=src_z, line_color="black", ) im = plot.image(image="image532", source=src_img, color_mapper=color_mapper, dh=zmax, dw=twidth, x=tmin, y=zmin ) #l1 = plot.line( x='tcenter', y='zbase', source=source, line_width=2, alpha=0.8, color="black") #l2 = plot.line( x='tcenter', y='zpbl' , source=source, line_width=2, alpha=0.8, color="red") r1 = plot.circle( x='tcenter', y='zbase', source=src, fill_color="black", line_color="black", fill_alpha=0.3, size=4, name="r1" ) r2 = plot.square( x='tcenter', y='zpbl', source=src, fill_color="red", line_color="red", fill_alpha=0.3, size=4, name="r2" ) for item in [r1,r2]: item.visible = False color_bar = ColorBar(color_mapper=color_mapper, ticker=FixedTicker(ticks=[0,5,20,25]), label_standoff=12, border_line_color=None, location=(0,0) ) color_bar.bar_line_color = "black" color_bar.major_tick_line_color = "black" color_bar.formatter = FuncTickFormatter(code="return {}[tick].toExponential(2)".format(levs)) legend = Legend(items=[("Cloud Base",[r1]), ("PBL Height",[r2])], location="top_left" ) legend.click_policy = "hide" legend.visible = False hover = HoverTool(names=["r1","r2"]) hover.tooltips=[("Cloud base", "@zbase km"), ("PBL height", "@zpbl km"), ("Time", "@tcenter{%d-%b-%y %H:%M}")] hover.formatters = { "tcenter": "datetime"} hover2 = HoverTool(tooltips=[ ("Signal", "@profile532"), ("Range", "@range"), ]) plot.add_layout(color_bar, 'right') plot.add_layout(legend) plot.add_tools(hover) plot2.add_tools(hover2) plot.xaxis.formatter = DatetimeTickFormatter(months = ['%Y-%b'], years = ['%Y'], days = ['%d-%b-%Y'] ) callback = CustomJS(args=dict(im=im), code=""" var f = cb_obj.active if (f==0){ im.glyph.image.field = "image532" } else { im.glyph.image.field = "image1064" } im.glyph.change.emit(); """) radio_button_group = RadioButtonGroup(labels=["532 nm", "1064 nm"], active=0) radio_button_group.js_on_change('active',callback) callback2 = CustomJS(args=dict(leg=legend), code=""" leg.visible = !leg.visible console.log("ol") """) toggle = Toggle(label="Legend", button_type="default") toggle.js_on_click(callback2) layout = column( children=[ row(widgetbox(radio_button_group, width=200), widgetbox(toggle, width=80) ), plot, Spacer(height=50), # row(Spacer(width=50),plot2), plot2, ], ) #show(plot) return create_js(layout,path,jsfile)
def plotfits(dirname): session.modifeid = True session['pathname'] = app.config['UPLOAD_FOLDER']+'/'+dirname+'/' session['stats'] = {} session['date'] = {} # pegar a data para converter em juliana e inserir nas análises with open(session['pathname']+'data.json') as f: dirdata = json.load(f) r = dirdata['r'] session['r'] = r celestial = False # Faz logo algumas estatísticas da imagem for fil in BANDAS: for fname in dirdata[fil]: img, header = fits.getdata(session['pathname']+fname, header=True) session['stats'][fil+':'+fname] = sigma_clipped_stats(img,sigma=3.0) if not celestial: celestial = WCS(header).has_celestial session['wcs'] = session['pathname']+fname session['date'][fil+':'+fname] = Time(header['DATE-OBS']).jd # a data de observação de cada imagem # Abrindo coordenadas se salvas try: cordata = pd.read_excel(session['pathname']+'data.xlsx') # Dados que serão usados para fazer computação e visualizar os pontos source = ColumnDataSource(cordata) print('Coordenadas carregadas.') except FileNotFoundError: print('Não há coordenadas salvas em %s' % session['pathname']) # Dados que serão usados para fazer computação e visualizar os pontos source = ColumnDataSource(dict( ra=[], dec=[], x=[], y=[], flux = [], j = [], k = [], tipo=[], # se é obj, src ou sky banda=[], # o filtro da imagem e arquivo sid=[], # id da estrela copiada colors=[], # para colorir de acordo o tipo de objeto )) # Constrói a tabaela de table que poderá ser usada para designar as posições do objeto, estrela e céu tabela = DataTable(source=source,columns=[ TableColumn(field='x',title='x'), TableColumn(field='y',title='y'), TableColumn(field='ra',title='ra'), TableColumn(field='dec',title='dec'), TableColumn(field='j',title='j'), TableColumn(field='k',title='k'), TableColumn(field='flux',title='flux'), TableColumn(field='tipo',title='tipo'), TableColumn(field='banda',title='banda'), TableColumn(field='sid',title='sid') ], editable=True) P = [] # lista de gráficos para o plot Nimg = [] # lista de imagens normalizadas para o contraste for fil in BANDAS: for fname in dirdata[fil]: img = fits.getdata(session['pathname']+fname) stretch = HistEqStretch(img) # Histograma, melhor função para granular a imagem h,w = img.shape # número de linhas e colunas da matriz da imagem nimg = stretch(normal(img)).tolist() p = figure(plot_width=700, active_scroll='wheel_zoom') p.image(image=[nimg], x=0, y=0, dw=w, dh=h, palette='Greys256', level="image") p.x_range.range_padding = p.y_range.range_padding = 0 p.grid.grid_line_width = 0 view = CDSView(source=source,filters=[GroupFilter(column_name='banda', group=fil+':'+fname)]) c = p.circle('x','y', source=source, view=view, color='colors', fill_color=None, radius=r, line_width=2) cd = p.circle_dot('x','y', source=source, view=view, color='colors', size=2) tool = PointDrawTool(renderers=[c,cd],empty_value='na') p.add_tools(tool) p.toolbar.active_tap = tool p.toolbar.active_inspect = None tab = Panel(child=p, title=fil+':'+fname) P.append(tab) Nimg.append(nimg) graficos = Tabs(tabs=P) graficos.js_on_change('active', CustomJS(code=''' tabs_onchange(cb_obj); ''')) contrast = Slider(start=-1, end=6, value=1, step=0.05, title="Contraste") contrast.js_on_change('value',CustomJS(args = dict(tabs=graficos.tabs, im=Nimg), code = ''' contrast_onchange(cb_obj,tabs,im); ''')) # Selecionar o tipo de fonte luminosa: obj, src ou sky radio_title = Paragraph(text='Escolha o tipo:') LABELS = ['obj','src','sky'] radio_group = RadioGroup(labels=LABELS, active=0) # Evento de mudança da tabela de table, para inserir table padrão nas colunas inalteradas source.js_on_change('data', CustomJS(args=dict(radio=radio_group, graficos=graficos), code=''' source_onchange(cb_obj, radio, graficos); ''')) # Muda o raio da abertura fotométrica spinner = Spinner(title="Raio", low=1, high=40, step=0.5, value=r, width=80) spinner.js_on_change('value', CustomJS(args=dict(source=source, tabs=graficos.tabs), code=''' radius_onchange(cb_obj,source,tabs); ''')) # Coluna de requisição text1 = Div(text='<b>Instruções:</b><p>1. Digite a chave do Astrometry.net') apikey_input = TextInput(title='Apikey do Astrometry.net', placeholder='digite a chave aqui') text2 = Div(text='''<p>2. Selecione qual imagem será usada como referência para o astrometry.net e para o cálculo das coordenadas celestes</p>''') seletor = Select(title='Escolha a imagem de referência', options=[*session['stats'].keys()]) text3 = Div(text='3. Clique abaixo pra requisitar a correção WCS') send_astrometry = Toggle(label='Solução de placa do astrometry.net', disabled=celestial) send_astrometry.js_on_click(CustomJS(args=dict(key=apikey_input, source=source, selected=seletor), code=''' send_astrometry(cb_obj,key,source,selected); ''')) # o Botão de salvar irá enviar um json para o servidor que irá ler e fazer os procedimentos posteriores text4 = Div(text='4. Salve a tabela de table clicando em salvar.') salvar = Button(label='Salvar tabela', button_type="success") salvar.js_on_click(CustomJS(args=dict(source=source), code=''' salvar_onclick(source); ''')) reset = Button(label='Limpar', button_type='success') reset.js_on_click(CustomJS(args=dict(source=source), code=''' reset_onclick(source); ''')) copiar = Button(label='Copiar coordenadas', button_type='success') copiar.js_on_click(CustomJS(args=dict(source=source, ref=seletor, active=graficos), code=''' add_data(source,ref,active); ''')) div, script = components(row(column(contrast,spinner,radio_title,radio_group),\ column(row(reset,copiar,salvar), graficos, tabela, sizing_mode='stretch_both'), column(text1,apikey_input,text2,seletor,text3,send_astrometry,text4))) return render_template('plot.html', the_div=div, the_script=script,filename=dirdata['name'])
def bokeh_plot(node, link, name='NetworkMap'): from bokeh.plotting import figure, from_networkx, save from bokeh.models import ColumnDataSource, HoverTool from bokeh.io import export_png from bokeh.models import CustomJS, TextInput, CustomJSFilter, CDSView, TapTool from bokeh.layouts import column from bokeh.plotting import output_file, show from bokeh.tile_providers import get_provider, Vendors from bokeh.models import Circle, MultiLine, LabelSet, Toggle, CheckboxGroup from bokeh.models.graphs import NodesAndLinkedEdges text_input = TextInput(value="", title="Filter Nodes:") wgs84_to_web_mercator(node) node_source_data = ColumnDataSource( data=dict(x=node['MX'], y=node['MY'], desc=node['id'])) # link G = nx.from_pandas_edgelist(link, source='id', target='anode') nx.set_node_attributes(G, dict(zip(link.id, link.id)), 'desc') n_loc = {k: (x, y) for k, x, y in zip(node['id'], node['MX'], node['MY'])} nx.set_node_attributes(G, n_loc, 'pos') n_color = {k: 'orange' if 'C' in k else 'green' for k in node['id']} nx.set_node_attributes(G, n_color, 'color') n_alpha = {k: 1 if 'C' in k else 0 for k in node['id']} nx.set_node_attributes(G, n_alpha, 'alpha') e_color = {(s, t): 'red' if 'C' in s else 'black' for s, t in zip(link['id'], link['anode'])} nx.set_edge_attributes(G, e_color, 'color') e_line_type = {(s, t): 'dashed' if 'C' in s else 'solid' for s, t in zip(link['id'], link['anode'])} nx.set_edge_attributes(G, e_line_type, 'line_type') tile_provider = get_provider(Vendors.CARTODBPOSITRON) bokeh_plot = figure(title="%s network map" % name.split('/')[-1].split('.')[0], sizing_mode="scale_height", plot_width=1300, x_range=(min(node['MX']), max(node['MX'])), tools='pan,wheel_zoom', active_drag="pan", active_scroll="wheel_zoom") bokeh_plot.add_tile(tile_provider) # This callback is crucial, otherwise the filter will not be triggered when the slider changes callback = CustomJS(args=dict(source=node_source_data), code=""" source.change.emit(); """) text_input.js_on_change('value', callback) # Define the custom filter to return the indices from 0 to the desired percentage of total data rows. You could # also compare against values in source.data js_filter = CustomJSFilter(args=dict(text_input=text_input), code=f""" const z = source.data['desc']; var indices = ((() => {{ var result = []; for (let i = 0, end = source.get_length(), asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) {{ if (z[i].includes(text_input.value.toString(10))) {{ result.push(i); }} }} return result; }})()); return indices;""") # Use the filter in a view view = CDSView(source=node_source_data, filters=[js_filter]) callback2 = CustomJS(args=dict(x_range=bokeh_plot.x_range, y_range=bokeh_plot.y_range, text_input=text_input, source=node_source_data), code=f""" const z = source.data['desc']; const x = source.data['x']; const y = source.data['y']; var result = []; for (let i = 0, end = source.get_length(), asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) {{ if (z[i].includes(text_input.value.toString(10))) {{ result.push(i); }} }} var indices = result[0]; var Xstart = x[indices]; var Ystart = y[indices]; y_range.setv({{"start": Ystart-280, "end": Ystart+280}}); x_range.setv({{"start": Xstart-500, "end": Xstart+500}}); x_range.change.emit(); y_range.change.emit(); """) text_input.js_on_change('value', callback2) graph = from_networkx(G, nx.get_node_attributes(G, 'pos'), scale=2, center=(0, 0)) graph.node_renderer.glyph = Circle(radius=15, fill_color='color', fill_alpha='alpha') graph.node_renderer.hover_glyph = Circle(radius=15, fill_color='red') graph.edge_renderer.glyph = MultiLine( line_alpha=1, line_color='color', line_width=1, line_dash='line_type') # zero line alpha graph.edge_renderer.hover_glyph = MultiLine(line_color='#abdda4', line_width=5) graph.inspection_policy = NodesAndLinkedEdges() bokeh_plot.circle('x', 'y', source=node_source_data, radius=10, color='green', alpha=0.7, view=view) labels = LabelSet(x='x', y='y', text='desc', text_font_size="8pt", text_color='black', x_offset=5, y_offset=5, source=node_source_data, render_mode='canvas') code = '''\ if (toggle.active) { box.text_alpha = 0.0; console.log('enabling box'); } else { box.text_alpha = 1.0; console.log('disabling box'); } ''' callback3 = CustomJS(code=code, args={}) toggle = Toggle(label="Annotation", button_type="success") toggle.js_on_click(callback3) callback3.args = {'toggle': toggle, 'box': labels} bokeh_plot.add_tools(HoverTool(tooltips=[("id", "@desc")]), TapTool()) # Output filepath bokeh_plot.renderers.append(graph) bokeh_plot.add_layout(labels) layout = column(toggle, text_input, bokeh_plot) # export_png(p, filename="plot.png") output_file(name) show(layout)