lighthouse 11.1.0-dev.20231001 → 11.1.0-dev.20231003
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/test/smokehouse/__snapshots__/report-assert-test.js.snap +8 -0
- package/core/audits/audit.js +4 -9
- package/core/audits/byte-efficiency/uses-long-cache-ttl.js +1 -1
- package/core/audits/csp-xss.js +1 -1
- package/core/audits/installable-manifest.js +2 -2
- package/core/audits/metrics/cumulative-layout-shift.js +4 -1
- package/core/audits/metrics/first-contentful-paint.js +1 -0
- package/core/audits/metrics/largest-contentful-paint.js +1 -0
- package/core/audits/metrics/speed-index.js +1 -0
- package/core/audits/metrics/total-blocking-time.js +1 -0
- package/core/lib/csp-evaluator.js +13 -13
- package/dist/report/bundle.esm.js +243 -89
- package/dist/report/flow.js +248 -94
- package/dist/report/standalone.js +244 -90
- package/package.json +1 -1
- package/report/assets/styles.css +227 -78
- package/report/assets/templates.html +22 -35
- package/report/renderer/components.d.ts +2 -2
- package/report/renderer/components.js +34 -51
- package/report/renderer/dom.d.ts +24 -4
- package/report/renderer/dom.js +30 -3
- package/report/renderer/explodey-gauge.d.ts +11 -0
- package/report/renderer/explodey-gauge.js +357 -0
- package/report/renderer/performance-category-renderer.d.ts +16 -18
- package/report/renderer/performance-category-renderer.js +93 -102
- package/shared/localization/locales/en-US.json +14 -14
- package/shared/localization/locales/en-XL.json +14 -14
- package/shared/util.d.ts +22 -0
- package/shared/util.js +31 -0
- package/types/audit.d.ts +3 -1
- package/types/lhr/audit-result.d.ts +9 -0
- /package/{core/lib → shared}/statistics.d.ts +0 -0
- /package/{core/lib → shared}/statistics.js +0 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license Copyright 2023 The Lighthouse Authors. 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
|
+
/* eslint-env browser */
|
|
8
|
+
/* eslint-disable max-len */
|
|
9
|
+
|
|
10
|
+
import {ReportUtils} from './report-utils.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {import('./dom.js').DOM} dom
|
|
14
|
+
*/
|
|
15
|
+
function createGauge(dom) {
|
|
16
|
+
const docFrag = dom.createComponent('explodeyGauge');
|
|
17
|
+
return dom.find('.lh-exp-gauge-component', docFrag);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {import('./dom.js').DOM} dom
|
|
22
|
+
* @param {Element} componentEl
|
|
23
|
+
* @param {LH.ReportResult.Category} category
|
|
24
|
+
*/
|
|
25
|
+
function updateGauge(dom, componentEl, category) {
|
|
26
|
+
const wrapperEl = dom.find('div.lh-exp-gauge__wrapper', componentEl);
|
|
27
|
+
wrapperEl.className = '';
|
|
28
|
+
wrapperEl.classList.add('lh-exp-gauge__wrapper',
|
|
29
|
+
`lh-exp-gauge__wrapper--${ReportUtils.calculateRating(category.score)}`);
|
|
30
|
+
_setPerfGaugeExplodey(dom, wrapperEl, category);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {number} sizeSVG
|
|
35
|
+
* @param {number} percent
|
|
36
|
+
* @param {number=} strokeWidth
|
|
37
|
+
*/
|
|
38
|
+
function _determineTrig(sizeSVG, percent, strokeWidth) {
|
|
39
|
+
strokeWidth = strokeWidth || sizeSVG / 32;
|
|
40
|
+
|
|
41
|
+
const radiusInner = sizeSVG / strokeWidth;
|
|
42
|
+
const strokeGap = 0.5 * strokeWidth;
|
|
43
|
+
const radiusOuter = radiusInner + strokeGap + strokeWidth;
|
|
44
|
+
|
|
45
|
+
const circumferenceInner = 2 * Math.PI * radiusInner;
|
|
46
|
+
// arc length we need to subtract
|
|
47
|
+
// for very small strokeWidth:radius ratios this is ≈ strokeWidth
|
|
48
|
+
// angle = acute angle of isosceles △ with 2 edges equal to radius & 3rd equal to strokeWidth
|
|
49
|
+
// angle formula given by law of cosines
|
|
50
|
+
const endDiffInner = Math.acos(1 - 0.5 * Math.pow((0.5 * strokeWidth) / radiusInner, 2)) * radiusInner;
|
|
51
|
+
|
|
52
|
+
const circumferenceOuter = 2 * Math.PI * radiusOuter;
|
|
53
|
+
const endDiffOuter = Math.acos(1 - 0.5 * Math.pow((0.5 * strokeWidth) / radiusOuter, 2)) * radiusOuter;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
radiusInner,
|
|
57
|
+
radiusOuter,
|
|
58
|
+
circumferenceInner,
|
|
59
|
+
circumferenceOuter,
|
|
60
|
+
getArcLength: () => Math.max(0, Number((percent * circumferenceInner))),
|
|
61
|
+
/**
|
|
62
|
+
* @param {number} weightingPct
|
|
63
|
+
* @param {boolean} isButt for metricArcHoverTarget
|
|
64
|
+
*/
|
|
65
|
+
getMetricArcLength: (weightingPct, isButt = false) => {
|
|
66
|
+
// TODO: this math isn't perfect butt it's very close.
|
|
67
|
+
const linecapFactor = isButt ? 0 : 2 * endDiffOuter;
|
|
68
|
+
return Math.max(0, Number((weightingPct * circumferenceOuter - strokeGap - linecapFactor)));
|
|
69
|
+
},
|
|
70
|
+
endDiffInner,
|
|
71
|
+
endDiffOuter,
|
|
72
|
+
strokeWidth,
|
|
73
|
+
strokeGap,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {import('./dom.js').DOM} dom
|
|
79
|
+
* @param {HTMLElement} wrapperEl
|
|
80
|
+
* @param {LH.ReportResult.Category} category
|
|
81
|
+
*/
|
|
82
|
+
function _setPerfGaugeExplodey(dom, wrapperEl, category) {
|
|
83
|
+
const sizeSVG = 128;
|
|
84
|
+
const offsetSVG = -0.5 * sizeSVG;
|
|
85
|
+
|
|
86
|
+
const percent = Number(category.score);
|
|
87
|
+
const {
|
|
88
|
+
radiusInner,
|
|
89
|
+
radiusOuter,
|
|
90
|
+
circumferenceInner,
|
|
91
|
+
circumferenceOuter,
|
|
92
|
+
getArcLength,
|
|
93
|
+
getMetricArcLength,
|
|
94
|
+
endDiffInner,
|
|
95
|
+
endDiffOuter,
|
|
96
|
+
strokeWidth,
|
|
97
|
+
strokeGap,
|
|
98
|
+
} = _determineTrig(sizeSVG, percent);
|
|
99
|
+
|
|
100
|
+
const SVG = dom.find('svg.lh-exp-gauge', wrapperEl);
|
|
101
|
+
|
|
102
|
+
dom.find('.lh-exp-gauge__label', SVG).textContent = category.title;
|
|
103
|
+
|
|
104
|
+
SVG.setAttribute('viewBox', [offsetSVG, offsetSVG / 2, sizeSVG, sizeSVG / 2].join(' '));
|
|
105
|
+
SVG.style.setProperty('--stroke-width', `${strokeWidth}px`);
|
|
106
|
+
SVG.style.setProperty('--circle-meas', (2 * Math.PI).toFixed(4));
|
|
107
|
+
|
|
108
|
+
const groupOuter = dom.find('g.lh-exp-gauge__outer', wrapperEl);
|
|
109
|
+
const groupInner = dom.find('g.lh-exp-gauge__inner', wrapperEl);
|
|
110
|
+
const cover = dom.find('circle.lh-cover', groupOuter);
|
|
111
|
+
const gaugeArc = dom.find('circle.lh-exp-gauge__arc', groupInner);
|
|
112
|
+
const gaugePerc = dom.find('text.lh-exp-gauge__percentage', groupInner);
|
|
113
|
+
|
|
114
|
+
groupOuter.style.setProperty('--scale-initial', String(radiusInner / radiusOuter));
|
|
115
|
+
groupOuter.style.setProperty('--radius', `${radiusOuter}px`);
|
|
116
|
+
cover.style.setProperty('--radius', `${0.5 * (radiusInner + radiusOuter)}px`);
|
|
117
|
+
cover.setAttribute('stroke-width', String(strokeGap));
|
|
118
|
+
SVG.style.setProperty('--radius', `${radiusInner}px`);
|
|
119
|
+
|
|
120
|
+
gaugeArc.setAttribute('stroke-dasharray',
|
|
121
|
+
`${getArcLength()} ${(circumferenceInner - getArcLength()).toFixed(4)}`);
|
|
122
|
+
gaugeArc.setAttribute('stroke-dashoffset', String(0.25 * circumferenceInner - endDiffInner));
|
|
123
|
+
|
|
124
|
+
gaugePerc.textContent = Math.round(percent * 100).toString();
|
|
125
|
+
|
|
126
|
+
const radiusTextOuter = radiusOuter + strokeWidth;
|
|
127
|
+
const radiusTextInner = radiusOuter - strokeWidth;
|
|
128
|
+
|
|
129
|
+
const metrics = category.auditRefs.filter(r => r.group === 'metrics' && r.weight);
|
|
130
|
+
const totalWeight = metrics.reduce((sum, each) => (sum += each.weight), 0);
|
|
131
|
+
let offsetAdder = 0.25 * circumferenceOuter - endDiffOuter - 0.5 * strokeGap;
|
|
132
|
+
let angleAdder = -0.5 * Math.PI;
|
|
133
|
+
|
|
134
|
+
// Extra hack on top of the HACK for element reuse below. Delete any metric elems that aren't needed anymore (happens when the same gauge goes from v5 to v6)
|
|
135
|
+
groupOuter.querySelectorAll('.metric').forEach(metricElem => {
|
|
136
|
+
const classNamesToRetain = metrics.map(metric => `metric--${metric.id}`);
|
|
137
|
+
const match = classNamesToRetain.find(selector => metricElem.classList.contains(selector));
|
|
138
|
+
if (!match) metricElem.remove();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
metrics.forEach((metric, i) => {
|
|
142
|
+
const alias = metric.acronym ?? metric.id;
|
|
143
|
+
|
|
144
|
+
// Hack
|
|
145
|
+
const needsDomPopulation = !groupOuter.querySelector(`.metric--${alias}`);
|
|
146
|
+
|
|
147
|
+
// HACK:This isn't ideal but it was quick. Create element during initialization or reuse existing during updates
|
|
148
|
+
const metricGroup = dom.maybeFind(`g.metric--${alias}`, groupOuter) || dom.createSVGElement('g');
|
|
149
|
+
const metricArcMax = dom.maybeFind(`.metric--${alias} circle.lh-exp-gauge--faded`, groupOuter) || dom.createSVGElement('circle');
|
|
150
|
+
const metricArc = dom.maybeFind(`.metric--${alias} circle.lh-exp-gauge--miniarc`, groupOuter) || dom.createSVGElement('circle');
|
|
151
|
+
const metricArcHoverTarget = dom.maybeFind(`.metric--${alias} circle.lh-exp-gauge-hovertarget`, groupOuter) || dom.createSVGElement('circle');
|
|
152
|
+
const metricLabel = dom.maybeFind(`.metric--${alias} text.metric__label`, groupOuter) || dom.createSVGElement('text');
|
|
153
|
+
const metricValue = dom.maybeFind(`.metric--${alias} text.metric__value`, groupOuter) || dom.createSVGElement('text');
|
|
154
|
+
|
|
155
|
+
metricGroup.classList.add('metric', `metric--${alias}`);
|
|
156
|
+
metricArcMax.classList.add('lh-exp-gauge__arc', 'lh-exp-gauge__arc--metric', 'lh-exp-gauge--faded');
|
|
157
|
+
metricArc.classList.add('lh-exp-gauge__arc', 'lh-exp-gauge__arc--metric', 'lh-exp-gauge--miniarc');
|
|
158
|
+
metricArcHoverTarget.classList.add('lh-exp-gauge__arc', 'lh-exp-gauge__arc--metric', 'lh-exp-gauge-hovertarget');
|
|
159
|
+
|
|
160
|
+
const weightingPct = metric.weight / totalWeight;
|
|
161
|
+
const metricLengthMax = getMetricArcLength(weightingPct);
|
|
162
|
+
const metricPercent = metric.result.score ? metric.result.score * weightingPct : 0;
|
|
163
|
+
const metricLength = getMetricArcLength(metricPercent);
|
|
164
|
+
const metricOffset = weightingPct * circumferenceOuter;
|
|
165
|
+
const metricHoverLength = getMetricArcLength(weightingPct, true);
|
|
166
|
+
const rating = ReportUtils.calculateRating(metric.result.score, metric.result.scoreDisplayMode);
|
|
167
|
+
|
|
168
|
+
metricGroup.style.setProperty('--metric-rating', rating);
|
|
169
|
+
metricGroup.style.setProperty('--metric-color', `var(--color-${rating})`);
|
|
170
|
+
metricGroup.style.setProperty('--metric-offset', `${offsetAdder}`);
|
|
171
|
+
metricGroup.style.setProperty('--i', i.toString());
|
|
172
|
+
|
|
173
|
+
metricArcMax.setAttribute('stroke-dasharray', `${metricLengthMax} ${circumferenceOuter - metricLengthMax}`);
|
|
174
|
+
metricArc.style.setProperty('--metric-array', `${metricLength} ${circumferenceOuter - metricLength}`);
|
|
175
|
+
metricArcHoverTarget.setAttribute('stroke-dasharray', `${metricHoverLength} ${circumferenceOuter - metricHoverLength - endDiffOuter}`);
|
|
176
|
+
|
|
177
|
+
metricLabel.classList.add('metric__label');
|
|
178
|
+
metricValue.classList.add('metric__value');
|
|
179
|
+
metricLabel.textContent = alias;
|
|
180
|
+
metricValue.textContent = `+${Math.round(metricPercent * 100)}`;
|
|
181
|
+
|
|
182
|
+
const midAngle = angleAdder + weightingPct * Math.PI;
|
|
183
|
+
const cos = Math.cos(midAngle);
|
|
184
|
+
const sin = Math.sin(midAngle);
|
|
185
|
+
|
|
186
|
+
// only set non-default alignments
|
|
187
|
+
switch (true) {
|
|
188
|
+
case cos > 0:
|
|
189
|
+
metricValue.setAttribute('text-anchor', 'end');
|
|
190
|
+
break;
|
|
191
|
+
case cos < 0:
|
|
192
|
+
metricLabel.setAttribute('text-anchor', 'end');
|
|
193
|
+
break;
|
|
194
|
+
case cos === 0:
|
|
195
|
+
metricLabel.setAttribute('text-anchor', 'middle');
|
|
196
|
+
metricValue.setAttribute('text-anchor', 'middle');
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
switch (true) {
|
|
201
|
+
case sin > 0:
|
|
202
|
+
metricLabel.setAttribute('dominant-baseline', 'hanging');
|
|
203
|
+
break;
|
|
204
|
+
case sin < 0:
|
|
205
|
+
metricValue.setAttribute('dominant-baseline', 'hanging');
|
|
206
|
+
break;
|
|
207
|
+
case sin === 0:
|
|
208
|
+
metricLabel.setAttribute('dominant-baseline', 'middle');
|
|
209
|
+
metricValue.setAttribute('dominant-baseline', 'middle');
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
metricLabel.setAttribute('x', (radiusTextOuter * cos).toFixed(2));
|
|
214
|
+
metricLabel.setAttribute('y', (radiusTextOuter * sin).toFixed(2));
|
|
215
|
+
metricValue.setAttribute('x', (radiusTextInner * cos).toFixed(2));
|
|
216
|
+
metricValue.setAttribute('y', (radiusTextInner * sin).toFixed(2));
|
|
217
|
+
|
|
218
|
+
if (needsDomPopulation) {
|
|
219
|
+
metricGroup.appendChild(metricArcMax);
|
|
220
|
+
metricGroup.appendChild(metricArc);
|
|
221
|
+
metricGroup.appendChild(metricArcHoverTarget);
|
|
222
|
+
metricGroup.appendChild(metricLabel);
|
|
223
|
+
metricGroup.appendChild(metricValue);
|
|
224
|
+
groupOuter.appendChild(metricGroup);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
offsetAdder -= metricOffset;
|
|
228
|
+
angleAdder += weightingPct * 2 * Math.PI;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Catch pointerover movement between the hovertarget arcs. Without this the metric-highlights can clear when moving between.
|
|
232
|
+
const underHoverTarget = groupOuter.querySelector(`.lh-exp-gauge-underhovertarget`) || dom.createSVGElement('circle');
|
|
233
|
+
underHoverTarget.classList.add('lh-exp-gauge__arc', 'lh-exp-gauge__arc--metric', 'lh-exp-gauge-hovertarget', 'lh-exp-gauge-underhovertarget');
|
|
234
|
+
const underHoverLength = getMetricArcLength(1, true);
|
|
235
|
+
underHoverTarget.setAttribute('stroke-dasharray', `${underHoverLength} ${circumferenceOuter - underHoverLength - endDiffOuter}`);
|
|
236
|
+
if (!underHoverTarget.isConnected) {
|
|
237
|
+
groupOuter.prepend(underHoverTarget);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Hack. Not ideal.
|
|
241
|
+
if (SVG.dataset.listenersSetup) return;
|
|
242
|
+
// @ts-expect-error
|
|
243
|
+
SVG.dataset.listenersSetup = true;
|
|
244
|
+
|
|
245
|
+
peekGauge(SVG);
|
|
246
|
+
|
|
247
|
+
/*
|
|
248
|
+
wrapperEl.state-expanded: gauge is exploded
|
|
249
|
+
wrapperEl.state-highlight: gauge is exploded and one of the metrics is being highlighted
|
|
250
|
+
metric.metric-highlight: highlight this particular metric
|
|
251
|
+
*/
|
|
252
|
+
SVG.addEventListener('pointerover', e => {
|
|
253
|
+
// If hovering outside of the arcs, reset back to unexploded state
|
|
254
|
+
if (e.target === SVG && SVG.classList.contains('state--expanded')) {
|
|
255
|
+
SVG.classList.remove('state--expanded');
|
|
256
|
+
|
|
257
|
+
if (SVG.classList.contains('state--highlight')) {
|
|
258
|
+
SVG.classList.remove('state--highlight');
|
|
259
|
+
dom.find('.metric--highlight', SVG).classList.remove('metric--highlight');
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!(e.target instanceof Element)) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const parentEl = e.target.parentNode;
|
|
269
|
+
if (!(parentEl instanceof SVGElement)) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// if hovering on the primary (inner) part, then explode it but dont highlight
|
|
274
|
+
if (parentEl && parentEl === groupInner) {
|
|
275
|
+
if (!SVG.classList.contains('state--expanded')) SVG.classList.add('state--expanded');
|
|
276
|
+
else if (SVG.classList.contains('state--highlight')) {
|
|
277
|
+
SVG.classList.remove('state--highlight');
|
|
278
|
+
dom.find('.metric--highlight', SVG).classList.remove('metric--highlight');
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// if hovering on a metric, highlight that one.
|
|
284
|
+
// TODO: The hover target is a little small. ideally it's thicker.
|
|
285
|
+
if (parentEl && parentEl.classList && parentEl.classList.contains('metric')) {
|
|
286
|
+
// match the bg color of the gauge during a metric highlight
|
|
287
|
+
const metricRating = parentEl.style.getPropertyValue('--metric-rating');
|
|
288
|
+
wrapperEl.style.setProperty('--color-highlight', `var(--color-${metricRating}-secondary)`);
|
|
289
|
+
|
|
290
|
+
if (!SVG.classList.contains('state--highlight')) {
|
|
291
|
+
SVG.classList.add('state--highlight');
|
|
292
|
+
parentEl.classList.add('metric--highlight');
|
|
293
|
+
} else {
|
|
294
|
+
const highlightEl = dom.find('.metric--highlight', SVG);
|
|
295
|
+
|
|
296
|
+
if (parentEl !== highlightEl) {
|
|
297
|
+
highlightEl.classList.remove('metric--highlight');
|
|
298
|
+
parentEl.classList.add('metric--highlight');
|
|
299
|
+
console.log({highlightEl, parent: parentEl});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// clear on mouselave even if mousemove didn't catch it.
|
|
306
|
+
SVG.addEventListener('mouseleave', () => {
|
|
307
|
+
// SVG.classList.remove('state--expanded');
|
|
308
|
+
SVG.classList.remove('state--highlight');
|
|
309
|
+
const mh = SVG.querySelector('.metric--highlight');
|
|
310
|
+
mh?.classList.remove('metric--highlight');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* On the first run, tease with a little peek reveal
|
|
315
|
+
* @param {SVGElement} SVG
|
|
316
|
+
*/
|
|
317
|
+
async function peekGauge(SVG) {
|
|
318
|
+
// Delay just a tad to let the user aclimatize beforehand.
|
|
319
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
320
|
+
|
|
321
|
+
// Early exit if it's already engaged
|
|
322
|
+
if (SVG.classList.contains('state--expanded')) return;
|
|
323
|
+
|
|
324
|
+
// To visually get the outer ring to peek on the edge, we need the inner ring on top. This is SVG's equivalent to `innerElem.zIndex = 100`
|
|
325
|
+
const inner = dom.find('.lh-exp-gauge__inner', SVG);
|
|
326
|
+
const id = `uniq-${Math.random()}`;
|
|
327
|
+
inner.setAttribute('id', id);
|
|
328
|
+
const useElem = dom.createSVGElement('use');
|
|
329
|
+
useElem.setAttribute('href', `#${id}`);
|
|
330
|
+
// for paint order this must come _after_ the outer.
|
|
331
|
+
SVG.appendChild(useElem);
|
|
332
|
+
|
|
333
|
+
const peekDurationSec = 2.5;
|
|
334
|
+
SVG.style.setProperty('--peek-dur', `${peekDurationSec}s`);
|
|
335
|
+
SVG.classList.add('state--peek', 'state--expanded');
|
|
336
|
+
|
|
337
|
+
// Fancy double cleanup
|
|
338
|
+
const cleanup = () => {
|
|
339
|
+
SVG.classList.remove('state--peek', 'state--expanded');
|
|
340
|
+
useElem.remove();
|
|
341
|
+
};
|
|
342
|
+
const tId = setTimeout(() => {
|
|
343
|
+
SVG.removeEventListener('mouseenter', handleEarlyInteraction);
|
|
344
|
+
cleanup();
|
|
345
|
+
}, peekDurationSec * 1000 * 1.5); // lil extra time just cuz
|
|
346
|
+
function handleEarlyInteraction() {
|
|
347
|
+
clearTimeout(tId);
|
|
348
|
+
cleanup();
|
|
349
|
+
}
|
|
350
|
+
SVG.addEventListener('mouseenter', handleEarlyInteraction, {once: true});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export {
|
|
355
|
+
createGauge,
|
|
356
|
+
updateGauge,
|
|
357
|
+
};
|
|
@@ -4,20 +4,6 @@ export class PerformanceCategoryRenderer extends CategoryRenderer {
|
|
|
4
4
|
* @return {!Element}
|
|
5
5
|
*/
|
|
6
6
|
_renderMetric(audit: LH.ReportResult.AuditRef): Element;
|
|
7
|
-
/**
|
|
8
|
-
* @param {LH.ReportResult.AuditRef} audit
|
|
9
|
-
* @param {number} scale
|
|
10
|
-
* @return {!Element}
|
|
11
|
-
*/
|
|
12
|
-
_renderOpportunity(audit: LH.ReportResult.AuditRef, scale: number): Element;
|
|
13
|
-
/**
|
|
14
|
-
* Get an audit's wastedMs to sort the opportunity by, and scale the sparkline width
|
|
15
|
-
* Opportunities with an error won't have a details object, so MIN_VALUE is returned to keep any
|
|
16
|
-
* erroring opportunities last in sort order.
|
|
17
|
-
* @param {LH.ReportResult.AuditRef} audit
|
|
18
|
-
* @return {number}
|
|
19
|
-
*/
|
|
20
|
-
_getWastedMs(audit: LH.ReportResult.AuditRef): number;
|
|
21
7
|
/**
|
|
22
8
|
* Get a link to the interactive scoring calculator with the metric values.
|
|
23
9
|
* @param {LH.ReportResult.AuditRef[]} auditRefs
|
|
@@ -25,13 +11,25 @@ export class PerformanceCategoryRenderer extends CategoryRenderer {
|
|
|
25
11
|
*/
|
|
26
12
|
_getScoringCalculatorHref(auditRefs: LH.ReportResult.AuditRef[]): string;
|
|
27
13
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
14
|
+
* Returns true if the audit is a general performance insight (i.e. not a metric or hidden audit).
|
|
15
|
+
*
|
|
16
|
+
* @param {LH.ReportResult.AuditRef} audit
|
|
17
|
+
* @return {boolean}
|
|
18
|
+
*/
|
|
19
|
+
_isPerformanceInsight(audit: LH.ReportResult.AuditRef): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Returns overallImpact and linearImpact for an audit.
|
|
22
|
+
* The overallImpact is determined by the audit saving's effect on the overall performance score.
|
|
23
|
+
* We use linearImpact to compare audits where their overallImpact is rounded down to 0.
|
|
30
24
|
*
|
|
31
25
|
* @param {LH.ReportResult.AuditRef} audit
|
|
32
|
-
* @
|
|
26
|
+
* @param {LH.ReportResult.AuditRef[]} metricAudits
|
|
27
|
+
* @return {{overallImpact: number, overallLinearImpact: number}}
|
|
33
28
|
*/
|
|
34
|
-
|
|
29
|
+
overallImpact(audit: LH.ReportResult.AuditRef, metricAudits: LH.ReportResult.AuditRef[]): {
|
|
30
|
+
overallImpact: number;
|
|
31
|
+
overallLinearImpact: number;
|
|
32
|
+
};
|
|
35
33
|
/**
|
|
36
34
|
* @param {LH.ReportResult.Category} category
|
|
37
35
|
* @param {Object<string, LH.Result.ReportGroup>} groups
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import {CategoryRenderer} from './category-renderer.js';
|
|
10
10
|
import {ReportUtils} from './report-utils.js';
|
|
11
11
|
import {Globals} from './report-globals.js';
|
|
12
|
+
import {Util} from '../../shared/util.js';
|
|
13
|
+
import {createGauge, updateGauge} from './explodey-gauge.js';
|
|
12
14
|
|
|
13
15
|
export class PerformanceCategoryRenderer extends CategoryRenderer {
|
|
14
16
|
/**
|
|
@@ -43,61 +45,6 @@ export class PerformanceCategoryRenderer extends CategoryRenderer {
|
|
|
43
45
|
return element;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
/**
|
|
47
|
-
* @param {LH.ReportResult.AuditRef} audit
|
|
48
|
-
* @param {number} scale
|
|
49
|
-
* @return {!Element}
|
|
50
|
-
*/
|
|
51
|
-
_renderOpportunity(audit, scale) {
|
|
52
|
-
const oppTmpl = this.dom.createComponent('opportunity');
|
|
53
|
-
const element = this.populateAuditValues(audit, oppTmpl);
|
|
54
|
-
element.id = audit.result.id;
|
|
55
|
-
|
|
56
|
-
if (!audit.result.details || audit.result.scoreDisplayMode === 'error') {
|
|
57
|
-
return element;
|
|
58
|
-
}
|
|
59
|
-
const details = audit.result.details;
|
|
60
|
-
if (details.overallSavingsMs === undefined) {
|
|
61
|
-
return element;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Overwrite the displayValue with opportunity's wastedMs
|
|
65
|
-
// TODO: normalize this to one tagName.
|
|
66
|
-
const displayEl =
|
|
67
|
-
this.dom.find('span.lh-audit__display-text, div.lh-audit__display-text', element);
|
|
68
|
-
const sparklineWidthPct = `${details.overallSavingsMs / scale * 100}%`;
|
|
69
|
-
this.dom.find('div.lh-sparkline__bar', element).style.width = sparklineWidthPct;
|
|
70
|
-
displayEl.textContent = Globals.i18n.formatSeconds(details.overallSavingsMs, 0.01);
|
|
71
|
-
|
|
72
|
-
// Set [title] tooltips
|
|
73
|
-
if (audit.result.displayValue) {
|
|
74
|
-
const displayValue = audit.result.displayValue;
|
|
75
|
-
this.dom.find('div.lh-load-opportunity__sparkline', element).title = displayValue;
|
|
76
|
-
displayEl.title = displayValue;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return element;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Get an audit's wastedMs to sort the opportunity by, and scale the sparkline width
|
|
84
|
-
* Opportunities with an error won't have a details object, so MIN_VALUE is returned to keep any
|
|
85
|
-
* erroring opportunities last in sort order.
|
|
86
|
-
* @param {LH.ReportResult.AuditRef} audit
|
|
87
|
-
* @return {number}
|
|
88
|
-
*/
|
|
89
|
-
_getWastedMs(audit) {
|
|
90
|
-
if (audit.result.details) {
|
|
91
|
-
const details = audit.result.details;
|
|
92
|
-
if (typeof details.overallSavingsMs !== 'number') {
|
|
93
|
-
throw new Error('non-opportunity details passed to _getWastedMs');
|
|
94
|
-
}
|
|
95
|
-
return details.overallSavingsMs;
|
|
96
|
-
} else {
|
|
97
|
-
return Number.MIN_VALUE;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
48
|
/**
|
|
102
49
|
* Get a link to the interactive scoring calculator with the metric values.
|
|
103
50
|
* @param {LH.ReportResult.AuditRef[]} auditRefs
|
|
@@ -146,18 +93,55 @@ export class PerformanceCategoryRenderer extends CategoryRenderer {
|
|
|
146
93
|
}
|
|
147
94
|
|
|
148
95
|
/**
|
|
149
|
-
*
|
|
150
|
-
* The audit details type will determine which of the two groups an audit is in.
|
|
96
|
+
* Returns true if the audit is a general performance insight (i.e. not a metric or hidden audit).
|
|
151
97
|
*
|
|
152
98
|
* @param {LH.ReportResult.AuditRef} audit
|
|
153
|
-
* @return {
|
|
99
|
+
* @return {boolean}
|
|
154
100
|
*/
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
101
|
+
_isPerformanceInsight(audit) {
|
|
102
|
+
return !audit.group;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Returns overallImpact and linearImpact for an audit.
|
|
107
|
+
* The overallImpact is determined by the audit saving's effect on the overall performance score.
|
|
108
|
+
* We use linearImpact to compare audits where their overallImpact is rounded down to 0.
|
|
109
|
+
*
|
|
110
|
+
* @param {LH.ReportResult.AuditRef} audit
|
|
111
|
+
* @param {LH.ReportResult.AuditRef[]} metricAudits
|
|
112
|
+
* @return {{overallImpact: number, overallLinearImpact: number}}
|
|
113
|
+
*/
|
|
114
|
+
overallImpact(audit, metricAudits) {
|
|
115
|
+
if (!audit.result.metricSavings) {
|
|
116
|
+
return {overallImpact: 0, overallLinearImpact: 0};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let overallImpact = 0;
|
|
120
|
+
let overallLinearImpact = 0;
|
|
121
|
+
for (const [k, savings] of Object.entries(audit.result.metricSavings)) {
|
|
122
|
+
// Get metric savings for individual audit.
|
|
123
|
+
if (savings === undefined) continue;
|
|
124
|
+
|
|
125
|
+
// Get the metric data.
|
|
126
|
+
const mAudit = metricAudits.find(audit => audit.acronym === k);
|
|
127
|
+
if (!mAudit) continue;
|
|
128
|
+
if (mAudit.result.score === null) continue;
|
|
129
|
+
|
|
130
|
+
const mValue = mAudit.result.numericValue;
|
|
131
|
+
if (!mValue) continue;
|
|
132
|
+
|
|
133
|
+
const linearImpact = savings / mValue * mAudit.weight;
|
|
134
|
+
overallLinearImpact += linearImpact;
|
|
135
|
+
|
|
136
|
+
const scoringOptions = mAudit.result.scoringOptions;
|
|
137
|
+
if (!scoringOptions) continue;
|
|
138
|
+
|
|
139
|
+
const newMetricScore = Util.computeLogNormalScore(scoringOptions, mValue - savings);
|
|
140
|
+
|
|
141
|
+
const weightedMetricImpact = (newMetricScore - mAudit.result.score) * mAudit.weight;
|
|
142
|
+
overallImpact += weightedMetricImpact;
|
|
159
143
|
}
|
|
160
|
-
return
|
|
144
|
+
return {overallImpact, overallLinearImpact};
|
|
161
145
|
}
|
|
162
146
|
|
|
163
147
|
/**
|
|
@@ -227,53 +211,54 @@ export class PerformanceCategoryRenderer extends CategoryRenderer {
|
|
|
227
211
|
filmstripEl && timelineEl.append(filmstripEl);
|
|
228
212
|
}
|
|
229
213
|
|
|
230
|
-
// Opportunities
|
|
231
|
-
const opportunityAudits = category.auditRefs
|
|
232
|
-
.filter(audit => this._classifyPerformanceAudit(audit) === 'load-opportunity')
|
|
233
|
-
.filter(audit => !ReportUtils.showAsPassed(audit.result))
|
|
234
|
-
.sort((auditA, auditB) => this._getWastedMs(auditB) - this._getWastedMs(auditA));
|
|
235
|
-
|
|
236
214
|
const filterableMetrics = metricAudits.filter(a => !!a.relevantAudits);
|
|
237
215
|
// TODO: only add if there are opportunities & diagnostics rendered.
|
|
238
216
|
if (filterableMetrics.length) {
|
|
239
217
|
this.renderMetricAuditFilter(filterableMetrics, element);
|
|
240
218
|
}
|
|
241
219
|
|
|
242
|
-
if (opportunityAudits.length) {
|
|
243
|
-
// Scale the sparklines relative to savings, minimum 2s to not overstate small savings
|
|
244
|
-
const minimumScale = 2000;
|
|
245
|
-
const wastedMsValues = opportunityAudits.map(audit => this._getWastedMs(audit));
|
|
246
|
-
const maxWaste = Math.max(...wastedMsValues);
|
|
247
|
-
const scale = Math.max(Math.ceil(maxWaste / 1000) * 1000, minimumScale);
|
|
248
|
-
const [groupEl, footerEl] = this.renderAuditGroup(groups['load-opportunities']);
|
|
249
|
-
const tmpl = this.dom.createComponent('opportunityHeader');
|
|
250
|
-
|
|
251
|
-
this.dom.find('.lh-load-opportunity__col--one', tmpl).textContent =
|
|
252
|
-
strings.opportunityResourceColumnLabel;
|
|
253
|
-
this.dom.find('.lh-load-opportunity__col--two', tmpl).textContent =
|
|
254
|
-
strings.opportunitySavingsColumnLabel;
|
|
255
|
-
|
|
256
|
-
const headerEl = this.dom.find('.lh-load-opportunity__header', tmpl);
|
|
257
|
-
groupEl.insertBefore(headerEl, footerEl);
|
|
258
|
-
opportunityAudits.forEach(item =>
|
|
259
|
-
groupEl.insertBefore(this._renderOpportunity(item, scale), footerEl));
|
|
260
|
-
groupEl.classList.add('lh-audit-group--load-opportunities');
|
|
261
|
-
element.append(groupEl);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
220
|
// Diagnostics
|
|
265
221
|
const diagnosticAudits = category.auditRefs
|
|
266
|
-
.filter(audit => this.
|
|
267
|
-
.filter(audit => !ReportUtils.showAsPassed(audit.result))
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
222
|
+
.filter(audit => this._isPerformanceInsight(audit))
|
|
223
|
+
.filter(audit => !ReportUtils.showAsPassed(audit.result));
|
|
224
|
+
|
|
225
|
+
/** @type {Array<{auditRef:LH.ReportResult.AuditRef, overallImpact: number, overallLinearImpact: number, guidanceLevel: number}>} */
|
|
226
|
+
const auditImpacts = [];
|
|
227
|
+
diagnosticAudits.forEach(audit => {
|
|
228
|
+
const {
|
|
229
|
+
overallImpact: overallImpact,
|
|
230
|
+
overallLinearImpact: overallLinearImpact,
|
|
231
|
+
} = this.overallImpact(audit, metricAudits);
|
|
232
|
+
const guidanceLevel = audit.result.guidanceLevel ?? 1;
|
|
233
|
+
auditImpacts.push({auditRef: audit, overallImpact, overallLinearImpact, guidanceLevel});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
auditImpacts.sort((a, b) => {
|
|
237
|
+
// Sort audits by impact, prioritizing those with a higher overallImpact first,
|
|
238
|
+
// then falling back to linearImpact, guidance level and score.
|
|
239
|
+
if (a.overallImpact !== b.overallImpact) return b.overallImpact - a.overallImpact;
|
|
240
|
+
|
|
241
|
+
if (
|
|
242
|
+
a.overallImpact === 0 && b.overallImpact === 0 &&
|
|
243
|
+
a.overallLinearImpact !== b.overallLinearImpact
|
|
244
|
+
) {
|
|
245
|
+
return b.overallLinearImpact - a.overallLinearImpact;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (a.guidanceLevel !== b.guidanceLevel) return b.guidanceLevel - a.guidanceLevel;
|
|
249
|
+
|
|
250
|
+
const scoreA = a.auditRef.result.scoreDisplayMode === 'informative'
|
|
251
|
+
? 100
|
|
252
|
+
: Number(a.auditRef.result.score);
|
|
253
|
+
const scoreB = b.auditRef.result.scoreDisplayMode === 'informative'
|
|
254
|
+
? 100
|
|
255
|
+
: Number(b.auditRef.result.score);
|
|
256
|
+
return scoreA - scoreB;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (auditImpacts.length) {
|
|
275
260
|
const [groupEl, footerEl] = this.renderAuditGroup(groups['diagnostics']);
|
|
276
|
-
|
|
261
|
+
auditImpacts.forEach(item => groupEl.insertBefore(this.renderAudit(item.auditRef), footerEl));
|
|
277
262
|
groupEl.classList.add('lh-audit-group--diagnostics');
|
|
278
263
|
element.append(groupEl);
|
|
279
264
|
}
|
|
@@ -281,7 +266,7 @@ export class PerformanceCategoryRenderer extends CategoryRenderer {
|
|
|
281
266
|
// Passed audits
|
|
282
267
|
const passedAudits = category.auditRefs
|
|
283
268
|
.filter(audit =>
|
|
284
|
-
this.
|
|
269
|
+
this._isPerformanceInsight(audit) && ReportUtils.showAsPassed(audit.result));
|
|
285
270
|
|
|
286
271
|
if (!passedAudits.length) return element;
|
|
287
272
|
|
|
@@ -313,6 +298,12 @@ export class PerformanceCategoryRenderer extends CategoryRenderer {
|
|
|
313
298
|
element.append(groupEl);
|
|
314
299
|
}
|
|
315
300
|
|
|
301
|
+
if (!options || options?.gatherMode === 'navigation') {
|
|
302
|
+
const el = createGauge(this.dom);
|
|
303
|
+
updateGauge(this.dom, el, category);
|
|
304
|
+
this.dom.find('.lh-score__gauge', element).replaceWith(el);
|
|
305
|
+
}
|
|
306
|
+
|
|
316
307
|
return element;
|
|
317
308
|
}
|
|
318
309
|
|