Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add interactive plots with Plotly #163

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
+++++++
Expand Down
1 change: 1 addition & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Dashboard Modules
modules.FlowStatus
modules.ImageViewer
modules.Notes
modules.PlotlyViewer
modules.Schema
modules.StatepointList
modules.TextDisplay
Expand Down
57 changes: 56 additions & 1 deletion examples/plots/dashboard.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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()
11 changes: 11 additions & 0 deletions examples/plots/init.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand All @@ -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})
Expand Down
2 changes: 2 additions & 0 deletions signac_dashboard/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@
"ImageViewer",
"Navigator",
"Notes",
"PlotlyViewer",
"Schema",
"StatepointList",
"TextDisplay",
Expand Down
140 changes: 140 additions & 0 deletions signac_dashboard/modules/plotly_viewer.py
Original file line number Diff line number Diff line change
@@ -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":
joaander marked this conversation as resolved.
Show resolved Hide resolved
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
<https://plotly.com/javascript/>`_.

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/<path:filename>")
@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"
4 changes: 4 additions & 0 deletions signac_dashboard/templates/cards/plotly_viewer.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div class="plotly_viewer"
data-endpoint="{{ endpoint }}"
data-jobid="{{ jobid }}">
</div>
11 changes: 11 additions & 0 deletions signac_dashboard/templates/plotly_viewer/js/plotly_viewer.js
Original file line number Diff line number Diff line change
@@ -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)
})
});
})