// Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {kChunkHeight, kChunkVisualWidth, kChunkWidth} from '../../log/map.mjs';
import {SelectionEvent, SelectTimeEvent, SynchronizeSelectionEvent, ToolTipEvent,} from '../events.mjs';
import {CSSColor, delay, DOM, formatDurationMicros, V8CustomElement} from '../helper.mjs';
export const kTimelineHeight = 200;
export class TimelineTrackBase extends V8CustomElement {
_timeline;
_nofChunks = 500;
_chunks = [];
_selectedEntry;
_focusedEntry;
_timeToPixel;
_timeStartPixelOffset;
_legend;
_lastContentWidth = 0;
_cachedTimelineBoundingClientRect;
_cachedTimelineScrollLeft;
constructor(templateText) {
super(templateText);
this._selectionHandler = new SelectionHandler(this);
this._legend = new Legend(this.$('#legendTable'));
this.timelineChunks = this.$('#timelineChunks');
this.timelineSamples = this.$('#timelineSamples');
this.timelineNode = this.$('#timeline');
this._toolTipTargetNode = undefined;
this.hitPanelNode = this.$('#hitPanel');
this.timelineAnnotationsNode = this.$('#timelineAnnotations');
this.timelineMarkersNode = this.$('#timelineMarkers');
this._scalableContentNode = this.$('#scalableContent');
this.isLocked = false;
this.setAttribute('tabindex', 0);
}
_initEventListeners() {
this._legend.onFilter = this._handleFilterTimeline.bind(this);
this.timelineNode.addEventListener(
'scroll', this._handleTimelineScroll.bind(this));
this.hitPanelNode.onclick = this._handleClick.bind(this);
this.hitPanelNode.ondblclick = this._handleDoubleClick.bind(this);
this.hitPanelNode.onmousemove = this._handleMouseMove.bind(this);
this.$('#selectionForeground')
.addEventListener('mousemove', this._handleMouseMove.bind(this));
window.addEventListener('resize', () => this._resetCachedDimensions());
}
static get observedAttributes() {
return ['title'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name == 'title') {
this.$('#title').innerHTML = newValue;
}
}
_handleFilterTimeline(type) {
this._updateChunks();
this._legend.update(true);
}
set data(timeline) {
console.assert(timeline);
if (!this._timeline) this._initEventListeners();
this._timeline = timeline;
this._legend.timeline = timeline;
this.$('.content').style.display = timeline.isEmpty() ? 'none' : 'relative';
this._updateChunks();
}
set timeSelection({start, end, focus = false, zoom = false}) {
this._selectionHandler.timeSelection = {start, end};
this.updateSelection();
if (focus || zoom) {
if (!Number.isFinite(start) || !Number.isFinite(end)) {
throw new Error('Invalid number ranges');
}
if (focus) {
this.currentTime = (start + end) / 2;
}
if (zoom) {
const margin = 0.2;
const newVisibleTime = (end - start) * (1 + 2 * margin);
const currentVisibleTime =
this._cachedTimelineBoundingClientRect.width / this._timeToPixel;
this.nofChunks = this.nofChunks * (currentVisibleTime / newVisibleTime);
}
}
}
updateSelection() {
this._selectionHandler.update();
this._legend.update();
}
get _timelineBoundingClientRect() {
if (this._cachedTimelineBoundingClientRect === undefined) {
this._cachedTimelineBoundingClientRect =
this.timelineNode.getBoundingClientRect();
}
return this._cachedTimelineBoundingClientRect;
}
get _timelineScrollLeft() {
if (this._cachedTimelineScrollLeft === undefined) {
this._cachedTimelineScrollLeft = this.timelineNode.scrollLeft;
}
return this._cachedTimelineScrollLeft;
}
_resetCachedDimensions() {
this._cachedTimelineBoundingClientRect = undefined;
this._cachedTimelineScrollLeft = undefined;
}
// Maps the clicked x position to the x position on timeline
positionOnTimeline(pagePosX) {
let rect = this._timelineBoundingClientRect;
let posClickedX = pagePosX - rect.left + this._timelineScrollLeft;
return posClickedX;
}
positionToTime(pagePosX) {
return this.relativePositionToTime(this.positionOnTimeline(pagePosX));
}
relativePositionToTime(timelineRelativeX) {
const timelineAbsoluteX = timelineRelativeX + this._timeStartPixelOffset;
return (timelineAbsoluteX / this._timeToPixel) | 0;
}
timeToPosition(time) {
let relativePosX = time * this._timeToPixel;
relativePosX -= this._timeStartPixelOffset;
return relativePosX;
}
set nofChunks(count) {
const centerTime = this.currentTime;
const kMinNofChunks = 100;
if (count < kMinNofChunks) count = kMinNofChunks;
const kMaxNofChunks = 10 * 1000;
if (count > kMaxNofChunks) count = kMaxNofChunks;
this._nofChunks = count | 0;
this._updateChunks();
this.currentTime = centerTime;
}
get nofChunks() {
return this._nofChunks;
}
_updateChunks() {
this._chunks = undefined;
this._updateDimensions();
this.requestUpdate();
}
get chunks() {
if (this._chunks?.length != this.nofChunks) {
this._chunks =
this._timeline.chunks(this.nofChunks, this._legend.filterPredicate);
console.assert(this._chunks.length == this._nofChunks);
}
return this._chunks;
}
set selectedEntry(value) {
this._selectedEntry = value;
}
get selectedEntry() {
return this._selectedEntry;
}
get focusedEntry() {
return this._focusedEntry;
}
set focusedEntry(entry) {
this._focusedEntry = entry;
if (entry) this._drawAnnotations(entry);
}
set scrollLeft(offset) {
this.timelineNode.scrollLeft = offset;
this._cachedTimelineScrollLeft = offset;
}
get scrollLeft() {
return this._cachedTimelineScrollLeft;
}
set currentTime(time) {
const position = this.timeToPosition(time);
const centerOffset = this._timelineBoundingClientRect.width / 2;
this.scrollLeft = Math.max(0, position - centerOffset);
}
get currentTime() {
const centerOffset =
this._timelineBoundingClientRect.width / 2 + this.scrollLeft;
return this.relativePositionToTime(centerOffset);
}
handleEntryTypeDoubleClick(e) {
this.dispatchEvent(new SelectionEvent(e.target.parentNode.entries));
}
timelineIndicatorMove(offset) {
this.timelineNode.scrollLeft += offset;
this._cachedTimelineScrollLeft = undefined;
}
_handleTimelineScroll(e) {
let scrollLeft = e.currentTarget.scrollLeft;
this._cachedTimelineScrollLeft = scrollLeft;
this.dispatchEvent(new CustomEvent(
'scrolltrack', {bubbles: true, composed: true, detail: scrollLeft}));
}
_updateDimensions() {
// No data in this timeline, no need to resize
if (!this._timeline) return;
const centerOffset = this._timelineBoundingClientRect.width / 2;
const time =
this.relativePositionToTime(this._timelineScrollLeft + centerOffset);
const start = this._timeline.startTime;
const width = this._nofChunks * kChunkWidth;
this._lastContentWidth = parseInt(this.timelineMarkersNode.style.width);
this._timeToPixel = width / this._timeline.duration();
this._timeStartPixelOffset = start * this._timeToPixel;
this.$('#cropper').style.width = `${width}px`;
this.timelineMarkersNode.style.width = `${width}px`;
this.timelineAnnotationsNode.style.width = `${width}px`;
this.hitPanelNode.style.width = `${width}px`;
const ratio = this._scaleContent(width) || 1;
this.timelineChunks.style.width = `${width / Math.min(1, ratio)}px`;
this._drawMarkers();
this._selectionHandler.update();
this._cachedTimelineScrollLeft = this.timelineNode.scrollLeft =
this.timeToPosition(time) - centerOffset;
}
_scaleContent(currentWidth) {
if (!this._lastContentWidth) return 1;
const ratio = currentWidth / this._lastContentWidth;
this._scalableContentNode.style.transform = `scale(${ratio}, 1)`;
return ratio;
}
_adjustHeight(height) {
const dataHeight = Math.max(height, 200);
const viewHeight = Math.min(dataHeight, 400);
this.style.setProperty('--data-height', dataHeight + 'px');
this.style.setProperty('--view-height', viewHeight + 'px');
this.timelineNode.style.overflowY =
(height > kTimelineHeight) ? 'scroll' : 'hidden';
}
_update() {
this._legend.update();
this._drawContent().then(() => this._drawAnnotations(this.selectedEntry));
this._resetCachedDimensions();
}
async _drawContent() {
if (this._timeline.isEmpty()) return;
await delay(5);
const chunks = this.chunks;
const max = chunks.max(each => each.size());
let buffer = '';
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const height = (chunk.size() / max * kChunkHeight);
chunk.height = height;
if (chunk.isEmpty()) continue;
buffer += '';
buffer += this._drawChunk(i, chunk);
buffer += ''
}
this._scalableContentNode.innerHTML = buffer;
this._scalableContentNode.style.transform = 'scale(1, 1)';
}
_drawChunk(chunkIndex, chunk) {
const groups = chunk.getBreakdown(event => event.type);
let buffer = '';
const kHeight = chunk.height;
let lastHeight = kTimelineHeight;
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
if (group.length == 0) break;
const height = (group.length / chunk.size() * kHeight) | 0;
lastHeight -= height;
const color = this._legend.colorForType(group.key);
buffer += ``
}
return buffer;
}
_drawMarkers() {
// Put a time marker roughly every 20 chunks.
const expected = this._timeline.duration() / this._nofChunks * 20;
let interval = (10 ** Math.floor(Math.log10(expected)));
let correction = Math.log10(expected / interval);
correction = (correction < 0.33) ? 1 : (correction < 0.75) ? 2.5 : 5;
interval *= correction;
const start = this._timeline.startTime;
let time = start;
let buffer = '';
while (time < this._timeline.endTime) {
const delta = time - start;
const text = `${(delta / 1000) | 0} ms`;
const x = (delta * this._timeToPixel) | 0;
buffer += `${text}`
buffer +=
``
time += interval;
}
this.timelineMarkersNode.innerHTML = buffer;
}
_drawAnnotations(logEntry, time) {
if (!this._focusedEntry) return;
this._drawEntryMark(this._focusedEntry);
}
_drawEntryMark(entry) {
const [x, y] = this._positionForEntry(entry);
const color = this._legend.colorForType(entry.type);
const mark =
``;
this.timelineAnnotationsNode.innerHTML = mark;
}
_handleUnlockedMouseEvent(event) {
this._focusedEntry = this._getEntryForEvent(event);
if (!this._focusedEntry) return false;
this._updateToolTip(event);
const time = this.positionToTime(event.pageX);
this._drawAnnotations(this._focusedEntry, time);
}
_updateToolTip(event) {
if (!this._focusedEntry) return false;
this.dispatchEvent(new ToolTipEvent(
this._focusedEntry, this._toolTipTargetNode, event.ctrlKey));
event.stopImmediatePropagation();
}
_handleClick(event) {
if (event.button !== 0) return;
if (event.target === this.timelineChunks) return;
this.isLocked = !this.isLocked;
// Do this unconditionally since we want the tooltip to be update to the
// latest locked state.
this._handleUnlockedMouseEvent(event);
return false;
}
_handleDoubleClick(event) {
if (event.button !== 0) return;
this._selectionHandler.clearSelection();
const time = this.positionToTime(event.pageX);
const chunk = this._getChunkForEvent(event)
if (!chunk) return;
event.stopImmediatePropagation();
this.dispatchEvent(new SelectTimeEvent(chunk.start, chunk.end));
return false;
}
_handleMouseMove(event) {
if (event.button !== 0) return;
if (this._selectionHandler.isSelecting) return false;
if (this.isLocked && this._focusedEntry) {
this._updateToolTip(event);
return false;
}
this._handleUnlockedMouseEvent(event);
}
_getChunkForEvent(event) {
const time = this.positionToTime(event.pageX);
return this._chunkForTime(time);
}
_chunkForTime(time) {
const chunkIndex = ((time - this._timeline.startTime) /
this._timeline.duration() * this._nofChunks) |
0;
return this.chunks[chunkIndex];
}
_positionForEntry(entry) {
const chunk = this._chunkForTime(entry.time);
if (chunk === undefined) return [-1, -1];
const xFrom = (chunk.index * kChunkWidth + kChunkVisualWidth / 2) | 0;
const yFrom = kTimelineHeight - chunk.yOffset(entry) | 0;
return [xFrom, yFrom];
}
_getEntryForEvent(event) {
const chunk = this._getChunkForEvent(event);
if (chunk?.isEmpty() ?? true) return false;
const relativeIndex = Math.round(
(kTimelineHeight - event.layerY) / chunk.height * (chunk.size() - 1));
if (relativeIndex > chunk.size()) return false;
const logEntry = chunk.at(relativeIndex);
const node = this.getToolTipTargetNode(logEntry);
if (!node) return logEntry;
const style = node.style;
style.left = `${chunk.index * kChunkWidth}px`;
style.top = `${kTimelineHeight - chunk.height}px`;
style.height = `${chunk.height}px`;
style.width = `${kChunkVisualWidth}px`;
return logEntry;
}
getToolTipTargetNode(logEntry) {
let node = this._toolTipTargetNode;
if (node) {
if (node.logEntry === logEntry) return undefined;
node.parentNode.removeChild(node);
}
node = this._toolTipTargetNode = DOM.div('toolTipTarget');
node.logEntry = logEntry;
this.$('#cropper').appendChild(node);
return node;
}
};
class SelectionHandler {
// TODO turn into static field once Safari supports it.
static get SELECTION_OFFSET() {
return 10
};
_timeSelection = {start: -1, end: Infinity};
_selectionOriginTime = -1;
constructor(timeline) {
this._timeline = timeline;
this._timelineNode = this._timeline.$('#timeline');
this._timelineNode.addEventListener(
'mousedown', this._handleMouseDown.bind(this));
this._timelineNode.addEventListener(
'mouseup', this._handleMouseUp.bind(this));
this._timelineNode.addEventListener(
'mousemove', this._handleMouseMove.bind(this));
this._selectionNode = this._timeline.$('#selection');
this._selectionForegroundNode = this._timeline.$('#selectionForeground');
this._selectionForegroundNode.addEventListener(
'dblclick', this._handleDoubleClick.bind(this));
this._selectionBackgroundNode = this._timeline.$('#selectionBackground');
this._leftHandleNode = this._timeline.$('#leftHandle');
this._rightHandleNode = this._timeline.$('#rightHandle');
}
update() {
if (!this.hasSelection) {
this._selectionNode.style.display = 'none';
return;
}
this._selectionNode.style.display = 'inherit';
const startPosition = this.timeToPosition(this._timeSelection.start);
const endPosition = this.timeToPosition(this._timeSelection.end);
this._leftHandleNode.style.left = startPosition + 'px';
this._rightHandleNode.style.left = endPosition + 'px';
const delta = endPosition - startPosition;
this._selectionForegroundNode.style.left = startPosition + 'px';
this._selectionForegroundNode.style.width = delta + 'px';
this._selectionBackgroundNode.style.left = startPosition + 'px';
this._selectionBackgroundNode.style.width = delta + 'px';
}
set timeSelection(selection) {
this._timeSelection.start = selection.start;
this._timeSelection.end = selection.end;
}
clearSelection() {
this._timeline.dispatchEvent(new SelectTimeEvent());
}
timeToPosition(posX) {
return this._timeline.timeToPosition(posX);
}
positionToTime(posX) {
return this._timeline.positionToTime(posX);
}
get isSelecting() {
return this._selectionOriginTime >= 0;
}
get hasSelection() {
return this._timeSelection.start >= 0 &&
this._timeSelection.end != Infinity;
}
get _leftHandlePosX() {
return this._leftHandleNode.getBoundingClientRect().x;
}
get _rightHandlePosX() {
return this._rightHandleNode.getBoundingClientRect().x;
}
_isOnLeftHandle(posX) {
return Math.abs(this._leftHandlePosX - posX) <=
SelectionHandler.SELECTION_OFFSET;
}
_isOnRightHandle(posX) {
return Math.abs(this._rightHandlePosX - posX) <=
SelectionHandler.SELECTION_OFFSET;
}
_handleMouseDown(event) {
if (event.button !== 0) return;
let xPosition = event.clientX
// Update origin time in case we click on a handle.
if (this._isOnLeftHandle(xPosition)) {
xPosition = this._rightHandlePosX;
}
else if (this._isOnRightHandle(xPosition)) {
xPosition = this._leftHandlePosX;
}
this._selectionOriginTime = this.positionToTime(xPosition);
}
_handleMouseMove(event) {
if (event.button !== 0) return;
if (!this.isSelecting) return;
const currentTime = this.positionToTime(event.clientX);
this._timeline.dispatchEvent(new SynchronizeSelectionEvent(
Math.min(this._selectionOriginTime, currentTime),
Math.max(this._selectionOriginTime, currentTime)));
}
_handleMouseUp(event) {
if (event.button !== 0) return;
this._selectionOriginTime = -1;
if (this._timeSelection.start === -1) return;
const delta = this._timeSelection.end - this._timeSelection.start;
if (delta <= 1 || isNaN(delta)) return;
this._timeline.dispatchEvent(new SelectTimeEvent(
this._timeSelection.start, this._timeSelection.end));
}
_handleDoubleClick(event) {
if (!this.hasSelection) return;
// Focus and zoom to the current selection.
this._timeline.dispatchEvent(new SelectTimeEvent(
this._timeSelection.start, this._timeSelection.end, true, true));
}
}
class Legend {
_timeline;
_lastSelection;
_typesFilters = new Map();
_typeClickHandler = this._handleTypeClick.bind(this);
_filterPredicate = this.filter.bind(this);
onFilter = () => {};
constructor(table) {
this._table = table;
this._enableDuration = false;
}
set timeline(timeline) {
this._timeline = timeline;
const groups = timeline.getBreakdown();
this._typesFilters = new Map(groups.map(each => [each.key, true]));
this._colors =
new Map(groups.map(each => [each.key, CSSColor.at(each.id)]));
}
get selection() {
return this._timeline.selectionOrSelf;
}
get filterPredicate() {
for (let visible of this._typesFilters.values()) {
if (!visible) return this._filterPredicate;
}
return undefined;
}
colorForType(type) {
let color = this._colors.get(type);
if (color === undefined) {
color = CSSColor.at(this._colors.size);
this._colors.set(type, color);
}
return color;
}
filter(logEntry) {
return this._typesFilters.get(logEntry.type);
}
update(force = false) {
if (!force && this._lastSelection === this.selection) return;
this._lastSelection = this.selection;
const tbody = DOM.tbody();
const missingTypes = new Set(this._typesFilters.keys());
this._checkDurationField();
let selectionDuration = 0;
const breakdown =
this.selection.getBreakdown(undefined, this._enableDuration);
if (this._enableDuration) {
if (this.selection.cachedDuration === undefined) {
this.selection.cachedDuration = this._breakdownTotalDuration(breakdown);
}
selectionDuration = this.selection.cachedDuration;
}
breakdown.forEach(group => {
tbody.appendChild(this._addTypeRow(group, selectionDuration));
missingTypes.delete(group.key);
});
missingTypes.forEach(key => {
const emptyGroup = {key, length: 0, duration: 0};
tbody.appendChild(this._addTypeRow(emptyGroup, selectionDuration));
});
if (this._timeline.selection) {
tbody.appendChild(this._addRow(
'', 'Selection', this.selection.length, '100%', selectionDuration,
'100%'));
}
// Showing 100% for 'All' and for 'Selection' would be confusing.
const allPercent = this._timeline.selection ? '' : '100%';
tbody.appendChild(this._addRow(
'', 'All', this._timeline.length, allPercent,
this._timeline.cachedDuration, allPercent));
this._table.tBodies[0].replaceWith(tbody);
}
_checkDurationField() {
if (this._enableDuration) return;
const example = this.selection.at(0);
if (!example || !('duration' in example)) return;
this._enableDuration = true;
this._table.tHead.rows[0].appendChild(DOM.td('Duration'));
}
_addRow(colorNode, type, count, countPercent, duration, durationPercent) {
const row = DOM.tr();
const colorCell = row.appendChild(DOM.td(colorNode, 'color'));
colorCell.setAttribute('title', `Toggle '${type}' entries.`);
const typeCell = row.appendChild(DOM.td(type, 'text'));
typeCell.setAttribute('title', type);
row.appendChild(DOM.td(count.toString()));
row.appendChild(DOM.td(countPercent));
if (this._enableDuration) {
row.appendChild(DOM.td(formatDurationMicros(duration ?? 0)));
row.appendChild(DOM.td(durationPercent ?? '0%'));
}
return row
}
_addTypeRow(group, selectionDuration) {
const color = this.colorForType(group.key);
const classes = ['colorbox'];
if (group.length == 0) classes.push('empty');
const colorDiv = DOM.div(classes);
colorDiv.style.borderColor = color;
if (this._typesFilters.get(group.key)) {
colorDiv.style.backgroundColor = color;
} else {
colorDiv.style.backgroundColor = CSSColor.backgroundImage;
}
let duration = 0;
let durationPercent = '';
if (this._enableDuration) {
// group.duration was added in _breakdownTotalDuration.
duration = group.duration;
durationPercent = selectionDuration == 0 ?
'0%' :
this._formatPercent(duration / selectionDuration);
}
const countPercent =
this._formatPercent(group.length / this.selection.length);
const row = this._addRow(
colorDiv, group.key, group.length, countPercent, duration,
durationPercent);
row.className = 'clickable';
row.onclick = this._typeClickHandler;
row.data = group.key;
return row;
}
_handleTypeClick(e) {
const type = e.currentTarget.data;
this._typesFilters.set(type, !this._typesFilters.get(type));
this.onFilter(type);
}
_breakdownTotalDuration(breakdown) {
let duration = 0;
breakdown.forEach(group => {
group.duration = this._groupDuration(group);
duration += group.duration;
})
return duration;
}
_groupDuration(group) {
let duration = 0;
const entries = group.entries;
for (let i = 0; i < entries.length; i++) {
duration += entries[i].duration;
}
return duration;
}
_formatPercent(ratio) {
return `${(ratio * 100).toFixed(1)}%`;
}
}