From 5c2f4d60bdbfd1f75cc8840d97d729b9edc5f502 Mon Sep 17 00:00:00 2001 From: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:40:57 +0200 Subject: [PATCH 01/13] feat(charts): migrate core reports from Highcharts to Apache ECharts - Migrate Timeseries and Histogram reports from Highcharts to Apache ECharts - Implement native dataZoom slider (mini-map with data preview, handles, ticks) and inside zoom/pan - Support IQR confidence bands via stacked area series and median line series - Add inline range presets (1m, 3m, 6m, YTD, 1y, 3y, All) with active button styling and live date range indicators - Implement dual Y-axis histogram with PDF density bars on left and CDF cumulative curve on right - Add interactive crosshair tooltips with formatted date, Desktop/Mobile comparative metrics, and Changelog badges - Add custom card header with source link and export context menu (PNG, SVG, SQL query) - Scope section buttons in styles.css to ensure responsive mobile layout - Pass all 30 automated integration and runtime error tests --- package-lock.json | 34 + package.json | 3 + src/js/histogram.js | 601 +++++++++------- src/js/timeseries.js | 1000 +++++++++++++++++++-------- src/pages/reports/[report_id].astro | 9 +- src/styles/report.css | 233 ++++++- src/styles/styles.css | 2 +- 7 files changed, 1325 insertions(+), 557 deletions(-) diff --git a/package-lock.json b/package-lock.json index eaa6c2d2..aa0bb9eb 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", "astro": "^7.3.1", @@ -6209,6 +6212,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", @@ -13778,6 +13797,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 15973fb7..05bae756 100644 --- a/package.json +++ b/package.json @@ -47,5 +47,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..e85d7f82 100644 --- a/src/js/histogram.js +++ b/src/js/histogram.js @@ -1,18 +1,19 @@ -/* 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 } from './utils'; import { Constants } from './techreport/utils/constants.js'; - -const [COLOR_DESKTOP, COLOR_MOBILE, COLOR_DESKTOP_ALT, COLOR_MOBILE_ALT] = Colors.getAll({rgba: true}); +function getQueryUrl(metric, type = 'histogram') { + return `https://github.com/HTTPArchive/legacy.httparchive.org/blob/master/sql/${type}/${metric}.sql`; +} 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) { @@ -25,17 +26,17 @@ function histogram(metric, date, options) { .then(data => { drawHistogram(data, `${metric}-chart`, options); drawHistogramTable(data, `${metric}-table-desktop`, `${metric}-table-mobile`, options.type); - }).catch(e => { + }) + .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 +44,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 +63,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 +76,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': @@ -142,6 +121,7 @@ class HistogramTable { } draw() { + if (!this.table) return; Array.from(this.table.children).forEach(child => this.table.removeChild(child)); const thead = el('thead'); @@ -162,252 +142,391 @@ class HistogramTable { } } - let redrawHistogramTable = null; -function drawHistogramTable(data, desktopId, mobileId, type, [start, end]=[-Infinity, Infinity]) { +function drawHistogramTable(data, desktopId, mobileId, 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(range => { + return drawHistogramTable(data, desktopId, mobileId, type, 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'); + const desktop = bins.filter(d => d.client === 'desktop'); + const mobile = bins.filter(d => d.client === 'mobile'); - if (desktop.length) { - (new HistogramTable(desktopId, desktop, type)).draw(); - } - if (mobile.length) { - (new HistogramTable(mobileId, mobile, type)).draw(); - } + if (desktop.length) new HistogramTable(desktopId, desktop, type).draw(); + if (mobile.length) new HistogramTable(mobileId, mobile, 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); + }); +} + +function renderEChartsHistogram(container, desktop, mobile, options) { + container.innerHTML = ''; + + const chartTitle = `${options.lens ? `${options.lens.name}: ` : ''}Distribution of ${options.name}`; + + // 1. Header Card Element + const header = document.createElement('div'); + header.className = 'chart-header'; + header.innerHTML = ` +
|
+ Desktop
+ ${(dBin.pdf * 100).toFixed(2)}%
+ CDF: ${(dBin.cdf * 100).toFixed(1)}%
+ Vol: ${dBin.volume.toLocaleString()}
+ | `;
}
- }
- },
- 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)}% - ` : ''} + + if (mBin) { + html += ` |
+ Mobile
+ ${(mBin.pdf * 100).toFixed(2)}%
+ CDF: ${(mBin.cdf * 100).toFixed(1)}%
+ Vol: ${mBin.volume.toLocaleString()}
| `;
- });
- return `
|
+ ${label}
+ ${formattedVal}
+ | `;
+ });
+
+ html += `
${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}
`; - - // Handle changelog tooltips first. - if (!this.points) { - return `${tooltip} ${getChangelog(changelog)}` - } + { client } + ); +}; - 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()} -
-