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 }