Add v3 UI to perf dashboard
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 16 Dec 2015 05:19:08 +0000 (05:19 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 16 Dec 2015 05:19:08 +0000 (05:19 +0000)
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.

git-svn-id: http://svn.webkit.org/repository/webkit/trunk@194130 268f45cc-cd09-0410-ab3c-d52691b4dbfc

47 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/public/api/analysis-tasks.php
Websites/perf.webkit.org/public/include/json-header.php
Websites/perf.webkit.org/public/v3/components/base.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/button-base.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/chart-status-view.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/close-button.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/commit-log-viewer.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/interactive-time-series-chart.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/pane-selector.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/spinner-icon.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/time-series-chart.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/index.html [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/instrumentation.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/main.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/analysis-task.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/bug-tracker.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/bug.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/builder.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/commit-log.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/data-model.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/measurement-cluster.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/measurement-set.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/metric.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/platform.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/repository.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/models/test.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/analysis-category-page.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/analysis-category-toolbar.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/analysis-task-page.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/chart-pane-status-view.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/chart-pane.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/charts-page.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/charts-toolbar.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/create-analysis-task-page.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/dashboard-page.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/dashboard-toolbar.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/domain-control-toolbar.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/heading.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/page-router.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/page-with-charts.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/page-with-heading.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/page.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/pages/toolbar.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/remote.js [new file with mode: 0644]
Websites/perf.webkit.org/tools/bundle-v3-scripts.py [new file with mode: 0644]
Websites/perf.webkit.org/tools/jsmin.py [new file with mode: 0644]

index c1c2fb9..ee96043 100644 (file)
@@ -1,5 +1,199 @@
 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.
 
         * public/v2/data.js:
index 947f952..bfed71f 100644 (file)
@@ -10,8 +10,10 @@ function main($path) {
     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));
@@ -73,6 +75,35 @@ function fetch_and_push_bugs_to_tasks($db, &$tasks) {
         $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;
 }
 
@@ -88,6 +119,7 @@ function format_task($task_row) {
         '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(),
index 96e262c..9ba593c 100644 (file)
@@ -53,12 +53,29 @@ function camel_case_words_separated_by_underscore($name) {
 
 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 = '') {
diff --git a/Websites/perf.webkit.org/public/v3/components/base.js b/Websites/perf.webkit.org/public/v3/components/base.js
new file mode 100644 (file)
index 0000000..1e46ee4
--- /dev/null
@@ -0,0 +1,165 @@
+
+// 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 = {};
diff --git a/Websites/perf.webkit.org/public/v3/components/button-base.js b/Websites/perf.webkit.org/public/v3/components/button-base.js
new file mode 100644 (file)
index 0000000..bfb3f82
--- /dev/null
@@ -0,0 +1,30 @@
+
+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;
+            }
+        `;
+    }
+
+}
diff --git a/Websites/perf.webkit.org/public/v3/components/chart-status-view.js b/Websites/perf.webkit.org/public/v3/components/chart-status-view.js
new file mode 100644 (file)
index 0000000..0d0e165
--- /dev/null
@@ -0,0 +1,198 @@
+
+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
diff --git a/Websites/perf.webkit.org/public/v3/components/close-button.js b/Websites/perf.webkit.org/public/v3/components/close-button.js
new file mode 100644 (file)
index 0000000..270bda8
--- /dev/null
@@ -0,0 +1,21 @@
+
+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);
diff --git a/Websites/perf.webkit.org/public/v3/components/commit-log-viewer.js b/Websites/perf.webkit.org/public/v3/components/commit-log-viewer.js
new file mode 100644 (file)
index 0000000..d64a99c
--- /dev/null
@@ -0,0 +1,134 @@
+
+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);
diff --git a/Websites/perf.webkit.org/public/v3/components/interactive-time-series-chart.js b/Websites/perf.webkit.org/public/v3/components/interactive-time-series-chart.js
new file mode 100644 (file)
index 0000000..1957eef
--- /dev/null
@@ -0,0 +1,448 @@
+
+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');
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/components/pane-selector.js b/Websites/perf.webkit.org/public/v3/components/pane-selector.js
new file mode 100644 (file)
index 0000000..302f35e
--- /dev/null
@@ -0,0 +1,214 @@
+
+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);
diff --git a/Websites/perf.webkit.org/public/v3/components/spinner-icon.js b/Websites/perf.webkit.org/public/v3/components/spinner-icon.js
new file mode 100644 (file)
index 0000000..195b650
--- /dev/null
@@ -0,0 +1,86 @@
+
+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);
diff --git a/Websites/perf.webkit.org/public/v3/components/time-series-chart.js b/Websites/perf.webkit.org/public/v3/components/time-series-chart.js
new file mode 100644 (file)
index 0000000..6308db3
--- /dev/null
@@ -0,0 +1,672 @@
+
+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;
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/index.html b/Websites/perf.webkit.org/public/v3/index.html
new file mode 100644 (file)
index 0000000..045b5c3
--- /dev/null
@@ -0,0 +1,93 @@
+<!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>
diff --git a/Websites/perf.webkit.org/public/v3/instrumentation.js b/Websites/perf.webkit.org/public/v3/instrumentation.js
new file mode 100644 (file)
index 0000000..0649789
--- /dev/null
@@ -0,0 +1,55 @@
+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);
+        }
+    }
+
+}
diff --git a/Websites/perf.webkit.org/public/v3/main.js b/Websites/perf.webkit.org/public/v3/main.js
new file mode 100644 (file)
index 0000000..81aa298
--- /dev/null
@@ -0,0 +1,112 @@
+
+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);
diff --git a/Websites/perf.webkit.org/public/v3/models/analysis-task.js b/Websites/perf.webkit.org/public/v3/models/analysis-task.js
new file mode 100644 (file)
index 0000000..274b2f7
--- /dev/null
@@ -0,0 +1,143 @@
+
+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,
+        });
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/models/bug-tracker.js b/Websites/perf.webkit.org/public/v3/models/bug-tracker.js
new file mode 100644 (file)
index 0000000..5d53210
--- /dev/null
@@ -0,0 +1,12 @@
+
+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; }
+}
diff --git a/Websites/perf.webkit.org/public/v3/models/bug.js b/Websites/perf.webkit.org/public/v3/models/bug.js
new file mode 100644 (file)
index 0000000..9f555a5
--- /dev/null
@@ -0,0 +1,17 @@
+
+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()}`; }
+}
diff --git a/Websites/perf.webkit.org/public/v3/models/builder.js b/Websites/perf.webkit.org/public/v3/models/builder.js
new file mode 100644 (file)
index 0000000..d6523b4
--- /dev/null
@@ -0,0 +1,8 @@
+
+class Builder extends LabeledObject {
+    constructor(id, object)
+    {
+        super(id, object);
+        this._buildURL = object.buildUrl;
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/models/commit-log.js b/Websites/perf.webkit.org/public/v3/models/commit-log.js
new file mode 100644 (file)
index 0000000..86bebab
--- /dev/null
@@ -0,0 +1,70 @@
+
+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;
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/models/data-model.js b/Websites/perf.webkit.org/public/v3/models/data-model.js
new file mode 100644 (file)
index 0000000..aa610b4
--- /dev/null
@@ -0,0 +1,74 @@
+
+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(); }
+}
diff --git a/Websites/perf.webkit.org/public/v3/models/measurement-cluster.js b/Websites/perf.webkit.org/public/v3/models/measurement-cluster.js
new file mode 100644 (file)
index 0000000..45b007f
--- /dev/null
@@ -0,0 +1,82 @@
+
+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];
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/models/measurement-set.js b/Websites/perf.webkit.org/public/v3/models/measurement-set.js
new file mode 100644 (file)
index 0000000..684a0d3
--- /dev/null
@@ -0,0 +1,243 @@
+
+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];
+}
diff --git a/Websites/perf.webkit.org/public/v3/models/metric.js b/Websites/perf.webkit.org/public/v3/models/metric.js
new file mode 100644 (file)
index 0000000..22bfe80
--- /dev/null
@@ -0,0 +1,88 @@
+
+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 || '');
+        }
+    };
+}
diff --git a/Websites/perf.webkit.org/public/v3/models/platform.js b/Websites/perf.webkit.org/public/v3/models/platform.js
new file mode 100644 (file)
index 0000000..9ad7579
--- /dev/null
@@ -0,0 +1,36 @@
+
+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()];
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/models/repository.js b/Websites/perf.webkit.org/public/v3/models/repository.js
new file mode 100644 (file)
index 0000000..17aef30
--- /dev/null
@@ -0,0 +1,20 @@
+
+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);
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/models/test.js b/Websites/perf.webkit.org/public/v3/models/test.js
new file mode 100644 (file)
index 0000000..b2de0ad
--- /dev/null
@@ -0,0 +1,44 @@
+
+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); }
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/analysis-category-page.js b/Websites/perf.webkit.org/public/v3/pages/analysis-category-page.js
new file mode 100644 (file)
index 0000000..41ac472
--- /dev/null
@@ -0,0 +1,267 @@
+
+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;
+            }
+`;
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/analysis-category-toolbar.js b/Websites/perf.webkit.org/public/v3/pages/analysis-category-toolbar.js
new file mode 100644 (file)
index 0000000..9fa38db
--- /dev/null
@@ -0,0 +1,83 @@
+
+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>`;
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/analysis-task-page.js b/Websites/perf.webkit.org/public/v3/pages/analysis-task-page.js
new file mode 100644 (file)
index 0000000..b80aa63
--- /dev/null
@@ -0,0 +1,80 @@
+
+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;
+            }
+`;
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/chart-pane-status-view.js b/Websites/perf.webkit.org/public/v3/pages/chart-pane-status-view.js
new file mode 100644 (file)
index 0000000..3292109
--- /dev/null
@@ -0,0 +1,222 @@
+
+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;
+            }
+        `;
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/chart-pane.js b/Websites/perf.webkit.org/public/v3/pages/chart-pane.js
new file mode 100644 (file)
index 0000000..0de56e0
--- /dev/null
@@ -0,0 +1,568 @@
+
+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;
+            }
+`;
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/charts-page.js b/Websites/perf.webkit.org/public/v3/pages/charts-page.js
new file mode 100644 (file)
index 0000000..958d550
--- /dev/null
@@ -0,0 +1,276 @@
+
+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>`;
+    }
+
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/charts-toolbar.js b/Websites/perf.webkit.org/public/v3/pages/charts-toolbar.js
new file mode 100644 (file)
index 0000000..f4c8b19
--- /dev/null
@@ -0,0 +1,176 @@
+
+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;
+            }
+        `;
+    }
+
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/create-analysis-task-page.js b/Websites/perf.webkit.org/public/v3/pages/create-analysis-task-page.js
new file mode 100644 (file)
index 0000000..803012f
--- /dev/null
@@ -0,0 +1,53 @@
+
+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;
+            }
+`;
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/dashboard-page.js b/Websites/perf.webkit.org/public/v3/pages/dashboard-page.js
new file mode 100644 (file)
index 0000000..3994228
--- /dev/null
@@ -0,0 +1,214 @@
+
+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;
+            }
+        `;
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/dashboard-toolbar.js b/Websites/perf.webkit.org/public/v3/pages/dashboard-toolbar.js
new file mode 100644 (file)
index 0000000..7fe5692
--- /dev/null
@@ -0,0 +1,61 @@
+
+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>`;
+    }
+
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/domain-control-toolbar.js b/Websites/perf.webkit.org/public/v3/pages/domain-control-toolbar.js
new file mode 100644 (file)
index 0000000..f9b7e7e
--- /dev/null
@@ -0,0 +1,39 @@
+
+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;
+    }
+
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/heading.js b/Websites/perf.webkit.org/public/v3/pages/heading.js
new file mode 100644 (file)
index 0000000..003ac1e
--- /dev/null
@@ -0,0 +1,183 @@
+
+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;
+            }`;
+    }
+
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/page-router.js b/Websites/perf.webkit.org/public/v3/pages/page-router.js
new file mode 100644 (file)
index 0000000..6d04ef1
--- /dev/null
@@ -0,0 +1,149 @@
+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;
+        }
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/page-with-charts.js b/Websites/perf.webkit.org/public/v3/pages/page-with-charts.js
new file mode 100644 (file)
index 0000000..98bb3b1
--- /dev/null
@@ -0,0 +1,130 @@
+
+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;
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/page-with-heading.js b/Websites/perf.webkit.org/public/v3/pages/page-with-heading.js
new file mode 100644 (file)
index 0000000..9eaaec7
--- /dev/null
@@ -0,0 +1,43 @@
+
+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>`;
+    }
+
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/page.js b/Websites/perf.webkit.org/public/v3/pages/page.js
new file mode 100644 (file)
index 0000000..54693d3
--- /dev/null
@@ -0,0 +1,39 @@
+
+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) { }
+}
diff --git a/Websites/perf.webkit.org/public/v3/pages/toolbar.js b/Websites/perf.webkit.org/public/v3/pages/toolbar.js
new file mode 100644 (file)
index 0000000..cfb8a30
--- /dev/null
@@ -0,0 +1,78 @@
+
+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;
+            }`;
+    }
+}
diff --git a/Websites/perf.webkit.org/public/v3/remote.js b/Websites/perf.webkit.org/public/v3/remote.js
new file mode 100644 (file)
index 0000000..1925aa7
--- /dev/null
@@ -0,0 +1,84 @@
+
+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;
diff --git a/Websites/perf.webkit.org/tools/bundle-v3-scripts.py b/Websites/perf.webkit.org/tools/bundle-v3-scripts.py
new file mode 100644 (file)
index 0000000..2fd6187
--- /dev/null
@@ -0,0 +1,36 @@
+#!/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)
diff --git a/Websites/perf.webkit.org/tools/jsmin.py b/Websites/perf.webkit.org/tools/jsmin.py
new file mode 100644 (file)
index 0000000..372418b
--- /dev/null
@@ -0,0 +1,238 @@
+# 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')