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 = '

Loading geographic data…

'; + } + + 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 %} + + + + 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 %} + + - {% if tech_report_page.config.geo_breakdown %} -
- {% include "techreport/components/geo_breakdown.html" %} + {% set ts_id = tech_report_page.config.good_cwv_timeseries.id %} +
+ + {% 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'),