Skip to content

Commit dbac947

Browse files
committed
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) - Latest data timestamp populated from API response
1 parent df3b08f commit dbac947

File tree

8 files changed

+374
-1
lines changed

8 files changed

+374
-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": "Geographic Breakdown",
728+
"description": "Top geographies by number of origins, showing the percentage with good Core Web Vitals and individual LCP, INP, and CLS scores.",
729+
"metric_options": [
730+
{ "label": "Overall CWVs", "value": "overall" },
731+
{ "label": "LCP", "value": "LCP" },
732+
{ "label": "INP", "value": "INP" },
733+
{ "label": "CLS", "value": "CLS" },
734+
{ "label": "FCP", "value": "FCP" },
735+
{ "label": "TTFB", "value": "TTFB" }
736+
]
724737
}
725738
}
726739
},

src/js/techreport/geoBreakdown.js

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