Skip to content

Commit

Permalink
fix selection and rename variables for clarity (#25)
Browse files Browse the repository at this point in the history
* rename variables and add comments

The idea is for the row indexes to refer more clearly to the original
data or to the sorted data.

* fix bug

* shorter names

* in -> on

* +s

* fix name

* space
  • Loading branch information
severo authored Jan 13, 2025
1 parent 54b8b74 commit da40bff
Showing 1 changed file with 99 additions and 40 deletions.
139 changes: 99 additions & 40 deletions src/HighTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export { HighTable }

const rowHeight = 33 // row height px

/**
* Mouse event handler for a cell in the table.
* @param event mouse event
* @param col column index
* @param row row index in the data frame
*/
type MouseEventCellHandler = (event: React.MouseEvent, col: number, row: number) => void

interface TableProps {
data: DataFrame
cacheKey?: string // used to persist column widths
Expand All @@ -29,20 +37,23 @@ interface TableProps {
focus?: boolean // focus table on mount? (default true)
tableControl?: TableControl // control the table from outside
selectable?: boolean // enable row selection (default false)
onDoubleClickCell?: (event: React.MouseEvent, col: number, row: number) => void
onMouseDownCell?: (event: React.MouseEvent, col: number, row: number) => void
onDoubleClickCell?: MouseEventCellHandler
onMouseDownCell?: MouseEventCellHandler
onError?: (error: Error) => void
}

/**
* State of the component
*/
type State = {
columnWidths: Array<number | undefined>
invalidate: boolean
hasCompleteRow: boolean
startIndex: number
rows: AsyncRow[]
orderBy?: string
selection: Selection
anchor?: number // anchor row index for selection, the first element when selecting a range
columnWidths: Array<number | undefined> // width of each column
invalidate: boolean // true if the data must be fetched again
hasCompleteRow: boolean // true if at least one row is fully resolved (all of its cells)
rows: AsyncRow[] // slice of the virtual table rows (sorted rows) to render as HTML
startIndex: number // offset of the slice of sorted rows to render (rows[0] is the startIndex'th sorted row)
orderBy?: string // column name to sort by
selection: Selection // rows selection. The values are indexes of the virtual table (sorted rows), and thus depend on the order.
anchor?: number // anchor row used as a reference for shift+click selection. It's a virtual table index (sorted), and thus depends on the order.
}

type Action =
Expand Down Expand Up @@ -74,6 +85,7 @@ function reducer(state: State, action: Action): State {
if (state.orderBy === action.orderBy) {
return state
} else {
// the selection is relative to the order, and must be reset if the order changes
return { ...state, orderBy: action.orderBy, rows: [], selection: [], anchor: undefined }
}
}
Expand Down Expand Up @@ -110,6 +122,14 @@ export default function HighTable({
onMouseDownCell,
onError = console.error,
}: TableProps) {
/**
* The component relies on the model of a virtual table which rows are ordered and only the visible rows are fetched and rendered as HTML <tr> elements.
* We use two reference domains for the rows:
* - data: the index of a row in the original (unsorted) data frame is referred as dataIndex. The mouse event callbacks receive this index.
* - virtual table: the index of a row in the virtual table (sorted) is referred as tableIndex. The selection uses this index, and thus depends on the order.
* startIndex lives in the table domain: it's the first virtual row to be rendered in HTML.
* data.rows(dataIndex, dataIndex + 1) is the same row as data.rows(tableIndex, tableIndex + 1, orderBy)
*/
const [state, dispatch] = useReducer(reducer, initialState)

const { anchor, columnWidths, startIndex, rows, orderBy, invalidate, hasCompleteRow, selection } = state
Expand Down Expand Up @@ -139,7 +159,7 @@ export default function HighTable({
const clientHeight = scrollRef.current?.clientHeight || 100 // view window height
const scrollTop = scrollRef.current?.scrollTop || 0 // scroll position

// determine rows to fetch based on current scroll position
// determine rows to fetch based on current scroll position (indexes refer to the virtual table domain)
const startView = Math.floor(data.numRows * scrollTop / scrollHeight)
const endView = Math.ceil(data.numRows * (scrollTop + clientHeight) / scrollHeight)
const start = Math.max(0, startView - overscan)
Expand All @@ -163,7 +183,7 @@ export default function HighTable({
const rows = asyncRows(unwrapped, end - start, data.header)

const updateRows = throttle(() => {
const resolved = []
const resolved: Row[] = []
let hasCompleteRow = false // true if at least one row is fully resolved
for (const row of rows) {
// Return only resolved values
Expand Down Expand Up @@ -233,21 +253,32 @@ export default function HighTable({
}
}, [tableControl])

const rowLabel = useCallback((rowIndex: number): string => {
// rowIndex + 1 because the displayed row numbers are 1-based
return (rowIndex + 1).toLocaleString()
}, [
// no dependencies, but we could add a setting to allow 0-based row numbers
])

/**
* Validate row length
*/
function rowError(row: Record<string, any>, rowIndex: number): string | undefined {
function rowError(row: Record<string, any>, dataIndex: number): string | undefined {
if (row.length > 0 && row.length !== data.header.length) {
return `Row ${rowIndex + 1} length ${row.length} does not match header length ${data.header.length}`
return `Row ${rowLabel(dataIndex)} length ${row.length} does not match header length ${data.header.length}`
}
}

const memoizedStyles = useMemo(() => columnWidths.map(cellStyle), [columnWidths])

/**
* Render a table cell <td> with title and optional custom rendering
*
* @param value cell value
* @param col column index
* @param row row index in the original (unsorted) data frame
*/
function Cell(value: any, col: number, row: number, rowIndex?: number): ReactNode {
function Cell(value: any, col: number, row: number): ReactNode {
// render as truncated text
let str = stringify(value)
let title: string | undefined
Expand All @@ -258,8 +289,8 @@ export default function HighTable({
return <td
className={str === undefined ? 'pending' : undefined}
key={col}
onDoubleClick={e => onDoubleClickCell?.(e, col, rowIndex ?? row)}
onMouseDown={e => onMouseDownCell?.(e, col, rowIndex ?? row)}
onDoubleClick={e => onDoubleClickCell?.(e, col, row)}
onMouseDown={e => onMouseDownCell?.(e, col, row)}
style={memoizedStyles[col]}
title={title}>
{str}
Expand All @@ -273,24 +304,38 @@ export default function HighTable({
}
}, [focus])

const rowNumber = useCallback((rowIndex: number): number => {
const index = rows[rowIndex].__index__
/**
* Get the row index in original (unsorted) data frame, and in the sorted virtual table.
*
* @param sliceIndex row index in the "rows" slice
*
* @returns an object with two properties:
* dataIndex: row index in the original (unsorted) data frame
* tableIndex: row index in the virtual table (sorted)
*/
const getRowIndexes = useCallback((sliceIndex: number): { dataIndex: number, tableIndex: number } => {
const tableIndex = startIndex + sliceIndex
/// TODO(SL): improve row typing to get __index__ type if sorted
/// Maybe even better to always have an __index__, sorted or not
const index = rows[sliceIndex].__index__
const resolved = typeof index === 'object' ? index.resolved : index
return (resolved ?? rowIndex + startIndex) + 1
/// TODO(SL): improve rows typing
return {
dataIndex: resolved ?? tableIndex, // .__index__ only exists if the rows are sorted. If not sorted, use the table index
tableIndex,
}
}, [rows, startIndex])


const onRowNumberClick = useCallback(({ useAnchor, index }: {useAnchor: boolean, index: number}) => {
const onRowNumberClick = useCallback(({ useAnchor, tableIndex }: {useAnchor: boolean, tableIndex: number}) => {
if (!selectable) return false
if (useAnchor) {
const newSelection = extendFromAnchor({ selection, anchor, index })
const newSelection = extendFromAnchor({ selection, anchor, index: tableIndex })
// did not throw: we can set the anchor (keep the same)
dispatch({ type: 'SET_SELECTION', selection: newSelection, anchor })
} else {
const newSelection = toggleIndex({ selection, index })
const newSelection = toggleIndex({ selection, index: tableIndex })
// did not throw: we can set the anchor
dispatch({ type: 'SET_SELECTION', selection: newSelection, anchor: index })
dispatch({ type: 'SET_SELECTION', selection: newSelection, anchor: tableIndex })
}
}, [selection, anchor])

Expand Down Expand Up @@ -328,31 +373,45 @@ export default function HighTable({
setColumnWidths={columnWidths => dispatch({ type: 'SET_COLUMN_WIDTHS', columnWidths })}
setOrderBy={orderBy => data.sortable && dispatch({ type: 'SET_ORDER', orderBy })} />
<tbody>
{prePadding.map((row, rowIndex) =>
<tr key={startIndex - prePadding.length + rowIndex}>
{prePadding.map((_, prePaddingIndex) => {
const tableIndex = startIndex - prePadding.length + prePaddingIndex
return <tr key={tableIndex}>
<td style={cornerStyle}>
{(startIndex - prePadding.length + rowIndex + 1).toLocaleString()}
{
/// TODO(SL): if the data is sorted, this sequence of row labels is incorrect and might include duplicate
/// labels with respect to the next slice of rows. Better to hide this number if the data is sorted?
rowLabel(tableIndex)
}
</td>
</tr>
)}
{rows.map((row, rowIndex) =>
<tr key={startIndex + rowIndex} title={rowError(row, rowIndex)} className={isSelected({ selection, index: rowNumber(rowIndex) }) ? 'selected' : ''}>
<td style={cornerStyle} onClick={event => onRowNumberClick({ useAnchor: event.shiftKey, index: rowNumber(rowIndex) })}>
<span>{rowNumber(rowIndex).toLocaleString()}</span>
<input type='checkbox' checked={isSelected({ selection, index: rowNumber(rowIndex) })} />
})}
{rows.map((row, sliceIndex) => {
const { tableIndex, dataIndex } = getRowIndexes(sliceIndex)
return <tr key={tableIndex} title={rowError(row, dataIndex)} className={isSelected({ selection, index: tableIndex }) ? 'selected' : ''}>
<td style={cornerStyle} onClick={event => onRowNumberClick({ useAnchor: event.shiftKey, tableIndex })}>
<span>{
/// TODO(SL): we might want to show two columns: one for the tableIndex (for selection) and one for the dataIndex (to refer to the original data ids)
rowLabel(dataIndex)
}</span>
<input type='checkbox' checked={isSelected({ selection, index: tableIndex })} />
</td>
{data.header.map((col, colIndex) =>
Cell(row[col], colIndex, startIndex + rowIndex, rowNumber(rowIndex) - 1)
Cell(row[col], colIndex, dataIndex)
)}
</tr>
)}
{postPadding.map((row, rowIndex) =>
<tr key={startIndex + rows.length + rowIndex}>
})}
{postPadding.map((_, postPaddingIndex) => {
const tableIndex = startIndex + rows.length + postPaddingIndex
return <tr key={tableIndex}>
<td style={cornerStyle}>
{(startIndex + rows.length + rowIndex + 1).toLocaleString()}
{
/// TODO(SL): if the data is sorted, this sequence of row labels is incorrect and might include duplicate
/// labels with respect to the previous slice of rows. Better to hide this number if the data is sorted?
rowLabel(tableIndex)
}
</td>
</tr>
)}
})}
</tbody>
</table>
</div>
Expand Down

0 comments on commit da40bff

Please sign in to comment.