niceeval 0.4.6 → 0.5.1
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/docs-site/zh/concepts/adapter.mdx +6 -6
- package/docs-site/zh/concepts/experiment.mdx +7 -7
- package/docs-site/zh/concepts/overview.mdx +1 -1
- package/docs-site/zh/concepts/tier.mdx +6 -6
- package/docs-site/zh/guides/agent-feedback-loop.mdx +64 -21
- package/docs-site/zh/guides/connect-your-agent.mdx +7 -7
- package/docs-site/zh/guides/custom-reports.mdx +243 -0
- package/docs-site/zh/guides/experiments.mdx +3 -3
- package/docs-site/zh/guides/report-components.mdx +252 -0
- package/docs-site/zh/guides/reporters.mdx +1 -1
- package/docs-site/zh/guides/results-data.mdx +224 -0
- package/docs-site/zh/guides/runner.mdx +1 -1
- package/docs-site/zh/guides/sandbox-agent.mdx +2 -2
- package/docs-site/zh/guides/viewing-results.mdx +147 -77
- package/docs-site/zh/guides/write-experiment.mdx +9 -9
- package/docs-site/zh/guides/write-send.mdx +6 -6
- package/docs-site/zh/index.mdx +1 -1
- package/docs-site/zh/introduction.mdx +1 -1
- package/docs-site/zh/reference/cli.mdx +11 -3
- package/docs-site/zh/reference/define-agent.mdx +5 -5
- package/docs-site/zh/reference/define-config.mdx +1 -1
- package/docs-site/zh/reference/define-eval.mdx +3 -3
- package/package.json +3 -2
- package/src/agents/ai-sdk.test.ts +1 -1
- package/src/agents/ai-sdk.ts +2 -2
- package/src/agents/claude-code.ts +1 -1
- package/src/agents/codex.ts +2 -2
- package/src/agents/streaming.test.ts +1 -1
- package/src/agents/types.ts +4 -4
- package/src/agents/ui-message-stream.test.ts +1 -1
- package/src/cli.ts +120 -18
- package/src/context/context.test.ts +1 -1
- package/src/context/context.ts +3 -3
- package/src/context/session.ts +2 -2
- package/src/context/types.ts +2 -2
- package/src/i18n/en.ts +29 -7
- package/src/i18n/zh-CN.ts +24 -2
- package/src/report/aggregate.ts +79 -50
- package/src/report/components.tsx +190 -0
- package/src/report/compute.ts +149 -84
- package/src/report/default-report.tsx +222 -0
- package/src/report/dual-face.test.tsx +527 -0
- package/src/report/format.ts +29 -0
- package/src/report/index.ts +79 -24
- package/src/report/load.ts +67 -0
- package/src/report/metrics.ts +2 -1
- package/src/report/param.ts +18 -0
- package/src/report/primitives.tsx +117 -0
- package/src/report/react/CaseList.tsx +2 -2
- package/src/report/react/DeltaTable.tsx +2 -2
- package/src/report/react/MetricBars.tsx +109 -0
- package/src/report/react/MetricLine.tsx +200 -0
- package/src/report/react/MetricMatrix.tsx +1 -1
- package/src/report/react/MetricScatter.tsx +1 -1
- package/src/report/react/MetricTable.tsx +1 -1
- package/src/report/react/RunOverview.tsx +4 -4
- package/src/report/react/Scoreboard.tsx +3 -3
- package/src/report/react/cell.tsx +1 -1
- package/src/report/react/fixtures.ts +98 -36
- package/src/report/react/format.ts +3 -28
- package/src/report/react/index.tsx +41 -24
- package/src/report/react/render.test.tsx +8 -8
- package/src/report/react/styles.css +77 -0
- package/src/report/report.test.ts +248 -128
- package/src/report/report.ts +69 -0
- package/src/report/text/faces.ts +356 -0
- package/src/report/text/layout.ts +125 -0
- package/src/report/text/plot.ts +127 -0
- package/src/report/tree.ts +220 -0
- package/src/report/types.ts +75 -27
- package/src/report/web.ts +40 -0
- package/src/results/copy.ts +95 -41
- package/src/results/format.ts +12 -11
- package/src/results/index.ts +34 -17
- package/src/results/open.ts +201 -81
- package/src/results/results.test.ts +595 -191
- package/src/results/select.ts +107 -55
- package/src/results/types.ts +137 -45
- package/src/results/writer.ts +258 -0
- package/src/runner/attempt.ts +3 -3
- package/src/runner/fingerprint.ts +1 -1
- package/src/runner/reporters/artifacts.ts +38 -78
- package/src/runner/reporters/braintrust.test.ts +2 -2
- package/src/runner/reporters/braintrust.ts +3 -3
- package/src/runner/run.ts +1 -1
- package/src/runner/types.ts +18 -12
- package/src/show/compose.ts +215 -0
- package/src/show/index.ts +263 -0
- package/src/show/render.ts +456 -0
- package/src/show/show.test.ts +505 -0
- package/src/view/app/App.tsx +85 -24
- package/src/view/app/components/CostScoreChart.tsx +29 -10
- package/src/view/app/components/ExperimentTable.tsx +27 -9
- package/src/view/app/components/GroupSelector.tsx +1 -1
- package/src/view/app/i18n.ts +18 -3
- package/src/view/app/lib/attempt-route.test.ts +9 -9
- package/src/view/app/lib/attempt-route.ts +5 -5
- package/src/view/app/lib/outcome.ts +1 -3
- package/src/view/app/lib/rows.ts +95 -15
- package/src/view/app/main.tsx +18 -6
- package/src/view/app/pages/RunsPage.tsx +4 -7
- package/src/view/app/pages/TracesPage.tsx +3 -8
- package/src/view/app/types.ts +50 -6
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +20 -20
- package/src/view/data.test.ts +254 -0
- package/src/view/data.ts +336 -0
- package/src/view/index.ts +64 -10
- package/src/view/server.ts +31 -9
- package/src/view/shared/types.ts +47 -41
- package/src/view/styles.css +5 -0
- package/src/view/template.html +1 -0
- package/src/view/view-report.test.ts +308 -0
- package/docs-site/.mintignore +0 -13
- package/docs-site/AGENTS.md +0 -46
- package/docs-site/concepts/adapter.mdx +0 -287
- package/docs-site/concepts/assert.mdx +0 -243
- package/docs-site/concepts/drive.mdx +0 -118
- package/docs-site/concepts/evals.mdx +0 -108
- package/docs-site/concepts/experiment.mdx +0 -37
- package/docs-site/concepts/hitl.mdx +0 -83
- package/docs-site/concepts/judge.mdx +0 -129
- package/docs-site/concepts/overview.mdx +0 -98
- package/docs-site/concepts/tier.mdx +0 -42
- package/docs-site/docs-ref/00-index.md +0 -23
- package/docs-site/docs-ref/01-page-types.md +0 -43
- package/docs-site/docs-ref/03-style-rules.md +0 -45
- package/docs-site/docs-ref/04-code-examples.md +0 -37
- package/docs-site/docs-ref/05-dx-failure-paths.md +0 -45
- package/docs-site/docs-ref/06-checklists.md +0 -53
- package/docs-site/docs.json +0 -256
- package/docs-site/example/ai-agent-application.mdx +0 -148
- package/docs-site/example/claude-code-codex-plugin.mdx +0 -162
- package/docs-site/example/claude-code-codex-skill.mdx +0 -147
- package/docs-site/example/showcase.mdx +0 -39
- package/docs-site/example/tier1-ai-sdk-v7.mdx +0 -136
- package/docs-site/example/tier1-claude-sdk.mdx +0 -119
- package/docs-site/example/tier1-codex-sdk.mdx +0 -111
- package/docs-site/example/tier1-langgraph.mdx +0 -119
- package/docs-site/example/tier1-pi-sdk.mdx +0 -119
- package/docs-site/favicon.svg +0 -5
- package/docs-site/github-diff.css +0 -135
- package/docs-site/github-diff.js +0 -11
- package/docs-site/guides/authoring.mdx +0 -129
- package/docs-site/guides/ci-integration.mdx +0 -97
- package/docs-site/guides/connect-otel.mdx +0 -209
- package/docs-site/guides/connect-your-agent.mdx +0 -221
- package/docs-site/guides/dataset-fanout.mdx +0 -98
- package/docs-site/guides/experiments.mdx +0 -71
- package/docs-site/guides/fixtures.mdx +0 -147
- package/docs-site/guides/reporters.mdx +0 -113
- package/docs-site/guides/runner.mdx +0 -79
- package/docs-site/guides/sandbox-agent.mdx +0 -138
- package/docs-site/guides/sandbox-backends.mdx +0 -64
- package/docs-site/guides/scoring-guide.mdx +0 -92
- package/docs-site/guides/viewing-results.mdx +0 -195
- package/docs-site/guides/write-experiment.mdx +0 -81
- package/docs-site/guides/write-send.mdx +0 -347
- package/docs-site/index.mdx +0 -181
- package/docs-site/introduction.mdx +0 -141
- package/docs-site/quickstart.mdx +0 -136
- package/docs-site/reference/builtin-agents.mdx +0 -161
- package/docs-site/reference/capabilities.mdx +0 -76
- package/docs-site/reference/cli.mdx +0 -120
- package/docs-site/reference/define-agent.mdx +0 -168
- package/docs-site/reference/define-config.mdx +0 -41
- package/docs-site/reference/define-eval.mdx +0 -160
- package/docs-site/reference/events.mdx +0 -131
- package/docs-site/reference/expect.mdx +0 -112
- package/docs-site/tracker.js +0 -8
- package/src/report/react/data.ts +0 -17
- package/src/view/aggregate.ts +0 -103
- package/src/view/loader.test.ts +0 -61
- package/src/view/loader.ts +0 -257
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// defineReport:一份报告 = 一个报告文件(默认导出),两扇门(show / view)共用。
|
|
2
|
+
// 宿主打开结果目录、按官方口径挑好选集、注入上下文;报告函数折数据、摆积木,
|
|
3
|
+
// 返回一棵组件树。计算全部发生在报告函数体里(读句柄、await 折数据只在这里合法);
|
|
4
|
+
// 渲染面是纯同步函数 —— 可达百 MB 的工件永远不进渲染路径。
|
|
5
|
+
//
|
|
6
|
+
// renderReportToText 是 text 宿主(show)的装载入口;web 宿主(view)的
|
|
7
|
+
// renderReportToStaticHtml 在 ./web.ts(那一侧才 import react-dom)。宿主接线是下一波,
|
|
8
|
+
// 这两个入口先以内部函数的身份可独立测试。
|
|
9
|
+
|
|
10
|
+
import type { Results, Selection } from "../results/index.ts";
|
|
11
|
+
import {
|
|
12
|
+
createTextContext,
|
|
13
|
+
renderNodeToText,
|
|
14
|
+
validateReportTree,
|
|
15
|
+
type ReportNode,
|
|
16
|
+
type TextRenderOptions,
|
|
17
|
+
} from "./tree.ts";
|
|
18
|
+
import { prepareDefaultReportData, runWithDefaultReportData } from "./default-report.tsx";
|
|
19
|
+
|
|
20
|
+
export interface ReportContext {
|
|
21
|
+
/** results.latest() 挑好的选集:现刻水位快照 + 结构化挑选警告,同官方榜单口径。 */
|
|
22
|
+
selection: Selection;
|
|
23
|
+
/** 默认挑法不合口径时,全量数据自己挑(见 docs/results-lib.md)。 */
|
|
24
|
+
results: Results;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const REPORT_DEFINITION: unique symbol = Symbol.for("niceeval.report.definition");
|
|
28
|
+
|
|
29
|
+
export interface ReportDefinition {
|
|
30
|
+
build(ctx: ReportContext): ReportNode | Promise<ReportNode>;
|
|
31
|
+
[REPORT_DEFINITION]: true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function defineReport(
|
|
35
|
+
build: (ctx: ReportContext) => ReportNode | Promise<ReportNode>,
|
|
36
|
+
): ReportDefinition {
|
|
37
|
+
if (typeof build !== "function") {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"defineReport expects a build function: defineReport(async ({ selection, results }) => <Col>...</Col>).",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return { build, [REPORT_DEFINITION]: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 宿主装载报告文件时用:默认导出是不是 defineReport 的产物。 */
|
|
46
|
+
export function isReportDefinition(value: unknown): value is ReportDefinition {
|
|
47
|
+
return (
|
|
48
|
+
typeof value === "object" &&
|
|
49
|
+
value !== null &&
|
|
50
|
+
(value as Partial<ReportDefinition>)[REPORT_DEFINITION] === true &&
|
|
51
|
+
typeof (value as Partial<ReportDefinition>).build === "function"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* text 宿主的装载语义:build → 渲染前树校验 → 备好官方水位(DefaultReport 的数据)
|
|
57
|
+
* → 遍历渲染 text 面。不需要 react-dom。
|
|
58
|
+
*/
|
|
59
|
+
export async function renderReportToText(
|
|
60
|
+
definition: ReportDefinition,
|
|
61
|
+
ctx: ReportContext,
|
|
62
|
+
options?: TextRenderOptions,
|
|
63
|
+
): Promise<string> {
|
|
64
|
+
const node = await definition.build(ctx);
|
|
65
|
+
validateReportTree(node);
|
|
66
|
+
const defaultData = await prepareDefaultReportData(ctx.selection);
|
|
67
|
+
const textCtx = createTextContext(options);
|
|
68
|
+
return runWithDefaultReportData(defaultData, () => renderNodeToText(node, textCtx));
|
|
69
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// 官方组件的 text 面:同一份算好的数据,渲染成终端字符(niceeval show 的形态)。
|
|
2
|
+
// 输出形态照 docs-site/zh/guides/report-components.mdx 的示例块;与 web 面共守
|
|
3
|
+
// 诚实契约:排序随 better、samples < total 角标、缺数据 — 不补 0、截断报剩余。
|
|
4
|
+
// 零 react、零 IO、纯同步 —— 这是 text 宿主不需要 react-dom 的那一半。
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
CaseListData,
|
|
8
|
+
DeltaData,
|
|
9
|
+
LineData,
|
|
10
|
+
MatrixData,
|
|
11
|
+
MetricColumn,
|
|
12
|
+
OverviewData,
|
|
13
|
+
ScatterData,
|
|
14
|
+
ScoreboardData,
|
|
15
|
+
TableData,
|
|
16
|
+
} from "../types.ts";
|
|
17
|
+
import type { TextContext } from "../tree.ts";
|
|
18
|
+
import { MISSING_TEXT, formatDurationMs, formatMetricValue, formatPlainNumber, formatUSD } from "../format.ts";
|
|
19
|
+
import { indentBlock, padDisplay, renderAlignedRows, textBar, wrapDisplay } from "./layout.ts";
|
|
20
|
+
import { renderCharPlot, renderCoordinateTable, type PlotPoint } from "./plot.ts";
|
|
21
|
+
|
|
22
|
+
const MISSING_MARK = "—";
|
|
23
|
+
|
|
24
|
+
/** 格子的文本形态:缺数据 —,覆盖不全带 samples/total 角标。 */
|
|
25
|
+
export function cellText(cell: { value: number | null; display: string; samples: number; total: number }): string {
|
|
26
|
+
if (cell.value === null) return MISSING_MARK;
|
|
27
|
+
return cell.samples < cell.total ? `${cell.display} ${cell.samples}/${cell.total}` : cell.display;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ───────────────────────── RunOverview ─────────────────────────
|
|
31
|
+
|
|
32
|
+
export function overviewText(data: OverviewData): string {
|
|
33
|
+
const { totals, snapshots } = data;
|
|
34
|
+
const runs = new Set(snapshots.map((s) => s.startedAt)).size;
|
|
35
|
+
const latest = snapshots.map((s) => s.startedAt).sort().at(-1);
|
|
36
|
+
const head = [
|
|
37
|
+
`${snapshots.length} ${snapshots.length === 1 ? "experiment" : "experiments"}`,
|
|
38
|
+
`${totals.evals} evals`,
|
|
39
|
+
`${totals.attempts} attempts`,
|
|
40
|
+
`composed from ${runs} ${runs === 1 ? "run" : "runs"}`,
|
|
41
|
+
...(latest ? [`latest ${latest}`] : []),
|
|
42
|
+
].join(" · ");
|
|
43
|
+
const tallies = [
|
|
44
|
+
`passed ${totals.passed}`,
|
|
45
|
+
`failed ${totals.failed}`,
|
|
46
|
+
`errored ${totals.errored}`,
|
|
47
|
+
`skipped ${totals.skipped}`,
|
|
48
|
+
totals.costUSD === null ? MISSING_TEXT : formatUSD(totals.costUSD),
|
|
49
|
+
formatDurationMs(totals.durationMs),
|
|
50
|
+
].join(" · ");
|
|
51
|
+
const lines = [head, tallies];
|
|
52
|
+
for (const warning of data.warnings) lines.push(`! ${warning.message}`);
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ───────────────────────── MetricTable ─────────────────────────
|
|
57
|
+
|
|
58
|
+
export function tableText(data: TableData): string {
|
|
59
|
+
const header = [data.dimension, ...data.columns.map((c) => c.label)];
|
|
60
|
+
const rows = data.rows.map((row) => [
|
|
61
|
+
row.key,
|
|
62
|
+
...data.columns.map((col) => {
|
|
63
|
+
const cell = (row.cells as Record<string, TableData["rows"][number]["cells"][string]>)[col.key];
|
|
64
|
+
return cell ? cellText(cell) : MISSING_MARK;
|
|
65
|
+
}),
|
|
66
|
+
]);
|
|
67
|
+
return renderAlignedRows([header, ...rows]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ───────────────────────── MetricMatrix ─────────────────────────
|
|
71
|
+
|
|
72
|
+
export function matrixText(data: MatrixData): string {
|
|
73
|
+
const rowKeys: string[] = [];
|
|
74
|
+
const columnKeys: string[] = [];
|
|
75
|
+
const byPosition = new Map<string, MatrixData["cells"][number]["cell"]>();
|
|
76
|
+
for (const entry of data.cells) {
|
|
77
|
+
if (!rowKeys.includes(entry.row)) rowKeys.push(entry.row);
|
|
78
|
+
if (!columnKeys.includes(entry.column)) columnKeys.push(entry.column);
|
|
79
|
+
byPosition.set(JSON.stringify([entry.row, entry.column]), entry.cell);
|
|
80
|
+
}
|
|
81
|
+
const header = [data.rows, ...columnKeys];
|
|
82
|
+
const rows = rowKeys.map((row) => [
|
|
83
|
+
row,
|
|
84
|
+
...columnKeys.map((column) => {
|
|
85
|
+
const cell = byPosition.get(JSON.stringify([row, column]));
|
|
86
|
+
return cell ? cellText(cell) : MISSING_MARK; // 稀疏格子在文本里以 — 呈现,不编数
|
|
87
|
+
}),
|
|
88
|
+
]);
|
|
89
|
+
const table = renderAlignedRows([header, ...rows]);
|
|
90
|
+
|
|
91
|
+
// 下钻命令:行维度是 eval 时,指向最值得看的一行(先挑有缺格的,再挑按 better 最差的)
|
|
92
|
+
if (data.rows !== "eval" || rowKeys.length === 0) return table;
|
|
93
|
+
const better = data.metric.better ?? "higher";
|
|
94
|
+
let next: string | undefined;
|
|
95
|
+
let worst: { key: string; value: number } | undefined;
|
|
96
|
+
for (const row of rowKeys) {
|
|
97
|
+
let sum = 0;
|
|
98
|
+
let count = 0;
|
|
99
|
+
for (const column of columnKeys) {
|
|
100
|
+
const cell = byPosition.get(JSON.stringify([row, column]));
|
|
101
|
+
if (!cell || cell.value === null) {
|
|
102
|
+
next ??= row;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
sum += cell.value;
|
|
106
|
+
count += 1;
|
|
107
|
+
}
|
|
108
|
+
if (count > 0) {
|
|
109
|
+
const value = sum / count;
|
|
110
|
+
const isWorse = worst === undefined || (better === "higher" ? value < worst.value : value > worst.value);
|
|
111
|
+
if (isWorse) worst = { key: row, value };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
next ??= worst?.key;
|
|
115
|
+
return next === undefined ? table : `${table}\n\nnext: niceeval show ${next}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ───────────────────────── MetricBars(矩阵数据的另一种摆法)─────────────────────────
|
|
119
|
+
|
|
120
|
+
const BAR_WIDTH = 20;
|
|
121
|
+
|
|
122
|
+
export function barsText(data: MatrixData): string {
|
|
123
|
+
const groupKeys: string[] = [];
|
|
124
|
+
const seriesKeys: string[] = [];
|
|
125
|
+
const byPosition = new Map<string, MatrixData["cells"][number]["cell"]>();
|
|
126
|
+
for (const entry of data.cells) {
|
|
127
|
+
if (!groupKeys.includes(entry.row)) groupKeys.push(entry.row);
|
|
128
|
+
if (!seriesKeys.includes(entry.column)) seriesKeys.push(entry.column);
|
|
129
|
+
byPosition.set(JSON.stringify([entry.row, entry.column]), entry.cell);
|
|
130
|
+
}
|
|
131
|
+
const better = data.metric.better ?? "higher";
|
|
132
|
+
// 条长刻度:% 的天然域是 [0,1],其余以全图最大值为满条
|
|
133
|
+
const values = data.cells.map((c) => c.cell.value).filter((v): v is number => v !== null);
|
|
134
|
+
const maxValue = values.length > 0 ? Math.max(...values) : 0;
|
|
135
|
+
const ratioOf = (value: number) =>
|
|
136
|
+
data.metric.unit === "%" ? value : maxValue === 0 ? 0 : value / maxValue;
|
|
137
|
+
|
|
138
|
+
const seriesWidth = Math.max(...seriesKeys.map((k) => k.length), 0);
|
|
139
|
+
const lines: string[] = [];
|
|
140
|
+
for (const group of groupKeys) {
|
|
141
|
+
lines.push(group);
|
|
142
|
+
const entries = seriesKeys.map((series) => ({
|
|
143
|
+
series,
|
|
144
|
+
cell: byPosition.get(JSON.stringify([group, series])),
|
|
145
|
+
}));
|
|
146
|
+
// 组内按值排序,方向随 better(缺数据沉底)
|
|
147
|
+
entries.sort((a, b) => {
|
|
148
|
+
const va = a.cell?.value ?? null;
|
|
149
|
+
const vb = b.cell?.value ?? null;
|
|
150
|
+
if (va === null && vb === null) return 0;
|
|
151
|
+
if (va === null) return 1;
|
|
152
|
+
if (vb === null) return -1;
|
|
153
|
+
return better === "lower" ? va - vb : vb - va;
|
|
154
|
+
});
|
|
155
|
+
for (const { series, cell } of entries) {
|
|
156
|
+
const label = padDisplay(series, seriesWidth);
|
|
157
|
+
if (!cell || cell.value === null) {
|
|
158
|
+
lines.push(` ${label} ${MISSING_MARK}`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
lines.push(` ${label} ${textBar(ratioOf(cell.value), BAR_WIDTH)} ${cellText(cell)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ───────────────────────── Scoreboard ─────────────────────────
|
|
168
|
+
|
|
169
|
+
export function scoreboardText(data: ScoreboardData): string {
|
|
170
|
+
const subjectKeys: string[] = [];
|
|
171
|
+
for (const row of data.rows) {
|
|
172
|
+
for (const subject of row.subjects) {
|
|
173
|
+
if (!subjectKeys.includes(subject.key)) subjectKeys.push(subject.key);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const header = [data.dimension, "total", ...subjectKeys];
|
|
177
|
+
const rows = data.rows.map((row) => [
|
|
178
|
+
row.key,
|
|
179
|
+
`${row.total.display}/${data.fullMarks}`,
|
|
180
|
+
...subjectKeys.map((key) => {
|
|
181
|
+
const subject = row.subjects.find((s) => s.key === key);
|
|
182
|
+
if (!subject) return MISSING_MARK;
|
|
183
|
+
const score = `${formatPlainNumber(subject.earned)}/${formatPlainNumber(subject.possible)}`;
|
|
184
|
+
return subject.missing > 0 ? `${score} (${subject.missing} missing)` : score;
|
|
185
|
+
}),
|
|
186
|
+
]);
|
|
187
|
+
const table = renderAlignedRows([header, ...rows]);
|
|
188
|
+
if (data.weights.length === 0) return table;
|
|
189
|
+
// 实际生效的权重表 —— 成绩单可审计
|
|
190
|
+
const weights = data.weights.map((w) => `${w.prefix} ×${w.weight}`).join(" · ");
|
|
191
|
+
return `${table}\nweights: ${weights} · others ×1`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ───────────────────────── MetricScatter ─────────────────────────
|
|
195
|
+
|
|
196
|
+
const POINT_MARKS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
197
|
+
|
|
198
|
+
function axisLabel(col: MetricColumn): string {
|
|
199
|
+
return col.label;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function scatterText(data: ScatterData, ctx: TextContext): string {
|
|
203
|
+
const drawable = data.rows.filter((r) => r.x.value !== null && r.y.value !== null);
|
|
204
|
+
const missing = data.rows.length - drawable.length;
|
|
205
|
+
const footnotes: string[] = [];
|
|
206
|
+
if (missing > 0) footnotes.push(`${missing} ${missing === 1 ? "point" : "points"} missing data`);
|
|
207
|
+
|
|
208
|
+
if (drawable.length === 0) {
|
|
209
|
+
return [MISSING_TEXT, ...footnotes].join("\n");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 点太密排不下时降级为坐标表,不硬挤
|
|
213
|
+
if (drawable.length > POINT_MARKS.length || ctx.width < 44) {
|
|
214
|
+
const table = renderCoordinateTable(
|
|
215
|
+
drawable.map((r) => ({ key: r.key, x: r.x.display, y: r.y.display })),
|
|
216
|
+
{ key: data.points, x: axisLabel(data.x), y: axisLabel(data.y) },
|
|
217
|
+
);
|
|
218
|
+
return [table, ...footnotes].join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const points: PlotPoint[] = drawable.map((r, i) => ({
|
|
222
|
+
mark: POINT_MARKS[i],
|
|
223
|
+
x: r.x.value as number,
|
|
224
|
+
y: r.y.value as number,
|
|
225
|
+
}));
|
|
226
|
+
// 同系列的点按 x 排序连线
|
|
227
|
+
const bySeries = new Map<string, { x: number; y: number }[]>();
|
|
228
|
+
for (const r of drawable) {
|
|
229
|
+
if (r.series === undefined) continue;
|
|
230
|
+
const list = bySeries.get(r.series) ?? [];
|
|
231
|
+
list.push({ x: r.x.value as number, y: r.y.value as number });
|
|
232
|
+
bySeries.set(r.series, list);
|
|
233
|
+
}
|
|
234
|
+
for (const list of bySeries.values()) list.sort((a, b) => a.x - b.x);
|
|
235
|
+
|
|
236
|
+
const invertX = data.x.better === "lower";
|
|
237
|
+
const plot = renderCharPlot({
|
|
238
|
+
width: ctx.width,
|
|
239
|
+
points,
|
|
240
|
+
lines: [...bySeries.values()].filter((l) => l.length > 1),
|
|
241
|
+
xLabel: `${axisLabel(data.x)}${invertX ? " (axis reversed: right = better)" : ""}`,
|
|
242
|
+
yLabel: axisLabel(data.y),
|
|
243
|
+
formatX: (v) => formatMetricValue(v, data.x.unit),
|
|
244
|
+
formatY: (v) => formatMetricValue(v, data.y.unit),
|
|
245
|
+
invertX,
|
|
246
|
+
invertY: data.y.better === "lower",
|
|
247
|
+
});
|
|
248
|
+
const legend = drawable.map((r, i) => `${POINT_MARKS[i]} ${r.key}`).join(" ");
|
|
249
|
+
return [plot, "", `better → upper right`, legend, ...footnotes].join("\n");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ───────────────────────── MetricLine ─────────────────────────
|
|
253
|
+
|
|
254
|
+
export function lineText(data: LineData, ctx: TextContext): string {
|
|
255
|
+
const drawable = data.rows.filter((r) => r.x !== null && r.y.value !== null);
|
|
256
|
+
const missing = data.rows.length - drawable.length;
|
|
257
|
+
const footnotes: string[] = [];
|
|
258
|
+
if (missing > 0) footnotes.push(`${missing} ${missing === 1 ? "point" : "points"} missing data`);
|
|
259
|
+
|
|
260
|
+
if (drawable.length === 0) return [MISSING_TEXT, ...footnotes].join("\n");
|
|
261
|
+
|
|
262
|
+
// 系列 → 字母;无系列 = 单系列
|
|
263
|
+
const seriesKeys: string[] = [];
|
|
264
|
+
for (const r of drawable) {
|
|
265
|
+
const key = r.series ?? "";
|
|
266
|
+
if (!seriesKeys.includes(key)) seriesKeys.push(key);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (seriesKeys.length > POINT_MARKS.length || ctx.width < 44) {
|
|
270
|
+
const table = renderCoordinateTable(
|
|
271
|
+
drawable.map((r) => ({ key: r.series ? `${r.key} (${r.series})` : r.key, x: r.xDisplay, y: r.y.display })),
|
|
272
|
+
{ key: "experiment", x: data.x.label, y: axisLabel(data.y) },
|
|
273
|
+
);
|
|
274
|
+
return [table, ...footnotes].join("\n");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const markOf = (r: LineData["rows"][number]) => POINT_MARKS[seriesKeys.indexOf(r.series ?? "")];
|
|
278
|
+
const points: PlotPoint[] = drawable.map((r) => ({
|
|
279
|
+
mark: markOf(r),
|
|
280
|
+
x: r.x as number,
|
|
281
|
+
y: r.y.value as number,
|
|
282
|
+
}));
|
|
283
|
+
const lines = seriesKeys.map((key) =>
|
|
284
|
+
drawable
|
|
285
|
+
.filter((r) => (r.series ?? "") === key)
|
|
286
|
+
.map((r) => ({ x: r.x as number, y: r.y.value as number }))
|
|
287
|
+
.sort((a, b) => a.x - b.x),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const plot = renderCharPlot({
|
|
291
|
+
width: ctx.width,
|
|
292
|
+
points,
|
|
293
|
+
lines: lines.filter((l) => l.length > 1),
|
|
294
|
+
xLabel: data.x.label,
|
|
295
|
+
yLabel: axisLabel(data.y),
|
|
296
|
+
formatX: (v) => formatMetricValue(v, data.x.unit),
|
|
297
|
+
formatY: (v) => formatMetricValue(v, data.y.unit),
|
|
298
|
+
invertY: data.y.better === "lower",
|
|
299
|
+
});
|
|
300
|
+
const legend = seriesKeys
|
|
301
|
+
.map((key, i) => `${POINT_MARKS[i]} ${key === "" ? data.y.label : key}`)
|
|
302
|
+
.join(" ");
|
|
303
|
+
return [plot, "", legend, ...footnotes].join("\n");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ───────────────────────── DeltaTable ─────────────────────────
|
|
307
|
+
|
|
308
|
+
export function deltaText(data: DeltaData): string {
|
|
309
|
+
const header = ["pair", ...data.columns.map((c) => c.label)];
|
|
310
|
+
const rows = data.rows.map((row) => [
|
|
311
|
+
row.key,
|
|
312
|
+
...data.columns.map((col) => {
|
|
313
|
+
const cell = (row.cells as Record<string, DeltaData["rows"][number]["cells"][string]>)[col.key];
|
|
314
|
+
if (!cell) return MISSING_MARK;
|
|
315
|
+
const a = cell.a.value === null ? MISSING_MARK : cell.a.display;
|
|
316
|
+
const b = cell.b.value === null ? MISSING_MARK : cell.b.display;
|
|
317
|
+
return `${a} → ${b} ${cell.display}`;
|
|
318
|
+
}),
|
|
319
|
+
]);
|
|
320
|
+
return renderAlignedRows([header, ...rows]);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ───────────────────────── CaseList ─────────────────────────
|
|
324
|
+
|
|
325
|
+
export function caseListText(data: CaseListData, ctx: TextContext): string {
|
|
326
|
+
if (data.rows.length === 0) return "No failed or errored attempts";
|
|
327
|
+
const lines: string[] = [];
|
|
328
|
+
for (const row of data.rows) {
|
|
329
|
+
const head = [
|
|
330
|
+
`✗ ${row.eval}`,
|
|
331
|
+
row.experimentId,
|
|
332
|
+
row.outcome,
|
|
333
|
+
formatDurationMs(row.durationMs),
|
|
334
|
+
...(row.costUSD !== undefined ? [formatUSD(row.costUSD)] : []),
|
|
335
|
+
].join(" · ");
|
|
336
|
+
lines.push(head);
|
|
337
|
+
if (row.error) {
|
|
338
|
+
lines.push(indentBlock(wrapDisplay(row.error, ctx.width - 4).join("\n"), " "));
|
|
339
|
+
}
|
|
340
|
+
for (const assertion of row.failedAssertions) {
|
|
341
|
+
const summary = assertion.detail
|
|
342
|
+
? `${assertion.name} — ${assertion.detail}`
|
|
343
|
+
: `${assertion.name} — score ${assertion.score}`;
|
|
344
|
+
lines.push(indentBlock(wrapDisplay(summary, ctx.width - 4).join("\n"), " "));
|
|
345
|
+
if (assertion.evidence) {
|
|
346
|
+
lines.push(indentBlock(wrapDisplay(assertion.evidence, ctx.width - 6).join("\n"), " "));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
lines.push(` → niceeval show ${row.eval}`);
|
|
350
|
+
}
|
|
351
|
+
if (data.truncated > 0) {
|
|
352
|
+
lines.push("");
|
|
353
|
+
lines.push(`(${data.truncated} more not shown)`);
|
|
354
|
+
}
|
|
355
|
+
return lines.join("\n");
|
|
356
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// text 面的排版工具:显示宽度(CJK 记 2 列)、填充、折行、对齐列、字符条形。
|
|
2
|
+
// 全部纯函数、零依赖;niceeval show 的终端输出与 Row 分栏都建立在这几样上。
|
|
3
|
+
|
|
4
|
+
/** 终端显示宽度:CJK / 全角记 2 列,其余记 1。启发式覆盖常用区段,够对齐表格用。 */
|
|
5
|
+
export function charDisplayWidth(codePoint: number): number {
|
|
6
|
+
if (
|
|
7
|
+
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
|
|
8
|
+
(codePoint >= 0x2e80 && codePoint <= 0x303e) ||
|
|
9
|
+
(codePoint >= 0x3041 && codePoint <= 0x33ff) ||
|
|
10
|
+
(codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
|
|
11
|
+
(codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
|
|
12
|
+
(codePoint >= 0xa000 && codePoint <= 0xa4cf) ||
|
|
13
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
14
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
15
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe4f) ||
|
|
16
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
17
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
|
18
|
+
(codePoint >= 0x20000 && codePoint <= 0x3fffd)
|
|
19
|
+
) {
|
|
20
|
+
return 2;
|
|
21
|
+
}
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function stringWidth(text: string): number {
|
|
26
|
+
let width = 0;
|
|
27
|
+
for (const ch of text) width += charDisplayWidth(ch.codePointAt(0)!);
|
|
28
|
+
return width;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 右侧补空格到目标显示宽度(超宽不截断,如实溢出)。 */
|
|
32
|
+
export function padDisplay(text: string, width: number): string {
|
|
33
|
+
const gap = width - stringWidth(text);
|
|
34
|
+
return gap > 0 ? text + " ".repeat(gap) : text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 左侧补空格(数字右对齐用)。 */
|
|
38
|
+
export function padStartDisplay(text: string, width: number): string {
|
|
39
|
+
const gap = width - stringWidth(text);
|
|
40
|
+
return gap > 0 ? " ".repeat(gap) + text : text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 按显示宽度折行:优先在空格处断,连续超宽(如中文)按列宽硬断。
|
|
45
|
+
* 返回至少一行(空串输入 → [""])。
|
|
46
|
+
*/
|
|
47
|
+
export function wrapDisplay(text: string, width: number): string[] {
|
|
48
|
+
const max = Math.max(4, width);
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
for (const paragraph of text.split("\n")) {
|
|
51
|
+
let line = "";
|
|
52
|
+
let lineWidth = 0;
|
|
53
|
+
const flush = () => {
|
|
54
|
+
lines.push(line);
|
|
55
|
+
line = "";
|
|
56
|
+
lineWidth = 0;
|
|
57
|
+
};
|
|
58
|
+
for (const word of paragraph.split(" ")) {
|
|
59
|
+
const wordWidth = stringWidth(word);
|
|
60
|
+
if (lineWidth > 0 && lineWidth + 1 + wordWidth > max) flush();
|
|
61
|
+
if (wordWidth > max) {
|
|
62
|
+
// 单词本身超宽(URL / 中文长句):逐字符硬断
|
|
63
|
+
for (const ch of word) {
|
|
64
|
+
const w = charDisplayWidth(ch.codePointAt(0)!);
|
|
65
|
+
if (lineWidth + w > max) flush();
|
|
66
|
+
line += ch;
|
|
67
|
+
lineWidth += w;
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (lineWidth > 0) {
|
|
72
|
+
line += " ";
|
|
73
|
+
lineWidth += 1;
|
|
74
|
+
}
|
|
75
|
+
line += word;
|
|
76
|
+
lineWidth += wordWidth;
|
|
77
|
+
}
|
|
78
|
+
flush();
|
|
79
|
+
}
|
|
80
|
+
return lines.length > 0 ? lines : [""];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 每行前加缩进。 */
|
|
84
|
+
export function indentBlock(block: string, indent: string): string {
|
|
85
|
+
return block
|
|
86
|
+
.split("\n")
|
|
87
|
+
.map((line) => (line.length > 0 ? indent + line : line))
|
|
88
|
+
.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 对齐列渲染:每列宽 = 该列最宽格,列间 3 空格。首行是表头。 */
|
|
92
|
+
export function renderAlignedRows(rows: string[][]): string {
|
|
93
|
+
const columnCount = Math.max(...rows.map((r) => r.length), 0);
|
|
94
|
+
const widths: number[] = [];
|
|
95
|
+
for (let c = 0; c < columnCount; c++) {
|
|
96
|
+
widths.push(Math.max(...rows.map((r) => stringWidth(r[c] ?? ""))));
|
|
97
|
+
}
|
|
98
|
+
return rows
|
|
99
|
+
.map((row) =>
|
|
100
|
+
row
|
|
101
|
+
.map((cell, c) => (c === row.length - 1 ? cell : padDisplay(cell, widths[c])))
|
|
102
|
+
.join(" ")
|
|
103
|
+
.replace(/\s+$/, ""),
|
|
104
|
+
)
|
|
105
|
+
.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 字符条形:filled 比例 → █ 填充、░ 补齐到 barWidth。 */
|
|
109
|
+
export function textBar(ratio: number, barWidth: number): string {
|
|
110
|
+
const clamped = Math.max(0, Math.min(1, ratio));
|
|
111
|
+
const filled = Math.round(clamped * barWidth);
|
|
112
|
+
return "█".repeat(filled) + "░".repeat(barWidth - filled);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 多块并排(Row 的 text 面):逐行拼接,各栏折到自己的宽度,短栏补齐。 */
|
|
116
|
+
export function joinColumns(blocks: string[], columnWidths: number[], separator = " │ "): string {
|
|
117
|
+
const columns = blocks.map((block) => block.split("\n"));
|
|
118
|
+
const height = Math.max(...columns.map((lines) => lines.length), 0);
|
|
119
|
+
const out: string[] = [];
|
|
120
|
+
for (let i = 0; i < height; i++) {
|
|
121
|
+
const parts = columns.map((lines, c) => padDisplay(lines[i] ?? "", columnWidths[c]));
|
|
122
|
+
out.push(parts.join(separator).replace(/\s+$/, ""));
|
|
123
|
+
}
|
|
124
|
+
return out.join("\n");
|
|
125
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// text 面的字符坐标图:MetricScatter / MetricLine 共用。
|
|
2
|
+
// 形态照 docs-site/zh/guides/report-components.mdx 的示例块:y 轴刻度 + │ 边框,
|
|
3
|
+
// 点用字母标注、图例列在图下;lower-better 的轴反向,「好」的角落恒在右上。
|
|
4
|
+
|
|
5
|
+
import { padDisplay, padStartDisplay, stringWidth } from "./layout.ts";
|
|
6
|
+
|
|
7
|
+
export interface PlotPoint {
|
|
8
|
+
/** 图上的标记字符(点或系列的字母)。 */
|
|
9
|
+
mark: string;
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CharPlotOptions {
|
|
15
|
+
/** 总列宽(含 y 轴刻度区)。 */
|
|
16
|
+
width: number;
|
|
17
|
+
/** 网格行数;默认 9。 */
|
|
18
|
+
height?: number;
|
|
19
|
+
points: PlotPoint[];
|
|
20
|
+
/** 值空间的折线(同系列点按 x 排序连线),用 · 描画,字母覆盖其上。 */
|
|
21
|
+
lines?: { x: number; y: number }[][];
|
|
22
|
+
xLabel: string;
|
|
23
|
+
yLabel: string;
|
|
24
|
+
/** 值 → 刻度文案(已格式化的 display)。 */
|
|
25
|
+
formatX: (value: number) => string;
|
|
26
|
+
formatY: (value: number) => string;
|
|
27
|
+
/** better: "lower" 的轴反向 —— 好的一端在右 / 上。 */
|
|
28
|
+
invertX?: boolean;
|
|
29
|
+
invertY?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Scale {
|
|
33
|
+
lo: number;
|
|
34
|
+
hi: number;
|
|
35
|
+
/** 值 → [0, cells-1] 的下标。 */
|
|
36
|
+
at(value: number): number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeScale(values: number[], cells: number, invert: boolean): Scale {
|
|
40
|
+
const lo = Math.min(...values);
|
|
41
|
+
const hi = Math.max(...values);
|
|
42
|
+
const span = hi - lo;
|
|
43
|
+
return {
|
|
44
|
+
lo,
|
|
45
|
+
hi,
|
|
46
|
+
at(value: number): number {
|
|
47
|
+
// 单值域:唯一的点落正中
|
|
48
|
+
let t = span === 0 ? 0.5 : (value - lo) / span;
|
|
49
|
+
if (invert) t = 1 - t;
|
|
50
|
+
return Math.max(0, Math.min(cells - 1, Math.round(t * (cells - 1))));
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 值空间折线 → 网格 · 描画:相邻点之间按列逐格线性插值。 */
|
|
56
|
+
function drawLine(grid: string[][], a: { col: number; row: number }, b: { col: number; row: number }): void {
|
|
57
|
+
const steps = Math.max(Math.abs(b.col - a.col), Math.abs(b.row - a.row));
|
|
58
|
+
for (let i = 1; i < steps; i++) {
|
|
59
|
+
const col = Math.round(a.col + ((b.col - a.col) * i) / steps);
|
|
60
|
+
const row = Math.round(a.row + ((b.row - a.row) * i) / steps);
|
|
61
|
+
if (grid[row][col] === " ") grid[row][col] = "·";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function renderCharPlot(opts: CharPlotOptions): string {
|
|
66
|
+
const height = Math.max(4, opts.height ?? 9);
|
|
67
|
+
const yTickTexts = [opts.formatY(Math.min(...opts.points.map((p) => p.y))), opts.formatY(Math.max(...opts.points.map((p) => p.y)))];
|
|
68
|
+
const gutter = Math.max(...yTickTexts.map(stringWidth)) + 1;
|
|
69
|
+
const plotWidth = Math.max(16, opts.width - gutter - 1);
|
|
70
|
+
|
|
71
|
+
const xScale = makeScale(opts.points.map((p) => p.x), plotWidth, opts.invertX ?? false);
|
|
72
|
+
// y:大下标 = 「好」的一端;网格第 0 行是顶端,行下标 = height-1 - at,
|
|
73
|
+
// 所以 invertY=false(higher 好)时 hi 折到 at=height-1 → 顶行,lower 好时反之
|
|
74
|
+
const yScale = makeScale(opts.points.map((p) => p.y), height, opts.invertY ?? false);
|
|
75
|
+
const rowOf = (y: number) => height - 1 - yScale.at(y);
|
|
76
|
+
|
|
77
|
+
const grid: string[][] = Array.from({ length: height }, () => Array.from({ length: plotWidth }, () => " "));
|
|
78
|
+
for (const line of opts.lines ?? []) {
|
|
79
|
+
for (let i = 1; i < line.length; i++) {
|
|
80
|
+
drawLine(
|
|
81
|
+
grid,
|
|
82
|
+
{ col: xScale.at(line[i - 1].x), row: rowOf(line[i - 1].y) },
|
|
83
|
+
{ col: xScale.at(line[i].x), row: rowOf(line[i].y) },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const point of opts.points) {
|
|
88
|
+
grid[rowOf(point.y)][xScale.at(point.x)] = point.mark;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// y 轴刻度:极值行标注 display,其余空
|
|
92
|
+
const tickByRow = new Map<number, string>();
|
|
93
|
+
tickByRow.set(rowOf(yScale.lo), opts.formatY(yScale.lo));
|
|
94
|
+
tickByRow.set(rowOf(yScale.hi), opts.formatY(yScale.hi));
|
|
95
|
+
|
|
96
|
+
const out: string[] = [];
|
|
97
|
+
out.push(`${" ".repeat(gutter)}${opts.yLabel} ↑`);
|
|
98
|
+
for (let row = 0; row < height; row++) {
|
|
99
|
+
const tick = tickByRow.get(row) ?? "";
|
|
100
|
+
out.push(`${padStartDisplay(tick, gutter)}│${grid[row].join("")}`.replace(/\s+$/, ""));
|
|
101
|
+
}
|
|
102
|
+
out.push(`${" ".repeat(gutter)}└${"─".repeat(plotWidth)}→ ${opts.xLabel}`);
|
|
103
|
+
|
|
104
|
+
// x 轴刻度:两端极值(反向轴时右端是 lo),标在真实位置上
|
|
105
|
+
const ticksRow = Array.from({ length: plotWidth }, () => " ");
|
|
106
|
+
for (const value of xScale.lo === xScale.hi ? [xScale.lo] : [xScale.lo, xScale.hi]) {
|
|
107
|
+
const label = opts.formatX(value);
|
|
108
|
+
let col = xScale.at(value);
|
|
109
|
+
col = Math.min(col, plotWidth - stringWidth(label));
|
|
110
|
+
for (let i = 0; i < label.length && col + i < plotWidth; i++) ticksRow[col + i] = label[i];
|
|
111
|
+
}
|
|
112
|
+
out.push(`${" ".repeat(gutter + 1)}${ticksRow.join("")}`.replace(/\s+$/, ""));
|
|
113
|
+
return out.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 点太密排不下时的降级:坐标表,不硬挤(每行 key、x、y 的 display)。 */
|
|
117
|
+
export function renderCoordinateTable(
|
|
118
|
+
rows: { key: string; x: string; y: string }[],
|
|
119
|
+
header: { key: string; x: string; y: string },
|
|
120
|
+
): string {
|
|
121
|
+
const all = [header, ...rows];
|
|
122
|
+
const w1 = Math.max(...all.map((r) => stringWidth(r.key)));
|
|
123
|
+
const w2 = Math.max(...all.map((r) => stringWidth(r.x)));
|
|
124
|
+
return all
|
|
125
|
+
.map((r) => `${padDisplay(r.key, w1)} ${padDisplay(r.x, w2)} ${r.y}`.replace(/\s+$/, ""))
|
|
126
|
+
.join("\n");
|
|
127
|
+
}
|