From c2e13845882b420001961feff0d4dbcb124558e6 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Sun, 28 Sep 2025 13:15:45 -0700 Subject: [PATCH 1/6] Less vertical whitespace in HTML reprs --- xarray/static/css/style.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/xarray/static/css/style.css b/xarray/static/css/style.css index 78f7c35d9cb..0a48514dec3 100644 --- a/xarray/static/css/style.css +++ b/xarray/static/css/style.css @@ -121,6 +121,8 @@ body.vscode-dark { padding-left: 0 !important; display: grid; grid-template-columns: 150px auto auto 1fr 0 20px 0 20px; + margin-block-start: 0; + margin-block-end: 0; } .xr-section-item { @@ -131,6 +133,7 @@ body.vscode-dark { display: inline-block; opacity: 0; height: 0; + margin: 0; } .xr-section-item input + label { @@ -189,7 +192,6 @@ body.vscode-dark { .xr-section-summary, .xr-section-inline-details { padding-top: 4px; - padding-bottom: 4px; } .xr-section-inline-details { @@ -199,6 +201,7 @@ body.vscode-dark { .xr-section-details { display: none; grid-column: 1 / -1; + margin-top: 4px; margin-bottom: 5px; } From b3bb3861f803ab6c8a0c2266bf786a8bbe19cea4 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Sun, 28 Sep 2025 13:34:09 -0700 Subject: [PATCH 2/6] ensure consistent line-height in google colab --- xarray/static/css/style.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/xarray/static/css/style.css b/xarray/static/css/style.css index 0a48514dec3..8b8e854b62a 100644 --- a/xarray/static/css/style.css +++ b/xarray/static/css/style.css @@ -1,6 +1,4 @@ -/* CSS stylesheet for displaying xarray objects in jupyterlab. - * - */ +/* CSS stylesheet for displaying xarray objects in notebooks */ :root { --xr-font-color0: var( @@ -79,6 +77,7 @@ body.vscode-dark { display: block !important; min-width: 300px; max-width: 700px; + line-height: 1.6; } .xr-text-repr-fallback { From ca16214feb8b60efc90128f4d8d9dab47df34439 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Mon, 29 Sep 2025 09:25:53 -0700 Subject: [PATCH 3/6] Refactor DataTree HTML repr --- xarray/core/formatting_html.py | 142 +++++++++++++++++++++++---------- xarray/core/options.py | 10 ++- xarray/static/css/style.css | 77 +++++++++++++----- 3 files changed, 166 insertions(+), 63 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 77842751681..1b1fabaae0d 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -3,6 +3,7 @@ import uuid from collections import OrderedDict from collections.abc import Mapping +from dataclasses import dataclass, field from functools import lru_cache, partial from html import escape from importlib.resources import files @@ -172,7 +173,12 @@ def summarize_indexes(indexes) -> str: def collapsible_section( - name, inline_details="", details="", n_items=None, enabled=True, collapsed=False + name: str | None, + inline_details="", + details="", + n_items=None, + enabled=True, + collapsed=False, ) -> str: # "unique" id to expand/collapse the section data_id = "section-" + str(uuid.uuid4()) @@ -183,14 +189,17 @@ def collapsible_section( collapsed = "" if collapsed or not has_items else "checked" tip = " title='Expand/collapse section'" if enabled else "" - return ( - f"" - f"" - f"
{inline_details}
" - f"
{details}
" - ) + if name is None: + # uncollapsable (no header) + return f"
{details}
" + else: + html = f""" + + +
{inline_details}
+
{details}
+ """ + return "".join(t.strip() for t in html.split("\n")) def _mapping_section( @@ -201,9 +210,10 @@ def _mapping_section( expand_option_name, enabled=True, max_option_name: Literal["display_max_children"] | None = None, + **kwargs, ) -> str: n_items = len(mapping) - expanded = _get_boolean_with_default( + expanded = max_items_collapse is None or _get_boolean_with_default( expand_option_name, n_items < max_items_collapse ) collapsed = not expanded @@ -217,7 +227,7 @@ def _mapping_section( return collapsible_section( name, inline_details=inline_details, - details=details_func(mapping), + details=details_func(mapping, **kwargs), n_items=n_items, enabled=enabled, collapsed=collapsed, @@ -384,7 +394,18 @@ def dataset_repr(ds) -> str: return _obj_repr(ds, header_components, sections) -def datatree_node_sections(node: DataTree, root: bool = False) -> list[str]: +@dataclass +class _DataTreeDisplayContext: + items_shown: int = 0 + node_count_cache: dict[int, int] = field(default_factory=dict) + + +def datatree_node_sections( + node: DataTree, + *, + root: bool, + display_context: _DataTreeDisplayContext, +) -> tuple[list[str], int]: from xarray.core.coordinates import Coordinates ds = node._to_dataset_view(rebuild_dims=False, inherit=True) @@ -403,13 +424,19 @@ def datatree_node_sections(node: DataTree, root: bool = False) -> list[str]: or node._data_variables ) - sections = [] + n_items = ( + +len(node.children) + + int(bool(show_dims)) + + int(bool(node_coords)) + + len(node_coords) + + int(root) * (int(bool(inherited_coords)) + len(inherited_coords)) + + int(bool(ds.data_vars)) + + len(ds.data_vars) + + int(bool(ds.attrs)) + + len(ds.attrs) + ) - if node.children: - children_max_items = 1 if ds.data_vars else 6 - sections.append( - children_section(node.children, max_items_collapse=children_max_items) - ) + sections = [] if show_dims: sections.append(dim_section(ds)) @@ -427,10 +454,10 @@ def datatree_node_sections(node: DataTree, root: bool = False) -> list[str]: if ds.attrs: sections.append(attr_section(ds.attrs)) - return sections + return sections, n_items -def summarize_datatree_children(children: Mapping[str, DataTree]) -> str: +def summarize_datatree_children(children: Mapping[str, DataTree], **kwargs) -> str: MAX_CHILDREN = OPTIONS["display_max_children"] n_children = len(children) @@ -438,22 +465,17 @@ def summarize_datatree_children(children: Mapping[str, DataTree]) -> str: for i, child in enumerate(children.values()): if i < ceil(MAX_CHILDREN / 2) or i >= ceil(n_children - MAX_CHILDREN / 2): is_last = i == (n_children - 1) - children_html.append(datatree_child_repr(child, end=is_last)) + children_html.append(datatree_child_repr(child, end=is_last, **kwargs)) elif n_children > MAX_CHILDREN and i == ceil(MAX_CHILDREN / 2): - children_html.append("
...
") - - return "".join( - [ - "
", - "".join(children_html), - "
", - ] - ) + n_hidden = MAX_CHILDREN - n_children + children_html.append(f"
... ({n_hidden} items hidden)
") + + return "
" + "".join(children_html) + "
" children_section = partial( _mapping_section, - name="Groups", + name=None, details_func=summarize_datatree_children, max_option_name="display_max_children", expand_option_name="display_expand_groups", @@ -468,7 +490,23 @@ def summarize_datatree_children(children: Mapping[str, DataTree]) -> str: ) -def datatree_child_repr(node: DataTree, end: bool = False) -> str: +def _tree_item_count(node: DataTree, cache: dict[int, int]) -> int: + if id(node) in cache: + return cache[id(node)] + + node_ds = node.to_dataset(inherit=False) + node_count = len(node_ds.variables) + len(node_ds.attrs) + child_count = sum( + _tree_item_count(child, cache) for child in node.children.values() + ) + total = node_count + child_count + cache[id(node)] = total + return total + + +def datatree_child_repr( + node: DataTree, *, end: bool, display_context: _DataTreeDisplayContext +) -> str: # Wrap DataTree HTML representation with a tee to the left of it. # # Enclosing HTML tag is a
with :code:`display: inline-grid` style. @@ -491,19 +529,41 @@ def datatree_child_repr(node: DataTree, end: bool = False) -> str: height = "100%" if end is False else "1.2em" # height of line path = escape(node.path) - sections = datatree_node_sections(node, root=False) - section_items = "".join(f"
  • {s}
  • " for s in sections) + sections, n_display_items = datatree_node_sections( + node, root=False, display_context=display_context + ) + + n_items = _tree_item_count(node, display_context.node_count_cache) + n_items_span = f"({n_items})" + + group_id = "group-" + str(uuid.uuid4()) + enabled = "" if sections else "disabled" + tip = " title='Expand/collapse group'" if enabled else "" - # TODO: Can we make the group name clickable to toggle the sections below? - # This looks like it would require the input/label pattern used above. + if display_context.items_shown + n_display_items > OPTIONS["display_max_items"]: + collapsed = "checked" + else: + display_context.items_shown += n_display_items + collapsed = "" + + if node.children: + sections.insert( + 0, + children_section( + node.children, + max_items_collapse=None, + display_context=display_context, + ), + ) + + section_items = "".join(f"
  • {s}
  • " for s in sections) html = f"""
    -
    -
    {path}
    -
    + +
      {section_items}
    @@ -521,5 +581,7 @@ def datatree_repr(node: DataTree) -> str: name = escape(repr(node.name)) header_components.append(f"
    {name}
    ") - sections = datatree_node_sections(node, root=True) + sections, _ = datatree_node_sections( + node, root=True, display_context=_DataTreeDisplayContext() + ) return _obj_repr(node, header_components, sections) diff --git a/xarray/core/options.py b/xarray/core/options.py index c8d00eea5d2..4f753bd0043 100644 --- a/xarray/core/options.py +++ b/xarray/core/options.py @@ -16,6 +16,7 @@ "cmap_sequential", "display_max_children", "display_max_rows", + "display_max_items", "display_values_threshold", "display_style", "display_width", @@ -46,6 +47,7 @@ class T_Options(TypedDict): cmap_sequential: str | Colormap display_max_children: int display_max_rows: int + display_max_items: int display_values_threshold: int display_style: Literal["text", "html"] display_width: int @@ -74,8 +76,9 @@ class T_Options(TypedDict): "chunk_manager": "dask", "cmap_divergent": "RdBu_r", "cmap_sequential": "viridis", - "display_max_children": 6, + "display_max_children": 12, "display_max_rows": 12, + "display_max_items": 30, "display_values_threshold": 200, "display_style": "html", "display_width": 80, @@ -112,6 +115,7 @@ def _positive_integer(value: Any) -> bool: "arithmetic_join": _JOIN_OPTIONS.__contains__, "display_max_children": _positive_integer, "display_max_rows": _positive_integer, + "display_max_items": _positive_integer, "display_values_threshold": _positive_integer, "display_style": _DISPLAY_OPTIONS.__contains__, "display_width": _positive_integer, @@ -236,10 +240,12 @@ class set_options: * ``True`` : to always expand indexes * ``False`` : to always collapse indexes * ``default`` : to expand unless over a pre-defined limit (always collapse for html style) - display_max_children : int, default: 6 + display_max_children : int, default: 12 Maximum number of children to display for each node in a DataTree. display_max_rows : int, default: 12 Maximum display rows. + display_max_items : int, default 30 + Maximum number of items to display for a DataTree, across all levels. display_values_threshold : int, default: 200 Total number of array elements which trigger summarization rather than full repr for variable data views (numpy arrays). diff --git a/xarray/static/css/style.css b/xarray/static/css/style.css index 8b8e854b62a..f4eb7d45ae9 100644 --- a/xarray/static/css/style.css +++ b/xarray/static/css/style.css @@ -88,8 +88,11 @@ body.vscode-dark { .xr-header { padding-top: 6px; padding-bottom: 6px; - margin-bottom: 4px; +} + +.xr-header { border-bottom: solid 1px var(--xr-border-color); + margin-bottom: 4px; } .xr-header > div, @@ -100,20 +103,15 @@ body.vscode-dark { } .xr-obj-type, -.xr-obj-name, -.xr-group-name { +.xr-obj-name { margin-left: 2px; margin-right: 10px; } -.xr-group-name::before { - content: "📁"; - padding-right: 0.3em; -} - -.xr-group-name, -.xr-obj-type { +.xr-obj-type, +.xr-group-box-contents > label { color: var(--xr-font-color2); + display: block; } .xr-sections { @@ -128,28 +126,31 @@ body.vscode-dark { display: contents; } -.xr-section-item input { - display: inline-block; +.xr-section-item > input, +.xr-group-box-contents > input { + display: block; opacity: 0; height: 0; margin: 0; } -.xr-section-item input + label { +.xr-section-item > input + label { color: var(--xr-disabled-color); border: 2px solid transparent !important; } -.xr-section-item input:enabled + label { +.xr-section-item > input:enabled + label, +.xr-group-box-contents > input + label { cursor: pointer; color: var(--xr-font-color2); } -.xr-section-item input:focus + label { +.xr-section-item > input:focus + label { border: 2px solid var(--xr-font-color0) !important; } -.xr-section-item input:enabled + label:hover { +.xr-section-item > input:enabled + label:hover, +.xr-group-box-contents > input:enabled + label:hover { color: var(--xr-font-color0); } @@ -159,7 +160,8 @@ body.vscode-dark { font-weight: 500; } -.xr-section-summary > span { +.xr-section-summary > span, +.xr-group-box-contents > input:checked + label > span { display: inline-block; padding-left: 0.5em; } @@ -189,7 +191,8 @@ body.vscode-dark { } .xr-section-summary, -.xr-section-inline-details { +.xr-section-inline-details, +.xr-group-box-contents > label { padding-top: 4px; } @@ -198,19 +201,29 @@ body.vscode-dark { } .xr-section-details { - display: none; grid-column: 1 / -1; margin-top: 4px; margin-bottom: 5px; } +.xr-section-summary-in ~ .xr-section-details { + display: none; +} + .xr-section-summary-in:checked ~ .xr-section-details { display: contents; } +.xr-children { + display: inline-grid; + grid-template-columns: 100%; + grid-column: 1 / -1; + width: 100%; +} + .xr-group-box { display: inline-grid; - grid-template-columns: 0px 20px auto; + grid-template-columns: 0px 30px auto; width: 100%; } @@ -225,13 +238,35 @@ body.vscode-dark { grid-column-start: 2; grid-row-start: 1; height: 1em; - width: 20px; + width: 26px; border-bottom: 0.2em solid; border-color: var(--xr-border-color); } .xr-group-box-contents { grid-column-start: 3; + width: 100%; +} + +.xr-group-box-contents > label::before { + content: "📂"; + padding-right: 0.3em; +} + +.xr-group-box-contents > input:checked + label::before { + content: "📁"; +} + +.xr-group-box-contents > input:checked + label { + padding-bottom: 0px; +} + +.xr-group-box-contents > input:checked ~ .xr-sections { + display: none; +} + +.xr-group-box-contents > input + label > span { + display: none; } .xr-array-wrap { From 1842a39164cd1c130e2d1b443dd595142454981d Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Tue, 30 Sep 2025 00:12:05 -0700 Subject: [PATCH 4/6] Collapsable DataTree nodes --- xarray/core/formatting_html.py | 254 ++++++++++++++------------- xarray/core/options.py | 14 +- xarray/static/css/style.css | 18 +- xarray/tests/test_formatting_html.py | 4 +- 4 files changed, 157 insertions(+), 133 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 1b1fabaae0d..9075a065f7d 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -3,11 +3,10 @@ import uuid from collections import OrderedDict from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from functools import lru_cache, partial from html import escape from importlib.resources import files -from math import ceil from typing import TYPE_CHECKING, Literal from xarray.core.formatting import ( @@ -173,7 +172,7 @@ def summarize_indexes(indexes) -> str: def collapsible_section( - name: str | None, + name: str, inline_details="", details="", n_items=None, @@ -185,20 +184,16 @@ def collapsible_section( has_items = n_items is not None and n_items n_items_span = "" if n_items is None else f" ({n_items})" - enabled = "" if enabled and has_items else "disabled" - collapsed = "" if collapsed or not has_items else "checked" + enabled = "" if enabled and has_items else " disabled" + collapsed = "" if collapsed or not has_items else " checked" tip = " title='Expand/collapse section'" if enabled else "" - if name is None: - # uncollapsable (no header) - return f"
    {details}
    " - else: - html = f""" - - -
    {inline_details}
    -
    {details}
    - """ + html = f""" + + +
    {inline_details}
    +
    {details}
    + """ return "".join(t.strip() for t in html.split("\n")) @@ -304,6 +299,11 @@ def _get_indexes_dict(indexes): } +def _sections_repr(sections: list[str]) -> str: + section_items = "".join(f"
  • {s}
  • " for s in sections) + return f"
      {section_items}
    " + + def _obj_repr(obj, header_components, sections): """Return HTML repr of an xarray object. @@ -311,7 +311,6 @@ def _obj_repr(obj, header_components, sections): """ header = f"
    {''.join(h for h in header_components)}
    " - sections = "".join(f"
  • {s}
  • " for s in sections) icons_svg, css_style = _load_static_files() return ( @@ -320,7 +319,7 @@ def _obj_repr(obj, header_components, sections): f"
    {escape(repr(obj))}
    " "" "
    " ) @@ -394,18 +393,16 @@ def dataset_repr(ds) -> str: return _obj_repr(ds, header_components, sections) -@dataclass -class _DataTreeDisplayContext: - items_shown: int = 0 - node_count_cache: dict[int, int] = field(default_factory=dict) +inherited_coord_section = partial( + _mapping_section, + name="Inherited coordinates", + details_func=summarize_coords, + max_items_collapse=25, + expand_option_name="display_expand_coords", +) -def datatree_node_sections( - node: DataTree, - *, - root: bool, - display_context: _DataTreeDisplayContext, -) -> tuple[list[str], int]: +def _datatree_node_sections(node: DataTree, root: bool) -> tuple[list[str], int]: from xarray.core.coordinates import Coordinates ds = node._to_dataset_view(rebuild_dims=False, inherit=True) @@ -418,76 +415,33 @@ def datatree_node_sections( ) # Only show dimensions if also showing a variable or coordinates section. - show_dims = ( - node._node_coord_variables - or (root and inherited_coords) - or node._data_variables - ) - - n_items = ( - +len(node.children) - + int(bool(show_dims)) - + int(bool(node_coords)) - + len(node_coords) - + int(root) * (int(bool(inherited_coords)) + len(inherited_coords)) - + int(bool(ds.data_vars)) - + len(ds.data_vars) - + int(bool(ds.attrs)) - + len(ds.attrs) - ) + show_dims = node_coords or (root and inherited_coords) or ds.data_vars sections = [] - if show_dims: sections.append(dim_section(ds)) - if node_coords: sections.append(coord_section(node_coords)) - - # only show inherited coordinates on the root if root and inherited_coords: sections.append(inherited_coord_section(inherited_coords)) - if ds.data_vars: sections.append(datavar_section(ds.data_vars)) - if ds.attrs: sections.append(attr_section(ds.attrs)) - return sections, n_items - - -def summarize_datatree_children(children: Mapping[str, DataTree], **kwargs) -> str: - MAX_CHILDREN = OPTIONS["display_max_children"] - n_children = len(children) - - children_html = [] - for i, child in enumerate(children.values()): - if i < ceil(MAX_CHILDREN / 2) or i >= ceil(n_children - MAX_CHILDREN / 2): - is_last = i == (n_children - 1) - children_html.append(datatree_child_repr(child, end=is_last, **kwargs)) - elif n_children > MAX_CHILDREN and i == ceil(MAX_CHILDREN / 2): - n_hidden = MAX_CHILDREN - n_children - children_html.append(f"
    ... ({n_hidden} items hidden)
    ") - - return "
    " + "".join(children_html) + "
    " - - -children_section = partial( - _mapping_section, - name=None, - details_func=summarize_datatree_children, - max_option_name="display_max_children", - expand_option_name="display_expand_groups", -) + displayed_line_count = ( + len(node.children) + + int(bool(show_dims)) + + int(bool(node_coords)) + + len(node_coords) + + int(root) * (int(bool(inherited_coords)) + len(inherited_coords)) + + int(bool(ds.data_vars)) + + len(ds.data_vars) + + int(bool(ds.attrs)) + + len(ds.attrs) + ) -inherited_coord_section = partial( - _mapping_section, - name="Inherited coordinates", - details_func=summarize_coords, - max_items_collapse=25, - expand_option_name="display_expand_coords", -) + return sections, displayed_line_count def _tree_item_count(node: DataTree, cache: dict[int, int]) -> int: @@ -504,8 +458,85 @@ def _tree_item_count(node: DataTree, cache: dict[int, int]) -> int: return total +@dataclass +class _DataTreeDisplay: + node: DataTree + sections: list[str] + item_count: int + collapsed: bool + disabled: bool + + +def _build_datatree_displays(tree: DataTree) -> dict[str, _DataTreeDisplay]: + displayed_line_count = 0 + displays: dict[str, _DataTreeDisplay] = {} + item_count_cache: dict[int, int] = {} + root = True + collapsed = False + + for node in tree.subtree: # breadth-first + parent = node.parent + if parent is not None: + parent_display = displays.get(parent.path, None) + if parent_display is not None and parent_display.disabled: + break # no need to build display + + item_count = _tree_item_count(node, item_count_cache) + + sections, node_line_count = _datatree_node_sections(node, root) + new_count = displayed_line_count + node_line_count + + disabled = not root and new_count > OPTIONS["display_max_html_elements"] + + if disabled: + sections = [] + collapsed = True + else: + if not root: + collapsed = collapsed or new_count > OPTIONS["display_max_items"] + if not collapsed: + displayed_line_count = new_count + + displays[node.path] = _DataTreeDisplay( + node, sections, item_count, collapsed, disabled + ) + root = False + + return displays + + +def children_section( + children: Mapping[str, DataTree], displays: dict[str, _DataTreeDisplay] +) -> str: + child_elements = [] + for i, child in enumerate(children.values()): + is_last = i == (len(children) - 1) + child_elements.append(datatree_child_repr(child, displays, end=is_last)) + + children_html = "".join(child_elements) + html = f""" +
    +
    {children_html}
    +
    + """ + return "".join(t.strip() for t in html.split("\n")) + + +def datatree_sections( + node: DataTree, displays: dict[str, _DataTreeDisplay] +) -> list[str]: + display = displays[node.path] + sections = [] + if node.children and not display.disabled: + sections.append(children_section(node.children, displays)) + sections.extend(display.sections) + return sections + + def datatree_child_repr( - node: DataTree, *, end: bool, display_context: _DataTreeDisplayContext + node: DataTree, + displays: dict[str, _DataTreeDisplay], + end: bool, ) -> str: # Wrap DataTree HTML representation with a tee to the left of it. # @@ -525,48 +556,33 @@ def datatree_child_repr( # └─ [ title ] # | details | # |_____________| - end = bool(end) - height = "100%" if end is False else "1.2em" # height of line - path = escape(node.path) - sections, n_display_items = datatree_node_sections( - node, root=False, display_context=display_context - ) + vline_height = "1.2em" if end else "100%" - n_items = _tree_item_count(node, display_context.node_count_cache) - n_items_span = f"({n_items})" + path = escape(node.path) + display = displays[node.path] group_id = "group-" + str(uuid.uuid4()) - enabled = "" if sections else "disabled" - tip = " title='Expand/collapse group'" if enabled else "" + collapsed = " checked" if display.collapsed else "" + disabled = " disabled" if display.disabled else "" + tip = " title='Expand/collapse group'" if not display.disabled else "" - if display_context.items_shown + n_display_items > OPTIONS["display_max_items"]: - collapsed = "checked" - else: - display_context.items_shown += n_display_items - collapsed = "" - - if node.children: - sections.insert( - 0, - children_section( - node.children, - max_items_collapse=None, - display_context=display_context, - ), - ) + sections = datatree_sections(node, displays) + sections_html = _sections_repr(sections) if sections else "" + + item_count = f"{display.item_count}" + (" truncated" if disabled else "") - section_items = "".join(f"
  • {s}
  • " for s in sections) html = f"""
    -
    +
    - - -
      - {section_items} -
    + + + {sections_html}
    """ @@ -574,14 +590,12 @@ def datatree_child_repr( def datatree_repr(node: DataTree) -> str: + displays = _build_datatree_displays(node) header_components = [ f"
    xarray.{type(node).__name__}
    ", ] if node.name is not None: name = escape(repr(node.name)) header_components.append(f"
    {name}
    ") - - sections, _ = datatree_node_sections( - node, root=True, display_context=_DataTreeDisplayContext() - ) + sections = datatree_sections(node, displays) return _obj_repr(node, header_components, sections) diff --git a/xarray/core/options.py b/xarray/core/options.py index 4f753bd0043..20da9a39ca1 100644 --- a/xarray/core/options.py +++ b/xarray/core/options.py @@ -15,6 +15,7 @@ "cmap_divergent", "cmap_sequential", "display_max_children", + "display_max_html_elements", "display_max_rows", "display_max_items", "display_values_threshold", @@ -46,6 +47,7 @@ class T_Options(TypedDict): cmap_divergent: str | Colormap cmap_sequential: str | Colormap display_max_children: int + display_max_html_elements: int display_max_rows: int display_max_items: int display_values_threshold: int @@ -77,8 +79,9 @@ class T_Options(TypedDict): "cmap_divergent": "RdBu_r", "cmap_sequential": "viridis", "display_max_children": 12, + "display_max_html_elements": 500, "display_max_rows": 12, - "display_max_items": 30, + "display_max_items": 20, "display_values_threshold": 200, "display_style": "html", "display_width": 80, @@ -114,6 +117,7 @@ def _positive_integer(value: Any) -> bool: "arithmetic_broadcast": lambda value: isinstance(value, bool), "arithmetic_join": _JOIN_OPTIONS.__contains__, "display_max_children": _positive_integer, + "display_max_html_elements": _positive_integer, "display_max_rows": _positive_integer, "display_max_items": _positive_integer, "display_values_threshold": _positive_integer, @@ -242,10 +246,14 @@ class set_options: * ``default`` : to expand unless over a pre-defined limit (always collapse for html style) display_max_children : int, default: 12 Maximum number of children to display for each node in a DataTree. + display_max_html_elements : int, default: 500 + Maximum number of HTML elements to include in DataTree HTML displays. + Additional items are truncated. display_max_rows : int, default: 12 Maximum display rows. - display_max_items : int, default 30 - Maximum number of items to display for a DataTree, across all levels. + display_max_items : int, default 20 + Maximum number of items to display for a DataTree before collapsing + child nodes, across all levels. display_values_threshold : int, default: 200 Total number of array elements which trigger summarization rather than full repr for variable data views (numpy arrays). diff --git a/xarray/static/css/style.css b/xarray/static/css/style.css index f4eb7d45ae9..26d8b48c0e3 100644 --- a/xarray/static/css/style.css +++ b/xarray/static/css/style.css @@ -136,19 +136,19 @@ body.vscode-dark { .xr-section-item > input + label { color: var(--xr-disabled-color); - border: 2px solid transparent !important; + /* border: 2px solid transparent !important; */ } +/* .xr-section-item > input:active + label { + border: 2px solid var(--xr-font-color0) !important; +} */ + .xr-section-item > input:enabled + label, -.xr-group-box-contents > input + label { +.xr-group-box-contents > input:enabled + label { cursor: pointer; color: var(--xr-font-color2); } -.xr-section-item > input:focus + label { - border: 2px solid var(--xr-font-color0) !important; -} - .xr-section-item > input:enabled + label:hover, .xr-group-box-contents > input:enabled + label:hover { color: var(--xr-font-color0); @@ -166,7 +166,8 @@ body.vscode-dark { padding-left: 0.5em; } -.xr-section-summary-in:disabled + label { +.xr-section-summary-in:disabled + label, +.xr-group-box-contents > input:disabled + label { color: var(--xr-font-color2); } @@ -178,7 +179,8 @@ body.vscode-dark { text-align: center; } -.xr-section-summary-in:disabled + label:before { +.xr-section-summary-in:disabled + label:before, +.xr-group-box-contents > input:disabled + label:before { color: var(--xr-disabled-color); } diff --git a/xarray/tests/test_formatting_html.py b/xarray/tests/test_formatting_html.py index 2f0177f3181..f58e8aee49e 100644 --- a/xarray/tests/test_formatting_html.py +++ b/xarray/tests/test_formatting_html.py @@ -197,7 +197,7 @@ def test_repr_of_dataset(dataset: xr.Dataset) -> None: formatted = xarray_html_only_repr(dataset) # coords, attrs, and data_vars are expanded assert ( - formatted.count("class='xr-section-summary-in' type='checkbox' checked>") == 3 + formatted.count("class='xr-section-summary-in' type='checkbox' checked />") == 3 ) # indexes is omitted assert "Indexes" not in formatted @@ -216,7 +216,7 @@ def test_repr_of_dataset(dataset: xr.Dataset) -> None: formatted = xarray_html_only_repr(dataset) # coords, attrs, and data_vars are collapsed, indexes is shown & expanded assert ( - formatted.count("class='xr-section-summary-in' type='checkbox' checked>") + formatted.count("class='xr-section-summary-in' type='checkbox' checked />") == 1 ) assert "Indexes" in formatted From 73e46ec0a762c99fc779525854cbf6e304058899 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Thu, 2 Oct 2025 21:58:48 -0700 Subject: [PATCH 5/6] more formatting --- xarray/core/formatting_html.py | 56 +++++++++++++++++++++------------- xarray/static/css/style.css | 33 +++++++++++--------- 2 files changed, 54 insertions(+), 35 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 9075a065f7d..0a6ab7bd5f6 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -172,7 +172,7 @@ def summarize_indexes(indexes) -> str: def collapsible_section( - name: str, + header: str, inline_details="", details="", n_items=None, @@ -187,14 +187,17 @@ def collapsible_section( enabled = "" if enabled and has_items else " disabled" collapsed = "" if collapsed or not has_items else " checked" tip = " title='Expand/collapse section'" if enabled else "" + span_grid = " xr-span-grid" if not inline_details else "" - html = f""" - - -
    {inline_details}
    -
    {details}
    - """ - return "".join(t.strip() for t in html.split("\n")) + html = ( + f"" + f"" + ) + if inline_details: + html += f"
    {inline_details}
    " + if details: + html += f"
    {details}
    " + return html def _mapping_section( @@ -220,7 +223,7 @@ def _mapping_section( inline_details = f"({max_items}/{n_items})" return collapsible_section( - name, + f"{name}:", inline_details=inline_details, details=details_func(mapping, **kwargs), n_items=n_items, @@ -233,7 +236,7 @@ def dim_section(obj) -> str: dim_list = format_dims(obj.sizes, obj.xindexes.dims) return collapsible_section( - "Dimensions", inline_details=dim_list, enabled=False, collapsed=True + "Dimensions:", inline_details=dim_list, enabled=False, collapsed=True ) @@ -502,6 +505,16 @@ def _build_datatree_displays(tree: DataTree) -> dict[str, _DataTreeDisplay]: ) root = False + # If any node is collapsed, ensure its immediate siblings are also collapsed + for display in displays.values(): + if not display.disabled: + if any( + displays[child.path].collapsed + for child in display.node.children.values() + ): + for child in display.node.children.values(): + displays[child.path].collapsed = True + return displays @@ -514,12 +527,7 @@ def children_section( child_elements.append(datatree_child_repr(child, displays, end=is_last)) children_html = "".join(child_elements) - html = f""" -
    -
    {children_html}
    -
    - """ - return "".join(t.strip() for t in html.split("\n")) + return f"
    {children_html}
    " def datatree_sections( @@ -564,23 +572,29 @@ def datatree_child_repr( group_id = "group-" + str(uuid.uuid4()) collapsed = " checked" if display.collapsed else "" - disabled = " disabled" if display.disabled else "" tip = " title='Expand/collapse group'" if not display.disabled else "" sections = datatree_sections(node, displays) - sections_html = _sections_repr(sections) if sections else "" + if display.disabled: + sections.append( + collapsible_section( + f"Too many items ({display.item_count}) to display", + enabled=False, + collapsed=True, + ) + ) - item_count = f"{display.item_count}" + (" truncated" if disabled else "") + sections_html = _sections_repr(sections) if sections else "" html = f"""
    - + {sections_html}
    diff --git a/xarray/static/css/style.css b/xarray/static/css/style.css index 26d8b48c0e3..af569f8f248 100644 --- a/xarray/static/css/style.css +++ b/xarray/static/css/style.css @@ -78,6 +78,7 @@ body.vscode-dark { min-width: 300px; max-width: 700px; line-height: 1.6; + padding-bottom: 4px; } .xr-text-repr-fallback { @@ -136,13 +137,8 @@ body.vscode-dark { .xr-section-item > input + label { color: var(--xr-disabled-color); - /* border: 2px solid transparent !important; */ } -/* .xr-section-item > input:active + label { - border: 2px solid var(--xr-font-color0) !important; -} */ - .xr-section-item > input:enabled + label, .xr-group-box-contents > input:enabled + label { cursor: pointer; @@ -160,14 +156,25 @@ body.vscode-dark { font-weight: 500; } -.xr-section-summary > span, +.xr-section-summary > em { + font-weight: normal; +} + +.xr-span-grid { + grid-column-end: -1; +} + +.xr-section-summary > span { + display: inline-block; + padding-left: 0.3em; +} + .xr-group-box-contents > input:checked + label > span { display: inline-block; - padding-left: 0.5em; + padding-left: 0.6em; } -.xr-section-summary-in:disabled + label, -.xr-group-box-contents > input:disabled + label { +.xr-section-summary-in:disabled + label { color: var(--xr-font-color2); } @@ -179,8 +186,7 @@ body.vscode-dark { text-align: center; } -.xr-section-summary-in:disabled + label:before, -.xr-group-box-contents > input:disabled + label:before { +.xr-section-summary-in:disabled + label:before { color: var(--xr-disabled-color); } @@ -220,13 +226,12 @@ body.vscode-dark { display: inline-grid; grid-template-columns: 100%; grid-column: 1 / -1; - width: 100%; + padding-top: 4px; } .xr-group-box { display: inline-grid; grid-template-columns: 0px 30px auto; - width: 100%; } .xr-group-box-vline { @@ -247,7 +252,7 @@ body.vscode-dark { .xr-group-box-contents { grid-column-start: 3; - width: 100%; + padding-bottom: 4px; } .xr-group-box-contents > label::before { From 1958bc9c2f888ab167a2b3668672d2e0a8f68d53 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Fri, 3 Oct 2025 17:38:16 -0700 Subject: [PATCH 6/6] Tweaks --- xarray/core/formatting_html.py | 37 +++++++++++---------- xarray/core/options.py | 4 +-- xarray/tests/test_formatting_html.py | 48 +++++++--------------------- 3 files changed, 34 insertions(+), 55 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 0a6ab7bd5f6..e1f2ae64ab4 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -472,10 +472,21 @@ class _DataTreeDisplay: def _build_datatree_displays(tree: DataTree) -> dict[str, _DataTreeDisplay]: displayed_line_count = 0 + html_line_count = 0 displays: dict[str, _DataTreeDisplay] = {} item_count_cache: dict[int, int] = {} root = True collapsed = False + disabled = False + + html_limit = OPTIONS["display_max_html_elements"] + uncollapsed_limit = OPTIONS["display_max_items"] + + too_many_items_section = collapsible_section( + "Too many items to display (display_max_html_elements exceeded)", + enabled=False, + collapsed=True, + ) for node in tree.subtree: # breadth-first parent = node.parent @@ -487,18 +498,19 @@ def _build_datatree_displays(tree: DataTree) -> dict[str, _DataTreeDisplay]: item_count = _tree_item_count(node, item_count_cache) sections, node_line_count = _datatree_node_sections(node, root) - new_count = displayed_line_count + node_line_count - - disabled = not root and new_count > OPTIONS["display_max_html_elements"] + new_displayed_count = displayed_line_count + node_line_count + new_html_count = html_line_count + node_line_count + disabled = not root and (disabled or new_html_count > html_limit) if disabled: - sections = [] + sections = [too_many_items_section] collapsed = True else: - if not root: - collapsed = collapsed or new_count > OPTIONS["display_max_items"] - if not collapsed: - displayed_line_count = new_count + html_line_count = new_html_count + + collapsed = not root and (collapsed or new_displayed_count > uncollapsed_limit) + if not collapsed: + displayed_line_count = new_displayed_count displays[node.path] = _DataTreeDisplay( node, sections, item_count, collapsed, disabled @@ -575,15 +587,6 @@ def datatree_child_repr( tip = " title='Expand/collapse group'" if not display.disabled else "" sections = datatree_sections(node, displays) - if display.disabled: - sections.append( - collapsible_section( - f"Too many items ({display.item_count}) to display", - enabled=False, - collapsed=True, - ) - ) - sections_html = _sections_repr(sections) if sections else "" html = f""" diff --git a/xarray/core/options.py b/xarray/core/options.py index 20da9a39ca1..ba0b71d159e 100644 --- a/xarray/core/options.py +++ b/xarray/core/options.py @@ -79,7 +79,7 @@ class T_Options(TypedDict): "cmap_divergent": "RdBu_r", "cmap_sequential": "viridis", "display_max_children": 12, - "display_max_html_elements": 500, + "display_max_html_elements": 300, "display_max_rows": 12, "display_max_items": 20, "display_values_threshold": 200, @@ -246,7 +246,7 @@ class set_options: * ``default`` : to expand unless over a pre-defined limit (always collapse for html style) display_max_children : int, default: 12 Maximum number of children to display for each node in a DataTree. - display_max_html_elements : int, default: 500 + display_max_html_elements : int, default: 300 Maximum number of HTML elements to include in DataTree HTML displays. Additional items are truncated. display_max_rows : int, default: 12 diff --git a/xarray/tests/test_formatting_html.py b/xarray/tests/test_formatting_html.py index f58e8aee49e..a4c169f6b04 100644 --- a/xarray/tests/test_formatting_html.py +++ b/xarray/tests/test_formatting_html.py @@ -271,48 +271,24 @@ def test_nonstr_variable_repr_html() -> None: class TestDataTreeTruncatesNodes: def test_many_nodes(self) -> None: - # construct a datatree with 500 nodes - number_of_files = 20 - number_of_groups = 25 + number_of_files = 10 + number_of_groups = 10 tree_dict = {} for f in range(number_of_files): for g in range(number_of_groups): tree_dict[f"file_{f}/group_{g}"] = xr.Dataset({"g": f * g}) tree = xr.DataTree.from_dict(tree_dict) - with xr.set_options(display_style="html"): - result = tree._repr_html_() - - assert "6/20" in result - for i in range(number_of_files): - if i < 3 or i >= (number_of_files - 3): - assert f"file_{i}
    " in result - else: - assert f"file_{i}
    " not in result - - assert "6/25" in result - for i in range(number_of_groups): - if i < 3 or i >= (number_of_groups - 3): - assert f"group_{i}
    " in result - else: - assert f"group_{i}" not in result - - with xr.set_options(display_style="html", display_max_children=3): - result = tree._repr_html_() - - assert "3/20" in result - for i in range(number_of_files): - if i < 2 or i >= (number_of_files - 1): - assert f"file_{i}" in result - else: - assert f"file_{i}" not in result - - assert "3/25" in result - for i in range(number_of_groups): - if i < 2 or i >= (number_of_groups - 1): - assert f"group_{i}" in result - else: - assert f"group_{i}" not in result + + with xr.set_options(display_max_html_elements=25): + result = xarray_html_only_repr(tree) + assert result.count("file_0/group_9") == 1 + assert result.count("file_1/group_0") == 0 # disabled + assert result.count("Too many items to display") == 9 + 10 + + with xr.set_options(display_max_html_elements=1000): + result = xarray_html_only_repr(tree) + assert result.count("Too many items to display") == 0 class TestDataTreeInheritance: