1import type { Point } from "chart.js"; 2import type { Series } from "../types/chart.js"; 3import type { ChartData, Metric, Metrics, Range } from "../types/data.js"; 4import type { Mapper } from "./data-transforms.js"; 5 6const SAMPLED_SUFFIX = '(S)'; 7 8function sampledRanges(metrics: Metrics<number>): Record<string, Range> { 9 const ranges: Record<string, Range> = {}; 10 const sampled = metrics.sampled; 11 if (sampled) { 12 for (let i = 0; i < sampled.length; i += 1) { 13 const metric = sampled[i]; 14 const label = rangeLabel(metric); 15 let range = ranges[label]; 16 if (!range) { 17 range = { 18 label: label, 19 min: Number.MAX_VALUE, 20 max: Number.MIN_VALUE 21 }; 22 } 23 const data: Record<string, ChartData<number[]>> = metric.data; 24 const chartData: ChartData<number[]>[] = Object.values(data); 25 for (let j = 0; j < chartData.length; j++) { 26 const values = chartData[j].values.flat(); 27 for (let k = 0; k < values.length; k++) { 28 if (values[k] < range.min) { 29 range.min = values[k]; 30 } 31 if (values[k] > range.max) { 32 range.max = values[k]; 33 } 34 } 35 } 36 ranges[label] = range; 37 } 38 } 39 return ranges; 40} 41 42function sampledMapper(metric: Metric<number[]>, buckets: number, range: Range | null): Series[] { 43 const series: Series[] = []; 44 const data: Record<string, ChartData<number[]>> = metric.data; 45 const entries = Object.entries(data); 46 for (let i = 0; i < entries.length; i += 1) { 47 const [source, chartData] = entries[i]; 48 const label = labelFor(metric, source, true); 49 const [points, _, __] = histogramPoints(chartData.values, buckets, /* target */ undefined, range); 50 series.push({ 51 descriptiveLabel: label, 52 type: "line", 53 data: points, 54 options: { 55 tension: 0.3 56 } 57 }); 58 } 59 return series; 60} 61 62function standardMapper(metric: Metric<number>): Series[] { 63 const series: Series[] = []; 64 const data: Record<string, ChartData<number>> = metric.data; 65 const entries = Object.entries(data); 66 for (let i = 0; i < entries.length; i += 1) { 67 const [source, chartData] = entries[i]; 68 const label = labelFor(metric, source, false); 69 const points = singlePoints(chartData.values); 70 series.push({ 71 descriptiveLabel: label, 72 type: "line", 73 data: points, 74 options: { 75 tension: 0.3 76 } 77 }); 78 } 79 return series; 80} 81 82export function histogramPoints( 83 runs: number[][], 84 buckets: number = 100, 85 target: number | null = null, 86 range: Range | null = null, 87): [Point[], Point[] | null, number | null] { 88 const flattened = runs.flat(); 89 // Actuals 90 let min: number; 91 let max: number; 92 if (range) { 93 min = range.min; 94 max = range.max; 95 } else { 96 // Use a custom comparator, given the default coerces numbers 97 // to a string type. 98 flattened.sort((a, b) => a - b); 99 // Natural Ranges 100 const nmin = flattened[0]; 101 const nmax = flattened[flattened.length - 1]; 102 min = nmin; 103 max = nmax; 104 } 105 let targetPoints: Point[] | null = null; 106 let pMin: number = 0; 107 let pMax: number = 0; 108 let maxFreq: number = 0; 109 const histogram: Point[] = new Array(buckets).fill(null); 110 // The actual number of slots in the histogram 111 const slots = buckets - 1; 112 for (let i = 0; i < buckets; i += 1) { 113 const interpolated = interpolate(i / slots, min, max); 114 histogram[i] = { x: interpolated, y: 0 }; 115 } 116 for (let i = 0; i < flattened.length; i += 1) { 117 const value = flattened[i]; 118 if (target && value < target) { 119 pMin += 1; 120 } 121 if (target && value >= target) { 122 pMax += 1; 123 } 124 const n = normalize(value, min, max); 125 const index = Math.ceil(n * slots); 126 histogram[index].y = histogram[index].y + 1; 127 if (maxFreq < histogram[index].y) { 128 maxFreq = histogram[index].y; 129 } 130 } 131 if (target) { 132 const n = normalize(target, min, max); 133 const index = Math.ceil(n * slots); 134 targetPoints = selectPoints(buckets, index, maxFreq); 135 } 136 // Pay attention to both sides of the normal distribution. 137 let p = Math.min(pMin / flattened.length, pMax / flattened.length); 138 return [histogram, targetPoints, p]; 139} 140 141function selectPoints(buckets: number, index: number, target: number) { 142 const points: Point[] = []; 143 for (let i = 0; i < buckets; i += 1) { 144 const y = i == index ? target : 0; 145 points.push({ 146 x: i + 1, // 1 based index 147 y: y 148 }); 149 } 150 return points; 151} 152 153function singlePoints(runs: number[]): Point[] { 154 const points: Point[] = []; 155 for (let i = 0; i < runs.length; i += 1) { 156 points.push({ 157 x: i + 1, // 1 based index 158 y: runs[i] 159 }); 160 } 161 return points; 162} 163 164function normalize(n: number, min: number, max: number): number { 165 if (n < min || n > max) { 166 console.warn(`Warning n(${n}) is not in the range of (${min}, ${max})`); 167 if (n < min) { 168 n = min; 169 } 170 if (n > max) { 171 n = max; 172 } 173 } 174 return (n - min) / ((max - min) + 1e-9); 175} 176 177function interpolate(normalized: number, min: number, max: number): number { 178 const range = max - min; 179 const value = normalized * range; 180 return value + min; 181} 182 183/** 184 * Generates a series label. 185 */ 186function labelFor<T>(metric: Metric<T>, source: string, sampled: boolean): string { 187 const suffix = sampled ? SAMPLED_SUFFIX : ''; 188 return `${source} {${metric.class} ${metric.benchmark}} - ${metric.label} ${suffix}`; 189} 190 191export function datasetName(metric: Metric<any>): string { 192 return `${metric.class}_${metric.benchmark}`; 193} 194 195/** 196 * Helps build cache keys for ranges to ensure we are 197 * comparing equal distributions. 198 */ 199function rangeLabel(metric: Metric<unknown>): string { 200 return `${metric.label}`; 201} 202 203/** 204 * The Standard Mapper. 205 */ 206class StandardMapper { 207 constructor(private buckets: number) { 208 // Does nothing. 209 } 210 // Delegate 211 rangeLabel(metric: Metric<unknown>): string { 212 return rangeLabel(metric); 213 } 214 standard(metric: Metric<number>): Series[] { 215 return standardMapper(metric); 216 } 217 sampled(metric: Metric<number[]>, range: Range | null): Series[] { 218 return sampledMapper(metric, this.buckets, range); 219 } 220 sampledRanges(metrics: Metrics<number>): Record<string, Range> { 221 return sampledRanges(metrics); 222 } 223} 224 225/** 226 * Builds a Standard mapper. 227 * @param buckets are the number of buckets in the histogram to use. 228 * @return an instance of `Mapper`. 229 */ 230export function buildMapper(buckets: number): Mapper<number> { 231 return new StandardMapper(buckets); 232} 233 234export function isSampled(label: string | null | undefined): boolean { 235 if (label) { 236 return label.indexOf(SAMPLED_SUFFIX) >= 0; 237 } 238 return false; 239} 240