niceeval 0.6.0 → 0.6.2

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.
Files changed (314) hide show
  1. package/dist/agents/types.d.ts +72 -6
  2. package/dist/context/types.d.ts +32 -12
  3. package/dist/i18n/en.d.ts +54 -0
  4. package/dist/i18n/zh-CN.d.ts +55 -1
  5. package/dist/o11y/types.d.ts +16 -2
  6. package/dist/report/aggregate.d.ts +5 -3
  7. package/dist/report/aggregate.js +32 -5
  8. package/dist/report/built-ins/experiment-comparison.d.ts +39 -0
  9. package/dist/report/built-ins/experiment-comparison.js +119 -0
  10. package/dist/report/built-ins/index.d.ts +2 -1
  11. package/dist/report/built-ins/index.js +2 -2
  12. package/dist/report/components.d.ts +10 -2
  13. package/dist/report/components.js +3 -3
  14. package/dist/report/compute.d.ts +11 -18
  15. package/dist/report/compute.js +68 -66
  16. package/dist/report/flag.d.ts +16 -1
  17. package/dist/report/flag.js +19 -1
  18. package/dist/report/format.d.ts +16 -14
  19. package/dist/report/format.js +28 -30
  20. package/dist/report/index.d.ts +5 -4
  21. package/dist/report/index.js +6 -5
  22. package/dist/report/locale.d.ts +23 -3
  23. package/dist/report/locale.js +47 -6
  24. package/dist/report/metrics.d.ts +13 -1
  25. package/dist/report/metrics.js +66 -15
  26. package/dist/report/primitives.d.ts +6 -0
  27. package/dist/report/react/AttemptList.d.ts +4 -4
  28. package/dist/report/react/AttemptList.js +8 -10
  29. package/dist/report/react/EvalList.d.ts +1 -1
  30. package/dist/report/react/EvalList.js +0 -0
  31. package/dist/report/react/ExperimentComparison.d.ts +8 -0
  32. package/dist/report/react/ExperimentComparison.js +11 -0
  33. package/dist/report/react/ExperimentList.d.ts +4 -2
  34. package/dist/report/react/ExperimentList.js +57 -7
  35. package/dist/report/react/MetricScatter.js +6 -16
  36. package/dist/report/react/chart-math.d.ts +23 -6
  37. package/dist/report/react/chart-math.js +71 -19
  38. package/dist/report/react/fixtures.d.ts +3 -3
  39. package/dist/report/react/fixtures.js +30 -18
  40. package/dist/report/react/format.d.ts +1 -1
  41. package/dist/report/react/format.js +1 -1
  42. package/dist/report/react/index.d.ts +1 -1
  43. package/dist/report/report.d.ts +5 -1
  44. package/dist/report/report.js +6 -2
  45. package/dist/report/text/faces.d.ts +1 -1
  46. package/dist/report/text/faces.js +100 -61
  47. package/dist/report/text/table.js +36 -5
  48. package/dist/report/types.d.ts +40 -34
  49. package/dist/results/types.d.ts +11 -0
  50. package/dist/runner/feedback/sink.d.ts +110 -0
  51. package/dist/runner/types.d.ts +513 -22
  52. package/dist/sandbox/docker.d.ts +23 -2
  53. package/dist/sandbox/e2b.d.ts +15 -1
  54. package/dist/sandbox/errors.d.ts +30 -3
  55. package/dist/sandbox/io-retry.d.ts +17 -0
  56. package/dist/sandbox/registry.d.ts +2 -0
  57. package/dist/sandbox/resolve.d.ts +18 -5
  58. package/dist/sandbox/retry.d.ts +11 -1
  59. package/dist/sandbox/types.d.ts +39 -5
  60. package/dist/sandbox/vercel.d.ts +7 -1
  61. package/dist/scoring/coverage.d.ts +30 -0
  62. package/dist/scoring/display.d.ts +21 -0
  63. package/dist/scoring/display.js +120 -0
  64. package/dist/scoring/types.d.ts +103 -20
  65. package/dist/shared/aggregate.d.ts +1 -0
  66. package/dist/shared/aggregate.js +3 -3
  67. package/dist/shared/types.d.ts +28 -0
  68. package/dist/tty-line.d.ts +0 -4
  69. package/dist/util.d.ts +23 -0
  70. package/docs-site/zh/concepts/adapter.mdx +24 -6
  71. package/docs-site/zh/concepts/assert.mdx +11 -10
  72. package/docs-site/zh/concepts/evals.mdx +7 -6
  73. package/docs-site/zh/concepts/experiment.mdx +1 -1
  74. package/docs-site/zh/concepts/overview.mdx +7 -7
  75. package/docs-site/zh/guides/agent-feedback-loop.mdx +35 -31
  76. package/docs-site/zh/guides/authoring.mdx +33 -0
  77. package/docs-site/zh/guides/ci-integration.mdx +23 -12
  78. package/docs-site/zh/guides/connect-your-agent.mdx +29 -3
  79. package/docs-site/zh/guides/custom-reports.mdx +29 -34
  80. package/docs-site/zh/guides/dataset-fanout.mdx +25 -3
  81. package/docs-site/zh/guides/debug-sandbox.mdx +57 -0
  82. package/docs-site/zh/guides/debugging.mdx +210 -0
  83. package/docs-site/zh/guides/experiments.mdx +10 -3
  84. package/docs-site/zh/guides/fixtures.mdx +3 -1
  85. package/docs-site/zh/guides/official-adapters.mdx +27 -3
  86. package/docs-site/zh/guides/publish-report.mdx +30 -16
  87. package/docs-site/zh/guides/report-components.mdx +49 -37
  88. package/docs-site/zh/guides/reporters.mdx +2 -2
  89. package/docs-site/zh/guides/results-data.mdx +42 -8
  90. package/docs-site/zh/guides/runner.mdx +17 -7
  91. package/docs-site/zh/guides/sandbox-agent.mdx +57 -7
  92. package/docs-site/zh/guides/sandbox-providers.mdx +258 -10
  93. package/docs-site/zh/guides/scoring-guide.mdx +4 -4
  94. package/docs-site/zh/guides/viewing-results.mdx +85 -41
  95. package/docs-site/zh/guides/write-experiment.mdx +5 -3
  96. package/docs-site/zh/guides/write-send.mdx +19 -2
  97. package/docs-site/zh/index.mdx +1 -1
  98. package/docs-site/zh/reference/builtin-agents.mdx +27 -0
  99. package/docs-site/zh/reference/capabilities.mdx +2 -2
  100. package/docs-site/zh/reference/cli.mdx +35 -9
  101. package/docs-site/zh/reference/define-agent.mdx +60 -5
  102. package/docs-site/zh/reference/define-config.mdx +1 -1
  103. package/docs-site/zh/reference/define-eval.mdx +42 -9
  104. package/docs-site/zh/reference/events.mdx +2 -2
  105. package/docs-site/zh/reference/expect.mdx +36 -6
  106. package/package.json +5 -1
  107. package/src/agents/ai-sdk-otel.test.ts +1 -0
  108. package/src/agents/ai-sdk.test.ts +3 -0
  109. package/src/agents/ai-sdk.ts +3 -0
  110. package/src/agents/bub-install-spec.test.ts +34 -0
  111. package/src/agents/bub-install-spec.ts +32 -0
  112. package/src/agents/bub.ts +31 -32
  113. package/src/agents/claude-code.test.ts +130 -9
  114. package/src/agents/claude-code.ts +76 -4
  115. package/src/agents/codex.test.ts +189 -40
  116. package/src/agents/codex.ts +155 -14
  117. package/src/agents/coding-cli-versions.test.ts +15 -0
  118. package/src/agents/coding-cli-versions.ts +3 -0
  119. package/src/agents/index.ts +11 -0
  120. package/src/agents/langgraph.test.ts +204 -0
  121. package/src/agents/langgraph.ts +495 -0
  122. package/src/agents/marketplace.ts +85 -0
  123. package/src/agents/native-config.test.ts +179 -0
  124. package/src/agents/native-config.ts +267 -0
  125. package/src/agents/openai-compat.test.ts +1 -0
  126. package/src/agents/openclaw.test.ts +31 -0
  127. package/src/agents/openclaw.ts +171 -0
  128. package/src/agents/plugin-config.test.ts +1 -0
  129. package/src/agents/sdk-streams.test.ts +79 -0
  130. package/src/agents/sdk-streams.ts +55 -10
  131. package/src/agents/skills.test.ts +1 -0
  132. package/src/agents/streaming.test.ts +3 -9
  133. package/src/agents/types.ts +73 -6
  134. package/src/agents/ui-message-stream.test.ts +3 -0
  135. package/src/cli.ts +411 -108
  136. package/src/context/context.test.ts +51 -12
  137. package/src/context/context.ts +161 -29
  138. package/src/context/session.test.ts +1 -0
  139. package/src/context/session.ts +114 -6
  140. package/src/context/types.ts +30 -12
  141. package/src/define.test.ts +13 -8
  142. package/src/define.ts +25 -4
  143. package/src/expect/index.ts +53 -23
  144. package/src/i18n/en.ts +65 -4
  145. package/src/i18n/zh-CN.ts +66 -4
  146. package/src/o11y/cost.test.ts +1 -0
  147. package/src/o11y/execution-tree.test.ts +1 -20
  148. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  149. package/src/o11y/otlp/parse.test.ts +1 -0
  150. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  151. package/src/o11y/parsers/bub.test.ts +1 -0
  152. package/src/o11y/parsers/claude-code.test.ts +1 -34
  153. package/src/o11y/parsers/openclaw.test.ts +154 -0
  154. package/src/o11y/parsers/openclaw.ts +310 -0
  155. package/src/o11y/prices.json +746 -311
  156. package/src/o11y/tool-names.test.ts +1 -0
  157. package/src/o11y/types.ts +16 -2
  158. package/src/report/aggregate.ts +34 -5
  159. package/src/report/built-in-user-parity.test.tsx +127 -173
  160. package/src/report/built-ins/experiment-comparison.tsx +179 -0
  161. package/src/report/built-ins/index.ts +7 -2
  162. package/src/report/components.tsx +11 -3
  163. package/src/report/compute.ts +80 -74
  164. package/src/report/dual-render.test.tsx +222 -91
  165. package/src/report/flag.ts +30 -2
  166. package/src/report/format.ts +36 -27
  167. package/src/report/index.ts +23 -6
  168. package/src/report/locale.ts +49 -6
  169. package/src/report/metrics.ts +68 -15
  170. package/src/report/primitives.tsx +6 -0
  171. package/src/report/react/AttemptList.tsx +9 -36
  172. package/src/report/react/EvalList.tsx +0 -0
  173. package/src/report/react/ExperimentComparison.tsx +68 -0
  174. package/src/report/react/ExperimentList.tsx +173 -55
  175. package/src/report/react/MetricScatter.tsx +13 -25
  176. package/src/report/react/chart-math.test.ts +85 -0
  177. package/src/report/react/chart-math.ts +101 -22
  178. package/src/report/react/enhance.js +72 -1
  179. package/src/report/react/fixtures.ts +34 -21
  180. package/src/report/react/format.ts +1 -1
  181. package/src/report/react/index.tsx +0 -1
  182. package/src/report/react/render.test.tsx +30 -69
  183. package/src/report/react/styles.css +112 -14
  184. package/src/report/report.test.ts +308 -105
  185. package/src/report/report.ts +6 -2
  186. package/src/report/text/faces.ts +111 -67
  187. package/src/report/text/table.ts +42 -5
  188. package/src/report/types.ts +42 -34
  189. package/src/results/annotated-source.test.ts +62 -9
  190. package/src/results/annotated-source.ts +64 -6
  191. package/src/results/attempt-evidence.test.ts +9 -7
  192. package/src/results/attempt-evidence.ts +15 -8
  193. package/src/results/attempt-source.ts +6 -3
  194. package/src/results/copy.ts +145 -55
  195. package/src/results/host-equivalence.test.ts +11 -9
  196. package/src/results/index.ts +2 -0
  197. package/src/results/locator.test.ts +1 -22
  198. package/src/results/open.ts +7 -1
  199. package/src/results/publish.ts +149 -0
  200. package/src/results/results.test.ts +85 -51
  201. package/src/results/truncate.ts +90 -0
  202. package/src/results/types.ts +7 -0
  203. package/src/results/writer.ts +31 -13
  204. package/src/runner/attempt.test.ts +138 -7
  205. package/src/runner/attempt.ts +603 -104
  206. package/src/runner/discover.test.ts +47 -0
  207. package/src/runner/discover.ts +36 -2
  208. package/src/runner/eval-source.test.ts +1 -27
  209. package/src/runner/feedback/agent.test.ts +504 -0
  210. package/src/runner/feedback/agent.ts +409 -0
  211. package/src/runner/feedback/ci.test.ts +562 -0
  212. package/src/runner/feedback/ci.ts +401 -0
  213. package/src/runner/feedback/coordinator.test.ts +317 -0
  214. package/src/runner/feedback/coordinator.ts +397 -0
  215. package/src/runner/feedback/failure.ts +40 -0
  216. package/src/runner/feedback/human.test.ts +616 -0
  217. package/src/runner/feedback/human.ts +535 -0
  218. package/src/runner/feedback/index.ts +66 -0
  219. package/src/runner/feedback/io.ts +78 -0
  220. package/src/runner/feedback/profile.test.ts +50 -0
  221. package/src/runner/feedback/profile.ts +58 -0
  222. package/src/runner/feedback/reducer.test.ts +395 -0
  223. package/src/runner/feedback/reducer.ts +260 -0
  224. package/src/runner/feedback/renderer.ts +82 -0
  225. package/src/runner/feedback/sink.ts +203 -0
  226. package/src/runner/feedback/testing.ts +106 -0
  227. package/src/runner/ledger.test.ts +230 -0
  228. package/src/runner/ledger.ts +329 -0
  229. package/src/runner/report.test.ts +128 -3
  230. package/src/runner/report.ts +33 -9
  231. package/src/runner/reporters/artifacts.ts +8 -2
  232. package/src/runner/reporters/braintrust.test.ts +8 -7
  233. package/src/runner/reporters/braintrust.ts +9 -2
  234. package/src/runner/reporters/index.ts +2 -2
  235. package/src/runner/reporters/json.test.ts +162 -0
  236. package/src/runner/reporters/json.ts +35 -8
  237. package/src/runner/reporters/shared.ts +1 -5
  238. package/src/runner/run.test.ts +760 -3
  239. package/src/runner/run.ts +242 -36
  240. package/src/runner/sandbox-prep.ts +3 -42
  241. package/src/runner/timing.ts +158 -0
  242. package/src/runner/types.ts +518 -22
  243. package/src/sandbox/checkpoint.test.ts +55 -0
  244. package/src/sandbox/checkpoint.ts +29 -8
  245. package/src/sandbox/cli-commands.ts +407 -0
  246. package/src/sandbox/docker.ts +115 -16
  247. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  248. package/src/sandbox/e2b-agent-template.ts +94 -0
  249. package/src/sandbox/e2b.ts +74 -9
  250. package/src/sandbox/errors.ts +111 -4
  251. package/src/sandbox/index.ts +2 -0
  252. package/src/sandbox/io-retry.test.ts +58 -0
  253. package/src/sandbox/io-retry.ts +45 -0
  254. package/src/sandbox/keep-registry.test.ts +86 -0
  255. package/src/sandbox/keep-registry.ts +142 -0
  256. package/src/sandbox/keep.ts +178 -0
  257. package/src/sandbox/paths.test.ts +1 -0
  258. package/src/sandbox/paths.ts +19 -8
  259. package/src/sandbox/registry.ts +20 -3
  260. package/src/sandbox/resolve.ts +76 -11
  261. package/src/sandbox/retry.test.ts +70 -0
  262. package/src/sandbox/retry.ts +46 -4
  263. package/src/sandbox/types.ts +44 -6
  264. package/src/sandbox/vercel.ts +43 -20
  265. package/src/scoring/collector.ts +60 -17
  266. package/src/scoring/coverage.ts +95 -0
  267. package/src/scoring/diff.ts +81 -0
  268. package/src/scoring/display.test.ts +121 -0
  269. package/src/scoring/display.ts +133 -0
  270. package/src/scoring/evidence.test.ts +189 -0
  271. package/src/scoring/judge.test.ts +142 -0
  272. package/src/scoring/judge.ts +15 -18
  273. package/src/scoring/scoped.ts +217 -50
  274. package/src/scoring/types.ts +117 -20
  275. package/src/scoring/verdict.ts +16 -4
  276. package/src/shared/aggregate.ts +3 -2
  277. package/src/shared/types.ts +31 -0
  278. package/src/show/compose.ts +2 -2
  279. package/src/show/index.ts +29 -16
  280. package/src/show/render.ts +626 -308
  281. package/src/show/show.test.ts +251 -36
  282. package/src/tty-line.ts +8 -26
  283. package/src/util.test.ts +1 -0
  284. package/src/util.ts +41 -0
  285. package/src/view/app/components/AttemptModal.tsx +153 -2
  286. package/src/view/app/components/CodeView.tsx +32 -11
  287. package/src/view/app/components/CopyControls.tsx +2 -2
  288. package/src/view/app/i18n.ts +6 -0
  289. package/src/view/app/lib/attempt-route.test.ts +1 -0
  290. package/src/view/app/lib/verdict.ts +7 -9
  291. package/src/view/artifact-serving.test.ts +2 -1
  292. package/src/view/client-dist/app.css +1 -1
  293. package/src/view/client-dist/app.js +17 -17
  294. package/src/view/data.test.ts +2 -1
  295. package/src/view/data.ts +17 -7
  296. package/src/view/index.ts +12 -1
  297. package/src/view/server.ts +2 -0
  298. package/src/view/shared/types.ts +1 -1
  299. package/src/view/styles.css +3 -0
  300. package/src/view/view-report.test.ts +11 -10
  301. package/dist/o11y/execution-tree.d.ts +0 -103
  302. package/dist/o11y/otlp/select.d.ts +0 -22
  303. package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +0 -1
  304. package/dist/report/built-ins/cost-pass-rate-comparison.js +0 -17
  305. package/dist/results/annotated-source.d.ts +0 -61
  306. package/dist/results/attempt-evidence.d.ts +0 -69
  307. package/dist/results/attempt-source.d.ts +0 -15
  308. package/src/report/built-ins/cost-pass-rate-comparison.tsx +0 -23
  309. package/src/runner/reporters/console.ts +0 -70
  310. package/src/runner/reporters/live.test.ts +0 -56
  311. package/src/runner/reporters/live.ts +0 -247
  312. package/src/runner/reporters/quiet.test.ts +0 -66
  313. package/src/runner/reporters/quiet.ts +0 -49
  314. package/src/runner/reporters/table.ts +0 -277
