Skip to content

Commit 354b541

Browse files
committed
Add Core Web Vitals distribution histogram to tech report drilldown
Adds a collapsible histogram chart to the CWV section showing how origins are distributed across performance buckets for LCP, CLS, INP, FCP, and TTFB. Features: - Column chart with bars color-coded green/orange/red by CWV thresholds - Dashed plotlines marking good/needs-improvement boundaries - Tail buckets aggregated into an overflow "X+" bar so all origins are shown - Metric selector in the collapsed summary bar for quick switching - Loading spinner while the API call is in progress - Error message when data is unavailable - Light and dark mode support with theme-aware colors - Anchor link (#section-cwv_distribution) with auto-expand on direct navigation - URL hash updates when the section is expanded Fetches data from /v1/cwv-distribution (HTTPArchive/tech-report-apis#105) with technology, date, rank, and geo parameters. Also scopes the global .highcharts-point CSS rule to line/spline series only, so column chart bar colors are not overridden. Closes #1147
1 parent d9d5957 commit 354b541

File tree

9 files changed

+439
-11
lines changed

9 files changed

+439
-11
lines changed

config/last_updated.json

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@
5151
},
5252
"/static/css/techreport/techreport.css": {
5353
"date_published": "2023-10-09T00:00:00.000Z",
54-
"date_modified": "2026-03-24T00:00:00.000Z",
55-
"hash": "fed0915210b6a05bb8430623fe296586"
54+
"date_modified": "2026-04-07T00:00:00.000Z",
55+
"hash": "e41ea0e91f5be962a9f9b1691c12fbd3"
5656
},
5757
"/static/js/accessibility.js": {
5858
"date_published": "2023-10-09T00:00:00.000Z",
@@ -166,8 +166,13 @@
166166
},
167167
"/static/js/techreport.js": {
168168
"date_published": "2023-10-09T00:00:00.000Z",
169-
"date_modified": "2026-03-10T00:00:00.000Z",
170-
"hash": "dfcef45ae09e7c2fcd3ab825e9503729"
169+
"date_modified": "2026-04-07T00:00:00.000Z",
170+
"hash": "f97ea4a7588c80c2530d2e460a150d8c"
171+
},
172+
"/static/js/techreport/cwvDistribution.js": {
173+
"date_published": "2026-04-07T00:00:00.000Z",
174+
"date_modified": "2026-04-07T00:00:00.000Z",
175+
"hash": "6c6673739fab5da63c7d2b41ad106ebd"
171176
},
172177
"/static/js/techreport/geoBreakdown.js": {
173178
"date_published": "2026-03-24T00:00:00.000Z",
@@ -176,8 +181,8 @@
176181
},
177182
"/static/js/techreport/section.js": {
178183
"date_published": "2023-10-09T00:00:00.000Z",
179-
"date_modified": "2026-03-24T00:00:00.000Z",
180-
"hash": "376404acd77a2e5adeab188a9b5ccb94"
184+
"date_modified": "2026-04-07T00:00:00.000Z",
185+
"hash": "c813fe60fb1bcd338221f72b64739701"
181186
},
182187
"/static/js/techreport/timeseries.js": {
183188
"date_published": "2023-10-09T00:00:00.000Z",
@@ -191,8 +196,8 @@
191196
},
192197
"/static/js/web-vitals.js": {
193198
"date_published": "2022-01-03T00:00:00.000Z",
194-
"date_modified": "2025-08-18T00:00:00.000Z",
195-
"hash": "e7b8ecda99703fdc7c6a33b6a3d07cc6"
199+
"date_modified": "2026-04-07T00:00:00.000Z",
200+
"hash": "1b30cb4e8907aa62bc9045690570a4eb"
196201
},
197202
"about.html": {
198203
"date_published": "2018-05-08T00:00:00.000Z",

config/techreport.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,18 @@
734734
{ "label": "Good FCP", "value": "FCP" },
735735
{ "label": "Good TTFB", "value": "TTFB" }
736736
]
737+
},
738+
"cwv_distribution": {
739+
"id": "cwv_distribution",
740+
"title": "Core Web Vitals distribution",
741+
"description": "How origins are distributed across performance buckets for individual Core Web Vitals metrics. Green, orange, and red zones indicate good, needs improvement, and poor thresholds respectively.",
742+
"metric_options": [
743+
{ "label": "LCP", "value": "LCP" },
744+
{ "label": "CLS", "value": "CLS" },
745+
{ "label": "INP", "value": "INP" },
746+
{ "label": "FCP", "value": "FCP" },
747+
{ "label": "TTFB", "value": "TTFB" }
748+
]
737749
}
738750
}
739751
},
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/* global Highcharts */
2+
3+
import { Constants } from './utils/constants';
4+
5+
const METRIC_CONFIG = {
6+
LCP: { bucketField: 'loading_bucket', originsField: 'lcp_origins', unit: 'ms', label: 'LCP (ms)', step: 100 },
7+
FCP: { bucketField: 'loading_bucket', originsField: 'fcp_origins', unit: 'ms', label: 'FCP (ms)', step: 100 },
8+
TTFB: { bucketField: 'loading_bucket', originsField: 'ttfb_origins', unit: 'ms', label: 'TTFB (ms)', step: 100 },
9+
INP: { bucketField: 'inp_bucket', originsField: 'inp_origins', unit: 'ms', label: 'INP (ms)', step: 25 },
10+
CLS: { bucketField: 'cls_bucket', originsField: 'cls_origins', unit: '', label: 'CLS', step: 0.05 },
11+
};
12+
13+
const THRESHOLDS = {
14+
LCP: [{ value: 2500, label: 'Good' }, { value: 4000, label: 'Needs improvement' }],
15+
FCP: [{ value: 1800, label: 'Good' }, { value: 3000, label: 'Needs improvement' }],
16+
TTFB: [{ value: 800, label: 'Good' }, { value: 1800, label: 'Needs improvement' }],
17+
INP: [{ value: 200, label: 'Good' }, { value: 500, label: 'Needs improvement' }],
18+
CLS: [{ value: 0.1, label: 'Good' }, { value: 0.25, label: 'Needs improvement' }],
19+
};
20+
21+
const ZONE_COLORS = {
22+
light: { good: '#0CCE6B', needsImprovement: '#FFA400', poor: '#FF4E42', text: '#444', gridLine: '#e6e6e6' },
23+
dark: { good: '#0CCE6B', needsImprovement: '#FBBC04', poor: '#FF6659', text: '#ccc', gridLine: '#444' },
24+
};
25+
26+
class CwvDistribution {
27+
// eslint-disable-next-line no-unused-vars -- pageConfig, config, data satisfy the Section component contract
28+
constructor(id, pageConfig, config, filters, data) {
29+
this.id = id;
30+
this.pageFilters = filters;
31+
this.distributionData = null;
32+
this.selectedMetric = 'LCP';
33+
this.chart = null;
34+
this.root = document.querySelector(`[data-id="${this.id}"]`);
35+
36+
this.bindEventListeners();
37+
38+
// Auto-expand if URL hash targets this section
39+
if (window.location.hash === `#section-${this.id}`) {
40+
const details = this.root?.closest('details');
41+
if (details) details.open = true;
42+
}
43+
}
44+
45+
bindEventListeners() {
46+
if (!this.root) return;
47+
const root = this.root;
48+
49+
root.querySelectorAll('.cwv-distribution-metric-selector').forEach(dropdown => {
50+
dropdown.addEventListener('change', event => {
51+
this.selectedMetric = event.target.value;
52+
if (this.distributionData) this.renderChart();
53+
});
54+
});
55+
56+
const details = root.closest('details');
57+
if (details) {
58+
details.addEventListener('toggle', () => {
59+
if (details.open) {
60+
history.replaceState(null, '', `#${details.id}`);
61+
if (!this.distributionData) {
62+
this.fetchData();
63+
} else if (this.chart) {
64+
this.chart.reflow();
65+
}
66+
}
67+
});
68+
}
69+
}
70+
71+
get chartContainer() {
72+
return document.getElementById(`${this.id}-chart`);
73+
}
74+
75+
updateContent() {
76+
if (this.distributionData) this.renderChart();
77+
}
78+
79+
showLoader() {
80+
if (!this.chartContainer) return;
81+
this.chartContainer.innerHTML = '<div class="cwv-distribution-loader"><div class="cwv-distribution-spinner"></div><p>Loading distribution data…</p></div>';
82+
}
83+
84+
hideLoader() {
85+
if (!this.chartContainer) return;
86+
const loader = this.chartContainer.querySelector('.cwv-distribution-loader');
87+
if (loader) loader.remove();
88+
}
89+
90+
showError() {
91+
if (!this.chartContainer) return;
92+
this.chartContainer.innerHTML = '<div class="cwv-distribution-error">Distribution data is not available for this selection.</div>';
93+
}
94+
95+
fetchData() {
96+
this.showLoader();
97+
98+
const technology = this.pageFilters.app.map(encodeURIComponent).join(',');
99+
const rank = encodeURIComponent(this.pageFilters.rank || 'ALL');
100+
const geo = encodeURIComponent(this.pageFilters.geo || 'ALL');
101+
const date = this.pageFilters.end || this.root?.dataset?.latestDate || '';
102+
103+
let url = `${Constants.apiBase}/cwv-distribution?technology=${technology}&rank=${rank}&geo=${geo}`;
104+
if (date) {
105+
url += `&date=${encodeURIComponent(date)}`;
106+
}
107+
108+
fetch(url)
109+
.then(r => {
110+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
111+
return r.json();
112+
})
113+
.then(rows => {
114+
if (!Array.isArray(rows) || rows.length === 0) throw new Error('Empty response');
115+
this.distributionData = rows;
116+
this.hideLoader();
117+
this.renderChart();
118+
})
119+
.catch(err => {
120+
console.error('CWV Distribution fetch error:', err);
121+
this.showError();
122+
});
123+
}
124+
125+
trimWithOverflow(rows, originsField, percentile) {
126+
const total = rows.reduce((sum, row) => sum + row[originsField], 0);
127+
if (total === 0) return { visible: rows, overflowCount: 0 };
128+
129+
const cutoff = total * percentile;
130+
let cumulative = 0;
131+
let cutIndex = rows.length;
132+
for (let i = 0; i < rows.length; i++) {
133+
cumulative += rows[i][originsField];
134+
if (cumulative >= cutoff) {
135+
cutIndex = Math.min(i + 2, rows.length);
136+
break;
137+
}
138+
}
139+
140+
const visible = rows.slice(0, cutIndex);
141+
const visibleSum = visible.reduce((sum, row) => sum + row[originsField], 0);
142+
return { visible, overflowCount: total - visibleSum };
143+
}
144+
145+
renderChart() {
146+
if (!this.distributionData || this.distributionData.length === 0) return;
147+
if (!this.root) return;
148+
149+
const client = this.root.dataset.client || 'mobile';
150+
const metricCfg = METRIC_CONFIG[this.selectedMetric];
151+
const thresholds = THRESHOLDS[this.selectedMetric];
152+
153+
const clientRows = this.distributionData
154+
.filter(row => row.client === client)
155+
.sort((a, b) => a[metricCfg.bucketField] - b[metricCfg.bucketField]);
156+
157+
const { visible, overflowCount } = this.trimWithOverflow(
158+
clientRows, metricCfg.originsField, 0.995
159+
);
160+
161+
const formatBucket = (val) => {
162+
if (metricCfg.unit === 'ms') {
163+
return val >= 1000 ? `${(val / 1000).toFixed(1)}s` : `${val}ms`;
164+
}
165+
return String(val);
166+
};
167+
168+
const categories = visible.map(row => formatBucket(row[metricCfg.bucketField]));
169+
const seriesData = visible.map(row => row[metricCfg.originsField]);
170+
171+
if (overflowCount > 0) {
172+
const nextBucket = visible[visible.length - 1][metricCfg.bucketField] + metricCfg.step;
173+
categories.push(`${formatBucket(nextBucket)}+`);
174+
seriesData.push(overflowCount);
175+
}
176+
177+
const theme = document.querySelector('html').dataset.theme;
178+
const zoneColors = theme === 'dark' ? ZONE_COLORS.dark : ZONE_COLORS.light;
179+
180+
const getColor = (val) => {
181+
if (val < thresholds[0].value) return zoneColors.good;
182+
if (val < thresholds[1].value) return zoneColors.needsImprovement;
183+
return zoneColors.poor;
184+
};
185+
186+
const colors = visible.map(row => getColor(row[metricCfg.bucketField]));
187+
if (overflowCount > 0) {
188+
colors.push(zoneColors.poor);
189+
}
190+
191+
if (this.chart) {
192+
this.chart.destroy();
193+
this.chart = null;
194+
}
195+
196+
if (!this.chartContainer) return;
197+
const chartContainerId = `${this.id}-chart`;
198+
199+
const textColor = zoneColors.text;
200+
const gridLineColor = zoneColors.gridLine;
201+
202+
const plotLineColors = [zoneColors.good, zoneColors.needsImprovement];
203+
const plotLines = thresholds.map((t, i) => {
204+
const idx = visible.findIndex(row => row[metricCfg.bucketField] >= t.value);
205+
if (idx === -1) return null;
206+
return {
207+
value: idx,
208+
color: plotLineColors[i],
209+
width: 2,
210+
dashStyle: 'Dash',
211+
label: {
212+
text: `${t.label} (${metricCfg.unit ? t.value + metricCfg.unit : t.value})`,
213+
style: { fontSize: '11px', color: textColor },
214+
},
215+
zIndex: 5,
216+
};
217+
}).filter(Boolean);
218+
219+
this.chart = Highcharts.chart(chartContainerId, {
220+
chart: { type: 'column', backgroundColor: 'transparent' },
221+
title: { text: null },
222+
xAxis: {
223+
categories,
224+
title: { text: metricCfg.label, style: { color: textColor } },
225+
labels: {
226+
step: Math.ceil(categories.length / 20),
227+
rotation: -45,
228+
style: { color: textColor },
229+
formatter: function () {
230+
const lastIndex = categories.length - 1;
231+
const labelStep = Math.ceil(categories.length / 20);
232+
if (this.pos === lastIndex || this.pos % labelStep === 0) {
233+
return this.value;
234+
}
235+
return null;
236+
},
237+
},
238+
lineColor: gridLineColor,
239+
plotLines,
240+
},
241+
yAxis: {
242+
title: { text: 'Number of origins', style: { color: textColor } },
243+
labels: { style: { color: textColor } },
244+
gridLineColor,
245+
min: 0,
246+
},
247+
legend: { enabled: false },
248+
tooltip: {
249+
formatter: function () {
250+
return `<b>${this.x}</b><br/>Origins: <b>${this.y.toLocaleString()}</b>`;
251+
},
252+
},
253+
plotOptions: {
254+
column: {
255+
pointPadding: 0,
256+
groupPadding: 0,
257+
borderWidth: 0,
258+
},
259+
},
260+
series: [{
261+
name: 'Origins',
262+
data: seriesData.map((value, i) => ({ y: value, color: colors[i] })),
263+
}],
264+
credits: { enabled: false },
265+
});
266+
}
267+
}
268+
269+
window.CwvDistribution = CwvDistribution;

src/js/techreport/section.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* global Timeseries, GeoBreakdown */
1+
/* global Timeseries, GeoBreakdown, CwvDistribution */
22

33
import SummaryCard from "./summaryCards";
44
import TableLinked from "./tableLinked";
@@ -37,6 +37,10 @@ class Section {
3737
this.initializeGeoBreakdown(component);
3838
break;
3939

40+
case "cwvDistribution":
41+
this.initializeCwvDistribution(component);
42+
break;
43+
4044
default:
4145
break;
4246
}
@@ -83,6 +87,16 @@ class Section {
8387
);
8488
}
8589

90+
initializeCwvDistribution(component) {
91+
this.components[component.dataset.id] = new CwvDistribution(
92+
component.dataset.id,
93+
this.pageConfig,
94+
this.config,
95+
this.pageFilters,
96+
this.data
97+
);
98+
}
99+
86100
updateSection(content) {
87101
Object.values(this.components).forEach(component => {
88102
if(component.data !== this.data) {

0 commit comments

Comments
 (0)