[tools][system-analyzer] Create timeline track component

This CL creates a timeline track component to
make the timeline view extensible as different
data sources added. The timeline track component will
take data source and display it with respect to time
axis of timeline overview.

Bug: v8:10644, v8:10735

Change-Id: I1c88dd2dc967be68e6235e517dcf8554a891eee4
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/2302053
Commit-Queue: Zeynep Cankara <zcankara@google.com>
Reviewed-by: Camillo Bruni <cbruni@chromium.org>
Cr-Commit-Position: refs/heads/master@{#69102}
This commit is contained in:
Zeynep Cankara 2020-07-28 13:46:10 +01:00 committed by Commit Bot
parent 385382097a
commit ff4833f14c
12 changed files with 602 additions and 455 deletions

View File

@ -2,80 +2,88 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {$} from './helper.mjs';
import { V8Map } from './map-processor.mjs';
class State {
#mapTimeline;
#icTimeline;
#timeline;
#transitions;
constructor(mapPanelId, timelinePanelId) {
this.mapPanel_ = $(mapPanelId);
this.timelinePanel_ = $(timelinePanelId);
this._navigation = new Navigation(this);
this.timelinePanel_.addEventListener(
#mapPanel;
#timelinePanel;
#mapTrack;
#icTrack;
#map;
#ic;
#navigation;
constructor(mapPanel, timelinePanel, mapTrack, icTrack) {
this.#mapPanel = mapPanel;
this.#timelinePanel = timelinePanel;
this.#mapTrack = mapTrack;
this.#icTrack = icTrack;
this.#navigation = new Navigation(this);
this.#timelinePanel.addEventListener(
'mapchange', e => this.handleMapChange(e));
this.timelinePanel_.addEventListener(
this.#timelinePanel.addEventListener(
'showmaps', e => this.handleShowMaps(e));
this.mapPanel_.addEventListener(
'statemapchange', e => this.handleStateMapChange(e));
this.mapPanel_.addEventListener(
this.#mapPanel.addEventListener(
'mapchange', e => this.handleMapChange(e));
this.#mapPanel.addEventListener(
'selectmapdblclick', e => this.handleDblClickSelectMap(e));
this.mapPanel_.addEventListener(
this.#mapPanel.addEventListener(
'sourcepositionsclick', e => this.handleClickSourcePositions(e));
}
get chunks(){
//TODO(zcankara) for timeline dependency
return this.#mapTrack.chunks;
}
handleMapChange(e){
if (!(e.detail instanceof V8Map)) return;
this.map = e.detail;
}
handleStateMapChange(e){
this.map = e.detail;
}
handleShowMaps(e){
this.mapPanel_.mapEntries = e.detail.filter();
}
get icTimeline() {
return this.#icTimeline;
}
set icTimeline(value) {
this.#icTimeline = value;
if (!(e.detail instanceof V8Map)) return;
this.#mapPanel.mapEntries = e.detail.filter();
}
set transitions(value) {
this.mapPanel_.transitions = value;
this.#mapPanel.transitions = value;
}
get timeline() {
return this.#timeline;
}
set timeline(value) {
this.#timeline = value;
this.timelinePanel.timelineEntries = value;
this.timelinePanel.updateTimeline(this.map);
this.mapPanel_.timeline = this.timeline;
}
get chunks() {
return this.timelinePanel.chunks;
set mapTimeline(value) {
this.#mapPanel.timeline = value;
}
get nofChunks() {
return this.timelinePanel.nofChunks;
}
set nofChunks(count) {
this.timelinePanel.nofChunks = count;
this.timelinePanel.updateTimeline(this.map);
}
get mapPanel() {
return this.mapPanel_;
return this.#mapPanel;
}
get timelinePanel() {
return this.timelinePanel_;
return this.#timelinePanel;
}
get navigation() {
return this._navigation
return this.#navigation
}
get map() {
return this.timelinePanel.map;
return this.#map;
}
set map(value) {
if(!value) return;
this.timelinePanel.map = value;
this._navigation.updateUrl();
this.#map = value;
this.#mapTrack.selectedEntry = value;
this.#navigation.updateUrl();
this.mapPanel.map = value;
}
get ic() {
return this.#ic;
}
set ic(value) {
if(!value) return;
this.#ic = value;
}
get entries() {
if (!this.map) return {};
return {
@ -92,11 +100,6 @@ class State {
//TODO(zcankara) Handle double clicked map
console.log("double clicked map: ", e.detail);
}
handleStateMapChange(e){
this.map = e.detail;
}
}
class Navigation {
@ -174,5 +177,4 @@ class Navigation {
}
}
export { State };
export { State };

View File

@ -6,6 +6,7 @@ class Event {
#time;
#type;
constructor(type, time) {
//TODO(zcankara) remove type and add empty getters to override
this.#time = time;
this.#type = type;
}

View File

@ -118,11 +118,22 @@ function transitionTypeToColor(type) {
return CSSColor.primaryColor;
case 'ReplaceDescriptors':
return CSSColor.red;
case 'LoadGlobalIC':
return CSSColor.green;
case 'StoreInArrayLiteralIC':
return CSSColor.violet;
case 'StoreIC':
return CSSColor.orange;
case 'KeyedLoadIC':
return CSSColor.red;
case 'KeyedStoreIC':
return CSSColor.primaryColor;
}
return CSSColor.primaryColor;
}
function div(classes) {
let node = document.createElement('div');
if (classes !== void 0) {

View File

@ -261,4 +261,4 @@ class Entry extends Event {
}
}
export { CustomIcProcessor as default };
export { CustomIcProcessor as default };

View File

@ -93,8 +93,8 @@ found in the LICENSE file. -->
<script type="module" >
import {App} from './index.mjs';
globalThis.app = new App("#log-file-reader", "#map-panel",
"#timeline-panel", "#ic-panel");
globalThis.app = new App("#log-file-reader", "#map-panel", "#timeline-panel",
"#ic-panel", "#map-track", "#ic-track");
</script>
</head>
<body>
@ -105,7 +105,10 @@ globalThis.app = new App("#log-file-reader", "#map-panel",
<br></br>
</section>
<div id="container" style="display: none;">
<timeline-panel id="timeline-panel"></timeline-panel>
<timeline-panel id="timeline-panel">
<timeline-track id="map-track"></timeline-track>
<timeline-track id="ic-track"></timeline-track>
</timeline-panel>
<map-panel id="map-panel" onclick="app.handleMapAddressSearch(event)" onchange="app.handleShowMaps(event)"></map-panel>
<ic-panel id="ic-panel" onchange="app.handleSelectIc(event)"></ic-panel>
</div>

View File

@ -11,23 +11,31 @@ import './map-panel.mjs';
import './log-file-reader.mjs';
class App {
#timeSelection = {start: 0, end: Infinity};
constructor(fileReaderId, mapPanelId, timelinePanelId, icPanelId) {
this.mapPanelId_ = mapPanelId;
this.timelinePanelId_ = timelinePanelId;
this.icPanelId_ = icPanelId;
this.icPanel_ = this.$(icPanelId);
this.fileLoaded = false;
this.logFileReader_ = this.$(fileReaderId);
this.logFileReader_.addEventListener('fileuploadstart',
#mapPanel;
#timelinePanel;
#icPanel;
#mapTrack;
#icTrack;
#logFileReader;
constructor(fileReaderId, mapPanelId, timelinePanelId,
icPanelId, mapTrackId, icTrackId) {
this.#logFileReader = this.$(fileReaderId);
this.#mapPanel = this.$(mapPanelId);
this.#timelinePanel = this.$(timelinePanelId);
this.#icPanel = this.$(icPanelId);
this.#mapTrack = this.$(mapTrackId);
this.#icTrack = this.$(icTrackId);
this.#logFileReader.addEventListener('fileuploadstart',
e => this.handleFileUpload(e));
this.logFileReader_.addEventListener('fileuploadend',
this.#logFileReader.addEventListener('fileuploadend',
e => this.handleDataUpload(e));
document.addEventListener('keydown', e => this.handleKeyDown(e));
this.icPanel_.addEventListener(
this.#icPanel.addEventListener(
'ictimefilter', e => this.handleICTimeFilter(e));
this.icPanel_.addEventListener(
this.#icPanel.addEventListener(
'mapclick', e => this.handleMapClick(e));
this.icPanel_.addEventListener(
this.#icPanel.addEventListener(
'filepositionclick', e => this.handleFilePositionClick(e));
this.toggleSwitch = this.$('.theme-switch input[type="checkbox"]');
this.toggleSwitch.addEventListener('change', e => this.switchTheme(e));
@ -48,9 +56,9 @@ class App {
handleICTimeFilter(event) {
this.#timeSelection.start = event.detail.startTime;
this.#timeSelection.end = event.detail.endTime;
document.state.icTimeline.selectTimeRange(this.#timeSelection.start,
this.#icTrack.data.selectTimeRange(this.#timeSelection.start,
this.#timeSelection.end);
this.icPanel_.filteredEntries = document.state.icTimeline.selection;
this.#icPanel.filteredEntries = this.#icTrack.data.selection;
}
@ -102,13 +110,13 @@ class App {
reader.onload = (evt) => {
let icProcessor = new CustomIcProcessor();
//TODO(zcankara) Assign timeline directly to the ic panel
//TODO(zcankara) Exe: this.icPanel_.timeline = document.state.icTimeline
document.state.icTimeline = icProcessor.processString(fileData.chunk);
this.icPanel_.filteredEntries = document.state.icTimeline.all;
this.icPanel_.count.innerHTML = document.state.icTimeline.all.length;
//TODO(zcankara) Exe: this.#icPanel.timeline = document.state.icTimeline
this.#icTrack.data = icProcessor.processString(fileData.chunk);
this.#icPanel.filteredEntries = this.#icTrack.data.all;
this.#icPanel.count.innerHTML = this.#icTrack.data.all.length;
}
reader.readAsText(fileData.file);
this.icPanel_.initGroupKeySelect();
this.#icPanel.initGroupKeySelect();
}
// call when a new file uploaded
@ -117,12 +125,14 @@ class App {
this.$('#container').style.display = 'block';
// instantiate the app logic
let fileData = e.detail;
document.state = new State(this.mapPanelId_, this.timelinePanelId_);
document.state = new State(this.#mapPanel, this.#timelinePanel,
this.#mapTrack, this.#icTrack);
try {
const timeline = this.handleLoadTextMapProcessor(fileData.chunk);
// Transitions must be set before timeline for stats panel.
document.state.transitions= timeline.transitions;
document.state.timeline = timeline;
this.#mapTrack.data = timeline;
document.state.mapTimeline = timeline;
} catch (error) {
console.log(error);
}

View File

@ -45,7 +45,7 @@ defineCustomElement('./map-panel/map-transitions', (templateText) =>
this.dispatchEvent(new CustomEvent(
'mapdetailsupdate', {bubbles: true, composed: true, detail: map}));
this.dispatchEvent(new CustomEvent(
'statemapchange', {bubbles: true, composed: true, detail: map}));
'mapchange', {bubbles: true, composed: true, detail: map}));
}
dblClickSelectMap(map) {

View File

@ -247,11 +247,12 @@ class V8Map extends Event {
leftId= 0;
rightId = 0;
filePosition = '';
constructor(id, time = -1) {
if (time <= 0) throw new Error('Invalid time');
id = -1;
constructor(id, time) {
if (!time) throw new Error('Invalid time');
super(id, time);
V8Map.set(id, this);
this.id = id;
}
finalizeRootMap(id) {
@ -333,7 +334,7 @@ class V8Map extends Event {
return transitions;
}
getType() {
get type() {
return this.edge === void 0 ? 'new' : this.edge.type;
}
@ -516,4 +517,4 @@ class ArgumentsProcessor extends BaseArgumentsProcessor {
}
}
export { MapProcessor, V8Map, kChunkWidth, kChunkHeight};
export { MapProcessor, V8Map, kChunkWidth, kChunkHeight};

View File

@ -4,55 +4,6 @@ found in the LICENSE file. -->
<style>
@import "./index.css";
#timeline {
position: relative;
height: 300px;
overflow-y: hidden;
overflow-x: scroll;
user-select: none;
background-color: var(--timeline-background-color);
box-shadow: 0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22);
}
#timelineLabel {
transform: rotate(90deg);
transform-origin: left bottom 0;
position: absolute;
left: 0;
width: 250px;
text-align: center;
font-size: 10px;
opacity: 0.5;
}
#timelineChunks {
height: 250px;
position: absolute;
margin-right: 100px;
}
#timelineCanvas {
height: 250px;
position: relative;
overflow: visible;
pointer-events: none;
}
.chunk {
width: 6px;
border: 0px var(--timeline-background-color) solid;
border-width: 0 2px 0 2px;
position: absolute;
background-size: 100% 100%;
image-rendering: pixelated;
bottom: 0px;
}
.timestamp {
height: 250px;
width: 100px;
border-left: 1px var(--surface-color) dashed;
padding-left: 4px;
position: absolute;
pointer-events: none;
font-size: 10px;
opacity: 0.5;
}
#timelineOverview {
width: 100%;
height: 50px;
@ -89,10 +40,8 @@ found in the LICENSE file. -->
<div class="panel">
<h2>Timeline Panel</h2>
<h3>Timeline</h3>
<div id="timeline">
<div id="timelineLabel">Frequency</div>
<div id="timelineChunks"></div>
<canvas id="timelineCanvas"></canvas>
<div>
<slot></slot>
</div>
<div id="timelineOverview">
<div id="timelineOverviewIndicator">

View File

@ -1,9 +1,9 @@
// 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 {defineCustomElement, V8CustomElement,
transitionTypeToColor, CSSColor} from './helper.mjs';
import {kChunkWidth, kChunkHeight} from './map-processor.mjs';
import {defineCustomElement, V8CustomElement} from './helper.mjs';
import './timeline/timeline-track.mjs';
defineCustomElement('timeline-panel', (templateText) =>
class TimelinePanel extends V8CustomElement {
@ -11,14 +11,11 @@ defineCustomElement('timeline-panel', (templateText) =>
super(templateText);
this.timelineOverview.addEventListener(
'mousemove', e => this.handleTimelineIndicatorMove(e));
setInterval(this.updateOverviewWindow(), 50);
this.addEventListener(
'overviewupdate', e => this.handleOverviewBackgroundUpdate(e));
this.backgroundCanvas = document.createElement('canvas');
this.isLocked = false;
}
#timelineEntries;
#nofChunks = 400;
#chunks;
#map;
get timelineOverview() {
return this.$('#timelineOverview');
@ -29,50 +26,34 @@ defineCustomElement('timeline-panel', (templateText) =>
}
get timelineCanvas() {
return this.$('#timelineCanvas');
}
get timelineChunks() {
return this.$('#timelineChunks');
//TODO Don't access the timeline canvas from outside timeline track
if(!this.timelineTracks || !this.timelineTracks.length) return;
return this.timelineTracks[0].timelineCanvas;
}
get timeline() {
return this.$('#timeline');
//TODO(zcankara) Don't access the timeline from outside timeline track
if(!this.timelineTracks || !this.timelineTracks.length) return;
return this.timelineTracks[0].timeline;
}
set timelineEntries(value){
this.#timelineEntries = value;
this.updateChunks();
}
get timelineEntries(){
return this.#timelineEntries;
}
set nofChunks(count){
this.#nofChunks = count;
this.updateChunks();
for (const track of this.timelineTracks) {
track.nofChunks = count;
}
}
get nofChunks(){
return this.#nofChunks;
}
updateChunks() {
this.#chunks = this.timelineEntries.chunks(this.nofChunks);
}
get chunks(){
return this.#chunks;
}
set map(value){
this.#map = value;
this.redraw(this.map);
}
get map(){
return this.#map;
get timelineTracks(){
return this.$("slot").assignedNodes().filter(
track => track.nodeType === Node.ELEMENT_NODE);
}
handleTimelineIndicatorMove(event) {
if (event.buttons == 0) return;
let timelineTotalWidth = this.timelineCanvas.offsetWidth;
let factor = this.timelineOverview.offsetWidth / timelineTotalWidth;
this.timeline.scrollLeft += event.movementX / factor;
for (const track of this.timelineTracks) {
track.handleTimelineIndicatorMove(event.movementX / factor);
}
}
updateOverviewWindow() {
@ -80,8 +61,7 @@ defineCustomElement('timeline-panel', (templateText) =>
let totalIndicatorWidth =
this.timelineOverview.offsetWidth;
let div = this.timeline;
let timelineTotalWidth =
this.timelineCanvas.offsetWidth;
let timelineTotalWidth = this.timelineCanvas.offsetWidth;
let factor = totalIndicatorWidth / timelineTotalWidth;
let width = div.offsetWidth * factor;
let left = div.scrollLeft * factor;
@ -89,289 +69,9 @@ defineCustomElement('timeline-panel', (templateText) =>
indicator.style.left = left + 'px';
}
asyncSetTimelineChunkBackground(backgroundTodo) {
const kIncrement = 100;
let start = 0;
let delay = 1;
while (start < backgroundTodo.length) {
let end = Math.min(start + kIncrement, backgroundTodo.length);
setTimeout((from, to) => {
for (let i = from; i < to; i++) {
let [chunk, node] = backgroundTodo[i];
this.setTimelineChunkBackground(chunk, node);
}
}, delay++, start, end);
start = end;
}
}
setTimelineChunkBackground(chunk, node) {
// Render the types of transitions as bar charts
const kHeight = chunk.height;
const kWidth = 1;
this.backgroundCanvas.width = kWidth;
this.backgroundCanvas.height = kHeight;
let ctx = this.backgroundCanvas.getContext('2d');
ctx.clearRect(0, 0, kWidth, kHeight);
let y = 0;
let total = chunk.size();
let type, count;
if (true) {
chunk.getBreakdown(map => map.getType()).forEach(([type, count]) => {
ctx.fillStyle = transitionTypeToColor(type);
let height = count / total * kHeight;
ctx.fillRect(0, y, kWidth, y + height);
y += height;
});
} else {
chunk.items.forEach(map => {
ctx.fillStyle = transitionTypeToColor(map.getType());
let y = chunk.yOffset(map);
ctx.fillRect(0, y, kWidth, y + 1);
});
}
let imageData = this.backgroundCanvas.toDataURL('image/webp', 0.2);
node.style.backgroundImage = 'url(' + imageData + ')';
}
// TODO(zcankara) Timeline colors
updateTimeline(isMapSelected) {
let chunksNode = this.timelineChunks;
this.removeAllChildren(chunksNode);
let chunks = this.chunks;
let max = chunks.max(each => each.size());
let start = this.timelineEntries.startTime;
let end = this.timelineEntries.endTime;
let duration = end - start;
const timeToPixel = chunks.length * kChunkWidth / duration;
let addTimestamp = (time, name) => {
let timeNode = this.div('timestamp');
timeNode.innerText = name;
timeNode.style.left = ((time - start) * timeToPixel) + 'px';
chunksNode.appendChild(timeNode);
};
let backgroundTodo = [];
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 = this.div();
node.className = 'chunk';
node.style.left = (i * kChunkWidth) + 'px';
node.style.height = height + 'px';
node.chunk = chunk;
node.addEventListener('mousemove', e => this.handleChunkMouseMove(e));
node.addEventListener('click', e => this.handleChunkClick(e));
node.addEventListener('dblclick', e => this.handleChunkDoubleClick(e));
backgroundTodo.push([chunk, node])
chunksNode.appendChild(node);
}
this.asyncSetTimelineChunkBackground(backgroundTodo)
// 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) {
addTimestamp(time, ((time - start) / 1000) + ' ms');
time += interval;
}
this.drawOverview();
this.redraw(isMapSelected);
}
handleChunkMouseMove(event) {
if (this.isLocked) return false;
let chunk = event.target.chunk;
if (!chunk) return;
// topmost map (at chunk.height) == map #0.
let relativeIndex =
Math.round(event.layerY / event.target.offsetHeight * chunk.size());
let map = chunk.at(relativeIndex);
this.dispatchEvent(new CustomEvent(
'mapchange', {bubbles: true, composed: true, detail: map}));
}
handleChunkClick(event) {
this.isLocked = !this.isLocked;
}
handleChunkDoubleClick(event) {
this.isLocked = true;
let chunk = event.target.chunk;
if (!chunk) return;
this.dispatchEvent(new CustomEvent(
'showmaps', {bubbles: true, composed: true, detail: chunk}));
}
drawOverview() {
const height = 50;
const kFactor = 2;
let canvas = this.backgroundCanvas;
canvas.height = height;
canvas.width = window.innerWidth;
let ctx = canvas.getContext('2d');
let chunks = this.timelineEntries.chunkSizes(canvas.width * kFactor);
let max = chunks.max();
ctx.clearRect(0, 0, canvas.width, height);
ctx.fillStyle = CSSColor.onBackgroundColor;
ctx.beginPath();
ctx.moveTo(0, height);
for (let i = 0; i < chunks.length; i++) {
ctx.lineTo(i / kFactor, height - chunks[i] / max * height);
}
ctx.lineTo(chunks.length, height);
ctx.strokeStyle = CSSColor.onBackgroundColor;
ctx.stroke();
ctx.closePath();
ctx.fill();
let imageData = canvas.toDataURL('image/webp', 0.2);
handleOverviewBackgroundUpdate(e){
this.timelineOverview.style.backgroundImage =
'url(' + imageData + ')';
}
redraw(isMapSelected) {
let canvas = this.timelineCanvas;
canvas.width = (this.chunks.length + 1) * kChunkWidth;
canvas.height = kChunkHeight;
let ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, kChunkHeight);
if (!isMapSelected) return;
this.drawEdges(ctx);
}
setMapStyle(map, ctx) {
ctx.fillStyle = map.edge && map.edge.from
? CSSColor.onBackgroundColor : CSSColor.onPrimaryColor;
}
setEdgeStyle(edge, ctx) {
const color = transitionTypeToColor(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.map;
while (current && nofEdges < kMaxOutgoingEdges) {
nofEdges += current.children.length;
stack.push(current);
current = current.parent();
}
ctx.save();
this.drawOutgoingEdges(ctx, this.map, 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.map);
}
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.strokeStyle = CSSColor.onBackgroundColor;
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.strokeStyle = CSSColor.onBackgroundColor;
ctx.moveTo(centerX, centerY);
ctx.lineTo(centerX + offsetX, centerY - labelOffset);
ctx.stroke();
ctx.textAlign = 'left';
ctx.fillStyle = CSSColor.onBackgroundColor;
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);
}
'url(' + e.detail + ')';
}
});

View File

@ -0,0 +1,78 @@
<!-- 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. -->
<head>
<link href="./index.css" rel="stylesheet">
</head>
<style>
#timeline {
position: relative;
height: 300px;
overflow-y: hidden;
overflow-x: scroll;
user-select: none;
background-color: var(--timeline-background-color);
box-shadow: 0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22);
}
#timelineLabel {
transform: rotate(90deg);
transform-origin: left bottom 0;
position: absolute;
left: 0;
width: 250px;
text-align: center;
font-size: 10px;
opacity: 0.5;
}
#timelineChunks {
height: 250px;
position: absolute;
margin-right: 100px;
}
#timelineCanvas {
height: 250px;
position: relative;
overflow: visible;
pointer-events: none;
}
.chunk {
width: 6px;
border: 0px var(--timeline-background-color) solid;
border-width: 0 2px 0 2px;
position: absolute;
background-size: 100% 100%;
image-rendering: pixelated;
bottom: 0px;
}
.timestamp {
height: 250px;
width: 100px;
border-left: 1px var(--surface-color) dashed;
padding-left: 4px;
position: absolute;
pointer-events: none;
font-size: 10px;
opacity: 0.5;
}
#timelineLegend {
position: relative;
float: right;
text-align: center;
}
.timeline {
background-color: var(--timeline-background-color);
}
</style>
<div class="timeline">
<div id="timelineLegend">
<p>Category</p>
<dl id="timelineLegendContent" style="float:right; padding:20px">
</dl>
</div>
<div id="timeline">
<div id="timelineLabel">Frequency</div>
<div id="timelineChunks"></div>
<canvas id="timelineCanvas"></canvas>
</div>
</div>

View File

@ -0,0 +1,392 @@
// 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 {defineCustomElement, V8CustomElement,
transitionTypeToColor, CSSColor} from '../helper.mjs';
import {kChunkWidth, kChunkHeight} from '../map-processor.mjs';
defineCustomElement('./timeline/timeline-track', (templateText) =>
class TimelineTrack extends V8CustomElement {
#timeline;
#nofChunks = 400;
#chunks;
#selectedEntry;
constructor() {
super(templateText);
this.backgroundCanvas = document.createElement('canvas');
this.isLocked = false;
}
get timelineCanvas() {
return this.$('#timelineCanvas');
}
get timelineChunks() {
return this.$('#timelineChunks');
}
get timeline() {
return this.$('#timeline');
}
get timelineLegendContent() {
return this.$('#timelineLegendContent');
}
set data(value) {
this.#timeline = value;
this.updateChunks();
this.updateTimeline();
this.updateStats();
}
get data() {
return this.#timeline;
}
set nofChunks(count){
this.#nofChunks = count;
this.updateChunks();
this.updateTimeline();
}
get nofChunks(){
return this.#nofChunks;
}
updateChunks() {
this.#chunks = this.data.chunks(this.nofChunks);
}
get chunks(){
return this.#chunks;
}
set selectedEntry(value){
this.#selectedEntry = value;
if(value.edge) this.redraw();
}
get selectedEntry(){
return this.#selectedEntry;
}
updateStats(){
let unique = new Map();
for (const entry of this.data.all) {
if(!unique.has(entry.type)) {
unique.set(entry.type, 1);
} else {
unique.set(entry.type, unique.get(entry.type) + 1);
}
}
console.log(unique);
//TODO(zcankara) Update it to make it work without relying on hex colors
this.renderStatsWindow(unique);
}
renderStatsWindow(unique){
//TODO(zcankara) Update legend with colors and entries
let timelineLegendContent = this.timelineLegendContent;
this.removeAllChildren(timelineLegendContent);
let fragment = document.createDocumentFragment();
let colorIterator = 0;
unique.forEach((key, val) => {
let dt = document.createElement("dt");
dt.innerHTML = key;
dt.style.backgroundColor = transitionTypeToColor(val);
dt.style.color = CSSColor.surfaceColor;
fragment.appendChild(dt);
let dd = document.createElement("dd");
dd.innerHTML = val;
fragment.appendChild(dd);
colorIterator += 1;
});
timelineLegendContent.appendChild(fragment);
}
// TODO(zcankara) Emit event and handle data in timeline track
handleTimelineIndicatorMove(offset) {
this.timeline.scrollLeft += offset;
}
asyncSetTimelineChunkBackground(backgroundTodo) {
const kIncrement = 100;
let start = 0;
let delay = 1;
while (start < backgroundTodo.length) {
let end = Math.min(start + kIncrement, backgroundTodo.length);
setTimeout((from, to) => {
for (let i = from; i < to; i++) {
let [chunk, node] = backgroundTodo[i];
this.setTimelineChunkBackground(chunk, node);
}
}, delay++, start, end);
start = end;
}
}
setTimelineChunkBackground(chunk, node) {
// Render the types of transitions as bar charts
const kHeight = chunk.height;
const kWidth = 1;
this.backgroundCanvas.width = kWidth;
this.backgroundCanvas.height = kHeight;
let ctx = this.backgroundCanvas.getContext('2d');
ctx.clearRect(0, 0, kWidth, kHeight);
let y = 0;
let total = chunk.size();
let type, count;
if (true) {
chunk.getBreakdown(map => map.type).forEach(([type, count]) => {
ctx.fillStyle = transitionTypeToColor(type);
let height = count / total * kHeight;
ctx.fillRect(0, y, kWidth, y + height);
y += height;
});
} else {
chunk.items.forEach(map => {
ctx.fillStyle = transitionTypeToColor(map.type);
let y = chunk.yOffset(map);
ctx.fillRect(0, y, kWidth, y + 1);
});
}
let imageData = this.backgroundCanvas.toDataURL('image/webp', 0.2);
node.style.backgroundImage = 'url(' + imageData + ')';
}
updateTimeline() {
let chunksNode = this.timelineChunks;
this.removeAllChildren(chunksNode);
let chunks = this.chunks;
let max = chunks.max(each => each.size());
let start = this.data.startTime;
let end = this.data.endTime;
let duration = end - start;
const timeToPixel = chunks.length * kChunkWidth / duration;
let addTimestamp = (time, name) => {
let timeNode = this.div('timestamp');
timeNode.innerText = name;
timeNode.style.left = ((time - start) * timeToPixel) + 'px';
chunksNode.appendChild(timeNode);
};
let backgroundTodo = [];
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 = this.div();
node.className = 'chunk';
node.style.left = (i * kChunkWidth) + 'px';
node.style.height = height + 'px';
node.chunk = chunk;
node.addEventListener('mousemove', e => this.handleChunkMouseMove(e));
node.addEventListener('click', e => this.handleChunkClick(e));
node.addEventListener('dblclick', e => this.handleChunkDoubleClick(e));
backgroundTodo.push([chunk, node])
chunksNode.appendChild(node);
}
this.asyncSetTimelineChunkBackground(backgroundTodo)
// 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) {
addTimestamp(time, ((time - start) / 1000) + ' ms');
time += interval;
}
this.drawOverview();
this.redraw();
}
handleChunkMouseMove(event) {
if (this.isLocked) return false;
let chunk = event.target.chunk;
if (!chunk) return;
// topmost map (at chunk.height) == map #0.
let relativeIndex =
Math.round(event.layerY / event.target.offsetHeight * chunk.size());
let map = chunk.at(relativeIndex);
this.dispatchEvent(new CustomEvent(
'mapchange', {bubbles: true, composed: true, detail: map}));
}
handleChunkClick(event) {
this.isLocked = !this.isLocked;
}
handleChunkDoubleClick(event) {
this.isLocked = true;
let chunk = event.target.chunk;
if (!chunk) return;
this.dispatchEvent(new CustomEvent(
'showmaps', {bubbles: true, composed: true, detail: chunk}));
}
drawOverview() {
const height = 50;
const kFactor = 2;
let canvas = this.backgroundCanvas;
canvas.height = height;
canvas.width = window.innerWidth;
let ctx = canvas.getContext('2d');
let chunks = this.data.chunkSizes(canvas.width * kFactor);
let max = chunks.max();
ctx.clearRect(0, 0, canvas.width, height);
ctx.fillStyle = CSSColor.onBackgroundColor;
ctx.beginPath();
ctx.moveTo(0, height);
for (let i = 0; i < chunks.length; i++) {
ctx.lineTo(i / kFactor, height - chunks[i] / max * height);
}
ctx.lineTo(chunks.length, height);
ctx.strokeStyle = CSSColor.onBackgroundColor;
ctx.stroke();
ctx.closePath();
ctx.fill();
let imageData = canvas.toDataURL('image/webp', 0.2);
this.dispatchEvent(new CustomEvent(
'overviewupdate', {bubbles: true, composed: true, detail: imageData}));
}
redraw() {
let canvas = this.timelineCanvas;
canvas.width = (this.chunks.length + 1) * kChunkWidth;
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 = transitionTypeToColor(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.strokeStyle = CSSColor.onBackgroundColor;
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.strokeStyle = CSSColor.onBackgroundColor;
ctx.moveTo(centerX, centerY);
ctx.lineTo(centerX + offsetX, centerY - labelOffset);
ctx.stroke();
ctx.textAlign = 'left';
ctx.fillStyle = CSSColor.onBackgroundColor;
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);
}
}
}
);