Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions config/techreport.json
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,19 @@
"title": "Weight in bytes"
}
}
},
"geo_breakdown": {
"id": "geo_breakdown",
"title": "Core Web Vitals geographic breakdown",
"description": "Top geographies by number of origins, showing the percentage with good Core Web Vitals and individual metrics.",
"metric_options": [
{ "label": "Good Core Web Vitals", "value": "overall" },
{ "label": "Good LCP", "value": "LCP" },
{ "label": "Good INP", "value": "INP" },
{ "label": "Good CLS", "value": "CLS" },
{ "label": "Good FCP", "value": "FCP" },
{ "label": "Good TTFB", "value": "TTFB" }
]
}
}
},
Expand Down
207 changes: 207 additions & 0 deletions src/js/techreport/geoBreakdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { Constants } from './utils/constants';
import { UIUtils } from './utils/ui';

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.sortColumn = 'total';
this.sortDir = 'desc';
this.showAll = false;

this.bindEventListeners();
this.fetchData();
}

bindEventListeners() {
const root = `[data-id="${this.id}"]`;
document.querySelectorAll(`${root} .geo-metric-selector`).forEach(dropdown => {
dropdown.addEventListener('change', event => {
this.selectedMetric = event.target.value;
if (this.geoData) this.renderTable();
});
});
}

fetchData() {
const technology = this.pageFilters.app.map(encodeURIComponent).join(',');
const rank = encodeURIComponent(this.pageFilters.rank || 'ALL');
const end = this.pageFilters.end ? `&end=${encodeURIComponent(this.pageFilters.end)}` : '';
const url = `${Constants.apiBase}/geo-breakdown?technology=${technology}&rank=${rank}${end}`;

fetch(url)
.then(r => r.json())
.then(rows => {
this.geoData = rows;
this.renderTable();
})
.catch(err => console.error('GeoBreakdown fetch error:', err));
}

updateContent() {
if (this.geoData) this.renderTable();
}

sortEntries(entries) {
const col = this.sortColumn;
const dir = this.sortDir === 'desc' ? -1 : 1;
return entries.slice().sort((a, b) => {
let av, bv;
if (col === 'total') { av = a.total; bv = b.total; }
else if (col === 'good_pct') { av = a.good / a.total; bv = b.good / b.total; }
else { av = a[col] != null ? a[col] : -1; bv = b[col] != null ? b[col] : -1; }
return (av - bv) * dir;
});
}

renderTable() {
if (!this.geoData || !this.geoData.length || this.geoData.length === 0) return;

const component = document.querySelector(`[data-id="${this.id}"]`);
const client = component?.dataset?.client || 'mobile';

// Determine bar label and which extra columns to show
const metric = this.selectedMetric;
const barLabel = metric === 'overall' ? 'Good Core Web Vitals' : `Good ${metric}`;
const extraCols = ['LCP', 'FCP', 'TTFB'].includes(metric)
? [{ key: 'ttfb', label: 'TTFB' }, { key: 'fcp', label: 'FCP' }, { key: 'lcp', label: 'LCP' }].filter(c => c.key !== metric.toLowerCase())
: metric === 'overall'
? [{ key: 'lcp', label: 'LCP' }, { key: 'inp', label: 'INP' }, { key: 'cls', label: 'CLS' }]
: []; // INP/CLS: no extra columns

// If current sort column is no longer visible, reset to total
const extraKeys = extraCols.map(c => c.key);
if (['lcp', 'inp', 'cls', 'fcp', 'ttfb'].includes(this.sortColumn) && !extraKeys.includes(this.sortColumn)) {
this.sortColumn = 'total';
this.sortDir = 'desc';
}

// Pick latest date per geo, excluding "ALL"
const geoMap = {};
let latestDate = null;
this.geoData.forEach(row => {
if (!geoMap[row.geo] || row.date > geoMap[row.geo].date) geoMap[row.geo] = row;
if (!latestDate || row.date > latestDate) latestDate = row.date;
});

// Fill timestamp slot
const tsSlot = component?.querySelector('[data-slot="geo-timestamp"]');
if (tsSlot && latestDate) tsSlot.textContent = UIUtils.printMonthYear(latestDate);

// Extract data per geo
const rawEntries = Object.entries(geoMap).map(([geo, row]) => {
const vital = row.vitals?.find(v => v.name === metric);
const d = vital?.[client] || { good_number: 0, tested: 0 };
const pctFor = name => {
const v = row.vitals?.find(x => x.name === name)?.[client];
return v?.tested > 0 ? Math.round(v.good_number / v.tested * 100) : null;
};
return { geo, good: d.good_number, total: d.tested, lcp: pctFor('LCP'), inp: pctFor('INP'), cls: pctFor('CLS'), fcp: pctFor('FCP'), ttfb: pctFor('TTFB') };
}).filter(e => e.total > 0);

// Sort by origins, limit to top 10 unless showAll
const sorted = rawEntries.slice().sort((a, b) => b.total - a.total);
const limited = this.showAll ? sorted : sorted.slice(0, 10);
const entries = this.sortEntries(limited);

const container = document.getElementById(`${this.id}-table`);
if (!container) return;

const table = document.createElement('table');
table.className = 'table-ui geo-breakdown-table';

// Header with sortable columns
const cols = [
{ key: 'geo', label: 'Geography', cls: '' },
{ key: 'total', label: 'Origins', cls: 'geo-origins-col' },
{ key: 'good_pct', label: barLabel, cls: 'geo-cwv-col' },
...extraCols.map(c => ({ ...c, cls: 'geo-vital-col' })),
];

const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
cols.forEach(col => {
const th = document.createElement('th');
th.className = [col.cls, 'geo-sortable-col'].filter(Boolean).join(' ');
const isActive = this.sortColumn === col.key;
th.innerHTML = col.label + (isActive ? `<span class="geo-sort-arrow">${this.sortDir === 'desc' ? ' ▼' : ' ▲'}</span>` : '');
if (col.key !== 'geo') {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
this.sortColumn = col.key;
this.sortDir = this.sortColumn === col.key && this.sortDir === 'desc' ? 'asc' : 'desc';
this.renderTable();
});
}
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);

