Skip to content

Commit 576ec70

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. Bars are color-coded by good/needs-improvement/poor thresholds with dashed plotlines marking the boundaries. Supports light and dark mode. Uses hardcoded sample data for now (Wix, Feb 2026). The data will be replaced by a live API fetch once the cwv-distribution endpoint is finalized. Closes #1147
1 parent d9d5957 commit 576ec70

10 files changed

Lines changed: 354 additions & 11 deletions

File tree

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: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/* global Highcharts */
2+
3+
// Hardcoded distribution data — will be replaced by API fetch when the endpoint is finalized
4+
import distributionData from './cwvDistributionData.json';
5+
6+
const METRIC_CONFIG = {
7+
LCP: { bucketField: 'loading_bucket', originsField: 'lcp_origins', unit: 'ms', label: 'LCP (ms)' },
8+
FCP: { bucketField: 'loading_bucket', originsField: 'fcp_origins', unit: 'ms', label: 'FCP (ms)' },
9+
TTFB: { bucketField: 'loading_bucket', originsField: 'ttfb_origins', unit: 'ms', label: 'TTFB (ms)' },
10+
INP: { bucketField: 'inp_bucket', originsField: 'inp_origins', unit: 'ms', label: 'INP (ms)' },
11+
CLS: { bucketField: 'cls_bucket', originsField: 'cls_origins', unit: '', label: 'CLS' },
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+
// Bright, saturated CWV zone colors for both themes
23+
const ZONE_COLORS = {
24+
light: { good: '#0CCE6B', needsImprovement: '#FFA400', poor: '#FF4E42', text: '#444', gridLine: '#e6e6e6' },
25+
dark: { good: '#0CCE6B', needsImprovement: '#FBBC04', poor: '#FF6659', text: '#ccc', gridLine: '#444' },
26+
};
27+
28+
class CwvDistribution {
29+
// pageConfig, config, filters, data are accepted to satisfy the Section component contract
30+
constructor(id, pageConfig, config, filters, data) {
31+
this.id = id;
32+
this.pageFilters = filters;
33+
this.data = data;
34+
this.selectedMetric = 'LCP';
35+
this.chart = null;
36+
this.root = document.querySelector(`[data-id="${this.id}"]`);
37+
38+
this.bindEventListeners();
39+
}
40+
41+
bindEventListeners() {
42+
if (!this.root) return;
43+
const root = this.root;
44+
45+
// Metric selector
46+
root.querySelectorAll('.cwv-distribution-metric-selector').forEach(dropdown => {
47+
dropdown.addEventListener('change', event => {
48+
this.selectedMetric = event.target.value;
49+
this.renderChart();
50+
});
51+
});
52+
53+
// Lazy render on <details> toggle
54+
const details = root.closest('details');
55+
if (details) {
56+
details.addEventListener('toggle', () => {
57+
if (details.open && !this.chart) {
58+
this.renderChart();
59+
} else if (details.open && this.chart) {
60+
this.chart.reflow();
61+
}
62+
});
63+
}
64+
}
65+
66+
updateContent() {
67+
if (this.chart) this.renderChart();
68+
}
69+
70+
trimWithOverflow(rows, originsField, percentile) {
71+
const total = rows.reduce((sum, row) => sum + row[originsField], 0);
72+
if (total === 0) return { visible: rows, overflowCount: 0 };
73+
74+
const cutoff = total * percentile;
75+
let cumulative = 0;
76+
let cutIndex = rows.length;
77+
for (let i = 0; i < rows.length; i++) {
78+
cumulative += rows[i][originsField];
79+
if (cumulative >= cutoff) {
80+
cutIndex = Math.min(i + 2, rows.length);
81+
break;
82+
}
83+
}
84+
85+
const visible = rows.slice(0, cutIndex);
86+
const overflowCount = rows.slice(cutIndex).reduce((sum, row) => sum + row[originsField], 0);
87+
return { visible, overflowCount };
88+
}
89+
90+
renderChart() {
91+
if (!distributionData || distributionData.length === 0) return;
92+
if (!this.root) return;
93+
94+
const client = this.root.dataset.client || 'mobile';
95+
const metricCfg = METRIC_CONFIG[this.selectedMetric];
96+
const thresholds = THRESHOLDS[this.selectedMetric] || [];
97+
98+
// Filter by client and sort by bucket
99+
const clientRows = distributionData
100+
.filter(row => row.client === client)
101+
.sort((a, b) => a[metricCfg.bucketField] - b[metricCfg.bucketField]);
102+
103+
// Trim to 99.5th percentile and aggregate the tail into an overflow bucket
104+
const { visible, overflowCount } = this.trimWithOverflow(
105+
clientRows, metricCfg.originsField, 0.995
106+
);
107+
108+
const formatBucket = (val) => {
109+
if (metricCfg.unit === 'ms') {
110+
return val >= 1000 ? `${(val / 1000).toFixed(1)}s` : `${val}ms`;
111+
}
112+
return String(val);
113+
};
114+
115+
const categories = visible.map(row => formatBucket(row[metricCfg.bucketField]));
116+
const seriesData = visible.map(row => row[metricCfg.originsField]);
117+
118+
// Add overflow bucket if there are hidden origins
119+
if (overflowCount > 0) {
120+
const lastBucket = visible[visible.length - 1][metricCfg.bucketField];
121+
categories.push(`${formatBucket(lastBucket)}+`);
122+
seriesData.push(overflowCount);
123+
}
124+
125+
// Color each bar based on threshold zones, with theme support
126+
const theme = document.querySelector('html').dataset.theme;
127+
const zoneColors = theme === 'dark' ? ZONE_COLORS.dark : ZONE_COLORS.light;
128+
129+
const getColor = (val) => {
130+
if (thresholds.length >= 2) {
131+
if (val < thresholds[0].value) return zoneColors.good;
132+
if (val < thresholds[1].value) return zoneColors.needsImprovement;
133+
return zoneColors.poor;
134+
}
135+
return zoneColors.good;
136+
};
137+
138+
const colors = visible.map(row => getColor(row[metricCfg.bucketField]));
139+
if (overflowCount > 0) {
140+
colors.push(zoneColors.poor);
141+
}
142+
143+
// Destroy previous chart
144+
if (this.chart) {
145+
this.chart.destroy();
146+
this.chart = null;
147+
}
148+
149+
const chartContainerId = `${this.id}-chart`;
150+
const container = document.getElementById(chartContainerId);
151+
if (!container) return;
152+
153+
const textColor = zoneColors.text;
154+
const gridLineColor = zoneColors.gridLine;
155+
156+
// Build plotLines for thresholds
157+
const plotLineColors = [zoneColors.good, zoneColors.needsImprovement];
158+
const plotLines = thresholds.map((t, i) => {
159+
const idx = visible.findIndex(row => row[metricCfg.bucketField] >= t.value);
160+
if (idx === -1) return null;
161+
return {
162+
value: idx,
163+
color: plotLineColors[i],
164+
width: 2,
165+
dashStyle: 'Dash',
166+
label: {
167+
text: `${t.label} (${metricCfg.unit ? t.value + metricCfg.unit : t.value})`,
168+
style: { fontSize: '11px', color: textColor },
169+
},
170+
zIndex: 5,
171+
};
172+
}).filter(Boolean);
173+
174+
this.chart = Highcharts.chart(chartContainerId, {
175+
chart: { type: 'column', backgroundColor: 'transparent' },
176+
title: { text: null },
177+
xAxis: {
178+
categories,
179+
title: { text: metricCfg.label, style: { color: textColor } },
180+
labels: {
181+
step: Math.ceil(categories.length / 20),
182+
rotation: -45,
183+
style: { color: textColor },
184+
},
185+
lineColor: gridLineColor,
186+
plotLines,
187+
},
188+
yAxis: {
189+
title: { text: 'Number of origins', style: { color: textColor } },
190+
labels: { style: { color: textColor } },
191+
gridLineColor,
192+
min: 0,
193+
},
194+
legend: { enabled: false },
195+
tooltip: {
196+
formatter: function () {
197+
return `<b>${this.x}</b><br/>Origins: <b>${this.y.toLocaleString()}</b>`;
198+
},
199+
},
200+
plotOptions: {
201+
column: {
202+
pointPadding: 0,
203+
groupPadding: 0,
204+
borderWidth: 0,
205+
},
206+
},
207+
series: [{
208+
name: 'Origins',
209+
data: seriesData.map((value, i) => ({ y: value, color: colors[i] })),
210+
}],
211+
credits: { enabled: false },
212+
});
213+
214+
}
215+
}
216+
217+
window.CwvDistribution = CwvDistribution;

src/js/techreport/cwvDistributionData.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

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) {

static/css/techreport/techreport.css

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1922,8 +1922,12 @@ h2.summary-heading {
19221922
/* -------------------- */
19231923

19241924
/* Highcharts */
1925-
.highcharts-background,
1926-
.highcharts-point {
1925+
.highcharts-background {
1926+
fill: var(--color-card-background) !important;
1927+
}
1928+
1929+
.highcharts-line-series .highcharts-point,
1930+
.highcharts-spline-series .highcharts-point {
19271931
fill: var(--color-card-background) !important;
19281932
}
19291933

@@ -2336,3 +2340,41 @@ path.highcharts-tick {
23362340
min-width: 7.5rem;
23372341
}
23382342
}
2343+
2344+
/* CWV Distribution histogram */
2345+
.cwv-distribution-details {
2346+
width: 100%;
2347+
}
2348+
2349+
.cwv-distribution-summary {
2350+
cursor: pointer;
2351+
list-style: none;
2352+
display: flex;
2353+
align-items: center;
2354+
gap: 0.5rem;
2355+
padding: 0.5rem 0;
2356+
}
2357+
2358+
.cwv-distribution-summary::-webkit-details-marker {
2359+
display: none;
2360+
}
2361+
2362+
.cwv-distribution-summary::before {
2363+
content: "\25B6";
2364+
font-size: 0.75rem;
2365+
transition: transform 0.2s;
2366+
}
2367+
2368+
.cwv-distribution-details[open] > .cwv-distribution-summary::before {
2369+
transform: rotate(90deg);
2370+
}
2371+
2372+
.cwv-distribution-summary h3 {
2373+
margin: 0;
2374+
display: inline;
2375+
}
2376+
2377+
.cwv-distribution-details .meta {
2378+
margin-bottom: 1rem;
2379+
}
2380+

0 commit comments

Comments
 (0)