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);
|