[tools][system-analyzer] Switch to SVG rendering + various improvements

- Introduce proper TickLogEntry and use a separate Timeline object
- Update the main rendering to use SVG for speed
- Separate custom-elements: timeline-track-map and timeline-track-tick
- Revamp flame-chart drawing
- Enable map-transitions overlay
- Use mouse position to infer current log-entry instead of individual
  event handlers
- Fix first timelineLegend column header
- Fixing scrollbar-color for FireFox

Change-Id: I7c53c13366b3e4614b1c5592dfaa69d0654a3b5f
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/2944430
Commit-Queue: Camillo Bruni <cbruni@chromium.org>
Reviewed-by: Patrick Thier <pthier@chromium.org>
Cr-Commit-Position: refs/heads/master@{#74987}
This commit is contained in:
Camillo Bruni 2021-06-07 16:57:44 +02:00 committed by V8 LUCI CQ
parent b308c41a07
commit 72eb1ca18d
17 changed files with 939 additions and 743 deletions

View File

@ -159,14 +159,6 @@ class SourceInfo {
}
}
class Tick {
constructor(time_ns, vmState, processedStack) {
this.time = time_ns;
this.state = vmState;
this.stack = processedStack;
}
}
/**
* Creates a profile object for processing profiling-related events
* and calculating function execution times.
@ -178,7 +170,6 @@ export class Profile {
topDownTree_ = new CallTree();
bottomUpTree_ = new CallTree();
c_entries_ = {};
ticks_ = [];
scripts_ = [];
urlToScript_ = new Map();
@ -491,7 +482,7 @@ export class Profile {
this.bottomUpTree_.addPath(nameStack);
nameStack.reverse();
this.topDownTree_.addPath(nameStack);
this.ticks_.push(new Tick(time_ns, vmState, entryStack));
return entryStack;
}
/**

View File

@ -18,6 +18,7 @@ class State {
_deoptTimeline;
_codeTimeline;
_apiTimeline;
_tickTimeline;
_minStartTime = Number.POSITIVE_INFINITY;
_maxEndTime = Number.NEGATIVE_INFINITY;
@ -40,12 +41,14 @@ class State {
}
setTimelines(
mapTimeline, icTimeline, deoptTimeline, codeTimeline, apiTimeline) {
mapTimeline, icTimeline, deoptTimeline, codeTimeline, apiTimeline,
tickTimeline) {
this._mapTimeline = mapTimeline;
this._icTimeline = icTimeline;
this._deoptTimeline = deoptTimeline;
this._codeTimeline = codeTimeline;
this._apiTimeline = apiTimeline;
this._tickTimeline = tickTimeline;
for (let timeline of arguments) {
if (timeline === undefined) return;
this._minStartTime = Math.min(this._minStartTime, timeline.startTime);
@ -77,10 +80,14 @@ class State {
return this._apiTimeline;
}
get tickTimeline() {
return this._tickTimeline;
}
get timelines() {
return [
this._mapTimeline, this._icTimeline, this._deoptTimeline,
this._codeTimeline, this._apiTimeline
this._codeTimeline, this._apiTimeline, this._tickTimeline
];
}

View File

@ -20,18 +20,7 @@
--violet: #d26edc;
--border-color-rgb: 128, 128, 128;
--border-color: rgba(var(--border-color-rgb), 0.2);
}
body {
font-family: sans-serif;
font-size: 14px;
color: var(--on-background-color);
margin: 10px 10px 0 10px;
background-color: var(--background-color);
}
section {
margin-bottom: 10px;
scrollbar-color: rgba(128, 128, 128, 0.5) rgba(0, 0, 0, 0.0);
}
::-webkit-scrollbar, ::-webkit-scrollbar-track, ::-webkit-scrollbar-corner {
@ -50,6 +39,18 @@ section {
background-color: rgba(128, 128, 128, 0.8);
}
body {
font-family: sans-serif;
font-size: 14px;
color: var(--on-background-color);
margin: 10px 10px 0 10px;
background-color: var(--background-color);
}
section {
margin-bottom: 10px;
}
kbd {
color: var(--on-primary-color);
background-color: var(--primary-color);

View File

@ -55,8 +55,8 @@ found in the LICENSE file. -->
<section id="container" class="initial">
<timeline-panel id="timeline-panel">
<timeline-track id="sample-track" title="Samples"></timeline-track>
<timeline-track id="map-track" title="Map"></timeline-track>
<timeline-track-tick id="tick-track" title="Samples"></timeline-track-tick>
<timeline-track-map id="map-track" title="Map"></timeline-track-map>
<timeline-track id="ic-track" title="IC"></timeline-track>
<timeline-track id="deopt-track" title="Deopt"></timeline-track>
<timeline-track id="code-track" title="Code"></timeline-track>

View File

@ -6,11 +6,12 @@ import {Script, SourcePosition} from '../profile.mjs';
import {State} from './app-model.mjs';
import {ApiLogEntry} from './log/api.mjs';
import {DeoptLogEntry} from './log/code.mjs';
import {CodeLogEntry} from './log/code.mjs';
import {DeoptLogEntry} from './log/code.mjs';
import {IcLogEntry} from './log/ic.mjs';
import {LogEntry} from './log/log.mjs';
import {MapLogEntry} from './log/map.mjs';
import {TickLogEntry} from './log/tick.mjs';
import {Processor} from './processor.mjs';
import {Timeline} from './timeline.mjs'
import {FocusEvent, SelectionEvent, SelectRelatedEvent, SelectTimeEvent, ToolTipEvent,} from './view/events.mjs';
@ -27,7 +28,7 @@ class App {
logFileReader: $('#log-file-reader'),
timelinePanel: $('#timeline-panel'),
sampleTrack: $('#sample-track'),
tickTrack: $('#tick-track'),
mapTrack: $('#map-track'),
icTrack: $('#ic-track'),
deoptTrack: $('#deopt-track'),
@ -51,12 +52,13 @@ class App {
this._view.logFileReader.addEventListener(
'fileuploadend', (e) => this.handleFileUploadEnd(e));
this._startupPromise = this.runAsyncInitialize();
this._view.codeTrack.svg = true;
}
static get allEventTypes() {
return new Set([
SourcePosition, MapLogEntry, IcLogEntry, ApiLogEntry, CodeLogEntry,
DeoptLogEntry
DeoptLogEntry, TickLogEntry
]);
}
@ -103,6 +105,8 @@ class App {
break;
case CodeLogEntry:
break;
case TickLogEntry:
break;
case DeoptLogEntry:
// TODO select map + code entries
if (entry.fileSourcePosition) entries.push(entry.fileSourcePosition);
@ -160,38 +164,37 @@ class App {
return this.showCodeEntries(entries);
case DeoptLogEntry:
return this.showDeoptEntries(entries);
case TickLogEntry:
break;
default:
throw new Error(`Unknown selection type: ${entryType?.name}`);
}
}
showMapEntries(entries) {
this._state.selectedMapLogEntries = entries;
this._view.mapPanel.selectedLogEntries = entries;
this._view.mapList.selectedLogEntries = entries;
}
showIcEntries(entries) {
this._state.selectedIcLogEntries = entries;
this._view.icList.selectedLogEntries = entries;
}
showDeoptEntries(entries) {
this._state.selectedDeoptLogEntries = entries;
this._view.deoptList.selectedLogEntries = entries;
}
showCodeEntries(entries) {
this._state.selectedCodeLogEntries = entries;
this._view.codePanel.selectedEntries = entries;
this._view.codeList.selectedLogEntries = entries;
}
showApiEntries(entries) {
this._state.selectedApiLogEntries = entries;
this._view.apiList.selectedLogEntries = entries;
}
showTickEntries(entries) {}
showSourcePositions(entries) {
this._view.scriptPanel.selectedSourcePositions = entries
}
@ -208,6 +211,7 @@ class App {
this.showDeoptEntries(this._state.deoptTimeline.selectionOrSelf);
this.showCodeEntries(this._state.codeTimeline.selectionOrSelf);
this.showApiEntries(this._state.apiTimeline.selectionOrSelf);
this.showTickEntries(this._state.tickTimeline.selectionOrSelf);
this._view.timelinePanel.timeSelection = {start, end};
}
@ -232,6 +236,8 @@ class App {
return this.focusCodeLogEntry(entry);
case DeoptLogEntry:
return this.focusDeoptLogEntry(entry);
case TickLogEntry:
return this.focusTickLogEntry(entry);
default:
throw new Error(`Unknown selection type: ${entry.constructor?.name}`);
}
@ -255,7 +261,7 @@ class App {
}
focusDeoptLogEntry(entry) {
this._state.DeoptLogEntry = entry;
this._state.deoptLogEntry = entry;
}
focusApiLogEntry(entry) {
@ -263,6 +269,11 @@ class App {
this._view.apiTrack.focusedEntry = entry;
}
focusTickLogEntry(entry) {
this._state.tickLogEntry = entry;
this._view.tickTrack.focusedEntry = entry;
}
focusSourcePosition(sourcePosition) {
if (!sourcePosition) return;
this._view.scriptPanel.focusedSourcePositions = [sourcePosition];
@ -281,6 +292,7 @@ class App {
case ApiLogEntry:
case CodeLogEntry:
case DeoptLogEntry:
case TickLogEntry:
content = content.toolTipDict;
break;
default:
@ -315,8 +327,10 @@ class App {
const deoptTimeline = processor.deoptTimeline;
const codeTimeline = processor.codeTimeline;
const apiTimeline = processor.apiTimeline;
const tickTimeline = processor.tickTimeline;
this._state.setTimelines(
mapTimeline, icTimeline, deoptTimeline, codeTimeline, apiTimeline);
mapTimeline, icTimeline, deoptTimeline, codeTimeline, apiTimeline,
tickTimeline);
this._view.mapPanel.timeline = mapTimeline;
this._view.icList.timeline = icTimeline;
this._view.mapList.timeline = mapTimeline;
@ -336,17 +350,12 @@ class App {
}
refreshTimelineTrackView() {
const ticks = this._state.profile.ticks_;
if (ticks.length > 0) {
this._view.sampleTrack.data = new Timeline(
Object, ticks, ticks[0].time, ticks[ticks.length - 1].time);
this._view.sampleTrack.ticks = ticks;
}
this._view.mapTrack.data = this._state.mapTimeline;
this._view.icTrack.data = this._state.icTimeline;
this._view.deoptTrack.data = this._state.deoptTimeline;
this._view.codeTrack.data = this._state.codeTimeline;
this._view.apiTrack.data = this._state.apiTimeline;
this._view.tickTrack.data = this._state.tickTimeline;
}
}

View File

@ -121,8 +121,8 @@ class MapLogEntry extends LogEntry {
position(chunks) {
const index = this.chunkIndex(chunks);
if (index === -1) return [0, 0];
const xFrom = (index + 1.5) * kChunkWidth;
const yFrom = kChunkHeight - chunks[index].yOffset(this);
const xFrom = (index + 0.5) * kChunkWidth | 0;
const yFrom = kChunkHeight - chunks[index].yOffset(this) | 0;
return [xFrom, yFrom];
}

View File

@ -0,0 +1,23 @@
// Copyright 2021 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 {Profile} from '../../profile.mjs'
import {LogEntry} from './log.mjs';
export class TickLogEntry extends LogEntry {
constructor(time, vmState, processedStack) {
super(TickLogEntry.extractType(processedStack), time);
this.state = vmState;
this.stack = processedStack;
}
static extractType(processedStack) {
if (processedStack.length == 0) return 'idle';
const topOfStack = processedStack[processedStack.length - 1];
if (topOfStack?.state) {
return Profile.getKindFromState(topOfStack.state);
}
return 'native';
}
}

View File

@ -9,17 +9,19 @@ import {ApiLogEntry} from './log/api.mjs';
import {CodeLogEntry, DeoptLogEntry} from './log/code.mjs';
import {IcLogEntry} from './log/ic.mjs';
import {Edge, MapLogEntry} from './log/map.mjs';
import {TickLogEntry} from './log/tick.mjs';
import {Timeline} from './timeline.mjs';
// ===========================================================================
export class Processor extends LogReader {
_profile = new Profile();
_mapTimeline = new Timeline();
_icTimeline = new Timeline();
_deoptTimeline = new Timeline();
_codeTimeline = new Timeline();
_apiTimeline = new Timeline();
_codeTimeline = new Timeline();
_deoptTimeline = new Timeline();
_icTimeline = new Timeline();
_mapTimeline = new Timeline();
_tickTimeline = new Timeline();
_formatPCRegexp = /(.*):[0-9]+:[0-9]+$/;
_lastTimestamp = 0;
_lastCodeLogEntry;
@ -275,8 +277,8 @@ export class Processor extends LogReader {
}
processTick(
pc, ns_since_start, is_external_callback, tos_or_external_callback,
vmState, stack) {
pc, time_ns, is_external_callback, tos_or_external_callback, vmState,
stack) {
if (is_external_callback) {
// Don't use PC when in external callback code, as it can point
// inside callback's code, and we will erroneously report
@ -292,9 +294,10 @@ export class Processor extends LogReader {
tos_or_external_callback = 0;
}
}
this._profile.recordTick(
ns_since_start, vmState,
const entryStack = this._profile.recordTick(
time_ns, vmState,
this.processStack(pc, tos_or_external_callback, stack));
this._tickTimeline.push(new TickLogEntry(time_ns, vmState, entryStack))
}
processCodeSourceInfo(
@ -476,6 +479,10 @@ export class Processor extends LogReader {
return this._apiTimeline;
}
get tickTimeline() {
return this._tickTimeline;
}
get scripts() {
return this._profile.scripts_.filter(script => script !== undefined);
}

View File

@ -120,8 +120,7 @@ class Timeline {
}
duration() {
if (this.isEmpty()) return 0;
return this.last().time - this.first().time;
return this.endTime - this.startTime;
}
forEachChunkSize(count, fn) {
@ -240,7 +239,7 @@ class Chunk {
yOffset(event) {
// items[0] == oldest event, displayed at the top of the chunk
// items[n-1] == youngest event, displayed at the bottom of the chunk
return (1 - (this.indexOf(event) + 0.5) / this.size()) * this.height;
return ((this.indexOf(event) + 0.5) / this.size()) * this.height;
}
indexOf(event) {

View File

@ -125,11 +125,24 @@ export class CSSColor {
export class DOM {
static element(type, classes) {
const node = document.createElement(type);
if (classes === undefined) return node;
if (classes !== undefined) {
if (typeof classes === 'string') {
node.className = classes;
} else {
DOM.addClasses(node, classes);
}
}
return node;
}
static addClasses(node, classes) {
const classList = node.classList;
if (typeof classes === 'string') {
node.className = classes;
classList.add(classes);
} else {
classes.forEach(cls => node.classList.add(cls));
for (let i = 0; i < classes.length; i++) {
classList.add(classes[i]);
}
}
return node;
}
@ -182,8 +195,17 @@ export class DOM {
range.deleteContents();
}
static defineCustomElement(path, generator) {
let name = path.substring(path.lastIndexOf('/') + 1, path.length);
static defineCustomElement(
path, nameOrGenerator, maybeGenerator = undefined) {
let generator = nameOrGenerator;
let name = nameOrGenerator;
if (typeof nameOrGenerator == 'function') {
console.assert(maybeGenerator === undefined);
name = path.substring(path.lastIndexOf('/') + 1, path.length);
} else {
console.assert(typeof nameOrGenerator == 'string');
generator = maybeGenerator;
}
path = path + '-template.html';
fetch(path)
.then(stream => stream.text())
@ -193,6 +215,27 @@ export class DOM {
}
}
const SVGNamespace = 'http://www.w3.org/2000/svg';
export class SVG {
static element(type, classes) {
const node = document.createElementNS(SVGNamespace, type);
if (classes !== undefined) DOM.addClasses(node, classes);
return node;
}
static svg() {
return this.element('svg');
}
static rect(classes) {
return this.element('rect', classes);
}
static g() {
return this.element('g');
}
}
export function $(id) {
return document.querySelector(id)
}
@ -394,7 +437,7 @@ export function gradientStopsFromGroups(
for (let group of groups) {
const color = colorFn(group.key);
increment += group.count;
let height = (increment / totalLength * kMaxHeight) | 0;
const height = (increment / totalLength * kMaxHeight) | 0;
stops.push(`${color} ${lastHeight}${kUnit} ${height}${kUnit}`)
lastHeight = height;
}

View File

@ -3,6 +3,8 @@
// found in the LICENSE file.
import './timeline/timeline-track.mjs';
import './timeline/timeline-track-map.mjs';
import './timeline/timeline-track-tick.mjs';
import {SynchronizeSelectionEvent} from './events.mjs';
import {DOM, V8CustomElement} from './helper.mjs';
@ -19,8 +21,10 @@ DOM.defineCustomElement(
}
set nofChunks(count) {
const time = this.currentTime
for (const track of this.timelineTracks) {
track.nofChunks = count;
track.currentTime = time;
}
}
@ -28,6 +32,10 @@ DOM.defineCustomElement(
return this.timelineTracks[0].nofChunks;
}
get currentTime() {
return this.timelineTracks[0].currentTime;
}
get timelineTracks() {
return this.$('slot').assignedNodes().filter(
node => node.nodeType === Node.ELEMENT_NODE);

View File

@ -0,0 +1,512 @@
// 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, kChunkWidth} from '../../log/map.mjs';
import {SelectionEvent, SelectTimeEvent, SynchronizeSelectionEvent, ToolTipEvent,} from '../events.mjs';
import {CSSColor, DOM, SVG, V8CustomElement} from '../helper.mjs';
export class TimelineTrackBase extends V8CustomElement {
_timeline;
_nofChunks = 500;
_chunks;
_selectedEntry;
_timeToPixel;
_timeStartOffset;
_legend;
_chunkMouseMoveHandler = this._handleChunkMouseMove.bind(this);
_chunkClickHandler = this._handleChunkClick.bind(this);
_chunkDoubleClickHandler = this._handleChunkDoubleClick.bind(this);
_flameMouseOverHandler = this._handleFlameMouseOver.bind(this);
constructor(templateText) {
super(templateText);
this._selectionHandler = new SelectionHandler(this);
this._legend = new Legend(this.$('#legendTable'));
this._legend.onFilter = (type) => this._handleFilterTimeline();
this.timelineNode.addEventListener(
'scroll', e => this._handleTimelineScroll(e));
this.timelineNode.ondblclick = (e) =>
this._selectionHandler.clearSelection();
this.timelineChunks.onmousemove = this._chunkMouseMoveHandler;
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();
}
// Maps the clicked x position to the x position on timeline canvas
positionOnTimeline(pagePosX) {
let rect = this.timelineNode.getBoundingClientRect();
let posClickedX = pagePosX - rect.left + this.timelineNode.scrollLeft;
return posClickedX;
}
positionToTime(pagePosX) {
let posTimelineX =
this.positionOnTimeline(pagePosX) + this._timeStartOffset;
return posTimelineX / this._timeToPixel;
}
timeToPosition(time) {
let relativePosX = time * this._timeToPixel;
relativePosX -= this._timeStartOffset;
return relativePosX;
}
get currentTime() {
const centerOffset = this.timelineNode.getBoundingClientRect().width / 2;
return this.positionToTime(this.timelineNode.scrollLeft + centerOffset);
}
set currentTime(time) {
const centerOffset = this.timelineNode.getBoundingClientRect().width / 2;
this.timelineNode.scrollLeft = this.timeToPosition(time) - centerOffset;
}
get timelineCanvas() {
return this.$('#timelineCanvas');
}
get timelineChunks() {
if (this._timelineChunks === undefined) {
this._timelineChunks = this.$('#timelineChunks');
}
return this._timelineChunks;
}
get timelineSamples() {
if (this._timelineSamples === undefined) {
this._timelineSamples = this.$('#timelineSamples');
}
return this._timelineSamples;
}
get timelineNode() {
if (this._timelineNode === undefined) {
this._timelineNode = this.$('#timeline');
}
return this._timelineNode;
}
get timelineAnnotationsNode() {
return this.$('#timelineAnnotations');
}
get timelineMarkersNode() {
return this.$('#timelineMarkers');
}
_update() {
this._updateTimeline();
this._legend.update();
}
_handleFlameMouseOver(event) {
const codeEntry = event.target.data;
this.dispatchEvent(new ToolTipEvent(codeEntry.logEntry, event.target));
}
set nofChunks(count) {
this._nofChunks = count;
this._updateChunks();
}
get nofChunks() {
return this._nofChunks;
}
_updateChunks() {
this._chunks =
this._timeline.chunks(this.nofChunks, this._legend.filterPredicate);
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;
}
handleEntryTypeDoubleClick(e) {
this.dispatchEvent(new SelectionEvent(e.target.parentNode.entries));
}
timelineIndicatorMove(offset) {
this.timelineNode.scrollLeft += offset;
}
_handleTimelineScroll(e) {
let horizontal = e.currentTarget.scrollLeft;
this.dispatchEvent(new CustomEvent(
'scrolltrack', {bubbles: true, composed: true, detail: horizontal}));
}
_updateTimeline() {
const chunks = this.chunks;
const start = this._timeline.startTime;
const end = this._timeline.endTime;
const duration = end - start;
const width = chunks.length * kChunkWidth;
let oldWidth = width;
if (this.timelineChunks.style.width) {
oldWidth = parseInt(this.timelineChunks.style.width);
}
this._timeToPixel = width / duration;
this._timeStartOffset = start * this._timeToPixel;
this.timelineChunks.style.width = `${width}px`;
this.timelineMarkersNode.style.width = `${width}px`;
this.timelineAnnotationsNode.style.width = `${width}px`;
this._drawMarkers();
this._drawContent();
this._drawAnnotations(this.selectedEntry);
}
async _drawContent() {
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.timelineChunks.innerHTML = buffer;
}
_drawChunk(chunkIndex, chunk) {
const groups = chunk.getBreakdown(event => event.type);
let buffer = '';
const kHeight = chunk.height;
let lastHeight = 200;
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
if (group.count == 0) break;
const height = (group.count / chunk.size() * kHeight) | 0;
lastHeight -= height;
const color = this._legend.colorForType(group.key);
buffer += `<rect x=${chunkIndex * kChunkWidth} y=${lastHeight} height=${
height} `
buffer += `width=6 fill=${color} />`
}
return buffer;
}
_drawMarkers() {
const fragment = new DocumentFragment();
// Put a time marker roughly every 20 chunks.
const expected = this._timeline.duration() / this.chunks.length * 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;
while (time < this._timeline.endTime) {
const timeNode = DOM.div('timestamp');
timeNode.innerText = `${((time - start) / 1000) | 0} ms`;
timeNode.style.left = `${((time - start) * this._timeToPixel) | 0}px`;
fragment.appendChild(timeNode);
time += interval;
}
DOM.removeAllChildren(this.timelineMarkersNode);
this.timelineMarkersNode.appendChild(fragment);
}
_handleChunkMouseMove(event) {
if (this.isLocked) return false;
if (this._selectionHandler.isSelecting) return false;
let target = event.target;
if (target === this.timelineChunks) return false;
target = target.parentNode;
const time = this.positionToTime(event.pageX);
const chunkIndex = (time - this._timeline.startTime) /
this._timeline.duration() * this._nofChunks;
const chunk = this.chunks[chunkIndex | 0];
if (!chunk || chunk.isEmpty()) return;
const relativeIndex =
Math.round((200 - event.layerY) / chunk.height * (chunk.size() - 1));
if (relativeIndex > chunk.size()) return;
const logEntry = chunk.at(relativeIndex);
this.dispatchEvent(new ToolTipEvent(logEntry, target));
this._drawAnnotations(logEntry);
}
_drawAnnotations(logEntry) {
// Subclass responsibility.
}
_handleChunkClick(event) {
this.isLocked = !this.isLocked;
}
_handleChunkDoubleClick(event) {
const chunk = event.target.chunk;
if (!chunk) return;
event.stopPropagation();
this.dispatchEvent(new SelectTimeEvent(chunk.start, chunk.end));
}
};
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(e) {
let xPosition = e.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(e) {
if (!this.isSelecting) return;
const currentTime = this.positionToTime(e.clientX);
this._timeline.dispatchEvent(new SynchronizeSelectionEvent(
Math.min(this._selectionOriginTime, currentTime),
Math.max(this._selectionOriginTime, currentTime)));
}
_handleTimeSelectionMouseUp(e) {
this._selectionOriginTime = -1;
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;
}
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) {
return this._colors.get(type);
}
filter(logEntry) {
return this._typesFilters.get(logEntry.type);
}
update() {
const tbody = DOM.tbody();
const missingTypes = new Set(this._typesFilters.keys());
this.selection.getBreakdown().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);
}
_row(color, type, count, percent) {
const row = DOM.tr();
row.appendChild(DOM.td(color));
row.appendChild(DOM.td(type));
row.appendChild(DOM.td(count.toString()));
row.appendChild(DOM.td(percent));
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 percent = `${(group.count / this.selection.length * 100).toFixed(1)}%`;
const row = this._row(colorDiv, group.key, group.count, percent);
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);
}
}

View File

@ -0,0 +1,138 @@
// Copyright 2021 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 {MapLogEntry} from '../../log/map.mjs';
import {CSSColor, DOM, SVG, V8CustomElement} from '../helper.mjs';
import {TimelineTrackBase} from './timeline-track-base.mjs'
DOM.defineCustomElement('view/timeline/timeline-track', 'timeline-track-map',
(templateText) =>
class TimelineTrackMap extends TimelineTrackBase {
constructor() {
super(templateText);
}
getMapStyle(map) {
return map.edge && map.edge.from ? CSSColor.onBackgroundColor :
CSSColor.onPrimaryColor;
}
markMap(map) {
const [x, y] = map.position(this.chunks);
const strokeColor = this.getMapStyle(map);
return `<circle cx=${x} cy=${y} r=${2} stroke=${
strokeColor} class=annotationPoint />`
}
markSelectedMap(map) {
const [x, y] = map.position(this.chunks);
const strokeColor = this.getMapStyle(map);
return `<circle cx=${x} cy=${y} r=${3} stroke=${
strokeColor} class=annotationPoint />`
}
_drawAnnotations(logEntry) {
if (!(logEntry instanceof MapLogEntry)) return;
if (!logEntry.edge) {
this.timelineAnnotationsNode.innerHTML = '';
return;
}
// Draw the trace of maps in reverse order to make sure the outgoing
// transitions of previous maps aren't drawn over.
const kOpaque = 1.0;
let stack = [];
let current = logEntry;
while (current !== undefined) {
stack.push(current);
current = current.parent();
}
// Draw outgoing refs as fuzzy background. Skip the last map entry.
let buffer = '';
let nofEdges = 0;
const kMaxOutgoingEdges = 100;
for (let i = stack.length - 2; i >= 0; i--) {
const map = stack[i].parent();
nofEdges += map.children.length;
if (nofEdges > kMaxOutgoingEdges) break;
buffer += this.drawOutgoingEdges(map, 0.4, 1);
}
// Draw main connection.
let labelOffset = 15;
let xPrev = 0;
for (let i = stack.length - 1; i >= 0; i--) {
let map = stack[i];
if (map.edge) {
const [xTo, data] = this.drawEdge(map.edge, kOpaque, labelOffset);
buffer += data;
if (xTo == xPrev) {
labelOffset += 10;
} else {
labelOffset = 15
}
xPrev = xTo;
}
buffer += this.markMap(map);
}
buffer += this.drawOutgoingEdges(logEntry, 0.9, 3);
// Mark selected map
buffer += this.markSelectedMap(logEntry);
this.timelineAnnotationsNode.innerHTML = buffer;
}
drawEdge(edge, opacity, labelOffset = 20) {
let buffer = '';
if (!edge.from || !edge.to) return [-1, buffer];
const [xFrom, yFrom] = edge.from.position(this.chunks);
const [xTo, yTo] = edge.to.position(this.chunks);
const sameChunk = xTo == xFrom;
if (sameChunk) labelOffset += 10;
const color = this._legend.colorForType(edge.type);
const offsetX = 20;
const midX = xFrom + (xTo - xFrom) / 2;
const midY = (yFrom + yTo) / 2 - 100;
if (!sameChunk) {
if (opacity == 1.0) {
buffer += `<path d="M ${xFrom} ${yFrom} Q ${midX} ${midY}, ${xTo} ${
yTo}" class=strokeBG />`
}
buffer += `<path d="M ${xFrom} ${yFrom} Q ${midX} ${midY}, ${xTo} ${
yTo}" stroke=${color} fill=none opacity=${opacity} />`
} else {
if (opacity == 1.0) {
buffer += `<line x1=${xFrom} x2=${xTo} y1=${yFrom} y2=${
yTo} class=strokeBG />`;
}
buffer += `<line x1=${xFrom} x2=${xTo} y1=${yFrom} y2=${yTo} stroke=${
color} fill=none opacity=${opacity} />`;
}
if (opacity == 1.0) {
const centerX = sameChunk ? xTo : ((xFrom / 2 + midX + xTo / 2) / 2) | 0;
const centerY = sameChunk ? yTo : ((yFrom / 2 + midY + yTo / 2) / 2) | 0;
const centerYTo = centerY - labelOffset;
buffer += `<line x1=${centerX} x2=${centerX + offsetX} y1=${centerY} y2=${
centerYTo} stroke=${color} fill=none opacity=${opacity} />`;
buffer += `<text x=${centerX + offsetX + 2} y=${
centerYTo} class=annotationLabel opacity=${opacity} >${
edge.toString()}</text>`;
}
return [xTo, buffer];
}
drawOutgoingEdges(map, opacity = 1.0, max = 10, depth = 0) {
let buffer = '';
if (!map || depth >= max) return buffer;
const limit = Math.min(map.children.length, 100)
for (let i = 0; i < limit; i++) {
const edge = map.children[i];
const [xTo, data] = this.drawEdge(edge, opacity);
buffer += data;
buffer += this.drawOutgoingEdges(edge.to, opacity * 0.5, max, depth + 1);
}
return buffer;
}
})

View File

@ -25,11 +25,16 @@ found in the LICENSE file. -->
opacity: 0.5;
}
#timelineSamples, #timelineChunks {
#timelineSamples, #timelineChunks,
#timelineMarkers, #timelineAnnotations {
top: 0px;
height: 200px;
position: absolute;
margin-right: 100px;
}
#timelineMarkers, #timelineAnnotations {
pointer-events: none;
}
#timelineCanvas {
height: 200px;
@ -46,6 +51,7 @@ found in the LICENSE file. -->
bottom: 0px;
background-color: var(--on-surface-color);
cursor: pointer;
content-visibility: auto;
}
.chunk:hover {
border-radius: 2px 2px 0 0;
@ -106,10 +112,12 @@ found in the LICENSE file. -->
padding: 1px 3px 2px 3px;
}
#legendTable td {
padding-top: 3px;
}
/* Center colors */
#legendTable td:nth-of-type(4n+1) {
text-align: center;
padding-top: 3px;
}
/* Left align text*/
#legendTable td:nth-of-type(4n+2) {
@ -177,6 +185,7 @@ found in the LICENSE file. -->
height: 10px;
position: absolute;
font-size: 8px;
content-visibility: auto;
}
.flame.Opt{
background-color: red;
@ -187,6 +196,22 @@ found in the LICENSE file. -->
.flame.default {
background-color: black;
}
.txt {
font: 8px monospace;
}
.annotationLabel {
fill: var(--on-surface-color);
font-size: 9px;
}
.annotationPoint {
fill: var(--on-background-color);
stroke-width: 1;
}
.strokeBG {
stroke: var(--on-background-color);
stroke-width: 2;
fill: none;
}
</style>
<div class="content">
@ -201,8 +226,9 @@ found in the LICENSE file. -->
<div id="rightHandle"></div>
</div>
<div id="timelineLabel">Frequency</div>
<div id="timelineChunks"></div>
<div id="timelineSamples"></div>
<svg id="timelineChunks" xmlns="http://www.w3.org/2000/svg"></svg>
<svg id="timelineAnnotations" xmlns="http://www.w3.org/2000/svg"></svg>
<div id="timelineMarkers"></div>
<canvas id="timelineCanvas"></canvas>
</div>
@ -210,6 +236,7 @@ found in the LICENSE file. -->
<table id="legendTable">
<thead>
<tr>
<td></td>
<td>Type</td>
<td>Count</td>
<td>Percent</td>

View File

@ -0,0 +1,97 @@
// Copyright 2021 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 {Profile} from '../../../profile.mjs'
import {delay} from '../../helper.mjs';
import {CSSColor, DOM, SVG, V8CustomElement} from '../helper.mjs';
import {TimelineTrackBase} from './timeline-track-base.mjs'
class Flame {
constructor(time, entry) {
this.start = time;
this.end = this.start;
this.entry = entry;
}
stop(time) {
this.end = time;
this.duration = time - this.start
}
}
DOM.defineCustomElement('view/timeline/timeline-track', 'timeline-track-tick',
(templateText) =>
class TimelineTrackTick extends TimelineTrackBase {
constructor() {
super(templateText);
}
async _drawContent() {
this.timelineChunks.innerHTML = '';
const stack = [];
let buffer = '';
const kMinPixelWidth = 1
const kMinTimeDelta = kMinPixelWidth / this._timeToPixel;
let lastTime = 0;
let flameCount = 0;
const ticks = this._timeline.values;
for (let tickIndex = 0; tickIndex < ticks.length; tickIndex++) {
const tick = ticks[tickIndex];
// Skip ticks beyond visible resolution.
if ((tick.time - lastTime) < kMinTimeDelta) continue;
lastTime = tick.time;
if (flameCount > 1000) {
const svg = SVG.svg();
svg.innerHTML = buffer;
this.timelineChunks.appendChild(svg);
buffer = '';
flameCount = 0;
await delay(15);
}
for (let stackIndex = 0; stackIndex < tick.stack.length; stackIndex++) {
const entry = tick.stack[stackIndex];
if (stack.length <= stackIndex) {
stack.push(new Flame(tick.time, entry));
} else {
const flame = stack[stackIndex];
if (flame.entry !== entry) {
for (let k = stackIndex; k < stack.length; k++) {
stack[k].stop(tick.time);
buffer += this.drawFlame(stack[k], k);
flameCount++
}
stack.length = stackIndex;
stack[stackIndex] = new Flame(tick.time, entry);
}
}
}
}
const svg = SVG.svg();
svg.innerHTML = buffer;
this.timelineChunks.appendChild(svg);
}
drawFlame(flame, depth) {
let type = 'native';
if (flame.entry?.state) {
type = Profile.getKindFromState(flame.entry.state);
}
const kHeight = 9;
const x = this.timeToPosition(flame.start);
const y = depth * (kHeight + 1);
const width = (flame.duration * this._timeToPixel - 0.5);
const color = this._legend.colorForType(type);
let buffer =
`<rect x=${x} y=${y} width=${width} height=${kHeight} fill=${color} />`;
if (width < 15 || type == 'native') return buffer;
const rawName = flame.entry.getRawName();
if (rawName.length == 0) return buffer;
const kChartWidth = 5;
const maxChars = Math.floor(width / kChartWidth)
const text = rawName.substr(0, maxChars);
buffer += `<text x=${x + 1} y=${y - 3} class=txt>${text}</text>`
return buffer;
}
})

View File

@ -1,682 +1,15 @@
// Copyright 2020 the V8 project authors. All rights reserved.
// Copyright 2021 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 {Profile} from '../../../profile.mjs'
import {delay} from '../../helper.mjs';
import {kChunkHeight, kChunkWidth} from '../../log/map.mjs';
import {MapLogEntry} from '../../log/map.mjs';
import {FocusEvent, SelectionEvent, SelectTimeEvent, SynchronizeSelectionEvent, ToolTipEvent,} from '../events.mjs';
import {CSSColor, DOM, gradientStopsFromGroups, V8CustomElement} from '../helper.mjs';
import {CSSColor, DOM, SVG, V8CustomElement} from '../helper.mjs';
class Flame {
constructor(time, entry) {
this.start = time;
this.end = this.start;
this.entry = entry;
}
stop(time) {
this.end = time;
this.duration = time - this.start
}
}
import {TimelineTrackBase} from './timeline-track-base.mjs'
DOM.defineCustomElement('view/timeline/timeline-track',
(templateText) =>
class TimelineTrack extends V8CustomElement {
_timeline;
_nofChunks = 400;
_chunks;
_selectedEntry;
_timeToPixel;
_timeStartOffset;
_legend;
_chunkMouseMoveHandler = this._handleChunkMouseMove.bind(this);
_chunkClickHandler = this._handleChunkClick.bind(this);
_chunkDoubleClickHandler = this._handleChunkDoubleClick.bind(this);
_flameMouseOverHandler = this._handleFlameMouseOver.bind(this);
constructor() {
super(templateText);
this._selectionHandler = new SelectionHandler(this);
this._legend = new Legend(this.$('#legendTable'));
this._legend.onFilter = (type) => this._handleFilterTimeline();
this.timelineNode.addEventListener(
'scroll', e => this._handleTimelineScroll(e));
this.timelineNode.ondblclick = (e) =>
this._selectionHandler.clearSelection();
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();
}
// Maps the clicked x position to the x position on timeline canvas
positionOnTimeline(posX) {
let rect = this.timelineNode.getBoundingClientRect();
let posClickedX = posX - rect.left + this.timelineNode.scrollLeft;
return posClickedX;
}
positionToTime(posX) {
let posTimelineX = this.positionOnTimeline(posX) + this._timeStartOffset;
return posTimelineX / this._timeToPixel;
}
timeToPosition(time) {
let posX = time * this._timeToPixel;
posX -= this._timeStartOffset;
return posX;
}
get timelineCanvas() {
return this.$('#timelineCanvas');
}
get timelineChunks() {
return this.$('#timelineChunks');
}
get timelineSamples() {
return this.$('#timelineSamples');
}
get timelineNode() {
return this.$('#timeline');
}
_update() {
this._updateTimeline();
this._legend.update();
// TODO(cbruni: Create separate component for profile ticks
if (this.ticks) {
this.drawFlamechart()
}
}
async drawFlamechart() {
await delay(100);
// TODO(cbruni): Use dynamic drawing strategy here that scales
DOM.removeAllChildren(this.timelineSamples);
const stack = [];
const kMaxNumOfDisplayedFlames = 2000
const kIncrement = Math.max(1, this.ticks.length / 5000) | 0;
for (let i = 0; i < this.ticks.length; i += kIncrement) {
if (i % 500 == 0) {
await delay(10);
DOM.defineCustomElement(
'view/timeline/timeline-track',
(templateText) => class TimelineTrack extends TimelineTrackBase {
constructor() {
super(templateText);
}
const tick = this.ticks[i];
for (let j = 0; j < tick.stack.length; j++) {
const entry = tick.stack[j];
if (typeof entry !== 'object') continue;
if (stack.length <= j) {
stack.push(new Flame(tick.time, entry));
} else {
const flame = stack[j];
if (flame.entry !== entry) {
for (let k = j; k < stack.length; k++) {
stack[k].stop(tick.time);
this.drawFlame(stack[k], k);
}
stack.length = j;
stack[j] = new Flame(tick.time, entry);
}
}
}
}
}
drawFlame(flame, depth) {
let type = 'default';
if (flame.entry.state) {
type = Profile.getKindFromState(flame.entry.state);
}
const node = DOM.div(['flame', type]);
node.data = flame.entry;
node.onmouseover = this._flameMouseOverHandler
const style = node.style;
style.top = `${depth * 10}px`;
style.left = `${flame.start * this._timeToPixel}px`;
style.width = `${flame.duration * this._timeToPixel}px`;
node.innerText = flame.entry.getRawName();
this.timelineSamples.appendChild(node);
}
_handleFlameMouseOver(event) {
const codeEntry = event.target.data;
this.dispatchEvent(new ToolTipEvent(codeEntry.logEntry, event.target));
}
set nofChunks(count) {
this._nofChunks = count;
this._updateChunks();
}
get nofChunks() {
return this._nofChunks;
}
_updateChunks() {
this._chunks =
this._timeline.chunks(this.nofChunks, this._legend.filterPredicate);
this.requestUpdate();
}
get chunks() {
return this._chunks;
}
set selectedEntry(value) {
this._selectedEntry = value;
if (value.edge) this.redraw();
}
get selectedEntry() {
return this._selectedEntry;
}
set scrollLeft(offset) {
this.timelineNode.scrollLeft = offset;
}
handleEntryTypeDoubleClick(e) {
this.dispatchEvent(new SelectionEvent(e.target.parentNode.entries));
}
timelineIndicatorMove(offset) {
this.timelineNode.scrollLeft += offset;
}
_handleTimelineScroll(e) {
let horizontal = e.currentTarget.scrollLeft;
this.dispatchEvent(new CustomEvent(
'scrolltrack', {bubbles: true, composed: true, detail: horizontal}));
}
_createBackgroundImage(chunk) {
const stops = gradientStopsFromGroups(
chunk.length, chunk.height, chunk.getBreakdown(event => event.type),
type => this._legend.colorForType(type));
return `linear-gradient(0deg,${stops.join(',')})`;
}
_updateTimeline() {
const reusableNodes = Array.from(this.timelineChunks.childNodes).reverse();
let fragment = new DocumentFragment();
let chunks = this.chunks;
let max = chunks.max(each => each.size());
let start = this._timeline.startTime;
let end = this._timeline.endTime;
let duration = end - start;
this._timeToPixel = chunks.length * kChunkWidth / duration;
this._timeStartOffset = start * this._timeToPixel;
// TODO(cbruni: Create separate component for profile ticks
if (this.ticks) return;
for (let i = 0; i < chunks.length; i++) {
let chunk = chunks[i];
let height = (chunk.size() / max * kChunkHeight);
chunk.height = height;
if (chunk.isEmpty()) continue;
let node = reusableNodes[reusableNodes.length - 1];
let reusedNode = false;
if (node?.className == 'chunk') {
reusableNodes.pop();
reusedNode = true;
} else {
node = DOM.div('chunk');
node.onmousemove = this._chunkMouseMoveHandler;
node.onclick = this._chunkClickHandler;
node.ondblclick = this._chunkDoubleClickHandler;
}
const style = node.style;
style.left = `${i * kChunkWidth}px`;
style.height = `${height | 0}px`;
style.backgroundImage = this._createBackgroundImage(chunk);
node.chunk = chunk;
if (!reusedNode) fragment.appendChild(node);
}
// Put a time marker roughly every 20 chunks.
let expected = duration / chunks.length * 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;
let time = start;
while (time < end) {
let timeNode = DOM.div('timestamp');
timeNode.innerText = `${((time - start) / 1000) | 0} ms`;
timeNode.style.left = `${((time - start) * this._timeToPixel) | 0}px`;
fragment.appendChild(timeNode);
time += interval;
}
// Remove superfluos nodes lazily, for Chrome this is a very expensive
// operation.
if (reusableNodes.length > 0) {
for (const node of reusableNodes) {
node.style.display = 'none';
}
setTimeout(() => {
const range = document.createRange();
const first = reusableNodes[reusableNodes.length - 1];
const last = reusableNodes[0];
range.setStartBefore(first);
range.setEndAfter(last);
range.deleteContents();
}, 100);
}
this.timelineChunks.appendChild(fragment);
this.redraw();
}
_handleChunkMouseMove(event) {
if (this.isLocked) return false;
if (this._selectionHandler.isSelecting) return false;
let chunk = event.target.chunk;
if (!chunk) return;
if (chunk.isEmpty()) return;
// topmost map (at chunk.height) == map #0.
let relativeIndex = Math.round(
event.layerY / event.target.offsetHeight * (chunk.size() - 1));
let logEntry = chunk.at(relativeIndex);
this.dispatchEvent(new ToolTipEvent(logEntry, event.target));
}
_handleChunkClick(event) {
this.isLocked = !this.isLocked;
}
_handleChunkDoubleClick(event) {
let chunk = event.target.chunk;
if (!chunk) return;
event.stopPropagation();
this.dispatchEvent(new SelectTimeEvent(chunk.start, chunk.end));
}
redraw() {
window.requestAnimationFrame(() => this._redraw());
}
_redraw() {
if (!(this._timeline.at(0) instanceof MapLogEntry)) return;
let canvas = this.timelineCanvas;
let width = (this.chunks.length + 1) * kChunkWidth;
if (width > 32767) width = 32767;
canvas.width = width;
canvas.height = kChunkHeight;
let ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, kChunkHeight);
if (!this.selectedEntry || !this.selectedEntry.edge) return;
this.drawEdges(ctx);
}
setMapStyle(map, ctx) {
ctx.fillStyle = map.edge && map.edge.from ? CSSColor.onBackgroundColor :
CSSColor.onPrimaryColor;
}
setEdgeStyle(edge, ctx) {
let color = this._legend.colorForType(edge.type);
ctx.strokeStyle = color;
ctx.fillStyle = color;
}
markMap(ctx, map) {
let [x, y] = map.position(this.chunks);
ctx.beginPath();
this.setMapStyle(map, ctx);
ctx.arc(x, y, 3, 0, 2 * Math.PI);
ctx.fill();
ctx.beginPath();
ctx.fillStyle = CSSColor.onBackgroundColor;
ctx.arc(x, y, 2, 0, 2 * Math.PI);
ctx.fill();
}
markSelectedMap(ctx, map) {
let [x, y] = map.position(this.chunks);
ctx.beginPath();
this.setMapStyle(map, ctx);
ctx.arc(x, y, 6, 0, 2 * Math.PI);
ctx.strokeStyle = CSSColor.onBackgroundColor;
ctx.stroke();
}
drawEdges(ctx) {
// Draw the trace of maps in reverse order to make sure the outgoing
// transitions of previous maps aren't drawn over.
const kMaxOutgoingEdges = 100;
let nofEdges = 0;
let stack = [];
let current = this.selectedEntry;
while (current && nofEdges < kMaxOutgoingEdges) {
nofEdges += current.children.length;
stack.push(current);
current = current.parent();
}
ctx.save();
this.drawOutgoingEdges(ctx, this.selectedEntry, 3);
ctx.restore();
let labelOffset = 15;
let xPrev = 0;
while (current = stack.pop()) {
if (current.edge) {
this.setEdgeStyle(current.edge, ctx);
let [xTo, yTo] = this.drawEdge(ctx, current.edge, true, labelOffset);
if (xTo == xPrev) {
labelOffset += 8;
} else {
labelOffset = 15
}
xPrev = xTo;
}
this.markMap(ctx, current);
current = current.parent();
ctx.save();
// this.drawOutgoingEdges(ctx, current, 1);
ctx.restore();
}
// Mark selected map
this.markSelectedMap(ctx, this.selectedEntry);
}
drawEdge(ctx, edge, showLabel = true, labelOffset = 20) {
if (!edge.from || !edge.to) return [-1, -1];
let [xFrom, yFrom] = edge.from.position(this.chunks);
let [xTo, yTo] = edge.to.position(this.chunks);
let sameChunk = xTo == xFrom;
if (sameChunk) labelOffset += 8;
ctx.beginPath();
ctx.moveTo(xFrom, yFrom);
let offsetX = 20;
let offsetY = 20;
let midX = xFrom + (xTo - xFrom) / 2;
let midY = (yFrom + yTo) / 2 - 100;
if (!sameChunk) {
ctx.quadraticCurveTo(midX, midY, xTo, yTo);
} else {
ctx.lineTo(xTo, yTo);
}
if (!showLabel) {
ctx.stroke();
} else {
let centerX, centerY;
if (!sameChunk) {
centerX = (xFrom / 2 + midX + xTo / 2) / 2;
centerY = (yFrom / 2 + midY + yTo / 2) / 2;
} else {
centerX = xTo;
centerY = yTo;
}
ctx.moveTo(centerX, centerY);
ctx.lineTo(centerX + offsetX, centerY - labelOffset);
ctx.stroke();
ctx.textAlign = 'left';
ctx.fillStyle = this._legend.colorForType(edge.type);
ctx.fillText(
edge.toString(), centerX + offsetX + 2, centerY - labelOffset);
}
return [xTo, yTo];
}
drawOutgoingEdges(ctx, map, max = 10, depth = 0) {
if (!map) return;
if (depth >= max) return;
ctx.globalAlpha = 0.5 - depth * (0.3 / max);
ctx.strokeStyle = CSSColor.timelineBackgroundColor;
const limit = Math.min(map.children.length, 100)
for (let i = 0; i < limit; i++) {
let edge = map.children[i];
this.drawEdge(ctx, edge, true);
this.drawOutgoingEdges(ctx, edge.to, max, depth + 1);
}
}
});
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(e) {
let xPosition = e.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(e) {
if (!this.isSelecting) return;
const currentTime = this.positionToTime(e.clientX);
this._timeline.dispatchEvent(new SynchronizeSelectionEvent(
Math.min(this._selectionOriginTime, currentTime),
Math.max(this._selectionOriginTime, currentTime)));
}
_handleTimeSelectionMouseUp(e) {
this._selectionOriginTime = -1;
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;
}
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) {
return this._colors.get(type);
}
filter(logEntry) {
return this._typesFilters.get(logEntry.type);
}
update() {
const tbody = DOM.tbody();
const missingTypes = new Set(this._typesFilters.keys());
this.selection.getBreakdown().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);
}
_row(color, type, count, percent) {
const row = DOM.tr();
row.appendChild(DOM.td(color));
row.appendChild(DOM.td(type));
row.appendChild(DOM.td(count.toString()));
row.appendChild(DOM.td(percent));
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 percent = `${(group.count / this.selection.length * 100).toFixed(1)}%`;
const row = this._row(colorDiv, group.key, group.count, percent);
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);
}
}
})

View File

@ -50,10 +50,11 @@ DOM.defineCustomElement(
set targetNode(targetNode) {
this._intersectionObserver.disconnect();
this._targetNode = targetNode;
if (targetNode) {
if (targetNode === undefined) return;
if (!(targetNode instanceof SVGElement)) {
this._intersectionObserver.observe(targetNode);
this.requestUpdate(true);
}
this.requestUpdate(true);
}
set position(position) {