import { Metric } from "./metric.mjs"; export const COLORS = Object.freeze(["blue", "blue-light", "green-light", "green", "yellow", "orange", "red", "magenta", "violet", "purple", "blue-dark", "green-dark", "ochre", "rust"]); export function renderMetricView(viewParams) { let { metrics, width = 500, trackHeight = 20, subMetricMargin = 35, title = "", colors = COLORS } = viewParams; // Make sure subMetricMargin is set for use in renderSubMetrics. viewParams.subMetricMargin = subMetricMargin; const scatterPlotParams = { width, trackHeight, colors }; scatterPlotParams.xAxisPositiveOnly = false; scatterPlotParams.xAxisShowZero = true; scatterPlotParams.values = prepareScatterPlotValues(metrics, true); scatterPlotParams.unit = "%"; scatterPlotParams.xAxisLabel = "Spread Normalized"; const normalizedScatterPlot = renderScatterPlot(scatterPlotParams); scatterPlotParams.xAxisPositiveOnly = true; scatterPlotParams.xAxisShowZero = false; scatterPlotParams.values = prepareScatterPlotValues(metrics, false); scatterPlotParams.unit = metrics[0].unit; scatterPlotParams.xAxisLabel = metrics[0].unit; const absoluteScatterPlot = renderScatterPlot(scatterPlotParams); const legend = metrics .map( (metric, i) => ` ● ${metric.shortName} ${metric.mean.toFixed(2)} ± ${metric.deltaString} ${metric.unit} ` ) .join(""); return `

${title}

${absoluteScatterPlot}
${normalizedScatterPlot}
${legend}
${renderSubMetrics(viewParams)}
`; } function renderSubMetrics(viewParams) { const { metrics, width, subMetricMargin, colors = COLORS, renderChildren = true } = viewParams; const valuesTable = `
${renderMetricsTable(metrics)}
`; const hasChildMetric = metrics.length > 0 && metrics[0].children.length > 0; if (!hasChildMetric || !renderChildren) return valuesTable; const subMetricWidth = width - subMetricMargin; const childColors = [...colors]; const subMetrics = metrics .map((metric) => { // Rotate colors to get different colors for sub-plots. for (let i = 0; i < metric.children.length; i++) { const color = childColors.pop(); childColors.unshift(color); } const subMetricParams = { ...viewParams, parentMetric: metric, metrics: metric.children, title: metric.name, width: subMetricWidth, colors: childColors, }; return renderMetricView(subMetricParams); }) .join(""); return `${valuesTable}
${subMetrics}
`; } function renderMetricsTable(metrics, min, max) { let numRows = 0; let columnHeaders = ""; let commonPrefixes = metrics[0].name.split(Metric.separator); for (const metric of metrics) { const prefixes = metric.name.split(Metric.separator); for (let i = commonPrefixes.length - 1; i >= 0; i--) { if (commonPrefixes[i] !== prefixes[i]) commonPrefixes.pop(); } } const commonPrefix = commonPrefixes.join(Metric.separator); let commonPrefixHeader = ""; if (commonPrefix) { commonPrefixHeader = ` ${commonPrefix} `; } for (const metric of metrics) { const name = metric.name.substring(commonPrefix.length); columnHeaders += `${name} [${metric.unit}]`; numRows = Math.max(metric.values.length, numRows); } let body = ""; for (let row = 0; row < numRows; row++) { let columns = ""; for (const metric of metrics) { const value = metric.values[row]; if (value === undefined) continue; const delta = metric.max - metric.min; const percent = Math.max(Math.min((value - metric.min) / delta, 1), 0) * 100; const percentGradient = `background: linear-gradient(90deg, var(--foreground-alpha) ${percent}%, rgba(0,0,0,0) ${percent}%);`; columns += `${value.toFixed(2)}`; } body += ` ${row} ${columns} `; } return ` ${commonPrefixHeader} ${columnHeaders} ${body}
Iteration
`; } function prepareScatterPlotValues(metrics, normalize = true) { let points = []; // Arrange child-metrics values in a single coordinate system: // - metric 1: x values are in range [0, 1] // - metric 2: y values are in range [1, 2] // - ... // This way each metric data point is on a separate track in the scatter // plot. // If normalize == true: // All x values are normalized by the mean of each metric and // centered on 0. // Example: [90ms, 100ms, 110ms] => [-10%, 0%, +10%] const toPercent = 100; let unit; for (let metricIndex = 0; metricIndex < metrics.length; metricIndex++) { const metric = metrics[metricIndex]; // If the mean is 0 we can't normalize values properly. const mean = metric.mean || 1; if (!unit) unit = metric.unit; else if (unit !== metric.unit) throw new Error("All metrics must have the same unit."); let width = metric.delta || 1; let center = mean; if (normalize) { width = (metric.delta / mean) * toPercent; center = 0; } const left = center - width / 2; const y = metricIndex; const label = `Mean: ${metric.valueString}\n` + `Min: ${metric.min.toFixed(2)}${unit}\n` + `Max: ${metric.max.toFixed(2)}${unit}`; const rect = [left, y, label, width]; // Add data for individual points: points.push(rect); const values = metric.values; const length = values.length; for (let i = 0; i < length; i++) { const value = values[i]; let x = value; let normalized = (value / mean - 1) * toPercent; if (normalize) x = normalized; const sign = normalized < 0 ? "-" : "+"; normalized = Math.abs(normalized); // Each value is mapped to a y-coordinate in the range of [metricIndex, metricIndex + 1] const valueOffsetY = length === 1 ? 0.5 : i / length; const y = metricIndex + valueOffsetY; let label = `Iteration ${i}: ${value.toFixed(3)}${unit}\n` + `Normalized: ${metric.mean.toFixed(3)}${unit} ${sign} ${normalized.toFixed(2)}%`; const point = [x, y, label]; points.push(point); } } return points; } function renderScatterPlot({ values, width = 500, height, trackHeight, xAxisPositiveOnly = false, xAxisShowZero = false, xAxisLabel, unit = "", colors = COLORS }) { if (!height && !trackHeight) throw new Error("Either height or trackHeight must be specified"); let xMin = Infinity; let xMax = 0; let yMin = Infinity; let yMax = 0; for (let value of values) { let [x, y] = value; xMin = Math.min(xMin, x); xMax = Math.max(xMax, x); yMin = Math.min(yMin, y); yMax = Math.max(yMax, y); } if (xAxisPositiveOnly) xMin = Math.max(xMin, 0); // Max delta of values across each axis: const trackCount = Math.ceil(yMax - yMin) || 1; const spreadX = xMax - xMin; // Axis + labels height: const axisHeight = 18; const axisMarginY = 4; const trackMargin = 2; let markerSize = 5; // Auto-adjust markers to [2px, 5px] for high iteration counts: const iterationsLimit = 20; if (values.length > iterationsLimit) markerSize = 2 + (3 / values.length) * iterationsLimit; // Recalculate height: if (height) trackHeight = (height - axisHeight - axisMarginY) / trackCount; else height = trackCount * trackHeight + axisHeight + axisMarginY; // Horizontal axis position: const axisY = height - axisHeight + axisMarginY; const unitToPosX = width / spreadX; const unitToPosY = trackHeight - trackMargin - markerSize / 2; const points = values.map(renderValue).join(""); let xAxisZeroLine = ""; if (xAxisShowZero) { const xZeroPos = (0 - xMin) * unitToPosX; xAxisZeroLine = ``; } return ` ${xMin.toFixed(2)}${unit} ${xAxisLabel} ${xMax.toFixed(2)}${unit} ${xAxisZeroLine} ${points} `; function renderValue(value) { const [rawX, rawY, label, rawWidth = 0] = value; const trackIndex = rawY | 0; const y = (rawY - yMin) * unitToPosY + markerSize * trackIndex; const cssClass = colors[trackIndex % colors.length]; if (value.length <= 3) { // Render a simple marker: const x = (rawX - xMin) * unitToPosX; const adjustedY = y + markerSize / 2; return ` ${label} `; } else { // Render a rect with 4 input values: const x = (rawX - xMin) * unitToPosX + rawWidth / 2; const w = rawWidth * unitToPosX; const centerX = x + w / 2; const top = y; const height = trackHeight - trackMargin; const bottom = top + height; return ` ${label} `; } } }