| <!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/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/ui/value_set_view.html"> |
| <link rel="import" href="/tracing/value/unit.html"> |
| |
| <dom-module id="tr-v-ui-value-set-table"> |
| <template> |
| <style> |
| :host { |
| display: flex; |
| flex-direction: column; |
| } |
| table-container { |
| display: flex; |
| min-height: 0px; |
| overflow-y: auto; |
| } |
| div#error { |
| color: red; |
| } |
| #histogram { |
| display: none; |
| } |
| #search { |
| max-width: 20em; |
| margin-top: 5px; |
| margin-bottom: 5px; |
| } |
| </style> |
| |
| <input id="search" placeholder="Find Value name"> |
| <div> |
| <input type="checkbox" id="show_all"> |
| <label for="show_all">Show all</label> |
| </div> |
| <div id="error"></div> |
| <table-container> |
| <tr-ui-b-table id="table"></tr-ui-b-table> |
| </table-container> |
| <tr-v-ui-histogram-span id="histogram"></tr-v-ui-histogram-span> |
| </template> |
| </dom-module> |
| |
| <script> |
| 'use strict'; |
| tr.exportTo('tr.ui', function() { |
| |
| /** |
| * @param {!tr.v.Value} value |
| * @param {string} fieldName |
| * @param {*} defaultValue |
| * @return {*} |
| */ |
| function getIterationInfoField(value, fieldName, defaultValue) { |
| var iteration = tr.v.d.IterationInfo.getFromValue(value); |
| if (!(iteration instanceof tr.v.d.IterationInfo)) |
| return defaultValue; |
| return iteration[fieldName]; |
| } |
| |
| /** |
| * @param {!tr.v.Value} value |
| * @param {string} fieldName |
| * @return {string} |
| */ |
| function getStoryGroupingKeyLabel(value, storyGroupingKey) { |
| var iteration = tr.v.d.IterationInfo.getFromValue(value); |
| if (!(iteration instanceof tr.v.d.IterationInfo)) |
| return storyGroupingKey + ': undefined'; |
| return storyGroupingKey + ': ' + |
| iteration.storyGroupingKeys[storyGroupingKey]; |
| } |
| |
| /** |
| * @param {!tr.v.Value} value |
| * @return {string} |
| */ |
| var getDisplayLabel = v => getIterationInfoField(v, 'displayLabel', 'Value'); |
| |
| 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'; |
| |
| /** |
| * Recursively groups |values|. |
| * TODO(benjhayden): Use ES6 Maps instead of dictionaries? |
| * |
| * @param {!Array.<!tr.v.Value>} values |
| * @param {!Array.<!function(!tr.v.Value):(string|number)>} groupingCallbacks |
| * @return {!(Object|tr.v.Value)} |
| */ |
| function organizeValues(values, groupingCallbacks, level) { |
| if (groupingCallbacks.length === level) { |
| // Recursion base case: there should only be a single value when we've |
| // grouped by every possible grouping. |
| if (values.length > 1) { |
| console.warn('Multiple Values with same name, benchmarkName, ' + |
| 'storyGroupingKeys, storyName, start, storysetRepeatCounter, ' + |
| 'storyRepeatCounter, and displayLabel', values); |
| } |
| return values[0]; |
| } |
| |
| // 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, function(key, groupValues) { |
| return organizeValues(groupValues, groupingCallbacks, level + 1); |
| }); |
| } |
| |
| Polymer({ |
| is: 'tr-v-ui-value-set-table', |
| |
| /** |
| * Return true if this view supports this ValueSet. |
| * Value-set-table supports all possible metrics, so it always returns true. |
| * |
| * @param {!tr.v.ValueSet} values |
| * @return {boolean} |
| */ |
| supportsValueSet: function(values) { |
| return true; |
| }, |
| |
| /** |
| * 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.Value>} */ |
| this.sourceValues_ = []; |
| /** @type {!Object} */ |
| this.summaryValuesByGuid_ = {}; |
| |
| this.rows_ = undefined; |
| this.columns_ = undefined; |
| }, |
| |
| ready: function() { |
| this.$.table.selectionMode = tr.ui.b.TableFormat.SelectionMode.CELL; |
| this.$.table.addEventListener('selection-changed', |
| this.onSelectionChanged_.bind(this)); |
| this.$.table.addEventListener('selected-column-changed', |
| this.onSelectedColumnChanged_.bind(this)); |
| this.addEventListener('requestSelectionChange', |
| this.onRelatedValueSelected_.bind(this)); |
| this.$.search.addEventListener('keyup', this.onSearch_.bind(this)); |
| this.$.show_all.checked = tr.b.Settings.get(SHOW_ALL_SETTINGS_KEY, false); |
| this.$.show_all.addEventListener('change', |
| this.onShowAllChange_.bind(this)); |
| }, |
| |
| onShowAllChange_: function() { |
| tr.b.Settings.set(SHOW_ALL_SETTINGS_KEY, this.$.show_all.checked); |
| this.updateContents_(); |
| }, |
| |
| onSelectedColumnChanged_: function() { |
| // Force the table to rebuild the cell values without forgetting which |
| // rows were expanded. |
| var expansionStates = this.getExpansionStates_(this.rows_); |
| this.$.table.tableRows = this.rows_; |
| 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]) { |
| 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.Value)) |
| 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) { |
| 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.NumericValue) && |
| (cell.numeric instanceof tr.v.Numeric)) { |
| this.$.histogram.style.display = 'block'; |
| this.$.histogram.histogram = cell.numeric; |
| |
| tr.b.Settings.set(SELECTED_VALUE_SETTINGS_KEY, JSON.stringify({ |
| row: row.name, |
| column: this.columns_[col].title |
| })); |
| } else { |
| this.$.histogram.style.display = 'none'; |
| } |
| }, |
| |
| handleFailureValues_: function() { |
| this.values.map(function(value) { |
| if (value instanceof tr.v.FailureValue) { |
| Polymer.dom(this.$.error).textContent = value.description; |
| this.$.table.style.display = 'none'; |
| this.style.width = '10em'; |
| } |
| }, this); |
| }, |
| |
| addDiagnosticSubRows_: function(value, row, column) { |
| value.diagnostics.forEach(function(name, diagnostic) { |
| if (name === tr.v.SUMMARY_VALUE_MAP_DIAGNOSTIC_NAME) |
| return; |
| |
| // If a previous |value| had a diagnostic with the same name, then |
| // there is already a subRow that should contain this diagnostic. |
| for (var subRow of row.subRows) { |
| if (subRow.name === name) { |
| subRow.columns[column] = diagnostic; |
| return; |
| } |
| } |
| |
| // 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_; |
| }, |
| |
| findSummaryValues_: function() { |
| this.summaryValuesByGuid_ = {}; |
| this.values.map(function(value) { |
| var summaryValueMap = value.diagnostics.get( |
| tr.v.SUMMARY_VALUE_MAP_DIAGNOSTIC_NAME); |
| if (!(summaryValueMap instanceof tr.v.d.RelatedValueMap)) |
| return; |
| |
| summaryValueMap.values.forEach(function(summaryValue) { |
| this.summaryValuesByGuid_[summaryValue.guid] = summaryValue; |
| }, this); |
| }, this); |
| }, |
| |
| /** |
| * @param {!tr.v.ValueSet} values |
| */ |
| set values(values) { |
| this.values_ = values; |
| this.sourceValues_ = values.sourceValues; |
| this.findSummaryValues_(); |
| this.updateContents_(); |
| }, |
| |
| updateContents_: function() { |
| if (!this.values_) |
| return; |
| this.style.width = ''; |
| this.$.table.style.display = ''; |
| Polymer.dom(this.$.error).textContent = ''; |
| this.$.histogram.style.display = 'none'; |
| |
| this.handleFailureValues_(); |
| if (Polymer.dom(this.$.error).textContent) |
| return; |
| |
| this.buildRows_(); |
| |
| if (this.rows_.length === 0) { |
| Polymer.dom(this.$.error).textContent = 'zero values'; |
| this.$.table.style.display = 'none'; |
| this.style.width = '10em'; |
| return; |
| } |
| |
| this.buildColumns_(); |
| |
| this.$.table.tableColumns = this.columns_; |
| this.$.table.tableRows = this.rows_; |
| this.$.table.sortColumnIndex = 0; |
| this.$.table.rebuild(); |
| this.selectValue_(); |
| |
| tr.b.requestAnimationFrame(function() { |
| this.style.width = this.$.table.getBoundingClientRect().width; |
| }, this); |
| }, |
| |
| 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.Value) { |
| // 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] = row.columns[name].merge(value); |
| else |
| row.columns[name] = value; |
| } |
| |
| var row = hierarchy[hierarchy.length - 1]; |
| this.addDiagnosticSubRows_(value, row, name); |
| } 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) |
| if (this.summaryValuesByGuid_[value.guid] === undefined) |
| values.push(value); |
| |
| var groupingCallbacks = []; |
| groupingCallbacks.push(v => v.name); |
| groupingCallbacks.push( |
| v => getIterationInfoField(v, 'benchmarkName', '')); |
| for (var storyGroupingKey of this.storyGroupingKeys) { |
| // Javascript closures are dumb. |
| groupingCallbacks.push((sgk => ( |
| v => getStoryGroupingKeyLabel(v, sgk)))(storyGroupingKey)); |
| } |
| groupingCallbacks.push( |
| v => getIterationInfoField(v, 'storyDisplayName', '')); |
| groupingCallbacks.push( |
| v => getIterationInfoField(v, 'benchmarkStartString', '')); |
| groupingCallbacks.push( |
| v => getIterationInfoField(v, 'storysetRepeatCounterLabel', 0)); |
| groupingCallbacks.push( |
| v => getIterationInfoField(v, 'storyRepeatCounterLabel', 0)); |
| 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 = []; |
| this.buildRow_(this.organizedValues_, hierarchy); |
| this.rows_ = this.rows_.filter(this.rowMatchesSearch_.bind(this)); |
| }, |
| |
| get startTimesForDisplayLabels() { |
| var startTimesForDisplayLabels = {}; |
| for (var value of this.values) { |
| if (this.summaryValuesByGuid_[value.guid]) |
| continue; |
| |
| var displayLabel = getDisplayLabel(value); |
| startTimesForDisplayLabels[displayLabel] = Math.min( |
| startTimesForDisplayLabels[displayLabel] || 0, |
| getIterationInfoField( |
| value, 'benchmarkStart', new Date(0)).getTime()); |
| } |
| return startTimesForDisplayLabels; |
| }, |
| |
| get displayLabels() { |
| var startTimesForDisplayLabels = this.startTimesForDisplayLabels; |
| var displayLabels = Object.keys(startTimesForDisplayLabels); |
| displayLabels.sort(function(a, b) { |
| return startTimesForDisplayLabels[a] - startTimesForDisplayLabels[b]; |
| }); |
| return displayLabels; |
| }, |
| |
| buildColumn_: function(displayLabel) { |
| function getValueForValue(value) { |
| return value.numeric instanceof tr.v.Numeric ? value.numeric.average : |
| value.numeric.value; |
| } |
| |
| return { |
| title: displayLabel, |
| align: tr.ui.b.TableFormat.ColumnAlignment.RIGHT, |
| supportsCellSelection: true, |
| selectable: true, |
| |
| value: function(row) { |
| var cell = row.columns[displayLabel]; |
| if (cell === undefined) |
| return ''; |
| |
| if (cell instanceof tr.v.NumericValue) { |
| if (this.$.table.selectedTableColumn && |
| this.$.table.selectedTableColumn.title !== displayLabel) { |
| var referenceCell = row.columns[ |
| this.$.table.selectedTableColumn.title]; |
| |
| if (referenceCell instanceof tr.v.NumericValue && |
| cell.numeric.unit === referenceCell.numeric.unit) { |
| var significance = tr.v.Significance.DONT_CARE; |
| |
| if (cell.numeric instanceof tr.v.Numeric && |
| referenceCell.numeric instanceof tr.v.Numeric) { |
| significance = cell.numeric.getDifferenceSignificance( |
| referenceCell.numeric); |
| } |
| |
| return tr.v.ui.createScalarSpan( |
| getValueForValue(cell) - getValueForValue(referenceCell), |
| {unit: cell.numeric.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.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.NumericValue) || |
| !(cellB instanceof tr.v.NumericValue)) { |
| 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.$.table.selectedTableColumn && |
| this.$.table.selectedTableColumn.title !== displayLabel) { |
| var referenceColumn = this.$.table.selectedTableColumn.title; |
| var referenceCellA = rowA.columns[referenceColumn]; |
| var referenceCellB = rowB.columns[referenceColumn]; |
| if (referenceCellA instanceof tr.v.NumericValue && |
| referenceCellB instanceof tr.v.NumericValue && |
| cellA.numeric.unit === referenceCellA.numeric.unit && |
| cellB.numeric.unit === referenceCellB.numeric.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)); |
| } |
| }); |
| |
| tr.v.ui.registerValueSetView('tr-v-ui-value-set-table'); |
| |
| return {}; |
| }); |
| </script> |