LCOV - code coverage report
Current view: top level - dashql-app/src/views/query_result - histogram_brush.ts Coverage Total Hit
Test: DashQL 915828b Lines: 98.4 % 124 122
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              : 
       4            1 : const BRUSH_SETTLE_DELAY_MS = 120;
       5              : 
       6            1 : function hideBrushSelection(container: SVGGElement): void {
       7            1 :     d3.select(container)
       8            1 :         .selectAll<SVGRectElement, unknown>('.selection, .handle')
       9            1 :         .style('display', 'none')
      10            1 :         .attr('x', 0)
      11            1 :         .attr('y', 0)
      12            1 :         .attr('width', 0)
      13            1 :         .attr('height', 0);
      14            1 : }
      15              : 
      16            1 : export class SettledBrushUpdates<T> {
      17            1 :     private timeout: number | null = null;
      18            1 :     private pending: T | null = null;
      19              : 
      20            1 :     public schedule(event: T, callback: (event: T) => void): void {
      21            1 :         this.pending = event;
      22            1 :         if (this.timeout != null) {
      23            1 :             window.clearTimeout(this.timeout);
      24              :         }
      25            1 :         this.timeout = window.setTimeout(() => {
      26            1 :             this.timeout = null;
      27            1 :             const pending = this.pending;
      28            1 :             this.pending = null;
      29            1 :             if (pending != null) {
      30            1 :                 callback(pending);
      31              :             }
      32            1 :         }, BRUSH_SETTLE_DELAY_MS);
      33            1 :     }
      34              : 
      35            1 :     public flush(event: T, callback: (event: T) => void): void {
      36            1 :         this.cancel();
      37            1 :         callback(event);
      38            1 :     }
      39              : 
      40            1 :     public cancel(): void {
      41            1 :         if (this.timeout != null) {
      42            1 :             window.clearTimeout(this.timeout);
      43            1 :             this.timeout = null;
      44            1 :         }
      45            1 :         this.pending = null;
      46            1 :     }
      47            1 : }
      48              : 
      49              : interface HistogramBrushOptions {
      50              :     xScale: d3.ScaleBand<string>;
      51              :     height: number;
      52              :     selection: [number, number] | null;
      53              :     onBrushUpdate: (event: d3.D3BrushEvent<unknown>) => void;
      54              :     onClear: () => void;
      55              :     onBrushingChange?: (isBrushing: boolean) => void;
      56              : }
      57              : 
      58            1 : export function useHistogramBrush(options: HistogramBrushOptions): {
      59              :     brushContainer: React.RefObject<SVGGElement | null>;
      60              :     clearBrush: () => void;
      61            1 : } {
      62            1 :     const brushContainer = React.useRef<SVGGElement>(null);
      63            1 :     const brushBehavior = React.useRef<d3.BrushBehavior<unknown> | null>(null);
      64            1 :     const syncingSelection = React.useRef(false);
      65            1 :     const settledBrushUpdates = React.useRef(new SettledBrushUpdates<d3.D3BrushEvent<unknown>>());
      66              : 
      67            1 :     const onBrushUpdateRef = React.useRef(options.onBrushUpdate);
      68            1 :     onBrushUpdateRef.current = options.onBrushUpdate;
      69              : 
      70            1 :     const onBrushStart = React.useCallback(() => {
      71            1 :         if (syncingSelection.current) {
      72            1 :             return;
      73              :         }
      74            1 :         settledBrushUpdates.current.cancel();
      75            1 :         options.onBrushingChange?.(true);
      76            1 :     }, [options.onBrushingChange]);
      77              : 
      78            1 :     const onBrushMove = React.useCallback((event: d3.D3BrushEvent<unknown>) => {
      79            1 :         if (syncingSelection.current) {
      80            1 :             return;
      81              :         }
      82            1 :         settledBrushUpdates.current.schedule(event, pending => onBrushUpdateRef.current(pending));
      83            1 :     }, []);
      84              : 
      85            1 :     const onBrushEnd = React.useCallback((event: d3.D3BrushEvent<unknown>) => {
      86            1 :         if (syncingSelection.current) {
      87            1 :             return;
      88              :         }
      89            1 :         settledBrushUpdates.current.flush(event, options.onBrushUpdate);
      90            1 :         options.onBrushingChange?.(false);
      91            1 :     }, [options.onBrushUpdate, options.onBrushingChange]);
      92              : 
      93            1 :     const onBrushStartRef = React.useRef(onBrushStart);
      94            1 :     const onBrushMoveRef = React.useRef(onBrushMove);
      95            1 :     const onBrushEndRef = React.useRef(onBrushEnd);
      96            1 :     onBrushStartRef.current = onBrushStart;
      97            1 :     onBrushMoveRef.current = onBrushMove;
      98            1 :     onBrushEndRef.current = onBrushEnd;
      99              : 
     100            1 :     React.useEffect(() => () => settledBrushUpdates.current.cancel(), []);
     101              : 
     102            1 :     React.useLayoutEffect(() => {
     103            1 :         const brush = d3.brushX()
     104            1 :             .extent([
     105            1 :                 [options.xScale.range()[0], 0],
     106            1 :                 [options.xScale.range()[1], options.height]
     107            1 :             ])
     108            1 :             .on('start', () => onBrushStartRef.current())
     109            1 :             .on('brush', event => onBrushMoveRef.current(event))
     110            1 :             .on('end', event => onBrushEndRef.current(event));
     111            1 :         brushBehavior.current = brush;
     112              : 
     113            1 :         d3.select(brushContainer.current!)
     114            1 :             .selectChildren()
     115            1 :             .remove();
     116            1 :         d3.select(brushContainer.current!)
     117            1 :             .call(brush)
     118            1 :             .selectAll('rect')
     119            1 :             .attr('y', 0)
     120            1 :             .attr('height', options.height);
     121            1 :         if (options.selection == null) {
     122            1 :             hideBrushSelection(brushContainer.current!);
     123              :         }
     124            1 :         return () => {
     125            1 :             brushBehavior.current = null;
     126            1 :         };
     127            1 :     }, [options.xScale, options.height]);
     128              : 
     129            1 :     React.useLayoutEffect(() => {
     130            1 :         const container = brushContainer.current;
     131            1 :         const brush = brushBehavior.current;
     132            1 :         if (container == null || brush == null) {
     133            0 :             return;
     134              :         }
     135            1 :         const current = d3.brushSelection(container) as [number, number] | null;
     136            1 :         const next = options.selection;
     137            1 :         if (current == null ? next == null : next != null && current[0] === next[0] && current[1] === next[1]) {
     138            1 :             return;
     139              :         }
     140            1 :         settledBrushUpdates.current.cancel();
     141            1 :         syncingSelection.current = true;
     142            1 :         try {
     143            1 :             d3.select(container).call(brush.move, next);
     144            1 :             if (next == null) {
     145            1 :                 hideBrushSelection(container);
     146              :             }
     147            1 :         } finally {
     148            1 :             syncingSelection.current = false;
     149            1 :         }
     150            1 :     }, [options.selection, options.xScale, options.height]);
     151              : 
     152            1 :     const clearBrush = React.useCallback(() => {
     153            1 :         const container = brushContainer.current;
     154            1 :         const brush = brushBehavior.current;
     155            1 :         if (container != null && brush != null) {
     156            1 :             d3.select(container).call(brush.move, null);
     157            1 :             hideBrushSelection(container);
     158            1 :             return;
     159            1 :         }
     160            0 :         options.onClear();
     161            1 :     }, [options.onClear]);
     162              : 
     163            1 :     return { brushContainer, clearBrush };
     164            1 : }
        

Generated by: LCOV version 2.0-1