github.com/grafana/pyroscope@v1.18.0/public/app/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  
   141        // cancel out any text selections
   142        document.body.focus();
   143  
   144        // prevent text selection and drag in old-school browsers
   145        if (
   146          document.onselectstart !== undefined &&
   147          savedhandlers.onselectstart == null
   148        ) {
   149          savedhandlers.onselectstart = document.onselectstart;
   150          document.onselectstart = function () {
   151            return false;
   152          };
   153        }
   154        if (document.ondrag !== undefined && savedhandlers.ondrag == null) {
   155          savedhandlers.ondrag = document.ondrag;
   156          document.ondrag = function () {
   157            return false;
   158          };
   159        }
   160  
   161        if (options?.selection?.selectionType === 'single') {
   162          const { left, right } = getPlotSelection();
   163          const clickX = getCursorPositionX(e);
   164          const dragSide = getDragSide({
   165            x: clickX,
   166            leftSelectionX: left,
   167            rightSelectionX: right,
   168          });
   169  
   170          if (dragSide) {
   171            setCursor('grabbing');
   172          }
   173  
   174          const offset = placeholder.offset();
   175          const plotOffset = plot.getPlotOffset();
   176  
   177          if (dragSide === 'right') {
   178            setSelectionPos(selection.first, {
   179              pageX: left - plotOffset.left + offset!.left + plotOffset.left,
   180            } as EventType);
   181          } else if (dragSide === 'left') {
   182            setSelectionPos(selection.first, {
   183              pageX: right - plotOffset.left + offset!.left + plotOffset.left,
   184            } as EventType);
   185          } else {
   186            setSelectionPos(selection.first, e);
   187          }
   188  
   189          (selection.selectingSide as 'left' | 'right' | null) = dragSide;
   190        } else {
   191          setSelectionPos(selection.first, e);
   192        }
   193  
   194        selection.active = true;
   195  
   196        // this is a bit silly, but we have to use a closure to be
   197        // able to whack the same handler again
   198        mouseUpHandler = function (e: EventType) {
   199          onMouseUp(e);
   200        };
   201  
   202        $(document).one('mouseup', mouseUpHandler);
   203      }
   204  
   205      function onMouseUp(e: EventType) {
   206        mouseUpHandler = null;
   207  
   208        // revert drag stuff for old-school browsers
   209        if (document.onselectstart !== undefined) {
   210          document.onselectstart = savedhandlers.onselectstart;
   211        }
   212        if (document.ondrag !== undefined) {
   213          document.ondrag = savedhandlers.ondrag;
   214        }
   215  
   216        // no more dragging
   217        selection.active = false;
   218        updateSelection(e);
   219  
   220        if (selectionIsSane()) {
   221          triggerSelectedEvent();
   222        } else {
   223          // this counts as a clear
   224          placeholder.trigger('plotunselected', []);
   225          placeholder.trigger('plotselecting', [null]);
   226        }
   227  
   228        setCursor('crosshair');
   229  
   230        return false;
   231      }
   232  
   233      function getSelection() {
   234        if (!selectionIsSane()) {
   235          return null;
   236        }
   237  
   238        if (!selection.show) {
   239          return null;
   240        }
   241  
   242        const r: ShamefulAny = {};
   243        const c1 = selection.first;
   244        const c2 = selection.second;
   245        $.each(plot.getAxes(), function (name, axis: ShamefulAny) {
   246          if (axis.used) {
   247            const p1 = axis.c2p(c1[axis.direction as 'x' | 'y']);
   248            const p2 = axis.c2p(c2[axis.direction as 'x' | 'y']);
   249            r[name] = { from: Math.min(p1, p2), to: Math.max(p1, p2) };
   250          }
   251        });
   252        return r;
   253      }
   254  
   255      function triggerSelectedEvent() {
   256        const r = getSelection();
   257  
   258        placeholder.trigger('plotselected', [r]);
   259  
   260        // backwards-compat stuff, to be removed in future
   261        if (r.xaxis && r.yaxis) {
   262          placeholder.trigger('selected', [
   263            {
   264              x1: r.xaxis.from,
   265              y1: r.yaxis.from,
   266              x2: r.xaxis.to,
   267              y2: r.yaxis.to,
   268            },
   269          ]);
   270        }
   271      }
   272  
   273      function setSelectionPos(pos: { x: number; y: number }, e: EventType) {
   274        const options: IFlotOptions = plot.getOptions();
   275        const offset = placeholder.offset();
   276        const plotOffset = plot.getPlotOffset();
   277        pos.x = clamp(0, plot.width(), e.pageX - offset!.left - plotOffset.left);
   278        pos.y = clamp(0, plot.height(), e.pageY - offset!.top - plotOffset.top);
   279  
   280        if (options?.selection?.mode == 'y') {
   281          pos.x = pos == selection.first ? 0 : plot.width();
   282        }
   283  
   284        if (options?.selection?.mode == 'x') {
   285          pos.y = pos == selection.first ? 0 : plot.height();
   286        }
   287      }
   288  
   289      function updateSelection(pos: EventType) {
   290        if (pos.pageX == null) {
   291          return;
   292        }
   293  
   294        setSelectionPos(selection.second, pos);
   295        if (selectionIsSane()) {
   296          selection.show = true;
   297          plot.triggerRedrawOverlay();
   298        } else {
   299          clearSelection(true);
   300        }
   301      }
   302  
   303      function clearSelection(preventEvent: boolean) {
   304        if (selection.show) {
   305          selection.show = false;
   306          plot.triggerRedrawOverlay();
   307          if (!preventEvent) {
   308            placeholder.trigger('plotunselected', []);
   309          }
   310        }
   311      }
   312  
   313      function selectionIsSane() {
   314        const options: IFlotOptions = plot.getOptions();
   315        const minSize = options?.selection?.minSize || 5;
   316  
   317        return (
   318          Math.abs(selection.second.x - selection.first.x) >= minSize &&
   319          Math.abs(selection.second.y - selection.first.y) >= minSize
   320        );
   321      }
   322  
   323      plot.clearSelection = clearSelection;
   324      plot.getSelection = getSelection;
   325  
   326      plot.hooks!.bindEvents!.push(function (plot, eventHolder) {
   327        const options: IFlotOptions = plot.getOptions();
   328        if (options?.selection?.mode != null) {
   329          eventHolder.mousemove(onMouseMove);
   330          eventHolder.mousedown(onMouseDown);
   331        }
   332      });
   333  
   334      plot.hooks!.drawOverlay!.push(function (plot, ctx) {
   335        // draw selection
   336        if (selection.show && selectionIsSane()) {
   337          const plotOffset = plot.getPlotOffset();
   338          const options: IFlotOptions = plot.getOptions();
   339  
   340          ctx.save();
   341          ctx.translate(plotOffset.left, plotOffset.top);
   342  
   343          const c = ($ as ShamefulAny).color.parse(options?.selection?.color);
   344  
   345          ctx.strokeStyle = c.scale('a', 0.8).toString();
   346          ctx.lineWidth = 1;
   347          ctx.lineJoin = options.selection!.shape;
   348          ctx.fillStyle = c.scale('a', 0.4).toString();
   349  
   350          const x = Math.min(selection.first.x, selection.second.x) + 0.5;
   351          const y = Math.min(selection.first.y, selection.second.y) + 0.5;
   352          const w = Math.abs(selection.second.x - selection.first.x) - 1;
   353          const h = Math.abs(selection.second.y - selection.first.y) - 1;
   354  
   355          if (selection.selectingSide) {
   356            ctx.fillStyle = options?.selection?.overlayColor || 'transparent';
   357            ctx.fillRect(x, y, w, h);
   358            drawHorizontalSelectionLines({
   359              ctx,
   360              opts: options,
   361              leftX: x,
   362              rightX: x + w,
   363              yMax: h,
   364              yMin: 0,
   365            });
   366            drawVerticalSelectionLines({
   367              ctx,
   368              opts: options,
   369              leftX: x,
   370              rightX: x + w,
   371              yMax: h,
   372              yMin: 0,
   373              drawHandles: false,
   374            });
   375  
   376            drawRoundedRect(
   377              ctx,
   378              (selection.selectingSide === 'left' ? x : x + w) -
   379                handleWidth / 2 +
   380                0.5,
   381              h / 2 - handleHeight / 2 - 1,
   382              handleWidth,
   383              handleHeight,
   384              2,
   385              options?.selection?.boundaryColor
   386            );
   387          } else {
   388            ctx.fillRect(x, y, w, h);
   389            ctx.strokeRect(x, y, w, h);
   390          }
   391  
   392          ctx.restore();
   393        }
   394      });
   395  
   396      plot.hooks!.draw!.push(function (plot, ctx) {
   397        const options: IFlotOptions = plot.getOptions();
   398  
   399        if (
   400          options?.selection?.selectionType === 'single' &&
   401          options?.selection?.selectionWithHandler
   402        ) {
   403          const plotOffset = plot.getPlotOffset();
   404          const extractedY = extractRange(plot, 'y');
   405          const { left, right } = getPlotSelection();
   406  
   407          const yMax =
   408            Math.floor(extractedY.axis.p2c(extractedY.axis.min)) + plotOffset.top;
   409          const yMin = 0 + plotOffset.top;
   410  
   411          // draw selection overlay
   412          ctx.fillStyle = options.selection.overlayColor || 'transparent';
   413          ctx.fillRect(left, yMin, right - left, yMax - plotOffset.top);
   414  
   415          drawHorizontalSelectionLines({
   416            ctx,
   417            opts: options,
   418            leftX: left,
   419            rightX: right,
   420            yMax,
   421            yMin,
   422          });
   423          drawVerticalSelectionLines({
   424            ctx,
   425            opts: options,
   426            leftX: left + 0.5,
   427            rightX: right - 0.5,
   428            yMax,
   429            yMin: yMin + 4,
   430            drawHandles: true,
   431          });
   432        }
   433      });
   434  
   435      plot.hooks!.shutdown!.push(function (plot, eventHolder) {
   436        eventHolder.unbind('mousemove', onMouseMove);
   437        eventHolder.unbind('mousedown', onMouseDown);
   438  
   439        if (mouseUpHandler) {
   440          $(document).unbind('mouseup', mouseUpHandler);
   441        }
   442      });
   443    }
   444  
   445    $.plot.plugins.push({
   446      init,
   447      options: {
   448        selection: {
   449          mode: null, // one of null, "x", "y" or "xy"
   450          color: '#e8cfac',
   451          shape: 'round', // one of "round", "miter", or "bevel"
   452          minSize: 5, // minimum number of pixels
   453        },
   454      },
   455      name: 'selection',
   456      version: '1.1',
   457    });
   458  })(jQuery);
   459  
   460  const drawVerticalSelectionLines = ({
   461    ctx,
   462    opts,
   463    leftX,
   464    rightX,
   465    yMax,
   466    yMin,
   467    drawHandles,
   468  }: {
   469    ctx: ShamefulAny;
   470    opts: ShamefulAny;
   471    leftX: number;
   472    rightX: number;
   473    yMax: number;
   474    yMin: number;
   475    drawHandles: boolean;
   476  }) => {
   477    if (leftX && rightX && yMax) {
   478      const lineWidth =
   479        opts.grid.markings?.[opts.grid.markings?.length - 1].lineWidth || 1;
   480      const subPixel = lineWidth / 2 || 0;
   481      // left line
   482      ctx.beginPath();
   483      ctx.strokeStyle = opts.selection.boundaryColor;
   484      ctx.lineWidth = lineWidth;
   485  
   486      if (opts?.selection?.selectionType === 'single') {
   487        ctx.setLineDash([2]);
   488      }
   489  
   490      ctx.moveTo(leftX + subPixel, yMax);
   491      ctx.lineTo(leftX + subPixel, yMin);
   492      ctx.stroke();
   493  
   494      if (drawHandles) {
   495        drawRoundedRect(
   496          ctx,
   497          leftX - handleWidth / 2 + subPixel,
   498          yMax / 2 - handleHeight / 2 + 3,
   499          handleWidth,
   500          handleHeight,
   501          2,
   502          opts.selection.boundaryColor
   503        );
   504      }
   505  
   506      // right line
   507      ctx.beginPath();
   508      ctx.strokeStyle = opts.selection.boundaryColor;
   509      ctx.lineWidth = lineWidth;
   510  
   511      if (opts?.selection?.selectionType === 'single') {
   512        ctx.setLineDash([2]);
   513      }
   514  
   515      ctx.moveTo(rightX + subPixel, yMax);
   516      ctx.lineTo(rightX + subPixel, yMin);
   517      ctx.stroke();
   518  
   519      if (drawHandles) {
   520        drawRoundedRect(
   521          ctx,
   522          rightX - handleWidth / 2 + subPixel,
   523          yMax / 2 - handleHeight / 2 + 3,
   524          handleWidth,
   525          handleHeight,
   526          2,
   527          opts.selection.boundaryColor
   528        );
   529      }
   530    }
   531  };
   532  
   533  const drawHorizontalSelectionLines = ({
   534    ctx,
   535    opts,
   536    leftX,
   537    rightX,
   538    yMax,
   539    yMin,
   540  }: {
   541    ctx: ShamefulAny;
   542    opts: ShamefulAny;
   543    leftX: number;
   544    rightX: number;
   545    yMax: number;
   546    yMin: number;
   547  }) => {
   548    if (leftX && rightX && yMax) {
   549      const topLineWidth = 4;
   550      const lineWidth =
   551        opts.grid.markings?.[opts.grid.markings?.length - 1].lineWidth || 1;
   552      const subPixel = lineWidth / 2 || 0;
   553  
   554      // top line
   555      ctx.beginPath();
   556      ctx.strokeStyle = opts.selection.boundaryColor;
   557      ctx.lineWidth = topLineWidth;
   558      ctx.setLineDash([]);
   559      ctx.moveTo(rightX + subPixel, yMin + topLineWidth / 2);
   560      ctx.lineTo(leftX + subPixel, yMin + topLineWidth / 2);
   561      ctx.stroke();
   562  
   563      // bottom line
   564      ctx.beginPath();
   565      ctx.strokeStyle = opts.selection.boundaryColor;
   566      ctx.lineWidth = lineWidth;
   567      ctx.setLineDash([2]);
   568      ctx.moveTo(rightX + subPixel, yMax);
   569      ctx.lineTo(leftX + subPixel, yMax);
   570      ctx.stroke();
   571    }
   572  };
   573  
   574  function drawRoundedRect(
   575    ctx: ShamefulAny,
   576    left: number,
   577    top: number,
   578    width: number,
   579    height: number,
   580    radius: number,
   581    fillColor?: string
   582  ) {
   583    const K = (4 * (Math.SQRT2 - 1)) / 3;
   584    const right = left + width;
   585    const bottom = top + height;
   586    ctx.beginPath();
   587    ctx.setLineDash([]);
   588    // top left
   589    ctx.moveTo(left + radius, top);
   590    // top right
   591    ctx.lineTo(right - radius, top);
   592    // right top
   593    ctx.bezierCurveTo(
   594      right + radius * (K - 1),
   595      top,
   596      right,
   597      top + radius * (1 - K),
   598      right,
   599      top + radius
   600    );
   601    // right bottom
   602    ctx.lineTo(right, bottom - radius);
   603    // bottom right
   604    ctx.bezierCurveTo(
   605      right,
   606      bottom + radius * (K - 1),
   607      right + radius * (K - 1),
   608      bottom,
   609      right - radius,
   610      bottom
   611    );
   612    // bottom left
   613    ctx.lineTo(left + radius, bottom);
   614    // left bottom
   615    ctx.bezierCurveTo(
   616      left + radius * (1 - K),
   617      bottom,
   618      left,
   619      bottom + radius * (K - 1),
   620      left,
   621      bottom - radius
   622    );
   623    // left top
   624    ctx.lineTo(left, top + radius);
   625    // top left again
   626    ctx.bezierCurveTo(
   627      left,
   628      top + radius * (1 - K),
   629      left + radius * (1 - K),
   630      top,
   631      left + radius,
   632      top
   633    );
   634    ctx.lineWidth = 1;
   635    ctx.strokeStyle = fillColor;
   636    ctx.fillStyle = fillColor;
   637    ctx.fill();
   638    ctx.stroke();
   639  }