blob: cfc3391fe1e94151a6b8a9df0053a0151f80dfdb [file]
import { makeKey } from '../paramtools';
import { TraceRow } from './trace-types';
/**
* Find start and end indices of rows that are visible within viewMin/Max,
* including one point buffer on each side for continuous lines.
*/
export function getCulledIndices(
rows: any[],
viewMin: number,
viewMax: number,
xAccessor: (r: any) => number = (r) => r.commit_number
): { start: number; end: number } {
let startIdx = 0;
while (startIdx < rows.length && xAccessor(rows[startIdx]) < viewMin) startIdx++;
if (startIdx > 0) startIdx--;
let endIdx = rows.length - 1;
while (endIdx >= 0 && xAccessor(rows[endIdx]) > viewMax) endIdx--;
if (endIdx < rows.length - 1) endIdx++;
return {
start: startIdx,
end: Math.max(startIdx, endIdx),
};
}
/**
* Normalize a set of values based on centering and scaling strategies.
* Operates on a range [start, end] of the input array to avoid allocations.
*/
export function normalizeValues(
vals: number[] | { val: number }[] | { originalVal: number }[],
center: 'none' | 'first' | 'average' | 'median',
scaleStrategy: 'none' | 'minmax' | 'stddev' | 'iqr' | 'smoothed_std',
start: number = 0,
end: number = -1,
valueKey: 'val' | 'originalVal' | null = null
): { offset: number; scale: number } {
const actualEnd = end === -1 ? vals.length - 1 : end;
const count = actualEnd - start + 1;
if (count <= 0 || vals.length === 0) return { offset: 0, scale: 1 };
const getVal = (idx: number): number => {
const item = vals[idx];
if (typeof item === 'number') return item;
if (valueKey) return (item as any)[valueKey];
return (item as any).val;
};
let offset = 0;
if (center === 'first') {
offset = getVal(start);
} else if (center === 'average') {
let sum = 0;
for (let i = start; i <= actualEnd; i++) sum += getVal(i);
offset = sum / count;
} else if (center === 'median') {
const slice = [];
for (let i = start; i <= actualEnd; i++) slice.push(getVal(i));
slice.sort((a, b) => a - b);
offset = slice[Math.floor(slice.length / 2)];
}
let scale = 1;
if (scaleStrategy === 'minmax') {
let min = Infinity;
let max = -Infinity;
for (let i = start; i <= actualEnd; i++) {
const v = getVal(i) - offset;
if (v < min) min = v;
if (v > max) max = v;
}
const range = max - min;
if (range > 0) scale = 1 / range;
} else if (scaleStrategy === 'stddev') {
let sum = 0;
for (let i = start; i <= actualEnd; i++) sum += getVal(i) - offset;
const avg = sum / count;
let sumSqDiff = 0;
for (let i = start; i <= actualEnd; i++) {
sumSqDiff += Math.pow(getVal(i) - offset - avg, 2);
}
const stdDev = Math.sqrt(sumSqDiff / count);
if (stdDev > 0) scale = 1 / stdDev;
} else if (scaleStrategy === 'iqr') {
const centered = [];
for (let i = start; i <= actualEnd; i++) centered.push(getVal(i) - offset);
centered.sort((a, b) => a - b);
const q1 = centered[Math.floor(centered.length * 0.25)];
const q3 = centered[Math.floor(centered.length * 0.75)];
const iqr = q3 - q1;
if (iqr > 0) scale = 1 / iqr;
} else if (scaleStrategy === 'smoothed_std') {
if (count > 1) {
const diffs = [];
for (let i = start + 1; i <= actualEnd; i++) {
diffs.push(Math.abs(getVal(i) - getVal(i - 1)));
}
diffs.sort((a, b) => a - b);
const medianDiff = diffs[Math.floor(diffs.length / 2)];
// 1.4826 is the scale factor for MAD to approximate stdDev for normal distribution
let robustStd = medianDiff * 1.4826;
if (robustStd === 0) {
// Fallback to mean absolute difference if median is 0 (e.g. signal is mostly flat with spikes)
let sumDiff = 0;
for (let i = 0; i < diffs.length; i++) sumDiff += diffs[i];
robustStd = (sumDiff / diffs.length) * 1.2533; // sqrt(pi/2) factor for mean absolute deviation
}
if (robustStd > 0) scale = 1 / robustStd;
} else {
// Fallback for single point
scale = 1;
}
}
return { offset, scale };
}
/**
* Calculates measurement differences (delta and percentage)
*/
export function calculateMeasurement(
startVal: number,
endVal: number
): { diff: number; percent: number } {
const diff = endVal - startVal;
const percent = startVal !== 0 ? (diff / Math.abs(startVal)) * 100 : 0;
return { diff, percent };
}
const HIDDEN_PARAMS = new Set(['master', 'unit', 'improvement_direction']);
function parseId(id: string): Record<string, string> {
const pairs: Record<string, string> = {};
id.split(',').forEach((p) => {
const parts = p.split('=');
if (parts.length === 2) {
pairs[parts[0]] = parts[1];
}
});
return pairs;
}
export function computeDiffParamNames(series: { id: string }[]): Map<string, string> {
const map = new Map<string, string>();
if (series.length === 0) return map;
const parsed: Record<string, string>[] = [];
series.forEach((s) => {
const pairs = parseId(s.id);
parsed.push(pairs);
});
if (parsed.length === 0) return map;
const keys = Object.keys(parsed[0]);
const commonKeys: string[] = [];
keys.forEach((k) => {
if (HIDDEN_PARAMS.has(k)) return;
const firstVal = parsed[0][k];
const allMatch = parsed.every((p) => p[k] === firstVal);
if (allMatch) {
commonKeys.push(k);
}
});
series.forEach((s, idx) => {
const p = parsed[idx];
const diffs = Object.entries(p)
.filter(([k]) => !HIDDEN_PARAMS.has(k) && !commonKeys.includes(k))
.map(([k, v]) => `${k}=${v}`)
.join(', ');
map.set(s.id, diffs || 'Shared Baseline');
});
return map;
}
export function computeChartDimensions(series: { id: string }[]): string[] {
if (series.length === 0) return [];
const parsed: Record<string, string>[] = [];
const allKeys = new Set<string>();
series.forEach((s) => {
const pairs = parseId(s.id);
parsed.push(pairs);
Object.keys(pairs).forEach((k) => allKeys.add(k));
});
const differingKeys: string[] = [];
allKeys.forEach((k) => {
if (HIDDEN_PARAMS.has(k)) return;
const firstVal = parsed[0][k];
const allMatch = parsed.every((p) => p[k] === firstVal);
if (!allMatch) {
differingKeys.push(k);
}
});
return differingKeys.sort();
}
export function computeTraceDiffs(
series: { id: string; rows: any[] }[],
diffBase: { key: string; value: string }
): { id: string; rows: any[] }[] {
const bases: { id: string; rows: any[] }[] = [];
const targets: { id: string; rows: any[] }[] = [];
series.forEach((s) => {
const params = parseId(s.id);
if (params[diffBase.key] === diffBase.value) {
bases.push(s);
} else {
targets.push(s);
}
});
if (bases.length === 0) return series;
const baseMap = new Map<string, { id: string; rows: any[] }>();
bases.forEach((b) => {
const params = parseId(b.id);
const sig = Object.entries(params)
.filter(([k]) => k !== diffBase.key && !HIDDEN_PARAMS.has(k))
.sort((e1, e2) => e1[0].localeCompare(e2[0]))
.map(([k, v]) => `${k}=${v}`)
.join('|');
baseMap.set(sig, b);
});
const diffs: { id: string; rows: any[] }[] = [];
targets.forEach((t) => {
const params = parseId(t.id);
const sig = Object.entries(params)
.filter(([k]) => k !== diffBase.key && !HIDDEN_PARAMS.has(k))
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([k, v]) => `${k}=${v}`)
.join('|');
const base = baseMap.get(sig);
if (!base) return;
const sortedBaseRows = [...base.rows].sort((a, b) => a.commit_number - b.commit_number);
const newRows = t.rows
.map((r) => {
let bVal: number | null = null;
let low = 0,
high = sortedBaseRows.length - 1;
let idx = -1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (sortedBaseRows[mid].commit_number >= r.commit_number) {
idx = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
if (idx !== -1) {
const r2 = sortedBaseRows[idx];
if (r2.commit_number === r.commit_number) {
bVal = r2.val;
} else if (idx > 0) {
const rPrev = sortedBaseRows[idx - 1];
const distNext = r2.commit_number - r.commit_number;
const distPrev = r.commit_number - rPrev.commit_number;
if (distNext < distPrev) {
bVal = r2.val;
} else {
bVal = rPrev.val;
}
} else {
bVal = r2.val;
}
} else if (sortedBaseRows.length > 0) {
bVal = sortedBaseRows[sortedBaseRows.length - 1].val;
}
if (bVal !== null && bVal !== 0) {
return { ...r, val: (r.val - bVal) / bVal };
}
return null;
})
.filter((r) => r !== null);
if (newRows.length > 0) {
diffs.push({ ...t, rows: newRows as any[] });
}
});
return diffs;
}
export function computeSplitGroups(
series: { id: string; rows: any[] }[],
splitKeys: Set<string>
): { id: string; title: string; series: { id: string; rows: any[] }[] }[] {
if (series.length === 0) return [];
const effectiveKeys = Array.from(splitKeys);
const groups: Record<string, { id: string; rows: any[] }[]> = {};
const keyValues: Record<string, Set<string>> = {};
effectiveKeys.forEach((k) => (keyValues[k] = new Set()));
series.forEach((s) => {
const params = parseId(s.id);
const values = effectiveKeys.map((k) => {
const val = params[k] || '(missing)';
keyValues[k].add(val);
return val;
});
const key = values.join('|');
if (!groups[key]) groups[key] = [];
groups[key].push(s);
});
const sortedGroups = Object.entries(groups)
.map(([compKey, groupSeries]) => {
const parsed = groupSeries.map((s) => parseId(s.id));
const allKeys = new Set<string>();
parsed.forEach((p) => Object.keys(p).forEach((k) => allKeys.add(k)));
const titleParts: string[] = [];
allKeys.forEach((k) => {
if (HIDDEN_PARAMS.has(k)) return;
const firstVal = parsed[0][k];
if (firstVal !== undefined) {
const allMatch = parsed.every((p) => p[k] === firstVal);
if (allMatch) {
titleParts.push(`${k}=${firstVal}`);
}
}
});
titleParts.sort();
const values = compKey.split('|');
return {
id: compKey,
title: titleParts.join(', ') || 'Global Traces',
series: groupSeries,
values,
};
})
.sort((a, b) => {
for (let i = 0; i < effectiveKeys.length; i++) {
const valA = a.values[i];
const valB = b.values[i];
const cmp = valA.localeCompare(valB);
if (cmp !== 0) return cmp;
}
return 0;
});
return sortedGroups.map((g) => ({
id: g.id,
title: g.title,
series: g.series,
}));
}
/**
* Paginate traces after initial grouping to preserve group order but limit total visible traces.
*/
export function paginateTraces<T>(sortedGroups: T[][], pageSize: number): T[][] {
const pages: T[][] = [];
let currentPage: T[] = [];
for (const group of sortedGroups) {
if (group.length <= pageSize) {
if (currentPage.length + group.length <= pageSize) {
currentPage.push(...group);
} else {
if (currentPage.length > 0) pages.push(currentPage);
currentPage = [...group];
}
} else {
if (currentPage.length > 0) pages.push(currentPage);
currentPage = [];
for (let i = 0; i < group.length; i += pageSize) {
pages.push(group.slice(i, i + pageSize));
}
}
}
if (currentPage.length > 0) pages.push(currentPage);
return pages;
}
/**
* Calculates the min and max commit numbers for each trace in the series.
*/
export function calculateLoadedBounds(
series: { id: string; rows: { commit_number: number; createdat?: number }[] }[],
byTime: boolean = false
): Record<string, { min: number; max: number }> {
const bounds: Record<string, { min: number; max: number }> = {};
series.forEach((s) => {
if (s.rows && s.rows.length > 0) {
let min = Infinity;
let max = -Infinity;
s.rows.forEach((r) => {
const val = byTime ? r.createdat : r.commit_number;
if (val !== undefined) {
if (val < min) min = val;
if (val > max) max = val;
}
});
bounds[s.id] = { min, max };
}
});
return bounds;
}
export function computeLeftPadding(maxY: number, minY: number): number {
const maxLabel = Math.max(Math.abs(maxY), Math.abs(minY)).toFixed(2);
const length = maxLabel.length;
// Estimate width: 6 pixels per character + 25 pixels for margin and title
const estimatedWidth = length * 6 + 25;
return Math.max(60, estimatedWidth);
}
export function calculateSharedBounds(
series: { id: string; source?: string; rows: any[] }[],
globalBounds: Record<string, { min: number; max: number }> | null,
isDateMode: boolean = false,
xAccessor: (r: any) => number = (r) => r.commit_number
): Record<string, { min: number; max: number }> | null {
if (!series || series.length === 0) return null;
const boundsBySource: Record<string, { min: number; max: number }> = {};
// First pass: Only look at actual loaded rows
series.forEach((s) => {
const source = s.source || 'chrome';
if (!boundsBySource[source]) boundsBySource[source] = { min: Infinity, max: -Infinity };
s.rows.forEach((r) => {
const val = xAccessor(r);
if (val < boundsBySource[source].min) boundsBySource[source].min = val;
if (val > boundsBySource[source].max) boundsBySource[source].max = val;
});
});
// Second pass: If we have literally no rows anywhere, THEN fallback to global bounds
Object.keys(boundsBySource).forEach((source) => {
if (boundsBySource[source].min === Infinity) {
// ONLY fallback to globalBounds if we are NOT in date mode.
if (!isDateMode) {
series
.filter((s) => (s.source || 'chrome') === source)
.forEach((s) => {
const gb = globalBounds?.[s.id];
if (gb) {
if (gb.min < boundsBySource[source].min) boundsBySource[source].min = gb.min;
if (gb.max > boundsBySource[source].max) boundsBySource[source].max = gb.max;
}
});
}
if (boundsBySource[source].min === Infinity) {
delete boundsBySource[source];
}
}
});
return Object.keys(boundsBySource).length > 0 ? boundsBySource : null;
}
const MISSING_DATA_SENTINEL = 1e32;
const avg = (arr: number[], i: number, windowSize: number): number => {
const start = i - windowSize + 1;
const end = i;
const slice = arr
.slice(Math.max(0, start), Math.min(arr.length, end + 1))
.filter((v) => v !== MISSING_DATA_SENTINEL);
if (slice.length === 0) return MISSING_DATA_SENTINEL;
return slice.reduce((a, b) => a + b, 0) / slice.length;
};
const median = (arr: number[], i: number, windowSize: number): number => {
const start = i - windowSize + 1;
const end = i;
const slice = arr
.slice(Math.max(0, start), Math.min(arr.length, end + 1))
.filter((v) => v !== MISSING_DATA_SENTINEL);
if (slice.length === 0) return MISSING_DATA_SENTINEL;
slice.sort((a, b) => a - b);
return slice[Math.floor(slice.length / 2)];
};
const stddev = (arr: number[], i: number, windowSize: number): number => {
const start = i - windowSize + 1;
const end = i;
const slice = arr
.slice(Math.max(0, start), Math.min(arr.length, end + 1))
.filter((v) => v !== MISSING_DATA_SENTINEL);
if (slice.length === 0) return MISSING_DATA_SENTINEL;
const avgVal = slice.reduce((a, b) => a + b, 0) / slice.length;
const sumSq = slice.reduce((sum, v) => sum + Math.pow(v - avgVal, 2), 0);
return Math.sqrt(sumSq / slice.length);
};
export interface TransformableSeries {
id: string;
originalId?: string;
rows: TraceRow[];
[key: string]: any;
}
export function computeCustomTransforms(
series: TransformableSeries[],
preset: string
): TransformableSeries[] {
if (!preset || preset === 'none') return series;
const result: TransformableSeries[] = [];
series.forEach((s) => {
result.push(s);
const params = parseId(s.id);
params['special'] = 'transform';
const diffId = makeKey(params);
const rows = s.rows || [];
const X = rows.map((r: TraceRow) => r.val);
const commits = rows.map((r: TraceRow) => r.commit_number);
const diffRows: TraceRow[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
let val = MISSING_DATA_SENTINEL;
if (preset === 'delta') {
if (i > 0) {
val = X[i] - X[i - 1];
}
} else if (preset === 'rel_delta') {
if (i > 0 && X[i - 1] !== 0 && X[i - 1] !== MISSING_DATA_SENTINEL) {
val = (X[i] - X[i - 1]) / X[i - 1];
}
} else if (preset === 'velocity') {
if (i > 0 && commits[i] !== commits[i - 1]) {
val = (X[i] - X[i - 1]) / (commits[i] - commits[i - 1]);
}
} else if (preset === 'avg3') {
val = avg(X, i, 3);
} else if (preset === 'median3') {
val = median(X, i, 3);
} else if (preset === 'stddev10') {
val = stddev(X, i, 10);
}
if (val !== MISSING_DATA_SENTINEL && !isNaN(val)) {
diffRows.push({
...row,
val: val,
smoothedVal: undefined,
});
}
}
if (diffRows.length > 0) {
result.push({
...s,
id: diffId,
originalId: s.id,
rows: diffRows,
});
}
});
return result;
}