// Body
const tbody = document.createElement('tbody');
entries.forEach(e => {
const goodPct = Math.round(e.good / e.total * 100);
const tr = document.createElement('tr');

const tdGeo = document.createElement('td');
tdGeo.className = 'geo-name-cell';
tdGeo.textContent = e.geo;
tdGeo.title = e.geo;
tr.appendChild(tdGeo);

const tdOrigins = document.createElement('td');
tdOrigins.className = 'geo-origins-col numeric';
tdOrigins.textContent = e.total.toLocaleString();
tr.appendChild(tdOrigins);

const tdBar = document.createElement('td');
tdBar.className = 'cwv-cell geo-pct-cell geo-cwv-col';
tdBar.style.setProperty('--good-stop', goodPct + '%');
tdBar.style.setProperty('--bar-total', '1');
tdBar.dataset.value = goodPct;
const label = document.createElement('span');
label.className = 'geo-pct-label';
label.textContent = goodPct + '%';
tdBar.appendChild(label);
const bar = document.createElement('span');
bar.className = 'geo-bar';
bar.setAttribute('aria-hidden', 'true');
tdBar.appendChild(bar);
tr.appendChild(tdBar);

extraCols.forEach(({ key }) => {
const td = document.createElement('td');
td.className = 'geo-vital-col numeric';
td.textContent = e[key] != null ? e[key] + '%' : '—';
tr.appendChild(td);
});

tbody.appendChild(tr);
});

table.appendChild(tbody);
const wrapper = document.createElement('div');
wrapper.className = 'table-ui-wrapper';
wrapper.appendChild(table);
const btn = document.createElement('button');
btn.className = 'btn show-table';
btn.type = 'button';
btn.textContent = this.showAll ? 'Show fewer' : `Show all ${sorted.length} geographies`;
btn.addEventListener('click', () => {
this.showAll = !this.showAll;
this.renderTable();
});

container.innerHTML = '';
container.appendChild(wrapper);
container.appendChild(btn);
}
}

window.GeoBreakdown = GeoBreakdown;
16 changes: 15 additions & 1 deletion src/js/techreport/section.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* global Timeseries */
/* global Timeseries, GeoBreakdown */

import SummaryCard from "./summaryCards";
import TableLinked from "./tableLinked";
Expand Down Expand Up @@ -33,6 +33,10 @@ class Section {
this.initializeTable(component);
break;

case "geoBreakdown":
this.initializeGeoBreakdown(component);
break;

default:
break;
}
Expand Down Expand Up @@ -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) {
Expand Down
83 changes: 83 additions & 0 deletions static/css/techreport/techreport.css
Original file line number Diff line number Diff line change
Expand Up @@ -1499,6 +1499,85 @@ select {
line-height: 1.25em;
}

/* Geographic Breakdown */

.geo-breakdown-controls {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 0.5rem;
margin-top: 1rem;
margin-bottom: 0.5rem;
}

.geo-breakdown-meta .heading {
margin: 0 0 0.25rem;
}


.geo-sort-arrow {
font-size: 0.7em;
opacity: 0.7;
}

.geo-sortable-col:hover {
background: var(--table-row-hover);
cursor: pointer;
}


/* Geo breakdown table bar cell */
.table-ui.geo-breakdown-table td.geo-name-cell {
max-width: 14rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.table-ui.geo-breakdown-table th.geo-vital-col,
.table-ui.geo-breakdown-table td.geo-vital-col {
text-align: right;
padding-right: 1rem;
white-space: nowrap;
color: var(--color-text-lighter);
font-size: 0.875rem;
}

.table-ui.geo-breakdown-table th.geo-origins-col,
.table-ui.geo-breakdown-table td.geo-origins-col {
text-align: right;
padding-right: 1.5rem;
white-space: nowrap;
}

.table-ui.geo-breakdown-table td.geo-pct-cell {
min-width: 16rem;
white-space: nowrap;
}

.table-ui.geo-breakdown-table td.geo-pct-cell .geo-pct-label {
display: inline-block;
width: 3rem;
font-weight: 600;
font-size: 0.875rem;
vertical-align: middle;
}

.table-ui.geo-breakdown-table td.geo-pct-cell .geo-bar {
display: inline-block;
height: 0.5rem;
vertical-align: middle;
width: calc(var(--bar-total, 1) * (100% - 3.5rem));
border: 1px solid var(--color-progress-basic-border);
border-radius: 3px;
background-image: linear-gradient(
90deg,
var(--color-progress-basic-fill) 0% var(--good-stop, 0%),
var(--color-progress-basic-bg) var(--good-stop, 0%) 100%
);
}

/* -------------------- */
/* ----- Sections ----- */
/* -------------------- */
Expand Down Expand Up @@ -2209,6 +2288,10 @@ path.highcharts-tick {
display: none;
}

.table-ui.geo-breakdown-table td.geo-pct-cell {
min-width: 8rem;
}

.card {
padding: 0.75rem;
}
Expand Down
Loading
Loading