1// Make svg pannable and zoomable. 2// Call clickHandler(t) if a click event is caught by the pan event handlers. 3function initPanAndZoom(svg, clickHandler) { 4 'use strict'; 5 6 // Current mouse/touch handling mode 7 const IDLE = 0; 8 const MOUSEPAN = 1; 9 const TOUCHPAN = 2; 10 const TOUCHZOOM = 3; 11 let mode = IDLE; 12 13 // State needed to implement zooming. 14 let currentScale = 1.0; 15 const initWidth = svg.viewBox.baseVal.width; 16 const initHeight = svg.viewBox.baseVal.height; 17 18 // State needed to implement panning. 19 let panLastX = 0; // Last event X coordinate 20 let panLastY = 0; // Last event Y coordinate 21 let moved = false; // Have we seen significant movement 22 let touchid = null; // Current touch identifier 23 24 // State needed for pinch zooming 25 let touchid2 = null; // Second id for pinch zooming 26 let initGap = 1.0; // Starting gap between two touches 27 let initScale = 1.0; // currentScale when pinch zoom started 28 let centerPoint = null; // Center point for scaling 29 30 // Convert event coordinates to svg coordinates. 31 function toSvg(x, y) { 32 const p = svg.createSVGPoint(); 33 p.x = x; 34 p.y = y; 35 let m = svg.getCTM(); 36 if (m == null) m = svg.getScreenCTM(); // Firefox workaround. 37 return p.matrixTransform(m.inverse()); 38 } 39 40 // Change the scaling for the svg to s, keeping the point denoted 41 // by u (in svg coordinates]) fixed at the same screen location. 42 function rescale(s, u) { 43 // Limit to a good range. 44 if (s < 0.2) s = 0.2; 45 if (s > 10.0) s = 10.0; 46 47 currentScale = s; 48 49 // svg.viewBox defines the visible portion of the user coordinate 50 // system. So to magnify by s, divide the visible portion by s, 51 // which will then be stretched to fit the viewport. 52 const vb = svg.viewBox; 53 const w1 = vb.baseVal.width; 54 const w2 = initWidth / s; 55 const h1 = vb.baseVal.height; 56 const h2 = initHeight / s; 57 vb.baseVal.width = w2; 58 vb.baseVal.height = h2; 59 60 // We also want to adjust vb.baseVal.x so that u.x remains at same 61 // screen X coordinate. In other words, want to change it from x1 to x2 62 // so that: 63 // (u.x - x1) / w1 = (u.x - x2) / w2 64 // Simplifying that, we get 65 // (u.x - x1) * (w2 / w1) = u.x - x2 66 // x2 = u.x - (u.x - x1) * (w2 / w1) 67 vb.baseVal.x = u.x - (u.x - vb.baseVal.x) * (w2 / w1); 68 vb.baseVal.y = u.y - (u.y - vb.baseVal.y) * (h2 / h1); 69 } 70 71 function handleWheel(e) { 72 if (e.deltaY == 0) return; 73 // Change scale factor by 1.1 or 1/1.1 74 rescale(currentScale * (e.deltaY < 0 ? 1.1 : (1/1.1)), 75 toSvg(e.offsetX, e.offsetY)); 76 } 77 78 function setMode(m) { 79 mode = m; 80 touchid = null; 81 touchid2 = null; 82 } 83 84 function panStart(x, y) { 85 moved = false; 86 panLastX = x; 87 panLastY = y; 88 } 89 90 function panMove(x, y) { 91 let dx = x - panLastX; 92 let dy = y - panLastY; 93 if (Math.abs(dx) <= 2 && Math.abs(dy) <= 2) return; // Ignore tiny moves 94 95 moved = true; 96 panLastX = x; 97 panLastY = y; 98 99 // Firefox workaround: get dimensions from parentNode. 100 const swidth = svg.clientWidth || svg.parentNode.clientWidth; 101 const sheight = svg.clientHeight || svg.parentNode.clientHeight; 102 103 // Convert deltas from screen space to svg space. 104 dx *= (svg.viewBox.baseVal.width / swidth); 105 dy *= (svg.viewBox.baseVal.height / sheight); 106 107 svg.viewBox.baseVal.x -= dx; 108 svg.viewBox.baseVal.y -= dy; 109 } 110 111 function handleScanStart(e) { 112 if (e.button != 0) return; // Do not catch right-clicks etc. 113 setMode(MOUSEPAN); 114 panStart(e.clientX, e.clientY); 115 e.preventDefault(); 116 svg.addEventListener('mousemove', handleScanMove); 117 } 118 119 function handleScanMove(e) { 120 if (e.buttons == 0) { 121 // Missed an end event, perhaps because mouse moved outside window. 122 setMode(IDLE); 123 svg.removeEventListener('mousemove', handleScanMove); 124 return; 125 } 126 if (mode == MOUSEPAN) panMove(e.clientX, e.clientY); 127 } 128 129 function handleScanEnd(e) { 130 if (mode == MOUSEPAN) panMove(e.clientX, e.clientY); 131 setMode(IDLE); 132 svg.removeEventListener('mousemove', handleScanMove); 133 if (!moved) clickHandler(e.target); 134 } 135 136 // Find touch object with specified identifier. 137 function findTouch(tlist, id) { 138 for (const t of tlist) { 139 if (t.identifier == id) return t; 140 } 141 return null; 142 } 143 144 // Return distance between two touch points 145 function touchGap(t1, t2) { 146 const dx = t1.clientX - t2.clientX; 147 const dy = t1.clientY - t2.clientY; 148 return Math.hypot(dx, dy); 149 } 150 151 function handleTouchStart(e) { 152 if (mode == IDLE && e.changedTouches.length == 1) { 153 // Start touch based panning 154 const t = e.changedTouches[0]; 155 setMode(TOUCHPAN); 156 touchid = t.identifier; 157 panStart(t.clientX, t.clientY); 158 e.preventDefault(); 159 } else if (mode == TOUCHPAN && e.touches.length == 2) { 160 // Start pinch zooming 161 setMode(TOUCHZOOM); 162 const t1 = e.touches[0]; 163 const t2 = e.touches[1]; 164 touchid = t1.identifier; 165 touchid2 = t2.identifier; 166 initScale = currentScale; 167 initGap = touchGap(t1, t2); 168 centerPoint = toSvg((t1.clientX + t2.clientX) / 2, 169 (t1.clientY + t2.clientY) / 2); 170 e.preventDefault(); 171 } 172 } 173 174 function handleTouchMove(e) { 175 if (mode == TOUCHPAN) { 176 const t = findTouch(e.changedTouches, touchid); 177 if (t == null) return; 178 if (e.touches.length != 1) { 179 setMode(IDLE); 180 return; 181 } 182 panMove(t.clientX, t.clientY); 183 e.preventDefault(); 184 } else if (mode == TOUCHZOOM) { 185 // Get two touches; new gap; rescale to ratio. 186 const t1 = findTouch(e.touches, touchid); 187 const t2 = findTouch(e.touches, touchid2); 188 if (t1 == null || t2 == null) return; 189 const gap = touchGap(t1, t2); 190 rescale(initScale * gap / initGap, centerPoint); 191 e.preventDefault(); 192 } 193 } 194 195 function handleTouchEnd(e) { 196 if (mode == TOUCHPAN) { 197 const t = findTouch(e.changedTouches, touchid); 198 if (t == null) return; 199 panMove(t.clientX, t.clientY); 200 setMode(IDLE); 201 e.preventDefault(); 202 if (!moved) clickHandler(t.target); 203 } else if (mode == TOUCHZOOM) { 204 setMode(IDLE); 205 e.preventDefault(); 206 } 207 } 208 209 svg.addEventListener('mousedown', handleScanStart); 210 svg.addEventListener('mouseup', handleScanEnd); 211 svg.addEventListener('touchstart', handleTouchStart); 212 svg.addEventListener('touchmove', handleTouchMove); 213 svg.addEventListener('touchend', handleTouchEnd); 214 svg.addEventListener('wheel', handleWheel, true); 215} 216 217function initMenus() { 218 'use strict'; 219 220 let activeMenu = null; 221 let activeMenuHdr = null; 222 223 function cancelActiveMenu() { 224 if (activeMenu == null) return; 225 activeMenu.style.display = 'none'; 226 activeMenu = null; 227 activeMenuHdr = null; 228 } 229 230 // Set click handlers on every menu header. 231 for (const menu of document.getElementsByClassName('submenu')) { 232 const hdr = menu.parentElement; 233 if (hdr == null) return; 234 if (hdr.classList.contains('disabled')) return; 235 function showMenu(e) { 236 // menu is a child of hdr, so this event can fire for clicks 237 // inside menu. Ignore such clicks. 238 if (e.target.parentElement != hdr) return; 239 activeMenu = menu; 240 activeMenuHdr = hdr; 241 menu.style.display = 'block'; 242 } 243 hdr.addEventListener('mousedown', showMenu); 244 hdr.addEventListener('touchstart', showMenu); 245 } 246 247 // If there is an active menu and a down event outside, retract the menu. 248 for (const t of ['mousedown', 'touchstart']) { 249 document.addEventListener(t, (e) => { 250 // Note: to avoid unnecessary flicker, if the down event is inside 251 // the active menu header, do not retract the menu. 252 if (activeMenuHdr != e.target.closest('.menu-item')) { 253 cancelActiveMenu(); 254 } 255 }, { passive: true, capture: true }); 256 } 257 258 // If there is an active menu and an up event inside, retract the menu. 259 document.addEventListener('mouseup', (e) => { 260 if (activeMenu == e.target.closest('.submenu')) { 261 cancelActiveMenu(); 262 } 263 }, { passive: true, capture: true }); 264} 265 266function sendURL(method, url, done) { 267 fetch(url.toString(), {method: method}) 268 .then((response) => { done(response.ok); }) 269 .catch((error) => { done(false); }); 270} 271 272// Initialize handlers for saving/loading configurations. 273function initConfigManager() { 274 'use strict'; 275 276 // Initialize various elements. 277 function elem(id) { 278 const result = document.getElementById(id); 279 if (!result) console.warn('element ' + id + ' not found'); 280 return result; 281 } 282 const overlay = elem('dialog-overlay'); 283 const saveDialog = elem('save-dialog'); 284 const saveInput = elem('save-name'); 285 const saveError = elem('save-error'); 286 const delDialog = elem('delete-dialog'); 287 const delPrompt = elem('delete-prompt'); 288 const delError = elem('delete-error'); 289 290 let currentDialog = null; 291 let currentDeleteTarget = null; 292 293 function showDialog(dialog) { 294 if (currentDialog != null) { 295 overlay.style.display = 'none'; 296 currentDialog.style.display = 'none'; 297 } 298 currentDialog = dialog; 299 if (dialog != null) { 300 overlay.style.display = 'block'; 301 dialog.style.display = 'block'; 302 } 303 } 304 305 function cancelDialog(e) { 306 showDialog(null); 307 } 308 309 // Show dialog for saving the current config. 310 function showSaveDialog(e) { 311 saveError.innerText = ''; 312 showDialog(saveDialog); 313 saveInput.focus(); 314 } 315 316 // Commit save config. 317 function commitSave(e) { 318 const name = saveInput.value; 319 const url = new URL(document.URL); 320 // Set path relative to existing path. 321 url.pathname = new URL('./saveconfig', document.URL).pathname; 322 url.searchParams.set('config', name); 323 saveError.innerText = ''; 324 sendURL('POST', url, (ok) => { 325 if (!ok) { 326 saveError.innerText = 'Save failed'; 327 } else { 328 showDialog(null); 329 location.reload(); // Reload to show updated config menu 330 } 331 }); 332 } 333 334 function handleSaveInputKey(e) { 335 if (e.key === 'Enter') commitSave(e); 336 } 337 338 function deleteConfig(e, elem) { 339 e.preventDefault(); 340 const config = elem.dataset.config; 341 delPrompt.innerText = 'Delete ' + config + '?'; 342 currentDeleteTarget = elem; 343 showDialog(delDialog); 344 } 345 346 function commitDelete(e, elem) { 347 if (!currentDeleteTarget) return; 348 const config = currentDeleteTarget.dataset.config; 349 const url = new URL('./deleteconfig', document.URL); 350 url.searchParams.set('config', config); 351 delError.innerText = ''; 352 sendURL('DELETE', url, (ok) => { 353 if (!ok) { 354 delError.innerText = 'Delete failed'; 355 return; 356 } 357 showDialog(null); 358 // Remove menu entry for this config. 359 if (currentDeleteTarget && currentDeleteTarget.parentElement) { 360 currentDeleteTarget.parentElement.remove(); 361 } 362 }); 363 } 364 365 // Bind event on elem to fn. 366 function bind(event, elem, fn) { 367 if (elem == null) return; 368 elem.addEventListener(event, fn); 369 if (event == 'click') { 370 // Also enable via touch. 371 elem.addEventListener('touchstart', fn); 372 } 373 } 374 375 bind('click', elem('save-config'), showSaveDialog); 376 bind('click', elem('save-cancel'), cancelDialog); 377 bind('click', elem('save-confirm'), commitSave); 378 bind('keydown', saveInput, handleSaveInputKey); 379 380 bind('click', elem('delete-cancel'), cancelDialog); 381 bind('click', elem('delete-confirm'), commitDelete); 382 383 // Activate deletion button for all config entries in menu. 384 for (const del of Array.from(document.getElementsByClassName('menu-delete-btn'))) { 385 bind('click', del, (e) => { 386 deleteConfig(e, del); 387 }); 388 } 389} 390 391// options if present can contain: 392// hiliter: function(Number, Boolean): Boolean 393// Overridable mechanism for highlighting/unhighlighting specified node. 394// current: function() Map[Number,Boolean] 395// Overridable mechanism for fetching set of currently selected nodes. 396function viewer(baseUrl, nodes, options) { 397 'use strict'; 398 399 // Elements 400 const search = document.getElementById('search'); 401 const graph0 = document.getElementById('graph0'); 402 const svg = (graph0 == null ? null : graph0.parentElement); 403 const toptable = document.getElementById('toptable'); 404 405 let regexpActive = false; 406 let selected = new Map(); 407 let origFill = new Map(); 408 let searchAlarm = null; 409 let buttonsEnabled = true; 410 411 // Return current selection. 412 function getSelection() { 413 if (selected.size > 0) { 414 return selected; 415 } else if (options && options.current) { 416 return options.current(); 417 } 418 return new Map(); 419 } 420 421 function handleDetails(e) { 422 e.preventDefault(); 423 const detailsText = document.getElementById('detailsbox'); 424 if (detailsText != null) { 425 if (detailsText.style.display === 'block') { 426 detailsText.style.display = 'none'; 427 } else { 428 detailsText.style.display = 'block'; 429 } 430 } 431 } 432 433 function handleKey(e) { 434 if (e.keyCode != 13) return; 435 setHrefParams(window.location, function (params) { 436 params.set('f', search.value); 437 }); 438 e.preventDefault(); 439 } 440 441 function handleSearch() { 442 // Delay expensive processing so a flurry of key strokes is handled once. 443 if (searchAlarm != null) { 444 clearTimeout(searchAlarm); 445 } 446 searchAlarm = setTimeout(selectMatching, 300); 447 448 regexpActive = true; 449 updateButtons(); 450 } 451 452 function selectMatching() { 453 searchAlarm = null; 454 let re = null; 455 if (search.value != '') { 456 try { 457 re = new RegExp(search.value); 458 } catch (e) { 459 // TODO: Display error state in search box 460 return; 461 } 462 } 463 464 function match(text) { 465 return re != null && re.test(text); 466 } 467 468 // drop currently selected items that do not match re. 469 selected.forEach(function(v, n) { 470 if (!match(nodes[n])) { 471 unselect(n); 472 } 473 }) 474 475 // add matching items that are not currently selected. 476 if (nodes) { 477 for (let n = 0; n < nodes.length; n++) { 478 if (!selected.has(n) && match(nodes[n])) { 479 select(n); 480 } 481 } 482 } 483 484 updateButtons(); 485 } 486 487 function toggleSvgSelect(elem) { 488 // Walk up to immediate child of graph0 489 while (elem != null && elem.parentElement != graph0) { 490 elem = elem.parentElement; 491 } 492 if (!elem) return; 493 494 // Disable regexp mode. 495 regexpActive = false; 496 497 const n = nodeId(elem); 498 if (n < 0) return; 499 if (selected.has(n)) { 500 unselect(n); 501 } else { 502 select(n); 503 } 504 updateButtons(); 505 } 506 507 function unselect(n) { 508 if (setNodeHighlight(n, false)) selected.delete(n); 509 } 510 511 function select(n, elem) { 512 if (setNodeHighlight(n, true)) selected.set(n, true); 513 } 514 515 function nodeId(elem) { 516 const id = elem.id; 517 if (!id) return -1; 518 if (!id.startsWith('node')) return -1; 519 const n = parseInt(id.slice(4), 10); 520 if (isNaN(n)) return -1; 521 if (n < 0 || n >= nodes.length) return -1; 522 return n; 523 } 524 525 // Change highlighting of node (returns true if node was found). 526 function setNodeHighlight(n, set) { 527 if (options && options.hiliter) return options.hiliter(n, set); 528 529 const elem = document.getElementById('node' + n); 530 if (!elem) return false; 531 532 // Handle table row highlighting. 533 if (elem.nodeName == 'TR') { 534 elem.classList.toggle('hilite', set); 535 return true; 536 } 537 538 // Handle svg element highlighting. 539 const p = findPolygon(elem); 540 if (p != null) { 541 if (set) { 542 origFill.set(p, p.style.fill); 543 p.style.fill = '#ccccff'; 544 } else if (origFill.has(p)) { 545 p.style.fill = origFill.get(p); 546 } 547 } 548 549 return true; 550 } 551 552 function findPolygon(elem) { 553 if (elem.localName == 'polygon') return elem; 554 for (const c of elem.children) { 555 const p = findPolygon(c); 556 if (p != null) return p; 557 } 558 return null; 559 } 560 561 function setSampleIndexLink(si) { 562 const elem = document.getElementById('sampletype-' + si); 563 if (elem != null) { 564 setHrefParams(elem, function (params) { 565 params.set("si", si); 566 }); 567 } 568 } 569 570 // Update id's href to reflect current selection whenever it is 571 // liable to be followed. 572 function makeSearchLinkDynamic(id) { 573 const elem = document.getElementById(id); 574 if (elem == null) return; 575 576 // Most links copy current selection into the 'f' parameter, 577 // but Refine menu links are different. 578 let param = 'f'; 579 if (id == 'ignore') param = 'i'; 580 if (id == 'hide') param = 'h'; 581 if (id == 'show') param = 's'; 582 if (id == 'show-from') param = 'sf'; 583 584 // We update on mouseenter so middle-click/right-click work properly. 585 elem.addEventListener('mouseenter', updater); 586 elem.addEventListener('touchstart', updater); 587 588 function updater() { 589 // The selection can be in one of two modes: regexp-based or 590 // list-based. Construct regular expression depending on mode. 591 let re = regexpActive 592 ? search.value 593 : Array.from(getSelection().keys()).map(key => pprofQuoteMeta(nodes[key])).join('|'); 594 595 setHrefParams(elem, function (params) { 596 if (re != '') { 597 // For focus/show/show-from, forget old parameter. For others, add to re. 598 if (param != 'f' && param != 's' && param != 'sf' && params.has(param)) { 599 const old = params.get(param); 600 if (old != '') { 601 re += '|' + old; 602 } 603 } 604 params.set(param, re); 605 } else { 606 params.delete(param); 607 } 608 }); 609 } 610 } 611 612 function setHrefParams(elem, paramSetter) { 613 let url = new URL(elem.href); 614 url.hash = ''; 615 616 // Copy params from this page's URL. 617 const params = url.searchParams; 618 for (const p of new URLSearchParams(window.location.search)) { 619 params.set(p[0], p[1]); 620 } 621 622 // Give the params to the setter to modify. 623 paramSetter(params); 624 625 elem.href = url.toString(); 626 } 627 628 function handleTopClick(e) { 629 // Walk back until we find TR and then get the Name column (index 5) 630 let elem = e.target; 631 while (elem != null && elem.nodeName != 'TR') { 632 elem = elem.parentElement; 633 } 634 if (elem == null || elem.children.length < 6) return; 635 636 e.preventDefault(); 637 const tr = elem; 638 const td = elem.children[5]; 639 if (td.nodeName != 'TD') return; 640 const name = td.innerText; 641 const index = nodes.indexOf(name); 642 if (index < 0) return; 643 644 // Disable regexp mode. 645 regexpActive = false; 646 647 if (selected.has(index)) { 648 unselect(index, elem); 649 } else { 650 select(index, elem); 651 } 652 updateButtons(); 653 } 654 655 function updateButtons() { 656 const enable = (search.value != '' || getSelection().size != 0); 657 if (buttonsEnabled == enable) return; 658 buttonsEnabled = enable; 659 for (const id of ['focus', 'ignore', 'hide', 'show', 'show-from']) { 660 const link = document.getElementById(id); 661 if (link != null) { 662 link.classList.toggle('disabled', !enable); 663 } 664 } 665 } 666 667 // Initialize button states 668 updateButtons(); 669 670 // Setup event handlers 671 initMenus(); 672 if (svg != null) { 673 initPanAndZoom(svg, toggleSvgSelect); 674 } 675 if (toptable != null) { 676 toptable.addEventListener('mousedown', handleTopClick); 677 toptable.addEventListener('touchstart', handleTopClick); 678 } 679 680 const ids = ['topbtn', 'graphbtn', 681 'flamegraph', 682 'peek', 'list', 683 'disasm', 'focus', 'ignore', 'hide', 'show', 'show-from']; 684 ids.forEach(makeSearchLinkDynamic); 685 686 const sampleIDs = [{{range .SampleTypes}}'{{.}}', {{end}}]; 687 sampleIDs.forEach(setSampleIndexLink); 688 689 // Bind action to button with specified id. 690 function addAction(id, action) { 691 const btn = document.getElementById(id); 692 if (btn != null) { 693 btn.addEventListener('click', action); 694 btn.addEventListener('touchstart', action); 695 } 696 } 697 698 addAction('details', handleDetails); 699 initConfigManager(); 700 701 search.addEventListener('input', handleSearch); 702 search.addEventListener('keydown', handleKey); 703 704 // Give initial focus to main container so it can be scrolled using keys. 705 const main = document.getElementById('bodycontainer'); 706 if (main) { 707 main.focus(); 708 } 709} 710 711// convert a string to a regexp that matches exactly that string. 712function pprofQuoteMeta(str) { 713 return '^' + str.replace(/([\\\.?+*\[\](){}|^$])/g, '\\$1') + '$'; 714} 715