[tools] System-analyzer timeline improvements

- Timeline.selection is now a Timeline as well
- Allow remove the current timeline-track selection by double-clicking
  outside-the selection
- Update the timeline-track stats based on the current selection
- Simplify DOM element creation methods
- Add separate SelectionHandler class for timeline-track

Bug: v8:10644
Change-Id: I4f15d6ab4f5ec6b7330e22769472ca3074b00edd
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/2565130
Commit-Queue: Camillo Bruni <cbruni@chromium.org>
Reviewed-by: Sathya Gunasekaran  <gsathya@chromium.org>
Cr-Commit-Position: refs/heads/master@{#71497}
This commit is contained in:
Camillo Bruni 2020-11-30 16:09:54 +01:00 committed by Commit Bot
parent 21cec63d11
commit 94f0536635
11 changed files with 357 additions and 296 deletions

View File

@ -44,7 +44,9 @@ import { LogEntry} from "../../../tools/system-analyzer/log/log.mjs";
let startTime = time;
let endTime = time + 2;
timeline.selectTimeRange(startTime, endTime);
assertArrayEquals(timeline.selection, [entry1, entry2]);
assertArrayEquals(timeline.selection.startTime, startTime);
assertArrayEquals(timeline.selection.endTime, endTime);
assertArrayEquals(timeline.selection.values, [entry1, entry2]);
let entryIdx = timeline.find(time + 1);
let entry = timeline.at(entryIdx);
assertEquals(entry.time, time + 1);

View File

@ -34,8 +34,7 @@ export class Group {
static groupBy(entries, property) {
let accumulator = Object.create(null);
let length = entries.length;
for (let i = 0; i < length; i++) {
let entry = entries[i];
for (let entry of entries) {
let key = entry[property];
if (accumulator[key] == undefined) {
accumulator[key] = new Group(property, key, entry);

View File

@ -144,6 +144,7 @@ button:hover {
}
.colorbox {
display: inline-block;
width: 10px;
height: 10px;
border: 1px var(--background-color) solid;

View File

@ -64,11 +64,12 @@ class App {
}
handleShowEntries(e) {
if (e.entries[0] instanceof MapLogEntry) {
const entry = e.entries[0];
if (entry instanceof MapLogEntry) {
this.showMapEntries(e.entries);
} else if (e.entries[0] instanceof IcLogEntry) {
} else if (entry instanceof IcLogEntry) {
this.showIcEntries(e.entries);
} else if (e.entries[0] instanceof SourcePosition) {
} else if (entry instanceof SourcePosition) {
this.showSourcePositionEntries(e.entries);
} else {
throw new Error('Unknown selection type!');
@ -117,11 +118,12 @@ class App {
}
handleShowEntryDetail(e) {
if (e.entry instanceof MapLogEntry) {
const entry = e.entry;
if (entry instanceof MapLogEntry) {
this.selectMapLogEntry(e.entry);
} else if (e.entry instanceof IcLogEntry) {
} else if (entry instanceof IcLogEntry) {
this.selectICLogEntry(e.entry);
} else if (e.entry instanceof SourcePosition) {
} else if (entry instanceof SourcePosition) {
this.selectSourcePosition(e.entry);
} else {
throw new Error('Unknown selection type!');
@ -137,7 +139,7 @@ class App {
selectICLogEntry(entry) {
this._state.ic = entry;
this._view.icPanel.selectedLogEntries = [entry];
this._view.icPanel.selectedEntry = [entry];
}
selectSourcePosition(sourcePositions) {

View File

@ -9,13 +9,13 @@ class Timeline {
_values;
// Current selection, subset of #values:
_selection;
_uniqueTypes;
_breakdown;
constructor(model) {
constructor(model, values = [], startTime = 0, endTime = 0) {
this._model = model;
this._values = [];
this.startTime = 0;
this.endTime = 0;
this._values = values;
this.startTime = startTime;
this.endTime = endTime;
}
get model() {
@ -35,16 +35,19 @@ class Timeline {
}
selectTimeRange(startTime, endTime) {
this._selection = this.range(startTime, endTime);
const items = this.range(startTime, endTime);
this._selection = new Timeline(this._model, items, startTime, endTime);
}
clearSelection() {
this._selection = undefined;
}
getChunks(windowSizeMs) {
// TODO(zcankara) Fill this one
return this.chunkSizes(windowSizeMs);
}
get values() {
// TODO(zcankara) Not to break something delete later
return this._values;
}
@ -94,6 +97,10 @@ class Timeline {
return this._values.length;
}
slice(startIndex, endIndex) {
return this._values.slice(startIndex, endIndex);
}
first() {
return this._values[0];
}
@ -102,11 +109,17 @@ class Timeline {
return this._values[this._values.length - 1];
}
* [Symbol.iterator]() {
yield* this._values;
}
duration() {
if (this.isEmpty()) return 0;
return this.last().time - this.first().time;
}
forEachChunkSize(count, fn) {
if (this.isEmpty()) return;
const increment = this.duration() / count;
let currentTime = this.first().time + increment;
let index = 0;
@ -162,24 +175,12 @@ class Timeline {
return minIndex;
}
_initializeTypes() {
const types = new Map();
let index = 0;
for (const entry of this.all) {
let entries = types.get(entry.type);
if (entries != undefined) {
entries.push(entry)
} else {
entries = [entry];
entries.index = index++;
types.set(entry.type, entries)
}
getBreakdown(keyFunction) {
if (keyFunction) return breakdown(this._values, keyFunction);
if (this._breakdown === undefined) {
this._breakdown = breakdown(this._values, each => each.type);
}
return this._uniqueTypes = types;
}
get uniqueTypes() {
return this._uniqueTypes ?? this._initializeTypes();
return this._breakdown;
}
depthHistogram() {
@ -259,26 +260,7 @@ class Chunk {
}
getBreakdown(keyFunction) {
if (this.items.length === 0) return [];
if (keyFunction === void 0) {
keyFunction = each => each;
}
const typeToindex = new Map();
const breakdown = [];
// This is performance critical, resorting to for-loop
for (let i = 0; i < this.items.length; i++) {
const each = this.items[i];
const type = keyFunction(each);
const index = typeToindex.get(type);
if (index === void 0) {
typeToindex.set(type, breakdown.length);
breakdown.push([type, 0]);
} else {
breakdown[index][1]++;
}
}
// Sort by count
return breakdown.sort((a, b) => a[1] - b[1]);
return breakdown(this.items, keyFunction);
}
filter() {
@ -286,4 +268,26 @@ class Chunk {
}
}
function breakdown(array, keyFunction) {
if (array.length === 0) return [];
if (keyFunction === undefined) keyFunction = each => each;
const typeToindex = new Map();
const breakdown = [];
let id = 0;
// This is performance critical, resorting to for-loop
for (let i = 0; i < array.length; i++) {
const each = array[i];
const type = keyFunction(each);
const index = typeToindex.get(type);
if (index === void 0) {
typeToindex.set(type, breakdown.length);
breakdown.push({type: type, count: 0, id: id++});
} else {
breakdown[index].count++;
}
}
// Sort by count
return breakdown.sort((a, b) => b.count - a.count);
}
export {Timeline, Chunk};

View File

@ -30,7 +30,7 @@ export class SelectTimeEvent extends CustomEvent {
static get name() {
return 'timerangeselect';
}
constructor(start, end) {
constructor(start = -1, end = Infinity) {
super(SelectTimeEvent.name, {bubbles: true, composed: true});
this.start = start;
this.end = end;

View File

@ -13,7 +13,7 @@ class CSSColor {
if (color === undefined) {
throw new Error(`CSS color does not exist: ${name}`);
}
this._cache.set(name, color);
this._cache.set(name, color.trim());
return color;
}
static reset() {
@ -74,51 +74,61 @@ class CSSColor {
static get violet() {
return this.get('violet');
}
static at(index) {
return this.list[index % this.list.length];
}
static get list() {
if (!this._colors) {
this._colors = [
this.green,
this.violet,
this.orange,
this.yellow,
this.primaryColor,
this.red,
this.blue,
this.yellow,
this.secondaryColor,
];
}
return this._colors;
}
}
const kColors = [
CSSColor.green,
CSSColor.violet,
CSSColor.orange,
CSSColor.yellow,
CSSColor.primaryColor,
CSSColor.red,
CSSColor.blue,
CSSColor.yellow,
CSSColor.secondaryColor,
];
class DOM {
static element(type, classes) {
const node = document.createElement(type);
if (classes === undefined) return node;
if (typeof classes === 'string') {
node.className = classes;
} else {
classes.forEach(cls => node.classList.add(cls));
}
return node;
}
static text(string) {
return document.createTextNode(string);
}
static div(classes) {
const node = document.createElement('div');
if (classes !== void 0) {
if (typeof classes === 'string') {
node.className = classes;
} else {
classes.forEach(cls => node.classList.add(cls));
}
}
return node;
return this.element('div', classes);
}
static span(classes) {
const node = document.createElement('span');
if (classes !== undefined) {
if (typeof classes === 'string') {
node.className = classes;
} else {
classes.forEach(cls => node.classList.add(cls));
}
}
return node;
return this.element('span', classes);
}
static table(className) {
const node = document.createElement('table');
if (className) node.className = className;
return node;
static table(classes) {
return this.element('table', classes);
}
static tbody(classes) {
return this.element('tbody', classes);
}
static td(textOrNode, className) {
const node = document.createElement('td');
const node = this.element('td');
if (typeof textOrNode === 'object') {
node.appendChild(textOrNode);
} else if (textOrNode) {
@ -128,14 +138,8 @@ class DOM {
return node;
}
static tr(className) {
const node = document.createElement('tr');
if (className) node.className = className;
return node;
}
static text(string) {
return document.createTextNode(string);
static tr(classes) {
return this.element('tr', classes);
}
static removeAllChildren(node) {
@ -225,7 +229,6 @@ export * from '../helper.mjs';
export {
DOM,
$,
kColors,
V8CustomElement,
CSSColor,
LazyTable,

View File

@ -12,6 +12,7 @@ import {DOM, V8CustomElement} from './helper.mjs';
DOM.defineCustomElement(
'view/ic-panel', (templateText) => class ICPanel extends V8CustomElement {
_selectedLogEntries;
_selectedLogEntry;
_timeline;
_detailsClickHandler = this.handleDetailsClick.bind(this);
@ -54,6 +55,10 @@ DOM.defineCustomElement(
this.update();
}
set selectedLogEntry(entry) {
// TODO: show details
}
_update() {
this._updateCount();
this._updateTable();

View File

@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {FocusEvent, ToolTipEvent} from '../events.mjs';
import {kColors} from '../helper.mjs';
import {CSSColor} from '../helper.mjs';
import {DOM, V8CustomElement} from '../helper.mjs';
DOM.defineCustomElement(
@ -10,6 +10,7 @@ DOM.defineCustomElement(
(templateText) => class MapTransitions extends V8CustomElement {
_timeline;
_map;
_edgeToColor = new Map();
_selectedMapLogEntries;
_displayedMapsInTree;
_toggleSubtreeHandler = this._handleToggleSubtree.bind(this);
@ -42,6 +43,10 @@ DOM.defineCustomElement(
set timeline(timeline) {
this._timeline = timeline;
this._edgeToColor.clear();
timeline.getBreakdown().forEach(breakdown => {
this._edgeToColor.set(breakdown.type, CSSColor.at(breakdown.id));
});
}
set selectedMapLogEntries(list) {
@ -53,11 +58,6 @@ DOM.defineCustomElement(
return this._selectedMapLogEntries;
}
_edgeToColor(edge) {
const index = this._timeline.uniqueTypes.get(edge.type).index
return kColors[index % kColors.length];
}
_handleTransitionViewChange(e) {
this.tooltip.style.left = e.pageX + 'px';
this.tooltip.style.top = e.pageY + 'px';
@ -124,7 +124,7 @@ DOM.defineCustomElement(
_addTransitionEdge(map) {
let classes = ['transitionEdge'];
let edge = DOM.div(classes);
edge.style.backgroundColor = this._edgeToColor(map.edge);
edge.style.backgroundColor = this._edgeToColor.get(map.edge.type);
let labelNode = DOM.div('transitionLabel');
labelNode.innerText = map.edge.toString();
edge.appendChild(labelNode);
@ -153,7 +153,8 @@ DOM.defineCustomElement(
_addMapNode(map) {
let node = DOM.div('map');
if (map.edge) node.style.backgroundColor = this._edgeToColor(map.edge);
if (map.edge)
node.style.backgroundColor = this._edgeToColor.get(map.edge.type);
node.map = map;
node.onclick = this._selectMapHandler
node.onmouseover = this._mouseoverMapHandler

View File

@ -85,6 +85,10 @@ found in the LICENSE file. -->
#legend td:nth-of-type(4n+4) {
text-align: right;
}
/* Center colors */
#legend td:nth-of-type(4n+1) {
text-align: center;;
}
.legendTypeColumn {
width: 100%;
@ -94,8 +98,8 @@ found in the LICENSE file. -->
background-color: var(--timeline-background-color);
}
#timeline .rightHandle,
#timeline .leftHandle {
#rightHandle,
#leftHandle {
background-color: rgba(200, 200, 200, 0.5);
height: 100%;
width: 5px;
@ -103,14 +107,14 @@ found in the LICENSE file. -->
z-index: 3;
cursor: col-resize;
}
#timeline .leftHandle {
#leftHandle {
border-left: 1px solid var(--on-surface-color);
}
#timeline .rightHandle {
#rightHandle {
border-right: 1px solid var(--on-surface-color);
}
#timeline .selection {
#selectionBackground {
background-color: rgba(133, 68, 163, 0.5);
height: 100%;
position: absolute;
@ -125,13 +129,14 @@ found in the LICENSE file. -->
<td>Percent</td>
</tr>
</thead>
<tbody id="legendContent">
</tbody>
<tbody></tbody>
</table>
<div id="timeline">
<div class="leftHandle"></div>
<div class="selection"></div>
<div class="rightHandle"></div>
<div id="selection">
<div id="leftHandle"></div>
<div id="selectionBackground"></div>
<div id="rightHandle"></div>
</div>
<div id="timelineLabel">Frequency</div>
<div id="timelineChunks"></div>
<canvas id="timelineCanvas"></canvas>

View File

@ -5,113 +5,59 @@
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, kColors, V8CustomElement} from '../helper.mjs';
import {CSSColor, DOM, V8CustomElement} from '../helper.mjs';
DOM.defineCustomElement('view/timeline/timeline-track',
(templateText) =>
class TimelineTrack extends V8CustomElement {
// TODO turn into static field once Safari supports it.
static get SELECTION_OFFSET() {
return 10
};
_timeline;
_nofChunks = 400;
_chunks;
_selectedEntry;
_timeToPixel;
_timeSelection = {start: -1, end: Infinity};
_timeStartOffset;
_selectionOriginTime;
_typeToColor;
_legend;
_entryTypeDoubleClickHandler = this.handleEntryTypeDoubleClick.bind(this);
_chunkMouseMoveHandler = this.handleChunkMouseMove.bind(this);
_chunkClickHandler = this.handleChunkClick.bind(this);
_chunkDoubleClickHandler = this.handleChunkDoubleClick.bind(this);
_chunkMouseMoveHandler = this._handleChunkMouseMove.bind(this);
_chunkClickHandler = this._handleChunkClick.bind(this);
_chunkDoubleClickHandler = this._handleChunkDoubleClick.bind(this);
constructor() {
super(templateText);
this.timeline.addEventListener('scroll', e => this.handleTimelineScroll(e));
this.timeline.addEventListener(
'mousedown', e => this.handleTimeSelectionMouseDown(e));
this.timeline.addEventListener(
'mouseup', e => this.handleTimeSelectionMouseUp(e));
this.timeline.addEventListener(
'mousemove', e => this.handleTimeSelectionMouseMove(e));
this._selectionHandler = new SelectionHandler(this);
this._legend = new Legend(this.$('#legend'));
this.timelineNode.addEventListener(
'scroll', e => this._handleTimelineScroll(e));
this.timelineNode.ondblclick = (e) =>
this._selectionHandler.clearSelection();
this.isLocked = false;
}
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);
}
isOnLeftHandle(posX) {
return (
Math.abs(this.leftHandlePosX - posX) <= TimelineTrack.SELECTION_OFFSET);
}
isOnRightHandle(posX) {
return (
Math.abs(this.rightHandlePosX - posX) <=
TimelineTrack.SELECTION_OFFSET);
}
handleTimeSelectionMouseMove(e) {
if (!this._isSelecting) return;
const currentTime = this.positionToTime(e.clientX);
this.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.dispatchEvent(new SelectTimeEvent(
this._timeSelection.start, this._timeSelection.end));
set data(timeline) {
this._timeline = timeline;
this._typeToColor = new Map();
timeline.getBreakdown().forEach(
each => this._typeToColor.set(each.type, CSSColor.at(each.id)));
this._legend.timeline = timeline;
this._updateChunks();
this.update();
}
set timeSelection(selection) {
this._timeSelection.start = selection.start;
this._timeSelection.end = selection.end;
this._selectionHandler.timeSelection = selection;
this.updateSelection();
}
get _isSelecting() {
return this._selectionOriginTime >= 0;
}
updateSelection() {
const startPosition = this.timeToPosition(this._timeSelection.start);
const endPosition = this.timeToPosition(this._timeSelection.end);
const delta = endPosition - startPosition;
this.leftHandle.style.left = startPosition + 'px';
this.selection.style.left = startPosition + 'px';
this.rightHandle.style.left = endPosition + 'px';
this.selection.style.width = delta + 'px';
}
get leftHandlePosX() {
return this.leftHandle.getBoundingClientRect().x;
}
get rightHandlePosX() {
return this.rightHandle.getBoundingClientRect().x;
this._selectionHandler.update();
this._legend.update();
}
// Maps the clicked x position to the x position on timeline canvas
positionOnTimeline(posX) {
let rect = this.timeline.getBoundingClientRect();
let posClickedX = posX - rect.left + this.timeline.scrollLeft;
let rect = this.timelineNode.getBoundingClientRect();
let posClickedX = posX - rect.left + this.timelineNode.scrollLeft;
return posClickedX;
}
@ -122,22 +68,10 @@ DOM.defineCustomElement('view/timeline/timeline-track',
timeToPosition(time) {
let posX = time * this._timeToPixel;
posX -= this._timeStartOffset
posX -= this._timeStartOffset;
return posX;
}
get leftHandle() {
return this.$('.leftHandle');
}
get rightHandle() {
return this.$('.rightHandle');
}
get selection() {
return this.$('.selection');
}
get timelineCanvas() {
return this.$('#timelineCanvas');
}
@ -146,41 +80,13 @@ DOM.defineCustomElement('view/timeline/timeline-track',
return this.$('#timelineChunks');
}
get timeline() {
get timelineNode() {
return this.$('#timeline');
}
get timelineLegend() {
return this.$('#legend');
}
get timelineLegendContent() {
return this.$('#legendContent');
}
set data(value) {
this._timeline = value;
this._resetTypeToColorCache();
// Only update legend if the timeline data has changed.
this._updateLegend();
this._updateChunks();
this.update();
}
_update() {
this._updateTimeline();
}
_resetTypeToColorCache() {
this._typeToColor = new Map();
let lastIndex = 0;
for (const type of this.data.uniqueTypes.keys()) {
this._typeToColor.set(type, kColors[lastIndex++]);
}
}
get data() {
return this._timeline;
this._legend.update();
}
set nofChunks(count) {
@ -194,7 +100,7 @@ DOM.defineCustomElement('view/timeline/timeline-track',
}
_updateChunks() {
this._chunks = this.data.chunks(this.nofChunks);
this._chunks = this._timeline.chunks(this.nofChunks);
}
get chunks() {
@ -211,43 +117,7 @@ DOM.defineCustomElement('view/timeline/timeline-track',
}
set scrollLeft(offset) {
this.timeline.scrollLeft = offset;
}
typeToColor(type) {
return this._typeToColor.get(type);
}
_updateLegend() {
let timelineLegendContent = this.timelineLegendContent;
DOM.removeAllChildren(timelineLegendContent);
this._timeline.uniqueTypes.forEach((entries, type) => {
let row = DOM.tr('clickable');
row.entries = entries;
row.ondblclick = this.entryTypeDoubleClickHandler_;
let color = this.typeToColor(type);
if (color !== null) {
let div = DOM.div('colorbox');
div.style.backgroundColor = color;
row.appendChild(DOM.td(div));
} else {
row.appendChild(DOM.td());
}
let td = DOM.td(type);
row.appendChild(td);
row.appendChild(DOM.td(entries.length));
let percent = (entries.length / this.data.all.length) * 100;
row.appendChild(DOM.td(percent.toFixed(1) + '%'));
timelineLegendContent.appendChild(row);
});
// Add Total row.
let row = DOM.tr();
row.appendChild(DOM.td(''));
row.appendChild(DOM.td('All'));
row.appendChild(DOM.td(this.data.all.length));
row.appendChild(DOM.td('100%'));
timelineLegendContent.appendChild(row);
this.timelineLegend.appendChild(timelineLegendContent);
this.timelineNode.scrollLeft = offset;
}
handleEntryTypeDoubleClick(e) {
@ -255,10 +125,10 @@ DOM.defineCustomElement('view/timeline/timeline-track',
}
timelineIndicatorMove(offset) {
this.timeline.scrollLeft += offset;
this.timelineNode.scrollLeft += offset;
}
handleTimelineScroll(e) {
_handleTimelineScroll(e) {
let horizontal = e.currentTarget.scrollLeft;
this.dispatchEvent(new CustomEvent(
'scrolltrack', {bubbles: true, composed: true, detail: horizontal}));
@ -271,11 +141,9 @@ DOM.defineCustomElement('view/timeline/timeline-track',
let increment = 0;
let lastHeight = 0.0;
const stops = [];
const breakDown = chunk.getBreakdown(map => map.type);
for (let i = 0; i < breakDown.length; i++) {
let [type, count] = breakDown[i];
const color = this.typeToColor(type);
increment += count;
for (let breakdown of chunk.getBreakdown(map => map.type)) {
const color = CSSColor.at(breakdown.id);
increment += breakdown.count;
let height = (increment / total * kHeight) | 0;
stops.push(`${color} ${lastHeight}px ${height}px`)
lastHeight = height;
@ -288,8 +156,8 @@ DOM.defineCustomElement('view/timeline/timeline-track',
let fragment = new DocumentFragment();
let chunks = this.chunks;
let max = chunks.max(each => each.size());
let start = this.data.startTime;
let end = this.data.endTime;
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;
@ -307,7 +175,7 @@ DOM.defineCustomElement('view/timeline/timeline-track',
node = DOM.div('chunk');
node.onmousemove = this._chunkMouseMoveHandler;
node.onclick = this._chunkClickHandler;
node.ondblclick = this.chunkDoubleClickHandler;
node.ondblclick = this._chunkDoubleClickHandler;
}
const style = node.style;
style.left = `${((chunk.start - start) * this._timeToPixel) | 0}px`;
@ -352,26 +220,28 @@ DOM.defineCustomElement('view/timeline/timeline-track',
this.redraw();
}
handleChunkMouseMove(event) {
_handleChunkMouseMove(event) {
if (this.isLocked) return false;
if (this._isSelecting) 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());
let relativeIndex = Math.round(
event.layerY / event.target.offsetHeight * (chunk.size() - 1));
let logEntry = chunk.at(relativeIndex);
this.dispatchEvent(new FocusEvent(logEntry));
this.dispatchEvent(new ToolTipEvent(logEntry.toString(), event.target));
}
handleChunkClick(event) {
_handleChunkClick(event) {
this.isLocked = !this.isLocked;
}
handleChunkDoubleClick(event) {
_handleChunkDoubleClick(event) {
let chunk = event.target.chunk;
if (!chunk) return;
event.stopPropagation();
this.dispatchEvent(new SelectTimeEvent(chunk.start, chunk.end));
}
@ -391,13 +261,14 @@ DOM.defineCustomElement('view/timeline/timeline-track',
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.typeToColor(edge.type);
let color = this._typeToColor.get(edge.type);
ctx.strokeStyle = color;
ctx.fillStyle = color;
}
@ -495,7 +366,7 @@ DOM.defineCustomElement('view/timeline/timeline-track',
ctx.lineTo(centerX + offsetX, centerY - labelOffset);
ctx.stroke();
ctx.textAlign = 'left';
ctx.fillStyle = this.typeToColor(edge.type);
ctx.fillStyle = this._typeToColor.get(edge.type);
ctx.fillText(
edge.toString(), centerX + offsetX + 2, centerY - labelOffset);
}
@ -515,3 +386,171 @@ DOM.defineCustomElement('view/timeline/timeline-track',
}
}
});
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;
}
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;
constructor(table) {
this._table = table;
}
set timeline(timeline) {
this._timeline = timeline;
}
update() {
const tbody = DOM.tbody();
const selection = this._timeline.selection ?? this._timeline;
const length = selection.length;
selection.getBreakdown().forEach(breakdown => {
const color = CSSColor.at(breakdown.id);
let colorDiv;
if (color !== null) {
colorDiv = DOM.div('colorbox');
colorDiv.style.backgroundColor = color;
}
let percent = `${(breakdown.count / length * 100).toFixed(1)}%`;
tbody.appendChild(
this.row(colorDiv, breakdown.type, breakdown.count, percent));
});
tbody.appendChild(this.row('', 'Selection', length, '100%'));
tbody.appendChild(this.row('', 'All', this._timeline.length, '100%'));
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));
row.appendChild(DOM.td(percent));
return row
}
}