Skip to content

Commit 2ebcb18

Browse files
committed
Initial pass - hierarchy layout option
1 parent 5615fdd commit 2ebcb18

18 files changed

Lines changed: 2669 additions & 615 deletions

README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Returns a `PivotTableResult` dict containing the current `config` state.
9595
| `height` | `int \| None` | `None` | Fixed height in pixels. `None` means auto-size (capped by `max_height`). |
9696
| `max_height` | `int` | `500` | Maximum auto-size height in pixels. Table becomes scrollable when content exceeds this. Ignored when `height` is set. |
9797
| `sticky_headers` | `bool` | `True` | Column headers stick to the top of the scroll container. |
98+
| `row_layout` | `"table" \| "hierarchy"` | `"table"` | Controls how row dimensions are rendered. `"table"` uses separate row-header columns, while `"hierarchy"` renders a single indented tree column with breadcrumb-level controls. |
9899

99100
#### Interactivity and Callbacks
100101

@@ -332,6 +333,34 @@ When a temporal field is on `columns`, parent headers such as `2024` or `Q1 2024
332333

333334
Grouped buckets export as grouped labels such as `Jan 2024`, `Q1 2024`, or `2024-W03`; they are intentionally not exported as fake raw Excel dates.
334335

336+
### Row Layout Modes
337+
338+
Choose between two row presentation modes:
339+
340+
| Mode | Value | Description |
341+
|------|-------|-------------|
342+
| Table | `"table"` | Classic pivot layout with one visible row-header column per row dimension, plus expanded temporal levels as separate row-header columns when applicable. |
343+
| Hierarchy | `"hierarchy"` | Compact tree layout with a single visible row hierarchy column, indentation by depth, breadcrumb controls, and inline expand/collapse. |
344+
345+
```python
346+
st_pivot_table(
347+
df,
348+
key="row_layout_example",
349+
rows=["Region", "Category", "Customer"],
350+
columns=["Year"],
351+
values=["Revenue", "Profit"],
352+
row_layout="hierarchy",
353+
)
354+
```
355+
356+
Behavior notes:
357+
358+
- `table` preserves the traditional multi-column row-axis layout and works naturally with `repeat_row_labels`.
359+
- `hierarchy` renders parent groups before their children and uses a single visible row column rather than separate columns per row dimension.
360+
- Temporal date hierarchies work in both layouts. In `table`, date levels expand into separate row-header columns; in `hierarchy`, those same levels render as nested tree levels within the single hierarchy column.
361+
- Export parity is preserved. CSV, TSV, clipboard, and XLSX outputs follow the selected row layout, including hierarchy indentation.
362+
- Execution-mode parity is also preserved. `row_layout` works in both `client_only` and `threshold_hybrid`; the layout mostly affects rendering, not whether hybrid execution is allowed.
363+
335364
### Number Format Patterns
336365

337366
Patterns follow a lightweight d3-style syntax.
@@ -569,6 +598,8 @@ st_pivot_table(
569598
**Limitations:**
570599
- Synthetic measures are not supported in hybrid mode (falls back to client-side).
571600

601+
`row_layout` is supported in both execution paths. Switching between `table` and `hierarchy` does not by itself force a fallback out of `threshold_hybrid`.
602+
572603
### Locked Mode
573604

574605
Use `locked=True` for a viewer-mode experience with exploration enabled. The Settings Panel and toolbar config controls are locked so end-users cannot change rows, columns, values, or per-measure aggregation. Reset, Swap, and config import/export are hidden, while data export remains available. Expand/Collapse All group controls remain accessible in the toolbar utility menu. Header-menu sorting, filtering, and `Show Values As` remain available, and drill-down still works.
@@ -608,7 +639,9 @@ The panel contains:
608639
- **Available Fields** — unassigned columns shown as draggable chips. Click a chip's menu to add it to Rows, Columns, or Values. When more than 8 fields are available, a search input appears.
609640
- **Rows / Columns / Values** drop zones — drag chips to reorder within a zone, drag between zones, or use the `x` button to remove. Value chips show an aggregation picker (click the badge to change).
610641
- **Synthetic Measures** — click **+ Add measure** to create derived metrics (ratio of sums, difference of sums) with optional format patterns.
611-
- **Display Toggles** — Row Totals, Column Totals, Subtotals, Repeat Labels, and Sticky Headers.
642+
- **Display Toggles** — Row Totals, Column Totals, Subtotals, Repeat Labels, Row Layout, and Sticky Headers.
643+
644+
In `hierarchy` row layout, `Repeat Labels` is not applicable because the row axis is rendered as a single hierarchy column rather than repeated across separate row-dimension columns.
612645

613646
External config changes (toolbar DnD, Reset, Swap, config import) while the panel is open will close it and discard uncommitted edits.
614647

streamlit_layout_demo.py

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
# Copyright 2025 Snowflake Inc.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Standalone demo app for comparing row layout modes.
17+
18+
Run locally with:
19+
streamlit run streamlit_layout_demo.py
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import datetime as dt
25+
from typing import Literal, cast
26+
27+
import pandas as pd # type: ignore[import-untyped]
28+
import streamlit as st
29+
30+
from streamlit_pivot import st_pivot_table
31+
32+
33+
def make_sales_demo_data() -> pd.DataFrame:
34+
rows: list[dict[str, object]] = []
35+
scenarios = [
36+
("BI Tools", "Streamlit", "Enterprise", "Acme Corp", 2024, 420_000, 138_000),
37+
("BI Tools", "Streamlit", "Enterprise", "Globex", 2024, 355_000, 122_000),
38+
("BI Tools", "Sigma", "Enterprise", "Acme Corp", 2024, 390_000, 129_000),
39+
("BI Tools", "Sigma", "Mid Market", "Initech", 2024, 210_000, 71_000),
40+
("BI Tools", "Power BI", "Enterprise", "Umbrella", 2024, 330_000, 111_000),
41+
("BI Tools", "Power BI", "Mid Market", "Soylent", 2024, 180_000, 58_000),
42+
(
43+
"AI Apps",
44+
"Cortex Analyst",
45+
"Enterprise",
46+
"Acme Corp",
47+
2024,
48+
510_000,
49+
189_000,
50+
),
51+
("AI Apps", "Cortex Analyst", "Enterprise", "Globex", 2024, 470_000, 172_000),
52+
("AI Apps", "Copilot", "Mid Market", "Initech", 2024, 260_000, 88_000),
53+
("AI Apps", "Copilot", "SMB", "Initrode", 2024, 120_000, 36_000),
54+
("AI Apps", "Notebook UX", "Enterprise", "Umbrella", 2024, 300_000, 102_000),
55+
("AI Apps", "Notebook UX", "SMB", "Soylent", 2024, 95_000, 29_000),
56+
("BI Tools", "Streamlit", "Enterprise", "Acme Corp", 2025, 465_000, 151_000),
57+
("BI Tools", "Streamlit", "Enterprise", "Globex", 2025, 380_000, 131_000),
58+
("BI Tools", "Sigma", "Enterprise", "Acme Corp", 2025, 410_000, 135_000),
59+
("BI Tools", "Sigma", "Mid Market", "Initech", 2025, 232_000, 79_000),
60+
("BI Tools", "Power BI", "Enterprise", "Umbrella", 2025, 348_000, 118_000),
61+
("BI Tools", "Power BI", "Mid Market", "Soylent", 2025, 192_000, 61_000),
62+
(
63+
"AI Apps",
64+
"Cortex Analyst",
65+
"Enterprise",
66+
"Acme Corp",
67+
2025,
68+
560_000,
69+
205_000,
70+
),
71+
("AI Apps", "Cortex Analyst", "Enterprise", "Globex", 2025, 498_000, 181_000),
72+
("AI Apps", "Copilot", "Mid Market", "Initech", 2025, 279_000, 95_000),
73+
("AI Apps", "Copilot", "SMB", "Initrode", 2025, 130_000, 39_000),
74+
("AI Apps", "Notebook UX", "Enterprise", "Umbrella", 2025, 322_000, 110_000),
75+
("AI Apps", "Notebook UX", "SMB", "Soylent", 2025, 108_000, 33_000),
76+
]
77+
for use_case, product, segment, customer, year, arr, profit in scenarios:
78+
rows.append(
79+
{
80+
"Use Case": use_case,
81+
"Product": product,
82+
"Segment": segment,
83+
"Customer": customer,
84+
"Year": year,
85+
"ARR": arr,
86+
"Profit": profit,
87+
"Deals": 1,
88+
}
89+
)
90+
return pd.DataFrame(rows)
91+
92+
93+
def make_temporal_demo_data() -> pd.DataFrame:
94+
rows: list[dict[str, object]] = []
95+
for region, customers, base in [
96+
("North America", ["Acme Corp", "Globex"], 120_000),
97+
("Europe", ["Initech", "Umbrella"], 95_000),
98+
]:
99+
for month_index, month in enumerate([1, 2, 3, 4, 5, 6], start=1):
100+
for customer_index, customer in enumerate(customers, start=1):
101+
booking = base + month_index * 8_000 + customer_index * 3_500
102+
rows.append(
103+
{
104+
"Order Date": dt.date(2024, month, 1),
105+
"Region": region,
106+
"Customer": customer,
107+
"Bookings": booking,
108+
"Pipeline": round(booking * 1.45),
109+
}
110+
)
111+
return pd.DataFrame(rows)
112+
113+
114+
st.set_page_config(page_title="Pivot Row Layout Demo", layout="wide")
115+
st.title("Pivot Row Layout Demo")
116+
st.caption(
117+
"This standalone app compares the two row rendering modes: "
118+
'`row_layout="table"` and `row_layout="hierarchy"`.'
119+
)
120+
121+
st.markdown(
122+
"""
123+
Use this page to compare the same pivot configuration rendered in two ways:
124+
125+
- **Table**: one visible row-header column per row field
126+
- **Hierarchy**: a single indented tree-style first column with parent rows and inline toggles
127+
128+
Open the **Settings** panel on either interactive pivot to switch the layout at runtime.
129+
"""
130+
)
131+
132+
sales_df = make_sales_demo_data()
133+
temporal_df = make_temporal_demo_data()
134+
135+
st.divider()
136+
st.subheader("Business Hierarchy Comparison")
137+
st.markdown(
138+
"""
139+
This example uses a business hierarchy with four row levels so the layout difference is obvious.
140+
The underlying grouping is identical in both pivots; only the row presentation changes.
141+
"""
142+
)
143+
144+
st.markdown("#### Table Layout")
145+
st_pivot_table(
146+
sales_df,
147+
key="layout_demo_table",
148+
rows=["Use Case", "Product", "Segment", "Customer"],
149+
columns=["Year"],
150+
values=["ARR", "Profit"],
151+
aggregation={"ARR": "sum", "Profit": "sum"},
152+
show_totals=True,
153+
show_subtotals=True,
154+
repeat_row_labels=False,
155+
row_layout="table",
156+
number_format={"ARR": "$,.0f", "Profit": "$,.0f"},
157+
max_height=420,
158+
)
159+
160+
st.markdown("#### Hierarchy Layout")
161+
st_pivot_table(
162+
sales_df,
163+
key="layout_demo_hierarchy",
164+
rows=["Use Case", "Product", "Segment", "Customer"],
165+
columns=["Year"],
166+
values=["ARR", "Profit"],
167+
aggregation={"ARR": "sum", "Profit": "sum"},
168+
show_totals=True,
169+
row_layout="hierarchy",
170+
number_format={"ARR": "$,.0f", "Profit": "$,.0f"},
171+
max_height=420,
172+
)
173+
174+
with st.expander("View code for the business hierarchy example"):
175+
st.code(
176+
"""
177+
st_pivot_table(
178+
sales_df,
179+
key="layout_demo_table",
180+
rows=["Use Case", "Product", "Segment", "Customer"],
181+
columns=["Year"],
182+
values=["ARR", "Profit"],
183+
aggregation={"ARR": "sum", "Profit": "sum"},
184+
show_totals=True,
185+
show_subtotals=True,
186+
repeat_row_labels=False,
187+
row_layout="table",
188+
)
189+
190+
st_pivot_table(
191+
sales_df,
192+
key="layout_demo_hierarchy",
193+
rows=["Use Case", "Product", "Segment", "Customer"],
194+
columns=["Year"],
195+
values=["ARR", "Profit"],
196+
aggregation={"ARR": "sum", "Profit": "sum"},
197+
show_totals=True,
198+
row_layout="hierarchy",
199+
)
200+
""",
201+
language="python",
202+
)
203+
204+
st.divider()
205+
st.subheader("Temporal Hierarchy Comparison")
206+
st.markdown(
207+
"""
208+
This example puts a date field on rows so you can compare how temporal parents appear.
209+
In **table** mode the renderer expands the date hierarchy into visible row-header columns.
210+
In **hierarchy** mode those same levels render as a single indented tree.
211+
"""
212+
)
213+
214+
st.markdown("#### Table Layout with Row Date Hierarchy")
215+
st_pivot_table(
216+
temporal_df,
217+
key="layout_demo_temporal_table",
218+
rows=["Order Date", "Region", "Customer"],
219+
columns=[],
220+
values=["Bookings", "Pipeline"],
221+
aggregation={"Bookings": "sum", "Pipeline": "sum"},
222+
show_totals=True,
223+
row_layout="table",
224+
number_format={"Bookings": "$,.0f", "Pipeline": "$,.0f"},
225+
max_height=420,
226+
)
227+
228+
st.markdown("#### Hierarchy Layout with Row Date Hierarchy")
229+
st_pivot_table(
230+
temporal_df,
231+
key="layout_demo_temporal_hierarchy",
232+
rows=["Order Date", "Region", "Customer"],
233+
columns=[],
234+
values=["Bookings", "Pipeline"],
235+
aggregation={"Bookings": "sum", "Pipeline": "sum"},
236+
show_totals=True,
237+
row_layout="hierarchy",
238+
number_format={"Bookings": "$,.0f", "Pipeline": "$,.0f"},
239+
max_height=420,
240+
)
241+
242+
st.divider()
243+
st.subheader("Interactive Layout Switch")
244+
st.markdown(
245+
"""
246+
This final pivot starts in whichever mode you choose below so you can inspect the
247+
same sample data with the exact config parameter that application code would set.
248+
"""
249+
)
250+
251+
selected_layout = st.radio(
252+
"Initial row layout",
253+
options=["table", "hierarchy"],
254+
horizontal=True,
255+
)
256+
selected_layout = cast(Literal["table", "hierarchy"], selected_layout)
257+
258+
st_pivot_table(
259+
sales_df,
260+
key="layout_demo_switchable",
261+
rows=["Use Case", "Product", "Segment", "Customer"],
262+
columns=["Year"],
263+
values=["ARR"],
264+
aggregation="sum",
265+
show_totals=True,
266+
row_layout=selected_layout,
267+
number_format={"ARR": "$,.0f"},
268+
max_height=420,
269+
)
270+
271+
with st.expander("View sample data"):
272+
left, right = st.columns(2)
273+
with left:
274+
st.markdown("**Business hierarchy sample**")
275+
st.dataframe(sales_df, width="stretch")
276+
with right:
277+
st.markdown("**Temporal hierarchy sample**")
278+
st.dataframe(temporal_df, width="stretch")

0 commit comments

Comments
 (0)