blob: 5c8f5b18a94e185880a39c38d770a2f45ae6834a [file] [log] [blame]
// 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 {SelectionEvent, SelectEvent} from '../events.mjs';
defineCustomElement('./timeline/timeline-track', (templateText) =>
class TimelineTrack extends V8CustomElement {
#timeline;
#nofChunks = 400;
#chunks;
#selectedEntry;
constructor() {
super(templateText);
this.timeline.addEventListener("scroll",
e => this.handleTimelineScroll(e));
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;
}
set scrollLeft(offset){
this.timeline.scrollLeft = offset;
}
updateStats(){
let unique = new Map();
for (const entry of this.data.all) {
if(!unique.has(entry.type)) {
unique.set(entry.type, [entry]);
} else {
unique.get(entry.type).push(entry);
}
}
this.renderStatsWindow(unique);
}
renderStatsWindow(unique){
let timelineLegendContent = this.timelineLegendContent;
this.removeAllChildren(timelineLegendContent);
let fragment = document.createDocumentFragment();
let colorIterator = 0;
unique.forEach((entries, type) => {
let dt = document.createElement("dt");
dt.innerHTML = entries.length;
dt.style.backgroundColor = transitionTypeToColor(type);
dt.style.color = CSSColor.surfaceColor;
fragment.appendChild(dt);
let dd = document.createElement("dd");
dd.innerHTML = type;
dd.entries = entries;
dd.addEventListener('dblclick', e => this.handleEntryTypeDblClick(e));
fragment.appendChild(dd);
colorIterator += 1;
});
timelineLegendContent.appendChild(fragment);
}
handleEntryTypeDblClick(e){
this.dispatchEvent(new SelectionEvent(e.target.entries));
}
timelineIndicatorMove(offset) {
this.timeline.scrollLeft += offset;
}
handleTimelineScroll(e){
let horizontal = e.currentTarget.scrollLeft;
this.dispatchEvent(new CustomEvent(
'scrolltrack', {bubbles: true, composed: true,
detail: horizontal}));
}
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 SelectEvent(map));
}
handleChunkClick(event) {
this.isLocked = !this.isLocked;
}
handleChunkDoubleClick(event) {
this.isLocked = true;
let chunk = event.target.chunk;
if (!chunk) return;
let maps = chunk.filter();
this.dispatchEvent(new SelectionEvent(maps));
}
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);
}
}
}
);