from typing import Optional
import ipympl.backend_nbagg as ipympl
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from matplotlib.figure import Figure
import solara
plt.switch_backend("module://ipympl.backend_nbagg")
@solara.component_vue("viewlistener.vue")
def ViewListener(view_data=None, on_view_data=None, children=[], style={}):
...
def use_matplotlib_zoom(fig: Optional[Figure], ax: Optional[Axes], scale=2.0, reverse=True, disable=False):
def connect():
if disable:
return
if fig is None:
return
if ax is None:
return
def zoom(event):
assert ax is not None
xlim = ax.get_xlim()
ylim = ax.get_ylim()
xdata = event.xdata # get event x location
ydata = event.ydata # get event y location
if event.button == "down":
# zoom in
scale_factor = 1 / scale
elif event.button == "up":
# zoom our
scale_factor = scale
else:
raise ValueError(f"Unknown button: {event.button}")
if reverse:
scale_factor = 1 / scale_factor
width = (xlim[1] - xlim[0]) * scale_factor
height = (ylim[1] - ylim[0]) * scale_factor
relx = (xlim[1] - xdata) / (xlim[1] - xlim[0])
rely = (ylim[1] - ydata) / (ylim[1] - ylim[0])
ax.set_xlim([xdata - width * (1 - relx), xdata + width * (relx)])
ax.set_ylim([ydata - height * (1 - rely), ydata + height * (rely)])
ax.figure.canvas.draw()
cid = fig.canvas.mpl_connect("scroll_event", zoom)
# print("connected", cid)
def cleanup():
if fig is None:
return
fig.canvas.mpl_disconnect(cid)
return cleanup
solara.use_effect(connect, [fig, ax, scale, disable])
def use_matplotlib_pan(fig: Optional[Figure], ax: Optional[Axes], scale=2.0, disable=False):
def connect():
if disable:
return
if fig is None:
return
if ax is None:
return
xlim = ax.get_xlim()
ylim = ax.get_ylim()
x_start = 0
y_start = 0
dragging = False
def mouse_down(event):
nonlocal xlim, ylim, x_start, y_start, dragging
if ax is None:
return
if event.inaxes != ax:
return
xlim = ax.get_xlim()
ylim = ax.get_ylim()
x_start = event.xdata
y_start = event.ydata
dragging = True
def mouse_up(event):
nonlocal dragging
dragging = False
if fig is not None:
fig.canvas.draw()
def mouse_move(event):
nonlocal xlim, ylim
if ax is None or fig is None:
return
if not dragging:
return
if event.inaxes != ax:
return
dx = event.xdata - x_start
dy = event.ydata - y_start
print(dx, dy)
# mutate, because the event coordinates are
# relative to the current limits
xlim -= dx
ylim -= dy
ax.set_xlim(xlim)
ax.set_ylim(ylim)
fig.canvas.draw()
# attach the call back
cid1 = fig.canvas.mpl_connect("button_press_event", mouse_down)
cid2 = fig.canvas.mpl_connect("button_release_event", mouse_up)
cid3 = fig.canvas.mpl_connect("motion_notify_event", mouse_move)
def cleanup():
if fig is None:
return
fig.canvas.mpl_disconnect(cid1)
fig.canvas.mpl_disconnect(cid2)
fig.canvas.mpl_disconnect(cid3)
return cleanup
solara.use_effect(connect, [fig, ax, scale, disable])
@solara.component
def Plot1(seed: int = 42):
view_data = solara.use_reactive({"width": 800, "height": 600})
width, height = view_data.value["width"], view_data.value["height"]
dpi = 100 # doesn't really matter, we work in pixels
#plt.switch_backend("module://ipympl.backend_nbagg")
# it is important to create the figure just once, since we do not want to create
# the canvas (which is a widget) every time we render the component
def make():
fig = plt.figure(figsize=(width / dpi, height / dpi), dpi=dpi)
# import traceback
# used this to debug why call was closed
# def myclose():
# traceback.print_stack()
# print("Why was close called?")
# fig.canvas.close = myclose
print(fig.canvas)
return fig
fig = solara.use_memo(make, [])
assert isinstance(fig.canvas, ipympl.Canvas)
fig.canvas.header_visible = False
fig.canvas.resizable = False # does not make sense if we use a responsive layout
# fig.canvas.capture_scroll = True
fig.set_size_inches(width / dpi, height / dpi)
# because we reuse the figure, we need to clear it
fig.clear()
ax = fig.subplots()
ax.clear()
# financial data random walk
np.random.seed(seed)
returns = 0.01 * np.random.randn(1000).cumsum() + 0.05
ax.plot(returns)
# some custom hooks to enable zooming and panning
# but you can also use the ipympl toolbar
# use_matplotlib_zoom(fig, ax, scale=1.3)
# use_matplotlib_pan(fig, ax, scale=1.3)
# sometimes needed to avoid some draw artifacts
# fig.canvas._force_full = True
fig.canvas.draw()
with ViewListener(view_data=view_data.value, on_view_data=view_data.set, style={"width": "100%", "height": "40vh"}):
FigureMatplotlib(fig)
def use_ipympl_size_fix(fig: Figure, disable=False):
width_inch, height_inch = fig.get_size_inches()
dpi = fig.get_dpi()
width, height = int(width_inch * dpi), int(height_inch * dpi)
def update_size():
if disable:
return
assert fig is not None
canvas_widget = fig.canvas
if isinstance(canvas_widget, ipympl.Canvas):
# when using ipympl, we force the size
canvas_widget.layout.width = f"{width}px"
canvas_widget.layout.height = f"{height}px"
solara.use_effect(update_size, [width, height])
@solara.component
def FigureMatplotlib(fig: Figure):
use_ipympl_size_fix(fig)
# reacton is picky about wanting to have an element as return type
# so we wrap the canvas widget in a column
return solara.Column(children=[fig.canvas], style={"height": "100%"})
seed = solara.reactive(42)
@solara.component
def Page():
with solara.Sidebar():
with solara.Card("Seed"):
solara.SliderInt("seed", seed, min=0, max=1000, step=1)
with solara.ColumnsResponsive(12, 12, 6):
with solara.Card("This months profits"):
Plot1(seed.value)
pass
with solara.Card("This months expenses"):
print("Plot2", seed.value)
Plot1(seed.value + 1)