Skip to content

Commit 9561330

Browse files
authored
plots: dashboard - identify variance over lighthouse versions (#2520)
* up * fix lint errors but not working * fix * pilot dashboard * pilot * fix merge issue * fixup * fmt * fixups * clean up * fixit * refactor * fixup * cl feedback * cl fb * fix * fixup * filter out nulls * update text * done
1 parent 6df6b0e commit 9561330

File tree

7 files changed

+588
-7
lines changed

7 files changed

+588
-7
lines changed

plots/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ $ node measure.js --out out-123
2424
# This will launch the charts web page in the browser
2525
# node analyze.js {out_directory}
2626
$ node analyze.js ./out-hello
27+
28+
# Generate dashboard using a parent folder with multiple batch results
29+
$ node generate-dashboard.js out-parent-folder
30+
31+
# Or you can specify each batch result explicitly
32+
$ node generate-dashboard.js out-1 out-2 out-3
33+
34+
2735
```
2836

2937
### Advanced usage

plots/dashboard/dashboard.css

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* @license Copyright 2017 Google Inc. All Rights Reserved.
3+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
4+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
5+
*/
6+
7+
* {
8+
box-sizing: border-box;
9+
}
10+
11+
:root {
12+
--header-height: 60px;
13+
--primary-color: #3367d6;
14+
--form-color: #333;
15+
--overlay-offset: 50px;
16+
}
17+
18+
body {
19+
font-family: "Roboto",
20+
-apple-system,
21+
BlinkMacSystemFont,
22+
"Segoe UI",
23+
"Oxygen",
24+
"Ubuntu",
25+
"Cantarell",
26+
"Fira Sans",
27+
"Droid Sans",
28+
"Helvetica Neue",
29+
sans-serif;
30+
color: #555;
31+
width: 100%;
32+
margin-top: calc(var(--header-height) + 35px);
33+
}
34+
35+
#nav {
36+
position: fixed;
37+
top: 0;
38+
left: 0;
39+
height: var(--header-height);
40+
width: 100%;
41+
color: white;
42+
padding: 15px;
43+
font-size: 16px;
44+
background-color: var(--primary-color);
45+
box-shadow: 0 2px 4px rgba(0, 0, 0, .28);
46+
z-index: 1;
47+
}
48+
49+
.notes {
50+
margin-top: -10px;
51+
flex-direction: column;
52+
padding-left: 20px;
53+
font-size: 12px;
54+
font-style: italic;
55+
display: inline-flex;
56+
}
57+
58+
.title {
59+
font-size: 1.5rem;
60+
}
61+
62+
.dth-select {
63+
background-image: url(./dropdown_lt_2x.png);
64+
color: var(--form-color);
65+
background-color: #fff;
66+
-webkit-appearance: none;
67+
border-color: rgba(0, 0, 0, 0.2);
68+
position: relative;
69+
background-size: 12px 12px;
70+
background-repeat: no-repeat;
71+
background-position: right 6px center;
72+
padding-left: 4px;
73+
padding-right: 24px;
74+
font-size: 12px;
75+
line-height: 16px;
76+
}
77+
78+
.select-metric {
79+
padding-left: 24px;
80+
font-size: 0.9rem;
81+
}
82+
83+
.plot-container.plotly {
84+
margin: -25px;
85+
}
86+
87+
.dth-button {
88+
left: 0;
89+
position: absolute;
90+
top: 35px;
91+
cursor: pointer;
92+
color: var(--primary-color);;
93+
background-color: #fff;
94+
border-color: rgba(0, 0, 0, 0.2);
95+
padding: 3px 12px;
96+
font-weight: 600;
97+
-webkit-appearance: none;
98+
margin: 0;
99+
outline: 0;
100+
height: 24px;
101+
font-size: 12px;
102+
line-height: 16px;
103+
border: 1px solid;
104+
border-radius: 2px;
105+
}
106+
107+
#overlay {
108+
position: absolute;
109+
width: 100%;
110+
height: 100vh;
111+
top: 0;
112+
background: #e3f2f7;
113+
}
114+
115+
#overlay .close-button {
116+
position: absolute;
117+
top: calc(var(--header-height) + 30px);
118+
left: var(--overlay-offset);;
119+
}
120+
121+
#overlay .chart {
122+
position: absolute;
123+
top: calc(var(--header-height) + 100px);
124+
left: var(--overlay-offset);;
125+
}

plots/dashboard/dashboard.js

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/**
2+
* @license Copyright 2017 Google Inc. All Rights Reserved.
3+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
4+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
5+
*/
6+
'use strict';
7+
8+
/* global Plotly, dashboardResults */
9+
/* eslint-env browser */
10+
11+
class Dashboard {
12+
constructor(metrics, charts) {
13+
this._charts = charts;
14+
this._currentMetric = metrics[0];
15+
this._numberOfBatchesToShow = 0;
16+
this._initializeSelectMetricControl(metrics);
17+
this._initializeSelectNumberOfBatchesToShow();
18+
}
19+
20+
render() {
21+
this._charts.render(this._currentMetric, this._numberOfBatchesToShow);
22+
}
23+
24+
_initializeSelectMetricControl(metrics) {
25+
const metricsControl = document.getElementById('select-metric');
26+
for (const metric of metrics) {
27+
const option = document.createElement('option');
28+
option.label = metric;
29+
option.value = metric;
30+
metricsControl.appendChild(option);
31+
}
32+
metricsControl.addEventListener('change', e => this._onSelectMetric(e), false);
33+
}
34+
35+
_onSelectMetric(event) {
36+
this._currentMetric = event.target.value;
37+
this.render();
38+
}
39+
40+
_initializeSelectNumberOfBatchesToShow() {
41+
const control = document.getElementById('select-number-of-batches');
42+
control.addEventListener('change', e => this._onSelectNumberOfBatchesToShow(e), false);
43+
}
44+
45+
_onSelectNumberOfBatchesToShow(event) {
46+
if (event.target.value === 'all') {
47+
this._numberOfBatchesToShow = 0;
48+
} else {
49+
this._numberOfBatchesToShow = parseInt(event.target.value, 10);
50+
}
51+
this.render();
52+
}
53+
}
54+
55+
class Charts {
56+
constructor(renderingScheduler) {
57+
this._renderingScheduler = renderingScheduler;
58+
this._elementId = 1;
59+
this._layout = {
60+
width: 400,
61+
height: 300,
62+
xaxis: {
63+
showgrid: false,
64+
zeroline: false,
65+
tickangle: 60,
66+
showticklabels: false
67+
},
68+
yaxis: {
69+
zeroline: true,
70+
rangemode: 'tozero'
71+
},
72+
showlegend: false,
73+
titlefont: {
74+
family: `"Roboto", -apple-system, BlinkMacSystemFont, sans-serif`,
75+
size: 14
76+
}
77+
};
78+
}
79+
80+
render(currentMetric, numberOfBatchesToShow) {
81+
Utils.removeChildren(document.getElementById('charts'));
82+
for (const [metricName, site] of Object.entries(dashboardResults[currentMetric])) {
83+
const percentiles = Object.entries(site)
84+
.map(([batchName, batch]) => {
85+
return {
86+
x: batchName,
87+
higher: Utils.calculatePercentile(batch.map(metric => metric.timing), 0.8),
88+
median: Utils.calculatePercentile(batch.map(metric => metric.timing), 0.5),
89+
lower: Utils.calculatePercentile(batch.map(metric => metric.timing), 0.2)
90+
};
91+
})
92+
.slice(-1 * numberOfBatchesToShow);
93+
94+
const median = {
95+
x: percentiles.map(r => r.x),
96+
y: percentiles.map(r => r.median),
97+
type: 'scatter',
98+
mode: 'line',
99+
name: 'median'
100+
};
101+
102+
const errorBands = {
103+
x: percentiles.map(r => r.x).concat(percentiles.map(r => r.x).reverse()),
104+
y: percentiles.map(r => r.higher).concat(percentiles.map(r => r.lower).reverse()),
105+
fill: 'toself',
106+
fillcolor: 'rgba(0,176,246,0.2)',
107+
line: {color: 'transparent'},
108+
name: 'error bands',
109+
showlegend: false,
110+
type: 'scatter'
111+
};
112+
this._renderPreviewChart([median, errorBands], metricName);
113+
}
114+
}
115+
116+
_renderPreviewChart(data, title) {
117+
this._renderingScheduler.enqueue(_ => {
118+
Plotly.newPlot(
119+
this._createPreviewChartElement(data, title),
120+
data,
121+
Object.assign({title}, this._layout)
122+
);
123+
});
124+
}
125+
126+
_createPreviewChartElement(data, title) {
127+
const chart = document.createElement('div');
128+
chart.style = 'display: inline-block; position: relative';
129+
chart.id = 'chart' + this._elementId++;
130+
131+
const button = document.createElement('button');
132+
button.className = 'dth-button show-bigger-button';
133+
button.appendChild(document.createTextNode('Focus'));
134+
button.addEventListener('click', () => this._onFocusChart(data, title), false);
135+
chart.appendChild(button);
136+
137+
const container = document.getElementById('charts');
138+
container.appendChild(chart);
139+
return chart.id;
140+
}
141+
142+
_onFocusChart(data, title) {
143+
const overlay = document.createElement('div');
144+
overlay.id = 'overlay';
145+
document.body.appendChild(overlay);
146+
147+
document.getElementById('charts').style.display = 'none';
148+
149+
const closeButton = document.createElement('button');
150+
closeButton.className = 'dth-button close-button';
151+
closeButton.appendChild(document.createTextNode('Close'));
152+
closeButton.addEventListener('click', onCloseFocusedChart, false);
153+
overlay.appendChild(closeButton);
154+
155+
const chart = document.createElement('div');
156+
chart.className = 'chart';
157+
overlay.appendChild(chart);
158+
this._renderFocusedChart(data, title, chart);
159+
160+
function onCloseFocusedChart() {
161+
document.getElementById('charts').style.display = 'block';
162+
document.body.removeChild(overlay);
163+
}
164+
}
165+
166+
_renderFocusedChart(data, title, element) {
167+
Plotly.newPlot(
168+
element,
169+
data,
170+
Object.assign({title}, this._layout, {
171+
width: document.body.clientWidth - 100,
172+
height: 500
173+
})
174+
);
175+
}
176+
}
177+
178+
class RenderingScheduler {
179+
constructor() {
180+
this._queue = [];
181+
}
182+
183+
enqueue(fn) {
184+
const isFirst = this._queue.length == 0;
185+
this._queue.push(fn);
186+
if (isFirst) {
187+
this._render();
188+
}
189+
}
190+
191+
_render() {
192+
window.requestAnimationFrame(_ => {
193+
const plotFn = this._queue.shift();
194+
if (plotFn) {
195+
plotFn();
196+
this._render();
197+
}
198+
});
199+
}
200+
}
201+
202+
const Utils = {
203+
/**
204+
* @param {!Element} parent
205+
*/
206+
removeChildren(parent) {
207+
while (parent.firstChild) {
208+
parent.removeChild(parent.firstChild);
209+
}
210+
},
211+
212+
/**
213+
* Calculate the value at a given percentile
214+
* Based on: https://gist.github.com/IceCreamYou/6ffa1b18c4c8f6aeaad2
215+
* @param {!Array<number>} array
216+
* @param {number} percentile should be from 0 to 1
217+
*/
218+
calculatePercentile(array, percentile) {
219+
const sorted = array.filter(x => x !== null).sort((a, b) => a - b);
220+
221+
if (sorted.length === 0) {
222+
return 0;
223+
}
224+
if (sorted.length === 1 || percentile <= 0) {
225+
return sorted[0];
226+
}
227+
if (percentile >= 1) {
228+
return sorted[sorted.length - 1];
229+
}
230+
231+
const index = (sorted.length - 1) * percentile;
232+
const lower = Math.floor(index);
233+
const upper = lower + 1;
234+
const weight = index % 1;
235+
236+
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
237+
}
238+
};
239+
240+
function main() {
241+
/**
242+
* Navigation Start is usually not a very informative metric.
243+
*/
244+
const metrics = Object.keys(dashboardResults).filter(m => m !== 'Navigation Start');
245+
246+
const renderingScheduler = new RenderingScheduler();
247+
const charts = new Charts(renderingScheduler);
248+
const dashboard = new Dashboard(metrics, charts);
249+
dashboard.render();
250+
}
251+
252+
main();

plots/dashboard/dropdown_lt_2x.png

142 Bytes
Loading

0 commit comments

Comments
 (0)