diff --git a/changelog.txt b/changelog.txt index 99d0b9f7..dfda5ef2 100644 --- a/changelog.txt +++ b/changelog.txt @@ -49,6 +49,7 @@ Added - Improve security on multi-user systems. Dashboard now generates a login token when started. Users must login with the token to view project and job data in the dashboard (#122, #158). +- PlotlyViewer module for showing interactive plots in the dashboard (#162, #163). Changed +++++++ diff --git a/doc/api.rst b/doc/api.rst index d203cb1d..d2314ab5 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -41,6 +41,7 @@ Dashboard Modules modules.FlowStatus modules.ImageViewer modules.Notes + modules.PlotlyViewer modules.Schema modules.StatepointList modules.TextDisplay diff --git a/examples/plots/dashboard.py b/examples/plots/dashboard.py old mode 100755 new mode 100644 index 4a5e5b64..5d811168 --- a/examples/plots/dashboard.py +++ b/examples/plots/dashboard.py @@ -3,7 +3,12 @@ # All rights reserved. # This software is licensed under the BSD 3-Clause License. from signac_dashboard import Dashboard -from signac_dashboard.modules import ImageViewer, StatepointList, TextDisplay +from signac_dashboard.modules import ( + ImageViewer, + PlotlyViewer, + StatepointList, + TextDisplay, +) class PlotDashboard(Dashboard): @@ -18,9 +23,59 @@ def correlation_text(job): return "Correlation coefficient: {:.5f}".format(job.doc["correlation"]) +# Visualization adapted from: +# https://matplotlib.org/gallery/lines_bars_and_markers/cohere.html + +# It's necessary to cast to list because the list elements of the job +# document are BufferedJSONAttrList, which is not serializable + + +def signals_plotly_args(job): + signals_traces = [ + { + "x": list(job.doc["t"]), + "y": list(job.doc["s1"]), + "name": "s1", + }, + { + "x": list(job.doc["t"]), + "y": list(job.doc["s2"]), + "name": "s2", + }, + ] + signals_layout = { + "xaxis": { + "title": "time", + "range": [0, 2], + }, + "height": 200, + "margin": dict(t=30, b=40, l=40, r=0), + } + return (signals_traces, signals_layout) + + +def coherence_plotly_args(job): + coherence_traces = [ + { + "x": list(job.doc["f"]), + "y": list(job.doc["cxy"]), + } + ] + coherence_layout = { + "title": f"Coherence time = {job.sp.coherence_time}", + "xaxis": {"title": "frequency"}, + "yaxis": {"title": "coherence", "range": [0, 1]}, + "height": 200, + "margin": dict(t=30, b=40, l=40, r=0), + } + return (coherence_traces, coherence_layout) + + if __name__ == "__main__": modules = [] modules.append(StatepointList()) modules.append(ImageViewer()) + modules.append(PlotlyViewer("Signals", plotly_args=signals_plotly_args)) + modules.append(PlotlyViewer("Coherence", plotly_args=coherence_plotly_args)) modules.append(TextDisplay(name="Correlation", message=correlation_text)) PlotDashboard(modules=modules).main() diff --git a/examples/plots/init.py b/examples/plots/init.py old mode 100755 new mode 100644 index 919f3cb9..b70fe8e3 --- a/examples/plots/init.py +++ b/examples/plots/init.py @@ -33,6 +33,7 @@ def plot_coherence(job): # Save correlation coefficient job.doc["correlation"] = np.corrcoef(s1, s2)[0, 1] + # Image plot needs to be saved to the job folder for later visualization fig, axs = plt.subplots(2, 1) plt.title(f"Coherence time = {job.sp.coherence_time}") @@ -50,6 +51,16 @@ def plot_coherence(job): plt.savefig(job.fn("coherence.png")) plt.close() + # Save the signal and coherence data for the Plotly visualization. + # This could also be computed while the dashboard is running when + # it is requested. + job.doc["t"] = t.tolist() + job.doc["s1"] = s1.tolist() + job.doc["s2"] = s2.tolist() + job.doc["cxy"] = cxy.tolist() + job.doc["f"] = f.tolist() + job.doc["cxy"] = cxy.tolist() + for i in range(30): job = project.open_job({"coherence_time": i, "seed": 42}) diff --git a/signac_dashboard/modules/__init__.py b/signac_dashboard/modules/__init__.py index cc714fde..fd13fdf0 100644 --- a/signac_dashboard/modules/__init__.py +++ b/signac_dashboard/modules/__init__.py @@ -5,6 +5,7 @@ from .image_viewer import ImageViewer from .navigator import Navigator from .notes import Notes +from .plotly_viewer import PlotlyViewer from .schema import Schema from .statepoint_list import StatepointList from .text_display import TextDisplay @@ -18,6 +19,7 @@ "ImageViewer", "Navigator", "Notes", + "PlotlyViewer", "Schema", "StatepointList", "TextDisplay", diff --git a/signac_dashboard/modules/plotly_viewer.py b/signac_dashboard/modules/plotly_viewer.py new file mode 100644 index 00000000..f4dfa460 --- /dev/null +++ b/signac_dashboard/modules/plotly_viewer.py @@ -0,0 +1,140 @@ +# Copyright (c) 2022 The Regents of the University of Michigan +# All rights reserved. +# This software is licensed under the BSD 3-Clause License. +import hashlib +from typing import Callable, Dict, List, Tuple, Union + +import flask_login +from flask import abort, jsonify, render_template, request +from flask.views import View +from jinja2.exceptions import TemplateNotFound +from signac import Project +from signac.job import Job + +from signac_dashboard.dashboard import Dashboard +from signac_dashboard.module import Module + + +class PlotlyView(View): + decorators = [flask_login.login_required] + + def __init__(self, dashboard, args_function, context): + self.dashboard = dashboard + self.args_function = args_function + self.context = context + + def dispatch_request(self): + if self.context == "JobContext": + jobid = request.args.get("jobid") + job = self.dashboard.project.open_job(id=jobid) + traces, layout = self.args_function(job) + elif self.context == "ProjectContext": + traces, layout = self.args_function(self.dashboard.project) + else: + raise NotImplementedError() + return jsonify({"traces": traces, "layout": layout}) + + +class PlotlyViewer(Module): + """Displays a plot associated with the job. + + The PlotlyViewer module can display an interactive plot by using the + Plotly JavaScript library. For information on the different accepted + parameters for the data and layout, refer to the `Plotly JS documentation + `_. + + Example: + + .. code-block:: python + + from signac_dashboard.modules import PlotlyViewer + + def plotly_args_function(project): + return [ + (# each element on the data list is a different trace + [{ + "x": [1, 2, 3, 4, 5], # x coordinates of the trace + "y": [1, 2, 4, 8, 16] # y coordinates of the trace + }], + {"margin": {"t": 0}} # layout specification for the whole plot + ) + ] + + plot_module = PlotlyViewer(plotly_args=plotly_args_function, context="ProjectContext") + + :param name: Default name for the card. Ignored if the :code:`plotly_args` + callable provides one for each card. + :type name: str + :param plotly_args: A callable that accepts a job (in the :code:`'JobContext'`) + or a project (in the :code:`'ProjectContext'`) and returns a tuple of two + elements: the plotly data and the plotly layout specification, respectively. + :type plotly_args: callable + :param context: Supports :code:`'JobContext'` and :code:`'ProjectContext'`. + :type context: str + """ + + _supported_contexts = {"JobContext", "ProjectContext"} + _assets_url_registered = False + + def __init__( + self, + name="Plotly Viewer", + plotly_args: Callable[ + [Union[Job, Project]], Tuple[List[Dict], Dict] + ] = lambda _: ([{}], {}), + context="JobContext", + template="cards/plotly_viewer.html", + **kwargs, + ): + super().__init__( + name=name, + context=context, + template=template, + **kwargs, + ) + self.plotly_args = plotly_args + self.card_id = hashlib.sha1(str(id(self)).encode("utf-8")).hexdigest() + + def get_cards(self, job_or_project): + return [ + { + "name": self.name, + "content": render_template( + self.template, + jobid=job_or_project.id, # will not work for project anymore + endpoint=self.arguments_endpoint(), + ), + } + ] + + def register(self, dashboard: Dashboard): + # Register routes + if not PlotlyViewer._assets_url_registered: + + @dashboard.app.route("/module/plotly_viewer/") + @flask_login.login_required + def plotly_viewer_asset(filename): + try: + return render_template(f"plotly_viewer/{filename}") + except TemplateNotFound: + abort(404, "The file requested does not exist.") + + # Register assets + assets = [ + "/module/plotly_viewer/js/plotly_viewer.js", + "https://cdn.plot.ly/plotly-2.16.1.min.js", + ] + for asseturl in assets: + dashboard.register_module_asset({"url": asseturl}) + + PlotlyViewer._assets_url_registered = True + + dashboard.app.add_url_rule( + self.arguments_endpoint(), + view_func=PlotlyView.as_view( + f"plotly-{self.card_id}", dashboard, self.plotly_args, self.context + ), + ) + + def arguments_endpoint(self): + return f"/module/plotly_viewer/{self.card_id}/arguments" diff --git a/signac_dashboard/templates/cards/plotly_viewer.html b/signac_dashboard/templates/cards/plotly_viewer.html new file mode 100644 index 00000000..95aefa48 --- /dev/null +++ b/signac_dashboard/templates/cards/plotly_viewer.html @@ -0,0 +1,4 @@ +
+
diff --git a/signac_dashboard/templates/plotly_viewer/js/plotly_viewer.js b/signac_dashboard/templates/plotly_viewer/js/plotly_viewer.js new file mode 100644 index 00000000..a97d2d30 --- /dev/null +++ b/signac_dashboard/templates/plotly_viewer/js/plotly_viewer.js @@ -0,0 +1,11 @@ +$(document).on('turbolinks:load', function() { + $('.plotly_viewer').each((index, element) => { + let endpoint = element.getAttribute("data-endpoint") + let jobid = element.getAttribute("data-jobid") + jQuery.get(endpoint, {jobid: jobid}, (data, textStatus, response) => { + let traces = data["traces"] + let layout = data["layout"] + Plotly.newPlot(element, traces, layout) + }) + }); +})