From 0ab9b9a2d4c0ea89c4ea23b780f8882802852ae6 Mon Sep 17 00:00:00 2001 From: Alon Kochba Date: Sat, 11 Apr 2026 14:43:53 +0300 Subject: [PATCH] Add Core Web Vitals distribution histogram to tech report drilldown Adds a collapsible histogram chart to the CWV section showing how origins are distributed across performance buckets for LCP, CLS, INP, FCP, and TTFB. Features: - Column chart with bars color-coded green/orange/red by CWV thresholds - Dashed plotlines marking good/needs-improvement boundaries - Tail buckets aggregated into an overflow "X+" bar so all origins are shown - Metric selector in the collapsed summary bar for quick switching - Loading spinner while the API call is in progress - Error message when data is unavailable - Light and dark mode support with theme-aware colors - Anchor link (#section-cwv_distribution) with auto-expand on direct navigation - URL hash updates when the section is expanded Fetches data from /v1/cwv-distribution (HTTPArchive/tech-report-apis#105) with technology, date, rank, and geo parameters. Also scopes the global .highcharts-point CSS rule to line/spline series only, so column chart bar colors are not overridden. Closes #1147 --- config/last_updated.json | 21 +- config/techreport.json | 12 + src/js/techreport/cwvDistribution.js | 261 ++++++++++++++++++ src/js/techreport/section.js | 16 +- static/css/techreport/techreport.css | 85 +++++- .../components/cwv_distribution.html | 39 +++ templates/techreport/drilldown.html | 6 + templates/techreport/techreport.html | 1 + webpack.config.js | 1 + 9 files changed, 431 insertions(+), 11 deletions(-) create mode 100644 src/js/techreport/cwvDistribution.js create mode 100644 templates/techreport/components/cwv_distribution.html 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.title }}

+
+
+
+ + +
+
+
+
+ +
+

{{ 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'),