@@ -1,108 +1,226 @@
1
- // ExperimentList:实体列表的第一级——每项固定代表一个 experiment,主行是身份(experiment id /
2
- // agent / model / flags)、Eval 判定构成与官方两级聚合汇总指标(passRate/cost/duration/tokens,
3
- // 复用 MetricCellView 与其它指标图形同一份渲染)。零 JS 用原生 <details> 展开到这个 experiment
4
- // 的 Eval:每道题一行,判定符 + 该题全部 Attempt 的 locator 徽标(内联,不用再点开一层)+
5
- // 通过时的平均耗时/成本、失败/出错时的原因摘要。
1
+ // ExperimentList:实体列表的第一级。web 面是一行一个 experiment 的固定列比较表;
2
+ // 每行用原生 <details> 展开到 Eval 与 Attempt locator。数据仍完全来自
3
+ // ExperimentList.data(),不恢复旧 ExperimentTable 的混合实体计算层。
6
4
 
7
5
  import type { ReactElement } from "react";
8
- import type { ExperimentListEvalRow, ExperimentListItem } from "../types.ts";
9
6
  import type { AttemptLocator } from "../../results/locator.ts";
7
+ import type { AttemptListItem, ExperimentListEvalRow, ExperimentListItem } from "../types.ts";
8
+ import { attemptItemReason, experimentDisplayName } from "../format.ts";
10
9
  import { DEFAULT_REPORT_LOCALE, localeText, type ReportLocale } from "../locale.ts";
