[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 startTime = time;
let endTime = time + 2; let endTime = time + 2;
timeline.selectTimeRange(startTime, endTime); 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 entryIdx = timeline.find(time + 1);
let entry = timeline.at(entryIdx); let entry = timeline.at(entryIdx);
assertEquals(entry.time, time + 1); assertEquals(entry.time, time + 1);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,113 +5,59 @@
import {kChunkHeight, kChunkWidth} from '../../log/map.mjs'; import {kChunkHeight, kChunkWidth} from '../../log/map.mjs';
import {MapLogEntry} from '../../log/map.mjs'; import {MapLogEntry} from '../../log/map.mjs';
import {FocusEvent, SelectionEvent, SelectTimeEvent, SynchronizeSelectionEvent, ToolTipEvent,} from '../events.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', DOM.defineCustomElement('view/timeline/timeline-track',
(templateText) => (templateText) =>
class TimelineTrack extends V8CustomElement { class TimelineTrack extends V8CustomElement {
// TODO turn into static field once Safari supports it.
static get SELECTION_OFFSET() {
return 10
};
_timeline; _timeline;
_nofChunks = 400; _nofChunks = 400;
_chunks; _chunks;
_selectedEntry; _selectedEntry;
_timeToPixel; _timeToPixel;
_timeSelection = {start: -1, end: Infinity};
_timeStartOffset; _timeStartOffset;
_selectionOriginTime;
_typeToColor; _typeToColor;
_legend;
_entryTypeDoubleClickHandler = this.handleEntryTypeDoubleClick.bind(this); _chunkMouseMoveHandler = this._handleChunkMouseMove.bind(this);
_chunkMouseMoveHandler = this.handleChunkMouseMove.bind(this); _chunkClickHandler = this._handleChunkClick.bind(this);
_chunkClickHandler = this.handleChunkClick.bind(this); _chunkDoubleClickHandler = this._handleChunkDoubleClick.bind(this);
_chunkDoubleClickHandler = this.handleChunkDoubleClick.bind(this);
constructor() { constructor() {
super(templateText); super(templateText);
this.timeline.addEventListener('scroll', e => this.handleTimelineScroll(e)); this._selectionHandler = new SelectionHandler(this);
this.timeline.addEventListener( this._legend = new Legend(this.$('#legend'));
'mousedown', e => this.handleTimeSelectionMouseDown(e)); this.timelineNode.addEventListener(
this.timeline.addEventListener( 'scroll', e => this._handleTimelineScroll(e));
'mouseup', e => this.handleTimeSelectionMouseUp(e)); this.timelineNode.ondblclick = (e) =>
this.timeline.addEventListener( this._selectionHandler.clearSelection();
'mousemove', e => this.handleTimeSelectionMouseMove(e));
this.isLocked = false; this.isLocked = false;
} }
handleTimeSelectionMouseDown(e) { set data(timeline) {
let xPosition = e.clientX this._timeline = timeline;
// Update origin time in case we click on a handle. this._typeToColor = new Map();
if (this.isOnLeftHandle(xPosition)) { timeline.getBreakdown().forEach(
xPosition = this.rightHandlePosX; each => this._typeToColor.set(each.type, CSSColor.at(each.id)));
} this._legend.timeline = timeline;
else if (this.isOnRightHandle(xPosition)) { this._updateChunks();
xPosition = this.leftHandlePosX; this.update();
}
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 timeSelection(selection) { set timeSelection(selection) {
this._timeSelection.start = selection.start; this._selectionHandler.timeSelection = selection;
this._timeSelection.end = selection.end;
this.updateSelection(); this.updateSelection();
} }
get _isSelecting() {
return this._selectionOriginTime >= 0;
}
updateSelection() { updateSelection() {
const startPosition = this.timeToPosition(this._timeSelection.start); this._selectionHandler.update();
const endPosition = this.timeToPosition(this._timeSelection.end); this._legend.update();
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;
} }
// Maps the clicked x position to the x position on timeline canvas // Maps the clicked x position to the x position on timeline canvas
positionOnTimeline(posX) { positionOnTimeline(posX) {
let rect = this.timeline.getBoundingClientRect(); let rect = this.timelineNode.getBoundingClientRect();
let posClickedX = posX - rect.left + this.timeline.scrollLeft; let posClickedX = posX - rect.left + this.timelineNode.scrollLeft;
return posClickedX; return posClickedX;
} }
@ -122,22 +68,10 @@ DOM.defineCustomElement('view/timeline/timeline-track',
timeToPosition(time) { timeToPosition(time) {
let posX = time * this._timeToPixel; let posX = time * this._timeToPixel;
posX -= this._timeStartOffset posX -= this._timeStartOffset;
return posX; return posX;
} }
get leftHandle() {
return this.$('.leftHandle');
}
get rightHandle() {
return this.$('.rightHandle');
}
get selection() {
return this.$('.selection');
}
get timelineCanvas() { get timelineCanvas() {
return this.$('#timelineCanvas'); return this.$('#timelineCanvas');
} }
@ -146,41 +80,13 @@ DOM.defineCustomElement('view/timeline/timeline-track',
return this.$('#timelineChunks'); return this.$('#timelineChunks');
} }
get timeline() { get timelineNode() {
return this.$('#timeline'); 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() { _update() {
this._updateTimeline(); this._updateTimeline();
} this._legend.update();
_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;
} }
set nofChunks(count) { set nofChunks(count) {
@ -194,7 +100,7 @@ DOM.defineCustomElement('view/timeline/timeline-track',
} }
_updateChunks() { _updateChunks() {
this._chunks = this.data.chunks(this.nofChunks); this._chunks = this._timeline.chunks(this.nofChunks);
} }
get chunks() { get chunks() {
@ -211,43 +117,7 @@ DOM.defineCustomElement('view/timeline/timeline-track',
} }
set scrollLeft(offset) { set scrollLeft(offset) {
this.timeline.scrollLeft = offset; this.timelineNode.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);
} }
handleEntryTypeDoubleClick(e) { handleEntryTypeDoubleClick(e) {
@ -255,10 +125,10 @@ DOM.defineCustomElement('view/timeline/timeline-track',
} }
timelineIndicatorMove(offset) { timelineIndicatorMove(offset) {
this.timeline.scrollLeft += offset; this.timelineNode.scrollLeft += offset;
} }
handleTimelineScroll(e) { _handleTimelineScroll(e) {
let horizontal = e.currentTarget.scrollLeft; let horizontal = e.currentTarget.scrollLeft;
this.dispatchEvent(new CustomEvent( this.dispatchEvent(new CustomEvent(
'scrolltrack', {bubbles: true, composed: true, detail: horizontal})); 'scrolltrack', {bubbles: true, composed: true, detail: horizontal}));
@ -271,11 +141,9 @@ DOM.defineCustomElement('view/timeline/timeline-track',
let increment = 0; let increment = 0;
let lastHeight = 0.0; let lastHeight = 0.0;
const stops = []; const stops = [];
const breakDown = chunk.getBreakdown(map => map.type); for (let breakdown of chunk.getBreakdown(map => map.type)) {
for (let i = 0; i < breakDown.length; i++) { const color = CSSColor.at(breakdown.id);
let [type, count] = breakDown[i]; increment += breakdown.count;
const color = this.typeToColor(type);
increment += count;
let height = (increment / total * kHeight) | 0; let height = (increment / total * kHeight) | 0;
stops.push(`${color} ${lastHeight}px ${height}px`) stops.push(`${color} ${lastHeight}px ${height}px`)
lastHeight = height; lastHeight = height;
@ -288,8 +156,8 @@ DOM.defineCustomElement('view/timeline/timeline-track',
let fragment = new DocumentFragment(); let fragment = new DocumentFragment();
let chunks = this.chunks; let chunks = this.chunks;
let max = chunks.max(each => each.size()); let max = chunks.max(each => each.size());
let start = this.data.startTime; let start = this._timeline.startTime;
let end = this.data.endTime; let end = this._timeline.endTime;
let duration = end - start; let duration = end - start;
this._timeToPixel = chunks.length * kChunkWidth / duration; this._timeToPixel = chunks.length * kChunkWidth / duration;
this._timeStartOffset = start * this._timeToPixel; this._timeStartOffset = start * this._timeToPixel;
@ -307,7 +175,7 @@ DOM.defineCustomElement('view/timeline/timeline-track',
node = DOM.div('chunk'); node = DOM.div('chunk');
node.onmousemove = this._chunkMouseMoveHandler; node.onmousemove = this._chunkMouseMoveHandler;
node.onclick = this._chunkClickHandler; node.onclick = this._chunkClickHandler;
node.ondblclick = this.chunkDoubleClickHandler; node.ondblclick = this._chunkDoubleClickHandler;
} }
const style = node.style; const style = node.style;
style.left = `${((chunk.start - start) * this._timeToPixel) | 0}px`; style.left = `${((chunk.start - start) * this._timeToPixel) | 0}px`;
@ -352,26 +220,28 @@ DOM.defineCustomElement('view/timeline/timeline-track',
this.redraw(); this.redraw();
} }
handleChunkMouseMove(event) { _handleChunkMouseMove(event) {
if (this.isLocked) return false; if (this.isLocked) return false;
if (this._isSelecting) return false; if (this._selectionHandler.isSelecting) return false;
let chunk = event.target.chunk; let chunk = event.target.chunk;
if (!chunk) return; if (!chunk) return;
if (chunk.isEmpty()) return;
// topmost map (at chunk.height) == map #0. // topmost map (at chunk.height) == map #0.
let relativeIndex = let relativeIndex = Math.round(
Math.round(event.layerY / event.target.offsetHeight * chunk.size()); event.layerY / event.target.offsetHeight * (chunk.size() - 1));
let logEntry = chunk.at(relativeIndex); let logEntry = chunk.at(relativeIndex);
this.dispatchEvent(new FocusEvent(logEntry)); this.dispatchEvent(new FocusEvent(logEntry));
this.dispatchEvent(new ToolTipEvent(logEntry.toString(), event.target)); this.dispatchEvent(new ToolTipEvent(logEntry.toString(), event.target));
} }
handleChunkClick(event) { _handleChunkClick(event) {
this.isLocked = !this.isLocked; this.isLocked = !this.isLocked;
} }
handleChunkDoubleClick(event) { _handleChunkDoubleClick(event) {
let chunk = event.target.chunk; let chunk = event.target.chunk;
if (!chunk) return; if (!chunk) return;
event.stopPropagation();
this.dispatchEvent(new SelectTimeEvent(chunk.start, chunk.end)); 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; if (!this.selectedEntry || !this.selectedEntry.edge) return;
this.drawEdges(ctx); this.drawEdges(ctx);
} }
setMapStyle(map, ctx) { setMapStyle(map, ctx) {
ctx.fillStyle = map.edge && map.edge.from ? CSSColor.onBackgroundColor : ctx.fillStyle = map.edge && map.edge.from ? CSSColor.onBackgroundColor :
CSSColor.onPrimaryColor; CSSColor.onPrimaryColor;
} }
setEdgeStyle(edge, ctx) { setEdgeStyle(edge, ctx) {
let color = this.typeToColor(edge.type); let color = this._typeToColor.get(edge.type);
ctx.strokeStyle = color; ctx.strokeStyle = color;
ctx.fillStyle = color; ctx.fillStyle = color;
} }
@ -495,7 +366,7 @@ DOM.defineCustomElement('view/timeline/timeline-track',
ctx.lineTo(centerX + offsetX, centerY - labelOffset); ctx.lineTo(centerX + offsetX, centerY - labelOffset);
ctx.stroke(); ctx.stroke();
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillStyle = this.typeToColor(edge.type); ctx.fillStyle = this._typeToColor.get(edge.type);
ctx.fillText( ctx.fillText(
edge.toString(), centerX + offsetX + 2, centerY - labelOffset); 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
}
}