Skip to content

Commit 9148797

Browse files
committed
Merge branch 'shuowei-anywidget-nested-strcut-array' into shuowei-anywidget-fix-empty-index
2 parents 32985d8 + fb2d029 commit 9148797

File tree

9 files changed

+1504
-349
lines changed

9 files changed

+1504
-349
lines changed

bigframes/display/_flatten.py

Lines changed: 575 additions & 0 deletions
Large diffs are not rendered by default.

bigframes/display/html.py

Lines changed: 107 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
import bigframes
3030
from bigframes._config import display_options, options
31-
from bigframes.display import plaintext
31+
from bigframes.display import _flatten, plaintext
3232
import bigframes.formatting_helpers as formatter
3333

3434
if typing.TYPE_CHECKING:
@@ -48,13 +48,17 @@ def render_html(
4848
orderable_columns: list[str] | None = None,
4949
max_columns: int | None = None,
5050
) -> str:
51-
"""Render a pandas DataFrame to HTML with specific styling."""
51+
"""Render a pandas DataFrame to HTML with specific styling and nested data support."""
52+
# Flatten nested data first
53+
flatten_result = _flatten.flatten_nested_data(dataframe)
54+
flat_df = flatten_result.dataframe
55+
5256
orderable_columns = orderable_columns or []
5357
classes = "dataframe table table-striped table-hover"
5458
table_html_parts = [f'<table border="1" class="{classes}" id="{table_id}">']
5559

