benchforge 0.1.9 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +99 -260
- package/bin/benchforge +1 -2
- package/dist/AnalyzeArchive-8NCJhmhS.mjs +145 -0
- package/dist/AnalyzeArchive-8NCJhmhS.mjs.map +1 -0
- package/dist/BenchMatrix-BZVrBB_h.mjs +1050 -0
- package/dist/BenchMatrix-BZVrBB_h.mjs.map +1 -0
- package/dist/BenchRunner-DglX1NOn.d.mts +302 -0
- package/dist/CoverageSampler-D5T9DRqe.mjs +27 -0
- package/dist/CoverageSampler-D5T9DRqe.mjs.map +1 -0
- package/dist/Formatters-BWj3d4sv.mjs +95 -0
- package/dist/Formatters-BWj3d4sv.mjs.map +1 -0
- package/dist/{HeapSampler-B8dtKHn1.mjs → HeapSampler-Dq-hpXem.mjs} +4 -4
- package/dist/HeapSampler-Dq-hpXem.mjs.map +1 -0
- package/dist/RunBenchCLI-C17DrJz8.mjs +3075 -0
- package/dist/RunBenchCLI-C17DrJz8.mjs.map +1 -0
- package/dist/StatisticalUtils-BD92crgM.mjs +255 -0
- package/dist/StatisticalUtils-BD92crgM.mjs.map +1 -0
- package/dist/TimeSampler-Ds8n7l2B.mjs +29 -0
- package/dist/TimeSampler-Ds8n7l2B.mjs.map +1 -0
- package/dist/ViewerServer-BJhdnxlN.mjs +639 -0
- package/dist/ViewerServer-BJhdnxlN.mjs.map +1 -0
- package/dist/ViewerServer-CuMNdNBz.mjs +2 -0
- package/dist/bin/benchforge.mjs +4 -5
- package/dist/bin/benchforge.mjs.map +1 -1
- package/dist/index.d.mts +731 -522
- package/dist/index.mjs +98 -3
- package/dist/index.mjs.map +1 -0
- package/dist/runners/WorkerScript.d.mts +12 -4
- package/dist/runners/WorkerScript.mjs +92 -120
- package/dist/runners/WorkerScript.mjs.map +1 -1
- package/dist/viewer/assets/CIPlot-BkOvMoMa.js +1 -0
- package/dist/viewer/assets/HistogramKde-CmSyUFY0.js +1 -0
- package/dist/viewer/assets/LegendUtils-BJpbn_jr.js +55 -0
- package/dist/viewer/assets/SampleTimeSeries-C4VBhXr3.js +1 -0
- package/dist/viewer/assets/index-Br9bp_cX.js +153 -0
- package/dist/viewer/assets/index-NzXXe_CC.css +1 -0
- package/dist/viewer/index.html +19 -0
- package/dist/viewer/speedscope/LICENSE +21 -0
- package/dist/viewer/speedscope/SourceCodePro-Regular.ttf-ILST5JV6.woff2 +0 -0
- package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js +2 -0
- package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js.map +7 -0
- package/dist/viewer/speedscope/favicon-16x16-VSI62OPJ.png +0 -0
- package/dist/viewer/speedscope/favicon-32x32-3EB2YCUY.png +0 -0
- package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js +2 -0
- package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js.map +7 -0
- package/dist/viewer/speedscope/favicon-FOKUP5Y5.ico +0 -0
- package/dist/viewer/speedscope/favicon-M34RF7BI.js +2 -0
- package/dist/viewer/speedscope/favicon-M34RF7BI.js.map +7 -0
- package/dist/viewer/speedscope/file-format-schema.json +274 -0
- package/dist/viewer/speedscope/index.html +19 -0
- package/dist/viewer/speedscope/jfrview_bg-BLJXNNQB.wasm +0 -0
- package/dist/viewer/speedscope/perf-vertx-stacks-01-collapsed-all-ZNUIGAJL.txt +199 -0
- package/dist/viewer/speedscope/release.txt +3 -0
- package/dist/viewer/speedscope/source-code-pro.LICENSE.md +93 -0
- package/dist/viewer/speedscope/speedscope-GHPHNKXC.css +2 -0
- package/dist/viewer/speedscope/speedscope-GHPHNKXC.css.map +7 -0
- package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js +212 -0
- package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js.map +7 -0
- package/package.json +52 -26
- package/src/bin/benchforge.ts +2 -2
- package/src/cli/AnalyzeArchive.ts +232 -0
- package/src/cli/BrowserBench.ts +322 -0
- package/src/cli/CliArgs.ts +164 -48
- package/src/cli/CliExport.ts +179 -0
- package/src/cli/CliOptions.ts +147 -0
- package/src/cli/CliReport.ts +197 -0
- package/src/cli/FilterBenchmarks.ts +18 -30
- package/src/cli/RunBenchCLI.ts +138 -844
- package/src/cli/SuiteRunner.ts +160 -0
- package/src/cli/ViewerServer.ts +282 -0
- package/src/export/AllocExport.ts +121 -0
- package/src/export/ArchiveExport.ts +146 -0
- package/src/export/ArchiveFormat.ts +50 -0
- package/src/export/CoverageExport.ts +148 -0
- package/src/export/EditorUri.ts +10 -0
- package/src/export/PerfettoExport.ts +91 -126
- package/src/export/SpeedscopeTypes.ts +98 -0
- package/src/export/TimeExport.ts +115 -0
- package/src/index.ts +87 -62
- package/src/matrix/BenchMatrix.ts +230 -0
- package/src/matrix/CaseLoader.ts +8 -6
- package/src/matrix/MatrixDirRunner.ts +153 -0
- package/src/matrix/MatrixFilter.ts +55 -53
- package/src/matrix/MatrixInlineRunner.ts +50 -0
- package/src/matrix/MatrixReport.ts +94 -254
- package/src/matrix/VariantLoader.ts +9 -9
- package/src/profiling/browser/BenchLoop.ts +51 -0
- package/src/profiling/browser/BrowserCDP.ts +133 -0
- package/src/profiling/browser/BrowserGcStats.ts +33 -0
- package/src/profiling/browser/BrowserProfiler.ts +160 -0
- package/src/profiling/browser/CdpClient.ts +82 -0
- package/src/profiling/browser/CdpPage.ts +138 -0
- package/src/profiling/browser/ChromeLauncher.ts +158 -0
- package/src/profiling/browser/ChromeTraceEvent.ts +28 -0
- package/src/profiling/browser/PageLoadMode.ts +61 -0
- package/src/profiling/node/CoverageSampler.ts +27 -0
- package/src/profiling/node/CoverageTypes.ts +23 -0
- package/src/profiling/node/HeapSampleReport.ts +261 -0
- package/src/{heap-sample → profiling/node}/HeapSampler.ts +55 -13
- package/src/profiling/node/ResolvedProfile.ts +98 -0
- package/src/profiling/node/TimeSampler.ts +57 -0
- package/src/report/BenchmarkReport.ts +146 -0
- package/src/report/Colors.ts +9 -0
- package/src/report/Formatters.ts +110 -0
- package/src/report/GcSections.ts +151 -0
- package/src/{GitUtils.ts → report/GitUtils.ts} +18 -19
- package/src/report/HtmlReport.ts +223 -0
- package/src/report/ParseStats.ts +73 -0
- package/src/report/StandardSections.ts +147 -0
- package/src/report/ViewerSections.ts +286 -0
- package/src/report/text/TableReport.ts +253 -0
- package/src/report/text/TextReport.ts +123 -0
- package/src/runners/AdaptiveWrapper.ts +167 -287
- package/src/runners/BenchRunner.ts +27 -22
- package/src/{Benchmark.ts → runners/BenchmarkSpec.ts} +5 -6
- package/src/runners/CreateRunner.ts +5 -7
- package/src/runners/GcStats.ts +58 -61
- package/src/{MeasuredResults.ts → runners/MeasuredResults.ts} +43 -37
- package/src/runners/MergeBatches.ts +123 -0
- package/src/{NodeGC.ts → runners/NodeGC.ts} +2 -3
- package/src/runners/RunnerOrchestrator.ts +180 -296
- package/src/runners/RunnerUtils.ts +75 -1
- package/src/runners/SampleStats.ts +100 -0
- package/src/runners/TimingRunner.ts +244 -0
- package/src/runners/TimingUtils.ts +3 -2
- package/src/runners/WorkerScript.ts +162 -178
- package/src/stats/BootstrapDifference.ts +282 -0
- package/src/{PermutationTest.ts → stats/PermutationTest.ts} +31 -40
- package/src/stats/StatisticalUtils.ts +445 -0
- package/src/{tests → test}/AdaptiveConvergence.test.ts +10 -10
- package/src/test/AdaptiveRunner.test.ts +39 -41
- package/src/{tests → test}/AdaptiveSampling.test.ts +9 -9
- package/src/test/AdaptiveStatistics.integration.ts +9 -41
- package/src/{tests → test}/BenchMatrix.test.ts +31 -28
- package/src/test/BenchmarkReport.test.ts +63 -13
- package/src/test/BrowserBench.e2e.test.ts +186 -17
- package/src/test/BrowserBench.test.ts +10 -5
- package/src/test/BuildTimeSection.test.ts +130 -0
- package/src/test/CapSamples.test.ts +82 -0
- package/src/test/CoverageExport.test.ts +115 -0
- package/src/test/CoverageSampler.test.ts +33 -0
- package/src/test/HeapAttribution.test.ts +51 -0
- package/src/{tests → test}/MatrixFilter.test.ts +16 -16
- package/src/{tests → test}/MatrixReport.test.ts +1 -1
- package/src/test/PermutationTest.test.ts +1 -1
- package/src/{tests → test}/RealDataValidation.test.ts +6 -6
- package/src/test/RunBenchCLI.test.ts +57 -56
- package/src/test/RunnerOrchestrator.test.ts +12 -12
- package/src/test/StatisticalUtils.test.ts +48 -12
- package/src/{table-util/test → test}/TableReport.test.ts +2 -2
- package/src/test/TestUtils.ts +35 -30
- package/src/test/TimeExport.test.ts +139 -0
- package/src/test/TimeSampler.test.ts +37 -0
- package/src/test/ViewerLive.e2e.test.ts +159 -0
- package/src/test/ViewerStatic.static.e2e.test.ts +137 -0
- package/src/{tests → test}/fixtures/baseline/impl.ts +1 -1
- package/src/{tests → test}/fixtures/bevy30-samples.ts +3 -1
- package/src/test/fixtures/cases/asyncCases.ts +9 -0
- package/src/{tests → test}/fixtures/cases/cases.ts +5 -2
- package/src/test/fixtures/cases/variants/product.ts +2 -0
- package/src/test/fixtures/cases/variants/sum.ts +2 -0
- package/src/test/fixtures/discover/fast.ts +1 -0
- package/src/{tests → test}/fixtures/discover/slow.ts +1 -1
- package/src/test/fixtures/invalid/bad.ts +1 -0
- package/src/test/fixtures/loader/fast.ts +1 -0
- package/src/{tests → test}/fixtures/loader/slow.ts +1 -1
- package/src/test/fixtures/loader/stateful.ts +2 -0
- package/src/test/fixtures/stateful/stateful.ts +2 -0
- package/src/test/fixtures/variants/extra.ts +1 -0
- package/src/test/fixtures/variants/impl.ts +1 -0
- package/src/test/fixtures/worker/fast.ts +1 -0
- package/src/{tests → test}/fixtures/worker/slow.ts +1 -1
- package/src/viewer/DateFormat.ts +30 -0
- package/src/viewer/Helpers.ts +23 -0
- package/src/viewer/LineData.ts +120 -0
- package/src/viewer/Providers.ts +191 -0
- package/src/viewer/ReportData.ts +123 -0
- package/src/viewer/State.ts +49 -0
- package/src/viewer/Theme.ts +15 -0
- package/src/viewer/components/App.tsx +73 -0
- package/src/viewer/components/DropZone.tsx +71 -0
- package/src/viewer/components/LazyPlot.ts +33 -0
- package/src/viewer/components/SamplesPanel.tsx +214 -0
- package/src/viewer/components/Shell.tsx +26 -0
- package/src/viewer/components/SourcePanel.tsx +216 -0
- package/src/viewer/components/SummaryPanel.tsx +332 -0
- package/src/viewer/components/TabBar.tsx +131 -0
- package/src/viewer/components/TabContent.tsx +46 -0
- package/src/viewer/components/ThemeToggle.tsx +50 -0
- package/src/viewer/index.html +20 -0
- package/src/viewer/main.tsx +4 -0
- package/src/viewer/plots/CIPlot.ts +313 -0
- package/src/{html/browser → viewer/plots}/HistogramKde.ts +42 -47
- package/src/viewer/plots/LegendUtils.ts +134 -0
- package/src/viewer/plots/PlotTypes.ts +85 -0
- package/src/viewer/plots/RenderPlots.ts +230 -0
- package/src/viewer/plots/SampleTimeSeries.ts +306 -0
- package/src/viewer/plots/SvgHelpers.ts +136 -0
- package/src/viewer/plots/TimeSeriesMarks.ts +319 -0
- package/src/viewer/report.css +427 -0
- package/src/viewer/shell.css +357 -0
- package/src/viewer/tsconfig.json +11 -0
- package/dist/BenchRunner-CSKN9zPy.d.mts +0 -225
- package/dist/BrowserHeapSampler-DCeL42RE.mjs +0 -202
- package/dist/BrowserHeapSampler-DCeL42RE.mjs.map +0 -1
- package/dist/GcStats-ByEovUi1.mjs +0 -77
- package/dist/GcStats-ByEovUi1.mjs.map +0 -1
- package/dist/HeapSampler-B8dtKHn1.mjs.map +0 -1
- package/dist/TimingUtils-ClclVQ7E.mjs +0 -597
- package/dist/TimingUtils-ClclVQ7E.mjs.map +0 -1
- package/dist/browser/index.js +0 -914
- package/dist/src-Cf_LXwlp.mjs +0 -2873
- package/dist/src-Cf_LXwlp.mjs.map +0 -1
- package/src/BenchMatrix.ts +0 -380
- package/src/BenchmarkReport.ts +0 -156
- package/src/HtmlDataPrep.ts +0 -148
- package/src/StandardSections.ts +0 -261
- package/src/StatisticalUtils.ts +0 -176
- package/src/TypeUtil.ts +0 -8
- package/src/browser/BrowserGcStats.ts +0 -44
- package/src/browser/BrowserHeapSampler.ts +0 -271
- package/src/export/JsonExport.ts +0 -103
- package/src/export/JsonFormat.ts +0 -91
- package/src/heap-sample/HeapSampleReport.ts +0 -196
- package/src/html/HtmlReport.ts +0 -131
- package/src/html/HtmlTemplate.ts +0 -284
- package/src/html/Types.ts +0 -88
- package/src/html/browser/CIPlot.ts +0 -287
- package/src/html/browser/LegendUtils.ts +0 -163
- package/src/html/browser/RenderPlots.ts +0 -263
- package/src/html/browser/SampleTimeSeries.ts +0 -389
- package/src/html/browser/Types.ts +0 -96
- package/src/html/browser/index.ts +0 -1
- package/src/html/index.ts +0 -17
- package/src/runners/BasicRunner.ts +0 -364
- package/src/table-util/ConvergenceFormatters.ts +0 -19
- package/src/table-util/Formatters.ts +0 -152
- package/src/table-util/README.md +0 -70
- package/src/table-util/TableReport.ts +0 -293
- package/src/tests/fixtures/cases/asyncCases.ts +0 -7
- package/src/tests/fixtures/cases/variants/product.ts +0 -2
- package/src/tests/fixtures/cases/variants/sum.ts +0 -2
- package/src/tests/fixtures/discover/fast.ts +0 -1
- package/src/tests/fixtures/invalid/bad.ts +0 -1
- package/src/tests/fixtures/loader/fast.ts +0 -1
- package/src/tests/fixtures/loader/stateful.ts +0 -2
- package/src/tests/fixtures/stateful/stateful.ts +0 -2
- package/src/tests/fixtures/variants/extra.ts +0 -1
- package/src/tests/fixtures/variants/impl.ts +0 -1
- package/src/tests/fixtures/worker/fast.ts +0 -1
- package/src/{table-util/test → test}/TableValueExtractor.test.ts +0 -0
- package/src/{table-util/test → test}/TableValueExtractor.ts +9 -9
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MeasuredResults,
|
|
3
|
+
OptStatusInfo,
|
|
4
|
+
} from "../runners/MeasuredResults.ts";
|
|
5
|
+
import { isBootstrappable } from "../stats/StatisticalUtils.ts";
|
|
6
|
+
import type { ReportSection } from "./BenchmarkReport.ts";
|
|
7
|
+
import { formatConvergence, timeMs } from "./Formatters.ts";
|
|
8
|
+
import { gcSections } from "./GcSections.ts";
|
|
9
|
+
import { parseStatsArg } from "./ParseStats.ts";
|
|
10
|
+
|
|
11
|
+
/** Default timing section: mean, p50, p99. */
|
|
12
|
+
export const timeSection: ReportSection = buildTimeSection();
|
|
13
|
+
|
|
14
|
+
/** Report section: number of sample iterations. */
|
|
15
|
+
export const runsSection: ReportSection = {
|
|
16
|
+
title: "",
|
|
17
|
+
columns: [
|
|
18
|
+
{
|
|
19
|
+
key: "runs",
|
|
20
|
+
title: "runs",
|
|
21
|
+
formatter: v => String(v),
|
|
22
|
+
value: (r: MeasuredResults) => r.samples.length,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Report section: total sampling duration. */
|
|
28
|
+
export const totalTimeSection: ReportSection = {
|
|
29
|
+
title: "",
|
|
30
|
+
columns: [
|
|
31
|
+
{
|
|
32
|
+
key: "totalTime",
|
|
33
|
+
title: "time",
|
|
34
|
+
formatter: formatTotalTime,
|
|
35
|
+
value: (r: MeasuredResults) => r.totalTime,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Report sections: timing stats and convergence for adaptive mode. */
|
|
41
|
+
export const adaptiveSections: ReportSection[] = [
|
|
42
|
+
{
|
|
43
|
+
title: "time",
|
|
44
|
+
columns: [
|
|
45
|
+
{
|
|
46
|
+
key: "median",
|
|
47
|
+
title: "median",
|
|
48
|
+
formatter: timeMs,
|
|
49
|
+
comparable: true,
|
|
50
|
+
statKind: { percentile: 0.5 },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: "mean",
|
|
54
|
+
title: "mean",
|
|
55
|
+
formatter: timeMs,
|
|
56
|
+
comparable: true,
|
|
57
|
+
statKind: "mean",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: "p99",
|
|
61
|
+
title: "p99",
|
|
62
|
+
formatter: timeMs,
|
|
63
|
+
statKind: { percentile: 0.99 },
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
title: "",
|
|
69
|
+
columns: [
|
|
70
|
+
{
|
|
71
|
+
key: "convergence",
|
|
72
|
+
title: "conv%",
|
|
73
|
+
formatter: formatConvergence,
|
|
74
|
+
value: (r: MeasuredResults) => r.convergence?.confidence,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** Report section: V8 optimization tier distribution and deopt count. */
|
|
81
|
+
export const optSection: ReportSection = {
|
|
82
|
+
title: "v8 opt",
|
|
83
|
+
columns: [
|
|
84
|
+
{
|
|
85
|
+
key: "tiers",
|
|
86
|
+
title: "tiers",
|
|
87
|
+
formatter: v => (typeof v === "string" ? v : ""),
|
|
88
|
+
value: (r: MeasuredResults) => {
|
|
89
|
+
const opt = r.optStatus;
|
|
90
|
+
return opt ? formatTierSummary(opt) : undefined;
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: "deopt",
|
|
95
|
+
title: "deopt",
|
|
96
|
+
formatter: v => (typeof v === "number" ? String(v) : ""),
|
|
97
|
+
value: (r: MeasuredResults) => {
|
|
98
|
+
const opt = r.optStatus;
|
|
99
|
+
return opt && opt.deoptCount > 0 ? opt.deoptCount : undefined;
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Build a time section with user-chosen percentile/stat columns. */
|
|
106
|
+
export function buildTimeSection(stats = "mean,p50,p99"): ReportSection {
|
|
107
|
+
const specs = parseStatsArg(stats);
|
|
108
|
+
return {
|
|
109
|
+
title: "time",
|
|
110
|
+
columns: specs.map(s => ({
|
|
111
|
+
key: s.key,
|
|
112
|
+
title: s.title,
|
|
113
|
+
formatter: timeMs,
|
|
114
|
+
comparable: isBootstrappable(s.statKind),
|
|
115
|
+
statKind: s.statKind,
|
|
116
|
+
})),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Format V8 tier distribution sorted by count (e.g. "turbofan:85% sparkplug:15%"). */
|
|
121
|
+
export function formatTierSummary(
|
|
122
|
+
opt: OptStatusInfo,
|
|
123
|
+
sep = ":",
|
|
124
|
+
glue = " ",
|
|
125
|
+
): string {
|
|
126
|
+
const tiers = Object.entries(opt.byTier);
|
|
127
|
+
const total = tiers.reduce((s, [, t]) => s + t.count, 0);
|
|
128
|
+
const pct = (n: number) => `${((n / total) * 100).toFixed(0)}%`;
|
|
129
|
+
return tiers
|
|
130
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
131
|
+
.map(([name, t]) => `${name}${sep}${pct(t.count)}`)
|
|
132
|
+
.join(glue);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @return default report sections from CLI flags (GC stats if enabled, plus run count). */
|
|
136
|
+
export function buildGenericSections(args: {
|
|
137
|
+
"gc-stats"?: boolean;
|
|
138
|
+
alloc?: boolean;
|
|
139
|
+
}): ReportSection[] {
|
|
140
|
+
return [...gcSections(args), runsSection];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Format total time; brackets indicate >= 30s. */
|
|
144
|
+
function formatTotalTime(v: unknown): string {
|
|
145
|
+
if (typeof v !== "number") return "";
|
|
146
|
+
return v >= 30 ? `[${v.toFixed(1)}s]` : `${v.toFixed(1)}s`;
|
|
147
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { MeasuredResults } from "../runners/MeasuredResults.ts";
|
|
2
|
+
import {
|
|
3
|
+
type BlockDiffOptions,
|
|
4
|
+
binBootstrapResult,
|
|
5
|
+
diffCIs,
|
|
6
|
+
} from "../stats/BootstrapDifference.ts";
|
|
7
|
+
import {
|
|
8
|
+
type BootstrapResult,
|
|
9
|
+
bootstrapCIs,
|
|
10
|
+
type DifferenceCI,
|
|
11
|
+
flipCI,
|
|
12
|
+
isBootstrappable,
|
|
13
|
+
type StatKind,
|
|
14
|
+
swapDirection,
|
|
15
|
+
} from "../stats/StatisticalUtils.ts";
|
|
16
|
+
import type {
|
|
17
|
+
BootstrapCIData,
|
|
18
|
+
ViewerEntry,
|
|
19
|
+
ViewerRow,
|
|
20
|
+
ViewerSection,
|
|
21
|
+
} from "../viewer/ReportData.ts";
|
|
22
|
+
import {
|
|
23
|
+
type ComparisonOptions,
|
|
24
|
+
computeColumnValues,
|
|
25
|
+
type ReportColumn,
|
|
26
|
+
type ReportSection,
|
|
27
|
+
type UnknownRecord,
|
|
28
|
+
} from "./BenchmarkReport.ts";
|
|
29
|
+
|
|
30
|
+
/** Context for building viewer rows within a column group */
|
|
31
|
+
interface RowContext {
|
|
32
|
+
current: MeasuredResults;
|
|
33
|
+
baseline?: MeasuredResults;
|
|
34
|
+
curVals: Record<string, unknown>;
|
|
35
|
+
baseVals?: Record<string, unknown>;
|
|
36
|
+
currentMeta?: UnknownRecord;
|
|
37
|
+
baselineMeta?: UnknownRecord;
|
|
38
|
+
comparison?: ComparisonOptions;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Pre-computed bootstrap results for a single column */
|
|
42
|
+
interface ColCIs {
|
|
43
|
+
cur?: BootstrapResult;
|
|
44
|
+
base?: BootstrapResult;
|
|
45
|
+
diff?: DifferenceCI;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Annotatable {
|
|
49
|
+
direction: string;
|
|
50
|
+
label?: string;
|
|
51
|
+
ciReliable?: boolean;
|
|
52
|
+
ciLevel?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const minBatches = 20;
|
|
56
|
+
|
|
57
|
+
/** @return true if comparing with fewer than minBatches on either side */
|
|
58
|
+
export function hasLowBatchCount(
|
|
59
|
+
baseline: MeasuredResults | undefined,
|
|
60
|
+
current: MeasuredResults | undefined,
|
|
61
|
+
): boolean {
|
|
62
|
+
if (!baseline) return false;
|
|
63
|
+
return batchCount(baseline) < minBatches || batchCount(current) < minBatches;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @return true if either side has no real batch structure */
|
|
67
|
+
export function isSingleBatch(
|
|
68
|
+
baseline: MeasuredResults | undefined,
|
|
69
|
+
current: MeasuredResults | undefined,
|
|
70
|
+
): boolean {
|
|
71
|
+
if (!baseline) return batchCount(current) < 2;
|
|
72
|
+
return batchCount(baseline) < 2 || batchCount(current) < 2;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Add label, mark unreliable, and override direction when batch count is low */
|
|
76
|
+
export function annotateCI<T extends Annotatable | undefined>(
|
|
77
|
+
ci: T,
|
|
78
|
+
title?: string,
|
|
79
|
+
lowBatches?: boolean,
|
|
80
|
+
): T {
|
|
81
|
+
if (!ci) return ci;
|
|
82
|
+
if (lowBatches) ci.direction = "uncertain";
|
|
83
|
+
ci.ciReliable = !lowBatches && ci.ciLevel !== "sample";
|
|
84
|
+
if (title) ci.label = `${title} Δ%`;
|
|
85
|
+
return ci;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build ViewerSections from ReportSections, with bootstrap CIs for comparable columns */
|
|
89
|
+
export function buildViewerSections(
|
|
90
|
+
sections: ReportSection[],
|
|
91
|
+
base: Omit<RowContext, "curVals" | "baseVals">,
|
|
92
|
+
): ViewerSection[] {
|
|
93
|
+
const { current, baseline, currentMeta, baselineMeta } = base;
|
|
94
|
+
return sections.flatMap(section => {
|
|
95
|
+
const curVals = computeColumnValues(section, current, currentMeta);
|
|
96
|
+
const baseVals = baseline
|
|
97
|
+
? computeColumnValues(section, baseline, baselineMeta)
|
|
98
|
+
: undefined;
|
|
99
|
+
const ctx: RowContext = { ...base, curVals, baseVals };
|
|
100
|
+
const rows = buildGroupRows(section.columns as ReportColumn[], ctx);
|
|
101
|
+
if (!rows.length) return [];
|
|
102
|
+
return [{ title: section.title, rows } satisfies ViewerSection];
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function batchCount(m?: MeasuredResults): number {
|
|
107
|
+
return m?.batchOffsets?.length ?? 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Build ViewerRow[] for a column group, using shared resampling for statKind columns */
|
|
111
|
+
function buildGroupRows(columns: ReportColumn[], ctx: RowContext): ViewerRow[] {
|
|
112
|
+
const ciMap = buildCIMap(columns, ctx);
|
|
113
|
+
const rows: ViewerRow[] = [];
|
|
114
|
+
for (const col of columns) {
|
|
115
|
+
const key = (col.key ?? col.title) as string;
|
|
116
|
+
const row = buildRow(col, key, ctx, ciMap.get(key));
|
|
117
|
+
if (row) rows.push(row);
|
|
118
|
+
}
|
|
119
|
+
const first = rows.find(r => r.entries.some(e => e.bootstrapCI));
|
|
120
|
+
if (first) first.primary = true;
|
|
121
|
+
return rows;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Compute batched bootstrap CIs, returning a Map keyed by column key */
|
|
125
|
+
function buildCIMap(
|
|
126
|
+
columns: ReportColumn[],
|
|
127
|
+
ctx: RowContext,
|
|
128
|
+
): Map<string, ColCIs> {
|
|
129
|
+
const ciCols = columns.filter(
|
|
130
|
+
c => c.comparable && c.statKind && isBootstrappable(c.statKind),
|
|
131
|
+
);
|
|
132
|
+
const statKinds = ciCols.map(c => c.statKind!);
|
|
133
|
+
const map = new Map<string, ColCIs>();
|
|
134
|
+
if (statKinds.length === 0) return map;
|
|
135
|
+
|
|
136
|
+
const curSamples = ctx.current.samples;
|
|
137
|
+
const baseSamples = ctx.baseline?.samples;
|
|
138
|
+
const curResults =
|
|
139
|
+
curSamples?.length > 1
|
|
140
|
+
? bootstrapCIs(curSamples, ctx.current.batchOffsets, statKinds)
|
|
141
|
+
: undefined;
|
|
142
|
+
const baseResults =
|
|
143
|
+
baseSamples?.length && baseSamples.length > 1
|
|
144
|
+
? bootstrapCIs(baseSamples, ctx.baseline!.batchOffsets, statKinds)
|
|
145
|
+
: undefined;
|
|
146
|
+
const diffResults = buildDiffResults(ciCols, statKinds, ctx);
|
|
147
|
+
|
|
148
|
+
for (let i = 0; i < ciCols.length; i++) {
|
|
149
|
+
const key = (ciCols[i].key ?? ciCols[i].title) as string;
|
|
150
|
+
map.set(key, {
|
|
151
|
+
cur: curResults?.[i],
|
|
152
|
+
base: baseResults?.[i],
|
|
153
|
+
diff: diffResults?.[i],
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return map;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Build a ViewerRow for a column, using pre-computed CIs if available */
|
|
160
|
+
function buildRow(
|
|
161
|
+
col: ReportColumn,
|
|
162
|
+
key: string,
|
|
163
|
+
ctx: RowContext,
|
|
164
|
+
cis?: ColCIs,
|
|
165
|
+
): ViewerRow | undefined {
|
|
166
|
+
const curRaw = ctx.curVals[key];
|
|
167
|
+
const baseRaw = ctx.baseVals?.[key];
|
|
168
|
+
if (curRaw === undefined && baseRaw === undefined) return undefined;
|
|
169
|
+
|
|
170
|
+
const format = (v: unknown) => {
|
|
171
|
+
if (v === undefined) return "";
|
|
172
|
+
return (col.formatter ? col.formatter(v) : String(v)) ?? "";
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Non-comparable: shared single value
|
|
176
|
+
if (!col.comparable) {
|
|
177
|
+
const value = format(curRaw ?? baseRaw);
|
|
178
|
+
if (!value || value === "—") return undefined;
|
|
179
|
+
return {
|
|
180
|
+
label: col.title,
|
|
181
|
+
entries: [{ runName: ctx.current.name, value }],
|
|
182
|
+
shared: true,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Comparable: current + baseline entries, optional CI
|
|
187
|
+
const curEntry = buildEntry(
|
|
188
|
+
ctx.current.name,
|
|
189
|
+
format(curRaw),
|
|
190
|
+
col,
|
|
191
|
+
cis?.cur,
|
|
192
|
+
ctx.current.batchOffsets,
|
|
193
|
+
ctx.currentMeta,
|
|
194
|
+
);
|
|
195
|
+
const entries: ViewerEntry[] = [curEntry];
|
|
196
|
+
if (ctx.baseline && baseRaw !== undefined) {
|
|
197
|
+
const baseEntry = buildEntry(
|
|
198
|
+
"baseline",
|
|
199
|
+
format(baseRaw),
|
|
200
|
+
col,
|
|
201
|
+
cis?.base,
|
|
202
|
+
ctx.baseline.batchOffsets,
|
|
203
|
+
ctx.baselineMeta,
|
|
204
|
+
);
|
|
205
|
+
entries.push(baseEntry);
|
|
206
|
+
}
|
|
207
|
+
return { label: col.title, entries, comparisonCI: cis?.diff };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Compute difference CIs with annotation and higher-is-better flip */
|
|
211
|
+
function buildDiffResults(
|
|
212
|
+
cols: ReportColumn[],
|
|
213
|
+
stats: StatKind[],
|
|
214
|
+
ctx: RowContext,
|
|
215
|
+
): (DifferenceCI | undefined)[] | undefined {
|
|
216
|
+
const { baseline, current, comparison } = ctx;
|
|
217
|
+
if (!baseline?.samples?.length || !current.samples?.length) return undefined;
|
|
218
|
+
|
|
219
|
+
const opts: BlockDiffOptions = {
|
|
220
|
+
equivMargin: comparison?.equivMargin,
|
|
221
|
+
noBatchTrim: comparison?.noBatchTrim,
|
|
222
|
+
};
|
|
223
|
+
const rawCIs = diffCIs(
|
|
224
|
+
baseline.samples,
|
|
225
|
+
baseline.batchOffsets,
|
|
226
|
+
current.samples,
|
|
227
|
+
current.batchOffsets,
|
|
228
|
+
stats,
|
|
229
|
+
opts,
|
|
230
|
+
);
|
|
231
|
+
const lowBatches = hasLowBatchCount(baseline, current);
|
|
232
|
+
return rawCIs.map((ci, i) => {
|
|
233
|
+
if (!ci) return undefined;
|
|
234
|
+
const col = cols[i];
|
|
235
|
+
const adjusted = col.higherIsBetter ? swapDirection(flipCI(ci)) : ci;
|
|
236
|
+
return annotateCI(adjusted, col.title, lowBatches);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Build a ViewerEntry, attaching bootstrap CI data if available */
|
|
241
|
+
function buildEntry(
|
|
242
|
+
runName: string,
|
|
243
|
+
value: string,
|
|
244
|
+
col: ReportColumn,
|
|
245
|
+
result: BootstrapResult | undefined,
|
|
246
|
+
batchOffsets: number[] | undefined,
|
|
247
|
+
metadata?: UnknownRecord,
|
|
248
|
+
): ViewerEntry {
|
|
249
|
+
if (!result) return { runName, value };
|
|
250
|
+
const bootstrapCI = formatBootstrapCI(col, result, batchOffsets, metadata);
|
|
251
|
+
return { runName, value, bootstrapCI };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Format a BootstrapResult into display-domain BootstrapCIData */
|
|
255
|
+
function formatBootstrapCI(
|
|
256
|
+
col: ReportColumn,
|
|
257
|
+
result: BootstrapResult,
|
|
258
|
+
batchOffsets: number[] | undefined,
|
|
259
|
+
metadata?: UnknownRecord,
|
|
260
|
+
): BootstrapCIData {
|
|
261
|
+
const toDisplay = col.toDisplay
|
|
262
|
+
? (v: number) => col.toDisplay!(v, metadata)
|
|
263
|
+
: (v: number) => v;
|
|
264
|
+
const formatValue = (v: number) =>
|
|
265
|
+
(col.formatter ? col.formatter(v) : String(v)) ?? String(v);
|
|
266
|
+
|
|
267
|
+
const binned = binBootstrapResult(result);
|
|
268
|
+
const dLo = toDisplay(binned.ci[0]);
|
|
269
|
+
const dHi = toDisplay(binned.ci[1]);
|
|
270
|
+
const ci = (dLo <= dHi ? [dLo, dHi] : [dHi, dLo]) as [number, number];
|
|
271
|
+
const histogram = binned.histogram.map(b => ({
|
|
272
|
+
x: toDisplay(b.x),
|
|
273
|
+
count: b.count,
|
|
274
|
+
}));
|
|
275
|
+
const ciLabels = [formatValue(ci[0]), formatValue(ci[1])] as [string, string];
|
|
276
|
+
const nBatches = batchOffsets?.length ?? 0;
|
|
277
|
+
const ciReliable = result.ciLevel === "block" && nBatches >= minBatches;
|
|
278
|
+
return {
|
|
279
|
+
estimate: toDisplay(binned.estimate),
|
|
280
|
+
ci,
|
|
281
|
+
histogram,
|
|
282
|
+
ciLabels,
|
|
283
|
+
ciLevel: result.ciLevel,
|
|
284
|
+
ciReliable,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { Alignment, SpanningCellConfig, TableUserConfig } from "table";
|
|
2
|
+
import { table } from "table";
|
|
3
|
+
import colors from "../Colors.ts";
|
|
4
|
+
import { diffPercent } from "../Formatters.ts";
|
|
5
|
+
|
|
6
|
+
/** Named group of columns, rendered with an optional spanning header. */
|
|
7
|
+
export interface ColumnGroup<T> {
|
|
8
|
+
groupTitle?: string;
|
|
9
|
+
columns: AnyColumn<T>[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type AnyColumn<T> = Column<T> | DiffColumn<T>;
|
|
13
|
+
|
|
14
|
+
/** Table column with a value formatter (non-diff). */
|
|
15
|
+
export interface Column<T> extends ColumnFormat<T> {
|
|
16
|
+
formatter?: (value: unknown) => string | null;
|
|
17
|
+
diffKey?: undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Pre-computed header rows and table config for the `table` library. */
|
|
21
|
+
export interface TableSetup {
|
|
22
|
+
headerRows: string[][];
|
|
23
|
+
config: TableUserConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Group of result rows with an optional baseline for diff columns. */
|
|
27
|
+
export interface ResultGroup<T extends Record<string, any>> {
|
|
28
|
+
results: T[];
|
|
29
|
+
baseline?: T;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface DiffColumn<T> extends ColumnFormat<T> {
|
|
33
|
+
diffFormatter?: (value: unknown, baseline: unknown) => string | null;
|
|
34
|
+
formatter?: undefined;
|
|
35
|
+
diffKey: keyof T;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ColumnFormat<T> {
|
|
39
|
+
key: keyof T;
|
|
40
|
+
title: string;
|
|
41
|
+
alignment?: Alignment;
|
|
42
|
+
width?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Lines {
|
|
46
|
+
drawHorizontalLine: (index: number, size: number) => boolean;
|
|
47
|
+
drawVerticalLine: (index: number, size: number) => boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { bold } = colors;
|
|
51
|
+
|
|
52
|
+
const ansiEscapeRegex = new RegExp(
|
|
53
|
+
String.fromCharCode(27) + "\\[[0-9;]*m",
|
|
54
|
+
"g",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** Build formatted table with column groups and baseline diffs. */
|
|
58
|
+
export function buildTable<T extends Record<string, any>>(
|
|
59
|
+
columnGroups: ColumnGroup<T>[],
|
|
60
|
+
resultGroups: ResultGroup<T>[],
|
|
61
|
+
nameKey: keyof T = "name" as keyof T,
|
|
62
|
+
): string {
|
|
63
|
+
const allRecords = flattenGroups(columnGroups, resultGroups, nameKey);
|
|
64
|
+
return createTable(columnGroups, allRecords);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Convert records to string arrays for table rendering. */
|
|
68
|
+
export function toRows<T extends Record<string, any>>(
|
|
69
|
+
records: T[],
|
|
70
|
+
groups: ColumnGroup<T>[],
|
|
71
|
+
): string[][] {
|
|
72
|
+
const allColumns = groups.flatMap(group => group.columns);
|
|
73
|
+
|
|
74
|
+
const rawRows = records.map(record =>
|
|
75
|
+
allColumns.map(col => {
|
|
76
|
+
const value = record[col.key];
|
|
77
|
+
return col.formatter ? col.formatter(value) : value;
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return rawRows.map(row => row.map(cell => cell ?? " "));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Flatten result groups into a single array, inserting blank separator rows. */
|
|
85
|
+
function flattenGroups<T extends Record<string, any>>(
|
|
86
|
+
groups: ColumnGroup<T>[],
|
|
87
|
+
resultGroups: ResultGroup<T>[],
|
|
88
|
+
nameKey: keyof T,
|
|
89
|
+
): T[] {
|
|
90
|
+
return resultGroups.flatMap((group, i) => {
|
|
91
|
+
const records = addBaseline(groups, group, nameKey);
|
|
92
|
+
const isLast = i === resultGroups.length - 1;
|
|
93
|
+
return isLast ? records : [...records, {} as T];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Render column groups and records into a formatted table string. */
|
|
98
|
+
function createTable<T extends Record<string, any>>(
|
|
99
|
+
groups: ColumnGroup<T>[],
|
|
100
|
+
records: T[],
|
|
101
|
+
): string {
|
|
102
|
+
const dataRows = toRows(records, groups);
|
|
103
|
+
const { headerRows, config } = buildTableConfig(groups, dataRows);
|
|
104
|
+
const allRows = [...headerRows, ...dataRows];
|
|
105
|
+
return table(allRows, config);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Append baseline row and inject diff values into result rows. */
|
|
109
|
+
function addBaseline<T extends Record<string, any>>(
|
|
110
|
+
groups: ColumnGroup<T>[],
|
|
111
|
+
group: ResultGroup<T>,
|
|
112
|
+
nameKey: keyof T,
|
|
113
|
+
): T[] {
|
|
114
|
+
const { results, baseline } = group;
|
|
115
|
+
if (!baseline) return results;
|
|
116
|
+
const diffResults = results.map(r => addComparisons(groups, r, baseline));
|
|
117
|
+
const marked = { ...baseline, [nameKey]: `--> ${baseline[nameKey]}` };
|
|
118
|
+
return [...diffResults, marked];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Build header rows, spanning cells, column widths, and border rules. */
|
|
122
|
+
function buildTableConfig<T>(
|
|
123
|
+
groups: ColumnGroup<T>[],
|
|
124
|
+
dataRows: string[][],
|
|
125
|
+
): TableSetup {
|
|
126
|
+
const titles = getTitles(groups);
|
|
127
|
+
const headerRows = [...createGroupHeaders(groups, titles.length), titles];
|
|
128
|
+
const config: TableUserConfig = {
|
|
129
|
+
spanningCells: createSectionSpans(groups),
|
|
130
|
+
columns: calcColumnWidths(groups, titles, dataRows),
|
|
131
|
+
...createLines(groups),
|
|
132
|
+
};
|
|
133
|
+
return { headerRows, config };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Compute formatted diff values by comparing a row against baseline. */
|
|
137
|
+
function addComparisons<T extends Record<string, any>>(
|
|
138
|
+
groups: ColumnGroup<T>[],
|
|
139
|
+
main: T,
|
|
140
|
+
baseline: T,
|
|
141
|
+
): T {
|
|
142
|
+
const cols = groups
|
|
143
|
+
.flatMap(g => g.columns)
|
|
144
|
+
.filter((col): col is DiffColumn<T> => col.diffKey !== undefined);
|
|
145
|
+
const diffs = Object.fromEntries(
|
|
146
|
+
cols.map(col => {
|
|
147
|
+
const fmt = col.diffFormatter ?? diffPercent;
|
|
148
|
+
return [col.key, fmt(main[col.diffKey], baseline[col.diffKey])];
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
return { ...main, ...diffs };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @return bolded column title strings */
|
|
155
|
+
function getTitles<T>(groups: ColumnGroup<T>[]): string[] {
|
|
156
|
+
return groups.flatMap(g => g.columns.map(c => bold(c.title || " ")));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @return header rows with group titles, or empty if no groups have titles. */
|
|
160
|
+
function createGroupHeaders<T>(
|
|
161
|
+
groups: ColumnGroup<T>[],
|
|
162
|
+
numColumns: number,
|
|
163
|
+
): string[][] {
|
|
164
|
+
if (!groups.some(g => g.groupTitle)) return [];
|
|
165
|
+
|
|
166
|
+
const sectionRow = groups.flatMap(g => {
|
|
167
|
+
const title = g.groupTitle ? [bold(g.groupTitle)] : [];
|
|
168
|
+
return padWithBlanks(title, g.columns.length);
|
|
169
|
+
});
|
|
170
|
+
const blankRow = padWithBlanks([], numColumns);
|
|
171
|
+
return [sectionRow, blankRow];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @return spanning cell configs for group title headers */
|
|
175
|
+
function createSectionSpans<T>(groups: ColumnGroup<T>[]): SpanningCellConfig[] {
|
|
176
|
+
const offsets = groupOffsets(groups);
|
|
177
|
+
return groups.map((g, i) => ({
|
|
178
|
+
row: 0,
|
|
179
|
+
col: offsets[i],
|
|
180
|
+
colSpan: g.columns.length,
|
|
181
|
+
alignment: "center" as Alignment,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Calculate column widths based on content, widening to fit group titles. */
|
|
186
|
+
function calcColumnWidths<T>(
|
|
187
|
+
groups: ColumnGroup<T>[],
|
|
188
|
+
titles: string[],
|
|
189
|
+
dataRows: unknown[][],
|
|
190
|
+
): Record<number, { width: number; wrapWord: boolean }> {
|
|
191
|
+
const maxData = (i: number) =>
|
|
192
|
+
dataRows.reduce((m, row) => Math.max(m, cellWidth(row[i])), 0);
|
|
193
|
+
const widths = titles.map((t, i) => Math.max(cellWidth(t), maxData(i)));
|
|
194
|
+
|
|
195
|
+
// Widen columns so group titles fit (accounting for " | " separators)
|
|
196
|
+
const offsets = groupOffsets(groups);
|
|
197
|
+
for (const [i, group] of groups.entries()) {
|
|
198
|
+
const titleWidth = cellWidth(group.groupTitle);
|
|
199
|
+
if (titleWidth <= 0) continue;
|
|
200
|
+
const col = offsets[i];
|
|
201
|
+
const n = group.columns.length;
|
|
202
|
+
const sepWidth = (n - 1) * 3;
|
|
203
|
+
const curWidth = widths.slice(col, col + n).reduce((a, b) => a + b, 0);
|
|
204
|
+
const needed = titleWidth - curWidth - sepWidth;
|
|
205
|
+
if (needed > 0) widths[col + n - 1] += needed;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return Object.fromEntries(
|
|
209
|
+
widths.map((w, i) => [i, { width: w, wrapWord: false }]),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @return draw functions for horizontal/vertical table borders */
|
|
214
|
+
function createLines<T>(groups: ColumnGroup<T>[]): Lines {
|
|
215
|
+
const { sectionBorders, headerBottom } = calcBorders(groups);
|
|
216
|
+
return {
|
|
217
|
+
drawVerticalLine: (i, size) =>
|
|
218
|
+
i === 0 || i === size || sectionBorders.includes(i),
|
|
219
|
+
drawHorizontalLine: (i, size) =>
|
|
220
|
+
i === 0 || i === size || i === headerBottom,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @return array padded with blank strings to the given length */
|
|
225
|
+
function padWithBlanks(arr: string[], length: number): string[] {
|
|
226
|
+
if (arr.length >= length) return arr;
|
|
227
|
+
return [...arr, ...Array(length - arr.length).fill(" ")];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @return cumulative column offsets for each group boundary */
|
|
231
|
+
function groupOffsets<T>(groups: ColumnGroup<T>[]): number[] {
|
|
232
|
+
let offset = 0;
|
|
233
|
+
return groups.map(g => {
|
|
234
|
+
const start = offset;
|
|
235
|
+
offset += g.columns.length;
|
|
236
|
+
return start;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** @return visible length of a cell value, stripping ANSI escape codes. */
|
|
241
|
+
function cellWidth(value: unknown): number {
|
|
242
|
+
if (value == null) return 0;
|
|
243
|
+
const str = String(value);
|
|
244
|
+
return str.replace(ansiEscapeRegex, "").length;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @return vertical line positions between sections and header bottom row. */
|
|
248
|
+
function calcBorders<T>(groups: ColumnGroup<T>[]) {
|
|
249
|
+
const offsets = groupOffsets(groups);
|
|
250
|
+
const sectionBorders = offsets.map((o, i) => o + groups[i].columns.length);
|
|
251
|
+
const headerBottom = groups.length === 0 ? 1 : 3;
|
|
252
|
+
return { sectionBorders, headerBottom };
|
|
253
|
+
}
|