Skip to content

Commit dd16d1b

Browse files
committed
feat(frontend): wide-column support with raised cardinality cap and horizontal overscan
Raises maxColumnCardinality from 200 to 1000 with a feature flag (wideColumnMode) that controls whether the legacy 200-column truncation or the new virtualization-first approach is used. Adds enhanced horizontal overscan (min 6 columns) for tables with 200+ columns to reduce flicker during horizontal scrolling. Made-with: Cursor
1 parent d510d83 commit dd16d1b

7 files changed

Lines changed: 178 additions & 30 deletions

File tree

streamlit_pivot/frontend/src/engine/PivotData.bench.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,10 @@ describe("PivotData computation benchmarks", () => {
110110
bench("stress dataset (200K rows, 500x20 grid)", () => {
111111
new PivotData(stress.records, stress.config);
112112
});
113+
114+
// Wide columns: 50,000 rows, 20 regions, 1,000 years -> 20,000 unique cells
115+
const wide = generateRecords(50000, 20, 1000);
116+
bench("wide columns (50K rows, 20x1000 grid)", () => {
117+
new PivotData(wide.records, wide.config);
118+
});
113119
});

streamlit_pivot/frontend/src/engine/perf.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe("DEFAULT_BUDGETS", () => {
3636
it("matches expected values", () => {
3737
expect(DEFAULT_BUDGETS).toMatchInlineSnapshot(`
3838
{
39-
"maxColumnCardinality": 200,
39+
"maxColumnCardinality": 1000,
4040
"maxComputeMs": 500,
4141
"maxRenderMs": 200,
4242
"maxVisibleCells": 5000,
@@ -78,7 +78,7 @@ describe("checkBudgets", () => {
7878
});
7979

8080
it("warns when column cardinality exceeds cap", () => {
81-
const warnings = checkBudgets({ ...okMetrics, totalCols: 250 });
81+
const warnings = checkBudgets({ ...okMetrics, totalCols: 1200 });
8282
expect(warnings).toHaveLength(1);
8383
expect(warnings[0]).toContain("column values exceed cardinality cap");
8484
});
@@ -88,7 +88,7 @@ describe("checkBudgets", () => {
8888
pivotComputeMs: 800,
8989
renderMs: 400,
9090
totalRows: 5000,
91-
totalCols: 300,
91+
totalCols: 1200,
9292
totalCells: 10000,
9393
};
9494
const warnings = checkBudgets(badMetrics);

streamlit_pivot/frontend/src/engine/perf.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,26 @@ export interface PerfBudgets {
6060
maxRenderMs: number;
6161
/** Max visible cells before virtualization activates */
6262
maxVisibleCells: number;
63-
/** Max unique column values before truncation */
63+
/** Max unique column values before truncation (wide-column mode cap) */
6464
maxColumnCardinality: number;
6565
}
6666

67+
/** Column count above which horizontal windowing is preferred (legacy cap when wideColumnMode is off). */
68+
export const COLUMN_VIRTUALIZATION_THRESHOLD = 200;
69+
70+
/** Hard cap when {@link FEATURE_FLAGS.wideColumnMode} is false (backward compatible). */
71+
export const LEGACY_MAX_COLUMN_CARDINALITY = 200;
72+
73+
export const FEATURE_FLAGS = {
74+
/** When false, column cardinality uses {@link LEGACY_MAX_COLUMN_CARDINALITY} (200). */
75+
wideColumnMode: true,
76+
};
77+
6778
export const DEFAULT_BUDGETS: PerfBudgets = {
6879
maxComputeMs: 500,
6980
maxRenderMs: 200,
7081
maxVisibleCells: 5000,
71-
maxColumnCardinality: 200,
82+
maxColumnCardinality: 1000,
7283
};
7384

7485
/**

streamlit_pivot/frontend/src/renderers/VirtualizedTableRenderer.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,27 @@ describe("VirtualizedTableRenderer", () => {
163163
expect(cellText).not.toMatch(/^R\d+$/);
164164
});
165165

166+
it("wide column counts stay DOM-bounded (500+ columns)", () => {
167+
const records = makeRecords(4, 600);
168+
const config = makeConfig({ show_totals: false });
169+
const pivotData = new PivotData(records, config);
170+
171+
render(
172+
<VirtualizedTableRenderer
173+
pivotData={pivotData}
174+
config={config}
175+
containerHeight={400}
176+
columnWidth={120}
177+
/>,
178+
);
179+
180+
const dataRows = screen.getAllByTestId("pivot-data-row");
181+
const dataCells = screen.getAllByTestId("pivot-data-cell");
182+
const cellsPerRow = dataCells.length / dataRows.length;
183+
expect(cellsPerRow).toBeLessThan(600);
184+
expect(dataCells.length).toBeLessThan(600 * dataRows.length);
185+
});
186+
166187
it("column windowing with multiple row dims still renders all row header columns", () => {
167188
// Create records with 2 row dimensions
168189
const records: DataRecord[] = [];

streamlit_pivot/frontend/src/shared/VirtualScroll.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
useEffect,
2525
useMemo,
2626
} from "react";
27+
import { COLUMN_VIRTUALIZATION_THRESHOLD } from "../engine/perf";
2728

2829
export interface VirtualScrollProps {
2930
totalRows: number;
@@ -98,6 +99,14 @@ const VirtualScroll: FC<VirtualScrollProps> = ({
9899
const totalContentWidth = totalColumns * columnWidth;
99100
const viewportWidth = measuredWidth || totalContentWidth;
100101

102+
const effectiveOverscanColumns = useMemo(
103+
() =>
104+
totalColumns > COLUMN_VIRTUALIZATION_THRESHOLD
105+
? Math.max(overscanColumns, 6)
106+
: overscanColumns,
107+
[totalColumns, overscanColumns],
108+
);
109+
101110
const { startRow, endRow } = useMemo(() => {
102111
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscanRows);
103112
const visibleCount = Math.ceil(bodyHeight / rowHeight);
@@ -108,15 +117,21 @@ const VirtualScroll: FC<VirtualScrollProps> = ({
108117
const { startCol, endCol } = useMemo(() => {
109118
const start = Math.max(
110119
0,
111-
Math.floor(scrollLeft / columnWidth) - overscanColumns,
120+
Math.floor(scrollLeft / columnWidth) - effectiveOverscanColumns,
112121
);
113122
const visibleCount = Math.ceil(viewportWidth / columnWidth);
114123
const end = Math.min(
115124
totalColumns,
116-
start + visibleCount + overscanColumns * 2,
125+
start + visibleCount + effectiveOverscanColumns * 2,
117126
);
118127
return { startCol: start, endCol: end };
119-
}, [scrollLeft, columnWidth, viewportWidth, totalColumns, overscanColumns]);
128+
}, [
129+
scrollLeft,
130+
columnWidth,
131+
viewportWidth,
132+
totalColumns,
133+
effectiveOverscanColumns,
134+
]);
120135

121136
const colRange: [number, number] = [startCol, endCol];
122137

streamlit_pivot/frontend/src/shared/budgetCheck.test.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,24 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { describe, expect, it } from "vitest";
18+
import { afterEach, describe, expect, it } from "vitest";
1919
import { checkRenderBudget } from "./budgetCheck";
20-
import { DEFAULT_BUDGETS } from "../engine/perf";
20+
import {
21+
DEFAULT_BUDGETS,
22+
FEATURE_FLAGS,
23+
LEGACY_MAX_COLUMN_CARDINALITY,
24+
} from "../engine/perf";
2125

2226
describe("checkRenderBudget", () => {
27+
afterEach(() => {
28+
FEATURE_FLAGS.wideColumnMode = true;
29+
});
30+
2331
it("returns no warnings for small pivot", () => {
2432
const result = checkRenderBudget(10, 5, 1);
2533
expect(result.needsVirtualization).toBe(false);
2634
expect(result.columnsTruncated).toBe(false);
35+
expect(result.needsColumnVirtualization).toBe(false);
2736
expect(result.warnings).toHaveLength(0);
2837
});
2938

@@ -38,8 +47,8 @@ describe("checkRenderBudget", () => {
3847
expect(result.warnings.length).toBeGreaterThan(0);
3948
});
4049

41-
it("truncates columns when cardinality exceeds limit", () => {
42-
const result = checkRenderBudget(10, 300, 1);
50+
it("truncates columns when cardinality exceeds limit (wideColumnMode on)", () => {
51+
const result = checkRenderBudget(10, 1500, 1);
4352
expect(result.columnsTruncated).toBe(true);
4453
expect(result.truncatedColumnCount).toBe(
4554
DEFAULT_BUDGETS.maxColumnCardinality,
@@ -49,6 +58,23 @@ describe("checkRenderBudget", () => {
4958
);
5059
});
5160

61+
it("does not truncate at 500 columns when wideColumnMode is true", () => {
62+
const result = checkRenderBudget(10, 500, 1);
63+
expect(result.columnsTruncated).toBe(false);
64+
expect(result.truncatedColumnCount).toBe(500);
65+
expect(result.needsColumnVirtualization).toBe(true);
66+
});
67+
68+
it("truncates at 200 columns when wideColumnMode is false (backward compat)", () => {
69+
FEATURE_FLAGS.wideColumnMode = false;
70+
const result = checkRenderBudget(10, 500, 1);
71+
expect(result.columnsTruncated).toBe(true);
72+
expect(result.truncatedColumnCount).toBe(LEGACY_MAX_COLUMN_CARDINALITY);
73+
expect(result.warnings.some((w) => w.includes("Column cardinality"))).toBe(
74+
true,
75+
);
76+
});
77+
5278
it("accounts for multiple values in cell count", () => {
5379
const result = checkRenderBudget(100, 10, 5);
5480
expect(result.needsVirtualization).toBe(false);

streamlit_pivot/frontend/src/shared/budgetCheck.ts

Lines changed: 87 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,33 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { DEFAULT_BUDGETS } from "../engine/perf";
18+
import {
19+
COLUMN_VIRTUALIZATION_THRESHOLD,
20+
DEFAULT_BUDGETS,
21+
FEATURE_FLAGS,
22+
LEGACY_MAX_COLUMN_CARDINALITY,
23+
} from "../engine/perf";
1924

2025
export interface BudgetResult {
2126
needsVirtualization: boolean;
27+
/** True when column count exceeds the threshold where horizontal windowing is recommended. */
28+
needsColumnVirtualization: boolean;
2229
columnsTruncated: boolean;
2330
truncatedColumnCount: number;
2431
warnings: string[];
2532
}
2633

34+
function exceedsCellBudget(
35+
rowCount: number,
36+
colCount: number,
37+
valueCount: number,
38+
): boolean {
39+
return (
40+
rowCount * colCount * Math.max(valueCount, 1) >
41+
DEFAULT_BUDGETS.maxVisibleCells
42+
);
43+
}
44+
2745
/**
2846
* Check whether the pivot dimensions exceed rendering budgets.
2947
* Returns whether virtualization is needed and any warning messages.
@@ -34,32 +52,83 @@ export function checkRenderBudget(
3452
valueCount: number,
3553
): BudgetResult {
3654
const warnings: string[] = [];
37-
let needsVirtualization = false;
38-
let columnsTruncated = false;
39-
let truncatedColumnCount = colCount;
55+
const needsColumnVirtualization = colCount > COLUMN_VIRTUALIZATION_THRESHOLD;
56+
57+
if (!FEATURE_FLAGS.wideColumnMode) {
58+
let columnsTruncated = false;
59+
let truncatedColumnCount = colCount;
60+
61+
if (colCount > LEGACY_MAX_COLUMN_CARDINALITY) {
62+
columnsTruncated = true;
63+
truncatedColumnCount = LEGACY_MAX_COLUMN_CARDINALITY;
64+
warnings.push(
65+
`Column cardinality (${colCount}) exceeds limit (${LEGACY_MAX_COLUMN_CARDINALITY}). ` +
66+
`Showing first ${LEGACY_MAX_COLUMN_CARDINALITY} columns.`,
67+
);
68+
}
4069

41-
if (colCount > DEFAULT_BUDGETS.maxColumnCardinality) {
42-
columnsTruncated = true;
43-
truncatedColumnCount = DEFAULT_BUDGETS.maxColumnCardinality;
44-
warnings.push(
45-
`Column cardinality (${colCount}) exceeds limit (${DEFAULT_BUDGETS.maxColumnCardinality}). ` +
46-
`Showing first ${DEFAULT_BUDGETS.maxColumnCardinality} columns.`,
70+
const needsVirtualization = exceedsCellBudget(
71+
rowCount,
72+
truncatedColumnCount,
73+
valueCount,
4774
);
75+
76+
if (needsVirtualization) {
77+
warnings.push(
78+
`Total cells (${(rowCount * truncatedColumnCount * Math.max(valueCount, 1)).toLocaleString()}) exceeds DOM budget ` +
79+
`(${DEFAULT_BUDGETS.maxVisibleCells.toLocaleString()}). Virtualization enabled.`,
80+
);
81+
}
82+
83+
return {
84+
needsVirtualization,
85+
needsColumnVirtualization,
86+
columnsTruncated,
87+
truncatedColumnCount,
88+
warnings,
89+
};
4890
}
4991

50-
const effectiveCells =
51-
rowCount * truncatedColumnCount * Math.max(valueCount, 1);
92+
const maxColCap = DEFAULT_BUDGETS.maxColumnCardinality;
93+
const colsAfterHardCap = Math.min(colCount, maxColCap);
94+
const exceedsHardCap = colCount > maxColCap;
5295

53-
if (effectiveCells > DEFAULT_BUDGETS.maxVisibleCells) {
54-
needsVirtualization = true;
55-
warnings.push(
56-
`Total cells (${effectiveCells.toLocaleString()}) exceeds DOM budget ` +
57-
`(${DEFAULT_BUDGETS.maxVisibleCells.toLocaleString()}). Virtualization enabled.`,
58-
);
96+
const needsVirtualizationFromCells = exceedsCellBudget(
97+
rowCount,
98+
colsAfterHardCap,
99+
valueCount,
100+
);
101+
const needsVirtualization =
102+
needsVirtualizationFromCells || needsColumnVirtualization;
103+
104+
let columnsTruncated = false;
105+
let truncatedColumnCount = colCount;
106+
107+
if (needsVirtualization) {
108+
truncatedColumnCount = colsAfterHardCap;
109+
columnsTruncated = exceedsHardCap;
110+
111+
if (exceedsHardCap) {
112+
warnings.push(
113+
`Column cardinality (${colCount}) exceeds limit (${maxColCap}). ` +
114+
`Showing first ${maxColCap} columns.`,
115+
);
116+
}
117+
118+
if (needsVirtualizationFromCells) {
119+
warnings.push(
120+
`Total cells (${(rowCount * colsAfterHardCap * Math.max(valueCount, 1)).toLocaleString()}) exceeds DOM budget ` +
121+
`(${DEFAULT_BUDGETS.maxVisibleCells.toLocaleString()}). Virtualization enabled.`,
122+
);
123+
}
124+
} else {
125+
truncatedColumnCount = colCount;
126+
columnsTruncated = false;
59127
}
60128

61129
return {
62130
needsVirtualization,
131+
needsColumnVirtualization,
63132
columnsTruncated,
64133
truncatedColumnCount,
65134
warnings,

0 commit comments

Comments
 (0)