diff --git a/config/last_updated.json b/config/last_updated.json
index 45165ab7..00aab990 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-14T00:00:00.000Z",
+ "hash": "5a211ca184de3cd5731dfedaa1af929d"
},
"/static/js/accessibility.js": {
"date_published": "2023-10-09T00:00:00.000Z",
@@ -166,18 +166,23 @@
},
"/static/js/techreport.js": {
"date_published": "2023-10-09T00:00:00.000Z",
- "date_modified": "2026-03-10T00:00:00.000Z",
+ "date_modified": "2026-04-14T00:00:00.000Z",
"hash": "dfcef45ae09e7c2fcd3ab825e9503729"
},
+ "/static/js/techreport/cwvDistribution.js": {
+ "date_published": "2026-04-07T00:00:00.000Z",
+ "date_modified": "2026-04-14T00:00:00.000Z",
+ "hash": "a9e884b2786b23670ac13e2c8a121183"
+ },
"/static/js/techreport/geoBreakdown.js": {
"date_published": "2026-03-24T00:00:00.000Z",
- "date_modified": "2026-03-24T00:00:00.000Z",
- "hash": "030573b2da410620601352f9f6df8695"
+ "date_modified": "2026-04-14T00:00:00.000Z",
+ "hash": "2eab1a9b9c47001e8f5f757d041f4897"
},
"/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..68071f77
--- /dev/null
+++ b/src/js/techreport/cwvDistribution.js
@@ -0,0 +1,285 @@
+/* global Highcharts */
+
+import { Constants } from './utils/constants';
+import { UIUtils } from './utils/ui';
+
+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 {
+ // eslint-disable-next-line no-unused-vars -- pageConfig, config, data 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.date = this.pageFilters.end || this.root?.dataset?.latestDate || '';
+
+ // Populate "Latest data" timestamp immediately
+ const tsSlot = this.root?.querySelector('[data-slot="cwv-distribution-timestamp"]');
+ if (tsSlot && this.date) tsSlot.textContent = UIUtils.printMonthYear(this.date);
+
+
+ this.bindEventListeners();
+
+ // Auto-expand if URL hash targets this section
+ if (window.location.hash === `#section-${this.id}`) {
+ this.toggle(true);
+ }
+ }
+
+ bindEventListeners() {
+ if (!this.root) return;
+
+ this.root.querySelectorAll('.cwv-distribution-metric-selector').forEach(dropdown => {
+ dropdown.addEventListener('change', event => {
+ this.selectedMetric = event.target.value;
+ if (this.distributionData) this.renderChart();
+ });
+ });
+
+ const btn = document.getElementById('cwv-distribution-btn');
+ if (btn) {
+ btn.addEventListener('click', () => {
+ const isVisible = !this.root.classList.contains('hidden');
+ this.toggle(!isVisible);
+ });
+ }
+ }
+
+ toggle(show) {
+ const btn = document.getElementById('cwv-distribution-btn');
+ if (show) {
+ this.root.classList.remove('hidden');
+ if (btn) btn.textContent = 'Hide distribution';
+ if (!this.distributionData) {
+ this.fetchData();
+ } else if (this.chart) {
+ this.chart.reflow();
+ }
+ } else {
+ this.root.classList.add('hidden');
+ if (btn) btn.textContent = 'Show distribution';
+ }
+ }
+
+ 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');
+ let url = `${Constants.apiBase}/cwv-distribution?technology=${technology}&rank=${rank}&geo=${geo}`;
+ if (this.date) {
+ url += `&date=${encodeURIComponent(this.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 rawNext = visible[visible.length - 1][metricCfg.bucketField] + metricCfg.step;
+ const nextBucket = Math.round(rawNext * 1e6) / 1e6;
+ 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 - 0.5,
+ 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 },
+ formatter: function () {
+ const lastIndex = categories.length - 1;
+ const labelStep = Math.ceil(categories.length / 20);
+ if (this.pos === lastIndex || this.pos % labelStep === 0) {
+ return this.value;
+ }
+ return null;
+ },
+ },
+ 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,
+ borderRadius: 0,
+ crisp: false,
+ },
+ },
+ 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/geoBreakdown.js b/src/js/techreport/geoBreakdown.js
index a995c721..3e682455 100644
--- a/src/js/techreport/geoBreakdown.js
+++ b/src/js/techreport/geoBreakdown.js
@@ -15,7 +15,26 @@ class GeoBreakdown {
this.showAll = false;
this.bindEventListeners();
- this.fetchData();
+
+ // Auto-expand if URL hash targets this section
+ if (window.location.hash === `#section-${this.id}`) {
+ this.toggle(true);
+ }
+ }
+
+ toggle(show) {
+ const wrapper = document.getElementById(`section-${this.id}`);
+ const btn = document.getElementById('geo-breakdown-btn');
+ if (show) {
+ if (wrapper) wrapper.classList.remove('hidden');
+ if (btn) btn.textContent = 'Hide geographic breakdown';
+ if (!this.geoData) {
+ this.fetchData();
+ }
+ } else {
+ if (wrapper) wrapper.classList.add('hidden');
+ if (btn) btn.textContent = 'Show geographic breakdown';
+ }
}
bindEventListeners() {
@@ -26,9 +45,33 @@ class GeoBreakdown {
if (this.geoData) this.renderTable();
});
});
+
+ const btn = document.getElementById('geo-breakdown-btn');
+ if (btn) {
+ btn.addEventListener('click', () => {
+ const wrapper = document.getElementById(`section-${this.id}`);
+ const isVisible = wrapper && !wrapper.classList.contains('hidden');
+ this.toggle(!isVisible);
+ });
+ }
+ }
+
+ showLoader() {
+ const container = document.getElementById(`${this.id}-table`);
+ if (!container) return;
+ container.innerHTML = '';
+ }
+
+ hideLoader() {
+ const container = document.getElementById(`${this.id}-table`);
+ if (!container) return;
+ const loader = container.querySelector('.cwv-distribution-loader');
+ if (loader) loader.remove();
}
fetchData() {
+ this.showLoader();
+
const technology = this.pageFilters.app.map(encodeURIComponent).join(',');
const rank = encodeURIComponent(this.pageFilters.rank || 'ALL');
const end = this.pageFilters.end ? `&end=${encodeURIComponent(this.pageFilters.end)}` : '';
@@ -38,9 +81,13 @@ class GeoBreakdown {
.then(r => r.json())
.then(rows => {
this.geoData = rows;
+ this.hideLoader();
this.renderTable();
})
- .catch(err => console.error('GeoBreakdown fetch error:', err));
+ .catch(err => {
+ console.error('GeoBreakdown fetch error:', err);
+ this.hideLoader();
+ });
}
updateContent() {
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..2efe6ba9 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,91 @@ path.highcharts-tick {
min-width: 7.5rem;
}
}
+
+/* Hide original "Show table" button — replaced by one in the button bar */
+#good-cwvs .card > [data-component="timeseries"] > .show-table {
+ display: none;
+}
+
+
+/* CWV button bar (Show table, geographic breakdown, distribution) */
+.cwv-button-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.cwv-button-bar > .geo-breakdown-wrapper,
+.cwv-button-bar > .cwv-distribution-wrapper,
+.cwv-button-bar > [id$="-table-wrapper"] {
+ flex-basis: 100%;
+ order: 1;
+}
+
+/* CWV Distribution histogram */
+.cwv-distribution-wrapper {
+ margin-top: 1rem;
+ border-top: 1px solid var(--color-border);
+ padding-top: 1rem;
+}
+
+/* Geographic breakdown wrapper */
+.geo-breakdown-wrapper {
+ margin-top: 1rem;
+ border-top: 1px solid var(--color-border);
+ padding-top: 1rem;
+ overflow-x: auto;
+}
+
+.cwv-distribution-header {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+}
+
+.cwv-distribution-header .descr {
+ max-width: 30rem;
+}
+
+.cwv-distribution-wrapper [id$="-chart"] {
+ width: 100%;
+ overflow-x: auto;
+}
+
+.cwv-distribution-wrapper .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..81867105
--- /dev/null
+++ b/templates/techreport/components/cwv_distribution.html
@@ -0,0 +1,44 @@
+{% set cwv_distribution_config = tech_report_page.config.cwv_distribution %}
+
+
+ Show distribution
+
+
+
diff --git a/templates/techreport/components/geo_breakdown.html b/templates/techreport/components/geo_breakdown.html
index 1f8c5f8b..0fba8ecb 100644
--- a/templates/techreport/components/geo_breakdown.html
+++ b/templates/techreport/components/geo_breakdown.html
@@ -1,7 +1,12 @@
{% set geo_breakdown_config = tech_report_page.config.geo_breakdown %}
+
+ Show geographic breakdown
+
+
{{ tech_report_page.config.good_cwv_summary.title }}
{% set timeseries = tech_report_page.config.good_cwv_timeseries %}
{% set selected_subcategory = request.args.get('good-cwv-over-time', '') or tech_report_page.config.good_cwv_timeseries.viz.default or 'overall' %}
{% include "techreport/components/timeseries.html" %}
-
- {% if tech_report_page.config.geo_breakdown %}
-
- {% include "techreport/components/geo_breakdown.html" %}
+ {% set ts_id = tech_report_page.config.good_cwv_timeseries.id %}
+
+ Show table
+ {% if tech_report_page.config.geo_breakdown %}
+ {% include "techreport/components/geo_breakdown.html" %}
+ {% endif %}
+ {% if tech_report_page.config.cwv_distribution %}
+ {% include "techreport/components/cwv_distribution.html" %}
+ {% endif %}
+
+
- {% 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'),