Skip to content

Commit 6c334b8

Browse files
committed
add new logic: viewport/canvas/rows slice/table slice
1 parent ff2643b commit 6c334b8

File tree

9 files changed

+540
-208
lines changed

9 files changed

+540
-208
lines changed

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,7 @@ interface TableProps {
100100
focus?: boolean // focus table on mount? (default true)
101101
maxRowNumber?: number // maximum row number to display (for row headers). Useful for filtered data. If undefined, the number of rows in the data frame is applied.
102102
orderBy?: OrderBy // order by column (if defined, the component order is controlled by the parent)
103-
overscan?: number // number of rows to fetch outside of the viewport (default 20)
104-
padding?: number // number of extra rows to render outside of the viewport (default 20)
103+
padding?: number // number of extra rows to fetch and render outside of the viewport (default 20)
105104
selection?: Selection // selection state (if defined, the component selection is controlled by the parent)
106105
styled?: boolean // use styled component? (default true)
107106
onColumnsVisibilityChange?: (columnVisibilityStates: Record<string, MaybeHiddenColumn>) => void // columns visibility change handler

src/HighTable.module.css

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,23 @@
1717

1818
/* Note that this class cannot be easily overriden by custom CSS. It's not really an issue as its role is functional. */
1919
.table-scroll {
20-
flex: 1;
21-
position: relative;
22-
overflow: auto;
20+
/* flex: 1; */
21+
overflow: auto;
2322
/* avoid the row and column headers (sticky) to overlap the current navigation cell */
2423
scroll-padding-inline-start: var(--row-number-width);
2524
scroll-padding-block-start: var(--column-header-height);
25+
26+
/* the canvas */
27+
& > div {
28+
overflow-y: clip;
29+
/* used by the mock row labels which are positioned absolutely */
30+
position: relative;
31+
}
2632
}
2733
table {
2834
max-width: 100%;
29-
overflow-x: auto;
35+
/* overflow-x: auto; */
36+
3037
}
3138

3239
/* cells */

src/components/HighTable/HighTable.tsx

Lines changed: 85 additions & 202 deletions
Large diffs are not rendered by default.

src/contexts/CanvasSizeContext.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { createContext } from 'react'
2+
3+
/**
4+
* Hook to provide the size of the canvas (the area containing the table slice).
5+
* The canvas height is computed based on the number of rows, the row height and the header height.
6+
* It can also be constrained by optional minimum and maximum heights.
7+
* The canvasRef can be used to attach to the canvas element to measure its size if needed.
8+
*/
9+
interface CanvasSizeContextType {
10+
/* height of the canvas in pixels */
11+
canvasHeight: number
12+
}
13+
14+
// TODO(SL): make min and max height different constants. For now, they are the same, to reduce the moving parts in the app.
15+
export const DEFAULT_MIN_HEIGHT = 10_000
16+
export const DEFAULT_MAX_HEIGHT = 10_000
17+
18+
export const defaultCanvasSizeContext: CanvasSizeContextType = {
19+
canvasHeight: DEFAULT_MAX_HEIGHT,
20+
}
21+
22+
export const CanvasSizeContext = createContext<CanvasSizeContextType>(defaultCanvasSizeContext)

src/contexts/RowsSliceContext.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { createContext } from 'react'
2+
3+
export interface RowsSliceContextType {
4+
// TODO(SL): harmonize the row indexes (with/without header, aria-rowindex, etc.)
5+
/* First row of the slice
6+
* It does not include the header row, and is comprised between 0 and df.numRows - 1.
7+
*/
8+
firstDataRow: number
9+
/* Number of rows in the slice, excluding the header */
10+
numDataRows: number
11+
/* Position of the table in the canvas
12+
* Number of pixels from the top of the canvas to the header
13+
*
14+
* It can be negative, and we hide the extra pixels (canvas must have overflow: hidden,
15+
* or the maths will be wrong).
16+
*/
17+
tableOffset: number
18+
/**
19+
* Go to a specific row in the virtual canvas, trying to minimize the scrolling.
20+
* The scrolling is done with the behavior 'instant'.
21+
* If the row is already fully visible, do nothing.
22+
* If the row is partially visible, scroll just enough to make it fully visible.
23+
* If the row is not visible, scroll to make it the first or last visible row, whichever minimizes scrolling.
24+
* We assume the row height is fixed and less than the viewport height.
25+
* TODO(SL): handle the case where the row height is greater than the viewport height.
26+
* TODO(SL): provide scrolling beahavior 'smooth' instead of 'instant', in the case
27+
* where the row is already in the slice (but not fully visible)? Using scrollIntoView?
28+
* @param rowIndex The row to go to (same semantic as aria-rowindex: 1-based, includes header, see cells navigation)
29+
*/
30+
scrollToRowIndex?: (rowIndex: number) => void
31+
}
32+
33+
export const defaultRowsSliceContext: RowsSliceContextType = {
34+
firstDataRow: 0,
35+
numDataRows: 0,
36+
tableOffset: 0,
37+
}
38+
39+
export const RowsSliceContext = createContext<RowsSliceContextType>(defaultRowsSliceContext)

src/contexts/ViewportContext.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { RefObject } from 'react'
2+
import { createContext } from 'react'
3+
4+
interface ViewportContextType {
5+
viewportRef: RefObject<HTMLDivElement | null>
6+
viewportHeight: number // height of the viewport in pixels
7+
viewportWidth: number // width of the viewport in pixels
8+
scrollTop: number // current scroll position of the viewport
9+
instantScrollTo?: (top: number) => void // function to scroll the viewport to a specific position, with 'instant' behavior
10+
}
11+
12+
export const defaultViewportContext: ViewportContextType = {
13+
viewportRef: { current: null },
14+
viewportHeight: 0,
15+
viewportWidth: 0,
16+
scrollTop: 0,
17+
}
18+
19+
export const ViewportContext = createContext<ViewportContextType>(defaultViewportContext)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { ReactNode } from 'react'
2+
3+
import { CanvasSizeContext, DEFAULT_MAX_HEIGHT, DEFAULT_MIN_HEIGHT } from '../contexts/CanvasSizeContext'
4+
5+
interface CanvasSizeProviderProps {
6+
/* children components */
7+
children: ReactNode
8+
/* number of rows in the canvas */
9+
numRows: number
10+
/* height of each row in pixels. We assume all the rows have the same height. */
11+
rowHeight: number
12+
/* header height in pixels. Defaults to rowHeight. */
13+
headerHeight?: number
14+
/* optional minimum height of the canvas in pixels. The default is 0 */
15+
minHeight?: number
16+
/* optional maximum height of the canvas in pixels. The default is 1M pixels - see https://meyerweb.com/eric/thoughts/2025/08/07/infinite-pixels/ */
17+
maxHeight?: number
18+
}
19+
20+
export function CanvasSizeProvider({ children, numRows, rowHeight, headerHeight, minHeight, maxHeight }: CanvasSizeProviderProps) {
21+
const height = numRows * rowHeight + (headerHeight ?? rowHeight)
22+
const canvasHeight = Math.min(Math.max(height, minHeight ?? DEFAULT_MIN_HEIGHT), maxHeight ?? DEFAULT_MAX_HEIGHT)
23+
24+
return (
25+
<CanvasSizeContext.Provider value={{ canvasHeight }}>
26+
{children}
27+
</CanvasSizeContext.Provider>
28+
)
29+
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { type ReactNode, useCallback, useContext, useState } from 'react'
2+
3+
import { CanvasSizeContext } from '../contexts/CanvasSizeContext'
4+
import { defaultRowsSliceContext, RowsSliceContext } from '../contexts/RowsSliceContext'
5+
import { ViewportContext } from '../contexts/ViewportContext'
6+
7+
interface RowsSliceProviderProps {
8+
children: ReactNode
9+
numRows: number
10+
headerHeight: number
11+
rowHeight: number
12+
padding: number
13+
}
14+
15+
export function RowsSliceProvider({ children, numRows, headerHeight, rowHeight, padding }: RowsSliceProviderProps) {
16+
const { canvasHeight } = useContext(CanvasSizeContext)
17+
const { viewportHeight, scrollTop, instantScrollTo } = useContext(ViewportContext)
18+
19+
if (scrollTop < 0) {
20+
throw new Error(`Invalid scrollTop: ${scrollTop}. It should be a non-negative number.`)
21+
}
22+
if (scrollTop > canvasHeight - viewportHeight) {
23+
throw new Error(`Invalid scrollTop: ${scrollTop}. It should be less than or equal to canvasHeight - viewportHeight (${canvasHeight - viewportHeight}).`)
24+
}
25+
26+
const virtualCanvasHeight = headerHeight + numRows * rowHeight
27+
28+
const toVirtualScrollTop = useCallback((scrollTop: number) => {
29+
// convert scrollTop (in canvas coordinates, between 0px and canvasHeight - viewportHeight)
30+
// to virtualScrollTop (in virtual canvas coordinates, between 0px and headerHeight + numRows * rowHeight - viewportHeight)
31+
return scrollTop * (virtualCanvasHeight - viewportHeight) / (canvasHeight - viewportHeight)
32+
}, [virtualCanvasHeight, canvasHeight, viewportHeight])
33+
34+
const toScrollTop = useCallback((virtualScrollTop: number) => {
35+
// convert virtualScrollTop (in virtual canvas coordinates, between 0px and headerHeight + numRows * rowHeight - viewportHeight)
36+
// to scrollTop (in canvas coordinates, between 0px and canvasHeight - viewportHeight)
37+
return virtualScrollTop * (canvasHeight - viewportHeight) / (virtualCanvasHeight - viewportHeight)
38+
}, [virtualCanvasHeight, canvasHeight, viewportHeight])
39+
40+
// scrollTop in the virtual canvas coordinates
41+
const [virtualScrollTop, setVirtualScrollTop] = useState(0)
42+
43+
// sync virtualScrollTop with scrollTop
44+
const tolerancePixels = 1
45+
if (Math.abs(scrollTop - toScrollTop(virtualScrollTop)) > tolerancePixels) {
46+
setVirtualScrollTop(toVirtualScrollTop(scrollTop))
47+
}
48+
49+
// scrollTop has a limited precision (1px, or subpixel on some browsers) and is not predictable exactly, in particular when used with zooming.
50+
// preciseScrollTop is decimal, computed for the virtual canvas, and updated by user action only when scrollTop changes significantly.
51+
// The scroll changed significantly, update preciseScrollTop
52+
// sync virtualScrollTop with scrollTop, limiting to valid range
53+
54+
// safety checks
55+
if (rowHeight <= 0) {
56+
throw new Error(`Invalid rowHeight: ${rowHeight}. It should be a positive number.`)
57+
}
58+
if (headerHeight <= 0) {
59+
throw new Error(`Invalid headerHeight: ${headerHeight}. It should be a positive number.`)
60+
}
61+
if (canvasHeight <= 0) {
62+
throw new Error(`Invalid canvasHeight: ${canvasHeight}. It should be a positive number.`)
63+
}
64+
if (numRows < 0 || !Number.isInteger(numRows)) {
65+
throw new Error(`Invalid numRows: ${numRows}. It should be a non-negative integer.`)
66+
}
67+
if (padding < 0 || !Number.isInteger(padding)) {
68+
throw new Error(`Invalid padding: ${padding}. It should be a non-negative integer.`)
69+
}
70+
71+
// Compute the derived values
72+
// TODO(SL): memoize?
73+
74+
// special cases
75+
const isInHeader = virtualScrollTop < headerHeight
76+
77+
// a. first visible row (r, d). It can be the header row (0).
78+
const firstVisibleRow = isInHeader
79+
? 0
80+
: Math.max(0,
81+
Math.min(numRows - 1,
82+
Math.floor((virtualScrollTop - headerHeight) / rowHeight)
83+
)
84+
)
85+
// hidden pixels in the first visible row, or header
86+
const hiddenPixelsBefore = isInHeader
87+
? virtualScrollTop
88+
: virtualScrollTop - headerHeight - firstVisibleRow * rowHeight
89+
90+
// b. last visible row (s, e)
91+
const lastVisibleRow = Math.max(firstVisibleRow,
92+
Math.min(numRows - 1,
93+
Math.floor((virtualScrollTop + viewportHeight - headerHeight) / rowHeight)
94+
)
95+
)
96+
// const hiddenPixelsAfter = headerHeight + (lastVisibleRow + 1) * rowHeight - (virtualScrollTop + viewportHeight)
97+
98+
// c. previous rows (k)
99+
const previousRows = Math.max(0, Math.min(padding, firstVisibleRow))
100+
101+
// d. following rows (l)
102+
const followingRows = Math.max(0, Math.min(padding, numRows - 1 - lastVisibleRow))
103+
104+
// e. offset of the first row in the canvas (u)
105+
const tableOffset = isInHeader
106+
? scrollTop - hiddenPixelsBefore
107+
: scrollTop - headerHeight - previousRows * rowHeight - hiddenPixelsBefore
108+
109+
// f. first data row and number of data rows
110+
const firstDataRow = firstVisibleRow - previousRows
111+
const numDataRows = previousRows + followingRows + lastVisibleRow - firstVisibleRow + 1
112+
113+
/**
114+
* Programmatically scroll to a specific row if needed.
115+
* Beware:
116+
* - row 1: header
117+
* - row 2: first data row
118+
* - row numRows + 1: last data row
119+
* @param row The row to scroll to (same semantic as aria-rowindex: 1-based, includes header)
120+
*/
121+
const scrollToRowIndex = useCallback((rowIndex: number) => {
122+
if (rowIndex < 1 || rowIndex > numRows + 2 || !Number.isInteger(rowIndex)) {
123+
throw new Error(`Invalid first visible row index: ${rowIndex}. It should be an integer between 1 and ${numRows + 2}.`)
124+
}
125+
if (!instantScrollTo) {
126+
console.warn('instantScrollTo function is not available. Cannot scroll to row.')
127+
return
128+
}
129+
130+
if (rowIndex === 1) {
131+
// header row
132+
setVirtualScrollTop(0)
133+
instantScrollTo(0)
134+
return
135+
}
136+
137+
const row = rowIndex - 2 // convert to 0-based data row index
138+
139+
// Three cases:
140+
// - the row is fully visible: do nothing
141+
// - the row start is before virtualScrollTop + headerHeight: scroll to snap its start with that value
142+
// - the row end is after virtualScrollTop + viewportHeight: scroll to snap its end with that value
143+
const hiddenPixelsBefore = virtualScrollTop - (headerHeight + row * rowHeight)
144+
const hiddenPixelsAfter = headerHeight + row * rowHeight + rowHeight - virtualScrollTop - viewportHeight
145+
146+
if (hiddenPixelsBefore <= 0 && hiddenPixelsAfter <= 0) {
147+
// fully visible, do nothing
148+
return
149+
}
150+
151+
// partly or totally hidden: update the scroll position
152+
const newVirtualScrollTop = virtualScrollTop + (hiddenPixelsBefore > 0 ? -hiddenPixelsBefore : hiddenPixelsAfter)
153+
154+
const newScrollTop = toScrollTop(newVirtualScrollTop)
155+
156+
// Ensure the new scrollTop is within bounds
157+
if (newScrollTop < 0 || newScrollTop > canvasHeight - viewportHeight) {
158+
console.warn(`Computed scrollTop ${newScrollTop} is out of bounds (0, ${canvasHeight - viewportHeight}). Cannot scroll to table row index: ${rowIndex}.`)
159+
return
160+
}
161+
162+
if (Math.abs(newScrollTop - scrollTop) > tolerancePixels) {
163+
// Update the coarse scroll position if the change is significant enough
164+
// for now, we ask for an instant scroll, there is no smooth scrolling
165+
// TODO(SL): if smooth scrolling is implemented, it might be async, so we should await it
166+
instantScrollTo(newScrollTop)
167+
} else {
168+
// Update the virtual scroll top
169+
setVirtualScrollTop(newVirtualScrollTop)
170+
}
171+
}, [numRows, scrollTop, virtualScrollTop, headerHeight, rowHeight, viewportHeight, toScrollTop, instantScrollTo, canvasHeight])
172+
173+
// Note: we don't change the scroll position if numRows or viewportHeight change, we just adapt to the new situation.
174+
// TODO(SL): is this the desired behavior? We might try to keep the same first visible row if possible.
175+
// Also: we consider that headerHeight, rowHeight, canvasHeight and padding are fixed.
176+
177+
// Don't check further if viewportHeight is zero or negative
178+
if (viewportHeight <= 0) {
179+
return (
180+
<RowsSliceContext.Provider value={defaultRowsSliceContext}>
181+
{children}
182+
</RowsSliceContext.Provider>
183+
)
184+
}
185+
186+
const context = {
187+
firstDataRow,
188+
numDataRows,
189+
tableOffset,
190+
scrollToRowIndex,
191+
}
192+
193+
// Checks
194+
// TODO(SL): investigate if these cases can occur in practice, and handle them properly instead of throwing errors.
195+
if (firstVisibleRow < 0 || firstVisibleRow > numRows + 1) {
196+
throw new Error(`Invalid first visible row: ${firstVisibleRow}. It should be between 0 and ${numRows + 1}.`)
197+
}
198+
if (isInHeader) {
199+
if (hiddenPixelsBefore < 0 || hiddenPixelsBefore >= headerHeight) {
200+
throw new Error(`Invalid hidden pixels before: ${hiddenPixelsBefore}. It should be positive and less than ${headerHeight} because the first hidden row is the header.`)
201+
}
202+
} else {
203+
if (hiddenPixelsBefore < 0 || hiddenPixelsBefore >= rowHeight) {
204+
throw new Error(`Invalid hidden pixels before: ${hiddenPixelsBefore}. It should be positive and less than ${rowHeight}.`)
205+
}
206+
}
207+
if (lastVisibleRow < firstVisibleRow || lastVisibleRow > numRows + 1) {
208+
throw new Error(`Invalid last visible row: ${lastVisibleRow}. It should be between firstVisibleRow (${firstVisibleRow}) and ${numRows + 1}.`)
209+
}
210+
// if (hiddenPixelsAfter < 0 || hiddenPixelsAfter > rowHeight) {
211+
// throw new Error(`Invalid hidden pixels after: ${hiddenPixelsAfter}. It should be positive and less than or equal to ${rowHeight} (we might have a subpixel approximation).`)
212+
// }
213+
if (previousRows < 0 || previousRows > padding || previousRows > firstVisibleRow || previousRows > numRows) {
214+
throw new Error(`Invalid previous rows: ${previousRows}. It should be between 0 and min(padding (${padding}), firstVisibleRow (${firstVisibleRow}), numRows (${numRows})).`)
215+
}
216+
if (followingRows < 0 || followingRows > padding || followingRows > numRows) {
217+
throw new Error(`Invalid following rows: ${followingRows}. It should be between 0 and min(padding (${padding}), numRows (${numRows})).`)
218+
}
219+
220+
if (firstDataRow < 0 || firstDataRow >= numRows) {
221+
throw new Error(`Invalid first data row: ${firstDataRow}. It should be between 0 and ${numRows - 1}.`)
222+
}
223+
if (numDataRows < 0 || firstDataRow + numDataRows > numRows) {
224+
throw new Error(`Invalid number of data rows: ${numDataRows}. firstDataRow + numDataRows should be less than or equal to ${numRows}.`)
225+
}
226+
if (tableOffset > scrollTop) {
227+
throw new Error(`Invalid table offset: ${tableOffset}. It should be less than or equal to scrollTop (${scrollTop}).`)
228+
}
229+
230+
return (
231+
<RowsSliceContext.Provider value={context}>
232+
{children}
233+
</RowsSliceContext.Provider>
234+
)
235+
}

0 commit comments

Comments
 (0)