diff --git a/glue_plotly/__init__.py b/glue_plotly/__init__.py index 5c907a8..d8411c3 100644 --- a/glue_plotly/__init__.py +++ b/glue_plotly/__init__.py @@ -70,10 +70,18 @@ def setup_qt(): try: from glue_vispy_viewers.scatter.scatter_viewer import VispyScatterViewer + from glue_vispy_viewers.volume.volume_viewer import VispyVolumeViewer except ImportError: pass else: - VispyScatterViewer.subtools['save'] = VispyScatterViewer.subtools['save'] + ['save:plotly3d'] + VispyScatterViewer.subtools = { + **VispyScatterViewer.subtools, + "save": VispyScatterViewer.subtools["save"] + ["save:plotly3d"] + } + VispyVolumeViewer.subtools = { + **VispyVolumeViewer.subtools, + "save": VispyVolumeViewer.subtools["save"] + ["save:plotlyvolume"] + } def setup_jupyter(): diff --git a/glue_plotly/common/base_3d.py b/glue_plotly/common/base_3d.py new file mode 100644 index 0000000..cff3ea6 --- /dev/null +++ b/glue_plotly/common/base_3d.py @@ -0,0 +1,107 @@ +import re + +from glue.config import settings +from glue_plotly.common import DEFAULT_FONT + + +def dimensions(viewer_state): + # when vispy viewer is in "native aspect ratio" mode, scale axes size by data + if viewer_state.native_aspect: + width = viewer_state.x_max - viewer_state.x_min + height = viewer_state.y_max - viewer_state.y_min + depth = viewer_state.z_max - viewer_state.z_min + + # otherwise, set all axes to be equal size + else: + width = 1200 # this 1200 size is arbitrary, could change to any width; just need to scale rest accordingly + height = 1200 + depth = 1200 + + return [width, height, depth] + + +def projection_type(viewer_state): + return "perspective" if viewer_state.perspective_view else "orthographic" + + +def axis(viewer_state, ax): + title = getattr(viewer_state, f'{ax}_att').label + range = [getattr(viewer_state, f'{ax}_min'), getattr(viewer_state, f'{ax}_max')] + return dict( + title=title, + titlefont=dict( + family=DEFAULT_FONT, + size=20, + color=settings.FOREGROUND_COLOR + ), + backgroundcolor=settings.BACKGROUND_COLOR, + showspikes=False, + linecolor=settings.FOREGROUND_COLOR, + tickcolor=settings.FOREGROUND_COLOR, + zeroline=False, + mirror=True, + ticks='outside', + showline=True, + showgrid=False, + showticklabels=True, + tickfont=dict( + family=DEFAULT_FONT, + size=12, + color=settings.FOREGROUND_COLOR), + range=range, + type='linear', + rangemode='normal', + visible=viewer_state.visible_axes + ) + + +def bbox_mask(viewer_state, x, y, z): + return (x >= viewer_state.x_min) & (x <= viewer_state.x_max) & \ + (y >= viewer_state.y_min) & (y <= viewer_state.y_max) & \ + (z >= viewer_state.z_min) & (z <= viewer_state.z_max) + + +def clipped_data(viewer_state, layer_state): + x = layer_state.layer[viewer_state.x_att] + y = layer_state.layer[viewer_state.y_att] + z = layer_state.layer[viewer_state.z_att] + + # Plotly doesn't show anything outside the bounding box + mask = bbox_mask(viewer_state, x, y, z) + + return x[mask], y[mask], z[mask], mask + + +def plotly_up_from_vispy(vispy_up): + regex = re.compile("(\\+|-)(x|y|z)") + up = {"x": 0, "y": 0, "z": 0} + m = regex.match(vispy_up) + if m is not None and len(m.groups()) == 2: + sign = 1 if m.group(1) == "+" else -1 + up[m.group(2)] = sign + return up + + +def layout_config(viewer_state): + width, height, depth = dimensions(viewer_state) + return dict( + margin=dict(r=50, l=50, b=50, t=50), # noqa + width=1200, + paper_bgcolor=settings.BACKGROUND_COLOR, + scene=dict( + xaxis=axis(viewer_state, 'x'), + yaxis=axis(viewer_state, 'y'), + zaxis=axis(viewer_state, 'z'), + camera=dict( + projection=dict( + type=projection_type(viewer_state) + ), + # Currently there's no way to change this in glue + up=plotly_up_from_vispy("+z") + ), + aspectratio=dict(x=1 * viewer_state.x_stretch, + y=height / width * viewer_state.y_stretch, + z=depth / width * viewer_state.z_stretch), + aspectmode='manual' + ) + ) diff --git a/glue_plotly/common/common.py b/glue_plotly/common/common.py index 21acd54..d47d70c 100644 --- a/glue_plotly/common/common.py +++ b/glue_plotly/common/common.py @@ -108,7 +108,7 @@ def sanitize(*arrays): def fixed_color(layer_state): layer_color = layer_state.color if layer_color == '0.35' or layer_color == '0.75': - layer_color = 'gray' + layer_color = '#808080' if is_rgba_hex(layer_color): layer_color = rgba_hex_to_rgb_hex(layer_color) return layer_color diff --git a/glue_plotly/common/scatter3d.py b/glue_plotly/common/scatter3d.py index 83f7da0..e0d1747 100644 --- a/glue_plotly/common/scatter3d.py +++ b/glue_plotly/common/scatter3d.py @@ -1,5 +1,4 @@ import numpy as np -from glue.config import settings from glue.core import BaseData from glue.utils import ensure_numerical from matplotlib.colors import to_rgb @@ -7,71 +6,8 @@ from plotly.graph_objs import Cone, Scatter3d from uuid import uuid4 -from glue_plotly.common import DEFAULT_FONT, color_info - - -def dimensions(viewer_state): - # when vispy viewer is in "native aspect ratio" mode, scale axes size by data - if viewer_state.native_aspect: - width = viewer_state.x_max - viewer_state.x_min - height = viewer_state.y_max - viewer_state.y_min - depth = viewer_state.z_max - viewer_state.z_min - - # otherwise, set all axes to be equal size - else: - width = 1200 # this 1200 size is arbitrary, could change to any width; just need to scale rest accordingly - height = 1200 - depth = 1200 - - return [width, height, depth] - - -def projection_type(viewer_state): - return "perspective" if viewer_state.perspective_view else "orthographic" - - -def axis(viewer_state, ax): - title = getattr(viewer_state, f'{ax}_att').label - range = [getattr(viewer_state, f'{ax}_min'), getattr(viewer_state, f'{ax}_max')] - return dict( - title=title, - titlefont=dict( - family=DEFAULT_FONT, - size=20, - color=settings.FOREGROUND_COLOR - ), - backgroundcolor=settings.BACKGROUND_COLOR, - showspikes=False, - linecolor=settings.FOREGROUND_COLOR, - tickcolor=settings.FOREGROUND_COLOR, - zeroline=False, - mirror=True, - ticks='outside', - showline=True, - showgrid=False, - showticklabels=True, - tickfont=dict( - family=DEFAULT_FONT, - size=12, - color=settings.FOREGROUND_COLOR), - range=range, - type='linear', - rangemode='normal', - visible=viewer_state.visible_axes - ) - - -def clipped_data(viewer_state, layer_state): - x = layer_state.layer[viewer_state.x_att] - y = layer_state.layer[viewer_state.y_att] - z = layer_state.layer[viewer_state.z_att] - - # Plotly doesn't show anything outside the bounding box - mask = (x >= viewer_state.x_min) & (x <= viewer_state.x_max) & \ - (y >= viewer_state.y_min) & (y <= viewer_state.y_max) & \ - (z >= viewer_state.z_min) & (z <= viewer_state.z_max) - - return x[mask], y[mask], z[mask], mask +from glue_plotly.common import color_info +from glue_plotly.common.base_3d import clipped_data def size_info(layer_state, mask): @@ -157,29 +93,6 @@ def error_bar_info(layer_state, mask): return errs -def layout_config(viewer_state): - width, height, depth = dimensions(viewer_state) - return dict( - margin=dict(r=50, l=50, b=50, t=50), # noqa - width=1200, - paper_bgcolor=settings.BACKGROUND_COLOR, - scene=dict( - xaxis=axis(viewer_state, 'x'), - yaxis=axis(viewer_state, 'y'), - zaxis=axis(viewer_state, 'z'), - camera=dict( - projection=dict( - type=projection_type(viewer_state) - ) - ), - aspectratio=dict(x=1 * viewer_state.x_stretch, - y=height / width * viewer_state.y_stretch, - z=depth / width * viewer_state.z_stretch), - aspectmode='manual' - ) - ) - - def traces_for_layer(viewer_state, layer_state, hover_data=None, add_data_label=True): x, y, z, mask = clipped_data(viewer_state, layer_state) diff --git a/glue_plotly/common/volume.py b/glue_plotly/common/volume.py new file mode 100644 index 0000000..2e501c0 --- /dev/null +++ b/glue_plotly/common/volume.py @@ -0,0 +1,119 @@ +from glue_plotly.utils import rgba_components +from numpy import linspace, meshgrid, nan_to_num, nanmin + +from glue.core import BaseData +from glue.core.state_objects import State +from glue.core.subset_group import GroupedSubset + +from glue_plotly.common import color_info +from glue_plotly.common.base_3d import bbox_mask + +import plotly.graph_objects as go + + +def positions(bounds): + # The viewer bounds are in reverse order + coord_arrays = [linspace(b[0], b[1], num=b[2]) for b in reversed(bounds)] + return meshgrid(*coord_arrays) + + +def parent_layer(viewer_or_state, subset): + data = subset.data + for layer in viewer_or_state.layers: + if layer.layer is data: + return layer + return None + + +def values(viewer_state, layer_state, bounds, precomputed=None): + subset_layer = isinstance(layer_state.layer, GroupedSubset) + parent = layer_state.layer.data if subset_layer else layer_state.layer + parent_label = parent.label + if precomputed is not None and parent_label in precomputed: + data = precomputed[parent_label] + else: + data = parent.compute_fixed_resolution_buffer( + target_data=viewer_state.reference_data, + bounds=bounds, + target_cid=layer_state.attribute + ) + + if subset_layer: + subcube = parent.compute_fixed_resolution_buffer( + target_data=viewer_state.reference_data, + bounds=bounds, + subset_state=layer_state.layer.subset_state + ) + values = subcube * data + else: + values = data + + # This accounts for two transformations: the fact that the viewer bounds are in reverse order, + # plus a need to change R -> L handedness for Plotly + values = values.transpose(1, 2, 0) + min_value = nanmin(values) + replacement = min_value - 1 + replaced = nan_to_num(values, replacement) + return replaced + + +def colorscale(layer_state, size=10): + color = color_info(layer_state) + r, g, b, a = rgba_components(color) + fractions = [(i / size) ** 0.25 for i in range(size + 1)] + return [f"rgba({f*r},{f*g},{f*b},{f*a})" for f in fractions] + + +def opacity_scale(layer_state): + return [[0, 0], [1, 1]] + + +def isomin_for_layer(viewer_or_state, layer): + if isinstance(layer.layer, GroupedSubset): + parent = parent_layer(viewer_or_state, layer.layer) + if parent is not None: + parent_state = parent if isinstance(parent, State) else parent.state + return parent_state.vmin + + state = layer if isinstance(layer, State) else layer + return state.vmin + + +def isomax_for_layer(viewer_or_state, layer): + if isinstance(layer.layer, GroupedSubset): + parent = parent_layer(viewer_or_state, layer.layer) + if parent is not None: + parent_state = parent if isinstance(parent, State) else parent.state + return parent_state.vmax + + state = layer if isinstance(layer, State) else layer + return state.vmax + + +def traces_for_layer(viewer_state, layer_state, bounds, + isosurface_count=5, add_data_label=True): + + xyz = positions(bounds) + mask = bbox_mask(viewer_state, *xyz) + clipped_xyz = [c[mask] for c in xyz] + clipped_values = values(viewer_state, layer_state, bounds)[mask] + name = layer_state.layer.label + if add_data_label and not isinstance(layer_state.layer, BaseData): + name += " ({0})".format(layer_state.layer.data.label) + + return [go.Volume( + name=name, + hoverinfo="skip", + hovertext=None, + x=clipped_xyz[0], + y=clipped_xyz[1], + z=clipped_xyz[2], + value=clipped_values, + colorscale=colorscale(layer_state), + opacityscale=opacity_scale(layer_state), + isomin=isomin_for_layer(viewer_state, layer_state), + isomax=isomax_for_layer(viewer_state, layer_state), + opacity=layer_state.alpha, + surface_count=isosurface_count, + showscale=False + )] diff --git a/glue_plotly/html_exporters/qt/__init__.py b/glue_plotly/html_exporters/qt/__init__.py index 880255c..c2e0302 100644 --- a/glue_plotly/html_exporters/qt/__init__.py +++ b/glue_plotly/html_exporters/qt/__init__.py @@ -5,3 +5,5 @@ from . import profile # noqa from . import table # noqa from . import dendrogram # noqa +from . import volume # noqa +from .options_state import * # noqa diff --git a/glue_plotly/html_exporters/qt/options_state.py b/glue_plotly/html_exporters/qt/options_state.py new file mode 100644 index 0000000..42841ee --- /dev/null +++ b/glue_plotly/html_exporters/qt/options_state.py @@ -0,0 +1,28 @@ +from echo import CallbackProperty +from glue.config import DictRegistry +from glue.core.state_objects import State +from glue_vispy_viewers.volume.layer_state import VolumeLayerState + + +__all__ = ["qt_export_options", "VolumeExportOptionsState"] + + +class QtExportLayerOptionsRegistry(DictRegistry): + + def add(self, layer_state_cls, layer_options_state): + if not issubclass(layer_options_state, State): + raise ValueError("Layer options must be a glue State type") + self._members[layer_state_cls] = layer_options_state + + def __call__(self, layer_state_cls): + def adder(export_state_class): + self.add(layer_state_cls, export_state_class) + return adder + + +qt_export_options = QtExportLayerOptionsRegistry() + + +@qt_export_options(VolumeLayerState) +class VolumeExportOptionsState(State): + isosurface_count = CallbackProperty(5) diff --git a/glue_plotly/html_exporters/qt/scatter3d.py b/glue_plotly/html_exporters/qt/scatter3d.py index 8faffa2..f0b06e6 100644 --- a/glue_plotly/html_exporters/qt/scatter3d.py +++ b/glue_plotly/html_exporters/qt/scatter3d.py @@ -14,7 +14,8 @@ from glue_plotly import PLOTLY_ERROR_MESSAGE, PLOTLY_LOGO from glue_plotly.common import data_count, layers_to_export -from glue_plotly.common.scatter3d import layout_config, traces_for_layer +from glue_plotly.common.base_3d import layout_config +from glue_plotly.common.scatter3d import traces_for_layer from ... import save_hover, export_dialog from plotly.offline import plot diff --git a/glue_plotly/html_exporters/qt/tests/test_base.py b/glue_plotly/html_exporters/qt/tests/test_base.py index 3beb927..734ce4c 100644 --- a/glue_plotly/html_exporters/qt/tests/test_base.py +++ b/glue_plotly/html_exporters/qt/tests/test_base.py @@ -5,6 +5,8 @@ from glue_plotly.sort_components import SortComponentsDialog from qtpy.QtWidgets import QMessageBox +from glue_plotly.volume_options import VolumeOptionsDialog + class TestQtExporter: @@ -47,6 +49,7 @@ def export_figure(self, tmpdir, output_filename): fd.return_value = output_path, 'html' with patch.object(SaveHoverDialog, 'exec_', self.auto_accept_selectdialog()), \ patch.object(SortComponentsDialog, 'exec_', self.auto_accept_selectdialog()), \ + patch.object(VolumeOptionsDialog, 'exec_', self.auto_accept_messagebox()), \ patch.object(QMessageBox, 'exec_', self.auto_accept_messagebox()): self.tool.activate() return output_path diff --git a/glue_plotly/html_exporters/qt/tests/test_dendrogram.py b/glue_plotly/html_exporters/qt/tests/test_dendrogram.py index c074fd2..299dea9 100644 --- a/glue_plotly/html_exporters/qt/tests/test_dendrogram.py +++ b/glue_plotly/html_exporters/qt/tests/test_dendrogram.py @@ -6,9 +6,9 @@ importorskip('glue_qt.plugins.dendro_viewer.data_viewer') -from glue_qt.plugins.dendro_viewer.data_viewer import DendrogramViewer # noqa +from glue_qt.plugins.dendro_viewer.data_viewer import DendrogramViewer # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestDendrogram(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_histogram.py b/glue_plotly/html_exporters/qt/tests/test_histogram.py index fce34cd..317a2a8 100644 --- a/glue_plotly/html_exporters/qt/tests/test_histogram.py +++ b/glue_plotly/html_exporters/qt/tests/test_histogram.py @@ -6,9 +6,9 @@ importorskip('glue_qt') -from glue_qt.viewers.histogram import HistogramViewer # noqa +from glue_qt.viewers.histogram import HistogramViewer # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestHistogram(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_image.py b/glue_plotly/html_exporters/qt/tests/test_image.py index 51c5746..896f527 100644 --- a/glue_plotly/html_exporters/qt/tests/test_image.py +++ b/glue_plotly/html_exporters/qt/tests/test_image.py @@ -6,11 +6,11 @@ importorskip('glue_qt') -from glue_qt.viewers.image.data_viewer import ImageViewer # noqa +from glue_qt.viewers.image.data_viewer import ImageViewer # noqa: E402 -from numpy import arange, ones # noqa +from numpy import arange, ones # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestImage(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_profile.py b/glue_plotly/html_exporters/qt/tests/test_profile.py index d5e94fc..c7fe449 100644 --- a/glue_plotly/html_exporters/qt/tests/test_profile.py +++ b/glue_plotly/html_exporters/qt/tests/test_profile.py @@ -6,9 +6,9 @@ importorskip('glue_qt') -from glue_qt.viewers.profile import ProfileViewer # noqa +from glue_qt.viewers.profile import ProfileViewer # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestProfile(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_scatter2d.py b/glue_plotly/html_exporters/qt/tests/test_scatter2d.py index 7b6303c..b7f3214 100644 --- a/glue_plotly/html_exporters/qt/tests/test_scatter2d.py +++ b/glue_plotly/html_exporters/qt/tests/test_scatter2d.py @@ -6,9 +6,9 @@ importorskip('glue_qt') -from glue_qt.viewers.scatter import ScatterViewer # noqa +from glue_qt.viewers.scatter import ScatterViewer # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestScatter2D(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_scatter3d.py b/glue_plotly/html_exporters/qt/tests/test_scatter3d.py index 001b96b..6ae1559 100644 --- a/glue_plotly/html_exporters/qt/tests/test_scatter3d.py +++ b/glue_plotly/html_exporters/qt/tests/test_scatter3d.py @@ -6,9 +6,9 @@ pytest.importorskip('glue_vispy_viewers') -from glue_vispy_viewers.scatter.scatter_viewer import VispyScatterViewer # noqa +from glue_vispy_viewers.scatter.scatter_viewer import VispyScatterViewer # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestScatter3D(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_table.py b/glue_plotly/html_exporters/qt/tests/test_table.py index c030b7b..9bd7116 100644 --- a/glue_plotly/html_exporters/qt/tests/test_table.py +++ b/glue_plotly/html_exporters/qt/tests/test_table.py @@ -6,10 +6,10 @@ importorskip('glue_qt') -from glue_qt.app import GlueApplication # noqa -from glue_qt.viewers.table import TableViewer # noqa +from glue_qt.app import GlueApplication # noqa: E402 +from glue_qt.viewers.table import TableViewer # noqa: E402 -from .test_base import TestQtExporter # noqa +from .test_base import TestQtExporter # noqa: E402 class TestTable(TestQtExporter): diff --git a/glue_plotly/html_exporters/qt/tests/test_volume.py b/glue_plotly/html_exporters/qt/tests/test_volume.py new file mode 100644 index 0000000..bb2372b --- /dev/null +++ b/glue_plotly/html_exporters/qt/tests/test_volume.py @@ -0,0 +1,30 @@ +import os + +from glue.core import Data + +from pytest import importorskip + +importorskip('glue_qt') +importorskip('glue_vispy_viewers') + +from glue_vispy_viewers.volume.volume_viewer import VispyVolumeViewer # noqa: E402 + +from numpy import arange, ones # noqa: E402 + +from .test_base import TestQtExporter # noqa: E402 + + +class TestVolume(TestQtExporter): + + viewer_type = VispyVolumeViewer + tool_id = 'save:plotlyvolume' + + def make_data(self): + return Data(label='d1', + x=arange(24).reshape((2, 3, 4)), + y=ones((2, 3, 4)), + z=arange(100, 124).reshape((2, 3, 4))) + + def test_default(self, tmpdir): + output_path = self.export_figure(tmpdir, 'test.html') + assert os.path.exists(output_path) diff --git a/glue_plotly/html_exporters/qt/utils.py b/glue_plotly/html_exporters/qt/utils.py new file mode 100644 index 0000000..aea468d --- /dev/null +++ b/glue_plotly/html_exporters/qt/utils.py @@ -0,0 +1,77 @@ +from echo.qt import connect_checkable_button, connect_float_text + +from glue.core import Subset + +from qtpy.QtWidgets import QCheckBox, QHBoxLayout, QLabel, QLineEdit +from qtpy.QtGui import QIntValidator, QDoubleValidator + + +def display_name(prop_name): + return prop_name.replace("_", " ").capitalize() + + +def layer_label(layer): + label = layer.layer.label + if isinstance(layer.layer, Subset): + label += f" ({layer.layer.data.label})" + return label + + +def clear_layout(layout): + if layout is not None: + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + else: + clear_layout(item.layout()) + + +def widgets_for_property(instance, property, display_name=None): + value = getattr(instance, property) + t = type(value) + connections = [] + widgets = [] + display_name = display_name or property + if t is bool: + widget = QCheckBox() + widget.setChecked(value) + widget.setText(display_name) + connections.append(connect_checkable_button(instance, property, widget)) + widgets.append(widget) + elif t in [int, float]: + label = QLabel() + prompt = f"{display_name}:" + label.setText(prompt) + widget = QLineEdit() + validator = QIntValidator() if t is int else QDoubleValidator() + widget.setText(str(value)) + widget.setValidator(validator) + connections.append(connect_float_text(instance, property, widget)) + widgets.extend((label, widget)) + + return connections, widgets + + +def widgets_for_state(state): + connections = [] + widgets = [] + if state is not None: + for property in state.callback_properties(): + conns, wdgts = widgets_for_property(state, property, display_name(property)) + connections.extend(conns) + widgets.extend(wdgts) + + return connections, widgets + + +def update_layout_for_state(layout, state): + clear_layout(layout) + connections, widgets = widgets_for_state(state) + for widget in widgets: + row = QHBoxLayout() + row.addWidget(widget) + layout.addRow(row) + + return connections diff --git a/glue_plotly/html_exporters/qt/volume.py b/glue_plotly/html_exporters/qt/volume.py new file mode 100644 index 0000000..09e0f27 --- /dev/null +++ b/glue_plotly/html_exporters/qt/volume.py @@ -0,0 +1,70 @@ +from qtpy import compat +from qtpy.QtWidgets import QDialog + +from glue.config import viewer_tool +from glue_qt.utils import messagebox_on_error +from glue_qt.utils.threading import Worker +from glue_qt.viewers.common.tool import Tool +from glue_vispy_viewers.scatter.layer_artist import ScatterLayerArtist + +from glue_plotly import PLOTLY_ERROR_MESSAGE, PLOTLY_LOGO, export_dialog, volume_options +from glue_plotly.common import data_count, layers_to_export +from glue_plotly.common.base_3d import layout_config +from glue_plotly.common.scatter3d import traces_for_layer as scatter3d_traces_for_layer +from glue_plotly.common.volume import traces_for_layer as volume_traces_for_layer + +from plotly.offline import plot +import plotly.graph_objs as go + + +@viewer_tool +class PlotlyVolumeStaticExport(Tool): + icon = PLOTLY_LOGO + tool_id = 'save:plotlyvolume' + action_text = 'Save Plotly HTML page' + tool_tip = 'Save Plotly HTML page' + + @messagebox_on_error(PLOTLY_ERROR_MESSAGE) + def _export_to_plotly(self, filename, state_dictionary): + + config = layout_config(self.viewer.state) + layout = go.Layout(**config) + fig = go.Figure(layout=layout) + + layers = layers_to_export(self.viewer) + add_data_label = data_count(layers) > 1 + bounds = self.viewer._vispy_widget._multivol._data_bounds + for layer in layers: + if isinstance(layer, ScatterLayerArtist): + traces = scatter3d_traces_for_layer(self.viewer.state, layer.state, + add_data_label=add_data_label) + else: + options = state_dictionary[layer.layer.label] + count = int(options.isosurface_count) + traces = volume_traces_for_layer(self.viewer.state, layer.state, bounds, + isosurface_count=count, + add_data_label=add_data_label) + + for trace in traces: + fig.add_trace(trace) + + plot(fig, filename=filename, auto_open=False) + + def activate(self): + + dialog = volume_options.VolumeOptionsDialog(viewer=self.viewer) + result = dialog.exec_() + if result == QDialog.Rejected: + return + + filename, _ = compat.getsavefilename( + parent=self.viewer, basedir="plot.html") + if not filename: + return + + worker = Worker(self._export_to_plotly, filename, dialog.state_dictionary) + exp_dialog = export_dialog.ExportDialog(parent=self.viewer) + worker.result.connect(exp_dialog.close) + worker.error.connect(exp_dialog.close) + worker.start() + exp_dialog.exec_() diff --git a/glue_plotly/utils.py b/glue_plotly/utils.py index 00ab11e..6622cf7 100644 --- a/glue_plotly/utils.py +++ b/glue_plotly/utils.py @@ -46,7 +46,7 @@ def rgba_string_to_values(rgba_str): if not m or len(m.groups()) != 4: raise ValueError("Invalid RGBA expression") r, g, b, a = m.groups() - return [r, g, b, a] + return [int(t) for t in (r, g, b, a)] def is_rgba_hex(color): @@ -54,8 +54,34 @@ def is_rgba_hex(color): def is_rgb_hex(color): - return color.starswith("#") and len(color) == 7 + return color.startswith("#") and len(color) == 7 def rgba_hex_to_rgb_hex(color): return color[:-2] + + +def hex_string(number): + return format(number, '02x') + + +def rgb_hex_to_rgba_hex(color, opacity=1): + return f"{color}{hex_string(opacity)}" + + +def hex_to_components(color): + return [int(color[idx:idx+2], 16) for idx in range(1, len(color), 2)] + + +def rgba_components(color): + if is_rgb_hex(color): + color = rgb_hex_to_rgba_hex(color) + if is_rgba_hex(color): + return hex_to_components(color) + else: + return rgba_string_to_values(color) + + +def components_to_hex(r, g, b, a=None): + components = [hex_string(t) for t in (r, g, b, a) if t is not None] + return f"#{''.join(components)}" diff --git a/glue_plotly/viewers/common/tests/test_tools.py b/glue_plotly/viewers/common/tests/test_tools.py index 72c23f7..9ed8d2b 100644 --- a/glue_plotly/viewers/common/tests/test_tools.py +++ b/glue_plotly/viewers/common/tests/test_tools.py @@ -86,7 +86,6 @@ def test_home(self): self.viewer.state.y_max = 13 tool = self.get_tool('plotly:home') tool.activate() - print(self.viewer.state) assert self.viewer.state.x_min == xmin assert self.viewer.state.x_max == xmax assert self.viewer.state.y_min == ymin diff --git a/glue_plotly/volume_options.py b/glue_plotly/volume_options.py new file mode 100644 index 0000000..9bf4543 --- /dev/null +++ b/glue_plotly/volume_options.py @@ -0,0 +1,63 @@ +import os +from glue_plotly.html_exporters.qt.utils import update_layout_for_state + +from qtpy.QtWidgets import QDialog + +from echo import SelectionCallbackProperty +from echo.qt import autoconnect_callbacks_to_qt + +from glue.core.data_combo_helper import ComboHelper +from glue.core.state_objects import State +from glue_qt.utils import load_ui + +from glue_plotly.html_exporters.qt.options_state import qt_export_options +from glue_plotly.html_exporters.qt.utils import layer_label + + +__all__ = ["VolumeOptionsDialog"] + + +class VolumeDialogState(State): + + layer = SelectionCallbackProperty() + + def __init__(self, layers): + super(VolumeDialogState, self).__init__() + + self.layers = layers + self.layer_helper = ComboHelper(self, 'layer') + self.layer_helper.choices = [layer_label(state) for state in self.layers] + + +class VolumeOptionsDialog(QDialog): + + def __init__(self, parent=None, viewer=None): + + super(VolumeOptionsDialog, self).__init__(parent=parent) + + self.viewer = viewer + layers = [layer for layer in self.viewer.layers if layer.enabled and layer.state.visible] + self.state = VolumeDialogState(layers) + self.ui = load_ui('volume_options.ui', self, directory=os.path.dirname(__file__)) + self._connections = autoconnect_callbacks_to_qt(self.state, self.ui) + + self.state_dictionary = { + layer_label(layer): self.state_for_layer(layer) + for layer in layers + } + + self.ui.button_cancel.clicked.connect(self.reject) + self.ui.button_ok.clicked.connect(self.accept) + + self.state.add_callback('layer', self._on_layer_change) + + self._on_layer_change(self.state.layer) + + def state_for_layer(self, layer): + t = qt_export_options.members.get(type(layer.state), None) + if t: + return t() + return None + + def _on_layer_change(self, layer): + self._layer_connections = update_layout_for_state(self.ui.layer_layout, self.state_dictionary.get(layer, None)) diff --git a/glue_plotly/volume_options.ui b/glue_plotly/volume_options.ui new file mode 100644 index 0000000..ba82682 --- /dev/null +++ b/glue_plotly/volume_options.ui @@ -0,0 +1,76 @@ + + + Dialog + + + + 0 + 0 + 318 + 411 + + + + Export 3D Volume + + + + + + + + + The Plotly volume exporter uses isosurfaces to represent volumes. The number of isosurfaces used can be set differently for each layer. + + + true + + + + + + + + + + Qt::Horizontal + + + + 110 + 20 + + + + + + + + Cancel + + + false + + + + + + + Export + + + true + + + + + + + + + + + + + +