Skip to content

Commit bfc5c1d

Browse files
feat: add geographic CWV breakdown chart to tech report drilldown (#1220)
* feat: add geographic CWV breakdown table to tech report drilldown Adds a Geographic Breakdown section inside the Core Web Vitals card on the tech report drilldown page. Shows top geographies (plus ALL) by origin count with a Good CWV progress bar and sortable columns. - Bar label and extra columns adapt to selected metric: Overall → Good Core Web Vitals + LCP/INP/CLS columns LCP/FCP/TTFB → Good <metric> bar + the other two load metrics INP/CLS → Good <metric> bar only - Metric selector styled to match existing subcategory-selector pattern - Show all / Show fewer toggle below the table - Table wrapped in table-ui-wrapper for mobile horizontal scroll - Dark mode hover contrast fixed using --table-row-hover variable - Guards against non-array API responses (e.g. 404 error objects) - Respects end date filter; latest data timestamp reflects selected date * Apply suggestions from code review Co-authored-by: Barry Pollard <barrypollard@google.com> --------- Co-authored-by: Barry Pollard <barrypollard@google.com>
1 parent df3b08f commit bfc5c1d

File tree

8 files changed

+375
-1
lines changed

8 files changed

+375
-1
lines changed

config/techreport.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,19 @@
721721
"title": "Weight in bytes"
722722
}
723723
}
724+
},
725+
"geo_breakdown": {
726+
"id": "geo_breakdown",
727+
"title": "Core Web Vitals geographic breakdown",
728+
"description": "Top geographies by number of origins, showing the percentage with good Core Web Vitals and individual metrics.",
729+
"metric_options": [
730+
{ "label": "Good Core Web Vitals", "value": "overall" },
731+
{ "label": "Good LCP", "value": "LCP" },
732+
{ "label": "Good INP", "value": "INP" },
733+
{ "label": "Good CLS", "value": "CLS" },
734+
{ "label": "Good FCP", "value": "FCP" },
735+
{ "label": "Good TTFB", "value": "TTFB" }
736+
]
724737
}
725738
}
726739
},

