Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions config/last_updated.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions config/techreport.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
}
}
},
Expand Down
261 changes: 261 additions & 0 deletions src/js/techreport/cwvDistribution.js
Original file line number Diff line number Diff line change
@@ -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 = '<div class="cwv-distribution-loader"><div class="cwv-distribution-spinner"></div><p>Loading distribution data…</p></div>';
}

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 = '<div class="cwv-distribution-error">Distribution data is not available for this selection.</div>';
}

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 `<b>${this.x}</b><br/>Origins: <b>${this.y.toLocaleString()}</b>`;
},
},
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;
16 changes: 15 additions & 1 deletion src/js/techreport/section.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* global Timeseries, GeoBreakdown */
/* global Timeseries, GeoBreakdown, CwvDistribution */

import SummaryCard from "./summaryCards";
import TableLinked from "./tableLinked";
Expand Down Expand Up @@ -37,6 +37,10 @@ class Section {
this.initializeGeoBreakdown(component);
break;

case "cwvDistribution":
this.initializeCwvDistribution(component);
break;

default:
break;
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading