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.
- package/dist/agents/types.d.ts +72 -6
- package/dist/context/types.d.ts +32 -12
- package/dist/i18n/en.d.ts +54 -0
- package/dist/i18n/zh-CN.d.ts +55 -1
- package/dist/o11y/types.d.ts +16 -2
- package/dist/report/aggregate.d.ts +5 -3
- package/dist/report/aggregate.js +32 -5
- package/dist/report/built-ins/experiment-comparison.d.ts +39 -0
- package/dist/report/built-ins/experiment-comparison.js +119 -0
- package/dist/report/built-ins/index.d.ts +2 -1
- package/dist/report/built-ins/index.js +2 -2
- package/dist/report/components.d.ts +10 -2
- package/dist/report/components.js +3 -3
- package/dist/report/compute.d.ts +11 -18
- package/dist/report/compute.js +68 -66
- package/dist/report/flag.d.ts +16 -1
- package/dist/report/flag.js +19 -1
- package/dist/report/format.d.ts +16 -14
- package/dist/report/format.js +28 -30
- package/dist/report/index.d.ts +5 -4
- package/dist/report/index.js +6 -5
- package/dist/report/locale.d.ts +23 -3
- package/dist/report/locale.js +47 -6
- package/dist/report/metrics.d.ts +13 -1
- package/dist/report/metrics.js +66 -15
- package/dist/report/primitives.d.ts +6 -0
- package/dist/report/react/AttemptList.d.ts +4 -4
- package/dist/report/react/AttemptList.js +8 -10
- package/dist/report/react/EvalList.d.ts +1 -1
- package/dist/report/react/EvalList.js +0 -0
- package/dist/report/react/ExperimentComparison.d.ts +8 -0
- package/dist/report/react/ExperimentComparison.js +11 -0
- package/dist/report/react/ExperimentList.d.ts +4 -2
- package/dist/report/react/ExperimentList.js +57 -7
- package/dist/report/react/MetricScatter.js +6 -16
- package/dist/report/react/chart-math.d.ts +23 -6
- package/dist/report/react/chart-math.js +71 -19
- package/dist/report/react/fixtures.d.ts +3 -3
- package/dist/report/react/fixtures.js +30 -18
- package/dist/report/react/format.d.ts +1 -1
- package/dist/report/react/format.js +1 -1
- package/dist/report/react/index.d.ts +1 -1
- package/dist/report/report.d.ts +5 -1
- package/dist/report/report.js +6 -2
- package/dist/report/text/faces.d.ts +1 -1
- package/dist/report/text/faces.js +100 -61
- package/dist/report/text/table.js +36 -5
- package/dist/report/types.d.ts +40 -34
- package/dist/results/types.d.ts +11 -0
- package/dist/runner/feedback/sink.d.ts +110 -0
- package/dist/runner/types.d.ts +513 -22
- package/dist/sandbox/docker.d.ts +23 -2
- package/dist/sandbox/e2b.d.ts +15 -1
- package/dist/sandbox/errors.d.ts +30 -3
- package/dist/sandbox/io-retry.d.ts +17 -0
- package/dist/sandbox/registry.d.ts +2 -0
- package/dist/sandbox/resolve.d.ts +18 -5
- package/dist/sandbox/retry.d.ts +11 -1
- package/dist/sandbox/types.d.ts +39 -5
- package/dist/sandbox/vercel.d.ts +7 -1
- package/dist/scoring/coverage.d.ts +30 -0
- package/dist/scoring/display.d.ts +21 -0
- package/dist/scoring/display.js +120 -0
- package/dist/scoring/types.d.ts +103 -20
- package/dist/shared/aggregate.d.ts +1 -0
- package/dist/shared/aggregate.js +3 -3
- package/dist/shared/types.d.ts +28 -0
- package/dist/tty-line.d.ts +0 -4
- package/dist/util.d.ts +23 -0
- package/docs-site/zh/concepts/adapter.mdx +24 -6
- package/docs-site/zh/concepts/assert.mdx +11 -10
- package/docs-site/zh/concepts/evals.mdx +7 -6
- package/docs-site/zh/concepts/experiment.mdx +1 -1
- package/docs-site/zh/concepts/overview.mdx +7 -7
- package/docs-site/zh/guides/agent-feedback-loop.mdx +35 -31
- package/docs-site/zh/guides/authoring.mdx +33 -0
- package/docs-site/zh/guides/ci-integration.mdx +23 -12
- package/docs-site/zh/guides/connect-your-agent.mdx +29 -3
- package/docs-site/zh/guides/custom-reports.mdx +29 -34
- package/docs-site/zh/guides/dataset-fanout.mdx +25 -3
- package/docs-site/zh/guides/debug-sandbox.mdx +57 -0
- package/docs-site/zh/guides/debugging.mdx +210 -0
- package/docs-site/zh/guides/experiments.mdx +10 -3
- package/docs-site/zh/guides/fixtures.mdx +3 -1
- package/docs-site/zh/guides/official-adapters.mdx +27 -3
- package/docs-site/zh/guides/publish-report.mdx +30 -16
- package/docs-site/zh/guides/report-components.mdx +49 -37
- package/docs-site/zh/guides/reporters.mdx +2 -2
- package/docs-site/zh/guides/results-data.mdx +42 -8
- package/docs-site/zh/guides/runner.mdx +17 -7
- package/docs-site/zh/guides/sandbox-agent.mdx +57 -7
- package/docs-site/zh/guides/sandbox-providers.mdx +258 -10
- package/docs-site/zh/guides/scoring-guide.mdx +4 -4
- package/docs-site/zh/guides/viewing-results.mdx +85 -41
- package/docs-site/zh/guides/write-experiment.mdx +5 -3
- package/docs-site/zh/guides/write-send.mdx +19 -2
- package/docs-site/zh/index.mdx +1 -1
- package/docs-site/zh/reference/builtin-agents.mdx +27 -0
- package/docs-site/zh/reference/capabilities.mdx +2 -2
- package/docs-site/zh/reference/cli.mdx +35 -9
- package/docs-site/zh/reference/define-agent.mdx +60 -5
- package/docs-site/zh/reference/define-config.mdx +1 -1
- package/docs-site/zh/reference/define-eval.mdx +42 -9
- package/docs-site/zh/reference/events.mdx +2 -2
- package/docs-site/zh/reference/expect.mdx +36 -6
- package/package.json +5 -1
- package/src/agents/ai-sdk-otel.test.ts +1 -0
- package/src/agents/ai-sdk.test.ts +3 -0
- package/src/agents/ai-sdk.ts +3 -0
- package/src/agents/bub-install-spec.test.ts +34 -0
- package/src/agents/bub-install-spec.ts +32 -0
- package/src/agents/bub.ts +31 -32
- package/src/agents/claude-code.test.ts +130 -9
- package/src/agents/claude-code.ts +76 -4
- package/src/agents/codex.test.ts +189 -40
- package/src/agents/codex.ts +155 -14
- package/src/agents/coding-cli-versions.test.ts +15 -0
- package/src/agents/coding-cli-versions.ts +3 -0
- package/src/agents/index.ts +11 -0
- package/src/agents/langgraph.test.ts +204 -0
- package/src/agents/langgraph.ts +495 -0
- package/src/agents/marketplace.ts +85 -0
- package/src/agents/native-config.test.ts +179 -0
- package/src/agents/native-config.ts +267 -0
- package/src/agents/openai-compat.test.ts +1 -0
- package/src/agents/openclaw.test.ts +31 -0
- package/src/agents/openclaw.ts +171 -0
- package/src/agents/plugin-config.test.ts +1 -0
- package/src/agents/sdk-streams.test.ts +79 -0
- package/src/agents/sdk-streams.ts +55 -10
- package/src/agents/skills.test.ts +1 -0
- package/src/agents/streaming.test.ts +3 -9
- package/src/agents/types.ts +73 -6
- package/src/agents/ui-message-stream.test.ts +3 -0
- package/src/cli.ts +411 -108
- package/src/context/context.test.ts +51 -12
- package/src/context/context.ts +161 -29
- package/src/context/session.test.ts +1 -0
- package/src/context/session.ts +114 -6
- package/src/context/types.ts +30 -12
- package/src/define.test.ts +13 -8
- package/src/define.ts +25 -4
- package/src/expect/index.ts +53 -23
- package/src/i18n/en.ts +65 -4
- package/src/i18n/zh-CN.ts +66 -4
- package/src/o11y/cost.test.ts +1 -0
- package/src/o11y/execution-tree.test.ts +1 -20
- package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
- package/src/o11y/otlp/parse.test.ts +1 -0
- package/src/o11y/otlp/turn-otel.test.ts +1 -0
- package/src/o11y/parsers/bub.test.ts +1 -0
- package/src/o11y/parsers/claude-code.test.ts +1 -34
- package/src/o11y/parsers/openclaw.test.ts +154 -0
- package/src/o11y/parsers/openclaw.ts +310 -0
- package/src/o11y/prices.json +746 -311
- package/src/o11y/tool-names.test.ts +1 -0
- package/src/o11y/types.ts +16 -2
- package/src/report/aggregate.ts +34 -5
- package/src/report/built-in-user-parity.test.tsx +127 -173
- package/src/report/built-ins/experiment-comparison.tsx +179 -0
- package/src/report/built-ins/index.ts +7 -2
- package/src/report/components.tsx +11 -3
- package/src/report/compute.ts +80 -74
- package/src/report/dual-render.test.tsx +222 -91
- package/src/report/flag.ts +30 -2
- package/src/report/format.ts +36 -27
- package/src/report/index.ts +23 -6
- package/src/report/locale.ts +49 -6
- package/src/report/metrics.ts +68 -15
- package/src/report/primitives.tsx +6 -0
- package/src/report/react/AttemptList.tsx +9 -36
- package/src/report/react/EvalList.tsx +0 -0
- package/src/report/react/ExperimentComparison.tsx +68 -0
- package/src/report/react/ExperimentList.tsx +173 -55
- package/src/report/react/MetricScatter.tsx +13 -25
- package/src/report/react/chart-math.test.ts +85 -0
- package/src/report/react/chart-math.ts +101 -22
- package/src/report/react/enhance.js +72 -1
- package/src/report/react/fixtures.ts +34 -21
- package/src/report/react/format.ts +1 -1
- package/src/report/react/index.tsx +0 -1
- package/src/report/react/render.test.tsx +30 -69
- package/src/report/react/styles.css +112 -14
- package/src/report/report.test.ts +308 -105
- package/src/report/report.ts +6 -2
- package/src/report/text/faces.ts +111 -67
- package/src/report/text/table.ts +42 -5
- package/src/report/types.ts +42 -34
- package/src/results/annotated-source.test.ts +62 -9
- package/src/results/annotated-source.ts +64 -6
- package/src/results/attempt-evidence.test.ts +9 -7
- package/src/results/attempt-evidence.ts +15 -8
- package/src/results/attempt-source.ts +6 -3
- package/src/results/copy.ts +145 -55
- package/src/results/host-equivalence.test.ts +11 -9
- package/src/results/index.ts +2 -0
- package/src/results/locator.test.ts +1 -22
- package/src/results/open.ts +7 -1
- package/src/results/publish.ts +149 -0
- package/src/results/results.test.ts +85 -51
- package/src/results/truncate.ts +90 -0
- package/src/results/types.ts +7 -0
- package/src/results/writer.ts +31 -13
- package/src/runner/attempt.test.ts +138 -7
- package/src/runner/attempt.ts +603 -104
- package/src/runner/discover.test.ts +47 -0
- package/src/runner/discover.ts +36 -2
- package/src/runner/eval-source.test.ts +1 -27
- package/src/runner/feedback/agent.test.ts +504 -0
- package/src/runner/feedback/agent.ts +409 -0
- package/src/runner/feedback/ci.test.ts +562 -0
- package/src/runner/feedback/ci.ts +401 -0
- package/src/runner/feedback/coordinator.test.ts +317 -0
- package/src/runner/feedback/coordinator.ts +397 -0
- package/src/runner/feedback/failure.ts +40 -0
- package/src/runner/feedback/human.test.ts +616 -0
- package/src/runner/feedback/human.ts +535 -0
- package/src/runner/feedback/index.ts +66 -0
- package/src/runner/feedback/io.ts +78 -0
- package/src/runner/feedback/profile.test.ts +50 -0
- package/src/runner/feedback/profile.ts +58 -0
- package/src/runner/feedback/reducer.test.ts +395 -0
- package/src/runner/feedback/reducer.ts +260 -0
- package/src/runner/feedback/renderer.ts +82 -0
- package/src/runner/feedback/sink.ts +203 -0
- package/src/runner/feedback/testing.ts +106 -0
- package/src/runner/ledger.test.ts +230 -0
- package/src/runner/ledger.ts +329 -0
- package/src/runner/report.test.ts +128 -3
- package/src/runner/report.ts +33 -9
- package/src/runner/reporters/artifacts.ts +8 -2
- package/src/runner/reporters/braintrust.test.ts +8 -7
- package/src/runner/reporters/braintrust.ts +9 -2
- package/src/runner/reporters/index.ts +2 -2
- package/src/runner/reporters/json.test.ts +162 -0
- package/src/runner/reporters/json.ts +35 -8
- package/src/runner/reporters/shared.ts +1 -5
- package/src/runner/run.test.ts +760 -3
- package/src/runner/run.ts +242 -36
- package/src/runner/sandbox-prep.ts +3 -42
- package/src/runner/timing.ts +158 -0
- package/src/runner/types.ts +518 -22
- package/src/sandbox/checkpoint.test.ts +55 -0
- package/src/sandbox/checkpoint.ts +29 -8
- package/src/sandbox/cli-commands.ts +407 -0
- package/src/sandbox/docker.ts +115 -16
- package/src/sandbox/e2b-agent-template.test.ts +56 -0
- package/src/sandbox/e2b-agent-template.ts +94 -0
- package/src/sandbox/e2b.ts +74 -9
- package/src/sandbox/errors.ts +111 -4
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/io-retry.test.ts +58 -0
- package/src/sandbox/io-retry.ts +45 -0
- package/src/sandbox/keep-registry.test.ts +86 -0
- package/src/sandbox/keep-registry.ts +142 -0
- package/src/sandbox/keep.ts +178 -0
- package/src/sandbox/paths.test.ts +1 -0
- package/src/sandbox/paths.ts +19 -8
- package/src/sandbox/registry.ts +20 -3
- package/src/sandbox/resolve.ts +76 -11
- package/src/sandbox/retry.test.ts +70 -0
- package/src/sandbox/retry.ts +46 -4
- package/src/sandbox/types.ts +44 -6
- package/src/sandbox/vercel.ts +43 -20
- package/src/scoring/collector.ts +60 -17
- package/src/scoring/coverage.ts +95 -0
- package/src/scoring/diff.ts +81 -0
- package/src/scoring/display.test.ts +121 -0
- package/src/scoring/display.ts +133 -0
- package/src/scoring/evidence.test.ts +189 -0
- package/src/scoring/judge.test.ts +142 -0
- package/src/scoring/judge.ts +15 -18
- package/src/scoring/scoped.ts +217 -50
- package/src/scoring/types.ts +117 -20
- package/src/scoring/verdict.ts +16 -4
- package/src/shared/aggregate.ts +3 -2
- package/src/shared/types.ts +31 -0
- package/src/show/compose.ts +2 -2
- package/src/show/index.ts +29 -16
- package/src/show/render.ts +626 -308
- package/src/show/show.test.ts +251 -36
- package/src/tty-line.ts +8 -26
- package/src/util.test.ts +1 -0
- package/src/util.ts +41 -0
- package/src/view/app/components/AttemptModal.tsx +153 -2
- package/src/view/app/components/CodeView.tsx +32 -11
- package/src/view/app/components/CopyControls.tsx +2 -2
- package/src/view/app/i18n.ts +6 -0
- package/src/view/app/lib/attempt-route.test.ts +1 -0
- package/src/view/app/lib/verdict.ts +7 -9
- package/src/view/artifact-serving.test.ts +2 -1
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +17 -17
- package/src/view/data.test.ts +2 -1
- package/src/view/data.ts +17 -7
- package/src/view/index.ts +12 -1
- package/src/view/server.ts +2 -0
- package/src/view/shared/types.ts +1 -1
- package/src/view/styles.css +3 -0
- package/src/view/view-report.test.ts +11 -10
- package/dist/o11y/execution-tree.d.ts +0 -103
- package/dist/o11y/otlp/select.d.ts +0 -22
- package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +0 -1
- package/dist/report/built-ins/cost-pass-rate-comparison.js +0 -17
- package/dist/results/annotated-source.d.ts +0 -61
- package/dist/results/attempt-evidence.d.ts +0 -69
- package/dist/results/attempt-source.d.ts +0 -15
- package/src/report/built-ins/cost-pass-rate-comparison.tsx +0 -23
- package/src/runner/reporters/console.ts +0 -70
- package/src/runner/reporters/live.test.ts +0 -56
- package/src/runner/reporters/live.ts +0 -247
- package/src/runner/reporters/quiet.test.ts +0 -66
- package/src/runner/reporters/quiet.ts +0 -49
- package/src/runner/reporters/table.ts +0 -277
|
@@ -1,108 +1,226 @@
|
|
|
1
|
-
// ExperimentList
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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-
|
|
25
|
-
<span className=
|
|
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
|
-
{
|
|
29
|
-
<AttemptLocatorBadge key={attempt.locator} item={attempt} attemptHref={attemptHref} />
|
|
30
|
-
))}
|
|
56
|
+
<AttemptLocatorBadge item={attempt} attemptHref={attemptHref} />
|
|
31
57
|
</span>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
55
|
-
<
|
|
56
|
-
<
|
|
57
|
-
<
|
|
58
|
-
{item.experimentId}
|
|
59
|
-
</
|
|
60
|
-
<
|
|
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
|
-
{` · ${
|
|
75
|
-
</
|
|
76
|
-
</
|
|
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
|
-
<
|
|
160
|
+
<EvalAttempts key={row.evalId} row={row} attemptHref={attemptHref} locale={locale} />
|
|
80
161
|
))}
|
|
81
162
|
</ul>
|
|
82
|
-
</
|
|
83
|
-
</
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
</
|
|
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 刻度 + 网格线;每个点直接标注(
|
|
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 {
|
|
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,
|
|
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
|
-
//
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
{/*
|
|
225
|
-
{
|
|
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={
|
|
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
|
|
37
|
+
export interface PointLabelInput {
|
|
39
38
|
cx: number;
|
|
40
39
|
cy: number;
|
|
40
|
+
/** 标签文本的估算宽度(px)。 */
|
|
41
41
|
width: number;
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
*
|
|
48
|
-
*
|
|
112
|
+
* 散点直接标签的候选位择优布局(docs/feature/reports/library.md「MetricScatter」):
|
|
113
|
+
* 每个标签在点四周由近及远的候选环上打分——与已放置标签的重叠、与任何数据点的重叠、
|
|
114
|
+
* 越出画布的面积、离点距离逐项累加,取代价最小的候选。存在无冲突候选时标签必不遮点、
|
|
115
|
+
* 不叠标签、不越界;全候选冲突时取重叠最小者,绝不丢标签。重合点簇因此向不同方向散开,
|
|
116
|
+
* 而不是单向堆叠压到下方的数据点上。
|
|
49
117
|
*/
|
|
50
|
-
export function
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
143
|
+
placedBoxes.push(best!.box);
|
|
144
|
+
result[i] = { ...best!.candidate, leader: best!.leader };
|
|
66
145
|
}
|
|
67
|
-
return
|
|
146
|
+
return result;
|
|
68
147
|
}
|