LCOV - code coverage report
Current view: top level - dashql-app/src/views/query_result - mostfrequent_cell.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 273 273
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 './mostfrequent_cell.module.css';
       4              : 
       5              : import { ColumnAggregationVariant, StringColumnAggregation, STRING_COLUMN, TableAggregation, WithFilterEpoch } from '../../compute/computation_types.js';
       6              : import { dataTypeToString } from '../../compute/arrow_formatter.js';
       7              : import { observeSize } from '../../ui/foundations/size_observer.js';
       8              : import { assert } from '../../utils/assert.js';
       9              : import { NULL_SYMBOL } from './histogram_cell.js';
      10              : import { COLUMN_SUMMARY_BASELINE_COLOR, COLUMN_SUMMARY_COLORS, getTotalBarColor, getFilteredBarColor } from './data_table_colors.js';
      11              : import { formatHistogramFocusDescription } from './histogram_label.js';
      12              : import { arePropsEqualWithStyle } from './memo_props.js';
      13              : 
      14              : export type MostFrequentValueFilterCallback = (table: TableAggregation, columnIndex: number, column: StringColumnAggregation, frequentValueId: number | null) => void;
      15              : 
      16              : export const MAX_FOCUSED_VALUE_LENGTH = 100;
      17              : 
      18            1 : export function truncateFocusedValue(value: string): string {
      19            1 :     if (value.length <= MAX_FOCUSED_VALUE_LENGTH) return value;
      20            1 :     return `${value.slice(0, MAX_FOCUSED_VALUE_LENGTH - 3)}...`;
      21            1 : }
      22              : 
      23              : interface MostFrequentCellProps {
      24              :     className?: string;
      25              :     style?: React.CSSProperties;
      26              :     tableAggregation: TableAggregation;
      27              :     columnIndex: number;
      28              :     columnAggregate: StringColumnAggregation;
      29              :     filteredColumnAggregation: WithFilterEpoch<ColumnAggregationVariant> | null;
      30              :     onFilter: MostFrequentValueFilterCallback;
      31              : }
      32              : 
      33            1 : function resolveRowUsingOffset(offsets: BigInt64Array, offset: number) {
      34              :     /// XXX Binary search
      35            1 :     for (let i = 0; i < offsets.length; ++i) {
      36            1 :         if (offsets[i] > offset) {
      37            1 :             return Math.max(i - 1, 0);
      38              :         }
      39              :     }
      40            1 :     return Math.max(offsets.length - 1, 0);
      41            1 : }
      42              : 
      43            1 : function MostFrequentCellImpl(props: MostFrequentCellProps): React.ReactElement {
      44            1 :     const svgContainer = React.useRef<HTMLDivElement>(null);
      45            1 :     const svgContainerSize = observeSize(svgContainer);
      46            1 :     const pendingFilterFrame = React.useRef<number | null>(null);
      47            1 :     const pendingFilterValueId = React.useRef<number | null>(null);
      48            1 :     const diagonalStripesId = React.useId();
      49            1 :     const roundedBarClipId = React.useId();
      50              : 
      51            1 :     const margin = { top: 4, right: 8, bottom: 20, left: 8 },
      52            1 :         width = (svgContainerSize?.width ?? 130) - margin.left - margin.right,
      53            1 :         height = (svgContainerSize?.height ?? 50) - margin.top - margin.bottom;
      54              : 
      55              :     // Resolve the frequent values
      56            1 :     const frequentValues = props.columnAggregate.frequentValuesTable;
      57            1 :     const frequentValueStrings = props.columnAggregate.analysis.frequentValueStrings;
      58            1 :     const frequentValueCounts = props.columnAggregate.analysis.frequentValueCounts;
      59            1 :     const frequentValuePercentages = props.columnAggregate.analysis.frequentValuePercentages;
      60            1 :     const isUnique = props.columnAggregate.analysis.isUnique;
      61            1 :     const hasMore = props.columnAggregate.analysis.countDistinct > props.columnAggregate.frequentValuesTable.numRows;
      62              : 
      63              :     const moreButtonWidth = 8;
      64            1 :     const barWidth = Math.max(width - (hasMore ? moreButtonWidth : 0), 0);
      65              : 
      66              :     // Find row of null value
      67            1 :     let nullRow: number | null = null;
      68            1 :     for (let i = 0; i < frequentValueStrings.length; ++i) {
      69            1 :         if (frequentValueStrings[i] == null) {
      70            1 :             nullRow = i;
      71              :         }
      72              :     }
      73              : 
      74              :     // Compute x-scale and offsets
      75            1 :     const [xScale, xOffsets, xCounts, xSum] = React.useMemo(() => {
      76            1 :         assert(frequentValues.schema.fields[1].name == "count" || frequentValues.schema.fields[2].name == "count");
      77              : 
      78            1 :         const xCounts: BigInt64Array = frequentValueCounts;
      79            1 :         const xOffsets: BigInt64Array = new BigInt64Array(xCounts.length);
      80            1 :         let xSum = BigInt(0);
      81            1 :         for (let i = 0; i < xCounts.length; ++i) {
      82            1 :             xOffsets[i] = xSum;
      83            1 :             xSum += xCounts[i];
      84            1 :         }
      85            1 :         const xScale = d3.scaleLinear()
      86            1 :             .range([0, barWidth])
      87            1 :             .domain([0, Number(xSum)]);
      88              : 
      89            1 :         return [xScale, xOffsets, xCounts, Number(xSum)];
      90            1 :     }, [frequentValues, barWidth]);
      91              : 
      92              :     // Build a lookup map from value ID to filtered count
      93            1 :     const filteredCountByValueId = React.useMemo(() => {
      94            1 :         if (props.filteredColumnAggregation?.type !== STRING_COLUMN) {
      95            1 :             return null;
      96              :         }
      97            1 :         const filteredAgg = props.filteredColumnAggregation.value;
      98            1 :         const map = new Map<bigint, bigint>();
      99            1 :         const filteredIds = filteredAgg.analysis.frequentValueIds;
     100            1 :         const filteredCounts = filteredAgg.analysis.frequentValueCounts;
     101            1 :         for (let i = 0; i < filteredIds.length; ++i) {
     102            1 :             map.set(filteredIds[i], filteredCounts[i]);
     103              :         }
     104            1 :         return map;
     105            1 :     }, [props.filteredColumnAggregation]);
     106              : 
     107            1 :     const filteredRowCount = props.filteredColumnAggregation?.type === STRING_COLUMN
     108            1 :         ? props.filteredColumnAggregation.value.analysis.totalCount
     109            1 :         : null;
     110              : 
     111              :     // Get the value IDs for the unfiltered aggregate
     112            1 :     const frequentValueIds = props.columnAggregate.analysis.frequentValueIds;
     113              : 
     114            1 :     const xUB = xScale(xSum);
     115            1 :     const xPadding = 0.5;
     116              : 
     117              :     // Track the focused bin id
     118            1 :     const [focusedRow, setFocusedRow] = React.useState<number | null>(null);
     119            1 :     let focusedValue: string | null = null;
     120            1 :     let focusDescription: string | null = null;
     121            1 :     if (focusedRow != null) {
     122            1 :         const value = frequentValueStrings[focusedRow];
     123            1 :         focusedValue = value == null ? null : truncateFocusedValue(value);
     124            1 :         const valueId = frequentValueIds[focusedRow];
     125            1 :         const filteredCount = filteredCountByValueId == null
     126            1 :             ? null
     127            1 :             : filteredCountByValueId.get(valueId) ?? 0n;
     128            1 :         focusDescription = formatHistogramFocusDescription(
     129            1 :             frequentValueCounts[focusedRow],
     130            1 :             props.columnAggregate.analysis.totalCount,
     131            1 :             filteredCount,
     132            1 :             filteredRowCount,
     133            1 :         );
     134            1 :     }
     135              : 
     136            1 :     const onFilterRef = React.useRef(props.onFilter);
     137            1 :     const tableAggregationRef = React.useRef(props.tableAggregation);
     138            1 :     const columnIndexRef = React.useRef(props.columnIndex);
     139            1 :     const columnAggregateRef = React.useRef(props.columnAggregate);
     140            1 :     onFilterRef.current = props.onFilter;
     141            1 :     tableAggregationRef.current = props.tableAggregation;
     142            1 :     columnIndexRef.current = props.columnIndex;
     143            1 :     columnAggregateRef.current = props.columnAggregate;
     144              : 
     145            1 :     const queueFilter = React.useCallback((valueId: number | null) => {
     146            1 :         pendingFilterValueId.current = valueId;
     147            1 :         if (pendingFilterFrame.current != null) {
     148            1 :             return;
     149              :         }
     150            1 :         pendingFilterFrame.current = requestAnimationFrame(() => {
     151            1 :             pendingFilterFrame.current = null;
     152            1 :             onFilterRef.current(
     153            1 :                 tableAggregationRef.current,
     154            1 :                 columnIndexRef.current,
     155            1 :                 columnAggregateRef.current,
     156            1 :                 pendingFilterValueId.current,
     157            1 :             );
     158            1 :         });
     159            1 :     }, []);
     160              : 
     161            1 :     React.useEffect(() => () => {
     162            1 :         if (pendingFilterFrame.current != null) {
     163            1 :             cancelAnimationFrame(pendingFilterFrame.current);
     164              :         }
     165            1 :     }, []);
     166              : 
     167              :     // Listen for pointer events events
     168            1 :     const onPointerOver = React.useCallback((elem: React.MouseEvent<SVGGElement>) => {
     169            1 :         const boundingBox = elem.currentTarget.getBoundingClientRect();
     170            1 :         const relativeX = elem.clientX - boundingBox.left;
     171            1 :         if (relativeX < 0 || relativeX >= barWidth || frequentValueStrings.length == 0) {
     172            1 :             setFocusedRow(null);
     173            1 :             return;
     174            1 :         }
     175            1 :         const invertedX = xScale.invert(relativeX);
     176            1 :         const row = Math.min(resolveRowUsingOffset(xOffsets, invertedX), frequentValueStrings.length - 1);
     177            1 :         setFocusedRow(row);
     178            1 :     }, [barWidth, frequentValueStrings.length, xOffsets, xScale]);
     179            1 :     const onPointerOut = React.useCallback((_elem: React.MouseEvent<SVGGElement>) => {
     180            1 :         setFocusedRow(null);
     181            1 :     }, []);
     182            1 :     const onClick = React.useCallback((elem: React.MouseEvent<SVGGElement>) => {
     183            1 :         const boundingBox = elem.currentTarget.getBoundingClientRect();
     184            1 :         const relativeX = elem.clientX - boundingBox.left;
     185            1 :         if (relativeX < 0 || relativeX >= barWidth || frequentValueStrings.length == 0) {
     186            1 :             return;
     187              :         }
     188            1 :         const invertedX = xScale.invert(relativeX);
     189            1 :         const row = Math.min(resolveRowUsingOffset(xOffsets, invertedX), frequentValueStrings.length - 1);
     190            1 :         elem.stopPropagation();
     191            1 :         queueFilter(Number(frequentValueIds[row]));
     192            1 :     }, [barWidth, frequentValueIds, frequentValueStrings.length, queueFilter, xOffsets, xScale]);
     193              : 
     194              :     // A column without rows has no frequent values
     195            1 :     const percentageLeft = frequentValuePercentages[0] ?? 0;
     196            1 :     const percentageRight = frequentValuePercentages[frequentValuePercentages.length - 1] ?? 0;
     197            1 :     const labelLeft = `${Math.round(percentageLeft * 100 * 100) / 100}%`;
     198            1 :     const labelRight = `${Math.round(percentageRight * 100 * 100) / 100}%`;
     199              : 
     200            1 :     return (
     201            1 :         <div
     202            1 :             className={props.className}
     203            1 :             style={{
     204            1 :                 ...props.style,
     205            1 :                 zIndex: focusedRow != null ? 100 : props.style?.zIndex
     206            1 :             }}
     207            1 :             onClick={() => queueFilter(null)}
     208              :         >
     209            1 :             <div className={styles.root}>
     210            1 :                 <div className={styles.header_container}>
     211            1 :                     {focusDescription ?? dataTypeToString(props.columnAggregate.columnEntry.inputFieldType)}
     212            1 :                 </div>
     213            1 :                 <div className={styles.plot_container} ref={svgContainer}>
     214            1 :                     <svg
     215            1 :                         className={styles.plot_svg}
     216            1 :                         width={width + margin.left + margin.right}
     217            1 :                         height={height + margin.top + margin.bottom}
     218              :                     >
     219            1 :                         <defs>
     220            1 :                             <pattern id={diagonalStripesId} patternUnits="userSpaceOnUse" width="4" height="4">
     221            1 :                                 <path d="M-1,1 l2,-2 M0,4 l4,-4 M3,5 l2,-2" style={{
     222            1 :                                     stroke: COLUMN_SUMMARY_COLORS.totalBar,
     223            1 :                                     strokeOpacity: 0.8,
     224            1 :                                     strokeWidth: 1
     225            1 :                                 }}
     226            1 :                                 />
     227            1 :                             </pattern>
     228            1 :                             <clipPath id={roundedBarClipId}>
     229            1 :                                 <rect x={0} y={0} width={width} height={height} rx={3} ry={3} />
     230            1 :                             </clipPath>
     231            1 :                         </defs>
     232            1 :                         <g transform={`translate(${margin.left},${margin.top})`} clipPath={`url(#${roundedBarClipId})`}>
     233            1 :                             <rect
     234            1 :                                 x={0}
     235            1 :                                 y={0}
     236            1 :                                 width={width}
     237            1 :                                 height={height}
     238            1 :                                 style={{ fill: COLUMN_SUMMARY_COLORS.totalBarWithFilter }}
     239            1 :                             />
     240            1 :                             {[...Array(frequentValueStrings.length)].map((_, i) => {
     241            1 :                                 const barX = Math.min(xScale(Number(xOffsets[i])) + xPadding, xUB);
     242            1 :                                 const barWidth = Math.max(xScale(Number(xCounts[i])) - 2 * xPadding, 0);
     243              :                                 // Skip if invalid
     244            1 :                                 if (isNaN(barX) || isNaN(barWidth)) return null;
     245              : 
     246            1 :                                 const totalCount = xCounts[i];
     247            1 :                                 const isNull = i == nullRow;
     248            1 :                                 const isFocused = i == focusedRow;
     249            1 :                                 const hasFilter = filteredCountByValueId != null;
     250              : 
     251              :                                 // Compute filtered fill height
     252            1 :                                 let fillHeight = 0;
     253            1 :                                 if (hasFilter && totalCount > 0n) {
     254            1 :                                     const valueId = frequentValueIds[i];
     255            1 :                                     const filteredCount = filteredCountByValueId.get(valueId) ?? 0n;
     256            1 :                                     fillHeight = height * Number(filteredCount) / Number(totalCount);
     257            1 :                                 }
     258              : 
     259            1 :                                 return (
     260            1 :                                     <g key={i}>
     261              :                                         {/* Background bar (total count) */}
     262            1 :                                         <rect
     263            1 :                                             x={barX}
     264            1 :                                             y={0}
     265            1 :                                             width={barWidth}
     266            1 :                                             height={height}
     267            1 :                                             style={{ fill: getTotalBarColor(hasFilter, isFocused, isNull) }}
     268            1 :                                         />
     269              :                                         {/* Filtered fill (from bottom) */}
     270            1 :                                         {hasFilter && fillHeight > 0 && (
     271            1 :                                             <rect
     272            1 :                                                 x={barX}
     273            1 :                                                 y={height - fillHeight}
     274            1 :                                                 width={barWidth}
     275            1 :                                                 height={fillHeight}
     276            1 :                                                 style={{ fill: getFilteredBarColor(isFocused, isNull) }}
     277            1 :                                             />
     278              :                                         )}
     279            1 :                                     </g>
     280              :                                 );
     281            1 :                             })}
     282            1 :                             {(nullRow != null) && <rect
     283            1 :                                 x={Math.min(xScale(Number(xOffsets[nullRow])) + xPadding, xUB)}
     284            1 :                                 y={0}
     285            1 :                                 width={Math.max(xScale(Number(xCounts[nullRow])) - 2 * xPadding, 0)}
     286            1 :                                 height={height}
     287            1 :                                 fill={`url(#${diagonalStripesId})`}
     288            1 :                             />}
     289            1 :                             {hasMore && (
     290            1 :                                 <g>
     291            1 :                                     <rect
     292            1 :                                         x={barWidth + xPadding}
     293            1 :                                         height={height}
     294            1 :                                         width={moreButtonWidth - xPadding}
     295            1 :                                         style={{ fill: getTotalBarColor(filteredCountByValueId != null, false) }}
     296            1 :                                     />
     297            1 :                                 </g>
     298              :                             )}
     299            1 :                         </g>
     300            1 :                         <g transform={`translate(${margin.left},${margin.top})`}>
     301            1 :                             <rect
     302            1 :                                 x={0} y={0}
     303            1 :                                 width={width}
     304            1 :                                 height={height}
     305            1 :                                 rx={3} ry={3}
     306            1 :                                 style={{ stroke: COLUMN_SUMMARY_BASELINE_COLOR }}
     307            1 :                                 strokeWidth={1}
     308            1 :                                 fill="transparent"
     309            1 :                             />
     310            1 :                         </g>
     311            1 :                         <g
     312            1 :                             transform={`translate(${margin.left},${margin.top})`}
     313            1 :                             onPointerOver={onPointerOver}
     314            1 :                             onPointerMove={onPointerOver}
     315            1 :                             onPointerOut={onPointerOut}
     316            1 :                             onClick={onClick}
     317              :                         >
     318            1 :                             <rect
     319            1 :                                 x={0} y={0}
     320            1 :                                 width={width}
     321            1 :                                 height={height + margin.bottom - 1}
     322            1 :                                 fillOpacity={0}
     323            1 :                             />
     324            1 :                         </g>
     325            1 :                     </svg>
     326            1 :                     {isUnique && (
     327            1 :                         <span className={styles.plot_label_overlay} style={{
     328            1 :                             top: `${margin.top + height / 2}px`,
     329            1 :                             left: `${margin.left + width / 2}px`,
     330            1 :                         }}>
     331              :                             all distinct
     332            1 :                         </span>
     333              :                     )}
     334            1 :                     {(focusedRow == null)
     335              :                         ? (
     336            1 :                             <div
     337            1 :                                 className={styles.axis_labels_container}
     338            1 :                                 style={{
     339            1 :                                     position: "absolute",
     340            1 :                                     top: `${margin.top + height + 2}px`,
     341            1 :                                     left: `${margin.left}px`,
     342            1 :                                     width: `${width}px`,
     343            1 :                                 }}
     344              :                             >
     345            1 :                                 <span className={styles.axis_label_left}>{labelLeft}</span>
     346            1 :                                 <span className={styles.axis_label_right} >{labelRight}</span>
     347            1 :                             </div>
     348              :                         ) : (
     349            1 :                             <span className={styles.axis_label_overlay} style={{
     350            1 :                                 top: `${margin.top + height + 13 - 12}px`,
     351            1 :                                 left: `${margin.left + xScale(Number(xOffsets[focusedRow])) + xScale(Number(xCounts[focusedRow]) / 2)}px`,
     352            1 :                             }}>{focusedValue ?? NULL_SYMBOL}</span>
     353              :                         )}
     354            1 :                 </div>
     355            1 :             </div>
     356            1 :         </div>
     357              :     );
     358            1 : }
     359              : 
     360            1 : export const MostFrequentCell = React.memo(MostFrequentCellImpl, arePropsEqualWithStyle);
        

Generated by: LCOV version 2.0-1