src/js/techreport/geoBreakdown.js

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { Constants } from './utils/constants';
2+
import { UIUtils } from './utils/ui';
3+
4+
class GeoBreakdown {
5+
constructor(id, pageConfig, config, filters, data) {
6+
this.id = id;
7+
this.pageConfig = pageConfig;
8+
this.config = config;
9+
this.pageFilters = filters;
10+
this.data = data;
11+
this.geoData = null;
12+
this.selectedMetric = 'overall';
13+
this.sortColumn = 'total';
14+
this.sortDir = 'desc';
15+
this.showAll = false;
16+
17+
this.bindEventListeners();
18+
this.fetchData();
19+
}
20+
21+
bindEventListeners() {
22+
const root = `[data-id="${this.id}"]`;
23+
document.querySelectorAll(`${root} .geo-metric-selector`).forEach(dropdown => {
24+
dropdown.addEventListener('change', event => {
25+
this.selectedMetric = event.target.value;
26+
if (this.geoData) this.renderTable();
27+
});
28+
});
29+
}
30+
31+
fetchData() {
32+
const technology = this.pageFilters.app.map(encodeURIComponent).join(',');
33+
const rank = encodeURIComponent(this.pageFilters.rank || 'ALL');
34+
const end = this.pageFilters.end ? `&end=${encodeURIComponent(this.pageFilters.end)}` : '';
35+
const url = `${Constants.apiBase}/geo-breakdown?technology=${technology}&rank=${rank}${end}`;
36+
37+
fetch(url)
38+
.then(r => r.json())
39+
.then(rows => {
40+
this.geoData = rows;
41+
this.renderTable();
42+
})
43+
.catch(err => console.error('GeoBreakdown fetch error:', err));
44+
}
45+
46+
updateContent() {
47+
if (this.geoData) this.renderTable();
48+
}
49+
50+
sortEntries(entries) {
51+
const col = this.sortColumn;
52+
const dir = this.sortDir === 'desc' ? -1 : 1;
53+
return entries.slice().sort((a, b) => {
54+
let av, bv;
55+
if (col === 'total') { av = a.total; bv = b.total; }
56+
else if (col === 'good_pct') { av = a.good / a.total; bv = b.good / b.total; }
57+
else { av = a[col] != null ? a[col] : -1; bv = b[col] != null ? b[col] : -1; }
58+
return (av - bv) * dir;
59+
});
60+
}
61+
62+
renderTable() {
63+
if (!this.geoData || !this.geoData.length || this.geoData.length === 0) return;
64+
65+
const component = document.querySelector(`[data-id="${this.id}"]`);
66+
const client = component?.dataset?.client || 'mobile';
67+
68+
// Determine bar label and which extra columns to show
69+
const metric = this.selectedMetric;
70+
const barLabel = metric === 'overall' ? 'Good Core Web Vitals' : `Good ${metric}`;
71+
const extraCols = ['LCP', 'FCP', 'TTFB'].includes(metric)
72+
? [{ key: 'ttfb', label: 'TTFB' }, { key: 'fcp', label: 'FCP' }, { key: 'lcp', label: 'LCP' }].filter(c => c.key !== metric.toLowerCase())
73+
: metric === 'overall'
74+
? [{ key: 'lcp', label: 'LCP' }, { key: 'inp', label: 'INP' }, { key: 'cls', label: 'CLS' }]
75+
: []; // INP/CLS: no extra columns
76+
77+
// If current sort column is no longer visible, reset to total
78+
const extraKeys = extraCols.map(c => c.key);
79+
if (['lcp', 'inp', 'cls', 'fcp', 'ttfb'].includes(this.sortColumn) && !extraKeys.includes(this.sortColumn)) {
80+
this.sortColumn = 'total';
81+
this.sortDir = 'desc';
82+
}
83+
84+
// Pick latest date per geo, excluding "ALL"
85+
const geoMap = {};
86+
let latestDate = null;
87+
this.geoData.forEach(row => {
88+
if (!geoMap[row.geo] || row.date > geoMap[row.geo].date) geoMap[row.geo] = row;
89+
if (!latestDate || row.date > latestDate) latestDate = row.date;
90+
});
91+
92+
// Fill timestamp slot
93+
const tsSlot = component?.querySelector('[data-slot="geo-timestamp"]');
94+
if (tsSlot && latestDate) tsSlot.textContent = UIUtils.printMonthYear(latestDate);
95+
96+
// Extract data per geo
97+
const rawEntries = Object.entries(geoMap).map(([geo, row]) => {
98+
const vital = row.vitals?.find(v => v.name === metric);
99+
const d = vital?.[client] || { good_number: 0, tested: 0 };
100+
const pctFor = name => {
101+
const v = row.vitals?.find(x => x.name === name)?.[client];
102+
return v?.tested > 0 ? Math.round(v.good_number / v.tested * 100) : null;
103+
};
104+
return { geo, good: d.good_number, total: d.tested, lcp: pctFor('LCP'), inp: pctFor('INP'), cls: pctFor('CLS'), fcp: pctFor('FCP'), ttfb: pctFor('TTFB') };
105+
}).filter(e => e.total > 0);
106+
107+
// Sort by origins, limit to top 10 unless showAll
108+
const sorted = rawEntries.slice().sort((a, b) => b.total - a.total);
109+
const limited = this.showAll ? sorted : sorted.slice(0, 10);
110+
const entries = this.sortEntries(limited);
111+
112+
const container = document.getElementById(`${this.id}-table`);
113+
if (!container) return;
114+
115+
const table = document.createElement('table');
116+
table.className = 'table-ui geo-breakdown-table';
117+
118+
// Header with sortable columns
119+
const cols = [
120+
{ key: 'geo', label: 'Geography', cls: '' },
121+
{ key: 'total', label: 'Origins', cls: 'geo-origins-col' },
122+
{ key: 'good_pct', label: barLabel, cls: 'geo-cwv-col' },
123+
...extraCols.map(c => ({ ...c, cls: 'geo-vital-col' })),
124+
];
125+
126+
const thead = document.createElement('thead');
127+
const headerRow = document.createElement('tr');
128+
cols.forEach(col => {
129+
const th = document.createElement('th');
130+
th.className = [col.cls, 'geo-sortable-col'].filter(Boolean).join(' ');
131+
const isActive = this.sortColumn === col.key;
132+
th.innerHTML = col.label + (isActive ? `<span class="geo-sort-arrow">${this.sortDir === 'desc' ? ' ▼' : ' ▲'}</span>` : '');
133+
if (col.key !== 'geo') {
134+
th.style.cursor = 'pointer';
135+
th.addEventListener('click', () => {
136+
this.sortColumn = col.key;
137+
this.sortDir = this.sortColumn === col.key && this.sortDir === 'desc' ? 'asc' : 'desc';
138+
this.renderTable();
139+
});
140+
}
141+
headerRow.appendChild(th);
142+
});
143+
thead.appendChild(headerRow);
144+
table.appendChild(thead);
145+
146+
// Body
147+
const tbody = document.createElement('tbody');
148+
entries.forEach(e => {
149+
const goodPct = Math.round(e.good / e.total * 100);
150+
const tr = document.createElement('tr');
151+
152+
const tdGeo = document.createElement('td');
153+
tdGeo.className = 'geo-name-cell';
154+
tdGeo.textContent = e.geo;
155+
tdGeo.title = e.geo;
156+
tr.appendChild(tdGeo);
157+
158+
const tdOrigins = document.createElement('td');
159+
tdOrigins.className = 'geo-origins-col numeric';
160+
tdOrigins.textContent = e.total.toLocaleString();
161+
tr.appendChild(tdOrigins);
162+
163+
const tdBar = document.createElement('td');
164+
tdBar.className = 'cwv-cell geo-pct-cell geo-cwv-col';
165+
tdBar.style.setProperty('--good-stop', goodPct + '%');
166+
tdBar.style.setProperty('--bar-total', '1');
167+
tdBar.dataset.value = goodPct;
168+
const label = document.createElement('span');
169+
label.className = 'geo-pct-label';
170+
label.textContent = goodPct + '%';
171+
tdBar.appendChild(label);
172+
const bar = document.createElement('span');
173+
bar.className = 'geo-bar';
174+
bar.setAttribute('aria-hidden', 'true');
175+
tdBar.appendChild(bar);
176+
tr.appendChild(tdBar);
177+
178+
extraCols.forEach(({ key }) => {
179+
const td = document.createElement('td');
180+
td.className = 'geo-vital-col numeric';
181+
td.textContent = e[key] != null ? e[key] + '%' : '—';
182+
tr.appendChild(td);
183+
});
184+
185+
tbody.appendChild(tr);
186+
});
187+
188+
table.appendChild(tbody);
189+
const wrapper = document.createElement('div');
190+
wrapper.className = 'table-ui-wrapper';
191+
wrapper.appendChild(table);
192+
const btn = document.createElement('button');
193+
btn.className = 'btn show-table';
194+
btn.type = 'button';
195+
btn.textContent = this.showAll ? 'Show fewer' : `Show all ${sorted.length} geographies`;
196+
btn.addEventListener('click', () => {
197+
this.showAll = !this.showAll;
198+
this.renderTable();
199+
});
200+
201+
container.innerHTML = '';
202+
container.appendChild(wrapper);
203+
container.appendChild(btn);
204+
}
205+
}
206+
207+
window.GeoBreakdown = GeoBreakdown;

