[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:
parent
b308c41a07
commit
72eb1ca18d
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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];
|
||||
}
|
||||
|
||||
|
23
tools/system-analyzer/log/tick.mjs
Normal file
23
tools/system-analyzer/log/tick.mjs
Normal 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';
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
512
tools/system-analyzer/view/timeline/timeline-track-base.mjs
Normal file
512
tools/system-analyzer/view/timeline/timeline-track-base.mjs
Normal 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);
|
||||
}
|
||||
}
|
138
tools/system-analyzer/view/timeline/timeline-track-map.mjs
Normal file
138
tools/system-analyzer/view/timeline/timeline-track-map.mjs
Normal 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;
|
||||
}
|
||||
})
|
@ -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>
|
||||
|
97
tools/system-analyzer/view/timeline/timeline-track-tick.mjs
Normal file
97
tools/system-analyzer/view/timeline/timeline-track-tick.mjs
Normal 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;
|
||||
}
|
||||
})
|
@ -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);
|
||||
}
|
||||
}
|
||||
})
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user