Skip to content

Commit 9d68f1b

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 9d68f1b

File tree

9 files changed

+449
-11
lines changed

9 files changed

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