nova-trame 0.15.0__py3-none-any.whl → 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
1
  from .interactive_2d_plot import Interactive2DPlot
2
+ from .matplotlib_figure import MatplotlibFigure
2
3
 
3
- __all__ = ["Interactive2DPlot"]
4
+ __all__ = ["Interactive2DPlot", "MatplotlibFigure"]
@@ -0,0 +1,307 @@
1
+ """View implementation for MatplotlibFigure."""
2
+
3
+ import json
4
+ import os
5
+ import socketserver
6
+ from asyncio import FIRST_COMPLETED, new_event_loop, set_event_loop, wait
7
+ from io import BytesIO
8
+ from mimetypes import types_map
9
+ from pathlib import Path
10
+ from threading import Thread
11
+ from typing import Any, Optional
12
+
13
+ import tornado
14
+ from aiohttp import ClientSession, WSMsgType, web
15
+ from matplotlib import get_data_path
16
+ from matplotlib.backends.backend_webagg import FigureManagerWebAgg, new_figure_manager_given_figure # type: ignore
17
+ from matplotlib.figure import Figure
18
+ from trame.app import get_server
19
+ from trame.widgets import client, html, matplotlib
20
+ from wslink.backends.aiohttp import WebAppServer
21
+
22
+
23
+ class _MPLApplication(tornado.web.Application):
24
+ """Tornado application compatible with Matplotlib's WebAgg backend."""
25
+
26
+ class WebSocket(tornado.websocket.WebSocketHandler):
27
+ """Implements the WebSocket manager for WebAgg."""
28
+
29
+ supports_binary = True
30
+
31
+ def check_origin(self, origin: Any) -> bool:
32
+ return True
33
+
34
+ def open(self, *args: Any, **kwargs: Any) -> None:
35
+ # Register the websocket with the FigureManager.
36
+ manager = self.application.manager # type: ignore
37
+ manager.add_web_socket(self)
38
+ if hasattr(self, "set_nodelay"):
39
+ self.set_nodelay(True)
40
+
41
+ def on_close(self) -> None:
42
+ # When the socket is closed, deregister the websocket with
43
+ # the FigureManager.
44
+ manager = self.application.manager # type: ignore
45
+ manager.remove_web_socket(self)
46
+
47
+ def on_message(self, message: Any) -> None:
48
+ # The 'supports_binary' message is relevant to the
49
+ # websocket itself. The other messages get passed along
50
+ # to matplotlib as-is.
51
+
52
+ # Every message has a "type" and a "figure_id".
53
+ message = json.loads(message)
54
+ if message["type"] == "supports_binary":
55
+ self.supports_binary = message["value"]
56
+ else:
57
+ manager = self.application.manager # type: ignore
58
+ manager.handle_json(message)
59
+
60
+ def send_json(self, content: Any) -> None:
61
+ set_event_loop(self.application.loop) # type: ignore
62
+ self.write_message(json.dumps(content))
63
+
64
+ def send_binary(self, blob: Any) -> None:
65
+ set_event_loop(self.application.loop) # type: ignore
66
+ if self.supports_binary:
67
+ self.write_message(blob, binary=True)
68
+ else:
69
+ data_uri = "data:image/png;base64," + blob.encode("base64").replace("\n", "")
70
+ self.write_message(data_uri)
71
+
72
+ def __init__(self, figure: Figure) -> None:
73
+ self.figure = figure
74
+ self.manager = new_figure_manager_given_figure(id(figure), figure)
75
+ self.loop = new_event_loop()
76
+
77
+ super().__init__([("/ws", self.WebSocket)])
78
+
79
+
80
+ class MatplotlibFigure(matplotlib.Figure):
81
+ """Creates an interactive Matplotlib Figure using the WebAgg backend.
82
+
83
+ By default, this will leverage the built-in Trame widget for Matplotlib support. This built-in widget can display
84
+ poor performance for detailed plots due to it being locked into using SVG rendering.
85
+
86
+ If you experience this, then you can use the `webagg` parameter to enable the WebAgg backend for Matplotlib. This
87
+ will switch to server-side rendering leveraging the Anti-Grain Geometry engine.
88
+
89
+ .. code-block:: python
90
+
91
+ my_figure = matplotlib.figure.Figure()
92
+ MatplotlibFigure(my_figure) # Display SVG-based plot in Trame
93
+ MatplotlibFigure(my_figure, webagg=True) # Display WebAgg-based plot in Trame
94
+ """
95
+
96
+ mpl_initialized = False
97
+ mpl_instances: dict[int, "MatplotlibFigure"] = {}
98
+
99
+ @classmethod
100
+ def _get_free_port(cls) -> int:
101
+ with socketserver.TCPServer(("localhost", 0), None) as s: # type: ignore
102
+ return s.server_address[1]
103
+
104
+ @classmethod
105
+ def _setup_mpl(cls) -> None:
106
+ server = get_server(None, client_type="vue3")
107
+
108
+ @server.controller.add("on_server_bind")
109
+ def _add_routes(wslink_server: WebAppServer) -> None:
110
+ # matplotlib WebAgg serves JS that will reference the base URL of the application, so we need to add
111
+ # endpoints to the main server to handle these requests.
112
+ wslink_server.app.add_routes(
113
+ [
114
+ web.get("/_images/{image}", cls._mpl_image_endpoint),
115
+ web.get("/download/{port}/{format}", cls._mpl_download_endpoint),
116
+ web.get("/mpl/{port}", cls._mpl_ws_proxy_endpoint),
117
+ ]
118
+ )
119
+
120
+ # The CSS and JS files, on the other hand, can be preloaded into the page which is simpler.
121
+ css_path = Path(FigureManagerWebAgg.get_static_file_path(), "css")
122
+ for fname in os.listdir(css_path):
123
+ with open(Path(css_path, fname)) as css_file:
124
+ content = css_file.read()
125
+ client.Style(content)
126
+ client.Script(FigureManagerWebAgg.get_javascript())
127
+
128
+ MatplotlibFigure.mpl_initialized = True
129
+
130
+ @classmethod
131
+ async def _mpl_download_endpoint(cls, request: web.Request) -> web.Response:
132
+ # We use the websocket port to differentiate between plots if there are multiple WebAgg figures.
133
+ port = request.match_info.get("port", "")
134
+ format = request.match_info.get("format", "png")
135
+
136
+ if not port or int(port) not in MatplotlibFigure.mpl_instances:
137
+ raise web.HTTPNotFound
138
+
139
+ instance = MatplotlibFigure.mpl_instances[int(port)]
140
+
141
+ buff = BytesIO()
142
+ if instance._figure:
143
+ instance._figure.savefig(buff, format=format)
144
+ buff.seek(0)
145
+
146
+ return web.Response(body=buff.read(), headers={"Content-Type": types_map.get(format, "binary")})
147
+
148
+ @classmethod
149
+ async def _mpl_image_endpoint(cls, request: web.Request) -> web.Response:
150
+ image_name = request.match_info.get("image", None)
151
+
152
+ if image_name:
153
+ try:
154
+ with open(Path(get_data_path(), "images", image_name), mode="rb") as image_file:
155
+ image_data = image_file.read()
156
+ return web.Response(body=image_data)
157
+ except OSError as err:
158
+ raise web.HTTPNotFound from err
159
+
160
+ raise web.HTTPNotFound
161
+
162
+ @classmethod
163
+ async def _mpl_ws_proxy_endpoint(cls, request: web.Request) -> web.WebSocketResponse:
164
+ # The WebAgg backend assumes a tornado-based WebSocket handler, so we need to proxy it to work with Trame's
165
+ # aiohttp setup.
166
+ port = request.match_info.get("port", "")
167
+
168
+ # Initialize the proxy
169
+ ws_server = web.WebSocketResponse()
170
+ await ws_server.prepare(request)
171
+
172
+ # Connect to the tornado WebSocket handler
173
+ client_session = ClientSession(cookies=request.cookies)
174
+ async with client_session.ws_connect(
175
+ f"http://localhost:{port}/ws",
176
+ ) as ws_client:
177
+
178
+ async def ws_forward(ws_from: Any, ws_to: Any) -> None:
179
+ # The browser will send text messages for rendering requests and the server will send bytes to transmit
180
+ # rendered image data.
181
+ async for msg in ws_from:
182
+ if msg.type == WSMsgType.TEXT:
183
+ await ws_to.send_str(msg.data)
184
+ elif msg.type == WSMsgType.BINARY:
185
+ await ws_to.send_bytes(msg.data)
186
+ else:
187
+ raise ValueError("unexpected message type: %s", print(msg))
188
+
189
+ # Forward websocket data in both directions
190
+ await wait(
191
+ [ws_forward(ws_server, ws_client), ws_forward(ws_client, ws_server)], return_when=FIRST_COMPLETED
192
+ )
193
+ await client_session.close() # Ensure the connection is cleaned up when the Trame client disconnects.
194
+
195
+ return ws_server
196
+
197
+ def __init__(self, figure: Optional[Figure] = None, webagg: bool = False, **kwargs: Any) -> None:
198
+ """Creates a Matplotlib figure in the Trame UI.
199
+
200
+ Parameters
201
+ ----------
202
+ figure : `altair.Chart <https://altair-viz.github.io/user_guide/generated/toplevel/altair.Chart.html#altair.Chart>`_
203
+ Altair chart object
204
+ webagg : bool
205
+ If true, then the WebAgg backend for Matplotlib is used. If not, then the default Trame matplotlib plugin
206
+ is used.
207
+ kwargs
208
+ Arguments to be passed to `AbstractElement <https://trame.readthedocs.io/en/latest/core.widget.html#trame_client.widgets.core.AbstractElement>`_
209
+
210
+ Returns
211
+ -------
212
+ None
213
+ """
214
+ self._webagg = webagg
215
+ if webagg:
216
+ self._port = MatplotlibFigure._get_free_port()
217
+ if "classes" in kwargs:
218
+ kwargs["classes"] += " nova-mpl"
219
+ else:
220
+ kwargs["classes"] = "nova-mpl"
221
+
222
+ html.Div(id=f"nova_mpl_{self._port}", **kwargs)
223
+
224
+ self._server = get_server(None, client_type="vue3")
225
+
226
+ self._figure = figure
227
+ self._initialized = False
228
+
229
+ if not MatplotlibFigure.mpl_initialized:
230
+ MatplotlibFigure._setup_mpl()
231
+ MatplotlibFigure.mpl_instances[self._port] = self
232
+
233
+ self.update()
234
+ else:
235
+ super().__init__(figure, **kwargs)
236
+
237
+ def update(self, figure: Optional[Figure] = None) -> None:
238
+ if self._webagg:
239
+ if figure:
240
+ self._figure = figure
241
+
242
+ if self._figure is not None and not self._initialized:
243
+ self._setup_figure_websocket()
244
+ self._setup_figure_javascript()
245
+
246
+ self._initialized = True
247
+
248
+ # Re-render the figure in the UI
249
+ if self._figure is not None:
250
+ self._figure.canvas.draw_idle()
251
+ self._figure.canvas.flush_events()
252
+ else:
253
+ super().update(figure)
254
+
255
+ def _setup_figure_websocket(self) -> None:
256
+ thread = Thread(target=self._mpl_run_ws_server, daemon=True)
257
+ thread.start()
258
+
259
+ def _setup_figure_javascript(self) -> None:
260
+ figure_js = """
261
+ function ondownload_%(port)d(figure, format) {
262
+ window.open('download/%(port)d/' + format, '_blank');
263
+ };
264
+
265
+ function ready_%(port)d(fn) {
266
+ if (document.getElementById("nova_mpl_%(port)d") === null) {
267
+ setTimeout(() => {
268
+ ready_%(port)d(fn);
269
+ }, 100);
270
+ } else {
271
+ fn();
272
+ }
273
+ }
274
+
275
+ ready_%(port)d(
276
+ function() {
277
+ var websocket_type = mpl.get_websocket_type();
278
+ var websocket = new websocket_type(`ws://${window.location.host}/mpl/%(port)d`);
279
+
280
+ var fig = new mpl.figure(
281
+ // A unique numeric identifier for the figure
282
+ %(port)d,
283
+ // A websocket object (or something that behaves like one)
284
+ websocket,
285
+ // A function called when a file type is selected for download
286
+ ondownload_%(port)d,
287
+ // The HTML element in which to place the figure
288
+ document.getElementById("nova_mpl_%(port)d")
289
+ );
290
+ }
291
+ );
292
+ """
293
+
294
+ client.Script(figure_js % {"port": self._port}) # TODO
295
+
296
+ def _mpl_run_ws_server(self) -> None:
297
+ if not self._figure:
298
+ return
299
+
300
+ application = _MPLApplication(self._figure)
301
+
302
+ http_server = tornado.httpserver.HTTPServer(application)
303
+ sockets = tornado.netutil.bind_sockets(self._port, "")
304
+ http_server.add_sockets(sockets)
305
+
306
+ ioloop = tornado.ioloop.IOLoop.instance()
307
+ ioloop.start()
@@ -4,10 +4,22 @@ html {
4
4
 
5
5
  .d-grid {
6
6
  display: grid;
7
- gap: 0.25em;
7
+ gap: 0.8em;
8
8
  grid-auto-rows: auto;
9
9
  }
10
10
 
11
+ .v-toolbar {
12
+ box-shadow: none !important;
13
+ }
14
+
15
+ .mpl-message, .ui-dialog-titlebar {
16
+ display: none !important;
17
+ }
18
+
19
+ .nova-mpl * {
20
+ resize: none !important;
21
+ }
22
+
11
23
  @media only screen and (max-width: 959px) {
12
24
  .d-grid {
13
25
  grid-template-columns: repeat(1, 1fr) !important;
@@ -20,7 +32,15 @@ html {
20
32
 
21
33
  /* Global font sizes can't be set through the Vuetify configuration. */
22
34
  .v-theme--CompactTheme {
23
- font-size: 0.85rem;
35
+ font-size: 0.75rem;
36
+
37
+ &.v-btn--variant-elevated.bg-primary {
38
+ box-shadow: none !important;
39
+ background-color: transparent !important;
40
+ color: green !important;
41
+ border: solid 1px lightgrey;
42
+ border-radius: 4px;
43
+ }
24
44
 
25
45
  .v-card-title,
26
46
  .v-list-item-title,
@@ -29,7 +49,22 @@ html {
29
49
  }
30
50
 
31
51
  .v-btn {
32
- min-width: 0px;
33
- padding: 0 8px;
52
+ min-width: 0px !important;
53
+ padding: 5px 5px !important;
54
+ box-shadow: none !important;
55
+ }
56
+
57
+ .v-btn__content {
58
+ text-transform: none;
59
+ }
60
+
61
+ .v-label {
62
+ font-size: 0.75rem;
63
+ }
64
+
65
+ .v-tab {
66
+ height: 30px !important;
67
+ min-width: fit-content !important;
68
+ padding: 10px !important;
34
69
  }
35
70
  }
@@ -152,14 +152,11 @@
152
152
  "secondary": "#f48e5c"
153
153
  },
154
154
  "defaults": {
155
- "global": {
156
- "density": "compact"
157
- },
158
155
  "VBadge": {
159
156
  "dot": true
160
157
  },
161
158
  "VBtn": {
162
- "density": "default"
159
+ "size": "small"
163
160
  },
164
161
  "VTextField": {
165
162
  "VBtn": {
@@ -172,9 +169,13 @@
172
169
  }
173
170
  },
174
171
  "variations": {
175
- "colors": ["primary", "secondary", "accent"],
172
+ "colors": [
173
+ "primary",
174
+ "secondary",
175
+ "accent"
176
+ ],
176
177
  "darken": 5,
177
178
  "lighten": 5
178
179
  }
179
180
  }
180
- }
181
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nova-trame
3
- Version: 0.15.0
3
+ Version: 0.16.0
4
4
  Summary: A Python Package for injecting curated themes and custom components into Trame applications
5
5
  License: MIT
6
6
  Keywords: NDIP,Python,Trame,Vuetify
@@ -13,12 +13,16 @@ Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Dist: altair
16
17
  Requires-Dist: libsass
17
18
  Requires-Dist: mergedeep
18
19
  Requires-Dist: nova-mvvm
19
20
  Requires-Dist: pydantic
20
21
  Requires-Dist: tomli
22
+ Requires-Dist: tornado
21
23
  Requires-Dist: trame
24
+ Requires-Dist: trame-matplotlib
25
+ Requires-Dist: trame-plotly
22
26
  Requires-Dist: trame-vega
23
27
  Requires-Dist: trame-vuetify
24
28
  Description-Content-Type: text/markdown
@@ -4,24 +4,25 @@ nova/trame/model/remote_file_input.py,sha256=9KAf31ZHzpsh_aXUrNcF81Q5jvUZDWCzW1Q
4
4
  nova/trame/view/components/__init__.py,sha256=fopr6mVqcpDcVYK9ue7SLUHyswgvRPcFESTq86mu1R8,128
5
5
  nova/trame/view/components/input_field.py,sha256=8q18MyegDl0ni2Wpb6TwvTJ4UYoKSNINq9TilE5EnbE,14956
6
6
  nova/trame/view/components/remote_file_input.py,sha256=k2yrwkell_g0sGnWR9XLL1LxkmFLr8-AGhduo8E-N4A,8669
7
- nova/trame/view/components/visualization/__init__.py,sha256=kDX1fkbtAgXSGlqhlMNhYYoYrq-hfS636smjgLsh6gg,84
7
+ nova/trame/view/components/visualization/__init__.py,sha256=reqkkbhD5uSksHHlhVMy1qNUCwSekS5HlXk6wCREYxU,152
8
8
  nova/trame/view/components/visualization/interactive_2d_plot.py,sha256=foZCMoqbuahT5dtqIQvm8C4ZJcY9P211eJEcpQJltmM,3421
9
+ nova/trame/view/components/visualization/matplotlib_figure.py,sha256=yop7Kd_MylUiCwEial2jOYESbvchrYhrpSmRowUhePY,12003
9
10
  nova/trame/view/layouts/__init__.py,sha256=cMrlB5YMUoK8EGB83b34UU0kPTVrH8AxsYvKRtpUNEc,141
10
11
  nova/trame/view/layouts/grid.py,sha256=k-QHuH31XeAVDuMKUMoAMVnAM-Yavq7kdLYOC1ZrGTQ,5021
11
12
  nova/trame/view/layouts/hbox.py,sha256=r5irhFX6YWTWN4V4NwNQx6mheyM8p6PVcJbrbhvOAwo,2625
12
13
  nova/trame/view/layouts/vbox.py,sha256=Q4EvrtGJORyNF6AnCLGXToy8XU6yofiO5_kt7hK-AYs,2626
13
14
  nova/trame/view/theme/__init__.py,sha256=70_marDlTigIcPEOGiJb2JTs-8b2sGM5SlY7XBPtBDM,54
14
- nova/trame/view/theme/assets/core_style.scss,sha256=vr5L55sKj-eWeom4NaO3sYKtPgtM5O-KlYk1k15uI3Q,586
15
+ nova/trame/view/theme/assets/core_style.scss,sha256=_RUO_WnxsteRXftfLQiql0TLh_9Vx93bxLeUBu8Xp3M,1284
15
16
  nova/trame/view/theme/assets/favicon.png,sha256=Xbp1nUmhcBDeObjsebEbEAraPDZ_M163M_ZLtm5AbQc,1927
16
17
  nova/trame/view/theme/assets/js/delay_manager.js,sha256=vmb34DZ5YCQIlRW9Tf2M_uvJW6HFCmtlKZ5e_TPR8yg,536
17
18
  nova/trame/view/theme/assets/js/lodash.debounce.min.js,sha256=GLzlQH04WDUNYN7i39ttHHejSdu-CpAvfWgDgKDn-OY,4448
18
19
  nova/trame/view/theme/assets/js/lodash.throttle.min.js,sha256=9csqjX-M-LVGJnF3z4ha1R_36O5AfkFE8rPHkxmt3tE,4677
19
- nova/trame/view/theme/assets/vuetify_config.json,sha256=7WGV6rO7hv2sapGsX9yy1d-dINshYFXRNX99D9I3dKQ,4780
20
+ nova/trame/view/theme/assets/vuetify_config.json,sha256=SQOPcwuAYTgYfkhyC1eB1TA8VtnMWZe7-P2ItJkOrX0,4736
20
21
  nova/trame/view/theme/theme.py,sha256=18tHChrB1_qosjIiiLxMwWYRDTdXfgxEP4-4yJ8E3Cw,11893
21
22
  nova/trame/view/utilities/local_storage.py,sha256=vD8f2VZIpxhIKjZwEaD7siiPCTZO4cw9AfhwdawwYLY,3218
22
23
  nova/trame/view_model/remote_file_input.py,sha256=WHWCQkZBGeKLe1aTPbtVNI8tn-PDt64mi1-561uuBpQ,3320
23
- nova_trame-0.15.0.dist-info/LICENSE,sha256=Iu5QiDbwNbREg75iYaxIJ_V-zppuv4QFuBhAW-qiAlM,1061
24
- nova_trame-0.15.0.dist-info/METADATA,sha256=Acdlyzs80ZpAlhUnCw5iFfw7N2zgGRu7B1eR1g9ZLlI,1240
25
- nova_trame-0.15.0.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
26
- nova_trame-0.15.0.dist-info/entry_points.txt,sha256=J2AmeSwiTYZ4ZqHHp9HO6v4MaYQTTBPbNh6WtoqOT58,42
27
- nova_trame-0.15.0.dist-info/RECORD,,
24
+ nova_trame-0.16.0.dist-info/LICENSE,sha256=Iu5QiDbwNbREg75iYaxIJ_V-zppuv4QFuBhAW-qiAlM,1061
25
+ nova_trame-0.16.0.dist-info/METADATA,sha256=-lnKJubLE96B49GZ567TOGxbxeNwqYX_R9KLaBo0FuY,1345
26
+ nova_trame-0.16.0.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
27
+ nova_trame-0.16.0.dist-info/entry_points.txt,sha256=J2AmeSwiTYZ4ZqHHp9HO6v4MaYQTTBPbNh6WtoqOT58,42
28
+ nova_trame-0.16.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.1
2
+ Generator: poetry-core 2.1.2
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any