Skip to content

Commit 05e140a

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 10 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 - Table wrapped in table-ui-wrapper for mobile horizontal scroll - Latest data timestamp populated from API response
1 parent df3b08f commit 05e140a

8 files changed

Lines changed: 361 additions & 1 deletion

File tree

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

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

2290+
.table-ui.geo-breakdown-table td.geo-pct-cell {
2291+
min-width: 8rem;
2292+
}
2293+
22122294
.card {
22132295
padding: 0.75rem;
22142296
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{% set geo_breakdown_config = tech_report_page.config.geo_breakdown %}
2+
3+
<div
4+
id="section-{{ geo_breakdown_config.id }}"
5+
data-id="{{ geo_breakdown_config.id }}"
6+
data-component="geoBreakdown"
7+
data-client="{{ request.args.get('client', '') or 'mobile' }}"
8+
>
9+
<div class="component-heading-wrapper">
10+
<div class="component-heading">
11+
<h3><a href="#section-{{ geo_breakdown_config.id }}" class="anchor">{{ geo_breakdown_config.title }}</a></h3>
12+
<p class="descr">{{ geo_breakdown_config.description }}</p>
13+
</div>
14+
15+
<div class="component-filters">
16+
<div class="subcategory-selector-wrapper">
17+
<div class="position-wrapper">
18+
<label for="geo-metric-selector-{{ geo_breakdown_config.id }}">Metric</label>
19+
<select
20+
class="geo-metric-selector"
21+
id="geo-metric-selector-{{ geo_breakdown_config.id }}"
22+
>
23+
{% for option in geo_breakdown_config.metric_options %}
24+
{% if option.value == 'overall' %}
25+
<option value="{{ option.value }}" selected>{{ option.label }}</option>
26+
{% else %}
27+
<option value="{{ option.value }}">{{ option.label }}</option>
28+
{% endif %}
29+
{% endfor %}
30+
</select>
31+
</div>
32+
</div>
33+
</div>
34+
</div>
35+
36+
<div class="geo-breakdown-controls">
37+
<div class="geo-breakdown-meta">
38+
<h4 class="heading">Latest data: <span data-slot="geo-timestamp"></span></h4>
39+
<ul class="meta">
40+
<li>Client: <span data-slot="client">{{ (request.args.get('client', '') or 'Mobile') | capitalize }}</span></li>
41+
<li>Rank: <span data-slot="rank">{{ request.args.get('rank', '') or 'ALL' }}</span></li>
42+
{% set tech = request.args.get('tech', '') or 'ALL' %}
43+
<li>Technology: <span data-slot="tech">{{ tech }}</span></li>
44+
</ul>
45+
</div>
46+
</div>
47+
48+
<div id="{{ geo_breakdown_config.id }}-table"></div>
49+
</div>

0 commit comments

Comments
 (0)