| // Copyright (c) 2012 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. |
| |
| var g_browserBridge; |
| var g_mainView; |
| |
| // TODO(eroman): The handling of "max" across snapshots is not correct. |
| // For starters the browser needs to be aware to generate new maximums. |
| // Secondly, we need to take into account the "max" of intermediary snapshots, |
| // not just the terminal ones. |
| |
| /** |
| * Main entry point called once the page has loaded. |
| */ |
| function onLoad() { |
| g_browserBridge = new BrowserBridge(); |
| g_mainView = new MainView(); |
| } |
| |
| document.addEventListener('DOMContentLoaded', onLoad); |
| |
| /** |
| * This class provides a "bridge" for communicating between the javascript and |
| * the browser. Used as a singleton. |
| */ |
| var BrowserBridge = (function() { |
| 'use strict'; |
| |
| /** |
| * @constructor |
| */ |
| function BrowserBridge() { |
| } |
| |
| BrowserBridge.prototype = { |
| //-------------------------------------------------------------------------- |
| // Messages sent to the browser |
| //-------------------------------------------------------------------------- |
| |
| sendGetData: function() { |
| chrome.send('getData'); |
| }, |
| |
| sendResetData: function() { |
| chrome.send('resetData'); |
| }, |
| |
| //-------------------------------------------------------------------------- |
| // Messages received from the browser. |
| //-------------------------------------------------------------------------- |
| |
| receivedData: function(data) { |
| // TODO(eroman): The browser should give an indication of which snapshot |
| // this data belongs to. For now we always assume it is for the latest. |
| g_mainView.addDataToSnapshot(data); |
| }, |
| }; |
| |
| return BrowserBridge; |
| })(); |
| |
| /** |
| * This class handles the presentation of our profiler view. Used as a |
| * singleton. |
| */ |
| var MainView = (function() { |
| 'use strict'; |
| |
| // -------------------------------------------------------------------------- |
| // Important IDs in the HTML document |
| // -------------------------------------------------------------------------- |
| |
| // The search box to filter results. |
| var FILTER_SEARCH_ID = 'filter-search'; |
| |
| // The container node to put all the "Group by" dropdowns into. |
| var GROUP_BY_CONTAINER_ID = 'group-by-container'; |
| |
| // The container node to put all the "Sort by" dropdowns into. |
| var SORT_BY_CONTAINER_ID = 'sort-by-container'; |
| |
| // The DIV to put all the tables into. |
| var RESULTS_DIV_ID = 'results-div'; |
| |
| // The container node to put all the column (visibility) checkboxes into. |
| var COLUMN_TOGGLES_CONTAINER_ID = 'column-toggles-container'; |
| |
| // The container node to put all the column (merge) checkboxes into. |
| var COLUMN_MERGE_TOGGLES_CONTAINER_ID = 'column-merge-toggles-container'; |
| |
| // The anchor which toggles visibility of column checkboxes. |
| var EDIT_COLUMNS_LINK_ID = 'edit-columns-link'; |
| |
| // The container node to show/hide when toggling the column checkboxes. |
| var EDIT_COLUMNS_ROW = 'edit-columns-row'; |
| |
| // The checkbox which controls whether things like "Worker Threads" and |
| // "PAC threads" will be merged together. |
| var MERGE_SIMILAR_THREADS_CHECKBOX_ID = 'merge-similar-threads-checkbox'; |
| |
| var RESET_DATA_LINK_ID = 'reset-data-link'; |
| |
| var TOGGLE_SNAPSHOTS_LINK_ID = 'snapshots-link'; |
| var SNAPSHOTS_ROW = 'snapshots-row'; |
| var SNAPSHOT_SELECTION_SUMMARY_ID = 'snapshot-selection-summary'; |
| var TAKE_SNAPSHOT_BUTTON_ID = 'take-snapshot-button'; |
| |
| var SAVE_SNAPSHOTS_BUTTON_ID = 'save-snapshots-button'; |
| var SNAPSHOT_FILE_LOADER_ID = 'snapshot-file-loader'; |
| var LOAD_ERROR_ID = 'file-load-error'; |
| |
| var DOWNLOAD_ANCHOR_ID = 'download-anchor'; |
| |
| // -------------------------------------------------------------------------- |
| // Row keys |
| // -------------------------------------------------------------------------- |
| |
| // Each row of our data is an array of values rather than a dictionary. This |
| // avoids some overhead from repeating the key string multiple times, and |
| // speeds up the property accesses a bit. The following keys are well-known |
| // indexes into the array for various properties. |
| // |
| // Note that the declaration order will also define the default display order. |
| |
| var BEGIN_KEY = 1; // Start at 1 rather than 0 to simplify sorting code. |
| var END_KEY = BEGIN_KEY; |
| |
| var KEY_COUNT = END_KEY++; |
| var KEY_RUN_TIME = END_KEY++; |
| var KEY_AVG_RUN_TIME = END_KEY++; |
| var KEY_MAX_RUN_TIME = END_KEY++; |
| var KEY_QUEUE_TIME = END_KEY++; |
| var KEY_AVG_QUEUE_TIME = END_KEY++; |
| var KEY_MAX_QUEUE_TIME = END_KEY++; |
| var KEY_BIRTH_THREAD = END_KEY++; |
| var KEY_DEATH_THREAD = END_KEY++; |
| var KEY_PROCESS_TYPE = END_KEY++; |
| var KEY_PROCESS_ID = END_KEY++; |
| var KEY_FUNCTION_NAME = END_KEY++; |
| var KEY_SOURCE_LOCATION = END_KEY++; |
| var KEY_FILE_NAME = END_KEY++; |
| var KEY_LINE_NUMBER = END_KEY++; |
| |
| var NUM_KEYS = END_KEY - BEGIN_KEY; |
| |
| // -------------------------------------------------------------------------- |
| // Aggregators |
| // -------------------------------------------------------------------------- |
| |
| // To generalize computing/displaying the aggregate "counts" for each column, |
| // we specify an optional "Aggregator" class to use with each property. |
| |
| // The following are actually "Aggregator factories". They create an |
| // aggregator instance by calling 'create()'. The instance is then fed |
| // each row one at a time via the 'consume()' method. After all rows have |
| // been consumed, the 'getValueAsText()' method will return the aggregated |
| // value. |
| |
| /** |
| * This aggregator counts the number of unique values that were fed to it. |
| */ |
| var UniquifyAggregator = (function() { |
| function Aggregator(key) { |
| this.key_ = key; |
| this.valuesSet_ = {}; |
| } |
| |
| Aggregator.prototype = { |
| consume: function(e) { |
| this.valuesSet_[e[this.key_]] = true; |
| }, |
| |
| getValueAsText: function() { |
| return getDictionaryKeys(this.valuesSet_).length + ' unique'; |
| }, |
| }; |
| |
| return { |
| create: function(key) { return new Aggregator(key); } |
| }; |
| })(); |
| |
| /** |
| * This aggregator sums a numeric field. |
| */ |
| var SumAggregator = (function() { |
| function Aggregator(key) { |
| this.key_ = key; |
| this.sum_ = 0; |
| } |
| |
| Aggregator.prototype = { |
| consume: function(e) { |
| this.sum_ += e[this.key_]; |
| }, |
| |
| getValue: function() { |
| return this.sum_; |
| }, |
| |
| getValueAsText: function() { |
| return formatNumberAsText(this.getValue()); |
| }, |
| }; |
| |
| return { |
| create: function(key) { return new Aggregator(key); } |
| }; |
| })(); |
| |
| /** |
| * This aggregator computes an average by summing two |
| * numeric fields, and then dividing the totals. |
| */ |
| var AvgAggregator = (function() { |
| function Aggregator(numeratorKey, divisorKey) { |
| this.numeratorKey_ = numeratorKey; |
| this.divisorKey_ = divisorKey; |
| |
| this.numeratorSum_ = 0; |
| this.divisorSum_ = 0; |
| } |
| |
| Aggregator.prototype = { |
| consume: function(e) { |
| this.numeratorSum_ += e[this.numeratorKey_]; |
| this.divisorSum_ += e[this.divisorKey_]; |
| }, |
| |
| getValue: function() { |
| return this.numeratorSum_ / this.divisorSum_; |
| }, |
| |
| getValueAsText: function() { |
| return formatNumberAsText(this.getValue()); |
| }, |
| }; |
| |
| return { |
| create: function(numeratorKey, divisorKey) { |
| return { |
| create: function(key) { |
| return new Aggregator(numeratorKey, divisorKey); |
| }, |
| }; |
| } |
| }; |
| })(); |
| |
| /** |
| * This aggregator finds the maximum for a numeric field. |
| */ |
| var MaxAggregator = (function() { |
| function Aggregator(key) { |
| this.key_ = key; |
| this.max_ = -Infinity; |
| } |
| |
| Aggregator.prototype = { |
| consume: function(e) { |
| this.max_ = Math.max(this.max_, e[this.key_]); |
| }, |
| |
| getValue: function() { |
| return this.max_; |
| }, |
| |
| getValueAsText: function() { |
| return formatNumberAsText(this.getValue()); |
| }, |
| }; |
| |
| return { |
| create: function(key) { return new Aggregator(key); } |
| }; |
| })(); |
| |
| // -------------------------------------------------------------------------- |
| // Key properties |
| // -------------------------------------------------------------------------- |
| |
| // Custom comparator for thread names (sorts main thread and IO thread |
| // higher than would happen lexicographically.) |
| var threadNameComparator = |
| createLexicographicComparatorWithExceptions([ |
| 'CrBrowserMain', |
| 'Chrome_IOThread', |
| 'Chrome_FileThread', |
| 'Chrome_HistoryThread', |
| 'Chrome_DBThread', |
| 'Still_Alive', |
| ]); |
| |
| function diffFuncForCount(a, b) { |
| return b - a; |
| } |
| |
| function diffFuncForMax(a, b) { |
| return b; |
| } |
| |
| /** |
| * Enumerates information about various keys. Such as whether their data is |
| * expected to be numeric or is a string, a descriptive name (title) for the |
| * property, and what function should be used to aggregate the property when |
| * displayed in a column. |
| * |
| * -------------------------------------- |
| * The following properties are required: |
| * -------------------------------------- |
| * |
| * [name]: This is displayed as the column's label. |
| * [aggregator]: Aggregator factory that is used to compute an aggregate |
| * value for this column. |
| * |
| * -------------------------------------- |
| * The following properties are optional: |
| * -------------------------------------- |
| * |
| * [inputJsonKey]: The corresponding key for this property in the original |
| * JSON dictionary received from the browser. If this is |
| * present, values for this key will be automatically |
| * populated during import. |
| * [comparator]: A comparator function for sorting this column. |
| * [textPrinter]: A function that transforms values into the user-displayed |
| * text shown in the UI. If unspecified, will default to the |
| * "toString()" function. |
| * [cellAlignment]: The horizonal alignment to use for columns of this |
| * property (for instance 'right'). If unspecified will |
| * default to left alignment. |
| * [sortDescending]: When first clicking on this column, we will default to |
| * sorting by |comparator| in ascending order. If this |
| * property is true, we will reverse that to descending. |
| * [diff]: Function to call to compute a "difference" value between |
| * parameters (a, b). This is used when calculating the difference |
| * between two snapshots. Diffing numeric quantities generally |
| * involves subtracting, but some fields like max may need to do |
| * something different. |
| */ |
| var KEY_PROPERTIES = []; |
| |
| KEY_PROPERTIES[KEY_PROCESS_ID] = { |
| name: 'PID', |
| cellAlignment: 'right', |
| aggregator: UniquifyAggregator, |
| }; |
| |
| KEY_PROPERTIES[KEY_PROCESS_TYPE] = { |
| name: 'Process type', |
| aggregator: UniquifyAggregator, |
| }; |
| |
| KEY_PROPERTIES[KEY_BIRTH_THREAD] = { |
| name: 'Birth thread', |
| inputJsonKey: 'birth_thread', |
| aggregator: UniquifyAggregator, |
| comparator: threadNameComparator, |
| }; |
| |
| KEY_PROPERTIES[KEY_DEATH_THREAD] = { |
| name: 'Exec thread', |
| inputJsonKey: 'death_thread', |
| aggregator: UniquifyAggregator, |
| comparator: threadNameComparator, |
| }; |
| |
| KEY_PROPERTIES[KEY_FUNCTION_NAME] = { |
| name: 'Function name', |
| inputJsonKey: 'birth_location.function_name', |
| aggregator: UniquifyAggregator, |
| }; |
| |
| KEY_PROPERTIES[KEY_FILE_NAME] = { |
| name: 'File name', |
| inputJsonKey: 'birth_location.file_name', |
| aggregator: UniquifyAggregator, |
| }; |
| |
| KEY_PROPERTIES[KEY_LINE_NUMBER] = { |
| name: 'Line number', |
| cellAlignment: 'right', |
| inputJsonKey: 'birth_location.line_number', |
| aggregator: UniquifyAggregator, |
| }; |
| |
| KEY_PROPERTIES[KEY_COUNT] = { |
| name: 'Count', |
| cellAlignment: 'right', |
| sortDescending: true, |
| textPrinter: formatNumberAsText, |
| inputJsonKey: 'death_data.count', |
| aggregator: SumAggregator, |
| diff: diffFuncForCount, |
| }; |
| |
| KEY_PROPERTIES[KEY_QUEUE_TIME] = { |
| name: 'Total queue time', |
| cellAlignment: 'right', |
| sortDescending: true, |
| textPrinter: formatNumberAsText, |
| inputJsonKey: 'death_data.queue_ms', |
| aggregator: SumAggregator, |
| diff: diffFuncForCount, |
| }; |
| |
| KEY_PROPERTIES[KEY_MAX_QUEUE_TIME] = { |
| name: 'Max queue time', |
| cellAlignment: 'right', |
| sortDescending: true, |
| textPrinter: formatNumberAsText, |
| inputJsonKey: 'death_data.queue_ms_max', |
| aggregator: MaxAggregator, |
| diff: diffFuncForMax, |
| }; |
| |
| KEY_PROPERTIES[KEY_RUN_TIME] = { |
| name: 'Total run time', |
| cellAlignment: 'right', |
| sortDescending: true, |
| textPrinter: formatNumberAsText, |
| inputJsonKey: 'death_data.run_ms', |
| aggregator: SumAggregator, |
| diff: diffFuncForCount, |
| }; |
| |
| KEY_PROPERTIES[KEY_AVG_RUN_TIME] = { |
| name: 'Avg run time', |
| cellAlignment: 'right', |
| sortDescending: true, |
| textPrinter: formatNumberAsText, |
| aggregator: AvgAggregator.create(KEY_RUN_TIME, KEY_COUNT), |
| }; |
| |
| KEY_PROPERTIES[KEY_MAX_RUN_TIME] = { |
| name: 'Max run time', |
| cellAlignment: 'right', |
| sortDescending: true, |
| textPrinter: formatNumberAsText, |
| inputJsonKey: 'death_data.run_ms_max', |
| aggregator: MaxAggregator, |
| diff: diffFuncForMax, |
| }; |
| |
| KEY_PROPERTIES[KEY_AVG_QUEUE_TIME] = { |
| name: 'Avg queue time', |
| cellAlignment: 'right', |
| sortDescending: true, |
| textPrinter: formatNumberAsText, |
| aggregator: AvgAggregator.create(KEY_QUEUE_TIME, KEY_COUNT), |
| }; |
| |
| KEY_PROPERTIES[KEY_SOURCE_LOCATION] = { |
| name: 'Source location', |
| type: 'string', |
| aggregator: UniquifyAggregator, |
| }; |
| |
| /** |
| * Returns the string name for |key|. |
| */ |
| function getNameForKey(key) { |
| var props = KEY_PROPERTIES[key]; |
| if (props == undefined) |
| throw 'Did not define properties for key: ' + key; |
| return props.name; |
| } |
| |
| /** |
| * Ordered list of all keys. This is the order we generally want |
| * to display the properties in. Default to declaration order. |
| */ |
| var ALL_KEYS = []; |
| for (var k = BEGIN_KEY; k < END_KEY; ++k) |
| ALL_KEYS.push(k); |
| |
| // -------------------------------------------------------------------------- |
| // Default settings |
| // -------------------------------------------------------------------------- |
| |
| /** |
| * List of keys for those properties which we want to initially omit |
| * from the table. (They can be re-enabled by clicking [Edit columns]). |
| */ |
| var INITIALLY_HIDDEN_KEYS = [ |
| KEY_FILE_NAME, |
| KEY_LINE_NUMBER, |
| KEY_QUEUE_TIME, |
| ]; |
| |
| /** |
| * The ordered list of grouping choices to expose in the "Group by" |
| * dropdowns. We don't include the numeric properties, since they |
| * leads to awkward bucketing. |
| */ |
| var GROUPING_DROPDOWN_CHOICES = [ |
| KEY_PROCESS_TYPE, |
| KEY_PROCESS_ID, |
| KEY_BIRTH_THREAD, |
| KEY_DEATH_THREAD, |
| KEY_FUNCTION_NAME, |
| KEY_SOURCE_LOCATION, |
| KEY_FILE_NAME, |
| KEY_LINE_NUMBER, |
| ]; |
| |
| /** |
| * The ordered list of sorting choices to expose in the "Sort by" |
| * dropdowns. |
| */ |
| var SORT_DROPDOWN_CHOICES = ALL_KEYS; |
| |
| /** |
| * The ordered list of all columns that can be displayed in the tables (not |
| * including whatever has been hidden via [Edit Columns]). |
| */ |
| var ALL_TABLE_COLUMNS = ALL_KEYS; |
| |
| /** |
| * The initial keys to sort by when loading the page (can be changed later). |
| */ |
| var INITIAL_SORT_KEYS = [-KEY_COUNT]; |
| |
| /** |
| * The default sort keys to use when nothing has been specified. |
| */ |
| var DEFAULT_SORT_KEYS = [-KEY_COUNT]; |
| |
| /** |
| * The initial keys to group by when loading the page (can be changed later). |
| */ |
| var INITIAL_GROUP_KEYS = []; |
| |
| /** |
| * The columns to give the option to merge on. |
| */ |
| var MERGEABLE_KEYS = [ |
| KEY_PROCESS_ID, |
| KEY_PROCESS_TYPE, |
| KEY_BIRTH_THREAD, |
| KEY_DEATH_THREAD, |
| ]; |
| |
| /** |
| * The columns to merge by default. |
| */ |
| var INITIALLY_MERGED_KEYS = []; |
| |
| /** |
| * The full set of columns which define the "identity" for a row. A row is |
| * considered equivalent to another row if it matches on all of these |
| * fields. This list is used when merging the data, to determine which rows |
| * should be merged together. The remaining columns not listed in |
| * IDENTITY_KEYS will be aggregated. |
| */ |
| var IDENTITY_KEYS = [ |
| KEY_BIRTH_THREAD, |
| KEY_DEATH_THREAD, |
| KEY_PROCESS_TYPE, |
| KEY_PROCESS_ID, |
| KEY_FUNCTION_NAME, |
| KEY_SOURCE_LOCATION, |
| KEY_FILE_NAME, |
| KEY_LINE_NUMBER, |
| ]; |
| |
| /** |
| * The time (in milliseconds) to wait after receiving new data before |
| * re-drawing it to the screen. The reason we wait a bit is to avoid |
| * repainting repeatedly during the loading phase (which can slow things |
| * down). Note that this only slows down the addition of new data. It does |
| * not impact the latency of user-initiated operations like sorting or |
| * merging. |
| */ |
| var PROCESS_DATA_DELAY_MS = 500; |
| |
| /** |
| * The initial number of rows to display (the rest are hidden) when no |
| * grouping is selected. We use a higher limit than when grouping is used |
| * since there is a lot of vertical real estate. |
| */ |
| var INITIAL_UNGROUPED_ROW_LIMIT = 30; |
| |
| /** |
| * The initial number of rows to display (rest are hidden) for each group. |
| */ |
| var INITIAL_GROUP_ROW_LIMIT = 10; |
| |
| /** |
| * The number of extra rows to show/hide when clicking the "Show more" or |
| * "Show less" buttons. |
| */ |
| var LIMIT_INCREMENT = 10; |
| |
| // -------------------------------------------------------------------------- |
| // General utility functions |
| // -------------------------------------------------------------------------- |
| |
| /** |
| * Returns a list of all the keys in |dict|. |
| */ |
| function getDictionaryKeys(dict) { |
| var keys = []; |
| for (var key in dict) { |
| keys.push(key); |
| } |
| return keys; |
| } |
| |
| /** |
| * Formats the number |x| as a decimal integer. Strips off any decimal parts, |
| * and comma separates the number every 3 characters. |
| */ |
| function formatNumberAsText(x) { |
| var orig = x.toFixed(0); |
| |
| var parts = []; |
| for (var end = orig.length; end > 0; ) { |
| var chunk = Math.min(end, 3); |
| parts.push(orig.substr(end - chunk, chunk)); |
| end -= chunk; |
| } |
| return parts.reverse().join(','); |
| } |
| |
| /** |
| * Simple comparator function which works for both strings and numbers. |
| */ |
| function simpleCompare(a, b) { |
| if (a == b) |
| return 0; |
| if (a < b) |
| return -1; |
| return 1; |
| } |
| |
| /** |
| * Returns a comparator function that compares values lexicographically, |
| * but special-cases the values in |orderedList| to have a higher |
| * rank. |
| */ |
| function createLexicographicComparatorWithExceptions(orderedList) { |
| var valueToRankMap = {}; |
| for (var i = 0; i < orderedList.length; ++i) |
| valueToRankMap[orderedList[i]] = i; |
| |
| function getCustomRank(x) { |
| var rank = valueToRankMap[x]; |
| if (rank == undefined) |
| rank = Infinity; // Unmatched. |
| return rank; |
| } |
| |
| return function(a, b) { |
| var aRank = getCustomRank(a); |
| var bRank = getCustomRank(b); |
| |
| // Not matched by any of our exceptions. |
| if (aRank == bRank) |
| return simpleCompare(a, b); |
| |
| if (aRank < bRank) |
| return -1; |
| return 1; |
| }; |
| } |
| |
| /** |
| * Returns dict[key]. Note that if |key| contains periods (.), they will be |
| * interpreted as meaning a sub-property. |
| */ |
| function getPropertyByPath(dict, key) { |
| var cur = dict; |
| var parts = key.split('.'); |
| for (var i = 0; i < parts.length; ++i) { |
| if (cur == undefined) |
| return undefined; |
| cur = cur[parts[i]]; |
| } |
| return cur; |
| } |
| |
| /** |
| * Creates and appends a DOM node of type |tagName| to |parent|. Optionally, |
| * sets the new node's text to |opt_text|. Returns the newly created node. |
| */ |
| function addNode(parent, tagName, opt_text) { |
| var n = parent.ownerDocument.createElement(tagName); |
| parent.appendChild(n); |
| if (opt_text != undefined) { |
| addText(n, opt_text); |
| } |
| return n; |
| } |
| |
| /** |
| * Adds |text| to |parent|. |
| */ |
| function addText(parent, text) { |
| var textNode = parent.ownerDocument.createTextNode(text); |
| parent.appendChild(textNode); |
| return textNode; |
| } |
| |
| /** |
| * Deletes all the strings in |array| which appear in |valuesToDelete|. |
| */ |
| function deleteValuesFromArray(array, valuesToDelete) { |
| var valueSet = arrayToSet(valuesToDelete); |
| for (var i = 0; i < array.length; ) { |
| if (valueSet[array[i]]) { |
| array.splice(i, 1); |
| } else { |
| i++; |
| } |
| } |
| } |
| |
| /** |
| * Deletes all the repeated ocurrences of strings in |array|. |
| */ |
| function deleteDuplicateStringsFromArray(array) { |
| // Build up set of each entry in array. |
| var seenSoFar = {}; |
| |
| for (var i = 0; i < array.length; ) { |
| var value = array[i]; |
| if (seenSoFar[value]) { |
| array.splice(i, 1); |
| } else { |
| seenSoFar[value] = true; |
| i++; |
| } |
| } |
| } |
| |
| /** |
| * Builds a map out of the array |list|. |
| */ |
| function arrayToSet(list) { |
| var set = {}; |
| for (var i = 0; i < list.length; ++i) |
| set[list[i]] = true; |
| return set; |
| } |
| |
| function trimWhitespace(text) { |
| var m = /^\s*(.*)\s*$/.exec(text); |
| return m[1]; |
| } |
| |
| /** |
| * Selects the option in |select| which has a value of |value|. |
| */ |
| function setSelectedOptionByValue(select, value) { |
| for (var i = 0; i < select.options.length; ++i) { |
| if (select.options[i].value == value) { |
| select.options[i].selected = true; |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| /** |
| * Adds a checkbox to |parent|. The checkbox will have a label on its right |
| * with text |label|. Returns the checkbox input node. |
| */ |
| function addLabeledCheckbox(parent, label) { |
| var labelNode = addNode(parent, 'label'); |
| var checkbox = addNode(labelNode, 'input'); |
| checkbox.type = 'checkbox'; |
| addText(labelNode, label); |
| return checkbox; |
| } |
| |
| /** |
| * Return the last component in a path which is separated by either forward |
| * slashes or backslashes. |
| */ |
| function getFilenameFromPath(path) { |
| var lastSlash = Math.max(path.lastIndexOf('/'), |
| path.lastIndexOf('\\')); |
| if (lastSlash == -1) |
| return path; |
| |
| return path.substr(lastSlash + 1); |
| } |
| |
| /** |
| * Returns the current time in milliseconds since unix epoch. |
| */ |
| function getTimeMillis() { |
| return (new Date()).getTime(); |
| } |
| |
| /** |
| * Toggle a node between hidden/invisible. |
| */ |
| function toggleNodeDisplay(n) { |
| if (n.style.display == '') { |
| n.style.display = 'none'; |
| } else { |
| n.style.display = ''; |
| } |
| } |
| |
| /** |
| * Set the visibility state of a node. |
| */ |
| function setNodeDisplay(n, visible) { |
| if (visible) { |
| n.style.display = ''; |
| } else { |
| n.style.display = 'none'; |
| } |
| } |
| |
| // -------------------------------------------------------------------------- |
| // Functions that augment, bucket, and compute aggregates for the input data. |
| // -------------------------------------------------------------------------- |
| |
| /** |
| * Adds new derived properties to row. Mutates the provided dictionary |e|. |
| */ |
| function augmentDataRow(e) { |
| computeDataRowAverages(e); |
| e[KEY_SOURCE_LOCATION] = e[KEY_FILE_NAME] + ' [' + e[KEY_LINE_NUMBER] + ']'; |
| } |
| |
| function computeDataRowAverages(e) { |
| e[KEY_AVG_QUEUE_TIME] = e[KEY_QUEUE_TIME] / e[KEY_COUNT]; |
| e[KEY_AVG_RUN_TIME] = e[KEY_RUN_TIME] / e[KEY_COUNT]; |
| } |
| |
| /** |
| * Creates and initializes an aggregator object for each key in |columns|. |
| * Returns an array whose keys are values from |columns|, and whose |
| * values are Aggregator instances. |
| */ |
| function initializeAggregates(columns) { |
| var aggregates = []; |
| |
| for (var i = 0; i < columns.length; ++i) { |
| var key = columns[i]; |
| var aggregatorFactory = KEY_PROPERTIES[key].aggregator; |
| aggregates[key] = aggregatorFactory.create(key); |
| } |
| |
| return aggregates; |
| } |
| |
| function consumeAggregates(aggregates, row) { |
| for (var key in aggregates) |
| aggregates[key].consume(row); |
| } |
| |
| function bucketIdenticalRows(rows, identityKeys, propertyGetterFunc) { |
| var identicalRows = {}; |
| for (var i = 0; i < rows.length; ++i) { |
| var r = rows[i]; |
| |
| var rowIdentity = []; |
| for (var j = 0; j < identityKeys.length; ++j) |
| rowIdentity.push(propertyGetterFunc(r, identityKeys[j])); |
| rowIdentity = rowIdentity.join('\n'); |
| |
| var l = identicalRows[rowIdentity]; |
| if (!l) { |
| l = []; |
| identicalRows[rowIdentity] = l; |
| } |
| l.push(r); |
| } |
| return identicalRows; |
| } |
| |
| /** |
| * Merges the rows in |origRows|, by collapsing the columns listed in |
| * |mergeKeys|. Returns an array with the merged rows (in no particular |
| * order). |
| * |
| * If |mergeSimilarThreads| is true, then threads with a similar name will be |
| * considered equivalent. For instance, "WorkerThread-1" and "WorkerThread-2" |
| * will be remapped to "WorkerThread-*". |
| * |
| * If |outputAsDictionary| is false then the merged rows will be returned as a |
| * flat list. Otherwise the result will be a dictionary, where each row |
| * has a unique key. |
| */ |
| function mergeRows(origRows, mergeKeys, mergeSimilarThreads, |
| outputAsDictionary) { |
| // Define a translation function for each property. Normally we copy over |
| // properties as-is, but if we have been asked to "merge similar threads" we |
| // we will remap the thread names that end in a numeric suffix. |
| var propertyGetterFunc; |
| |
| if (mergeSimilarThreads) { |
| propertyGetterFunc = function(row, key) { |
| var value = row[key]; |
| // If the property is a thread name, try to remap it. |
| if (key == KEY_BIRTH_THREAD || key == KEY_DEATH_THREAD) { |
| var m = /^(.*[^\d])(\d+)$/.exec(value); |
| if (m) |
| value = m[1] + '*'; |
| } |
| return value; |
| } |
| } else { |
| propertyGetterFunc = function(row, key) { return row[key]; }; |
| } |
| |
| // Determine which sets of properties a row needs to match on to be |
| // considered identical to another row. |
| var identityKeys = IDENTITY_KEYS.slice(0); |
| deleteValuesFromArray(identityKeys, mergeKeys); |
| |
| // Set |aggregateKeys| to everything else, since we will be aggregating |
| // their value as part of the merge. |
| var aggregateKeys = ALL_KEYS.slice(0); |
| deleteValuesFromArray(aggregateKeys, IDENTITY_KEYS); |
| deleteValuesFromArray(aggregateKeys, mergeKeys); |
| |
| // Group all the identical rows together, bucketed into |identicalRows|. |
| var identicalRows = |
| bucketIdenticalRows(origRows, identityKeys, propertyGetterFunc); |
| |
| var mergedRows = outputAsDictionary ? {} : []; |
| |
| // Merge the rows and save the results to |mergedRows|. |
| for (var k in identicalRows) { |
| // We need to smash the list |l| down to a single row... |
| var l = identicalRows[k]; |
| |
| var newRow = []; |
| |
| if (outputAsDictionary) { |
| mergedRows[k] = newRow; |
| } else { |
| mergedRows.push(newRow); |
| } |
| |
| // Copy over all the identity columns to the new row (since they |
| // were the same for each row matched). |
| for (var i = 0; i < identityKeys.length; ++i) |
| newRow[identityKeys[i]] = propertyGetterFunc(l[0], identityKeys[i]); |
| |
| // Compute aggregates for the other columns. |
| var aggregates = initializeAggregates(aggregateKeys); |
| |
| // Feed the rows to the aggregators. |
| for (var i = 0; i < l.length; ++i) |
| consumeAggregates(aggregates, l[i]); |
| |
| // Suck out the data generated by the aggregators. |
| for (var aggregateKey in aggregates) |
| newRow[aggregateKey] = aggregates[aggregateKey].getValue(); |
| } |
| |
| return mergedRows; |
| } |
| |
| /** |
| * Takes two dictionaries data1 and data2, and returns a new flat list which |
| * represents the difference between them. The exact meaning of "difference" |
| * is column specific, but for most numeric fields (like the count, or total |
| * time), it is found by subtracting. |
| * |
| * Rows in data1 and data2 are expected to use the same scheme for the keys. |
| * In other words, data1[k] is considered the analagous row to data2[k]. |
| */ |
| function subtractSnapshots(data1, data2, columnsToExclude) { |
| // These columns are computed from the other columns. We won't bother |
| // diffing/aggregating these, but rather will derive them again from the |
| // final row. |
| var COMPUTED_AGGREGATE_KEYS = [KEY_AVG_QUEUE_TIME, KEY_AVG_RUN_TIME]; |
| |
| // These are the keys which determine row equality. Since we are not doing |
| // any merging yet at this point, it is simply the list of all identity |
| // columns. |
| var identityKeys = IDENTITY_KEYS.slice(0); |
| deleteValuesFromArray(identityKeys, columnsToExclude); |
| |
| // The columns to compute via aggregation is everything else. |
| var aggregateKeys = ALL_KEYS.slice(0); |
| deleteValuesFromArray(aggregateKeys, IDENTITY_KEYS); |
| deleteValuesFromArray(aggregateKeys, COMPUTED_AGGREGATE_KEYS); |
| deleteValuesFromArray(aggregateKeys, columnsToExclude); |
| |
| var diffedRows = []; |
| |
| for (var rowId in data2) { |
| var row1 = data1[rowId]; |
| var row2 = data2[rowId]; |
| |
| var newRow = []; |
| |
| // Copy over all the identity columns to the new row (since they |
| // were the same for each row matched). |
| for (var i = 0; i < identityKeys.length; ++i) |
| newRow[identityKeys[i]] = row2[identityKeys[i]]; |
| |
| // Diff the two rows. |
| if (row1) { |
| for (var i = 0; i < aggregateKeys.length; ++i) { |
| var aggregateKey = aggregateKeys[i]; |
| var a = row1[aggregateKey]; |
| var b = row2[aggregateKey]; |
| |
| var diffFunc = KEY_PROPERTIES[aggregateKey].diff; |
| newRow[aggregateKey] = diffFunc(a, b); |
| } |
| } else { |
| // If the the row doesn't appear in snapshot1, then there is nothing to |
| // diff, so just copy row2 as is. |
| for (var i = 0; i < aggregateKeys.length; ++i) { |
| var aggregateKey = aggregateKeys[i]; |
| newRow[aggregateKey] = row2[aggregateKey]; |
| } |
| } |
| |
| if (newRow[KEY_COUNT] == 0) { |
| // If a row's count has gone to zero, it means there were no new |
| // occurrences of it in the second snapshot, so remove it. |
| continue; |
| } |
| |
| // Since we excluded the averages during the diffing phase, re-compute |
| // them using the diffed totals. |
| computeDataRowAverages(newRow); |
| diffedRows.push(newRow); |
| } |
| |
| return diffedRows; |
| } |
| |
| // -------------------------------------------------------------------------- |
| // HTML drawing code |
| // -------------------------------------------------------------------------- |
| |
| function getTextValueForProperty(key, value) { |
| if (value == undefined) { |
| // A value may be undefined as a result of having merging rows. We |
| // won't actually draw it, but this might be called by the filter. |
| return ''; |
| } |
| |
| var textPrinter = KEY_PROPERTIES[key].textPrinter; |
| if (textPrinter) |
| return textPrinter(value); |
| return value.toString(); |
| } |
| |
| /** |
| * Renders the property value |value| into cell |td|. The name of this |
| * property is |key|. |
| */ |
| function drawValueToCell(td, key, value) { |
| // Get a text representation of the value. |
| var text = getTextValueForProperty(key, value); |
| |
| // Apply the desired cell alignment. |
| var cellAlignment = KEY_PROPERTIES[key].cellAlignment; |
| if (cellAlignment) |
| td.align = cellAlignment; |
| |
| if (key == KEY_SOURCE_LOCATION) { |
| // Linkify the source column so it jumps to the source code. This doesn't |
| // take into account the particular code this build was compiled from, or |
| // local edits to source. It should however work correctly for top of tree |
| // builds. |
| var m = /^(.*) \[(\d+)\]$/.exec(text); |
| if (m) { |
| var filepath = m[1]; |
| var filename = getFilenameFromPath(filepath); |
| var linenumber = m[2]; |
| |
| var link = addNode(td, 'a', filename + ' [' + linenumber + ']'); |
| // http://chromesrc.appspot.com is a server I wrote specifically for |
| // this task. It redirects to the appropriate source file; the file |
| // paths given by the compiler can be pretty crazy and different |
| // between platforms. |
| link.href = 'http://chromesrc.appspot.com/?path=' + |
| encodeURIComponent(filepath) + '&line=' + linenumber; |
| link.target = '_blank'; |
| return; |
| } |
| } |
| |
| // String values can get pretty long. If the string contains no spaces, then |
| // CSS fails to wrap it, and it overflows the cell causing the table to get |
| // really big. We solve this using a hack: insert a <wbr> element after |
| // every single character. This will allow the rendering engine to wrap the |
| // value, and hence avoid it overflowing! |
| var kMinLengthBeforeWrap = 20; |
| |
| addText(td, text.substr(0, kMinLengthBeforeWrap)); |
| for (var i = kMinLengthBeforeWrap; i < text.length; ++i) { |
| addNode(td, 'wbr'); |
| addText(td, text.substr(i, 1)); |
| } |
| } |
| |
| // -------------------------------------------------------------------------- |
| // Helper code for handling the sort and grouping dropdowns. |
| // -------------------------------------------------------------------------- |
| |
| function addOptionsForGroupingSelect(select) { |
| // Add "no group" choice. |
| addNode(select, 'option', '---').value = ''; |
| |
| for (var i = 0; i < GROUPING_DROPDOWN_CHOICES.length; ++i) { |
| var key = GROUPING_DROPDOWN_CHOICES[i]; |
| var option = addNode(select, 'option', getNameForKey(key)); |
| option.value = key; |
| } |
| } |
| |
| function addOptionsForSortingSelect(select) { |
| // Add "no sort" choice. |
| addNode(select, 'option', '---').value = ''; |
| |
| // Add a divider. |
| addNode(select, 'optgroup').label = ''; |
| |
| for (var i = 0; i < SORT_DROPDOWN_CHOICES.length; ++i) { |
| var key = SORT_DROPDOWN_CHOICES[i]; |
| addNode(select, 'option', getNameForKey(key)).value = key; |
| } |
| |
| // Add a divider. |
| addNode(select, 'optgroup').label = ''; |
| |
| // Add the same options, but for descending. |
| for (var i = 0; i < SORT_DROPDOWN_CHOICES.length; ++i) { |
| var key = SORT_DROPDOWN_CHOICES[i]; |
| var n = addNode(select, 'option', getNameForKey(key) + ' (DESC)'); |
| n.value = reverseSortKey(key); |
| } |
| } |
| |
| /** |
| * Helper function used to update the sorting and grouping lists after a |
| * dropdown changes. |
| */ |
| function updateKeyListFromDropdown(list, i, select) { |
| // Update the list. |
| if (i < list.length) { |
| list[i] = select.value; |
| } else { |
| list.push(select.value); |
| } |
| |
| // Normalize the list, so setting 'none' as primary zeros out everything |
| // else. |
| for (var i = 0; i < list.length; ++i) { |
| if (list[i] == '') { |
| list.splice(i, list.length - i); |
| break; |
| } |
| } |
| } |
| |
| /** |
| * Comparator for property |key|, having values |value1| and |value2|. |
| * If the key has defined a custom comparator use it. Otherwise use a |
| * default "less than" comparison. |
| */ |
| function compareValuesForKey(key, value1, value2) { |
| var comparator = KEY_PROPERTIES[key].comparator; |
| if (comparator) |
| return comparator(value1, value2); |
| return simpleCompare(value1, value2); |
| } |
| |
| function reverseSortKey(key) { |
| return -key; |
| } |
| |
| function sortKeyIsReversed(key) { |
| return key < 0; |
| } |
| |
| function sortKeysMatch(key1, key2) { |
| return Math.abs(key1) == Math.abs(key2); |
| } |
| |
| function getKeysForCheckedBoxes(checkboxes) { |
| var keys = []; |
| for (var k in checkboxes) { |
| if (checkboxes[k].checked) |
| keys.push(k); |
| } |
| return keys; |
| } |
| |
| // -------------------------------------------------------------------------- |
| |
| /** |
| * @constructor |
| */ |
| function MainView() { |
| // Make sure we have a definition for each key. |
| for (var k = BEGIN_KEY; k < END_KEY; ++k) { |
| if (!KEY_PROPERTIES[k]) |
| throw 'KEY_PROPERTIES[] not defined for key: ' + k; |
| } |
| |
| this.init_(); |
| } |
| |
| MainView.prototype = { |
| addDataToSnapshot: function(data) { |
| // TODO(eroman): We need to know which snapshot this data belongs to! |
| // For now we assume it is the most recent snapshot. |
| var snapshotIndex = this.snapshots_.length - 1; |
| |
| var snapshot = this.snapshots_[snapshotIndex]; |
| |
| var pid = data.process_id; |
| var ptype = data.process_type; |
| |
| // Save the browser's representation of the data |
| snapshot.origData.push(data); |
| |
| // Augment each data row with the process information. |
| var rows = data.list; |
| for (var i = 0; i < rows.length; ++i) { |
| // Transform the data from a dictionary to an array. This internal |
| // representation is more compact and faster to access. |
| var origRow = rows[i]; |
| var newRow = []; |
| |
| newRow[KEY_PROCESS_ID] = pid; |
| newRow[KEY_PROCESS_TYPE] = ptype; |
| |
| // Copy over the known properties which have a 1:1 mapping with JSON. |
| for (var k = BEGIN_KEY; k < END_KEY; ++k) { |
| var inputJsonKey = KEY_PROPERTIES[k].inputJsonKey; |
| if (inputJsonKey != undefined) { |
| newRow[k] = getPropertyByPath(origRow, inputJsonKey); |
| } |
| } |
| |
| if (newRow[KEY_COUNT] == 0) { |
| // When resetting the data, it is possible for the backend to give us |
| // counts of "0". There is no point adding these rows (in fact they |
| // will cause us to do divide by zeros when calculating averages and |
| // stuff), so we skip past them. |
| continue; |
| } |
| |
| // Add our computed properties. |
| augmentDataRow(newRow); |
| |
| snapshot.flatData.push(newRow); |
| } |
| |
| if (!arrayToSet(this.getSelectedSnapshotIndexes_())[snapshotIndex]) { |
| // Optimization: If this snapshot is not a data dependency for the |
| // current display, then don't bother updating anything. |
| return; |
| } |
| |
| // We may end up calling addDataToSnapshot_() repeatedly (once for each |
| // process). To avoid this from slowing us down we do bulk updates on a |
| // timer. |
| this.updateMergedDataSoon_(); |
| }, |
| |
| updateMergedDataSoon_: function() { |
| if (this.updateMergedDataPending_) { |
| // If a delayed task has already been posted to re-merge the data, |
| // then we don't need to do anything extra. |
| return; |
| } |
| |
| // Otherwise schedule updateMergedData_() to be called later. We want it |
| // to be called no more than once every PROCESS_DATA_DELAY_MS |
| // milliseconds. |
| |
| if (this.lastUpdateMergedDataTime_ == undefined) |
| this.lastUpdateMergedDataTime_ = 0; |
| |
| var timeSinceLastMerge = getTimeMillis() - this.lastUpdateMergedDataTime_; |
| var timeToWait = Math.max(0, PROCESS_DATA_DELAY_MS - timeSinceLastMerge); |
| |
| var functionToRun = function() { |
| // Do the actual update. |
| this.updateMergedData_(); |
| // Keep track of when we last ran. |
| this.lastUpdateMergedDataTime_ = getTimeMillis(); |
| this.updateMergedDataPending_ = false; |
| }.bind(this); |
| |
| this.updateMergedDataPending_ = true; |
| window.setTimeout(functionToRun, timeToWait); |
| }, |
| |
| /** |
| * Returns a list of the currently selected snapshots. This list is |
| * guaranteed to be of length 1 or 2. |
| */ |
| getSelectedSnapshotIndexes_: function() { |
| var indexes = this.getSelectedSnapshotBoxes_(); |
| for (var i = 0; i < indexes.length; ++i) |
| indexes[i] = indexes[i].__index; |
| return indexes; |
| }, |
| |
| /** |
| * Same as getSelectedSnapshotIndexes_(), only it returns the actual |
| * checkbox input DOM nodes rather than the snapshot ID. |
| */ |
| getSelectedSnapshotBoxes_: function() { |
| // Figure out which snaphots to use for our data. |
| var boxes = []; |
| for (var i = 0; i < this.snapshots_.length; ++i) { |
| var box = this.getSnapshotCheckbox_(i); |
| if (box.checked) |
| boxes.push(box); |
| } |
| return boxes; |
| }, |
| |
| /** |
| * Re-draw the description that explains which snapshots are currently |
| * selected (if two snapshots were selected we explain that the *difference* |
| * between them is being displayed). |
| */ |
| updateSnapshotSelectionSummaryDiv_: function() { |
| var summaryDiv = $(SNAPSHOT_SELECTION_SUMMARY_ID); |
| |
| var selectedSnapshots = this.getSelectedSnapshotIndexes_(); |
| if (selectedSnapshots.length == 0) { |
| // This can occur during an attempt to load a file or following file |
| // load failure. We just ignore it and move on. |
| } else if (selectedSnapshots.length == 1) { |
| // If only one snapshot is chosen then we will display that snapshot's |
| // data in its entirety. |
| this.flatData_ = this.snapshots_[selectedSnapshots[0]].flatData; |
| |
| // Don't bother displaying any text when just 1 snapshot is selected, |
| // since it is obvious what this should do. |
| summaryDiv.innerText = ''; |
| } else if (selectedSnapshots.length == 2) { |
| // Otherwise if two snapshots were chosen, show the difference between |
| // them. |
| var snapshot1 = this.snapshots_[selectedSnapshots[0]]; |
| var snapshot2 = this.snapshots_[selectedSnapshots[1]]; |
| |
| var timeDeltaInSeconds = |
| ((snapshot2.time - snapshot1.time) / 1000).toFixed(0); |
| |
| // Explain that what is being shown is the difference between two |
| // snapshots. |
| summaryDiv.innerText = |
| 'Showing the difference between snapshots #' + |
| selectedSnapshots[0] + ' and #' + |
| selectedSnapshots[1] + ' (' + timeDeltaInSeconds + |
| ' seconds worth of data)'; |
| } else { |
| // This shouldn't be possible... |
| throw 'Unexpected number of selected snapshots'; |
| } |
| }, |
| |
| updateMergedData_: function() { |
| // Retrieve the merge options. |
| var mergeColumns = this.getMergeColumns_(); |
| var shouldMergeSimilarThreads = this.shouldMergeSimilarThreads_(); |
| |
| var selectedSnapshots = this.getSelectedSnapshotIndexes_(); |
| |
| // We do merges a bit differently depending if we are displaying the diffs |
| // between two snapshots, or just displaying a single snapshot. |
| if (selectedSnapshots.length == 1) { |
| var snapshot = this.snapshots_[selectedSnapshots[0]]; |
| this.mergedData_ = mergeRows(snapshot.flatData, |
| mergeColumns, |
| shouldMergeSimilarThreads, |
| false); |
| |
| } else if (selectedSnapshots.length == 2) { |
| var snapshot1 = this.snapshots_[selectedSnapshots[0]]; |
| var snapshot2 = this.snapshots_[selectedSnapshots[1]]; |
| |
| // Merge the data for snapshot1. |
| var mergedRows1 = mergeRows(snapshot1.flatData, |
| mergeColumns, |
| shouldMergeSimilarThreads, |
| true); |
| |
| // Merge the data for snapshot2. |
| var mergedRows2 = mergeRows(snapshot2.flatData, |
| mergeColumns, |
| shouldMergeSimilarThreads, |
| true); |
| |
| // Do a diff between the two snapshots. |
| this.mergedData_ = subtractSnapshots(mergedRows1, |
| mergedRows2, |
| mergeColumns); |
| } else { |
| throw 'Unexpected number of selected snapshots'; |
| } |
| |
| // Recompute filteredData_ (since it is derived from mergedData_) |
| this.updateFilteredData_(); |
| }, |
| |
| updateFilteredData_: function() { |
| // Recompute filteredData_. |
| this.filteredData_ = []; |
| var filterFunc = this.getFilterFunction_(); |
| for (var i = 0; i < this.mergedData_.length; ++i) { |
| var r = this.mergedData_[i]; |
| if (!filterFunc(r)) { |
| // Not matched by our filter, discard. |
| continue; |
| } |
| this.filteredData_.push(r); |
| } |
| |
| // Recompute groupedData_ (since it is derived from filteredData_) |
| this.updateGroupedData_(); |
| }, |
| |
| updateGroupedData_: function() { |
| // Recompute groupedData_. |
| var groupKeyToData = {}; |
| var entryToGroupKeyFunc = this.getGroupingFunction_(); |
| for (var i = 0; i < this.filteredData_.length; ++i) { |
| var r = this.filteredData_[i]; |
| |
| var groupKey = entryToGroupKeyFunc(r); |
| |
| var groupData = groupKeyToData[groupKey]; |
| if (!groupData) { |
| groupData = { |
| key: JSON.parse(groupKey), |
| aggregates: initializeAggregates(ALL_KEYS), |
| rows: [], |
| }; |
| groupKeyToData[groupKey] = groupData; |
| } |
| |
| // Add the row to our list. |
| groupData.rows.push(r); |
| |
| // Update aggregates for each column. |
| consumeAggregates(groupData.aggregates, r); |
| } |
| this.groupedData_ = groupKeyToData; |
| |
| // Figure out a display order for the groups themselves. |
| this.sortedGroupKeys_ = getDictionaryKeys(groupKeyToData); |
| this.sortedGroupKeys_.sort(this.getGroupSortingFunction_()); |
| |
| // Sort the group data. |
| this.sortGroupedData_(); |
| }, |
| |
| sortGroupedData_: function() { |
| var sortingFunc = this.getSortingFunction_(); |
| for (var k in this.groupedData_) |
| this.groupedData_[k].rows.sort(sortingFunc); |
| |
| // Every cached data dependency is now up to date, all that is left is |
| // to actually draw the result. |
| this.redrawData_(); |
| }, |
| |
| getVisibleColumnKeys_: function() { |
| // Figure out what columns to include, based on the selected checkboxes. |
| var columns = this.getSelectionColumns_(); |
| columns = columns.slice(0); |
| |
| // Eliminate columns which we are merging on. |
| deleteValuesFromArray(columns, this.getMergeColumns_()); |
| |
| // Eliminate columns which we are grouped on. |
| if (this.sortedGroupKeys_.length > 0) { |
| // The grouping will be the the same for each so just pick the first. |
| var randomGroupKey = this.groupedData_[this.sortedGroupKeys_[0]].key; |
| |
| // The grouped properties are going to be the same for each row in our, |
| // table, so avoid drawing them in our table! |
| var keysToExclude = []; |
| |
| for (var i = 0; i < randomGroupKey.length; ++i) |
| keysToExclude.push(randomGroupKey[i].key); |
| deleteValuesFromArray(columns, keysToExclude); |
| } |
| |
| // If we are currently showing a "diff", hide the max columns, since we |
| // are not populating it correctly. See the TODO at the top of this file. |
| if (this.getSelectedSnapshotIndexes_().length > 1) |
| deleteValuesFromArray(columns, [KEY_MAX_RUN_TIME, KEY_MAX_QUEUE_TIME]); |
| |
| return columns; |
| }, |
| |
| redrawData_: function() { |
| // Clear the results div, sine we may be overwriting older data. |
| var parent = $(RESULTS_DIV_ID); |
| parent.innerHTML = ''; |
| |
| var columns = this.getVisibleColumnKeys_(); |
| |
| // Draw each group. |
| for (var i = 0; i < this.sortedGroupKeys_.length; ++i) { |
| var k = this.sortedGroupKeys_[i]; |
| this.drawGroup_(parent, k, columns); |
| } |
| }, |
| |
| /** |
| * Renders the information for a particular group. |
| */ |
| drawGroup_: function(parent, groupKey, columns) { |
| var groupData = this.groupedData_[groupKey]; |
| |
| var div = addNode(parent, 'div'); |
| div.className = 'group-container'; |
| |
| this.drawGroupTitle_(div, groupData.key); |
| |
| var table = addNode(div, 'table'); |
| |
| this.drawDataTable_(table, groupData, columns, groupKey); |
| }, |
| |
| /** |
| * Draws a title into |parent| that describes |groupKey|. |
| */ |
| drawGroupTitle_: function(parent, groupKey) { |
| if (groupKey.length == 0) { |
| // Empty group key means there was no grouping. |
| return; |
| } |
| |
| var parent = addNode(parent, 'div'); |
| parent.className = 'group-title-container'; |
| |
| // Each component of the group key represents the "key=value" constraint |
| // for this group. Show these as an AND separated list. |
| for (var i = 0; i < groupKey.length; ++i) { |
| if (i > 0) |
| addNode(parent, 'i', ' and '); |
| var e = groupKey[i]; |
| addNode(parent, 'b', getNameForKey(e.key) + ' = '); |
| addNode(parent, 'span', e.value); |
| } |
| }, |
| |
| /** |
| * Renders a table which summarizes all |column| fields for |data|. |
| */ |
| drawDataTable_: function(table, data, columns, groupKey) { |
| table.className = 'results-table'; |
| var thead = addNode(table, 'thead'); |
| var tbody = addNode(table, 'tbody'); |
| |
| var displaySettings = this.getGroupDisplaySettings_(groupKey); |
| var limit = displaySettings.limit; |
| |
| this.drawAggregateRow_(thead, data.aggregates, columns); |
| this.drawTableHeader_(thead, columns); |
| this.drawTableBody_(tbody, data.rows, columns, limit); |
| this.drawTruncationRow_(tbody, data.rows.length, limit, columns.length, |
| groupKey); |
| }, |
| |
| drawTableHeader_: function(thead, columns) { |
| var tr = addNode(thead, 'tr'); |
| for (var i = 0; i < columns.length; ++i) { |
| var key = columns[i]; |
| var th = addNode(tr, 'th', getNameForKey(key)); |
| th.onclick = this.onClickColumn_.bind(this, key); |
| |
| // Draw an indicator if we are currently sorted on this column. |
| // TODO(eroman): Should use an icon instead of asterisk! |
| for (var j = 0; j < this.currentSortKeys_.length; ++j) { |
| if (sortKeysMatch(this.currentSortKeys_[j], key)) { |
| var sortIndicator = addNode(th, 'span', '*'); |
| sortIndicator.style.color = 'red'; |
| if (sortKeyIsReversed(this.currentSortKeys_[j])) { |
| // Use double-asterisk for descending columns. |
| addText(sortIndicator, '*'); |
| } |
| break; |
| } |
| } |
| } |
| }, |
| |
| drawTableBody_: function(tbody, rows, columns, limit) { |
| for (var i = 0; i < rows.length && i < limit; ++i) { |
| var e = rows[i]; |
| |
| var tr = addNode(tbody, 'tr'); |
| |
| for (var c = 0; c < columns.length; ++c) { |
| var key = columns[c]; |
| var value = e[key]; |
| |
| var td = addNode(tr, 'td'); |
| drawValueToCell(td, key, value); |
| } |
| } |
| }, |
| |
| /** |
| * Renders a row that describes all the aggregate values for |columns|. |
| */ |
| drawAggregateRow_: function(tbody, aggregates, columns) { |
| var tr = addNode(tbody, 'tr'); |
| tr.className = 'aggregator-row'; |
| |
| for (var i = 0; i < columns.length; ++i) { |
| var key = columns[i]; |
| var td = addNode(tr, 'td'); |
| |
| // Most of our outputs are numeric, so we want to align them to the |
| // right. However for the unique counts we will center. |
| if (KEY_PROPERTIES[key].aggregator == UniquifyAggregator) { |
| td.align = 'center'; |
| } else { |
| td.align = 'right'; |
| } |
| |
| var aggregator = aggregates[key]; |
| if (aggregator) |
| td.innerText = aggregator.getValueAsText(); |
| } |
| }, |
| |
| /** |
| * Renders a row which describes how many rows the table has, how many are |
| * currently hidden, and a set of buttons to show more. |
| */ |
| drawTruncationRow_: function(tbody, numRows, limit, numColumns, groupKey) { |
| var numHiddenRows = Math.max(numRows - limit, 0); |
| var numVisibleRows = numRows - numHiddenRows; |
| |
| var tr = addNode(tbody, 'tr'); |
| tr.className = 'truncation-row'; |
| var td = addNode(tr, 'td'); |
| td.colSpan = numColumns; |
| |
| addText(td, numRows + ' rows'); |
| if (numHiddenRows > 0) { |
| var s = addNode(td, 'span', ' (' + numHiddenRows + ' hidden) '); |
| s.style.color = 'red'; |
| } |
| |
| if (numVisibleRows > LIMIT_INCREMENT) { |
| addNode(td, 'button', 'Show less').onclick = |
| this.changeGroupDisplayLimit_.bind( |
| this, groupKey, -LIMIT_INCREMENT); |
| } |
| if (numVisibleRows > 0) { |
| addNode(td, 'button', 'Show none').onclick = |
| this.changeGroupDisplayLimit_.bind(this, groupKey, -Infinity); |
| } |
| |
| if (numHiddenRows > 0) { |
| addNode(td, 'button', 'Show more').onclick = |
| this.changeGroupDisplayLimit_.bind(this, groupKey, LIMIT_INCREMENT); |
| addNode(td, 'button', 'Show all').onclick = |
| this.changeGroupDisplayLimit_.bind(this, groupKey, Infinity); |
| } |
| }, |
| |
| /** |
| * Adjusts the row limit for group |groupKey| by |delta|. |
| */ |
| changeGroupDisplayLimit_: function(groupKey, delta) { |
| // Get the current settings for this group. |
| var settings = this.getGroupDisplaySettings_(groupKey, true); |
| |
| // Compute the adjusted limit. |
| var newLimit = settings.limit; |
| var totalNumRows = this.groupedData_[groupKey].rows.length; |
| newLimit = Math.min(totalNumRows, newLimit); |
| newLimit += delta; |
| newLimit = Math.max(0, newLimit); |
| |
| // Update the settings with the new limit. |
| settings.limit = newLimit; |
| |
| // TODO(eroman): It isn't necessary to redraw *all* the data. Really we |
| // just need to insert the missing rows (everything else stays the same)! |
| this.redrawData_(); |
| }, |
| |
| /** |
| * Returns the rendering settings for group |groupKey|. This includes things |
| * like how many rows to display in the table. |
| */ |
| getGroupDisplaySettings_: function(groupKey, opt_create) { |
| var settings = this.groupDisplaySettings_[groupKey]; |
| if (!settings) { |
| // If we don't have any settings for this group yet, create some |
| // default ones. |
| if (groupKey == '[]') { |
| // (groupKey of '[]' is what we use for ungrouped data). |
| settings = {limit: INITIAL_UNGROUPED_ROW_LIMIT}; |
| } else { |
| settings = {limit: INITIAL_GROUP_ROW_LIMIT}; |
| } |
| if (opt_create) |
| this.groupDisplaySettings_[groupKey] = settings; |
| } |
| return settings; |
| }, |
| |
| init_: function() { |
| this.snapshots_ = []; |
| |
| // Start fetching the data from the browser; this will be our snapshot #0. |
| this.takeSnapshot_(); |
| |
| // Data goes through the following pipeline: |
| // (1) Raw data received from browser, and transformed into our own |
| // internal row format (where properties are indexed by KEY_* |
| // constants.) |
| // (2) We "augment" each row by adding some extra computed columns |
| // (like averages). |
| // (3) The rows are merged using current merge settings. |
| // (4) The rows that don't match current search expression are |
| // tossed out. |
| // (5) The rows are organized into "groups" based on current settings, |
| // and aggregate values are computed for each resulting group. |
| // (6) The rows within each group are sorted using current settings. |
| // (7) The grouped rows are drawn to the screen. |
| this.mergedData_ = []; |
| this.filteredData_ = []; |
| this.groupedData_ = {}; |
| this.sortedGroupKeys_ = []; |
| |
| this.groupDisplaySettings_ = {}; |
| |
| this.fillSelectionCheckboxes_($(COLUMN_TOGGLES_CONTAINER_ID)); |
| this.fillMergeCheckboxes_($(COLUMN_MERGE_TOGGLES_CONTAINER_ID)); |
| |
| $(FILTER_SEARCH_ID).onsearch = this.onChangedFilter_.bind(this); |
| |
| this.currentSortKeys_ = INITIAL_SORT_KEYS.slice(0); |
| this.currentGroupingKeys_ = INITIAL_GROUP_KEYS.slice(0); |
| |
| this.fillGroupingDropdowns_(); |
| this.fillSortingDropdowns_(); |
| |
| $(EDIT_COLUMNS_LINK_ID).onclick = |
| toggleNodeDisplay.bind(null, $(EDIT_COLUMNS_ROW)); |
| |
| $(TOGGLE_SNAPSHOTS_LINK_ID).onclick = |
| toggleNodeDisplay.bind(null, $(SNAPSHOTS_ROW)); |
| |
| $(MERGE_SIMILAR_THREADS_CHECKBOX_ID).onchange = |
| this.onMergeSimilarThreadsCheckboxChanged_.bind(this); |
| |
| $(RESET_DATA_LINK_ID).onclick = |
| g_browserBridge.sendResetData.bind(g_browserBridge); |
| |
| $(TAKE_SNAPSHOT_BUTTON_ID).onclick = this.takeSnapshot_.bind(this); |
| |
| $(SAVE_SNAPSHOTS_BUTTON_ID).onclick = this.saveSnapshots_.bind(this); |
| $(SNAPSHOT_FILE_LOADER_ID).onchange = this.loadFileChanged_.bind(this); |
| }, |
| |
| takeSnapshot_: function() { |
| // Start a new empty snapshot. Make note of the current time, so we know |
| // when the snaphot was taken. |
| this.snapshots_.push({flatData: [], origData: [], time: getTimeMillis()}); |
| |
| // Update the UI to reflect the new snapshot. |
| this.addSnapshotToList_(this.snapshots_.length - 1); |
| |
| // Ask the browser for the profiling data. We will receive the data |
| // later through a callback to addDataToSnapshot_(). |
| g_browserBridge.sendGetData(); |
| }, |
| |
| saveSnapshots_: function() { |
| var snapshots = []; |
| for (var i = 0; i < this.snapshots_.length; ++i) { |
| snapshots.push({ data: this.snapshots_[i].origData, |
| timestamp: Math.floor( |
| this.snapshots_[i].time / 1000) }); |
| } |
| |
| var dump = { |
| 'userAgent': navigator.userAgent, |
| 'version': 1, |
| 'snapshots': snapshots |
| }; |
| |
| var dumpText = JSON.stringify(dump, null, ' '); |
| var textBlob = new Blob([dumpText], |
| { type: 'octet/stream', endings: 'native' }); |
| var blobUrl = window.URL.createObjectURL(textBlob); |
| $(DOWNLOAD_ANCHOR_ID).href = blobUrl; |
| $(DOWNLOAD_ANCHOR_ID).click(); |
| }, |
| |
| loadFileChanged_: function() { |
| this.loadSnapshots_($(SNAPSHOT_FILE_LOADER_ID).files[0]); |
| }, |
| |
| loadSnapshots_: function(file) { |
| if (file) { |
| var fileReader = new FileReader(); |
| |
| fileReader.onload = this.onLoadSnapshotsFile_.bind(this, file); |
| fileReader.onerror = this.onLoadSnapshotsFileError_.bind(this, file); |
| |
| fileReader.readAsText(file); |
| } |
| }, |
| |
| onLoadSnapshotsFile_: function(file, event) { |
| try { |
| var parsed = null; |
| parsed = JSON.parse(event.target.result); |
| |
| if (parsed.version != 1) { |
| throw new Error('Unrecognized version: ' + parsed.version); |
| } |
| |
| if (parsed.snapshots.length < 1) { |
| throw new Error('File contains no data'); |
| } |
| |
| this.displayLoadedFile_(file, parsed); |
| this.hideFileLoadError_(); |
| } catch (error) { |
| this.displayFileLoadError_('File load failure: ' + error.message); |
| } |
| }, |
| |
| clearExistingSnapshots_: function() { |
| var tbody = $('snapshots-tbody'); |
| this.snapshots_ = []; |
| tbody.innerHTML = ''; |
| this.updateMergedDataSoon_(); |
| }, |
| |
| displayLoadedFile_: function(file, content) { |
| this.clearExistingSnapshots_(); |
| $(TAKE_SNAPSHOT_BUTTON_ID).disabled = true; |
| $(SAVE_SNAPSHOTS_BUTTON_ID).disabled = true; |
| |
| if (content.snapshots.length > 1) { |
| setNodeDisplay($(SNAPSHOTS_ROW), true); |
| } |
| |
| for (var i = 0; i < content.snapshots.length; ++i) { |
| var snapshot = content.snapshots[i]; |
| this.snapshots_.push({flatData: [], origData: [], |
| time: snapshot.timestamp * 1000}); |
| this.addSnapshotToList_(this.snapshots_.length - 1); |
| var snapshotData = snapshot.data; |
| for (var j = 0; j < snapshotData.length; ++j) { |
| this.addDataToSnapshot(snapshotData[j]); |
| } |
| } |
| this.redrawData_(); |
| }, |
| |
| onLoadSnapshotsFileError_: function(file, filedata) { |
| this.displayFileLoadError_('Error loading ' + file.name); |
| }, |
| |
| displayFileLoadError_: function(message) { |
| $(LOAD_ERROR_ID).textContent = message; |
| $(LOAD_ERROR_ID).hidden = false; |
| }, |
| |
| hideFileLoadError_: function() { |
| $(LOAD_ERROR_ID).textContent = ''; |
| $(LOAD_ERROR_ID).hidden = true; |
| }, |
| |
| getSnapshotCheckbox_: function(i) { |
| return $(this.getSnapshotCheckboxId_(i)); |
| }, |
| |
| getSnapshotCheckboxId_: function(i) { |
| return 'snapshotCheckbox-' + i; |
| }, |
| |
| addSnapshotToList_: function(i) { |
| var tbody = $('snapshots-tbody'); |
| |
| var tr = addNode(tbody, 'tr'); |
| |
| var id = this.getSnapshotCheckboxId_(i); |
| |
| var checkboxCell = addNode(tr, 'td'); |
| var checkbox = addNode(checkboxCell, 'input'); |
| checkbox.type = 'checkbox'; |
| checkbox.id = id; |
| checkbox.__index = i; |
| checkbox.onclick = this.onSnapshotCheckboxChanged_.bind(this); |
| |
| addNode(tr, 'td', '#' + i); |
| |
| var labelCell = addNode(tr, 'td'); |
| var l = addNode(labelCell, 'label'); |
| |
| var dateString = new Date(this.snapshots_[i].time).toLocaleString(); |
| addText(l, dateString); |
| l.htmlFor = id; |
| |
| // If we are on snapshot 0, make it the default. |
| if (i == 0) { |
| checkbox.checked = true; |
| checkbox.__time = getTimeMillis(); |
| this.updateSnapshotCheckboxStyling_(); |
| } |
| }, |
| |
| updateSnapshotCheckboxStyling_: function() { |
| for (var i = 0; i < this.snapshots_.length; ++i) { |
| var checkbox = this.getSnapshotCheckbox_(i); |
| checkbox.parentNode.parentNode.className = |
| checkbox.checked ? 'selected_snapshot' : ''; |
| } |
| }, |
| |
| onSnapshotCheckboxChanged_: function(event) { |
| // Keep track of when we clicked this box (for when we need to uncheck |
| // older boxes). |
| event.target.__time = getTimeMillis(); |
| |
| // Find all the checked boxes. Either 1 or 2 can be checked. If a third |
| // was just checked, then uncheck one of the earlier ones so we only have |
| // 2. |
| var checked = this.getSelectedSnapshotBoxes_(); |
| checked.sort(function(a, b) { return b.__time - a.__time; }); |
| if (checked.length > 2) { |
| for (var i = 2; i < checked.length; ++i) |
| checked[i].checked = false; |
| checked.length = 2; |
| } |
| |
| // We should always have at least 1 selection. Prevent the user from |
| // unselecting the final box. |
| if (checked.length == 0) |
| event.target.checked = true; |
| |
| this.updateSnapshotCheckboxStyling_(); |
| this.updateSnapshotSelectionSummaryDiv_(); |
| |
| // Recompute mergedData_ (since it is derived from selected snapshots). |
| this.updateMergedData_(); |
| }, |
| |
| fillSelectionCheckboxes_: function(parent) { |
| this.selectionCheckboxes_ = {}; |
| |
| var onChangeFunc = this.onSelectCheckboxChanged_.bind(this); |
| |
| for (var i = 0; i < ALL_TABLE_COLUMNS.length; ++i) { |
| var key = ALL_TABLE_COLUMNS[i]; |
| var checkbox = addLabeledCheckbox(parent, getNameForKey(key)); |
| checkbox.checked = true; |
| checkbox.onchange = onChangeFunc; |
| addText(parent, ' '); |
| this.selectionCheckboxes_[key] = checkbox; |
| } |
| |
| for (var i = 0; i < INITIALLY_HIDDEN_KEYS.length; ++i) { |
| this.selectionCheckboxes_[INITIALLY_HIDDEN_KEYS[i]].checked = false; |
| } |
| }, |
| |
| getSelectionColumns_: function() { |
| return getKeysForCheckedBoxes(this.selectionCheckboxes_); |
| }, |
| |
| getMergeColumns_: function() { |
| return getKeysForCheckedBoxes(this.mergeCheckboxes_); |
| }, |
| |
| shouldMergeSimilarThreads_: function() { |
| return $(MERGE_SIMILAR_THREADS_CHECKBOX_ID).checked; |
| }, |
| |
| fillMergeCheckboxes_: function(parent) { |
| this.mergeCheckboxes_ = {}; |
| |
| var onChangeFunc = this.onMergeCheckboxChanged_.bind(this); |
| |
| for (var i = 0; i < MERGEABLE_KEYS.length; ++i) { |
| var key = MERGEABLE_KEYS[i]; |
| var checkbox = addLabeledCheckbox(parent, getNameForKey(key)); |
| checkbox.onchange = onChangeFunc; |
| addText(parent, ' '); |
| this.mergeCheckboxes_[key] = checkbox; |
| } |
| |
| for (var i = 0; i < INITIALLY_MERGED_KEYS.length; ++i) { |
| this.mergeCheckboxes_[INITIALLY_MERGED_KEYS[i]].checked = true; |
| } |
| }, |
| |
| fillGroupingDropdowns_: function() { |
| var parent = $(GROUP_BY_CONTAINER_ID); |
| parent.innerHTML = ''; |
| |
| for (var i = 0; i <= this.currentGroupingKeys_.length; ++i) { |
| // Add a dropdown. |
| var select = addNode(parent, 'select'); |
| select.onchange = this.onChangedGrouping_.bind(this, select, i); |
| |
| addOptionsForGroupingSelect(select); |
| |
| if (i < this.currentGroupingKeys_.length) { |
| var key = this.currentGroupingKeys_[i]; |
| setSelectedOptionByValue(select, key); |
| } |
| } |
| }, |
| |
| fillSortingDropdowns_: function() { |
| var parent = $(SORT_BY_CONTAINER_ID); |
| parent.innerHTML = ''; |
| |
| for (var i = 0; i <= this.currentSortKeys_.length; ++i) { |
| // Add a dropdown. |
| var select = addNode(parent, 'select'); |
| select.onchange = this.onChangedSorting_.bind(this, select, i); |
| |
| addOptionsForSortingSelect(select); |
| |
| if (i < this.currentSortKeys_.length) { |
| var key = this.currentSortKeys_[i]; |
| setSelectedOptionByValue(select, key); |
| } |
| } |
| }, |
| |
| onChangedGrouping_: function(select, i) { |
| updateKeyListFromDropdown(this.currentGroupingKeys_, i, select); |
| this.fillGroupingDropdowns_(); |
| this.updateGroupedData_(); |
| }, |
| |
| onChangedSorting_: function(select, i) { |
| updateKeyListFromDropdown(this.currentSortKeys_, i, select); |
| this.fillSortingDropdowns_(); |
| this.sortGroupedData_(); |
| }, |
| |
| onSelectCheckboxChanged_: function() { |
| this.redrawData_(); |
| }, |
| |
| onMergeCheckboxChanged_: function() { |
| this.updateMergedData_(); |
| }, |
| |
| onMergeSimilarThreadsCheckboxChanged_: function() { |
| this.updateMergedData_(); |
| }, |
| |
| onChangedFilter_: function() { |
| this.updateFilteredData_(); |
| }, |
| |
| /** |
| * When left-clicking a column, change the primary sort order to that |
| * column. If we were already sorted on that column then reverse the order. |
| * |
| * When alt-clicking, add a secondary sort column. Similarly, if |
| * alt-clicking a column which was already being sorted on, reverse its |
| * order. |
| */ |
| onClickColumn_: function(key, event) { |
| // If this property wants to start off in descending order rather then |
| // ascending, flip it. |
| if (KEY_PROPERTIES[key].sortDescending) |
| key = reverseSortKey(key); |
| |
| // Scan through our sort order and see if we are already sorted on this |
| // key. If so, reverse that sort ordering. |
| var foundIndex = -1; |
| for (var i = 0; i < this.currentSortKeys_.length; ++i) { |
| var curKey = this.currentSortKeys_[i]; |
| if (sortKeysMatch(curKey, key)) { |
| this.currentSortKeys_[i] = reverseSortKey(curKey); |
| foundIndex = i; |
| break; |
| } |
| } |
| |
| if (event.altKey) { |
| if (foundIndex == -1) { |
| // If we weren't already sorted on the column that was alt-clicked, |
| // then add it to our sort. |
| this.currentSortKeys_.push(key); |
| } |
| } else { |
| if (foundIndex != 0 || |
| !sortKeysMatch(this.currentSortKeys_[foundIndex], key)) { |
| // If the column we left-clicked wasn't already our primary column, |
| // make it so. |
| this.currentSortKeys_ = [key]; |
| } else { |
| // If the column we left-clicked was already our primary column (and |
| // we just reversed it), remove any secondary sorts. |
| this.currentSortKeys_.length = 1; |
| } |
| } |
| |
| this.fillSortingDropdowns_(); |
| this.sortGroupedData_(); |
| }, |
| |
| getSortingFunction_: function() { |
| var sortKeys = this.currentSortKeys_.slice(0); |
| |
| // Eliminate the empty string keys (which means they were unspecified). |
| deleteValuesFromArray(sortKeys, ['']); |
| |
| // If no sort is specified, use our default sort. |
| if (sortKeys.length == 0) |
| sortKeys = [DEFAULT_SORT_KEYS]; |
| |
| return function(a, b) { |
| for (var i = 0; i < sortKeys.length; ++i) { |
| var key = Math.abs(sortKeys[i]); |
| var factor = sortKeys[i] < 0 ? -1 : 1; |
| |
| var propA = a[key]; |
| var propB = b[key]; |
| |
| var comparison = compareValuesForKey(key, propA, propB); |
| comparison *= factor; // Possibly reverse the ordering. |
| |
| if (comparison != 0) |
| return comparison; |
| } |
| |
| // Tie breaker. |
| return simpleCompare(JSON.stringify(a), JSON.stringify(b)); |
| }; |
| }, |
| |
| getGroupSortingFunction_: function() { |
| return function(a, b) { |
| var groupKey1 = JSON.parse(a); |
| var groupKey2 = JSON.parse(b); |
| |
| for (var i = 0; i < groupKey1.length; ++i) { |
| var comparison = compareValuesForKey( |
| groupKey1[i].key, |
| groupKey1[i].value, |
| groupKey2[i].value); |
| |
| if (comparison != 0) |
| return comparison; |
| } |
| |
| // Tie breaker. |
| return simpleCompare(a, b); |
| }; |
| }, |
| |
| getFilterFunction_: function() { |
| var searchStr = $(FILTER_SEARCH_ID).value; |
| |
| // Normalize the search expression. |
| searchStr = trimWhitespace(searchStr); |
| searchStr = searchStr.toLowerCase(); |
| |
| return function(x) { |
| // Match everything when there was no filter. |
| if (searchStr == '') |
| return true; |
| |
| // Treat the search text as a LOWERCASE substring search. |
| for (var k = BEGIN_KEY; k < END_KEY; ++k) { |
| var propertyText = getTextValueForProperty(k, x[k]); |
| if (propertyText.toLowerCase().indexOf(searchStr) != -1) |
| return true; |
| } |
| |
| return false; |
| }; |
| }, |
| |
| getGroupingFunction_: function() { |
| var groupings = this.currentGroupingKeys_.slice(0); |
| |
| // Eliminate the empty string groupings (which means they were |
| // unspecified). |
| deleteValuesFromArray(groupings, ['']); |
| |
| // Eliminate duplicate primary/secondary group by directives, since they |
| // are redundant. |
| deleteDuplicateStringsFromArray(groupings); |
| |
| return function(e) { |
| var groupKey = []; |
| |
| for (var i = 0; i < groupings.length; ++i) { |
| var entry = {key: groupings[i], |
| value: e[groupings[i]]}; |
| groupKey.push(entry); |
| } |
| |
| return JSON.stringify(groupKey); |
| }; |
| }, |
| }; |
| |
| return MainView; |
| })(); |