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..76a72302 --- /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}`; + + 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'),