11
- import { MetricCellView } from "./cell.tsx";
12
10
  import { AttemptLocatorBadge } from "./AttemptList.tsx";
11
+ import { MetricCellView } from "./cell.tsx";
13
12
  import { colorClassForKey } from "./colors.ts";
14
13
  import { cx, formatDurationMs, formatUSD, verdictMark } from "./format.ts";
15
14
 
16
- function EvalRow({
17
- row,
15
+ const verdictOrder = ["passed", "failed", "errored", "skipped"] as const;
16
+
17
+ function passRateTone(value: number | null): string | undefined {
18
+ if (value === null) return undefined;
19
+ if (value >= 0.8) return "nre-good";
20
+ if (value >= 0.5) return "nre-warn";
21
+ return "nre-bad";
22
+ }
23
+
24
+ function formatDate(value: string, locale: ReportLocale): string {
25
+ const date = new Date(value);
26
+ if (Number.isNaN(date.valueOf())) return value;
27
+ return new Intl.DateTimeFormat(locale === "zh-CN" ? "zh-CN" : "en", {
28
+ month: "short",
29
+ day: "numeric",
30
+ hour: "2-digit",
31
+ minute: "2-digit",
32
+ }).format(date);
33
+ }
34
+
35
+ function VerdictSummary({ item, locale }: { item: ExperimentListItem; locale: ReportLocale }): ReactElement {
36
+ const parts = verdictOrder
37
+ .filter((verdict) => item.verdicts[verdict] > 0)
38
+ .map((verdict) => `${item.verdicts[verdict]} ${localeText(locale, `verdict.${verdict}`)}`);
39
+ return <span className="nre-experiment-pill">{parts.join(" / ") || "—"}</span>;
40
+ }
41
+
42
+ function ExperimentAttemptRow({
43
+ attempt,
44
+ last,
18
45
  attemptHref,
19
46
  }: {
20
- row: ExperimentListEvalRow;
47
+ attempt: AttemptListItem;
48
+ last: boolean;
21
49
  attemptHref: (locator: AttemptLocator) => string;
22
50
  }): ReactElement {
51
+ const reason = attemptItemReason(attempt);
23
52
  return (
24
- <li className={cx("nre-experiment-eval-row", `nre-eval-${row.verdict}`)}>
25
- <span className={cx("nre-eval-verdict", `nre-verdict-${row.verdict}`)}>{verdictMark(row.verdict)}</span>
26
- <span className="nre-eval-id">{row.evalId}</span>
53
+ <li className={cx("nre-experiment-attempt-row", `nre-eval-${attempt.verdict}`)}>
54
+ <span className="nre-attempt-branch" aria-hidden="true">{last ? "└─" : "├─"}</span>
27
55
  <span className="nre-eval-attempt-badges">
28
- {row.attempts.map((attempt) => (
29
- <AttemptLocatorBadge key={attempt.locator} item={attempt} attemptHref={attemptHref} />
30
- ))}
56
+ <AttemptLocatorBadge item={attempt} attemptHref={attemptHref} />
31
57
  </span>
32
- {row.verdict === "passed" ? (
33
- <span className="nre-eval-avg">
34
- {formatDurationMs(row.duration.value ?? 0)}
35
- {row.cost.value !== null && <> · {formatUSD(row.cost.value)}</>}
58
+ <span className="nre-eval-attempt-metrics">
59
+ {formatDurationMs(attempt.durationMs)}
60
+ {attempt.costUSD !== undefined && <> · {formatUSD(attempt.costUSD)}</>}
61
+ </span>
62
+ <span className="nre-eval-reason">{reason ?? "—"}</span>
63
+ </li>
64
+ );
65
+ }
66
+
67
+ function EvalAttempts({
68
+ row,
69
+ attemptHref,
70
+ locale,
71
+ }: {
72
+ row: ExperimentListEvalRow;
73
+ attemptHref: (locator: AttemptLocator) => string;
74
+ locale: ReportLocale;
75
+ }): ReactElement {
76
+ const duration = row.duration.value === null ? localeText(locale, "cell.missing") : formatDurationMs(row.duration.value);
77
+ const cost = row.cost.value === null ? localeText(locale, "cell.missing") : formatUSD(row.cost.value);
78
+ return (
79
+ <li className="nre-experiment-eval">
80
+ <div className={cx("nre-experiment-eval-header", `nre-eval-${row.verdict}`)}>
81
+ <span className={cx("nre-eval-verdict", `nre-verdict-${row.verdict}`)}>{verdictMark(row.verdict)}</span>
82
+ <span className="nre-eval-id">{row.evalId}</span>
83
+ <span className="nre-eval-attempt-count">{localeText(locale, "overview.attemptsCount", { n: row.attempts.length })}</span>
84
+ <span className="nre-eval-rollup">
85
+ {localeText(locale, "entityList.average", { value: duration })}
86
+ {" · "}
87
+ {localeText(locale, "entityList.average", { value: cost })}
36
88
  </span>
37
- ) : (
38
- <span className="nre-eval-reason">{row.reason}</span>
39
- )}
89
+ </div>
90
+ <ul className="nre-experiment-attempts">
91
+ {row.attempts.map((attempt, index) => (
92
+ <ExperimentAttemptRow
93
+ key={attempt.locator}
94
+ attempt={attempt}
95
+ last={index === row.attempts.length - 1}
96
+ attemptHref={attemptHref}
97
+ />
98
+ ))}
99
+ </ul>
40
100
  </li>
41
101
  );
42
102
  }
43
103
 
44
- function ExperimentCard({
104
+ function Flags({ flags, locale }: { flags: Record<string, unknown> | undefined; locale: ReportLocale }): ReactElement | null {
105
+ if (!flags || Object.keys(flags).length === 0) return null;
106
+ return (
107
+ <div className="nre-experiment-flags">
108
+ <span>{localeText(locale, "experimentList.flags")}</span>
109
+ {Object.entries(flags).map(([key, value]) => (
110
+ <b key={key}>{key}={String(value)}</b>
111
+ ))}
112
+ </div>
113
+ );
114
+ }
115
+
116
+ function ExperimentRow({
45
117
  item,
46
118
  attemptHref,
47
119
  locale,
120
+ relativeTo,
48
121
  }: {
49
122
  item: ExperimentListItem;
50
123
  attemptHref: (locator: AttemptLocator) => string;
51
124
  locale: ReportLocale;
125
+ relativeTo?: string;
52
126
  }): ReactElement {
53
127
  return (
54
- <li className="nre-experiment-entry">
55
- <details className="nre-experiment-details">
56
- <summary className="nre-experiment-summary">
57
- <span className={cx("nre-experiment-id", "nre-key", colorClassForKey(item.experimentId))}>
58
- {item.experimentId}
59
- </span>
60
- <span className="nre-experiment-agent">{item.agent}</span>
61
- {item.model && <span className="nre-experiment-model">{item.model}</span>}
62
- <span className="nre-experiment-cell">
63
- <MetricCellView cell={item.passRate} attemptHref={attemptHref} locale={locale} />
64
- </span>
65
- <span className="nre-experiment-cell">
66
- <MetricCellView cell={item.cost} attemptHref={attemptHref} locale={locale} />
67
- </span>
68
- <span className="nre-experiment-cell">
69
- <MetricCellView cell={item.duration} attemptHref={attemptHref} locale={locale} />
70
- </span>
71
- <span className="nre-experiment-meta-sub">
128
+ <details className="nre-experiment-entry">
129
+ <summary className="nre-experiment-summary">
130
+ <span className="nre-experiment-name" data-sort-value={item.experimentId}>
131
+ <b className={cx("nre-experiment-id", "nre-key", colorClassForKey(item.experimentId))}>
132
+ {experimentDisplayName(item.experimentId, relativeTo)}
133
+ </b>
134
+ <small>
72
135
  {localeText(locale, "overview.evalsCount", { n: item.evals })}
73
136
  {item.attempts > item.evals ? ` · ${localeText(locale, "overview.attemptsCount", { n: item.attempts })}` : ""}
74
- {` · ${localeText(locale, "latestRun", { run: item.lastRunAt })}`}
75
- </span>
76
- </summary>
137
+ {` · ${formatDate(item.lastRunAt, locale)}`}
138
+ </small>
139
+ </span>
140
+ <span data-sort-value={item.model ?? ""}>{item.model ?? localeText(locale, "experimentList.defaultModel")}</span>
141
+ <span data-sort-value={item.agent}>{item.agent}</span>
142
+ <span className="nre-num" data-sort-value={item.duration.value ?? ""}>
143
+ <MetricCellView cell={item.duration} locale={locale} />
144
+ </span>
145
+ <span className={cx("nre-num", passRateTone(item.passRate.value))} data-sort-value={item.passRate.value ?? ""}>
146
+ <MetricCellView cell={item.passRate} locale={locale} />
147
+ </span>
148
+ <span className="nre-num" data-sort-value={item.tokens.value ?? ""}>
149
+ <MetricCellView cell={item.tokens} locale={locale} />
150
+ </span>
151
+ <span className="nre-num" data-sort-value={item.cost.value ?? ""}>
152
+ <MetricCellView cell={item.cost} locale={locale} />
153
+ </span>
154
+ <span data-sort-value={item.verdicts.passed}><VerdictSummary item={item} locale={locale} /></span>
155
+ </summary>
156
+ <div className="nre-experiment-detail">
157
+ <Flags flags={item.flags} locale={locale} />
77
158
  <ul className="nre-experiment-evals">
78
159
  {item.evalRows.map((row) => (
79
- <EvalRow key={row.evalId} row={row} attemptHref={attemptHref} />
160
+ <EvalAttempts key={row.evalId} row={row} attemptHref={attemptHref} locale={locale} />
80
161
  ))}
81
162
  </ul>
82
- </details>
83
- </li>
163
+ </div>
164
+ </details>
84
165
  );
85
166
  }
86
167
 
87
168
  export function ExperimentList({
88
169
  items,
89
170
  attemptHref,
171
+ filter = false,
90
172
  className,
91
173
  locale = DEFAULT_REPORT_LOCALE,
174
+ relativeTo,
92
175
  }: {
93
176
  items: ExperimentListItem[];
94
177
  attemptHref: (locator: AttemptLocator) => string;
178
+ filter?: boolean;
95
179
  className?: string;
96
180
  locale?: ReportLocale;
181
+ relativeTo?: string;
97
182
  }): ReactElement {
98
- return (
99
- <div className={cx("nre", "nre-experiment-list", className)}>
100
- {items.length === 0 && <p className="nre-experiment-list-empty">{localeText(locale, "attemptList.empty")}</p>}
101
- <ul className="nre-experiments">
102
- {items.map((item) => (
103
- <ExperimentCard key={item.experimentId} item={item} attemptHref={attemptHref} locale={locale} />
183
+ const labels = [
184
+ localeText(locale, "experimentList.experiment"),
185
+ localeText(locale, "table.model"),
186
+ localeText(locale, "table.agent"),
187
+ localeText(locale, "experimentList.avgDuration"),
188
+ localeText(locale, "experimentList.passRate"),
189
+ localeText(locale, "experimentList.tokens"),
190
+ localeText(locale, "experimentList.estimatedCost"),
191
+ localeText(locale, "experimentList.result"),
192
+ ];
193
+ const board = (
194
+ <div className="nre-experiment-table">
195
+ <div className="nre-experiment-head">
196
+ {labels.map((label, index) => (
197
+ <button
198
+ type="button"
199
+ data-nre-experiment-sort={index}
200
+ className={index === 4 ? "nre-sort-desc" : undefined}
201
+ key={label}
202
+ >
203
+ {label}
204
+ </button>
104
205
  ))}
105
- </ul>
206
+ </div>
207
+ {items.length === 0 && <p className="nre-experiment-list-empty">{localeText(locale, "attemptList.empty")}</p>}
208
+ {items.map((item) => (
209
+ <ExperimentRow key={item.experimentId} item={item} attemptHref={attemptHref} locale={locale} relativeTo={relativeTo} />
210
+ ))}
211
+ </div>
212
+ );
213
+ return (
214
+ <div className={cx("nre", "nre-experiment-list", filter && "nre-experiment-table-wrap", className)}>
215
+ {filter && (
216
+ <input
217
+ className="nre-filter"
218
+ data-nre-experiment-filter=""
219
+ type="search"
220
+ placeholder={localeText(locale, "experimentList.filterPlaceholder")}
221
+ />
222
+ )}
223
+ {board}
106
224
  </div>
107
225
  );
108
226
  }
@@ -1,6 +1,7 @@
1
1
  // MetricScatter:质量 × 成本 frontier 的积木,内联 SVG、零图表库、零 hooks。
2
2
  // 轴向随 better:"lower" 的轴反向画,「好」的角落恒在右上(成本轴 $20 → $0 就是这么来的);
3
- // niceTicks 刻度 + 网格线;每个点直接标注(防撞布局,被挤开时补 leader line);
3
+ // niceTicks 刻度 + 网格线;每个点直接标注(placePointLabels 候选位择优:避开其它标签、
4
+ // 数据点与画布边界,离开左右紧邻位时补 leader line);
4
5
  // 同系列的点按 x 值排序连线,系列图例列在图下。x 或 y 为 null 的点不画,
5
6
  // 底部注脚如实报「n 个点缺数据」;hover 信息退化为 SVG <title>,不 hydrate 也在
6
7
  // (enhance.js 在场时升级为样式化 tooltip)。配色走类名(nre-series-cN)由 CSS 上色,
@@ -10,7 +11,7 @@ import type { ReactElement } from "react";
10
11
  import type { MetricColumn, ScatterData } from "../types.ts";
11
12
  import { formatMetricValue } from "../format.ts";
12
13
  import { DEFAULT_REPORT_LOCALE, countText, localeText, resolveMetricLabel, type ReportLocale } from "../locale.ts";
13
- import { layoutLabelOffsets, niceTicks } from "./chart-math.ts";
14
+ import { niceTicks, placePointLabels } from "./chart-math.ts";
14
15
  import { colorClassForKey, seriesClassForKey } from "./colors.ts";
15
16
  import { cx } from "./format.ts";
16
17
 
@@ -88,16 +89,6 @@ export function MetricScatter({
88
89
  );
89
90
  }
90
91
 
91
- // 恰好 1 个可画点:成本 × 通过率的对比至少要两个实验,单点不成图 —— 显式说清而不是画个孤点。
92
- if (drawableRows.length === 1) {
93
- return (
94
- <figure className={cx("nre", "nre-metric-scatter", className)}>
95
- <p className="nre-scatter-empty">{localeText(locale, "scatter.needTwo", { x: xLabel, y: yLabel })}</p>
96
- {missingNote}
97
- </figure>
98
- );
99
- }
100
-
101
92
  const axisLabel = (label: string, col: MetricColumn) => `${label}${col.unit ? `(${col.unit})` : ""}`;
102
93
 
103
94
  const xScale = axisScale(drawableRows.map((r) => r.x.value as number), data.x.better, MARGIN.left, MARGIN.left + PLOT_W);
@@ -113,7 +104,7 @@ export function MetricScatter({
113
104
  label: pointLabel(r.key),
114
105
  xValue,
115
106
  yValue,
116
- // hover 内容:experiment(点键)+ 系列(series,CostPassRateComparison 传的是 agent 维度,
107
+ // hover 内容:experiment(点键)+ 系列(series,ExperimentComparison 传的是 agent 维度,
117
108
  // 有则加一行;无系列的散点没有这行)+ 两轴 display 与 samples/total(docs/feature/reports/library.md「MetricScatter」行为清单)
118
109
  title: `${r.key}${r.series !== undefined ? `\n${r.series}` : ""}\n${xLabel}: ${r.x.display}(${r.x.samples}/${r.x.total})\n${yLabel}: ${r.y.display}(${r.y.samples}/${r.y.total})`,
119
110
  px: xScale.scale(xValue),
@@ -134,12 +125,11 @@ export function MetricScatter({
134
125
  }
135
126
  for (const list of bySeries.values()) list.sort((a, b) => a.xValue - b.xValue);
136
127
 
137
- // 直接标签的防撞布局:靠右的点把标签锚到左侧,重叠的往下推、补 leader line
138
- const positions = points.map((p) => {
139
- const anchorLeft = p.px >= MARGIN.left + PLOT_W * 0.72;
140
- return { cx: p.px, cy: p.py, anchorLeft, width: p.label.length * 6.4 + 10 };
141
- });
142
- const labelOffsets = layoutLabelOffsets(positions);
128
+ // 直接标签的候选位择优布局:锚向、避让方向都由布局按空间决定,画布边界含边距
129
+ const labels = placePointLabels(
130
+ points.map((p) => ({ cx: p.px, cy: p.py, width: p.label.length * 6.4 + 10 })),
131
+ { x0: 2, y0: 2, x1: WIDTH - 2, y1: HEIGHT - 2 },
132
+ );
143
133
 
144
134
  return (
145
135
  <figure className={cx("nre", "nre-metric-scatter", className)}>
@@ -212,20 +202,18 @@ export function MetricScatter({
212
202
  {/* 点:g 内带 <title>(无 JS 的原生 hover)、直接标签与 leader line;
213
203
  pointHref 时包普通 <a>,静态导出也能下钻 */}
214
204
  {points.map((p, i) => {
215
- const { anchorLeft } = positions[i];
216
- const labelX = p.px + (anchorLeft ? -10 : 10);
217
- const labelY = p.py + 4 + labelOffsets[i];
205
+ const label = labels[i];
218
206
  const group = (
219
207
  <g
220
208
  className={cx("nre-scatter-point", p.series !== undefined ? seriesClassForKey(p.series) : "nre-series-none")}
221
209
  data-key={p.key}
222
210
  >
223
211
  <title>{p.title}</title>
224
- {/* 标签被挤开时补一条 leader line,避免脱离原点看不出对应关系 */}
225
- {labelOffsets[i] !== 0 && <line className="nre-leader" x1={p.px} y1={p.py} x2={labelX} y2={labelY - 4} />}
212
+ {/* 标签不在紧邻位时补一条 leader line,避免脱离原点看不出对应关系 */}
213
+ {label.leader && <line className="nre-leader" x1={p.px} y1={p.py} x2={label.x} y2={label.y - 4} />}
226
214
  <circle className="nre-scatter-hit" cx={p.px} cy={p.py} r={12} />
227
215
  <circle className="nre-scatter-dot" cx={p.px} cy={p.py} r={4.5} />
228
- <text className="nre-scatter-point-label" x={labelX} y={labelY} textAnchor={anchorLeft ? "end" : "start"}>
216
+ <text className="nre-scatter-point-label" x={label.x} y={label.y} textAnchor={label.anchor}>
229
217
  {p.label}
230
218
  </text>
231
219
  </g>
@@ -0,0 +1,85 @@
1
+ // cases: docs/engineering/unit-tests/reports/cases.md
2
+ // 分区「MetricScatter 点标签布局(web 面)」:对 placePointLabels 直接断言标签框与点框的几何关系,不经 HTML。
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { placePointLabels, type LabelBounds, type PlacedPointLabel, type PointLabelInput } from "./chart-math.ts";
6
+
7
+ const BOUNDS: LabelBounds = { x0: 2, y0: 2, x1: 638, y1: 358 };
8
+ const WIDTH = 90;
9
+
10
+ interface Box {
11
+ x0: number;
12
+ y0: number;
13
+ x1: number;
14
+ y1: number;
15
+ }
16
+
17
+ // 与契约声明的标签框一致:基线上方 10px、下方 2px,锚向决定水平延伸方向
18
+ function labelBox(l: PlacedPointLabel, width: number): Box {
19
+ const x0 = l.anchor === "start" ? l.x : l.anchor === "end" ? l.x - width : l.x - width / 2;
20
+ return { x0, y0: l.y - 10, x1: x0 + width, y1: l.y + 2 };
21
+ }
22
+
23
+ function dotBox(p: PointLabelInput): Box {
24
+ return { x0: p.cx - 6, y0: p.cy - 6, x1: p.cx + 6, y1: p.cy + 6 };
25
+ }
26
+
27
+ function overlaps(a: Box, b: Box): boolean {
28
+ return a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0;
29
+ }
30
+
31
+ describe("placePointLabels", () => {
32
+ it("近重合点簇:标签框两两不叠、不压任何数据点(只向下推的级联会把第三个标签推到下方点上)", () => {
33
+ // 三点近重合 + 右下方另一点:down-only 布局把第三个标签推到 y∈[122,134]、x≥310,
34
+ // 正好压住 (330,128) 的点框;候选位择优必须避开
35
+ const points: PointLabelInput[] = [
36
+ { cx: 300, cy: 100, width: WIDTH },
37
+ { cx: 303, cy: 102, width: WIDTH },
38
+ { cx: 298, cy: 104, width: WIDTH },
39
+ { cx: 330, cy: 128, width: WIDTH },
40
+ ];
41
+ const labels = placePointLabels(points, BOUNDS);
42
+ const boxes = labels.map((l) => labelBox(l, WIDTH));
43
+
44
+ for (let i = 0; i < boxes.length; i++) {
45
+ for (let j = i + 1; j < boxes.length; j++) {
46
+ expect(overlaps(boxes[i], boxes[j]), `标签 ${i} 与标签 ${j} 重叠`).toBe(false);
47
+ }
48
+ for (let j = 0; j < points.length; j++) {
49
+ expect(overlaps(boxes[i], dotBox(points[j])), `标签 ${i} 压住点 ${j}`).toBe(false);
50
+ }
51
+ }
52
+ // 离开左右紧邻位的标签必须带 leader 标记,渲染层据此连回原点
53
+ for (let i = 0; i < labels.length; i++) {
54
+ const adjacent =
55
+ labels[i].y === points[i].cy + 4 &&
56
+ ((labels[i].anchor === "start" && labels[i].x === points[i].cx + 10) ||
57
+ (labels[i].anchor === "end" && labels[i].x === points[i].cx - 10));
58
+ expect(labels[i].leader, `标签 ${i} 的 leader 标记与位置不符`).toBe(!adjacent);
59
+ }
60
+ });
61
+
62
+ it("无冲突取右侧紧邻位不带 leader;右缘点锚到左侧紧邻位且标签框不越出画布", () => {
63
+ const sparse = placePointLabels(
64
+ [
65
+ { cx: 100, cy: 100, width: WIDTH },
66
+ { cx: 400, cy: 250, width: WIDTH },
67
+ ],
68
+ BOUNDS,
69
+ );
70
+ for (const [i, l] of sparse.entries()) {
71
+ expect(l.anchor).toBe("start");
72
+ expect(l.x).toBe([100, 400][i] + 10);
73
+ expect(l.leader).toBe(false);
74
+ }
75
+
76
+ const [edge] = placePointLabels([{ cx: 630, cy: 200, width: WIDTH }], BOUNDS);
77
+ const box = labelBox(edge, WIDTH);
78
+ expect(edge.anchor).toBe("end");
79
+ expect(edge.leader).toBe(false);
80
+ expect(box.x0).toBeGreaterThanOrEqual(BOUNDS.x0);
81
+ expect(box.x1).toBeLessThanOrEqual(BOUNDS.x1);
82
+ expect(box.y0).toBeGreaterThanOrEqual(BOUNDS.y0);
83
+ expect(box.y1).toBeLessThanOrEqual(BOUNDS.y1);
84
+ });
85
+ });
@@ -1,5 +1,4 @@
1
- // 通用图表数值工具:round-number 刻度生成与标签防撞布局,零依赖纯函数。
2
- // 从 src/view/app/lib/chart.ts 迁来(view 侧从这里 re-export),报告组件与 view 共用同一份。
1
+ // 通用图表数值工具:round-number 刻度生成与点标签布局,零依赖纯函数。
3
2
 
4
3
  function niceNumber(range: number, round: boolean): number {
5
4
  if (range <= 0) return 1;
@@ -35,34 +34,114 @@ export function niceTicks(min: number, max: number, count = 5): number[] {
35
34
  return ticks;
36
35
  }
37
36
 
38
- export interface LabelInput {
37
+ export interface PointLabelInput {
39
38
  cx: number;
40
39
  cy: number;
40
+ /** 标签文本的估算宽度(px)。 */
41
41
  width: number;
42
- /** true = 文字锚在点左侧(text-anchor: end),false = 锚在右侧(text-anchor: start)。 */
43
- anchorLeft: boolean;
42
+ }
43
+
44
+ /** 标签允许占用的画布范围(含坐标轴边距,标签可以借用空白边距)。 */
45
+ export interface LabelBounds {
46
+ x0: number;
47
+ y0: number;
48
+ x1: number;
49
+ y1: number;
50
+ }
51
+
52
+ export interface PlacedPointLabel {
53
+ /** <text> 的锚点坐标(y 为文字基线)。 */
54
+ x: number;
55
+ y: number;
56
+ anchor: "start" | "end" | "middle";
57
+ /** 标签不在点左右紧邻位:渲染层补 leader line 连回原点。 */
58
+ leader: boolean;
59
+ }
60
+
61
+ /** 标签框高度(基线上方 10px、下方 2px,对应 11px 字号)。 */
62
+ const LABEL_ASCENT = 10;
63
+ const LABEL_DESCENT = 2;
64
+ /** 数据点视为边长 2×DOT_R 的方形障碍(点半径 4.5 + 命中余量)。 */
65
+ const DOT_R = 6;
66
+ /** 紧邻位与点的水平间距;候选环按它逐环外扩。 */
67
+ const NEAR = 10;
68
+ const RINGS = [NEAR, NEAR + 12, NEAR + 26];
69
+
70
+ interface Box {
71
+ x0: number;
72
+ y0: number;
73
+ x1: number;
74
+ y1: number;
75
+ }
76
+
77
+ function labelBoxAt(x: number, y: number, anchor: PlacedPointLabel["anchor"], width: number): Box {
78
+ const x0 = anchor === "start" ? x : anchor === "end" ? x - width : x - width / 2;
79
+ return { x0, y0: y - LABEL_ASCENT, x1: x0 + width, y1: y + LABEL_DESCENT };
80
+ }
81
+
82
+ function overlapArea(a: Box, b: Box): number {
83
+ const w = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
84
+ const h = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
85
+ return w > 0 && h > 0 ? w * h : 0;
86
+ }
87
+
88
+ function outOfBoundsArea(box: Box, bounds: LabelBounds): number {
89
+ const total = (box.x1 - box.x0) * (box.y1 - box.y0);
90
+ const w = Math.min(box.x1, bounds.x1) - Math.max(box.x0, bounds.x0);
91
+ const h = Math.min(box.y1, bounds.y1) - Math.max(box.y0, bounds.y0);
92
+ const inside = w > 0 && h > 0 ? w * h : 0;
93
+ return total - inside;
94
+ }
95
+
96
+ /** 一个点在距离 d 的候选环:左右紧邻、四个斜角、正上正下。顺序即同代价时的偏好序。 */
97
+ function ringCandidates(p: PointLabelInput, d: number): Omit<PlacedPointLabel, "leader">[] {
98
+ const diag = d * 0.75;
99
+ return [
100
+ { x: p.cx + d, y: p.cy + 4, anchor: "start" },
101
+ { x: p.cx - d, y: p.cy + 4, anchor: "end" },
102
+ { x: p.cx + diag, y: p.cy - diag - 2, anchor: "start" },
103
+ { x: p.cx - diag, y: p.cy - diag - 2, anchor: "end" },
104
+ { x: p.cx, y: p.cy - d - LABEL_DESCENT - 2, anchor: "middle" },
105
+ { x: p.cx + diag, y: p.cy + diag + LABEL_ASCENT, anchor: "start" },
106
+ { x: p.cx - diag, y: p.cy + diag + LABEL_ASCENT, anchor: "end" },
107
+ { x: p.cx, y: p.cy + d + LABEL_ASCENT + 2, anchor: "middle" },
108
+ ];
44
109
  }
45
110
 
46
111
  /**
47
- * 贪心地把互相重叠的直接标签往下推开(按 y 排序、逐个查重叠再下移),点本身位置不变。
48
- * 散点常见同分不同价的聚簇,这一步避免标签叠成一团糊字。
112
+ * 散点直接标签的候选位择优布局(docs/feature/reports/library.md「MetricScatter」):
113
+ * 每个标签在点四周由近及远的候选环上打分——与已放置标签的重叠、与任何数据点的重叠、
114
+ * 越出画布的面积、离点距离逐项累加,取代价最小的候选。存在无冲突候选时标签必不遮点、
115
+ * 不叠标签、不越界;全候选冲突时取重叠最小者,绝不丢标签。重合点簇因此向不同方向散开,
116
+ * 而不是单向堆叠压到下方的数据点上。
49
117
  */
50
- export function layoutLabelOffsets(items: LabelInput[], lineHeight = 14, padX = 4): number[] {
51
- const offsets = new Array(items.length).fill(0);
52
- const order = [...items.keys()].sort((a, b) => items[a].cy - items[b].cy || items[a].cx - items[b].cx);
53
- const placed: { x0: number; x1: number; y: number }[] = [];
118
+ export function placePointLabels(points: PointLabelInput[], bounds: LabelBounds): PlacedPointLabel[] {
119
+ const dots: Box[] = points.map((p) => ({ x0: p.cx - DOT_R, y0: p.cy - DOT_R, x1: p.cx + DOT_R, y1: p.cy + DOT_R }));
120
+ const placedBoxes: Box[] = [];
121
+ const result: PlacedPointLabel[] = new Array(points.length);
122
+ // 由上到下、由左到右逐个放置,布局确定可复现
123
+ const order = [...points.keys()].sort((a, b) => points[a].cy - points[b].cy || points[a].cx - points[b].cx);
124
+
54
125
  for (const i of order) {
55
- const it = items[i];
56
- const x0 = it.anchorLeft ? it.cx - it.width : it.cx;
57
- const x1 = it.anchorLeft ? it.cx : it.cx + it.width;
58
- let y = it.cy;
59
- let guard = 0;
60
- while (guard < 40 && placed.some((p) => x0 < p.x1 + padX && x1 > p.x0 - padX && Math.abs(y - p.y) < lineHeight)) {
61
- y += lineHeight;
62
- guard++;
126
+ const p = points[i];
127
+ let best: { candidate: Omit<PlacedPointLabel, "leader">; box: Box; cost: number; leader: boolean } | null = null;
128
+ for (let ring = 0; ring < RINGS.length; ring++) {
129
+ const candidates = ringCandidates(p, RINGS[ring]);
130
+ for (let c = 0; c < candidates.length; c++) {
131
+ const candidate = candidates[c];
132
+ const box = labelBoxAt(candidate.x, candidate.y, candidate.anchor, p.width);
133
+ let cost = 0;
134
+ for (const placed of placedBoxes) cost += overlapArea(box, placed) * 4;
135
+ for (const dot of dots) cost += overlapArea(box, dot) * 3;
136
+ cost += outOfBoundsArea(box, bounds) * 6;
137
+ cost += ring * 8 + c * 0.5; // 近环、偏好序靠前者优先,只作同冲突程度下的排序
138
+ if (best === null || cost < best.cost) {
139
+ best = { candidate, box, cost, leader: !(ring === 0 && c <= 1) };
140
+ }
141
+ }
63
142
  }
64
- placed.push({ x0, x1, y });
65
- offsets[i] = y - it.cy;
143
+ placedBoxes.push(best!.box);
144
+ result[i] = { ...best!.candidate, leader: best!.leader };
66
145
  }
67
- return offsets;
146
+ return result;
68
147
  }