LCOV - code coverage report
Current view: top level - dashql-app/src/views/visualization - column_aggregation_bar.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 130 130
Test Date: 2026-11-06 19:30:32

            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 = () => { };
        

Generated by: LCOV version 2.0-1