src/js/techreport/section.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* global Timeseries */
1+
/* global Timeseries, GeoBreakdown */
22

33
import SummaryCard from "./summaryCards";
44
import TableLinked from "./tableLinked";
@@ -33,6 +33,10 @@ class Section {
3333
this.initializeTable(component);
3434
break;
3535

36+
case "geoBreakdown":
37+
this.initializeGeoBreakdown(component);
38+
break;
39+
3640
default:
3741
break;
3842
}
@@ -69,6 +73,16 @@ class Section {
6973
);
7074
}
7175

76+
initializeGeoBreakdown(component) {
77+
this.components[component.dataset.id] = new GeoBreakdown(
78+
component.dataset.id,
79+
this.pageConfig,
80+
this.config,
81+
this.pageFilters,
82+
this.data
83+
);
84+
}
85+
7286
updateSection(content) {
7387
Object.values(this.components).forEach(component => {
7488
if(component.data !== this.data) {

static/css/techreport/techreport.css

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,6 +1499,85 @@ select {
14991499
line-height: 1.25em;
15001500
}
15011501

1502+
/* Geographic Breakdown */
1503+
1504+
.geo-breakdown-controls {
1505+
display: flex;
1506+
flex-wrap: wrap;
1507+
align-items: flex-end;
1508+
justify-content: space-between;
1509+
gap: 0.5rem;
1510+
margin-top: 1rem;
1511+
margin-bottom: 0.5rem;
1512+
}
1513+
1514+
.geo-breakdown-meta .heading {
1515+
margin: 0 0 0.25rem;
1516+
}
1517+
1518+
1519+
.geo-sort-arrow {
1520+
font-size: 0.7em;
1521+
opacity: 0.7;
1522+
}
1523+
1524+
.geo-sortable-col:hover {
1525+
background: var(--table-row-hover);
1526+
cursor: pointer;
1527+
}
1528+
1529+
1530+
/* Geo breakdown table bar cell */
1531+
.table-ui.geo-breakdown-table td.geo-name-cell {
1532+
max-width: 14rem;
1533+
overflow: hidden;
1534+
text-overflow: ellipsis;
1535+
white-space: nowrap;
1536+
}
1537+
1538+
.table-ui.geo-breakdown-table th.geo-vital-col,
1539+
.table-ui.geo-breakdown-table td.geo-vital-col {
1540+
text-align: right;
1541+
padding-right: 1rem;
1542+
white-space: nowrap;
1543+
color: var(--color-text-lighter);
1544+
font-size: 0.875rem;
1545+
}
1546+
1547+
.table-ui.geo-breakdown-table th.geo-origins-col,
1548+
.table-ui.geo-breakdown-table td.geo-origins-col {
1549+
text-align: right;
1550+
padding-right: 1.5rem;
1551+
white-space: nowrap;
1552+
}
1553+
1554+
.table-ui.geo-breakdown-table td.geo-pct-cell {
1555+
min-width: 16rem;
1556+
white-space: nowrap;
1557+
}
1558+
1559+
.table-ui.geo-breakdown-table td.geo-pct-cell .geo-pct-label {
1560+
display: inline-block;
1561+
width: 3rem;
1562+
font-weight: 600;
1563+
font-size: 0.875rem;
1564+
vertical-align: middle;
1565+
}
1566+
1567+
.table-ui.geo-breakdown-table td.geo-pct-cell .geo-bar {
1568+
display: inline-block;
1569+
height: 0.5rem;
1570+
vertical-align: middle;
1571+
width: calc(var(--bar-total, 1) * (100% - 3.5rem));
1572+
border: 1px solid var(--color-progress-basic-border);
1573+
border-radius: 3px;
1574+
background-image: linear-gradient(
1575+
90deg,
1576+
var(--color-progress-basic-fill) 0% var(--good-stop, 0%),
1577+
var(--color-progress-basic-bg) var(--good-stop, 0%) 100%
1578+
);
1579+
}
1580+
15021581
/* -------------------- */
15031582
/* ----- Sections ----- */
15041583
/* -------------------- */
@@ -2209,6 +2288,10 @@ path.highcharts-tick {
22092288
display: none;
22102289
}
22112290

2291+
.table-ui.geo-breakdown-table td.geo-pct-cell {
2292+
min-width: 8rem;
2293+
}
2294+
22122295
.card {
22132296
padding: 0.75rem;
22142297
}

0 commit comments

Comments
 (0)