diff --git a/glue_jupyter/__init__.py b/glue_jupyter/__init__.py index cf8acb99..699e1317 100755 --- a/glue_jupyter/__init__.py +++ b/glue_jupyter/__init__.py @@ -75,7 +75,7 @@ def jglue(*args, settings=None, show=False, links=None, **kwargs): return japp -def example_data_xyz(seed=42, N=500, loc=0, scale=1, label='xyz'): +def example_data_xyz(seed=42, N=500, loc=0, scale=1, label='xyz', log=False): """ Create an example dataset with three attributes x, y, and z set to random values. @@ -87,6 +87,13 @@ def example_data_xyz(seed=42, N=500, loc=0, scale=1, label='xyz'): vx = x - x.mean() vy = y - y.mean() vz = z - z.mean() + if log: + x = 10**x + y = 10**y + z = 10**z + vx = 10**vx + vy = 10**vy + vz = 10**vz speed = np.sqrt(vx**2 + vy**2 + vz**2) data_xyz = Data(x=x, y=y, z=z, vx=vx, vy=vy, vz=vz, speed=speed, label=label) return data_xyz diff --git a/glue_jupyter/bqplot/common/reactviewer.py b/glue_jupyter/bqplot/common/reactviewer.py new file mode 100644 index 00000000..96cc14ee --- /dev/null +++ b/glue_jupyter/bqplot/common/reactviewer.py @@ -0,0 +1,227 @@ +from typing import Dict, Type +from functools import partial + +import bqplot +import react_ipywidgets as react +import react_ipywidgets.bqplot as bq +from bqplot_image_gl.interacts import MouseInteraction, keyboard_events, mouse_events +from echo.callback_container import CallbackContainer +from glue.core.command import ApplySubsetState +from glue.core.subset import roi_to_subset_state +from glue.viewers.common.state import State + +from ...common.hooks import use_echo_state, use_layer_watch + +from ...view import IPyWidgetView +from .tools import ROIClickAndDrag + + +def create_scale(viewer_state, name): + is_log, set_x_log = use_echo_state(viewer_state, f"{name}_log") + v_min, set_v_min = use_echo_state(viewer_state, f"{name}_min") + v_max, set_v_max = use_echo_state(viewer_state, f"{name}_max") + + def cast(v): + return float(v) if v is not None else None + if is_log: + scale = bq.LogScale(min=cast(v_min), max=cast(v_max), allow_padding=False).shared() + else: + scale = bq.LinearScale(min=cast(v_min), max=cast(v_max), allow_padding=False).shared() + return scale + + +@react.component +def Figure( + viewer: IPyWidgetView, + viewer_state, + is2d, + components: Dict[Type[State], react.core.Component], +): + use_layer_watch(viewer) # will simply trigger when the layers change + + scale_x = create_scale(viewer_state, "x") + scale_y = create_scale(viewer_state, "y") + + x_att, _ = use_echo_state(viewer_state, "x_att") + try: + # Extract units from data + x_unit = viewer_state.reference_data.get_component( + viewer_state.x_att_world + ).units + except AttributeError: + # If no data loaded yet, ignore units + x_unit = "" + finally: + # Append units to axis label + label_x = str(x_att) + " " + str(x_unit) + + if is2d: # use is conditional, which is ok because is2d will not change + y_att, _ = use_echo_state(viewer_state, "y_att") + label_y = str(y_att) + try: + y_unit = viewer_state.reference_data.get_component( + viewer_state.y_att_world + ).units + except AttributeError: + y_unit = "" + finally: + label_y = str(y_att) + " " + str(y_unit) + else: + label_y = "no label" + + marks = [ + components[type(state)](scale_x, scale_y, viewer_state, state) + for state in viewer_state.layers + if not state.disabled + ] + + show_axes, _ = use_echo_state(viewer_state, "show_axes") + axis_x = bq.Axis(scale=scale_x, grid_lines="none", label=label_x, visible=show_axes) + axis_y = bq.Axis( + scale=scale_y, + orientation="vertical", + tick_format="0.2f", + grid_lines="none", + label=label_y, + visible=show_axes, + ) + + if show_axes: + fig_margin = {"left": 60, "bottom": 60, "top": 10, "right": 10} + else: + fig_margin = {"left": 0, "bottom": 0, "top": 10, "right": 10} + + mouse_interact = MouseInteraction.element( + x_scale=scale_x, y_scale=scale_y, move_throttle=70, events=[] + ) + return bq.Figure( + scale_x=scale_x, + scale_y=scale_y, + animation_duration=0, + marks=marks, + interaction=mouse_interact, + axes=[axis_x, axis_y], + fig_margin=fig_margin, + padding_y=0, + ) + + +class BqplotBaseViewReact(IPyWidgetView): + + allow_duplicate_data = False + allow_duplicate_subset = False + is2d = True + _default_mouse_mode_cls = ROIClickAndDrag + + def initialize_figure(self): + self.figure_el = Figure( + self, self.state, is2d=self.is2d, components=self.components + ) + self._figure: bqplot.Figure = react.render_fixed(self.figure_el, handle_error=False)[0] + self.scale_x = self._figure.scale_x + self.scale_y = self._figure.scale_y + self.scales = {"x": self.scale_x, "y": self.scale_y} + self._mouse_interact = self._figure.interaction + # Set up a MouseInteraction instance here tied to the figure. In the + # tools we then chain this with any other active interact so that we can + # always listen for certain events. This allows us to then have e.g. + # mouse-over coordinates regardless of whether tools are active or not. + self._event_callbacks = CallbackContainer() + self._events_for_callback = {} + + @property + def figure_widget(self): + return self._figure + + def apply_roi(self, roi, use_current=False): + # TODO: partial copy paste from glue/viewers/matplotlib/qt/data_viewer.py + # with self._output_widget: + + if len(self.layers) > 0: + subset_state = self._roi_to_subset_state(roi) + cmd = ApplySubsetState( + data_collection=self._data, + subset_state=subset_state, + override_mode=use_current, + ) + self._session.command_stack.do(cmd) + + def _roi_to_subset_state(self, roi): + # TODO: copy paste from glue/viewers/image/qt/data_viewer.py#L66 + + # next lines don't work.. comp has no datetime? + # x_date = any(comp.datetime for comp in self.state._get_x_components()) + # y_date = any(comp.datetime for comp in self.state._get_y_components()) + + # if x_date or y_date: + # roi = roi.transformed(xfunc=mpl_to_datetime64 if x_date else None, + # yfunc=mpl_to_datetime64 if y_date else None) + if self.is2d: + return roi_to_subset_state( + roi, x_att=self.state.x_att, y_att=self.state.y_att + ) + + def add_event_callback(self, callback, events=None): + """ + Add a callback function for mouse and keyboard events when the mouse is over the figure. + + Parameters + ---------- + callback : func + The callback function. This should take a single argument which is a + dictionary containing the event details. One of the keys of the + dictionary is ``event`` which is a string that describes the event + (see the ``events`` parameter for possible strings). The rest of the + dictionary depends on the specific event triggered. + events : list, optional + The list of events to listen for. The following events are available: + + * ``'click'`` + * ``'dblclick'`` + * ``'mouseenter'`` + * ``'mouseleave'`` + * ``'contextmenu'`` + * ``'mousemove'`` + * ``'keydown'`` + * ``'keyup'`` + + If this parameter is not passed, all events will be listened for. + """ + + if events is None: + events = keyboard_events + mouse_events + + self._event_callbacks.append(callback) + key = self._callback_key(callback) + self._events_for_callback[key] = set(events) + self._update_interact_events() + + def remove_event_callback(self, callback): + """ + Remove a callback function for mouse and keyboard events. + """ + key = self._callback_key(callback) + self._events_for_callback.pop(key) + self._event_callbacks.remove(callback) + self._update_interact_events() + + def _update_interact_events(self): + events = set() + for individual_events in self._events_for_callback.values(): + events |= individual_events + events = sorted(events) + self._mouse_interact.events = sorted(events) + + def _on_mouse_interaction(self, interaction, data, buffers): + for callback in self._event_callbacks: + key = self._callback_key(callback) + events = self._events_for_callback.get(key, []) + if data["event"] in events: + callback(data) + + def _callback_key(self, callback): + if CallbackContainer.is_bound_method(callback): + return (callback.__func__, (callback.__self__,)) + elif isinstance(callback, partial): + return (callback.func, callback.args) + return callback diff --git a/glue_jupyter/bqplot/common/viewer.py b/glue_jupyter/bqplot/common/viewer.py index 92bbed58..c16df997 100644 --- a/glue_jupyter/bqplot/common/viewer.py +++ b/glue_jupyter/bqplot/common/viewer.py @@ -24,7 +24,9 @@ class BqplotBaseView(IPyWidgetView): _default_mouse_mode_cls = ROIClickAndDrag def __init__(self, session, state=None): + super(BqplotBaseView, self).__init__(session, state=state) + def initialize_figure(self): # if we allow padding, we sometimes get odd behaviour with the interacts self.scale_x = bqplot.LinearScale(min=0, max=1, allow_padding=False) self.scale_y = bqplot.LinearScale(min=0, max=1) @@ -58,8 +60,7 @@ def __init__(self, session, state=None): self.figure.interaction = self._mouse_interact self._events_for_callback = {} - super(BqplotBaseView, self).__init__(session, state=state) - + def create_layout(self): # Remove the following two lines once glue v0.16 is required - see # https://github.com/glue-viz/glue/pull/2099/files for more information. self.state.remove_callback('layers', self._sync_layer_artist_container) @@ -99,7 +100,7 @@ def update_axes(*ignore): on_change([(self.state, 'show_axes')])(self._sync_show_axes) - self.create_layout() + super().create_layout() def _update_bqplot_limits(self, *args): diff --git a/glue_jupyter/bqplot/histogram/layer_artist.py b/glue_jupyter/bqplot/histogram/layer_artist.py index 5b750c21..97e5667b 100644 --- a/glue_jupyter/bqplot/histogram/layer_artist.py +++ b/glue_jupyter/bqplot/histogram/layer_artist.py @@ -1,12 +1,6 @@ -import numpy as np -import bqplot - -from glue.core.exceptions import IncompatibleAttribute from glue.viewers.histogram.state import HistogramLayerState -from glue.viewers.common.layer_artist import LayerArtist -from glue.utils import color2hex -from ...link import link, dlink +from glue.viewers.common.layer_artist import LayerArtist __all__ = ['BqplotHistogramLayerArtist'] @@ -14,135 +8,10 @@ class BqplotHistogramLayerArtist(LayerArtist): _layer_state_cls = HistogramLayerState + # component = Histogram def __init__(self, view, viewer_state, layer_state=None, layer=None): - super(BqplotHistogramLayerArtist, self).__init__(viewer_state, - layer_state=layer_state, layer=layer) - - self.view = view - - self.bars = bqplot.Bars( - scales=self.view.scales, x=[0, 1], y=[0, 1]) - - self.view.figure.marks = list(self.view.figure.marks) + [self.bars] - - dlink((self.state, 'color'), (self.bars, 'colors'), lambda x: [color2hex(x)]) - dlink((self.state, 'alpha'), (self.bars, 'opacities'), lambda x: [x]) - - self._viewer_state.add_global_callback(self._update_histogram) - self.state.add_global_callback(self._update_histogram) - self.bins = None - - link((self.state, 'visible'), (self.bars, 'visible')) - - def remove(self): - marks = self.view.figure.marks[:] - marks.remove(self.bars) - self.bars = None - self.view.figure.marks = marks - return super().remove() - - def _update_xy_att(self, *args): - self.update() - - def _calculate_histogram(self): - try: - self.bins, self.hist_unscaled = self.state.histogram - except IncompatibleAttribute: - self.disable('Could not compute histogram') - self.bins = self.hist_unscaled = None - - def _scale_histogram(self): - # TODO: comes from glue/viewers/histogram/layer_artist.py - if self.bins is None: - return # can happen when the subset is empty - - if self.bins.size == 0 or self.hist_unscaled.sum() == 0: - return - - self.hist = self.hist_unscaled.astype(float) - dx = self.bins[1] - self.bins[0] - - if self._viewer_state.cumulative: - self.hist = self.hist.cumsum() - if self._viewer_state.normalize: - self.hist /= self.hist.max() - elif self._viewer_state.normalize: - self.hist /= (self.hist.sum() * dx) - - # TODO this won't work for log ... - centers = (self.bins[:-1] + self.bins[1:]) / 2 - assert len(centers) == len(self.hist) - self.bars.x = centers - self.bars.y = self.hist - - # We have to do the following to make sure that we reset the y_max as - # needed. We can't simply reset based on the maximum for this layer - # because other layers might have other values, and we also can't do: - # - # self._viewer_state.y_max = max(self._viewer_state.y_max, result[0].max()) - # - # because this would never allow y_max to get smaller. - - self.state._y_max = self.hist.max() - if self._viewer_state.y_log: - self.state._y_max *= 2 - else: - self.state._y_max *= 1.2 - - if self._viewer_state.y_log: - self.state._y_min = self.hist[self.hist > 0].min() / 10 - else: - self.state._y_min = 0 - - largest_y_max = max(getattr(layer, '_y_max', 0) - for layer in self._viewer_state.layers) - if largest_y_max != self._viewer_state.y_max: - self._viewer_state.y_max = largest_y_max - - smallest_y_min = min(getattr(layer, '_y_min', np.inf) - for layer in self._viewer_state.layers) - if smallest_y_min != self._viewer_state.y_min: - self._viewer_state.y_min = smallest_y_min - - self.redraw() - - def _update_visual_attributes(self): - - if not self.enabled: - return - # TODO: set visual attrs - self.redraw() - - def _update_histogram(self, force=False, **kwargs): - - # TODO: comes from glue/viewers/histogram/layer_artist.py - - if (self.bars is None or - self._viewer_state.hist_x_min is None or - self._viewer_state.hist_x_max is None or - self._viewer_state.hist_n_bin is None or - self._viewer_state.x_att is None or - self.state.layer is None): - return - - # NOTE: we need to evaluate this even if force=True so that the cache - # of updated properties is up to date after this method has been called. - changed = self.pop_changed_properties() - - if force or any(prop in changed for prop in ('layer', 'x_att', 'hist_x_min', - 'hist_x_max', 'hist_n_bin', 'x_log')): - self._calculate_histogram() - force = True # make sure scaling and visual attributes are updated - - if force or any(prop in changed for prop in ('y_log', 'normalize', 'cumulative')): - self._scale_histogram() - - if force or any(prop in changed for prop in ('alpha', 'color', 'zorder', 'visible')): - self._update_visual_attributes() - - def update(self): - self.state.reset_cache() - self._update_histogram(force=True) - self.redraw() + super(BqplotHistogramLayerArtist, self).__init__( + viewer_state, layer_state=layer_state, layer=layer + ) diff --git a/glue_jupyter/bqplot/histogram/tests/test_viewer.py b/glue_jupyter/bqplot/histogram/tests/test_viewer.py index 56a22794..ea5955d4 100644 --- a/glue_jupyter/bqplot/histogram/tests/test_viewer.py +++ b/glue_jupyter/bqplot/histogram/tests/test_viewer.py @@ -1,3 +1,7 @@ +import glue_jupyter as gj +from glue.core import Data + + def test_non_hex_colors(app, dataxyz): # Make sure non-hex colors such as '0.4' and 'red', which are valid @@ -17,8 +21,22 @@ def test_remove(app, dataxz, dataxyz): s = app.histogram1d(data=dataxyz) s.add_data(dataxz) app.data_collection.new_subset_group(subset_state=dataxz.id['x'] > 1, label='test') - assert len(s.figure.marks) == 4 + assert len(s.figure_widget.marks) == 4 s.remove_data(dataxyz) - assert len(s.figure.marks) == 2 + assert len(s.figure_widget.marks) == 2 s.remove_data(dataxz) - assert len(s.figure.marks) == 0 + assert len(s.figure_widget.marks) == 0 + + +def test_normalize(): + data = Data(x=[1, 2, 3, 4], label="x data") + app = gj.jglue(data=data) + s = app.histogram1d(data=data) + s.state.hist_x_min = 0 + s.state.hist_x_max = 5 + s.state.hist_n_bin = 5 + assert len(s.figure_widget.marks) == 1 + assert s.figure_widget.marks[0].x.tolist() == [0.5, 1.5, 2.5, 3.5, 4.5] + assert s.figure_widget.marks[0].y.tolist() == [1.0, 1.0, 1.0, 1.0, 0.0] + s.state.normalize = True + assert s.figure_widget.marks[0].y.tolist() == [1/4., 1/4., 1/4., 1/4., 0.0] diff --git a/glue_jupyter/bqplot/histogram/viewer.py b/glue_jupyter/bqplot/histogram/viewer.py index 76c2ff9b..f2dc2709 100644 --- a/glue_jupyter/bqplot/histogram/viewer.py +++ b/glue_jupyter/bqplot/histogram/viewer.py @@ -1,18 +1,129 @@ from glue.core.subset import roi_to_subset_state from glue.core.roi import RangeROI + +from glue.viewers.histogram.state import HistogramLayerState from glue.viewers.histogram.state import HistogramViewerState +from glue_jupyter.common.state_widgets.layer_histogram import HistogramLayerStateWidget +from glue_jupyter.common.state_widgets.viewer_histogram import ( + HistogramViewerStateWidget, +) + +from glue.core.exceptions import IncompatibleAttribute +from glue.utils import color2hex -from ..common.viewer import BqplotBaseView from .layer_artist import BqplotHistogramLayerArtist -from glue_jupyter.common.state_widgets.layer_histogram import HistogramLayerStateWidget -from glue_jupyter.common.state_widgets.viewer_histogram import HistogramViewerStateWidget +from ..common.reactviewer import BqplotBaseViewReact +from ...common.hooks import use_echo_state + +import react_ipywidgets as react +import react_ipywidgets.bqplot as bq + + +import numpy as np __all__ = ['BqplotHistogramView'] -class BqplotHistogramView(BqplotBaseView): +def calculate_bins( + viewer_state: HistogramViewerState, layer_state: HistogramLayerState +): + use_echo_state(viewer_state, "hist_x_max") + use_echo_state(viewer_state, "hist_x_min") + use_echo_state(viewer_state, "hist_n_bin") + use_echo_state(viewer_state, "cumulative") + use_echo_state(viewer_state, "normalize") + disabled, set_disabled = use_echo_state(layer_state, "disabled") + disabled_reason, set_disabled_reason = use_echo_state( + layer_state, "disabled_reason" + ) + # layer_state.reset_cache() + + try: + bins, hist_unscaled = layer_state.histogram + set_disabled(False) + except IncompatibleAttribute: + bins = None + set_disabled(True) + set_disabled_reason("Could not compute histogram") + + if bins is None: + return # can happen when the subset is empty + + if bins.size == 0 or hist_unscaled.sum() == 0: + return + + hist = hist_unscaled.astype(np.float) + dx = bins[1] - bins[0] + + if viewer_state.cumulative: + hist = hist.cumsum() + if viewer_state.normalize: + hist /= hist.max() + elif viewer_state.normalize: + hist /= hist.sum() * dx + + # TODO this won't work for log ... + centers = (bins[:-1] + bins[1:]) / 2 + assert len(centers) == len(hist) + x = centers + y = hist + # We have to do the following to make sure that we reset the y_max as + # needed. We can't simply reset based on the maximum for this layer + # because other layers might have other values, and we also can't do: + # + # viewer_state.y_max = max(viewer_state.y_max, result[0].max()) + # + # because this would never allow y_max to get smaller. + + layer_state._y_max = hist.max() + if viewer_state.y_log: + layer_state._y_max *= 2 + else: + layer_state._y_max *= 1.2 + + if viewer_state.y_log: + layer_state._y_min = hist[hist > 0].min() / 10 + else: + layer_state._y_min = 0 + + largest_y_max = max(getattr(layer, "_y_max", 0) for layer in viewer_state.layers) + if largest_y_max != viewer_state.y_max: + viewer_state.y_max = largest_y_max + + smallest_y_min = min( + getattr(layer, "_y_min", np.inf) for layer in viewer_state.layers + ) + if smallest_y_min != viewer_state.y_min: + viewer_state.y_min = smallest_y_min + + return x, y + + +@react.component +def Histogram( + scale_x, + scale_y, + viewer_state: HistogramViewerState, + layer_state: HistogramLayerState, +): + visible, set_visible = use_echo_state(layer_state, "visible") + color, set_color = use_echo_state(layer_state, "color") + alpha, set_color = use_echo_state(layer_state, "alpha") + scales = dict(x=scale_x, y=scale_y) + bins = calculate_bins(viewer_state, layer_state) + if bins is None: + x = y = [0, 1] # dummy data, in case we cannot bin + else: + x, y = bins + + bars = bq.Bars(scales=scales, x=x, y=y, colors=[color2hex(color)], visible=visible, + opacities=[alpha]) + return bars + + +class BqplotHistogramView(BqplotBaseViewReact): allow_duplicate_data = False allow_duplicate_subset = False large_data_size = 1e5 @@ -24,7 +135,9 @@ class BqplotHistogramView(BqplotBaseView): _subset_artist_cls = BqplotHistogramLayerArtist _layer_style_widget_cls = HistogramLayerStateWidget - tools = ['bqplot:home', 'bqplot:panzoom', 'bqplot:xrange'] + tools = ["bqplot:home", "bqplot:panzoom", "bqplot:xrange"] + + components = {HistogramLayerState: Histogram} def _roi_to_subset_state(self, roi): # TODO: copy paste from glue/viewers/histogram/qt/data_viewer.py @@ -40,6 +153,9 @@ def _roi_to_subset_state(self, roi): if hi <= bins.max(): hi = bins[bins >= hi].min() - roi_new = RangeROI(min=lo, max=hi, orientation='x') + roi_new = RangeROI(min=lo, max=hi, orientation="x") return roi_to_subset_state(roi_new, x_att=self.state.x_att) + + def redraw(self): + pass # i don't think we need to do anything diff --git a/glue_jupyter/bqplot/tests/test_bqplot.py b/glue_jupyter/bqplot/tests/test_bqplot.py index e9f8435f..692df5c5 100644 --- a/glue_jupyter/bqplot/tests/test_bqplot.py +++ b/glue_jupyter/bqplot/tests/test_bqplot.py @@ -1,6 +1,7 @@ import os import nbformat import numpy as np +import bqplot from numpy.testing import assert_allclose from nbconvert.preprocessors import ExecutePreprocessor from glue.core import Data @@ -17,30 +18,42 @@ def test_histogram1d(app, dataxyz): s.state.hist_x_min = 1.5 s.state.hist_x_max = 4.5 s.state.hist_n_bin = 3 - assert s.layers[0].bins.tolist() == [1.5, 2.5, 3.5, 4.5] - assert s.layers[0].hist.tolist() == [1, 1, 1] + assert s.figure_widget.marks[0].x.tolist() == [2, 3, 4] + assert s.figure_widget.marks[0].y.tolist() == [1, 1, 1] app.subset('test', dataxyz.id['x'] > 1) assert len(s.layers) == 2 assert s.layers[1].layer['y'].tolist() == [3, 4] - assert s.layers[1].bins.tolist() == [1.5, 2.5, 3.5, 4.5] - assert s.layers[1].hist.tolist() == [0, 1, 1] + assert s.figure_widget.marks[1].x.tolist() == [2, 3, 4] + assert s.figure_widget.marks[1].y.tolist() == [0, 1, 1] tool = s.toolbar.tools['bqplot:xrange'] tool.activate() tool.interact.brushing = True - tool.interact.selected = [2.5, 3.5] + tool.interact.selected = [2.5, 4.5] tool.interact.brushing = False assert len(s.layers) == 3 - assert s.layers[2].bins.tolist() == [1.5, 2.5, 3.5, 4.5] - assert s.layers[2].hist.tolist() == [0, 1, 0] + # what should happen? empty selection + # assert s.figure_widget.marks[2].x.tolist() == [1.5, 2.5, 3.5, 4.5] + # assert s.figure_widget.marks[2].y.tolist() == [0, 1, 0] # s.state.hist_n_bin = 6 # assert s.layers[2].bins.tolist() == [1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5] # assert s.layers[2].hist.tolist() == [0, 1, 0, 0, 0, 0] +def test_histogram1d_log(app, dataxyz): + s = app.histogram1d(x='y', data=dataxyz) + assert s.state.y_log is False + prev_scale = s.scale_y + assert isinstance(s.figure_widget.scale_y, bqplot.LinearScale) + s.state.y_log = True + assert isinstance(s.figure_widget.scale_y, bqplot.LogScale) + s.state.y_log = False + assert s.scale_y is prev_scale + + def test_histogram1d_multiple_subsets(app, data_unlinked, datax): # Make sure that things work fine if an incompatible subset is added viewer = app.histogram1d(x='x', data=datax) @@ -48,7 +61,9 @@ def test_histogram1d_multiple_subsets(app, data_unlinked, datax): app.subset('test2', data_unlinked.id['a'] > 1) assert viewer.layers[0].enabled assert viewer.layers[1].enabled - assert not viewer.layers[2].enabled + assert not viewer.layers[0].state.disabled + assert not viewer.layers[1].state.disabled + assert viewer.layers[2].state.disabled def test_interact(app, dataxyz): diff --git a/glue_jupyter/common/hooks.py b/glue_jupyter/common/hooks.py new file mode 100644 index 00000000..06b14610 --- /dev/null +++ b/glue_jupyter/common/hooks.py @@ -0,0 +1,44 @@ +from glue.viewers.common.viewer import Viewer +from glue.viewers.common.state import State +import react_ipywidgets as react + + +def use_echo_state(state: State, name): + value, set_value = react.use_state(getattr(state, name), key=name) + + def add_event_handler(): + def handler(new_value): + set_value(new_value) + + def cleanup(): + state.remove_callback(name, handler) + + state.add_callback(name, handler) + return cleanup + + react.use_effect(add_event_handler) + + def set_value_sync(new_value): + setattr( + state, name, new_value + ) # this will update us via add_event_handler, no need to call set_value + + return value, set_value_sync + + +def use_layer_watch(viewer: Viewer): + # use a counter to force updates due to external state changes + counter, set_counter = react.use_state(0) + + def hookup(): + def handler(): + new_counter = counter + 1 + set_counter(new_counter) + + def cleanup(): + viewer._layer_artist_container.change_callbacks.remove(handler) + + viewer._layer_artist_container.on_changed(handler) + return cleanup + + react.use_effect(hookup) diff --git a/glue_jupyter/ipyvolume/common/viewer.py b/glue_jupyter/ipyvolume/common/viewer.py index b0aa48db..5a12fbe1 100644 --- a/glue_jupyter/ipyvolume/common/viewer.py +++ b/glue_jupyter/ipyvolume/common/viewer.py @@ -23,11 +23,14 @@ class IpyvolumeBaseView(IPyWidgetView): def __init__(self, *args, **kwargs): + super(IpyvolumeBaseView, self).__init__(*args, **kwargs) + + self.create_layout() + + def initialize_figure(self): self.figure = ipv.figure(animation_exponent=1.) self.figure.selector = '' - super(IpyvolumeBaseView, self).__init__(*args, **kwargs) - # FIXME: hack for the movie maker to have access to the figure self.state.figure = self.figure @@ -52,8 +55,6 @@ def attribute_to_label(attribute): self._figure_widget = ipv.gcc() - self.create_layout() - def _update_axes_visibility(self, *args): with self.figure: if self.state.visible_axes: diff --git a/glue_jupyter/view.py b/glue_jupyter/view.py index f4c0ea89..0541369b 100644 --- a/glue_jupyter/view.py +++ b/glue_jupyter/view.py @@ -34,9 +34,11 @@ class IPyWidgetView(Viewer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.initialize_figure() self._output_widget = Output() self.initialize_layer_options() self.initialize_toolbar() + self.create_layout() @property def toolbar_selection_tools(self): diff --git a/setup.cfg b/setup.cfg index 9953b1ce..ab029594 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,7 @@ install_requires = bqplot-image-gl>=1.4.3 bqplot>=0.12.17 scikit-image + react-ipywidgets>=0.11.0 [options.extras_require] test =