diff --git a/package-lock.json b/package-lock.json
index e3fc6db9..5289806d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,9 @@
"name": "httparchive.org",
"version": "0.0.1",
"license": "Apache-2.0",
+ "dependencies": {
+ "echarts": "^6.1.0"
+ },
"devDependencies": {
"@astrojs/sitemap": "^3.7.4",
"@astrojs/starlight": "^0.42.4",
@@ -6650,6 +6653,22 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/echarts": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
+ "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "2.3.0",
+ "zrender": "6.1.0"
+ }
+ },
+ "node_modules/echarts/node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+ "license": "0BSD"
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -17074,6 +17093,21 @@
"zod": "^3.25.28 || ^4"
}
},
+ "node_modules/zrender": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
+ "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tslib": "2.3.0"
+ }
+ },
+ "node_modules/zrender/node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+ "license": "0BSD"
+ },
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
diff --git a/package.json b/package.json
index 72b50f41..be5e29d0 100644
--- a/package.json
+++ b/package.json
@@ -50,5 +50,8 @@
},
"engines": {
"node": ">=26.0.0"
+ },
+ "dependencies": {
+ "echarts": "^6.1.0"
}
}
diff --git a/src/js/histogram.js b/src/js/histogram.js
index 7462e0e6..dc9fc43d 100644
--- a/src/js/histogram.js
+++ b/src/js/histogram.js
@@ -1,18 +1,17 @@
-/* global Highcharts */
-
+import * as echarts from 'echarts';
import { Colors } from './colors';
import debounce from './debounce';
import { Metric } from './metric';
-import { el, prettyDate, chartExportOptions, drawMetricSummary, callOnceWhenVisible } from './utils';
+import { el, prettyDate, drawMetricSummary, callOnceWhenVisible, getQueryUrl } from './utils';
import { Constants } from './techreport/utils/constants.js';
-
-const [COLOR_DESKTOP, COLOR_MOBILE, COLOR_DESKTOP_ALT, COLOR_MOBILE_ALT] = Colors.getAll({rgba: true});
+const [COLOR_DESKTOP, COLOR_MOBILE] = Colors.getAll({ rgba: true });
function histogram(metric, date, options) {
options.date = date;
options.metric = metric;
const dataUrl = `${Constants.apiBase}/static/reports/${options.lens ? `${options.lens.id}/` : ''}${date}/${metric}.json`;
+
fetch(dataUrl)
.then(response => {
if (!response.ok) {
@@ -24,18 +23,18 @@ function histogram(metric, date, options) {
.then(jsonStr => JSON.parse(jsonStr))
.then(data => {
drawHistogram(data, `${metric}-chart`, options);
- drawHistogramTable(data, `${metric}-table-desktop`, `${metric}-table-mobile`, options.type);
- }).catch(e => {
+ drawHistogramTable(data, `${metric}-table`, options.type);
+ })
+ .catch(e => {
const chart = document.getElementById(`${metric}-chart`);
- chart.textContent = `Error loading data: ${e}. Try a more recent start date.`;
+ if (chart) {
+ chart.textContent = `Error loading data: ${e}. Try a more recent start date.`;
+ }
});
}
function drawClientSummary(data, options, client) {
- if (!data.length) {
- return;
- }
-
+ if (!data.length) return;
const value = getSummary(data, options);
drawMetricSummary(options, client, value);
}
@@ -43,16 +42,13 @@ function drawClientSummary(data, options, client) {
function getSummary(data, options) {
const summary = getPrimaryMetric(data);
const metric = new Metric(options, summary);
-
return metric.toString();
}
function getPrimaryMetric(data) {
- data = data.filter(o => +o.cdf > 0.5);
- if (!data.length) {
- return '?';
- }
- return data[0].bin;
+ const filtered = data.filter(o => +o.cdf > 0.5);
+ if (!filtered.length) return '?';
+ return filtered[0].bin;
}
class Bin {
@@ -65,11 +61,11 @@ class Bin {
}
toPoint() {
- return [this.bin, this.pdf * 100];
+ return [this.bin, Math.round(this.pdf * 10000) / 100];
}
toCdfPoint() {
- return [this.bin, this.cdf * 100];
+ return [this.bin, Math.round(this.cdf * 10000) / 100];
}
toRow(maxPdf) {
@@ -78,46 +74,27 @@ class Bin {
const td = document.createElement('td');
if (col === 'volume') {
const bar = document.createElement('div');
- bar.style.width = (this.pdf * 100 / maxPdf) + '%';
+ bar.style.width = `${(this.pdf * 100) / maxPdf}%`;
bar.classList.add(this.client);
td.appendChild(bar);
const text = document.createElement('span');
text.textContent = this.format(col);
td.appendChild(text);
- }
- else {
+ } else {
td.textContent = this.format(col);
}
td.classList.add(col);
tr.appendChild(td);
- })
- return tr;
- }
-
- add(bin) {
- this.volume += bin.volume;
- this.pdf += bin.pdf;
- this.cdf = Math.max(this.cdf, bin.cdf);
- }
-
- clone() {
- return new Bin({
- client: this.client,
- bin: this.bin,
- volume: this.volume,
- pdf: this.pdf,
- cdf: this.cdf
});
+ return tr;
}
format(property) {
- switch(property) {
+ switch (property) {
case 'pdf':
case 'cdf': {
let value = (Math.floor(this[property] * 10000) / 100).toFixed(2);
- if (value < 10) {
- value = '0' + value;
- }
+ if (value < 10) value = '0' + value;
return value + '%';
}
case 'volume':
@@ -137,277 +114,611 @@ class HistogramTable {
this.table = document.getElementById(id);
this.bins = bins;
this.type = type;
- this.schema = bins[0].getSchema();
- this.maxPdf = Math.max.apply(null, this.bins.map(bin => bin.pdf));
+ this.maxPdf = Math.max.apply(null, this.bins.map(bin => bin.pdf)) || 1;
}
draw() {
+ if (!this.table) return;
Array.from(this.table.children).forEach(child => this.table.removeChild(child));
const thead = el('thead');
const headerRow = el('tr');
- this.schema.forEach(col => {
+ ['Bin (' + (this.type || 'Value') + ')', 'Client', 'Volume', 'PDF', 'CDF'].forEach(col => {
const th = el('th');
- th.textContent = col === 'bin' ? this.type : col;
+ th.textContent = col;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
this.table.appendChild(thead);
const tbody = el('tbody');
- this.bins.forEach(bin => {
- tbody.appendChild(bin.toRow(this.maxPdf));
+ const binMap = new Map();
+ this.bins.forEach(b => {
+ if (!binMap.has(b.bin)) binMap.set(b.bin, []);
+ binMap.get(b.bin).push(b);
+ });
+
+ const sortedBins = Array.from(binMap.keys()).sort((a, b) => a - b);
+ sortedBins.forEach(binVal => {
+ const clientBins = binMap.get(binVal);
+ clientBins.sort(a => (a.client === 'desktop' ? -1 : 1));
+ clientBins.forEach((b, idx) => {
+ const tr = el('tr');
+ if (idx === 0) {
+ const tdBin = el('td');
+ if (clientBins.length > 1) {
+ tdBin.setAttribute('rowspan', clientBins.length.toString());
+ }
+ tdBin.textContent = b.format('bin');
+ tdBin.classList.add('bin');
+ tr.appendChild(tdBin);
+ }
+
+ const tdClient = el('td');
+ tdClient.textContent = b.client ? b.client.charAt(0).toUpperCase() + b.client.slice(1) : '';
+ tdClient.classList.add('client');
+ tr.appendChild(tdClient);
+
+ const tdVol = el('td');
+ tdVol.classList.add('volume');
+ const bar = el('div');
+ bar.style.width = `${(b.pdf * 100) / this.maxPdf}%`;
+ bar.classList.add(b.client);
+ tdVol.appendChild(bar);
+ const text = el('span');
+ text.textContent = b.format('volume');
+ tdVol.appendChild(text);
+ tr.appendChild(tdVol);
+
+ const tdPdf = el('td');
+ tdPdf.classList.add('pdf');
+ tdPdf.textContent = b.format('pdf');
+ tr.appendChild(tdPdf);
+
+ const tdCdf = el('td');
+ tdCdf.classList.add('cdf');
+ tdCdf.textContent = b.format('cdf');
+ tr.appendChild(tdCdf);
+
+ tbody.appendChild(tr);
+ });
});
this.table.appendChild(tbody);
}
}
-
let redrawHistogramTable = null;
-function drawHistogramTable(data, desktopId, mobileId, type, [start, end]=[-Infinity, Infinity]) {
+function drawHistogramTable(data, tableId, type, [start, end] = [-Infinity, Infinity]) {
if (!redrawHistogramTable) {
- // Return a curried function to redraw the table given start/end bins.
- redrawHistogramTable = debounce((dateRange) => {
- return drawHistogramTable(data, desktopId, mobileId, type, dateRange);
+ redrawHistogramTable = debounce((tblId, typ, range) => {
+ return drawHistogramTable(data, tblId, typ, range);
}, 100);
}
- const bins = data.filter(data => {
- return data.bin >= start && data.bin <= end
- }).map(data => new Bin(data));
+ const bins = data
+ .filter(d => d.bin >= start && d.bin <= end)
+ .map(d => new Bin(d));
- const desktop = bins.filter(data => data.client === 'desktop');
- const mobile = bins.filter(data => data.client === 'mobile');
-
- if (desktop.length) {
- (new HistogramTable(desktopId, desktop, type)).draw();
- }
- if (mobile.length) {
- (new HistogramTable(mobileId, mobile, type)).draw();
- }
+ new HistogramTable(tableId, bins, type).draw();
}
function drawHistogram(data, containerId, options) {
- // Outliers must be at least this bin index, regardless of CDF.
- // This guarantees 30 bins worth of non-outlier data.
- // Important for histograms that are extremely skewed,
- // eg where the first bin is already 90+% PDF.
const OUTLIER_MIN = 30;
- data = data.map((data) => new Bin(data));
+ const parsedBins = data.map(d => new Bin(d));
- let desktop = data.filter(({client}) => client=='desktop');
- let mobile = data.filter(({client}) => client=='mobile');
+ let desktop = parsedBins.filter(({ client }) => client === 'desktop');
+ let mobile = parsedBins.filter(({ client }) => client === 'mobile');
- const max = Math.max(OUTLIER_MIN,
- desktop.filter(current => current.cdf < 0.95).length,
- mobile.filter(current => current.cdf < 0.95).length);
+ const max = Math.max(
+ OUTLIER_MIN,
+ desktop.filter(c => c.cdf < 0.95).length,
+ mobile.filter(c => c.cdf < 0.95).length
+ );
desktop = desktop.filter((_, i) => i < max);
mobile = mobile.filter((_, i) => i < max);
- let desktopCDF = desktop.map(data => data.toCdfPoint());
- let mobileCDF = mobile.map(data => data.toCdfPoint());
-
- // Draw summary metrics (derived median).
drawClientSummary(desktop, options, 'desktop');
drawClientSummary(mobile, options, 'mobile');
- const series = [];
+ const container = document.getElementById(containerId);
+ if (!container) return;
+
+ callOnceWhenVisible(container, () => {
+ renderEChartsHistogram(container, desktop, mobile, options, data);
+ });
+}
+
+function renderEChartsHistogram(container, desktop, mobile, options, rawData) {
+ container.innerHTML = '';
+
+ const chartTitle = `${options.lens ? `${options.lens.name}: ` : ''}Histogram of ${options.name}`;
+
+ // 1. Header Card Element
+ const header = document.createElement('div');
+ header.className = 'chart-header';
+ header.innerHTML = `
+
${chartTitle}
+ Source:
httparchive.org ${options.date ? ` (${prettyDate(options.date)})` : ''}
+
+ `;
+ container.appendChild(header);
+
+ // 2. Main Plot Host
+ const mainPlotEl = document.createElement('div');
+ mainPlotEl.className = 'chart-main-plot';
+ container.appendChild(mainPlotEl);
+
+ const chart = echarts.init(mainPlotEl, null, { renderer: 'svg' });
+
+ // Extract all unique bins sorted
+ const binSet = new Set();
+ desktop.forEach(b => binSet.add(b.bin));
+ mobile.forEach(b => binSet.add(b.bin));
+ const sortedBins = Array.from(binSet).sort((a, b) => a - b);
+
+ // Index maps
+ const desktopMap = new Map(desktop.map(b => [b.bin, b]));
+ const mobileMap = new Map(mobile.map(b => [b.bin, b]));
+
+ const seriesList = [];
+ const legendNames = [];
+ const benchmarkMarkLines = [];
+
if (desktop.length) {
- series.push({
- data: desktop.map((data) => data.toPoint()),
- pointPadding: 0,
- groupPadding: 0,
- pointPlacement: 'between',
+ legendNames.push('Desktop');
+ seriesList.push({
name: 'Desktop',
- color: COLOR_DESKTOP
+ type: 'bar',
+ data: sortedBins.map(bin => {
+ const b = desktopMap.get(bin);
+ return [bin, b ? Math.round(b.pdf * 10000) / 100 : 0];
+ }),
+ yAxisIndex: 0,
+ itemStyle: { color: COLOR_DESKTOP },
+ barCategoryGap: '20%',
+ barGap: '-100%'
});
- series.push({
- data: desktopCDF,
- type: 'line',
- marker: {
- enabled: false,
- states: {
- hover: {
- enabled: false
- }
- }
- },
+
+ seriesList.push({
name: 'Desktop CDF',
- color: COLOR_DESKTOP_ALT,
- yAxis: 1,
- showInLegend: false
+ type: 'line',
+ data: desktop.map(b => [b.bin, Math.round(b.cdf * 10000) / 100]).sort((a, b) => a[0] - b[0]),
+ yAxisIndex: 1,
+ itemStyle: { color: Colors.DESKTOP },
+ lineStyle: { color: Colors.DESKTOP, width: 2, type: 'solid' },
+ showSymbol: false,
+ symbol: 'none',
+ emphasis: { disabled: true }
});
}
+
if (mobile.length) {
- series.push({
- data: mobile.map((data) => data.toPoint()),
- pointPadding: 0,
- groupPadding: 0,
- pointPlacement: 'between',
+ legendNames.push('Mobile');
+ seriesList.push({
name: 'Mobile',
- color: COLOR_MOBILE
+ type: 'bar',
+ data: sortedBins.map(bin => {
+ const b = mobileMap.get(bin);
+ return [bin, b ? Math.round(b.pdf * 10000) / 100 : 0];
+ }),
+ yAxisIndex: 0,
+ itemStyle: { color: COLOR_MOBILE },
+ barCategoryGap: '20%',
+ barGap: '-100%'
});
- series.push({
- data: mobileCDF,
- type: 'line',
- marker: {
- enabled: false,
- states: {
- hover: {
- enabled: false
- }
- }
- },
+
+ seriesList.push({
name: 'Mobile CDF',
- color: COLOR_MOBILE_ALT,
- yAxis: 1,
- showInLegend: false
+ type: 'line',
+ data: mobile.map(b => [b.bin, Math.round(b.cdf * 10000) / 100]).sort((a, b) => a[0] - b[0]),
+ yAxisIndex: 1,
+ itemStyle: { color: Colors.MOBILE },
+ lineStyle: { color: Colors.MOBILE, width: 2, type: 'solid' },
+ showSymbol: false,
+ symbol: 'none',
+ emphasis: { disabled: true }
});
}
- const chart = document.getElementById(`${options.metric}-chart`);
- callOnceWhenVisible(chart, () => {
- drawChart(series, containerId, options);
- });
-}
-
-function drawChart(series, containerId, options) {
- const chart = Highcharts.chart(containerId, {
- metric: options.metric,
- type: 'histogram',
- date: options.date,
- chart: {
- type: 'column',
- zoomType: 'x',
- resetZoomButton: {
- position: {
- x: 0,
- y: -50
- }
+ const option = {
+ aria: {
+ enabled: true,
+ decal: { show: false }
+ },
+ animation: false,
+ grid: {
+ top: 25,
+ left: 60,
+ right: 60,
+ bottom: 62,
+ containLabel: false
+ },
+ legend: {
+ bottom: 0,
+ left: 'center',
+ data: legendNames,
+ icon: 'circle',
+ itemWidth: 8,
+ itemHeight: 8,
+ textStyle: {
+ color: '#374151',
+ fontSize: 12
+ }
+ },
+ tooltip: {
+ trigger: 'axis',
+ backgroundColor: 'rgba(255, 255, 255, 0.98)',
+ borderColor: '#e2e8f0',
+ borderWidth: 1,
+ padding: [8, 12],
+ extraCssText: 'box-shadow: 0 4px 16px rgba(0, 0, 0, 0.14); border-radius: 6px; backdrop-filter: blur(4px);',
+ axisPointer: {
+ type: 'shadow',
+ shadowStyle: { color: 'rgba(15, 23, 42, 0.05)' }
},
- zooming: {
- mouseWheel: {
- enabled: false
+ formatter: params => {
+ if (!params || !params.length) return '';
+ const binVal = params[0].value[0];
+ const metricObj = new Metric(options, Math.round(binVal * 100) / 100);
+
+ const dBin = desktopMap.get(binVal);
+ const mBin = mobileMap.get(binVal);
+
+ let html = ``;
+ return html;
}
},
- title: {
- text: `${options.lens ? `${options.lens.name}: ` : '' }` + `Histogram of ${options.name}`,
- style: {
- "font-weight": "normal"
+ dataZoom: [
+ {
+ type: 'inside',
+ xAxisIndex: 0,
+ filterMode: 'none',
+ zoomOnMouseWheel: false,
+ moveOnMouseMove: false
}
+ ],
+ xAxis: {
+ type: 'value',
+ name: options.type || 'Metric Value',
+ nameLocation: 'center',
+ nameGap: 30,
+ nameTextStyle: { color: '#64748b', fontSize: 12 },
+ axisLine: { lineStyle: { color: '#cbd5e1' } },
+ axisTick: { lineStyle: { color: '#cbd5e1' } },
+ axisLabel: {
+ color: '#64748b',
+ fontSize: 11,
+ formatter: d => (d >= 1000 ? `${(d / 1000).toFixed(0)}k` : d.toString())
+ },
+ splitLine: { show: true, lineStyle: { color: '#f1f5f9' } }
},
- subtitle: {
- text: `Source: httparchive.org (${prettyDate(options.date)})`,
- useHTML: true
- },
- plotOptions: {
- column: {
- grouping: false
+ yAxis: [
+ {
+ type: 'value',
+ name: 'Density',
+ nameLocation: 'center',
+ nameGap: 45,
+ nameTextStyle: { color: '#64748b', fontSize: 12 },
+ axisLabel: {
+ color: '#64748b',
+ fontSize: 11,
+ formatter: '{value}%'
+ },
+ splitLine: { show: true, lineStyle: { color: '#f1f5f9' } }
},
- series: {
- events: {
- // Keep CDF visibility in sync.
- hide: function() {
- const cdf = chart.series.find(s => s.name === `${this.name} CDF`);
- if (cdf) {
- cdf.hide();
- }
- },
- show: function() {
- const cdf = chart.series.find(s => s.name === `${this.name} CDF`);
- if (cdf) {
- cdf.show();
+ {
+ type: 'value',
+ name: 'Cumulative Density',
+ nameLocation: 'center',
+ nameRotate: -90,
+ nameGap: 45,
+ min: 0,
+ max: 100,
+ nameTextStyle: { color: '#64748b', fontSize: 12 },
+ axisLabel: {
+ color: '#64748b',
+ fontSize: 11,
+ formatter: '{value}%'
+ },
+ splitLine: { show: false }
+ }
+ ],
+ series: seriesList
+ };
+
+ chart.setOption(option);
+
+ // Reset zoom & selection zoom interactions
+ const resetZoomBtn = header.querySelector('.reset-zoom-btn');
+
+ const resetZoom = () => {
+ chart.dispatchAction({
+ type: 'dataZoom',
+ start: 0,
+ end: 100
+ });
+ resetZoomBtn?.classList.add('hidden');
+ if (rawData) {
+ drawHistogramTable(rawData, `${options.metric}-table`, options.type, [-Infinity, Infinity]);
+ }
+ };
+
+ resetZoomBtn?.addEventListener('click', resetZoom);
+
+ // Mouse drag selection zoom with visual background area
+ let dragStartX = null;
+ let selectionBox = null;
+ mainPlotEl.style.position = 'relative';
+
+ const onMouseDown = e => {
+ if (e.button !== 0) return;
+ const rect = mainPlotEl.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ if (x >= 40 && x <= rect.width - 40 && y >= 10 && y <= rect.height - 40) {
+ dragStartX = e.clientX;
+ chart.dispatchAction({ type: 'hideTip' });
+ e.preventDefault();
+ document.body.style.userSelect = 'none';
+ document.body.style.cursor = 'crosshair';
+ window.addEventListener('mousemove', onMouseMove, { capture: true });
+ window.addEventListener('mouseup', onMouseUp, { capture: true });
+ }
+ };
+
+ const onMouseMove = e => {
+ if (dragStartX === null) return;
+ const dx = e.clientX - dragStartX;
+ if (Math.abs(dx) > 3) {
+ e.stopPropagation();
+ if (!selectionBox) {
+ selectionBox = document.createElement('div');
+ selectionBox.className = 'chart-zoom-selection';
+ const rect = mainPlotEl.getBoundingClientRect();
+ const plotHeight = rect.height - 25 - 62;
+ selectionBox.style.height = `${plotHeight}px`;
+ mainPlotEl.appendChild(selectionBox);
+ }
+ chart.dispatchAction({ type: 'hideTip' });
+ const rect = mainPlotEl.getBoundingClientRect();
+ const left = Math.max(60, Math.min(dragStartX, e.clientX) - rect.left);
+ const right = Math.min(rect.width - 60, Math.max(dragStartX, e.clientX) - rect.left);
+ selectionBox.style.left = `${left}px`;
+ selectionBox.style.width = `${Math.max(0, right - left)}px`;
+ }
+ };
+
+ const onMouseUp = e => {
+ window.removeEventListener('mousemove', onMouseMove, { capture: true });
+ window.removeEventListener('mouseup', onMouseUp, { capture: true });
+ document.body.style.userSelect = '';
+ document.body.style.cursor = '';
+
+ if (dragStartX !== null) {
+ const dx = Math.abs(e.clientX - dragStartX);
+ if (dx > 10) {
+ const rect = mainPlotEl.getBoundingClientRect();
+ const startX = Math.min(dragStartX, e.clientX) - rect.left;
+ const endX = Math.max(dragStartX, e.clientX) - rect.left;
+ const val1 = chart.convertFromPixel({ xAxisIndex: 0 }, startX);
+ const val2 = chart.convertFromPixel({ xAxisIndex: 0 }, endX);
+ if (val1 != null && val2 != null) {
+ const minVal = Math.min(val1, val2);
+ const maxVal = Math.max(val1, val2);
+ if (maxVal > minVal) {
+ chart.dispatchAction({
+ type: 'dataZoom',
+ startValue: minVal,
+ endValue: maxVal
+ });
+ resetZoomBtn?.classList.remove('hidden');
+ if (rawData) {
+ drawHistogramTable(rawData, `${options.metric}-table`, options.type, [minVal, maxVal]);
}
}
}
}
- },
- tooltip: {
- shared: true,
- useHTML: true,
- borderColor: 'rgb(247,247,247,0.85)',
- formatter: function() {
- const metric = new Metric(options, Math.round(this.points[0].x * 100) / 100);
- const tooltips = this.points.filter(p => !p.series.name.includes('CDF')).map((point) => {
- const cdf = this.points.find(p => p.series.name == `${point.series.name} CDF`);
- return `
-
- ${point.series.name}
-
-
- ${(point.y).toFixed(2)}%
-
- ${cdf ?
- `
- Cumulative
-
-
- ${(cdf.y).toFixed(2)}%
-
` : ''}
- `;
- });
- return `
- ${metric.toString()}
-
-
-
- ${tooltips.join('')}
-
-
`;
+ dragStartX = null;
+ }
+
+ if (selectionBox) {
+ selectionBox.remove();
+ selectionBox = null;
+ }
+ };
+
+ mainPlotEl.addEventListener('mousedown', onMouseDown, { capture: true });
+
+ chart.on('datazoom', () => {
+ const opt = chart.getOption();
+ const dz = opt.dataZoom && opt.dataZoom[0];
+ if (dz) {
+ const isZoomed =
+ (dz.startValue !== undefined && dz.endValue !== undefined) ||
+ (dz.start !== undefined && (dz.start > 0.5 || dz.end < 99.5));
+ if (isZoomed) {
+ resetZoomBtn?.classList.remove('hidden');
+ } else {
+ resetZoomBtn?.classList.add('hidden');
}
- },
- xAxis: {
- title: {
- text: options.type
- },
- events: {
- setExtremes: e => redrawHistogramTable([e.min || -Infinity, e.max || Infinity])
+
+ if (rawData) {
+ let minVal = -Infinity;
+ let maxVal = Infinity;
+ if (dz.startValue !== undefined && dz.endValue !== undefined) {
+ minVal = dz.startValue;
+ maxVal = dz.endValue;
+ } else if (dz.start !== undefined && dz.end !== undefined) {
+ const xMin = opt.xAxis?.[0]?.min ?? sortedBins[0];
+ const xMax = opt.xAxis?.[0]?.max ?? sortedBins[sortedBins.length - 1];
+ const span = xMax - xMin;
+ minVal = xMin + (span * dz.start) / 100;
+ maxVal = xMin + (span * dz.end) / 100;
+ }
+ drawHistogramTable(rawData, `${options.metric}-table`, options.type, [minVal, maxVal]);
}
- },
- yAxis: [{
- title: {
- text: 'Density'
- },
- labels: {
- format: '{value}%'
- },
- tickAmount: 6,
- }, {
- title: {
- text: 'Cumulative Density'
- },
- labels: {
- format: '{value}%'
- },
- max: 100,
- tickAmount: 6,
- opposite: true
- }],
- series,
- credits: {
- text: 'highcharts.com',
- href: 'http://highcharts.com'
- },
- exporting: chartExportOptions
+ }
});
- chart.drawBenchmark = (name, value, color) => {
- chart.xAxis[0].update({
- plotLines: [{
- value,
- color,
- dashStyle: 'dash',
- width: 2,
- label: {
- text: name
- }
- }]
+
+ // Legend toggling for CDF curves together with bars
+ chart.on('legendselectchanged', params => {
+ const isSelected = params.selected[params.name];
+ const updateSelected = { ...params.selected };
+
+ if (params.name === 'Desktop') {
+ updateSelected['Desktop CDF'] = isSelected;
+ } else if (params.name === 'Mobile') {
+ updateSelected['Mobile CDF'] = isSelected;
+ }
+
+ chart.setOption({
+ legend: { selected: updateSelected }
});
+ });
+
+ // Context menu toggle
+ const menuBtn = header.querySelector('.chart-menu-btn');
+ const dropdown = header.querySelector('.chart-menu-dropdown');
+
+ menuBtn?.addEventListener('click', e => {
+ e.stopPropagation();
+ dropdown?.classList.toggle('hidden');
+ menuBtn.classList.toggle('active');
+ });
+
+ document.addEventListener('click', () => {
+ dropdown?.classList.add('hidden');
+ menuBtn?.classList.remove('active');
+ });
+
+ // Context menu export actions
+ dropdown?.querySelectorAll('.chart-menu-item').forEach(item => {
+ item.addEventListener('click', () => {
+ const action = item.dataset.action;
+ dropdown.classList.add('hidden');
+ menuBtn?.classList.remove('active');
+
+ if (action === 'download-png') {
+ exportEChartsHistogram(mainPlotEl, `${options.metric}-histogram`, 'png');
+ } else if (action === 'download-svg') {
+ exportEChartsHistogram(mainPlotEl, `${options.metric}-histogram`, 'svg');
+ } else if (action === 'show-query') {
+ const url = getQueryUrl(options.metric);
+ if (url) window.open(url, '_blank');
+ }
+ });
+ });
+
+ // Window resize
+ const onResize = debounce(() => {
+ chart.resize();
+ }, 100);
+ window.addEventListener('resize', onResize);
+
+ const chartController = {
+ drawBenchmark: (name, value, color) => {
+ benchmarkMarkLines.push({
+ xAxis: value,
+ name,
+ lineStyle: { color: color || '#94a3b8', type: 'dashed' },
+ label: { formatter: name, position: 'insideEndTop' }
+ });
+ if (seriesList.length) {
+ seriesList[0].markLine = {
+ symbol: ['none', 'none'],
+ silent: true,
+ data: benchmarkMarkLines
+ };
+ chart.setOption({ series: seriesList });
+ }
+ }
};
+
window.charts = window.charts || {};
- window.charts[options.metric] = chart;
+ window.charts[options.metric] = chartController;
+
+ return chartController;
+}
+
+function exportEChartsHistogram(container, filename, format) {
+ const svgEl = container.querySelector('svg');
+ if (!svgEl) return;
+
+ if (format === 'svg') {
+ const svgData = new XMLSerializer().serializeToString(svgEl);
+ const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `${filename}.svg`;
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ } else if (format === 'png') {
+ const svgData = new XMLSerializer().serializeToString(svgEl);
+ const canvas = document.createElement('canvas');
+ const bbox = svgEl.getBoundingClientRect();
+ const scale = 2;
+ canvas.width = (bbox.width || 800) * scale;
+ canvas.height = (bbox.height || 450) * scale;
+ const ctx = canvas.getContext('2d');
+ ctx.scale(scale, scale);
+
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => {
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(img, 0, 0, bbox.width || 800, bbox.height || 450);
+ const pngUrl = canvas.toDataURL('image/png');
+ const link = document.createElement('a');
+ link.href = pngUrl;
+ link.download = `${filename}.png`;
+ link.click();
+ };
+ img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
+ }
}
-// Export directly to global scope for use by Jinja template.
+// Export directly to global scope
window.histogram = histogram;
+export default histogram;
diff --git a/src/js/timeseries.js b/src/js/timeseries.js
index 4bc4f83b..55e0a9a1 100644
--- a/src/js/timeseries.js
+++ b/src/js/timeseries.js
@@ -1,12 +1,24 @@
-/* global Highcharts */
-
+import * as echarts from 'echarts';
import Changelog from './changelog';
import { Colors } from './colors';
import debounce from './debounce';
import { Metric } from './metric';
-import { el, prettyDate, chartExportOptions, drawMetricSummary, callOnceWhenVisible } from './utils';
+import { el, prettyDate, drawMetricSummary, callOnceWhenVisible, getQueryUrl } from './utils';
import { Constants } from './techreport/utils/constants.js';
+const DEFAULT_COLS = ['date', 'client'];
+const DEFAULT_FIELDS = ['p10', 'p25', 'p50', 'p75', 'p90'];
+
+// Standard SI unit formatting for Y-axis (10M, 1.5M, 200k, etc.)
+function formatSI(val) {
+ if (val === 0) return '0';
+ const abs = Math.abs(val);
+ if (abs >= 1e9) return (val / 1e9).toFixed(abs >= 1e10 ? 0 : 1).replace(/\.0$/, '') + 'B';
+ if (abs >= 1e6) return (val / 1e6).toFixed(abs >= 1e7 ? 0 : 1).replace(/\.0$/, '') + 'M';
+ if (abs >= 1e3) return (val / 1e3).toFixed(abs >= 1e4 ? 0 : 1).replace(/\.0$/, '') + 'k';
+ if (abs < 0.01) return val.toFixed(3);
+ return val.toLocaleString();
+}
function timeseries(metric, options, start, end) {
const dataUrl = `${Constants.apiBase}/static/reports/${options.lens ? `${options.lens.id}/` : ''}${metric}.json`;
@@ -17,14 +29,14 @@ function timeseries(metric, options, start, end) {
fetch(dataUrl)
.then(response => response.text())
.then(jsonStr => JSON.parse(jsonStr))
- .then(data => data.sort((a, b) => a.date < b.date ? -1 : 1))
+ .then(data => data.sort((a, b) => (a.date < b.date ? -1 : 1)))
.then(data => {
let [YYYY, MM, DD] = start.split('_');
options.min = Date.UTC(YYYY, MM - 1, DD);
[YYYY, MM, DD] = end.split('_');
options.max = Date.UTC(YYYY, MM - 1, DD);
- // Ensure null values are filtered out.
+ // Ensure null values are filtered out
data = data.filter(o => getUnformattedPrimaryMetric(o, options) !== null);
drawTimeseries(data, options);
@@ -41,12 +53,9 @@ function drawSummary(data, options, start, end) {
}
function drawClientSummary(data, options, client) {
- if (!data.length) {
- return;
- }
+ if (!data.length) return;
const value = getSummary(data, options);
- // Assume the metric is not the median if the options have custom fields.
const isMedian = !(options.timeseries && options.timeseries.fields);
const change = getChange(data, options);
@@ -57,17 +66,13 @@ function getSummary(data, options) {
const o = data[data.length - 1];
const summary = getPrimaryMetric(o, options);
const metric = new Metric(options, summary);
-
return metric.toString();
}
function getChange(data, options) {
- if (data.length < 2) {
- return;
- }
+ if (data.length < 2) return;
let oldestIndex;
-
for (let i = 0; i < data.length; i++) {
if (getPrimaryMetric(data[i], options) > 0) {
oldestIndex = i;
@@ -75,23 +80,18 @@ function getChange(data, options) {
}
}
- if (oldestIndex === undefined) {
- return;
- }
+ if (oldestIndex === undefined) return;
const oldest = getPrimaryMetric(data[oldestIndex], options);
const latest = getPrimaryMetric(data[data.length - 1], options);
-
- return (latest - oldest) * 100 / oldest;
+ return ((latest - oldest) * 100) / oldest;
}
function getPrimaryMetric(o, options) {
const field = getPrimaryFieldName(o, options);
const primaryMetric = getUnformattedPrimaryMetric(o, options);
const formatter = formatters[field];
- if (formatter) {
- return formatter(primaryMetric);
- }
+ if (formatter) return formatter(primaryMetric);
return primaryMetric;
}
@@ -99,7 +99,6 @@ function getPrimaryFieldName(o, options) {
if (options.timeseries && options.timeseries.fields) {
return options.timeseries.fields[0];
}
-
return 'p50';
}
@@ -108,55 +107,938 @@ function getUnformattedPrimaryMetric(o, options) {
return o[field];
}
+const flags = {};
+let changelogPromise = null;
+const loadChangelog = () => {
+ if (!changelogPromise) {
+ changelogPromise = fetch(Changelog.URL)
+ .then(response => response.json())
+ .catch(() => []);
+ }
+ return changelogPromise;
+};
+
function drawTimeseries(data, options) {
- data = data.map(toNumeric);
- const desktop = data.filter(isDesktop);
- const mobile = data.filter(isMobile);
+ const numericData = data.map(toNumeric);
+ const desktop = numericData.filter(isDesktop);
+ const mobile = numericData.filter(isMobile);
+
+ loadChangelog().then(changelogData => {
+ changelogData.forEach(change => {
+ flags[+change.date] = {
+ title: change.title,
+ desc: change.desc
+ };
+ });
+
+ const activeChanges = changelogData.filter(o => o.displayInTimeSeries !== false);
+ const container = document.getElementById(options.chartId);
+ if (!container) return;
+
+ callOnceWhenVisible(container, () => {
+ renderEChartsTimeseries(container, desktop, mobile, activeChanges, options, data);
+ });
+ });
+}
+
+function renderEChartsTimeseries(container, desktop, mobile, changelogData, options, allData) {
+ container.innerHTML = '';
+
+ const chartTitle = `${options.lens ? `${options.lens.name}: ` : ''}Timeseries of ${options.name}`;
+
+ // 1. Header Card Element
+ const header = document.createElement('div');
+ header.className = 'chart-header';
+ header.innerHTML = `
+ ${chartTitle}
+
+
+ `;
+ container.appendChild(header);
+
+ // 2. Navigation Bar Element (Zoom Presets & Range Indicator)
+ const navBar = document.createElement('div');
+ navBar.className = 'chart-nav-bar';
+ navBar.innerHTML = `
+
+ Zoom
+ 1m
+ 3m
+ 6m
+ YTD
+ 1y
+ 3y
+ All
+
+
+ `;
+ container.appendChild(navBar);
+
+ // 3. ECharts Main Plot Host
+ const mainPlotEl = document.createElement('div');
+ mainPlotEl.className = 'chart-main-plot';
+ container.appendChild(mainPlotEl);
+
+ // Calculate full data boundaries and unique crawl timestamps
+ const allTimestamps = Array.from(new Set([...desktop, ...mobile].map(d => d.timestamp).filter(Boolean))).sort((a, b) => a - b);
+ const earliest = Math.min(...allTimestamps);
+ const latest = Math.max(...allTimestamps);
+
+ const initialMin = options.min || earliest;
+ const initialMax = options.max || latest;
+
+ // Initialize ECharts instance with SVG renderer for vector clarity
+ const chart = echarts.init(mainPlotEl, null, {
+ renderer: 'svg'
+ });
+
+ // Build high-fidelity changelog mapping
+ // Map by:
+ // 1. Exact changelog date timestamp
+ // 2. UTC midnight of changelog date
+ // 3. Closest crawl timestamp in the dataset
+ const changelogMap = new Map();
+
+ // Filter changelog items to only those within the metric's lifespan
+ // so obsolete milestones (e.g. from 2012-2018 on a 2020+ metric) aren't processed
+ const relevantChanges = changelogData.filter(c => {
+ const ts = +c.date;
+ return ts >= earliest - 15 * 86400000 && ts <= latest + 15 * 86400000;
+ });
+
+ const changelogSeriesData = relevantChanges.map(c => {
+ // Preserve canonical global letter matching HTTPArchive standard
+ const globalIdx = changelogData.findIndex(x => x.date === c.date);
+ const letter = String.fromCharCode(65 + ((globalIdx >= 0 ? globalIdx : 0) % 26));
+ const item = {
+ ...c,
+ letter,
+ title: c.title,
+ desc: c.desc
+ };
+
+ // 1. Exact date
+ changelogMap.set(+c.date, item);
+
+ // 2. UTC midnight
+ const cd = new Date(+c.date);
+ const midnight = Date.UTC(cd.getUTCFullYear(), cd.getUTCMonth(), cd.getUTCDate());
+ changelogMap.set(midnight, item);
+
+ // 3. Closest crawl timestamp
+ let closestTs = allTimestamps[0];
+ let minDiff = Infinity;
+ for (const ts of allTimestamps) {
+ const diff = Math.abs(ts - c.date);
+ if (diff < minDiff) {
+ minDiff = diff;
+ closestTs = ts;
+ }
+ }
+ if (minDiff <= 15 * 86400000) {
+ changelogMap.set(closestTs, item);
+ item.crawlTs = closestTs;
+ } else {
+ item.crawlTs = midnight;
+ }
+
+ return [item.crawlTs, 0, letter, item.title, item];
+ });
+
+ // Dynamic benchmarks
+ const benchmarkMarkLines = [];
+
+ // Build Series
+ const seriesList = [];
+ const legendNames = [];
+ const hasCustomFields = !!(options.timeseries && options.timeseries.fields);
- const series = [];
if (desktop.length) {
- if (options.timeseries && options.timeseries.fields) {
+ if (hasCustomFields) {
options.timeseries.fields.forEach(field => {
- series.push(getLineSeries('Desktop', desktop.map(o => [o.timestamp, o[field]]), Colors.DESKTOP));
+ const name = options.timeseries.fields.length === 1 ? 'Desktop' : `Desktop ${field}`;
+ legendNames.push(name);
+ seriesList.push({
+ name,
+ type: 'line',
+ data: desktop.map(o => [o.timestamp, o[field]]),
+ showSymbol: false,
+ smooth: false,
+ lineStyle: { color: Colors.DESKTOP, width: 2 },
+ itemStyle: { color: Colors.DESKTOP },
+ markLine: field === options.timeseries.fields[0] && benchmarkMarkLines.length ? {
+ symbol: ['none', 'none'],
+ silent: true,
+ data: benchmarkMarkLines
+ } : undefined
+ });
});
} else {
- series.push(getLineSeries('Desktop', desktop.map(toLine), Colors.DESKTOP));
- series.push(getAreaSeries('Desktop', desktop.map(toIQR), Colors.DESKTOP));
+ legendNames.push('Desktop');
+
+ // Desktop IQR Lower Base (transparent, stacked)
+ seriesList.push({
+ name: 'Desktop IQR Base',
+ type: 'line',
+ data: desktop.map(o => [o.timestamp, o.p25]),
+ stack: 'desktop-iqr',
+ lineStyle: { opacity: 0 },
+ areaStyle: { opacity: 0 },
+ symbol: 'none',
+ showSymbol: false,
+ silent: true,
+ tooltip: { show: false }
+ });
+
+ // Desktop IQR Span (p75 - p25, shaded)
+ seriesList.push({
+ name: 'Desktop IQR',
+ type: 'line',
+ data: desktop.map(o => [o.timestamp, Math.max(0, o.p75 - o.p25)]),
+ stack: 'desktop-iqr',
+ lineStyle: { opacity: 0 },
+ areaStyle: { color: 'rgba(4, 199, 253, 0.15)', opacity: 1 },
+ symbol: 'none',
+ showSymbol: false,
+ silent: true,
+ tooltip: { show: false }
+ });
+
+ // Desktop Median Line (p50)
+ seriesList.push({
+ name: 'Desktop',
+ type: 'line',
+ data: desktop.map(o => [o.timestamp, o.p50]),
+ lineStyle: { color: Colors.DESKTOP, width: 2 },
+ itemStyle: { color: Colors.DESKTOP },
+ showSymbol: false,
+ smooth: false,
+ z: 3,
+ markLine: benchmarkMarkLines.length ? {
+ symbol: ['none', 'none'],
+ silent: true,
+ data: benchmarkMarkLines
+ } : undefined
+ });
}
}
+
if (mobile.length) {
- if (options.timeseries && options.timeseries.fields) {
+ if (hasCustomFields) {
options.timeseries.fields.forEach(field => {
- series.push(getLineSeries('Mobile', mobile.map(o => [o.timestamp, o[field]]), Colors.MOBILE));
+ const name = options.timeseries.fields.length === 1 ? 'Mobile' : `Mobile ${field}`;
+ legendNames.push(name);
+ seriesList.push({
+ name,
+ type: 'line',
+ data: mobile.map(o => [o.timestamp, o[field]]),
+ showSymbol: false,
+ smooth: false,
+ lineStyle: { color: Colors.MOBILE, width: 2 },
+ itemStyle: { color: Colors.MOBILE }
+ });
});
} else {
- series.push(getLineSeries('Mobile', mobile.map(toLine), Colors.MOBILE));
- series.push(getAreaSeries('Mobile', mobile.map(toIQR), Colors.MOBILE));
+ legendNames.push('Mobile');
+
+ // Mobile IQR Lower Base
+ seriesList.push({
+ name: 'Mobile IQR Base',
+ type: 'line',
+ data: mobile.map(o => [o.timestamp, o.p25]),
+ stack: 'mobile-iqr',
+ lineStyle: { opacity: 0 },
+ areaStyle: { opacity: 0 },
+ symbol: 'none',
+ showSymbol: false,
+ silent: true,
+ tooltip: { show: false }
+ });
+
+ // Mobile IQR Span (p75 - p25)
+ seriesList.push({
+ name: 'Mobile IQR',
+ type: 'line',
+ data: mobile.map(o => [o.timestamp, Math.max(0, o.p75 - o.p25)]),
+ stack: 'mobile-iqr',
+ lineStyle: { opacity: 0 },
+ areaStyle: { color: 'rgba(182, 24, 119, 0.15)', opacity: 1 },
+ symbol: 'none',
+ showSymbol: false,
+ silent: true,
+ tooltip: { show: false }
+ });
+
+ // Mobile Median Line (p50)
+ seriesList.push({
+ name: 'Mobile',
+ type: 'line',
+ data: mobile.map(o => [o.timestamp, o.p50]),
+ lineStyle: { color: Colors.MOBILE, width: 2 },
+ itemStyle: { color: Colors.MOBILE },
+ showSymbol: false,
+ smooth: false,
+ z: 3
+ });
+ }
+ }
+
+ // Append Changelog custom series (Option A: Clean bottom axis pins, on-demand crosshair)
+ if (changelogSeriesData.length) {
+ seriesList.push({
+ name: 'Changelog',
+ type: 'custom',
+ coordinateSystem: 'cartesian2d',
+ xAxisIndex: 0,
+ yAxisIndex: 0,
+ z: 10,
+ data: changelogSeriesData,
+ renderItem: (params, api) => {
+ const ts = api.value(0);
+ const letter = api.value(2);
+ const coord = api.coord([ts, 0]);
+ const x = coord[0];
+ const gridBottom = params.coordSys.y + params.coordSys.height;
+
+ // Clip if outside visible chart bounds
+ if (x < params.coordSys.x - 12 || x > params.coordSys.x + params.coordSys.width + 12) {
+ return;
+ }
+
+ const dataIdx = params.dataIndex;
+ let stemH = 14;
+ const boxW = 16;
+ const boxH = 16;
+
+ // Dynamic stem staggering when adjacent milestones are close in pixels (< 18px)
+ if (dataIdx > 0) {
+ const prevTs = changelogSeriesData[dataIdx - 1][0];
+ const prevX = api.coord([prevTs, 0])[0];
+ if (Math.abs(x - prevX) < 18) {
+ stemH = (dataIdx % 2 === 1) ? 28 : 14;
+ }
+ }
+
+ const boxY = gridBottom - stemH - boxH;
+ const boxX = Math.round(x - boxW / 2);
+
+ return {
+ type: 'group',
+ children: [
+ // Stem connecting tick to flag
+ {
+ type: 'line',
+ shape: {
+ x1: Math.round(x),
+ y1: gridBottom,
+ x2: Math.round(x),
+ y2: boxY + boxH
+ },
+ style: {
+ stroke: '#94a3b8',
+ lineWidth: 1
+ }
+ },
+ // Flag badge box
+ {
+ type: 'rect',
+ shape: {
+ x: boxX,
+ y: boxY,
+ width: boxW,
+ height: boxH,
+ r: 3
+ },
+ style: {
+ fill: '#ffffff',
+ stroke: '#94a3b8',
+ lineWidth: 1,
+ shadowColor: 'rgba(0, 0, 0, 0.08)',
+ shadowBlur: 2
+ },
+ emphasis: {
+ style: {
+ fill: '#f0f9ff',
+ stroke: '#0284c7',
+ lineWidth: 1.5,
+ shadowColor: 'rgba(2, 132, 199, 0.25)',
+ shadowBlur: 4
+ }
+ }
+ },
+ // Letter text
+ {
+ type: 'text',
+ style: {
+ text: letter,
+ x: Math.round(x),
+ y: boxY + Math.round(boxH / 2) + 1,
+ fill: '#475569',
+ font: 'bold 9px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
+ textAlign: 'center',
+ textVerticalAlign: 'middle'
+ },
+ emphasis: {
+ style: {
+ fill: '#0284c7'
+ }
+ }
+ }
+ ]
+ };
+ }
+ });
+ }
+
+ const yAxisTitle = `${options.name}${options.redundant ? '' : ` (${options.type})`}`;
+
+ // ECharts Option Configuration
+ const option = {
+ aria: {
+ enabled: true,
+ decal: { show: false }
+ },
+ animation: false,
+ grid: {
+ top: 25,
+ left: 70,
+ right: 25,
+ bottom: 95,
+ containLabel: false
+ },
+ legend: {
+ bottom: 4,
+ left: 'center',
+ data: legendNames,
+ icon: 'roundRect',
+ itemWidth: 16,
+ itemHeight: 4,
+ textStyle: {
+ color: '#374151',
+ fontSize: 12
+ }
+ },
+ tooltip: {
+ trigger: 'axis',
+ confine: true,
+ backgroundColor: 'rgba(255, 255, 255, 0.98)',
+ borderColor: '#d1d5db',
+ borderWidth: 1,
+ padding: 10,
+ extraCssText: 'box-shadow: 0 4px 16px rgba(0, 0, 0, 0.14); border-radius: 6px; backdrop-filter: blur(4px);',
+ axisPointer: {
+ type: 'line',
+ lineStyle: {
+ color: '#94a3b8',
+ type: 'dashed',
+ width: 1
+ }
+ },
+ formatter: params => {
+ if (!params || !params.length) return '';
+ const visibleParams = params.filter(p => !p.seriesName.includes('IQR') && p.seriesName !== 'Changelog');
+ if (!visibleParams.length) {
+ const changelogParam = params.find(p => p.seriesName === 'Changelog');
+ if (changelogParam) {
+ const ts = changelogParam.value[0];
+ const d = new Date(ts);
+ const formattedDate = ts >= Date.UTC(2019, 0, 1)
+ ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })
+ : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
+ const title = changelogParam.value[3];
+ return `
+
+ `;
+ }
+ return '';
+ }
+
+ const ts = visibleParams[0].value[0];
+ const d = new Date(ts);
+ const formattedDate = ts >= Date.UTC(2019, 0, 1)
+ ? d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })
+ : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
+
+ let html = ``;
+ return html;
+ }
+ },
+ xAxis: {
+ type: 'time',
+ boundaryGap: false,
+ axisLine: { lineStyle: { color: '#cbd5e1' } },
+ axisTick: { lineStyle: { color: '#cbd5e1' } },
+ axisLabel: {
+ color: '#64748b',
+ fontSize: 11
+ },
+ splitLine: {
+ show: true,
+ lineStyle: { color: '#f1f5f9', type: 'solid' }
+ }
+ },
+ yAxis: {
+ type: 'value',
+ name: yAxisTitle,
+ nameLocation: 'middle',
+ nameRotate: 90,
+ nameGap: 50,
+ nameTextStyle: {
+ color: '#64748b',
+ fontSize: 12
+ },
+ axisLabel: {
+ color: '#64748b',
+ fontSize: 11,
+ formatter: val => formatSI(val)
+ },
+ splitLine: {
+ show: true,
+ lineStyle: { color: '#f1f5f9' }
+ }
+ },
+ dataZoom: [
+ {
+ type: 'slider',
+ xAxisIndex: 0,
+ startValue: initialMin,
+ endValue: initialMax,
+ left: 70,
+ right: 25,
+ bottom: 34,
+ height: 26,
+ borderColor: '#e2e8f0',
+ backgroundColor: '#f8fafc',
+ fillerColor: 'rgba(4, 199, 253, 0.12)',
+ handleIcon: 'path://M-9.35,34.56V42m0-40V9.5m-2,0h4a2,2,0,0,1,2,2v21a2,2,0,0,1-2,2h-4a2,2,0,0,1-2-2v-21A2,2,0,0,1-11.35,9.5Z',
+ handleSize: '100%',
+ handleStyle: {
+ color: '#ffffff',
+ borderColor: '#0284c7',
+ borderWidth: 1.5,
+ shadowBlur: 2,
+ shadowColor: 'rgba(0,0,0,0.1)'
+ },
+ moveHandleSize: 6,
+ moveHandleStyle: { color: '#0284c7', opacity: 0.6 },
+ showDetail: false,
+ dataBackground: {
+ lineStyle: { color: '#04c7fd', width: 1 },
+ areaStyle: { color: 'rgba(4, 199, 253, 0.08)' }
+ },
+ selectedDataBackground: {
+ lineStyle: { color: '#0284c7', width: 1.5 },
+ areaStyle: { color: 'rgba(4, 199, 253, 0.2)' }
+ }
+ },
+ {
+ type: 'inside',
+ xAxisIndex: 0,
+ zoomOnMouseWheel: true,
+ moveOnMouseMove: false,
+ moveOnMouseWheel: false
+ }
+ ],
+ series: seriesList
+ };
+
+ chart.setOption(option);
+
+ // Year labels overlay inside the zoom/pan slider
+ const sliderYearsEl = document.createElement('div');
+ sliderYearsEl.className = 'chart-slider-years';
+ sliderYearsEl.style.cssText = 'position: absolute; left: 70px; right: 25px; bottom: 34px; height: 26px; pointer-events: none; overflow: hidden; display: flex; align-items: center; z-index: 2;';
+
+ const startYr = new Date(earliest).getUTCFullYear();
+ const endYr = new Date(latest).getUTCFullYear();
+ const yrSpan = endYr - startYr;
+ const step = yrSpan > 10 ? 4 : (yrSpan > 5 ? 2 : 1);
+ const firstYr = yrSpan > 10 ? 2014 : Math.ceil((startYr + 1) / step) * step;
+
+ for (let yr = firstYr; yr < endYr; yr += step) {
+ const yrTs = Date.UTC(yr, 0, 1);
+ const pct = ((yrTs - earliest) / (latest - earliest)) * 100;
+ if (pct >= 8 && pct <= 92) {
+ const span = document.createElement('span');
+ span.style.cssText = `position: absolute; left: ${pct}%; font-size: 11px; color: #475569; font-weight: 600; transform: translateX(-50%); user-select: none; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; text-shadow: 0 0 4px #ffffff, 0 0 2px #ffffff;`;
+ span.textContent = yr.toString();
+ sliderYearsEl.appendChild(span);
+ }
+ }
+ mainPlotEl.appendChild(sliderYearsEl);
+
+ // Link IQR series with legend toggling
+ chart.on('legendselectchanged', params => {
+ const isSelected = params.selected[params.name];
+ const updateSelected = { ...params.selected };
+
+ if (params.name === 'Desktop') {
+ updateSelected['Desktop IQR Base'] = isSelected;
+ updateSelected['Desktop IQR'] = isSelected;
+ } else if (params.name === 'Mobile') {
+ updateSelected['Mobile IQR Base'] = isSelected;
+ updateSelected['Mobile IQR'] = isSelected;
+ }
+
+ chart.setOption({
+ legend: { selected: updateSelected }
+ });
+ });
+
+ // Range Display element & Sync
+ const rangeDisplay = navBar.querySelector('.chart-range-display');
+ const zoomBtns = navBar.querySelectorAll('.zoom-btn');
+
+ function updateRangeDisplay(minT, maxT) {
+ const fmt = ts => {
+ const d = new Date(ts);
+ return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
+ };
+ if (rangeDisplay) {
+ rangeDisplay.textContent = `${fmt(minT)} → ${fmt(maxT)}`;
}
}
- if (!series.length) {
- console.error('No timeseries data to draw', data, options);
- return;
+ function syncZoomButtonState(minT, maxT) {
+ const span = maxT - minT;
+ const totalSpan = latest - earliest;
+ const yearMs = 365.25 * 86400000;
+ const monthMs = 30.5 * 86400000;
+
+ zoomBtns.forEach(btn => btn.classList.remove('active'));
+
+ const tolerance = 0.15;
+ if (span >= totalSpan * (1 - tolerance)) {
+ navBar.querySelector('[data-range="All"]')?.classList.add('active');
+ } else if (Math.abs(span - 3 * yearMs) / (3 * yearMs) < tolerance) {
+ navBar.querySelector('[data-range="3y"]')?.classList.add('active');
+ } else if (Math.abs(span - yearMs) / yearMs < tolerance) {
+ navBar.querySelector('[data-range="1y"]')?.classList.add('active');
+ } else if (Math.abs(span - 6 * monthMs) / (6 * monthMs) < tolerance) {
+ navBar.querySelector('[data-range="6m"]')?.classList.add('active');
+ } else if (Math.abs(span - 3 * monthMs) / (3 * monthMs) < tolerance) {
+ navBar.querySelector('[data-range="3m"]')?.classList.add('active');
+ } else if (Math.abs(span - monthMs) / monthMs < tolerance) {
+ navBar.querySelector('[data-range="1m"]')?.classList.add('active');
+ }
}
- getFlagSeries()
- .then(flagSeries => series.push(flagSeries))
- // If the getFlagSeries request fails (503), catch so we can still draw the chart
- .catch(console.error)
- .then( () => {
- const chart = document.getElementById(options.chartId);
- callOnceWhenVisible(chart, () => drawChart(options, series));
+ updateRangeDisplay(initialMin, initialMax);
+ syncZoomButtonState(initialMin, initialMax);
+
+ // Debounced table sync
+ const debouncedTableSync = debounce((minT, maxT) => {
+ drawTimeseriesTable(allData, options, [minT, maxT]);
+ }, 100);
+
+ // Listen to ECharts DataZoom events (fired by slider drag, wheel zoom, pan)
+ chart.on('datazoom', () => {
+ const axis = chart.getModel()?.getComponent('xAxis', 0)?.axis;
+ if (!axis) return;
+ const extent = axis.scale.getExtent();
+ const curMin = extent[0];
+ const curMax = extent[1];
+
+ updateRangeDisplay(curMin, curMax);
+ syncZoomButtonState(curMin, curMax);
+ debouncedTableSync(curMin, curMax);
+ });
+
+ // Range button handlers
+ zoomBtns.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const range = btn.dataset.range;
+ let newMin = earliest;
+ const endTs = latest;
+
+ const yearMs = 365.25 * 86400000;
+ const monthMs = 30.5 * 86400000;
+
+ if (range === '1m') newMin = endTs - monthMs;
+ else if (range === '3m') newMin = endTs - 3 * monthMs;
+ else if (range === '6m') newMin = endTs - 6 * monthMs;
+ else if (range === 'YTD') {
+ const yr = new Date(endTs).getUTCFullYear();
+ newMin = Date.UTC(yr, 0, 1);
+ } else if (range === '1y') newMin = endTs - yearMs;
+ else if (range === '3y') newMin = endTs - 3 * yearMs;
+ else if (range === 'All') newMin = earliest;
+
+ newMin = Math.max(earliest, newMin);
+
+ zoomBtns.forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+
+ chart.dispatchAction({
+ type: 'dataZoom',
+ startValue: newMin,
+ endValue: endTs
+ });
+
+ updateRangeDisplay(newMin, endTs);
+ debouncedTableSync(newMin, endTs);
+ });
+ });
+
+ // Mouse drag selection zoom with visual background area
+ let dragStartX = null;
+ let selectionBox = null;
+ mainPlotEl.style.position = 'relative';
+
+ const onMouseDown = e => {
+ if (e.button !== 0) return;
+ const rect = mainPlotEl.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ if (x >= 50 && x <= rect.width - 20 && y >= 20 && y <= rect.height - 90) {
+ dragStartX = e.clientX;
+ chart.dispatchAction({ type: 'hideTip' });
+ e.preventDefault();
+ document.body.style.userSelect = 'none';
+ document.body.style.cursor = 'crosshair';
+ window.addEventListener('mousemove', onMouseMove, { capture: true });
+ window.addEventListener('mouseup', onMouseUp, { capture: true });
+ }
+ };
+
+ const onMouseMove = e => {
+ if (dragStartX === null) return;
+ const dx = e.clientX - dragStartX;
+ if (Math.abs(dx) > 3) {
+ e.stopPropagation();
+ if (!selectionBox) {
+ selectionBox = document.createElement('div');
+ selectionBox.className = 'chart-zoom-selection';
+ const rect = mainPlotEl.getBoundingClientRect();
+ const plotHeight = rect.height - 25 - 95;
+ selectionBox.style.height = `${plotHeight}px`;
+ mainPlotEl.appendChild(selectionBox);
+ }
+ chart.dispatchAction({ type: 'hideTip' });
+ const rect = mainPlotEl.getBoundingClientRect();
+ const left = Math.max(70, Math.min(dragStartX, e.clientX) - rect.left);
+ const right = Math.min(rect.width - 25, Math.max(dragStartX, e.clientX) - rect.left);
+ selectionBox.style.left = `${left}px`;
+ selectionBox.style.width = `${Math.max(0, right - left)}px`;
+ }
+ };
+
+ const onMouseUp = e => {
+ window.removeEventListener('mousemove', onMouseMove, { capture: true });
+ window.removeEventListener('mouseup', onMouseUp, { capture: true });
+ document.body.style.userSelect = '';
+ document.body.style.cursor = '';
+
+ if (dragStartX !== null) {
+ const dx = Math.abs(e.clientX - dragStartX);
+ if (dx > 10) {
+ const rect = mainPlotEl.getBoundingClientRect();
+ const startX = Math.min(dragStartX, e.clientX) - rect.left;
+ const endX = Math.max(dragStartX, e.clientX) - rect.left;
+ const val1 = chart.convertFromPixel({ xAxisIndex: 0 }, startX);
+ const val2 = chart.convertFromPixel({ xAxisIndex: 0 }, endX);
+ if (val1 != null && val2 != null) {
+ const minVal = Math.min(val1, val2);
+ const maxVal = Math.max(val1, val2);
+ if (maxVal > minVal) {
+ chart.dispatchAction({
+ type: 'dataZoom',
+ startValue: minVal,
+ endValue: maxVal
+ });
+ updateRangeDisplay(minVal, maxVal);
+ syncZoomButtonState(minVal, maxVal);
+ debouncedTableSync(minVal, maxVal);
+ }
+ }
+ }
+ dragStartX = null;
+ }
+
+ if (selectionBox) {
+ selectionBox.remove();
+ selectionBox = null;
+ }
+ };
+
+ mainPlotEl.addEventListener('mousedown', onMouseDown, { capture: true });
+
+ // Context menu toggle
+ const menuBtn = header.querySelector('.chart-menu-btn');
+ const dropdown = header.querySelector('.chart-menu-dropdown');
+
+ menuBtn?.addEventListener('click', e => {
+ e.stopPropagation();
+ dropdown?.classList.toggle('hidden');
+ menuBtn.classList.toggle('active');
+ });
+
+ document.addEventListener('click', () => {
+ dropdown?.classList.add('hidden');
+ menuBtn?.classList.remove('active');
+ });
+
+ // Context menu action exports
+ dropdown?.querySelectorAll('.chart-menu-item').forEach(item => {
+ item.addEventListener('click', () => {
+ const action = item.dataset.action;
+ dropdown.classList.add('hidden');
+ menuBtn?.classList.remove('active');
+
+ if (action === 'download-png') {
+ exportECharts(chart, mainPlotEl, `${options.metric}-timeseries`, 'png');
+ } else if (action === 'download-svg') {
+ exportECharts(chart, mainPlotEl, `${options.metric}-timeseries`, 'svg');
+ } else if (action === 'show-query') {
+ const url = getQueryUrl(options.metric);
+ if (url) window.open(url, '_blank');
+ }
});
+ });
+
+ // Responsive window resize
+ const onResize = debounce(() => {
+ chart.resize();
+ }, 100);
+ window.addEventListener('resize', onResize);
+
+ // Return chart controller for external benchmarks
+ const chartController = {
+ drawBenchmark: (name, value, color) => {
+ benchmarkMarkLines.push({
+ yAxis: value,
+ name,
+ lineStyle: { color: color || '#94a3b8', type: 'dashed' },
+ label: { formatter: name, position: 'insideEndTop' }
+ });
+ // Re-apply benchmark markLines
+ if (seriesList.length) {
+ seriesList[0].markLine = {
+ symbol: ['none', 'none'],
+ silent: true,
+ data: benchmarkMarkLines
+ };
+ chart.setOption({ series: seriesList });
+ }
+ }
+ };
+
+ window.charts = window.charts || {};
+ window.charts[options.metric] = chartController;
+
+ return chartController;
+}
+
+// Export high-resolution PNG or vector SVG
+function exportECharts(chart, container, filename, format) {
+ if (format === 'svg') {
+ const svgEl = container.querySelector('svg');
+ if (!svgEl) {
+ alert('SVG export is only available in vector mode.');
+ return;
+ }
+ const svgData = new XMLSerializer().serializeToString(svgEl);
+ const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `${filename}.svg`;
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ } else if (format === 'png') {
+ const svgEl = container.querySelector('svg');
+ if (!svgEl) return;
+ const svgData = new XMLSerializer().serializeToString(svgEl);
+ const canvas = document.createElement('canvas');
+ const bbox = svgEl.getBoundingClientRect();
+ const scale = 2;
+ canvas.width = (bbox.width || 800) * scale;
+ canvas.height = (bbox.height || 450) * scale;
+ const ctx = canvas.getContext('2d');
+ ctx.scale(scale, scale);
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => {
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(img, 0, 0, bbox.width || 800, bbox.height || 450);
+ const pngUrl = canvas.toDataURL('image/png');
+ const link = document.createElement('a');
+ link.href = pngUrl;
+ link.download = `${filename}.png`;
+ link.click();
+ };
+ img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
+ }
}
+
let redrawTimeseriesTable = {};
-function drawTimeseriesTable(data, options, [start, end]=[-Infinity, Infinity]) {
+function drawTimeseriesTable(data, options, [start, end] = [-Infinity, Infinity]) {
start = Math.floor(start);
end = Math.floor(end);
if (!redrawTimeseriesTable[options.metric]) {
- // Return a curried function to redraw the table given start/end times.
- redrawTimeseriesTable[options.metric] = debounce((dateRange) => {
+ redrawTimeseriesTable[options.metric] = debounce(dateRange => {
return drawTimeseriesTable(data, options, dateRange);
}, 100);
}
@@ -168,8 +1050,9 @@ function drawTimeseriesTable(data, options, [start, end]=[-Infinity, Infinity])
cols = DEFAULT_COLS.concat(options.timeseries.fields);
}
- Promise.resolve(zip(data)).then(data => {
+ Promise.resolve(zip(data)).then(groupedData => {
const table = document.getElementById(options.tableId);
+ if (!table) return;
Array.from(table.children).forEach(child => table.removeChild(child));
const frag = document.createDocumentFragment();
@@ -178,33 +1061,29 @@ function drawTimeseriesTable(data, options, [start, end]=[-Infinity, Infinity])
if (!options.timeseries || !options.timeseries.fields) {
const trMeta = el('tr');
trMeta.classList.add('meta-row');
- DEFAULT_COLS.map(() => {
- return el('td');
- }).forEach(td => trMeta.appendChild(td));
+ DEFAULT_COLS.forEach(() => trMeta.appendChild(el('td')));
const th = el('th');
th.classList.add('text-center');
- th.setAttribute('colspan', cols.length - DEFAULT_COLS.length);
- th.textContent = 'Percentile' + (th.colspan === 1 ? '' : 's');
+ th.setAttribute('colspan', (cols.length - DEFAULT_COLS.length).toString());
+ th.textContent = 'Percentile' + (th.getAttribute('colspan') === '1' ? '' : 's');
trMeta.appendChild(th);
thead.appendChild(trMeta);
}
const tr = el('tr');
- cols.map(col => {
+ cols.forEach(col => {
const th = el('th');
th.textContent = col;
- return th;
- }).forEach(th => tr.appendChild(th));
+ tr.appendChild(th);
+ });
thead.appendChild(tr);
frag.appendChild(thead);
const tbody = el('tbody');
- data.forEach(([date, arr]) => {
- if (date < start || date > end) {
- return;
- }
-
- arr.forEach((o, i) => tbody.appendChild(toRow(o, i, arr.length, cols)));
+ groupedData.forEach(([timestamp, arr]) => {
+ const ts = +timestamp;
+ if (ts < start || ts > end) return;
+ arr.forEach(o => tbody.appendChild(toRow(o, cols)));
});
frag.appendChild(tbody);
table.appendChild(frag);
@@ -213,237 +1092,18 @@ function drawTimeseriesTable(data, options, [start, end]=[-Infinity, Infinity])
const isDesktop = o => o.client == 'desktop';
const isMobile = o => o.client == 'mobile';
-const toNumeric = ({client, ...other}) => {
- return Object.entries(other).reduce((o, [k, v]) => {
- o[k] = +v;
- return o;
- }, {client});
-};
-const toIQR = o => [o.timestamp, o.p25, o.p75];
-const toLine = o => [o.timestamp, o.p50];
-const getLineSeries = (name, data, color) => ({
- name,
- type: 'line',
- data,
- color,
- zIndex: 1,
- marker: {
- enabled: false
- }
-});
-const getAreaSeries = (name, data, color, opacity=0.1) => ({
- name,
- type: 'areasplinerange',
- linkedTo: ':previous',
- data,
- lineWidth: 0,
- color,
- fillOpacity: opacity,
- zIndex: 0,
- marker: {
- enabled: false,
- states: {
- hover: {
- enabled: false
- }
- }
- }
-});
-const flags = {};
-let changelog = null;
-const loadChangelog = () => {
- if (!changelog) {
- changelog = fetch(Changelog.URL).then(response => response.json());
- }
-
- return changelog;
-};
-const getFlagSeries = () => loadChangelog().then(data => {
- data.forEach(change => {
- flags[+change.date] = {
- title: change.title,
- desc: change.desc
- };
- });
- // Filter out changes that don't need to be displayed in time series
- data = data.filter(o => o.displayInTimeSeries !== false);
- return {
- type: 'flags',
- name: 'Changelog',
- data: data.map((change, i) => ({
- x: change.date,
- title: String.fromCharCode(65 + (i % 26))
- })),
- clip: false,
- color: '#90b1b6',
- y: 25,
- showInLegend: false
- };
-});
-
-function drawChart(options, series) {
- const chart = Highcharts.stockChart(options.chartId, {
- metric: options.metric,
- type: 'timeseries',
- chart: {
- zoomType: 'x',
- zooming: {
- mouseWheel: {
- enabled: false
- }
- }
- },
- title: {
- text: `${options.lens ? `${options.lens.name}: ` : '' }` + `Timeseries of ${options.name}`,
- style: {
- "font-weight": "normal"
- }
- },
- subtitle: {
- text: 'Source: httparchive.org ',
- useHTML: true
+const toNumeric = ({ client, date, ...other }) => {
+ return Object.entries(other).reduce(
+ (o, [k, v]) => {
+ o[k] = +v;
+ return o;
},
- legend: {
- enabled: true
- },
- tooltip: {
- crosshairs: true,
- shared: true,
- useHTML: true,
- borderColor: 'rgb(247,247,247,0.85)',
- formatter: function() {
- function getChangelog(changelog) {
- if (!changelog) return '';
- return `${changelog.title}
`;
- }
-
- const changelog = flags[this.x];
-
- // Use short format (month + year) for dates from 2019 onwards when
- // we switched to monthly crawls. Show full date for older data that
- // may have had mid-month crawls.
- const formattedDate = this.x >= Date.UTC(2019, 0, 1) ?
- Highcharts.dateFormat('%b %Y', this.x) :
- Highcharts.dateFormat('%b %e, %Y', this.x);
- const tooltip = `${formattedDate}
`;
+ { client, date }
+ );
+};
- // Handle changelog tooltips first.
- if (!this.points) {
- return `${tooltip} ${getChangelog(changelog)}`
- }
+const toFixed = value => (value ? (+value).toFixed(1) : value);
- function getRow(points) {
- if (!points.length) return '';
- let label;
- let data;
- if (options.timeseries && options.timeseries.fields) {
- label = points[0].series.name;
- const formatter = formatters[options.timeseries.fields[0]];
- if (formatter) {
- data = formatter(points[0].point.y);
- } else {
- data = points[0].point.y.toFixed(1);
- }
- } else {
- const [median] = points;
- label = `Median ${median.series.name}`;
- data = median.point.y.toFixed(1);
- }
- const metric = new Metric(options, data);
- return `
-
- ${label}
-
-
- ${metric.toString()}
-
- `;
- }
- const desktop = this.points.filter(o => o.series.name == 'Desktop');
- const mobile = this.points.filter(o => o.series.name == 'Mobile');
- return `${tooltip}
-
-
- ${getRow(desktop)}
- ${getRow(mobile)}
-
-
- ${getChangelog(changelog)}`;
- }
- },
- rangeSelector: {
- buttons: [{
- type: 'month',
- count: 1,
- text: '1m'
- }, {
- type: 'month',
- count: 3,
- text: '3m'
- }, {
- type: 'month',
- count: 6,
- text: '6m'
- }, {
- type: 'ytd',
- text: 'YTD'
- }, {
- type: 'year',
- count: 1,
- text: '1y'
- }, {
- type: 'year',
- count: 3,
- text: '3y'
- }, {
- type: 'all',
- text: 'All'
- }],
- inputDateFormat: '%b %Y',
- },
- xAxis: {
- type: 'datetime',
- events: {
- setExtremes: e => redrawTimeseriesTable[options.metric]([e.min, e.max])
- },
- min: options.min,
- max: options.max
- },
- yAxis: {
- title: {
- text: `${options.name}${options.redundant ? '' : ` (${options.type})`}`
- },
- opposite: false,
- min: 0
- },
- series,
- credits: {
- text: 'highcharts.com',
- href: 'http://highcharts.com'
- },
- exporting: chartExportOptions
- });
- chart.drawBenchmark = (name, value, color) => {
- chart.yAxis[0].update({
- plotLines: [{
- value,
- color,
- dashStyle: 'dash',
- width: 2,
- label: {
- text: name
- }
- }]
- });
- };
- chart.zooming.mouseWheel.enabled = false;
- window.charts = window.charts || {};
- window.charts[options.metric] = chart;
-}
-
-const DEFAULT_FIELDS = ['p10', 'p25', 'p50', 'p75', 'p90'];
-const DEFAULT_COLS = ['date', 'client'];
-const toFixed = value => parseFloat(value).toFixed(1);
const formatters = {
date: prettyDate,
p10: toFixed,
@@ -452,7 +1112,7 @@ const formatters = {
p75: toFixed,
p90: toFixed,
percent: toFixed,
- urls: value => parseInt(value).toLocaleString()
+ urls: value => parseInt(value, 10).toLocaleString()
};
const zip = data => {
@@ -461,28 +1121,33 @@ const zip = data => {
let row = dates[o.timestamp];
if (row) {
row.push(o);
- row.sort((a) => a.client == 'desktop' ? -1 : 1)
+ row.sort(a => (a.client === 'desktop' ? -1 : 1));
return;
}
dates[o.timestamp] = [o];
});
- return Object.entries(dates).sort(([a], [b]) => a > b ? -1 : 1);
+ return Object.entries(dates).sort(([a], [b]) => (+a > +b ? -1 : 1));
};
-const toRow = (o, i, n, cols) => {
+const toRow = (o, cols) => {
const row = el('tr');
- cols.map(col => {
+ cols.forEach(col => {
const td = el('td');
let text = o[col];
const formatter = formatters[col];
if (formatter) {
text = formatter(o[col]);
+ } else if (text === undefined || text === null) {
+ text = '';
}
td.textContent = text;
- return td;
- }).forEach(td => td && row.appendChild(td));
+ row.appendChild(td);
+ });
+
return row;
};
-// Export directly to global scope for use by Jinja template.
+// Export directly to global scope
+window.echarts = echarts;
window.timeseries = timeseries;
+export default timeseries;
diff --git a/src/js/utils.js b/src/js/utils.js
index 818680e9..ad9810b1 100644
--- a/src/js/utils.js
+++ b/src/js/utils.js
@@ -98,14 +98,8 @@ export function callOnceWhenVisible(element, callback) {
}).observe(element);
}
-const getQueryUrl = (metric, type) => {
- const URL_BASE = 'https://github.com/HTTPArchive/bigquery/blob/master/sql';
- if (type === 'timeseries') {
- return `${URL_BASE}/timeseries/${metric}.sql`;
- }
- if (type === 'histogram') {
- return `${URL_BASE}/histograms/${metric}.sql`;
- }
+export const getQueryUrl = (metric) => {
+ return `https://github.com/search?q=repo%3AHTTPArchive%2Fdataform+path%3Aincludes%2Freports.js+${metric}`;
};
const getSummaryElement = (metric, client) => {
diff --git a/src/pages/reports/[report_id].astro b/src/pages/reports/[report_id].astro
index d0d43b02..3a23502a 100644
--- a/src/pages/reports/[report_id].astro
+++ b/src/pages/reports/[report_id].astro
@@ -214,7 +214,6 @@ const imageHeight = report.image ? 460 : 160;
-
@@ -337,7 +336,13 @@ const imageHeight = report.image ? 460 : 160;
// Render charts for each enabled metric
report.metrics.forEach(metric => {
- if (!metric || !metric[viz]?.enabled) return;
+ if (!metric) return;
+ const sectionEl = document.getElementById(metric.id);
+ if (metric[viz]?.enabled === false) {
+ if (sectionEl) sectionEl.style.display = 'none';
+ return;
+ }
+ if (sectionEl) sectionEl.style.display = '';
if (viz === 'timeseries') {
timeseries(metric.id, metric, start, end);
diff --git a/src/styles/report.css b/src/styles/report.css
index b1fe4251..0a3d5322 100644
--- a/src/styles/report.css
+++ b/src/styles/report.css
@@ -162,8 +162,10 @@ table tbody tr:nth-child(odd) {
background-color: #f4f4f4;
}
-.highcharts-label table tbody tr:nth-child(odd) {
- background-color: initial;
+.highcharts-label table tbody tr:nth-child(odd),
+.echarts-tooltip-card table tbody tr:nth-child(odd),
+.echarts-tooltip-card table td {
+ background-color: transparent !important;
}
td,
@@ -201,18 +203,300 @@ th {
z-index: 1;
}
+/* ---- HTTP Archive Chart Card Component ---- */
.chart {
- min-width: 310px;
- height: 600px;
- margin: 0 auto;
+ position: relative;
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ padding: 16px 16px 12px;
+ margin: 0 auto 20px;
+ min-width: 300px;
+ box-sizing: border-box;
+}
+
+/* Header: centered title, subtitle, and top-right context menu */
+.chart-header {
+ position: relative;
+ text-align: center;
+ margin-bottom: 8px;
+}
+
+.chart-title {
+ font-size: 17px;
+ font-weight: 500;
+ color: #1f2937;
+ margin: 0;
+ line-height: 1.3;
}
-.highcharts-range-selector {
+.chart-subtitle {
+ font-size: 12px;
+ color: #6b7280;
+ margin-top: 2px;
+}
+
+.chart-subtitle a {
+ color: #6b7280;
+ text-decoration: underline;
+}
+
+.chart-header-actions,
+.chart-header > .chart-menu {
+ position: absolute;
+ top: -4px;
+ right: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.reset-zoom-btn {
+ background: #fff;
+ border: 1px solid #d1d5db;
+ border-radius: 4px;
+ color: #374151;
+ font-size: 11px;
+ font-weight: 500;
+ padding: 3px 8px;
+ cursor: pointer;
+ line-height: 1.3;
+ transition: all 0.15s ease;
+}
+
+.reset-zoom-btn:hover {
+ background: #f3f4f6;
+ border-color: #9ca3af;
+}
+
+.reset-zoom-btn.hidden {
display: none;
}
-.highcharts-contextbutton {
- color: red;
+.chart-zoom-selection {
+ position: absolute;
+ top: 25px;
+ background: rgb(51, 92, 173, 0.25);
+ border: 1px solid rgb(51, 92, 173, 0.6);
+ pointer-events: none;
+ z-index: 1000;
+ box-sizing: border-box;
+}
+
+.chart-menu {
+ position: relative;
+ top: 0;
+ right: 0;
+}
+
+.chart-menu-btn {
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ padding: 4px 6px;
+ color: #6b7280;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.15s ease;
+}
+
+.chart-menu-btn:hover,
+.chart-menu-btn.active {
+ background: #f3f4f6;
+ color: #111827;
+ border-color: #d1d5db;
+}
+
+.chart-menu-dropdown {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ margin-top: 4px;
+ background: #fff;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ box-shadow: 0 4px 16px rgb(0, 0, 0, 0.12);
+ z-index: 1000;
+ min-width: 200px;
+ padding: 4px 0;
+}
+
+.chart-menu-item {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: 8px 14px;
+ background: none;
+ border: none;
+ font-size: 13px;
+ color: #374151;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.chart-menu-item:hover {
+ background: #f3f4f6;
+ color: #111827;
+}
+
+/* Nav bar: inline zoom buttons and active date range indicator */
+.chart-nav-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+ font-size: 12px;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.chart-zoom-group {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.zoom-label {
+ color: #6b7280;
+ font-size: 12px;
+ margin-right: 4px;
+ font-weight: 500;
+}
+
+.zoom-btn {
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ padding: 2px 7px;
+ font-size: 12px;
+ color: #374151;
+ cursor: pointer;
+ transition: all 0.12s ease;
+ line-height: 1.4;
+}
+
+.zoom-btn:hover {
+ background: #e5e7eb;
+}
+
+.zoom-btn.active {
+ background: #e0e7ff;
+ border-color: #c7d2fe;
+ color: #3730a3;
+ font-weight: 600;
+}
+
+.chart-range-display {
+ font-size: 12px;
+ color: #2563eb;
+ font-weight: 500;
+}
+
+/* Main plot styling */
+.chart-main-plot {
+ width: 100%;
+ height: 450px;
+ position: relative;
+}
+
+.chart-menu-dropdown.hidden {
+ display: none;
+}
+
+/* Tooltip changelog callout */
+.echarts-tooltip-card {
+ font-family: inherit;
+ font-size: 12px;
+ color: #1f2937;
+ padding: 2px;
+}
+
+.echarts-tooltip-card .tooltip-date {
+ font-weight: 600;
+ text-align: center;
+ color: #111827;
+ margin-bottom: 6px;
+ font-size: 11px;
+}
+
+.echarts-tooltip-card table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.echarts-tooltip-card td {
+ padding: 3px 8px;
+ text-align: center;
+ vertical-align: top;
+}
+
+.echarts-tooltip-card .series-label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+}
+
+.echarts-tooltip-card .series-val {
+ font-size: 14px;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.echarts-tooltip-card .series-sublabel {
+ font-size: 9px;
+ text-transform: uppercase;
+ color: #6b7280;
+ letter-spacing: 0.5px;
+ margin-top: 4px;
+}
+
+.echarts-tooltip-card .series-subval {
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 1.2;
+}
+
+.echarts-tooltip-card .changelog-box {
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid #e5e7eb;
+ font-size: 11px;
+ color: #4b5563;
+ text-align: left;
+ line-height: 1.3;
+}
+
+/* Mobile responsive adjustments */
+@media (max-width: 600px) {
+ .chart {
+ padding: 12px 10px;
+ }
+
+ .chart-nav-bar {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ }
+
+ .chart-zoom-group {
+ flex-wrap: wrap;
+ }
+
+ .zoom-btn {
+ padding: 3px 6px;
+ font-size: 11px;
+ }
+
+ .chart-range-display {
+ font-size: 11px;
+ }
+
+ .chart-main-plot {
+ height: 400px;
+ }
}
#report-metrics.grid-view .report-metrics-inner-wrapper {
diff --git a/src/styles/styles.css b/src/styles/styles.css
index cf7a9322..9bd4fbc1 100644
--- a/src/styles/styles.css
+++ b/src/styles/styles.css
@@ -820,7 +820,7 @@ input {
text-align: inherit;
}
- section [class*="btn"] {
+ section [class*="btn"]:not(.chart *) {
display: block;
margin: 15px auto !important;
max-width: 85%;
diff --git a/tools/scripts/set_lighthouse_urls.sh b/tools/scripts/set_lighthouse_urls.sh
index da357ee0..c8c397ef 100755
--- a/tools/scripts/set_lighthouse_urls.sh
+++ b/tools/scripts/set_lighthouse_urls.sh
@@ -59,7 +59,7 @@ elif [ "${RUN_TYPE}" == "pull_request" ] && [ "${COMMIT_SHA}" != "" ]; then
git pull --quiet
git checkout main
# Then get the changes
- CHANGED_FILES=$(git diff --name-only "main...${COMMIT_SHA}" --diff-filter=d src/pages config/reports.json | grep -v ejs | grep -v sitemap | grep -v 404 | grep -v comparison | grep -v drilldown)
+ CHANGED_FILES=$(git diff --name-only "main...${COMMIT_SHA}" --diff-filter=d src/pages config/reports.json | grep -v ejs | grep -v sitemap | grep -v 404 | grep -v comparison | grep -v drilldown | grep -v '\[report_id\]')
echo "${CHANGED_FILES}"
# Then back to the pull request changes