WebKitPerfMonitor: Tooltips can be cut off at the top
[WebKit.git] / Websites / perf.webkit.org / public / js / helper-classes.js
1
2 // A point in a plot.
3 function PerfTestResult(runs, result, associatedBuild) {
4     this.metric = function () { return runs.metric(); }
5     this.values = function () { return result.values ? result.values.map(function (value) { return runs.scalingFactor() * value; }) : undefined; }
6     this.mean = function () { return runs.scalingFactor() * result.mean; }
7     this.unscaledMean = function () { return result.mean; }
8     this.confidenceIntervalDelta = function () {
9         return runs.scalingFactor() * this.unscaledConfidenceIntervalDelta();
10     }
11     this.unscaledConfidenceIntervalDelta = function (defaultValue) {
12         var delta = Statistics.confidenceIntervalDelta(0.95, result.iterationCount, result.sum, result.squareSum);
13         if (isNaN(delta) && defaultValue !== undefined)
14             return defaultValue;
15         return delta;
16     }
17     this.isInUnscaledInterval = function (min, max) {
18         var mean = this.unscaledMean();
19         var delta = this.unscaledConfidenceIntervalDelta(0);
20         return min <= mean - delta && mean + delta <= max;
21     }
22     this.isBetterThan = function(other) { return runs.smallerIsBetter() == (this.mean() < other.mean()); }
23     this.relativeDifference = function(other) { return (other.mean() - this.mean()) / this.mean(); }
24     this.formattedRelativeDifference = function (other) { return Math.abs(this.relativeDifference(other) * 100).toFixed(2) + '%'; }
25     this.formattedProgressionOrRegression = function (previousResult) {
26         return previousResult.formattedRelativeDifference(this) + ' ' + (this.isBetterThan(previousResult) ? 'better' : 'worse');
27     }
28     this.isStatisticallySignificant = function (other) {
29         var diff = Math.abs(other.mean() - this.mean());
30         return diff > this.confidenceIntervalDelta() && diff > other.confidenceIntervalDelta();
31     }
32     this.build = function () { return associatedBuild; }
33     this.label = function (previousResult) {
34         var mean = this.mean();
35         var label = mean.toPrecision(4) + ' ' + runs.unit();
36
37         var delta = this.confidenceIntervalDelta();
38         if (delta) {
39             var percentageStdev = delta * 100 / mean;
40             label += ' &plusmn; ' + percentageStdev.toFixed(2) + '%';
41         }
42
43         if (previousResult)
44             label += ' (' + this.formattedProgressionOrRegression(previousResult) + ')';
45
46         return label;
47     }
48 }
49
50 function TestBuild(repositories, builders, platform, rawRun) {
51     const revisions = rawRun.revisions;
52     var maxTime = 0;
53     var revisionCount = 0;
54     for (var repositoryName in revisions) {
55         maxTime = Math.max(maxTime, revisions[repositoryName][1]); // Revision is an pair (revision, time)
56         revisionCount++;
57     }
58     if (!maxTime)
59         maxTime = rawRun.buildTime;
60     maxTime = TestBuild.UTCtoPST(maxTime);
61     var maxTimeString = new Date(maxTime).toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
62     var buildTime = TestBuild.UTCtoPST(rawRun.buildTime);
63     var buildTimeString = new Date(buildTime).toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
64
65     this.time = function () { return maxTime; }
66     this.formattedTime = function () { return maxTimeString; }
67     this.buildTime = function () { return buildTime; }
68     this.formattedBuildTime = function () { return buildTimeString; }
69     this.builder = function () { return builders[rawRun.builder].name; }
70     this.buildNumber = function () { return rawRun.buildNumber; }
71     this.buildUrl = function () {
72         var template = builders[rawRun.builder].buildUrl;
73         return template ? template.replace(/\$buildNumber/g, this.buildNumber()) : null;
74     }
75     this.platform = function () { return platform; }
76     this.revision = function(repositoryName) { return revisions[repositoryName][0]; }
77     this.formattedRevisions = function (previousBuild) {
78         var result = {};
79         for (var repositoryName in repositories) {
80             if (!revisions[repositoryName])
81                 continue;
82             var previousRevision = previousBuild ? previousBuild.revision(repositoryName) : undefined;
83             var currentRevision = this.revision(repositoryName);
84             if (previousRevision === currentRevision)
85                 previousRevision = undefined;
86
87             var revisionPrefix = '';
88             var revisionDelimitor = '-';
89             if (parseInt(currentRevision) == currentRevision) { // e.g. r12345.
90                 revisionPrefix = 'r';
91                 if (previousRevision)
92                     previousRevision = (parseInt(previousRevision) + 1);
93             } else if (currentRevision.indexOf(' ') >= 0) // e.g. 10.9 13C64.
94                 revisionDelimitor = ' - ';
95
96             var labelForThisRepository;
97             if (previousRevision)
98                 labelForThisRepository = revisionPrefix + previousRevision + revisionDelimitor + revisionPrefix + currentRevision;
99             else
100                 labelForThisRepository = '@ ' + revisionPrefix + currentRevision;
101
102             var url;
103             var repository = repositories[repositoryName];
104             if (repository) {
105                 if (previousRevision)
106                     url = (repository['blameUrl'] || '').replace(/\$1/g, previousRevision).replace(/\$2/g, currentRevision);
107                 else
108                     url = (repository['url'] || '').replace(/\$1/g, currentRevision);
109             }
110
111             result[repositoryName] = {
112                 'label': labelForThisRepository,
113                 'currentRevision': currentRevision,
114                 'previousRevision': previousRevision,
115                 'url': url,
116             };
117         }
118         return result;
119     }
120 }
121
122 TestBuild.UTCtoPST = function (date) {
123     // Pretend that PST is UTC since vanilla flot doesn't support multiple timezones.
124     const PSTOffsetInMilliseconds = 8 * 3600 * 1000;
125     return date - PSTOffsetInMilliseconds;
126 }
127 TestBuild.now = function () { return this.UTCtoPST(Date.now()); }
128
129 // A sequence of test results for a specific test on a specific platform
130 function PerfTestRuns(metric, platform) {
131     var results = [];
132     var cachedUnit = null;
133     var cachedScalingFactor = null;
134     var baselines = {};
135     var unit = {'Combined': '', // Assume smaller is better for now.
136         'FrameRate': 'fps',
137         'Runs': '/s',
138         'Time': 'ms',
139         'Malloc': 'B',
140         'JSHeap': 'B',
141         'Allocations': 'B',
142         'EndAllocations': 'B',
143         'MaxAllocations': 'B',
144         'MeanAllocations': 'B'}[metric.name];
145
146     // We can't do this in PerfTestResult because all results for each metric need to share the same unit and the same scaling factor.
147     function computeScalingFactorIfNeeded() {
148         // FIXME: We shouldn't be adjusting units on every test result.
149         // We can only do this on the first test.
150         if (!results.length || cachedUnit)
151             return;
152
153         var mean = results[0].unscaledMean(); // FIXME: We should look at all values.
154         var kilo = unit == 'B' ? 1024 : 1000;
155         if (mean > 2 * kilo * kilo && unit != 'ms') {
156             cachedScalingFactor = 1 / kilo / kilo;
157             var unitFirstChar = unit.charAt(0);
158             cachedUnit = 'M' + (unitFirstChar.toUpperCase() == unitFirstChar ? '' : ' ') + unit;
159         } else if (mean > 2 * kilo) {
160             cachedScalingFactor = 1 / kilo;
161             cachedUnit = unit == 'ms' ? 's' : ('K ' + unit);
162         } else {
163             cachedScalingFactor = 1;
164             cachedUnit = unit;
165         }
166     }
167
168     this.metric = function () { return metric; }
169     this.platform = function () { return platform; }
170     this.addResult = function (newResult) {
171         if (results.indexOf(newResult) >= 0)
172             return;
173         results.push(newResult);
174         cachedUnit = null;
175         cachedScalingFactor = null;
176     }
177     this.lastResult = function () { return results[results.length - 1]; }
178     this.resultAt = function (i) { return results[i]; }
179
180     var resultsFilterCache;
181     var resultsFilterCacheMinTime;
182     function filteredResults(minTime) {
183         if (!minTime)
184             return results;
185         if (resultsFilterCacheMinTime != minTime) {
186             resultsFilterCache = results.filter(function (result) { return !minTime || result.build().time() >= minTime; });
187             resultsFilterCacheMinTime = minTime;
188         }
189         return resultsFilterCache;
190     }
191
192     function unscaledMeansForAllResults(minTime) {
193         return filteredResults(minTime).map(function (result) { return result.unscaledMean(); });
194     }
195
196     this.min = function (minTime) {
197         return this.scalingFactor() * filteredResults(minTime)
198             .reduce(function (minSoFar, result) { return Math.min(minSoFar, result.unscaledMean() - result.unscaledConfidenceIntervalDelta(0)); }, Number.MAX_VALUE);
199     }
200     this.max = function (minTime, baselineName) {
201         return this.scalingFactor() * filteredResults(minTime)
202             .reduce(function (maxSoFar, result) { return Math.max(maxSoFar, result.unscaledMean() + result.unscaledConfidenceIntervalDelta(0)); }, Number.MIN_VALUE);
203     }
204     this.countResults = function (minTime) {
205         return unscaledMeansForAllResults(minTime).length;
206     }
207     this.countResultsInInterval = function (minTime, min, max) {
208         var unscaledMin = min / this.scalingFactor();
209         var unscaledMax = max / this.scalingFactor();
210         return filteredResults(minTime).reduce(function (count, currentResult) {
211             return count + (currentResult.isInUnscaledInterval(unscaledMin, unscaledMax) ? 1 : 0); }, 0);
212     }
213     this.sampleStandardDeviation = function (minTime) {
214         var unscaledMeans = unscaledMeansForAllResults(minTime);
215         return this.scalingFactor() * Statistics.sampleStandardDeviation(unscaledMeans.length, Statistics.sum(unscaledMeans), Statistics.squareSum(unscaledMeans));
216     }
217     this.exponentialMovingArithmeticMean = function (minTime, alpha) {
218         var unscaledMeans = unscaledMeansForAllResults(minTime);
219         if (!unscaledMeans.length)
220             return NaN;
221         return this.scalingFactor() * unscaledMeans.reduce(function (movingAverage, currentMean) { return alpha * currentMean + (1 - alpha) * movingAverage; });
222     }
223     this.hasConfidenceInterval = function () { return !isNaN(this.lastResult().unscaledConfidenceIntervalDelta()); }
224     var meanPlotCache;
225     this.meanPlotData = function () {
226         if (!meanPlotCache)
227             meanPlotCache = results.map(function (result, index) { return [result.build().time(), result.mean()]; });
228         return meanPlotCache;
229     }
230     var upperConfidenceCache;
231     this.upperConfidencePlotData = function () {
232         if (!upperConfidenceCache) // FIXME: Use the actual confidence interval
233             upperConfidenceCache = results.map(function (result, index) { return [result.build().time(), result.mean() + result.confidenceIntervalDelta()]; });
234         return upperConfidenceCache;
235     }
236     var lowerConfidenceCache;
237     this.lowerConfidencePlotData = function () {
238         if (!lowerConfidenceCache) // FIXME: Use the actual confidence interval
239             lowerConfidenceCache = results.map(function (result, index) { return [result.build().time(), result.mean() - result.confidenceIntervalDelta()]; });
240         return lowerConfidenceCache;
241     }
242     this.scalingFactor = function() {
243         computeScalingFactorIfNeeded();
244         return cachedScalingFactor;
245     }
246     this.unit = function () {
247         computeScalingFactorIfNeeded();
248         return cachedUnit;
249     }
250     this.smallerIsBetter = function() { return unit == 'ms' || unit == 'bytes' || unit == ''; }
251 }
252
253 var URLState = new (function () {
254     var hash;
255     var parameters;
256     var updateTimer;
257
258     function parseIfNeeded() {
259         if (updateTimer || '#' + hash == location.hash)
260             return;
261         hash = location.hash.substr(1).replace(/\/+$/, '');
262         parameters = {};
263         hash.split(/[&;]/).forEach(function (token) {
264             var keyValue = token.split('=');
265             var key = decodeURIComponent(keyValue[0]);
266             if (key.length)
267                 parameters[key] = decodeURIComponent(keyValue.slice(1).join('='));
268         });
269     }
270
271     function updateHash() {
272         if (location.hash != hash)
273             location.hash = hash;
274     }
275
276     this.get = function (key, defaultValue) {
277         parseIfNeeded();
278         if (key in parameters)
279             return parameters[key];
280         else
281             return defaultValue;
282     }
283
284     function scheduleHashUpdate() {
285         var newHash = '';
286         for (key in parameters) {
287             if (newHash.length)
288                 newHash += '&';
289             newHash += encodeURIComponent(key) + '=' + encodeURIComponent(parameters[key]);
290         }
291         hash = newHash;
292         if (updateTimer)
293             clearTimeout(updateTimer);
294
295         updateTimer = setTimeout(function () {
296             updateTimer = undefined;
297             updateHash();
298         }, 500);
299     }
300
301     this.set = function (key, value) {
302         parseIfNeeded();
303         parameters[key] = value;
304         scheduleHashUpdate();
305     }
306
307     this.remove = function (key) {
308         parseIfNeeded();
309         delete parameters[key];
310         scheduleHashUpdate();
311     }
312
313     var watchCallbacks = {};
314     function onhashchange() {
315         if ('#' + hash == location.hash)
316             return;
317
318         // Race. If the hash had changed while we're waiting to update, ignore the change.
319         if (updateTimer) {
320             clearTimeout(updateTimer);
321             updateTimer = undefined;
322         }
323
324         // FIXME: Consider ignoring URLState.set/remove while notifying callbacks.
325         var oldParameters = parameters;
326         parseIfNeeded();
327         var callbacks = [];
328         var changedStates = [];
329         for (var key in watchCallbacks) {
330             if (parameters[key] == oldParameters[key])
331                 continue;
332             changedStates.push(key);
333             callbacks.push(watchCallbacks[key]);
334         }
335
336         for (var i = 0; i < callbacks.length; i++)
337             callbacks[i](changedStates);
338     }
339     $(window).bind('hashchange', onhashchange);
340
341     // FIXME: Support multiple callbacks on a single key.
342     this.watch = function (key, callback) {
343         parseIfNeeded();
344         watchCallbacks[key] = callback;
345     }
346 });
347
348 function Tooltip(containerParent, className) {
349     var container;
350     var self = this;
351     var hideTimer; // Use setTimeout(~, 0) to workaround the race condition that arises when moving mouse fast.
352     var previousContent;
353
354     function ensureContainer() {
355         if (container)
356             return;
357         container = document.createElement('div');
358         $(containerParent).append(container);
359         container.className = className;
360         container.style.position = 'absolute';
361         $(container).hide();
362     }
363
364     this.show = function (x, y, content) {
365         if (hideTimer) {
366             clearTimeout(hideTimer);
367             hideTimer = undefined;
368         }
369
370         if (previousContent === content) {
371             $(container).show();
372             return;
373         }
374         previousContent = content;
375
376         ensureContainer();
377         container.innerHTML = content;
378         $(container).show();
379         // FIXME: Style specific computation like this one shouldn't be in Tooltip class.
380         var top = y - $(container).outerHeight() - 15;
381         if (top < 0) {
382             $(container).addClass('inverted');
383             top = y + 15;
384         } else
385             $(container).removeClass('inverted');
386         $(container).offset({left: x - $(container).outerWidth() / 2, top: top});
387     }
388
389     this.hide = function () {
390         if (!container)
391             return;
392
393         if (hideTimer)
394             clearTimeout(hideTimer);
395         hideTimer = setTimeout(function () {
396             $(container).fadeOut(100);
397             previousResult = undefined;
398         }, 0);
399     }
400
401     this.remove = function (immediately) {
402         if (!container)
403             return;
404
405         if (hideTimer)
406             clearTimeout(hideTimer);
407
408         $(container).remove();
409         container = undefined;
410         previousResult = undefined;
411     }
412
413     this.toggle = function () {
414         $(container).toggle();
415         document.body.appendChild(container); // Toggled tooltip should show up at the top.
416     }
417
418     this.bindClick = function (callback) {
419         ensureContainer();
420         $(container).bind('click', callback);
421     }
422     this.bindMouseEnter = function (callback) {
423         ensureContainer();
424         $(container).bind('mouseenter', callback);
425     }
426 }