import os
from spb.defaults import cfg
from spb.backends.base_backend import Plot
from spb.backends.bokeh.renderers import (
Line2DRenderer, Vector2DRenderer, ComplexRenderer, ContourRenderer,
GeometryRenderer, GenericRenderer, HVLineRenderer, Arrow2DRenderer,
ZGridLineRenderer, SGridLineRenderer, NGridLineRenderer,
MCirclesRenderer, PoleZeroRenderer, RootLocusRenderer, NyquistRenderer,
NicholsLineRenderer
)
from spb.series import (
LineOver1DRangeSeries, List2DSeries, Parametric2DLineSeries,
ColoredLineOver1DRangeSeries, AbsArgLineSeries, ComplexPointSeries,
Vector2DSeries, ComplexDomainColoringSeries, ContourSeries,
GeometrySeries, GenericDataSeries, HVLineSeries, Arrow2DSeries,
ZGridLineSeries, SGridLineSeries, NGridLineSeries, NicholsLineSeries,
MCirclesSeries, PoleZeroSeries, PoleZeroWithSympySeries,
SystemResponseSeries, ColoredSystemResponseSeries, RootLocusSeries,
NyquistLineSeries
)
from spb.utils import get_environment
from sympy.external import import_module
[docs]
class BokehBackend(Plot):
"""
A backend for plotting SymPy's symbolic expressions using Bokeh.
This implementation only supports 2D plots.
Parameters
==========
aspect : str
Set the aspect ratio of a 2D plot. Default to ``None``. Set it to
``"equal"`` to sets equal spacing on the axis.
rendering_kw : dict, optional
A dictionary of keywords/values which is passed to Matplotlib's plot
functions to customize the appearance of lines, surfaces, images,
contours, quivers, streamlines...
To learn more about customization:
* Refer to:
- [#fn1]_ to customize lines plots. Default to:
``dict(line_width = 2)``.
- [#fn6]_ to customize scatter plots. Default to:
``dict(marker = "circle")``.
* Default options for quiver plots:
.. code-block:: python
dict(
scale = 1,
pivot = "mid", # "mid", "tip" or "tail"
arrow_heads = True, # show/hide arrow
line_width = 1
)
* Default options for streamline plots:
``dict(line_width=2, line_alpha=0.8)``
axis : boolean, optional
Turns on/off the axis visibility (and associated tick labels).
Default to True (axis are visible).
theme : str, optional
Set the theme. Find more Bokeh themes at [#fn2]_ .
update_event : bool, optional
If True, it binds pan/zoom events in order to automatically compute
new data as the user interact with the plot.
Default to False.
annotations : list, optional
A list of dictionaries specifying the type of annotation
required. The keys in the dictionary should be equivalent
to the arguments of the `bokeh.models.LabelSet` class.
This feature is experimental. It might get removed in the future.
markers : list, optional
A list of dictionaries specifying the type the markers required.
The keys in the dictionary should be equivalent to the arguments
of the `bokeh.models.Scatter` class.
This feature is experimental. It might get removed in the future.
rectangles : list, optional
A list of dictionaries specifying the dimensions of the
rectangles to be plotted. The ``"args"`` key must contain the
`bokeh.models.ColumnDataSource` object containing the
data. All other keyword arguments will be passed to the
`bokeh.models.Rect` class.
This feature is experimental. It might get removed in the future.
fill : dict, optional
A dictionary specifying the type of color filling required in
the plot. The keys in the dictionary should be equivalent to the
arguments of the `bokeh.models.VArea` class.
This feature is experimental. It might get removed in the future.
References
==========
.. [#fn1] https://docs.bokeh.org/en/latest/docs/reference/plotting.html#bokeh.plotting.Figure.line
.. [#fn2] https://docs.bokeh.org/en/latest/docs/reference/themes.html
.. [#fn6] https://docs.bokeh.org/en/latest/docs/reference/plotting/figure.html#bokeh.plotting.Figure.scatter
Notes
=====
By providing ``update_event=True`` to any plot function, this backend
binds pan/zoom events in order to automatically compute new data as the
user interact with the plot.
When executing this mode of operation inside:
* Jupyter Notebook/Lab: no problem has been encountered (with
Firefox/Chrome).
* A standard Python interpreter:
* No problem has been encountered with Chrome.
* Memory leaks has been observed with Firefox. Watch out your system
monitor!
See also
========
Plot, MatplotlibBackend, PlotlyBackend, K3DBackend
"""
_library = "bokeh"
_allowed_keys = Plot._allowed_keys + [
"markers", "annotations", "fill", "rectangles"]
colorloop = []
colormaps = []
cyclic_colormaps = []
renderers_map = {
LineOver1DRangeSeries: Line2DRenderer,
List2DSeries: Line2DRenderer,
Parametric2DLineSeries: Line2DRenderer,
ColoredLineOver1DRangeSeries: Line2DRenderer,
AbsArgLineSeries: Line2DRenderer,
ComplexPointSeries: Line2DRenderer,
Vector2DSeries: Vector2DRenderer,
ComplexDomainColoringSeries: ComplexRenderer,
ContourSeries: ContourRenderer,
GeometrySeries: GeometryRenderer,
GenericDataSeries: GenericRenderer,
HVLineSeries: HVLineRenderer,
Arrow2DSeries: Arrow2DRenderer,
RootLocusSeries: RootLocusRenderer,
SGridLineSeries: SGridLineRenderer,
ZGridLineSeries: ZGridLineRenderer,
SystemResponseSeries: Line2DRenderer,
ColoredSystemResponseSeries: Line2DRenderer,
PoleZeroSeries: PoleZeroRenderer,
PoleZeroWithSympySeries: PoleZeroRenderer,
NGridLineSeries: NGridLineRenderer,
NicholsLineSeries: NicholsLineRenderer,
MCirclesSeries: MCirclesRenderer,
NyquistLineSeries: NyquistRenderer,
}
pole_line_kw = {"line_color": "#000000", "line_dash": "dotted"}
grid_line_kw = {"line_color": "#aaa", "line_dash": "dotted"}
sgrid_line_kw = {"line_color": "#aaa", "line_dash": "dotted"}
ngrid_line_kw = {"line_color": "#aaa", "line_dash": "dotted"}
mcircles_line_kw = {"line_color": "#aaa", "line_dash": "dotted"}
def __init__(self, *args, **kwargs):
self.np = import_module('numpy')
self.bokeh = import_module(
'bokeh',
import_kwargs={
'fromlist': [
'models', 'events', 'plotting', 'io',
'palettes', 'embed', 'resources', 'server'
]
},
warn_not_installed=True,
min_module_version='2.3.0'
)
bp = self.bokeh.palettes
cc = import_module(
'colorcet',
min_module_version='3.0.0')
matplotlib = import_module(
'matplotlib',
import_kwargs={'fromlist': ['pyplot', 'cm']},
min_module_version='1.1.0',
catch=(RuntimeError,))
cm = matplotlib.cm
self.colorloop = bp.Category10[10]
self.colormaps = [cc.bmy, "aggrnyl", cc.kbc, cc.bjy, "plotly3"]
self.cyclic_colormaps = [
cm.hsv, cm.twilight, cc.cyclic_mygbm_30_95_c78_s25
]
# _init_cyclers needs to know if an existing figure was provided
self._use_existing_figure = kwargs.get("fig", False)
self._fig = None
self._init_cyclers()
super().__init__(*args, **kwargs)
if self.polar_axis:
raise ValueError("BokehBackend doesn't support polar axis.")
# set labels
self._use_latex = kwargs.get("use_latex", cfg["bokeh"]["use_latex"])
self._set_labels()
self._set_title()
self._theme = kwargs.get("theme", cfg["bokeh"]["theme"])
self._run_in_notebook = False
if get_environment() == 0:
self._run_in_notebook = True
self.bokeh.io.output_notebook(hide_banner=True)
if (
(len([s for s in self._series if s.is_2Dline]) > 10) and
(not type(self).colorloop) and
not ("process_piecewise" in kwargs.keys())
):
# add colors if needed
self.colorloop = bp.Category20[20]
self._handles = dict()
sizing_mode = cfg["bokeh"]["sizing_mode"]
title, xlabel, ylabel, zlabel = self._get_title_and_labels()
kw = dict(
title=title,
x_axis_label=xlabel if xlabel else "x",
y_axis_label=ylabel if ylabel else "y",
sizing_mode="fixed" if self.size else sizing_mode,
width=int(self.size[0]) if self.size else cfg["bokeh"]["width"],
height=int(self.size[1]) if self.size else cfg["bokeh"]["height"],
tools="pan,wheel_zoom,box_zoom,reset,save",
match_aspect=True if self.aspect == "equal" else False,
)
if self.xlim:
kw["x_range"] = self.xlim
if self.ylim:
kw["y_range"] = self.ylim
if self.xscale:
kw["x_axis_type"] = self.xscale
if self.yscale:
kw["y_axis_type"] = self.yscale
if self._use_existing_figure:
self._fig = self._use_existing_figure
self._use_existing_figure = True
else:
self._fig = self.bokeh.plotting.figure(**kw)
self._fig.axis.visible = self.axis
self.grid = kwargs.get("grid", cfg["bokeh"]["grid"])
self._fig.grid.visible = self.grid
if cfg["bokeh"]["show_minor_grid"]:
self._fig.grid.minor_grid_line_alpha = cfg["bokeh"]["minor_grid_line_alpha"]
self._fig.grid.minor_grid_line_color = self._fig.grid.grid_line_color[0]
self._fig.grid.minor_grid_line_dash = cfg["bokeh"]["minor_grid_line_dash"]
if self._invert_x_axis:
self._fig.x_range.flipped = True
self._update_event = kwargs.get(
"update_event", cfg["bokeh"]["update_event"])
if self._update_event:
self._fig.on_event(self.bokeh.events.RangesUpdate, self._ranges_update)
self._create_renderers()
def _ranges_update(self, event):
xlim = (event.x0, event.x1)
ylim = (event.y0, event.y1)
params = self._update_series_ranges(xlim, ylim)
self.update_interactive(params)
def _init_cyclers(self):
start_index_cl, start_index_cm = None, None
if self._use_existing_figure:
fig = self._use_existing_figure if self._fig is None else self._fig
# attempt to determine how many lines are plotted
# on the user-provided figure
start_index_cl = len(fig.renderers)
super()._init_cyclers(start_index_cl, 0)
@property
def fig(self):
"""Returns the figure."""
if (
(len(self.renderers) > 0) and
(
(self.renderers[0] and len(self.renderers[0].handles) == 0)
or (self.renderers[0] is None)
)
):
# if the backend was created without showing it
self.draw()
return self._fig
def draw(self):
""" Loop over data renderers, generates numerical data and add it to
the figure. Note that this method doesn't show the plot.
"""
self._process_renderers()
process_series = draw
def _set_piecewise_color(self, s, color):
"""Set the color to the given series"""
if "color" not in s.rendering_kw:
# only set the color if the user didn't do that already
s.rendering_kw["color"] = color
if s.is_point and (not s.is_filled):
s.rendering_kw["fill_color"] = "white"
@staticmethod
def _do_sum_kwargs(p1, p2):
kw = p1._copy_kwargs()
kw["theme"] = p1._theme
return kw
def _process_renderers(self):
self._init_cyclers()
if not self._use_existing_figure:
# If this instance visualizes only symbolic expressions,
# I want to clear axes so that each time `.show()` is called there
# won't be repeated handles.
# On the other hand, if the current axes is provided by the user,
# we don't want to erase its content.
# Must clear both the renderers as well as the
# colorbars which are added to the right side.
self._fig.renderers = []
self._fig.right = []
xlims, ylims = [], []
for r, s in zip(self.renderers, self.series):
self._check_supported_series(r, s)
r.draw()
if hasattr(r, "_xlims"):
xlims.extend(r._xlims)
ylims.extend(r._ylims)
if (len(xlims) > 0) and (self.xlim is None):
# this is used in order to properly visualized some *GridSeries
np = self.np
xlims = np.array(xlims)
xlim = (np.nanmin(xlims[:, 0]), np.nanmax(xlims[:, 1]))
self._fig.x_range = self.bokeh.models.Range1d(*xlim)
if (len(ylims) > 0) and (self.ylim is None):
# this is used in order to properly visualized some *GridSeries
np = self.np
ylims = np.array(ylims)
ylim = (np.nanmin(ylims[:, 0]), np.nanmax(ylims[:, 1]))
self._fig.y_range = self.bokeh.models.Range1d(*ylim)
if len(self._fig.legend) > 0:
# hide default legend
self._fig.legend.visible = False
# add a new legend only showing the appropriate items
legend_items = []
end = 0
if self._use_existing_figure:
legend_items = self._fig.legend.items
# keep existing legend entries if we are dealing with a
# user-provided figure
end = len(legend_items) - len(self.series)
legend_items = legend_items[:end]
for s, r in zip(self.series, self.renderers):
if (
s.show_in_legend and
(s.is_2Dline or s.is_geometry) and
(not s.use_cm)
):
if hasattr(r.handles[0][0], "__iter__"):
bokeh_renderer = r.handles[0][0][0]
else:
bokeh_renderer = r.handles[0][0]
legend_items.append(
self.bokeh.models.LegendItem(
label=s.get_label(self._use_latex),
renderers=[bokeh_renderer])
)
if self.legend and (len(legend_items) > 0):
legend = self.bokeh.models.Legend(items=legend_items)
# interactive legend
legend.click_policy = "hide"
self._fig.add_layout(legend, "right")
def _get_img(self, img):
np = import_module('numpy')
new_img = np.zeros(img.shape[:2], dtype=np.uint32)
pixel = new_img.view(dtype=np.uint8).reshape((*img.shape[:2], 4))
for i in range(img.shape[1]):
for j in range(img.shape[0]):
pixel[j, i] = [*img[j, i], 255]
return new_img
def _get_segments(self, x, y, *others):
# MultiLine works with line segments, not with line points! :|
xs = [x[i - 1 : i + 1] for i in range(1, len(x))]
ys = [y[i - 1 : i + 1] for i in range(1, len(y))]
# let n be the number of points. Then, the number of segments
# will be (n - 1). Therefore, we remove one parameter. If n is
# sufficiently high, there shouldn't be any noticeable problem in
# the visualization.
others = list(others)
for i, o in enumerate(others):
others[i] = o[:-1]
return [xs, ys, *others]
def _create_gradient_line(
self, x_key, y_key, p_key, source, colormap, name, line_kw,
is_point=False
):
param = source[p_key]
color_mapper = self.bokeh.models.LinearColorMapper(
palette=colormap, low=min(param), high=max(param))
data_source = self.bokeh.models.ColumnDataSource(source)
lkw = dict(
line_width=2,
name=name,
line_color={"field": p_key, "transform": color_mapper},
)
kw = self.merge({}, lkw, line_kw)
if not is_point:
glyph = self.bokeh.models.MultiLine(xs=x_key, ys=y_key, **kw)
else:
glyph = self.bokeh.models.Scatter(x=x_key, y=y_key, **kw)
colorbar = self.bokeh.models.ColorBar(
color_mapper=color_mapper, title=name, width=8)
return data_source, glyph, colorbar, kw
def update_interactive(self, params):
"""Implement the logic to update the data generated by
interactive-widget plots.
Parameters
==========
params : dict
Map parameter-symbols to numeric values.
"""
if len(self.renderers) > 0 and len(self.renderers[0].handles) == 0:
self.draw()
xlims, ylims = [], []
for r in self.renderers:
if r.series.is_interactive:
r.update(params)
if hasattr(r, "_xlims"):
xlims.extend(r._xlims)
ylims.extend(r._ylims)
if (len(xlims) > 0) and (self.xlim is None):
# this is used in order to properly visualized some *GridSeries
np = self.np
xlims = np.array(xlims)
xlim = (np.nanmin(xlims[:, 0]), np.nanmax(xlims[:, 1]))
self._fig.x_range.update(start=xlim[0], end=xlim[1])
if (len(ylims) > 0) and (self.ylim is None):
# this is used in order to properly visualized some *GridSeries
np = self.np
ylims = np.array(ylims)
ylim = (np.nanmin(ylims[:, 0]), np.nanmax(ylims[:, 1]))
self._fig.y_range.update(start=ylim[0], end=ylim[1])
self._set_axes_texts()
def _set_axes_texts(self):
title, xlabel, ylabel, zlabel = self._get_title_and_labels()
self._fig.title = title
self._fig.xaxis.axis_label = xlabel
self._fig.yaxis.axis_label = ylabel
def save(self, path, **kwargs):
""" Export the plot to a static picture or to an interactive html file.
Refer to [#fn3]_ and [#fn4]_ to visualize all the available keyword
arguments.
Notes
=====
1. In order to export static pictures, the user also need to install
the packages listed in [#fn5]_.
2. When exporting a fully portable html file, by default the necessary
Javascript libraries will be loaded with a CDN. This creates the
smallest file size possible, but it requires an internet connection
in order to view/load the file and its dependencies.
References
==========
.. [#fn3] https://docs.bokeh.org/en/latest/docs/user_guide/export.html
.. [#fn4] https://docs.bokeh.org/en/latest/docs/user_guide/embed.html
.. [#fn5] https://docs.bokeh.org/en/latest/docs/reference/io.html#module-bokeh.io.export
"""
merge = self.merge
ext = os.path.splitext(path)[1]
if ext.lower() in [".htm", ".html"]:
CDN = self.bokeh.resources.CDN
file_html = self.bokeh.embed.file_html
skw = dict(resources=CDN, title="Bokeh Plot")
html = file_html(self.fig, **merge(skw, kwargs))
with open(path, 'w') as f:
f.write(html)
elif ext == ".svg":
self._fig.output_backend = "svg"
self.bokeh.io.export_svg(self.fig, filename=path)
else:
if ext == "":
path += ".png"
self._fig.output_backend = "canvas"
self.bokeh.io.export_png(self._fig, filename=path)
def _launch_server(self, doc):
""" By launching a server application, we can use Python callbacks
associated to events.
"""
doc.theme = self._theme
doc.add_root(self.fig)
def show(self):
"""Visualize the plot on the screen."""
if len(self._fig.renderers) != len(self.series):
self._process_renderers()
if self._update_event:
if self._run_in_notebook:
self.bokeh.plotting.show(self._launch_server)
else:
# NOTE:
# 1. From: https://docs.bokeh.org/en/latest/docs/user_guide/server/library.html
# In particular: https://github.com/bokeh/bokeh/tree/3.4.0/examples/server/api/standalone_embed.py
# 2. TODO: Only works for one plot, then python needs to be
# closed and reopened.
# 3. Use Control+C to stop the server process
# 4. Watch out for memory leaks on Firefox.
from bokeh.server.server import Server
server = Server(self._launch_server, num_procs=1)
server.start()
server.io_loop.add_callback(server.show, "/")
server.io_loop.start()
else:
# launch a static figure
curdoc = self.bokeh.io.curdoc
curdoc().theme = self._theme
self.bokeh.plotting.show(self._fig)
def _get_quivers_data(self, xs, ys, u, v, **quiver_kw):
"""Compute the segments coordinates to plot quivers.
Parameters
==========
xs : np.ndarray
A 2D numpy array representing the discretization in the
x-coordinate
ys : np.ndarray
A 2D numpy array representing the discretization in the
y-coordinate
u : np.ndarray
A 2D numpy array representing the x-component of the vector
v : np.ndarray
A 2D numpy array representing the x-component of the vector
kwargs : dict, optional
An optional
Returns
=======
data: dict
A dictionary suitable to create a data source to be used with
Bokeh's Segment.
quiver_kw : dict
A dictionary containing keywords to customize the appearance
of Bokeh's Segment glyph
"""
np = import_module('numpy')
scale = quiver_kw.pop("scale", 1.0)
pivot = quiver_kw.pop("pivot", "mid")
arrow_heads = quiver_kw.pop("arrow_heads", True)
xs, ys, u, v = [t.flatten() for t in [xs, ys, u, v]]
magnitude = np.sqrt(u ** 2 + v ** 2)
rads = np.arctan2(v, u)
lens = magnitude / max(magnitude) * scale
# Compute segments and arrowheads
# Compute offset depending on pivot option
xoffsets = np.cos(rads) * lens / 2.0
yoffsets = np.sin(rads) * lens / 2.0
if pivot == "mid":
nxoff, pxoff = xoffsets, xoffsets
nyoff, pyoff = yoffsets, yoffsets
elif pivot == "tip":
nxoff, pxoff = 0, xoffsets * 2
nyoff, pyoff = 0, yoffsets * 2
elif pivot == "tail":
nxoff, pxoff = xoffsets * 2, 0
nyoff, pyoff = yoffsets * 2, 0
else:
raise ValueError(
"`pivot` must be one of ['mid', 'tip', 'tail']")
x0s, x1s = (xs + nxoff, xs - pxoff)
y0s, y1s = (ys + nyoff, ys - pyoff)
if arrow_heads:
arrow_len = lens / 4.0
xa1s = x0s - np.cos(rads + np.pi / 4) * arrow_len
ya1s = y0s - np.sin(rads + np.pi / 4) * arrow_len
xa2s = x0s - np.cos(rads - np.pi / 4) * arrow_len
ya2s = y0s - np.sin(rads - np.pi / 4) * arrow_len
x0s = np.tile(x0s, 3)
x1s = np.concatenate([x1s, xa1s, xa2s])
y0s = np.tile(y0s, 3)
y1s = np.concatenate([y1s, ya1s, ya2s])
data = {
"x0": x0s,
"x1": x1s,
"y0": y0s,
"y1": y1s,
}
return data, quiver_kw
BB = BokehBackend