Line data Source code
1 : import * as arrow from 'apache-arrow';
2 : import * as React from 'react';
3 : import * as styles from './data_table.module.css';
4 :
5 : import { Grid, useGridCallbackRef } from 'react-window';
6 :
7 : import { arrowValueToJson, ArrowTableFormatter, isArrowStructuredType } from '../../compute/arrow_formatter.js';
8 : import { ComputationAction, TableComputationState } from '../../compute/computation_state.js';
9 : import { Dispatch } from '../../utils/variant.js';
10 : import { DataCell, DataCellData, TableColumnHeader } from './data_table_cell.js';
11 : import { classNames } from '../../utils/classnames.js';
12 : import { computeTableLayout, DataTableLayout } from './data_table_layout.js';
13 : import { useCrossFilters } from './use_cross_filters.js';
14 : import { observeSize } from '../../ui/foundations/size_observer.js';
15 : import { CellDetailOverlay } from './cell_detail_overlay.js';
16 : import { useAppConfig } from '../../config/app_config.js';
17 : import { useLogger } from '../../platform/logger/logger_provider.js';
18 : import { useScrollbarHeight } from '../../utils/scrollbar.js';
19 : import { useDataTableOrdering } from './data_table_ordering.js';
20 : import { useDataTablePortalContainers } from './data_table_portals.js';
21 : import { ResizedColumnWidths, useDataTableColumnResize } from './data_table_column_resize.js';
22 : import { DataTableStickyColumn, DataTableStickyHeaders } from './data_table_sticky_views.js';
23 : import { DataTableFocusStore } from './data_table_focus.js';
24 : import { resolveSearchedRowIndices } from './visible_rows.js';
25 : import { collectSearchableResultColumns } from '../../compute/sql/query_result_search_sql.js';
26 :
27 : const LOG_CTX = 'data_table';
28 :
29 : interface Props {
30 : className?: string;
31 : table: TableComputationState;
32 : dispatchComputation: Dispatch<ComputationAction>;
33 : debugMode: boolean;
34 : maxRows?: number;
35 : columnHeader?: TableColumnHeader;
36 : cellBackground?: string;
37 : onShowTable?: () => void;
38 : fitHeight?: boolean;
39 : /// The grid takes the height of its host however few rows it shows, the last of `maxRows` may be cut off
40 : fillHeight?: boolean;
41 : maxHeight?: number;
42 : compact?: boolean;
43 : }
44 :
45 1 : const MIN_GRID_HEIGHT = 200;
46 1 : const MIN_GRID_WIDTH = 100;
47 1 : const COLUMN_HEADER_HEIGHT = 32;
48 1 : const COLUMN_HEADER_PLOTS_HEIGHT = 76;
49 : /// The row of the column plots shrinks to a strip that still holds its toggle
50 1 : const COLUMN_HEADER_PLOTS_COLLAPSED_HEIGHT = 18;
51 1 : const ROW_HEIGHT = 26;
52 1 : const GRID_OVERSCAN_COUNT = 10;
53 1 : const NOOP_BRUSHING = () => { };
54 :
55 1 : export function computeGridHeight(
56 1 : baseRowCount: number,
57 1 : visibleRowCount: number,
58 1 : headerHeight: number,
59 1 : scrollbarHeight: number,
60 1 : fitHeight: boolean,
61 1 : maxHeight?: number,
62 1 : measuredHeight: number = 0,
63 1 : maxRows?: number,
64 1 : ): number {
65 1 : const contentHeight = headerHeight + visibleRowCount * ROW_HEIGHT + scrollbarHeight;
66 1 : if (fitHeight) {
67 1 : const fittedRowCount = maxRows == null ? baseRowCount : Math.min(baseRowCount, maxRows);
68 1 : return Math.min(
69 1 : headerHeight + fittedRowCount * ROW_HEIGHT + scrollbarHeight,
70 1 : maxHeight ?? Number.POSITIVE_INFINITY,
71 1 : );
72 : }
73 1 : return maxRows != null ? contentHeight : Math.max(measuredHeight, MIN_GRID_HEIGHT);
74 1 : }
75 :
76 : /// The rows of a table with a column header of names that show in a height, at least one. The last row may be cut
77 : /// off, the table then ends at the bottom of the height and not a fraction of a row above it. A horizontal scrollbar
78 : /// covers the bottom of the rows.
79 1 : export function fittingRowCount(height: number): number {
80 1 : return Math.max(1, Math.ceil((height - COLUMN_HEADER_HEIGHT) / ROW_HEIGHT));
81 1 : }
82 :
83 : interface RenderedCells {
84 : rowStart: number;
85 : rowStop: number;
86 : columnStart: number;
87 : columnStop: number;
88 : }
89 :
90 1 : export const DataTable = React.memo((props: Props) => {
91 1 : const config = useAppConfig();
92 1 : const logger = useLogger();
93 1 : const dispatchComputation = props.dispatchComputation;
94 1 : const computationState = props.table;
95 1 : const dataTable = computationState.dataTable;
96 1 : const [gridApi, setGridApi] = useGridCallbackRef(null);
97 1 : const gridContainerElement = React.useRef(null);
98 1 : const gridContainerSize = observeSize(gridContainerElement);
99 1 : const gridContainerWidth = Math.max(gridContainerSize?.width ?? 0, MIN_GRID_WIDTH);
100 1 : const columnHeader = props.columnHeader ?? ((config?.settings?.enableTableColumnPlots ?? false)
101 0 : ? TableColumnHeader.WithColumnPlots
102 0 : : TableColumnHeader.OnlyColumnName);
103 1 : const [plotsCollapsed, setPlotsCollapsed] = React.useState(false);
104 1 : const togglePlots = React.useCallback(() => setPlotsCollapsed(c => !c), []);
105 1 : const plotsHeight = plotsCollapsed ? COLUMN_HEADER_PLOTS_COLLAPSED_HEIGHT : COLUMN_HEADER_PLOTS_HEIGHT;
106 :
107 : // Get the row-id indirection column from ordering or filtering
108 1 : const visibleRowIndices = React.useMemo(
109 1 : () => resolveSearchedRowIndices(computationState),
110 1 : [computationState.orderingTable, computationState.filterTable, computationState.dataSearchTable, computationState.dataSearch.matchingRows],
111 1 : );
112 :
113 : // Data row count. Headers are rendered separately via portals
114 : // When an indirection table is active, show only the derived visible rows
115 1 : const totalRowCount = visibleRowIndices?.length ?? dataTable.numRows ?? 0;
116 1 : const dataRowCount = props.maxRows != null ? Math.min(totalRowCount, props.maxRows) : totalRowCount;
117 : // Header configuration
118 1 : const headerRowCount = columnHeader === TableColumnHeader.WithColumnPlots ? 2 : 1;
119 1 : const visibleColumnGroups = React.useMemo(() => {
120 1 : const matches = computationState.columnSearch.matchingColumnGroups;
121 1 : return matches == null ? null : new Set(matches);
122 1 : }, [computationState.columnSearch.matchingColumnGroups]);
123 1 : const searchColumnIndexByGroup = React.useMemo(() => new Map(
124 1 : collectSearchableResultColumns(computationState.columnGroups)
125 1 : .map(column => [column.columnGroupIdx, column.columnIdx] as const),
126 1 : ), [computationState.columnGroups]);
127 :
128 : // Construct the arrow formatter and update it whenever the data table changes
129 1 : const tableFormatter = React.useMemo(() => {
130 1 : return new ArrowTableFormatter(dataTable.schema, dataTable.batches, logger);
131 1 : }, [dataTable]);
132 :
133 : // Columns that the user resized by dragging a border, kept while the table is mounted
134 1 : const [resizedColumnWidths, setResizedColumnWidths] = React.useState<ResizedColumnWidths>(() => new Map());
135 :
136 : // Determine grid dimensions and column widths
137 1 : const gridLayout = React.useMemo<DataTableLayout>(() => {
138 1 : if (!tableFormatter) {
139 0 : return {
140 0 : columnCount: 0,
141 0 : arrowFieldByColumnIndex: new Uint32Array(),
142 0 : columnXOffsets: new Float64Array([0]),
143 0 : columnAggregateByColumnIndex: new Int32Array(),
144 0 : columnGroupByColumnIndex: new Uint32Array(),
145 0 : isSystemColumn: new Uint8Array(),
146 0 : isTextColumn: new Uint8Array(),
147 0 : headerRowCount
148 0 : };
149 : }
150 1 : return computeTableLayout(tableFormatter, computationState, props.debugMode, headerRowCount, gridContainerWidth, visibleColumnGroups, resizedColumnWidths);
151 1 : }, [
152 1 : computationState.columnGroups,
153 1 : tableFormatter,
154 1 : props.debugMode,
155 1 : gridContainerWidth,
156 1 : headerRowCount,
157 1 : visibleColumnGroups,
158 1 : resizedColumnWidths,
159 1 : ]);
160 :
161 : // Compute helper to resolve column widths
162 1 : const getColumnWidth = React.useCallback((column: number) =>
163 1 : gridLayout.columnXOffsets[column + 1] - gridLayout.columnXOffsets[column],
164 1 : [gridLayout]);
165 :
166 : // Grid re-renders automatically when cellProps (gridData) changes
167 : // which includes gridLayout as a dependency
168 :
169 : // Track react-window's already-overscanned range for the sticky header and column.
170 1 : const [renderedCells, setRenderedCells] = React.useState<RenderedCells>({
171 1 : rowStart: 0,
172 1 : rowStop: 0,
173 1 : columnStart: 0,
174 1 : columnStop: 0,
175 1 : });
176 :
177 : // Shared cross-filter controller: selection lives on the computation state and drives
178 : // a single filterTable. Also owns the guarded filtering effect.
179 1 : const { crossFilters, histogramFilter, mostFrequentValueFilter, requestFilteredColumnAggregation } = useCrossFilters(
180 1 : computationState,
181 1 : dispatchComputation,
182 1 : gridLayout,
183 1 : );
184 :
185 1 : const { orderByColumn, getSortDirection } = useDataTableOrdering(computationState, dispatchComputation);
186 :
187 : // Maintain the focused cell outside of React state, cells subscribe to their own row
188 1 : const [focusStore] = React.useState(() => new DataTableFocusStore());
189 1 : const onMouseEnterCell: React.PointerEventHandler<HTMLDivElement> = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
190 0 : const tableRow = Number.parseInt(event.currentTarget.dataset["tableRow"]!);
191 0 : const tableCol = Number.parseInt(event.currentTarget.dataset["tableCol"]!);
192 0 : focusStore.setFocus(tableRow, tableCol);
193 1 : }, [focusStore]);
194 1 : const onMouseLeaveCell: React.PointerEventHandler<HTMLDivElement> = React.useCallback((_event: React.PointerEvent<HTMLDivElement>) => {
195 0 : focusStore.setFocus(null, null);
196 1 : }, [focusStore]);
197 :
198 : // Cell detail overlay state
199 1 : const [cellDetail, setCellDetail] = React.useState<{
200 : visibleRow: number;
201 : dataRow: number;
202 : fieldId: number;
203 : formattedValue: string | null;
204 : structuredValue: object | null;
205 : columnName: string | null;
206 1 : } | null>(null);
207 1 : const onClickCell: React.MouseEventHandler<HTMLDivElement> = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
208 0 : const visibleRow = Number.parseInt(event.currentTarget.dataset["visibleRow"]!);
209 0 : const dataRow = Number.parseInt(event.currentTarget.dataset["tableRow"]!);
210 0 : const fieldId = Number.parseInt(event.currentTarget.dataset["tableCol"]!);
211 0 : const field = computationState.dataTable.schema.fields[fieldId];
212 0 : if (field == null) return;
213 0 : const isText = field.type.typeId === arrow.Type.Utf8 || field.type.typeId === arrow.Type.LargeUtf8;
214 0 : const isStructured = isArrowStructuredType(field.type);
215 0 : if (!isText && !isStructured) return;
216 0 : const formattedValue = tableFormatter.getValue(dataRow, fieldId);
217 0 : const rawValue = isStructured ? dataTable.getChildAt(fieldId)?.get(dataRow) : null;
218 0 : const structuredValue = rawValue == null ? null : arrowValueToJson(rawValue) as object;
219 0 : const columnName = field.name ?? null;
220 0 : setCellDetail({ visibleRow, dataRow, fieldId, formattedValue, structuredValue, columnName });
221 1 : }, [tableFormatter, dataTable]);
222 1 : const onNavigateCell = React.useCallback((delta: number) => {
223 0 : setCellDetail(prev => {
224 0 : if (prev == null) return null;
225 0 : const visibleRow = prev.visibleRow + delta;
226 0 : if (visibleRow < 0 || visibleRow >= dataRowCount) return prev;
227 0 : const dataRow = visibleRowIndices == null
228 0 : ? visibleRow
229 0 : : visibleRowIndices[visibleRow];
230 0 : const formattedValue = tableFormatter.getValue(dataRow, prev.fieldId);
231 0 : const field = dataTable.schema.fields[prev.fieldId];
232 0 : const isStructured = isArrowStructuredType(field.type);
233 0 : const rawValue = isStructured ? dataTable.getChildAt(prev.fieldId)?.get(dataRow) : null;
234 0 : const structuredValue = rawValue == null ? null : arrowValueToJson(rawValue) as object;
235 0 : return { ...prev, visibleRow, dataRow, formattedValue, structuredValue };
236 0 : });
237 1 : }, [tableFormatter, dataTable, dataRowCount, visibleRowIndices]);
238 :
239 : // Maintain a rendering context for data cells.
240 : // This context is passed to grid elements as item data.
241 1 : const gridData = React.useMemo<DataCellData>(() => ({
242 1 : visibleRowIndices,
243 1 : gridLayout: gridLayout,
244 1 : hideRowHeader: true,
245 1 : columnGroups: computationState.columnGroups,
246 1 : tableFormatter: tableFormatter,
247 1 : onMouseEnter: onMouseEnterCell,
248 1 : onMouseLeave: onMouseLeaveCell,
249 1 : onClick: onClickCell,
250 1 : table: computationState.dataTable,
251 1 : focus: focusStore,
252 1 : rightmostVisibleColumn: gridLayout.columnCount - 1,
253 1 : matchingRows: computationState.dataSearch.matchingRows,
254 1 : searchColumnIndexByGroup,
255 1 : }), [
256 : // Data dependencies that legitimately require cell re-renders
257 1 : computationState.columnGroups,
258 1 : computationState.dataTable,
259 1 : visibleRowIndices,
260 1 : gridLayout,
261 1 : tableFormatter,
262 1 : computationState.dataSearch.matchingRows,
263 1 : searchColumnIndexByGroup,
264 : // Stable callbacks - included for correctness but won't cause re-renders
265 1 : focusStore,
266 1 : onMouseEnterCell,
267 1 : onMouseLeaveCell,
268 1 : onClickCell,
269 1 : ]);
270 : // Track only range boundary changes, rather than updating React state for every scroll event.
271 1 : const onCellsRendered = React.useCallback((
272 1 : _visibleCells: { columnStartIndex: number; columnStopIndex: number; rowStartIndex: number; rowStopIndex: number },
273 1 : allCells: { columnStartIndex: number; columnStopIndex: number; rowStartIndex: number; rowStopIndex: number }
274 1 : ) => {
275 1 : setRenderedCells(prev => {
276 1 : if (
277 1 : prev.rowStart === allCells.rowStartIndex
278 1 : && prev.rowStop === allCells.rowStopIndex
279 1 : && prev.columnStart === allCells.columnStartIndex
280 1 : && prev.columnStop === allCells.columnStopIndex
281 : ) {
282 1 : return prev;
283 : }
284 1 : return {
285 1 : rowStart: allCells.rowStartIndex,
286 1 : rowStop: allCells.rowStopIndex,
287 1 : columnStart: allCells.columnStartIndex,
288 1 : columnStop: allCells.columnStopIndex,
289 1 : };
290 1 : });
291 1 : }, []);
292 :
293 : // Compute grid dimensions
294 1 : const totalColumnsWidth = gridLayout.columnXOffsets[gridLayout.columnCount] ?? 0;
295 1 : const firstColumnWidth = getColumnWidth(0);
296 1 : const headerHeight = columnHeader === TableColumnHeader.WithColumnPlots
297 0 : ? COLUMN_HEADER_HEIGHT + plotsHeight
298 1 : : COLUMN_HEADER_HEIGHT;
299 1 : const scrollbarHeight = useScrollbarHeight();
300 1 : const needsHorizontalScroll = totalColumnsWidth > gridContainerWidth;
301 1 : const horizontalScrollbarHeight = needsHorizontalScroll ? scrollbarHeight : 0;
302 1 : const gridContainerHeight = props.fillHeight ? (gridContainerSize?.height ?? 0) : computeGridHeight(
303 1 : dataTable.numRows,
304 1 : dataRowCount,
305 1 : headerHeight,
306 1 : horizontalScrollbarHeight,
307 1 : props.fitHeight ?? false,
308 1 : props.maxHeight,
309 1 : gridContainerSize?.height ?? 0,
310 1 : props.maxRows,
311 1 : );
312 1 : const firstRenderedColumn = gridLayout.columnCount > 1
313 1 : ? Math.max(1, Math.min(renderedCells.columnStart, gridLayout.columnCount - 1))
314 0 : : 1;
315 1 : const lastRenderedColumn = gridLayout.columnCount > 1
316 1 : ? Math.max(firstRenderedColumn, Math.min(renderedCells.columnStop, gridLayout.columnCount - 1))
317 0 : : 0;
318 1 : const renderedColumnCount = Math.max(0, lastRenderedColumn - firstRenderedColumn + 1);
319 1 : const leftHeaderSpacerWidth = gridLayout.columnCount > 1
320 1 : ? gridLayout.columnXOffsets[firstRenderedColumn] - firstColumnWidth
321 0 : : 0;
322 1 : const rightHeaderSpacerWidth = lastRenderedColumn > 0
323 1 : ? totalColumnsWidth - gridLayout.columnXOffsets[lastRenderedColumn + 1]
324 0 : : 0;
325 :
326 :
327 : // Keep the header cell props stable so that the memoized header cells skip unrelated renders
328 1 : const nameCellProps = React.useMemo(() => ({
329 1 : table: computationState.dataTable,
330 1 : gridLayout,
331 1 : dataFrame: computationState.dataFrame,
332 1 : rightmostVisibleColumn: gridLayout.columnCount - 1,
333 1 : getSortDirection,
334 1 : onOrderByColumn: orderByColumn,
335 1 : onShowTable: props.onShowTable,
336 1 : }), [computationState.dataTable, gridLayout, computationState.dataFrame, getSortDirection, orderByColumn, props.onShowTable]);
337 1 : const selectionEpoch = computationState.filterTable?.version ?? computationState.version;
338 1 : const dataSearchRequestId = computationState.dataSearchTable?.requestId ?? null;
339 1 : const plotCellProps = React.useMemo(() => ({
340 1 : gridLayout,
341 1 : columnGroups: computationState.columnGroups,
342 1 : columnAggregations: computationState.columnAggregates,
343 1 : columnAggregationTasks: computationState.tasks.columnAggregationTasks,
344 1 : filteredColumnAggregations: computationState.filteredColumnAggregates,
345 1 : filteredColumnAggregationTasks: computationState.tasks.filteredColumnAggregationTasks,
346 1 : filteredColumnAggregationOutdated: computationState.filteredColumnAggregatesOutdated,
347 1 : tableAggregation: computationState.tableAggregation,
348 1 : selectionEpoch,
349 1 : dataSearchRequestId,
350 1 : crossFilters,
351 1 : isVisible: true,
352 1 : rightmostVisibleColumn: gridLayout.columnCount - 1,
353 1 : onRequestFilteredColumnAggregation: requestFilteredColumnAggregation,
354 1 : onHistogramFilter: histogramFilter,
355 1 : onBrushingChange: NOOP_BRUSHING,
356 1 : onMostFrequentValueFilter: mostFrequentValueFilter,
357 1 : }), [
358 1 : gridLayout,
359 1 : computationState.columnGroups,
360 1 : computationState.columnAggregates,
361 1 : computationState.tasks.columnAggregationTasks,
362 1 : computationState.filteredColumnAggregates,
363 1 : computationState.tasks.filteredColumnAggregationTasks,
364 1 : computationState.filteredColumnAggregatesOutdated,
365 1 : computationState.tableAggregation,
366 1 : selectionEpoch,
367 1 : dataSearchRequestId,
368 1 : crossFilters,
369 1 : requestFilteredColumnAggregation,
370 1 : histogramFilter,
371 1 : mostFrequentValueFilter,
372 1 : ]);
373 :
374 : // Create containers for sticky header and sticky column
375 1 : const portalContainers = useDataTablePortalContainers(gridApi);
376 :
377 : // Drag the right border of any cell to resize its column
378 1 : const columnResize = useDataTableColumnResize(gridApi, gridLayout, dataTable, setResizedColumnWidths);
379 :
380 : // Total height of data content (for sticky column sizing)
381 1 : const totalDataHeight = dataRowCount * ROW_HEIGHT;
382 :
383 1 : return (
384 : // The grid can be rendered within the session feed's Electron drag region. Opt out so
385 : // native scrollbar thumbs receive pointer drags instead of moving the application window.
386 1 : <div
387 1 : className={classNames(styles.root, props.className, {
388 1 : [styles.root_compact]: props.compact,
389 1 : })}
390 1 : data-electron-drag-region="false"
391 1 : style={{
392 1 : ...(props.cellBackground ? { '--data_table_bg': props.cellBackground } : {}),
393 1 : ...(props.fitHeight ? { height: gridContainerHeight } : {}),
394 1 : } as React.CSSProperties}
395 : >
396 1 : <div className={styles.grid_container} ref={gridContainerElement} {...columnResize}>
397 1 : <Grid
398 1 : gridRef={setGridApi}
399 1 : style={{
400 1 : width: gridContainerWidth,
401 1 : height: gridContainerHeight,
402 1 : overflowY: props.maxRows != null ? 'hidden' : undefined,
403 1 : }}
404 1 : columnCount={gridLayout.columnCount}
405 1 : columnWidth={getColumnWidth}
406 1 : rowCount={dataRowCount}
407 1 : rowHeight={ROW_HEIGHT}
408 1 : onCellsRendered={onCellsRendered}
409 1 : overscanCount={GRID_OVERSCAN_COUNT}
410 1 : cellComponent={DataCell}
411 1 : cellProps={gridData}
412 1 : className={styles.data_grid}
413 1 : />
414 1 : <DataTableStickyHeaders
415 1 : container={portalContainers?.header ?? null}
416 1 : totalWidth={totalColumnsWidth}
417 1 : headerHeight={headerHeight}
418 1 : firstColumnWidth={firstColumnWidth}
419 1 : leftSpacerWidth={leftHeaderSpacerWidth}
420 1 : rightSpacerWidth={rightHeaderSpacerWidth}
421 1 : firstRenderedColumn={firstRenderedColumn}
422 1 : renderedColumnCount={renderedColumnCount}
423 1 : getColumnWidth={getColumnWidth}
424 1 : columnHeader={columnHeader}
425 1 : plotsHeight={plotsHeight}
426 1 : plotsCollapsed={plotsCollapsed}
427 1 : onTogglePlots={togglePlots}
428 1 : nameCellProps={nameCellProps}
429 1 : plotCellProps={plotCellProps}
430 1 : />
431 1 : <DataTableStickyColumn
432 1 : container={portalContainers?.data ?? null}
433 1 : firstColumnWidth={firstColumnWidth}
434 1 : totalDataHeight={totalDataHeight}
435 1 : startRow={Math.max(0, renderedCells.rowStart)}
436 1 : stopRow={Math.min(dataRowCount - 1, renderedCells.rowStop)}
437 1 : gridData={gridData}
438 1 : />
439 1 : </div>
440 1 : <CellDetailOverlay
441 1 : isOpen={cellDetail != null}
442 1 : onClose={() => setCellDetail(null)}
443 1 : formattedValue={cellDetail?.formattedValue ?? null}
444 1 : structuredValue={cellDetail?.structuredValue ?? null}
445 1 : columnName={cellDetail?.columnName ?? null}
446 1 : dataRow={cellDetail?.visibleRow ?? 0}
447 1 : maxRow={dataRowCount - 1}
448 1 : onNavigate={onNavigateCell}
449 1 : />
450 1 : </div>
451 : );
452 1 : });
|