Skip to content

Commit d6cdb0b

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 d6cdb0b

File tree

11 files changed

+558
-19
lines changed

11 files changed

+558
-19
lines changed

config/last_updated.json

Lines changed: 14 additions & 9 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-14T00:00:00.000Z",
55+
"hash": "5a211ca184de3cd5731dfedaa1af929d"
5656
},
5757
"/static/js/accessibility.js": {
5858
"date_published": "2023-10-09T00:00:00.000Z",
@@ -166,18 +166,23 @@
166166
},
167167
"/static/js/techreport.js": {
168168
"date_published": "2023-10-09T00:00:00.000Z",
169-
"date_modified": "2026-03-10T00:00:00.000Z",
169+
"date_modified": "2026-04-14T00:00:00.000Z",
170170
"hash": "dfcef45ae09e7c2fcd3ab825e9503729"
171171
},
172+
"/static/js/techreport/cwvDistribution.js": {
173+
"date_published": "2026-04-07T00:00:00.000Z",
174+
"date_modified": "2026-04-14T00:00:00.000Z",
175+
"hash": "a9e884b2786b23670ac13e2c8a121183"
176+
},
172177
"/static/js/techreport/geoBreakdown.js": {
173178
"date_published": "2026-03-24T00:00:00.000Z",
174-
"date_modified": "2026-03-24T00:00:00.000Z",
175-
"hash": "030573b2da410620601352f9f6df8695"
179+
"date_modified": "2026-04-14T00:00:00.000Z",
180+
"hash": "2eab1a9b9c47001e8f5f757d041f4897"
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: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
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+
43+
this.bindEventListeners();
44+
45+
// Auto-expand if URL hash targets this section
46+
if (window.location.hash === `#section-${this.id}`) {
47+
this.toggle(true);
48+
}
49+
}
50+
51+
bindEventListeners() {
52+
if (!this.root) return;
53+
54+
this.root.querySelectorAll('.cwv-distribution-metric-selector').forEach(dropdown => {
55+
dropdown.addEventListener('change', event => {
56+
this.selectedMetric = event.target.value;
57+
if (this.distributionData) this.renderChart();
58+
});
59+
});
60+
61+
const btn = document.getElementById('cwv-distribution-btn');
62+
if (btn) {
63+
btn.addEventListener('click', () => {
64+
const isVisible = !this.root.classList.contains('hidden');
65+
this.toggle(!isVisible);
66+
});
67+
}
68+
}
69+
70+
toggle(show) {
71+
const btn = document.getElementById('cwv-distribution-btn');
72+
if (show) {
73+
this.root.classList.remove('hidden');
74+
if (btn) btn.textContent = 'Hide distribution';
75+
if (!this.distributionData) {
76+
this.fetchData();
77+
} else if (this.chart) {
78+
this.chart.reflow();
79+
}
80+
} else {
81+
this.root.classList.add('hidden');
82+
if (btn) btn.textContent = 'Show distribution';
83+
}
84+
}
85+
86+
get chartContainer() {
87+
return document.getElementById(`${this.id}-chart`);
88+
}
89+
90+
updateContent() {
91+
if (this.distributionData) this.renderChart();
92+
}
93+
94+
showLoader() {
95+
if (!this.chartContainer) return;
96+
this.chartContainer.innerHTML = '<div class="cwv-distribution-loader"><div class="cwv-distribution-spinner"></div><p>Loading distribution data…</p></div>';
97+
}
98+
99+
hideLoader() {
100+
if (!this.chartContainer) return;
101+
const loader = this.chartContainer.querySelector('.cwv-distribution-loader');
102+
if (loader) loader.remove();
103+
}
104+
105+
showError() {
106+
if (!this.chartContainer) return;
107+
this.chartContainer.innerHTML = '<div class="cwv-distribution-error">Distribution data is not available for this selection.</div>';
108+
}
109+
110+
fetchData() {
111+
this.showLoader();
112+
113+
const technology = this.pageFilters.app.map(encodeURIComponent).join(',');
114+
const rank = encodeURIComponent(this.pageFilters.rank || 'ALL');
115+
const geo = encodeURIComponent(this.pageFilters.geo || 'ALL');
116+
let url = `${Constants.apiBase}/cwv-distribution?technology=${technology}&rank=${rank}&geo=${geo}`;
117+
if (this.date) {
118+
url += `&date=${encodeURIComponent(this.date)}`;
119+
}
120+
121+
fetch(url)
122+
.then(r => {
123+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
124+
return r.json();
125+
})
126+
.then(rows => {
127+
if (!Array.isArray(rows) || rows.length === 0) throw new Error('Empty response');
128+
this.distributionData = rows;
129+
this.hideLoader();
130+
this.renderChart();
131+
})
132+
.catch(err => {
133+
console.error('CWV Distribution fetch error:', err);
134+
this.showError();
135+
});
136+
}
137+
138+
trimWithOverflow(rows, originsField, percentile) {
139+
const total = rows.reduce((sum, row) => sum + row[originsField], 0);
140+
if (total === 0) return { visible: rows, overflowCount: 0 };
141+
142+
const cutoff = total * percentile;
143+
let cumulative = 0;
144+
let cutIndex = rows.length;
145+
for (let i = 0; i < rows.length; i++) {
146+
cumulative += rows[i][originsField];
147+
if (cumulative >= cutoff) {
148+
cutIndex = Math.min(i + 2, rows.length);
149+
break;
150+
}
151+
}
152+
153+
const visible = rows.slice(0, cutIndex);
154+
const visibleSum = visible.reduce((sum, row) => sum + row[originsField], 0);
155+
return { visible, overflowCount: total - visibleSum };
156+
}
157+
158+
renderChart() {
159+
if (!this.distributionData || this.distributionData.length === 0) return;
160+
if (!this.root) return;
161+
162+
const client = this.root.dataset.client || 'mobile';
163+
const metricCfg = METRIC_CONFIG[this.selectedMetric];
164+
const thresholds = THRESHOLDS[this.selectedMetric];
165+
166+
const clientRows = this.distributionData
167+
.filter(row => row.client === client)
168+
.sort((a, b) => a[metricCfg.bucketField] - b[metricCfg.bucketField]);
169+
170+
const { visible, overflowCount } = this.trimWithOverflow(
171+
clientRows, metricCfg.originsField, 0.995
172+
);
173+
174+
const formatBucket = (val) => {
175+
if (metricCfg.unit === 'ms') {
176+
return val >= 1000 ? `${(val / 1000).toFixed(1)}s` : `${val}ms`;
177+
}
178+
return String(val);
179+
};
180+
181+
const categories = visible.map(row => formatBucket(row[metricCfg.bucketField]));
182+
const seriesData = visible.map(row => row[metricCfg.originsField]);
183+
184+
if (overflowCount > 0) {
185+
const rawNext = visible[visible.length - 1][metricCfg.bucketField] + metricCfg.step;
186+
const nextBucket = Math.round(rawNext * 1e6) / 1e6;
187+
categories.push(`${formatBucket(nextBucket)}+`);
188+
seriesData.push(overflowCount);
189+
}
190+
191+
const theme = document.querySelector('html').dataset.theme;
192+
const zoneColors = theme === 'dark' ? ZONE_COLORS.dark : ZONE_COLORS.light;
193+
194+
const getColor = (val) => {
195+
if (val < thresholds[0].value) return zoneColors.good;
196+
if (val < thresholds[1].value) return zoneColors.needsImprovement;
197+
return zoneColors.poor;
198+
};
199+
200+
const colors = visible.map(row => getColor(row[metricCfg.bucketField]));
201+
if (overflowCount > 0) {
202+
colors.push(zoneColors.poor);
203+
}
204+
205+
if (this.chart) {
206+
this.chart.destroy();
207+
this.chart = null;
208+
}
209+
210+
if (!this.chartContainer) return;
211+
const chartContainerId = `${this.id}-chart`;
212+
213+
const textColor = zoneColors.text;
214+
const gridLineColor = zoneColors.gridLine;
215+
216+
const plotLineColors = [zoneColors.good, zoneColors.needsImprovement];
217+
const plotLines = thresholds.map((t, i) => {
218+
const idx = visible.findIndex(row => row[metricCfg.bucketField] >= t.value);
219+
if (idx === -1) return null;
220+
return {
221+
value: idx - 0.5,
222+
color: plotLineColors[i],
223+
width: 2,
224+
dashStyle: 'Dash',
225+
label: {
226+
text: `${t.label} (${metricCfg.unit ? t.value + metricCfg.unit : t.value})`,
227+
style: { fontSize: '11px', color: textColor },
228+
},
229+
zIndex: 5,
230+
};
231+
}).filter(Boolean);
232+
233+
this.chart = Highcharts.chart(chartContainerId, {
234+
chart: { type: 'column', backgroundColor: 'transparent' },
235+
title: { text: null },
236+
xAxis: {
237+
categories,
238+
title: { text: metricCfg.label, style: { color: textColor } },
239+
labels: {
240+
step: Math.ceil(categories.length / 20),
241+
rotation: -45,
242+
style: { color: textColor },
243+
formatter: function () {
244+
const lastIndex = categories.length - 1;
245+
const labelStep = Math.ceil(categories.length / 20);
246+
if (this.pos === lastIndex || this.pos % labelStep === 0) {
247+
return this.value;
248+
}
249+
return null;
250+
},
251+
},
252+
lineColor: gridLineColor,
253+
plotLines,
254+
},
255+
yAxis: {
256+
title: { text: 'Number of origins', style: { color: textColor } },
257+
labels: { style: { color: textColor } },
258+
gridLineColor,
259+
min: 0,
260+
},
261+
legend: { enabled: false },
262+
tooltip: {
263+
formatter: function () {
264+
return `<b>${this.x}</b><br/>Origins: <b>${this.y.toLocaleString()}</b>`;
265+
},
266+
},
267+
plotOptions: {
268+
column: {
269+
pointPadding: 0,
270+
groupPadding: 0,
271+
borderWidth: 0,
272+
borderRadius: 0,
273+
crisp: false,
274+
},
275+
},
276+
series: [{
277+
name: 'Origins',
278+
data: seriesData.map((value, i) => ({ y: value, color: colors[i] })),
279+
}],
280+
credits: { enabled: false },
281+
});
282+
}
283+
}
284+
285+
window.CwvDistribution = CwvDistribution;

0 commit comments

Comments
 (0)