github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/webapp/javascript/components/TimelineChart/Selection.plugin.ts (about)

     1  /* eslint eqeqeq: "off" 
     2    -- TODO: Initial logic used == instead of ===  */
     3  // extending logic of Flot's selection plugin (react-flot/flot/jquery.flot.selection)
     4  import clamp from './clamp';
     5  import extractRange from './extractRange';
     6  
     7  const handleWidth = 4;
     8  const handleHeight = 22;
     9  
    10  interface IPlot extends jquery.flot.plot, jquery.flot.plotOptions {
    11    clearSelection: (preventEvent: boolean) => void;
    12    getSelection: () => void;
    13  }
    14  
    15  interface IFlotOptions extends jquery.flot.plotOptions {
    16    selection?: {
    17      selectionType: 'single' | 'double';
    18      mode?: 'x' | 'y';
    19      minSize: number;
    20      boundaryColor?: string;
    21      overlayColor?: string;
    22      shape: CanvasLineJoin;
    23      color: string;
    24      selectionWithHandler: boolean;
    25    };
    26  }
    27  
    28  type EventType = { pageX: number; pageY: number; which?: number };
    29  
    30  (function ($) {
    31    function init(plot: IPlot) {
    32      const placeholder = plot.getPlaceholder();
    33      const selection = {
    34        first: { x: -1, y: -1 },
    35        second: { x: -1, y: -1 },
    36        show: false,
    37        active: false,
    38        selectingSide: null,
    39      };
    40  
    41      // FIXME: The drag handling implemented here should be
    42      // abstracted out, there's some similar code from a library in
    43      // the navigation plugin, this should be massaged a bit to fit
    44      // the Flot cases here better and reused. Doing this would
    45      // make this plugin much slimmer.
    46      const savedhandlers: ShamefulAny = {};
    47  
    48      let mouseUpHandler: ShamefulAny = null;
    49  
    50      function getCursorPositionX(e: EventType) {
    51        const plotOffset = plot.getPlotOffset();
    52        const offset = placeholder.offset();
    53        return clamp(0, plot.width(), e.pageX - offset!.left - plotOffset.left);
    54      }
    55  
    56      function getPlotSelection() {
    57        // unlike function getSelection() which shows temp selection (it doesnt save any data between rerenders)
    58        // this function returns left X and right X coords of visible user selection (translates opts.grid.markings to X coords)
    59        const o = plot.getOptions();
    60        const plotOffset = plot.getPlotOffset();
    61        const extractedX = extractRange(plot, 'x');
    62  
    63        return {
    64          left:
    65            Math.floor(extractedX.axis.p2c(o.grid!.markings[0]?.xaxis.from)) +
    66            plotOffset.left,
    67          right:
    68            Math.floor(extractedX.axis.p2c(o.grid!.markings[0]?.xaxis.to)) +
    69            plotOffset.left,
    70        };
    71      }
    72  
    73      function getDragSide({
    74        x,
    75        leftSelectionX,
    76        rightSelectionX,
    77      }: {
    78        x: number;
    79        leftSelectionX: number;
    80        rightSelectionX: number;
    81      }) {
    82        const plotOffset = plot.getPlotOffset();
    83        const isLeftSelecting =
    84          Math.abs(x + plotOffset.left - leftSelectionX) <= 5;
    85        const isRightSelecting =
    86          Math.abs(x + plotOffset.left - rightSelectionX) <= 5;
    87  
    88        if (isLeftSelecting) {
    89          return 'left';
    90        }
    91        if (isRightSelecting) {
    92          return 'right';
    93        }
    94        return null;
    95      }
    96  
    97      function setCursor(type: string) {
    98        $('canvas.flot-overlay').css('cursor', type);
    99      }
   100  
   101      function onMouseMove(e: EventType) {
   102        const options: IFlotOptions = plot.getOptions();
   103  
   104        if (options?.selection?.selectionType === 'single') {
   105          const { left, right } = getPlotSelection();
   106          const clickX = getCursorPositionX(e);
   107          const dragSide = getDragSide({
   108            x: clickX,
   109            leftSelectionX: left,
   110            rightSelectionX: right,
   111          });
   112  
   113          if (dragSide) {
   114            setCursor('grab');
   115          } else {
   116            setCursor('crosshair');
   117          }
   118        }
   119  
   120        if (selection.active) {
   121          updateSelection(e);
   122  
   123          if (selection.selectingSide) {
   124            setCursor('grabbing');
   125          } else {
   126            setCursor('crosshair');
   127          }
   128  
   129          placeholder.trigger('plotselecting', [getSelection()]);
   130        }
   131      }
   132  
   133      function onMouseDown(e: EventType) {
   134        const options: IFlotOptions = plot.getOptions();
   135  
   136        if (e.which != 1)
   137          // only accept left-click
   138          return;
   139  
   140        // cancel out any text selections
   141        document.body.focus();
   142  
   143        // prevent text selection and drag in old-school browsers
   144        if (
   145          document.onselectstart !== undefined &&
   146          savedhandlers.onselectstart == null
   147        ) {
   148          savedhandlers.onselectstart = document.onselectstart;
   149          document.onselectstart = function () {
   150            return false;
   151          };
   152        }
   153        if (document.ondrag !== undefined && savedhandlers.ondrag == null) {
   154          savedhandlers.ondrag = document.ondrag;
   155          document.ondrag = function () {
   156            return false;
   157          };
   158        }
   159  
   160        if (options?.selection?.selectionType === 'single') {
   161          const { left, right } = getPlotSelection();
   162          const clickX = getCursorPositionX(e);
   163          const dragSide = getDragSide({
   164            x: clickX,
   165            leftSelectionX: left,
   166            rightSelectionX: right,
   167          });
   168  
   169          if (dragSide) {
   170            setCursor('grabbing');
   171          }
   172  
   173          const offset = placeholder.offset();
   174          const plotOffset = plot.getPlotOffset();
   175  
   176          if (dragSide === 'right') {
   177            setSelectionPos(selection.first, {
   178              pageX: left - plotOffset.left + offset!.left + plotOffset.left,
   179            } as EventType);
   180          } else if (dragSide === 'left') {
   181            setSelectionPos(selection.first, {
   182              pageX: right - plotOffset.left + offset!.left + plotOffset.left,
   183            } as EventType);
   184          } else {
   185            setSelectionPos(selection.first, e);
   186          }
   187  
   188          (selection.selectingSide as 'left' | 'right' | null) = dragSide;
   189        } else {
   190          setSelectionPos(selection.first, e);
   191        }
   192  
   193        selection.active = true;
   194  
   195        // this is a bit silly, but we have to use a closure to be
   196        // able to whack the same handler again
   197        mouseUpHandler = function (e: EventType) {
   198          onMouseUp(e);
   199        };
   200  
   201        $(document).one('mouseup', mouseUpHandler);
   202      }
   203  
   204      function onMouseUp(e: EventType) {
   205        mouseUpHandler = null;
   206  
   207        // revert drag stuff for old-school browsers
   208        if (document.onselectstart !== undefined)
   209          document.onselectstart = savedhandlers.onselectstart;
   210        if (document.ondrag !== undefined) document.ondrag = savedhandlers.ondrag;
   211  
   212        // no more dragging
   213        selection.active = false;
   214        updateSelection(e);
   215  
   216        if (selectionIsSane()) triggerSelectedEvent();
   217        else {
   218          // this counts as a clear
   219          placeholder.trigger('plotunselected', []);
   220          placeholder.trigger('plotselecting', [null]);
   221        }
   222  
   223        setCursor('crosshair');
   224  
   225        return false;
   226      }
   227  
   228      function getSelection() {
   229        if (!selectionIsSane()) return null;
   230  
   231        if (!selection.show) return null;
   232  
   233        const r: ShamefulAny = {};
   234        const c1 = selection.first;
   235        const c2 = selection.second;
   236        $.each(plot.getAxes(), function (name, axis: ShamefulAny) {
   237          if (axis.used) {
   238            const p1 = axis.c2p(c1[axis.direction as 'x' | 'y']);
   239            const p2 = axis.c2p(c2[axis.direction as 'x' | 'y']);
   240            r[name] = { from: Math.min(p1, p2), to: Math.max(p1, p2) };
   241          }
   242        });
   243        return r;
   244      }
   245  
   246      function triggerSelectedEvent() {
   247        const r = getSelection();
   248  
   249        placeholder.trigger('plotselected', [r]);
   250  
   251        // backwards-compat stuff, to be removed in future
   252        if (r.xaxis && r.yaxis)
   253          placeholder.trigger('selected', [
   254            {
   255              x1: r.xaxis.from,
   256              y1: r.yaxis.from,
   257              x2: r.xaxis.to,
   258              y2: r.yaxis.to,
   259            },
   260          ]);
   261      }
   262  
   263      function setSelectionPos(pos: { x: number; y: number }, e: EventType) {
   264        const options: IFlotOptions = plot.getOptions();
   265        const offset = placeholder.offset();
   266        const plotOffset = plot.getPlotOffset();
   267        pos.x = clamp(0, plot.width(), e.pageX - offset!.left - plotOffset.left);
   268        pos.y = clamp(0, plot.height(), e.pageY - offset!.top - plotOffset.top);
   269  
   270        if (options?.selection?.mode == 'y')
   271          pos.x = pos == selection.first ? 0 : plot.width();
   272  
   273        if (options?.selection?.mode == 'x')
   274          pos.y = pos == selection.first ? 0 : plot.height();
   275      }
   276  
   277      function updateSelection(pos: EventType) {
   278        if (pos.pageX == null) return;
   279  
   280        setSelectionPos(selection.second, pos);
   281        if (selectionIsSane()) {
   282          selection.show = true;
   283          plot.triggerRedrawOverlay();
   284        } else clearSelection(true);
   285      }
   286  
   287      function clearSelection(preventEvent: boolean) {
   288        if (selection.show) {
   289          selection.show = false;
   290          plot.triggerRedrawOverlay();
   291          if (!preventEvent) placeholder.trigger('plotunselected', []);
   292        }
   293      }
   294  
   295      function selectionIsSane() {
   296        const options: IFlotOptions = plot.getOptions();
   297        const minSize = options?.selection?.minSize || 5;
   298  
   299        return (
   300          Math.abs(selection.second.x - selection.first.x) >= minSize &&
   301          Math.abs(selection.second.y - selection.first.y) >= minSize
   302        );
   303      }
   304  
   305      plot.clearSelection = clearSelection;
   306      plot.getSelection = getSelection;
   307  
   308      plot.hooks!.bindEvents!.push(function (plot, eventHolder) {
   309        const options: IFlotOptions = plot.getOptions();
   310        if (options?.selection?.mode != null) {
   311          eventHolder.mousemove(onMouseMove);
   312          eventHolder.mousedown(onMouseDown);
   313        }
   314      });
   315  
   316      plot.hooks!.drawOverlay!.push(function (plot, ctx) {
   317        // draw selection
   318        if (selection.show && selectionIsSane()) {
   319          const plotOffset = plot.getPlotOffset();
   320          const options: IFlotOptions = plot.getOptions();
   321  
   322          ctx.save();
   323          ctx.translate(plotOffset.left, plotOffset.top);
   324  
   325          const c = ($ as ShamefulAny).color.parse(options?.selection?.color);
   326  
   327          ctx.strokeStyle = c.scale('a', 0.8).toString();
   328          ctx.lineWidth = 1;
   329          ctx.lineJoin = options.selection!.shape;
   330          ctx.fillStyle = c.scale('a', 0.4).toString();
   331  
   332          const x = Math.min(selection.first.x, selection.second.x) + 0.5;
   333          const y = Math.min(selection.first.y, selection.second.y) + 0.5;
   334          const w = Math.abs(selection.second.x - selection.first.x) - 1;
   335          const h = Math.abs(selection.second.y - selection.first.y) - 1;
   336  
   337          if (selection.selectingSide) {
   338            ctx.fillStyle = options?.selection?.overlayColor || 'transparent';
   339            ctx.fillRect(x, y, w, h);
   340            drawHorizontalSelectionLines({
   341              ctx,
   342              opts: options,
   343              leftX: x,
   344              rightX: x + w,
   345              yMax: h,
   346              yMin: 0,
   347            });
   348            drawVerticalSelectionLines({
   349              ctx,
   350              opts: options,
   351              leftX: x,
   352              rightX: x + w,
   353              yMax: h,
   354              yMin: 0,
   355              drawHandles: false,
   356            });
   357  
   358            drawRoundedRect(
   359              ctx,
   360              (selection.selectingSide === 'left' ? x : x + w) -
   361                handleWidth / 2 +
   362                0.5,
   363              h / 2 - handleHeight / 2 - 1,
   364              handleWidth,
   365              handleHeight,
   366              2,
   367              options?.selection?.boundaryColor
   368            );
   369          } else {
   370            ctx.fillRect(x, y, w, h);
   371            ctx.strokeRect(x, y, w, h);
   372          }
   373  
   374          ctx.restore();
   375        }
   376      });
   377  
   378      plot.hooks!.draw!.push(function (plot, ctx) {
   379        const options: IFlotOptions = plot.getOptions();
   380  
   381        if (
   382          options?.selection?.selectionType === 'single' &&
   383          options?.selection?.selectionWithHandler
   384        ) {
   385          const plotOffset = plot.getPlotOffset();
   386          const extractedY = extractRange(plot, 'y');
   387          const { left, right } = getPlotSelection();
   388  
   389          const yMax =
   390            Math.floor(extractedY.axis.p2c(extractedY.axis.min)) + plotOffset.top;
   391          const yMin = 0 + plotOffset.top;
   392  
   393          // draw selection overlay
   394          ctx.fillStyle = options.selection.overlayColor || 'transparent';
   395          ctx.fillRect(left, yMin, right - left, yMax - plotOffset.top);
   396  
   397          drawHorizontalSelectionLines({
   398            ctx,
   399            opts: options,
   400            leftX: left,
   401            rightX: right,
   402            yMax,
   403            yMin,
   404          });
   405          drawVerticalSelectionLines({
   406            ctx,
   407            opts: options,
   408            leftX: left + 0.5,
   409            rightX: right - 0.5,
   410            yMax,
   411            yMin: yMin + 4,
   412            drawHandles: true,
   413          });
   414        }
   415      });
   416  
   417      plot.hooks!.shutdown!.push(function (plot, eventHolder) {
   418        eventHolder.unbind('mousemove', onMouseMove);
   419        eventHolder.unbind('mousedown', onMouseDown);
   420  
   421        if (mouseUpHandler) $(document).unbind('mouseup', mouseUpHandler);
   422      });
   423    }
   424  
   425    $.plot.plugins.push({
   426      init,
   427      options: {
   428        selection: {
   429          mode: null, // one of null, "x", "y" or "xy"
   430          color: '#e8cfac',
   431          shape: 'round', // one of "round", "miter", or "bevel"
   432          minSize: 5, // minimum number of pixels
   433        },
   434      },
   435      name: 'selection',
   436      version: '1.1',
   437    });
   438  })(jQuery);
   439  
   440  const drawVerticalSelectionLines = ({
   441    ctx,
   442    opts,
   443    leftX,
   444    rightX,
   445    yMax,
   446    yMin,
   447    drawHandles,
   448  }: {
   449    ctx: ShamefulAny;
   450    opts: ShamefulAny;
   451    leftX: number;
   452    rightX: number;
   453    yMax: number;
   454    yMin: number;
   455    drawHandles: boolean;
   456  }) => {
   457    if (leftX && rightX && yMax) {
   458      const lineWidth =
   459        opts.grid.markings?.[opts.grid.markings?.length - 1].lineWidth || 1;
   460      const subPixel = lineWidth / 2 || 0;
   461      // left line
   462      ctx.beginPath();
   463      ctx.strokeStyle = opts.selection.boundaryColor;
   464      ctx.lineWidth = lineWidth;
   465  
   466      if (opts?.selection?.selectionType === 'single') {
   467        ctx.setLineDash([2]);
   468      }
   469  
   470      ctx.moveTo(leftX + subPixel, yMax);
   471      ctx.lineTo(leftX + subPixel, yMin);
   472      ctx.stroke();
   473  
   474      if (drawHandles) {
   475        drawRoundedRect(
   476          ctx,
   477          leftX - handleWidth / 2 + subPixel,
   478          yMax / 2 - handleHeight / 2 + 3,
   479          handleWidth,
   480          handleHeight,
   481          2,
   482          opts.selection.boundaryColor
   483        );
   484      }
   485  
   486      // right line
   487      ctx.beginPath();
   488      ctx.strokeStyle = opts.selection.boundaryColor;
   489      ctx.lineWidth = lineWidth;
   490  
   491      if (opts?.selection?.selectionType === 'single') {
   492        ctx.setLineDash([2]);
   493      }
   494  
   495      ctx.moveTo(rightX + subPixel, yMax);
   496      ctx.lineTo(rightX + subPixel, yMin);
   497      ctx.stroke();
   498  
   499      if (drawHandles) {
   500        drawRoundedRect(
   501          ctx,
   502          rightX - handleWidth / 2 + subPixel,
   503          yMax / 2 - handleHeight / 2 + 3,
   504          handleWidth,
   505          handleHeight,
   506          2,
   507          opts.selection.boundaryColor
   508        );
   509      }
   510    }
   511  };
   512  
   513  const drawHorizontalSelectionLines = ({
   514    ctx,
   515    opts,
   516    leftX,
   517    rightX,
   518    yMax,
   519    yMin,
   520  }: {
   521    ctx: ShamefulAny;
   522    opts: ShamefulAny;
   523    leftX: number;
   524    rightX: number;
   525    yMax: number;
   526    yMin: number;
   527  }) => {
   528    if (leftX && rightX && yMax) {
   529      const topLineWidth = 4;
   530      const lineWidth =
   531        opts.grid.markings?.[opts.grid.markings?.length - 1].lineWidth || 1;
   532      const subPixel = lineWidth / 2 || 0;
   533  
   534      // top line
   535      ctx.beginPath();
   536      ctx.strokeStyle = opts.selection.boundaryColor;
   537      ctx.lineWidth = topLineWidth;
   538      ctx.setLineDash([]);
   539      ctx.moveTo(rightX + subPixel, yMin + topLineWidth / 2);
   540      ctx.lineTo(leftX + subPixel, yMin + topLineWidth / 2);
   541      ctx.stroke();
   542  
   543      // bottom line
   544      ctx.beginPath();
   545      ctx.strokeStyle = opts.selection.boundaryColor;
   546      ctx.lineWidth = lineWidth;
   547      ctx.setLineDash([2]);
   548      ctx.moveTo(rightX + subPixel, yMax);
   549      ctx.lineTo(leftX + subPixel, yMax);
   550      ctx.stroke();
   551    }
   552  };
   553  
   554  function drawRoundedRect(
   555    ctx: ShamefulAny,
   556    left: number,
   557    top: number,
   558    width: number,
   559    height: number,
   560    radius: number,
   561    fillColor?: string
   562  ) {
   563    const K = (4 * (Math.SQRT2 - 1)) / 3;
   564    const right = left + width;
   565    const bottom = top + height;
   566    ctx.beginPath();
   567    ctx.setLineDash([]);
   568    // top left
   569    ctx.moveTo(left + radius, top);
   570    // top right
   571    ctx.lineTo(right - radius, top);
   572    // right top
   573    ctx.bezierCurveTo(
   574      right + radius * (K - 1),
   575      top,
   576      right,
   577      top + radius * (1 - K),
   578      right,
   579      top + radius
   580    );
   581    // right bottom
   582    ctx.lineTo(right, bottom - radius);
   583    // bottom right
   584    ctx.bezierCurveTo(
   585      right,
   586      bottom + radius * (K - 1),
   587      right + radius * (K - 1),
   588      bottom,
   589      right - radius,
   590      bottom
   591    );
   592    // bottom left
   593    ctx.lineTo(left + radius, bottom);
   594    // left bottom
   595    ctx.bezierCurveTo(
   596      left + radius * (1 - K),
   597      bottom,
   598      left,
   599      bottom + radius * (K - 1),
   600      left,
   601      bottom - radius
   602    );
   603    // left top
   604    ctx.lineTo(left, top + radius);
   605    // top left again
   606    ctx.bezierCurveTo(
   607      left,
   608      top + radius * (1 - K),
   609      left + radius * (1 - K),
   610      top,
   611      left + radius,
   612      top
   613    );
   614    ctx.lineWidth = 1;
   615    ctx.strokeStyle = fillColor;
   616    ctx.fillStyle = fillColor;
   617    ctx.fill();
   618    ctx.stroke();
   619  }