From 351a66ca877a52ab8b2f61ff66d23ff7bfe94c0b Mon Sep 17 00:00:00 2001 From: Alon Kochba Date: Wed, 11 Mar 2026 13:41:27 +0200 Subject: [PATCH 1/2] feat: add geographic CWV breakdown chart to tech report drilldown Adds a Geographic Breakdown section to the drilldown page showing origins with good/not-passing CWVs per country as a stacked horizontal bar chart, sorted by total origins. Supports CWV metric selection (overall, LCP, INP, CLS, FCP, TTFB), mobile/desktop toggle, and light/dark mode. Requires the new /v1/geo-breakdown API endpoint. Closes #1146 --- config/techreport.json | 375 +++++++++++++++--- src/js/techreport/geoBreakdown.js | 198 +++++++++ src/js/techreport/section.js | 16 +- .../techreport/components/geo_breakdown.html | 28 ++ templates/techreport/drilldown.html | 17 + templates/techreport/techreport.html | 1 + webpack.config.js | 1 + 7 files changed, 578 insertions(+), 58 deletions(-) create mode 100644 src/js/techreport/geoBreakdown.js create mode 100644 templates/techreport/components/geo_breakdown.html diff --git a/config/techreport.json b/config/techreport.json index 4dfd6dce..2c921e42 100644 --- a/config/techreport.json +++ b/config/techreport.json @@ -4,8 +4,15 @@ "summary": "The Core Web Vitals Technology Report is a dashboard combining the powers of real-user experiences in the [Chrome User Experience Report (CrUX)](https://developers.google.com/web/tools/chrome-user-experience-report/) dataset with web technology detections available in HTTP Archive, to allow analysis of the way websites are both built and experienced.", "config": { "default_apps": { - "drilldown": [ "ALL" ], - "comparison": [ "ALL", "WordPress", "Wix", "Next.js" ] + "drilldown": [ + "ALL" + ], + "comparison": [ + "ALL", + "WordPress", + "Wix", + "Next.js" + ] }, "default_category": "CMS", "cwv_subcategories": [ @@ -47,28 +54,36 @@ "description": "", "data": {}, "filters": { - "technologies": ["WordPress", "Squarespace", "Drupal"] + "technologies": [ + "WordPress", + "Squarespace", + "Drupal" + ] }, "config": { "default": { - "app": ["WordPress", "Squarespace", "Drupal"], + "app": [ + "WordPress", + "Squarespace", + "Drupal" + ], "series": { - "breakdown": "client", - "breakdown_values": [ - { - "name": "desktop", - "color": "#669E8E", - "color_dark": "#fff000", - "suffix": "%" - }, - { - "name": "mobile", - "color": "#BD6EBE", - "color_dark": "#ff00f0", - "suffix": "%" - } - ] - } + "breakdown": "client", + "breakdown_values": [ + { + "name": "desktop", + "color": "#669E8E", + "color_dark": "#fff000", + "suffix": "%" + }, + { + "name": "mobile", + "color": "#BD6EBE", + "color_dark": "#ff00f0", + "suffix": "%" + } + ] + } }, "popular_tech": { "id": "popular_tech", @@ -76,7 +91,11 @@ "description": "To write", "caption": "popular technologies table caption todo", "filters": { - "app": ["WordPress", "Squarespace", "Drupal"] + "app": [ + "WordPress", + "Squarespace", + "Drupal" + ] }, "ctaLabel": "Compare technologies", "ctaUrl": "/reports/techreport/comparison?app=WordPress,Squarespace,Drupal" @@ -90,7 +109,9 @@ "description": "View detailed information about one technology and compare mobile and desktop data over time.", "config": { "default": { - "app": ["ALL"], + "app": [ + "ALL" + ], "series": { "breakdown": "client", "breakdown_values": [ @@ -206,11 +227,37 @@ "defaults": [ { "name": "Desktop", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] }, { "name": "Mobile", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] } ] }, @@ -321,7 +368,7 @@ ] }, "table": { - "param":"good-cwv-over-time", + "param": "good-cwv-over-time", "default": "overall", "caption": "Good Core Web Vitals", "columns": [ @@ -352,7 +399,6 @@ "name": "Change", "styleChange": true, "keyNr": "momPerc" - }, { "key": "client", @@ -389,11 +435,37 @@ "defaults": [ { "name": "desktop", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] }, { "name": "mobile", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] } ] }, @@ -493,7 +565,7 @@ ] }, "table": { - "param":"median-lighthouse-over-time", + "param": "median-lighthouse-over-time", "default": "performance", "caption": "Lighthouse scores", "columns": [ @@ -544,11 +616,37 @@ "defaults": [ { "name": "Desktop", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] }, { "name": "Mobile", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] } ] }, @@ -637,7 +735,7 @@ ] }, "table": { - "param":"weight-over-time", + "param": "weight-over-time", "default": "images", "caption": "Weight", "columns": [ @@ -691,11 +789,37 @@ "defaults": [ { "name": "Desktop", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] }, { "name": "Mobile", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] } ] }, @@ -721,6 +845,37 @@ "title": "Weight in bytes" } } + }, + "geo_breakdown": { + "id": "geo_breakdown", + "title": "Geographic Breakdown", + "description": "Each bar shows origins with good CWVs (left) and not passing (right), sorted by total origins. Use the dropdown to switch between CWV metrics.", + "metric_options": [ + { + "label": "Overall CWVs", + "value": "overall" + }, + { + "label": "LCP", + "value": "LCP" + }, + { + "label": "INP", + "value": "INP" + }, + { + "label": "CLS", + "value": "CLS" + }, + { + "label": "FCP", + "value": "FCP" + }, + { + "label": "TTFB", + "value": "TTFB" + } + ] } } }, @@ -759,7 +914,11 @@ } }, "default": { - "app": ["ALL", "WordPress", "Drupal"], + "app": [ + "ALL", + "WordPress", + "Drupal" + ], "series": { "breakdown": "app" } @@ -897,11 +1056,37 @@ "defaults": [ { "name": "App", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] }, { "name": "App", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] } ], "xAxis": { @@ -994,11 +1179,37 @@ "defaults": [ { "name": "App", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] }, { "name": "App", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] } ] }, @@ -1054,7 +1265,7 @@ ] }, "table": { - "param":"median-weight-over-time", + "param": "median-weight-over-time", "default": "total", "caption": "Weight", "columns": [ @@ -1089,12 +1300,38 @@ "defaults": [ { "name": "App", - "data": [0,0,0,0,0,0,0,0,0,0,0,0], + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], "suffix": " bytes" }, { "name": "App", - "data": [0,0,0,0,0,0,0,0,0,0,0,0], + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], "suffix": " bytes" } ] @@ -1131,7 +1368,7 @@ "default": "adoption", "change": "true", "table": { - "param":"", + "param": "", "caption": "Adoption", "default": "adoption", "columns": [ @@ -1162,11 +1399,37 @@ "defaults": [ { "name": "App", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] }, { "name": "App", - "data": [0,0,0,0,0,0,0,0,0,0,0,0] + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] } ], "xAxis": { @@ -1204,7 +1467,11 @@ "config": { "default": { "category": "CMS", - "app": ["ALL", "WordPress", "Drupal"], + "app": [ + "ALL", + "WordPress", + "Drupal" + ], "series": { "breakdown": "app" } @@ -1300,7 +1567,6 @@ } } }, - "labels": { "metrics": { "lighthouse": { @@ -1336,7 +1602,7 @@ "LCP": { "label": "Good LCP", "title": "Good Largest Contentful Paint", - "description": "Largest Contentful Paint (LCP) is an important, stable Core Web Vital metric for measuring perceived load speed because it marks the point in the page load timeline when the page's main content has likely loaded—a fast LCP helps reassure the user that the page is useful. Good experiences are less than or equal to 2.5 seconds." + "description": "Largest Contentful Paint (LCP) is an important, stable Core Web Vital metric for measuring perceived load speed because it marks the point in the page load timeline when the page's main content has likely loaded\u2014a fast LCP helps reassure the user that the page is useful. Good experiences are less than or equal to 2.5 seconds." }, "INP": { "label": "Good INP", @@ -1391,37 +1657,32 @@ "date": "Date", "client": "Client", "origins": "Origins", - "origins_eligible_for_cwv": "Eligible", "origins_eligible_for_cls": "Eligible", "origins_eligible_for_lcp": "Eligible", "origins_eligible_for_fcp": "Eligible", "origins_eligible_for_inp": "Eligible", "origins_eligible_for_ttfb": "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", - "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", - "median_lighthouse_score_accessibility": "Median accessibility", "median_lighthouse_score_performance": "Median performance", "median_lighthouse_score_pwa": "Median PWA", "median_lighthouse_score_seo": "Median SEO", "median_lighthouse_score_best_practices": "Median best practices", - - "median_bytes_image":"Median bytes image", - "median_bytes_js":"Median bytes js", - "median_bytes_total":"Median bytes total" + "median_bytes_image": "Median bytes image", + "median_bytes_js": "Median bytes js", + "median_bytes_total": "Median bytes total" } }, "graphic": { @@ -1435,4 +1696,4 @@ "icon": "fas fa-tachometer-alt" } } -} +} \ No newline at end of file diff --git a/src/js/techreport/geoBreakdown.js b/src/js/techreport/geoBreakdown.js new file mode 100644 index 00000000..03d9c40c --- /dev/null +++ b/src/js/techreport/geoBreakdown.js @@ -0,0 +1,198 @@ +/* global Highcharts */ + +import { Constants } from './utils/constants'; + +class GeoBreakdown { + constructor(id, pageConfig, config, filters, data) { + this.id = id; + this.pageConfig = pageConfig; + this.config = config; + this.pageFilters = filters; + this.data = data; + this.geoData = null; + this.selectedMetric = 'overall'; + + this.bindEventListeners(); + this.fetchData(); + } + + bindEventListeners() { + const selector = `[data-id="${this.id}"] .geo-metric-selector`; + document.querySelectorAll(selector).forEach(dropdown => { + dropdown.addEventListener('change', event => { + this.selectedMetric = event.target.value; + if (this.geoData) this.renderChart(); + }); + }); + } + + fetchData() { + const technology = this.pageFilters.app.map(encodeURIComponent).join(','); + const rank = encodeURIComponent(this.pageFilters.rank || 'ALL'); + const url = `${Constants.apiBase}/geo-breakdown?technology=${technology}&rank=${rank}&start=latest`; + + fetch(url) + .then(r => r.json()) + .then(rows => { + this.geoData = rows; + this.renderChart(); + }) + .catch(err => console.error('GeoBreakdown fetch error:', err)); + } + + // Called by Section.updateSection() when global filters (client, theme) change + updateContent() { + if (this.geoData) { + this.renderChart(); + } + } + + renderChart() { + if (!this.geoData || this.geoData.length === 0) return; + + // Read the currently selected client from the DOM (updated by index.js updateClient) + const component = document.querySelector(`[data-id="${this.id}"]`); + const client = component?.dataset?.client || 'mobile'; + const metric = this.selectedMetric; + + // Pick the latest date per geo + const geoMap = {}; + this.geoData.forEach(row => { + if (!geoMap[row.geo] || row.date > geoMap[row.geo].date) { + geoMap[row.geo] = row; + } + }); + + // Extract good/total origins for the selected metric and client + const geoEntries = Object.entries(geoMap).map(([geo, row]) => { + const vitalEntry = row.vitals?.find(v => v.name === metric); + const clientData = vitalEntry?.[client] || { good_number: 0, tested: 0 }; + return { geo, good: clientData.good_number, total: clientData.tested }; + }).filter(e => e.total > 0); + + // Sort descending by total origins so the largest market appears at the top + geoEntries.sort((a, b) => b.total - a.total); + + const categories = geoEntries.map(e => e.geo); + const goodVals = geoEntries.map(e => e.good); + const badVals = geoEntries.map(e => e.total - e.good); + const goodPct = geoEntries.map(e => Math.round(e.good / e.total * 100)); + + const isDark = document.querySelector('html').dataset.theme === 'dark'; + const colorGood = isDark ? 'var(--color-teal-vibrant-lighter, #2095A2)' : 'var(--color-teal-vibrant, #1c818d)'; + const colorBad = isDark ? '#2f2f30' : '#e8e8e8'; + const colorText = isDark ? '#fff' : '#333'; + const colorLabels = isDark ? '#8EA1A4' : '#5f6768'; + const colorGrid = isDark ? '#1e1e1e' : '#f0f0f0'; + const colorAxis = isDark ? '#555' : '#cdd4d6'; + const colorBg = 'transparent'; + const tooltipBg = isDark ? '#111' : '#fff'; + + const chartHeight = Math.max(300, geoEntries.length * 26 + 80); + + Highcharts.chart(`${this.id}-chart`, { + chart: { + type: 'bar', + height: chartHeight, + backgroundColor: colorBg, + marginRight: 10, + style: { fontFamily: "var(--font-family-sans-serif, 'Open Sans', Arial, sans-serif)" }, + }, + title: { text: null }, + accessibility: { enabled: true }, + + xAxis: { + categories, + labels: { style: { fontSize: '11px', color: colorText } }, + lineColor: colorAxis, + tickColor: colorAxis, + }, + + yAxis: { + min: 0, + reversedStacks: false, // ensures series[0] (Good CWVs) renders on the LEFT + title: { + text: 'Number of origins', + style: { fontSize: '10px', color: colorLabels }, + }, + labels: { + formatter() { + return this.value >= 1000 ? Math.round(this.value / 1000) + 'K' : this.value; + }, + style: { fontSize: '10px', color: colorLabels }, + }, + gridLineColor: colorGrid, + }, + + legend: { + enabled: true, + align: 'left', + verticalAlign: 'top', + margin: 14, + itemStyle: { fontSize: '11px', fontWeight: '400', color: colorText }, + itemHoverStyle: { color: colorText }, + symbolRadius: 2, + }, + + plotOptions: { + series: { + stacking: 'normal', + borderWidth: 0, + borderRadius: 2, + pointPadding: 0.08, + groupPadding: 0.12, + }, + }, + + tooltip: { + useHTML: true, + shared: true, + backgroundColor: tooltipBg, + borderColor: colorAxis, + style: { color: colorText }, + formatter() { + const idx = this.points[0].point.index; + const e = geoEntries[idx]; + const pct = goodPct[idx]; + return `
+ ${e.geo}
+ Good CWVs: ${e.good.toLocaleString()} (${pct}%)
+ ● Not passing: ${(e.total - e.good).toLocaleString()}
+ Total: ${e.total.toLocaleString()} +
`; + }, + }, + + series: [ + { + name: 'Good CWVs', + data: goodVals, + color: colorGood, + dataLabels: { + enabled: true, + inside: true, + align: 'right', + style: { + fontSize: '10px', + fontWeight: '700', + color: '#fff', + textOutline: 'none', + }, + formatter() { + return goodPct[this.point.index] + '%'; + }, + }, + }, + { + name: 'Not passing', + data: badVals, + color: colorBad, + }, + ], + + credits: { enabled: false }, + }); + } +} + +window.GeoBreakdown = GeoBreakdown; diff --git a/src/js/techreport/section.js b/src/js/techreport/section.js index fa03011f..b6103657 100644 --- a/src/js/techreport/section.js +++ b/src/js/techreport/section.js @@ -1,4 +1,4 @@ -/* global Timeseries */ +/* global Timeseries, GeoBreakdown */ import SummaryCard from "./summaryCards"; import TableLinked from "./tableLinked"; @@ -33,6 +33,10 @@ class Section { this.initializeTable(component); break; + case "geoBreakdown": + this.initializeGeoBreakdown(component); + break; + default: break; } @@ -69,6 +73,16 @@ class Section { ); } + initializeGeoBreakdown(component) { + this.components[component.dataset.id] = new GeoBreakdown( + 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/templates/techreport/components/geo_breakdown.html b/templates/techreport/components/geo_breakdown.html new file mode 100644 index 00000000..58baec6f --- /dev/null +++ b/templates/techreport/components/geo_breakdown.html @@ -0,0 +1,28 @@ +{% set geo_breakdown_config = tech_report_page.config.geo_breakdown %} + +
+
+
+

{{ geo_breakdown_config.title }}

+

{{ geo_breakdown_config.description }}

+
+ +
+ +
+
+ + +
+
diff --git a/templates/techreport/drilldown.html b/templates/techreport/drilldown.html index b2dc2063..177ae1d0 100644 --- a/templates/techreport/drilldown.html +++ b/templates/techreport/drilldown.html @@ -70,6 +70,23 @@

{{ tech_report_page.config.good_cwv_summary.title }}

+ + {% if tech_report_page.config.geo_breakdown %} +
+

Geographic Breakdown

+

Origins and good Core Web Vitals by geography.

+ +
+ {% include "techreport/components/geo_breakdown.html" %} +
+
+ {% endif %} +
Accessibility + diff --git a/webpack.config.js b/webpack.config.js index 74d23eb8..d1fcc493 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -15,6 +15,7 @@ module.exports = { 'techreport': './src/js/techreport/index.js', 'techreport/timeseries': './src/js/techreport/timeseries.js', 'techreport/section': './src/js/techreport/section.js', + 'techreport/geoBreakdown': './src/js/techreport/geoBreakdown.js', }, output: { path: path.resolve(__dirname, 'static/js'), From 14179faafe2620af0237ad5f958cd0f920665ad0 Mon Sep 17 00:00:00 2001 From: Alon Kochba Date: Thu, 12 Mar 2026 13:47:04 +0200 Subject: [PATCH 2/2] fix: drop start=latest from geo-breakdown API call The endpoint now defaults to the latest month; no param needed. --- src/js/techreport/geoBreakdown.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/techreport/geoBreakdown.js b/src/js/techreport/geoBreakdown.js index 03d9c40c..76a72302 100644 --- a/src/js/techreport/geoBreakdown.js +++ b/src/js/techreport/geoBreakdown.js @@ -29,7 +29,7 @@ class GeoBreakdown { fetchData() { const technology = this.pageFilters.app.map(encodeURIComponent).join(','); const rank = encodeURIComponent(this.pageFilters.rank || 'ALL'); - const url = `${Constants.apiBase}/geo-breakdown?technology=${technology}&rank=${rank}&start=latest`; + const url = `${Constants.apiBase}/geo-breakdown?technology=${technology}&rank=${rank}`; fetch(url) .then(r => r.json())