1// Copyright 2020 the V8 project authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import {kChunkHeight, kChunkVisualWidth, kChunkWidth} from '../../log/map.mjs'; 6import {SelectionEvent, SelectTimeEvent, SynchronizeSelectionEvent, ToolTipEvent,} from '../events.mjs'; 7import {CSSColor, delay, DOM, formatDurationMicros, V8CustomElement} from '../helper.mjs'; 8 9export const kTimelineHeight = 200; 10 11export class TimelineTrackBase extends V8CustomElement { 12 _timeline; 13 _nofChunks = 500; 14 _chunks = []; 15 _selectedEntry; 16 _focusedEntry; 17 _timeToPixel; 18 _timeStartPixelOffset; 19 _legend; 20 _lastContentWidth = 0; 21 22 _cachedTimelineBoundingClientRect; 23 _cachedTimelineScrollLeft; 24 25 constructor(templateText) { 26 super(templateText); 27 this._selectionHandler = new SelectionHandler(this); 28 this._legend = new Legend(this.$('#legendTable')); 29 30 this.timelineChunks = this.$('#timelineChunks'); 31 this.timelineSamples = this.$('#timelineSamples'); 32 this.timelineNode = this.$('#timeline'); 33 this.toolTipTargetNode = this.$('#toolTipTarget'); 34 this.hitPanelNode = this.$('#hitPanel'); 35 this.timelineAnnotationsNode = this.$('#timelineAnnotations'); 36 this.timelineMarkersNode = this.$('#timelineMarkers'); 37 this._scalableContentNode = this.$('#scalableContent'); 38 this.isLocked = false; 39 this.setAttribute('tabindex', 0); 40 } 41 42 _initEventListeners() { 43 this._legend.onFilter = this._handleFilterTimeline.bind(this); 44 this.timelineNode.addEventListener( 45 'scroll', this._handleTimelineScroll.bind(this)); 46 this.hitPanelNode.onclick = this._handleClick.bind(this); 47 this.hitPanelNode.ondblclick = this._handleDoubleClick.bind(this); 48 this.hitPanelNode.onmousemove = this._handleMouseMove.bind(this); 49 this.$('#selectionForeground') 50 .addEventListener('mousemove', this._handleMouseMove.bind(this)); 51 window.addEventListener('resize', () => this._resetCachedDimensions()); 52 } 53 54 static get observedAttributes() { 55 return ['title']; 56 } 57 58 attributeChangedCallback(name, oldValue, newValue) { 59 if (name == 'title') { 60 this.$('#title').innerHTML = newValue; 61 } 62 } 63 64 _handleFilterTimeline(type) { 65 this._updateChunks(); 66 this._legend.update(true); 67 } 68 69 set data(timeline) { 70 console.assert(timeline); 71 if (!this._timeline) this._initEventListeners(); 72 this._timeline = timeline; 73 this._legend.timeline = timeline; 74 this.$('.content').style.display = timeline.isEmpty() ? 'none' : 'relative'; 75 this._updateChunks(); 76 } 77 78 set timeSelection({start, end, focus = false, zoom = false}) { 79 this._selectionHandler.timeSelection = {start, end}; 80 this.updateSelection(); 81 if (focus || zoom) { 82 if (!Number.isFinite(start) || !Number.isFinite(end)) { 83 throw new Error('Invalid number ranges'); 84 } 85 if (focus) { 86 this.currentTime = (start + end) / 2; 87 } 88 if (zoom) { 89 const margin = 0.2; 90 const newVisibleTime = (end - start) * (1 + 2 * margin); 91 const currentVisibleTime = 92 this._cachedTimelineBoundingClientRect.width / this._timeToPixel; 93 this.nofChunks = this.nofChunks * (currentVisibleTime / newVisibleTime); 94 } 95 } 96 } 97 98 updateSelection() { 99 this._selectionHandler.update(); 100 this._legend.update(); 101 } 102 103 get _timelineBoundingClientRect() { 104 if (this._cachedTimelineBoundingClientRect === undefined) { 105 this._cachedTimelineBoundingClientRect = 106 this.timelineNode.getBoundingClientRect(); 107 } 108 return this._cachedTimelineBoundingClientRect; 109 } 110 111 get _timelineScrollLeft() { 112 if (this._cachedTimelineScrollLeft === undefined) { 113 this._cachedTimelineScrollLeft = this.timelineNode.scrollLeft; 114 } 115 return this._cachedTimelineScrollLeft; 116 } 117 118 _resetCachedDimensions() { 119 this._cachedTimelineBoundingClientRect = undefined; 120 this._cachedTimelineScrollLeft = undefined; 121 } 122 123 // Maps the clicked x position to the x position on timeline 124 positionOnTimeline(pagePosX) { 125 let rect = this._timelineBoundingClientRect; 126 let posClickedX = pagePosX - rect.left + this._timelineScrollLeft; 127 return posClickedX; 128 } 129 130 positionToTime(pagePosX) { 131 return this.relativePositionToTime(this.positionOnTimeline(pagePosX)); 132 } 133 134 relativePositionToTime(timelineRelativeX) { 135 const timelineAbsoluteX = timelineRelativeX + this._timeStartPixelOffset; 136 return (timelineAbsoluteX / this._timeToPixel) | 0; 137 } 138 139 timeToPosition(time) { 140 let relativePosX = time * this._timeToPixel; 141 relativePosX -= this._timeStartPixelOffset; 142 return relativePosX; 143 } 144 145 set nofChunks(count) { 146 const centerTime = this.currentTime; 147 const kMinNofChunks = 100; 148 if (count < kMinNofChunks) count = kMinNofChunks; 149 const kMaxNofChunks = 10 * 1000; 150 if (count > kMaxNofChunks) count = kMaxNofChunks; 151 this._nofChunks = count | 0; 152 this._updateChunks(); 153 this.currentTime = centerTime; 154 } 155 156 get nofChunks() { 157 return this._nofChunks; 158 } 159 160 _updateChunks() { 161 this._chunks = undefined; 162 this._updateDimensions(); 163 this.requestUpdate(); 164 } 165 166 get chunks() { 167 if (this._chunks?.length != this.nofChunks) { 168 this._chunks = 169 this._timeline.chunks(this.nofChunks, this._legend.filterPredicate); 170 console.assert(this._chunks.length == this._nofChunks); 171 } 172 return this._chunks; 173 } 174 175 set selectedEntry(value) { 176 this._selectedEntry = value; 177 } 178 179 get selectedEntry() { 180 return this._selectedEntry; 181 } 182 183 get focusedEntry() { 184 return this._focusedEntry; 185 } 186 187 set focusedEntry(entry) { 188 this._focusedEntry = entry; 189 if (entry) this._drawAnnotations(entry); 190 } 191 192 set scrollLeft(offset) { 193 this.timelineNode.scrollLeft = offset; 194 this._cachedTimelineScrollLeft = offset; 195 } 196 197 get scrollLeft() { 198 return this._cachedTimelineScrollLeft; 199 } 200 201 set currentTime(time) { 202 const position = this.timeToPosition(time); 203 const centerOffset = this._timelineBoundingClientRect.width / 2; 204 this.scrollLeft = Math.max(0, position - centerOffset); 205 } 206 207 get currentTime() { 208 const centerOffset = 209 this._timelineBoundingClientRect.width / 2 + this.scrollLeft; 210 return this.relativePositionToTime(centerOffset); 211 } 212 213 handleEntryTypeDoubleClick(e) { 214 this.dispatchEvent(new SelectionEvent(e.target.parentNode.entries)); 215 } 216 217 timelineIndicatorMove(offset) { 218 this.timelineNode.scrollLeft += offset; 219 this._cachedTimelineScrollLeft = undefined; 220 } 221 222 _handleTimelineScroll(e) { 223 let scrollLeft = e.currentTarget.scrollLeft; 224 this._cachedTimelineScrollLeft = scrollLeft; 225 this.dispatchEvent(new CustomEvent( 226 'scrolltrack', {bubbles: true, composed: true, detail: scrollLeft})); 227 } 228 229 _updateDimensions() { 230 // No data in this timeline, no need to resize 231 if (!this._timeline) return; 232 233 const centerOffset = this._timelineBoundingClientRect.width / 2; 234 const time = 235 this.relativePositionToTime(this._timelineScrollLeft + centerOffset); 236 const start = this._timeline.startTime; 237 const width = this._nofChunks * kChunkWidth; 238 this._lastContentWidth = parseInt(this.timelineMarkersNode.style.width); 239 this._timeToPixel = width / this._timeline.duration(); 240 this._timeStartPixelOffset = start * this._timeToPixel; 241 this.timelineChunks.style.width = `${width}px`; 242 this.timelineMarkersNode.style.width = `${width}px`; 243 this.timelineAnnotationsNode.style.width = `${width}px`; 244 this.hitPanelNode.style.width = `${width}px`; 245 this._drawMarkers(); 246 this._selectionHandler.update(); 247 this._scaleContent(width); 248 this._cachedTimelineScrollLeft = this.timelineNode.scrollLeft = 249 this.timeToPosition(time) - centerOffset; 250 } 251 252 _scaleContent(currentWidth) { 253 if (!this._lastContentWidth) return; 254 const ratio = currentWidth / this._lastContentWidth; 255 this._scalableContentNode.style.transform = `scale(${ratio}, 1)`; 256 } 257 258 _adjustHeight(height) { 259 const dataHeight = Math.max(height, 200); 260 const viewHeight = Math.min(dataHeight, 400); 261 this.style.setProperty('--data-height', dataHeight + 'px'); 262 this.style.setProperty('--view-height', viewHeight + 'px'); 263 this.timelineNode.style.overflowY = 264 (height > kTimelineHeight) ? 'scroll' : 'hidden'; 265 } 266 267 _update() { 268 this._legend.update(); 269 this._drawContent().then(() => this._drawAnnotations(this.selectedEntry)); 270 this._resetCachedDimensions(); 271 } 272 273 async _drawContent() { 274 if (this._timeline.isEmpty()) return; 275 await delay(5); 276 const chunks = this.chunks; 277 const max = chunks.max(each => each.size()); 278 let buffer = ''; 279 for (let i = 0; i < chunks.length; i++) { 280 const chunk = chunks[i]; 281 const height = (chunk.size() / max * kChunkHeight); 282 chunk.height = height; 283 if (chunk.isEmpty()) continue; 284 buffer += '<g>'; 285 buffer += this._drawChunk(i, chunk); 286 buffer += '</g>' 287 } 288 this._scalableContentNode.innerHTML = buffer; 289 this._scalableContentNode.style.transform = 'scale(1, 1)'; 290 } 291 292 _drawChunk(chunkIndex, chunk) { 293 const groups = chunk.getBreakdown(event => event.type); 294 let buffer = ''; 295 const kHeight = chunk.height; 296 let lastHeight = kTimelineHeight; 297 for (let i = 0; i < groups.length; i++) { 298 const group = groups[i]; 299 if (group.length == 0) break; 300 const height = (group.length / chunk.size() * kHeight) | 0; 301 lastHeight -= height; 302 const color = this._legend.colorForType(group.key); 303 buffer += `<rect x=${chunkIndex * kChunkWidth} y=${lastHeight} height=${ 304 height} width=${kChunkVisualWidth} fill=${color} />` 305 } 306 return buffer; 307 } 308 309 _drawMarkers() { 310 // Put a time marker roughly every 20 chunks. 311 const expected = this._timeline.duration() / this._nofChunks * 20; 312 let interval = (10 ** Math.floor(Math.log10(expected))); 313 let correction = Math.log10(expected / interval); 314 correction = (correction < 0.33) ? 1 : (correction < 0.75) ? 2.5 : 5; 315 interval *= correction; 316 317 const start = this._timeline.startTime; 318 let time = start; 319 let buffer = ''; 320 while (time < this._timeline.endTime) { 321 const delta = time - start; 322 const text = `${(delta / 1000) | 0} ms`; 323 const x = (delta * this._timeToPixel) | 0; 324 buffer += `<text x=${x - 2} y=0 class=markerText >${text}</text>` 325 buffer += 326 `<line x1=${x} x2=${x} y1=12 y2=2000 dy=100% class=markerLine />` 327 time += interval; 328 } 329 this.timelineMarkersNode.innerHTML = buffer; 330 } 331 332 _drawAnnotations(logEntry, time) { 333 if (!this._focusedEntry) return; 334 this._drawEntryMark(this._focusedEntry); 335 } 336 337 _drawEntryMark(entry) { 338 const [x, y] = this._positionForEntry(entry); 339 const color = this._legend.colorForType(entry.type); 340 const mark = 341 `<circle cx=${x} cy=${y} r=3 stroke=${color} class=annotationPoint />`; 342 this.timelineAnnotationsNode.innerHTML = mark; 343 } 344 345 _handleUnlockedMouseEvent(event) { 346 this._focusedEntry = this._getEntryForEvent(event); 347 if (!this._focusedEntry) return false; 348 this._updateToolTip(event); 349 const time = this.positionToTime(event.pageX); 350 this._drawAnnotations(this._focusedEntry, time); 351 } 352 353 _updateToolTip(event) { 354 if (!this._focusedEntry) return false; 355 this.dispatchEvent( 356 new ToolTipEvent(this._focusedEntry, this.toolTipTargetNode)); 357 event.stopImmediatePropagation(); 358 } 359 360 _handleClick(event) { 361 if (event.button !== 0) return; 362 if (event.target === this.timelineChunks) return; 363 this.isLocked = !this.isLocked; 364 // Do this unconditionally since we want the tooltip to be update to the 365 // latest locked state. 366 this._handleUnlockedMouseEvent(event); 367 return false; 368 } 369 370 _handleDoubleClick(event) { 371 if (event.button !== 0) return; 372 this._selectionHandler.clearSelection(); 373 const time = this.positionToTime(event.pageX); 374 const chunk = this._getChunkForEvent(event) 375 if (!chunk) return; 376 event.stopImmediatePropagation(); 377 this.dispatchEvent(new SelectTimeEvent(chunk.start, chunk.end)); 378 return false; 379 } 380 381 _handleMouseMove(event) { 382 if (event.button !== 0) return; 383 if (this._selectionHandler.isSelecting) return false; 384 if (this.isLocked && this._focusedEntry) { 385 this._updateToolTip(event); 386 return false; 387 } 388 this._handleUnlockedMouseEvent(event); 389 } 390 391 _getChunkForEvent(event) { 392 const time = this.positionToTime(event.pageX); 393 return this._chunkForTime(time); 394 } 395 396 _chunkForTime(time) { 397 const chunkIndex = ((time - this._timeline.startTime) / 398 this._timeline.duration() * this._nofChunks) | 399 0; 400 return this.chunks[chunkIndex]; 401 } 402 403 _positionForEntry(entry) { 404 const chunk = this._chunkForTime(entry.time); 405 if (chunk === undefined) return [-1, -1]; 406 const xFrom = (chunk.index * kChunkWidth + kChunkVisualWidth / 2) | 0; 407 const yFrom = kTimelineHeight - chunk.yOffset(entry) | 0; 408 return [xFrom, yFrom]; 409 } 410 411 _getEntryForEvent(event) { 412 const chunk = this._getChunkForEvent(event); 413 if (chunk?.isEmpty() ?? true) return false; 414 const relativeIndex = Math.round( 415 (kTimelineHeight - event.layerY) / chunk.height * (chunk.size() - 1)); 416 if (relativeIndex > chunk.size()) return false; 417 const logEntry = chunk.at(relativeIndex); 418 const style = this.toolTipTargetNode.style; 419 style.left = `${chunk.index * kChunkWidth}px`; 420 style.top = `${kTimelineHeight - chunk.height}px`; 421 style.height = `${chunk.height}px`; 422 style.width = `${kChunkVisualWidth}px`; 423 return logEntry; 424 } 425}; 426 427class SelectionHandler { 428 // TODO turn into static field once Safari supports it. 429 static get SELECTION_OFFSET() { 430 return 10 431 }; 432 433 _timeSelection = {start: -1, end: Infinity}; 434 _selectionOriginTime = -1; 435 436 constructor(timeline) { 437 this._timeline = timeline; 438 this._timelineNode = this._timeline.$('#timeline'); 439 this._timelineNode.addEventListener( 440 'mousedown', this._handleMouseDown.bind(this)); 441 this._timelineNode.addEventListener( 442 'mouseup', this._handleMouseUp.bind(this)); 443 this._timelineNode.addEventListener( 444 'mousemove', this._handleMouseMove.bind(this)); 445 this._selectionNode = this._timeline.$('#selection'); 446 this._selectionForegroundNode = this._timeline.$('#selectionForeground'); 447 this._selectionForegroundNode.addEventListener( 448 'dblclick', this._handleDoubleClick.bind(this)); 449 this._selectionBackgroundNode = this._timeline.$('#selectionBackground'); 450 this._leftHandleNode = this._timeline.$('#leftHandle'); 451 this._rightHandleNode = this._timeline.$('#rightHandle'); 452 } 453 454 update() { 455 if (!this.hasSelection) { 456 this._selectionNode.style.display = 'none'; 457 return; 458 } 459 this._selectionNode.style.display = 'inherit'; 460 const startPosition = this.timeToPosition(this._timeSelection.start); 461 const endPosition = this.timeToPosition(this._timeSelection.end); 462 this._leftHandleNode.style.left = startPosition + 'px'; 463 this._rightHandleNode.style.left = endPosition + 'px'; 464 const delta = endPosition - startPosition; 465 this._selectionForegroundNode.style.left = startPosition + 'px'; 466 this._selectionForegroundNode.style.width = delta + 'px'; 467 this._selectionBackgroundNode.style.left = startPosition + 'px'; 468 this._selectionBackgroundNode.style.width = delta + 'px'; 469 } 470 471 set timeSelection(selection) { 472 this._timeSelection.start = selection.start; 473 this._timeSelection.end = selection.end; 474 } 475 476 clearSelection() { 477 this._timeline.dispatchEvent(new SelectTimeEvent()); 478 } 479 480 timeToPosition(posX) { 481 return this._timeline.timeToPosition(posX); 482 } 483 484 positionToTime(posX) { 485 return this._timeline.positionToTime(posX); 486 } 487 488 get isSelecting() { 489 return this._selectionOriginTime >= 0; 490 } 491 492 get hasSelection() { 493 return this._timeSelection.start >= 0 && 494 this._timeSelection.end != Infinity; 495 } 496 497 get _leftHandlePosX() { 498 return this._leftHandleNode.getBoundingClientRect().x; 499 } 500 501 get _rightHandlePosX() { 502 return this._rightHandleNode.getBoundingClientRect().x; 503 } 504 505 _isOnLeftHandle(posX) { 506 return Math.abs(this._leftHandlePosX - posX) <= 507 SelectionHandler.SELECTION_OFFSET; 508 } 509 510 _isOnRightHandle(posX) { 511 return Math.abs(this._rightHandlePosX - posX) <= 512 SelectionHandler.SELECTION_OFFSET; 513 } 514 515 _handleMouseDown(event) { 516 if (event.button !== 0) return; 517 let xPosition = event.clientX 518 // Update origin time in case we click on a handle. 519 if (this._isOnLeftHandle(xPosition)) { 520 xPosition = this._rightHandlePosX; 521 } 522 else if (this._isOnRightHandle(xPosition)) { 523 xPosition = this._leftHandlePosX; 524 } 525 this._selectionOriginTime = this.positionToTime(xPosition); 526 } 527 528 _handleMouseMove(event) { 529 if (event.button !== 0) return; 530 if (!this.isSelecting) return; 531 const currentTime = this.positionToTime(event.clientX); 532 this._timeline.dispatchEvent(new SynchronizeSelectionEvent( 533 Math.min(this._selectionOriginTime, currentTime), 534 Math.max(this._selectionOriginTime, currentTime))); 535 } 536 537 _handleMouseUp(event) { 538 if (event.button !== 0) return; 539 this._selectionOriginTime = -1; 540 if (this._timeSelection.start === -1) return; 541 const delta = this._timeSelection.end - this._timeSelection.start; 542 if (delta <= 1 || isNaN(delta)) return; 543 this._timeline.dispatchEvent(new SelectTimeEvent( 544 this._timeSelection.start, this._timeSelection.end)); 545 } 546 547 _handleDoubleClick(event) { 548 if (!this.hasSelection) return; 549 // Focus and zoom to the current selection. 550 this._timeline.dispatchEvent(new SelectTimeEvent( 551 this._timeSelection.start, this._timeSelection.end, true, true)); 552 } 553} 554 555class Legend { 556 _timeline; 557 _lastSelection; 558 _typesFilters = new Map(); 559 _typeClickHandler = this._handleTypeClick.bind(this); 560 _filterPredicate = this.filter.bind(this); 561 onFilter = () => {}; 562 563 constructor(table) { 564 this._table = table; 565 this._enableDuration = false; 566 } 567 568 set timeline(timeline) { 569 this._timeline = timeline; 570 const groups = timeline.getBreakdown(); 571 this._typesFilters = new Map(groups.map(each => [each.key, true])); 572 this._colors = 573 new Map(groups.map(each => [each.key, CSSColor.at(each.id)])); 574 } 575 576 get selection() { 577 return this._timeline.selectionOrSelf; 578 } 579 580 get filterPredicate() { 581 for (let visible of this._typesFilters.values()) { 582 if (!visible) return this._filterPredicate; 583 } 584 return undefined; 585 } 586 587 colorForType(type) { 588 let color = this._colors.get(type); 589 if (color === undefined) { 590 color = CSSColor.at(this._colors.size); 591 this._colors.set(type, color); 592 } 593 return color; 594 } 595 596 filter(logEntry) { 597 return this._typesFilters.get(logEntry.type); 598 } 599 600 update(force = false) { 601 if (!force && this._lastSelection === this.selection) return; 602 this._lastSelection = this.selection; 603 const tbody = DOM.tbody(); 604 const missingTypes = new Set(this._typesFilters.keys()); 605 this._checkDurationField(); 606 let selectionDuration = 0; 607 const breakdown = 608 this.selection.getBreakdown(undefined, this._enableDuration); 609 if (this._enableDuration) { 610 if (this.selection.cachedDuration === undefined) { 611 this.selection.cachedDuration = this._breakdownTotalDuration(breakdown); 612 } 613 selectionDuration = this.selection.cachedDuration; 614 } 615 breakdown.forEach(group => { 616 tbody.appendChild(this._addTypeRow(group, selectionDuration)); 617 missingTypes.delete(group.key); 618 }); 619 missingTypes.forEach(key => { 620 const emptyGroup = {key, length: 0, duration: 0}; 621 tbody.appendChild(this._addTypeRow(emptyGroup, selectionDuration)); 622 }); 623 if (this._timeline.selection) { 624 tbody.appendChild(this._addRow( 625 '', 'Selection', this.selection.length, '100%', selectionDuration, 626 '100%')); 627 } 628 // Showing 100% for 'All' and for 'Selection' would be confusing. 629 const allPercent = this._timeline.selection ? '' : '100%'; 630 tbody.appendChild(this._addRow( 631 '', 'All', this._timeline.length, allPercent, 632 this._timeline.cachedDuration, allPercent)); 633 this._table.tBodies[0].replaceWith(tbody); 634 } 635 636 _checkDurationField() { 637 if (this._enableDuration) return; 638 const example = this.selection.at(0); 639 if (!example || !('duration' in example)) return; 640 this._enableDuration = true; 641 this._table.tHead.rows[0].appendChild(DOM.td('Duration')); 642 } 643 644 _addRow(colorNode, type, count, countPercent, duration, durationPercent) { 645 const row = DOM.tr(); 646 const colorCell = row.appendChild(DOM.td(colorNode, 'color')); 647 colorCell.setAttribute('title', `Toggle '${type}' entries.`); 648 const typeCell = row.appendChild(DOM.td(type, 'text')); 649 typeCell.setAttribute('title', type); 650 row.appendChild(DOM.td(count.toString())); 651 row.appendChild(DOM.td(countPercent)); 652 if (this._enableDuration) { 653 row.appendChild(DOM.td(formatDurationMicros(duration ?? 0))); 654 row.appendChild(DOM.td(durationPercent ?? '0%')); 655 } 656 return row 657 } 658 659 _addTypeRow(group, selectionDuration) { 660 const color = this.colorForType(group.key); 661 const classes = ['colorbox']; 662 if (group.length == 0) classes.push('empty'); 663 const colorDiv = DOM.div(classes); 664 colorDiv.style.borderColor = color; 665 if (this._typesFilters.get(group.key)) { 666 colorDiv.style.backgroundColor = color; 667 } else { 668 colorDiv.style.backgroundColor = CSSColor.backgroundImage; 669 } 670 let duration = 0; 671 let durationPercent = ''; 672 if (this._enableDuration) { 673 // group.duration was added in _breakdownTotalDuration. 674 duration = group.duration; 675 durationPercent = selectionDuration == 0 ? 676 '0%' : 677 this._formatPercent(duration / selectionDuration); 678 } 679 const countPercent = 680 this._formatPercent(group.length / this.selection.length); 681 const row = this._addRow( 682 colorDiv, group.key, group.length, countPercent, duration, 683 durationPercent); 684 row.className = 'clickable'; 685 row.onclick = this._typeClickHandler; 686 row.data = group.key; 687 return row; 688 } 689 690 _handleTypeClick(e) { 691 const type = e.currentTarget.data; 692 this._typesFilters.set(type, !this._typesFilters.get(type)); 693 this.onFilter(type); 694 } 695 696 _breakdownTotalDuration(breakdown) { 697 let duration = 0; 698 breakdown.forEach(group => { 699 group.duration = this._groupDuration(group); 700 duration += group.duration; 701 }) 702 return duration; 703 } 704 705 _groupDuration(group) { 706 let duration = 0; 707 const entries = group.entries; 708 for (let i = 0; i < entries.length; i++) { 709 duration += entries[i].duration; 710 } 711 return duration; 712 } 713 714 _formatPercent(ratio) { 715 return `${(ratio * 100).toFixed(1)}%`; 716 } 717} 718