From b8247bc849402532b8cfd899e31215b00fee65df Mon Sep 17 00:00:00 2001 From: Kyle Conroy Date: Thu, 17 Nov 2022 10:54:54 -0500 Subject: [PATCH] basic markers plugin implementation --- docs/cubeviz/plugins.rst | 9 ++ docs/imviz/plugins.rst | 10 ++ docs/mosviz/plugins.rst | 8 ++ docs/specviz/plugins.rst | 8 ++ docs/specviz2d/plugins.rst | 8 ++ jdaviz/app.py | 1 + jdaviz/components/plugin_table.vue | 68 +++++++++++ jdaviz/configs/cubeviz/cubeviz.yaml | 1 + jdaviz/configs/default/plugins/__init__.py | 1 + .../default/plugins/markers/__init__.py | 1 + .../default/plugins/markers/markers.py | 115 ++++++++++++++++++ .../default/plugins/markers/markers.vue | 17 +++ jdaviz/configs/imviz/imviz.yaml | 1 + jdaviz/configs/mosviz/mosviz.yaml | 1 + jdaviz/configs/specviz/specviz.yaml | 1 + jdaviz/configs/specviz2d/specviz2d.yaml | 1 + jdaviz/core/marks.py | 8 +- jdaviz/core/template_mixin.py | 98 +++++++++++++++ 18 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 jdaviz/components/plugin_table.vue create mode 100644 jdaviz/configs/default/plugins/markers/__init__.py create mode 100644 jdaviz/configs/default/plugins/markers/markers.py create mode 100644 jdaviz/configs/default/plugins/markers/markers.vue diff --git a/docs/cubeviz/plugins.rst b/docs/cubeviz/plugins.rst index 10701e5c7f..abea089de8 100644 --- a/docs/cubeviz/plugins.rst +++ b/docs/cubeviz/plugins.rst @@ -55,6 +55,15 @@ Subset Tools :ref:`Subset Tools ` Imviz documentation describing the concept of subsets in Jdaviz. + +Markers +======= + +.. seealso:: + + :ref:`Markers ` + Imviz documentation describing the markers plugin. + .. _slice: Slice diff --git a/docs/imviz/plugins.rst b/docs/imviz/plugins.rst index 38df466801..22665f640d 100644 --- a/docs/imviz/plugins.rst +++ b/docs/imviz/plugins.rst @@ -74,6 +74,16 @@ parameters, shape, and orientation (if applicable) all update concurrently. Angle is counter-clockwise rotation around the center in degrees. +.. _markers-plugin: + +Markers +======= + +This plugin allows for interactively creating markers in any viewer and logging information about +the location of that marker into a table, which can then be exported via the API using +:meth:`~jdaviz.core.template_mixin.TableMixin.export_table` +(see :ref:`plugin-apis`). + .. _imviz-link-control: Link Control diff --git a/docs/mosviz/plugins.rst b/docs/mosviz/plugins.rst index 93f3aac7bf..0d9d1442ad 100644 --- a/docs/mosviz/plugins.rst +++ b/docs/mosviz/plugins.rst @@ -44,6 +44,14 @@ Subset Tools :ref:`Subset Tools ` Imviz documentation describing the concept of subsets in Jdaviz. +Markers +======= + +.. seealso:: + + :ref:`Markers ` + Imviz documentation describing the markers plugin. + Gaussian Smooth =============== diff --git a/docs/specviz/plugins.rst b/docs/specviz/plugins.rst index dee84f7ffd..d1159f96f1 100644 --- a/docs/specviz/plugins.rst +++ b/docs/specviz/plugins.rst @@ -50,6 +50,14 @@ Subset Tools :ref:`Subset Tools ` Imviz documentation describing the concept of subsets in Jdaviz. +Markers +======= + +.. seealso:: + + :ref:`Markers ` + Imviz documentation describing the markers plugin. + .. _gaussian-smooth: Gaussian Smooth diff --git a/docs/specviz2d/plugins.rst b/docs/specviz2d/plugins.rst index 734fdfaa3d..e4cdfc73c3 100644 --- a/docs/specviz2d/plugins.rst +++ b/docs/specviz2d/plugins.rst @@ -37,6 +37,14 @@ Subset Tools :ref:`Subset Tools ` Imviz documentation describing the concept of subsets in Jdaviz. + +Markers +======= + +.. seealso:: + + :ref:`Markers ` + Imviz documentation describing the markers plugin. .. _specviz2d-spectral-extraction: diff --git a/jdaviz/app.py b/jdaviz/app.py index 0dd2bbe75a..378f537653 100644 --- a/jdaviz/app.py +++ b/jdaviz/app.py @@ -77,6 +77,7 @@ 'j-plugin-section-header': 'components/plugin_section_header.vue', 'j-number-uncertainty': 'components/number_uncertainty.vue', 'j-plugin-popout': 'components/plugin_popout.vue', + 'plugin-table': 'components/plugin_table.vue', 'plugin-dataset-select': 'components/plugin_dataset_select.vue', 'plugin-subset-select': 'components/plugin_subset_select.vue', 'plugin-viewer-select': 'components/plugin_viewer_select.vue', diff --git a/jdaviz/components/plugin_table.vue b/jdaviz/components/plugin_table.vue new file mode 100644 index 0000000000..3d42dbe0bf --- /dev/null +++ b/jdaviz/components/plugin_table.vue @@ -0,0 +1,68 @@ + + + diff --git a/jdaviz/configs/cubeviz/cubeviz.yaml b/jdaviz/configs/cubeviz/cubeviz.yaml index a678bbadd0..4903187b53 100644 --- a/jdaviz/configs/cubeviz/cubeviz.yaml +++ b/jdaviz/configs/cubeviz/cubeviz.yaml @@ -20,6 +20,7 @@ tray: - g-metadata-viewer - g-plot-options - g-subset-plugin + - g-markers - cubeviz-slice - g-gaussian-smooth - g-collapse diff --git a/jdaviz/configs/default/plugins/__init__.py b/jdaviz/configs/default/plugins/__init__.py index ec435c53cf..f0fc504d4b 100644 --- a/jdaviz/configs/default/plugins/__init__.py +++ b/jdaviz/configs/default/plugins/__init__.py @@ -10,3 +10,4 @@ from .metadata_viewer.metadata_viewer import * # noqa from .export_plot.export_plot import * # noqa from .plot_options.plot_options import * # noqa +from .markers.markers import * # noqa diff --git a/jdaviz/configs/default/plugins/markers/__init__.py b/jdaviz/configs/default/plugins/markers/__init__.py new file mode 100644 index 0000000000..7ea1662669 --- /dev/null +++ b/jdaviz/configs/default/plugins/markers/__init__.py @@ -0,0 +1 @@ +from .markers import * # noqa diff --git a/jdaviz/configs/default/plugins/markers/markers.py b/jdaviz/configs/default/plugins/markers/markers.py new file mode 100644 index 0000000000..5f25f2a5f0 --- /dev/null +++ b/jdaviz/configs/default/plugins/markers/markers.py @@ -0,0 +1,115 @@ +from traitlets import observe + +from glue_jupyter.bqplot.image import BqplotImageView + +from jdaviz.configs.imviz.helper import layer_is_image_data +from jdaviz.configs.cubeviz.helper import layer_is_cube_image_data +from jdaviz.core.marks import MarkersMark +from jdaviz.core.registries import tray_registry +from jdaviz.core.template_mixin import PluginTemplateMixin, ViewerSelectMixin, TableMixin +from jdaviz.core.user_api import PluginUserApi + +__all__ = ['Markers'] + + +@tray_registry('g-markers', label="Markers") +class Markers(PluginTemplateMixin, ViewerSelectMixin, TableMixin): + """ + See the :ref:`Markers Plugin Documentation ` for more details. + + Only the following attributes and methods are available through the + :ref:`public plugin API `: + + * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.show` + * :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.open_in_tray` + """ + template_file = __file__, "markers.vue" + + @property + def user_api(self): + return PluginUserApi(self, expose=('clear_table', 'export_table',)) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + headers = ['x', 'y'] + + if self.config in ['imviz', 'cubeviz', 'mosviz', 'specviz2d']: + headers += ['viewer'] + if self.config in ['imviz', 'cubeviz']: + headers += ['data_label'] + + self.table.headers_avail = headers + self.table.headers_visible = headers + + def _get_mark(self, viewer): + matches = [mark for mark in viewer.figure.marks if isinstance(mark, MarkersMark)] + if len(matches): + return matches[0] + mark = MarkersMark(viewer) + viewer.figure.marks = viewer.figure.marks + [mark] + return mark + + @property + def marks(self): + return {viewer_id: self._get_mark(viewer) + for viewer_id, viewer in self.app._viewer_store.items()} + + @observe('plugin_opened') + def _on_plugin_opened_changed(self, *args): + if self.disabled_msg: + return + + # toggle visibility of markers + for mark in self.marks.values(): + mark.visible = self.plugin_opened + + # subscribe/unsubscribe to keypress events across all viewers + for viewer in self.app._viewer_store.values(): + callback = self._viewer_callback(viewer, self._on_viewer_key_event) + + if self.plugin_opened: + viewer.add_event_callback(callback, events=['keydown']) + else: + viewer.remove_event_callback(callback) + + def _on_viewer_key_event(self, viewer, data): + if data['event'] == 'keydown' and data['key'] in ('m', 'M'): + # TODO: refactor to share code with mouseover display if PR#1976 merged + # TODO: merge with mouseover display entirely and show mouseover info in table + + x = data['domain']['x'] + y = data['domain']['y'] + + if x is None or y is None: # Out of bounds + return + + row_info = {'x': x, 'y': y} + + if 'viewer' in self.table.headers_avail: + row_info['viewer'] = viewer.reference_id + + if isinstance(viewer, BqplotImageView): + # TODO: access viewer.active_image_layer if PR#1976 merged + visible_layers = [layer for layer in viewer.state.layers + if (layer.visible and (layer_is_image_data(layer.layer) or layer_is_cube_image_data(layer.layer)))] # noqa + + if len(visible_layers) == 0: + return + + active_layer = visible_layers[-1] + + row_info['data_label'] = active_layer.layer.label + elif 'data_label' in self.table.headers_avail: + row_info['data_label'] = '' + + self.table.add_row(row_info) + + self._get_mark(viewer).append_xy(x, y) + + def clear_table(self): + """ + Clear all entries/markers from the current table. + """ + super().clear_table() + for mark in self.marks.values(): + mark.clear() diff --git a/jdaviz/configs/default/plugins/markers/markers.vue b/jdaviz/configs/default/plugins/markers/markers.vue new file mode 100644 index 0000000000..efb151d729 --- /dev/null +++ b/jdaviz/configs/default/plugins/markers/markers.vue @@ -0,0 +1,17 @@ + diff --git a/jdaviz/configs/imviz/imviz.yaml b/jdaviz/configs/imviz/imviz.yaml index b95852648e..cab399db5b 100644 --- a/jdaviz/configs/imviz/imviz.yaml +++ b/jdaviz/configs/imviz/imviz.yaml @@ -21,6 +21,7 @@ tray: - g-metadata-viewer - g-plot-options - g-subset-plugin + - g-markers - imviz-links-control - imviz-compass - imviz-line-profile-xy diff --git a/jdaviz/configs/mosviz/mosviz.yaml b/jdaviz/configs/mosviz/mosviz.yaml index 7f40c5c1c6..b5a846350c 100644 --- a/jdaviz/configs/mosviz/mosviz.yaml +++ b/jdaviz/configs/mosviz/mosviz.yaml @@ -18,6 +18,7 @@ tray: - g-metadata-viewer - g-plot-options - g-subset-plugin + - g-markers - g-gaussian-smooth - g-slit-overlay - g-model-fitting diff --git a/jdaviz/configs/specviz/specviz.yaml b/jdaviz/configs/specviz/specviz.yaml index 7b291b7b0d..54c4fee7e8 100644 --- a/jdaviz/configs/specviz/specviz.yaml +++ b/jdaviz/configs/specviz/specviz.yaml @@ -19,6 +19,7 @@ tray: - g-metadata-viewer - g-plot-options - g-subset-plugin + - g-markers - g-gaussian-smooth - g-model-fitting - g-unit-conversion diff --git a/jdaviz/configs/specviz2d/specviz2d.yaml b/jdaviz/configs/specviz2d/specviz2d.yaml index 627a57f107..d62dac9ed9 100644 --- a/jdaviz/configs/specviz2d/specviz2d.yaml +++ b/jdaviz/configs/specviz2d/specviz2d.yaml @@ -17,6 +17,7 @@ tray: - g-metadata-viewer - g-plot-options - g-subset-plugin + - g-markers - spectral-extraction - g-gaussian-smooth - g-model-fitting diff --git a/jdaviz/core/marks.py b/jdaviz/core/marks.py index 955850e54b..2a6590d711 100644 --- a/jdaviz/core/marks.py +++ b/jdaviz/core/marks.py @@ -16,7 +16,7 @@ 'PluginMark', 'PluginLine', 'PluginScatter', 'LineAnalysisContinuum', 'LineAnalysisContinuumCenter', 'LineAnalysisContinuumLeft', 'LineAnalysisContinuumRight', - 'LineUncertainties', 'ScatterMask', 'SelectedSpaxel'] + 'LineUncertainties', 'ScatterMask', 'SelectedSpaxel', 'MarkersMark'] class OffscreenLinesMarks(HubListener): @@ -542,3 +542,9 @@ def __init__(self, **kwargs): class SelectedSpaxel(Lines): def __init__(self, **kwargs): super().__init__(**kwargs) + + +class MarkersMark(PluginScatter): + def __init__(self, viewer, **kwargs): + kwargs.setdefault('marker', 'circle') + super().__init__(viewer, **kwargs) diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index 9e0dfb06ad..2f1a7e78cb 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -1,3 +1,5 @@ +from astropy.table import QTable +from astropy.table.row import Row as QTableRow import numpy as np from functools import cached_property @@ -30,6 +32,7 @@ 'ViewerSelect', 'ViewerSelectMixin', 'LayerSelect', 'LayerSelectMixin', 'DatasetSelect', 'DatasetSelectMixin', + 'Table', 'TableMixin', 'AutoTextField', 'AutoTextFieldMixin', 'AddResults', 'AddResultsMixin', 'PlotOptionsSyncState'] @@ -141,6 +144,7 @@ class PluginTemplateMixin(TemplateMixin): plugin_opened = Bool(False).tag(sync=True) def __init__(self, **kwargs): + self._viewer_callbacks = {} super().__init__(**kwargs) self.app.state.add_callback('tray_items_open', self._mxn_update_plugin_opened) self.app.state.add_callback('drawer', self._mxn_update_plugin_opened) @@ -151,6 +155,28 @@ def user_api(self): # can even be dependent on config, etc. return PluginUserApi(self, expose=[]) + def _viewer_callback(self, viewer, plugin_method): + """ + Cached access to callbacks to a plugin method to attach to a viewer. + + To define a callback: + def _on_callback(self, viewer, data): + + To add callback: + viewer.add_event_calback(self._viewer_callback(viewer, self._on_callback), + events=['keydown']) + + To remove callback: + viewer.remove_event_callback(self._viewer_callback(viewer, self._on_callback)) + """ + def plugin_viewer_callback(viewer, plugin_method): + return lambda data: plugin_method(viewer, data) + + key = f'{viewer.reference_id}:{plugin_method.__name__}' + if key not in self._viewer_callbacks.keys(): + self._viewer_callbacks[key] = plugin_viewer_callback(viewer, plugin_method) + return self._viewer_callbacks.get(key) + def _mxn_update_plugin_opened(self, new_value): app_state = self.app.state tray_names_open = [app_state.tray_items[i]['name'] for i in app_state.tray_items_open] @@ -1490,6 +1516,78 @@ def __init__(self, *args, **kwargs): self.dataset = DatasetSelect(self, 'dataset_items', 'dataset_selected') +class Table(BasePluginComponent): + """ + """ + def __init__(self, plugin, headers_visible, headers_avail, items): + self._qtable = None + super().__init__(plugin, + headers_visible=headers_visible, + headers_avail=headers_avail, + items=items) + + def add_row(self, item): + def json_safe(item): + if hasattr(item, 'to_string'): + return item.to_string() + return item + + if isinstance(item, QTable): + for row in item: + self.add_row(row) + return + if isinstance(item, QTableRow): + # Row does not have .items() implemented + item = {k: v for k, v in zip(item.keys(), item.values())} + + # save original sent values to the cached QTable object + if self._qtable is None: + self._qtable = QTable([item]) + else: + # NOTE: this does not support adding columns that did not exist in the first + # call to add_row since the last call to clear_table + self._qtable.add_row(item) + + # clean data to show in the UI + self.items = self.items + [{k: json_safe(v) for k, v in item.items()}] + + def clear_table(self): + """ + Clear all entries/markers from the current table. + """ + self.items = [] + self._qtable = None + + def export_table(self): + """ + """ + # TODO: default to only showing selected columns? + return self._qtable + + +class TableMixin(VuetifyTemplate, HubListener): + """ + """ + table_headers_visible = List([]).tag(sync=True) # list of strings + table_headers_avail = List([]).tag(sync=True) # list of strings + table_items = List().tag(sync=True) # list of dictionaries, pass single dict to add_row + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.table = Table(self, 'table_headers_visible', 'table_headers_avail', 'table_items') + + def clear_table(self): + self.table.clear_table() + + def vue_clear_table(self, data=None): + # call clear_table directly in case the class overloads that method + # (to also clear markers, etc) + self.clear_table() + + def export_table(self): + return self.table.export_table() + + class AutoTextField(BasePluginComponent): """ Label component with the ability to synchronize to a plugin-provided default value or override