blob: aa9ba6fa5cd9df3d8100529bdbb109530f9d44bc [file] [log] [blame]
<!DOCTYPE html>
<!--
Copyright 2016 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->
<link rel="import" href="/tracing/base/raf.html">
<link rel="import" href="/tracing/base/unit.html">
<link rel="import" href="/tracing/ui/base/grouping_table_groupby_picker.html">
<link rel="import" href="/tracing/ui/base/table.html">
<link rel="import" href="/tracing/value/ui/diagnostic_span.html">
<link rel="import" href="/tracing/value/ui/histogram_span.html">
<link rel="import" href="/tracing/value/ui/scalar_span.html">
<link rel="import" href="/tracing/value/value_set.html">
<dom-module id="tr-v-ui-value-set-table">
<template>
<style>
:host {
display: block;
}
#container {
flex-direction: column;
display: none;
}
table-container {
margin-top: 5px;
display: flex;
min-height: 0px;
overflow-y: auto;
}
#histogram {
display: none;
}
#zero {
color: red;
}
#search {
max-width: 20em;
margin-right: 20px;
}
#controls {
white-space: nowrap;
}
#reference_column_container * {
margin-right: 20px;
}
</style>
<div id="zero">zero values</div>
<div id="container">
<div id="controls">
<input id="search" placeholder="Find Histogram name" on-keyup="onSearch_">
<span id="reference_column_container"></span>
<input type="checkbox" id="show_all" on-change="onShowAllChange_" title="When unchecked, less important histograms are hidden.">
<label for="show_all" title="When unchecked, less important histograms are hidden.">Show all</label>
</div>
<tr-ui-b-grouping-table-groupby-picker id="picker">
</tr-ui-b-grouping-table-groupby-picker>
<table-container>
<tr-ui-b-table id="table"/>
</table-container>
<tr-v-ui-histogram-span id="histogram"/>
</div>
</template>
</dom-module>
<script>
'use strict';
tr.exportTo('tr.ui', function() {
/**
* Returns a closure that gets a story grouping key label from a Histogram.
*
* @param {string} storyGroupingKey
* @return {!function(tr.v.Histogram):string}
*/
function makeStoryGroupingKeyLabelGetter(storyGroupingKey) {
return v => tr.v.d.IterationInfo.getStoryGroupingKeyLabel(
v, storyGroupingKey);
}
var getDisplayLabel = tr.v.ValueSet.GROUPINGS.DISPLAY_LABEL.dataFn;
var DEFAULT_POSSIBLE_GROUPS = [];
DEFAULT_POSSIBLE_GROUPS.push({
key: tr.v.ValueSet.GROUPINGS.HISTOGRAM_NAME.key,
label: tr.v.ValueSet.GROUPINGS.HISTOGRAM_NAME.label,
dataFn: v => v.shortName || v.name
});
tr.b.iterItems(tr.v.ValueSet.GROUPINGS, function(name, group) {
// DISPLAY_LABEL is used to define the columns, so don't allow grouping
// rows by it.
// Override HISTOGRAM_NAME so that we can display shortName.
if (group !== tr.v.ValueSet.GROUPINGS.DISPLAY_LABEL &&
group !== tr.v.ValueSet.GROUPINGS.HISTOGRAM_NAME)
DEFAULT_POSSIBLE_GROUPS.push(group);
});
var SELECTED_VALUE_SETTINGS_KEY = 'tr-v-ui-value-set-table-value';
var SHOW_ALL_SETTINGS_KEY = 'tr-v-ui-value-set-table-show-all';
var UNMERGEABLE = '(unmergeable)';
function mergeCells(a, b) {
if (a === UNMERGEABLE || b === UNMERGEABLE || !a || !b ||
!a.canAddHistogram(b))
return UNMERGEABLE;
a = a.clone();
a.addHistogram(b);
return a;
}
/**
* Recursively groups |values|.
* TODO(benjhayden): Use ES6 Maps instead of dictionaries?
*
* @param {!Array.<!tr.v.Histogram>} values
* @param {!Array.<!function(!tr.v.Histogram):(string|number)>}
* groupingCallbacks
* @return {!(Object|tr.v.Histogram)}
*/
function organizeValues(values, groupingCallbacks, level) {
if (groupingCallbacks.length === level) {
// Recursion base case! Merge all remaining values.
// Be careful to retain the clone() semantics instead of in-place merging.
return values.reduce(mergeCells);
}
// Group the values by the current grouping.
var groupedValues = tr.b.group(values, groupingCallbacks[level]);
// Skip this grouping level if it contains only a single group,
// but never skip the zeroth grouping level (value name) nor the last
// (displayLabel).
if (level > 0 && level < (groupingCallbacks.length - 1) &&
tr.b.dictionaryLength(groupedValues) === 1) {
return organizeValues(values, groupingCallbacks, level + 1);
}
// Recursively group groupedValues.
return tr.b.mapItems(groupedValues, (key, groupValues) => organizeValues(
groupValues, groupingCallbacks, level + 1));
}
Polymer({
is: 'tr-v-ui-value-set-table',
/**
* This can optionally depend on the ValueSet.
*
* @return {string}
*/
get tabLabel() {
return 'Table';
},
created: function() {
// TODO(benjhayden): Should these all be ValueSets?
/** @type {undefined|!tr.v.ValueSet} */
this.values_ = undefined;
/** @type {!Array.<!tr.v.Histogram>} */
this.sourceValues_ = [];
this.rows_ = undefined;
this.columns_ = undefined;
this.updatingContents_ = false;
this.displayLabels_ = undefined;
this.referenceDisplayLabel_ = undefined;
},
ready: function() {
this.$.table.selectionMode = tr.ui.b.TableFormat.SelectionMode.CELL;
this.$.table.addEventListener('selection-changed',
this.onSelectionChanged_.bind(this));
this.addEventListener('requestSelectionChange',
this.onRelatedValueSelected_.bind(this));
this.$.show_all.checked = tr.b.Settings.get(SHOW_ALL_SETTINGS_KEY, false);
this.$.picker.settingsKey = 'tr-v-ui-value-set-table-groupby-picker';
this.$.picker.possibleGroups = DEFAULT_POSSIBLE_GROUPS.slice();
this.$.picker.defaultGroupKeys = [
tr.v.ValueSet.GROUPINGS.HISTOGRAM_NAME.key,
tr.v.ValueSet.GROUPINGS.STORY_NAME.key];
this.$.picker.addEventListener('current-groups-changed',
this.currentGroupsChanged_.bind(this));
},
set groupingKeys(keys) {
this.$.picker.currentGroupKeys = keys;
},
get groupingKeys() {
return this.$.picker.currentGroupKeys;
},
get possibleGroupingKeys() {
return this.$.picker.possibleGroups.map(g => g.key);
},
currentGroupsChanged_: function() {
if (this.updatingContents_)
return;
if (this.$.picker.currentGroups.length === 0 &&
this.possibleGroupingKeys.length > 0) {
this.$.picker.currentGroupKeys = [this.$.picker.possibleGroups[0].key];
}
// TODO(benjhayden): remember selected cells and column
var expansionStates = undefined;
if (this.rows_)
expansionStates = this.getExpansionStates_(this.rows_);
this.updateContents_();
if (expansionStates)
this.setExpansionStates_(expansionStates, this.rows_);
},
onShowAllChange_: function() {
if (this.updatingContents_)
return;
tr.b.Settings.set(SHOW_ALL_SETTINGS_KEY, this.$.show_all.checked);
// TODO(benjhayden): remember selected cells and column
var expansionStates = this.getExpansionStates_(this.rows_);
this.updateContents_();
this.setExpansionStates_(expansionStates, this.rows_);
},
getExpansionStates_: function(rows) {
var states = {};
for (var i = 0; i < rows.length; ++i) {
var row = rows[i];
if (row.subRows && row.subRows.length &&
this.$.table.getExpandedForTableRow(row)) {
states[i] = this.getExpansionStates_(row.subRows);
}
}
return states;
},
setExpansionStates_: function(states, rows) {
for (var i = 0; i < rows.length; ++i) {
if (states[i] && rows[i] && rows[i].subRows &&
rows[i].subRows.length > 0) {
this.$.table.setExpandedForTableRow(rows[i], true);
this.setExpansionStates_(states[i], rows[i].subRows);
}
}
},
onSearch_: function() {
this.updateContents_();
},
rowMatchesSearch_: function(row) {
return row.name.indexOf(this.$.search.value) >= 0;
},
onRelatedValueSelected_: function(event) {
var value = event.selection;
if (!(value instanceof tr.v.Histogram))
return;
event.stopPropagation();
var displayLabel = getDisplayLabel(value);
var columnIndex = -1;
for (var i = 0; i < this.columns_.length; ++i) {
if (this.columns_[i].title === displayLabel) {
columnIndex = i;
break;
}
}
if (columnIndex < 0)
return;
var hierarchy = [];
var found = false;
function search(row) {
if (row.columns[displayLabel] === value) {
for (var hirow in hierarchy) {
this.$.table.setExpandedForTableRow(hirow, true);
}
found = true;
this.$.table.selectedTableRow = row;
this.$.table.selectedColumnIndex = columnIndex;
return;
}
if (!row.subRows)
return;
hierarchy.push(row);
row.subRows.forEach(search, this);
hierarchy.pop(row);
}
this.rows_.forEach(search, this);
if (found || this.$.show_all.checked)
return;
// Search hidden values for |value|.
for (var test of this.values) {
// Skip values that are already displayed -- we would have found them
// in search() above.
if (this.sourceValues_.indexOf(test) >= 0)
continue;
if (test === value) {
found = true;
this.$.show_all.checked = true;
this.onShowAllChange_();
this.onRelatedValueSelected_(event);
break;
}
}
},
onSelectionChanged_: function() {
var row = this.$.table.selectedTableRow;
var col = this.$.table.selectedColumnIndex;
var cell = undefined;
if (row && col && this.columns_)
cell = row.columns[this.columns_[col].title];
if (cell instanceof tr.v.Histogram) {
this.$.histogram.style.display = 'block';
this.$.histogram.histogram = cell;
tr.b.Settings.set(SELECTED_VALUE_SETTINGS_KEY, JSON.stringify({
row: row.name,
column: this.columns_[col].title
}));
} else {
this.$.histogram.style.display = 'none';
}
},
addDiagnosticSubRows_: function(value, row, column) {
for (var [name, diagnostic] of value.diagnostics) {
// If a previous |value| had a diagnostic with the same name, then
// there is already a subRow that should contain this diagnostic.
var foundSubRow = false;
for (var subRow of row.subRows) {
if (subRow.name === name) {
foundSubRow = true;
subRow.columns[column] = diagnostic;
continue;
}
}
if (foundSubRow)
continue;
// This is the first time that a diagnostic with this name has been
// seen for Values whose name is |value.name|, so create a new subRow.
var subRow = {name: name, columns: {}};
subRow.columns[column] = diagnostic;
row.subRows.push(subRow);
}
},
get values() {
return this.values_;
},
/**
* @param {!tr.v.ValueSet} values
*/
set values(values) {
this.values_ = values;
this.sourceValues_ = values ? values.sourceValues : [];
this.displayLabels_ = undefined;
this.referenceDisplayLabel_ = '';
this.updateContents_();
},
get referenceDisplayLabel() {
return this.referenceDisplayLabel_;
},
set referenceDisplayLabel(reference) {
this.referenceDisplayLabel_ = reference;
if (this.updatingContents_)
return;
this.$.table.selectedTableColumnIndex = this.referenceDisplayLabel ?
1 + this.displayLabels.indexOf(this.referenceDisplayLabel) : undefined;
// Force the table to rebuild the cell values without forgetting which
// rows were expanded.
// TODO(benjhayden): remember selected cell
var expansionStates = this.getExpansionStates_(this.rows_);
this.$.table.tableRows = this.rows_;
this.setExpansionStates_(expansionStates, this.rows_);
},
updateReferenceColumnSelector_: function() {
Polymer.dom(this.$.reference_column_container).textContent = '';
if (this.displayLabels.length < 2)
return;
var options = [{value: '', label: 'Select a reference column'}];
for (var displayLabel of this.displayLabels)
options.push({value: displayLabel, label: displayLabel});
var settingsKey =
'tr-v-ui-value-set-table-reference-display-label';
Polymer.dom(this.$.reference_column_container).appendChild(
tr.ui.b.createSelector(
this, 'referenceDisplayLabel', settingsKey, '', options));
},
updateGroups_: function() {
var groups = DEFAULT_POSSIBLE_GROUPS.filter(function(group) {
// Remove groups for which there is only one value, except
// HISTOGRAM_NAME.
if (group.key === tr.v.ValueSet.GROUPINGS.HISTOGRAM_NAME.key)
return true;
var values = new Set();
for (var value of this.values_) {
value = group.dataFn(value);
if (!value)
continue;
values.add(value);
if (values.size > 1)
return true;
}
return false; // Prune this grouping.
}, this);
// Add all storyGroupingKey groups for the current values.
for (var storyGroupingKey of this.storyGroupingKeys) {
groups.push({
key: 'storyGroupingKey_' + storyGroupingKey,
label: storyGroupingKey,
dataFn: makeStoryGroupingKeyLabelGetter(storyGroupingKey)
});
}
// Save and restore current grouping keys in order to let
// |set groupingKeys| filter out the keys that are no longer in
// possibleGroups.
var groupingKeys = this.groupingKeys;
this.$.picker.possibleGroups = groups;
this.$.picker.currentGroupKeys = groupingKeys;
this.$.picker.style.display = (groups.length === 1) ? 'none' : '';
},
updateContents_: function() {
if (this.updatingContents_)
return;
if (!this.values_ || (this.values_.length === 0)) {
this.$.container.style.display = '';
this.$.zero.style.display = '';
return;
}
this.updatingContents_ = true;
this.$.zero.style.display = 'none';
this.$.container.style.display = 'block';
this.$.table.style.display = '';
this.$.histogram.style.display = 'none';
this.updateReferenceColumnSelector_();
this.updateGroups_();
this.buildRows_();
this.buildColumns_();
this.$.table.tableColumns = this.columns_;
this.$.table.tableRows = this.rows_;
this.$.table.sortColumnIndex = 0;
this.$.table.rebuild();
this.selectValue_();
this.maybeDisableShowAll_();
this.$.table.selectedTableColumnIndex = this.referenceDisplayLabel ?
1 + this.displayLabels.indexOf(this.referenceDisplayLabel) : undefined;
this.updatingContents_ = false;
},
maybeDisableShowAll_: function() {
var allValuesAreSource = true;
for (var value of this.values) {
if (this.sourceValues_.indexOf(value) < 0) {
allValuesAreSource = false;
break;
}
}
// Disable show_all if hiddenValues is 0.
// Re-enable show_all if hiddenValues changes from 0.
this.$.show_all.disabled = allValuesAreSource;
// Check show_all if it is disabled.
// Do not automatically uncheck show_all.
if (this.$.show_all.disabled) {
this.$.show_all.checked = true;
}
},
selectValue_: function() {
var selectedValue = tr.b.Settings.get(
SELECTED_VALUE_SETTINGS_KEY, undefined);
if (selectedValue) {
selectedValue = JSON.parse(selectedValue);
for (var row of this.rows_) {
if (row.name === selectedValue.row) {
for (var coli = 1; coli < this.columns_.length; ++coli) {
var column = this.columns_[coli];
if (column.title === selectedValue.column) {
this.$.table.selectedTableRow = row;
this.$.table.selectedColumnIndex = coli;
return;
}
}
}
}
}
this.$.table.selectedTableRow = this.rows_[0];
this.$.table.selectedColumnIndex = 1;
},
/**
* Build table rows recursively from organized Values. The recursion stack
* of subRows is maintained in |hierarchy|.
*
* @param {!Object} organizedValues
* @param {!Array.<!Object>} hierarchy
*/
buildRow_: function(organizedValues, hierarchy) {
tr.b.iterItems(organizedValues, function(name, value) {
if (value instanceof tr.v.Histogram) {
// This recursion base case corresponds to the recursion base case of
// organizeValues(). The last groupingCallback is getDisplayLabel,
// which defines the columns of the table.
// Merge Values up the grouping hierarchy.
for (var row of hierarchy) {
if (row.description === undefined)
row.description = value.description;
if (row.columns[name])
row.columns[name] = mergeCells(value, row.columns[name]);
else
row.columns[name] = value;
}
var row = hierarchy[hierarchy.length - 1];
if (row)
this.addDiagnosticSubRows_(value, row, name);
} else if (value === UNMERGEABLE) {
var row = hierarchy[hierarchy.length - 1];
if (row)
row.columns[name] = value;
} else {
// |value| is actually a nested organizedValues.
var row = {name: name, subRows: [], columns: {}};
hierarchy.push(row);
this.buildRow_(value, hierarchy);
hierarchy.pop();
if (hierarchy.length === 0)
this.rows_.push(row);
else
hierarchy[hierarchy.length - 1].subRows.push(row);
}
}, this);
},
get storyGroupingKeys() {
var keys = new Set();
for (var value of this.values) {
var iteration = tr.v.d.IterationInfo.getFromValue(value);
if (!(iteration instanceof tr.v.d.IterationInfo) ||
!iteration.storyGroupingKeys)
continue;
for (var key in iteration.storyGroupingKeys)
keys.add(key);
}
return [...keys.values()].sort();
},
/**
* A ValueSet is a flat set of Values. Value-set-table must present a
* hierarchical view. This method recursively groups this.values as an
* intermediate step towards building tableRows in buildRow_().
* {
* valueA: {
* benchmarkA: {
* storyA: {
* startA: {
* storysetRepeatCounterA: {
* storyRepeatCounterA: {
* displayLabelA: Value,
* displayLabelB: Value
* }
* }
* }
* }
* }
* }
* }
* @return {!Object}
*/
get organizedValues_() {
var showingValues = this.$.show_all.checked ?
this.values : this.sourceValues_;
var values = [];
for (var value of showingValues)
values.push(value);
var groupingCallbacks = [];
for (var group of this.$.picker.currentGroups)
groupingCallbacks.push(group.dataFn);
groupingCallbacks.push(getDisplayLabel);
return organizeValues(values, groupingCallbacks, 0);
},
/* this.rows_ will look something like
* [
* {
* name: 'value name',
* columns: {
* displayLabelA: Value,
* displayLabelB: Value,
* },
* subRows: [
* {
* name: 'benchmark name if multiple',
* columns: {
* displayLabelA: Value,
* displayLabelB: Value,
* },
* subRows: [
* {
* name: 'story name if multiple',
* columns: {
* displayLabelA: Value,
* displayLabelB: Value,
* },
* subRows: [
* {
* name: 'benchmark start if multiple',
* columns: {
* displayLabelA: Value,
* displayLabelB: Value,
* },
* subRows: [
* {
* name: 'storyset repeat counter if multiple',
* columns: {
* displayLabelA: Value,
* displayLabelB: Value,
* },
* subRows: [
* {
* name: 'story repeat counter if multiple',
* columns: {
* displayLabelA: Value,
* displayLabelB: Value,
* },
* subRows: [
* {
* name: 'diagnostic map key',
* columns: {
* displayLabelA: Diagnostic,
* displayLabelB: Diagnostic,
* },
* }
* ]
* }
* ]
* }
* ]
* }
* ]
* }
* ]
* }
* ]
* }
* ]
*
* Any of those layers may be missing except 'value name'.
*/
buildRows_: function() {
this.rows_ = [];
var hierarchy = [];
var organizedValues = this.organizedValues_;
this.buildRow_(organizedValues, hierarchy);
this.rows_ = this.rows_.filter(this.rowMatchesSearch_.bind(this));
},
get startTimesForDisplayLabels() {
var startTimesForDisplayLabels = {};
for (var value of this.values) {
var displayLabel = getDisplayLabel(value);
startTimesForDisplayLabels[displayLabel] = Math.min(
startTimesForDisplayLabels[displayLabel] || 0,
tr.v.d.IterationInfo.getField(
value, 'benchmarkStart', new Date(0)).getTime());
}
return startTimesForDisplayLabels;
},
get displayLabels() {
if (this.displayLabels_ === undefined) {
var startTimesForDisplayLabels = this.startTimesForDisplayLabels;
this.displayLabels_ = Object.keys(startTimesForDisplayLabels);
this.displayLabels_.sort(function(a, b) {
return startTimesForDisplayLabels[a] - startTimesForDisplayLabels[b];
});
}
return this.displayLabels_;
},
buildColumn_: function(displayLabel) {
function getValueForValue(value) {
return value instanceof tr.v.Histogram ? value.average : value.value;
}
return {
title: displayLabel,
align: tr.ui.b.TableFormat.ColumnAlignment.RIGHT,
supportsCellSelection: true,
value: function(row) {
var cell = row.columns[displayLabel];
if (cell === undefined)
return '(missing)';
if (cell === UNMERGEABLE)
return cell;
if (cell instanceof tr.v.Histogram) {
if (cell.numValues === 0) {
return '(empty)';
}
if (this.referenceDisplayLabel &&
this.referenceDisplayLabel !== displayLabel) {
var referenceCell = row.columns[this.referenceDisplayLabel];
if (referenceCell instanceof tr.v.Histogram &&
cell.unit === referenceCell.unit &&
referenceCell.numValues > 0) {
var significance = cell.getDifferenceSignificance(
referenceCell);
return tr.v.ui.createScalarSpan(
getValueForValue(cell) - getValueForValue(referenceCell),
{unit: cell.unit.correspondingDeltaUnit,
significance: significance});
}
}
return tr.v.ui.createScalarSpan(cell);
}
if (cell instanceof tr.v.d.Diagnostic) {
var span = tr.v.ui.createDiagnosticSpan(cell);
span.addEventListener('click', (event) => event.stopPropagation());
span.style.textAlign = 'left';
return span;
}
throw new Error('Invalid cell', cell);
}.bind(this),
cmp: function(rowA, rowB) {
var cellA = rowA.columns[displayLabel];
var cellB = rowB.columns[displayLabel];
if (!(cellA instanceof tr.v.Histogram) ||
!(cellB instanceof tr.v.Histogram)) {
return undefined;
}
var valueA = getValueForValue(cellA);
var valueB = getValueForValue(cellB);
// If a reference column is selected, compare the *differences*
// between the two cells and their references.
if (this.referenceDisplayLabel &&
this.referenceDisplayLabel !== displayLabel) {
var referenceCellA = rowA.columns[this.referenceDisplayLabel];
var referenceCellB = rowB.columns[this.referenceDisplayLabel];
if (referenceCellA instanceof tr.v.Histogram &&
referenceCellB instanceof tr.v.Histogram &&
cellA.unit === referenceCellA.unit &&
cellB.unit === referenceCellB.unit) {
valueA -= getValueForValue(referenceCellA);
valueB -= getValueForValue(referenceCellB);
}
}
return valueA - valueB;
}.bind(this)
};
},
buildColumns_: function() {
this.columns_ = [
{
title: 'Name',
align: tr.ui.b.TableFormat.ColumnAlignment.LEFT,
supportsCellSelection: false,
value: function(row) {
var nameEl = document.createElement('span');
Polymer.dom(nameEl).textContent = row.name;
if (row.description)
nameEl.title = row.description;
nameEl.style.textOverflow = 'ellipsis';
return nameEl;
},
cmp: (a, b) => a.name.localeCompare(b.name)
}
];
for (var displayLabel of this.displayLabels)
this.columns_.push(this.buildColumn_(displayLabel));
}
});
return {};
});
</script>