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 = `
`; + html += `
${metricObj.toString()}
`; + html += ``; + + if (dBin) { + html += ``; } + + if (mBin) { + html += ``; + } + + html += `
+
Desktop
+
${(dBin.pdf * 100).toFixed(2)}%
+
Cumulative
+
${(dBin.cdf * 100).toFixed(2)}%
+
+
Mobile
+
${(mBin.pdf * 100).toFixed(2)}%
+
Cumulative
+
${(mBin.cdf * 100).toFixed(2)}%
+
`; + 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}

+
Source: httparchive.org
+
+
+ + +
+
+ `; + container.appendChild(header); + + // 2. Navigation Bar Element (Zoom Presets & Range Indicator) + const navBar = document.createElement('div'); + navBar.className = 'chart-nav-bar'; + navBar.innerHTML = ` +
+ Zoom + + + + + + + +
+ + `; + 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 ` +
+
${formattedDate}
+
+ ${title} +
+
+ `; + } + 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 = `
`; + html += `
${formattedDate}
`; + html += ``; + + visibleParams.forEach(p => { + const val = p.value[1]; + const color = p.color; + const label = p.seriesName; + let formattedVal = '-'; + if (val !== undefined && val !== null) { + if (options.timeseries && options.timeseries.fields) { + const fmt = formatters[options.timeseries.fields[0]]; + formattedVal = fmt ? fmt(val) : val.toFixed(1); + } else { + formattedVal = val.toFixed(1); + } + formattedVal = new Metric(options, formattedVal).toString(); + } + + html += ``; + }); + + html += `
+
${label}
+
${formattedVal}
+
`; + + // Check for changelog milestone + const cd = new Date(ts); + const midnight = Date.UTC(cd.getUTCFullYear(), cd.getUTCMonth(), cd.getUTCDate()); + const changelogItem = changelogMap.get(ts) || changelogMap.get(midnight); + if (changelogItem) { + html += ` +
+ ${changelogItem.title} +
+ `; + } + + 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