diff --git a/glue_jupyter/app.py b/glue_jupyter/app.py index 24403c22..8c92c73d 100644 --- a/glue_jupyter/app.py +++ b/glue_jupyter/app.py @@ -105,6 +105,45 @@ def new_data_viewer(self, *args, **kwargs): viewer.show() return viewer + def table(self, *, data=None, x=None, widget='ipyvuetify', viewer_state=None, layer_state=None, show=True): + """ + Open an interactive table viewer. + + Parameters + ---------- + data : str or `~glue.core.data.Data`, optional + The dataset to show in the viewer. + widget : {'ipyvuetify', 'matplotlib'} + Whether to use ipyvuetify or ... as table viewer + viewer_state : `~glue.viewers.common.state.ViewerState` + The initial state for the viewer (advanced). + layer_state : `~glue.viewers.common.state.LayerState` + The initial state for the data layer (advanced). + show : bool, optional + Whether to show the view immediately (`True`) or whether to only + show it later if the ``show()`` method is called explicitly + (`False`). + """ + + if widget == 'ipyvuetify': + from .table import TableViewer + viewer_cls = TableViewer + else: + raise ValueError("Widget type should be 'ipyvuetify'") + + data = validate_data_argument(self.data_collection, data) + + viewer_state_obj = viewer_cls._state_cls() + viewer_state = viewer_state or {} + + viewer_state_obj.update_from_dict(viewer_state) + + view = self.new_data_viewer(viewer_cls, data=data, + state=viewer_state_obj, show=show) + layer_state = layer_state or {} + view.layers[0].state.update_from_dict(layer_state) + return view + def histogram1d(self, *, data=None, x=None, widget='bqplot', color=None, x_min=None, x_max=None, n_bin=None, normalize=False, cumulative=False, viewer_state=None, layer_state=None, diff --git a/glue_jupyter/table/__init__.py b/glue_jupyter/table/__init__.py new file mode 100644 index 00000000..adf8d499 --- /dev/null +++ b/glue_jupyter/table/__init__.py @@ -0,0 +1 @@ +from .viewer import TableViewer diff --git a/glue_jupyter/table/table.vue b/glue_jupyter/table/table.vue new file mode 100644 index 00000000..c686571e --- /dev/null +++ b/glue_jupyter/table/table.vue @@ -0,0 +1,63 @@ + \ No newline at end of file diff --git a/glue_jupyter/table/tests/__init__.py b/glue_jupyter/table/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/glue_jupyter/table/tests/test_table.py b/glue_jupyter/table/tests/test_table.py new file mode 100644 index 00000000..ac17a07c --- /dev/null +++ b/glue_jupyter/table/tests/test_table.py @@ -0,0 +1,15 @@ +def test_table(app, dataxyz): + table = app.table(data=dataxyz) + assert len(table.layers) == 1 + assert table.widget_table is not None + table.widget_table.checked = [1] + table.apply_filter() + assert len(table.layers) == 2 + subset = table.layers[1].layer + assert table.widget_table.selections == [subset.label] + assert [k['text'] for k in table.widget_table.headers_selections] == [subset.label] + assert table.widget_table.selection_colors == [subset.style.color] + + app.subset('test', dataxyz.id['x'] > 1) + assert len(table.layers) == 3 + assert len(table.widget_table.selections) == 2 diff --git a/glue_jupyter/table/viewer.py b/glue_jupyter/table/viewer.py new file mode 100644 index 00000000..b796445f --- /dev/null +++ b/glue_jupyter/table/viewer.py @@ -0,0 +1,202 @@ +import os + +import traitlets +import ipywidgets as widgets +import ipyvuetify as v + +from glue.core.subset import ElementSubsetState +from glue.core.data import Subset +from glue.viewers.common.layer_artist import LayerArtist +from glue.config import viewer_tool +from ..view import IPyWidgetView + + +with open(os.path.join(os.path.dirname(__file__), "table.vue")) as f: + TEMPLATE = f.read() + + +class TableBase(v.VuetifyTemplate): + total_length = traitlets.CInt().tag(sync=True) + checked = traitlets.List([]).tag(sync=True) # indices of which rows are selected + items = traitlets.Any().tag(sync=True) # the data, a list of dict + headers = traitlets.Any().tag(sync=True) + headers_selections = traitlets.Any().tag(sync=True) + options = traitlets.Any().tag(sync=True) + items_per_page = traitlets.CInt(11).tag(sync=True) + selections = traitlets.Any([]).tag(sync=True) + selection_colors = traitlets.Any([]).tag(sync=True) + selection_enabled = traitlets.Bool(True).tag(sync=True) + + def _update(self): + self._update_columns() + + def _update_columns(self): + self.headers = self._get_headers() + self._update_items() + + @traitlets.observe('items_per_page') + def _items_per_page(self, change): + self._update_items() + + @traitlets.default('headers') + def _headers(self): + return self._get_headers() + + @traitlets.observe('selections') + def _on_headers(self, change): + self.headers_selections = self._get_headers_selections() + + @traitlets.default('headers_selections') + def _headers_selections(self): + return self._get_headers_selections() + + def _get_headers_selections(self): + return [{'text': name, 'value': name, 'sortable': False} for name in self.selections] + + @traitlets.default('total_length') + def _total_length(self): + return len(self) + + @traitlets.default('options') + def _options(self): + return {'descending': False, 'page': 1, 'itemsPerPage': 10, 'sortBy': None, 'totalItems': len(self)} + + def format(self, value): + return value + + def _update_items(self): + self.items = self._get_items() + + @traitlets.default('items') + def _items(self): + return self._get_items() + + @traitlets.observe('options') + def _on_change_options(self, change): + self.items = self._get_items() + + @traitlets.default('template') + def _template(self): + with open(os.path.join(os.path.dirname(__file__), "table.vue")) as f: + return f.read() + + def vue_apply_filter(self, data): + pass + + def vue_select(self, data): + is_checked, row = data['checked'], data['row'] + if not is_checked and row in self.checked: + copy = self.checked[:] + del copy[copy.index(row)] + self.checked = copy + if is_checked and row not in self.checked: + self.checked = self.checked + [row] + + +class TableGlue(TableBase): + data = traitlets.Any() # Glue data object + apply_filter = traitlets.Any() # callback + + def __len__(self): + return self.data.shape[0] + + def _get_headers(self): + components = [str(k) for k in self.data.main_components + self.data.derived_components] + return [{'text': k, 'value': k, 'sortable': False} for k in components] + + def _get_items(self): + page = self.options['page'] - 1 + page_size = self.options['itemsPerPage'] + i1 = page * page_size + i2 = min(len(self), (page + 1) * page_size) + + view = slice(i1, i2) + masks = {k.label: k.to_mask(view) for k in self.data.subsets} + + items = [] + for i in range(i2 - i1): + item = {'__row__': i + i1} # special key for the row number + for selection in self.selections: + selected = masks[selection][i] + item[selection] = bool(selected) + for j, component in enumerate(self.data.main_components + self.data.derived_components): + item[str(component)] = self.format(self.data[component][i + i1]) + items.append(item) + return items + + def vue_apply_filter(self, data): + self.apply_filter() + + +class TableLayerArtist(LayerArtist): + def __init__(self, table_viewer, viewer_state, layer_state=None, layer=None): + self._table_viewer = table_viewer + super(TableLayerArtist, self).__init__(viewer_state, layer_state=layer_state, layer=layer) + self.redraw() + + def _refresh(self): + self._table_viewer.redraw() + + def redraw(self): + self._refresh() + + def update(self): + self._refresh() + + def clear(self): + self._refresh() + + +class TableLayerStateWidget(widgets.VBox): + def __init__(self, layer_state): + super(TableLayerStateWidget, self).__init__() + self.state = layer_state + + +class TableViewerStateWidget(widgets.VBox): + def __init__(self, viewer_state): + super(TableViewerStateWidget, self).__init__() + self.state = viewer_state + + +class TableViewer(IPyWidgetView): + allow_duplicate_data = False + allow_duplicate_subset = False + large_data_size = 1e100 # Basically infinite (a googol) + + _options_cls = TableViewerStateWidget + _data_artist_cls = TableLayerArtist + _subset_artist_cls = TableLayerArtist + _layer_style_widget_cls = TableLayerStateWidget + + tools = [] + + def __init__(self, session, state=None): + super(TableViewer, self).__init__(session, state=state) + self.widget_table = None + + def create_table(self, data): + self.widget_table = TableGlue(data=data, apply_filter=self.apply_filter) + self.create_layout() + + def redraw(self): + subsets = [k.layer for k in self.layers if isinstance(k.layer, Subset)] + with self.widget_table.hold_sync(): + self.widget_table.selections = [subset.label for subset in subsets] + self.widget_table.selection_colors = [subset.style.color for subset in subsets] + self.widget_table._update() + + def apply_filter(self): + selected_rows = self.widget_table.checked + subset_state = ElementSubsetState(indices=selected_rows, data=self.layers[0].layer) + mode = self.session.edit_subset_mode + mode.update(self._data, subset_state, focus_data=self.layers[0].layer) + + def add_data(self, data, color=None, alpha=None, **layer_state): + self.create_table(data) + result = super().add_data(data, color=color, alpha=alpha, **layer_state) + return result + + @property + def figure_widget(self): + return self.widget_table