diff --git a/backend/funix/__init__.py b/backend/funix/__init__.py index ee0cf214..3aac8675 100644 --- a/backend/funix/__init__.py +++ b/backend/funix/__init__.py @@ -18,7 +18,7 @@ import funix.decorator.theme as theme import funix.decorator.widget as widget import funix.hint as hint -from funix.app import app, enable_funix_host_checker +from funix.app import app, sock, enable_funix_host_checker from funix.config.switch import GlobalSwitchOption from funix.frontend import run_open_frontend, start from funix.jupyter import jupyter diff --git a/backend/funix/__main__.py b/backend/funix/__main__.py index 0989a488..7f2a3f8e 100644 --- a/backend/funix/__main__.py +++ b/backend/funix/__main__.py @@ -81,7 +81,7 @@ def cli_main(): This function is called when you run `python -m funix` or `funix` from the command line. """ - plac.call(main, version="Funix 0.6.1") + plac.call(main, version="Funix 0.6.2") if __name__ == "__main__": diff --git a/backend/funix/app/__init__.py b/backend/funix/app/__init__.py index 8287a260..5926d04d 100644 --- a/backend/funix/app/__init__.py +++ b/backend/funix/app/__init__.py @@ -7,6 +7,7 @@ import os import re from datetime import datetime, timezone +from io import BytesIO from pathlib import Path from urllib.parse import urlparse from uuid import uuid4 @@ -22,6 +23,8 @@ from funix.frontend import start from funix.hint import LogLevel +from json import loads + app = Flask(__name__) app.secret_key = GlobalSwitchOption.get_session_key() app.config.update( @@ -32,6 +35,113 @@ app.json.sort_keys = False sock = Sock(app) +matplotlib_figure_manager = {} + + +class MatplotlibWebsocket: + supports_binary = True + + def __init__(self, manager=None, ws=None): + self.manager = manager + self.ws = ws + if self.manager is not None: + self.manager.add_web_socket(self) + self.supports_binary = True + + def on_close(self): + self.manager.remove_web_socket(self) + self.ws.close() + + def set_manager(self, manager): + self.manager = manager + self.manager.add_web_socket(self) + + def set_ws(self, ws): + self.ws = ws + + def on_message(self, message: dict): + if message["type"] == "supports_binary": + self.supports_binary = message["value"] + else: + self.manager.handle_json(message) + + def send_json(self, content: dict): + self.ws.send(json.dumps(content)) + + def send_binary(self, content: bytes): + if self.supports_binary: + self.ws.send(content) + else: + data_uri = "data:image/png;base64," + content.decode("utf-8") + self.ws.send(data_uri) + + +matplotlib_figure_managers = {} + + +def add_sock_route(flask_sock: Sock): + @flask_sock.route("/ws-plot/") + def __ws_plot(ws, figure_id: str): + """ + WebSocket route for plot. + + Routes: + /ws-plot: The WebSocket route for plot. + + Parameters: + ws (Any): The WebSocket. + + Returns: + None + """ + if figure_id not in matplotlib_figure_managers: + manager_class = MatplotlibWebsocket( + matplotlib_figure_manager[int(figure_id)], ws + ) + matplotlib_figure_managers[int(figure_id)] = manager_class + else: + manager_class = matplotlib_figure_managers[int(figure_id)] + manager_class.set_ws(ws) + while True: + data = ws.receive() + if data is not None: + dict_data = loads(data) + manager_class.on_message(dict_data) + + +def add_app_route(flask_app: Flask): + @flask_app.route("/plot-download//") + def __ws_plot(figure_id: str, format: str): + """ + Download the plot. + + Routes: + /plot-download: The route to download the plot. + + Parameters: + figure_id (str): The figure id. + format (str): The format of the plot. + + Returns: + None + """ + buffer = BytesIO() + manager = matplotlib_figure_manager[int(figure_id)] + manager.canvas.figure.savefig(buffer, format=format) + buffer.seek(0) + buffer_name = f"plot.{format}" + return Response( + buffer, + mimetype="application/octet-stream", + headers={ + "Content-Disposition": f"attachment; filename={buffer_name}", + "Content-Length": str(len(buffer.getvalue())), + }, + ) + + +add_sock_route(sock) + def funix_auto_cors(response: Response) -> Response: if "HTTP_ORIGIN" not in request.environ: @@ -63,7 +173,7 @@ def get_new_app_and_sock_for_jupyter() -> tuple[Flask, Sock]: SESSION_TYPE="filesystem", ) new_sock = Sock(new_app) - + add_sock_route(new_sock) new_app.after_request(funix_auto_cors) start(new_app) diff --git a/backend/funix/decorator/__init__.py b/backend/funix/decorator/__init__.py index aaa7ee5e..34068031 100644 --- a/backend/funix/decorator/__init__.py +++ b/backend/funix/decorator/__init__.py @@ -9,7 +9,7 @@ from inspect import getsource, isgeneratorfunction, signature from secrets import token_hex from types import ModuleType -from typing import Callable, Optional, ParamSpec, TypeVar, Union +from typing import Callable, Optional, ParamSpec, TypeVar, Union, Literal from uuid import uuid4 from docstring_parser import parse @@ -55,6 +55,7 @@ from funix.hint import ( AcceptableWidgetsList, ArgumentConfigType, + AutoRunType, ConditionalVisibleType, DestinationType, DirectionType, @@ -232,9 +233,9 @@ def funix( rate_limit: RateLimiter = None, reactive: ReactiveType = None, print_to_web: bool = False, - autorun: bool = False, + autorun: AutoRunType = False, disable: bool = False, - figure_to_image: bool = False, + matplotlib_format: Literal["png", "svg", "agg"] = "svg", keep_last: bool = False, app_and_sock: tuple[Flask, Sock] | None = None, jupyter_class: bool = False, @@ -249,7 +250,7 @@ def funix( The heart of Funix, all the beginning of the magic happens here (or at least most of it lol) - See document for more details, the docstring here is just a brief summary + See the document for more details, the docstring here is just a brief summary Parameters: path(str): path to the function, if None, the function name will be used (if title available, use title) @@ -284,7 +285,9 @@ def funix( print_to_web(bool): handle all stdout to web autorun(bool): allow users to use continuity runs on the front end disable(bool): disable this function - figure_to_image(bool): convert matplotlib figure to image + matplotlib_format(MatplotFormatType): Matplotlib format + available formats: "png", "svg", "agg" + for "agg", the image will be rendered in the interact widget, but has issues with the bbox keep_last(bool): keep the last input and output in the frontend app_and_sock(tuple[Flask, Sock]): the Flask app and the Sock instance, if None, use the global app and sock. In jupyter, funix automatically generates the new app and sock. @@ -508,6 +511,11 @@ def _function_reactive_update(): app_.post(f"/update/{function_id}")(_function_reactive_update) app_.post(f"/update/{endpoint}")(_function_reactive_update) + real_autorun = autorun + + if isinstance(autorun, bool): + real_autorun = "disable" if not autorun else "always" + decorated_functions_list_append( app_.name, { @@ -518,7 +526,7 @@ def _function_reactive_update(): "id": function_id, "websocket": need_websocket, "reactive": has_reactive_params, - "autorun": autorun, + "autorun": real_autorun, "keepLast": keep_last, "width": width if width else ["50%", "50%"], "class": is_class_method, @@ -550,7 +558,7 @@ def _function_reactive_update(): param_widget_example_callable = {} cast_to_list_flag, return_type_parsed = parse_function_annotation( - function_signature, figure_to_image + function_signature, matplotlib_format != "agg" ) safe_input_layout = [] if not input_layout else input_layout @@ -789,6 +797,7 @@ def wrapper(ws=None): json_schema_props, print_to_web, secret_key, + matplotlib_format, ws, ) if result is not None: diff --git a/backend/funix/decorator/call.py b/backend/funix/decorator/call.py index b69a96e7..3afbcfac 100644 --- a/backend/funix/decorator/call.py +++ b/backend/funix/decorator/call.py @@ -101,6 +101,7 @@ def funix_call( json_schema_props: dict, print_to_web: bool, secret_key: bool, + matplotlib_format: str, ws=None, ): for limiter in global_rate_limiters + limiters: @@ -173,6 +174,7 @@ def pre_anal_result(frame: Any, function_call_result: Any): function_call_result, return_type_parsed, cast_to_list_flag, + matplotlib_format, ) except: return { diff --git a/backend/funix/decorator/magic.py b/backend/funix/decorator/magic.py index 2ff428c7..18e4dd14 100644 --- a/backend/funix/decorator/magic.py +++ b/backend/funix/decorator/magic.py @@ -10,11 +10,13 @@ However, their logic is complex, with a lot of if-else, no comments and no unit tests, so it is not very good to infer the types of parameters, the types of return values and the rough logic. """ + import ast +import base64 import io import json from importlib import import_module -from inspect import Parameter, Signature, getsource, signature +from inspect import Parameter, Signature, getsource, signature, getfile from re import Match, search from types import ModuleType from typing import Any, Callable @@ -34,6 +36,7 @@ get_function_uuid_with_id, get_class_method_funix, ) +from funix.app import matplotlib_figure_manager __matplotlib_use = False """ @@ -41,10 +44,12 @@ """ try: - # From now on, Funix no longer mandates matplotlib and mpld3 + # From now on, Funix no longer mandates matplotlib import matplotlib - matplotlib.use("Agg") # No display + matplotlib.use("WebAgg") + matplotlib.rcParams["figure.autolayout"] = True + matplotlib.rcParams["savefig.bbox"] = "tight" __matplotlib_use = True except: pass @@ -65,12 +70,6 @@ pass -mpld3: ModuleType | None = None -""" -The mpld3 module. -""" - - def get_type_dict(annotation: any) -> dict: """ Get the type dict of the annotation. @@ -394,45 +393,52 @@ def get_figure(figure) -> dict: dict: The converted figure Raises: - Exception: If matplotlib or mpld3 is not installed + Exception: If matplotlib is not installed """ - global mpld3 - import matplotlib + import matplotlib.pyplot + from matplotlib.backends.backend_webagg import _BackendWebAgg - if __matplotlib_use: - if mpld3 is None: - try: - import matplotlib.pyplot + # matplotlib.pyplot.tight_layout() - mpld3 = import_module("mpld3") - except: - raise Exception("if you use matplotlib, you must install mpld3") + new_fig_manger = _BackendWebAgg.new_figure_manager_given_figure + manager = new_fig_manger(id(figure), figure) - fig = mpld3.fig_to_dict(figure) - matplotlib.pyplot.close() - return fig + matplotlib_figure_manager[id(figure)] = manager + + if __matplotlib_use: + return {"fig": manager.num} else: raise Exception("Install matplotlib to use this function") -def get_figure_image(figure) -> str: +def get_figure_image(figure, format_) -> str: """ Converts a matplotlib figure to a static image for drawing on the frontend Parameters: figure (matplotlib.figure.Figure): The figure to convert + format_ (str): The format of the image, e.g., "svg", "png" Returns: - str: The converted image with static URI + # str: The converted image with static URI + str: The converted image with base64 """ import matplotlib.pyplot matplotlib.pyplot.close() with io.BytesIO() as buf: - figure.savefig(buf, format="png") + figure.savefig(buf, format=format_, bbox_inches="tight") buf.seek(0) - return get_static_uri(buf.getvalue()) + base64_image = base64.b64encode(buf.getvalue()).decode("utf-8") + mime = "" + if format_ == "svg": + mime = "image/svg+xml" + elif format_ == "png": + mime = "image/png" + elif format_ == "jpeg": + mime = "image/jpeg" + return f"data:{mime};base64,{base64_image}" class LambdaVisitor(ast.NodeVisitor): @@ -525,6 +531,7 @@ def anal_function_result( function_call_result: Any, return_type_parsed: Any, cast_to_list_flag: bool, + matplotlib_format: str, ) -> Any: """ Analyze the function result to get the frontend-readable data. @@ -535,6 +542,7 @@ def anal_function_result( function_call_result (Any): The function call result. return_type_parsed (Any): The parsed return type. cast_to_list_flag (bool): Whether to cast the result to list. + matplotlib_format (str): The matplotlib format, used for image conversion. Returns: Any: The frontend-readable data. @@ -545,7 +553,7 @@ def anal_function_result( return [get_figure(call_result)] if return_type_parsed == "FigureImage": - return [get_figure_image(call_result)] + return [get_figure_image(call_result, matplotlib_format)] if return_type_parsed == "Dataframe": return [get_dataframe_json(call_result)] @@ -620,16 +628,18 @@ def anal_function_result( __ipython_display.Image, ), ): - call_result[ - position - ] = handle_ipython_audio_image_video( - call_result[position] + call_result[position] = ( + handle_ipython_audio_image_video( + call_result[position] + ) ) if single_return_type == "Figure": call_result[position] = get_figure(call_result[position]) if single_return_type == "FigureImage": - call_result[position] = get_figure_image(call_result[position]) + call_result[position] = get_figure_image( + call_result[position], matplotlib_format + ) if single_return_type == "Callable": call_result[position] = get_callable_result( @@ -645,16 +655,18 @@ def anal_function_result( if isinstance(call_result[position], list): if __ipython_use: call_result[position] = [ - handle_ipython_audio_image_video(single) - if isinstance( - single, - ( - __ipython_display.Audio, - __ipython_display.Video, - __ipython_display.Image, - ), + ( + handle_ipython_audio_image_video(single) + if isinstance( + single, + ( + __ipython_display.Audio, + __ipython_display.Video, + __ipython_display.Image, + ), + ) + else get_static_uri(single) ) - else get_static_uri(single) for single in call_result[position] ] else: @@ -687,7 +699,7 @@ def anal_function_result( if return_type_parsed == "Figure": call_result = [get_figure(call_result[0])] if return_type_parsed == "FigureImage": - call_result = [get_figure_image(call_result[0])] + call_result = [get_figure_image(call_result[0], matplotlib_format)] if return_type_parsed == "Dataframe": call_result = [get_dataframe_json(call_result[0])] if return_type_parsed == "Callable": @@ -699,16 +711,18 @@ def anal_function_result( if __ipython_use: call_result = [ [ - handle_ipython_audio_image_video(single) - if isinstance( - single, - ( - __ipython_display.Audio, - __ipython_display.Video, - __ipython_display.Image, - ), + ( + handle_ipython_audio_image_video(single) + if isinstance( + single, + ( + __ipython_display.Audio, + __ipython_display.Video, + __ipython_display.Image, + ), + ) + else get_static_uri(single) ) - else get_static_uri(single) for single in call_result[0] ] ] @@ -719,16 +733,18 @@ def anal_function_result( else: if __ipython_use: call_result = [ - handle_ipython_audio_image_video(call_result[0]) - if isinstance( - call_result[0], - ( - __ipython_display.Audio, - __ipython_display.Video, - __ipython_display.Image, - ), + ( + handle_ipython_audio_image_video(call_result[0]) + if isinstance( + call_result[0], + ( + __ipython_display.Audio, + __ipython_display.Video, + __ipython_display.Image, + ), + ) + else get_static_uri(call_result[0]) ) - else get_static_uri(call_result[0]) ] else: call_result = [get_static_uri(call_result[0])] @@ -786,6 +802,13 @@ def parse_function_annotation( full_type_name ] parsed_return_annotation_list.append(return_annotation_type_name) + if figure_to_image: + for i, return_annotation_type_name in enumerate( + parsed_return_annotation_list + ): + if return_annotation_type_name == "Figure": + parsed_return_annotation_list[i] = "FigureImage" + return_type_parsed = parsed_return_annotation_list else: if hasattr(function_signature.return_annotation, "__annotations__"): diff --git a/backend/funix/hint/__init__.py b/backend/funix/hint/__init__.py index bbf98817..55124e92 100644 --- a/backend/funix/hint/__init__.py +++ b/backend/funix/hint/__init__.py @@ -263,6 +263,8 @@ class ConditionalVisible(TypedDict): Document is on the way """ +AutoRunType = bool | Literal["always", "disable", "toggleable"] + ComponentMuiComponents = [ "@mui/material/TextField", "@mui/material/Switch", diff --git a/backend/funix/requirements.txt b/backend/funix/requirements.txt index 6457a660..ab2fd28f 100644 --- a/backend/funix/requirements.txt +++ b/backend/funix/requirements.txt @@ -1,7 +1,6 @@ flask>=2.2.2 functions-framework==3.* requests>=2.28.1 -mpld3>=0.5.8 plac>=1.3.5 gitignore-parser>=0.1.9 flask-sock>=0.7.0 @@ -9,3 +8,4 @@ SQLAlchemy>=2.0.23 docstring_parser>=0.16 matplotlib>=3.4.3 pandas>=2.0.3 +tornado>=6.4.2 diff --git a/frontend/config-overrides.js b/frontend/config-overrides.js index 5c15a1a9..e4200f6b 100644 --- a/frontend/config-overrides.js +++ b/frontend/config-overrides.js @@ -4,14 +4,14 @@ const webpack = require("webpack"); const scripts = process.env.REACT_APP_IN_FUNIX ? ` - - + + ` : ` - - + + `; module.exports = function override(config) { @@ -19,9 +19,9 @@ module.exports = function override(config) { config.plugins.push( new webpack.DefinePlugin({ "process.env.REACT_APP_MUI_PRO_LICENSE_KEY": JSON.stringify( - process.env.MUI_PRO_LICENSE_KEY + process.env.MUI_PRO_LICENSE_KEY, ), - }) + }), ); } @@ -42,26 +42,24 @@ module.exports = function override(config) { config.plugins.push( new SaveRemoteFilePlugin([ { - url: "https://d3js.org/d3.v5.js", - filepath: "static/js/d3.v5.js", + url: "https://code.jquery.com/jquery-3.7.1.min.js", + filepath: "static/js/jquery-3.7.1.min.js", hash: false, }, { - url: "https://mpld3.github.io/js/mpld3.v0.5.8.js", - filepath: "static/js/mpld3.v0.5.8.js", + url: "https://cdn.jsdelivr.net/gh/matplotlib/matplotlib@v3.10.x/lib/matplotlib/backends/web_backend/css/fbm.css", + filepath: "static/css/fbm.css", hash: false, }, { - url: "https://code.jquery.com/jquery-3.7.1.min.js", - filepath: "static/js/jquery-3.7.1.min.js", + url: "https://cdn.jsdelivr.net/gh/matplotlib/matplotlib@v3.10.x/lib/matplotlib/backends/web_backend/css/mpl.css", + filepath: "static/css/mpl.css", hash: false, - } - ]) + }, + ]), ); } - config.plugins.push( - new ProgressBarPlugin(), - ); + config.plugins.push(new ProgressBarPlugin()); return config; }; diff --git a/frontend/public/_images/back.png b/frontend/public/_images/back.png new file mode 100644 index 00000000..e3c4b581 Binary files /dev/null and b/frontend/public/_images/back.png differ diff --git a/frontend/public/_images/back_large.png b/frontend/public/_images/back_large.png new file mode 100644 index 00000000..e44a70a9 Binary files /dev/null and b/frontend/public/_images/back_large.png differ diff --git a/frontend/public/_images/filesave.png b/frontend/public/_images/filesave.png new file mode 100644 index 00000000..919e40bf Binary files /dev/null and b/frontend/public/_images/filesave.png differ diff --git a/frontend/public/_images/filesave_large.png b/frontend/public/_images/filesave_large.png new file mode 100644 index 00000000..a39b55a6 Binary files /dev/null and b/frontend/public/_images/filesave_large.png differ diff --git a/frontend/public/_images/forward.png b/frontend/public/_images/forward.png new file mode 100644 index 00000000..59400feb Binary files /dev/null and b/frontend/public/_images/forward.png differ diff --git a/frontend/public/_images/forward_large.png b/frontend/public/_images/forward_large.png new file mode 100644 index 00000000..de65815b Binary files /dev/null and b/frontend/public/_images/forward_large.png differ diff --git a/frontend/public/_images/hand.png b/frontend/public/_images/hand.png new file mode 100644 index 00000000..d956c5fd Binary files /dev/null and b/frontend/public/_images/hand.png differ diff --git a/frontend/public/_images/help.png b/frontend/public/_images/help.png new file mode 100644 index 00000000..a52fbbe8 Binary files /dev/null and b/frontend/public/_images/help.png differ diff --git a/frontend/public/_images/help_large.png b/frontend/public/_images/help_large.png new file mode 100644 index 00000000..3f3d4dfe Binary files /dev/null and b/frontend/public/_images/help_large.png differ diff --git a/frontend/public/_images/home.png b/frontend/public/_images/home.png new file mode 100644 index 00000000..6e5fdebb Binary files /dev/null and b/frontend/public/_images/home.png differ diff --git a/frontend/public/_images/home_large.png b/frontend/public/_images/home_large.png new file mode 100644 index 00000000..3357bfeb Binary files /dev/null and b/frontend/public/_images/home_large.png differ diff --git a/frontend/public/_images/matplotlib.png b/frontend/public/_images/matplotlib.png new file mode 100644 index 00000000..8eedfa7c Binary files /dev/null and b/frontend/public/_images/matplotlib.png differ diff --git a/frontend/public/_images/matplotlib_large.png b/frontend/public/_images/matplotlib_large.png new file mode 100644 index 00000000..c7dcfe6c Binary files /dev/null and b/frontend/public/_images/matplotlib_large.png differ diff --git a/frontend/public/_images/move.png b/frontend/public/_images/move.png new file mode 100644 index 00000000..4fbbaef4 Binary files /dev/null and b/frontend/public/_images/move.png differ diff --git a/frontend/public/_images/move_large.png b/frontend/public/_images/move_large.png new file mode 100644 index 00000000..96351c11 Binary files /dev/null and b/frontend/public/_images/move_large.png differ diff --git a/frontend/public/_images/qt4_editor_options.png b/frontend/public/_images/qt4_editor_options.png new file mode 100644 index 00000000..792ec812 Binary files /dev/null and b/frontend/public/_images/qt4_editor_options.png differ diff --git a/frontend/public/_images/qt4_editor_options_large.png b/frontend/public/_images/qt4_editor_options_large.png new file mode 100644 index 00000000..46d52c91 Binary files /dev/null and b/frontend/public/_images/qt4_editor_options_large.png differ diff --git a/frontend/public/_images/subplots.png b/frontend/public/_images/subplots.png new file mode 100644 index 00000000..bb0318c4 Binary files /dev/null and b/frontend/public/_images/subplots.png differ diff --git a/frontend/public/_images/subplots_large.png b/frontend/public/_images/subplots_large.png new file mode 100644 index 00000000..4440af17 Binary files /dev/null and b/frontend/public/_images/subplots_large.png differ diff --git a/frontend/public/_images/zoom_to_rect.png b/frontend/public/_images/zoom_to_rect.png new file mode 100644 index 00000000..12afa252 Binary files /dev/null and b/frontend/public/_images/zoom_to_rect.png differ diff --git a/frontend/public/_images/zoom_to_rect_large.png b/frontend/public/_images/zoom_to_rect_large.png new file mode 100644 index 00000000..5963603b Binary files /dev/null and b/frontend/public/_images/zoom_to_rect_large.png differ diff --git a/frontend/public/index.html b/frontend/public/index.html index 502bf6a7..27375224 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -14,13 +14,25 @@ rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" /> + + Funix diff --git a/frontend/public/static/js/mpl.js b/frontend/public/static/js/mpl.js new file mode 100644 index 00000000..14ea2b1b --- /dev/null +++ b/frontend/public/static/js/mpl.js @@ -0,0 +1,698 @@ +/* Put everything inside the global mpl namespace */ +/* global mpl */ +window.mpl = {}; + +mpl.get_websocket_type = function () { + if (typeof WebSocket !== 'undefined') { + return WebSocket; + } else if (typeof MozWebSocket !== 'undefined') { + return MozWebSocket; + } else { + alert( + 'Your browser does not have WebSocket support. ' + + 'Please try Chrome, Safari or Firefox ≥ 6. ' + + 'Firefox 4 and 5 are also supported but you ' + + 'have to enable WebSockets in about:config.' + ); + } +}; + +mpl.figure = function (figure_id, websocket, ondownload, parent_element) { + this.id = figure_id; + + this.ws = websocket; + + this.supports_binary = this.ws.binaryType !== undefined; + + if (!this.supports_binary) { + var warnings = document.getElementById('mpl-warnings'); + if (warnings) { + warnings.style.display = 'block'; + warnings.textContent = + 'This browser does not support binary websocket messages. ' + + 'Performance may be slow.'; + } + } + + this.imageObj = new Image(); + + this.context = undefined; + this.message = undefined; + this.canvas = undefined; + this.rubberband_canvas = undefined; + this.rubberband_context = undefined; + this.format_dropdown = undefined; + + this.image_mode = 'full'; + + this.root = document.createElement('div'); + this.root.setAttribute('style', 'display: inline-block'); + this._root_extra_style(this.root); + + parent_element.appendChild(this.root); + + this._init_header(this); + this._init_canvas(this); + this._init_toolbar(this); + + var fig = this; + + this.waiting = false; + + this.ws.onopen = function () { + fig.send_message('supports_binary', { value: fig.supports_binary }); + fig.send_message('send_image_mode', {}); + if (fig.ratio !== 1) { + fig.send_message('set_device_pixel_ratio', { + device_pixel_ratio: fig.ratio, + }); + } + fig.send_message('refresh', {}); + }; + + this.imageObj.onload = function () { + if (fig.image_mode === 'full') { + // Full images could contain transparency (where diff images + // almost always do), so we need to clear the canvas so that + // there is no ghosting. + fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height); + } + fig.context.drawImage(fig.imageObj, 0, 0); + }; + + this.imageObj.onunload = function () { + fig.ws.close(); + }; + + this.ws.onmessage = this._make_on_message_function(this); + + this.ondownload = ondownload; +}; + +mpl.figure.prototype._init_header = function () { + var titlebar = document.createElement('div'); + titlebar.classList = + 'ui-dialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix'; + var titletext = document.createElement('div'); + titletext.classList = 'ui-dialog-title'; + titletext.setAttribute( + 'style', + 'width: 100%; text-align: center; padding: 3px;' + ); + titlebar.appendChild(titletext); + this.root.appendChild(titlebar); + this.header = titletext; +}; + +mpl.figure.prototype._canvas_extra_style = function (_canvas_div) { }; + +mpl.figure.prototype._root_extra_style = function (_canvas_div) { }; + +mpl.figure.prototype._init_canvas = function () { + var fig = this; + + var canvas_div = (this.canvas_div = document.createElement('div')); + canvas_div.setAttribute('tabindex', '0'); + canvas_div.setAttribute( + 'style', + 'border: 1px solid #ddd;' + + 'box-sizing: content-box;' + + 'clear: both;' + + 'min-height: 1px;' + + 'min-width: 1px;' + + 'outline: 0;' + + 'overflow: hidden;' + + 'position: relative;' + + 'resize: both;' + + 'z-index: 2;' + ); + + function on_keyboard_event_closure(name) { + return function (event) { + return fig.key_event(event, name); + }; + } + + canvas_div.addEventListener( + 'keydown', + on_keyboard_event_closure('key_press') + ); + canvas_div.addEventListener( + 'keyup', + on_keyboard_event_closure('key_release') + ); + + this._canvas_extra_style(canvas_div); + this.root.appendChild(canvas_div); + + var canvas = (this.canvas = document.createElement('canvas')); + canvas.classList.add('mpl-canvas'); + canvas.setAttribute( + 'style', + 'box-sizing: content-box;' + + 'pointer-events: none;' + + 'position: relative;' + + 'z-index: 0;' + ); + + this.context = canvas.getContext('2d'); + + var backingStore = + this.context.backingStorePixelRatio || + this.context.webkitBackingStorePixelRatio || + this.context.mozBackingStorePixelRatio || + this.context.msBackingStorePixelRatio || + this.context.oBackingStorePixelRatio || + this.context.backingStorePixelRatio || + 1; + + this.ratio = (window.devicePixelRatio || 1) / backingStore; + + var rubberband_canvas = (this.rubberband_canvas = document.createElement( + 'canvas' + )); + rubberband_canvas.setAttribute( + 'style', + 'box-sizing: content-box;' + + 'left: 0;' + + 'pointer-events: none;' + + 'position: absolute;' + + 'top: 0;' + + 'z-index: 1;' + ); + + // Apply a ponyfill if ResizeObserver is not implemented by browser. + if (this.ResizeObserver === undefined) { + if (window.ResizeObserver !== undefined) { + this.ResizeObserver = window.ResizeObserver; + } else { + var obs = _JSXTOOLS_RESIZE_OBSERVER({}); + this.ResizeObserver = obs.ResizeObserver; + } + } + + this.resizeObserverInstance = new this.ResizeObserver(function (entries) { + // There's no need to resize if the WebSocket is not connected: + // - If it is still connecting, then we will get an initial resize from + // Python once it connects. + // - If it has disconnected, then resizing will clear the canvas and + // never get anything back to refill it, so better to not resize and + // keep something visible. + if (fig.ws.readyState != 1) { + return; + } + var nentries = entries.length; + for (var i = 0; i < nentries; i++) { + var entry = entries[i]; + var width, height; + if (entry.contentBoxSize) { + if (entry.contentBoxSize instanceof Array) { + // Chrome 84 implements new version of spec. + width = entry.contentBoxSize[0].inlineSize; + height = entry.contentBoxSize[0].blockSize; + } else { + // Firefox implements old version of spec. + width = entry.contentBoxSize.inlineSize; + height = entry.contentBoxSize.blockSize; + } + } else { + // Chrome <84 implements even older version of spec. + width = entry.contentRect.width; + height = entry.contentRect.height; + } + + // Keep the size of the canvas and rubber band canvas in sync with + // the canvas container. + if (entry.devicePixelContentBoxSize) { + // Chrome 84 implements new version of spec. + canvas.setAttribute( + 'width', + entry.devicePixelContentBoxSize[0].inlineSize + ); + canvas.setAttribute( + 'height', + entry.devicePixelContentBoxSize[0].blockSize + ); + } else { + canvas.setAttribute('width', width * fig.ratio); + canvas.setAttribute('height', height * fig.ratio); + } + /* This rescales the canvas back to display pixels, so that it + * appears correct on HiDPI screens. */ + canvas.style.width = width + 'px'; + canvas.style.height = height + 'px'; + + rubberband_canvas.setAttribute('width', width); + rubberband_canvas.setAttribute('height', height); + + // And update the size in Python. We ignore the initial 0/0 size + // that occurs as the element is placed into the DOM, which should + // otherwise not happen due to the minimum size styling. + if (width != 0 && height != 0) { + fig.request_resize(width, height); + } + } + }); + this.resizeObserverInstance.observe(canvas_div); + + function on_mouse_event_closure(name) { + /* User Agent sniffing is bad, but WebKit is busted: + * https://bugs.webkit.org/show_bug.cgi?id=144526 + * https://bugs.webkit.org/show_bug.cgi?id=181818 + * The worst that happens here is that they get an extra browser + * selection when dragging, if this check fails to catch them. + */ + var UA = navigator.userAgent; + var isWebKit = /AppleWebKit/.test(UA) && !/Chrome/.test(UA); + if (isWebKit) { + return function (event) { + /* This prevents the web browser from automatically changing to + * the text insertion cursor when the button is pressed. We + * want to control all of the cursor setting manually through + * the 'cursor' event from matplotlib */ + event.preventDefault() + return fig.mouse_event(event, name); + }; + } else { + return function (event) { + return fig.mouse_event(event, name); + }; + } + } + + canvas_div.addEventListener( + 'mousedown', + on_mouse_event_closure('button_press') + ); + canvas_div.addEventListener( + 'mouseup', + on_mouse_event_closure('button_release') + ); + canvas_div.addEventListener( + 'dblclick', + on_mouse_event_closure('dblclick') + ); + // Throttle sequential mouse events to 1 every 20ms. + canvas_div.addEventListener( + 'mousemove', + on_mouse_event_closure('motion_notify') + ); + + canvas_div.addEventListener( + 'mouseenter', + on_mouse_event_closure('figure_enter') + ); + canvas_div.addEventListener( + 'mouseleave', + on_mouse_event_closure('figure_leave') + ); + + canvas_div.addEventListener('wheel', function (event) { + if (event.deltaY < 0) { + event.step = 1; + } else { + event.step = -1; + } + on_mouse_event_closure('scroll')(event); + }); + + canvas_div.appendChild(canvas); + canvas_div.appendChild(rubberband_canvas); + + this.rubberband_context = rubberband_canvas.getContext('2d'); + this.rubberband_context.strokeStyle = '#000000'; + + this._resize_canvas = function (width, height, forward) { + if (forward) { + canvas_div.style.width = width + 'px'; + canvas_div.style.height = height + 'px'; + } + }; + + // Disable right mouse context menu. + canvas_div.addEventListener('contextmenu', function (_e) { + _e.preventDefault(); + return false; + }); +}; + +mpl.figure.prototype._init_toolbar = function () { + var fig = this; + + var toolbar = document.createElement('div'); + toolbar.classList = 'mpl-toolbar'; + this.root.appendChild(toolbar); + + function on_click_closure(name) { + return function (_event) { + return fig.toolbar_button_onclick(name); + }; + } + + function on_mouseover_closure(tooltip) { + return function (event) { + if (!event.currentTarget.disabled) { + return fig.toolbar_button_onmouseover(tooltip); + } + }; + } + + fig.buttons = {}; + var buttonGroup = document.createElement('div'); + buttonGroup.classList = 'mpl-button-group'; + for (var toolbar_ind in mpl.toolbar_items) { + var name = mpl.toolbar_items[toolbar_ind][0]; + var tooltip = mpl.toolbar_items[toolbar_ind][1]; + var image = mpl.toolbar_items[toolbar_ind][2]; + var method_name = mpl.toolbar_items[toolbar_ind][3]; + + if (!name) { + /* Instead of a spacer, we start a new button group. */ + if (buttonGroup.hasChildNodes()) { + toolbar.appendChild(buttonGroup); + } + buttonGroup = document.createElement('div'); + buttonGroup.classList = 'mpl-button-group'; + continue; + } + + var button = (fig.buttons[name] = document.createElement('button')); + button.classList = 'mpl-widget'; + button.setAttribute('role', 'button'); + button.setAttribute('aria-disabled', 'false'); + button.addEventListener('click', on_click_closure(method_name)); + button.addEventListener('mouseover', on_mouseover_closure(tooltip)); + + var icon_img = document.createElement('img'); + icon_img.src = '_images/' + image + '.png'; + icon_img.srcset = '_images/' + image + '_large.png 2x'; + icon_img.alt = tooltip; + button.appendChild(icon_img); + + buttonGroup.appendChild(button); + } + + if (buttonGroup.hasChildNodes()) { + toolbar.appendChild(buttonGroup); + } + + var fmt_picker = document.createElement('select'); + fmt_picker.classList = 'mpl-widget'; + toolbar.appendChild(fmt_picker); + this.format_dropdown = fmt_picker; + + for (var ind in mpl.extensions) { + var fmt = mpl.extensions[ind]; + var option = document.createElement('option'); + option.selected = fmt === mpl.default_extension; + option.innerHTML = fmt; + fmt_picker.appendChild(option); + } + + var status_bar = document.createElement('span'); + status_bar.classList = 'mpl-message'; + toolbar.appendChild(status_bar); + this.message = status_bar; +}; + +mpl.figure.prototype.request_resize = function (x_pixels, y_pixels) { + // Request matplotlib to resize the figure. Matplotlib will then trigger a resize in the client, + // which will in turn request a refresh of the image. + this.send_message('resize', { width: x_pixels, height: y_pixels }); +}; + +mpl.figure.prototype.send_message = function (type, properties) { + properties['type'] = type; + properties['figure_id'] = this.id; + this.ws.send(JSON.stringify(properties)); +}; + +mpl.figure.prototype.send_draw_message = function () { + if (!this.waiting) { + this.waiting = true; + this.ws.send(JSON.stringify({ type: 'draw', figure_id: this.id })); + } +}; + +mpl.figure.prototype.handle_save = function (fig, _msg) { + var format_dropdown = fig.format_dropdown; + var format = format_dropdown.options[format_dropdown.selectedIndex].value; + fig.ondownload(fig, format); +}; + +mpl.figure.prototype.handle_resize = function (fig, msg) { + var size = msg['size']; + if (size[0] !== fig.canvas.width || size[1] !== fig.canvas.height) { + fig._resize_canvas(size[0], size[1], msg['forward']); + fig.send_message('refresh', {}); + } +}; + +mpl.figure.prototype.handle_rubberband = function (fig, msg) { + var x0 = msg['x0'] / fig.ratio; + var y0 = (fig.canvas.height - msg['y0']) / fig.ratio; + var x1 = msg['x1'] / fig.ratio; + var y1 = (fig.canvas.height - msg['y1']) / fig.ratio; + x0 = Math.floor(x0) + 0.5; + y0 = Math.floor(y0) + 0.5; + x1 = Math.floor(x1) + 0.5; + y1 = Math.floor(y1) + 0.5; + var min_x = Math.min(x0, x1); + var min_y = Math.min(y0, y1); + var width = Math.abs(x1 - x0); + var height = Math.abs(y1 - y0); + + fig.rubberband_context.clearRect( + 0, + 0, + fig.canvas.width / fig.ratio, + fig.canvas.height / fig.ratio + ); + + fig.rubberband_context.strokeRect(min_x, min_y, width, height); +}; + +mpl.figure.prototype.handle_figure_label = function (fig, msg) { + // Updates the figure title. + fig.header.textContent = msg['label']; +}; + +mpl.figure.prototype.handle_cursor = function (fig, msg) { + fig.canvas_div.style.cursor = msg['cursor']; +}; + +mpl.figure.prototype.handle_message = function (fig, msg) { + fig.message.textContent = msg['message']; +}; + +mpl.figure.prototype.handle_draw = function (fig, _msg) { + // Request the server to send over a new figure. + fig.send_draw_message(); +}; + +mpl.figure.prototype.handle_image_mode = function (fig, msg) { + fig.image_mode = msg['mode']; +}; + +mpl.figure.prototype.handle_history_buttons = function (fig, msg) { + for (var key in msg) { + if (!(key in fig.buttons)) { + continue; + } + fig.buttons[key].disabled = !msg[key]; + fig.buttons[key].setAttribute('aria-disabled', !msg[key]); + } +}; + +mpl.figure.prototype.handle_navigate_mode = function (fig, msg) { + if (msg['mode'] === 'PAN') { + fig.buttons['Pan'].classList.add('active'); + fig.buttons['Zoom'].classList.remove('active'); + } else if (msg['mode'] === 'ZOOM') { + fig.buttons['Pan'].classList.remove('active'); + fig.buttons['Zoom'].classList.add('active'); + } else { + fig.buttons['Pan'].classList.remove('active'); + fig.buttons['Zoom'].classList.remove('active'); + } +}; + +mpl.figure.prototype.updated_canvas_event = function () { + // Called whenever the canvas gets updated. + this.send_message('ack', {}); +}; + +// A function to construct a web socket function for onmessage handling. +// Called in the figure constructor. +mpl.figure.prototype._make_on_message_function = function (fig) { + return function socket_on_message(evt) { + if (evt.data instanceof Blob) { + var img = evt.data; + if (img.type !== 'image/png') { + /* FIXME: We get "Resource interpreted as Image but + * transferred with MIME type text/plain:" errors on + * Chrome. But how to set the MIME type? It doesn't seem + * to be part of the websocket stream */ + img.type = 'image/png'; + } + + /* Free the memory for the previous frames */ + if (fig.imageObj.src) { + (window.URL || window.webkitURL).revokeObjectURL( + fig.imageObj.src + ); + } + + fig.imageObj.src = (window.URL || window.webkitURL).createObjectURL( + img + ); + fig.updated_canvas_event(); + fig.waiting = false; + return; + } else if ( + typeof evt.data === 'string' && + evt.data.slice(0, 21) === 'data:image/png;base64' + ) { + fig.imageObj.src = evt.data; + fig.updated_canvas_event(); + fig.waiting = false; + return; + } + + var msg = JSON.parse(evt.data); + var msg_type = msg['type']; + + // Call the "handle_{type}" callback, which takes + // the figure and JSON message as its only arguments. + try { + var callback = fig['handle_' + msg_type]; + } catch (e) { + console.log( + "No handler for the '" + msg_type + "' message type: ", + msg + ); + return; + } + + if (callback) { + try { + // console.log("Handling '" + msg_type + "' message: ", msg); + callback(fig, msg); + } catch (e) { + console.log( + "Exception inside the 'handler_" + msg_type + "' callback:", + e, + e.stack, + msg + ); + } + } + }; +}; + +function getModifiers(event) { + var mods = []; + if (event.ctrlKey) { + mods.push('ctrl'); + } + if (event.altKey) { + mods.push('alt'); + } + if (event.shiftKey) { + mods.push('shift'); + } + if (event.metaKey) { + mods.push('meta'); + } + return mods; +} + +/* + * return a copy of an object with only non-object keys + * we need this to avoid circular references + * https://stackoverflow.com/a/24161582/3208463 + */ +function simpleKeys(original) { + return Object.keys(original).reduce(function (obj, key) { + if (typeof original[key] !== 'object') { + obj[key] = original[key]; + } + return obj; + }, {}); +} + +mpl.figure.prototype.mouse_event = function (event, name) { + if (name === 'button_press') { + this.canvas.focus(); + this.canvas_div.focus(); + } + + // from https://stackoverflow.com/q/1114465 + var boundingRect = this.canvas.getBoundingClientRect(); + var x = (event.clientX - boundingRect.left) * this.ratio; + var y = (event.clientY - boundingRect.top) * this.ratio; + + this.send_message(name, { + x: x, + y: y, + button: event.button, + step: event.step, + buttons: event.buttons, + modifiers: getModifiers(event), + guiEvent: simpleKeys(event), + }); + + return false; +}; + +mpl.figure.prototype._key_event_extra = function (_event, _name) { + // Handle any extra behaviour associated with a key event +}; + +mpl.figure.prototype.key_event = function (event, name) { + // Prevent repeat events + if (name === 'key_press') { + if (event.key === this._key) { + return; + } else { + this._key = event.key; + } + } + if (name === 'key_release') { + this._key = null; + } + + var value = ''; + if (event.ctrlKey && event.key !== 'Control') { + value += 'ctrl+'; + } + else if (event.altKey && event.key !== 'Alt') { + value += 'alt+'; + } + else if (event.shiftKey && event.key !== 'Shift') { + value += 'shift+'; + } + + value += 'k' + event.key; + + this._key_event_extra(event, name); + + this.send_message(name, { key: value, guiEvent: simpleKeys(event) }); + return false; +}; + +mpl.figure.prototype.toolbar_button_onclick = function (name) { + if (name === 'download') { + this.handle_save(this, null); + } else { + this.send_message('toolbar_button', { name: name }); + } +}; + +mpl.figure.prototype.toolbar_button_onmouseover = function (tooltip) { + this.message.textContent = tooltip; +}; + +///////////////// REMAINING CONTENT GENERATED BY embed_js.py ///////////////// +// prettier-ignore +var _JSXTOOLS_RESIZE_OBSERVER = function (A) { var t, i = new WeakMap, n = new WeakMap, a = new WeakMap, r = new WeakMap, o = new Set; function s(e) { if (!(this instanceof s)) throw new TypeError("Constructor requires 'new' operator"); i.set(this, e) } function h() { throw new TypeError("Function is not a constructor") } function c(e, t, i, n) { e = 0 in arguments ? Number(arguments[0]) : 0, t = 1 in arguments ? Number(arguments[1]) : 0, i = 2 in arguments ? Number(arguments[2]) : 0, n = 3 in arguments ? Number(arguments[3]) : 0, this.right = (this.x = this.left = e) + (this.width = i), this.bottom = (this.y = this.top = t) + (this.height = n), Object.freeze(this) } function d() { t = requestAnimationFrame(d); var s = new WeakMap, p = new Set; o.forEach((function (t) { r.get(t).forEach((function (i) { var r = t instanceof window.SVGElement, o = a.get(t), d = r ? 0 : parseFloat(o.paddingTop), f = r ? 0 : parseFloat(o.paddingRight), l = r ? 0 : parseFloat(o.paddingBottom), u = r ? 0 : parseFloat(o.paddingLeft), g = r ? 0 : parseFloat(o.borderTopWidth), m = r ? 0 : parseFloat(o.borderRightWidth), w = r ? 0 : parseFloat(o.borderBottomWidth), b = u + f, F = d + l, v = (r ? 0 : parseFloat(o.borderLeftWidth)) + m, W = g + w, y = r ? 0 : t.offsetHeight - W - t.clientHeight, E = r ? 0 : t.offsetWidth - v - t.clientWidth, R = b + v, z = F + W, M = r ? t.width : parseFloat(o.width) - R - E, O = r ? t.height : parseFloat(o.height) - z - y; if (n.has(t)) { var k = n.get(t); if (k[0] === M && k[1] === O) return } n.set(t, [M, O]); var S = Object.create(h.prototype); S.target = t, S.contentRect = new c(u, d, M, O), s.has(i) || (s.set(i, []), p.add(i)), s.get(i).push(S) })) })), p.forEach((function (e) { i.get(e).call(e, s.get(e), e) })) } return s.prototype.observe = function (i) { if (i instanceof window.Element) { r.has(i) || (r.set(i, new Set), o.add(i), a.set(i, window.getComputedStyle(i))); var n = r.get(i); n.has(this) || n.add(this), cancelAnimationFrame(t), t = requestAnimationFrame(d) } }, s.prototype.unobserve = function (i) { if (i instanceof window.Element && r.has(i)) { var n = r.get(i); n.has(this) && (n.delete(this), n.size || (r.delete(i), o.delete(i))), n.size || r.delete(i), o.size || cancelAnimationFrame(t) } }, A.DOMRectReadOnly = c, A.ResizeObserver = s, A.ResizeObserverEntry = h, A }; // eslint-disable-line diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2585ff47..a8e4f63a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -391,7 +391,7 @@ const App = () => { onClick={() => { logLevel === 1 ? setCookie("DO_NOT_LOG_ME", "YES") - : (window.location.href = "https://funix.io"); + : (window.location.href = "http://funix.io"); localStorage.setItem("privacy-hash", lastPrivacyHash); setCookie("first-join", "false", { expires: 365 * 10 }); setPrivacy(false); @@ -787,11 +787,11 @@ const App = () => { records={{ org: [ "Powered by ", - Funix.io, - ", minimally building apps in Python", + Funix.io, + ", the laziest way to build apps in Python - lazier than Streamlit or Gradio.", ], year: new Date().getFullYear().toString(), - funixLink: Funix.io, + funixLink: Funix.io, }} /> diff --git a/frontend/src/components/FunixFunction/InputPanel.tsx b/frontend/src/components/FunixFunction/InputPanel.tsx index a3a067fa..ef0786e0 100644 --- a/frontend/src/components/FunixFunction/InputPanel.tsx +++ b/frontend/src/components/FunixFunction/InputPanel.tsx @@ -26,7 +26,6 @@ import _ from "lodash"; import { Form } from "@rjsf/mui"; import validator from "@rjsf/validator-ajv8"; import { RJSFSchema } from "@rjsf/utils"; -import TemplateString from "../Common/TemplateString"; const InputPanel = (props: { detail: FunctionDetail; @@ -58,7 +57,11 @@ const InputPanel = (props: { const [tempOutput, setTempOutput] = useState(null); const tempOutputRef = React.useRef(null); - const [autoRun, setAutoRun] = useState(props.preview.autorun); + const [autoRun, setAutoRun] = useState( + props.preview.autorun === "always" || props.preview.autorun === "toggleable" + ? true + : false, + ); const lock = useRef(false); const isLarge = @@ -159,7 +162,11 @@ const InputPanel = (props: { }, 100)(); } - if (props.preview.autorun && autoRun) { + if ( + (props.preview.autorun === "always" || + props.preview.autorun === "toggleable") && + autoRun + ) { _.debounce(() => { handleSubmitWithoutHistory(formData).then(); }, 100)(); @@ -386,33 +393,36 @@ const InputPanel = (props: { alignItems="center" > - { - setAutoRun(() => event.target.checked); - }} - disabled={!props.preview.autorun} - defaultChecked={props.preview.autorun} - /> - } - label={theme?.funix_autorun_label || "Auto-run"} - /> + {props.preview.autorun === "toggleable" && ( + { + setAutoRun(() => event.target.checked); + }} + defaultChecked={true} + /> + } + label={theme?.funix_autorun_label || "Auto-run"} + /> + )} - + {props.preview.autorun !== "always" && ( + + )} diff --git a/frontend/src/components/FunixFunction/ObjectFieldExtendedTemplate.tsx b/frontend/src/components/FunixFunction/ObjectFieldExtendedTemplate.tsx index 23ec078c..a224668c 100644 --- a/frontend/src/components/FunixFunction/ObjectFieldExtendedTemplate.tsx +++ b/frontend/src/components/FunixFunction/ObjectFieldExtendedTemplate.tsx @@ -594,7 +594,7 @@ const ObjectFieldExtendedTemplate = (props: ObjectFieldTemplateProps) => { if (rowElement) { rowElement = ( {rowElement} diff --git a/frontend/src/components/FunixFunction/OutputComponents/OutputMedias.tsx b/frontend/src/components/FunixFunction/OutputComponents/OutputMedias.tsx index 5fa67d1a..dc30b2c0 100644 --- a/frontend/src/components/FunixFunction/OutputComponents/OutputMedias.tsx +++ b/frontend/src/components/FunixFunction/OutputComponents/OutputMedias.tsx @@ -44,7 +44,6 @@ export default function OutputMedias(props: { height: "auto", maxWidth: "100%", maxHeight: "100%", - minWidth: "65%", }; return ( @@ -56,14 +55,7 @@ export default function OutputMedias(props: { {isPDF ? ( ) : ( - + )} ); diff --git a/frontend/src/components/FunixFunction/OutputComponents/OutputPlot.tsx b/frontend/src/components/FunixFunction/OutputComponents/OutputPlot.tsx index 63a24343..c03e9b82 100644 --- a/frontend/src/components/FunixFunction/OutputComponents/OutputPlot.tsx +++ b/frontend/src/components/FunixFunction/OutputComponents/OutputPlot.tsx @@ -1,24 +1,16 @@ -import { useLayoutEffect, useRef } from "react"; import { Box } from "@mui/material"; +import { useRef } from "react"; + +type PlotCode = { + fig: number | string; +}; export default function OutputPlot(props: { - plotCode: string; + plotCode: PlotCode; indexId: string; + backend: URL; }) { - const drawLock = useRef(false); - - useLayoutEffect(() => { - if (drawLock.current) { - return; - } - if (document.querySelector(`#plot-${props.indexId}`)?.innerHTML === "") { - const plot = JSON.parse(props.plotCode); - // @ts-expect-error: i got mpld3 here - mpld3.draw_figure(`plot-${props.indexId}`, plot); - drawLock.current = true; - } - }, []); - + const lock = useRef(false); return ( -
+
{ + if (ref) { + if (lock.current) { + return; + } + lock.current = true; + const websocket = + (props.backend.protocol === "https:" ? "wss" : "ws") + + "://" + + props.backend.host + + "/ws-plot/" + + props.plotCode.fig; + + // @ts-expect-error that's good here + new mpl.figure( + props.plotCode.fig, + new WebSocket(websocket), + (figure: any, format: string) => { + window.open( + new URL( + `/plot-download/${props.plotCode.fig}/${format}`, + props.backend, + ), + ); + }, + ref, + ); + } + }} + /> ); } diff --git a/frontend/src/components/FunixFunction/OutputComponents/OutputPlotImage.tsx b/frontend/src/components/FunixFunction/OutputComponents/OutputPlotImage.tsx new file mode 100644 index 00000000..ce2af770 --- /dev/null +++ b/frontend/src/components/FunixFunction/OutputComponents/OutputPlotImage.tsx @@ -0,0 +1,21 @@ +import { Card, CardMedia } from "@mui/material"; + +export default function OutputPlotMedias(props: { media: string }) { + return ( + + + + ); +} diff --git a/frontend/src/components/FunixFunction/OutputPanel.tsx b/frontend/src/components/FunixFunction/OutputPanel.tsx index c0accb65..088b7d50 100644 --- a/frontend/src/components/FunixFunction/OutputPanel.tsx +++ b/frontend/src/components/FunixFunction/OutputPanel.tsx @@ -34,6 +34,7 @@ import { DataGrid } from "../../Key"; import OutputDataframe from "./OutputComponents/OutputDataframe"; import InnerHTML from "dangerously-set-html-content"; import { useNavigate } from "react-router-dom"; +import OutputPlotMedias from "./OutputComponents/OutputPlotImage"; const guessJSON = (response: string | null): object | false => { if (response === null) return false; @@ -262,16 +263,21 @@ const OutputPanel = (props: { case "Figure": return ( ); case "Dataframe": return ( ); case "string": @@ -292,10 +298,11 @@ const OutputPanel = (props: { return ; case "HTML": return ; + case "FigureImage": + return ; case "Images": case "Videos": case "Audios": - case "FigureImage": return ( {item.content ?? ""}; } rowElements.push( - + {itemElement} , ); diff --git a/frontend/src/shared/index.ts b/frontend/src/shared/index.ts index 15417249..a8bdc769 100644 --- a/frontend/src/shared/index.ts +++ b/frontend/src/shared/index.ts @@ -81,7 +81,7 @@ export type FunctionPreview = { /** * autorun */ - autorun: boolean; + autorun: "always" | "disable" | "toggleable"; /** * keep last history */ diff --git a/pyproject.toml b/pyproject.toml index 2f7c94fe..a02128fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "funix" -version = "0.6.1" +version = "0.6.2" authors = [ {name = "Textea Inc.", email = "forrestbao@gmail.com"} ] @@ -26,9 +26,9 @@ dependencies = [ "flask-sock>=0.7.0", "SQLAlchemy>=2.0.23", "matplotlib>=3.4.3", - "mpld3>=0.5.8", "pandas>=2.0.3", "docstring_parser>=0.16", + "tornado>=6.4.2" ] [project.optional-dependencies]