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 }