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