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)
+ })
+ });
+})