diff --git a/config/last_updated.json b/config/last_updated.json
index 45165ab7..772fc636 100644
--- a/config/last_updated.json
+++ b/config/last_updated.json
@@ -51,8 +51,8 @@
},
"/static/css/techreport/techreport.css": {
"date_published": "2023-10-09T00:00:00.000Z",
- "date_modified": "2026-03-24T00:00:00.000Z",
- "hash": "fed0915210b6a05bb8430623fe296586"
+ "date_modified": "2026-04-07T00:00:00.000Z",
+ "hash": "e41ea0e91f5be962a9f9b1691c12fbd3"
},
"/static/js/accessibility.js": {
"date_published": "2023-10-09T00:00:00.000Z",
@@ -166,8 +166,13 @@
},
"/static/js/techreport.js": {
"date_published": "2023-10-09T00:00:00.000Z",
- "date_modified": "2026-03-10T00:00:00.000Z",
- "hash": "dfcef45ae09e7c2fcd3ab825e9503729"
+ "date_modified": "2026-04-07T00:00:00.000Z",
+ "hash": "f97ea4a7588c80c2530d2e460a150d8c"
+ },
+ "/static/js/techreport/cwvDistribution.js": {
+ "date_published": "2026-04-07T00:00:00.000Z",
+ "date_modified": "2026-04-07T00:00:00.000Z",
+ "hash": "6c6673739fab5da63c7d2b41ad106ebd"
},
"/static/js/techreport/geoBreakdown.js": {
"date_published": "2026-03-24T00:00:00.000Z",
@@ -176,8 +181,8 @@
},
"/static/js/techreport/section.js": {
"date_published": "2023-10-09T00:00:00.000Z",
- "date_modified": "2026-03-24T00:00:00.000Z",
- "hash": "376404acd77a2e5adeab188a9b5ccb94"
+ "date_modified": "2026-04-07T00:00:00.000Z",
+ "hash": "c813fe60fb1bcd338221f72b64739701"
},
"/static/js/techreport/timeseries.js": {
"date_published": "2023-10-09T00:00:00.000Z",
@@ -191,8 +196,8 @@
},
"/static/js/web-vitals.js": {
"date_published": "2022-01-03T00:00:00.000Z",
- "date_modified": "2025-08-18T00:00:00.000Z",
- "hash": "e7b8ecda99703fdc7c6a33b6a3d07cc6"
+ "date_modified": "2026-04-07T00:00:00.000Z",
+ "hash": "1b30cb4e8907aa62bc9045690570a4eb"
},
"about.html": {
"date_published": "2018-05-08T00:00:00.000Z",
diff --git a/config/techreport.json b/config/techreport.json
index 083925b4..b20e9da7 100644
--- a/config/techreport.json
+++ b/config/techreport.json
@@ -734,6 +734,18 @@
{ "label": "Good FCP", "value": "FCP" },
{ "label": "Good TTFB", "value": "TTFB" }
]
+ },
+ "cwv_distribution": {
+ "id": "cwv_distribution",
+ "title": "Core Web Vitals distribution",
+ "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.",
+ "metric_options": [
+ { "label": "LCP", "value": "LCP" },
+ { "label": "CLS", "value": "CLS" },
+ { "label": "INP", "value": "INP" },
+ { "label": "FCP", "value": "FCP" },
+ { "label": "TTFB", "value": "TTFB" }
+ ]
}
}
},
diff --git a/src/js/techreport/cwvDistribution.js b/src/js/techreport/cwvDistribution.js
new file mode 100644
index 00000000..f4be57b9
--- /dev/null
+++ b/src/js/techreport/cwvDistribution.js
@@ -0,0 +1,261 @@
+/* global Highcharts */
+
+import { Constants } from './utils/constants';
+
+const METRIC_CONFIG = {
+ LCP: { bucketField: 'loading_bucket', originsField: 'lcp_origins', unit: 'ms', label: 'LCP (ms)', step: 100 },
+ FCP: { bucketField: 'loading_bucket', originsField: 'fcp_origins', unit: 'ms', label: 'FCP (ms)', step: 100 },
+ TTFB: { bucketField: 'loading_bucket', originsField: 'ttfb_origins', unit: 'ms', label: 'TTFB (ms)', step: 100 },
+ INP: { bucketField: 'inp_bucket', originsField: 'inp_origins', unit: 'ms', label: 'INP (ms)', step: 25 },
+ CLS: { bucketField: 'cls_bucket', originsField: 'cls_origins', unit: '', label: 'CLS', step: 0.05 },
+};
+
+const THRESHOLDS = {
+ LCP: [{ value: 2500, label: 'Good' }, { value: 4000, label: 'Needs improvement' }],
+ FCP: [{ value: 1800, label: 'Good' }, { value: 3000, label: 'Needs improvement' }],
+ TTFB: [{ value: 800, label: 'Good' }, { value: 1800, label: 'Needs improvement' }],
+ INP: [{ value: 200, label: 'Good' }, { value: 500, label: 'Needs improvement' }],
+ CLS: [{ value: 0.1, label: 'Good' }, { value: 0.25, label: 'Needs improvement' }],
+};
+
+const ZONE_COLORS = {
+ light: { good: '#0CCE6B', needsImprovement: '#FFA400', poor: '#FF4E42', text: '#444', gridLine: '#e6e6e6' },
+ dark: { good: '#0CCE6B', needsImprovement: '#FBBC04', poor: '#FF6659', text: '#ccc', gridLine: '#444' },
+};
+
+class CwvDistribution {
+ // pageConfig, config are accepted to satisfy the Section component contract
+ constructor(id, pageConfig, config, filters, data) {
+ this.id = id;
+ this.pageFilters = filters;
+ this.distributionData = null;
+ this.selectedMetric = 'LCP';
+ this.chart = null;
+ this.root = document.querySelector(`[data-id="${this.id}"]`);
+
+ this.bindEventListeners();
+
+ // Auto-expand if URL hash targets this section
+ if (window.location.hash === `#section-${this.id}`) {
+ const details = this.root?.closest('details');
+ if (details) details.open = true;
+ }
+ }
+
+ bindEventListeners() {
+ if (!this.root) return;
+ const root = this.root;
+
+ root.querySelectorAll('.cwv-distribution-metric-selector').forEach(dropdown => {
+ dropdown.addEventListener('change', event => {
+ this.selectedMetric = event.target.value;
+ if (this.distributionData) this.renderChart();
+ });
+ });
+
+ const details = root.closest('details');
+ if (details) {
+ details.addEventListener('toggle', () => {
+ if (details.open) {
+ history.replaceState(null, '', `#${details.id}`);
+ if (!this.distributionData) {
+ this.fetchData();
+ } else if (this.chart) {
+ this.chart.reflow();
+ }
+ }
+ });
+ }
+ }
+
+ get chartContainer() {
+ return document.getElementById(`${this.id}-chart`);
+ }
+
+ updateContent() {
+ if (this.distributionData) this.renderChart();
+ }
+
+ showLoader() {
+ if (!this.chartContainer) return;
+ this.chartContainer.innerHTML = '
Loading distribution data…
';
+ }
+
+ hideLoader() {
+ if (!this.chartContainer) return;
+ const loader = this.chartContainer.querySelector('.cwv-distribution-loader');
+ if (loader) loader.remove();
+ }
+
+ showError() {
+ if (!this.chartContainer) return;
+ this.chartContainer.innerHTML = 'Distribution data is not available for this selection.
';
+ }
+
+ fetchData() {
+ this.showLoader();
+
+ const technology = this.pageFilters.app.map(encodeURIComponent).join(',');
+ const rank = encodeURIComponent(this.pageFilters.rank || 'ALL');
+ const geo = encodeURIComponent(this.pageFilters.geo || 'ALL');
+ const date = this.root?.dataset?.latestDate || '';
+
+ let url = `${Constants.apiBase}/cwv-distribution?technology=${technology}&rank=${rank}&geo=${geo}`;
+ if (date) {
+ url += `&date=${encodeURIComponent(date)}`;
+ }
+
+ fetch(url)
+ .then(r => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.json();
+ })
+ .then(rows => {
+ if (!Array.isArray(rows) || rows.length === 0) throw new Error('Empty response');
+ this.distributionData = rows;
+ this.hideLoader();
+ this.renderChart();
+ })
+ .catch(err => {
+ console.error('CWV Distribution fetch error:', err);
+ this.showError();
+ });
+ }
+
+ trimWithOverflow(rows, originsField, percentile) {
+ const total = rows.reduce((sum, row) => sum + row[originsField], 0);
+ if (total === 0) return { visible: rows, overflowCount: 0 };
+
+ const cutoff = total * percentile;
+ let cumulative = 0;
+ let cutIndex = rows.length;
+ for (let i = 0; i < rows.length; i++) {
+ cumulative += rows[i][originsField];
+ if (cumulative >= cutoff) {
+ cutIndex = Math.min(i + 2, rows.length);
+ break;
+ }
+ }
+
+ const visible = rows.slice(0, cutIndex);
+ const visibleSum = visible.reduce((sum, row) => sum + row[originsField], 0);
+ return { visible, overflowCount: total - visibleSum };
+ }
+
+ renderChart() {
+ if (!this.distributionData || this.distributionData.length === 0) return;
+ if (!this.root) return;
+
+ const client = this.root.dataset.client || 'mobile';
+ const metricCfg = METRIC_CONFIG[this.selectedMetric];
+ const thresholds = THRESHOLDS[this.selectedMetric];
+
+ const clientRows = this.distributionData
+ .filter(row => row.client === client)
+ .sort((a, b) => a[metricCfg.bucketField] - b[metricCfg.bucketField]);
+
+ const { visible, overflowCount } = this.trimWithOverflow(
+ clientRows, metricCfg.originsField, 0.995
+ );
+
+ const formatBucket = (val) => {
+ if (metricCfg.unit === 'ms') {
+ return val >= 1000 ? `${(val / 1000).toFixed(1)}s` : `${val}ms`;
+ }
+ return String(val);
+ };
+
+ const categories = visible.map(row => formatBucket(row[metricCfg.bucketField]));
+ const seriesData = visible.map(row => row[metricCfg.originsField]);
+
+ if (overflowCount > 0) {
+ const nextBucket = visible[visible.length - 1][metricCfg.bucketField] + metricCfg.step;
+ categories.push(`${formatBucket(nextBucket)}+`);
+ seriesData.push(overflowCount);
+ }
+
+ const theme = document.querySelector('html').dataset.theme;
+ const zoneColors = theme === 'dark' ? ZONE_COLORS.dark : ZONE_COLORS.light;
+
+ const getColor = (val) => {
+ if (val < thresholds[0].value) return zoneColors.good;
+ if (val < thresholds[1].value) return zoneColors.needsImprovement;
+ return zoneColors.poor;
+ };
+
+ const colors = visible.map(row => getColor(row[metricCfg.bucketField]));
+ if (overflowCount > 0) {
+ colors.push(zoneColors.poor);
+ }
+
+ if (this.chart) {
+ this.chart.destroy();
+ this.chart = null;
+ }
+
+ if (!this.chartContainer) return;
+ const chartContainerId = `${this.id}-chart`;
+
+ const textColor = zoneColors.text;
+ const gridLineColor = zoneColors.gridLine;
+
+ const plotLineColors = [zoneColors.good, zoneColors.needsImprovement];
+ const plotLines = thresholds.map((t, i) => {
+ const idx = visible.findIndex(row => row[metricCfg.bucketField] >= t.value);
+ if (idx === -1) return null;
+ return {
+ value: idx,
+ color: plotLineColors[i],
+ width: 2,
+ dashStyle: 'Dash',
+ label: {
+ text: `${t.label} (${metricCfg.unit ? t.value + metricCfg.unit : t.value})`,
+ style: { fontSize: '11px', color: textColor },
+ },
+ zIndex: 5,
+ };
+ }).filter(Boolean);
+
+ this.chart = Highcharts.chart(chartContainerId, {
+ chart: { type: 'column', backgroundColor: 'transparent' },
+ title: { text: null },
+ xAxis: {
+ categories,
+ title: { text: metricCfg.label, style: { color: textColor } },
+ labels: {
+ step: Math.ceil(categories.length / 20),
+ rotation: -45,
+ style: { color: textColor },
+ },
+ lineColor: gridLineColor,
+ plotLines,
+ },
+ yAxis: {
+ title: { text: 'Number of origins', style: { color: textColor } },
+ labels: { style: { color: textColor } },
+ gridLineColor,
+ min: 0,
+ },
+ legend: { enabled: false },
+ tooltip: {
+ formatter: function () {
+ return `${this.x}
Origins: ${this.y.toLocaleString()}`;
+ },
+ },
+ plotOptions: {
+ column: {
+ pointPadding: 0,
+ groupPadding: 0,
+ borderWidth: 0,
+ },
+ },
+ series: [{
+ name: 'Origins',
+ data: seriesData.map((value, i) => ({ y: value, color: colors[i] })),
+ }],
+ credits: { enabled: false },
+ });
+ }
+}
+
+window.CwvDistribution = CwvDistribution;
diff --git a/src/js/techreport/section.js b/src/js/techreport/section.js
index b6103657..7dbbabb7 100644
--- a/src/js/techreport/section.js
+++ b/src/js/techreport/section.js
@@ -1,4 +1,4 @@
-/* global Timeseries, GeoBreakdown */
+/* global Timeseries, GeoBreakdown, CwvDistribution */
import SummaryCard from "./summaryCards";
import TableLinked from "./tableLinked";
@@ -37,6 +37,10 @@ class Section {
this.initializeGeoBreakdown(component);
break;
+ case "cwvDistribution":
+ this.initializeCwvDistribution(component);
+ break;
+
default:
break;
}
@@ -83,6 +87,16 @@ class Section {
);
}
+ initializeCwvDistribution(component) {
+ this.components[component.dataset.id] = new CwvDistribution(
+ component.dataset.id,
+ this.pageConfig,
+ this.config,
+ this.pageFilters,
+ this.data
+ );
+ }
+
updateSection(content) {
Object.values(this.components).forEach(component => {
if(component.data !== this.data) {
diff --git a/static/css/techreport/techreport.css b/static/css/techreport/techreport.css
index 1de4f786..cae16978 100644
--- a/static/css/techreport/techreport.css
+++ b/static/css/techreport/techreport.css
@@ -1922,8 +1922,12 @@ h2.summary-heading {
/* -------------------- */
/* Highcharts */
-.highcharts-background,
-.highcharts-point {
+.highcharts-background {
+ fill: var(--color-card-background) !important;
+}
+
+.highcharts-line-series .highcharts-point,
+.highcharts-spline-series .highcharts-point {
fill: var(--color-card-background) !important;
}
@@ -2336,3 +2340,80 @@ path.highcharts-tick {
min-width: 7.5rem;
}
}
+
+/* CWV Distribution histogram */
+.cwv-distribution-details {
+ width: 100%;
+}
+
+.cwv-distribution-summary {
+ cursor: pointer;
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0;
+ justify-content: space-between;
+}
+
+.cwv-distribution-filters {
+ margin-left: auto;
+}
+
+.cwv-distribution-summary::-webkit-details-marker {
+ display: none;
+}
+
+.cwv-distribution-summary::before {
+ content: "\25B6";
+ font-size: 1rem;
+ transition: transform 0.2s;
+}
+
+.cwv-distribution-details[open] > .cwv-distribution-summary::before {
+ transform: rotate(90deg);
+}
+
+.cwv-distribution-summary h3 {
+ margin: 0;
+ display: inline;
+}
+
+.cwv-distribution-details .descr {
+ max-width: 30rem;
+}
+
+.cwv-distribution-details .meta {
+ margin-bottom: 1rem;
+}
+
+.cwv-distribution-loader {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 3rem 1rem;
+ gap: 1rem;
+ color: var(--color-text-lighter);
+}
+
+.cwv-distribution-spinner {
+ width: 2rem;
+ height: 2rem;
+ border: 3px solid var(--color-border);
+ border-top-color: var(--color-teal);
+ border-radius: 50%;
+ animation: cwv-spin 0.8s linear infinite;
+}
+
+@keyframes cwv-spin {
+ to { transform: rotate(360deg); }
+}
+
+.cwv-distribution-error {
+ padding: 2rem 1rem;
+ text-align: center;
+ color: var(--color-text-lighter);
+ font-style: italic;
+}
+
diff --git a/templates/techreport/components/cwv_distribution.html b/templates/techreport/components/cwv_distribution.html
new file mode 100644
index 00000000..a6996468
--- /dev/null
+++ b/templates/techreport/components/cwv_distribution.html
@@ -0,0 +1,39 @@
+{% set cwv_distribution_config = tech_report_page.config.cwv_distribution %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ cwv_distribution_config.description }}
+
+ {% include "techreport/components/filter_meta.html" %}
+
+
+
+
diff --git a/templates/techreport/drilldown.html b/templates/techreport/drilldown.html
index 32e9550e..e9fd9ece 100644
--- a/templates/techreport/drilldown.html
+++ b/templates/techreport/drilldown.html
@@ -74,6 +74,12 @@ {{ tech_report_page.config.good_cwv_summary.title }}
{% include "techreport/components/geo_breakdown.html" %}
{% endif %}
+
+ {% if tech_report_page.config.cwv_distribution %}
+
+ {% include "techreport/components/cwv_distribution.html" %}
+
+ {% endif %}
diff --git a/templates/techreport/techreport.html b/templates/techreport/techreport.html
index 0f8cdd1e..2d20ce4e 100644
--- a/templates/techreport/techreport.html
+++ b/templates/techreport/techreport.html
@@ -112,6 +112,7 @@ Accessibility
+
diff --git a/webpack.config.js b/webpack.config.js
index d1fcc493..5e6eefe1 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -16,6 +16,7 @@ module.exports = {
'techreport/timeseries': './src/js/techreport/timeseries.js',
'techreport/section': './src/js/techreport/section.js',
'techreport/geoBreakdown': './src/js/techreport/geoBreakdown.js',
+ 'techreport/cwvDistribution': './src/js/techreport/cwvDistribution.js',
},
output: {
path: path.resolve(__dirname, 'static/js'),