// 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 {delay} from '../../helper.mjs'; import {kChunkHeight, kChunkVisualWidth, kChunkWidth} from '../../log/map.mjs'; import {SelectionEvent, SelectTimeEvent, SynchronizeSelectionEvent, ToolTipEvent,} from '../events.mjs'; import {CSSColor, DOM, formatDurationMicros, SVG, 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._legend.onFilter = (type) => this._handleFilterTimeline(); this.timelineChunks = this.$('#timelineChunks'); this.timelineSamples = this.$('#timelineSamples'); this.timelineNode = this.$('#timeline'); this.toolTipTargetNode = this.$('#toolTipTarget'); this.hitPanelNode = this.$('#hitPanel'); this.timelineAnnotationsNode = this.$('#timelineAnnotations'); this.timelineMarkersNode = this.$('#timelineMarkers'); this._scalableContentNode = this.$('#scalableContent'); this.timelineNode.addEventListener( 'scroll', e => this._handleTimelineScroll(e)); this.hitPanelNode.onclick = this._handleClick.bind(this); this.hitPanelNode.ondblclick = this._handleDoubleClick.bind(this); this.hitPanelNode.onmousemove = this._handleMouseMove.bind(this); window.addEventListener('resize', () => this._resetCachedDimensions()); this.isLocked = false; } static get observedAttributes() { return ['title']; } attributeChangedCallback(name, oldValue, newValue) { if (name == 'title') { this.$('#title').innerHTML = newValue; } } _handleFilterTimeline(type) { this._updateChunks(); } set data(timeline) { this._timeline = timeline; this._legend.timeline = timeline; this.$('.content').style.display = timeline.isEmpty() ? 'none' : 'relative'; this._updateChunks(); } set timeSelection(selection) { this._selectionHandler.timeSelection = selection; this.updateSelection(); } 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) { this._nofChunks = count | 0; this._updateChunks(); } get nofChunks() { return this._nofChunks; } _updateChunks() { this._chunks = undefined; this._updateDimensions(); this.requestUpdate(); } get chunks() { return this._chunks; } set selectedEntry(value) { this._selectedEntry = value; this.drawAnnotations(value); } get selectedEntry() { return this._selectedEntry; } set scrollLeft(offset) { this.timelineNode.scrollLeft = offset; this._cachedTimelineScrollLeft = offset; } 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.timelineChunks.style.width = `${width}px`; this.timelineMarkersNode.style.width = `${width}px`; this.timelineAnnotationsNode.style.width = `${width}px`; this.hitPanelNode.style.width = `${width}px`; this._drawMarkers(); this._selectionHandler.update(); this._scaleContent(width); this._cachedTimelineScrollLeft = this.timelineNode.scrollLeft = this.timeToPosition(time) - centerOffset; } _scaleContent(currentWidth) { if (!this._lastContentWidth) return; const ratio = currentWidth / this._lastContentWidth; this._scalableContentNode.style.transform = `scale(${ratio}, 1)`; } _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(); this._drawAnnotations(this.selectedEntry); this._resetCachedDimensions(); } async _drawContent() { await delay(5); if (this._timeline.isEmpty()) return; if (this.chunks?.length != this.nofChunks) { this._chunks = this._timeline.chunks(this.nofChunks, this._legend.filterPredicate); console.assert(this._chunks.length == this._nofChunks); } 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 += '<g>'; buffer += this._drawChunk(i, chunk); buffer += '</g>' } 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 += `<rect x=${chunkIndex * kChunkWidth} y=${lastHeight} height=${ height} width=${kChunkVisualWidth} fill=${color} />` } 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 x=${x - 2} y=0 class=markerText >${text}</text>` buffer += `<line x1=${x} x2=${x} y1=12 y2=2000 dy=100% class=markerLine />` 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 = `<circle cx=${x} cy=${y} r=3 stroke=${color} class=annotationPoint />`; 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.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 style = this.toolTipTargetNode.style; style.left = `${chunk.index * kChunkWidth}px`; style.top = `${kTimelineHeight - chunk.height}px`; style.height = `${chunk.height}px`; style.width = `${kChunkVisualWidth}px`; return logEntry; } }; 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.addEventListener( 'mousedown', e => this._handleTimeSelectionMouseDown(e)); this._timelineNode.addEventListener( 'mouseup', e => this._handleTimeSelectionMouseUp(e)); this._timelineNode.addEventListener( 'mousemove', e => this._handleTimeSelectionMouseMove(e)); } 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; const selectionNode = this._selectionBackgroundNode; selectionNode.style.left = startPosition + 'px'; selectionNode.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 _timelineNode() { return this._timeline.$('#timeline'); } get _selectionNode() { return this._timeline.$('#selection'); } get _selectionBackgroundNode() { return this._timeline.$('#selectionBackground'); } get _leftHandleNode() { return this._timeline.$('#leftHandle'); } get _rightHandleNode() { return this._timeline.$('#rightHandle'); } 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; } _handleTimeSelectionMouseDown(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); } _handleTimeSelectionMouseMove(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))); } _handleTimeSelectionMouseUp(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)); } } class Legend { _timeline; _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() { const tbody = DOM.tbody(); const missingTypes = new Set(this._typesFilters.keys()); this._checkDurationField(); this.selection.getBreakdown(undefined, this._enableDuration) .forEach(group => { tbody.appendChild(this._addTypeRow(group)); missingTypes.delete(group.key); }); missingTypes.forEach(key => tbody.appendChild(this._row('', key, 0, '0%'))); if (this._timeline.selection) { tbody.appendChild( this._row('', 'Selection', this.selection.length, '100%')); } tbody.appendChild(this._row('', 'All', this._timeline.length, '')); 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.appendChild(DOM.td('Duration')); this._table.tHead.appendChild(DOM.td('')); } _row(colorNode, type, count, countPercent, duration, durationPercent) { const row = DOM.tr(); row.appendChild(DOM.td(colorNode)); const typeCell = row.appendChild(DOM.td(type)); 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) { const color = this.colorForType(group.key); const colorDiv = DOM.div('colorbox'); if (this._typesFilters.get(group.key)) { colorDiv.style.backgroundColor = color; } else { colorDiv.style.borderColor = color; colorDiv.style.backgroundColor = CSSColor.backgroundImage; } let duration = 0; if (this._enableDuration) { const entries = group.entries; for (let i = 0; i < entries.length; i++) { duration += entries[i].duration; } } let countPercent = `${(group.length / this.selection.length * 100).toFixed(1)}%`; const row = this._row( colorDiv, group.key, group.length, countPercent, duration, ''); 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); } }