Skip to content

Commit

Permalink
Selected rows conversion in JS
Browse files Browse the repository at this point in the history
  • Loading branch information
mwouts committed Sep 16, 2024
1 parent c6fe9bd commit 4f0f7c8
Show file tree
Hide file tree
Showing 7 changed files with 152 additions and 48 deletions.
3 changes: 2 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ ITables ChangeLog
------------------

**Added**
- ITables now has a Jupyter Widget ([#267](https://github.com/mwouts/itables/issues/267)) - this would have taken months without AnyWidget!
- ITables has a Jupyter Widget ([#267](https://github.com/mwouts/itables/issues/267)). Our widget was developed and packaged using [AnyWidget](https://anywidget.dev/) which I highly recommend!
- The selected rows are now available! Use either the `selected_rows` attribute of the `ITable` widget, or the returned value of the Streamlit `interactive_table` component ([#250](https://github.com/mwouts/itables/issues/250))


2.1.5 (2024-09-08)
Expand Down
98 changes: 89 additions & 9 deletions docs/ipywidgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,105 @@ kernelspec:

# Jupyter Widget

ITables is also available as a [Jupyter Widget](https://ipywidgets.readthedocs.io).
ITables is also available as a [Jupyter Widget](https://ipywidgets.readthedocs.io), since v2.2.

Make sure you install [AnyWidget](https://github.com/manzt/anywidget), the framework that we use to provide our widget:
## Using `show`

If you only want to _display_ the table, you **do not need**
our Jupyter widget. The `show` function is enough!

```{code-cell}
import ipywidgets as widgets
from itables import show
from itables.sample_dfs import get_dict_of_test_dfs
sample_dfs = get_dict_of_test_dfs()
def use_show_in_interactive_output(table_name: str):
show(
sample_dfs[table_name],
caption=table_name,
style="table-layout:auto;width:auto;float:left;caption-side:bottom",
)
table_selector = widgets.Dropdown(options=sample_dfs.keys(), value="int_float_str")
out = widgets.interactive_output(
use_show_in_interactive_output, {"table_name": table_selector}
)
widgets.VBox([table_selector, out])
```

```{tip}
Jupyter widgets only work in a live notebook.
Click on the rocket icon at the top of the page to run this demo in Binder.
```

## Using the ITable widget

The `ITable` widget has a few dependencies that you can install with
```bash
pip install anywidget
pip install itables[widget]
```

Then, create a table widget with `ITable` from `itables.widget`:
The `ITable` class accepts the same arguments as the `show` method, but
the `df` argument is optional.

```{code-cell}
from itables.sample_dfs import get_countries
from itables.widget import ITable
df = get_countries(html=False)
dt = ITable(df)
table = ITable(selected_rows=[0, 2, 5, 99])
def update_selected_table(change):
table_name = table_selector.value
table.update(
sample_dfs[table_name],
caption=table_name,
select=True,
style="table-layout:auto;width:auto;float:left",
)
# Update the table when the selector changes
table_selector.observe(update_selected_table, "value")
# Set the table to the initial table selected
update_selected_table(None)
widgets.VBox([table_selector, table])
```

## Get the selected rows

The `ITable` widget let you access the state of the table
and in particular, it has an `.selected_rows` attribute
that you can use to determine the rows that have been
selected by the user (allow selection by passing `select=True`
to the `ITable` widget).

```{code-cell}
dt
out = widgets.Output()
def show_selected_rows(change):
with out:
out.clear_output()
print("selected_rows: ", table.selected_rows)
table.observe(show_selected_rows, "selected_rows")
# Display the initial selection
show_selected_rows(None)
out
```

The `ITable` class accepts the same arguments as the `show` method. It comes with a few limitations - the same as for the [streamlit component](streamlit.md#limitations), e.g. you can't pass JavaScript callback.
## Limitations

Compared to `show`, the `ITable` widget has the same limitations as the [streamlit component](streamlit.md#limitations),
e.g. structured headers are not available, you can't pass JavaScript callback, etc.
27 changes: 26 additions & 1 deletion packages/itables_anywidget/js/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,22 @@ function render({ model, el }: RenderContext<WidgetModel>) {
function set_selected_rows_from_model() {
// We use this variable to avoid triggering model updates!
setting_selected_rows_from_model = true;

// The model selected rows are for the full table, so
// we map them to the actual data
let selected_rows = model.get('selected_rows');
let full_row_count = model.get('full_row_count');
let data_row_count = model.get('data').length;
if (data_row_count < full_row_count) {
let bottom_half = data_row_count / 2;
let top_half = full_row_count - bottom_half;
selected_rows = selected_rows.filter(i => i >= 0 && i < full_row_count && (i < bottom_half || i >= top_half)).map(
i => (i < bottom_half) ? i : i - full_row_count + data_row_count);
}

dt.rows().deselect();
dt.rows(model.get('selected_rows')).select();
dt.rows(selected_rows).select();

setting_selected_rows_from_model = false;
};

Expand Down Expand Up @@ -88,6 +102,17 @@ function render({ model, el }: RenderContext<WidgetModel>) {
return;

let selected_rows = Array.from(dt.rows({ selected: true }).indexes());

// Here the selected rows are for the datatable.
// We convert them back to the full table
let full_row_count = model.get('full_row_count');
let data_row_count = model.get('data').length;
if (data_row_count < full_row_count) {
let bottom_half = data_row_count / 2;
selected_rows = selected_rows.map(
i => (i < bottom_half ? i : i + full_row_count - data_row_count));
}

model.set('selected_rows', selected_rows);
model.save_changes();
};
Expand Down
16 changes: 14 additions & 2 deletions packages/itables_for_streamlit/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,20 @@ function onRender(event: Event): void {
}

function export_selected_rows() {
let selected_rows = Array.from(dt.rows({ selected: true }).indexes());
Streamlit.setComponentValue(selected_rows);
let selected_rows:Array<number> = Array.from(dt.rows({ selected: true }).indexes());

let full_row_count:number = other_args.full_row_count;
let data_row_count:number = dt_args.data.length;

// Here the selected rows are for the datatable.
// We convert them back to the full table
if (data_row_count < full_row_count) {
let bottom_half = data_row_count / 2;
selected_rows = selected_rows.map(
(x:number, i:number) => (x < bottom_half ? x : x + full_row_count - data_row_count));
}

Streamlit.setComponentValue({selected_rows});
};

dt.on('select', function (e: any, dt: any, type: any, indexes: any) {
Expand Down
33 changes: 17 additions & 16 deletions src/itables/javascript.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ def get_itables_extension_arguments(df, caption=None, selected_rows=None, **kwar
maxColumns = kwargs.pop("maxColumns", pd.get_option("display.max_columns") or 0)
warn_on_unexpected_types = kwargs.pop("warn_on_unexpected_types", False)

full_row_count = len(df)
df, downsampling_warning = downsample(
df, max_rows=maxRows, max_columns=maxColumns, max_bytes=maxBytes
)
Expand Down Expand Up @@ -559,18 +560,21 @@ def get_itables_extension_arguments(df, caption=None, selected_rows=None, **kwar
f"This dataframe can't be serialized to JSON:\n{e}\n{data_json}"
)

assert len(data) <= full_row_count

return {"columns": columns, "data": data, **kwargs}, {
"classes": classes,
"style": style,
"caption": caption,
"downsampling_warning": downsampling_warning,
"selected_rows": get_selected_rows_after_downsampling(
selected_rows, len(df), len(data)
"full_row_count": full_row_count,
"selected_rows": warn_if_selected_rows_are_not_visible(
selected_rows, full_row_count, len(data)
),
}


def get_selected_rows_after_downsampling(
def warn_if_selected_rows_are_not_visible(
selected_rows, full_row_count, downsampled_row_count
):
if selected_rows is None:
Expand All @@ -579,21 +583,18 @@ def get_selected_rows_after_downsampling(
return selected_rows
half = downsampled_row_count // 2
assert downsampled_row_count == 2 * half, downsampled_row_count
bottom_limit = half
top_limit = full_row_count - half

filtered_rows = full_row_count - downsampled_row_count
return [i if i < half else i - filtered_rows for i in selected_rows]


def get_selected_rows_before_downsampling(
selected_rows, full_row_count, downsampled_row_count
):
if full_row_count == downsampled_row_count:
return selected_rows
half = downsampled_row_count // 2
assert downsampled_row_count == 2 * half, downsampled_row_count
if any(bottom_limit <= i < top_limit for i in selected_rows):
warnings.warn(
f"This table has been downsampled. "
f"Only {downsampled_row_count} of the original {full_row_count} rows "
"are rendered, see https://mwouts.github.io/itables/downsampling.html. "
f"In particular the rows [{bottom_limit}:{top_limit}] cannot be selected."
)

filtered_rows = full_row_count - downsampled_row_count
return [i if i < half else i + filtered_rows for i in selected_rows]
return [i for i in selected_rows if i < bottom_limit or i >= top_limit]


def check_table_id(table_id):
Expand Down
22 changes: 3 additions & 19 deletions src/itables/widget/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import importlib.metadata
import pathlib
from typing import Sequence

import anywidget
import pandas as pd
import traitlets

from itables.javascript import (
get_itables_extension_arguments,
get_selected_rows_after_downsampling,
get_selected_rows_before_downsampling,
)
from itables.javascript import get_itables_extension_arguments

try:
__version__ = importlib.metadata.version("itables_anywidget")
Expand All @@ -22,6 +17,7 @@ class ITable(anywidget.AnyWidget):
_esm = pathlib.Path(__file__).parent / "static" / "widget.js"
_css = pathlib.Path(__file__).parent / "static" / "widget.css"

full_row_count = traitlets.Int().tag(sync=True)
data = traitlets.List(traitlets.List()).tag(sync=True)
selected_rows = traitlets.List(traitlets.Int).tag(sync=True)
destroy_and_recreate = traitlets.Int(0).tag(sync=True)
Expand All @@ -37,11 +33,11 @@ def __init__(self, df=None, caption=None, selected_rows=None, **kwargs) -> None:

if df is None:
df = pd.DataFrame()
self.df = df

dt_args, other_args = get_itables_extension_arguments(
df, caption, selected_rows, **kwargs
)
self.full_row_count = other_args.pop("full_row_count")
self.data = dt_args.pop("data")
self.dt_args = dt_args
self.classes = other_args.pop("classes")
Expand Down Expand Up @@ -79,15 +75,3 @@ def update(self, df=None, caption=None, selected_rows=None, **kwargs):
self.selected_rows = selected_rows

self.destroy_and_recreate += 1

def get_selected_rows(self) -> list[int]:
return get_selected_rows_before_downsampling(
self.selected_rows, len(self.df), len(self.data)
)

def set_selected_rows(self, selected_rows: Sequence[int]):
selected_rows = get_selected_rows_after_downsampling(
selected_rows, len(self.df), len(self.data)
)
if self.selected_rows != selected_rows:
self.selected_rows = selected_rows
1 change: 1 addition & 0 deletions tests/test_extension_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def test_get_itables_extension_arguments(df):
"caption",
"downsampling_warning",
"selected_rows",
"full_row_count",
}, set(dt_args)
assert isinstance(other_args["classes"], str)
assert isinstance(other_args["style"], str)
Expand Down

0 comments on commit 4f0f7c8

Please sign in to comment.