diff --git a/environment.worker.yml b/environment.worker.yml index e17c6110d..9dc20cef9 100644 --- a/environment.worker.yml +++ b/environment.worker.yml @@ -5,3 +5,4 @@ dependencies: - pillow - matplotlib==3.2.2 - simplejson + - plotly diff --git a/src/common/updates/Updates.js b/src/common/updates/Updates.js index 6988ae4f8..bb59a8dc1 100644 --- a/src/common/updates/Updates.js +++ b/src/common/updates/Updates.js @@ -1,13 +1,51 @@ /* globals define */ define([ 'deepforge/storage/index', + 'deepforge/viz/PlotlyDescExtractor', + 'deepforge/viz/FigureExtractor', './Version', 'q' ], function( Storage, + PlotlyDescExtractor, + FigureExtractor, Version, Q ) { + const GRAPH = 'Graph'; + const getGraphNodes = async function(core, rootNode, graphNodes=[]) { + const children = await core.loadChildren(rootNode); + for(let i = 0; i < children.length; i++) { + if (core.getAttribute(children[i], 'name') === GRAPH && !core.isMetaNode(children[i])) { + graphNodes.push(children[i]); + } + await getGraphNodes(core, children[i], graphNodes); + } + }; + + const addMetadataMixinToNodeSubTree = async function(core, META, node) { + const METADATA_NODE_PATH = core.getPath(META['pipeline.Metadata']); + const IMPLICIT_OPERATION_NODE = META['pipeline.ImplicitOperation']; + const graphNodeChildren = (await core.loadSubTree(node)) + .filter(node => { + return IMPLICIT_OPERATION_NODE ? + !core.isTypeOf(node, IMPLICIT_OPERATION_NODE) : + true; + }); + + graphNodeChildren.forEach(node => { + core.addMixin(node, METADATA_NODE_PATH); + }); + }; + + const getPipelineLibraryVersion = function(core, rootNode) { + const pipelineRoot = core.getLibraryRoot(rootNode, 'pipeline'); + const hasPipelineLibrary = !!pipelineRoot; + if (hasPipelineLibrary) { + const versionString = core.getAttribute(pipelineRoot, 'version'); + return new Version(versionString); + } + }; const allUpdates = [ { @@ -48,12 +86,9 @@ define([ { name: 'UpdateDataNodesToUserAssets', isNeeded: async function(core, rootNode) { - const pipelineRoot = core.getLibraryRoot(rootNode, 'pipeline'); - const hasPipelineLibrary = !!pipelineRoot; - if (hasPipelineLibrary) { - const versionString = core.getAttribute(pipelineRoot, 'version'); - const version = new Version(versionString); - return version.lessThan(new Version('0.13.0')); + const pipelineLibraryVersion = getPipelineLibraryVersion(core, rootNode); + if(pipelineLibraryVersion) { + return pipelineLibraryVersion.lessThan(new Version('0.13.0')); } }, apply: async function(core, rootNode, META) { @@ -72,6 +107,42 @@ define([ } } } + }, + { + name: 'UpdateGraphContainment', + beforeLibraryUpdates: true, + isNeeded: async function(core, rootNode) { + const pipelineLibraryVersion = getPipelineLibraryVersion(core, rootNode); + if (pipelineLibraryVersion) { + return pipelineLibraryVersion.lessThan(new Version('0.22.0')) && + pipelineLibraryVersion.greaterThan(new Version('0.19.1')); + } + }, + apply: async function(core, rootNode, META) { + let graphNodes = []; + await getGraphNodes(core, rootNode, graphNodes); + const coreFigureExtractor = new FigureExtractor.CoreFigureExtractor(core, rootNode); + const pipelineVersion = getPipelineLibraryVersion(core, rootNode); + const shouldAddMetadataMixin = pipelineVersion ? + pipelineVersion.lessThan(new Version('0.21.1')) : + false; + + for (let i = 0; i < graphNodes.length; i++){ + const graphNode = graphNodes[i]; + if(shouldAddMetadataMixin){ + await addMetadataMixinToNodeSubTree(core, META, graphNode); + } + const desc = await coreFigureExtractor.extract(graphNode); + const plotlyJSON = PlotlyDescExtractor.descToPlotlyJSON(desc); + const parentNode = core.getParent(graphNode); + const updatedGraphNode = core.createNode({ + parent: parentNode, + base: META['pipeline.Graph'] + }); + core.setAttribute(updatedGraphNode, 'data', JSON.stringify(plotlyJSON)); + core.deleteNode(graphNode); + } + } } ]; diff --git a/src/visualizers/widgets/PlotlyGraph/PlotlyDescExtractor.js b/src/common/viz/PlotlyDescExtractor.js similarity index 100% rename from src/visualizers/widgets/PlotlyGraph/PlotlyDescExtractor.js rename to src/common/viz/PlotlyDescExtractor.js diff --git a/src/plugins/ExecuteJob/metadata/Figure.js b/src/plugins/ExecuteJob/metadata/Figure.js index 0ee8b425d..98ff6651e 100644 --- a/src/plugins/ExecuteJob/metadata/Figure.js +++ b/src/plugins/ExecuteJob/metadata/Figure.js @@ -1,26 +1,12 @@ /* globals define */ define([ - './Metadata', + './Metadata' ], function( - Metadata, + Metadata ) { class Figure extends Metadata { async update(state) { - this.core.setAttribute(this.node, 'title', state.title); - await this.clearSubGraphs(); - - state.axes.forEach(axes => { - const axesNode = this.core.createNode({ - parent: this.node, - base: axes.is3D ? this.META.Plot3D : this.META.Plot2D - }); - this.setAxesProperties(axesNode, axes); - this.addAxesLines(axesNode, this.node, axes); - if(!axes.is3D){ - this.addAxesImage(axesNode, this.node, axes); - } - this.addAxesScatterPoints(axesNode, this.node, axes); - }); + this.core.setAttribute(this.node, 'data', JSON.stringify(state)); } setAxesProperties(axesNode, axes){ diff --git a/src/plugins/GenerateJob/GenerateJob.js b/src/plugins/GenerateJob/GenerateJob.js index 50aa0eb5a..ab0275168 100644 --- a/src/plugins/GenerateJob/GenerateJob.js +++ b/src/plugins/GenerateJob/GenerateJob.js @@ -103,7 +103,7 @@ define([ this.result.setSuccess(true); this.result.addArtifact(hash); callback(null, this.result); - }; + }; GenerateJob.prototype.createRunScript = async function (files) { let runDebug = Templates.RUN_DEBUG; @@ -291,7 +291,7 @@ define([ files.addFile('config.json', JSON.stringify(configs)); files.addFile('start.js', Templates.START); files.addFile('utils.build.js', Templates.UTILS); - files.addFile('backend_deepforge.py', Templates.MATPLOTLIB_BACKEND); + files.addFile('plotly_backend.py', Templates.MATPLOTLIB_BACKEND); inputs.forEach(pair => { const dataInfo = this.core.getAttribute(pair[2], 'data'); diff --git a/src/plugins/GenerateJob/templates/backend_deepforge.py b/src/plugins/GenerateJob/templates/backend_deepforge.py index 8b4fa13a6..d2a630aab 100644 --- a/src/plugins/GenerateJob/templates/backend_deepforge.py +++ b/src/plugins/GenerateJob/templates/backend_deepforge.py @@ -354,9 +354,9 @@ def draw(self): """ Draw the figure using the renderer """ - self.send_deepforge_update() renderer = RendererTemplate(self.figure.dpi) self.figure.draw(renderer) + self.send_deepforge_update() def send_deepforge_update(self): state = self.figure_to_state() diff --git a/src/plugins/GenerateJob/templates/index.js b/src/plugins/GenerateJob/templates/index.js index 7e3aea1a3..42ee64bbb 100644 --- a/src/plugins/GenerateJob/templates/index.js +++ b/src/plugins/GenerateJob/templates/index.js @@ -4,7 +4,7 @@ define([ 'text!./run-debug.js', 'text!./main.ejs', 'text!./deepforge.ejs', - 'text!./backend_deepforge.py', + 'text!./plotly_backend.py', 'text!./deepforge__init__.py', 'text!./serialize.ejs', 'text!./deserialize.ejs', diff --git a/src/plugins/GenerateJob/templates/plotly_backend.py b/src/plugins/GenerateJob/templates/plotly_backend.py new file mode 100644 index 000000000..0c4d8c03e --- /dev/null +++ b/src/plugins/GenerateJob/templates/plotly_backend.py @@ -0,0 +1,742 @@ +""" +Plotly backend for matplotlib +""" +from __future__ import (absolute_import, division, print_function, + unicode_literals) +import six +import io +import base64 +import math + +import numpy as np +import numpy.ma as ma + +import matplotlib +from matplotlib._pylab_helpers import Gcf +from matplotlib.backend_bases import ( + FigureCanvasBase, FigureManagerBase, GraphicsContextBase, RendererBase +) +import matplotlib.pyplot as plt +from matplotlib.figure import Figure +from matplotlib import transforms, collections +from matplotlib import ticker +from matplotlib.path import Path +from matplotlib.patches import PathPatch +from mpl_toolkits.mplot3d.axes3d import Axes3D +from mpl_toolkits.mplot3d.axis3d import ZAxis +from mpl_toolkits.mplot3d.art3d import Path3DCollection, Line3D + +from PIL import Image + +import plotly.graph_objects as go +from plotly.matplotlylib import mplexporter, PlotlyRenderer +from plotly.matplotlylib import mpltools + + +PLOTLY_3D_MARKER_SYMBOLS = ( + 'square', + 'square-open', + 'diamond', + 'circle-open', + 'circle', + 'cross', + 'cross-open', + 'x' +) + + +def get_z_axes_properties(ax): + """Parse figure z-axes parameter""" + props = {} + axis = ax.zaxis + domain = ax.get_zlim() + axname = 'z' + lim = domain + if isinstance(axis.converter, matplotlib.dates.DateConverter): + scale = 'date' + try: + import pandas as pd + from pandas.tseries.converter import PeriodConverter + except ImportError: + pd = None + + if (pd is not None and isinstance(axis.converter, + PeriodConverter)): + _dates = [pd.Period(ordinal=int(d), freq=axis.freq) + for d in domain] + domain = [(d.year, d.month - 1, d.day, + d.hour, d.minute, d.second, 0) + for d in _dates] + else: + domain = [(d.year, d.month - 1, d.day, + d.hour, d.minute, d.second, + d.microsecond * 1E-3) + for d in matplotlib.dates.num2date(domain)] + else: + scale = axis.get_scale() + + if scale not in ['date', 'linear', 'log']: + raise ValueError("Unknown axis scale: " + "{0}".format(axis.get_scale())) + + props[axname + 'scale'] = scale + props[axname + 'lim'] = lim + props[axname + 'domain'] = domain + + return props + + +def get_z_axis_properties(axis): + """Return the property dictionary for a matplotlib.Axis instance""" + props = {} + label1On = axis._major_tick_kw.get('label1On', True) + + if isinstance(axis, ZAxis): + if label1On: + props['position'] = "bottom" + else: + props['position'] = "top" + else: + raise ValueError("{0} should be an ZAxis instance".format(axis)) + + # Use tick values if appropriate + locator = axis.get_major_locator() + props['nticks'] = len(locator()) + if isinstance(locator, ticker.FixedLocator): + props['tickvalues'] = list(locator()) + else: + props['tickvalues'] = None + + # Find tick formats + formatter = axis.get_major_formatter() + if isinstance(formatter, ticker.NullFormatter): + props['tickformat'] = "" + elif isinstance(formatter, ticker.FixedFormatter): + props['tickformat'] = list(formatter.seq) + elif not any(label.get_visible() for label in axis.get_ticklabels()): + props['tickformat'] = "" + else: + props['tickformat'] = None + + # Get axis scale + props['scale'] = axis.get_scale() + + # Get major tick label size (assumes that's all we really care about!) + labels = axis.get_ticklabels() + if labels: + props['fontsize'] = labels[0].get_fontsize() + else: + props['fontsize'] = None + + # Get associated grid + props['grid'] = mplexporter.utils.get_grid_style(axis) + + # get axis visibility + props['visible'] = axis.get_visible() + + return props + + +def get_symbol_3d(marker_symbol): + """convert mpl marker symbols into supported plotly 3d symbols""" + symbol = mpltools.convert_symbol(marker_symbol) + if symbol not in PLOTLY_3D_MARKER_SYMBOLS: + return 'circle' + + +def convert_z_domain(mpl_plot_bounds, mpl_max_z_bounds): + """Get domain bounds for a 3d-ZAxis matplotlib""" + mpl_z_dom = [mpl_plot_bounds[2], mpl_plot_bounds[2] + mpl_plot_bounds[3]] + plotting_depth = mpl_max_z_bounds[1] - mpl_max_z_bounds[0] + z0 = (mpl_z_dom[0] - mpl_max_z_bounds[0]) / plotting_depth + z1 = (mpl_z_dom[1] - mpl_max_z_bounds[0]) / plotting_depth + return [z0, z1] + + +def prep_xyz_axis(ax, props, x_bounds, y_bounds, z_bounds): + """Crawl properties for a matplotlib Axes3D""" + xaxis = dict( + type=props['axes'][0]['scale'], + range=list(props['xlim']), + domain=mpltools.convert_x_domain(props['bounds'], x_bounds), + side=props['axes'][0]['position'], + tickfont=dict(size=props['axes'][0]['fontsize']) + ) + xaxis.update(mpltools.prep_ticks(ax, 0, 'x', props)) + + yaxis = dict( + type=props["axes"][1]["scale"], + range=list(props["ylim"]), + showgrid=props["axes"][1]["grid"]["gridOn"], + domain=mpltools.convert_y_domain(props["bounds"], y_bounds), + side=props["axes"][1]["position"], + tickfont=dict(size=props["axes"][1]["fontsize"]), + ) + + yaxis.update(mpltools.prep_ticks(ax, 1, "y", props)) + + zaxis = dict( + type=props['axes'][2]['scale'], + range=list(props['zlim']), + showgrid=props['axes'][1]['grid']['gridOn'], + side=props['axes'][2]['position'], + tickfont=dict(size=props['axes'][2]['fontsize']) + ) + + zaxis.update(mpltools.prep_ticks(ax, 2, "z", props)) + + return xaxis, yaxis, zaxis + + +def mpl_to_plotly(fig): + """Convert matplotlib figure to a plotly figure + + Parameters + ---------- + fig : matplotlib.pyplot.Figure + The matplotlib figure + + Returns + ------- + plotly.graph_objects.Figure + The converted plotly Figure + """ + renderer = DeepforgePlotlyRenderer() + exporter = mplexporter.Exporter(renderer) + exporter.run(fig) + renderer.crawl_3d_labels(fig) + return renderer.plotly_fig + + +class DeepforgePlotlyRenderer(PlotlyRenderer): + """PlotlyRenderer capable of handling images, 3D Plots + + Notes + ----- + Currently only supports handling images + """ + + def draw_image(self, **props): + """Write base64 encoded images into plotly figure""" + imdata = props['imdata'] + base64_decoded = base64.b64decode(imdata) + image = Image.open(io.BytesIO(base64_decoded)) + image_np = np.array(image) + self.plotly_fig.add_trace( + go.Image( + z=image_np, + xaxis='x{0}'.format(self.axis_ct), + yaxis='y{0}'.format(self.axis_ct), + ), + ) + + def get_3d_array(self, masked_array_tuple): + """convert a masked array into an array of 3d-coordinates""" + values = [] + for array in masked_array_tuple: + values.append(ma.getdata(array)) + return np.transpose(np.asarray(values)) + + def draw_path_collection(self, **props): + """Open path_collection to support 3d Objects from matplotlib figure""" + if props['offset_coordinates'] == 'data': + markerstyle = mpltools.get_markerstyle_from_collection(props) + scatter_props = { + 'coordinates': 'data', + 'data': props['offsets'], + 'label': None, + 'markerstyle': markerstyle, + 'linestyle': None, + } + if isinstance(props['mplobj'], Path3DCollection): # Support for scatter3d plots + scatter_props['data'] = self.get_3d_array(props['mplobj']._offsets3d) + self.draw_3d_collection(**scatter_props) + + else: + self.msg += ' Drawing path collection as markers\n' + self.draw_marked_line(**scatter_props) + else: + self.msg += ' Path collection not linked to "data", ' 'not drawing\n' + warnings.warn( + 'Dang! That path collection is out of this ' + 'world. I totally don\'t know what to do with ' + 'it yet! Plotly can only import path ' + 'collections linked to "data" coordinates' + ) + + def draw_marked_line(self, **props): + """Add support for Line3d matplotlib objects to the plotly renderer""" + if isinstance(props.get('mplobj'), Line3D): # 3D Line Plots + props['data'] = np.transpose(props['mplobj'].get_data_3d()) + self.draw_3d_collection(**props) + else: + super().draw_marked_line(**props) + + def draw_3d_collection(self, **props): + """Draw 3D collection for scatter plots""" + line, marker = {}, {} + if props['linestyle'] and props['markerstyle']: + mode = 'lines+markers' + elif props['linestyle']: + mode = 'lines' + elif props['markerstyle']: + mode = 'markers' + if props['linestyle']: + color = mpltools.merge_color_and_opacity( + props['linestyle']['color'], props['linestyle']['alpha'] + ) + line = go.scatter3d.Line( + color=color, + width=props['linestyle']['linewidth'], + dash=mpltools.convert_dash(props["linestyle"]["dasharray"]) + ) + + if props['markerstyle']: + marker = go.scatter3d.Marker( + opacity=props["markerstyle"]["alpha"], + color=props["markerstyle"]["facecolor"], + symbol=get_symbol_3d(props["markerstyle"]["marker"]), + size=props["markerstyle"]["markersize"], + line=dict( + color=props["markerstyle"]["edgecolor"], + width=props["markerstyle"]["edgewidth"], + ), + ) + + if props["coordinates"] == "data": + scatter_plot = go.Scatter3d( + mode=mode, + name=( + str(props["label"]) + if isinstance(props["label"], six.string_types) + else props["label"] + ), + x=[xyz_pair[0] for xyz_pair in props["data"]], + y=[xyz_pair[1] for xyz_pair in props["data"]], + z=[xyz_pair[2] for xyz_pair in props["data"]], + scene='scene{}'.format(self.axis_ct), + line=line, + marker=marker, + ) + if self.x_is_mpl_date: + formatter = ( + self.current_mpl_ax.get_xaxis() + .get_major_formatter() + .__class__.__name__ + ) + + scatter_plot["x"] = mpltools.mpl_dates_to_datestrings( + scatter_plot["x"], formatter + ) + + self.plotly_fig.add_trace( + scatter_plot + ) + + def crawl_3d_labels(self, fig): + """Crawl labels for 3d axes in matplotlib""" + for i, axes in enumerate(fig.axes): + if isinstance(axes, Axes3D): + for (text, ttype) in [ + (axes.xaxis.label, 'xlabel', ), + (axes.yaxis.label, 'ylabel'), + (axes.zaxis.label, 'zlabel'), + ]: + content = text.get_text() + if content: + transform = text.get_transform() + position = text.get_position() + coords, position = self.process_transfrom( + transform, + axes, + position, + force_trans=axes.transAxes + ) + style = mplexporter.utils.get_text_style(text) + method = getattr( + self, + f'draw_3d_{ttype}' + ) + method( + text=content, + position=position, + coordinates=coords, + text_type=ttype, + mplobj=text, + style=style, + scene_id=i+1 + ) + + def open_axes(self, ax, props): + """Open axes to support matplotlib Axes3D""" + if isinstance(ax, Axes3D): + props['axes'].append(get_z_axis_properties(ax.zaxis)) + self.axis_ct += 1 + self.bar_containers = [ + c + for c in ax.containers # empty is OK + if c.__class__.__name__ == "BarContainer" + ] + props.update(get_z_axes_properties(ax)) + self.current_mpl_ax = ax + xaxis = go.layout.scene.XAxis( + zeroline=False, + ticks='inside' + ) + yaxis = go.layout.scene.YAxis( + zeroline=False, + ticks='inside' + ) + zaxis = go.layout.scene.ZAxis( + zeroline=False, + ticks='inside' + ) + mpl_xaxis, mpl_yaxis, mpl_zaxis = prep_xyz_axis( + ax=ax, + props=props, + x_bounds=self.mpl_x_bounds, + y_bounds=self.mpl_y_bounds, + z_bounds=(0, 1) + ) + xaxis['range'] = mpl_xaxis['range'] + yaxis['range'] = mpl_yaxis['range'] + zaxis['range'] = mpl_zaxis['range'] + + scene = go.layout.Scene( + xaxis=xaxis, + yaxis=yaxis, + zaxis=zaxis + ) + scene['domain'] = { + 'x': mpl_xaxis.pop('domain'), + 'y': mpl_yaxis.pop('domain') + } + mpl_xaxis.pop('side') + mpl_yaxis.pop('side') + mpl_zaxis.pop('side') + xaxis.update(mpl_xaxis) + yaxis.update(mpl_yaxis) + zaxis.update(mpl_zaxis) + self.plotly_fig['layout'][f'scene{self.axis_ct}'] = scene + else: + super().open_axes(ax, props) + + def draw_text(self, **props): + """support zlabel for matplotlib Axes3D""" + if props['text_type'] == 'zlabel': + self.draw_3d_zlabel(**props) + else: + super().draw_text(**props) + + def draw_xlabel(self, **props): + try: + super().draw_xlabel(**props) + except KeyError: + self.draw_3d_xlabel(**props) + + def draw_3d_xlabel(self, **props): + scene_key = f'scene{self.axis_ct}' + self.plotly_fig['layout'][scene_key]['xaxis']['title'] = props['text'] + titlefont = dict(size=props["style"]["fontsize"], color=props["style"]["color"]) + self.plotly_fig["layout"][scene_key]['xaxis']["titlefont"] = titlefont + + def draw_ylabel(self, **props): + try: + super().draw_ylabel(**props) + except KeyError: + self.draw_3d_ylabel(**props) + + def draw_3d_ylabel(self, **props): + scene_key = f'scene{self.axis_ct}' + self.plotly_fig['layout'][scene_key]['yaxis']['title'] = props['text'] + titlefont = dict(size=props["style"]["fontsize"], color=props["style"]["color"]) + self.plotly_fig["layout"][scene_key]['yaxis']["titlefont"] = titlefont + + def draw_3d_zlabel(self, **props): + scene_key = f'scene{props["scene_id"]}' + self.plotly_fig['layout'][scene_key]['zaxis']['title'] = props['text'] + titlefont = dict(size=props["style"]["fontsize"], color=props["style"]["color"]) + self.plotly_fig["layout"][scene_key]['zaxis']["titlefont"] = titlefont + + @staticmethod + def process_transfrom(transform, + ax=None, + data=None, + return_trans=False, + force_trans=None): + """Process the transform and convert data to figure or data coordinates + + Parameters + ---------- + transform : matplotlib Transform object + The transform applied to the data + ax : matplotlib Axes object (optional) + The axes the data is associated with + data : ndarray (optional) + The array of data to be transformed. + return_trans : bool (optional) + If true, return the final transform of the data + force_trans : matplotlib.transform instance (optional) + If supplied, first force the data to this transform + + Returns + ------- + code : string + Code is either "data", "axes", "figure", or "display", indicating + the type of coordinates output. + transform : matplotlib transform + the transform used to map input data to output data. + Returned only if return_trans is True + new_data : ndarray + Data transformed to match the given coordinate code. + Returned only if data is specified + """ + if isinstance(transform, transforms.BlendedGenericTransform): + warnings.warn("Blended transforms not yet supported. " + "Zoom behavior may not work as expected.") + + if force_trans is not None: + if data is not None: + data = (transform - force_trans).transform(data) + transform = force_trans + + code = "display" + if ax is not None: + for (c, trans) in [("data", ax.transData), + ("axes", ax.transAxes), + ("figure", ax.figure.transFigure), + ("display", transforms.IdentityTransform())]: + if transform.contains_branch(trans): + code, transform = (c, transform - trans) + break + + if data is not None: + if return_trans: + return code, transform.transform(data), transform + else: + return code, transform.transform(data) + else: + if return_trans: + return code, transform + else: + return code + + +class RendererTemplate(RendererBase): + """ + The renderer handles drawing/rendering operations. + + This is a minimal do-nothing class that can be used to get started when + writing a new backend. Refer to backend_bases.RendererBase for + documentation of the classes methods. + """ + def __init__(self, dpi): + self.dpi = dpi + + def draw_path(self, gc, path, transform, rgbFace=None): + pass + + # draw_markers is optional, and we get more correct relative + # timings by leaving it out. backend implementers concerned with + # performance will probably want to implement it +# def draw_markers(self, gc, marker_path, marker_trans, path, trans, +# rgbFace=None): +# pass + + # draw_path_collection is optional, and we get more correct + # relative timings by leaving it out. backend implementers concerned with + # performance will probably want to implement it +# def draw_path_collection(self, gc, master_transform, paths, +# all_transforms, offsets, offsetTrans, +# facecolors, edgecolors, linewidths, linestyles, +# antialiaseds): +# pass + + # draw_quad_mesh is optional, and we get more correct + # relative timings by leaving it out. backend implementers concerned with + # performance will probably want to implement it +# def draw_quad_mesh(self, gc, master_transform, meshWidth, meshHeight, +# coordinates, offsets, offsetTrans, facecolors, +# antialiased, edgecolors): +# pass + + def draw_image(self, gc, x, y, im): + pass + + def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): + pass + + def flipy(self): + return True + + def get_canvas_width_height(self): + return 100, 100 + + def get_text_width_height_descent(self, s, prop, ismath): + return 1, 1, 1 + + def new_gc(self): + return GraphicsContextTemplate() + + def points_to_pixels(self, points): + # if backend doesn't have dpi, e.g., postscript or svg + return points + # elif backend assumes a value for pixels_per_inch + #return points/72.0 * self.dpi.get() * pixels_per_inch/72.0 + # else + #return points/72.0 * self.dpi.get() + + +class GraphicsContextTemplate(GraphicsContextBase): + """ + The graphics context provides the color, line styles, etc... See the gtk + and postscript backends for examples of mapping the graphics context + attributes (cap styles, join styles, line widths, colors) to a particular + backend. In GTK this is done by wrapping a gtk.gdk.GC object and + forwarding the appropriate calls to it using a dictionary mapping styles + to gdk constants. In Postscript, all the work is done by the renderer, + mapping line styles to postscript calls. + + If it's more appropriate to do the mapping at the renderer level (as in + the postscript backend), you don't need to override any of the GC methods. + If it's more appropriate to wrap an instance (as in the GTK backend) and + do the mapping here, you'll need to override several of the setter + methods. + + The base GraphicsContext stores colors as a RGB tuple on the unit + interval, e.g., (0.5, 0.0, 1.0). You may need to map this to colors + appropriate for your backend. + """ + pass + + + +######################################################################## +# +# The following functions and classes are for pylab and implement +# window/figure managers, etc... +# +######################################################################## + +def draw_if_interactive(): + """ + For image backends - is not required + For GUI backends - this should be overridden if drawing should be done in + interactive python mode + """ + + +def show(block=None): + """ + For image backends - is not required + For GUI backends - show() is usually the last line of a pylab script and + tells the backend that it is time to draw. In interactive mode, this may + be a do nothing func. See the GTK backend for an example of how to handle + interactive versus batch mode + """ + for manager in Gcf.get_all_fig_managers(): + manager.canvas.send_deepforge_update() + pass + + +def new_figure_manager(num, *args, **kwargs): + """ + Create a new figure manager instance + """ + # May be implemented via the `_new_figure_manager_template` helper. + # If a main-level app must be created, this (and + # new_figure_manager_given_figure) is the usual place to do it -- see + # backend_wx, backend_wxagg and backend_tkagg for examples. Not all GUIs + # require explicit instantiation of a main-level app (egg backend_gtk, + # backend_gtkagg) for pylab. + FigureClass = kwargs.pop('FigureClass', Figure) + thisFig = FigureClass(*args, **kwargs) + return new_figure_manager_given_figure(num, thisFig) + + +def new_figure_manager_given_figure(num, figure): + """ + Create a new figure manager instance for the given figure. + """ + # May be implemented via the `_new_figure_manager_template` helper. + canvas = FigureCanvasTemplate(figure) + manager = FigureManagerTemplate(canvas, num) + return manager + + +class FigureCanvasTemplate(FigureCanvasBase): + """ + The canvas the figure renders into. Calls the draw and print fig + methods, creates the renderers, etc... + + Note GUI templates will want to connect events for button presses, + mouse movements and key presses to functions that call the base + class methods button_press_event, button_release_event, + motion_notify_event, key_press_event, and key_release_event. See, + e.g., backend_gtk.py, backend_wx.py and backend_tkagg.py + + Attributes + ---------- + figure : `matplotlib.figure.Figure` + A high-level Figure instance + + """ + + def draw(self): + """ + Draw the figure using the renderer + """ + self.send_deepforge_update() + renderer = RendererTemplate(self.figure.dpi) + self.figure.draw(renderer) + + def send_deepforge_update(self): + state = self.figure_to_state() + # Probably should do some diff-ing if the state hasn't changed... + # TODO + print('deepforge-cmd PLOT ' + state) + + def figure_to_state(self): + figure = self.figure + plotly_figure = mpl_to_plotly( + figure + ) + + return plotly_figure.to_json() + + # You should provide a print_xxx function for every file format + # you can write. + + # If the file type is not in the base set of filetypes, + # you should add it to the class-scope filetypes dictionary as follows: + filetypes = FigureCanvasBase.filetypes.copy() + filetypes['foo'] = 'My magic Foo format' + + def print_foo(self, filename, *args, **kwargs): + """ + Write out format foo. The dpi, facecolor and edgecolor are restored + to their original values after this call, so you don't need to + save and restore them. + """ + pass + + def get_default_filetype(self): + return 'foo' + + +class FigureManagerTemplate(FigureManagerBase): + """ + Wrap everything up into a window for the pylab interface + + For non interactive backends, the base class does all the work + """ + pass + +######################################################################## +# +# Now just provide the standard names that backend.__init__ is expecting +# +######################################################################## + +FigureCanvas = FigureCanvasTemplate +FigureManager = FigureManagerTemplate diff --git a/src/plugins/GenerateJob/templates/start.js b/src/plugins/GenerateJob/templates/start.js index 7a16db4f1..c72c26686 100644 --- a/src/plugins/GenerateJob/templates/start.js +++ b/src/plugins/GenerateJob/templates/start.js @@ -55,7 +55,7 @@ requirejs([ main(); async function main() { - process.env.MPLBACKEND = 'module://backend_deepforge'; + process.env.MPLBACKEND = 'module://plotly_backend'; // Download the large files const inputData = require('./input-data.json'); diff --git a/src/seeds/pipeline/pipeline.webgmex b/src/seeds/pipeline/pipeline.webgmex index 610698c33..9db3421a3 100644 Binary files a/src/seeds/pipeline/pipeline.webgmex and b/src/seeds/pipeline/pipeline.webgmex differ diff --git a/src/seeds/pipeline/releases.jsonl b/src/seeds/pipeline/releases.jsonl index 6e493be8f..9c91ebe9d 100644 --- a/src/seeds/pipeline/releases.jsonl +++ b/src/seeds/pipeline/releases.jsonl @@ -1,4 +1,4 @@ {"version":"0.20.0","changelog":"Add provenance info to Data nodes"} {"version":"0.21.0","changelog":"Add provenance to metadata (via WithProvenance mixin)"} {"version":"0.21.1","changelog":"Update Inheritance of Subgraph, Line, Images, ScatterPoints etc.. nodes"} - +{"version":"0.22.0","changelog":"Incorporate PlotlyJSON into Graph meta node"} diff --git a/src/visualizers/panels/ExecutionIndex/ExecutionIndexControl.js b/src/visualizers/panels/ExecutionIndex/ExecutionIndexControl.js index c5b23a0ef..eee5d07fc 100644 --- a/src/visualizers/panels/ExecutionIndex/ExecutionIndexControl.js +++ b/src/visualizers/panels/ExecutionIndex/ExecutionIndexControl.js @@ -4,20 +4,14 @@ define([ 'js/Constants', 'deepforge/utils', - 'deepforge/viz/Execute', - 'deepforge/viz/FigureExtractor' + 'deepforge/viz/Execute' ], function ( CONSTANTS, utils, - Execute, - FigureExtractor + Execute ) { 'use strict'; - const ClientFigureExtractor = FigureExtractor.ClientFigureExtractor; - const GRAPH = ['Graph']; - const SUBGRAPHS = ['Plot2D', 'Plot3D']; - var ExecutionIndexControl; ExecutionIndexControl = function (options) { @@ -35,7 +29,6 @@ define([ this._graphsForExecution = {}; this._graphToExec = {}; this._pipelineNames = {}; - this.figureExtractor = new ClientFigureExtractor(this._client); this.abbrToId = {}; this.abbrFor = {}; @@ -83,108 +76,35 @@ define([ ExecutionIndexControl.prototype._consolidateGraphData = function (graphExecIDs) { let graphIds = graphExecIDs.map(execId => this._graphsForExecution[execId]); - let graphDescs = graphIds.map(id => this._getObjectDescriptor(id)).filter(desc => !!desc); - if (graphDescs.length > 0) { - let consolidatedDesc = this._combineGraphDesc(graphDescs); - consolidatedDesc.type = 'graph'; - return consolidatedDesc; - } - }; + let graphDescs = graphIds.map(id => this._getObjectDescriptor(id)) + .filter(desc => !!desc); - - ExecutionIndexControl.prototype._combineGraphDesc = function (graphDescs) { - const isMultiGraph = this.displayedExecCount() > 1; - if (!isMultiGraph) { - return graphDescs[0]; - } else { - let consolidatedDesc = null; - - graphDescs.forEach((desc) => { - if (!consolidatedDesc) { - consolidatedDesc = JSON.parse(JSON.stringify(desc)); - consolidatedDesc.subGraphs.forEach((subGraph) => { - subGraph.abbr = desc.abbr; - subGraph.title = getDisplayTitle(subGraph, true); - }); - consolidatedDesc.title = getDisplayTitle(consolidatedDesc, true); - } else { - consolidatedDesc.id += desc.id; - consolidatedDesc.execId += ` vs ${desc.execId}`; - consolidatedDesc.graphId += ` vs ${desc.graphId}`; - consolidatedDesc.title += ` vs ${getDisplayTitle(desc, true)}`; - this._combineSubGraphsDesc(consolidatedDesc, desc.subGraphs, desc.abbr); - } + if (graphDescs.length > 1) { + graphDescs.forEach(graphDesc => { + graphDesc.plotlyData.layout.title = + getDisplayTitle(graphDesc); }); - return consolidatedDesc; } - }; - - ExecutionIndexControl.prototype._combineSubGraphsDesc = function (consolidatedDesc, subGraphs, abbr) { - let currentSubGraph, imageSubGraphCopy, added=0, subgraphCopy; - const originalLength = consolidatedDesc.subGraphs.length; - for (let i = 0; i < originalLength; i++) { - if (!subGraphs[i]) break; - currentSubGraph = consolidatedDesc.subGraphs[i+added]; - subGraphs[i].abbr = abbr; - - if(subGraphs[i].type !== currentSubGraph.type){ - subgraphCopy = JSON.parse(JSON.stringify(subGraphs[i])); - subgraphCopy.title = getDisplayTitle(subGraphs[i], true); - consolidatedDesc.subGraphs.splice(i+added, 0, subgraphCopy); - added++; - continue; - } - if(currentSubGraph.images && subGraphs[i].images) { - if (subGraphs[i].images.length > 0 || currentSubGraph.images.length > 0) { - imageSubGraphCopy = JSON.parse(JSON.stringify(subGraphs[i])); - imageSubGraphCopy.title = getDisplayTitle(subGraphs[i], true); - consolidatedDesc.subGraphs.splice(i+added, 0, imageSubGraphCopy); - added++; - continue; - } - } - - currentSubGraph.title += ` vs. ${getDisplayTitle(subGraphs[i], true)}`; - if(currentSubGraph.xlabel !== subGraphs[i].xlabel){ - currentSubGraph.xlabel += ` ${subGraphs[i].xlabel}`; - } - if(currentSubGraph.ylabel !== subGraphs[i].ylabel){ - currentSubGraph.ylabel += ` ${subGraphs[i].ylabel}`; - } - - if(currentSubGraph.zlabel && currentSubGraph.zlabel !== subGraphs[i].zlabel){ - currentSubGraph.zlabel += ` ${subGraphs[i].zlabel}`; - } - - subGraphs[i].lines.forEach((line, index) => { - let lineClone = JSON.parse(JSON.stringify(line)); - lineClone.label = (lineClone.label || `line${index}`) + ` (${abbr})`; - currentSubGraph.lines.push(lineClone); - }); - - subGraphs[i].scatterPoints.forEach(scatterPoint => { - let scatterClone = JSON.parse(JSON.stringify(scatterPoint)); - currentSubGraph.scatterPoints.push(scatterClone); - }); - } - // Check if there are more subgraphs - let extraSubGraphIdx = consolidatedDesc.subGraphs.length; - while (extraSubGraphIdx < subGraphs.length) { - subGraphs[extraSubGraphIdx].abbr = abbr; - const clonedSubgraph = JSON.parse(JSON.stringify(subGraphs[extraSubGraphIdx])); - clonedSubgraph.title = getDisplayTitle(clonedSubgraph, true); - consolidatedDesc.subGraphs.push(clonedSubgraph); - extraSubGraphIdx++; + if (graphDescs.length > 0) { + graphDescs.type = 'graph'; + return graphDescs; } }; - const getDisplayTitle = function (desc, includeAbbr = false) { - let title = desc.title || desc.type; + const getDisplayTitle = function (desc) { + let title = desc.plotlyData.layout.title ? desc.plotlyData.layout.title : {}; - if (includeAbbr) { + if (title.text) { + title.text = `${title.text} (${desc.abbr})`; + } else if(typeof title === 'string') { title = `${title} (${desc.abbr})`; + } else { + title = { + text: `Graph (${desc.abbr})` + }; } + return title; }; @@ -257,7 +177,6 @@ define([ name: node.getAttribute('name') }; - const isGraphOrChildren = GRAPH.concat(SUBGRAPHS).includes(type); if (type === 'Execution') { desc.status = node.getAttribute('status'); @@ -278,12 +197,8 @@ define([ } else if (type === 'Pipeline') { desc.execs = node.getMemberIds('executions'); this._pipelineNames[desc.id] = desc.name; - } else if (isGraphOrChildren) { - let graphNode = node; - if (SUBGRAPHS.includes(type)){ - graphNode = this._client.getNode(node.getParentId()); - } - desc = this.getGraphDesc(graphNode); + } else if (type === 'Graph') { + desc = this.getGraphDesc(node); } } return desc; @@ -291,7 +206,12 @@ define([ ExecutionIndexControl.prototype.getGraphDesc = function (graphNode) { let id = graphNode.getId(); - let desc = this.figureExtractor.extract(graphNode); + const execId = this._client.getNode(graphNode.getParentId()).getParentId(); + const plotlyData = graphNode.getAttribute('data'); + let desc = { + execId: execId, + plotlyData: plotlyData ? JSON.parse(plotlyData) : null + }; if (!this._graphToExec[id]) { this._graphsForExecution[desc.execId] = id; @@ -305,7 +225,6 @@ define([ desc.name = `${desc.name} (${execAbbr})`; desc.abbr = execAbbr; } - return desc; }; @@ -321,17 +240,17 @@ define([ event = events[i]; switch (event.etype) { - case CONSTANTS.TERRITORY_EVENT_LOAD: - this._onLoad(event.eid); - break; - case CONSTANTS.TERRITORY_EVENT_UPDATE: - this._onUpdate(event.eid); - break; - case CONSTANTS.TERRITORY_EVENT_UNLOAD: - this._onUnload(event.eid); - break; - default: - break; + case CONSTANTS.TERRITORY_EVENT_LOAD: + this._onLoad(event.eid); + break; + case CONSTANTS.TERRITORY_EVENT_UPDATE: + this._onUpdate(event.eid); + break; + case CONSTANTS.TERRITORY_EVENT_UNLOAD: + this._onUnload(event.eid); + break; + default: + break; } } diff --git a/src/visualizers/panels/PlotlyGraph/PlotlyGraphControl.js b/src/visualizers/panels/PlotlyGraph/PlotlyGraphControl.js index 38ad02a14..32c48e513 100644 --- a/src/visualizers/panels/PlotlyGraph/PlotlyGraphControl.js +++ b/src/visualizers/panels/PlotlyGraph/PlotlyGraphControl.js @@ -1,17 +1,13 @@ /*globals define, WebGMEGlobal*/ define([ - 'js/Constants', - 'deepforge/viz/FigureExtractor', + 'js/Constants' ], function ( - CONSTANTS, - FigureExtractor + CONSTANTS ) { - 'use strict'; - const ClientFigureExtractor = FigureExtractor.ClientFigureExtractor; - const GRAPH = ['Graph']; - const SUBGRAPHS = ['Plot2D', 'Plot3D']; + + const GRAPH = 'Graph'; function PlotlyGraphControl(options) { @@ -26,9 +22,6 @@ define([ this._currentNodeId = null; this._currentNodeParentId = undefined; - - this.figureExtractor = new ClientFigureExtractor(this._client); - this._logger.debug('ctor finished'); } @@ -60,7 +53,7 @@ define([ }); // Update the territory - self._selfPatterns[nodeId] = {children: 3}; + self._selfPatterns[nodeId] = {children: 1}; self._client.updateTerritory(self._territoryId, self._selfPatterns); } }; @@ -68,18 +61,19 @@ define([ // This next function retrieves the relevant node information for the widget PlotlyGraphControl.prototype._getObjectDescriptor = function (nodeId) { let node = this._client.getNode(nodeId), - desc, graphNode; - if(node) { - const baseNode = this._client.getNode(node.getBaseId()); - const type = baseNode.getAttribute('name'); - const isGraph = GRAPH.concat(SUBGRAPHS).includes(type); - if(isGraph){ - graphNode = node; - if (SUBGRAPHS.includes(type)) { - graphNode = this._client.getNode(node.getParentId()); - } - desc = this.figureExtractor.extract(graphNode); + desc; + const isGraph = node => { + if(node) { + return this._client.getNode(node.getMetaTypeId()) + .getAttribute('name') === GRAPH; } + }; + if(isGraph(node)){ + desc = { + plotlyData: JSON.parse( + node.getAttribute('data') + ) + }; } return desc; }; diff --git a/src/visualizers/widgets/PlotlyGraph/PlotlyGraphWidget.js b/src/visualizers/widgets/PlotlyGraph/PlotlyGraphWidget.js index dac0116af..a6d43e4d3 100644 --- a/src/visualizers/widgets/PlotlyGraph/PlotlyGraphWidget.js +++ b/src/visualizers/widgets/PlotlyGraph/PlotlyGraphWidget.js @@ -1,17 +1,18 @@ /*globals define, _, $*/ define([ - './lib/plotly.min', - './PlotlyDescExtractor' + './lib/plotly.min' ], function ( - Plotly, - PlotlyDescExtractor) { + Plotly +) { 'use strict'; const WIDGET_CLASS = 'plotly-graph'; + const PLOT_BG_COLOR = '#EEEEEE'; function PlotlyGraphWidget(logger, container) { this.logger = logger.fork('widget'); + this._container = container; this.$el = container; this.$defaultTextDiv = $('
', { class: 'h2 center' @@ -22,10 +23,7 @@ define([ this.$el.append(this.$defaultTextDiv); this.$el.css('overflow', 'auto'); this.$el.addClass(WIDGET_CLASS); - this.nodes = {}; - this.plotlyJSON = null; - this.layout = {}; - this.created = false; + this.plots = []; this.logger.debug('ctor finished'); this.setTextVisibility(true); } @@ -48,48 +46,63 @@ define([ }; PlotlyGraphWidget.prototype.removeNode = function () { - this.plotlyJSON = null; this.refreshChart(); this.setTextVisibility(true); }; PlotlyGraphWidget.prototype.addOrUpdateNode = function (desc) { if (desc) { - this.plotlyJSON = PlotlyDescExtractor.descToPlotlyJSON(desc); + const plotlyJSONs = Array.isArray(desc) ? + desc.map(descr => descr.plotlyData) : [desc.plotlyData]; + + const len = plotlyJSONs.length; + + plotlyJSONs.forEach(json => { + if (len === 1) { + json.layout.height = this.$el.height(); + json.layout.width = this.$el.width(); + } else { + json.layout.autosize = true; + delete json.layout.width; + delete json.layout.height; + } + json.layout.plot_bgcolor = PLOT_BG_COLOR; + json.layout.paper_bgcolor = PLOT_BG_COLOR; + }); this.setTextVisibility(false); - this.refreshChart(); + this.refreshChart(plotlyJSONs); } }; PlotlyGraphWidget.prototype.updateNode = function (desc) { + this.deleteChart(); this.addOrUpdateNode(desc); }; - PlotlyGraphWidget.prototype.createOrUpdateChart = function () { - if (!this.plotlyJSON) { + PlotlyGraphWidget.prototype.createOrUpdateChart = function (plotlyJSONs) { + if (!plotlyJSONs) { this.deleteChart(); } else { - if (!this.created && !_.isEmpty(this.plotlyJSON)) { - Plotly.newPlot(this.$el[0], this.plotlyJSON); - this.created = true; - - } else if(!_.isEmpty(this.plotlyJSON)) { - // Currently in plotly, ImageTraces have no react support - // This will be updated when there's additional support - // for react with responsive layout - Plotly.newPlot(this.$el[0], this.plotlyJSON); - } + this.createChartSlider(plotlyJSONs); } }; + PlotlyGraphWidget.prototype.createChartSlider = function(plotlyJSONs) { + plotlyJSONs.forEach(plotlyJSON => { + const plotlyDiv = $('
'); + Plotly.newPlot(plotlyDiv[0], plotlyJSON); + this.plots.push(plotlyDiv); + this.$el.append(plotlyDiv); + }); + }; + PlotlyGraphWidget.prototype.refreshChart = _.debounce(PlotlyGraphWidget.prototype.createOrUpdateChart, 50); PlotlyGraphWidget.prototype.deleteChart = function () { - this.plotlyJSON = null; - if (this.created) { - Plotly.purge(this.$el[0]); - } - this.created = false; + this.plots.forEach($plot => { + Plotly.purge($plot[0]); + $plot.remove(); + }); }; PlotlyGraphWidget.prototype.setTextVisibility = function (display) { @@ -98,7 +111,7 @@ define([ }; /* * * * * * * * Visualizer life cycle callbacks * * * * * * * */ PlotlyGraphWidget.prototype.destroy = function () { - Plotly.purge(this.$el[0]); + this.deleteChart(); }; PlotlyGraphWidget.prototype.onActivate = function () { diff --git a/test/unit/plugins/ExecuteJob/ExecuteJob.spec.js b/test/unit/plugins/ExecuteJob/ExecuteJob.spec.js index fedff0adf..9037e673b 100644 --- a/test/unit/plugins/ExecuteJob/ExecuteJob.spec.js +++ b/test/unit/plugins/ExecuteJob/ExecuteJob.spec.js @@ -172,7 +172,6 @@ describe('ExecuteJob', function () { plugin.pulseClient.update = nopPromise; plugin.resumeJob = () => done(shouldResume ? null : 'Should not resume job!'); plugin.executeJob = () => done(shouldResume ? 'Should resume job!' : null); - plugin.main(); }; @@ -218,10 +217,6 @@ describe('ExecuteJob', function () { base: plugin.META.Graph, parent: plugin.activeNode }); - plugin.core.createNode({ - base: plugin.META.Line, - parent: graph - }); await plugin.save(); await plugin.prepare(true);