diff --git a/index.html b/index.html index 64b6f222a..84a18da4b 100644 --- a/index.html +++ b/index.html @@ -126,25 +126,75 @@ _: '#333333' }; - const benchColors = { - Windows: '#357ec7', - macOS: '#717378', - Linux: '#e95420', + const seriesColors = { + inproc: '#0072b2', + breakpad: '#009e73', + crashpad: '#d55e00', + native: '#cc79a7', }; + const maxVisibleDataPoints = 100; + function init() { - function collectBenchesPerTestCase(entries) { + function parseBenchName(name) { + const match = name.match(/^(.*) \(([^()]+)\)$/); + if (match) { + return { + chartName: match[1], + seriesName: match[2], + }; + } + return { + chartName: name, + seriesName: name, + }; + } + + function createChartData() { + return { + points: [], + series: new Map(), + }; + } + + function ensureSeries(chartData, name) { + let series = chartData.series.get(name); + if (series !== undefined) { + return series; + } + + series = { + name, + values: new Array(chartData.points.length).fill(null), + points: new Array(chartData.points.length).fill(null), + }; + chartData.series.set(name, series); + return series; + } + + function collectBenchesPerChart(entries) { const map = new Map(); - for (const entry of entries) { + const points = entries.map(entry => ({ + commit: entry.commit, + date: entry.date, + tool: entry.tool, + })); + + for (const [index, entry] of entries.entries()) { const {commit, date, tool, benches} = entry; for (const bench of benches) { const result = { commit, date, tool, bench }; - const arr = map.get(bench.name); - if (arr === undefined) { - map.set(bench.name, [result]); - } else { - arr.push(result); + const {chartName, seriesName} = parseBenchName(bench.name); + let chartData = map.get(chartName); + if (chartData === undefined) { + chartData = createChartData(); + chartData.points = points; + map.set(chartName, chartData); } + + const series = ensureSeries(chartData, seriesName); + series.values[index] = bench.value; + series.points[index] = result; } } return map; @@ -170,30 +220,227 @@ // Prepare data points for charts return Object.keys(data.entries).map(name => ({ name, - dataSet: collectBenchesPerTestCase(data.entries[name]), + dataSet: collectBenchesPerChart(data.entries[name]), })); } function renderAllChars(dataSets) { - function renderGraph(parent, name, dataset, color) { + function colorForSeries(name, index) { + if (seriesColors[name]) { + return seriesColors[name]; + } + const fallbackColors = [ + toolColors._, + '#f0e442', + '#56b4e9', + '#e69f00', + '#000000', + ]; + return fallbackColors[index % fallbackColors.length]; + } + + function firstLine(text) { + const newlineIndex = text.indexOf('\n'); + return newlineIndex === -1 ? text : text.slice(0, newlineIndex); + } + + function firstBench(chartData) { + for (const series of chartData.series.values()) { + for (const point of series.points) { + if (point) { + return point.bench; + } + } + } + return undefined; + } + + function seriesForTooltip(renderSeriesData, item) { + return renderSeriesData[item.datasetIndex]; + } + + function pointForTooltip(renderSeriesData, item) { + const series = seriesForTooltip(renderSeriesData, item); + return series ? series.renderPoints[item.index] : undefined; + } + + function chartValues(seriesData) { + const values = []; + for (const series of seriesData) { + for (const value of series.values) { + if (typeof value === 'number' && isFinite(value)) { + values.push(value); + } + } + } + return values; + } + + function shouldUseLogScale(seriesData) { + const values = chartValues(seriesData); + return values.length > 0 && values.every(value => value > 0); + } + + function trimTrailingZeros(value) { + return value.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, ''); + } + + function formatLogTick(value) { + const numericValue = Number(value); + if (!isFinite(numericValue) || numericValue <= 0) { + return ''; + } + + const exponent = Math.floor(Math.log10(numericValue)); + const mantissa = numericValue / Math.pow(10, exponent); + if (![1, 2, 5].some(value => Math.abs(mantissa - value) < 1e-10)) { + return ''; + } + + const absValue = Math.abs(numericValue); + if (absValue >= 1000) { + return trimTrailingZeros((numericValue / 1000).toFixed(absValue >= 10000 ? 0 : 1)) + 'k'; + } + if (absValue >= 100) { + return Math.round(numericValue).toString(); + } + if (absValue >= 10) { + return trimTrailingZeros(numericValue.toFixed(1)); + } + if (absValue >= 1) { + return trimTrailingZeros(numericValue.toFixed(2)); + } + if (absValue >= 0.001) { + return trimTrailingZeros(numericValue.toFixed(4)); + } + return trimTrailingZeros(numericValue.toPrecision(2)); + } + + function medianAxisLabel(unit, useLogScale) { + if (useLogScale) { + return 'Median' + (unit ? ` (${unit}, logarithmic)` : ' (logarithmic)'); + } + return 'Median' + (unit ? ` (${unit})` : ''); + } + + function lastVisibleChartData(chartData) { + const startIndex = Math.max(chartData.points.length - maxVisibleDataPoints, 0); + if (startIndex === 0) { + return chartData; + } + + const series = new Map(); + for (const [name, sourceSeries] of chartData.series.entries()) { + series.set(name, { + name, + values: sourceSeries.values.slice(startIndex), + points: sourceSeries.points.slice(startIndex), + }); + } + + return { + points: chartData.points.slice(startIndex), + series, + }; + } + + function renderGraph(parent, name, chartData) { const canvas = document.createElement('canvas'); canvas.className = 'benchmark-chart'; parent.appendChild(canvas); - const data = { - labels: dataset.map(d => d.commit.id.slice(0, 7)), - datasets: [ - { - label: name, - data: dataset.map(d => d.bench.value), + const visibleData = lastVisibleChartData(chartData); + const labels = visibleData.points.map(d => d.commit.id + ':' + d.date); + const tickLabels = visibleData.points.map(d => d.commit.id.slice(0, 7)); + const seriesData = Array.from(visibleData.series.values()); + const renderSeriesData = []; + const datasets = []; + for (const [seriesIndex, series] of seriesData.entries()) { + const color = colorForSeries(series.name, seriesIndex); + let segmentData = []; + let segmentPoints = []; + let segmentIndex = 0; + + const flushSegment = () => { + if (segmentData.length === 0) { + return; + } + + renderSeriesData.push({ + name: series.name, + renderPoints: segmentPoints, + }); + datasets.push({ + label: series.name, + data: segmentData, borderColor: color, backgroundColor: color + '60', // Add alpha for #rrggbbaa + fill: false, + spanGaps: false, + _hideLegend: segmentIndex > 0, + _seriesName: series.name, + }); + segmentData = []; + segmentPoints = []; + segmentIndex++; + }; + + for (let pointIndex = 0; pointIndex < series.values.length; pointIndex++) { + const value = series.values[pointIndex]; + if (typeof value !== 'number' || !isFinite(value)) { + flushSegment(); + continue; } - ], + + segmentData.push({ x: labels[pointIndex], y: value }); + segmentPoints.push(series.points[pointIndex]); + } + flushSegment(); + } + const data = { + labels, + datasets, }; - const unit = dataset.length > 0 ? dataset[0].bench.unit : ''; + const bench = firstBench(visibleData); + const unit = bench ? bench.unit : ''; + const useLogScale = shouldUseLogScale(seriesData); + const yAxis = { + scaleLabel: { + display: true, + labelString: medianAxisLabel(unit, useLogScale), + }, + ticks: {}, + }; + if (useLogScale) { + yAxis.type = 'logarithmic'; + yAxis.ticks.autoSkip = false; + yAxis.ticks.callback = formatLogTick; + } else { + yAxis.ticks.beginAtZero = true; + } const options = { + title: { + display: true, + text: name, + }, + legend: { + display: true, + labels: { + filter: (legendItem, chartData) => !chartData.datasets[legendItem.datasetIndex]._hideLegend, + }, + onClick: function(_mouseEvent, legendItem) { + const chart = this.chart; + const seriesName = chart.data.datasets[legendItem.datasetIndex]._seriesName; + const hidden = chart.isDatasetVisible(legendItem.datasetIndex); + for (let index = 0; index < chart.data.datasets.length; index++) { + if (chart.data.datasets[index]._seriesName === seriesName) { + chart.getDatasetMeta(index).hidden = hidden; + } + } + chart.update(); + }, + }, scales: { xAxes: [ { @@ -201,38 +448,41 @@ display: true, labelString: 'commit', }, - } - ], - yAxes: [ - { - scaleLabel: { - display: true, - labelString: 'Median' + (unit ? ` (${unit})` : ''), - }, ticks: { - beginAtZero: true, - } + callback: (_value, index) => tickLabels[index], + }, } ], + yAxes: [yAxis], }, tooltips: { callbacks: { afterTitle: items => { - const {index} = items[0]; - const data = dataset[index]; - return '\n' + data.commit.message.slice(0, data.commit.message.indexOf('\n')) + '\n\n' + data.commit.timestamp + ' by @' + data.commit.author.username + '\n'; + const data = pointForTooltip(renderSeriesData, items[0]); + if (!data) { + return ''; + } + return '\n' + firstLine(data.commit.message) + '\n\n' + data.commit.timestamp + ' by @' + data.commit.author.username + '\n'; }, label: item => { - let label = item.value; - const { range, unit } = dataset[item.index].bench; - label += ' ' + unit; + const data = pointForTooltip(renderSeriesData, item); + if (!data) { + return ''; + } + const { range, unit } = data.bench; + let label = data.bench.value + ' ' + unit; if (range) { label += ' (' + range + ')'; } - return label; + const series = seriesForTooltip(renderSeriesData, item); + return (series ? series.name : data.bench.name) + ': ' + label; }, afterLabel: item => { - const { extra } = dataset[item.index].bench; + const data = pointForTooltip(renderSeriesData, item); + if (!data) { + return ''; + } + const { extra } = data.bench; return extra ? '\n' + extra : ''; } } @@ -243,8 +493,12 @@ } // XXX: Undocumented. How can we know the index? const index = activeElems[0]._index; - const url = dataset[index].commit.url; - window.open(url, '_blank'); + const datasetIndex = activeElems[0]._datasetIndex; + const series = renderSeriesData[datasetIndex]; + const data = series ? series.renderPoints[index] : undefined; + if (data) { + window.open(data.commit.url, '_blank'); + } }, }; @@ -269,9 +523,8 @@ graphsElem.className = 'benchmark-graphs'; setElem.appendChild(graphsElem); - const color = benchColors[name]; - for (const [benchName, benches] of benchSet.entries()) { - renderGraph(graphsElem, benchName, benches, color) + for (const [benchName, chartData] of benchSet.entries()) { + renderGraph(graphsElem, benchName, chartData) } }