From 8f8106bd8ee70a089610f6df8da1739098e5b2a3 Mon Sep 17 00:00:00 2001 From: "P. L. Lim" <2090236+pllim@users.noreply.github.com> Date: Thu, 19 Jan 2023 11:47:01 -0500 Subject: [PATCH] Improve coordinates display panel for spectrum viewer (#1894) * Improved spectrum info panel for spectrum viewers in all the configurations supported by Jdaviz. * BUG: Fix spectrum mouseover when out of bounds. * PERF: Decrease lag for Cubeviz spec1d mouseover. Also remove unnecessary re-computation in the internals. A new internal cache on app level is introduced. * basic implementation of mouseover marker * only used in spectrum profile viewers * currently ALWAYS on * only show marker (and nearest spectrum details) if no tool selected (or default tool - slice in cubeviz, for example) * support for mouseover of spatial subsets in cubeviz * including clearing cache when subset is changed and ignoring hidden layers * remilestone changelog to 3.3 * add cursor and pixel information to mouseover display * mouseover marker as rectangle * Do not hardcode spectrum-viewer Co-authored-by: Kyle Conroy --- CHANGES.rst | 8 + docs/specviz/displaying.rst | 10 ++ jdaviz/app.py | 17 ++ .../tests/test_gaussian_smooth.py | 114 ++++++++++-- .../imviz/plugins/coords_info/coords_info.py | 34 ++++ .../imviz/plugins/coords_info/coords_info.vue | 8 +- jdaviz/configs/imviz/tests/test_linking.py | 1 + .../configs/mosviz/tests/test_data_loading.py | 22 ++- jdaviz/configs/specviz/plugins/viewers.py | 167 +++++++++++++----- jdaviz/configs/specviz/tests/test_helper.py | 71 +++++++- .../configs/specviz2d/tests/test_parsers.py | 13 +- jdaviz/core/marks.py | 24 ++- 12 files changed, 410 insertions(+), 79 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 41d7815f97..4436e46b67 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,8 @@ New Features Cubeviz ^^^^^^^ +- Improved mouseover info display for spectrum viewer. [#1894] + Imviz ^^^^^ @@ -14,12 +16,18 @@ Mosviz ^^^^^^ - Reliably retrieves identifier using each datasets' metadata entry. [#1851] +- Improved mouseover info display for spectrum viewer. [#1894] + Specviz ^^^^^^^ +- Improved mouseover info display for spectrum viewer. [#1894] + Specviz2d ^^^^^^^^^ +- Improved mouseover info display for spectrum viewer. [#1894] + API Changes ----------- diff --git a/docs/specviz/displaying.rst b/docs/specviz/displaying.rst index fd6d6c628a..ff417f115e 100644 --- a/docs/specviz/displaying.rst +++ b/docs/specviz/displaying.rst @@ -41,6 +41,16 @@ data menu. .. image:: img/data_tab.png +.. _specviz_cursor_info: + +Cursor Information +================== + +By moving your cursor along the spectrum viewer, you will be able to see information on the +cursor position as well as the spectral axis value, pixel, and flux of the closest data point +to the cursor. +This information is displayed in the top bar of the UI, on the middle-right side. + Home ==== diff --git a/jdaviz/app.py b/jdaviz/app.py index d4af2bbd58..0dd2bbe75a 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -28,6 +28,7 @@ from glue.core.message import (DataCollectionAddMessage, DataCollectionDeleteMessage, SubsetCreateMessage, + SubsetUpdateMessage, SubsetDeleteMessage) from glue.core.state_objects import State from glue.core.subset import Subset, RangeSubsetState, RoiSubsetState @@ -258,6 +259,12 @@ def __init__(self, configuration=None, *args, **kwargs): # Add a fitted_models dictionary that the helpers (or user) can access self.fitted_models = {} + # Internal cache so we don't have to keep calling get_object for the same Data. + # Key should be (data_label, statistic) and value the translated object. + self._get_object_cache = {} + self.hub.subscribe(self, SubsetUpdateMessage, + handler=lambda msg: self._clear_object_cache(msg.subset.label)) + # Add new and inverse colormaps to Glue global state. Also see ColormapRegistry in # https://github.com/glue-viz/glue/blob/main/glue/config.py new_cms = (['Rainbow', cm.rainbow], @@ -1486,6 +1493,14 @@ def _on_data_added(self, msg): data_item = self._create_data_item(msg.data) self.state.data_items.append(data_item) + def _clear_object_cache(self, data_label=None): + if data_label is None: + self._get_object_cache.clear() + else: + # keys are (data_label, statistic) tuples + self._get_object_cache = {k: v for k, v in self._get_object_cache.items() + if k[0] != data_label} + def _on_data_deleted(self, msg): """ Callback for when data is removed from the internal ``DataCollection``. @@ -1501,6 +1516,8 @@ def _on_data_deleted(self, msg): if data_item['name'] == msg.data.label: self.state.data_items.remove(data_item) + self._clear_object_cache(msg.data.label) + @staticmethod def _create_data_item(data): ndims = len(data.shape) diff --git a/jdaviz/configs/default/plugins/gaussian_smooth/tests/test_gaussian_smooth.py b/jdaviz/configs/default/plugins/gaussian_smooth/tests/test_gaussian_smooth.py index 4f9ad23781..f5b4cd7ce7 100644 --- a/jdaviz/configs/default/plugins/gaussian_smooth/tests/test_gaussian_smooth.py +++ b/jdaviz/configs/default/plugins/gaussian_smooth/tests/test_gaussian_smooth.py @@ -1,21 +1,18 @@ import pytest -from jdaviz import Application +from astropy.utils.exceptions import AstropyUserWarning from specutils import Spectrum1D -from jdaviz.configs.default.plugins.gaussian_smooth.gaussian_smooth import GaussianSmooth - -def test_linking_after_spectral_smooth(spectrum1d_cube): - - app = Application(configuration="cubeviz") +def test_linking_after_spectral_smooth(cubeviz_helper, spectrum1d_cube): + app = cubeviz_helper.app dc = app.data_collection - app.add_data(spectrum1d_cube, 'test') - app.add_data_to_viewer('flux-viewer', 'test') + cubeviz_helper.load_data(spectrum1d_cube, data_label='test') + spec_viewer = cubeviz_helper.app.get_viewer('spectrum-viewer') assert len(dc) == 1 - gs = GaussianSmooth(app=app) - gs.dataset_selected = 'test' + gs = cubeviz_helper.plugins['Gaussian Smooth']._obj + gs.dataset_selected = 'test[FLUX]' gs.mode_selected = 'Spectral' gs.stddev = 3.2 gs.add_to_viewer_selected = 'None' @@ -40,8 +37,8 @@ def test_linking_after_spectral_smooth(spectrum1d_cube): # itself is prepended to the default label, and there is no longer # an overwrite warning. assert len(gs.dataset_items) == 2 - assert gs.dataset_selected == 'test' - assert gs.results_label == 'test spectral-smooth stddev-3.2' + assert gs.dataset_selected == 'test[FLUX]' + assert gs.results_label == 'test[FLUX] spectral-smooth stddev-3.2' assert gs.results_label_overwrite is False assert len(dc) == 2 @@ -68,21 +65,100 @@ def test_linking_after_spectral_smooth(spectrum1d_cube): assert dc.external_links[2].cids1[0] == dc[0].pixel_component_ids[2] assert dc.external_links[2].cids2[0] == dc[-1].pixel_component_ids[2] + # Mouseover should automatically jump from one spectrum + # to another, depending on which one is closer. + + spec_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 4.6236e-7, 'y': 60}}) + assert spec_viewer.label_mouseover.pixel == '4.62360e-07, 6.00000e+01' + assert spec_viewer.label_mouseover.world_label_prefix == 'Wave' + assert spec_viewer.label_mouseover.world_ra == '4.62360e-07 m (1 pix)' + assert spec_viewer.label_mouseover.world_label_prefix_2 == 'Flux' + assert spec_viewer.label_mouseover.world_ra_deg == '9.20000e+01 Jy' + assert spec_viewer.label_mouseover.icon == 'a' + + spec_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 4.6236e-7, 'y': 20}}) + assert spec_viewer.label_mouseover.pixel == '4.62360e-07, 2.00000e+01' + assert spec_viewer.label_mouseover.world_label_prefix == 'Wave' + assert spec_viewer.label_mouseover.world_ra == '4.62360e-07 m (1 pix)' + assert spec_viewer.label_mouseover.world_label_prefix_2 == 'Flux' + assert spec_viewer.label_mouseover.world_ra_deg == '1.47943e+01 Jy' + assert spec_viewer.label_mouseover.icon == 'b' + + # Check mouseover behavior when we hide everything. + for lyr in spec_viewer.layers: + lyr.visible = False + + spec_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 4.6236e-7, 'y': 60}}) + assert spec_viewer.label_mouseover.pixel == '' + assert spec_viewer.label_mouseover.world_label_prefix == '\xa0' + assert spec_viewer.label_mouseover.world_ra == '' + assert spec_viewer.label_mouseover.world_dec == '' + assert spec_viewer.label_mouseover.world_label_prefix_2 == '\xa0' + assert spec_viewer.label_mouseover.world_ra_deg == '' + assert spec_viewer.label_mouseover.world_dec_deg == '' + assert spec_viewer.label_mouseover.icon == '' + -@pytest.mark.filterwarnings("ignore::UserWarning") def test_spatial_convolution(cubeviz_helper, spectrum1d_cube): dc = cubeviz_helper.app.data_collection - cubeviz_helper.app.add_data(spectrum1d_cube, 'test') - cubeviz_helper.app.add_data_to_viewer('flux-viewer', 'test') + cubeviz_helper.load_data(spectrum1d_cube, data_label='test') - gs = GaussianSmooth(app=cubeviz_helper.app) - gs.dataset_selected = 'test' + gs = cubeviz_helper.plugins['Gaussian Smooth']._obj + gs.dataset_selected = 'test[FLUX]' gs.mode_selected = 'Spatial' gs.stddev = 3 assert gs.results_label == 'spatial-smooth stddev-3.0' - gs.vue_apply() + with pytest.warns( + AstropyUserWarning, + match='The following attributes were set on the data object, but will be ignored'): + gs.vue_apply() assert len(dc) == 2 assert dc[1].label == "spatial-smooth stddev-3.0" + assert dc[1].shape == (2, 4, 2) # specutils moved spectral axis to last assert (dc["spatial-smooth stddev-3.0"].get_object(cls=Spectrum1D, statistic=None).shape - == (4, 2, 2)) + == (2, 4, 2)) + + +def test_spectrum1d_smooth(specviz_helper, spectrum1d): + dc = specviz_helper.app.data_collection + specviz_helper.load_data(spectrum1d, data_label='test') + spec_viewer = specviz_helper.app.get_viewer('spectrum-viewer') + + gs = specviz_helper.plugins['Gaussian Smooth']._obj + gs.dataset_selected = 'test' + gs.mode_selected = 'Spectral' + gs.stddev = 10 + gs.vue_apply() + + assert len(dc) == 2 + assert dc[1].label == 'smooth stddev-10.0' + + # Mouseover should automatically jump from one spectrum + # to another, depending on which one is closer. + + spec_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 6400, 'y': 120}}) + assert spec_viewer.label_mouseover.pixel == '6.40000e+03, 1.20000e+02' + assert spec_viewer.label_mouseover.world_label_prefix == 'Wave' + assert spec_viewer.label_mouseover.world_ra == '6.44444e+03 Angstrom (2 pix)' + assert spec_viewer.label_mouseover.world_label_prefix_2 == 'Flux' + assert spec_viewer.label_mouseover.world_ra_deg == '1.35366e+01 Jy' + assert spec_viewer.label_mouseover.icon == 'a' + + spec_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 6400, 'y': 5}}) + assert spec_viewer.label_mouseover.world_label_prefix == 'Wave' + assert spec_viewer.label_mouseover.world_ra == '6.44444e+03 Angstrom (2 pix)' + assert spec_viewer.label_mouseover.world_label_prefix_2 == 'Flux' + assert spec_viewer.label_mouseover.world_ra_deg == '5.34688e+00 Jy' + assert spec_viewer.label_mouseover.icon == 'b' + + # Out-of-bounds shows nothing. + spec_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 5500, 'y': 120}}) + assert spec_viewer.label_mouseover.pixel == '' + assert spec_viewer.label_mouseover.world_label_prefix == '\xa0' + assert spec_viewer.label_mouseover.world_ra == '' + assert spec_viewer.label_mouseover.world_dec == '' + assert spec_viewer.label_mouseover.world_label_prefix_2 == '\xa0' + assert spec_viewer.label_mouseover.world_ra_deg == '' + assert spec_viewer.label_mouseover.world_dec_deg == '' + assert spec_viewer.label_mouseover.icon == '' diff --git a/jdaviz/configs/imviz/plugins/coords_info/coords_info.py b/jdaviz/configs/imviz/plugins/coords_info/coords_info.py index 46a0c0cfdb..4a1694db81 100644 --- a/jdaviz/configs/imviz/plugins/coords_info/coords_info.py +++ b/jdaviz/configs/imviz/plugins/coords_info/coords_info.py @@ -1,7 +1,9 @@ from traitlets import Bool, Unicode +from jdaviz.configs.specviz.plugins.viewers import SpecvizProfileView from jdaviz.core.registries import tool_registry from jdaviz.core.template_mixin import TemplateMixin +from jdaviz.core.marks import PluginScatter __all__ = ['CoordsInfo'] @@ -10,9 +12,11 @@ class CoordsInfo(TemplateMixin): template_file = __file__, "coords_info.vue" icon = Unicode("").tag(sync=True) + pixel_prefix = Unicode("Pixel").tag(sync=True) pixel = Unicode("").tag(sync=True) value = Unicode("").tag(sync=True) world_label_prefix = Unicode("\u00A0").tag(sync=True) + world_label_prefix_2 = Unicode("\u00A0").tag(sync=True) world_label_icrs = Unicode("\u00A0").tag(sync=True) world_label_deg = Unicode("\u00A0").tag(sync=True) world_ra = Unicode("").tag(sync=True) @@ -22,8 +26,33 @@ class CoordsInfo(TemplateMixin): unreliable_world = Bool(False).tag(sync=True) unreliable_pixel = Bool(False).tag(sync=True) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._marks = {} + + @property + def marks(self): + """ + Access the marks created by this plugin. + """ + if self._marks: + # TODO: replace with cache property? + return self._marks + + # create marks for each of the spectral viewers (will need a listener event to create marks + # for new viewers if dynamic creation of spectral viewers is ever supported) + for id, viewer in self.app._viewer_store.items(): + if isinstance(viewer, SpecvizProfileView): + self._marks[id] = PluginScatter(viewer, + marker='rectangle', stroke_width=1, + visible=False) + viewer.figure.marks = viewer.figure.marks + [self._marks[id]] + return self._marks + def reset_coords_display(self): + self.pixel_prefix = "Pixel" self.world_label_prefix = '\u00A0' + self.world_label_prefix_2 = '\u00A0' self.world_label_icrs = '\u00A0' self.world_label_deg = '\u00A0' self.world_ra = '' @@ -44,6 +73,7 @@ def set_coords(self, sky, unreliable_world=False, unreliable_pixel=False): if "nan" in (world_ra, world_dec, world_ra_deg, world_dec_deg): self.reset_coords_display() else: + self.pixel_prefix = 'Pixel' self.world_label_prefix = 'World' self.world_label_icrs = '(ICRS)' self.world_label_deg = '(deg)' @@ -53,3 +83,7 @@ def set_coords(self, sky, unreliable_world=False, unreliable_pixel=False): self.world_dec_deg = world_dec_deg self.unreliable_world = unreliable_world self.unreliable_pixel = unreliable_pixel + if unreliable_world: + self.world_label_prefix_2 = '(est.)' + else: + self.world_label_prefix_2 = '\u00A0' diff --git a/jdaviz/configs/imviz/plugins/coords_info/coords_info.vue b/jdaviz/configs/imviz/plugins/coords_info/coords_info.vue index d875d80a78..fbdc8718ab 100644 --- a/jdaviz/configs/imviz/plugins/coords_info/coords_info.vue +++ b/jdaviz/configs/imviz/plugins/coords_info/coords_info.vue @@ -7,19 +7,19 @@ - + - - + + diff --git a/jdaviz/configs/imviz/tests/test_linking.py b/jdaviz/configs/imviz/tests/test_linking.py index 096d56b0f6..2ba0ed5a44 100644 --- a/jdaviz/configs/imviz/tests/test_linking.py +++ b/jdaviz/configs/imviz/tests/test_linking.py @@ -271,6 +271,7 @@ def test_wcslink_rotated(self): # but cursor is outside GWCS bounding box assert self.viewer.label_mouseover.unreliable_world assert self.viewer.label_mouseover.unreliable_pixel + assert self.viewer.label_mouseover.world_label_prefix_2 == '(est.)' class TestLink_GWCS_GWCS(BaseImviz_GWCS_GWCS): diff --git a/jdaviz/configs/mosviz/tests/test_data_loading.py b/jdaviz/configs/mosviz/tests/test_data_loading.py index 36164332fd..2a117ebc20 100644 --- a/jdaviz/configs/mosviz/tests/test_data_loading.py +++ b/jdaviz/configs/mosviz/tests/test_data_loading.py @@ -157,6 +157,8 @@ def test_load_single_image_multi_spec(mosviz_helper, mos_image, spectrum1d, mos_ spectra2d = [mos_spectrum2d] * 3 image_viewer = mosviz_helper.app.get_viewer('image-viewer') + spec1d_viewer = mosviz_helper.app.get_viewer('spectrum-viewer') + spec2d_viewer = mosviz_helper.app.get_viewer('spectrum-2d-viewer') # Coordinates info panel should not crash even when nothing is loaded. image_viewer.on_mouse_or_key_event({'event': 'mouseover'}) @@ -180,14 +182,15 @@ def test_load_single_image_multi_spec(mosviz_helper, mos_image, spectrum1d, mos_ assert len(qtable) == 3 # Also check coordinates info panels for Mosviz image viewer. - # 1D spectrum viewer panel is already tested in Specviz. - # 2D spectrum viewer panel is already tested in Specviz2d. + # 1D spectrum viewer panel is also tested in Specviz. + # 2D spectrum viewer panel is also tested in Specviz2d. image_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 0, 'y': 0}}) assert image_viewer.label_mouseover.pixel == 'x=000.0 y=000.0' assert image_viewer.label_mouseover.value == '+3.74540e-01 Jy' assert image_viewer.label_mouseover.world_ra_deg == '5.0297844783' assert image_viewer.label_mouseover.world_dec_deg == '4.9918991917' + assert image_viewer.label_mouseover.icon == 'a' image_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': None, 'y': 0}}) assert image_viewer.label_mouseover.pixel == '' @@ -207,6 +210,21 @@ def test_load_single_image_multi_spec(mosviz_helper, mos_image, spectrum1d, mos_ assert image_viewer.label_mouseover.world_ra_deg == '' assert image_viewer.label_mouseover.world_dec_deg == '' + spec2d_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 10, 'y': 100}}) + assert spec2d_viewer.label_mouseover.pixel == 'x=00010.0 y=00100.0' + assert spec2d_viewer.label_mouseover.value == '+8.12986e-01 ' + assert spec2d_viewer.label_mouseover.world_ra_deg == '' + assert spec2d_viewer.label_mouseover.world_dec_deg == '' + assert spec2d_viewer.label_mouseover.icon == 'b' + + spec1d_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 7000, 'y': 170}}) + assert spec1d_viewer.label_mouseover.pixel == '7.00000e+03, 1.70000e+02' + assert spec1d_viewer.label_mouseover.world_label_prefix == 'Wave' + assert spec1d_viewer.label_mouseover.world_ra == '6.88889e+03 Angstrom (4 pix)' + assert spec1d_viewer.label_mouseover.world_label_prefix_2 == 'Flux' + assert spec1d_viewer.label_mouseover.world_ra_deg == '1.35436e+01 Jy' + assert spec1d_viewer.label_mouseover.icon == 'c' + def test_zip_error(mosviz_helper, tmp_path): ''' diff --git a/jdaviz/configs/specviz/plugins/viewers.py b/jdaviz/configs/specviz/plugins/viewers.py index 5a68c99b61..05d1dcbef7 100644 --- a/jdaviz/configs/specviz/plugins/viewers.py +++ b/jdaviz/configs/specviz/plugins/viewers.py @@ -1,17 +1,17 @@ -import numpy as np +import math import warnings +import numpy as np +from astropy import table +from astropy import units as u from glue.core import BaseData -from glue.core.subset import Subset +from glue.core.subset import Subset, RoiSubsetState +from glue.core.subset_group import GroupedSubset from glue.config import data_translator from glue_jupyter.bqplot.profile import BqplotProfileView from glue.core.exceptions import IncompatibleAttribute - -from astropy import table -from specutils import Spectrum1D from matplotlib.colors import cnames -from astropy import units as u - +from specutils import Spectrum1D from jdaviz.core.events import SpectralMarksChangedMessage, LineIdentifyMessage from jdaviz.core.registries import viewer_registry @@ -66,6 +66,9 @@ def on_mouse_or_key_event(self, data): return if data['event'] == 'mousemove': + if len(self.jdaviz_app.data_collection) < 1: + return + # Extract data coordinates - these are pixels in the reference image x = data['domain']['x'] y = data['domain']['y'] @@ -76,19 +79,94 @@ def on_mouse_or_key_event(self, data): self.label_mouseover.value = "" return - fmt = 'x={:+10.5e} y={:+10.5e}' - self.label_mouseover.pixel = fmt.format(x, y) + # Snap to the closest data point, not the actual mouse location. + sp = None + closest_i = None + closest_wave = None + closest_flux = None + closest_label = '' + closest_distance = None + for lyr in self.state.layers: + if not lyr.visible: + continue + if isinstance(lyr.layer, GroupedSubset): + if not isinstance(lyr.layer.subset_state, RoiSubsetState): + # then this is a SPECTRAL subset + continue + elif ((not isinstance(lyr.layer, BaseData)) or (lyr.layer.ndim not in (1, 3)) + or (not lyr.visible)): + continue + + try: + # Cache should have been populated when spectrum was first plotted. + # But if not (maybe user changed statistic), we cache it here too. + statistic = getattr(self.state, 'function', None) + cache_key = (lyr.layer.label, statistic) + if cache_key in self.jdaviz_app._get_object_cache: + sp = self.jdaviz_app._get_object_cache[cache_key] + else: + sp = self.jdaviz_app.get_data_from_viewer( + self.jdaviz_app._default_spectrum_viewer_reference_name, + lyr.layer.label) + self.jdaviz_app._get_object_cache[cache_key] = sp + + # Out of range in spectral axis. + if x < sp.spectral_axis.value.min() or x > sp.spectral_axis.value.max(): + continue + + cur_i = np.argmin(abs(sp.spectral_axis.value - x)) + cur_wave = sp.spectral_axis[cur_i] + cur_flux = sp.flux[cur_i] + + dx = cur_wave.value - x + dy = cur_flux.value - y + cur_distance = math.sqrt(dx * dx + dy * dy) + if (closest_distance is None) or (cur_distance < closest_distance): + closest_distance = cur_distance + closest_i = cur_i + closest_wave = cur_wave + closest_flux = cur_flux + closest_label = self.jdaviz_app.state.layer_icons.get(lyr.layer.label) + except Exception: # nosec + # Something is loaded but not the right thing + continue + + if closest_wave is None: + self.label_mouseover.icon = "" + self.label_mouseover.pixel = "" + self.label_mouseover.reset_coords_display() + self.label_mouseover.value = "" + self.label_mouseover.marks[self._reference_id].visible = False + return - # We just want cursor position, so these are not used. - self.label_mouseover.icon = '' - self.label_mouseover.reset_coords_display() - self.label_mouseover.value = '' + # show the locked marker/coords only if either no tool or the default tool is active + locking_active = self.toolbar.active_tool_id in self.toolbar.default_tool_priority + [None] # noqa + self.label_mouseover.pixel_prefix = 'Cursor' + self.label_mouseover.pixel = f'{x:10.5e}, {y:10.5e}' + if locking_active: + self.label_mouseover.world_label_prefix = 'Wave' + self.label_mouseover.world_ra = f'{closest_wave.value:10.5e} {closest_wave.unit.to_string()}' # noqa + if closest_wave.unit != u.pix: + self.label_mouseover.world_ra += f' ({closest_i} pix)' + self.label_mouseover.world_dec = '' + self.label_mouseover.world_label_prefix_2 = 'Flux' + self.label_mouseover.world_ra_deg = f'{closest_flux.value:10.5e} {closest_flux.unit.to_string()}' # noqa + self.label_mouseover.world_dec_deg = '' + self.label_mouseover.icon = closest_label + self.label_mouseover.value = "" # Not used + self.label_mouseover.marks[self._reference_id].update_xy([closest_wave.value], [closest_flux.value]) # noqa + self.label_mouseover.marks[self._reference_id].visible = True + else: + # show exact plot coordinates (useful for drawing spectral subsets or zoom ranges) + self.label_mouseover.icon = "" + self.label_mouseover.marks[self._reference_id].visible = False elif data['event'] == 'mouseleave' or data['event'] == 'mouseenter': - + self.label_mouseover.icon = "" self.label_mouseover.pixel = "" self.label_mouseover.reset_coords_display() self.label_mouseover.value = "" + self.label_mouseover.marks[self._reference_id].visible = False def _expected_subset_layer_default(self, layer_state): super()._expected_subset_layer_default(layer_state) @@ -97,40 +175,39 @@ def _expected_subset_layer_default(self, layer_state): def data(self, cls=None): # Grab the user's chosen statistic for collapsing data - if hasattr(self.state, 'function'): - statistic = self.state.function - else: - statistic = None - + statistic = getattr(self.state, 'function', None) data = [] for layer_state in self.state.layers: if hasattr(layer_state, 'layer'): + lyr = layer_state.layer # For raw data, just include the data itself - if isinstance(layer_state.layer, BaseData): + if isinstance(lyr, BaseData): _class = cls or self.default_class if _class is not None: - # If spectrum, collapse via the defined statistic - if _class == Spectrum1D: - layer_data = layer_state.layer.get_object(cls=_class, - statistic=statistic) + cache_key = (lyr.label, statistic) + if cache_key in self.jdaviz_app._get_object_cache: + layer_data = self.jdaviz_app._get_object_cache[cache_key] else: - layer_data = layer_state.layer.get_object(cls=_class) + # If spectrum, collapse via the defined statistic + if _class == Spectrum1D: + layer_data = lyr.get_object(cls=_class, statistic=statistic) + else: + layer_data = lyr.get_object(cls=_class) + self.jdaviz_app._get_object_cache[cache_key] = layer_data data.append(layer_data) - # For subsets, make sure to apply the subset mask to the - # layer data first - elif isinstance(layer_state.layer, Subset): - layer_data = layer_state.layer + # For subsets, make sure to apply the subset mask to the layer data first + elif isinstance(lyr, Subset): + layer_data = lyr if _class is not None: handler, _ = data_translator.get_handler_for(_class) try: - layer_data = handler.to_object(layer_data, - statistic=statistic) + layer_data = handler.to_object(layer_data, statistic=statistic) except IncompatibleAttribute: continue data.append(layer_data) @@ -414,18 +491,20 @@ def _plot_mask(self): # Loop through all active data in the viewer for index, layer_state in enumerate(self.state.layers): - comps = [str(component) for component in layer_state.layer.components] + lyr = layer_state.layer + comps = [str(component) for component in lyr.components] # Skip subsets - if hasattr(layer_state.layer, "subset_state"): + if hasattr(lyr, "subset_state"): continue # Ignore data that does not have a mask component if "mask" in comps: - mask = np.array(layer_state.layer['mask'].data) + mask = np.array(lyr['mask'].data) - data_x = layer_state.layer.data.get_object().spectral_axis - data_y = layer_state.layer.data.get_object().flux.value + data_obj = lyr.data.get_object() + data_x = data_obj.spectral_axis.value + data_y = data_obj.flux.value # For plotting markers only for the masked data # points, erase un-masked data from trace. @@ -456,24 +535,26 @@ def _plot_uncertainties(self): # Loop through all active data in the viewer for index, layer_state in enumerate(self.state.layers): + lyr = layer_state.layer # Skip subsets - if hasattr(layer_state.layer, "subset_state"): + if hasattr(lyr, "subset_state"): continue - comps = [str(component) for component in layer_state.layer.components] + comps = [str(component) for component in lyr.components] # Ignore data that does not have an uncertainty component if "uncertainty" in comps: # noqa - error = np.array(layer_state.layer['uncertainty'].data) + error = np.array(lyr['uncertainty'].data) - data_x = layer_state.layer.data.get_object().spectral_axis - data_y = layer_state.layer.data.get_object().flux.value + data_obj = lyr.data.get_object() + data_x = data_obj.spectral_axis.value + data_y = data_obj.flux.value # The shaded band around the spectrum trace is bounded by # two lines, above and below the spectrum trace itself. - x = [np.ndarray.tolist(data_x), - np.ndarray.tolist(data_x)] + data_x_list = np.ndarray.tolist(data_x) + x = [data_x_list, data_x_list] y = [np.ndarray.tolist(data_y - error), np.ndarray.tolist(data_y + error)] diff --git a/jdaviz/configs/specviz/tests/test_helper.py b/jdaviz/configs/specviz/tests/test_helper.py index abfbe8708e..f3023e6f39 100644 --- a/jdaviz/configs/specviz/tests/test_helper.py +++ b/jdaviz/configs/specviz/tests/test_helper.py @@ -1,5 +1,6 @@ from zipfile import ZipFile +import numpy as np import pytest from astropy import units as u from astropy.tests.helper import assert_quantity_allclose @@ -204,16 +205,41 @@ def test_get_spectral_regions_unit(specviz_helper, spectrum1d): def test_get_spectral_regions_unit_conversion(specviz_helper, spectrum1d): + spec_viewer = specviz_helper.app.get_viewer('spectrum-viewer') + + # Mouseover without data should not crash. + spec_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 6100, 'y': 12.5}}) + assert spec_viewer.label_mouseover.pixel == '' + assert spec_viewer.label_mouseover.world_label_prefix == '\xa0' + assert spec_viewer.label_mouseover.world_ra == '' + assert spec_viewer.label_mouseover.world_dec == '' + assert spec_viewer.label_mouseover.world_label_prefix_2 == '\xa0' + assert spec_viewer.label_mouseover.world_ra_deg == '' + assert spec_viewer.label_mouseover.world_dec_deg == '' + assert spec_viewer.label_mouseover.icon == '' + # If the reference (visible) data changes via unit conversion, # check that the region's units convert too specviz_helper.load_spectrum(spectrum1d) - # Also check coordinates info panel - spec_viewer = specviz_helper.app.get_viewer('spectrum-viewer') + # Also check coordinates info panel. + # x=0 -> 6000 A, x=1 -> 6222.222 A spec_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 6100, 'y': 12.5}}) - assert spec_viewer.label_mouseover.pixel == 'x=+6.10000e+03 y=+1.25000e+01' + assert spec_viewer.label_mouseover.pixel == '6.10000e+03, 1.25000e+01' + assert spec_viewer.label_mouseover.world_label_prefix == 'Wave' + assert spec_viewer.label_mouseover.world_ra == '6.00000e+03 Angstrom (0 pix)' # actual: 0.4 + assert spec_viewer.label_mouseover.world_label_prefix_2 == 'Flux' + assert spec_viewer.label_mouseover.world_ra_deg == '1.24967e+01 Jy' + assert spec_viewer.label_mouseover.icon == 'a' spec_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': None, 'y': 12.5}}) assert spec_viewer.label_mouseover.pixel == '' + assert spec_viewer.label_mouseover.world_label_prefix == '\xa0' + assert spec_viewer.label_mouseover.world_ra == '' + assert spec_viewer.label_mouseover.world_dec == '' + assert spec_viewer.label_mouseover.world_label_prefix_2 == '\xa0' + assert spec_viewer.label_mouseover.world_ra_deg == '' + assert spec_viewer.label_mouseover.world_dec_deg == '' + assert spec_viewer.label_mouseover.icon == 'a' # Convert the wavelength axis to microns new_spectral_axis = "micron" @@ -238,9 +264,21 @@ def test_get_spectral_regions_unit_conversion(specviz_helper, spectrum1d): # Coordinates info panel should show new unit spec_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 0.61, 'y': 12.5}}) - assert spec_viewer.label_mouseover.pixel == 'x=+6.10000e-01 y=+1.25000e+01' + assert spec_viewer.label_mouseover.pixel == '6.10000e-01, 1.25000e+01' + assert spec_viewer.label_mouseover.world_label_prefix == 'Wave' + assert spec_viewer.label_mouseover.world_ra == '6.00000e-01 micron (0 pix)' + assert spec_viewer.label_mouseover.world_label_prefix_2 == 'Flux' + assert spec_viewer.label_mouseover.world_ra_deg == '1.24967e+01 Jy' + assert spec_viewer.label_mouseover.icon == 'b' spec_viewer.on_mouse_or_key_event({'event': 'mouseleave'}) assert spec_viewer.label_mouseover.pixel == '' + assert spec_viewer.label_mouseover.world_label_prefix == '\xa0' + assert spec_viewer.label_mouseover.world_ra == '' + assert spec_viewer.label_mouseover.world_dec == '' + assert spec_viewer.label_mouseover.world_label_prefix_2 == '\xa0' + assert spec_viewer.label_mouseover.world_ra_deg == '' + assert spec_viewer.label_mouseover.world_dec_deg == '' + assert spec_viewer.label_mouseover.icon == '' def test_subset_default_thickness(specviz_helper, spectrum1d): @@ -340,3 +378,28 @@ def test_data_label_as_posarg(specviz_helper, spectrum1d): # Passing in data_label keyword as posarg. specviz_helper.load_spectrum(spectrum1d, 'my_spec') assert specviz_helper.app.data_collection[0].label == 'my_spec' + + +def test_spectra_partial_overlap(specviz_helper): + spec_viewer = specviz_helper.app.get_viewer('spectrum-viewer') + + wave_1 = np.linspace(6000, 7000, 10) * u.AA + flux_1 = ([1200] * wave_1.size) * u.nJy + sp_1 = Spectrum1D(flux=flux_1, spectral_axis=wave_1) + + wave_2 = wave_1 + (800 * u.AA) + flux_2 = ([60] * wave_2.size) * u.nJy + sp_2 = Spectrum1D(flux=flux_2, spectral_axis=wave_2) + + specviz_helper.load_spectrum(sp_1, data_label='left') + specviz_helper.load_spectrum(sp_2, data_label='right') + + # Test mouseover outside of left but in range for right. + # Should show right spectrum even when mouse is near left flux. + spec_viewer.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 7022, 'y': 1000}}) + assert spec_viewer.label_mouseover.pixel == '7.02200e+03, 1.00000e+03' + assert spec_viewer.label_mouseover.world_label_prefix == 'Wave' + assert spec_viewer.label_mouseover.world_ra == '7.02222e+03 Angstrom (2 pix)' + assert spec_viewer.label_mouseover.world_label_prefix_2 == 'Flux' + assert spec_viewer.label_mouseover.world_ra_deg == '6.00000e+01 nJy' + assert spec_viewer.label_mouseover.icon == 'b' diff --git a/jdaviz/configs/specviz2d/tests/test_parsers.py b/jdaviz/configs/specviz2d/tests/test_parsers.py index 122a10fa29..8f8cd8dad9 100644 --- a/jdaviz/configs/specviz2d/tests/test_parsers.py +++ b/jdaviz/configs/specviz2d/tests/test_parsers.py @@ -68,13 +68,24 @@ def test_2d_parser_no_unit(specviz2d_helper, mos_spectrum2d): assert dc_1.label == 'Spectrum 1D' assert dc_1.get_component('flux').units == dc_0.get_component('flux').units - # Also check the coordinates info panel. + # Also check the coordinates info panels. + viewer_2d = specviz2d_helper.app.get_viewer('spectrum-2d-viewer') viewer_2d.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 0, 'y': 0}}) assert viewer_2d.label_mouseover.pixel == 'x=00000.0 y=00000.0' assert viewer_2d.label_mouseover.value == '+3.74540e-01 ' assert viewer_2d.label_mouseover.world_ra_deg == '' assert viewer_2d.label_mouseover.world_dec_deg == '' + assert viewer_2d.label_mouseover.icon == 'a' + + viewer_1d = specviz2d_helper.app.get_viewer('spectrum-viewer') + viewer_1d.on_mouse_or_key_event({'event': 'mousemove', 'domain': {'x': 6.5, 'y': 3}}) + assert viewer_1d.label_mouseover.pixel == '6.50000e+00, 3.00000e+00' + assert viewer_1d.label_mouseover.world_label_prefix == 'Wave' + assert viewer_1d.label_mouseover.world_ra == '6.00000e+00 pix' + assert viewer_1d.label_mouseover.world_label_prefix_2 == 'Flux' + assert viewer_1d.label_mouseover.world_ra_deg == '-3.59571e+00 ' # extra space for no unit + assert viewer_1d.label_mouseover.icon == 'b' def test_1d_parser(specviz2d_helper, spectrum1d): diff --git a/jdaviz/core/marks.py b/jdaviz/core/marks.py index 110cc87deb..955850e54b 100644 --- a/jdaviz/core/marks.py +++ b/jdaviz/core/marks.py @@ -13,7 +13,7 @@ __all__ = ['OffscreenLinesMarks', 'BaseSpectrumVerticalLine', 'SpectralLine', 'SliceIndicatorMarks', 'ShadowMixin', 'ShadowLine', 'ShadowLabelFixedY', - 'PluginLine', + 'PluginMark', 'PluginLine', 'PluginScatter', 'LineAnalysisContinuum', 'LineAnalysisContinuumCenter', 'LineAnalysisContinuumLeft', 'LineAnalysisContinuumRight', 'LineUncertainties', 'ScatterMask', 'SelectedSpaxel'] @@ -484,19 +484,31 @@ def _on_shadowing_changed(self, change): self._update_align() -class PluginLine(Lines, HubListener): - def __init__(self, viewer, x=[], y=[], **kwargs): - # color is same blue as import button - super().__init__(x=x, y=y, colors=["#007BA1"], scales=viewer.scales, **kwargs) - +class PluginMark(): def update_xy(self, x, y): self.x = np.asarray(x) self.y = np.asarray(y) + def append_xy(self, x, y): + self.x = np.append(self.x, x) + self.y = np.append(self.y, y) + def clear(self): self.update_xy([], []) +class PluginLine(Lines, PluginMark, HubListener): + def __init__(self, viewer, x=[], y=[], **kwargs): + # color is same blue as import button + super().__init__(x=x, y=y, colors=["#007BA1"], scales=viewer.scales, **kwargs) + + +class PluginScatter(Scatter, PluginMark, HubListener): + def __init__(self, viewer, x=[], y=[], **kwargs): + # color is same blue as import button + super().__init__(x=x, y=y, colors=["#007BA1"], scales=viewer.scales, **kwargs) + + class LineAnalysisContinuum(PluginLine): pass
- Pixel {{ pixel }}   + {{ pixel_prefix }} {{ pixel }}   Value {{ value }}
{{ world_label_prefix }}{{ world_ra }}{{ world_ra }} {{ world_dec }} {{ world_label_icrs }}
{{ unreliable_world ? '(est.)' : '' }}{{ world_ra_deg }}{{ world_label_prefix_2 }}{{ world_ra_deg }} {{ world_dec_deg }} {{ world_label_deg }}