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