393e434479
This CL unifies the custom events by creating classes specialised based on the event type. Multiple entry selection causes panels to emit 'showentries' event. Single entry selection causes panels to emit 'showentrydetail' event. The events are received by the controller App class and updates the view of the panels and state of the app. Bug: v8:10644 Change-Id: Ibe26223459ba605c6d6d3f0025bf3a556dfb0578 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/2335188 Commit-Queue: Zeynep Cankara <zcankara@google.com> Reviewed-by: Sathya Gunasekaran <gsathya@chromium.org> Cr-Commit-Position: refs/heads/master@{#69286}
407 lines
12 KiB
JavaScript
407 lines
12 KiB
JavaScript
// 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);
|
|
}
|
|
}
|
|
}
|
|
);
|