class ROIClickAndDrag(InteractCheckableTool): """ A tool that enables clicking and dragging of existing ROIs. """ def __init__(self, viewer, **kwargs): super().__init__(viewer, **kwargs) self.viewer = viewer self._edit_subset_mode = viewer.session.edit_subset_mode self.interact = MouseInteraction(x_scale=self.viewer.scale_x, y_scale=self.viewer.scale_y) self.interact.on_msg(self.on_msg) self._active_tool = None def on_msg(self, interact, msg, buffers): name = msg['event'] domain = msg['domain'] x, y = domain['x'], domain['y'] if name == 'dragstart': self.press(x, y) def press(self, x, y): from glue_jupyter.bqplot.image.layer_artist import BqplotImageSubsetLayerArtist for layer in self.viewer.layers: if not isinstance(layer, BqplotImageSubsetLayerArtist): continue subset_state = layer.state.layer.subset_state if layer.visible and isinstance(subset_state, RoiSubsetState): roi = subset_state.roi if roi.contains(x, y): if isinstance(roi, (EllipticalROI, CircularROI)): self._active_tool = BqplotCircleMode( self.viewer, roi=roi, finalize_callback=self.release) self.viewer.figure.interaction = self._active_tool.interact elif isinstance(roi, (PolygonalROI, RectangularROI)): self._active_tool = BqplotRectangleMode( self.viewer, roi=roi, finalize_callback=self.release) self.viewer.figure.interaction = self._active_tool.interact else: raise TypeError(f"Unexpected ROI type: {type(roi)}") self._edit_subset_mode.edit_subset = [ layer.state.layer.group ] break else: self._selected = False def release(self): self.viewer.figure.interaction = self.interact
def __init__(self, viewer, **kwargs): super().__init__(viewer, **kwargs) self.viewer = viewer self._edit_subset_mode = viewer.session.edit_subset_mode self.interact = MouseInteraction(x_scale=self.viewer.scale_x, y_scale=self.viewer.scale_y) self.interact.on_msg(self.on_msg) self._active_tool = None
def __init__(self, session, state=None): # 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) self.scales = {'x': self.scale_x, 'y': self.scale_y} self.axis_x = bqplot.Axis(scale=self.scale_x, grid_lines='none', label='x') self.axis_y = bqplot.Axis(scale=self.scale_y, orientation='vertical', tick_format='0.2f', grid_lines='none', label='y') self.figure = bqplot.Figure(scales=self.scales, animation_duration=0, axes=[self.axis_x, self.axis_y], fig_margin={ 'left': 60, 'bottom': 60, 'top': 10, 'right': 10 }) self.figure.padding_y = 0 self._fig_margin_default = self.figure.fig_margin self._fig_margin_zero = dict(self.figure.fig_margin) self._fig_margin_zero['left'] = 0 self._fig_margin_zero['bottom'] = 0 # 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._mouse_interact = MouseInteraction(x_scale=self.scale_x, y_scale=self.scale_y, move_throttle=70, events=[]) self._mouse_interact.on_msg(self._on_mouse_interaction) self.figure.interaction = self._mouse_interact self._events_for_callback = {} super(BqplotBaseView, self).__init__(session, state=state) # 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) self.state.add_callback('layers', self._sync_layer_artist_container, priority=10000) def update_axes(*ignore): try: # Extract units from data x_unit = self.state.reference_data.get_component( self.state.x_att_world).units except AttributeError: # If no data loaded yet, ignore units x_unit = "" finally: # Append units to axis label self.axis_x.label = str(self.state.x_att) + " " + str(x_unit) if self.is2d: self.axis_y.label = str(self.state.y_att) try: y_unit = self.state.reference_data.get_component( self.state.y_att_world).units except AttributeError: y_unit = "" finally: self.axis_y.label = str( self.state.y_att) + " " + str(y_unit) self.state.add_callback('x_att', update_axes) if self.is2d: self.state.add_callback('y_att', update_axes) self.scale_x.observe(self.update_glue_scales, names=['min', 'max']) self.scale_y.observe(self.update_glue_scales, names=['min', 'max']) dlink((self.state, 'x_min'), (self.scale_x, 'min'), float_or_none) dlink((self.state, 'x_max'), (self.scale_x, 'max'), float_or_none) dlink((self.state, 'y_min'), (self.scale_y, 'min'), float_or_none) dlink((self.state, 'y_max'), (self.scale_y, 'max'), float_or_none) on_change([(self.state, 'show_axes')])(self._sync_show_axes) self.create_layout()
class BqplotBaseView(IPyWidgetView): allow_duplicate_data = False allow_duplicate_subset = False is2d = True _default_mouse_mode_cls = ROIClickAndDrag def __init__(self, session, state=None): # 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) self.scales = {'x': self.scale_x, 'y': self.scale_y} self.axis_x = bqplot.Axis(scale=self.scale_x, grid_lines='none', label='x') self.axis_y = bqplot.Axis(scale=self.scale_y, orientation='vertical', tick_format='0.2f', grid_lines='none', label='y') self.figure = bqplot.Figure(scales=self.scales, animation_duration=0, axes=[self.axis_x, self.axis_y], fig_margin={ 'left': 60, 'bottom': 60, 'top': 10, 'right': 10 }) self.figure.padding_y = 0 self._fig_margin_default = self.figure.fig_margin self._fig_margin_zero = dict(self.figure.fig_margin) self._fig_margin_zero['left'] = 0 self._fig_margin_zero['bottom'] = 0 # 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._mouse_interact = MouseInteraction(x_scale=self.scale_x, y_scale=self.scale_y, move_throttle=70, events=[]) self._mouse_interact.on_msg(self._on_mouse_interaction) self.figure.interaction = self._mouse_interact self._events_for_callback = {} super(BqplotBaseView, self).__init__(session, state=state) # 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) self.state.add_callback('layers', self._sync_layer_artist_container, priority=10000) def update_axes(*ignore): try: # Extract units from data x_unit = self.state.reference_data.get_component( self.state.x_att_world).units except AttributeError: # If no data loaded yet, ignore units x_unit = "" finally: # Append units to axis label self.axis_x.label = str(self.state.x_att) + " " + str(x_unit) if self.is2d: self.axis_y.label = str(self.state.y_att) try: y_unit = self.state.reference_data.get_component( self.state.y_att_world).units except AttributeError: y_unit = "" finally: self.axis_y.label = str( self.state.y_att) + " " + str(y_unit) self.state.add_callback('x_att', update_axes) if self.is2d: self.state.add_callback('y_att', update_axes) self.scale_x.observe(self.update_glue_scales, names=['min', 'max']) self.scale_y.observe(self.update_glue_scales, names=['min', 'max']) dlink((self.state, 'x_min'), (self.scale_x, 'min'), float_or_none) dlink((self.state, 'x_max'), (self.scale_x, 'max'), float_or_none) dlink((self.state, 'y_min'), (self.scale_y, 'min'), float_or_none) dlink((self.state, 'y_max'), (self.scale_y, 'max'), float_or_none) on_change([(self.state, 'show_axes')])(self._sync_show_axes) self.create_layout() 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._events_for_callback[callback] = set(events) self._event_callbacks.append(callback) self._update_interact_events() def remove_event_callback(self, callback): """ Remove a callback function for mouse and keyboard events. """ self._events_for_callback.pop(callback) 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: callback(data) @debounced(delay_seconds=0.5, method=True) def update_glue_scales(self, *ignored): # To prevent glue from calling _adjust_limit_aspect() as each value comes in, we wait for # all values to be set and then update the glue-state atomically. # # If this is not done, the _adjust_limit_aspect() starts calculating with one of the new # values, which changes x_min, x_max, y_min and y_max, which gets synced to the front-end, # which causes another change resulting in a short feedback loop that ends with the values # being different than originally set. # In the unit tests @debounced does not work and will lead to unrelated failing tests. The # updating of glue-state to widgets isn't tested anyway, so skip this code when we don't # detect an ioloop. # TODO: come up with a better solution for this problem if get_ioloop(): state = self.state.as_dict() state['x_min'] = self.scale_x.min state['x_max'] = self.scale_x.max state['y_min'] = self.scale_y.min state['y_max'] = self.scale_y.max self.state.update_from_dict(state) @property def figure_widget(self): return self.figure def _sync_show_axes(self): # TODO: if moved to state, this would not rely on the widget self.axis_x.visible = self.axis_y.visible = self.state.show_axes self.figure.fig_margin = (self._fig_margin_default if self.state.show_axes else self._fig_margin_zero) 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, use_current=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 limits_to_scales(self, *args): if self.state.x_min is not None and self.state.x_max is not None: self.scale_x.min = float(self.state.x_min) self.scale_x.max = float(self.state.x_max) if self.state.y_min is not None and self.state.y_max is not None: self.scale_y.min = float(self.state.y_min) self.scale_y.max = float(self.state.y_max) def redraw(self): pass