class BaseColorView(BaseWheelMixin): """Provide facilities to add and select color schemes in the view. """ def __init__(self, *args, **kwargs): super(BaseColorView, self).__init__(*args, **kwargs) self.state_attrs += ('color_scheme', ) # Color schemes. self.color_schemes = RotatingProperty() self.add_color_scheme(fun=0, name='blank', colormap='blank', categorical=True) def add_color_scheme(self, fun=None, name=None, cluster_ids=None, colormap=None, categorical=None, logarithmic=None): """Add a color scheme to the view. Can be used as follows: ```python @connect def on_view_attached(gui, view): view.add_color_scheme(c.get_depth, name='depth', colormap='linear') ``` """ if fun is None: return partial(self.add_color_scheme, name=name, cluster_ids=cluster_ids, colormap=colormap, categorical=categorical, logarithmic=logarithmic) field = name or fun.__name__ cs = ClusterColorSelector(fun, cluster_ids=cluster_ids, colormap=colormap, categorical=categorical, logarithmic=logarithmic) self.color_schemes.add(field, cs) def get_cluster_colors(self, cluster_ids, alpha=1.0): """Return the cluster colors depending on the currently-selected color scheme.""" cs = self.color_schemes.get() if cs is None: # pragma: no cover raise RuntimeError( "Make sure that at least a color scheme is added.") return cs.get_colors(cluster_ids, alpha=alpha) def _neighbor_color_scheme(self, dir=+1): name = self.color_schemes._neighbor(dir=dir) logger.debug("Switch to `%s` color scheme in %s.", name, self.__class__.__name__) self.update_color() self.update_select_color() self.update_status() def next_color_scheme(self): """Switch to the next color scheme.""" self._neighbor_color_scheme(+1) def previous_color_scheme(self): """Switch to the previous color scheme.""" self._neighbor_color_scheme(-1) def update_color(self): """Update the cluster colors depending on the current color scheme. To be overriden.""" pass def update_select_color(self): """Update the cluster colors after the cluster selection changes.""" pass @property def color_scheme(self): """Current color scheme.""" return self.color_schemes.current @color_scheme.setter def color_scheme(self, color_scheme): """Change the current color scheme.""" logger.debug("Set color scheme to %s.", color_scheme) self.color_schemes.set(color_scheme) self.update_color() self.update_status() def attach(self, gui): super(BaseColorView, self).attach(gui) # Set the current color scheme to the GUI state color scheme (automatically set # in self.color_scheme). self.color_schemes.set(self.color_scheme) # Color scheme actions. def _make_color_scheme_action(cs): def callback(): self.color_scheme = cs return callback for cs in self.color_schemes.keys(): name = 'Change color scheme to %s' % cs self.actions.add(_make_color_scheme_action(cs), show_shortcut=False, name=name, view_submenu='Change color scheme') self.actions.add(self.next_color_scheme) self.actions.add(self.previous_color_scheme) self.actions.separator() def on_mouse_wheel(self, e): # pragma: no cover """Change the scaling with the wheel.""" super(BaseColorView, self).on_mouse_wheel(e) if e.modifiers == ('Shift', ): if e.delta > 0: self.next_color_scheme() elif e.delta < 0: self.previous_color_scheme()
class WaveformView(ScalingMixin, ManualClusteringView): """This view shows the waveforms of the selected clusters, on relevant channels, following the probe geometry. Constructor ----------- waveforms : dict of functions Every function maps a cluster id to a Bunch with the following attributes: * `data` : a 3D array `(n_spikes, n_samples, n_channels_loc)` * `channel_ids` : the channel ids corresponding to the third dimension in `data` * `channel_labels` : a list of channel labels for every channel in `channel_ids` * `channel_positions` : a 2D array with the coordinates of the channels on the probe * `masks` : a 2D array `(n_spikes, n_channels)` with the waveforms masks * `alpha` : the alpha transparency channel The keys of the dictionary are called **waveform types**. The `next_waveforms_type` action cycles through all available waveform types. The key `waveforms` is mandatory. waveforms_type : str Default key of the waveforms dictionary to plot initially. """ # Do not show too many clusters. max_n_clusters = 8 _default_position = 'right' ax_color = (.75, .75, .75, 1.) tick_size = 5. cluster_ids = () default_shortcuts = { 'toggle_waveform_overlap': 'o', 'toggle_show_labels': 'ctrl+l', 'next_waveforms_type': 'w', 'previous_waveforms_type': 'shift+w', 'toggle_mean_waveforms': 'm', # Box scaling. 'widen': 'ctrl+right', 'narrow': 'ctrl+left', 'increase': 'ctrl+up', 'decrease': 'ctrl+down', 'change_box_size': 'ctrl+wheel', # Probe scaling. 'extend_horizontally': 'shift+right', 'shrink_horizontally': 'shift+left', 'extend_vertically': 'shift+up', 'shrink_vertically': 'shift+down', } default_snippets = { 'change_n_spikes_waveforms': 'wn', } def __init__(self, waveforms=None, waveforms_type=None, sample_rate=None, **kwargs): self._overlap = False self.do_show_labels = True self.channel_ids = None self.filtered_tags = () self.wave_duration = 0. # updated in the plotting method self.data_bounds = None self.sample_rate = sample_rate self._status_suffix = '' assert sample_rate > 0., "The sample rate must be provided to the waveform view." # Initialize the view. super(WaveformView, self).__init__(**kwargs) self.state_attrs += ('waveforms_type', 'overlap', 'do_show_labels') self.local_state_attrs += ('box_scaling', 'probe_scaling') # Box and probe scaling. self.canvas.set_layout('boxed', box_pos=np.zeros((1, 2))) # Ensure waveforms is a dictionary, even if there is a single waveforms type. waveforms = waveforms or {} waveforms = waveforms if isinstance(waveforms, dict) else { 'waveforms': waveforms } self.waveforms = waveforms # Rotating property waveforms types. self.waveforms_types = RotatingProperty() for name, value in self.waveforms.items(): self.waveforms_types.add(name, value) # Current waveforms type. self.waveforms_types.set(waveforms_type) assert self.waveforms_type in self.waveforms self.text_visual = TextVisual() self.canvas.add_visual(self.text_visual) self.line_visual = LineVisual() self.canvas.add_visual(self.line_visual) self.tick_visual = UniformScatterVisual(marker='vbar', color=self.ax_color, size=self.tick_size) self.canvas.add_visual(self.tick_visual) # Two types of visuals: thin raw line visual for normal waveforms, thick antialiased # agg plot visual for mean and template waveforms. self.waveform_agg_visual = PlotAggVisual() self.waveform_visual = PlotVisual() self.canvas.add_visual(self.waveform_agg_visual) self.canvas.add_visual(self.waveform_visual) # Internal methods # ------------------------------------------------------------------------- @property def _current_visual(self): if self.waveforms_type == 'waveforms': return self.waveform_visual else: return self.waveform_agg_visual def _get_data_bounds(self, bunchs): m = min(_min(b.data) for b in bunchs) M = max(_max(b.data) for b in bunchs) # Symmetrize on the y axis. M = max(abs(m), abs(M)) return [-1, -M, +1, M] def get_clusters_data(self): if self.waveforms_type not in self.waveforms: return bunchs = [ self.waveforms_types.get()(cluster_id) for cluster_id in self.cluster_ids ] clu_offsets = _get_clu_offsets(bunchs) n_clu = max(clu_offsets) + 1 # Offset depending on the overlap. for i, (bunch, offset) in enumerate(zip(bunchs, clu_offsets)): bunch.index = i bunch.offset = offset bunch.n_clu = n_clu bunch.color = selected_cluster_color(i, bunch.get('alpha', .75)) return bunchs def _plot_cluster(self, bunch): wave = bunch.data if wave is None or not wave.size: return channel_ids_loc = bunch.channel_ids n_channels = len(channel_ids_loc) masks = bunch.get('masks', np.ones((wave.shape[0], n_channels))) # By default, this is 0, 1, 2 for the first 3 clusters. # But it can be customized when displaying several sets # of waveforms per cluster. n_spikes_clu, n_samples = wave.shape[:2] assert wave.shape[2] == n_channels assert masks.shape == (n_spikes_clu, n_channels) # Find the x coordinates. t = get_linear_x(n_spikes_clu * n_channels, n_samples) t = _overlap_transform(t, offset=bunch.offset, n=bunch.n_clu, overlap=self.overlap) # HACK: on the GPU, we get the actual masks with fract(masks) # since we add the relative cluster index. We need to ensure # that the masks is never 1.0, otherwise it is interpreted as # 0. eps = .001 masks = eps + (1 - 2 * eps) * masks # NOTE: we add the cluster index which is used for the # computation of the depth on the GPU. masks += bunch.index # Generate the box index (one number per channel). box_index = _index_of(channel_ids_loc, self.channel_ids) box_index = np.tile(box_index, n_spikes_clu) # Find the correct number of vertices depending on the current waveform visual. if self._current_visual == self.waveform_visual: # PlotVisual box_index = np.repeat(box_index, n_samples) assert box_index.size == n_spikes_clu * n_channels * n_samples else: # PlotAggVisual box_index = np.repeat(box_index, 2 * (n_samples + 2)) assert box_index.size == n_spikes_clu * n_channels * 2 * ( n_samples + 2) # Generate the waveform array. wave = np.transpose(wave, (0, 2, 1)) nw = n_spikes_clu * n_channels wave = wave.reshape((nw, n_samples)) assert self.data_bounds is not None self._current_visual.add_batch_data(x=t, y=wave, color=bunch.color, masks=masks, box_index=box_index, data_bounds=self.data_bounds) # Waveform axes. # -------------- # Horizontal y=0 lines. ax_db = self.data_bounds a, b = _overlap_transform(np.array([-1, 1]), offset=bunch.offset, n=bunch.n_clu, overlap=self.overlap) box_index = _index_of(channel_ids_loc, self.channel_ids) box_index = np.repeat(box_index, 2) box_index = np.tile(box_index, n_spikes_clu) hpos = np.tile([[a, 0, b, 0]], (nw, 1)) assert box_index.size == hpos.shape[0] * 2 self.line_visual.add_batch_data( pos=hpos, color=self.ax_color, data_bounds=ax_db, box_index=box_index, ) # Vertical ticks every millisecond. steps = np.arange(np.round(self.wave_duration * 1000)) # A vline every millisecond. x = .001 * steps # Scale to [-1, 1], same coordinates as the waveform points. x = -1 + 2 * x / self.wave_duration # Take overlap into account. x = _overlap_transform(x, offset=bunch.offset, n=bunch.n_clu, overlap=self.overlap) x = np.tile(x, len(channel_ids_loc)) # Generate the box index. box_index = _index_of(channel_ids_loc, self.channel_ids) box_index = np.repeat(box_index, x.size // len(box_index)) assert x.size == box_index.size self.tick_visual.add_batch_data( x=x, y=np.zeros_like(x), data_bounds=ax_db, box_index=box_index, ) def _plot_labels(self, channel_ids, n_clusters, channel_labels): # Add channel labels. if not self.do_show_labels: return self.text_visual.reset_batch() for i, ch in enumerate(channel_ids): label = channel_labels[ch] self.text_visual.add_batch_data( pos=[-1, 0], text=str(label), anchor=[-1.25, 0], box_index=i, ) self.canvas.update_visual(self.text_visual) def plot(self, **kwargs): """Update the view with the current cluster selection.""" if not self.cluster_ids: return bunchs = self.get_clusters_data() if not bunchs: return # All channel ids appearing in all selected clusters. channel_ids = sorted(set(_flatten([d.channel_ids for d in bunchs]))) self.channel_ids = channel_ids if bunchs[0].data is not None: self.wave_duration = bunchs[0].data.shape[1] / float( self.sample_rate) else: # pragma: no cover self.wave_duration = 1. # Channel labels. channel_labels = {} for d in bunchs: chl = d.get('channel_labels', ['%d' % ch for ch in d.channel_ids]) channel_labels.update({ channel_id: chl[i] for i, channel_id in enumerate(d.channel_ids) }) # Update the Boxed box positions as a function of the selected channels. if channel_ids: self.canvas.boxed.update_boxes(_get_box_pos(bunchs, channel_ids)) self.data_bounds = self.data_bounds or self._get_data_bounds(bunchs) self._current_visual.reset_batch() self.line_visual.reset_batch() self.tick_visual.reset_batch() for bunch in bunchs: self._plot_cluster(bunch) self.canvas.update_visual(self.tick_visual) self.canvas.update_visual(self.line_visual) self.canvas.update_visual(self._current_visual) self._plot_labels(channel_ids, len(self.cluster_ids), channel_labels) # Only show the current waveform visual. if self._current_visual == self.waveform_visual: self.waveform_visual.show() self.waveform_agg_visual.hide() elif self._current_visual == self.waveform_agg_visual: self.waveform_agg_visual.show() self.waveform_visual.hide() self.canvas.update() self.update_status() def attach(self, gui): """Attach the view to the GUI.""" super(WaveformView, self).attach(gui) self.actions.add(self.toggle_waveform_overlap, checkable=True, checked=self.overlap) self.actions.add(self.toggle_show_labels, checkable=True, checked=self.do_show_labels) self.actions.add(self.next_waveforms_type) self.actions.add(self.previous_waveforms_type) self.actions.add(self.toggle_mean_waveforms, checkable=True) self.actions.separator() # Box scaling. self.actions.add(self.widen) self.actions.add(self.narrow) self.actions.separator() # Probe scaling. self.actions.add(self.extend_horizontally) self.actions.add(self.shrink_horizontally) self.actions.separator() self.actions.add(self.extend_vertically) self.actions.add(self.shrink_vertically) self.actions.separator() @property def boxed(self): """Layout instance.""" return self.canvas.boxed @property def status(self): return self.waveforms_type # Overlap # ------------------------------------------------------------------------- @property def overlap(self): """Whether to overlap the waveforms belonging to different clusters.""" return self._overlap @overlap.setter def overlap(self, value): self._overlap = value self.plot() def toggle_waveform_overlap(self, checked): """Toggle the overlap of the waveforms.""" self.overlap = checked # Box scaling # ------------------------------------------------------------------------- def widen(self): """Increase the horizontal scaling of the waveforms.""" self.boxed.expand_box_width() def narrow(self): """Decrease the horizontal scaling of the waveforms.""" self.boxed.shrink_box_width() @property def box_scaling(self): return self.boxed._box_scaling @box_scaling.setter def box_scaling(self, value): self.boxed._box_scaling = value def _get_scaling_value(self): return self.boxed._box_scaling[1] def _set_scaling_value(self, value): w, h = self.boxed._box_scaling self.boxed._box_scaling = (w, value) self.boxed.update() # Probe scaling # ------------------------------------------------------------------------- @property def probe_scaling(self): return self.boxed._layout_scaling @probe_scaling.setter def probe_scaling(self, value): self.boxed._layout_scaling = value def extend_horizontally(self): """Increase the horizontal scaling of the probe.""" self.boxed.expand_layout_width() def shrink_horizontally(self): """Decrease the horizontal scaling of the waveforms.""" self.boxed.shrink_layout_width() def extend_vertically(self): """Increase the vertical scaling of the waveforms.""" self.boxed.expand_layout_height() def shrink_vertically(self): """Decrease the vertical scaling of the waveforms.""" self.boxed.shrink_layout_height() # Navigation # ------------------------------------------------------------------------- def toggle_show_labels(self, checked): """Whether to show the channel ids or not.""" self.do_show_labels = checked self.text_visual.show() if checked else self.text_visual.hide() self.canvas.update() def on_mouse_click(self, e): """Select a channel by clicking on a box in the waveform view.""" b = e.button nums = tuple('%d' % i for i in range(10)) if 'Control' in e.modifiers or e.key in nums: key = int(e.key) if e.key in nums else None # Get mouse position in NDC. channel_idx, _ = self.canvas.boxed.box_map(e.pos) channel_id = self.channel_ids[channel_idx] logger.debug("Click on channel_id %d with key %s and button %s.", channel_id, key, b) emit('select_channel', self, channel_id=channel_id, key=key, button=b) @property def waveforms_type(self): return self.waveforms_types.current @waveforms_type.setter def waveforms_type(self, value): self.waveforms_types.set(value) def next_waveforms_type(self): """Switch to the next waveforms type.""" self.waveforms_types.next() logger.debug("Switch to waveforms type %s.", self.waveforms_type) self.plot() def previous_waveforms_type(self): """Switch to the previous waveforms type.""" self.waveforms_types.previous() logger.debug("Switch to waveforms type %s.", self.waveforms_type) self.plot() def toggle_mean_waveforms(self, checked): """Switch to the `mean_waveforms` type, if it is available.""" if self.waveforms_type == 'mean_waveforms' and 'waveforms' in self.waveforms: self.waveforms_types.set('waveforms') logger.debug("Switch to raw waveforms.") self.plot() elif 'mean_waveforms' in self.waveforms: self.waveforms_types.set('mean_waveforms') logger.debug("Switch to mean waveforms.") self.plot()
class AmplitudeView(MarkerSizeMixin, LassoMixin, ManualClusteringView): """This view displays an amplitude plot for all selected clusters. Constructor ----------- amplitudes : dict Dictionary `{amplitudes_type: function}`, for different types of amplitudes. Each function maps `cluster_ids` to a list `[Bunch(amplitudes, spike_ids, spike_times), ...]` for each cluster. Use `cluster_id=None` for background amplitudes. """ # Do not show too many clusters. max_n_clusters = 8 _default_position = 'right' # Alpha channel of the markers in the scatter plot. marker_alpha = 1. time_range_color = (1., 1., 0., .25) # Number of bins in the histogram. n_bins = 100 # Alpha channel of the histogram in the background. histogram_alpha = .5 # Quantile used for scaling of the amplitudes (less than 1 to avoid outliers). quantile = .99 # Size of the histogram, between 0 and 1. histogram_scale = .25 default_shortcuts = { 'change_marker_size': 'alt+wheel', 'next_amplitudes_type': 'a', 'previous_amplitudes_type': 'shift+a', 'select_x_dim': 'shift+left click', 'select_y_dim': 'shift+right click', 'select_time': 'alt+click', } def __init__(self, amplitudes=None, amplitudes_type=None, duration=None): super(AmplitudeView, self).__init__() self.state_attrs += ('amplitudes_type', ) self.canvas.enable_axes() self.canvas.enable_lasso() # Ensure amplitudes is a dictionary, even if there is a single amplitude. if not isinstance(amplitudes, dict): amplitudes = {'amplitude': amplitudes} assert amplitudes self.amplitudes = amplitudes # Rotating property amplitudes types. self.amplitudes_types = RotatingProperty() for name, value in self.amplitudes.items(): self.amplitudes_types.add(name, value) # Current amplitudes type. self.amplitudes_types.set(amplitudes_type) assert self.amplitudes_type in self.amplitudes self.cluster_ids = () self.duration = duration or 1. # Histogram visual. self.hist_visual = HistogramVisual() self.hist_visual.transforms.add([ Range(NDC, (-1, -1, 1, -1 + 2 * self.histogram_scale)), Rotate('cw'), Scale((1, -1)), Translate((2.05, 0)), ]) self.canvas.add_visual(self.hist_visual) self.canvas.panzoom.zoom = self.canvas.panzoom._default_zoom = (.75, 1) self.canvas.panzoom.pan = self.canvas.panzoom._default_pan = (-.25, 0) # Yellow vertical bar showing the selected time interval. self.patch_visual = PatchVisual(primitive_type='triangle_fan') self.patch_visual.inserter.insert_vert( ''' const float MIN_INTERVAL_SIZE = 0.01; uniform float u_interval_size; ''', 'header') self.patch_visual.inserter.insert_vert( ''' gl_Position.y = pos_orig.y; // The following is used to ensure that (1) the bar width increases with the zoom level // but also (2) there is a minimum absolute width so that the bar remains visible // at low zoom levels. float w = max(MIN_INTERVAL_SIZE, u_interval_size * u_zoom.x); // HACK: the z coordinate is used to store 0 or 1, depending on whether the current // vertex is on the left or right edge of the bar. gl_Position.x += w * (-1 + 2 * int(a_position.z == 0)); ''', 'after_transforms') self.canvas.add_visual(self.patch_visual) # Scatter plot. self.visual = ScatterVisual() self.canvas.add_visual(self.visual) self.canvas.panzoom.set_constrain_bounds((-2, -2, +2, +2)) def _get_data_bounds(self, bunchs): """Compute the data bounds.""" if not bunchs: # pragma: no cover return (0, 0, self.duration, 1) m = min( np.quantile(bunch.amplitudes, 1 - self.quantile) for bunch in bunchs if len(bunch.amplitudes)) m = min(0, m) # ensure ymin <= 0 M = max( np.quantile(bunch.amplitudes, self.quantile) for bunch in bunchs if len(bunch.amplitudes)) return (0, m, self.duration, M) def _add_histograms(self, bunchs): # We do this after get_clusters_data because we need x_max. for bunch in bunchs: bunch.histogram = _compute_histogram( bunch.amplitudes, x_min=self.data_bounds[1], x_max=self.data_bounds[3], n_bins=self.n_bins, normalize=True, ignore_zeros=True, ) return bunchs def show_time_range(self, interval=(0, 0)): start, end = interval x0 = -1 + 2 * (start / self.duration) x1 = -1 + 2 * (end / self.duration) xm = .5 * (x0 + x1) pos = np.array([ [xm, -1], [xm, +1], [xm, +1], [xm, -1], ]) self.patch_visual.program['u_interval_size'] = .5 * (x1 - x0) self.patch_visual.set_data(pos=pos, color=self.time_range_color, depth=[0, 0, 1, 1]) self.canvas.update() def _plot_cluster(self, bunch): """Make the scatter plot.""" ms = self._marker_size if not len(bunch.histogram): return # Histogram in the background. self.hist_visual.add_batch_data(hist=bunch.histogram, ylim=self._ylim, color=add_alpha( bunch.color, self.histogram_alpha)) # Scatter plot. self.visual.add_batch_data(pos=bunch.pos, color=bunch.color, size=ms, data_bounds=self.data_bounds) def get_clusters_data(self, load_all=None): """Return a list of Bunch instances, with attributes pos and spike_ids.""" if not len(self.cluster_ids): return cluster_ids = list(self.cluster_ids) # Don't need the background when splitting. if not load_all: # Add None cluster which means background spikes. cluster_ids = [None] + cluster_ids bunchs = self.amplitudes[self.amplitudes_type](cluster_ids, load_all=load_all) or () # Add a pos attribute in bunchs in addition to x and y. for i, (cluster_id, bunch) in enumerate(zip(cluster_ids, bunchs)): spike_ids = _as_array(bunch.spike_ids) spike_times = _as_array(bunch.spike_times) amplitudes = _as_array(bunch.amplitudes) assert spike_ids.shape == spike_times.shape == amplitudes.shape # Ensure that bunch.pos exists, as it used by the LassoMixin. bunch.pos = np.c_[spike_times, amplitudes] assert bunch.pos.ndim == 2 bunch.cluster_id = cluster_id bunch.color = ( selected_cluster_color(i - 1, self.marker_alpha) # Background amplitude color. if cluster_id is not None else (.5, .5, .5, .5)) return bunchs def plot(self, **kwargs): """Update the view with the current cluster selection.""" bunchs = self.get_clusters_data(**kwargs) if not bunchs: return self.data_bounds = self._get_data_bounds(bunchs) bunchs = self._add_histograms(bunchs) # Use the same scale for all histograms. self._ylim = max(bunch.histogram.max() for bunch in bunchs) if bunchs else 1. self.visual.reset_batch() self.hist_visual.reset_batch() for bunch in bunchs: self._plot_cluster(bunch) self.canvas.update_visual(self.visual) self.canvas.update_visual(self.hist_visual) self._update_axes() self.canvas.update() self.update_status() def attach(self, gui): """Attach the view to the GUI.""" super(AmplitudeView, self).attach(gui) # Amplitude type actions. def _make_amplitude_action(a): def callback(): self.amplitudes_type = a self.plot() return callback for a in self.amplitudes_types.keys(): name = 'Change amplitudes type to %s' % a self.actions.add(_make_amplitude_action(a), show_shortcut=False, name=name, view_submenu='Change amplitudes type') self.actions.add(self.next_amplitudes_type, set_busy=True) self.actions.add(self.previous_amplitudes_type, set_busy=True) @property def status(self): return self.amplitudes_type @property def amplitudes_type(self): return self.amplitudes_types.current @amplitudes_type.setter def amplitudes_type(self, value): self.amplitudes_types.set(value) def next_amplitudes_type(self): """Switch to the next amplitudes type.""" self.amplitudes_types.next() logger.debug("Switch to amplitudes type: %s.", self.amplitudes_types.current) self.plot() def previous_amplitudes_type(self): """Switch to the previous amplitudes type.""" self.amplitudes_types.previous() logger.debug("Switch to amplitudes type: %s.", self.amplitudes_types.current) self.plot() def on_mouse_click(self, e): """Select a time from the amplitude view to display in the trace view.""" if 'Alt' in e.modifiers: mouse_pos = self.canvas.panzoom.window_to_ndc(e.pos) time = Range(NDC, self.data_bounds).apply(mouse_pos)[0][0] emit('select_time', self, time)