Skip to content
105 changes: 102 additions & 3 deletions config/techreport.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,21 @@
"LCP",
"FCP",
"INP",
"TTFB"
"TTFB",
"overall_sn",
"LCP_sn",
"CLS_sn",
"INP_sn"
],
"cwv_thresholds": {
"LCP": [{ "value": 2500, "label": "Good" }, { "value": 4000, "label": "Needs improvement" }],
"LCP_sn": [{ "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" }]
"INP_sn": [{ "value": 200, "label": "Good" }, { "value": 500, "label": "Needs improvement" }],
"CLS": [{ "value": 0.1, "label": "Good" }, { "value": 0.25, "label": "Needs improvement" }],
"CLS_sn": [{ "value": 0.1, "label": "Good" }, { "value": 0.25, "label": "Needs improvement" }]
},
"lighthouse_subcategories": [
"accessibility",
Expand Down Expand Up @@ -324,6 +331,22 @@
{
"label": "Good TTFB",
"value": "TTFB"
},
{
"label": "Good Core Web Vitals (with soft navs)",
"value": "overall_sn"
},
{
"label": "Good LCP (with soft navs)",
"value": "LCP_sn"
},
{
"label": "Good INP (with soft navs)",
"value": "INP_sn"
},
{
"label": "Good CLS (with soft navs)",
"value": "CLS_sn"
}
]
},
Expand Down Expand Up @@ -739,7 +762,11 @@
"INP": "Good INP",
"CLS": "Good CLS",
"FCP": "Good FCP",
"TTFB": "Good TTFB"
"TTFB": "Good TTFB",
"overall_sn": "Good Core Web Vitals (with soft navs)",
"LCP_sn": "Good LCP (with soft navs)",
"INP_sn": "Good INP (with soft navs)",
"CLS_sn": "Good CLS (with soft navs)"
}
},
"cwv_distribution": {
Expand All @@ -755,6 +782,14 @@
"axis_label": "LCP (ms)",
"step": 100
},
"LCP_sn": {
"label": "LCP (soft navs)",
"bucketField": "loading_bucket",
"originsField": "lcp_sn_origins",
"unit": "ms",
"axis_label": "LCP with soft navs (ms)",
"step": 100
},
"FCP": {
"label": "FCP",
"bucketField": "loading_bucket",
Expand All @@ -778,13 +813,29 @@
"axis_label": "INP (ms)",
"step": 25
},
"INP_sn": {
"label": "INP (soft navs)",
"bucketField": "inp_bucket",
"originsField": "inp_sn_origins",
"unit": "ms",
"axis_label": "INP with soft navs (ms)",
"step": 25
},
"CLS": {
"label": "CLS",
"bucketField": "cls_bucket",
"originsField": "cls_origins",
"unit": "",
"axis_label": "CLS",
"step": 0.05
},
"CLS_sn": {
"label": "CLS (soft navs)",
"bucketField": "cls_bucket",
"originsField": "cls_sn_origins",
"unit": "",
"axis_label": "CLS with soft navs",
"step": 0.05
}
},
"zone_colors": {
Expand Down Expand Up @@ -938,6 +989,22 @@
{
"label": "Good TTFB",
"value": "TTFB"
},
{
"label": "Good Core Web Vitals (with soft navs)",
"value": "overall_sn"
},
{
"label": "Good LCP (with soft navs)",
"value": "LCP_sn"
},
{
"label": "Good INP (with soft navs)",
"value": "INP_sn"
},
{
"label": "Good CLS (with soft navs)",
"value": "CLS_sn"
}
]
},
Expand Down Expand Up @@ -1440,6 +1507,26 @@
"label": "Good TTFB",
"title": "Good Time To First Byte",
"description": "TTFB is a metric that measures the time between the request for a resource and when the first byte of a response begins to arrive. A good TTFB is less than or equal to 800ms."
},
"overall_sn": {
"label": "Good Core Web Vitals (with soft navs)",
"title": "Good Core Web Vitals (with soft navs)",
"description": "The percentage of origins passing all three Core Web Vitals (LCP, INP, CLS) with a good experience, accounting for soft navigations in single-page applications. Note that if an origin is missing INP data, it's assessed based on the performance of the remaining metrics."
},
"LCP_sn": {
"label": "Good LCP (with soft navs)",
"title": "Good Largest Contentful Paint (with soft navs)",
"description": "Largest Contentful Paint including soft navigations measures perceived load speed across both hard page loads and subsequent in-app soft navigations. Good experiences are less than or equal to 2.5 seconds."
},
"INP_sn": {
"label": "Good INP (with soft navs)",
"title": "Good Interaction to Next Paint (with soft navs)",
"description": "Interaction to Next Paint including soft navigations assesses responsiveness across user interactions within the lifespan of a visit, including interactions occurring during soft navigations. A good experience is less than or equal to 200ms."
},
"CLS_sn": {
"label": "Good CLS (with soft navs)",
"title": "Good Cumulative Layout Shift (with soft navs)",
"description": "Cumulative Layout Shift including soft navigations measures visual stability across both initial loads and subsequent soft navigation page changes. Sites should strive to have a CLS of 0.1 or less for at least 75% of page visits."
}
},
"pageWeight": {
Expand Down Expand Up @@ -1480,20 +1567,32 @@
"origins_eligible_for_fcp": "Eligible",
"origins_eligible_for_inp": "Eligible",
"origins_eligible_for_ttfb": "Eligible",
"origins_eligible_for_cwv_sn": "Eligible",
"origins_eligible_for_cls_sn": "Eligible",
"origins_eligible_for_lcp_sn": "Eligible",
"origins_eligible_for_inp_sn": "Eligible",

"origins_with_good_cwv": "Having good Core Web Vitals",
"origins_with_good_cls": "Having good CLS",
"origins_with_good_lcp": "Having good LCP",
"origins_with_good_fcp": "Having good FCP",
"origins_with_good_inp": "Having good INP",
"origins_with_good_ttfb": "Having good TTFB",
"origins_with_good_cwv_sn": "Having good Core Web Vitals (with soft navs)",
"origins_with_good_cls_sn": "Having good CLS (with soft navs)",
"origins_with_good_lcp_sn": "Having good LCP (with soft navs)",
"origins_with_good_inp_sn": "Having good INP (with soft navs)",

"pct_good_cwv": "% Good Core Web Vitals",
"pct_good_cls": "% Good CLS",
"pct_good_lcp": "% Good LCP",
"pct_good_fcp": "% Good FCP",
"pct_good_inp": "% Good INP",
"pct_good_ttfb": "% Good TTFB",
"pct_good_cwv_sn": "% Good Core Web Vitals (with soft navs)",
"pct_good_cls_sn": "% Good CLS (with soft navs)",
"pct_good_lcp_sn": "% Good LCP (with soft navs)",
"pct_good_inp_sn": "% Good INP (with soft navs)",

"median_lighthouse_score_accessibility": "Median accessibility",
"median_lighthouse_score_performance": "Median performance",
Expand Down
65 changes: 51 additions & 14 deletions src/js/techreport/cwvDistribution.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,34 @@ class CwvDistribution {
}

this.updateTitle();
this.updateButton();
this.bindEventListeners();

// Auto-expand if URL hash targets this section
if (window.location.hash === `#section-${this.id}`) {
if (this.selectedMetric && window.location.hash === `#section-${this.id}`) {
this.toggle(true);
}
}

// Map the shared metric value (which may be 'overall') to a metric this chart can show
// Map the shared metric value to a metric this chart can show, or null if not available
resolveMetric(value) {
if (value && this.pageConfig.cwv_distribution.metric_config[value]) return this.pageConfig.cwv_distribution.metric_config[value].label;
return 'LCP';
if (value && this.pageConfig?.cwv_distribution?.metric_config?.[value]) return value;
return null;
}

updateButton() {
const btn = document.getElementById('cwv-distribution-btn');
if (btn) {
btn.disabled = !this.selectedMetric;
}
}

updateTitle() {
const slot = this.root?.querySelector('[data-slot="cwv-distribution-title-metric"]');
if (slot) slot.textContent = this.selectedMetric;
if (slot && this.selectedMetric) {
const cfg = this.pageConfig?.cwv_distribution?.metric_config?.[this.selectedMetric];
slot.textContent = cfg?.label || this.selectedMetric;
}
}

bindEventListeners() {
Expand All @@ -77,7 +88,14 @@ class CwvDistribution {
if (resolved !== this.selectedMetric) {
this.selectedMetric = resolved;
this.updateTitle();
if (this.distributionData) this.renderChart();
this.updateButton();
if (!this.selectedMetric) {
if (!this.root.classList.contains('hidden')) {
this.toggle(false);
}
} else if (this.distributionData) {
this.renderChart();
}
}
});

Expand All @@ -93,6 +111,7 @@ class CwvDistribution {
toggle(show) {
const btn = document.getElementById('cwv-distribution-btn');
if (show) {
if (!this.selectedMetric) return;
this.root.classList.remove('hidden');
if (btn) btn.textContent = 'Hide histogram';
if (!this.date) {
Expand Down Expand Up @@ -175,6 +194,10 @@ class CwvDistribution {
}

showError() {
if (this.chart) {
this.chart.destroy();
this.chart = null;
}
if (!this.chartContainer) return;
this.chartContainer.innerHTML = '<div class="cwv-distribution-error">Distribution data is not available for this selection.</div>';
}
Expand Down Expand Up @@ -207,41 +230,55 @@ class CwvDistribution {
}

trimWithOverflow(rows, originsField, percentile) {
const total = rows.reduce((sum, row) => sum + row[originsField], 0);
if (total === 0) return { visible: rows, overflowCount: 0 };
const total = rows.reduce((sum, row) => sum + (row[originsField] || 0), 0);
if (!total || isNaN(total)) return { visible: rows, overflowCount: 0, total: 0 };

const cutoff = total * percentile;
let cumulative = 0;
let cutIndex = rows.length;
for (let i = 0; i < rows.length; i++) {
cumulative += rows[i][originsField];
cumulative += (rows[i][originsField] || 0);
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 };
const visibleSum = visible.reduce((sum, row) => sum + (row[originsField] || 0), 0);
return { visible, overflowCount: total - visibleSum, total };
}

renderChart() {
if (!this.distributionData || this.distributionData.length === 0) return;
if (!this.root) return;

const client = this.root.dataset.client || 'mobile';
const metricCfg = this.pageConfig.cwv_distribution.metric_config[this.selectedMetric];
const thresholds = this.config.cwv_thresholds[this.selectedMetric];
const metricCfg = this.pageConfig?.cwv_distribution?.metric_config?.[this.selectedMetric];
if (!metricCfg) {
this.showError();
return;
}
const thresholds = this.config?.cwv_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(
if (clientRows.length === 0) {
this.showError();
return;
}

const { visible, overflowCount, total } = this.trimWithOverflow(
clientRows, metricCfg.originsField, 0.995
);

if (!total || total <= 0) {
this.showError();
return;
}

const formatBucket = (val) => {
if (metricCfg.unit === 'ms') {
return val >= 1000 ? `${(val / 1000).toFixed(1)}s` : `${val}ms`;
Expand Down
30 changes: 25 additions & 5 deletions src/js/techreport/geoBreakdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ class GeoBreakdown {
updateTitle() {
const slot = document.querySelector('[data-slot="geo-title-metric"]');
if (!slot) return;
slot.textContent = this.selectedMetric === 'overall' ? 'Core Web Vitals' : this.selectedMetric;
if (this.selectedMetric === 'overall') {
slot.textContent = 'Core Web Vitals';
} else if (this.selectedMetric === 'overall_sn') {
slot.textContent = 'Core Web Vitals (soft navs)';
} else {
slot.textContent = this.pageConfig?.cwv_distribution?.metric_config?.[this.selectedMetric]?.label || this.selectedMetric;
}
}

bindEventListeners() {
Expand Down Expand Up @@ -137,16 +143,18 @@ class GeoBreakdown {

// Determine bar label and which extra columns to show
const metric = this.selectedMetric;
const barLabel = metric === 'overall' ? 'Good Core Web Vitals' : `Good ${metric}`;
const barLabel = this.pageConfig?.geo_breakdown?.metric_labels?.[metric] || `Good ${metric}`;
const extraCols = ['LCP', 'FCP', 'TTFB'].includes(metric)
? [{ key: 'ttfb', label: 'TTFB' }, { key: 'fcp', label: 'FCP' }, { key: 'lcp', label: 'LCP' }].filter(c => c.key !== metric.toLowerCase())
: metric === 'overall'
? [{ key: 'lcp', label: 'LCP' }, { key: 'inp', label: 'INP' }, { key: 'cls', label: 'CLS' }]
: []; // INP/CLS: no extra columns
: metric === 'overall_sn'
? [{ key: 'lcp_sn', label: 'LCP' }, { key: 'inp_sn', label: 'INP' }, { key: 'cls_sn', label: 'CLS' }]
: []; // INP/CLS/individual softnav metrics: no extra columns

// If current sort column is no longer visible, reset to total
const extraKeys = extraCols.map(c => c.key);
if (['lcp', 'inp', 'cls', 'fcp', 'ttfb'].includes(this.sortColumn) && !extraKeys.includes(this.sortColumn)) {
if (['lcp', 'inp', 'cls', 'fcp', 'ttfb', 'lcp_sn', 'inp_sn', 'cls_sn'].includes(this.sortColumn) && !extraKeys.includes(this.sortColumn)) {
this.sortColumn = 'total';
this.sortDir = 'desc';
}
Expand All @@ -171,7 +179,19 @@ class GeoBreakdown {
const v = row.vitals?.find(x => x.name === name)?.[client];
return v?.tested > 0 ? Math.round(v.good_number / v.tested * 100) : null;
};
return { geo, good: d.good_number, total: d.tested, lcp: pctFor('LCP'), inp: pctFor('INP'), cls: pctFor('CLS'), fcp: pctFor('FCP'), ttfb: pctFor('TTFB') };
return {
geo,
good: d.good_number,
total: d.tested,
lcp: pctFor('LCP'),
inp: pctFor('INP'),
cls: pctFor('CLS'),
fcp: pctFor('FCP'),
ttfb: pctFor('TTFB'),
lcp_sn: pctFor('LCP_sn'),
inp_sn: pctFor('INP_sn'),
cls_sn: pctFor('CLS_sn')
};
}).filter(e => e.total > 0);

// Sort by origins, limit to top 10 unless showAll
Expand Down
Loading
Loading