Py.Cafe

maartenbreddels/

solara-matplotlib-responsive

Responsive matplotlib in Solara

DocsPricing
  • app.py
  • requirements.txt
app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
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)
viewlistener.vue
1
Could not load content