LCOV - code coverage report
Current view: top level - dashql-app/src/views/query_result - histogram_cell.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 98.1 % 260 255
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as d3 from 'd3';
       3              : import * as styles from './histogram_cell.module.css';
       4              : 
       5              : import { observeSize } from '../../ui/foundations/size_observer.js';
       6              : import { ColumnAggregationVariant, OrdinalColumnAggregation, ORDINAL_COLUMN, TableAggregation, WithFilterEpoch } from '../../compute/computation_types.js';
       7              : import { dataTypeToString } from '../../compute/arrow_formatter.js';
       8              : import { COLUMN_SUMMARY_BASELINE_COLOR, getTotalBarColor, getFilteredBarColor } from './data_table_colors.js';
       9              : import { HistogramBars, HistogramNullBar, NULL_SYMBOL } from './histogram_bars.js';
      10              : import { useHistogramBrush } from './histogram_brush.js';
      11              : import { formatHistogramFocusDescription } from './histogram_label.js';
      12              : import { arePropsEqualWithStyle } from './memo_props.js';
      13              : 
      14              : export { NULL_SYMBOL } from './histogram_bars.js';
      15              : export { SettledBrushUpdates } from './histogram_brush.js';
      16              : 
      17              : export type HistogramFilterCallback = (table: TableAggregation, columnId: number, column: OrdinalColumnAggregation, filter: [number, number] | null) => void;
      18              : export type BrushingStateCallback = (isBrushing: boolean) => void;
      19              : 
      20              : interface HistogramCellProps {
      21              :     className?: string;
      22              :     style?: React.CSSProperties;
      23              :     tableAggregation: TableAggregation;
      24              :     columnIndex: number;
      25              :     columnAggregate: OrdinalColumnAggregation;
      26              :     filteredColumnAggregation: WithFilterEpoch<ColumnAggregationVariant> | null;
      27              :     selection: [number, number] | null;
      28              :     onFilter: HistogramFilterCallback;
      29              :     onBrushingChange?: BrushingStateCallback;
      30              : }
      31              : 
      32            1 : function HistogramCellImpl(props: HistogramCellProps): React.ReactElement {
      33            1 :     const inputNullable = props.columnAggregate.columnEntry.inputFieldNullable;
      34            1 :     const countNull = props.columnAggregate.columnAnalysis.countNull;
      35            1 :     const binCounts = props.columnAggregate.columnAnalysis.binValueCounts;
      36            1 :     const binCount = props.columnAggregate.columnAnalysis.binCount;
      37              : 
      38            1 :     const svgContainer = React.useRef<HTMLDivElement>(null);
      39            1 :     const svgContainerSize = observeSize(svgContainer);
      40            1 :     const margin = { top: 8, right: 8, bottom: 20, left: 8 },
      41            1 :         width = (svgContainerSize?.width ?? 130) - margin.left - margin.right,
      42            1 :         height = (svgContainerSize?.height ?? 50) - margin.top - margin.bottom;
      43              : 
      44            1 :     let histWidth = width;
      45            1 :     const nullsWidth = 12;
      46            1 :     const nullsMargin = 2;
      47            1 :     if (inputNullable) {
      48            1 :         histWidth -= nullsWidth + nullsMargin;
      49              :     }
      50              : 
      51              :     // Compute d3 scales - fixed dependencies to include data that affects the scales
      52            1 :     const [histXScale, histYScale, nullsXScale, nullsYScale, nullsXWidth] = React.useMemo(() => {
      53            1 :         const xValues: string[] = [];
      54            1 :         for (let i = 0; i < binCount; ++i) {
      55            1 :             xValues.push(i.toString());
      56              :         }
      57            1 :         let yMin = BigInt(0);
      58            1 :         let yMax = BigInt(countNull ?? 0);
      59            1 :         for (let i = 0; i < binCounts.length; ++i) {
      60            1 :             yMax = binCounts[i] > yMax ? binCounts[i] : yMax;
      61              :         }
      62            1 :         let yDomain = [Number(yMin), Number(yMax)];
      63              : 
      64            1 :         const histXScale = d3.scaleBand()
      65            1 :             .range([0, histWidth])
      66            1 :             .domain(xValues)
      67            1 :             .padding(0.1);
      68            1 :         const histYScale = d3.scaleLinear()
      69            1 :             .range([height, 0])
      70            1 :             .domain(yDomain);
      71              :         // The x-scale of the null plot consists of a single band + padding
      72            1 :         const nullsXWidth = histXScale.bandwidth() + 2 * histXScale.paddingOuter();
      73            1 :         const nullsXScale = d3.scaleBand()
      74            1 :             .range([0, nullsXWidth])
      75            1 :             .domain([NULL_SYMBOL])
      76            1 :             .padding(0.1);
      77            1 :         const nullsYScale = d3.scaleLinear()
      78            1 :             .range([height, 0])
      79            1 :             .domain(yDomain);
      80              : 
      81            1 :         return [histXScale, histYScale, nullsXScale, nullsYScale, nullsXWidth];
      82            1 :     }, [histWidth, height, binCount, binCounts, countNull]);
      83              : 
      84              :     // Listen for brush events
      85            1 :     const onBrushUpdate = React.useCallback((e: d3.D3BrushEvent<unknown>) => {
      86            1 :         if (!props.onFilter) {
      87            0 :             return;
      88              :         }
      89            1 :         if (e.selection == null) {
      90            1 :             props.onFilter(props.tableAggregation, props.columnIndex, props.columnAggregate, null);
      91            1 :             return;
      92            1 :         }
      93            1 :         const pixelSelection = e.selection as [number, number];
      94            1 :         if (pixelSelection[0] == pixelSelection[1]) {
      95            0 :             props.onFilter(props.tableAggregation, props.columnIndex, props.columnAggregate, null);
      96            0 :             return;
      97            0 :         }
      98              :         // Bin i starts at binOffset + i * step, after the outer padding of the band scale
      99            1 :         const step = histXScale.step();
     100            1 :         const binOffset = histXScale.paddingOuter() * step;
     101            1 :         const fractionalBinStart = (pixelSelection[0] - binOffset) / step;
     102            1 :         const fractionalBinEnd = (pixelSelection[1] - binOffset) / step;
     103            1 :         const binSelection: [number, number] = [fractionalBinStart, fractionalBinEnd];
     104            1 :         props.onFilter(props.tableAggregation, props.columnIndex, props.columnAggregate, binSelection);
     105            1 :     }, [props.tableAggregation, props.columnAggregate, props.onFilter, props.columnIndex, histXScale]);
     106              : 
     107            1 :     const clearFilter = React.useCallback(() => {
     108            0 :         props.onFilter(props.tableAggregation, props.columnIndex, props.columnAggregate, null);
     109            1 :     }, [props.tableAggregation, props.columnIndex, props.columnAggregate, props.onFilter]);
     110              : 
     111            1 :     const brushSelection = React.useMemo<[number, number] | null>(() => {
     112            1 :         if (props.selection == null) {
     113            1 :             return null;
     114              :         }
     115            1 :         const step = histXScale.step();
     116            1 :         const binOffset = histXScale.paddingOuter() * step;
     117            1 :         return [binOffset + props.selection[0] * step, binOffset + props.selection[1] * step];
     118            1 :     }, [histXScale, props.selection]);
     119              : 
     120            1 :     const { brushContainer, clearBrush } = useHistogramBrush({
     121            1 :         xScale: histXScale,
     122            1 :         height,
     123            1 :         selection: brushSelection,
     124            1 :         onBrushUpdate,
     125            1 :         onClear: clearFilter,
     126            1 :         onBrushingChange: props.onBrushingChange,
     127            1 :     });
     128              : 
     129              :     // Adjust null padding to center null bar horizontally
     130            1 :     const nullsPadding = (nullsWidth - nullsXScale.bandwidth()) / 2;
     131              : 
     132              :     // Extract filtered bin counts and null count if available
     133            1 :     const hasFilteredAggregate = props.filteredColumnAggregation != null && props.filteredColumnAggregation.type === ORDINAL_COLUMN;
     134            1 :     const [filteredBinCounts, filteredNullCount, filteredRowCount] = React.useMemo(() => {
     135            1 :         if (props.filteredColumnAggregation?.type !== ORDINAL_COLUMN) return [null, null, null];
     136            1 :         const filteredAgg = props.filteredColumnAggregation.value;
     137            1 :         return [
     138            1 :             filteredAgg.columnAnalysis.binValueCounts,
     139            1 :             filteredAgg.columnAnalysis.countNull,
     140            1 :             filteredAgg.columnAnalysis.totalCount,
     141            1 :         ];
     142            1 :     }, [props.filteredColumnAggregation]);
     143              : 
     144              :     // Colors for total vs filtered bars (using shared color scheme)
     145            1 :     const totalBarColor = getTotalBarColor(hasFilteredAggregate, false);
     146            1 :     const totalBarFocusedColor = getTotalBarColor(hasFilteredAggregate, true);
     147            1 :     const filteredBarColor = getFilteredBarColor(false);
     148            1 :     const filteredBarFocusedColor = getFilteredBarColor(true);
     149              :     // Null bar colors
     150            1 :     const totalNullBarColor = getTotalBarColor(hasFilteredAggregate, false, true);
     151            1 :     const totalNullBarFocusedColor = getTotalBarColor(hasFilteredAggregate, true, true);
     152              : 
     153              :     // Track the focused bin id
     154            1 :     const [focusedBin, setFocusedBin] = React.useState<number | null>(null);
     155            1 :     const [focusedNull, setFocusedNull] = React.useState<boolean | null>(null);
     156            1 :     let focusDescription: string | null = null;
     157            1 :     if (focusedBin != null) {
     158            1 :         focusDescription = formatHistogramFocusDescription(
     159            1 :             binCounts[focusedBin],
     160            1 :             props.columnAggregate.columnAnalysis.totalCount,
     161            1 :             filteredBinCounts?.[focusedBin] ?? null,
     162            1 :             filteredRowCount,
     163            1 :         );
     164            1 :     } else if (focusedNull) {
     165            1 :         focusDescription = formatHistogramFocusDescription(
     166            1 :             BigInt(countNull),
     167            1 :             props.columnAggregate.columnAnalysis.totalCount,
     168            1 :             filteredNullCount == null ? null : BigInt(filteredNullCount),
     169            1 :             filteredRowCount,
     170            1 :         );
     171              :     }
     172              : 
     173              :     // Listen for pointer events
     174            1 :     const onPointerOverBin = React.useCallback((elem: React.MouseEvent<SVGGElement>) => {
     175              :         // Bin i starts at binOffset + i * step, after the outer padding of the band scale
     176            1 :         const step = histXScale.step();
     177            1 :         const binOffset = histXScale.paddingOuter() * step;
     178              :         // The position in the plot, the bounding box of the brush grows with the handles of a selection
     179            1 :         const [relativeX] = d3.pointer(elem.nativeEvent, elem.currentTarget);
     180            1 :         const innerX = Math.max(relativeX, binOffset) - binOffset;
     181            1 :         const bin = Math.min(Math.floor(innerX / step), binCount - 1);
     182              : 
     183            1 :         setFocusedBin(bin);
     184            1 :     }, [histXScale, binCount]);
     185            1 :     const onPointerOutBin = React.useCallback((_elem: React.MouseEvent<SVGGElement>) => {
     186            1 :         setFocusedBin(null);
     187            1 :     }, []);
     188            1 :     const onPointerOverNull = React.useCallback((_elem: React.MouseEvent<SVGGElement>) => {
     189            1 :         setFocusedNull(true);
     190            1 :     }, []);
     191            1 :     const onPointerOutNull = React.useCallback((_elem: React.MouseEvent<SVGGElement>) => {
     192            1 :         setFocusedNull(false);
     193            1 :     }, []);
     194              : 
     195              :     // Resolve bin labels
     196            1 :     const binLabels = props.columnAggregate.columnAnalysis.binLowerBounds;
     197            1 :     const binLabelLeft = binLabels[0];
     198            1 :     const binLabelRight = binLabels[binLabels.length - 1];
     199            1 :     const binLabelFocused = focusedBin != null ? binLabels[focusedBin] : null;
     200              : 
     201              :     // Memoize container style to avoid object recreation
     202            1 :     const containerStyle = React.useMemo(() => ({
     203            1 :         ...props.style,
     204            1 :         zIndex: focusedBin != null ? 100 : props.style?.zIndex
     205            1 :     }), [props.style, focusedBin]);
     206              : 
     207              :     // Memoize axis labels container style
     208            1 :     const axisLabelsStyle = React.useMemo(() => ({
     209            1 :         position: "absolute" as const,
     210            1 :         top: `${margin.top + height + 2}px`,
     211            1 :         left: `${margin.left}px`,
     212            1 :         width: `${histWidth}px`,
     213            1 :     }), [margin.top, margin.left, height, histWidth]);
     214              : 
     215              :     // Memoize focused label style
     216            1 :     const focusedLabelStyle = React.useMemo(() => {
     217            1 :         if (focusedBin == null) return null;
     218            1 :         return {
     219            1 :             position: "absolute" as const,
     220            1 :             top: `${margin.top + height + 13 - 12}px`,
     221            1 :             left: `${margin.left + (histXScale(focusedBin.toString()) ?? 0) + histXScale.bandwidth() / 2}px`,
     222            1 :             transform: 'translateX(-50%)',
     223            1 :             textWrap: "nowrap" as const,
     224            1 :             fontSize: "12px",
     225            1 :             fontWeight: 400,
     226            1 :             pointerEvents: "none" as const,
     227            1 :             color: "var(--color_fg_inverse)",
     228            1 :             backgroundColor: "var(--color_bg_inverse)",
     229            1 :             zIndex: 3,
     230            1 :             padding: "0px 4px 0px 4px",
     231            1 :             borderRadius: "3px",
     232            1 :         };
     233            1 :     }, [focusedBin, margin.top, margin.left, height, histXScale]);
     234              : 
     235              :     // Memoize null label style
     236            1 :     const nullLabelStyle = React.useMemo(() => ({
     237            1 :         position: "absolute" as const,
     238            1 :         top: `${margin.top + height + 2}px`,
     239            1 :         left: `${margin.left + histWidth + nullsMargin + nullsPadding + nullsXScale.bandwidth() / 2}px`,
     240            1 :         transform: 'translateX(-50%)',
     241            1 :         fontSize: "12px",
     242            1 :         fontWeight: 400,
     243            1 :         pointerEvents: "none" as const,
     244            1 :     }), [margin.top, margin.left, height, histWidth, nullsMargin, nullsPadding, nullsXScale]);
     245              : 
     246              :     // Memoize focused null label style
     247            1 :     const focusedNullLabelStyle = React.useMemo(() => ({
     248            1 :         top: `${margin.top + height + 13 - 12}px`,
     249            1 :         left: `${margin.left + histWidth + nullsMargin + nullsPadding + nullsXScale.bandwidth() / 2}px`,
     250            1 :     }), [margin.top, margin.left, height, histWidth, nullsMargin, nullsPadding, nullsXScale]);
     251              : 
     252            1 :     return (
     253            1 :         <div
     254            1 :             className={props.className}
     255            1 :             style={containerStyle}
     256            1 :             onClick={clearBrush}
     257              :         >
     258            1 :             <div className={styles.root}>
     259            1 :                 <div className={styles.header_container}>
     260            1 :                     {focusDescription ?? dataTypeToString(props.columnAggregate.columnEntry.inputFieldType)}
     261            1 :                 </div>
     262            1 :                 <div className={styles.plot_container} ref={svgContainer}>
     263            1 :                     <svg
     264            1 :                         className={styles.plot_svg}
     265            1 :                         width={width + margin.left + margin.right}
     266            1 :                         height={height + margin.top + margin.bottom}
     267              :                     >
     268            1 :                         <g transform={`translate(${margin.left},${margin.top})`}>
     269            1 :                             <g>
     270            1 :                                 <HistogramBars
     271            1 :                                     binCounts={binCounts}
     272            1 :                                     filteredBinCounts={filteredBinCounts}
     273            1 :                                     focusedBin={focusedBin}
     274            1 :                                     xScale={histXScale}
     275            1 :                                     yScale={histYScale}
     276            1 :                                     height={height}
     277            1 :                                     totalBarColor={totalBarColor}
     278            1 :                                     totalBarFocusedColor={totalBarFocusedColor}
     279            1 :                                     filteredBarColor={filteredBarColor}
     280            1 :                                     filteredBarFocusedColor={filteredBarFocusedColor}
     281            1 :                                 />
     282            1 :                             </g>
     283            1 :                             <g ref={brushContainer}
     284            1 :                                 onPointerOver={onPointerOverBin}
     285            1 :                                 onPointerMove={onPointerOverBin}
     286            1 :                                 onPointerOut={onPointerOutBin}
     287            1 :                                 onClick={event => event.stopPropagation()}
     288            1 :                             />
     289            1 :                             <g
     290            1 :                                 transform={`translate(0, ${height})`}
     291            1 :                                 onPointerOver={onPointerOverBin}
     292            1 :                                 onPointerMove={onPointerOverBin}
     293            1 :                                 onPointerOut={onPointerOutBin}
     294              :                             >
     295            1 :                                 <line x1={0} y1={1} x2={histWidth} y2={1}
     296            1 :                                     style={{ stroke: COLUMN_SUMMARY_BASELINE_COLOR }} />
     297            1 :                                 <rect
     298            1 :                                     x={0} y={0}
     299            1 :                                     width={histWidth}
     300            1 :                                     height={margin.bottom - 1}
     301            1 :                                     fillOpacity={0}
     302            1 :                                 />
     303            1 :                             </g>
     304            1 :                             {inputNullable && (
     305            1 :                                 <HistogramNullBar
     306            1 :                                     countNull={countNull ?? 0}
     307            1 :                                     filteredNullCount={filteredNullCount}
     308            1 :                                     focused={focusedNull}
     309            1 :                                     xScale={nullsXScale}
     310            1 :                                     yScale={nullsYScale}
     311            1 :                                     xWidth={nullsXWidth}
     312            1 :                                     height={height}
     313            1 :                                     bottomMargin={margin.bottom}
     314            1 :                                     transformX={histWidth + nullsMargin + nullsPadding}
     315            1 :                                     totalBarColor={totalNullBarColor}
     316            1 :                                     totalBarFocusedColor={totalNullBarFocusedColor}
     317            1 :                                     filteredBarColor={filteredBarColor}
     318            1 :                                     filteredBarFocusedColor={filteredBarFocusedColor}
     319            1 :                                     onPointerOver={onPointerOverNull}
     320            1 :                                     onPointerOut={onPointerOutNull}
     321            1 :                                 />
     322              :                             )}
     323            1 :                         </g>
     324              : 
     325            1 :                     </svg>
     326            1 :                     {(binLabelFocused == null)
     327              :                         ? (
     328            1 :                             <div
     329            1 :                                 className={styles.axis_labels_container}
     330            1 :                                 style={axisLabelsStyle}
     331              :                             >
     332            1 :                                 <span className={styles.axis_label_left}>{binLabelLeft}</span>
     333            1 :                                 <span className={styles.axis_label_right} >{binLabelRight}</span>
     334            1 :                             </div>
     335              :                         ) : (
     336            1 :                             <span style={focusedLabelStyle!}>{binLabelFocused}</span>
     337              :                         )}
     338            1 :                     {inputNullable && (
     339            1 :                         !focusedNull
     340              :                             ? (
     341            1 :                                 <span style={nullLabelStyle}>{NULL_SYMBOL}</span>
     342              : 
     343              :                             ) : (
     344            1 :                                 <span className={styles.axis_label_overlay} style={focusedNullLabelStyle}>{NULL_SYMBOL}</span>
     345              :                             )
     346              :                     )}
     347            1 :                 </div>
     348            1 :             </div>
     349            1 :         </div>
     350              :     );
     351            1 : };
     352              : 
     353            1 : export const HistogramCell = React.memo(HistogramCellImpl, arePropsEqualWithStyle);
        

Generated by: LCOV version 2.0-1