5660
# Handle column truncation
57-
columns = list(dataframe.columns)
61+
columns = list(flat_df.columns)
5862
if max_columns is not None and max_columns > 0 and len(columns) > max_columns:
5963
half = max_columns // 2
6064
left_columns = columns[:half]
@@ -70,11 +74,20 @@ def render_html(
7074

7175
table_html_parts.append(
7276
_render_table_header(
73-
dataframe, orderable_columns, left_columns, right_columns, show_ellipsis
77+
flat_df, orderable_columns, left_columns, right_columns, show_ellipsis
7478
)
7579
)
7680
table_html_parts.append(
77-
_render_table_body(dataframe, left_columns, right_columns, show_ellipsis)
81+
_render_table_body(
82+
flat_df,
83+
flatten_result.row_labels,
84+
flatten_result.continuation_rows,
85+
flatten_result.cleared_on_continuation,
86+
flatten_result.nested_columns,
87+
left_columns,
88+
right_columns,
89+
show_ellipsis,
90+
)
7891
)
7992
table_html_parts.append("</table>")
8093
return "".join(table_html_parts)
@@ -117,39 +130,66 @@ def render_col_header(col):
117130

118131
def _render_table_body(
119132
dataframe: pd.DataFrame,
133+
row_labels: list[str] | None,
134+
continuation_rows: set[int] | None,
135+
clear_on_continuation: list[str],
136+
nested_originated_columns: set[str],
120137
left_columns: list[Any],
121138
right_columns: list[Any],
122139
show_ellipsis: bool,
123140
) -> str:
124-
"""Render the body of the HTML table."""
141+
"""Render the table body.
142+
143+
Args:
144+
dataframe: The flattened dataframe to render.
145+
row_labels: Optional labels for each row, used for visual grouping of exploded rows.
146+
See `bigframes.display._flatten.FlattenResult` for details.
147+
continuation_rows: Indices of rows that are continuations of array explosion.
148+
See `bigframes.display._flatten.FlattenResult` for details.
149+
clear_on_continuation: Columns to render as empty in continuation rows.
150+
See `bigframes.display._flatten.FlattenResult` for details.
151+
nested_originated_columns: Columns created from nested data, used for alignment.
152+
left_columns: Columns to display on the left.
153+
right_columns: Columns to display on the right.
154+
show_ellipsis: Whether to show an ellipsis row.
155+
"""
125156
body_parts = [" <tbody>"]
126157
precision = options.display.precision
127158

128159
for i in range(len(dataframe)):
129-
body_parts.append(" <tr>")
160+
row_class = ""
161+
orig_row_idx = None
162+
is_continuation = False
163+
164+
if row_labels:
165+
orig_row_idx = row_labels[i]
166+
167+
if continuation_rows and i in continuation_rows:
168+
is_continuation = True
169+
row_class = "array-continuation"
170+
171+
if orig_row_idx is not None:
172+
body_parts.append(
173+
f' <tr class="{row_class}" data-orig-row="{orig_row_idx}">'
174+
)
175+
else:
176+
body_parts.append(" <tr>")
177+
130178
row = dataframe.iloc[i]
131179

132180
def render_col_cell(col_name):
133181
value = row[col_name]
134182
dtype = dataframe.dtypes.loc[col_name] # type: ignore
135-
align = "right" if _is_dtype_numeric(dtype) else "left"
136-
137-
# TODO(b/438181139): Consider semi-exploding ARRAY/STRUCT columns
138-
# into multiple rows/columns like the BQ UI does.
139-
if pandas.api.types.is_scalar(value) and pd.isna(value):
140-
body_parts.append(
141-
f' <td class="cell-align-{align}">'
142-
'<em class="null-value">&lt;NA&gt;</em></td>'
143-
)
144-
else:
145-
if isinstance(value, float):
146-
cell_content = f"{value:.{precision}f}"
147-
else:
148-
cell_content = str(value)
149-
body_parts.append(
150-
f' <td class="cell-align-{align}">'
151-
f"{html.escape(cell_content)}</td>"
152-
)
183+
cell_html = _render_cell(
184+
value,
185+
dtype,
186+
is_continuation,
187+
str(col_name),
188+
clear_on_continuation,
189+
nested_originated_columns,
190+
precision,
191+
)
192+
body_parts.append(cell_html)
153193

154194
for col in left_columns:
155195
render_col_cell(col)
@@ -166,6 +206,43 @@ def render_col_cell(col_name):
166206
return "\n".join(body_parts)
167207

168208

209+
def _render_cell(
210+
value: Any,
211+
dtype: Any,
212+
is_continuation: bool,
213+
col_name_str: str,
214+
clear_on_continuation: list[str],
215+
nested_originated_columns: set[str],
216+
precision: int,
217+
) -> str:
218+
"""Render a single cell of the HTML table."""
219+
if is_continuation and col_name_str in clear_on_continuation:
220+
return " <td></td>"
221+
222+
if col_name_str in nested_originated_columns:
223+
align = "left"
224+
else:
225+
align = "right" if _is_dtype_numeric(dtype) else "left"
226+
227+
if pandas.api.types.is_scalar(value) and pd.isna(value):
228+
if is_continuation:
229+
# For padding nulls in continuation rows, show empty cell
230+
return f' <td class="cell-align-{align}"></td>'
231+
else:
232+
# For primary nulls, keep showing the <NA> indicator but maybe styled
233+
return (
234+
f' <td class="cell-align-{align}">'
235+
'<em class="null-value">&lt;NA&gt;</em></td>'
236+
)
237+
238+
if isinstance(value, float):
239+
cell_content = f"{value:.{precision}f}"
240+
else:
241+
cell_content = str(value)
242+
243+
return f' <td class="cell-align-{align}">' f"{html.escape(cell_content)}</td>"
244+
245+
169246
def _obj_ref_rt_to_html(obj_ref_rt: str) -> str:
170247
obj_ref_rt_json = json.loads(obj_ref_rt)
171248
obj_ref_details = obj_ref_rt_json["objectref"]["details"]
@@ -252,8 +329,8 @@ def _get_obj_metadata(
252329

253330
def get_anywidget_bundle(
254331
obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series],
255-
include=None,
256-
exclude=None,
332+
include: typing.Container[str] | None = None,
333+
exclude: typing.Container[str] | None = None,
257334
) -> tuple[dict[str, Any], dict[str, Any]]:
258335
"""
259336
Helper method to create and return the anywidget mimebundle.
@@ -350,9 +427,9 @@ def repr_mimebundle_head(
350427

351428
def repr_mimebundle(
352429
obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series],
353-
include=None,
354-
exclude=None,
355-
):
430+
include: typing.Container[str] | None = None,
431+
exclude: typing.Container[str] | None = None,
432+
) -> dict[str, str] | tuple[dict[str, Any], dict[str, Any]] | None:
356433
"""Custom display method for IPython/Jupyter environments."""
357434
# TODO(b/467647693): Anywidget integration has been tested in Jupyter, VS Code, and
358435
# BQ Studio, but there is a known compatibility issue with Marimo that needs to be addressed.

bigframes/display/table_widget.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
--bf-header-bg: #f5f5f5;
2727
--bf-null-fg: gray;
2828
--bf-row-even-bg: #f5f5f5;
29+
--bf-row-hover-bg: #e8eaed;
2930
--bf-row-odd-bg: white;
3031

3132
background-color: var(--bf-bg);
@@ -59,6 +60,7 @@
5960
--bf-header-bg: var(--vscode-editor-background, black);
6061
--bf-null-fg: #aaa;
6162
--bf-row-even-bg: #202124;
63+
--bf-row-hover-bg: #4c4c4c;
6264
--bf-row-odd-bg: #383838;
6365
}
6466
}
@@ -75,6 +77,7 @@ body[data-theme='dark'] .bigframes-widget.bigframes-widget {
7577
--bf-header-bg: var(--vscode-editor-background, black);
7678
--bf-null-fg: #aaa;
7779
--bf-row-even-bg: #202124;
80+
--bf-row-hover-bg: #4c4c4c;
7881
--bf-row-odd-bg: #383838;
7982
}
8083

@@ -245,3 +248,8 @@ body[data-theme='dark'] .bigframes-widget.bigframes-widget {
245248
.bigframes-widget .debug-info {
246249
border-top: 1px solid var(--bf-border-color);
247250
}
251+
252+
.bigframes-widget table tbody tr:hover td,
253+
.bigframes-widget table tbody tr td.row-hover {
254+
background-color: var(--bf-row-hover-bg);
255+
}

bigframes/display/table_widget.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,38 @@ function render({ model, el }) {
286286
}
287287
});
288288

289+
// Add hover effect for flattened rows
290+
const rows = tableContainer.querySelectorAll('tbody tr');
291+
rows.forEach((row) => {
292+
row.addEventListener('mouseover', () => {
293+
const origRow = row.getAttribute('data-orig-row');
294+
if (origRow !== null) {
295+
const groupRows = tableContainer.querySelectorAll(
296+
`tr[data-orig-row="${origRow}"]`,
297+
);
298+
groupRows.forEach((r) => {
299+
r.querySelectorAll('td').forEach((cell) => {
300+
cell.classList.add('row-hover');
301+
});
302+
});
303+
}
304+
});
305+
306+
row.addEventListener('mouseout', () => {
307+
const origRow = row.getAttribute('data-orig-row');
308+
if (origRow !== null) {
309+
const groupRows = tableContainer.querySelectorAll(
310+
`tr[data-orig-row="${origRow}"]`,
311+
);
312+
groupRows.forEach((r) => {
313+
r.querySelectorAll('td').forEach((cell) => {
314+
cell.classList.remove('row-hover');
315+
});
316+
});
317+
}
318+
});
319+
});
320+
289321
updateButtonStates();
290322
}
291323

@@ -347,4 +379,4 @@ function render({ model, el }) {
347379
handleErrorMessageChange();
348380
}
349381

350-
export default { render };
382+
export { render };

0 commit comments

Comments
 (0)