+2015-12-15 Ryosuke Niwa <rniwa@webkit.org>
+
+ Add v3 UI to perf dashboard
+ https://bugs.webkit.org/show_bug.cgi?id=152311
+
+ Reviewed by Chris Dumez.
+
+ Add the third iteration of the perf dashboard UI. UI for viewing and modifying analysis tasks is coming soon.
+ The v3 UI is focused on speed, and removes all third-party script dependencies including jQuery, d3, and Ember.
+ Both the DOM-based UI and graphing are implemented manually.
+
+
+ The entire app is structured using new component library implemented in components/base.js. Each component is
+ an instance of a subclass of ComponentBase which owns a single DOM element. Each subclass may supply static
+ methods named htmlTemplate and cssTemplate as the template for a component instance. ComponentBase automatically
+ clones the templates inside the associated element (or its shadow root on the supported browsers). Each subclass
+ must supply a method called "render()" which constructs and updates the DOM as needed.
+
+ There is a special component called Page, which represents an entire page. Each Page is opened by PageRouter's
+ "route()" function. Each subclass of Page supplies "open()" for initialization and "updateFromSerializedState()"
+ for a hash URL transition.
+
+
+ The key feature of the v3 UI is the split of time series into chunks called clusters (see r194120). On an internal
+ instance of the dashboard, the v2 UI downloads 27MB of data whereas the same page loads only 3MB of data in the v3.
+ The key logic for fetching time series in chunks is implemented by MeasurementSet in /v3/models/measurement-set.js.
+ We first fetch the cached primary cluster (the cluster that contains the newest data) at:
+ /data/measurement-set-<platform-id>-<metric-id>.json
+
+ If that's outdated according to lastModified in manifest.json, then we immediately re-fetch the primary cluster at:
+ /api/measurement-set/?platform=<platform-id>&metric=<metric-id>
+
+ Once the up-to-date primary cluster is fetched, we fetch all "secondary" clusters. For each cluster being fetched,
+ including the primary, we invoke registered callbacks.
+
+
+ In addition, the v3 UI reduces the initial page load time by loading a single bundled JS file generated by
+ tools/bundle-v3-scripts.py. index.html has a fallback to load all 44 JS files individually during development.
+
+ * public/api/analysis-tasks.php:
+ (fetch_and_push_bugs_to_tasks): Added the code to fetch start and end run times. This is necessary in V3 UI
+ because no longer fetch the entire time series. See r194120 for the new measurement set JSON API.
+ (format_task): Compute the category of an analysis task based on "result" value. This will be re-vamped once
+ I add the UI for the analysis task page in v3.
+
+ * public/include/json-header.php:
+ (require_format): CamelCase the name.
+ (require_match_one_of_values): Ditto.
+ (validate_arguments): Renamed from require_existence_of and used in measurement-set.php landed in r194120.
+
+ * public/v3: Added.
+ * public/v3/components: Added.
+
+ * public/v3/components/base.js: Added.
+ (ComponentBase): The base component class.
+ (ComponentBase.prototype.element): Returns the DOM element associated with the DOM element.
+ (ComponentBase.prototype.content): Returns the shadow root if one exists and the associated element otherwise.
+ (ComponentBase.prototype.render): To be implemented by a subclass.
+ (ComponentBase.prototype.renderReplace): A helper function to "render" DOM contents.
+ (ComponentBase.prototype._constructShadowTree): Called inside the constructor to instantiate the templates.
+ (ComponentBase.prototype._recursivelyReplaceUnknownElementsByComponents): Instantiates components referred by
+ its element name inside the instantiated content.
+ (ComponentBase.isElementInViewport): A helper function. Returns true if the element is in the viewport and it has
+ non-zero width and height.
+ (ComponentBase.defineElement): Defines a custom element that can be automatically instantiated from htmlTemplate.
+ (ComponentBase.createElement): A helper function to create DOM tree to be used in "render()" method.
+ (ComponentBase._addContentToElement): A helper for "createElement".
+ (ComponentBase.createLink): A helper function to create a hyperlink or another clickable element (via callback).
+ (ComponentBase.createActionHandler): A helper function to create an event listener that prevents the default action
+ and stops the event propagation.
+
+ * public/v3/components/button-base.js: Added.
+
+ * public/v3/components/chart-status-view.js: Added.
+ (ChartStatusView): A component that reports the current status of time-series-chart. It's subclasses by
+ ChartPaneStatusView to provide additional information in the charts page's panes.
+
+ * public/v3/components/close-button.js: Added.
+ (CloseButton):
+ * public/v3/components/commit-log-viewer.js: Added.
+ (CommitLogViewer): A component that lists commit revisions along with commit messages for a range of data points.
+
+ * public/v3/components/interactive-time-series-chart.js: Added.
+ (InteractiveTimeSeriesChart): A subclass of InteractiveTimeSeriesChart with interactivity (selection & indicator).
+ Selection and indicator are mutually exclusive.
+
+ * public/v3/components/pane-selector.js: Added.
+ (PaneSelector): A component for selecting (platform, metric) pair to add in the charts page.
+
+ * public/v3/components/spinner-icon.js: Added.
+
+ * public/v3/components/time-series-chart.js: Added.
+ (TimeSeriesChart): A canvas-based chart component without interactivity. It takes a source list and options as
+ the constructor arguments. A source list is a list of measurement sets (measurement-set.js) with drawing options.
+ This component fetches data via MeasurementSet.fetchBetween inside TimeSeriesChart.prototype.setDomain and
+ progressively updates the charts as more data arrives. The canvas is updated on animation frame via rAF and all
+ layout and rendering metrics are lazily computed in _layout. In addition, this component samples data before
+ rendering the chart when there are more data points per pixel in _ensureSampledTimeSeries.
+
+ * public/v3/index.html: Added. Loads bundled-scripts.js if it exists, or individual script files otherwise.
+
+ * public/v3/instrumentation.js: Added. This class is used to gather runtime statistics of v3 UI. (It measures
+ the performance of the perf dashboard UI).
+
+ * public/v3/main.js: Added. Bootstraps the app.
+ (main):
+ (fetchManifest):
+
+ * public/v3/models: Added.
+ * public/v3/models/analysis-task.js: Added.
+ * public/v3/models/bug-tracker.js: Added.
+ * public/v3/models/bug.js: Added.
+ * public/v3/models/builder.js: Added.
+ * public/v3/models/commit-log.js: Added.
+ * public/v3/models/data-model.js: Added.
+ (DataModelObject): The base class for various data objects that correspond to database tables. It supplies static
+ hash map to find entries by id as well as other keys.
+ (LabeledObject): A subclass of DataModelObject with the capability to find an object via its name.
+
+ * public/v3/models/measurement-cluster.js: Added.
+ (MeasurementCluster): Represents a single cluster or a chunk of data in a measurement set.
+
+ * public/v3/models/measurement-set.js: Added.
+ (MeasurementSet): Represents a measurement set.
+ (MeasurementSet.findSet): Returns the singleton set given (metric, platform). We use singleton to avoid issuing
+ multiple HTTP requests for the same JSON when there are multiple TimeSeriesChart that show the same graph (e.g. on
+ charts page with overview and main charts).
+ (MeasurementSet.prototype.findClusters): Finds the list of clusters to fetch in a given time range.
+ (MeasurementSet.prototype.fetchBetween): Fetch clusters for a given time range and calls callback whenever new data
+ arrives. The number of callbacks depends on the how many clusters need to be newly fetched.
+ (MeasurementSet.prototype._fetchSecondaryClusters): Fetches non-primary (non-latest) clusters.
+ (MeasurementSet.prototype._fetch): Issues a HTTP request to fetch a cluster.
+ (MeasurementSet.prototype._didFetchJSON): Called when a cluster is fetched.
+ (MeasurementSet.prototype._failedToFetchJSON): Called when the fetching of a cluster has failed.
+ (MeasurementSet.prototype._invokeCallbacks): Invokes callbacks upon an approval of a new cluster.
+ (MeasurementSet.prototype._addFetchedCluster): Adds the newly fetched cluster in the order.
+ (MeasurementSet.prototype.fetchedTimeSeries): Returns a time series that contains data from all clusters that have
+ been fetched.
+ (TimeSeries.prototype.findById): Additions to TimeSeries defined in /v2/data.js.
+ (TimeSeries.prototype.dataBetweenPoints): Ditto.
+ (TimeSeries.prototype.firstPoint): Ditto.
+
+ * public/v3/models/metric.js: Added.
+ * public/v3/models/platform.js: Added.
+ * public/v3/models/repository.js: Added.
+ * public/v3/models/test.js: Added.
+
+ * public/v3/pages: Added.
+ * public/v3/pages/analysis-category-page.js: Added. The "Analysis" page that lists the analysis tasks.
+ * public/v3/pages/analysis-category-toolbar.js: Added. The toolbar to filter analysis tasks based on its category
+ (unconfirmed, bisecting, identified, closed) and a keyword.
+
+ * public/v3/pages/analysis-task-page.js: Added. Not implemented yet. It just has the hyperlink to the v2 UI.
+
+ * public/v3/pages/chart-pane-status-view.js: Added.
+ (ChartPaneStatusView): A subclass of ChartStatusView used in the charts page. In addition to the current value,
+ comparison to baseline/target, it shows the list of repository revisions (e.g. WebKit revision, OS version).
+
+ * public/v3/pages/chart-pane.js: Added.
+ (ChartPane): A component a pane in the charts page. Each pane has the overview chart and the main chart. The zooming
+ is synced across all panes in the charts page.
+
+ * public/v3/pages/charts-page.js: Added. Charts page.
+ * public/v3/pages/charts-toolbar.js: Added. The toolbar to set the number of days to show. This affects the overview
+ chart's domain in each pane.
+
+ * public/v3/pages/create-analysis-task-page.js: Added.
+ (CreateAnalysisTaskPage): A page that gets shown momentarily while creating a new analysis task.
+
+ * public/v3/pages/dashboard-page.js: Added. A dashboard page.
+ * public/v3/pages/dashboard-toolbar.js: Added. Its toolbar with buttons to select the number of days to show.
+ * public/v3/pages/domain-control-toolbar.js: Added. An abstract superclass of charts and dashboard toolbars.
+
+ * public/v3/pages/heading.js: Added. A component for displaying the header and toolbar, if exists, on each page.
+ * public/v3/pages/page-router.js: Added. This class is responsible for updating the URL hashes as well as opening
+ and updating each page when the hash changes (via back/forward navigation).
+ * public/v3/pages/page-with-charts.js: Added. An abstract subclass of page used by dashboards and charts page.
+ Supplies helper functions for creating TimeSeriesChart options.
+ * public/v3/pages/page-with-heading.js: Added. An abstract subclass of page that uses the heading component.
+ * public/v3/pages/page.js: Added. The Page component.
+ * public/v3/pages/toolbar.js: Added. An abstract toolbar component.
+
+ * public/v3/remote.js: Added.
+ (getJSON): Fetches JSON from the remote server.
+ (getJSONWithStatus): Ditto. Rejects the response if the status is not "OK".
+ (PrivilegedAPI.sendRequest): Posts a HTTP request to a privileged API in /privileged-api/.
+ (PrivilegedAPI.requestCSRFToken): Creates a new CSRF token to request a privileged API post.
+
+ * tools/bundle-v3-scripts.py: Added.
+ (main): Bundles js files together and minifies them by jsmin.py for the v3 UI. Without this script, we're forced to
+ download 44 JS files or making each JS file contain multiple classes.
+
+ * tools/jsmin.py: Copied from WebInspector / JavaScriptCore code.
+
2015-12-15 Ryosuke Niwa <rniwa@webkit.org>
Fix v2 UI after r194093.
if (count($path) > 1)
exit_with_error('InvalidRequest');
- if (count($path) > 0 && $path[0]) {
- $task_id = intval($path[0]);
+ $task_id = count($path) > 0 && $path[0] ? $path[0] : array_get($_GET, 'id');
+
+ if ($task_id) {
+ $task_id = intval($task_id);
$task = $db->select_first_row('analysis_tasks', 'task', array('id' => $task_id));
if (!$task)
exit_with_error('TaskNotFound', array('id' => $task_id));
$task['finishedBuildRequestCount'] = $build_count['finished'];
}
+ $run_ids = array();
+ $task_by_run = array();
+ foreach ($tasks as &$task) {
+ if ($task['startRun']) {
+ array_push($run_ids, $task['startRun']);
+ $task_by_run[$task['startRun']] = &$task;
+ }
+ if ($task['endRun']) {
+ array_push($run_ids, $task['endRun']);
+ $task_by_run[$task['endRun']] = &$task;
+ }
+ }
+
+ // FIXME: This query is quite expensive. We may need to store this directly in analysis_tasks table instead.
+ $build_revision_times = $db->query_and_fetch_all('SELECT run_id, build_time, max(commit_time) AS revision_time
+ FROM builds
+ LEFT OUTER JOIN build_commits ON commit_build = build_id
+ LEFT OUTER JOIN commits ON build_commit = commit_id, test_runs
+ WHERE run_build = build_id AND run_id = ANY($1) GROUP BY build_id, run_id',
+ array('{' . implode(', ', $run_ids) . '}'));
+ foreach ($build_revision_times as &$row) {
+ $time = $row['revision_time'] or $row['build_time'];
+ $id = $row['run_id'];
+ if ($task_by_run[$id]['startRun'] == $id)
+ $task_by_run[$id]['startRunTime'] = Database::to_js_time($time);
+ if ($task_by_run[$id]['endRun'] == $id)
+ $task_by_run[$id]['endRunTime'] = Database::to_js_time($time);
+ }
+
return $bugs;
}
'metric' => $task_row['task_metric'],
'startRun' => $task_row['task_start_run'],
'endRun' => $task_row['task_end_run'],
+ 'category' => $task_row['task_result'] ? 'bisecting' : 'unconfirmed',
'result' => $task_row['task_result'],
'needed' => $task_row['task_needed'] ? Database::is_true($task_row['task_needed']) : null,
'bugs' => array(),
function require_format($name, $value, $pattern) {
if (!preg_match($pattern, $value))
- exit_with_error('Invalid' . $name, array('value' => $value));
+ exit_with_error('Invalid' . camel_case_words_separated_by_underscore($name), array('value' => $value));
}
function require_match_one_of_values($name, $value, $valid_values) {
if (!in_array($value, $valid_values))
- exit_with_error('Invalid' . $name, array('value' => $value));
+ exit_with_error('Invalid' . camel_case_words_separated_by_underscore($name), array('value' => $value));
+}
+
+function validate_arguments($array, $list_of_arguments) {
+ $result = array();
+ foreach ($list_of_arguments as $name => $pattern) {
+ $value = array_get($array, $name, '');
+ if ($pattern == 'int') {
+ require_format($name, $value, '/^\d+$/');
+ $value = intval($value);
+ } else if ($pattern == 'int?') {
+ require_format($name, $value, '/^\d*$/');
+ $value = $value ? intval($value) : null;
+ } else
+ require_format($name, $value, $pattern);
+ $result[$name] = $value;
+ }
+ return $result;
}
function require_existence_of($array, $list_of_arguments, $prefix = '') {
--- /dev/null
+
+// FIXME: ComponentBase should inherit from HTMLElement when custom elements API is available.
+class ComponentBase {
+ constructor(name)
+ {
+ this._element = document.createElement(name);
+ this._element.component = (function () { return this; }).bind(this);
+ this._shadow = this._constructShadowTree();
+ }
+
+ element() { return this._element; }
+ content() { return this._shadow; }
+ render() { }
+
+ renderReplace(element, content)
+ {
+ element.innerHTML = '';
+ if (content)
+ ComponentBase._addContentToElement(element, content);
+ }
+
+ _constructShadowTree()
+ {
+ var newTarget = this.__proto__.constructor;
+
+ var htmlTemplate = newTarget['htmlTemplate'];
+ var cssTemplate = newTarget['cssTemplate'];
+
+ if (!htmlTemplate && !cssTemplate)
+ return null;
+
+ var shadow = null;
+ if ('attachShadow' in Element.prototype)
+ shadow = this._element.attachShadow({mode: 'closed'});
+ else if ('createShadowRoot' in Element.prototype) // Legacy Chromium API.
+ shadow = this._element.createShadowRoot();
+ else
+ shadow = this._element;
+
+ if (htmlTemplate) {
+ var template = document.createElement('template');
+ template.innerHTML = htmlTemplate();
+ shadow.appendChild(template.content.cloneNode(true));
+ this._recursivelyReplaceUnknownElementsByComponents(shadow);
+ }
+
+ if (cssTemplate) {
+ var style = document.createElement('style');
+ style.textContent = cssTemplate();
+ shadow.appendChild(style);
+ }
+
+ return shadow;
+ }
+
+ _recursivelyReplaceUnknownElementsByComponents(parent)
+ {
+ if (!ComponentBase._map)
+ return;
+
+ var nextSibling;
+ for (var child = parent.firstChild; child; child = child.nextSibling) {
+ if (child instanceof HTMLUnknownElement || child instanceof HTMLElement) {
+ var elementInterface = ComponentBase._map[child.localName];
+ if (elementInterface) {
+ var component = new elementInterface();
+ var newChild = component.element();
+ parent.replaceChild(newChild, child);
+ child = newChild;
+ }
+ }
+ this._recursivelyReplaceUnknownElementsByComponents(child);
+ }
+ }
+
+ static isElementInViewport(element)
+ {
+ var viewportHeight = window.innerHeight;
+ var boundingRect = element.getBoundingClientRect();
+ if (viewportHeight < boundingRect.top || boundingRect.bottom < 0
+ || !boundingRect.width || !boundingRect.height)
+ return false;
+ return true;
+ }
+
+ static defineElement(name, elementInterface)
+ {
+ if (!ComponentBase._map)
+ ComponentBase._map = {};
+ ComponentBase._map[name] = elementInterface;
+ }
+
+ static createElement(name, attributes, content)
+ {
+ var element = document.createElement(name);
+ if (!content && (attributes instanceof Array || attributes instanceof Node
+ || attributes instanceof ComponentBase || typeof(attributes) != 'object')) {
+ content = attributes;
+ attributes = {};
+ }
+
+ if (attributes) {
+ for (var name in attributes) {
+ if (name.startsWith('on'))
+ element.addEventListener(name.substring(2), attributes[name]);
+ else
+ element.setAttribute(name, attributes[name]);
+ }
+ }
+
+ if (content)
+ ComponentBase._addContentToElement(element, content);
+
+ return element;
+ }
+
+ static _addContentToElement(element, content)
+ {
+ if (content instanceof Array) {
+ for (var nestedChild of content)
+ this._addContentToElement(element, nestedChild);
+ } else if (content instanceof Node)
+ element.appendChild(content);
+ else if (content instanceof ComponentBase)
+ element.appendChild(content.element());
+ else
+ element.appendChild(document.createTextNode(content));
+ }
+
+ static createLink(content, titleOrCallback, callback, isExternal)
+ {
+ var title = titleOrCallback;
+ if (callback === undefined) {
+ title = content;
+ callback = titleOrCallback;
+ }
+
+ var attributes = {
+ href: '#',
+ title: title,
+ };
+
+ if (typeof(callback) === 'string')
+ attributes['href'] = callback;
+ else
+ attributes['onclick'] = ComponentBase.createActionHandler(callback);
+
+ if (isExternal)
+ attributes['target'] = '_blank';
+ return ComponentBase.createElement('a', attributes, content);
+ }
+
+ static createActionHandler(callback)
+ {
+ return function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ callback.call(this, event);
+ };
+ }
+}
+
+ComponentBase.css = Symbol();
+ComponentBase.html = Symbol();
+ComponentBase.map = {};
--- /dev/null
+
+class ButtonBase extends ComponentBase {
+ constructor(name)
+ {
+ super(name);
+ }
+
+ setCallback(callback)
+ {
+ this.content().querySelector('a').addEventListener('click', ComponentBase.createActionHandler(callback));
+ }
+
+ static cssTemplate()
+ {
+ return `
+ .button {
+ vertical-align: bottom;
+ display: inline-block;
+ width: 1rem;
+ height: 1rem;
+ opacity: 0.3;
+ }
+
+ .button:hover {
+ opacity: 0.6;
+ }
+ `;
+ }
+
+}
--- /dev/null
+
+class ChartStatusView extends ComponentBase {
+
+ constructor(metric, chart)
+ {
+ super('chart-status');
+ this._metric = metric;
+ this._chart = chart;
+
+ this._usedSelection = null;
+ this._usedCurrentPoint = null;
+ this._usedPreviousPoint = null;
+
+ this._currentValue = null;
+ this._comparisonClass = null;
+ this._comparisonLabel = null;
+
+ this._renderedCurrentValue = null;
+ this._renderedComparisonClass = null;
+ this._renderedComparisonLabel = null;
+ }
+
+ render()
+ {
+ this.updateStatusIfNeeded();
+
+ if (this._renderedCurrentValue == this._currentValue
+ && this._renderedComparisonClass == this._comparisonClass
+ && this._renderedComparisonLabel == this._comparisonLabel)
+ return;
+
+ this._renderedCurrentValue = this._currentValue;
+ this._renderedComparisonClass = this._comparisonClass;
+ this._renderedComparisonLabel = this._comparisonLabel;
+
+ this.content().querySelector('.chart-status-current-value').textContent = this._currentValue || '';
+ var comparison = this.content().querySelector('.chart-status-comparison');
+ comparison.className = 'chart-status-comparison ' + (this._comparisonClass || '');
+ comparison.textContent = this._comparisonLabel;
+ }
+
+ updateStatusIfNeeded()
+ {
+ var currentPoint;
+ var previousPoint;
+
+ if (this._chart instanceof InteractiveTimeSeriesChart) {
+ currentPoint = this._chart.currentPoint();
+
+ var selection = this._chart.currentSelection();
+ if (selection && this._usedSelection == selection)
+ return false;
+
+ if (selection) {
+ var data = this._chart.sampledDataBetween('current', selection[0], selection[1]);
+ if (!data)
+ return false;
+ this._usedSelection = selection;
+
+ if (data && data.length > 1) {
+ currentPoint = data[data.length - 1];
+ previousPoint = data[0];
+ }
+ } else if (currentPoint)
+ previousPoint = currentPoint.series.previousPoint(currentPoint);
+ } else {
+ var data = this._chart.sampledTimeSeriesData('current');
+ if (!data)
+ return false;
+ if (data.length)
+ currentPoint = data[data.length - 1];
+ }
+
+ if (currentPoint == this._usedCurrentPoint && previousPoint == this._usedPreviousPoint)
+ return false;
+
+ this._usedCurrentPoint = currentPoint;
+ this._usedPreviousPoint = previousPoint;
+
+ this.computeChartStatusLabels(currentPoint, previousPoint);
+
+ return true;
+ }
+
+ computeChartStatusLabels(currentPoint, previousPoint)
+ {
+ var status = currentPoint ? this._computeChartStatus(this._metric, this._chart, currentPoint, previousPoint) : null;
+ if (status) {
+ this._currentValue = status.currentValue;
+ if (previousPoint)
+ this._currentValue += ` (${status.valueDelta} / ${status.relativeDelta})`;
+ this._comparisonClass = status.className;
+ this._comparisonLabel = status.label;
+ } else {
+ this._currentValue = null;
+ this._comparisonClass = null;
+ this._comparisonLabel = null;
+ }
+ }
+
+ _computeChartStatus(metric, chart, currentPoint, previousPoint)
+ {
+ var currentTimeSeriesData = chart.sampledTimeSeriesData('current');
+ var baselineTimeSeriesData = chart.sampledTimeSeriesData('baseline');
+ var targetTimeSeriesData = chart.sampledTimeSeriesData('target');
+ if (!currentTimeSeriesData)
+ return null;
+
+ var formatter = metric.makeFormatter(3);
+ var deltaFormatter = metric.makeFormatter(2, true);
+
+ if (!currentPoint)
+ currentPoint = currentTimeSeriesData[currentTimeSeriesData.length - 1];
+
+ var diffFromBaseline = this._relativeDifferenceToLaterPointInTimeSeries(currentPoint, baselineTimeSeriesData);
+ var diffFromTarget = this._relativeDifferenceToLaterPointInTimeSeries(currentPoint, targetTimeSeriesData);
+
+ var label = '';
+ var className = '';
+
+ function labelForDiff(diff, name, comparison)
+ {
+ return Math.abs(diff * 100).toFixed(1) + '% ' + comparison + ' ' + name;
+ }
+
+ var smallerIsBetter = metric.isSmallerBetter();
+ if (diffFromBaseline !== undefined && diffFromTarget !== undefined) {
+ if (diffFromBaseline > 0 == smallerIsBetter) {
+ label = labelForDiff(diffFromBaseline, 'baseline', 'worse than');
+ className = 'worse';
+ } else if (diffFromTarget < 0 == smallerIsBetter) {
+ label = labelForDiff(diffFromBaseline, 'target', 'better than');
+ className = 'better';
+ } else
+ label = labelForDiff(diffFromTarget, 'target', 'until');
+ } else if (diffFromBaseline !== undefined) {
+ className = diffFromBaseline > 0 == smallerIsBetter ? 'worse' : 'better';
+ label = labelForDiff(diffFromBaseline, 'baseline', className + ' than');
+ } else if (diffFromTarget !== undefined) {
+ className = diffFromTarget < 0 == smallerIsBetter ? 'better' : 'worse';
+ label = labelForDiff(diffFromTarget, 'target', className + ' than');
+ }
+
+ var valueDelta = null;
+ var relativeDelta = null;
+ if (previousPoint) {
+ valueDelta = deltaFormatter(currentPoint.value - previousPoint.value);
+ var relativeDelta = (currentPoint.value - previousPoint.value) / previousPoint.value;
+ relativeDelta = (relativeDelta * 100).toFixed(0) + '%';
+ }
+ return {
+ className: className,
+ label: label,
+ currentValue: formatter(currentPoint.value),
+ valueDelta: valueDelta,
+ relativeDelta: relativeDelta,
+ };
+ }
+
+ _relativeDifferenceToLaterPointInTimeSeries(currentPoint, timeSeriesData)
+ {
+ if (!currentPoint || !timeSeriesData || !timeSeriesData.length)
+ return undefined;
+
+ // FIXME: We shouldn't be using the first data point to access the time series object.
+ var referencePoint = timeSeriesData[0].series.findPointAfterTime(currentPoint.time);
+ if (!referencePoint)
+ return undefined;
+
+ return (currentPoint.value - referencePoint.value) / referencePoint.value;
+ }
+
+
+ static htmlTemplate()
+ {
+ return `
+ <div>
+ <span class="chart-status-current-value"></span>
+ <span class="chart-status-comparison"></span>
+ </div>`;
+ }
+
+ static cssTemplate()
+ {
+ return `
+ .chart-status-current-value {
+ padding-right: 0.5rem;
+ }
+
+ .chart-status-comparison.worse {
+ color: #c33;
+ }
+
+ .chart-status-comparison.better {
+ color: #33c;
+ }`;
+ }
+}
\ No newline at end of file
--- /dev/null
+
+class CloseButton extends ButtonBase {
+ constructor()
+ {
+ super('close-button');
+ }
+
+ static htmlTemplate()
+ {
+ return `
+ <a class="button" href="#"><svg viewBox="0 0 100 100">
+ <g stroke="black" stroke-width="10">
+ <circle cx="50" cy="50" r="45" fill="transparent"/>
+ <polygon points="30,30 70,70" />
+ <polygon points="30,70 70,30" />
+ </g>
+ </svg></a>`;
+ }
+}
+
+ComponentBase.defineElement('close-button', CloseButton);
--- /dev/null
+
+class CommitLogViewer extends ComponentBase {
+
+ constructor()
+ {
+ super('commit-log-viewer');
+ this._repository = null;
+ this._fetchingPromise = null;
+ this._commits = null;
+ }
+
+ currentRepository() { return this._repository; }
+
+ view(repository, from, to)
+ {
+ this._commits = null;
+
+ if (!repository) {
+ this._fetchingPromise = null;
+ this._repository = null;
+ return Promise.resolve(null);
+ }
+
+ if (!to) {
+ this._fetchingPromise = null;
+ return Promise.resolve(null);
+ }
+
+ var promise = CommitLog.fetchBetweenRevisions(repository, from || to, to);
+
+ this._fetchingPromise = promise;
+
+ var self = this;
+ var spinnerTimer = setTimeout(function () {
+ self.render();
+ }, 300);
+
+ this._fetchingPromise.then(function (commits) {
+ clearTimeout(spinnerTimer);
+ if (self._fetchingPromise != promise)
+ return;
+ self._repository = repository;
+ self._fetchingPromise = null;
+ self._commits = commits;
+ });
+
+ return this._fetchingPromise;
+ }
+
+ render()
+ {
+ if (this._repository)
+ this.content().querySelector('caption').textContent = this._repository.name();
+
+ var element = ComponentBase.createElement;
+ var link = ComponentBase.createLink;
+
+ this.renderReplace(this.content().querySelector('tbody'), (this._commits || []).map(function (commit) {
+ var label = commit.label();
+ var url = commit.url();
+ return element('tr', [
+ element('th', [element('h4', {class: 'revision'}, url ? link(label, commit.title(), url) : label), commit.author() || '']),
+ element('td', commit.message() ? commit.message().substring(0, 80) : '')]);
+ }));
+
+ this.content().querySelector('.commits-viewer-spinner').style.display = this._fetchingPromise ? null : 'none';
+ }
+
+ static htmlTemplate()
+ {
+ return `
+ <div class="commits-viewer-container">
+ <div class="commits-viewer-spinner"><spinner-icon></spinner-icon></div>
+ <table class="commits-viewer-table">
+ <caption></caption>
+ <tbody>
+ </tbody>
+ </table>
+ </div>
+`;
+ }
+
+ static cssTemplate()
+ {
+ return `
+ .commits-viewer-container {
+ width: 100%;
+ height: calc(100% - 2px);
+ overflow-y: scroll;
+ }
+
+ .commits-viewer-table {
+ width: 100%;
+ }
+
+ .commits-viewer-table caption {
+ font-weight: inherit;
+ font-size: 1rem;
+ text-align: center;
+ padding: 0.2rem;
+ }
+
+ .commits-viewer-table {
+ border-collapse: collapse;
+ border-bottom: solid 1px #ccc;
+ }
+
+ .commits-viewer-table .revision {
+ white-space: nowrap;
+ font-weight: normal;
+ margin: 0;
+ padding: 0;
+ }
+
+ .commits-viewer-table td,
+ .commits-viewer-table th {
+ word-break: break-word;
+ border-top: solid 1px #ccc;
+ padding: 0.2rem;
+ margin: 0;
+ font-size: 0.8rem;
+ font-weight: normal;
+ }
+
+ .commits-viewer-spinner {
+ margin-top: 2rem;
+ text-align: center;
+ }
+`;
+ }
+
+}
+
+ComponentBase.defineElement('commit-log-viewer', CommitLogViewer);
--- /dev/null
+
+class InteractiveTimeSeriesChart extends TimeSeriesChart {
+ constructor(sourceList, options)
+ {
+ super(sourceList, options);
+ this._indicatorID = null;
+ this._indicatorIsLocked = false;
+ this._currentAnnotation = null;
+ this._forceRender = false;
+ this._lastMouseDownLocation = null;
+ this._dragStarted = false;
+ this._didEndDrag = false;
+ this._selectionTimeRange = null;
+ this._renderedSelection = null;
+ this._annotationLabel = null;
+ this._renderedAnnotation = null;
+ }
+
+ currentPoint(diff)
+ {
+ if (!this._sampledTimeSeriesData)
+ return null;
+
+ var id = this._indicatorID;
+ if (!id)
+ return null;
+
+ for (var data of this._sampledTimeSeriesData) {
+ if (!data)
+ continue;
+ var index = data.findIndex(function (point) { return point.id == id; });
+ if (index < 0)
+ continue;
+ if (diff)
+ index += diff;
+ return data[Math.min(Math.max(0, index), data.length)];
+ }
+ return null;
+ }
+
+ currentSelection() { return this._selectionTimeRange; }
+
+ setIndicator(id, shouldLock)
+ {
+ var selectionDidChange = !!this._sampledTimeSeriesData;
+
+ this._indicatorID = id;
+ this._indicatorIsLocked = shouldLock;
+
+ this._lastMouseDownLocation = null;
+ this._selectionTimeRange = null;
+ this._forceRender = true;
+
+ if (selectionDidChange)
+ this._notifySelectionChanged();
+ }
+
+ moveLockedIndicatorWithNotification(forward)
+ {
+ if (!this._indicatorID || !this._indicatorIsLocked)
+ return false;
+
+ console.assert(!this._selectionTimeRange);
+
+ var point = this.currentPoint(forward ? 1 : -1);
+ if (!point || this._indicatorID == point.id)
+ return false;
+
+ this._indicatorID = point.id;
+ this._lastMouseDownLocation = null;
+ this._forceRender = true;
+
+ this._notifyIndicatorChanged();
+ }
+
+ setSelection(newSelectionTimeRange)
+ {
+ var indicatorDidChange = !!this._indicatorID;
+ this._indicatorID = null;
+ this._indicatorIsLocked = false;
+
+ this._lastMouseDownLocation = null;
+ this._selectionTimeRange = newSelectionTimeRange;
+ this._forceRender = true;
+
+ if (indicatorDidChange)
+ this._notifyIndicatorChanged();
+ }
+
+ _createCanvas()
+ {
+ var canvas = super._createCanvas();
+ canvas.addEventListener('mousemove', this._mouseMove.bind(this));
+ canvas.addEventListener('mouseleave', this._mouseLeave.bind(this));
+ canvas.addEventListener('mousedown', this._mouseDown.bind(this));
+ window.addEventListener('mouseup', this._mouseUp.bind(this));
+ canvas.addEventListener('click', this._click.bind(this));
+
+ this._annotationLabel = this.content().querySelector('.time-series-chart-annotation-label');
+ this._zoomButton = this.content().querySelector('.time-series-chart-zoom-button');
+
+ var self = this;
+ this._zoomButton.onclick = function (event) {
+ event.preventDefault();
+ if (self._options.selection && self._options.selection.onzoom)
+ self._options.selection.onzoom(self._selectionTimeRange);
+ }
+
+ return canvas;
+ }
+
+ static htmlTemplate()
+ {
+ return `
+ <a href="#" title="Zoom" class="time-series-chart-zoom-button" style="display:none;">
+ <svg viewBox="0 0 100 100">
+ <g stroke-width="0" stroke="none">
+ <polygon points="25,25 5,50 25,75"/>
+ <polygon points="75,25 95,50 75,75"/>
+ </g>
+ <line x1="20" y1="50" x2="80" y2="50" stroke-width="10"></line>
+ </svg>
+ </a>
+ <span class="time-series-chart-annotation-label" style="display:none;"></span>
+ `;
+ }
+
+ static cssTemplate()
+ {
+ return TimeSeriesChart.cssTemplate() + `
+ .time-series-chart-zoom-button {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 1rem;
+ height: 1rem;
+ display: block;
+ background: rgba(255, 255, 255, 0.8);
+ -webkit-backdrop-filter: blur(0.3rem);
+ stroke: #666;
+ fill: #666;
+ border: solid 1px #ccc;
+ border-radius: 0.2rem;
+ }
+
+ .time-series-chart-annotation-label {
+ position: absolute;
+ left: 0;
+ top: 0;
+ display: inline;
+ background: rgba(255, 255, 255, 0.8);
+ -webkit-backdrop-filter: blur(0.5rem);
+ color: #000;
+ border: solid 1px #ccc;
+ border-radius: 0.2rem;
+ padding: 0.2rem;
+ font-size: 0.8rem;
+ font-weight: normal;
+ line-height: 0.9rem;
+ z-index: 10;
+ max-width: 15rem;
+ }
+ `;
+ }
+
+ _mouseMove(event)
+ {
+ var cursorLocation = {x: event.offsetX, y: event.offsetY};
+ if (this._startOrContinueDragging(cursorLocation) || this._selectionTimeRange)
+ return;
+
+ if (this._indicatorIsLocked)
+ return;
+
+ var oldIndicatorID = this._indicatorID;
+
+ this._currentAnnotation = this._findAnnotation(cursorLocation);
+ if (this._currentAnnotation)
+ this._indicatorID = null;
+ else
+ this._indicatorID = this._findClosestPoint(cursorLocation);
+
+ this._forceRender = true;
+ this._notifyIndicatorChanged();
+ }
+
+ _mouseLeave(event)
+ {
+ if (this._selectionTimeRange || this._indicatorIsLocked || !this._indicatorID)
+ return;
+
+ this._indicatorID = null;
+ this._forceRender = true;
+ this._notifyIndicatorChanged();
+ }
+
+ _mouseDown(event)
+ {
+ this._lastMouseDownLocation = {x: event.offsetX, y: event.offsetY};
+ }
+
+ _mouseUp(event)
+ {
+ if (this._dragStarted)
+ this._endDragging({x: event.offsetX, y: event.offsetY});
+ }
+
+ _click(event)
+ {
+ if (this._selectionTimeRange) {
+ if (!this._didEndDrag) {
+ this._lastMouseDownLocation = null;
+ this._selectionTimeRange = null;
+ this._forceRender = true;
+ this._notifySelectionChanged(true);
+ this._mouseMove(event);
+ }
+ return;
+ }
+
+ this._lastMouseDownLocation = null;
+
+ var cursorLocation = {x: event.offsetX, y: event.offsetY};
+ var annotation = this._findAnnotation(cursorLocation);
+ if (annotation) {
+ if (this._options.annotations.onclick)
+ this._options.annotations.onclick(annotation);
+ return;
+ }
+
+ this._indicatorIsLocked = !this._indicatorIsLocked;
+ this._indicatorID = this._findClosestPoint(cursorLocation);
+ this._forceRender = true;
+
+ this._notifyIndicatorChanged();
+ }
+
+ _startOrContinueDragging(cursorLocation, didEndDrag)
+ {
+ var mouseDownLocation = this._lastMouseDownLocation;
+ if (!mouseDownLocation || !this._options.selection)
+ return false;
+
+ var xDiff = mouseDownLocation.x - cursorLocation.x;
+ var yDiff = mouseDownLocation.y - cursorLocation.y;
+ if (!this._dragStarted && xDiff * xDiff + yDiff * yDiff < 10)
+ return false;
+ this._dragStarted = true;
+
+ var indicatorDidChange = !!this._indicatorID;
+ this._indicatorID = null;
+ this._indicatorIsLocked = false;
+
+ var metrics = this._layout();
+
+ var oldSelection = this._selectionTimeRange;
+ if (!didEndDrag) {
+ var selectionStart = Math.min(mouseDownLocation.x, cursorLocation.x);
+ var selectionEnd = Math.max(mouseDownLocation.x, cursorLocation.x);
+ this._selectionTimeRange = [metrics.xToTime(selectionStart), metrics.xToTime(selectionEnd)];
+ }
+ this._forceRender = true;
+
+ if (indicatorDidChange)
+ this._notifyIndicatorChanged();
+
+ var selectionDidChange = !oldSelection ||
+ oldSelection[0] != this._selectionTimeRange[0] || oldSelection[1] != this._selectionTimeRange[1];
+ if (selectionDidChange || didEndDrag)
+ this._notifySelectionChanged(didEndDrag);
+
+ return true;
+ }
+
+ _endDragging(cursorLocation)
+ {
+ if (!this._startOrContinueDragging(cursorLocation, true))
+ return;
+ this._dragStarted = false;
+ this._lastMouseDownLocation = null;
+ this._didEndDrag = true;
+ var self = this;
+ setTimeout(function () { self._didEndDrag = false; }, 0);
+ }
+
+ _notifyIndicatorChanged()
+ {
+ if (this._options.indicator && this._options.indicator.onchange)
+ this._options.indicator.onchange(this._indicatorID, this._indicatorIsLocked);
+ }
+
+ _notifySelectionChanged(didEndDrag)
+ {
+ if (this._options.selection && this._options.selection.onchange)
+ this._options.selection.onchange(this._selectionTimeRange, didEndDrag);
+ }
+
+ _findAnnotation(cursorLocation)
+ {
+ if (!this._annotations)
+ return null;
+
+ for (var item of this._annotations) {
+ if (item.x <= cursorLocation.x && cursorLocation.x <= item.x + item.width
+ && item.y <= cursorLocation.y && cursorLocation.y <= item.y + item.height)
+ return item;
+ }
+ return null;
+ }
+
+ _findClosestPoint(cursorLocation)
+ {
+ Instrumentation.startMeasuringTime('InteractiveTimeSeriesChart', 'findClosestPoint');
+
+ var metrics = this._layout();
+
+ function weightedDistance(point) {
+ var x = metrics.timeToX(point.time);
+ var y = metrics.valueToY(point.value);
+ var xDiff = cursorLocation.x - x;
+ var yDiff = cursorLocation.y - y;
+ return xDiff * xDiff + yDiff * yDiff / 16; // Favor horizontal affinity.
+ }
+
+ var minDistance;
+ var minPoint = null;
+ for (var i = 0; i < this._sampledTimeSeriesData.length; i++) {
+ var series = this._sampledTimeSeriesData[i];
+ var source = this._sourceList[i];
+ if (!series || !source.interactive)
+ continue;
+ for (var point of series) {
+ var distance = weightedDistance(point);
+ if (minDistance === undefined || distance < minDistance) {
+ minDistance = distance;
+ minPoint = point;
+ }
+ }
+ }
+
+ Instrumentation.endMeasuringTime('InteractiveTimeSeriesChart', 'findClosestPoint');
+
+ return minPoint ? minPoint.id : null;
+ }
+
+ _layout()
+ {
+ var metrics = super._layout();
+ metrics.doneWork |= this._forceRender;
+ this._forceRender = false;
+ this._lastRenderigMetrics = metrics;
+ return metrics;
+ }
+
+ _sampleTimeSeries(data, maximumNumberOfPoints, exclusionPointID)
+ {
+ console.assert(!exclusionPointID);
+ return super._sampleTimeSeries(data, maximumNumberOfPoints, this._indicatorID);
+ }
+
+ _renderChartContent(context, metrics)
+ {
+ super._renderChartContent(context, metrics);
+
+ Instrumentation.startMeasuringTime('InteractiveTimeSeriesChart', 'renderChartContent');
+
+ context.lineJoin = 'miter';
+
+ if (this._renderedAnnotation != this._currentAnnotation) {
+ this._renderedAnnotation = this._currentAnnotation;
+
+ var annotation = this._currentAnnotation;
+ if (annotation) {
+ var label = annotation.label;
+ var spacing = this._options.annotations.minWidth;
+
+ this._annotationLabel.textContent = label;
+ if (this._annotationLabel.style.display != 'inline')
+ this._annotationLabel.style.display = 'inline';
+
+ // Force a browser layout.
+ var labelWidth = this._annotationLabel.offsetWidth;
+ var labelHeight = this._annotationLabel.offsetHeight;
+
+ var x = Math.round(annotation.x - labelWidth / 2);
+ var y = Math.floor(annotation.y - labelHeight);
+
+ // Use transform: translate to position the label to avoid triggering another browser layout.
+ this._annotationLabel.style.transform = `translate(${x}px, ${y}px)`;
+ } else
+ this._annotationLabel.style.display = 'none';
+ }
+
+ var indicator = this._options.indicator;
+ if (this._indicatorID && indicator) {
+ context.fillStyle = indicator.lineStyle;
+ context.strokeStyle = indicator.lineStyle;
+ context.lineWidth = indicator.lineWidth;
+
+ var point = this.currentPoint();
+ if (point) {
+ var x = metrics.timeToX(point.time);
+ var y = metrics.valueToY(point.value);
+
+ context.beginPath();
+ context.moveTo(x, metrics.chartY);
+ context.lineTo(x, metrics.chartY + metrics.chartHeight);
+ context.stroke();
+
+ this._fillCircle(context, x, y, indicator.pointRadius);
+ }
+ }
+
+ var selectionOptions = this._options.selection;
+ var selectionX2 = 0;
+ var selectionY2 = 0;
+ if (this._selectionTimeRange && selectionOptions) {
+ context.fillStyle = selectionOptions.fillStyle;
+ context.strokeStyle = selectionOptions.lineStyle;
+ context.lineWidth = selectionOptions.lineWidth;
+
+ var x1 = metrics.timeToX(this._selectionTimeRange[0]);
+ var x2 = metrics.timeToX(this._selectionTimeRange[1]);
+ context.beginPath();
+ selectionX2 = x2;
+ selectionY2 = metrics.chartHeight - selectionOptions.lineWidth;
+ context.rect(x1, metrics.chartY + selectionOptions.lineWidth / 2,
+ x2 - x1, metrics.chartHeight - selectionOptions.lineWidth);
+ context.fill();
+ context.stroke();
+ }
+
+ if (this._renderedSelection != selectionX2) {
+ this._renderedSelection = selectionX2;
+ if (this._renderedSelection && selectionOptions && selectionOptions.onzoom
+ && selectionX2 > 0 && selectionX2 < metrics.chartX + metrics.chartWidth) {
+ if (this._zoomButton.style.display)
+ this._zoomButton.style.display = null;
+
+ this._zoomButton.style.left = Math.round(selectionX2 + metrics.fontSize / 4) + 'px';
+ this._zoomButton.style.top = Math.floor(selectionY2 - metrics.fontSize * 1.5 - 2) + 'px';
+ } else
+ this._zoomButton.style.display = 'none';
+ }
+
+ Instrumentation.endMeasuringTime('InteractiveTimeSeriesChart', 'renderChartContent');
+ }
+}
--- /dev/null
+
+class PaneSelector extends ComponentBase {
+ constructor()
+ {
+ super('pane-selector');
+ this._currentPlatform = null;
+ this._currentPath = [];
+ this._platformItems = null;
+ this._renderedPlatform = null;
+ this._renderedPath = null;
+ this._updateTimer = null;
+ this._container = this.content().querySelector('.pane-selector-container');
+ this._callback = null;
+ this._previouslySelectedItem = null;
+ }
+
+ render()
+ {
+ this._renderPlatformLists();
+ this._renderTestLists();
+ }
+
+ focus()
+ {
+ var select = this.content().querySelector('select');
+ console.log(select);
+ if (select) {
+ if (select.selectedIndex < 0)
+ select.selectedIndex = 0;
+ select.focus();
+ }
+ }
+
+ _renderPlatformLists()
+ {
+ if (!this._platformItems) {
+ this._platformItems = [];
+
+ var platforms = Platform.sortByName(Platform.all());
+ for (var platform of platforms)
+ this._platformItems.push(this._createListItem(platform, platform.label()));
+
+ this._replaceList(0, this._buildList(this._platformItems));
+ }
+
+ for (var li of this._platformItems) {
+ if (li.data == this._currentPlatform)
+ li.selected = true;
+ }
+ }
+
+ _renderTestLists()
+ {
+ if (this._renderedPlatform != this._currentPlatform) {
+ this._replaceList(1, this._buildTestList(Test.topLevelTests()), []);
+ this._renderedPlatform = this._currentPlatform;
+ }
+
+ for (var i = 0; i < this._currentPath.length; i++) {
+ var item = this._currentPath[i];
+ if (this._renderedPath[i] == item)
+ continue;
+ if (item instanceof Metric)
+ break;
+ var newList = this._buildTestList(Test.sortByName(item.childTests()), Metric.sortByName(item.metrics()));
+ this._replaceList(i + 2, newList);
+ }
+
+ var removeNodeCount = this._container.childNodes.length - i - 2;
+ if (removeNodeCount > 0) {
+ while (removeNodeCount--)
+ this._container.removeChild(this._container.lastChild);
+ }
+
+ for (var i = 0; i < this._currentPath.length; i++) {
+ var list = this._container.childNodes[i + 1];
+ var item = this._currentPath[i];
+ for (var j = 0; j < list.childNodes.length; j++) {
+ var option = list.childNodes[j];
+ if (option.data == item)
+ option.selected = true;
+ }
+ }
+
+ this._renderedPath = this._currentPath;
+ }
+
+ _buildTestList(tests, metrics)
+ {
+ var self = this;
+ var platform = this._currentPlatform;
+
+ var metricItems = (metrics || [])
+ .filter(function (metric) { return platform && platform.hasMetric(metric); })
+ .map(function (metric) { return self._createListItem(metric, metric.label()); });
+
+ var testItems = tests
+ .filter(function (test) { return platform && platform.hasTest(test); })
+ .map(function (test) {
+ var data = test;
+ var label = test.label();
+ if (test.onlyContainsSingleMetric()) {
+ data = test.metrics()[0];
+ label = test.label() + ' (' + data.label() + ')';
+ }
+ return self._createListItem(data, label);
+ });
+
+ return this._buildList([metricItems, testItems]);
+ }
+
+ _replaceList(position, newList)
+ {
+ var existingList = this._container.childNodes[position];
+ if (existingList)
+ this._container.replaceChild(newList, existingList);
+ else
+ this._container.appendChild(newList);
+ }
+
+ _createListItem(data, label, hoverCallback, activationCallback)
+ {
+ var element = ComponentBase.createElement;
+ var item = element('option', {
+ // Can't use mouseenter because of webkit.org/b/152149.
+ onmousemove: this._selectedItem.bind(this, data),
+ onclick: this._clickedItem.bind(this, data),
+ }, label);
+ item.data = data;
+ return item;
+ }
+
+ _buildList(items, onchange)
+ {
+ var self = this;
+ return ComponentBase.createElement('select', {
+ size: 10,
+ onmousemove: function (event) {
+
+ },
+ onchange: function () {
+ if (this.selectedIndex >= 0)
+ self._selectedItem(this.options[this.selectedIndex].data);
+ }
+ }, items);
+ }
+
+ _selectedItem(data)
+ {
+ if (data == this._previouslySelectedItem)
+ return;
+ this._previouslySelectedItem = data;
+
+ if (data instanceof Platform) {
+ this._currentPlatform = data;
+ this._currentPath = [];
+ } else {
+ this._currentPath = data.path();
+ if (data instanceof Metric && data.test().onlyContainsSingleMetric())
+ this._currentPath.splice(this._currentPath.length - 2, 1);
+ }
+ this.render();
+ }
+
+ setCallback(callback)
+ {
+ this._callback = callback;
+ }
+
+ _clickedItem(data, event)
+ {
+ if (!(data instanceof Metric) || !this._callback || !this._currentPlatform || !this._currentPath.length)
+ return;
+ event.preventDefault();
+ this._callback(this._currentPlatform, this._currentPath[this._currentPath.length - 1]);
+ }
+
+ static htmlTemplate()
+ {
+ return `
+ <div class="pane-selector-container"></div>
+ `;
+ }
+
+ static cssTemplate()
+ {
+ return `
+ .pane-selector-container {
+ display: flex;
+ flex-direction: row-reverse;
+ height: 10rem;
+ font-size: 0.9rem;
+ white-space: nowrap;
+ }
+
+ .pane-selector-container select {
+ height: 100%;
+ border: solid 1px red;
+ font-size: 0.9rem;
+ border: solid 1px #ccc;
+ border-radius: 0.2rem;
+ margin-right: 0.2rem;
+ background: transparent;
+ max-width: 20rem;
+ }
+
+ .pane-selector-container li.selected a {
+ background: rgba(204, 153, 51, 0.1);
+ }
+ `;
+ }
+}
+
+ComponentBase.defineElement('pane-selector', PaneSelector);
--- /dev/null
+
+class SpinnerIcon extends ComponentBase {
+ constructor()
+ {
+ super('spinner-icon');
+ }
+
+ static cssTemplate()
+ {
+ return `
+ .spinner {
+ width: 2rem;
+ height: 2rem;
+ -webkit-transform: translateZ(0);
+ }
+ .spinner line {
+ animation: spinner-animation 1.6s linear infinite;
+ -webkit-animation: spinner-animation 1.6s linear infinite;
+ opacity: 0.1;
+ }
+ .spinner line:nth-child(0) {
+ -webkit-animation-delay: 0.0s;
+ animation-delay: 0.0s;
+ }
+ .spinner line:nth-child(1) {
+ -webkit-animation-delay: 0.2s;
+ animation-delay: 0.2s;
+ }
+ .spinner line:nth-child(2) {
+ -webkit-animation-delay: 0.4s;
+ animation-delay: 0.4s;
+ }
+ .spinner line:nth-child(3) {
+ -webkit-animation-delay: 0.6s;
+ animation-delay: 0.6s;
+ }
+ .spinner line:nth-child(4) {
+ -webkit-animation-delay: 0.8s;
+ animation-delay: 0.8s;
+ }
+ .spinner line:nth-child(5) {
+ -webkit-animation-delay: 1s;
+ animation-delay: 1s;
+ }
+ .spinner line:nth-child(6) {
+ -webkit-animation-delay: 1.2s;
+ animation-delay: 1.2s;
+ }
+ .spinner line:nth-child(7) {
+ -webkit-animation-delay: 1.4s;
+ animation-delay: 1.4s;
+ }
+ .spinner line:nth-child(8) {
+ -webkit-animation-delay: 1.6s;
+ animation-delay: 1.6s;
+ }
+ @keyframes spinner-animation {
+ 0% { opacity: 0.9; }
+ 50% { opacity: 0.1; }
+ 100% { opacity: 0.1; }
+ }
+ @-webkit-keyframes spinner-animation {
+ 0% { opacity: 0.9; }
+ 50% { opacity: 0.1; }
+ 100% { opacity: 0.1; }
+ }
+ `;
+ }
+
+ static htmlTemplate()
+ {
+ return `<svg class="spinner" viewBox="0 0 100 100">
+ <line x1="10" y1="50" x2="30" y2="50" stroke="black" stroke-width="10" stroke-linecap="round"/>
+ <line x1="21.72" y1="21.72" x2="35.86" y2="35.86" stroke="black" stroke-width="10" stroke-linecap="round"/>
+ <line x1="50" y1="10" x2="50" y2="30" stroke="black" stroke-width="10" stroke-linecap="round"/>
+ <line x1="78.28" y1="21.72" x2="64.14" y2="35.86" stroke="black" stroke-width="10" stroke-linecap="round"/>
+ <line x1="70" y1="50" x2="90" y2="50" stroke="black" stroke-width="10" stroke-linecap="round"/>
+ <line x1="65.86" y1="65.86" x2="78.28" y2="78.28" stroke="black" stroke-width="10" stroke-linecap="round"/>
+ <line x1="50" y1="70" x2="50" y2="90" stroke="black" stroke-width="10" stroke-linecap="round"/>
+ <line x1="21.72" y1="78.28" x2="35.86" y2="65.86" stroke="black" stroke-width="10" stroke-linecap="round"/>
+ </svg>`;
+ }
+
+}
+
+ComponentBase.defineElement('spinner-icon', SpinnerIcon);
--- /dev/null
+
+class TimeSeriesChart extends ComponentBase {
+ constructor(sourceList, options)
+ {
+ super('time-series-chart');
+ this.element().style.display = 'block';
+ this.element().style.position = 'relative';
+ this._canvas = null;
+ this._sourceList = sourceList;
+ this._options = options;
+ this._fetchedTimeSeries = null;
+ this._sampledTimeSeriesData = null;
+ this._valueRangeCache = null;
+ this._annotations = null;
+ this._annotationRows = null;
+ this._startTime = null;
+ this._endTime = null;
+ this._width = null;
+ this._height = null;
+ this._contextScaleX = 1;
+ this._contextScaleY = 1;
+ this._rem = null;
+
+ if (this._options.updateOnRequestAnimationFrame) {
+ if (!TimeSeriesChart._chartList)
+ TimeSeriesChart._chartList = [];
+ TimeSeriesChart._chartList.push(this);
+ TimeSeriesChart._updateOnRAF();
+ }
+ }
+
+ _ensureCanvas()
+ {
+ if (!this._canvas) {
+ this._canvas = this._createCanvas();
+ this._canvas.style.display = 'block';
+ this._canvas.style.width = '100%';
+ this._canvas.style.height = '100%';
+ this.content().appendChild(this._canvas);
+ }
+ return this._canvas;
+ }
+
+ static cssTemplate() { return ''; }
+
+ _createCanvas()
+ {
+ return document.createElement('canvas');
+ }
+
+ static _updateOnRAF()
+ {
+ var self = this;
+ window.requestAnimationFrame(function ()
+ {
+ TimeSeriesChart._chartList.map(function (chart) { chart.render(); });
+ self._updateOnRAF();
+ });
+ }
+
+ setDomain(startTime, endTime)
+ {
+ console.assert(startTime < endTime, 'startTime must be before endTime');
+ this._startTime = startTime;
+ this._endTime = endTime;
+ for (var source of this._sourceList) {
+ if (source.measurementSet)
+ source.measurementSet.fetchBetween(startTime, endTime, this._didFetchMeasurementSet.bind(this, source.measurementSet));
+ }
+ this._sampledTimeSeriesData = null;
+ this._valueRangeCache = null;
+ this._annotationRows = null;
+ }
+
+ _didFetchMeasurementSet(set)
+ {
+ this._fetchedTimeSeries = null;
+ this._sampledTimeSeriesData = null;
+ this._valueRangeCache = null;
+ this._annotationRows = null;
+ }
+
+ // FIXME: Figure out a way to make this readonly.
+ sampledTimeSeriesData(type)
+ {
+ if (!this._sampledTimeSeriesData)
+ return null;
+ for (var i = 0; i < this._sourceList.length; i++) {
+ if (this._sourceList[i].type == type)
+ return this._sampledTimeSeriesData[i];
+ }
+ return null;
+ }
+
+ sampledDataBetween(type, startTime, endTime)
+ {
+ var data = this.sampledTimeSeriesData(type);
+ if (!data)
+ return null;
+ return data.filter(function (point) { return startTime <= point.time && point.time <= endTime; });
+ }
+
+ setAnnotations(annotations)
+ {
+ this._annotations = annotations;
+ this._annotationRows = null;
+ }
+
+ render()
+ {
+ if (!this._startTime || !this._endTime)
+ return;
+
+ // FIXME: Also detect horizontal scrolling.
+ var canvas = this._ensureCanvas();
+ if (!TimeSeriesChart.isElementInViewport(canvas))
+ return;
+
+ var metrics = this._layout();
+ if (!metrics.doneWork)
+ return;
+
+ Instrumentation.startMeasuringTime('TimeSeriesChart', 'render');
+
+ var context = canvas.getContext('2d');
+ context.scale(this._contextScaleX, this._contextScaleY);
+
+ context.clearRect(0, 0, this._width, this._height);
+
+ context.font = metrics.fontSize + 'px sans-serif';
+ context.fillStyle = this._options.axis.fillStyle;
+ context.strokeStyle = this._options.axis.gridStyle;
+ context.lineWidth = 1 / this._contextScaleY;
+
+ this._renderXAxis(context, metrics, this._startTime, this._endTime);
+ this._renderYAxis(context, metrics, this._valueRangeCache[0], this._valueRangeCache[1]);
+
+ context.save();
+
+ context.beginPath();
+ context.rect(metrics.chartX, metrics.chartY, metrics.chartWidth, metrics.chartHeight);
+ context.clip();
+
+ this._renderChartContent(context, metrics);
+
+ context.restore();
+
+ context.setTransform(1, 0, 0, 1, 0, 0);
+
+ Instrumentation.endMeasuringTime('TimeSeriesChart', 'render');
+ }
+
+ _layout()
+ {
+ // FIXME: We should detect changes in _options and _sourceList.
+ // FIXME: We should consider proactively preparing time series caches to avoid jaggy scrolling.
+ var doneWork = this._updateCanvasSizeIfClientSizeChanged();
+ var metrics = this._computeHorizontalRenderingMetrics();
+ doneWork |= this._ensureSampledTimeSeries(metrics);
+ doneWork |= this._ensureValueRangeCache();
+ this._computeVerticalRenderingMetrics(metrics);
+ doneWork |= this._layoutAnnotationBars(metrics);
+ metrics.doneWork = doneWork;
+ return metrics;
+ }
+
+ _computeHorizontalRenderingMetrics()
+ {
+ // FIXME: We should detect when rem changes.
+ if (!this._rem)
+ this._rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
+
+ var timeDiff = this._endTime - this._startTime;
+ var startTime = this._startTime;
+
+ var fontSize = this._options.axis.fontSize * this._rem;
+ var chartX = this._options.axis.yAxisWidth * fontSize;
+ var chartY = 0;
+ var chartWidth = this._width - chartX;
+ var chartHeight = this._height - this._options.axis.xAxisHeight * fontSize;
+
+ return {
+ xToTime: function (x)
+ {
+ var time = (x - chartX) / (chartWidth / timeDiff) + +startTime;
+ console.assert(Math.abs(x - this.timeToX(time)) < 1e-6);
+ return time;
+ },
+ timeToX: function (time) { return (chartWidth / timeDiff) * (time - startTime) + chartX; },
+ valueToY: function (value)
+ {
+ return ((chartHeight - this.annotationHeight) / this.valueDiff) * (this.endValue - value) + chartY;
+ },
+ chartX: chartX,
+ chartY: chartY,
+ chartWidth: chartWidth,
+ chartHeight: chartHeight,
+ annotationHeight: 0, // Computed later in _layoutAnnotationBars.
+ fontSize: fontSize,
+ valueDiff: 0,
+ endValue: 0,
+ };
+ }
+
+ _computeVerticalRenderingMetrics(metrics)
+ {
+ var minValue = this._valueRangeCache[0];
+ var maxValue = this._valueRangeCache[1];
+ var valueDiff = maxValue - minValue;
+ var valueMargin = valueDiff * 0.05;
+ var endValue = maxValue + valueMargin;
+ var valueDiffWithMargin = valueDiff + valueMargin * 2;
+
+ metrics.valueDiff = valueDiffWithMargin;
+ metrics.endValue = endValue;
+ }
+
+ _layoutAnnotationBars(metrics)
+ {
+ if (!this._annotations || !this._options.annotations)
+ return false;
+
+ var barHeight = this._options.annotations.barHeight;
+ var barSpacing = this._options.annotations.barSpacing;
+
+ if (this._annotationRows) {
+ metrics.annotationHeight = this._annotationRows.length * (barHeight + barSpacing);
+ return false;
+ }
+
+ Instrumentation.startMeasuringTime('TimeSeriesChart', 'layoutAnnotationBars');
+
+ var minWidth = this._options.annotations.minWidth;
+
+ // (1) Expand the width of each bar to hit the minimum width and sort them by left edges.
+ this._annotations.forEach(function (annotation) {
+ var x1 = metrics.timeToX(annotation.startTime);
+ var x2 = metrics.timeToX(annotation.endTime);
+ if (x2 - x1 < minWidth) {
+ x1 -= minWidth / 2;
+ x2 += minWidth / 2;
+ }
+ annotation.x = x1;
+ annotation.width = x2 - x1;
+ });
+ var sortedAnnotations = this._annotations.sort(function (a, b) { return a.x - b.x });
+
+ // (2) For each bar, find the first row in which the last bar's right edge appear
+ // on the left of the bar as each row contains non-overlapping bars in the acending x order.
+ var rows = [];
+ sortedAnnotations.forEach(function (currentItem) {
+ for (var rowIndex = 0; rowIndex < rows.length; rowIndex++) {
+ var currentRow = rows[rowIndex];
+ var lastItem = currentRow[currentRow.length - 1];
+ if (lastItem.x + lastItem.width + minWidth < currentItem.x) {
+ currentRow.push(currentItem);
+ return;
+ }
+ }
+ rows.push([currentItem]);
+ });
+
+ this._annotationRows = rows;
+ for (var rowIndex = 0; rowIndex < rows.length; rowIndex++) {
+ for (var annotation of rows[rowIndex]) {
+ annotation.y = metrics.chartY + metrics.chartHeight - (rows.length - rowIndex) * (barHeight + barSpacing);
+ annotation.height = barHeight;
+ }
+ }
+
+ metrics.annotationHeight = rows.length * (barHeight + barSpacing);
+
+ Instrumentation.endMeasuringTime('TimeSeriesChart', 'layoutAnnotationBars');
+
+ return true;
+ }
+
+ _renderXAxis(context, metrics, startTime, endTime)
+ {
+ var typicalWidth = context.measureText('12/31').width;
+ var maxXAxisLabels = Math.floor(metrics.chartWidth / typicalWidth);
+ var xAxisGrid = TimeSeriesChart.computeTimeGrid(startTime, endTime, maxXAxisLabels);
+
+ for (var item of xAxisGrid) {
+ context.beginPath();
+ var x = metrics.timeToX(item.time);
+ context.moveTo(x, metrics.chartY);
+ context.lineTo(x, metrics.chartY + metrics.chartHeight);
+ context.stroke();
+ }
+
+ if (!this._options.axis.xAxisHeight)
+ return;
+
+ var rightEdgeOfPreviousItem = 0;
+ for (var item of xAxisGrid) {
+ var xCenter = metrics.timeToX(item.time);
+ var width = context.measureText(item.label).width;
+ var x = xCenter - width / 2;
+ if (x + width > metrics.chartX + metrics.chartWidth) {
+ x = metrics.chartX + metrics.chartWidth - width;
+ if (x <= rightEdgeOfPreviousItem)
+ break;
+ }
+ rightEdgeOfPreviousItem = x + width;
+ context.fillText(item.label, x, metrics.chartY + metrics.chartHeight + metrics.fontSize);
+ }
+ }
+
+ _renderYAxis(context, metrics, minValue, maxValue)
+ {
+ var maxYAxisLabels = Math.floor(metrics.chartHeight / metrics.fontSize / 2);
+ var yAxisGrid = TimeSeriesChart.computeValueGrid(minValue, maxValue, maxYAxisLabels);
+
+ for (var value of yAxisGrid) {
+ context.beginPath();
+ var y = metrics.valueToY(value);
+ context.moveTo(metrics.chartX, y);
+ context.lineTo(metrics.chartX + metrics.chartWidth, y);
+ context.stroke();
+ }
+
+ if (!this._options.axis.yAxisWidth)
+ return;
+
+ for (var value of yAxisGrid) {
+ var label = this._options.axis.valueFormatter(value);
+ var x = (metrics.chartX - context.measureText(label).width) / 2;
+
+ var y = metrics.valueToY(value) + metrics.fontSize / 2.5;
+ if (y < metrics.fontSize)
+ y = metrics.fontSize;
+
+ context.fillText(label, x, y);
+ }
+ }
+
+ _renderChartContent(context, metrics)
+ {
+ context.lineJoin = 'round';
+ for (var i = 0; i < this._sourceList.length; i++) {
+ var source = this._sourceList[i];
+ var series = this._sampledTimeSeriesData[i];
+ if (series)
+ this._renderTimeSeries(context, metrics, source, series);
+ }
+
+ if (!this._annotationRows)
+ return;
+
+ for (var row of this._annotationRows) {
+ for (var bar of row) {
+ if (bar.x > this.chartWidth || bar.x + bar.width < 0)
+ continue;
+ context.fillStyle = bar.fillStyle;
+ context.fillRect(bar.x, bar.y, bar.width, bar.height);
+ }
+ }
+ }
+
+ _renderTimeSeries(context, metrics, source, series)
+ {
+ for (var point of series) {
+ point.x = metrics.timeToX(point.time);
+ point.y = metrics.valueToY(point.value);
+ }
+
+ context.strokeStyle = source.intervalStyle;
+ context.fillStyle = source.intervalStyle;
+ context.lineWidth = source.intervalWidth;
+ for (var i = 0; i < series.length; i++) {
+ var point = series[i];
+ if (!point.interval)
+ continue;
+ context.beginPath();
+ context.moveTo(point.x, metrics.valueToY(point.interval[0]))
+ context.lineTo(point.x, metrics.valueToY(point.interval[1]));
+ context.stroke();
+ }
+
+ context.strokeStyle = source.lineStyle;
+ context.lineWidth = source.lineWidth;
+ context.beginPath();
+ for (var point of series)
+ context.lineTo(point.x, point.y);
+ context.stroke();
+
+ context.fillStyle = source.pointStyle;
+ var radius = source.pointRadius;
+ for (var point of series)
+ this._fillCircle(context, point.x, point.y, radius);
+ }
+
+ _fillCircle(context, cx, cy, radius)
+ {
+ context.beginPath();
+ context.arc(cx, cy, radius, 0, 2 * Math.PI);
+ context.fill();
+ }
+
+ _ensureFetchedTimeSeries()
+ {
+ if (this._fetchedTimeSeries)
+ return false;
+
+ Instrumentation.startMeasuringTime('TimeSeriesChart', 'ensureFetchedTimeSeries');
+
+ this._fetchedTimeSeries = this._sourceList.map(function (source) {
+ return source.measurementSet.fetchedTimeSeries(source.type, source.includeOutliers, source.extendToFuture);
+ });
+
+ Instrumentation.endMeasuringTime('TimeSeriesChart', 'ensureFetchedTimeSeries');
+
+ return true;
+ }
+
+ _ensureSampledTimeSeries(metrics)
+ {
+ if (this._sampledTimeSeriesData)
+ return false;
+
+ this._ensureFetchedTimeSeries();
+
+ Instrumentation.startMeasuringTime('TimeSeriesChart', 'ensureSampledTimeSeries');
+
+ var self = this;
+ var startTime = this._startTime;
+ var endTime = this._endTime;
+ this._sampledTimeSeriesData = this._sourceList.map(function (source, sourceIndex) {
+ var timeSeries = self._fetchedTimeSeries[sourceIndex];
+ if (!timeSeries)
+ return null;
+
+ // A chart with X px width shouldn't have more than X / <radius-of-points> data points.
+ var maximumNumberOfPoints = metrics.chartWidth / source.pointRadius;
+
+ var pointAfterStart = timeSeries.findPointAfterTime(startTime);
+ var pointBeforeStart = (pointAfterStart ? timeSeries.previousPoint(pointAfterStart) : null) || timeSeries.firstPoint();
+ var pointAfterEnd = timeSeries.findPointAfterTime(endTime) || timeSeries.lastPoint();
+ if (!pointBeforeStart || !pointAfterEnd)
+ return null;
+
+ // FIXME: Move this to TimeSeries.prototype.
+ var filteredData = timeSeries.dataBetweenPoints(pointBeforeStart, pointAfterEnd);
+ if (filteredData.length <= maximumNumberOfPoints || !source.sampleData)
+ return filteredData;
+ else
+ return self._sampleTimeSeries(filteredData, maximumNumberOfPoints);
+ });
+
+ Instrumentation.endMeasuringTime('TimeSeriesChart', 'ensureSampledTimeSeries');
+
+ if (this._options.ondata)
+ this._options.ondata();
+
+ return true;
+ }
+
+ _sampleTimeSeries(data, maximumNumberOfPoints, exclusionPointID)
+ {
+ Instrumentation.startMeasuringTime('TimeSeriesChart', 'sampleTimeSeries');
+
+ // FIXME: Do this in O(n) using quickselect: https://en.wikipedia.org/wiki/Quickselect
+ function findMedian(list, startIndex, endIndex)
+ {
+ var sortedList = list.slice(startIndex, endIndex + 1).sort(function (a, b) { return a.value - b.value; });
+ return sortedList[Math.floor(sortedList.length / 2)];
+ }
+
+ var samplingSize = Math.ceil(data.length / maximumNumberOfPoints);
+
+ var totalTimeDiff = data[data.length - 1].time - data[0].time;
+ var timePerSample = totalTimeDiff / maximumNumberOfPoints;
+
+ var sampledData = [];
+ var lastIndex = data.length - 1;
+ var i = 0;
+ while (i <= lastIndex) {
+ var startPoint = data[i];
+ var j;
+ for (j = i; j <= lastIndex; j++) {
+ var endPoint = data[j];
+ if (endPoint.id == exclusionPointID) {
+ j--;
+ break;
+ }
+ if (endPoint.time - startPoint.time >= timePerSample)
+ break;
+ }
+ if (i < j) {
+ sampledData.push(findMedian(data, i, j));
+ i = j + 1;
+ } else {
+ sampledData.push(startPoint);
+ i++;
+ }
+ }
+
+ Instrumentation.endMeasuringTime('TimeSeriesChart', 'sampleTimeSeries');
+
+ Instrumentation.reportMeasurement('TimeSeriesChart', 'samplingRatio', '%', sampledData.length / data.length * 100);
+
+ return sampledData;
+ }
+
+ _ensureValueRangeCache()
+ {
+ if (this._valueRangeCache)
+ return false;
+
+ Instrumentation.startMeasuringTime('TimeSeriesChart', 'valueRangeCache');
+ var startTime = this._startTime;
+ var endTime = this._endTime;
+
+ var min;
+ var max;
+ for (var seriesData of this._sampledTimeSeriesData) {
+ if (!seriesData)
+ continue;
+ for (var point of seriesData) {
+ var minCandidate = point.interval ? point.interval[0] : point.value;
+ var maxCandidate = point.interval ? point.interval[1] : point.value;
+ min = (min === undefined) ? minCandidate : Math.min(min, minCandidate);
+ max = (max === undefined) ? maxCandidate : Math.max(max, maxCandidate);
+ }
+ }
+ this._valueRangeCache = [min, max];
+ Instrumentation.endMeasuringTime('TimeSeriesChart', 'valueRangeCache');
+
+ return true;
+ }
+
+ _updateCanvasSizeIfClientSizeChanged()
+ {
+ var canvas = this._ensureCanvas();
+
+ var newWidth = canvas.clientWidth;
+ var newHeight = canvas.clientHeight;
+ if (!newWidth || !newHeight || (newWidth == this._width && newHeight == this._height))
+ return false;
+
+ var scale = window.devicePixelRatio;
+ canvas.width = newWidth * scale;
+ canvas.height = newHeight * scale;
+ this._contextScaleX = scale;
+ this._contextScaleY = scale;
+ this._width = newWidth;
+ this._height = newHeight;
+ this._sampledTimeSeriesData = null;
+ this._annotationRows = null;
+
+ return true;
+ }
+
+ static computeTimeGrid(min, max, maxLabels)
+ {
+ var diffPerLabel = (max - min) / maxLabels;
+
+ var iterator;
+ for (iterator of this._timeIterators()) {
+ if (iterator.diff > diffPerLabel)
+ break;
+ }
+ console.assert(iterator);
+
+ var currentTime = new Date(min);
+ currentTime.setUTCMilliseconds(0);
+ currentTime.setUTCSeconds(0);
+ currentTime.setUTCMinutes(0);
+ iterator.next(currentTime);
+
+ var result = [];
+
+ while (currentTime <= max) {
+ var label = (currentTime.getUTCMonth() + 1) + '/' + currentTime.getUTCDate();
+ result.push({time: new Date(currentTime), label: label});
+ iterator.next(currentTime);
+ }
+
+ return result;
+ }
+
+ static _timeIterators()
+ {
+ var HOUR = 3600 * 1000;
+ var DAY = 24 * HOUR;
+ return [
+ {
+ diff: 12 * HOUR,
+ next: function (date) {
+ if (date.getUTCHours() >= 12) {
+ date.setUTCHours(0);
+ date.setUTCDate(date.getUTCDate() + 1);
+ } else
+ date.setUTCHours(12);
+ },
+ },
+ {
+ diff: DAY,
+ next: function (date) {
+ date.setUTCHours(0);
+ date.setUTCDate(date.getUTCDate() + 1);
+ }
+ },
+ {
+ diff: 2 * DAY,
+ next: function (date) {
+ date.setUTCHours(0);
+ date.setUTCDate(date.getUTCDate() + 2);
+ }
+ },
+ {
+ diff: 7 * DAY,
+ next: function (date) {
+ date.setUTCHours(0);
+ if (date.getUTCDay())
+ date.setUTCDate(date.getUTCDate() + (7 - date.getUTCDay()));
+ else
+ date.setUTCDate(date.getUTCDate() + 7);
+ }
+ },
+ {
+ diff: 16 * DAY,
+ next: function (date) {
+ date.setUTCHours(0);
+ if (date.getUTCDate() >= 15) {
+ date.setUTCMonth(date.getUTCMonth() + 1);
+ date.setUTCDate(1);
+ } else
+ date.setUTCDate(15);
+ }
+ },
+ {
+ diff: 31 * DAY,
+ next: function (date) {
+ date.setUTCHours(0);
+ date.setUTCMonth(date.getUTCMonth() + 1);
+ }
+ },
+ ];
+ }
+
+ static computeValueGrid(min, max, maxLabels)
+ {
+ var diff = max - min;
+ var scalingFactor = 1;
+ var diffPerLabel = diff / maxLabels;
+ if (diffPerLabel < 1) {
+ scalingFactor = Math.pow(10, Math.ceil(-Math.log(diffPerLabel) / Math.log(10)));
+ min *= scalingFactor;
+ max *= scalingFactor;
+ diff *= scalingFactor;
+ diffPerLabel *= scalingFactor;
+ }
+ diffPerLabel = Math.ceil(diffPerLabel);
+ var numberOfDigitsToIgnore = Math.ceil(Math.log(diffPerLabel) / Math.log(10));
+ var step = Math.pow(10, numberOfDigitsToIgnore);
+
+ if (diff / (step / 5) < maxLabels) // 0.2, 0.4, etc...
+ step /= 5;
+ else if (diff / (step / 2) < maxLabels) // 0.5, 1, 1.5, etc...
+ step /= 2;
+
+ var gridValues = [];
+ var currentValue = Math.ceil(min / step) * step;
+ while (currentValue <= max) {
+ gridValues.push(currentValue / scalingFactor);
+ currentValue += step;
+ }
+ return gridValues;
+ }
+}
--- /dev/null
+<!DOCTYPE html>
+<html>
+<head>
+ <!-- This document needs to a valid XML to be parsed by xml.dom.minidom.parse in tools/bundle-v3-scripts -->
+ <meta charset="utf-8" />
+ <title>Performance Dashboard is Loading...</title>
+
+ <link rel="prefetch" href="../data/manifest.json" />
+
+ <style>
+ html, body {
+ padding: 0;
+ margin: 0;
+ }
+
+ body {
+ font-family: 'San Francisco', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ font-weight: 300;
+ min-width: 50rem;
+ }
+ </style>
+
+ <script>
+ // Load indivisual JS files when bundled-scripts.js failed to load as a fallback during development.
+ function loadUnbundledScripts() {
+ var scripts = document.getElementById('unbundled-scripts').content.cloneNode(true).querySelectorAll('script');
+
+ console.log(
+`Loading ${scripts.length} JS files using the slow path for development.
+Run tools/bundle-v3-scripts to speed up the load time for production.`);
+
+ for (var i = 0; !(i >= scripts.length); i++) {
+ scripts[i].async = false;
+ document.head.appendChild(scripts[i]);
+ }
+ }
+ </script>
+
+ <template id="unbundled-scripts">
+ <script src="../v2/js/statistics.js"></script>
+ <script src="../v2/data.js"></script>
+
+ <script src="instrumentation.js"></script>
+ <script src="remote.js"></script>
+
+ <script src="models/measurement-cluster.js"></script>
+ <script src="models/measurement-set.js"></script>
+ <script src="models/commit-log.js"></script>
+ <script src="models/data-model.js"></script>
+ <script src="models/platform.js"></script>
+ <script src="models/builder.js"></script>
+ <script src="models/test.js"></script>
+ <script src="models/metric.js"></script>
+ <script src="models/repository.js"></script>
+ <script src="models/bug-tracker.js"></script>
+ <script src="models/bug.js"></script>
+ <script src="models/analysis-task.js"></script>
+
+ <script src="components/base.js"></script>
+ <script src="components/spinner-icon.js"></script>
+ <script src="components/button-base.js"></script>
+ <script src="components/close-button.js"></script>
+ <script src="components/commit-log-viewer.js"></script>
+ <script src="components/time-series-chart.js"></script>
+ <script src="components/interactive-time-series-chart.js"></script>
+ <script src="components/chart-status-view.js"></script>
+ <script src="components/platform-selector.js"></script>
+ <script src="components/pane-selector.js"></script>
+ <script src="pages/page.js"></script>
+ <script src="pages/page-router.js"></script>
+ <script src="pages/heading.js"></script>
+ <script src="pages/toolbar.js"></script>
+ <script src="pages/page-with-heading.js"></script>
+ <script src="pages/page-with-charts.js"></script>
+ <script src="pages/domain-control-toolbar.js"></script>
+ <script src="pages/dashboard-toolbar.js"></script>
+ <script src="pages/dashboard-page.js"></script>
+ <script src="pages/chart-pane-status-view.js"></script>
+ <script src="pages/chart-pane.js"></script>
+ <script src="pages/charts-toolbar.js"></script>
+ <script src="pages/charts-page.js"></script>
+ <script src="pages/analysis-category-toolbar.js"></script>
+ <script src="pages/analysis-category-page.js"></script>
+ <script src="pages/analysis-task-page.js"></script>
+ <script src="pages/create-analysis-task-page.js"></script>
+
+ <script src="main.js"></script>
+ </template>
+ <script src="bundled-scripts.js" onerror="loadUnbundledScripts()"></script>
+</head>
+<body>
+</body>
+</html>
--- /dev/null
+class Instrumentation {
+
+ static startMeasuringTime(domain, label)
+ {
+ label = domain + ':' + label;
+ if (!Instrumentation._statistics) {
+ Instrumentation._statistics = {};
+ Instrumentation._currentMeasurement = {};
+ }
+ Instrumentation._currentMeasurement[label] = Date.now();
+ }
+
+ static endMeasuringTime(domain, label)
+ {
+ var time = Date.now() - Instrumentation._currentMeasurement[domain + ':' + label];
+ this.reportMeasurement(domain, label, 'ms', time);
+ }
+
+ static reportMeasurement(domain, label, unit, value)
+ {
+ label = domain + ':' + label;
+ var statistics = Instrumentation._statistics;
+ if (label in statistics) {
+ statistics[label].value += value;
+ statistics[label].count++;
+ statistics[label].min = Math.min(statistics[label].min, value);
+ statistics[label].max = Math.max(statistics[label].max, value);
+ statistics[label].mean = statistics[label].value / statistics[label].count;
+ } else
+ statistics[label] = {value: value, unit: unit, count: 1, mean: value, min:value, max: value};
+ }
+
+ static dumpStatistics()
+ {
+ if (!this._statistics)
+ return;
+ var maxKeyLength = 0;
+ for (var key in this._statistics)
+ maxKeyLength = Math.max(key.length, maxKeyLength);
+
+ for (var key in this._statistics) {
+ var item = this._statistics[key];
+ var keySuffix = ' '.repeat(maxKeyLength - key.length);
+ var log = `${key}${keySuffix}: `;
+ log += ` mean = ${item.mean.toFixed(2)} ${item.unit}`;
+ if (item.unit == 'ms')
+ log += ` total = ${item.value.toFixed(2)} ${item.unit}`;
+ log += ` min = ${item.min.toFixed(2)} ${item.unit}`;
+ log += ` max = ${item.max.toFixed(2)} ${item.unit}`;
+ log += ` (${item.count} calls)`;
+ console.log(log);
+ }
+ }
+
+}
--- /dev/null
+
+class SpinningPage extends Page {
+ static htmlTemplate()
+ {
+ return `<div style="position: absolute; width: 100%; padding-top: 25%; text-align: center;"><spinner-icon></spinner-icon></div>`;
+ }
+}
+
+function main() {
+ (new SpinningPage).open();
+
+ fetchManifest().then(function (manifest) {
+ var dashboardToolbar = new DashboardToolbar;
+ var dashboardPages = [];
+ if (manifest.dashboards) {
+ for (var name in manifest.dashboards)
+ dashboardPages.push(new DashboardPage(name, manifest.dashboards[name], dashboardToolbar));
+ }
+
+ var router = new PageRouter();
+
+ var chartsToolbar = new ChartsToolbar;
+ var chartsPage = new ChartsPage(chartsToolbar);
+
+ var analysisCategoryPage = new AnalysisCategoryPage();
+
+ var createAnalysisTaskPage = new CreateAnalysisTaskPage();
+ createAnalysisTaskPage.setParentPage(analysisCategoryPage);
+
+ var analysisTaskPage = new AnalysisTaskPage();
+ analysisTaskPage.setParentPage(analysisCategoryPage);
+
+ var heading = new Heading(manifest.siteTitle);
+ heading.addPageGroup([chartsPage, analysisCategoryPage]);
+
+ heading.setTitle(manifest.siteTitle);
+ heading.addPageGroup(dashboardPages);
+
+ var router = new PageRouter();
+ router.addPage(chartsPage);
+ router.addPage(createAnalysisTaskPage);
+ router.addPage(analysisTaskPage);
+ router.addPage(analysisCategoryPage);
+ for (var page of dashboardPages)
+ router.addPage(page);
+
+ if (dashboardPages)
+ router.setDefaultPage(dashboardPages[0]);
+ else
+ router.setDefaultPage(chartsPage);
+
+ heading.setRouter(router);
+ router.route();
+ });
+}
+
+function fetchManifest()
+{
+ var manifestURL = '../data/manifest.json';
+
+ return getJSON(manifestURL).then(function (rawResponse) {
+ Instrumentation.startMeasuringTime('Manifest', 'constructor');
+
+ var tests = [];
+ var testParentMap = {};
+ for (var testId in rawResponse.tests) {
+ var test = rawResponse.tests[testId];
+ var topLevel = !test.parentId;
+ if (test.parentId)
+ testParentMap[testId] = parseInt(test.parentId);
+ tests.push(new Test(testId, test, topLevel));
+ }
+ for (var testId in testParentMap)
+ Test.findById(testId).setParentTest(Test.findById(testParentMap[testId]));
+
+ function buildObjectsFromIdMap(idMap, constructor, resolver) {
+ for (var id in idMap) {
+ if (resolver)
+ resolver(idMap[id]);
+ new constructor(id, idMap[id]);
+ }
+ }
+ buildObjectsFromIdMap(rawResponse.metrics, Metric, function (raw) {
+ raw.test = Test.findById(raw.test);
+ });
+
+ buildObjectsFromIdMap(rawResponse.all, Platform, function (raw) {
+ raw.lastModifiedByMetric = {};
+ raw.lastModified.forEach(function (lastModified, index) {
+ raw.lastModifiedByMetric[raw.metrics[index]] = lastModified;
+ });
+ raw.metrics = raw.metrics.map(function (id) { return Metric.findById(id); });
+ });
+ buildObjectsFromIdMap(rawResponse.builders, Builder);
+ buildObjectsFromIdMap(rawResponse.repositories, Repository);
+ buildObjectsFromIdMap(rawResponse.bugTrackers, BugTracker, function (raw) {
+ raw.repositories = raw.repositories.map(function (id) { return Repository.findById(id); });
+ });
+
+ Instrumentation.endMeasuringTime('Manifest', 'constructor');
+
+ return {
+ siteTitle: rawResponse.siteTitle,
+ dashboards: rawResponse.dashboards, // FIXME: Add an abstraction around dashboards.
+ }
+ });
+}
+
+if (document.readyState != 'loading')
+ main();
+else
+ document.addEventListener('DOMContentLoaded', main);
--- /dev/null
+
+class AnalysisTask extends LabeledObject {
+ constructor(id, object)
+ {
+ super(id, object);
+ this._author = object.author;
+ this._createdAt = object.createdAt;
+
+ console.assert(object.platform instanceof Platform);
+ this._platform = object.platform;
+
+ console.assert(object.metric instanceof Metric);
+ this._metric = object.metric;
+
+ this._startMeasurementId = object.startRun;
+ this._startTime = object.startRunTime;
+ this._endMeasurementId = object.endRun;
+ this._endTime = object.endRunTime;
+ this._category = object.category;
+ this._changeType = object.result;
+ this._needed = object.needed;
+ this._bugs = object.bugs || [];
+ this._buildRequestCount = object.buildRequestCount;
+ this._finishedBuildRequestCount = object.finishedBuildRequestCount;
+ }
+
+ static findByPlatformAndMetric(platformId, metricId)
+ {
+ return this.all().filter(function (task) { return task._platform.id() == platformId && task._metric.id() == metricId; });
+ }
+
+ hasResults() { return this._finishedBuildRequestCount; }
+ hasPendingRequests() { return this._finishedBuildRequestCount < this._buildRequestCount; }
+ requestLabel() { return `${this._finishedBuildRequestCount} of ${this._buildRequestCount}`; }
+
+ startTime() { return this._startTime; }
+ endTime() { return this._endTime; }
+
+ author() { return this._author || ''; }
+ createdAt() { return this._createdAt; }
+ bugs() { return this._bugs; }
+ platform() { return this._platform; }
+ metric() { return this._metric; }
+ category() { return this._category; }
+ changeType() { return this._changeType; }
+
+ static categories()
+ {
+ return [
+ 'unconfirmed',
+ 'bisecting',
+ 'identified',
+ 'closed'
+ ];
+ }
+
+ static fetchById(id)
+ {
+ return this._fetchSubset({id: id}).then(function (data) { return AnalysisTask.findById(id); });
+ }
+
+ static fetchByPlatformAndMetric(platformId, metricId)
+ {
+ return this._fetchSubset({platform: platformId, metric: metricId}).then(function (data) {
+ return AnalysisTask.findByPlatformAndMetric(platformId, metricId);
+ });
+ }
+
+ static _fetchSubset(params)
+ {
+ if (this._fetchAllPromise)
+ return this._fetchAllPromise;
+
+ var query = [];
+ for (var key in params)
+ query.push(key + '=' + parseInt(params[key]));
+ var queryString = query.join('&');
+
+ if (!this._fetchSubsetPromises)
+ this._fetchSubsetPromises = {};
+ else {
+ var existingPromise = this._fetchSubsetPromises[queryString];
+ if (existingPromise)
+ return existingPromise;
+ }
+
+ var newPromise = getJSONWithStatus('../api/analysis-tasks?' + queryString).then(this._constructAnalysisTasksFromRawData.bind(this));
+ this._fetchSubsetPromises[queryString] = newPromise;
+
+ return newPromise;
+ }
+
+ static fetchAll()
+ {
+ if (!this._fetchAllPromise)
+ this._fetchAllPromise = getJSONWithStatus('../api/analysis-tasks').then(this._constructAnalysisTasksFromRawData.bind(this));
+ return this._fetchAllPromise;
+ }
+
+ static _constructAnalysisTasksFromRawData(data)
+ {
+ Instrumentation.startMeasuringTime('AnalysisTask', 'construction');
+
+ // FIXME: The backend shouldn't create a separate bug row per task for the same bug number.
+ var taskToBug = {};
+ for (var rawData of data.bugs) {
+ var id = rawData.bugTracker + '-' + rawData.number;
+ rawData.bugTracker = BugTracker.findById(rawData.bugTracker);
+ if (!rawData.bugTracker)
+ continue;
+
+ var bug = Bug.findById(id) || new Bug(id, rawData);
+ if (!taskToBug[rawData.task])
+ taskToBug[rawData.task] = [];
+ taskToBug[rawData.task].push(bug);
+ }
+
+ var results = [];
+ for (var rawData of data.analysisTasks) {
+ rawData.platform = Platform.findById(rawData.platform);
+ rawData.metric = Metric.findById(rawData.metric);
+ if (!rawData.platform || !rawData.metric)
+ continue;
+
+ rawData.bugs = taskToBug[rawData.id];
+ var task = AnalysisTask.findById(rawData.id) || new AnalysisTask(rawData.id, rawData);
+ results.push(task);
+ }
+
+ Instrumentation.endMeasuringTime('AnalysisTask', 'construction');
+
+ return results;
+ }
+
+ static create(name, startRunId, endRunId)
+ {
+ return PrivilegedAPI.sendRequest('create-analysis-task', {
+ name: name,
+ startRun: startRunId,
+ endRun: endRunId,
+ });
+ }
+}
--- /dev/null
+
+class BugTracker extends LabeledObject {
+ constructor(id, object)
+ {
+ super(id, object);
+ this._bugUrl = object.bugUrl;
+ this._newBugUrl = object.newBugUrl;
+ this._repositories = object.repositories;
+ }
+
+ bugUrl(bugNumber) { return this._bugUrl && bugNumber ? this._bugUrl.replace(/\$number/g, bugNumber) : null; }
+}
--- /dev/null
+
+class Bug extends DataModelObject {
+ constructor(id, object)
+ {
+ super(id, object);
+
+ console.assert(object.bugTracker instanceof BugTracker);
+ this._bugTracker = object.bugTracker;
+ this._bugNumber = object.number;
+ }
+
+ bugTracker() { return this._bugTracker; }
+ bugNumber() { return this._bugNumber; }
+ url() { return this._bugTracker.bugUrl(this._bugNumber); }
+ label() { return this.bugNumber(); }
+ title() { return `${this._bugTracker.label()}: ${this.bugNumber()}`; }
+}
--- /dev/null
+
+class Builder extends LabeledObject {
+ constructor(id, object)
+ {
+ super(id, object);
+ this._buildURL = object.buildUrl;
+ }
+}
--- /dev/null
+
+class CommitLog {
+ constructor(repository, rawData)
+ {
+ this._repository = repository;
+ this._rawData = rawData;
+ }
+
+ time() { return new Date(this._rawData['time']); }
+ author() { return this._rawData['authorName']; }
+ revision() { return this._rawData['revision']; }
+ message() { return this._rawData['message']; }
+ url() { return this._repository.urlForRevision(this._rawData['revision']); }
+
+ label()
+ {
+ var revision = this.revision();
+ if (parseInt(revision) == revision) // e.g. r12345
+ return 'r' + revision;
+ else if (revision.length == 40) // e.g. git hash
+ return revision.substring(0, 8);
+ return revision;
+ }
+ title() { return this._repository.name() + ' at ' + this.label(); }
+
+ static fetchBetweenRevisions(repository, from, to)
+ {
+ var params = [];
+ if (from && to) {
+ params.push(['from', from]);
+ params.push(['to', to]);
+ }
+
+ var url = '../api/commits/' + repository.id() + '/?' + params.map(function (keyValue) {
+ return encodeURIComponent(keyValue[0]) + '=' + encodeURIComponent(keyValue[1]);
+ }).join('&');
+
+
+ var cachedLogs = this._cachedCommitLogs(repository, from, to);
+ if (cachedLogs)
+ return new Promise(function (resolve) { resolve(cachedLogs); });
+
+ var self = this;
+ return getJSONWithStatus(url).then(function (data) {
+ var commits = data['commits'].map(function (rawData) { return new CommitLog(repository, rawData); });
+ self._cacheCommitLogs(repository, from, to, commits);
+ return commits;
+ });
+ }
+
+ static _cachedCommitLogs(repository, from, to)
+ {
+ if (!this._caches)
+ return null;
+ var cache = this._caches[repository.id()];
+ if (!cache)
+ return null;
+ // FIXME: Make each commit know of its parent, then we can do a better caching.
+ return cache[from + '|' + to];
+ }
+
+ static _cacheCommitLogs(repository, from, to, logs)
+ {
+ if (!this._caches)
+ this._caches = {};
+ if (!this._caches[repository.id()])
+ this._caches[repository.id()] = {};
+ this._caches[repository.id()][from + '|' + to] = logs;
+ }
+}
--- /dev/null
+
+class DataModelObject {
+ constructor(id)
+ {
+ this._id = id;
+ this.namedStaticMap('id')[id] = this;
+ }
+ id() { return this._id; }
+
+ namedStaticMap(name)
+ {
+ var newTarget = this.__proto__.constructor;
+ if (!newTarget[DataModelObject.StaticMapSymbol])
+ newTarget[DataModelObject.StaticMapSymbol] = {};
+ var staticMap = newTarget[DataModelObject.StaticMapSymbol];
+ if (!staticMap[name])
+ staticMap[name] = [];
+ return staticMap[name];
+ }
+
+ static namedStaticMap(name)
+ {
+ var staticMap = this[DataModelObject.StaticMapSymbol];
+ return staticMap ? staticMap[name] : null;
+ }
+
+ static findById(id)
+ {
+ var idMap = this.namedStaticMap('id');
+ return idMap ? idMap[id] : null;
+ }
+
+ static all()
+ {
+ var list = [];
+ var idMap = this.namedStaticMap('id');
+ if (idMap) {
+ for (var id in idMap)
+ list.push(idMap[id]);
+ }
+ return list;
+ }
+}
+DataModelObject.StaticMapSymbol = Symbol();
+
+class LabeledObject extends DataModelObject {
+ constructor(id, object)
+ {
+ super(id);
+ this._name = object.name;
+ this.namedStaticMap('name')[this._name] = this;
+ }
+
+ static findByName(name)
+ {
+ var nameMap = this.namedStaticMap('id');
+ return nameMap ? nameMap[name] : null;
+ }
+
+ static sortByName(list)
+ {
+ return list.sort(function (a, b) {
+ if (a.name() < b.name())
+ return -1;
+ else if (a.name() > b.name())
+ return 1;
+ return 0;
+ });
+ }
+ sortByName(list) { return LabeledObject.sortByName(list); }
+
+ name() { return this._name; }
+ label() { return this.name(); }
+}
--- /dev/null
+
+class MeasurementCluster {
+ constructor(response)
+ {
+ this._response = response;
+ this._idMap = {};
+
+ var nameMap = {};
+ response['formatMap'].forEach(function (key, index) {
+ nameMap[key] = index;
+ });
+
+ this._idIndex = nameMap['id'];
+ this._commitTimeIndex = nameMap['commitTime'];
+ this._countIndex = nameMap['iterationCount'];
+ this._meanIndex = nameMap['mean'];
+ this._sumIndex = nameMap['sum'];
+ this._squareSumIndex = nameMap['squareSum'];
+ this._markedOutlierIndex = nameMap['markedOutlier'];
+ this._revisionsIndex = nameMap['revisions'];
+ this._buildIndex = nameMap['build'];
+ this._buildTimeIndex = nameMap['buildTime'];
+ this._buildNumberIndex = nameMap['buildNumber'];
+ this._builderIndex = nameMap['builder'];
+ }
+
+ startTime() { return this._response['startTime']; }
+
+ addToSeries(series, configType, includeOutliers, idMap)
+ {
+ var rawMeasurements = this._response['configurations'][configType];
+ if (!rawMeasurements)
+ return;
+
+ var self = this;
+ rawMeasurements.forEach(function (row) {
+ var id = row[self._idIndex];
+ if (id in idMap)
+ return;
+ if (row[self._markedOutlierIndex] && !includeOutliers)
+ return;
+
+ idMap[id] = true;
+
+ var mean = row[self._meanIndex];
+ var sum = row[self._sumIndex];
+ var squareSum = row[self._squareSumIndex];
+ series._series.push({
+ id: id,
+ _rawMeasurement: row,
+ measurement: function () {
+ // Create a new Measurement class that doesn't require mimicking what runs.php generates.
+ var revisionsMap = {};
+ for (var revisionRow of row[self._revisionsIndex])
+ revisionsMap[revisionRow[0]] = revisionRow.slice(1);
+ return new Measurement({
+ id: id,
+ mean: mean,
+ sum: sum,
+ squareSum: squareSum,
+ revisions: revisionsMap,
+ build: row[self._buildIndex],
+ buildTime: row[self._buildTimeIndex],
+ buildNumber: row[self._buildNumberIndex],
+ builder: row[self._builderIndex],
+ });
+ },
+ series: series,
+ seriesIndex: series._series.length,
+ time: row[self._commitTimeIndex],
+ value: mean,
+ interval: self._computeConfidenceInterval(row[self._countIndex], mean, sum, squareSum)
+ });
+ });
+ }
+
+ _computeConfidenceInterval(iterationCount, mean, sum, squareSum)
+ {
+ var delta = Statistics.confidenceIntervalDelta(0.95, iterationCount, sum, squareSum);
+ return isNaN(delta) ? null : [mean - delta, mean + delta];
+ }
+}
--- /dev/null
+
+class MeasurementSet {
+ constructor(platformId, metricId, lastModified)
+ {
+ this._platformId = platformId;
+ this._metricId = metricId;
+ this._lastModified = +lastModified;
+
+ this._waitingForPrimaryCluster = null;
+ this._fetchedPrimary = false;
+ this._endTimeToCallback = {};
+
+ this._sortedClusters = [];
+ this._primaryClusterEndTime = null;
+ this._clusterCount = null;
+ this._clusterStart = null;
+ this._clusterSize = null;
+ }
+
+ static findSet(platformId, metricId, lastModified)
+ {
+ if (!this._set)
+ this._set = {};
+ var key = platformId + '-' + metricId;
+ if (!this._set[key])
+ this._set[key] = new MeasurementSet(platformId, metricId, lastModified);
+ return this._set[key];
+ }
+
+ findClusters(startTime, endTime)
+ {
+ var clusterStart = this._clusterStart;
+ var clusterSize = this._clusterSize;
+ console.assert(clusterStart && clusterSize);
+
+ function computeClusterStart(time) {
+ var diff = time - clusterStart;
+ return clusterStart + Math.floor(diff / clusterSize) * clusterSize;
+ }
+
+ var clusters = [];
+ var clusterEnd = computeClusterStart(startTime);
+
+ var lastClusterEndTime = this._primaryClusterEndTime;
+ var firstClusterEndTime = lastClusterEndTime - clusterStart * this._clusterCount;
+ do {
+ clusterEnd += clusterSize;
+ if (firstClusterEndTime <= clusterEnd && clusterEnd <= this._primaryClusterEndTime)
+ clusters.push(clusterEnd);
+ } while (clusterEnd < endTime);
+
+ return clusters;
+ }
+
+ fetchBetween(startTime, endTime, callback)
+ {
+ if (!this._fetchedPrimary) {
+ var primaryFetchHadFailed = this._waitingForPrimaryCluster === false;
+ if (primaryFetchHadFailed) {
+ callback();
+ return;
+ }
+
+ var shouldStartPrimaryFetch = !this._waitingForPrimaryCluster;
+ if (shouldStartPrimaryFetch)
+ this._waitingForPrimaryCluster = [];
+
+ this._waitingForPrimaryCluster.push({startTime: startTime, endTime: endTime, callback: callback});
+
+ if (shouldStartPrimaryFetch)
+ this._fetch(null, true);
+
+ return;
+ }
+
+ this._fetchSecondaryClusters(startTime, endTime, callback);
+ }
+
+ _fetchSecondaryClusters(startTime, endTime, callback)
+ {
+ console.assert(this._fetchedPrimary);
+ console.assert(this._clusterStart && this._clusterSize);
+ console.assert(this._sortedClusters.length);
+
+ var clusters = this.findClusters(startTime, endTime);
+ var shouldInvokeCallackNow = false;
+ for (var endTime of clusters) {
+ var isPrimaryCluster = endTime == this._primaryClusterEndTime;
+ var shouldStartFetch = !isPrimaryCluster && !(endTime in this._endTimeToCallback);
+ if (shouldStartFetch)
+ this._endTimeToCallback[endTime] = [];
+
+ var callbackList = this._endTimeToCallback[endTime];
+ if (isPrimaryCluster || callbackList === true)
+ shouldInvokeCallackNow = true;
+ else if (!callbackList.includes(callback))
+ callbackList.push(callback);
+
+ if (shouldStartFetch) {
+ console.assert(!shouldInvokeCallackNow);
+ this._fetch(endTime, true);
+ }
+ }
+
+ if (shouldInvokeCallackNow)
+ callback();
+ }
+
+ _fetch(clusterEndTime, useCache)
+ {
+ console.assert(!clusterEndTime || useCache);
+
+ var url;
+ if (useCache) {
+ url = `../data/measurement-set-${this._platformId}-${this._metricId}`;
+ if (clusterEndTime)
+ url += '-' + +clusterEndTime;
+ url += '.json';
+ } else
+ url = `../api/measurement-set?platform=${this._platformId}&metric=${this._metricId}`;
+
+ var self = this;
+
+ return getJSONWithStatus(url).then(function (data) {
+ if (!clusterEndTime && useCache && +data['lastModified'] < self._lastModified)
+ self._fetch(clusterEndTime, false);
+ else
+ self._didFetchJSON(!clusterEndTime, data);
+ }, function (error, xhr) {
+ if (!clusterEndTime && error == 404 && useCache)
+ self._fetch(clusterEndTime, false);
+ else
+ self._failedToFetchJSON(clusterEndTime, error);
+ });
+ }
+
+ _didFetchJSON(isPrimaryCluster, response, clusterEndTime)
+ {
+ console.assert(isPrimaryCluster || this._fetchedPrimary);
+
+ if (isPrimaryCluster) {
+ this._primaryClusterEndTime = response['endTime'];
+ this._clusterCount = response['clusterCount'];
+ this._clusterStart = response['clusterStart'];
+ this._clusterSize = response['clusterSize'];
+ } else
+ console.assert(this._primaryClusterEndTime);
+
+ this._addFetchedCluster(new MeasurementCluster(response));
+
+ console.assert(this._waitingForPrimaryCluster);
+ if (!isPrimaryCluster) {
+ this._invokeCallbacks(response.endTime);
+ return;
+ }
+ console.assert(this._waitingForPrimaryCluster instanceof Array);
+
+ this._fetchedPrimary = true;
+ for (var entry of this._waitingForPrimaryCluster)
+ this._fetchSecondaryClusters(entry.startTime, entry.endTime, entry.callback);
+ this._waitingForPrimaryCluster = true;
+ }
+
+ _failedToFetchJSON(clusterEndTime, error)
+ {
+ if (clusterEndTime) {
+ this._invokeCallbacks(clusterEndTime);
+ return;
+ }
+
+ console.assert(!this._fetchedPrimary);
+ console.assert(this._waitingForPrimaryCluster instanceof Array);
+ for (var entry of this._waitingForPrimaryCluster)
+ entry.callback();
+ this._waitingForPrimaryCluster = false;
+ }
+
+ _invokeCallbacks(clusterEndTime)
+ {
+ var callbackList = this._endTimeToCallback[clusterEndTime];
+ for (var callback of callbackList)
+ callback();
+ this._endTimeToCallback[clusterEndTime] = true;
+ }
+
+ _addFetchedCluster(cluster)
+ {
+ this._sortedClusters.push(cluster);
+ this._sortedClusters = this._sortedClusters.sort(function (c1, c2) {
+ return c1.startTime() - c2.startTime();
+ });
+ }
+
+ fetchedTimeSeries(configType, includeOutliers, extendToFuture)
+ {
+ Instrumentation.startMeasuringTime('MeasurementSet', 'fetchedTimeSeries');
+
+ // FIXME: Properly construct TimeSeries.
+ var series = new TimeSeries([]);
+ var idMap = {};
+ for (var cluster of this._sortedClusters)
+ cluster.addToSeries(series, configType, includeOutliers, idMap);
+
+ if (extendToFuture && series._series.length) {
+ var lastPoint = series._series[series._series.length - 1];
+ series._series.push({
+ series: series,
+ seriesIndex: series._series.length,
+ measurement: null,
+ time: Date.now() + 24 * 3600 * 1000,
+ value: lastPoint.value,
+ interval: lastPoint.interval,
+ });
+ }
+
+ Instrumentation.endMeasuringTime('MeasurementSet', 'fetchedTimeSeries');
+
+ return series;
+ }
+}
+
+TimeSeries.prototype.findById = function (id)
+{
+ return this._series.find(function (point) { return point.id == id });
+}
+
+TimeSeries.prototype.dataBetweenPoints = function (firstPoint, lastPoint)
+{
+ var data = this._series;
+ var filteredData = [];
+ for (var i = firstPoint.seriesIndex; i <= lastPoint.seriesIndex; i++) {
+ if (!data[i].markedOutlier)
+ filteredData.push(data[i]);
+ }
+ return filteredData;
+}
+
+TimeSeries.prototype.firstPoint = function ()
+{
+ if (!this._series || !this._series.length)
+ return null;
+ return this._series[0];
+}
--- /dev/null
+
+class Metric extends LabeledObject {
+ constructor(id, object)
+ {
+ super(id, object);
+ this._aggregatorName = object.aggregator;
+ object.test.addMetric(this);
+ this._test = object.test;
+ this._platforms = [];
+ }
+
+ aggregatorName() { return this._aggregatorName; }
+
+ test() { return this._test; }
+ platforms() { return this._platforms; }
+
+ addPlatform(platform)
+ {
+ console.assert(platform instanceof Platform);
+ this._platforms.push(platform);
+ }
+
+ childMetrics()
+ {
+ var metrics = [];
+ for (var childTest of this._test.childTests()) {
+ for (var childMetric of childTest.metrics())
+ metrics.push(childMetric);
+ }
+ return metrics;
+ }
+
+ path() { return this._test.path().concat([this]); }
+
+ fullName()
+ {
+ return this._test.path().map(function (test) { return test.label(); }).join(' \u220B ') + ' : ' + this.label();
+ }
+
+ label()
+ {
+ var suffix = '';
+ switch (this._aggregatorName) {
+ case null:
+ break;
+ case 'Arithmetic':
+ suffix = ' : Arithmetic mean';
+ break;
+ case 'Geometric':
+ suffix = ' : Geometric mean';
+ break;
+ case 'Harmonic':
+ suffix = ' : Harmonic mean';
+ break;
+ case 'Total':
+ default:
+ suffix = ' : ' + this._aggregatorName;
+ }
+ return this.name() + suffix;
+ }
+
+ unit() { return RunsData.unitFromMetricName(this.name()); }
+ isSmallerBetter() { return RunsData.isSmallerBetter(this.unit()); }
+
+ makeFormatter(sigFig, alwaysShowSign)
+ {
+ var unit = this.unit();
+ var isMiliseconds = false;
+ if (unit == 'ms') {
+ isMiliseconds = true;
+ unit = 's';
+ }
+ var divisor = unit == 'B' ? 1024 : 1000;
+
+ var suffix = ['\u03BC', 'm', '', 'K', 'M', 'G', 'T', 'P', 'E'];
+ var threshold = sigFig >= 3 ? divisor : (divisor / 10);
+ return function (value) {
+ var i;
+ var sign = value >= 0 ? (alwaysShowSign ? '+' : '') : '-';
+ value = Math.abs(value);
+ for (i = isMiliseconds ? 1 : 2; value < 1 && i > 0; i--)
+ value *= divisor;
+ for (; value >= threshold; i++)
+ value /= divisor;
+ return sign + value.toPrecision(Math.max(2, sigFig)) + ' ' + suffix[i] + (unit || '');
+ }
+ };
+}
--- /dev/null
+
+class Platform extends LabeledObject {
+ constructor(id, object)
+ {
+ super(id, object);
+ this._metrics = object.metrics;
+ this._lastModifiedByMetric = object.lastModifiedByMetric;
+ this._containingTests = null;
+
+ for (var metric of this._metrics)
+ metric.addPlatform(this);
+ }
+
+ hasTest(test)
+ {
+ if (!this._containingTests) {
+ this._containingTests = {};
+ for (var metric of this._metrics) {
+ for (var test = metric.test(); test; test = test.parentTest()) {
+ if (test.id() in this._containingTests)
+ break;
+ this._containingTests[test.id()] = true;
+ }
+ }
+ }
+ return this._containingTests[test.id()];
+ }
+
+ hasMetric(metric) { return !!this.lastModified(metric); }
+
+ lastModified(metric)
+ {
+ console.assert(metric instanceof Metric);
+ return this._lastModifiedByMetric[metric.id()];
+ }
+}
--- /dev/null
+
+class Repository extends LabeledObject {
+ constructor(id, object)
+ {
+ super(id, object);
+ this._url = object.url;
+ this._blameUrl = object.blameUrl;
+ this._hasReportedCommits = object.hasReportedCommits;
+ }
+
+ urlForRevision(currentRevision)
+ {
+ return (this._url || '').replace(/\$1/g, currentRevision);
+ }
+
+ urlForRevisionRange(from, to)
+ {
+ return (this._blameUrl || '').replace(/\$1/g, from).replace(/\$2/g, to);
+ }
+}
--- /dev/null
+
+class Test extends LabeledObject {
+ constructor(id, object, isTopLevel)
+ {
+ super(id, object);
+ this._url = object.url; // FIXME: Unused
+ this._parent = null;
+ this._childTests = [];
+ this._metrics = [];
+
+ if (isTopLevel)
+ this.namedStaticMap('topLevelTests').push(this);
+ }
+
+ static topLevelTests() { return this.sortByName(this.namedStaticMap('topLevelTests')); }
+
+ parentTest() { return this._parent; }
+
+ path()
+ {
+ var path = [];
+ var currentTest = this;
+ while (currentTest) {
+ path.unshift(currentTest);
+ currentTest = currentTest.parentTest();
+ }
+ return path;
+ }
+
+ onlyContainsSingleMetric() { return !this._childTests.length && this._metrics.length == 1; }
+
+ // FIXME: We should store the child test order in the server.
+ childTests() { return this._childTests; }
+ metrics() { return this._metrics; }
+
+ setParentTest(parent)
+ {
+ parent.addChildTest(this);
+ this._parent = parent;
+ }
+
+ addChildTest(test) { this._childTests.push(test); }
+ addMetric(metric) { this._metrics.push(metric); }
+}
--- /dev/null
+
+class AnalysisCategoryPage extends PageWithHeading {
+ constructor()
+ {
+ super('Analysis', new AnalysisCategoryToolbar);
+ this.toolbar().setFilterCallback(this.render.bind(this));
+ this._renderedList = false;
+ this._renderedFilter = false;
+ this._fetched = false;
+ this._errorMessage = null;
+ }
+
+ title()
+ {
+ var category = this.toolbar().currentCategory();
+ return (category ? category.charAt(0).toUpperCase() + category.slice(1) + ' ' : '') + 'Analysis Tasks';
+ }
+ routeName() { return 'analysis'; }
+
+ open(state)
+ {
+ var self = this;
+ AnalysisTask.fetchAll().then(function () {
+ self._fetched = true;
+ self.render();
+ }, function (error) {
+ self._errorMessage = 'Failed to fetch the list of analysis tasks: ' + error;
+ self.render();
+ });
+ super.open(state);
+ }
+
+ updateFromSerializedState(state, isOpen)
+ {
+ if (this.toolbar().setCategoryIfValid(state.category))
+ this._renderedList = false;
+
+ if (!isOpen)
+ this.render();
+ }
+
+ render()
+ {
+ Instrumentation.startMeasuringTime('AnalysisCategoryPage', 'render');
+
+ if (!this._renderedList) {
+ super.render();
+ this.toolbar().render();
+ }
+
+ if (this._errorMessage) {
+ console.assert(!this._fetched);
+ var element = ComponentBase.createElement;
+ this.renderReplace(this.content().querySelector('tbody.analysis-tasks'),
+ element('tr',
+ element('td', {colspan: 6}, this._errorMessage)));
+ this._renderedList = true;
+ return;
+ }
+
+ if (!this._fetched)
+ return;
+
+ if (!this._renderedList) {
+ this._reconstructTaskList();
+ this._renderedList = true;
+ }
+
+ var filter = this.toolbar().filter();
+ if (filter || this._renderedFilter) {
+ Instrumentation.startMeasuringTime('AnalysisCategoryPage', 'filterByKeywords');
+ var keywordList = filter ? filter.toLowerCase().split(/\s+/) : [];
+ var tableRows = this.content().querySelectorAll('tbody.analysis-tasks tr');
+ for (var i = 0; i < tableRows.length; i++) {
+ var row = tableRows[i];
+ var textContent = row.textContent.toLowerCase();
+ var display = null;
+ for (var keyword of keywordList) {
+ if (textContent.indexOf(keyword) < 0) {
+ display = 'none';
+ break;
+ }
+ }
+ row.style.display = display;
+ }
+ this._renderedFilter = !!filter;
+ Instrumentation.endMeasuringTime('AnalysisCategoryPage', 'filterByKeywords');
+ }
+
+ Instrumentation.endMeasuringTime('AnalysisCategoryPage', 'render');
+ }
+
+ _reconstructTaskList()
+ {
+ Instrumentation.startMeasuringTime('AnalysisCategoryPage', 'reconstructTaskList');
+
+ console.assert(this.router());
+ var currentCategory = this.toolbar().currentCategory();
+
+ var tasks = AnalysisTask.all().filter(function (task) {
+ return task.category() == currentCategory;
+ }).sort(function (a, b) {
+ if (a.hasPendingRequests() == b.hasPendingRequests())
+ return b.createdAt() - a.createdAt();
+ else if (a.hasPendingRequests()) // a < b
+ return -1;
+ else if (b.hasPendingRequests()) // a > b
+ return 1;
+ return 0;
+ });
+
+ var element = ComponentBase.createElement;
+ var link = ComponentBase.createLink;
+ var router = this.router();
+ this.renderReplace(this.content().querySelector('tbody.analysis-tasks'),
+ tasks.map(function (task) {
+ var status = AnalysisCategoryPage._computeStatus(task);
+ return element('tr', [
+ element('td', {class: 'status'},
+ element('span', {class: status.class}, status.label)),
+ element('td', link(task.label(), router.url(`analysis/task/${task.id()}`))),
+ element('td', {class: 'bugs'},
+ element('ul', task.bugs().map(function (bug) {
+ var url = bug.url();
+ var title = bug.title();
+ return element('li', url ? link(bug.label(), title, url, true) : title);
+ }))),
+ element('td', {class: 'author'}, task.author()),
+ element('td', {class: 'platform'}, task.platform().label()),
+ element('td', task.metric().fullName()),
+ ]);
+ }));
+
+ Instrumentation.endMeasuringTime('AnalysisCategoryPage', 'reconstructTaskList');
+ }
+
+ static _computeStatus(task)
+ {
+ if (task.hasPendingRequests())
+ return {label: task.requestLabel(), class: 'bisecting'};
+
+ var type = task.changeType();
+ switch (type) {
+ case 'regression':
+ return {label: 'Regression', class: type};
+ case 'progression':
+ return {label: 'Progression', class: type};
+ case 'unchanged':
+ return {label: 'No change', class: type};
+ case 'inconclusive':
+ return {label: 'Inconclusive', class: type};
+ }
+
+ if (task.hasResults())
+ return {label: 'New results', class: 'bisecting'};
+
+ return {label: 'Unconfirmed', class: 'unconfirmed'};
+ }
+
+
+ static htmlTemplate()
+ {
+ return `
+ <div class="analysis-task-category">
+ <table>
+ <thead>
+ <tr>
+ <td class="status">Status</td>
+ <td>Name</td>
+ <td>Bugs</td>
+ <td>Author</td>
+ <td>Platform</td>
+ <td>Test Metric</td>
+ </tr>
+ </thead>
+ <tbody class="analysis-tasks"></tbody>
+ </table>
+ </div>`;
+ }
+
+ static cssTemplate()
+ {
+ return `
+ .analysis-task-category {
+ width: calc(100% - 2rem);
+ margin: 1rem;
+ }
+
+ .analysis-task-category table {
+ width: 100%;
+ border: 0;
+ border-collapse: collapse;
+ }
+
+ .analysis-task-category td,
+ .analysis-task-category th {
+ border: none;
+ border-collapse: collapse;
+ text-align: left;
+ font-size: 0.9rem;
+ font-weight: normal;
+ }
+
+ .analysis-task-category thead td {
+ color: #f96;
+ font-weight: inherit;
+ font-size: 1.1rem;
+ padding: 0.2rem 0.4rem;
+ }
+
+ .analysis-task-category tbody td {
+ border-top: solid 1px #eee;
+ border-bottom: solid 1px #eee;
+ padding: 0.2rem 0.2rem;
+ }
+
+ .analysis-task-category .bugs ul,
+ .analysis-task-category .bugs li {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ }
+
+ .analysis-task-category .status,
+ .analysis-task-category .author,
+ .analysis-task-category .platform {
+ text-align: center;
+ }
+
+ .analysis-task-category .status span {
+ display: inline;
+ white-space: nowrap;
+ border-radius: 0.3rem;
+ padding: 0.2rem 0.3rem;
+ font-size: 0.8rem;
+ }
+
+ .analysis-task-category .status .regression {
+ background: #c33;
+ color: #fff;
+ }
+
+ .analysis-task-category .status .progression {
+ background: #36f;
+ color: #fff;
+ }
+
+ .analysis-task-category .status .unchanged {
+ background: #ccc;
+ color: white;
+ }
+
+ .analysis-task-category .status .inconclusive {
+ background: #666;
+ color: white;
+ }
+
+ .analysis-task-category .status .bisecting {
+ background: #ff9;
+ }
+
+ .analysis-task-category .status .unconfirmed {
+ background: #f96;
+ }
+`;
+ }
+}
--- /dev/null
+
+class AnalysisCategoryToolbar extends Toolbar {
+ constructor()
+ {
+ super();
+ this._categories = AnalysisTask.categories();
+ this._currentCategory = null;
+ this._filter = null;
+ this._filterCallback = null;
+ this.setCategoryIfValid(null);
+ }
+
+ currentCategory() { return this._currentCategory; }
+
+ filter() { return this._filter; }
+ setFilterCallback(callback)
+ {
+ console.assert(!callback || callback instanceof Function);
+ this._filterCallback = callback;
+ }
+
+ render()
+ {
+ var router = this.router();
+ console.assert(router);
+
+ var currentPage = router.currentPage();
+ console.assert(currentPage instanceof AnalysisCategoryPage);
+
+ super.render();
+
+ var element = ComponentBase.createElement;
+ var link = ComponentBase.createLink;
+
+ var input = element('input',
+ {
+ oninput: this._filterMayHaveChanged.bind(this),
+ onchange: this._filterMayHaveChanged.bind(this),
+ });
+ if (this._filter != null)
+ input.value = this._filter;
+
+ var currentCategory = this._currentCategory;
+ this.renderReplace(this.content().querySelector('.analysis-task-category-toolbar'), [
+ element('ul', {class: 'buttoned-toolbar'},
+ this._categories.map(function (category) {
+ return element('li',
+ {class: category == currentCategory ? 'selected' : null},
+ link(category, router.url(currentPage.routeName(), {category: category})));
+ })),
+ input]);
+ }
+
+ _filterMayHaveChanged(event)
+ {
+ var input = event.target;
+ var oldFilter = this._filter;
+ this._filter = input.value;
+ if (this._filter != oldFilter && this._filterCallback)
+ this._filterCallback(this._filter);
+ }
+
+ setCategoryIfValid(category)
+ {
+ if (!category)
+ category = this._categories[0];
+ if (this._categories.indexOf(category) < 0)
+ return false;
+ this._currentCategory = category;
+
+ var filterDidChange = !!this._filter;
+ this._filter = null;
+ if (filterDidChange && this._filterCallback)
+ this._filterCallback(this._filter);
+
+ return true;
+ }
+
+ static htmlTemplate()
+ {
+ return `<div class="buttoned-toolbar analysis-task-category-toolbar"></div>`;
+ }
+}
--- /dev/null
+
+class AnalysisTaskPage extends PageWithHeading {
+ constructor()
+ {
+ super('Analysis Task');
+ this._taskId = null;
+ this._task = null;
+ this._errorMessage = null;
+ }
+
+ title() { return this._task ? this._task.label() : 'Analysis Task'; }
+ routeName() { return 'analysis/task'; }
+
+ updateFromSerializedState(state)
+ {
+ var taskId = parseInt(state.remainingRoute);
+ if (taskId != state.remainingRoute) {
+ this._errorMessage = `Invalid analysis task ID: ${state.remainingRoute}`;
+ return;
+ }
+
+ var self = this;
+ AnalysisTask.fetchById(taskId).then(function (task) {
+ self._task = task;
+ self.render();
+ });
+ }
+
+ render()
+ {
+ super.render();
+
+ Instrumentation.startMeasuringTime('AnalysisTaskPage', 'render');
+
+ this.content().querySelector('.error-message').textContent = this._errorMessage || '';
+
+ var v2URL = location.href.replace('/v3/', '/v2/');
+ this.content().querySelector('.overview-chart').innerHTML = `Not ready. Use <a href="${v2URL}">v2 page</a> for now.`;
+
+ if (this._task) {
+ this.renderReplace(this.content().querySelector('.analysis-task-name'), this._task.name());
+ }
+
+ Instrumentation.endMeasuringTime('AnalysisTaskPage', 'render');
+ }
+
+ static htmlTemplate()
+ {
+ return `
+ <h2 class="analysis-task-name"></h2>
+ <p class="error-message"></p>
+ <div class="overview-chart"></div>
+`;
+ }
+
+ static cssTemplate()
+ {
+ return `
+ .analysis-task-name {
+ font-size: 1.2rem;
+ font-weight: inherit;
+ color: #c93;
+ margin: 0 1rem;
+ padding: 0;
+ }
+
+ .error-message:not(:empty) {
+ margin: 1rem;
+ padding: 0;
+ }
+
+ .overview-chart {
+ width: auto;
+ height: 10rem;
+ margin: 1rem;
+ border: solid 0px red;
+ }
+`;
+ }
+}
--- /dev/null
+
+class ChartPaneStatusView extends ChartStatusView {
+
+ constructor(metric, chart, router, revisionCallback)
+ {
+ super(metric, chart);
+
+ this._router = router;
+ this._revisionList = [];
+ this._currentRepository = null;
+ this._revisionCallback = revisionCallback;
+ this._analyzeData = null;
+
+ this._renderedRevisionList = null;
+ this._renderedRepository = null;
+
+ this._usedRevisionRange = null;
+ }
+
+ analyzeData() { return this._analyzeData; }
+
+ render()
+ {
+ super.render();
+
+ if (this._renderedRevisionList == this._revisionList && this._renderedRepository == this._currentRepository)
+ return;
+ this._renderedRevisionList = this._revisionList;
+ this._renderedRepository = this._currentRepository;
+
+ var element = ComponentBase.createElement;
+ var link = ComponentBase.createLink;
+ var self = this;
+ this.renderReplace(this.content().querySelector('.chart-pane-revisions'),
+ this._revisionList.map(function (info, rowIndex) {
+ var selected = info.repository == self._currentRepository;
+ var action = function () {
+ if (self._currentRepository == info.repository)
+ self._setRevisionRange(null, null, null);
+ else
+ self._setRevisionRange(info.repository, info.from, info.to);
+ };
+
+ return element('tr', {class: selected ? 'selected' : '', onclick: action}, [
+ element('td', info.name),
+ element('td', info.url ? link(info.label, info.label, info.url, true) : info.label),
+ element('td', {class: 'commit-viewer-opener'}, link('\u00BB', action)),
+ ]);
+ }));
+ }
+
+ setCurrentRepository(repository)
+ {
+ this._currentRepository = repository;
+ this._forceRender = true;
+ }
+
+ _setRevisionRange(repository, from, to)
+ {
+ if (this._usedRevisionRange && this._usedRevisionRange[0] == repository
+ && this._usedRevisionRange[1] == from && this._usedRevisionRange[2] == to)
+ return;
+ this._usedRevisionRange = [repository, from, to];
+ this._revisionCallback(repository, from, to);
+ }
+
+ moveRepositoryWithNotification(forward)
+ {
+ var currentRepository = this._currentRepository;
+ if (!currentRepository)
+ return false;
+ var index = this._revisionList.findIndex(function (info) { return info.repository == currentRepository; });
+ console.assert(index >= 0);
+
+ var newIndex = index + (forward ? 1 : -1);
+ newIndex = Math.min(this._revisionList.length - 1, Math.max(0, newIndex));
+ if (newIndex == index)
+ return false;
+
+ var info = this._revisionList[newIndex];
+ this.setCurrentRepository(info.repository);
+ }
+
+ updateRevisionListWithNotification()
+ {
+ if (!this._currentRepository)
+ return;
+
+ this.updateStatusIfNeeded();
+
+ this._forceRender = true;
+ for (var info of this._revisionList) {
+ if (info.repository == this._currentRepository) {
+ this._setRevisionRange(info.repository, info.from, info.to);
+ return;
+ }
+ }
+ this._setRevisionRange(this._currentRepository, null, null);
+ }
+
+ computeChartStatusLabels(currentPoint, previousPoint)
+ {
+ super.computeChartStatusLabels(currentPoint, previousPoint);
+
+ if (!currentPoint || !currentPoint.measurement) {
+ this._revisionList = [];
+ this._analyzeData = null;
+ return;
+ }
+
+ if (currentPoint && previousPoint && this._chart.currentSelection()) {
+ this._analyzeData = {
+ startPointId: previousPoint.id,
+ endPointId: currentPoint.id,
+ };
+ } else
+ this._analyzeData = null;
+
+ // FIXME: Rewrite the interface to obtain the list of revision changes.
+ var previousMeasurement = previousPoint ? previousPoint.measurement() : null;
+ var currentMeasurement = currentPoint.measurement();
+
+ var revisions = currentMeasurement.formattedRevisions(previousMeasurement);
+
+ var revisionList = [];
+ for (var repositoryId in revisions) {
+ var repository = Repository.findById(repositoryId);
+ var revision = revisions[repositoryId];
+ var url = revision.previousRevision ? repository.urlForRevisionRange(revision.previousRevision, revision.currentRevision) : '';
+ if (!url)
+ url = repository.urlForRevision(revision.currentRevision);
+
+ revisionList.push({
+ from: revision.previousRevision,
+ to: revision.currentRevision,
+ repository: repository,
+ name: repository.name(),
+ label: revision.label,
+ url: url,
+ });
+ }
+
+ // Sort by repository names preferring ones with URL.
+ revisionList = revisionList.sort(function (a, b) {
+ if (!!a.url == !!b.url) {
+ if (a.name > b.name)
+ return 1;
+ else if (a.name < b.name)
+ return -1;
+ return 0;
+ } else if (b.url) // a > b
+ return 1;
+ return -1;
+ });
+
+ this._revisionList = revisionList;
+ }
+
+ static htmlTemplate()
+ {
+ return `
+ <div class="chart-pane-status">
+ <h3 class="chart-status-current-value"></h3>
+ <span class="chart-status-comparison"></span>
+ </div>
+ <table class="chart-pane-revisions"></table>
+ `;
+ }
+
+ static cssTemplate()
+ {
+ return Toolbar.cssTemplate() + ChartStatusView.cssTemplate() + `
+ .chart-pane-status {
+ display: block;
+ text-align: center;
+ }
+
+ .chart-pane-status .chart-status-current-value,
+ .chart-pane-status .chart-status-comparison {
+ display: block;
+ margin: 0;
+ padding: 0;
+ font-weight: normal;
+ font-size: 1rem;
+ }
+
+ .chart-pane-revisions {
+ line-height: 1rem;
+ font-size: 0.9rem;
+ font-weight: normal;
+ padding: 0;
+ margin: 0;
+ margin-top: 0.5rem;
+ border-bottom: solid 1px #ccc;
+ border-collapse: collapse;
+ width: 100%;
+ }
+
+ .chart-pane-revisions th,
+ .chart-pane-revisions td {
+ font-weight: inherit;
+ border-top: solid 1px #ccc;
+ padding: 0.2rem 0.2rem;
+ }
+
+ .chart-pane-revisions .selected > th,
+ .chart-pane-revisions .selected > td {
+ background: rgba(204, 153, 51, 0.1);
+ }
+
+ .chart-pane-revisions .commit-viewer-opener {
+ width: 1rem;
+ }
+
+ .chart-pane-revisions .commit-viewer-opener a {
+ text-decoration: none;
+ color: inherit;
+ font-weight: inherit;
+ }
+ `;
+ }
+}
--- /dev/null
+
+class ChartPane extends ComponentBase {
+ constructor(chartsPage, platformId, metricId)
+ {
+ super('chart-pane');
+
+ this._chartsPage = chartsPage;
+ this._platformId = platformId;
+ this._metricId = metricId;
+
+ var result = ChartsPage.createChartSourceList(platformId, metricId);
+ this._errorMessage = result.error;
+ this._platform = result.platform;
+ this._metric = result.metric;
+
+ this._overviewChart = null;
+ this._mainChart = null;
+ this._mainChartStatus = null;
+ this._mainSelection = null;
+ this._mainChartIndicatorWasLocked = false;
+ this._status = null;
+ this._revisions = null;
+
+ this._paneOpenedByClick = null;
+
+ this._commitLogViewer = this.content().querySelector('commit-log-viewer').component();
+ this.content().querySelector('close-button').component().setCallback(chartsPage.closePane.bind(chartsPage));
+
+ if (result.error)
+ return;
+
+ var formatter = result.metric.makeFormatter(3);
+ var self = this;
+
+ var overviewOptions = ChartsPage.overviewChartOptions(formatter);
+ overviewOptions.selection.onchange = function (domain, didEndDrag) {
+ self._chartsPage.setMainDomainFromOverviewSelection(domain, self, didEndDrag);
+ }
+
+ this._overviewChart = new InteractiveTimeSeriesChart(result.sourceList, overviewOptions);
+ this.renderReplace(this.content().querySelector('.chart-pane-overview'), this._overviewChart);
+
+ var mainOptions = ChartsPage.mainChartOptions(formatter);
+ mainOptions.indicator.onchange = this._indicatorDidChange.bind(this);
+ mainOptions.selection.onchange = this._mainSelectionDidChange.bind(this);
+ mainOptions.selection.onzoom = this._mainSelectionDidZoom.bind(this);
+ mainOptions.annotations.onclick = this._openAnalysisTask.bind(this);
+ mainOptions.ondata = this._didFetchData.bind(this);
+ this._mainChart = new InteractiveTimeSeriesChart(result.sourceList, mainOptions);
+ this.renderReplace(this.content().querySelector('.chart-pane-main'), this._mainChart);
+
+ this._mainChartStatus = new ChartPaneStatusView(result.metric, this._mainChart, chartsPage.router(), this._openCommitViewer.bind(this));
+ this.renderReplace(this.content().querySelector('.chart-pane-details'), this._mainChartStatus);
+
+ this.content().querySelector('.chart-pane').addEventListener('keyup', this._keyup.bind(this));
+ this._fetchAnalysisTasks();
+ }
+
+ _fetchAnalysisTasks()
+ {
+ var self = this;
+ AnalysisTask.fetchByPlatformAndMetric(this._platformId, this._metricId).then(function (tasks) {
+ self._mainChart.setAnnotations(tasks.map(function (task) {
+ var fillStyle = '#fc6';
+ switch (task.changeType()) {
+ case 'inconclusive':
+ fillStyle = '#fcc';
+ case 'progression':
+ fillStyle = '#39f';
+ break;
+ case 'regression':
+ fillStyle = '#c60';
+ break;
+ case 'unchanged':
+ fillStyle = '#ccc';
+ break;
+ }
+
+ return {
+ task: task,
+ startTime: task.startTime(),
+ endTime: task.endTime(),
+ label: task.label(),
+ fillStyle: fillStyle,
+ };
+ }));
+ });
+ }
+
+ platformId() { return this._platformId; }
+ metricId() { return this._metricId; }
+
+ serializeState()
+ {
+ var selection = this._mainChart ? this._mainChart.currentSelection() : null;
+ var point = this._mainChart ? this._mainChart.currentPoint() : null;
+ return [
+ this._platformId,
+ this._metricId,
+ selection || (point && this._mainChartIndicatorWasLocked ? point.id : null),
+ ];
+ }
+
+ updateFromSerializedState(state, isOpen)
+ {
+ if (!this._mainChart)
+ return;
+
+ var selectionOrIndicatedPoint = state[2];
+ if (selectionOrIndicatedPoint instanceof Array)
+ this._mainChart.setSelection([parseFloat(selectionOrIndicatedPoint[0]), parseFloat(selectionOrIndicatedPoint[1])]);
+ else if (typeof(selectionOrIndicatedPoint) == 'number') {
+ this._mainChart.setIndicator(selectionOrIndicatedPoint, true);
+ this._mainChartIndicatorWasLocked = true;
+ } else
+ this._mainChart.setIndicator(null, false);
+ }
+
+ setOverviewDomain(startTime, endTime)
+ {
+ if (this._overviewChart)
+ this._overviewChart.setDomain(startTime, endTime);
+ }
+
+ setOverviewSelection(selection)
+ {
+ if (this._overviewChart)
+ this._overviewChart.setSelection(selection);
+ }
+
+ setMainDomain(startTime, endTime)
+ {
+ if (this._mainChart)
+ this._mainChart.setDomain(startTime, endTime);
+ }
+
+ _mainSelectionDidChange(selection, didEndDrag)
+ {
+ this._chartsPage.mainChartSelectionDidChange(this, didEndDrag);
+ this.render();
+ }
+
+ _mainSelectionDidZoom(selection)
+ {
+ this._overviewChart.setSelection(selection, this);
+ this._mainChart.setSelection(null);
+ this._chartsPage.setMainDomainFromZoom(selection, this);
+ this.render();
+ }
+
+ _openAnalysisTask(annotation)
+ {
+ window.open(this._chartsPage.router().url(`analysis/task/${annotation.task.id()}`), '_blank');
+ }
+
+ _indicatorDidChange(indicatorID, isLocked)
+ {
+ this._chartsPage.mainChartIndicatorDidChange(this, isLocked || this._mainChartIndicatorWasLocked);
+ this._mainChartIndicatorWasLocked = isLocked;
+ this._mainChartStatus.updateRevisionListWithNotification();
+ this.render();
+ }
+
+ _openCommitViewer(repository, from, to)
+ {
+ var self = this;
+ this._commitLogViewer.view(repository, from, to).then(function () {
+ self._mainChartStatus.setCurrentRepository(self._commitLogViewer.currentRepository());
+ self.render();
+ });
+ }
+
+ _didFetchData()
+ {
+ this._mainChartStatus.updateRevisionListWithNotification();
+ this.render();
+ }
+
+ _keyup(event)
+ {
+ switch (event.keyCode) {
+ case 37: // Left
+ if (!this._mainChart.moveLockedIndicatorWithNotification(false))
+ return;
+ break;
+ case 39: // Right
+ if (!this._mainChart.moveLockedIndicatorWithNotification(true))
+ return;
+ break;
+ case 38: // Up
+ if (!this._mainChartStatus.moveRepositoryWithNotification(false))
+ return;
+ case 40: // Down
+ if (!this._mainChartStatus.moveRepositoryWithNotification(true))
+ return;
+ default:
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ render()
+ {
+ Instrumentation.startMeasuringTime('ChartPane', 'render');
+
+ super.render();
+
+ if (this._platform && this._metric) {
+ var metric = this._metric;
+ var platform = this._platform;
+
+ this.renderReplace(this.content().querySelector('.chart-pane-title'),
+ metric.fullName() + ' on ' + platform.name());
+ }
+
+ if (this._errorMessage) {
+ this.renderReplace(this.content().querySelector('.chart-pane-main'), this._errorMessage);
+ return;
+ }
+
+ if (this._mainChartStatus) {
+ this._mainChartStatus.render();
+ this._renderActionToolbar(this._mainChartStatus.analyzeData());
+ }
+
+ var body = this.content().querySelector('.chart-pane-body');
+ if (this._commitLogViewer.currentRepository()) {
+ body.classList.add('has-second-sidebar');
+ this._commitLogViewer.render();
+ } else
+ body.classList.remove('has-second-sidebar');
+
+ Instrumentation.endMeasuringTime('ChartPane', 'render');
+ }
+
+ _renderActionToolbar(analyzeData)
+ {
+ var actions = [];
+ var platform = this._platform;
+ var metric = this._metric;
+
+ var element = ComponentBase.createElement;
+ var link = ComponentBase.createLink;
+ var self = this;
+
+ if (this._chartsPage.canBreakdown(platform, metric)) {
+ actions.push(element('li', link('Breakdown', function () {
+ self._chartsPage.insertBreakdownPanesAfter(platform, metric, self);
+ })));
+ }
+
+ var platformPane = this.content().querySelector('.chart-pane-alternative-platforms');
+ var alternativePlatforms = this._chartsPage.alternatePlatforms(platform, metric);
+ if (alternativePlatforms.length) {
+ this.renderReplace(platformPane, Platform.sortByName(alternativePlatforms).map(function (platform) {
+ return element('li', link(platform.label(), function () {
+ self._chartsPage.insertPaneAfter(platform, metric, self);
+ }));
+ }));
+
+ actions.push(element('li', {class: this._paneOpenedByClick == platformPane ? 'selected' : ''},
+ this._makeAnchorToOpenPane(platformPane, 'Other Platforms', true)));
+ } else {
+ platformPane.style.display = 'none';
+ }
+
+ var analyzePane = this.content().querySelector('.chart-pane-analyze-pane');
+ if (analyzeData) {
+ actions.push(element('li', {class: this._paneOpenedByClick == analyzePane ? 'selected' : ''},
+ this._makeAnchorToOpenPane(analyzePane, 'Analyze', false)));
+
+ var router = this._chartsPage.router();
+ analyzePane.onsubmit = function (event) {
+ event.preventDefault();
+ var newWindow = window.open(router.url('analysis/task/create'), '_blank');
+
+ var name = analyzePane.querySelector('input').value;
+ AnalysisTask.create(name, analyzeData.startPointId, analyzeData.endPointId).then(function (data) {
+ newWindow.location.href = router.url('analysis/task/' + data['taskId']);
+ // FIXME: Refetch the list of analysis tasks.
+ }, function (error) {
+ newWindow.location.href = router.url('analysis/task/create', {error: error});
+ });
+ }
+ } else {
+ analyzePane.style.display = 'none';
+ analyzePane.onsubmit = function (event) { event.preventDefault(); }
+ }
+
+ this._paneOpenedByClick = null;
+ this.renderReplace(this.content().querySelector('.chart-pane-action-buttons'), actions);
+ }
+
+ _makeAnchorToOpenPane(pane, label, shouldRespondToHover)
+ {
+ var anchor = null;
+ var ignoreMouseLeave = false;
+ var self = this;
+ var setPaneVisibility = function (pane, shouldShow) {
+ var anchor = pane.anchor;
+ if (shouldShow) {
+ var width = anchor.offsetParent.offsetWidth;
+ pane.style.top = anchor.offsetTop + anchor.offsetHeight + 'px';
+ pane.style.right = (width - anchor.offsetLeft - anchor.offsetWidth) + 'px';
+ }
+ pane.style.display = shouldShow ? null : 'none';
+ anchor.parentNode.className = shouldShow ? 'selected' : '';
+ if (self._paneOpenedByClick == pane && !shouldShow)
+ self._paneOpenedByClick = null;
+ }
+
+ var attributes = {
+ href: '#',
+ onclick: function (event) {
+ event.preventDefault();
+ var shouldShowPane = pane.style.display == 'none';
+ if (shouldShowPane) {
+ if (self._paneOpenedByClick)
+ setPaneVisibility(self._paneOpenedByClick, false);
+ self._paneOpenedByClick = pane;
+ }
+ setPaneVisibility(pane, shouldShowPane);
+ },
+ };
+ if (shouldRespondToHover) {
+ var mouseIsInAnchor = false;
+ var mouseIsInPane = false;
+
+ attributes.onmouseenter = function () {
+ if (self._paneOpenedByClick)
+ return;
+ mouseIsInAnchor = true;
+ setPaneVisibility(pane, true);
+ }
+ attributes.onmouseleave = function () {
+ setTimeout(function () {
+ if (!mouseIsInPane)
+ setPaneVisibility(pane, false);
+ }, 0);
+ mouseIsInAnchor = false;
+ }
+
+ pane.onmouseleave = function () {
+ setTimeout(function () {
+ if (!mouseIsInAnchor)
+ setPaneVisibility(pane, false);
+ }, 0);
+ mouseIsInPane = false;
+ }
+ pane.onmouseenter = function () {
+ mouseIsInPane = true;
+ }
+ }
+
+ var anchor = ComponentBase.createElement('a', attributes, label);
+ pane.anchor = anchor;
+ return anchor;
+ }
+
+ static htmlTemplate()
+ {
+ return `
+ <section class="chart-pane" tabindex="0">
+ <header class="chart-pane-header">
+ <h2 class="chart-pane-title">-</h2>
+ <nav class="chart-pane-actions">
+ <ul>
+ <li class="close"><close-button></close-button></li>
+ </ul>
+ <ul class="chart-pane-action-buttons buttoned-toolbar"></ul>
+ <ul class="chart-pane-alternative-platforms" style="display:none"></ul>
+ <form class="chart-pane-analyze-pane" style="display:none">
+ <input type="text" required>
+ <button>Create</button>
+ </form>
+ </nav>
+ </header>
+ <div class="chart-pane-body">
+ <div class="chart-pane-main"></div>
+ <div class="chart-pane-sidebar">
+ <div class="chart-pane-overview"></div>
+ <div class="chart-pane-details"></div>
+ </div>
+ <div class="chart-pane-second-sidebar">
+ <commit-log-viewer></commit-log-viewer>
+ </div>
+ </div>
+ </section>
+`;
+ }
+
+ static cssTemplate()
+ {
+ return Toolbar.cssTemplate() + `
+ .chart-pane {
+ margin: 1rem;
+ margin-bottom: 2rem;
+ padding: 0rem;
+ height: 18rem;
+ border: solid 1px #ccc;
+ border-radius: 0.5rem;
+ outline: none;
+ }
+
+ .chart-pane:focus .chart-pane-header {
+ background: rgba(204, 153, 51, 0.1);
+ }
+
+ .chart-pane-header {
+ position: relative;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 2rem;
+ line-height: 2rem;
+ border-bottom: solid 1px #ccc;
+ }
+
+ .chart-pane-title {
+ margin: 0 0.5rem;
+ padding: 0;
+ padding-left: 1.5rem;
+ font-size: 1rem;
+ font-weight: inherit;
+ }
+
+ .chart-pane-actions {
+ position: absolute;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ height: 2rem;
+ top: 0;
+ padding: 0 0;
+ }
+
+ .chart-pane-actions ul {
+ display: block;
+ padding: 0;
+ margin: 0 0.5rem;
+ font-size: 1rem;
+ line-height: 1rem;
+ list-style: none;
+ }
+
+ .chart-pane-actions .chart-pane-action-buttons {
+ font-size: 0.9rem;
+ line-height: 0.9rem;
+ }
+
+ .chart-pane-actions .chart-pane-alternative-platforms,
+ .chart-pane-analyze-pane {
+ position: absolute;
+ top: 0;
+ right: 0;
+ border: solid 1px #ccc;
+ border-radius: 0.2rem;
+ z-index: 10;
+ background: rgba(255, 255, 255, 0.8);
+ -webkit-backdrop-filter: blur(0.5rem);
+ padding: 0.2rem 0;
+ margin: 0;
+ margin-top: -0.2rem;
+ margin-right: -0.2rem;
+ }
+
+ .chart-pane-alternative-platforms li {
+ }
+
+ .chart-pane-alternative-platforms li a {
+ display: block;
+ text-decoration: none;
+ color: inherit;
+ font-size: 0.9rem;
+ padding: 0.2rem 0.5rem;
+ }
+
+ .chart-pane-alternative-platforms a:hover,
+ .chart-pane-analyze-pane input:focus {
+ background: rgba(204, 153, 51, 0.1);
+ }
+
+ .chart-pane-analyze-pane {
+ padding: 0.5rem;
+ }
+
+ .chart-pane-analyze-pane input {
+ font-size: 1rem;
+ width: 15rem;
+ outline: none;
+ border: solid 1px #ccc;
+ }
+
+ .chart-pane-body {
+ position: relative;
+ width: 100%;
+ height: calc(100% - 2rem);
+ }
+
+ .chart-pane-main {
+ padding-right: 20rem;
+ height: 100%;
+ margin: 0;
+ vertical-align: middle;
+ text-align: center;
+ }
+
+ .has-second-sidebar .chart-pane-main {
+ padding-right: 40rem;
+ }
+
+ .chart-pane-main > * {
+ width: 100%;
+ height: 100%;
+ }
+
+ .chart-pane-sidebar,
+ .chart-pane-second-sidebar {
+ position: absolute;
+ right: 0;
+ top: 0;
+ width: 0;
+ border-left: solid 1px #ccc;
+ height: 100%;
+ }
+
+ :not(.has-second-sidebar) > .chart-pane-second-sidebar {
+ border-left: 0;
+ }
+
+ .chart-pane-sidebar {
+ width: 20rem;
+ }
+
+ .has-second-sidebar .chart-pane-sidebar {
+ right: 20rem;
+ }
+
+ .has-second-sidebar .chart-pane-second-sidebar {
+ width: 20rem;
+ }
+
+ .chart-pane-overview {
+ width: 100%;
+ height: 5rem;
+ border-bottom: solid 1px #ccc;
+ }
+
+ .chart-pane-overview > * {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+
+ .chart-pane-details {
+ position: relative;
+ display: block;
+ height: calc(100% - 5.5rem - 2px);
+ overflow-y: scroll;
+ padding-top: 0.5rem;
+ }
+`;
+ }
+}
--- /dev/null
+
+class ChartsPage extends PageWithCharts {
+ constructor(toolbar)
+ {
+ console.assert(toolbar instanceof ChartsToolbar);
+ super('Charts', toolbar);
+ this._paneList = [];
+ this._paneListChanged = false;
+ this._mainDomain = null;
+
+ toolbar.setAddPaneCallback(this.insertPaneAfter.bind(this));
+ }
+
+ routeName() { return 'charts'; }
+
+ static createStateForDashboardItem(platformId, metricId)
+ {
+ var state = {paneList: [[platformId, metricId]]};
+ return state;
+ }
+
+ open(state)
+ {
+ this.toolbar().setNumberOfDaysCallback(this.setNumberOfDaysFromToolbar.bind(this));
+ super.open(state);
+ }
+
+ serializeState()
+ {
+ var state = {since: this.toolbar().startTime()};
+ var serializedPaneList = [];
+ for (var pane of this._paneList)
+ serializedPaneList.push(pane.serializeState());
+
+ if (this._mainDomain)
+ state['zoom'] = this._mainDomain;
+ if (serializedPaneList.length)
+ state['paneList'] = serializedPaneList;
+
+ return state;
+ }
+
+ updateFromSerializedState(state, isOpen)
+ {
+ var paneList = [];
+ if (state.paneList instanceof Array)
+ paneList = state.paneList;
+
+ var newPaneList = this._updateChartPanesFromSerializedState(paneList);
+ if (newPaneList) {
+ this._paneList = newPaneList;
+ this._paneListChanged = true;
+ this.render();
+ }
+
+ this._updateDomainsFromSerializedState(state);
+
+ console.assert(this._paneList.length == paneList.length);
+ for (var i = 0; i < this._paneList.length; i++)
+ this._paneList[i].updateFromSerializedState(state.paneList[i], isOpen);
+ }
+
+ _updateDomainsFromSerializedState(state)
+ {
+ var since = parseFloat(state.since);
+ var zoom = state.zoom;
+ if (typeof(zoom) == 'string' && zoom.indexOf('-'))
+ zoom = zoom.split('-')
+ if (zoom instanceof Array && zoom.length >= 2)
+ zoom = zoom.map(function (value) { return parseFloat(value); });
+
+ if (zoom && since)
+ since = Math.min(zoom[0], since);
+ else if (zoom)
+ since = zoom[0] - (zoom[1] - zoom[0]) / 2;
+
+ this.toolbar().setStartTime(since);
+ this.toolbar().render();
+
+ this._mainDomain = zoom || null;
+
+ this._updateOverviewDomain();
+ this._updateMainDomain();
+ }
+
+ _updateChartPanesFromSerializedState(paneList)
+ {
+ var paneMap = {}
+ for (var pane of this._paneList)
+ paneMap[pane.platformId() + '-' + pane.metricId()] = pane;
+
+ var newPaneList = [];
+ var createdNewPane = false;
+ for (var paneInfo of paneList) {
+ var platformId = parseInt(paneInfo[0]);
+ var metricId = parseInt(paneInfo[1]);
+ var existingPane = paneMap[platformId + '-' + metricId];
+ if (existingPane)
+ newPaneList.push(existingPane);
+ else {
+ newPaneList.push(new ChartPane(this, platformId, metricId));
+ createdNewPane = true;
+ }
+ }
+
+ if (createdNewPane || newPaneList.length !== this._paneList.length)
+ return newPaneList;
+
+ for (var i = 0; i < newPaneList.length; i++) {
+ if (newPaneList[i] != this._paneList[i])
+ return newPaneList;
+ }
+
+ return null;
+ }
+
+ setNumberOfDaysFromToolbar(numberOfDays, shouldUpdateState)
+ {
+ this.toolbar().setNumberOfDays(numberOfDays, true);
+ this.toolbar().render();
+ this._updateOverviewDomain();
+ this._updateMainDomain();
+ if (shouldUpdateState)
+ this.scheduleUrlStateUpdate();
+ }
+
+ setMainDomainFromOverviewSelection(domain, originatingPane, shouldUpdateState)
+ {
+ this._mainDomain = domain;
+ this._updateMainDomain(originatingPane);
+ if (shouldUpdateState)
+ this.scheduleUrlStateUpdate();
+ }
+
+ setMainDomainFromZoom(selection, originatingPane)
+ {
+ this._mainDomain = selection;
+ this._updateMainDomain(originatingPane);
+ this.scheduleUrlStateUpdate();
+ }
+
+ mainChartSelectionDidChange(pane, shouldUpdateState)
+ {
+ if (shouldUpdateState)
+ this.scheduleUrlStateUpdate();
+ }
+
+ mainChartIndicatorDidChange(pane, shouldUpdateState)
+ {
+ if (shouldUpdateState)
+ this.scheduleUrlStateUpdate();
+ }
+
+ _updateOverviewDomain()
+ {
+ var startTime = this.toolbar().startTime();
+ var endTime = this.toolbar().endTime();
+ for (var pane of this._paneList)
+ pane.setOverviewDomain(startTime, endTime);
+ }
+
+ _updateMainDomain(originatingPane)
+ {
+ var startTime = this.toolbar().startTime();
+ var endTime = this.toolbar().endTime();
+ if (this._mainDomain) {
+ startTime = this._mainDomain[0];
+ endTime = this._mainDomain[1];
+ }
+
+ for (var pane of this._paneList) {
+ pane.setMainDomain(startTime, endTime);
+ if (pane != originatingPane) // Don't mess up the selection state.
+ pane.setOverviewSelection(this._mainDomain);
+ }
+ }
+
+ closePane(pane)
+ {
+ var index = this._paneList.indexOf(pane);
+ console.assert(index >= 0);
+ this._paneList.splice(index, 1);
+ this._didMutatePaneList(false);
+ }
+
+ insertPaneAfter(platform, metric, referencePane)
+ {
+ var newPane = new ChartPane(this, platform.id(), metric.id());
+ if (referencePane) {
+ var index = this._paneList.indexOf(referencePane);
+ console.assert(index >= 0);
+ this._paneList.splice(index + 1, 0, newPane);
+ } else
+ this._paneList.unshift(newPane);
+ this._didMutatePaneList(true);
+ }
+
+ alternatePlatforms(platform, metric)
+ {
+ var existingPlatforms = {};
+ for (var pane of this._paneList) {
+ if (pane.metricId() == metric.id())
+ existingPlatforms[pane.platformId()] = true;
+ }
+
+ return metric.platforms().filter(function (platform) {
+ return !existingPlatforms[platform.id()];
+ });
+ }
+
+ insertBreakdownPanesAfter(platform, metric, referencePane)
+ {
+ console.assert(referencePane);
+ var childMetrics = metric.childMetrics();
+
+ var index = this._paneList.indexOf(referencePane);
+ console.assert(index >= 0);
+ var args = [index + 1, 0];
+
+ for (var metric of childMetrics)
+ args.push(new ChartPane(this, platform.id(), metric.id()));
+
+ this._paneList.splice.apply(this._paneList, args);
+ this._didMutatePaneList(true);
+ }
+
+ canBreakdown(platform, metric)
+ {
+ var childMetrics = metric.childMetrics();
+ if (!childMetrics.length)
+ return false;
+
+ var existingMetrics = {};
+ for (var pane of this._paneList) {
+ if (pane.platformId() == platform.id())
+ existingMetrics[pane.metricId()] = true;
+ }
+
+ for (var metric of childMetrics) {
+ if (!existingMetrics[metric.id()])
+ return true;
+ }
+
+ return false;
+ }
+
+ _didMutatePaneList(addedNewPane)
+ {
+ this._paneListChanged = true;
+ if (addedNewPane) {
+ this._updateOverviewDomain();
+ this._updateMainDomain();
+ }
+ this.render();
+ this.scheduleUrlStateUpdate();
+ }
+
+ render()
+ {
+ super.render();
+
+ if (this._paneListChanged)
+ this.renderReplace(this.content().querySelector('.pane-list'), this._paneList);
+
+ for (var pane of this._paneList)
+ pane.render();
+
+ this._paneListChanged = false;
+ }
+
+ static htmlTemplate()
+ {
+ return `<div class="pane-list"></div>`;
+ }
+
+}
--- /dev/null
+
+class ChartsToolbar extends DomainControlToolbar {
+ constructor()
+ {
+ super('chars-toolbar', 7);
+
+ this._numberOfDaysCallback = null;
+ this._inputElement = this.content().querySelector('input');
+ this._labelSpan = this.content().querySelector('.day-count');
+
+ this._inputElement.addEventListener('change', this._inputValueMayHaveChanged.bind(this));
+ this._inputElement.addEventListener('mousemove', this._inputValueMayHaveChanged.bind(this));
+
+ this._addPaneCallback = null;
+ this._paneSelector = this.content().querySelector('pane-selector').component();
+ this._paneSelectorOpener = this.content().querySelector('.pane-selector-opener');
+ this._paneSelectorContainer = this.content().querySelector('.pane-selector-container');
+
+ this._paneSelector.setCallback(this._addPane.bind(this));
+ this._paneSelectorOpener.addEventListener('click', this._togglePaneSelector.bind(this));
+
+ var self = this;
+ this._paneSelectorOpener.addEventListener('mouseenter', function () {
+ self._openPaneSelector(false);
+ temporarilyIgnoreMouseleave = true;
+ setTimeout(function () { temporarilyIgnoreMouseleave = false; }, 0);
+ });
+ this._paneSelectorContainer.style.display = 'none';
+
+ var temporarilyIgnoreMouseleave = false;
+ this._paneSelectorContainer.addEventListener('mousemove', function () {
+ temporarilyIgnoreMouseleave = true; // Workaround webkit.org/b/152170
+ setTimeout(function () { temporarilyIgnoreMouseleave = false; }, 0);
+ });
+ this._paneSelectorContainer.addEventListener('mouseleave', function (event) {
+ setTimeout(function () {
+ if (!temporarilyIgnoreMouseleave)
+ self._closePaneSelector();
+ }, 0);
+ });
+ }
+
+ render()
+ {
+ super.render();
+ this._paneSelector.render();
+ this._labelSpan.textContent = this._numberOfDays;
+ this._inputElement.value = this._numberOfDays;
+ }
+
+ setNumberOfDaysCallback(callback)
+ {
+ console.assert(!callback || callback instanceof Function);
+ this._numberOfDaysCallback = callback;
+ }
+
+ setAddPaneCallback(callback)
+ {
+ console.assert(!callback || callback instanceof Function);
+ this._addPaneCallback = callback;
+ }
+
+ setStartTime(startTime)
+ {
+ if (startTime)
+ super.setStartTime(startTime);
+ else
+ super.setNumberOfDays(7);
+ }
+
+ _inputValueMayHaveChanged(event)
+ {
+ var numberOfDays = parseInt(this._inputElement.value);
+ if (this.numberOfDays() != numberOfDays && this._numberOfDaysCallback)
+ this._numberOfDaysCallback(numberOfDays, event.type == 'change');
+ }
+
+
+ _togglePaneSelector(event)
+ {
+ event.preventDefault();
+ if (this._paneSelectorContainer.style.display == 'none')
+ this._openPaneSelector(true);
+ else
+ this._closePaneSelector();
+ }
+
+ _openPaneSelector(shouldFocus)
+ {
+ var opener = this._paneSelectorOpener;
+ var container = this._paneSelectorContainer;
+ opener.parentNode.className = 'selected';
+ var right = container.parentNode.offsetWidth - (opener.offsetLeft + opener.offsetWidth);
+
+ container.style.display = 'block';
+ container.style.right = right + 'px';
+
+ if (shouldFocus)
+ this._paneSelector.focus();
+ }
+
+ _closePaneSelector()
+ {
+ this._paneSelectorOpener.parentNode.className = '';
+ this._paneSelectorContainer.style.display = 'none';
+ }
+
+ _addPane(platform, metric)
+ {
+ if (!this._addPaneCallback)
+ return;
+
+ this._closePaneSelector();
+ this._addPaneCallback(platform, metric);
+ }
+
+
+ static htmlTemplate()
+ {
+ return `
+ <nav class="charts-toolbar">
+ <ul class="buttoned-toolbar">
+ <li><a href="#" class="pane-selector-opener">Add pane</a></li>
+ </ul>
+ <ul class="buttoned-toolbar">
+ <li class="start-time-slider"><label><input type="range" min="7" max="365" step="1"><span class="day-count">?</span> days</label></li>
+ </ul>
+ <div class="pane-selector-container">
+ <pane-selector></pane-selector>
+ </div>
+ </nav>`;
+ }
+
+ static cssTemplate()
+ {
+ return Toolbar.cssTemplate() + `
+
+ .buttoned-toolbar li a.pane-selector-opener:hover {
+ background: rgba(204, 153, 51, 0.1);
+ }
+
+ .charts-toolbar > .pane-selector-container {
+ position: absolute;
+ right: 1rem;
+ margin: 0;
+ margin-top: -0.2rem;
+ margin-right: -0.5rem;
+ padding: 1rem;
+ border: solid 1px #ccc;
+ border-radius: 0.2rem;
+ background: rgba(255, 255, 255, 0.8);
+ -webkit-backdrop-filter: blur(0.5rem);
+ }
+
+ .start-time-slider {
+ line-height: 1em;
+ font-size: 0.9rem;
+ }
+
+ .start-time-slider label {
+ display: inline-block;
+ }
+
+ .start-time-slider input {
+ height: 0.8rem;
+ }
+
+ .start-time-slider .numberOfDays {
+ display: inline-block;
+ text-align: right;
+ width: 1.5rem;
+ }
+ `;
+ }
+
+}
--- /dev/null
+
+class CreateAnalysisTaskPage extends PageWithHeading {
+ constructor()
+ {
+ super('Create Analysis Task');
+ this._errorMessage = null;
+ }
+
+ title() { return 'Creating a New Analysis Task'; }
+ routeName() { return 'analysis/task/create'; }
+
+ updateFromSerializedState(state, isOpen)
+ {
+ this._errorMessage = state.error;
+ if (!isOpen)
+ this.render();
+ }
+
+ render()
+ {
+ super.render();
+ console.log(this._errorMessage)
+ if (this._errorMessage)
+ this.content().querySelector('.message').textContent = this._errorMessage;
+ }
+
+ static htmlTemplate()
+ {
+ return `
+ <div class="create-analysis-task-container">
+ <p class="message">Creating the analysis task page...</p>
+ </div>
+`;
+ }
+
+ static cssTemplate()
+ {
+ return `
+ .create-analysis-task-container {
+ display: flex;
+ }
+
+ .create-analysis-task-container > * {
+ margin: 1rem auto;
+ display: inline-block;
+ }
+
+ .create-analysis-task input {
+ font-size: inherit;
+ }
+`;
+ }
+}
--- /dev/null
+
+class DashboardPage extends PageWithCharts {
+ constructor(name, table, toolbar)
+ {
+ console.assert(toolbar instanceof DashboardToolbar);
+ super(name, toolbar);
+ this._table = table;
+ this._needsTableConstruction = true;
+ this._needsStatusUpdate = true;
+ this._statusViews = [];
+
+ this._startTime = Date.now() - 60 * 24 * 3600 * 1000;
+ this._endTime = Date.now();
+
+ this._tableGroups = null;
+ }
+
+ routeName() { return `dashboard/${this._name}`; }
+
+ serializeState()
+ {
+ return {numberOfDays: this.toolbar().numberOfDays()};
+ }
+
+ updateFromSerializedState(state, isOpen)
+ {
+ if (!isOpen || state.numberOfDays) {
+ this.toolbar().setNumberOfDays(state.numberOfDays);
+ this._numberOfDaysDidChange(isOpen);
+ }
+ this._updateChartsDomainFromToolbar();
+ }
+
+ _numberOfDaysDidChange(isOpen)
+ {
+ if (isOpen)
+ return;
+
+ this.toolbar().render();
+ this.heading().render(); // Update links for other dashboards.
+ }
+
+ _updateChartsDomainFromToolbar()
+ {
+ var startTime = this.toolbar().startTime();
+ var endTime = this.toolbar().endTime();
+ for (var chart of this._charts)
+ chart.setDomain(startTime, endTime);
+ }
+
+ open(state)
+ {
+ if (!this._tableGroups) {
+ var columnCount = 0;
+ var tableGroups = [];
+ for (var row of this._table) {
+ if (!row.some(function (cell) { return cell instanceof Array; })) {
+ tableGroups.push([]);
+ row = [''].concat(row);
+ }
+ tableGroups[tableGroups.length - 1].push(row);
+ columnCount = Math.max(columnCount, row.length);
+ }
+
+ for (var group of tableGroups) {
+ for (var row of group) {
+ for (var i = 0; i < row.length; i++) {
+ if (row[i] instanceof Array)
+ row[i] = this._createChartForCell(row[i]);
+ }
+ while (row.length < columnCount)
+ row.push([]);
+ }
+ }
+
+ this._tableGroups = tableGroups;
+ }
+
+ super.open(state);
+ }
+
+ render()
+ {
+ super.render();
+
+ console.assert(this._tableGroups);
+
+ var element = ComponentBase.createElement;
+ var link = ComponentBase.createLink;
+
+ if (this._needsTableConstruction) {
+ var tree = [];
+ for (var group of this._tableGroups) {
+ tree.push(element('thead', element('tr',
+ group[0].map(function (cell) { return element('td', cell.content || cell); }))));
+
+ tree.push(element('tbody', group.slice(1).map(function (row) {
+ return element('tr', row.map(function (cell, cellIndex) {
+ if (!cellIndex)
+ return element('th', element('span', {class: 'vertical-label'}, cell));
+
+ if (!cell.chart)
+ return element('td', cell);
+
+ return element('td', [cell.statusView, link(cell.chart.element(), cell.label, cell.url)]);
+ }));
+ })));
+ }
+
+ this.renderReplace(this.content().querySelector('.dashboard-table'), tree);
+ this._needsTableConstruction = false;
+ }
+
+ if (this._needsStatusUpdate) {
+ for (var statusView of this._statusViews)
+ statusView.render();
+ this._needsStatusUpdate = false;
+ }
+ }
+
+ _createChartForCell(cell)
+ {
+ console.assert(this.router());
+
+ var platformId = cell[0];
+ var metricId = cell[1];
+ if (!platformId || !metricId)
+ return '';
+
+ var result = DashboardPage.createChartSourceList(platformId, metricId);
+ if (result.error)
+ return result.error;
+
+ var options = DashboardPage.dashboardOptions(result.metric.makeFormatter(3));
+ options.ondata = this._fetchedData.bind(this);
+ var chart = new TimeSeriesChart(result.sourceList, options);
+ this._charts.push(chart);
+
+ var statusView = new ChartStatusView(result.metric, chart);
+ this._statusViews.push(statusView);
+
+ return {
+ chart: chart,
+ statusView: statusView,
+ metric: result.metric,
+ label: result.metric.fullName() + ' on ' + result.platform.label(),
+ url: this.router().url('charts', ChartsPage.createStateForDashboardItem(platformId, metricId))};
+ }
+
+ _fetchedData()
+ {
+ if (this._needsStatusUpdate)
+ return;
+
+ this._needsStatusUpdate = true;
+ setTimeout(this.render.bind(this), 10);
+ }
+
+ static htmlTemplate()
+ {
+ return `<section class="page-with-heading"><table class="dashboard-table"></table></section>`;
+ }
+
+ static cssTemplate()
+ {
+ return `
+ .dashboard-table td,
+ .dashboard-table th {
+ border: none;
+ text-align: center;
+ }
+
+ .dashboard-table th,
+ .dashboard-table thead td {
+ color: #f96;
+ font-weight: inherit;
+ font-size: 1.1rem;
+ text-align: center;
+ padding: 0.2rem 0.4rem;
+ }
+ .dashboard-table th {
+ height: 10rem;
+ width: 2rem;
+ position: relative;
+ }
+ .dashboard-table th .vertical-label {
+ position: absolute;
+ left: 0;
+ right: 0;
+ display: block;
+ -webkit-transform: rotate(-90deg) translate(-50%, 0);
+ -webkit-transform-origin: 0 0;
+ transform: rotate(-90deg) translate(-50%, 0);
+ transform-origin: 0 0;
+ width: 10rem;
+ height: 2rem;
+ line-height: 1.8rem;
+ }
+ table.dashboard-table {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ }
+ .dashboard-table td > * {
+ display: inline-block;
+ width: 20rem;
+ }
+
+ .dashboard-table td *:first-child {
+ margin: 0 0 0.2rem 0;
+ }
+ `;
+ }
+}
--- /dev/null
+
+class DashboardToolbar extends DomainControlToolbar {
+ constructor()
+ {
+ var options = [
+ {label: '1D', days: 1},
+ {label: '1W', days: 7},
+ {label: '1M', days: 30},
+ {label: '3M', days: 90},
+ ];
+ super('dashboard-toolbar', options[1].days);
+ this._options = options;
+ this._currentOption = this._options[1];
+ }
+
+ setNumberOfDays(days)
+ {
+ if (!days)
+ days = 7;
+ this._currentOption = this._options[this._options.length - 1];
+ for (var option of this._options) {
+ if (days <= option.days) {
+ this._currentOption = option;
+ break;
+ }
+ }
+ super.setNumberOfDays(this._currentOption.days);
+ }
+
+ render()
+ {
+ console.assert(this.router());
+
+ var currentPage = this.router().currentPage();
+ console.assert(currentPage);
+ console.assert(currentPage instanceof DashboardPage);
+
+ super.render();
+
+ var element = ComponentBase.createElement;
+ var link = ComponentBase.createLink;
+
+ var self = this;
+ var router = this.router();
+ var currentOption = this._currentOption;
+ this.renderReplace(this.content().querySelector('.dashboard-toolbar'),
+ element('ul', {class: 'buttoned-toolbar'},
+ this._options.map(function (option) {
+ return element('li', {
+ class: option == currentOption ? 'selected' : '',
+ }, link(option.label, router.url(currentPage.routeName(), {numberOfDays: option.days})));
+ })
+ ));
+ }
+
+ static htmlTemplate()
+ {
+ return `<div class="dashboard-toolbar"></div>`;
+ }
+
+}
--- /dev/null
+
+class DomainControlToolbar extends Toolbar {
+ constructor(name, numberOfDays)
+ {
+ super(name);
+ this._startTime = null;
+ this._numberOfDays = numberOfDays;
+ this._setByUser = false;
+ this._callback = null;
+ this._present = Date.now();
+ this._millisecondsPerDay = 24 * 3600 * 1000;
+ }
+
+ startTime() { return this._startTime || (this._present - this._numberOfDays * this._millisecondsPerDay); }
+ endTime() { return this._present; }
+ setByUser() { return this._setByUser; }
+
+ setStartTime(startTime)
+ {
+ this.setNumberOfDays(Math.max(1, Math.ceil((this._present - startTime) / this._millisecondsPerDay)));
+ this._startTime = startTime;
+ }
+
+ numberOfDays()
+ {
+ return this._numberOfDays;
+ }
+
+ setNumberOfDays(numberOfDays, setByUser)
+ {
+ if (!numberOfDays)
+ return;
+
+ this._startTime = null;
+ this._numberOfDays = numberOfDays;
+ this._setByUser = !!setByUser;
+ }
+
+}
--- /dev/null
+
+class Heading extends ComponentBase {
+ constructor()
+ {
+ super('page-heading');
+ this._title = '';
+ this._pageGroups = [];
+ this._renderedOnce = false;
+ this._toolbar = null;
+ this._toolbarChanged = false;
+ this._router = null;
+ }
+
+ title() { return this._title; }
+ setTitle(title) { this._title = title; }
+
+ addPageGroup(group)
+ {
+ for (var page of group)
+ page.setHeading(this);
+ this._pageGroups.push(group);
+ }
+
+ setToolbar(toolbar)
+ {
+ console.assert(!toolbar || toolbar instanceof Toolbar);
+ this._toolbar = toolbar;
+ if (toolbar)
+ toolbar.setRouter(this._router);
+
+ this._toolbarChanged = true;
+ }
+
+ setRouter(router)
+ {
+ this._router = router;
+ if (this._toolbar)
+ this._toolbar.setRouter(router);
+ }
+
+ render()
+ {
+ console.assert(this._router);
+ super.render();
+
+ if (this._toolbarChanged) {
+ this.renderReplace(this.content().querySelector('.heading-toolbar'),
+ this._toolbar ? this._toolbar.element() : null);
+ this._toolbarChanged = false;
+ }
+
+ if (this._toolbar)
+ this._toolbar.render();
+
+ // Workaround the bounding rects being 0x0 when the content is empty.
+ if (this._renderedOnce && !Heading.isElementInViewport(this.element()))
+ return;
+
+ var title = this.content().querySelector('.heading-title a');
+ title.textContent = this._title;
+
+ var element = ComponentBase.createElement;
+ var link = ComponentBase.createLink;
+ var router = this._router;
+
+ var currentPage = this._router.currentPage();
+ this.renderReplace(this.content().querySelector('.heading-navigation-list'),
+ this._pageGroups.map(function (group) {
+ return element('ul', group.map(function (page) {
+ return element('li',
+ { class: currentPage.belongsTo(page) ? 'selected' : '', },
+ link(page.name(), router.url(page.routeName(), page.serializeState())));
+ }));
+ }));
+
+ this._renderedOnce = true;
+ }
+
+ static htmlTemplate()
+ {
+ return `
+ <nav class="heading-navigation" role="navigation">
+ <h1 class="heading-title"><a href="#"></a></h1>
+ <div class="heading-navigation-list"></div>
+ <div class="heading-toolbar"></div>
+ </nav>
+ `;
+ }
+
+ static cssTemplate()
+ {
+ return `
+ .heading-navigation {
+ position: relative;
+ font-size: 1rem;
+ line-height: 1rem;
+ }
+
+ .heading-title {
+ position: relative;
+ z-index: 2;
+ margin: 0;
+ padding: 1rem;
+ border-bottom: solid 1px #ccc;
+ background: #fff;
+ color: #c93;
+ font-size: 1.5rem;
+ font-weight: inherit;
+ }
+
+ .heading-title a {
+ text-decoration: none;
+ color: inherit;
+ }
+
+ .heading-navigation-list {
+ display: block;
+ white-space: nowrap;
+ border-bottom: solid 1px #ccc;
+ text-align: center;
+ margin: 0;
+ margin-bottom: 1rem;
+ padding: 0;
+ padding-bottom: 0.3rem;
+ }
+
+ .heading-navigation-list ul {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ margin-left: 1rem;
+ border-left: solid 1px #ccc;
+ padding-left: 1rem;
+ }
+
+ .heading-navigation-list ul:first-child {
+ border-left: none;
+ }
+
+ .heading-navigation-list li {
+ display: inline-block;
+ position: relative;
+ list-style: none;
+ margin: 0.3rem 0.5rem;
+ padding: 0;
+ }
+
+ .heading-navigation-list a {
+ text-decoration: none;
+ color: inherit;
+ color: #666;
+ }
+
+ .heading-navigation-list a:hover {
+ color: #369;
+ }
+
+ .heading-navigation-list li.selected a {
+ color: #000;
+ }
+
+ .heading-navigation-list li.selected a:before {
+ content: '';
+ display: block;
+ border: solid 5px #ccc;
+ border-color: transparent transparent #ccc transparent;
+ width: 0px;
+ height: 0px;
+ position: absolute;
+ left: 50%;
+ margin-left: -5px;
+ bottom: -0.55rem;
+ }
+
+ .heading-toolbar {
+ position: absolute;
+ right: 1rem;
+ top: 0.8rem;
+ z-index: 3;
+ }`;
+ }
+
+}
--- /dev/null
+class PageRouter {
+ constructor()
+ {
+ this._pages = [];
+ this._defaultPage = null;
+ this._currentPage = null;
+ this._historyTimer = null;
+ this._hash = null;
+
+ window.onhashchange = this._hashDidChange.bind(this);
+ }
+
+ addPage(page)
+ {
+ this._pages.push(page);
+ page.setRouter(this);
+ }
+
+ setDefaultPage(defaultPage)
+ {
+ this._defaultPage = defaultPage;
+ }
+
+ currentPage() { return this._currentPage; }
+
+ route()
+ {
+ var destinationPage = this._defaultPage;
+ var parsed = this._deserializeFromHash(location.hash);
+ if (parsed.route) {
+ var hashUrl = parsed.route;
+ var queryIndex = hashUrl.indexOf('?');
+ if (queryIndex >= 0)
+ hashUrl = hashUrl.substring(0, queryIndex);
+
+ for (var page of this._pages) {
+ var routeName = page.routeName();
+ if (routeName == hashUrl
+ || (hashUrl.startsWith(routeName) && hashUrl.charAt(routeName.length) == '/')) {
+ parsed.state.remainingRoute = hashUrl.substring(routeName.length + 1);
+ destinationPage = page;
+ break;
+ }
+ }
+ }
+
+ if (!destinationPage)
+ return false;
+
+ if (this._currentPage != destinationPage) {
+ this._currentPage = destinationPage;
+ destinationPage.open(parsed.state);
+ } else
+ destinationPage.updateFromSerializedState(parsed.state, false);
+
+ return true;
+ }
+
+ pageDidOpen(page)
+ {
+ console.assert(page instanceof Page);
+ var pageDidChange = this._currentPage != page;
+ this._currentPage = page;
+ if (pageDidChange)
+ this.scheduleUrlStateUpdate();
+ }
+
+ scheduleUrlStateUpdate()
+ {
+ if (this._historyTimer)
+ return;
+ this._historyTimer = setTimeout(this._updateURLState.bind(this), 0);
+ }
+
+ url(routeName, state)
+ {
+ return this._serializeToHash(routeName, state);
+ }
+
+ _updateURLState()
+ {
+ this._historyTimer = null;
+ console.assert(this._currentPage);
+ var currentPage = this._currentPage;
+ this._hash = this._serializeToHash(currentPage.routeName(), currentPage.serializeState());
+ location.hash = this._hash;
+ }
+
+ _hashDidChange()
+ {
+ if (unescape(location.hash) == this._hash)
+ return;
+ this.route();
+ this._hash = null;
+ }
+
+ _serializeToHash(route, state)
+ {
+ var params = [];
+ for (var key in state)
+ params.push(key + '=' + this._serializeHashQueryValue(state[key]));
+ var query = params.length ? ('?' + params.join('&')) : '';
+ return `#/${route}${query}`;
+ }
+
+ _deserializeFromHash(hash)
+ {
+ if (!hash || !hash.startsWith('#/'))
+ return {route: null, state: {}};
+
+ hash = unescape(hash); // For Firefox.
+
+ var queryIndex = hash.indexOf('?');
+ var route;
+ var state = {};
+ if (queryIndex >= 0) {
+ route = hash.substring(2, queryIndex);
+ for (var part of hash.substring(queryIndex + 1).split('&')) {
+ var keyValuePair = part.split('=');
+ state[keyValuePair[0]] = this._deserializeHashQueryValue(keyValuePair[1]);
+ }
+ } else
+ route = hash.substring(2);
+
+ return {route: route, state: state};
+ }
+
+ _serializeHashQueryValue(value)
+ {
+ if (!(value instanceof Array)) {
+ console.assert(value === null || typeof(value) === 'number' || /[A-Za-z0-9]*/.test(value));
+ return value === null ? 'null' : value;
+ }
+
+ var serializedItems = [];
+ for (var item of value)
+ serializedItems.push(this._serializeHashQueryValue(item));
+ return '(' + serializedItems.join('-') + ')';
+ }
+
+ _deserializeHashQueryValue(value)
+ {
+ try {
+ return JSON.parse(value.replace(/\(/g, '[').replace(/\)/g, ']').replace(/-/g, ','));
+ } catch (error) {
+ return value;
+ }
+ }
+}
--- /dev/null
+
+class PageWithCharts extends PageWithHeading {
+ constructor(name, toolbar)
+ {
+ super(name, toolbar);
+ this._charts = [];
+ }
+
+ static createChartSourceList(platformId, metricId)
+ {
+ var platform = Platform.findById(platformId);
+ var metric = Metric.findById(metricId);
+ if (!platform || !metric)
+ return {error: `Invalid platform or metric: ${platformId} and ${metricId}`};
+
+ var lastModified = platform.lastModified(metric);
+ if (!lastModified)
+ return {platform: platform, metric: metric, error: `No results on ${platform.name()}`};
+
+ var measurementSet = MeasurementSet.findSet(platform.id(), metric.id(), lastModified);
+ var sourceList = [
+ this.baselineStyle(measurementSet, 'baseline'),
+ this.targetStyle(measurementSet, 'target'),
+ this.currentStyle(measurementSet, 'current'),
+ ];
+
+ return {
+ platform: platform,
+ metric: metric,
+ sourceList: sourceList,
+ };
+ }
+
+ static baselineStyle(measurementSet)
+ {
+ return {
+ measurementSet: measurementSet,
+ extendToFuture: true,
+ sampleData: true,
+ type: 'baseline',
+ pointStyle: '#f33',
+ pointRadius: 2,
+ lineStyle: '#f99',
+ lineWidth: 1.5,
+ intervalStyle: '#fdd',
+ intervalWidth: 2,
+ };
+ }
+
+ static targetStyle(measurementSet)
+ {
+ return {
+ measurementSet: measurementSet,
+ extendToFuture: true,
+ sampleData: true,
+ type: 'target',
+ pointStyle: '#33f',
+ pointRadius: 2,
+ lineStyle: '#99f',
+ lineWidth: 1.5,
+ intervalStyle: '#ddf',
+ intervalWidth: 2,
+ };
+ }
+
+ static currentStyle(measurementSet)
+ {
+ return {
+ measurementSet: measurementSet,
+ sampleData: true,
+ type: 'current',
+ pointStyle: '#333',
+ pointRadius: 2,
+ lineStyle: '#999',
+ lineWidth: 1.5,
+ intervalStyle: '#ddd',
+ intervalWidth: 2,
+ interactive: true,
+ };
+ }
+
+ static dashboardOptions(valueFormatter)
+ {
+ return {
+ updateOnRequestAnimationFrame: true,
+ axis: {
+ yAxisWidth: 4, // rem
+ xAxisHeight: 2, // rem
+ gridStyle: '#ddd',
+ fontSize: 0.8, // rem
+ valueFormatter: valueFormatter,
+ },
+ };
+ }
+
+ static overviewChartOptions(valueFormatter)
+ {
+ var options = this.dashboardOptions(valueFormatter);
+ options.axis.yAxisWidth = 0; // rem
+ options.selection = {
+ lineStyle: '#f93',
+ lineWidth: 2,
+ fillStyle: 'rgba(153, 204, 102, .125)',
+ }
+ return options;
+ }
+
+ static mainChartOptions(valueFormatter)
+ {
+ var options = this.dashboardOptions(valueFormatter);
+ options.selection = {
+ lineStyle: '#f93',
+ lineWidth: 2,
+ fillStyle: 'rgba(153, 204, 102, .125)',
+ }
+ options.indicator = {
+ lineStyle: '#f93',
+ lineWidth: 2,
+ pointRadius: 3,
+ };
+ options.annotations = {
+ textStyle: '#000',
+ textBackground: '#fff',
+ minWidth: 3,
+ barHeight: 7,
+ barSpacing: 2,
+ };
+ return options;
+ }
+}
--- /dev/null
+
+class PageWithHeading extends Page {
+ constructor(name, toolbar)
+ {
+ super(name);
+ this._heading = null;
+ this._parentPage = null;
+ this._toolbar = toolbar;
+ }
+
+ open(state)
+ {
+ console.assert(this.heading());
+ this.heading().setToolbar(this._toolbar);
+ super.open(state);
+ }
+
+ setParentPage(page) { this._parentPage = page; }
+ belongsTo(page) { return this == page || this._parentPage == page; }
+ setHeading(heading) { this._heading = heading; }
+ // FIXME: We shouldn't rely on the page hierarchy to find the heading.
+ heading() { return this._heading || this._parentPage.heading(); }
+ toolbar() { return this._toolbar; }
+ title() { return this.name(); }
+ pageTitle() { return this.title() + ' - ' + this.heading().title(); }
+
+ render()
+ {
+ console.assert(this.heading());
+
+ if (document.body.firstChild != this.heading().element())
+ document.body.insertBefore(this.heading().element(), document.body.firstChild);
+
+ super.render();
+ this.heading().render();
+ }
+
+ static htmlTemplate()
+ {
+ return `<section class="page-with-heading"></section>`;
+ }
+
+}
--- /dev/null
+
+class Page extends ComponentBase {
+ constructor(name, container)
+ {
+ super('page-component');
+ this._name = name;
+ this._router = null;
+ }
+
+ name() { return this._name; }
+ pageTitle() { return this.name(); }
+
+ open(state)
+ {
+ // FIXME: Do something better here.
+ document.body.innerHTML = '';
+ document.body.appendChild(this.element());
+ document.title = this.pageTitle();
+ if (this._router)
+ this._router.pageDidOpen(this);
+ this.updateFromSerializedState(state, true);
+ this.render();
+ }
+
+ render()
+ {
+ var title = this.pageTitle();
+ if (document.title != title)
+ document.title = title;
+ super.render();
+ }
+
+ setRouter(router) { this._router = router; }
+ router() { return this._router; }
+ scheduleUrlStateUpdate() { this._router.scheduleUrlStateUpdate(); }
+ routeName() { throw 'NotImplemented'; }
+ serializeState() { return {}; }
+ updateFromSerializedState(state, isOpen) { }
+}
--- /dev/null
+
+class Toolbar extends ComponentBase {
+
+ constructor(name)
+ {
+ super(name);
+ this._router = null;
+ }
+
+ router() { return this._router; }
+ setRouter(router) { this._router = router; }
+
+ static cssTemplate()
+ {
+ return `
+ .buttoned-toolbar {
+ display: inline-block;
+ margin: 0 0;
+ padding: 0;
+ font-size: 0.9rem;
+ border-radius: 0.5rem;
+ }
+
+ .buttoned-toolbar li > input {
+ margin: 0;
+ border: solid 1px #ccc;
+ border-radius: 0.5rem;
+ outline: none;
+ font-size: inherit;
+ padding: 0.2rem 0.3rem;
+ height: 1rem;
+ }
+
+ .buttoned-toolbar > input,
+ .buttoned-toolbar > ul {
+ margin-left: 0.5rem;
+ }
+
+ .buttoned-toolbar li {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ }
+
+ .buttoned-toolbar li a {
+ display: inline-block;
+ border: solid 1px #ccc;
+ font-weight: inherit;
+ text-decoration: none;
+ text-transform: capitalize;
+ background: #fff;
+ color: inherit;
+ margin: 0;
+ margin-right: -1px; /* collapse borders between two buttons */
+ padding: 0.2rem 0.3rem;
+ }
+
+ .buttoned-toolbar input:focus,
+ .buttoned-toolbar li.selected a {
+ background: rgba(204, 153, 51, 0.1);
+ }
+
+ .buttoned-toolbar li:not(.selected) a:hover {
+ background: #eee;
+ }
+
+ .buttoned-toolbar li:first-child a {
+ border-top-left-radius: 0.3rem;
+ border-bottom-left-radius: 0.3rem;
+ }
+
+ .buttoned-toolbar li:last-child a {
+ border-right-width: 1px;
+ border-top-right-radius: 0.3rem;
+ border-bottom-right-radius: 0.3rem;
+ }`;
+ }
+}
--- /dev/null
+
+function getJSON(path, data)
+{
+ console.assert(!path.startsWith('http:') && !path.startsWith('https:') && !path.startsWith('file:'));
+
+ return new Promise(function (resolve, reject) {
+ Instrumentation.startMeasuringTime('Remote', 'getJSON');
+
+ var xhr = new XMLHttpRequest;
+ xhr.onload = function () {
+ Instrumentation.endMeasuringTime('Remote', 'getJSON');
+
+ if (xhr.status != 200) {
+ reject(xhr.status);
+ return;
+ }
+
+ try {
+ var parsed = JSON.parse(xhr.responseText);
+ resolve(parsed);
+ } catch (error) {
+ reject(xhr.status + ', ' + error);
+ }
+ };
+
+ function onerror() {
+ Instrumentation.endMeasuringTime('Remote', 'getJSON');
+ reject(xhr.status);
+ }
+
+ xhr.onabort = onerror;
+ xhr.onerror = onerror;
+
+ if (data) {
+ xhr.open('POST', path, true);
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ xhr.send(JSON.stringify(data));
+ } else {
+ xhr.open('GET', path, true);
+ xhr.send();
+ }
+ });
+}
+
+function getJSONWithStatus(path, data)
+{
+ return getJSON(path, data).then(function (content) {
+ if (content['status'] != 'OK')
+ return Promise.reject(content['status']);
+ return content;
+ });
+}
+
+// FIXME: Use real class syntax once the dependency on data.js has been removed.
+PrivilegedAPI = class {
+
+ static sendRequest(path, data)
+ {
+ return this.requestCSRFToken().then(function (token) {
+ var clonedData = {};
+ for (var key in data)
+ clonedData[key] = data[key];
+ clonedData['token'] = token;
+ return getJSONWithStatus('../privileged-api/' + path, clonedData);
+ });
+ }
+
+ static requestCSRFToken()
+ {
+ var maxNetworkLatency = 3 * 60 * 1000; /* 3 minutes */
+ if (this._token && this._expiration > Date.now() + maxNetworkLatency)
+ return Promise.resolve(this._token);
+
+ return getJSONWithStatus('../privileged-api/generate-csrf-token', {}).then(function (result) {
+ PrivilegedAPI._token = result['token'];
+ PrivilegedAPI._expiration = new Date(result['expiration']);
+ return PrivilegedAPI._token;
+ });
+ }
+
+}
+
+PrivilegedAPI._token = null;
+PrivilegedAPI._expiration = null;
--- /dev/null
+#!/usr/bin/python
+
+import os
+import subprocess
+import sys
+import xml.dom.minidom
+
+
+def main(argv):
+ tools_dir = os.path.dirname(__file__)
+ public_v3_dir = os.path.abspath(os.path.join(tools_dir, '..', 'public', 'v3'))
+
+ bundled_script = ''
+
+ index_html = xml.dom.minidom.parse(os.path.join(public_v3_dir, 'index.html'))
+ for template in index_html.getElementsByTagName('template'):
+ if template.getAttribute('id') != 'unbundled-scripts':
+ continue
+ unbundled_scripts = template.getElementsByTagName('script')
+ for script in unbundled_scripts:
+ src = script.getAttribute('src')
+ with open(os.path.join(public_v3_dir, src)) as script_file:
+ bundled_script += script_file.read()
+
+ jsmin = subprocess.Popen(['python', os.path.join(tools_dir, 'jsmin.py')], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
+ minified_script = jsmin.communicate(input=bundled_script)[0]
+
+ new_size = float(len(minified_script))
+ old_size = float(len(bundled_script))
+ print '%d -> %d (%.1f%%)' % (old_size, new_size, new_size / old_size * 100)
+
+ with open(os.path.join(public_v3_dir, 'bundled-scripts.js'), 'w') as bundled_file:
+ bundled_file.write(minified_script)
+
+if __name__ == "__main__":
+ main(sys.argv)
--- /dev/null
+# This code is original from jsmin by Douglas Crockford, it was translated to
+# Python by Baruch Even. It was rewritten by Dave St.Germain for speed.
+#
+# The MIT License (MIT)
+#
+# Copyright (c) 2013 Dave St.Germain
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+
+import sys
+is_3 = sys.version_info >= (3, 0)
+if is_3:
+ import io
+else:
+ import StringIO
+ try:
+ import cStringIO
+ except ImportError:
+ cStringIO = None
+
+
+__all__ = ['jsmin', 'JavascriptMinify']
+__version__ = '2.0.9'
+
+
+def jsmin(js):
+ """
+ returns a minified version of the javascript string
+ """
+ if not is_3:
+ if cStringIO and not isinstance(js, unicode):
+ # strings can use cStringIO for a 3x performance
+ # improvement, but unicode (in python2) cannot
+ klass = cStringIO.StringIO
+ else:
+ klass = StringIO.StringIO
+ else:
+ klass = io.StringIO
+ ins = klass(js)
+ outs = klass()
+ JavascriptMinify(ins, outs).minify()
+ return outs.getvalue()
+
+
+class JavascriptMinify(object):
+ """
+ Minify an input stream of javascript, writing
+ to an output stream
+ """
+
+ def __init__(self, instream=None, outstream=None):
+ self.ins = instream
+ self.outs = outstream
+
+ def minify(self, instream=None, outstream=None):
+ if instream and outstream:
+ self.ins, self.outs = instream, outstream
+
+ self.is_return = False
+ self.return_buf = ''
+
+ def write(char):
+ # all of this is to support literal regular expressions.
+ # sigh
+ if char in 'return':
+ self.return_buf += char
+ self.is_return = self.return_buf == 'return'
+ self.outs.write(char)
+ if self.is_return:
+ self.return_buf = ''
+
+ read = self.ins.read
+
+ space_strings = "abcdefghijklmnopqrstuvwxyz"\
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$\\"
+ starters, enders = '{[(+-', '}])+-"\''
+ newlinestart_strings = starters + space_strings
+ newlineend_strings = enders + space_strings
+ do_newline = False
+ do_space = False
+ escape_slash_count = 0
+ doing_single_comment = False
+ previous_before_comment = ''
+ doing_multi_comment = False
+ in_re = False
+ in_quote = ''
+ quote_buf = []
+
+ previous = read(1)
+ if previous == '\\':
+ escape_slash_count += 1
+ next1 = read(1)
+ if previous == '/':
+ if next1 == '/':
+ doing_single_comment = True
+ elif next1 == '*':
+ doing_multi_comment = True
+ previous = next1
+ next1 = read(1)
+ else:
+ write(previous)
+ elif not previous:
+ return
+ elif previous >= '!':
+ if previous in "'\"":
+ in_quote = previous
+ write(previous)
+ previous_non_space = previous
+ else:
+ previous_non_space = ' '
+ if not next1:
+ return
+
+ while 1:
+ next2 = read(1)
+ if not next2:
+ last = next1.strip()
+ if not (doing_single_comment or doing_multi_comment)\
+ and last not in ('', '/'):
+ if in_quote:
+ write(''.join(quote_buf))
+ write(last)
+ break
+ if doing_multi_comment:
+ if next1 == '*' and next2 == '/':
+ doing_multi_comment = False
+ next2 = read(1)
+ elif doing_single_comment:
+ if next1 in '\r\n':
+ doing_single_comment = False
+ while next2 in '\r\n':
+ next2 = read(1)
+ if not next2:
+ break
+ if previous_before_comment in ')}]':
+ do_newline = True
+ elif previous_before_comment in space_strings:
+ write('\n')
+ elif in_quote:
+ quote_buf.append(next1)
+
+ if next1 == in_quote:
+ numslashes = 0
+ for c in reversed(quote_buf[:-1]):
+ if c != '\\':
+ break
+ else:
+ numslashes += 1
+ if numslashes % 2 == 0:
+ in_quote = ''
+ write(''.join(quote_buf))
+ elif next1 in '\r\n':
+ if previous_non_space in newlineend_strings \
+ or previous_non_space > '~':
+ while 1:
+ if next2 < '!':
+ next2 = read(1)
+ if not next2:
+ break
+ else:
+ if next2 in newlinestart_strings \
+ or next2 > '~' or next2 == '/':
+ do_newline = True
+ break
+ elif next1 < '!' and not in_re:
+ if (previous_non_space in space_strings \
+ or previous_non_space > '~') \
+ and (next2 in space_strings or next2 > '~'):
+ do_space = True
+ elif previous_non_space in '-+' and next2 == previous_non_space:
+ # protect against + ++ or - -- sequences
+ do_space = True
+ elif self.is_return and next2 == '/':
+ # returning a regex...
+ write(' ')
+ elif next1 == '/':
+ if do_space:
+ write(' ')
+ if in_re:
+ if previous != '\\' or (not escape_slash_count % 2) or next2 in 'gimy':
+ in_re = False
+ write('/')
+ elif next2 == '/':
+ doing_single_comment = True
+ previous_before_comment = previous_non_space
+ elif next2 == '*':
+ doing_multi_comment = True
+ previous = next1
+ next1 = next2
+ next2 = read(1)
+ else:
+ in_re = previous_non_space in '(,=:[?!&|' or self.is_return # literal regular expression
+ write('/')
+ else:
+ if do_space:
+ do_space = False
+ write(' ')
+ if do_newline:
+ write('\n')
+ do_newline = False
+
+ write(next1)
+ if not in_re and next1 in "'\"`":
+ in_quote = next1
+ quote_buf = []
+
+ previous = next1
+ next1 = next2
+
+ if previous >= '!':
+ previous_non_space = previous
+
+ if previous == '\\':
+ escape_slash_count += 1
+ else:
+ escape_slash_count = 0
+
+if __name__ == '__main__':
+ minifier = JavascriptMinify(sys.stdin, sys.stdout)
+ minifier.minify()
+ sys.stdout.write('\n')