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