Line data Source code
1 : import * as React from 'react';
2 :
3 : import * as styles from './column_aggregation_bar.module.css';
4 : import { QueryExecutionState } from '../../query/query_execution_state.js';
5 : import { useComputationRegistry } from '../../compute/computation_registry.js';
6 : import { ArrowTableFormatter } from '../../compute/arrow_formatter.js';
7 : import { computeTableLayout, DataTableLayout } from '../query_result/data_table_layout.js';
8 : import { HeaderPlotsCell } from '../query_result/data_table_cell.js';
9 : import { useCrossFilters } from '../query_result/use_cross_filters.js';
10 : import { useAppConfig } from '../../config/app_config.js';
11 : import { useLogger } from '../../platform/logger/logger_provider.js';
12 :
13 1 : const COLUMN_HEADER_PLOTS_HEIGHT = 76;
14 : /// Matches COLUMN_HEADER_HEIGHT in data_table.tsx so the name row lines up with the table header.
15 1 : const COLUMN_NAME_HEIGHT = 32;
16 : /// The aggregation bar always renders plots, so the layout is computed with a single header row.
17 1 : const HEADER_ROW_COUNT = 2;
18 :
19 : interface Props {
20 : query: QueryExecutionState | null;
21 : debugMode?: boolean;
22 : }
23 :
24 : /// A horizontally-scrollable strip of column aggregation cells shown above the visualization.
25 : ///
26 : /// It renders the same per-column histogram / most-frequent plots as the result table header
27 : /// (reusing `HeaderPlotsCell`) and drives the same shared cross-filter selection via
28 : /// `useCrossFilters`. Brushing a histogram here filters the underlying data table, which the
29 : /// visualization renderer then re-renders against.
30 1 : export function ColumnAggregationBar(props: Props): React.ReactElement | null {
31 1 : const [computationState, dispatchComputation] = useComputationRegistry();
32 1 : const logger = useLogger();
33 1 : const config = useAppConfig();
34 1 : const debugMode = props.debugMode ?? (config?.settings?.tableDebugMode ?? false);
35 :
36 1 : const queryId = props.query?.queryId ?? null;
37 1 : const tableComputation = queryId != null ? computationState.tableComputations[queryId] ?? null : null;
38 :
39 : // Measure the available width so the layout can distribute the leftover space across the
40 : // columns (same fill-to-width behaviour as the data table).
41 1 : const rootRef = React.useRef<HTMLDivElement | null>(null);
42 1 : const scrollRef = React.useRef<HTMLDivElement | null>(null);
43 1 : const [containerWidth, setContainerWidth] = React.useState(0);
44 1 : const [scrollLeft, setScrollLeft] = React.useState(0);
45 :
46 : // Build the arrow formatter and layout with the SAME util the table uses, so the
47 : // column -> aggregate index mapping matches exactly.
48 1 : const tableFormatter = React.useMemo(() => {
49 1 : if (!tableComputation) return null;
50 1 : const dataTable = tableComputation.dataTable;
51 1 : return new ArrowTableFormatter(dataTable.schema, dataTable.batches, logger);
52 1 : }, [tableComputation?.dataTable, logger]);
53 :
54 : // The layout only reads the column groups and the data table, so it is not recomputed on every
55 : // task step of the table computation.
56 1 : const dataTable = tableComputation?.dataTable ?? null;
57 1 : const columnGroups = tableComputation?.columnGroups ?? null;
58 : // The bar does not render the leading row-number column, but the layout still reserves
59 : // its width. Offset the container width by that reserved width so the data columns we DO
60 : // render fill the whole bar.
61 1 : const rowNumberWidth = React.useMemo<number | null>(() => {
62 1 : if (!tableComputation || !tableFormatter) return null;
63 1 : const probe = computeTableLayout(tableFormatter, tableComputation, debugMode, HEADER_ROW_COUNT);
64 1 : return probe.columnCount > 0 ? probe.columnXOffsets[1] : 0;
65 : // eslint-disable-next-line react-hooks/exhaustive-deps
66 1 : }, [dataTable, columnGroups, tableFormatter, debugMode]);
67 1 : const gridLayout = React.useMemo<DataTableLayout | null>(() => {
68 1 : if (!tableComputation || !tableFormatter || rowNumberWidth == null) return null;
69 1 : const effectiveWidth = containerWidth > 0 ? containerWidth + rowNumberWidth : 0;
70 1 : return computeTableLayout(tableFormatter, tableComputation, debugMode, HEADER_ROW_COUNT, effectiveWidth);
71 : // eslint-disable-next-line react-hooks/exhaustive-deps
72 1 : }, [dataTable, columnGroups, tableFormatter, debugMode, containerWidth, rowNumberWidth]);
73 :
74 : // The bar renders nothing until the table has columns, observe the root once it is mounted
75 1 : const hasScrollElement = tableComputation != null && gridLayout != null && gridLayout.columnCount > 0;
76 1 : React.useEffect(() => {
77 1 : const el = rootRef.current;
78 1 : if (!el) return;
79 1 : const observer = new ResizeObserver(entries => {
80 1 : const width = entries[0]?.contentRect.width ?? 0;
81 1 : setContainerWidth(width);
82 1 : });
83 1 : observer.observe(el);
84 1 : return () => observer.disconnect();
85 1 : }, [hasScrollElement]);
86 :
87 : // Track the scroll offset at most once per frame, it only decides which plots are mounted
88 1 : React.useEffect(() => {
89 1 : const element = scrollRef.current;
90 1 : if (!element) return;
91 1 : let frame: number | null = null;
92 1 : const updateScrollLeft = () => {
93 1 : setScrollLeft(previous => previous === element.scrollLeft ? previous : element.scrollLeft);
94 1 : };
95 1 : const onScroll = () => {
96 1 : if (frame != null) return;
97 1 : frame = requestAnimationFrame(() => {
98 1 : frame = null;
99 1 : updateScrollLeft();
100 1 : });
101 1 : };
102 1 : updateScrollLeft();
103 1 : element.addEventListener('scroll', onScroll, { passive: true });
104 1 : return () => {
105 1 : element.removeEventListener('scroll', onScroll);
106 1 : if (frame != null) cancelAnimationFrame(frame);
107 1 : };
108 1 : }, [hasScrollElement]);
109 :
110 : // The cross-filter controller must be called unconditionally to satisfy the rules of hooks;
111 : // it no-ops harmlessly when there is no table computation / data frame yet.
112 1 : const emptyLayout = React.useMemo<DataTableLayout>(() => ({
113 1 : columnCount: 0,
114 1 : arrowFieldByColumnIndex: new Uint32Array(),
115 1 : columnXOffsets: new Float64Array([0]),
116 1 : columnAggregateByColumnIndex: new Int32Array(),
117 1 : columnGroupByColumnIndex: new Uint32Array(),
118 1 : isSystemColumn: new Uint8Array(),
119 1 : isTextColumn: new Uint8Array(),
120 1 : headerRowCount: HEADER_ROW_COUNT,
121 1 : }), []);
122 1 : const controller = useCrossFilters(
123 1 : tableComputation,
124 1 : dispatchComputation,
125 1 : gridLayout ?? emptyLayout,
126 1 : );
127 :
128 1 : if (!tableComputation || !gridLayout || gridLayout.columnCount === 0) {
129 1 : return null;
130 : }
131 :
132 1 : const viewportLeft = Math.max(0, scrollLeft - containerWidth);
133 1 : const viewportRight = scrollLeft + 2 * containerWidth;
134 :
135 : // Skip the leading row-number column (columnIndex 0); render the aggregate plots for the rest.
136 1 : const fields = tableComputation.dataTable.schema.fields;
137 1 : const cells: React.ReactElement[] = [];
138 1 : for (let columnIndex = 1; columnIndex < gridLayout.columnCount; ++columnIndex) {
139 1 : const width = gridLayout.columnXOffsets[columnIndex + 1] - gridLayout.columnXOffsets[columnIndex];
140 1 : const columnLeft = gridLayout.columnXOffsets[columnIndex] - gridLayout.columnXOffsets[1];
141 1 : const columnRight = columnLeft + width;
142 1 : const isVisible = columnRight > viewportLeft && columnLeft < viewportRight;
143 1 : const fieldId = gridLayout.arrowFieldByColumnIndex[columnIndex];
144 1 : const columnName = fields[fieldId]?.name ?? '';
145 1 : cells.push(
146 1 : <div key={`agg-${columnIndex}`} className={styles.column} style={{ width, flexShrink: 0 }}>
147 1 : <div className={styles.column_name} style={{ height: COLUMN_NAME_HEIGHT }} title={columnName}>
148 1 : {columnName}
149 1 : </div>
150 1 : {isVisible
151 1 : ? <HeaderPlotsCell
152 1 : columnIndex={columnIndex}
153 1 : style={{ width, height: COLUMN_HEADER_PLOTS_HEIGHT, flexShrink: 0 }}
154 1 : gridLayout={gridLayout}
155 1 : columnGroups={tableComputation.columnGroups}
156 1 : columnAggregations={tableComputation.columnAggregates}
157 1 : columnAggregationTasks={tableComputation.tasks.columnAggregationTasks}
158 1 : filteredColumnAggregations={tableComputation.filteredColumnAggregates}
159 1 : filteredColumnAggregationTasks={tableComputation.tasks.filteredColumnAggregationTasks}
160 1 : filteredColumnAggregationOutdated={tableComputation.filteredColumnAggregatesOutdated}
161 1 : tableAggregation={tableComputation.tableAggregation}
162 1 : selectionEpoch={tableComputation.filterTable?.version ?? tableComputation.version}
163 1 : dataSearchRequestId={tableComputation.dataSearchTable?.requestId ?? null}
164 1 : crossFilters={controller.crossFilters}
165 1 : isVisible={true}
166 1 : rightmostVisibleColumn={gridLayout.columnCount - 1}
167 1 : onRequestFilteredColumnAggregation={controller.requestFilteredColumnAggregation}
168 1 : onHistogramFilter={controller.histogramFilter}
169 1 : onBrushingChange={NOOP_BRUSHING}
170 1 : onMostFrequentValueFilter={controller.mostFrequentValueFilter}
171 1 : />
172 1 : : <div style={{ width, height: COLUMN_HEADER_PLOTS_HEIGHT, flexShrink: 0 }} />}
173 1 : </div>
174 1 : );
175 1 : }
176 :
177 1 : return (
178 1 : <div ref={rootRef} className={styles.root}>
179 1 : <div ref={scrollRef} className={styles.scroll} style={{ height: COLUMN_HEADER_PLOTS_HEIGHT + COLUMN_NAME_HEIGHT }}>
180 1 : {cells}
181 1 : </div>
182 1 : </div>
183 : );
184 1 : }
185 :
186 1 : const NOOP_BRUSHING = () => { };
|