import bokeh
import bokeh.models
import bokeh.plotting
from bokeh import __version__ as bokeh_version
from bokeh.core.properties import value
import logging
import numpy as np
from packaging import version
from typing import List, Optional, Dict
from shyft.time_series import TsVector
from shyft.dashboard.time_series.state import Quantity
from shyft.dashboard.widgets.logger_box import LoggerBox
from shyft.dashboard.base.ports import States, StatePorts
from shyft.dashboard.base.app import LayoutComponents, update_value_factory
from shyft.dashboard.time_series.view import LegendItem, Line, FillInBetween, MultiLine
from shyft.dashboard.time_series.view_container.view_container_base import BaseViewContainer
[docs]
class Legend(BaseViewContainer):
"""
Examples
--------
| # create the viewer app
| viewer = TsViewer(bokeh_document=doc, title='Example Ts Viewer')
| # create the view container
| figure = Figure(viewer=viewer)
| # create the legend container
| legend = Legend(viewer=viewer)
| # set up a data source
| data_source = DataSource(ts_adapter=TsAdapter(), unit='MW')
| # create views
| fill_in_between = FillInBetween(view_container=figure, color='purple', label='Example Percentile', unit='MW',
| indices=(0, 1))
| line = Line(view_container=figure, color='blue', unit='MW', label='Example Line', index=1)
| # add views to a legend item
| legend_item = LegendItem(view_container=legend, label='Example Legend Item', views=[line, fill_in_between])
| # add data source and views to a DsViewHandle
| ds_view_handle = DsViewHandle(data_source=data_source, views=[line, fill_in_between, legend_item])
"""
[docs]
def __init__(self, *,
viewer: "shyft.dashboard.time_series.ts_viewer.TsViewer",
title: Optional[str] = '',
width: int = 200,
height: int = 300,
text_font_size: int = 8,
text_font: str = 'verdana',
title_text_font_style: str = 'bold',
title_text_font_size: str = '10pt',
logger: Optional[LoggerBox] = None) -> None:
"""
Parameters
----------
viewer:
the TsViewer this legend belongs to
title:
legend title text
width:
pixel width of the legend box
height:
pixel height of the legend box
text_font_size:
text font size for legend items
text_font:
text font for legend items (options: 'monospace','verdana', 'times', 'helvetica', etc.)
title_text_font_style:
text font style (bold, italic, ...) for legend title
title_text_font_size:
text font size for legend title
logger:
optional logger or LoggerBox instance
"""
super().__init__(viewer=viewer)
self.logger = logger or logging.getLogger(__file__)
self.state_port = StatePorts(parent=self, _receive_state=self._receive_state)
self._state = States.ACTIVE
self.height = max(200, height)
self.width = width
# empirical tested y range conversion
coeff = np.array([-1.56666667e+02, 1.77500000e+00, 4.16666667e-05])
self.y_range_start = -(coeff[0] + coeff[1]*self.height + coeff[2]*self.height**2)
# text_font_size
self.text_font_size = f'{int(text_font_size)}pt'
self.item_height = min(-2.5*text_font_size, -10)
self.max_y_range = 20*self.item_height
self.space_between_items = -2
if bokeh_version < '3.0.0':
self.bokeh_figure = bokeh.plotting.figure(plot_width=self.width, plot_height=self.height, title=title,
tools=['ywheel_pan'], x_axis_location=None, y_axis_location=None,
toolbar_location=None, )
else:
self.bokeh_figure = bokeh.plotting.figure(width=self.width, height=self.height, title=title,
tools=['ywheel_pan'], x_axis_location=None, y_axis_location=None,
toolbar_location=None, )
self.bokeh_figure.grid.grid_line_color = None
self.bokeh_figure.y_range = bokeh.models.Range1d(self.y_range_start, 0, bounds=None)
self.x_range = bokeh.models.Range1d(0, width)
self.bokeh_figure.x_range = self.x_range
if title: # set title border if title available
self.bokeh_figure.min_border_top = 30
self.bokeh_figure.title.text_font = text_font
self.bokeh_figure.title.text_font_style = title_text_font_style
self.bokeh_figure.title.text_font_size = title_text_font_size
self.legend_items = {}
self.legend_view_legend_item_map = {}
self.ds_keys = {'tag', 'xs', 'ys', 'ox', 'oy', 'color_box', 'xs_box', 'ys_box',
'color_line', 'xs_line', 'ys_line', 'alpha', 'label', 'line_dash'}
self.ds = bokeh.models.ColumnDataSource({k: [] for k in self.ds_keys})
self.color_patches = '#fffdf9'
self.color_patches_hover = '#fff7eb'
self.alpha_visible = 1
self.alpha_invisible = 0.4
self.patches = self.bokeh_figure.patches('xs', 'ys', source=self.ds,
alpha=0.0, line_color='#bcbcbc',
fill_color=self.color_patches,
hover_line_color=self.color_patches_hover,
hover_fill_color=self.color_patches_hover,
# set visual properties for selected glyphs
selection_line_color=self.color_patches,
selection_fill_color=self.color_patches,
selection_fill_alpha=0.0,
# set visual properties for non-selected glyphs
nonselection_alpha=0.0,
nonselection_color=self.color_patches,
nonselection_line_color=self.color_patches,
# nonselection_line_alpha=0.4
)
if version.parse(bokeh.__version__) < version.Version("2.3.0"):
text_font = text_font
text_font_size = self.text_font_size
else:
text_font = value(text_font)
text_font_size = value(self.text_font_size)
self.labels = bokeh.models.LabelSet(text='label', x='ox', y='oy', source=self.ds, text_color='#1c1b1e',
text_alpha='alpha', text_font_style='normal', text_font=text_font,
text_font_size=text_font_size)
self.bokeh_figure.add_layout(self.labels)
self.patches_box = self.bokeh_figure.patches(xs='xs_box', ys='ys_box', source=self.ds,
alpha='alpha', color='color_box')
self.multi_lines = self.bokeh_figure.multi_line(xs='xs_line', ys='ys_line', source=self.ds,
line_alpha='alpha', line_color='color_line',
line_dash='line_dash')
self.hover_tool = bokeh.models.HoverTool(renderers=[self.patches], attachment='horizontal',
anchor='center_right', mode='mouse', point_policy='follow_mouse',
show_arrow=False)
self.hover_tool.tooltips = [("", "@label"), ]
self.bokeh_figure.add_tools(self.hover_tool)
# self.bokeh_figure.toolbar.active_inspect = []
self.tap_tool = bokeh.models.TapTool(renderers=[self.patches])
self.bokeh_figure.add_tools(self.tap_tool)
self.ds.selected.on_change('indices', self._callback_selected)
self.set_selected = update_value_factory(self.ds.selected, 'indices')
# --- LAYOUT
@property
def layout(self):
return self.bokeh_figure
@property
def layout_components(self) -> LayoutComponents:
return {"widgets": [],
"figures": [self.bokeh_figure]}
[docs]
def add_view(self, *, view: LegendItem) -> None:
"""
This function adds a new view to the view_container
"""
self.legend_items[str(view.uid)] = view
for v in view.views:
if str(v.uid) not in self.legend_view_legend_item_map:
self.legend_view_legend_item_map[str(v.uid)] = [str(view.uid)]
else:
self.legend_view_legend_item_map[str(v.uid)].append(str(view.uid))
if isinstance(v, MultiLine):
if not v.expandable:
v.on_change(obj=self, attr='visible', callback=self.visible_callback)
else:
v.on_change(obj=self, attr='visible', callback=self.visible_callback)
view.on_change(obj=view, attr="label", callback=self.label_callback)
self.update_data_source()
[docs]
def label_callback(self, obj, attr, old_value, new_value) -> None:
if self._state == States.DEACTIVE:
return
if obj not in self.legend_items.values():
obj.remove_all_callbacks(self)
return
self.update_data_source()
[docs]
def update_view_data(self, *, view_data: Dict[LegendItem, Quantity[TsVector]]) -> None:
"""
This function updates the views with new data
"""
pass
[docs]
def clear(self) -> None:
"""
This function removes all views from the view_container and resets the meta information
"""
self.clear_views()
[docs]
def clear_views(self, *, specific_views: Optional[List[LegendItem]] = None) -> None:
"""
This function removes all or specific views from the view_container
"""
if specific_views:
views = specific_views
self.legend_items = {str(v.uid): v for v in self.legend_items.values() if v not in views}
else:
views = [v for v in self.legend_items.values()]
self.legend_items = {}
# remove callbacks of view
for view in views:
view.remove_all_callbacks(self)
for v in view.views:
v.remove_all_callbacks(self)
self.legend_view_legend_item_map[str(v.uid)].remove(str(view.uid))
if len(self.legend_view_legend_item_map[str(v.uid)]) == 0:
self.legend_view_legend_item_map.pop(str(v.uid))
# update data source
self.update_data_source()
[docs]
def update_data_source(self):
"""
This function updates the legend data source and shows the legend items
"""
ds_dict = {k: [] for k in self.ds_keys}
label_length = 0
legend_index = 0
for n, (uid, legend_item) in enumerate(self.legend_items.items()):
label_length = max(label_length, len(legend_item.label))
xs_line = []
ys_line = []
color_line = ''
xs_box = []
ys_box = []
color_box = ''
n_line = 0
n_fill_in_between = 0
visible = True
for i, v in enumerate(legend_item.views):
visible = v.visible
if isinstance(v, Line):
oy = 2 * self.space_between_items + (self.item_height + self.space_between_items) * legend_index
mid_point = oy - abs(self.item_height / 2)
ds_dict['tag'].append(uid)
ds_dict['ox'].append(21)
ds_dict['oy'].append(oy + self.item_height + self.space_between_items)
ds_dict['xs'].append([0, self.width, self.width, 0])
ds_dict['ys'].append([oy, oy, oy + self.item_height, oy + self.item_height])
ds_dict['label'].append(legend_item.label)
color_line = v.color
if n_line > 0:
ds_dict['alpha'].append(0)
ds_dict['color_box'].append('')
ds_dict['color_line'].append(color_line)
xs_box = []
ys_box = []
ds_dict['xs_box'].append(xs_box)
ds_dict['ys_box'].append(ys_box)
ds_dict['xs_line'].append([])
ds_dict['ys_line'].append([])
continue
xs_line = [0, 20]
ys_line = [mid_point, mid_point]
n_line += 1
ds_dict['xs_box'].append(xs_box)
ds_dict['ys_box'].append(ys_box)
ds_dict['color_box'].append(color_box)
ds_dict['alpha'].append([self.alpha_invisible, self.alpha_visible][visible])
ds_dict['xs_line'].append(xs_line)
ds_dict['ys_line'].append(ys_line)
ds_dict['color_line'].append(color_line)
legend_index += 1
if isinstance(v, FillInBetween):
oy = 2 * self.space_between_items + (self.item_height + self.space_between_items) * legend_index
mid_point = oy - abs(self.item_height / 2)
ds_dict['tag'].append(uid)
ds_dict['ox'].append(21)
ds_dict['oy'].append(oy + self.item_height + self.space_between_items)
ds_dict['xs'].append([0, self.width, self.width, 0])
ds_dict['ys'].append([oy, oy, oy + self.item_height, oy + self.item_height])
color_box = v.color
if n_fill_in_between > 0:
ds_dict['label'].append('')
xs_box = []
ys_box = []
ds_dict['xs_box'].append(xs_box)
ds_dict['ys_box'].append(ys_box)
ds_dict['color_box'].append(color_box)
ds_dict['alpha'].append([self.alpha_invisible, self.alpha_visible][visible])
ds_dict['xs_line'].append(xs_line)
ds_dict['ys_line'].append(ys_line)
ds_dict['color_line'].append(color_line)
ds_dict['line_dash'].append('solid')
continue
ds_dict['label'].append(legend_item.label)
xs_box = [6, 14, 14, 6]
ys_box = [oy - 2, oy - 2, oy - abs(self.item_height) + 2, oy - abs(self.item_height) + 2]
n_fill_in_between += 1
ds_dict['xs_box'].append(xs_box)
ds_dict['ys_box'].append(ys_box)
ds_dict['color_box'].append(color_box)
ds_dict['alpha'].append([self.alpha_invisible, self.alpha_visible][visible])
ds_dict['xs_line'].append(xs_line)
ds_dict['ys_line'].append(ys_line)
ds_dict['color_line'].append(color_line)
ds_dict['line_dash'].append('solid')
legend_index += 1
if isinstance(v, MultiLine) and len(v.color) > 1:
if v.expanded and v.expandable:
oy = 2 * self.space_between_items + (self.item_height + self.space_between_items) * legend_index
mid_point = oy - abs(self.item_height / 2)
xs_line = [0, 20]
ys_line = [mid_point, mid_point]
ds_dict['tag'].append(uid)
ds_dict['alpha'].append([self.alpha_invisible, self.alpha_visible][visible])
ds_dict['xs_line'].append(xs_line)
ds_dict['ys_line'].append(ys_line)
ds_dict['color_line'].append(v.color[0])
ds_dict['label'].append(legend_item.label)
ds_dict['xs_box'].append(xs_box)
ds_dict['ys_box'].append(ys_box)
ds_dict['color_box'].append(color_box)
ds_dict['ox'].append(21)
ds_dict['oy'].append(oy + self.item_height + self.space_between_items)
ds_dict['xs'].append([0, self.width, self.width, 0])
ds_dict['ys'].append([oy, oy, oy + self.item_height, oy + self.item_height])
ds_dict['line_dash'].append(v.line_styles[0])
legend_index += 1
for k in range(len(v.color)):
oy = 2 * self.space_between_items + (self.item_height + self.space_between_items) * legend_index
mid_point = oy - abs(self.item_height / 2)
ds_dict['tag'].append(uid)
xs_line = [7, 20]
ys_line = [mid_point, mid_point]
ds_dict['alpha'].append([self.alpha_invisible, self.alpha_visible][visible])
ds_dict['xs_line'].append(xs_line)
ds_dict['ys_line'].append(ys_line)
ds_dict['color_line'].append(v.color[k])
ds_dict['label'].append(v.label[k])
ds_dict['xs_box'].append(xs_box)
ds_dict['ys_box'].append(ys_box)
ds_dict['color_box'].append(color_box)
ds_dict['ox'].append(21)
ds_dict['oy'].append(oy + self.item_height + self.space_between_items)
ds_dict['xs'].append([0, self.width, self.width, 0])
ds_dict['ys'].append([oy, oy, oy + self.item_height, oy + self.item_height])
ds_dict['line_dash'].append(v.line_styles[k])
legend_index += 1
elif not v.expanded and v.expandable:
oy = 2 * self.space_between_items + (self.item_height + self.space_between_items) * legend_index
mid_point = oy - abs(self.item_height / 2)
xs_line = [0, 20]
ys_line = [mid_point, mid_point]
ds_dict['tag'].append(uid)
ds_dict['alpha'].append([self.alpha_invisible, self.alpha_visible][visible])
ds_dict['xs_line'].append(xs_line)
ds_dict['ys_line'].append(ys_line)
ds_dict['color_line'].append(v.color[0])
ds_dict['label'].append(legend_item.label)
ds_dict['xs_box'].append(xs_box)
ds_dict['ys_box'].append(ys_box)
ds_dict['color_box'].append(color_box)
ds_dict['ox'].append(21)
ds_dict['oy'].append(oy + self.item_height + self.space_between_items)
ds_dict['xs'].append([0, self.width, self.width, 0])
ds_dict['ys'].append([oy, oy, oy + self.item_height, oy + self.item_height])
ds_dict['line_dash'].append(v.line_styles[0])
legend_index += 1
elif not v.expandable:
oy = 2 * self.space_between_items + (self.item_height + self.space_between_items) * legend_index
mid_point = oy - abs(self.item_height / 2)
xs_line = [0, 20]
ys_line = [mid_point, mid_point]
ds_dict['tag'].append(uid)
alpha = [self.alpha_invisible, self.alpha_visible][visible]
ds_dict['alpha'].append(alpha)
ds_dict['xs_line'].append(xs_line)
ds_dict['ys_line'].append(ys_line)
ds_dict['color_line'].append(v.color[0])
ds_dict['label'].append(legend_item.label)
ds_dict['xs_box'].append(xs_box)
ds_dict['ys_box'].append(ys_box)
ds_dict['color_box'].append(color_box)
ds_dict['ox'].append(21)
ds_dict['oy'].append(oy + self.item_height + self.space_between_items)
ds_dict['xs'].append([0, self.width, self.width, 0])
ds_dict['ys'].append([oy, oy, oy + self.item_height, oy + self.item_height])
ds_dict['line_dash'].append(v.line_styles[0])
legend_index += 1
for k in range(len(v.color)):
oy = 2 * self.space_between_items + (
self.item_height + self.space_between_items) * legend_index
mid_point = oy - abs(self.item_height / 2)
ds_dict['tag'].append(uid)
xs_line = [7, 20]
ys_line = [mid_point, mid_point]
ds_dict['alpha'].append(alpha)
ds_dict['xs_line'].append(xs_line)
ds_dict['ys_line'].append(ys_line)
ds_dict['color_line'].append(v.color[k])
ds_dict['label'].append(v.label[k])
ds_dict['xs_box'].append(xs_box)
ds_dict['ys_box'].append(ys_box)
ds_dict['color_box'].append(color_box)
ds_dict['ox'].append(21)
ds_dict['oy'].append(oy + self.item_height + self.space_between_items)
ds_dict['xs'].append([0, self.width, self.width, 0])
ds_dict['ys'].append([oy, oy, oy + self.item_height, oy + self.item_height])
ds_dict['line_dash'].append(v.line_styles[k])
legend_index += 1
if isinstance(v, MultiLine) and len(v.color) == 1:
oy = 2 * self.space_between_items + (self.item_height + self.space_between_items) * legend_index
mid_point = oy - abs(self.item_height / 2)
xs_line = [0, 20]
ys_line = [mid_point, mid_point]
ds_dict['tag'].append(uid)
ds_dict['alpha'].append([self.alpha_invisible, self.alpha_visible][visible])
ds_dict['xs_line'].append(xs_line)
ds_dict['ys_line'].append(ys_line)
ds_dict['color_line'].append(v.color[0])
ds_dict['label'].append(legend_item.label)
ds_dict['xs_box'].append(xs_box)
ds_dict['ys_box'].append(ys_box)
ds_dict['color_box'].append(color_box)
ds_dict['ox'].append(21)
ds_dict['oy'].append(oy + self.item_height + self.space_between_items)
ds_dict['xs'].append([0, self.width, self.width, 0])
ds_dict['ys'].append([oy, oy, oy + self.item_height, oy + self.item_height])
ds_dict['line_dash'].append(v.line_styles[0])
legend_index += 1
legend_item.visible = visible
self.ds.data = ds_dict
def _collapse(self, view):
view.expanded = False
self.update_data_source()
def _expand(self, view):
view.expanded = True
self.update_data_source()
def _callback_selected(self, attrnm, old, new):
"""
This callback triggers when legend item was clicked
"""
selected_indices = new
if selected_indices:
index = selected_indices[0] # Dont support multi select
if index >= len(self.ds.data['tag']):
return
color = "#fcf9ef"
self.bokeh_figure.border_fill_color = color
self.bokeh_figure.background_fill_color = color
tag = self.ds.data['tag'][index]
self.legend_items[tag].visible = not self.legend_items[tag].visible
for view in self.legend_items[tag].views:
if isinstance(view, MultiLine):
if view.expanded and view.expandable:
self._collapse(view)
elif not view.expanded and view.expandable:
self._expand(view)
elif not view.expandable:
view.visible = self.legend_items[tag].visible
self.update_data_source()
else:
print("something undefined with the indices happened")
else:
view.visible = self.legend_items[tag].visible
self.set_selected([])
self.bokeh_figure.border_fill_color = 'white'
self.bokeh_figure.background_fill_color = 'white'
[docs]
def visible_callback(self, obj, attr, old_value, new_value) -> None:
"""
This function triggers the in change callback for visible attribute in legend_views
"""
if self._state == States.DEACTIVE:
return
if attr != 'visible':
return
tags = self.legend_view_legend_item_map[str(obj.uid)]
for tag in tags:
legend_item = self.legend_items[tag]
view_visibility = set([v.visible for v in legend_item.views])
# change to visible as soon as one is visible!
if len(view_visibility) != 1:
continue
index = self.ds.data['tag'].index(tag)
current_value = self.ds.data['alpha'][index]
new_alpha = [self.alpha_invisible, self.alpha_visible][new_value]
legend_item.visible = new_value
if current_value != new_alpha:
self.ds.patch({'alpha': [(index, new_alpha)]})
[docs]
def expand_callback(self, obj, attr, old_value, new_value) -> None:
"""
This function triggers the in change callback for visible attribute in legend_views
"""
if self._state == States.DEACTIVE:
return
if attr != 'expanded':
return
# Add code for making visible new legend items for each line in multiline
# --- STATES
def _receive_state(self, state: States) -> None:
if state == self._state:
return
self._state = state
if state == States.LOADING:
color = "#fcf9ef"
self.bokeh_figure.border_fill_color = color
self.bokeh_figure.background_fill_color = color
self.state_port.send_state(state)
elif state == States.READY:
self.bokeh_figure.border_fill_color = 'white'
self.bokeh_figure.background_fill_color = 'white'
self.state_port.send_state(state)
elif state == States.ACTIVE:
self.bokeh_figure.border_fill_color = 'white'
self.bokeh_figure.background_fill_color = 'white'
self.state_port.send_state(state)
elif state == States.DEACTIVE:
color = "#fcf9ef"
self.bokeh_figure.border_fill_color = color
self.bokeh_figure.background_fill_color = color