Skip to content

Commit 34b5975

Browse files
authored
feat: Add max_columns control for anywidget mode (#2374)
This PR introduces a max_columns configuration for anywidget mode, giving users control over how many columns are rendered in the interactive table. This is particularly useful for improving readability and performance when working with wide DataFrames. Key Changes: * Configurable Column Limit: Users can now set bigframes.options.display.max_columns to limit the number of displayed columns. * Interactive Control: The TableWidget now includes a "Max columns" dropdown in the footer, allowing users to dynamically adjust this setting (options: 3, 5, 10, 15, 20, All). * Smart Truncation: When columns exceed the limit, the table displays the first N/2 and last N/2 columns, separated by an ellipsis (...) column. * Default Value: The default max_columns is set to 7 to provide a balanced view on standard screens without requiring horizontal scrolling. Example Usage: ``` 1 import bigframes.pandas as bpd 2 3 # Set global option 4 bpd.options.display.max_columns = 10 5 6 # Or use context manager 7 with bpd.option_context("display.max_columns", 5): 8 display(df) ``` verified at: * vs code notebook: http://screencast/cast/NTE2MDM4NTkxNjE3NDMzNnw2ZGI5YjAxOS1jMw * colab notebook: screen/A8CBDFHyoJTzkAu Fixes #<452681068> 🦕
1 parent dbe8e7e commit 34b5975

File tree

7 files changed

+332
-12
lines changed

7 files changed

+332
-12
lines changed

bigframes/display/anywidget.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class TableWidget(_WIDGET_BASE):
6666

6767
page = traitlets.Int(0).tag(sync=True)
6868
page_size = traitlets.Int(0).tag(sync=True)
69+
max_columns = traitlets.Int(allow_none=True, default_value=None).tag(sync=True)
6970
row_count = traitlets.Int(allow_none=True, default_value=None).tag(sync=True)
7071
table_html = traitlets.Unicode("").tag(sync=True)
7172
sort_context = traitlets.List(traitlets.Dict(), default_value=[]).tag(sync=True)
@@ -103,10 +104,13 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame):
103104

104105
# respect display options for initial page size
105106
initial_page_size = bigframes.options.display.max_rows
107+
initial_max_columns = bigframes.options.display.max_columns
106108

107109
# set traitlets properties that trigger observers
108110
# TODO(b/462525985): Investigate and improve TableWidget UX for DataFrames with a large number of columns.
109111
self.page_size = initial_page_size
112+
self.max_columns = initial_max_columns
113+
110114
# TODO(b/469861913): Nested columns from structs (e.g., 'struct_col.name') are not currently sortable.
111115
# TODO(b/463754889): Support non-string column labels for sorting.
112116
if all(isinstance(col, str) for col in dataframe.columns):
@@ -218,6 +222,14 @@ def _validate_page_size(self, proposal: dict[str, Any]) -> int:
218222
max_page_size = 1000
219223
return min(value, max_page_size)
220224

225+
@traitlets.validate("max_columns")
226+
def _validate_max_columns(self, proposal: dict[str, Any]) -> int:
227+
"""Validate max columns to ensure it's positive or 0 (for all)."""
228+
value = proposal["value"]
229+
if value is None:
230+
return 0 # Normalize None to 0 for traitlet
231+
return max(0, value)
232+
221233
def _get_next_batch(self) -> bool:
222234
"""
223235
Gets the next batch of data from the generator and appends to cache.
@@ -348,6 +360,7 @@ def _set_table_html(self) -> None:
348360
dataframe=page_data,
349361
table_id=f"table-{self._table_id}",
350362
orderable_columns=self.orderable_columns,
363+
max_columns=self.max_columns,
351364
)
352365

353366
if new_page is not None:
@@ -382,3 +395,10 @@ def _page_size_changed(self, _change: dict[str, Any]) -> None:
382395

383396
# Update the table display
384397
self._set_table_html()
398+
399+
@traitlets.observe("max_columns")
400+
def _max_columns_changed(self, _change: dict[str, Any]) -> None:
401+
"""Handler for when max columns is changed from the frontend."""
402+
if not self._initial_load_complete:
403+
return
404+
self._set_table_html()

bigframes/display/html.py

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,21 +46,51 @@ def render_html(
4646
dataframe: pd.DataFrame,
4747
table_id: str,
4848
orderable_columns: list[str] | None = None,
49+
max_columns: int | None = None,
4950
) -> str:
5051
"""Render a pandas DataFrame to HTML with specific styling."""
5152
orderable_columns = orderable_columns or []
5253
classes = "dataframe table table-striped table-hover"
5354
table_html_parts = [f'<table border="1" class="{classes}" id="{table_id}">']
54-
table_html_parts.append(_render_table_header(dataframe, orderable_columns))
55-
table_html_parts.append(_render_table_body(dataframe))
55+
56+
# Handle column truncation
57+
columns = list(dataframe.columns)
58+
if max_columns is not None and max_columns > 0 and len(columns) > max_columns:
59+
half = max_columns // 2
60+
left_columns = columns[:half]
61+
# Ensure we don't take more than available if half is 0 or calculation is weird,
62+
# but typical case is safe.
63+
right_count = max_columns - half
64+
right_columns = columns[-right_count:] if right_count > 0 else []
65+
show_ellipsis = True
66+
else:
67+
left_columns = columns
68+
right_columns = []
69+
show_ellipsis = False
70+
71+
table_html_parts.append(
72+
_render_table_header(
73+
dataframe, orderable_columns, left_columns, right_columns, show_ellipsis
74+
)
75+
)
76+
table_html_parts.append(
77+
_render_table_body(dataframe, left_columns, right_columns, show_ellipsis)
78+
)
5679
table_html_parts.append("</table>")
5780
return "".join(table_html_parts)
5881

5982

60-
def _render_table_header(dataframe: pd.DataFrame, orderable_columns: list[str]) -> str:
83+
def _render_table_header(
84+
dataframe: pd.DataFrame,
85+
orderable_columns: list[str],
86+
left_columns: list[Any],
87+
right_columns: list[Any],
88+
show_ellipsis: bool,
89+
) -> str:
6190
"""Render the header of the HTML table."""
6291
header_parts = [" <thead>", " <tr>"]
63-
for col in dataframe.columns:
92+
93+
def render_col_header(col):
6494
th_classes = []
6595
if col in orderable_columns:
6696
th_classes.append("sortable")
@@ -69,19 +99,38 @@ def _render_table_header(dataframe: pd.DataFrame, orderable_columns: list[str])
6999
f' <th {class_str}><div class="bf-header-content">'
70100
f"{html.escape(str(col))}</div></th>"
71101
)
102+
103+
for col in left_columns:
104+
render_col_header(col)
105+
106+
if show_ellipsis:
107+
header_parts.append(
108+
' <th><div class="bf-header-content" style="cursor: default;">...</div></th>'
109+
)
110+
111+
for col in right_columns:
112+
render_col_header(col)
113+
72114
header_parts.extend([" </tr>", " </thead>"])
73115
return "\n".join(header_parts)
74116

75117

76-
def _render_table_body(dataframe: pd.DataFrame) -> str:
118+
def _render_table_body(
119+
dataframe: pd.DataFrame,
120+
left_columns: list[Any],
121+
right_columns: list[Any],
122+
show_ellipsis: bool,
123+
) -> str:
77124
"""Render the body of the HTML table."""
78125
body_parts = [" <tbody>"]
79126
precision = options.display.precision
80127

81128
for i in range(len(dataframe)):
82129
body_parts.append(" <tr>")
83130
row = dataframe.iloc[i]
84-
for col_name, value in row.items():
131+
132+
def render_col_cell(col_name):
133+
value = row[col_name]
85134
dtype = dataframe.dtypes.loc[col_name] # type: ignore
86135
align = "right" if _is_dtype_numeric(dtype) else "left"
87136

@@ -101,6 +150,17 @@ def _render_table_body(dataframe: pd.DataFrame) -> str:
101150
f' <td class="cell-align-{align}">'
102151
f"{html.escape(cell_content)}</td>"
103152
)
153+
154+
for col in left_columns:
155+
render_col_cell(col)
156+
157+
if show_ellipsis:
158+
# Ellipsis cell
159+
body_parts.append(' <td class="cell-align-left">...</td>')
160+
161+
for col in right_columns:
162+
render_col_cell(col)
163+
104164
body_parts.append(" </tr>")
105165
body_parts.append(" </tbody>")
106166
return "\n".join(body_parts)

bigframes/display/table_widget.css

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,24 @@ body[data-theme='dark'] .bigframes-widget.bigframes-widget {
117117
margin: 0 8px;
118118
}
119119

120-
.bigframes-widget .page-size {
120+
.bigframes-widget .settings {
121121
align-items: center;
122122
display: flex;
123123
flex-direction: row;
124-
gap: 4px;
124+
gap: 16px;
125125
justify-content: end;
126126
}
127127

128-
.bigframes-widget .page-size label {
128+
.bigframes-widget .page-size,
129+
.bigframes-widget .max-columns {
130+
align-items: center;
131+
display: flex;
132+
flex-direction: row;
133+
gap: 4px;
134+
}
135+
136+
.bigframes-widget .page-size label,
137+
.bigframes-widget .max-columns label {
129138
margin-right: 8px;
130139
}
131140

bigframes/display/table_widget.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const ModelProperty = {
2222
ROW_COUNT: 'row_count',
2323
SORT_CONTEXT: 'sort_context',
2424
TABLE_HTML: 'table_html',
25+
MAX_COLUMNS: 'max_columns',
2526
};
2627

2728
const Event = {
@@ -71,6 +72,10 @@ function render({ model, el }) {
7172
attributeFilter: ['class', 'data-theme', 'data-vscode-theme-kind'],
7273
});
7374

75+
// Settings controls container
76+
const settingsContainer = document.createElement('div');
77+
settingsContainer.classList.add('settings');
78+
7479
// Pagination controls
7580
const paginationContainer = document.createElement('div');
7681
paginationContainer.classList.add('pagination');
@@ -102,6 +107,32 @@ function render({ model, el }) {
102107
pageSizeInput.appendChild(option);
103108
}
104109

110+
// Max columns controls
111+
const maxColumnsContainer = document.createElement('div');
112+
maxColumnsContainer.classList.add('max-columns');
113+
const maxColumnsLabel = document.createElement('label');
114+
const maxColumnsInput = document.createElement('select');
115+
116+
maxColumnsLabel.textContent = 'Max columns:';
117+
118+
// 0 represents "All" (all columns)
119+
const maxColumnOptions = [5, 10, 15, 20, 0];
120+
for (const cols of maxColumnOptions) {
121+
const option = document.createElement('option');
122+
option.value = cols;
123+
option.textContent = cols === 0 ? 'All' : cols;
124+
125+
const currentMax = model.get(ModelProperty.MAX_COLUMNS);
126+
// Handle None/null from python as 0/All
127+
const currentMaxVal =
128+
currentMax === null || currentMax === undefined ? 0 : currentMax;
129+
130+
if (cols === currentMaxVal) {
131+
option.selected = true;
132+
}
133+
maxColumnsInput.appendChild(option);
134+
}
135+
105136
function updateButtonStates() {
106137
const currentPage = model.get(ModelProperty.PAGE);
107138
const pageSize = model.get(ModelProperty.PAGE_SIZE);
@@ -259,6 +290,12 @@ function render({ model, el }) {
259290
}
260291
});
261292

293+
maxColumnsInput.addEventListener(Event.CHANGE, (e) => {
294+
const newVal = Number(e.target.value);
295+
model.set(ModelProperty.MAX_COLUMNS, newVal);
296+
model.save_changes();
297+
});
298+
262299
model.on(Event.CHANGE_TABLE_HTML, handleTableHTMLChange);
263300
model.on(`change:${ModelProperty.ROW_COUNT}`, updateButtonStates);
264301
model.on(`change:${ModelProperty.ERROR_MESSAGE}`, handleErrorMessageChange);
@@ -270,11 +307,19 @@ function render({ model, el }) {
270307
paginationContainer.appendChild(prevPage);
271308
paginationContainer.appendChild(pageIndicator);
272309
paginationContainer.appendChild(nextPage);
310+
273311
pageSizeContainer.appendChild(pageSizeLabel);
274312
pageSizeContainer.appendChild(pageSizeInput);
313+
314+
maxColumnsContainer.appendChild(maxColumnsLabel);
315+
maxColumnsContainer.appendChild(maxColumnsInput);
316+
317+
settingsContainer.appendChild(maxColumnsContainer);
318+
settingsContainer.appendChild(pageSizeContainer);
319+
275320
footer.appendChild(rowCountLabel);
276321
footer.appendChild(paginationContainer);
277-
footer.appendChild(pageSizeContainer);
322+
footer.appendChild(settingsContainer);
278323

279324
el.appendChild(errorContainer);
280325
el.appendChild(tableContainer);

notebooks/dataframes/anywidget_mode.ipynb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
"- **Rich DataFrames & Series:** Both DataFrames and Series are displayed as interactive widgets.\n",
5252
"- **Pagination:** Navigate through large datasets page by page without overwhelming the output.\n",
5353
"- **Column Sorting:** Click column headers to toggle between ascending, descending, and unsorted views. Use **Shift + Click** to sort by multiple columns.\n",
54-
"- **Column Resizing:** Drag the dividers between column headers to adjust their width."
54+
"- **Column Resizing:** Drag the dividers between column headers to adjust their width.\n",
55+
"- **Max Columns Control:** Limit the number of displayed columns to improve performance and readability for wide datasets."
5556
]
5657
},
5758
{
@@ -511,7 +512,10 @@
511512
"metadata": {},
512513
"source": [
513514
"### Adjustable Column Widths\n",
514-
"You can easily adjust the width of any column in the table. Simply hover your mouse over the vertical dividers between column headers. When the cursor changes to a resize icon, click and drag to expand or shrink the column to your desired width. This allows for better readability and customization of your table view."
515+
"You can easily adjust the width of any column in the table. Simply hover your mouse over the vertical dividers between column headers. When the cursor changes to a resize icon, click and drag to expand or shrink the column to your desired width. This allows for better readability and customization of your table view.\n",
516+
"\n",
517+
"### Control Maximum Columns\n",
518+
"You can control the number of columns displayed in the widget using the **Max columns** dropdown in the footer. This is useful for wide DataFrames where you want to focus on a subset of columns or improve rendering performance. Options include 3, 5, 7, 10, 20, or All."
515519
]
516520
},
517521
{

0 commit comments

Comments
 (0)