@oh-my-pi/omp-stats 18.16.0 → 18.17.0
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/CHANGELOG.md +7 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +102 -99
- package/dist/types/client/api.d.ts +4 -1
- package/dist/types/client/app/nav.d.ts +1 -1
- package/dist/types/client/charts/Chart.d.ts +16 -8
- package/dist/types/client/charts/DotWhisker.d.ts +50 -0
- package/dist/types/client/charts/Sparkline.d.ts +1 -1
- package/dist/types/client/charts/Waterfall.d.ts +26 -0
- package/dist/types/client/charts/index.d.ts +2 -0
- package/dist/types/client/charts/types.d.ts +8 -2
- package/dist/types/client/routes/SpotlightRoute.d.ts +7 -0
- package/dist/types/client/routes/index.d.ts +1 -0
- package/dist/types/client/spotlight/Compounding.d.ts +8 -0
- package/dist/types/client/spotlight/Coverage.d.ts +6 -0
- package/dist/types/client/spotlight/Economics.d.ts +10 -0
- package/dist/types/client/spotlight/FetchBack.d.ts +9 -0
- package/dist/types/client/spotlight/Hero.d.ts +12 -0
- package/dist/types/client/spotlight/Overhead.d.ts +6 -0
- package/dist/types/client/spotlight/Ranking.d.ts +6 -0
- package/dist/types/client/spotlight/ResultsFeed.d.ts +6 -0
- package/dist/types/client/spotlight/Savings.d.ts +10 -0
- package/dist/types/client/spotlight/ShareTrend.d.ts +8 -0
- package/dist/types/client/spotlight/Steps.d.ts +6 -0
- package/dist/types/client/spotlight/TimeCost.d.ts +6 -0
- package/dist/types/client/spotlight/chips.d.ts +16 -0
- package/dist/types/client/spotlight/meta.d.ts +64 -0
- package/dist/types/db.d.ts +26 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/parser.d.ts +4 -0
- package/dist/types/rollup.d.ts +22 -1
- package/dist/types/shared-types.d.ts +419 -0
- package/dist/types/spotlight-aggregator.d.ts +189 -0
- package/dist/types/spotlight-bootstrap.d.ts +32 -0
- package/dist/types/spotlight-ingest.d.ts +258 -0
- package/dist/types/spotlight-measures.d.ts +51 -0
- package/package.json +5 -5
- package/src/aggregator.ts +3 -1
- package/src/client/App.tsx +3 -0
- package/src/client/api.ts +21 -0
- package/src/client/app/nav.ts +4 -1
- package/src/client/charts/Chart.tsx +105 -22
- package/src/client/charts/DotWhisker.tsx +149 -0
- package/src/client/charts/Sparkline.tsx +3 -2
- package/src/client/charts/Waterfall.tsx +86 -0
- package/src/client/charts/index.ts +2 -0
- package/src/client/charts/types.ts +9 -2
- package/src/client/react-css.d.ts +1 -1
- package/src/client/routes/SpotlightRoute.tsx +138 -0
- package/src/client/routes/index.ts +1 -0
- package/src/client/spotlight/Compounding.tsx +222 -0
- package/src/client/spotlight/Coverage.tsx +96 -0
- package/src/client/spotlight/Economics.tsx +167 -0
- package/src/client/spotlight/FetchBack.tsx +222 -0
- package/src/client/spotlight/Hero.tsx +177 -0
- package/src/client/spotlight/Overhead.tsx +135 -0
- package/src/client/spotlight/Ranking.tsx +248 -0
- package/src/client/spotlight/ResultsFeed.tsx +354 -0
- package/src/client/spotlight/Savings.tsx +355 -0
- package/src/client/spotlight/ShareTrend.tsx +103 -0
- package/src/client/spotlight/Steps.tsx +225 -0
- package/src/client/spotlight/TimeCost.tsx +64 -0
- package/src/client/spotlight/chips.tsx +63 -0
- package/src/client/spotlight/meta.ts +206 -0
- package/src/client/spotlight/spotlight.css +1387 -0
- package/src/client/styles.css +289 -1
- package/src/db.ts +480 -2
- package/src/index.ts +10 -1
- package/src/live.ts +21 -9
- package/src/parser.ts +44 -8
- package/src/rollup.ts +203 -10
- package/src/server.ts +42 -0
- package/src/shared-types.ts +453 -0
- package/src/spotlight-aggregator.ts +1738 -0
- package/src/spotlight-bootstrap.ts +110 -0
- package/src/spotlight-ingest.ts +981 -0
- package/src/spotlight-measures.ts +667 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { TriangleAlert } from "lucide-react";
|
|
2
|
+
import { formatCost, formatDurationMs, formatInteger } from "../data/formatters";
|
|
3
|
+
import type { SpotlightDashboardStats, SpotlightOverhead } from "../types";
|
|
4
|
+
import { Badge, Card, type Column, errorRateTone, Table } from "../ui";
|
|
5
|
+
import { formatShare } from "./meta";
|
|
6
|
+
|
|
7
|
+
/** p50 with p90 beside it, dim. */
|
|
8
|
+
function Percentiles({ p50, p90 }: { p50: number | null; p90: number | null }) {
|
|
9
|
+
if (p50 === null) return <span className="dim">–</span>;
|
|
10
|
+
return (
|
|
11
|
+
<span className="num">
|
|
12
|
+
{formatDurationMs(p50)}
|
|
13
|
+
<span className="dim"> / {formatDurationMs(p90)}</span>
|
|
14
|
+
</span>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const COLUMNS: Column<SpotlightOverhead>[] = [
|
|
19
|
+
{
|
|
20
|
+
key: "failures",
|
|
21
|
+
header: "Spotlight failures",
|
|
22
|
+
align: "right",
|
|
23
|
+
sort: row => row.failureRate ?? -1,
|
|
24
|
+
title: "Results whose Spotlight calls failed or timed out, of those that called it: they were cut by the no-score fallback",
|
|
25
|
+
render: row =>
|
|
26
|
+
row.failureRate === null ? (
|
|
27
|
+
<span className="dim">–</span>
|
|
28
|
+
) : (
|
|
29
|
+
<Badge tone={errorRateTone(row.failureRate)} mono>
|
|
30
|
+
{formatShare(row.failureRate)}
|
|
31
|
+
</Badge>
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: "failedWait",
|
|
36
|
+
header: "Waited on failures",
|
|
37
|
+
align: "right",
|
|
38
|
+
sort: row => row.failedWaitMs,
|
|
39
|
+
title: "Wall time tool calls spent on Spotlight calls that failed or timed out",
|
|
40
|
+
render: row =>
|
|
41
|
+
row.failedWaitMs > 0 ? (
|
|
42
|
+
<span className="num">{formatDurationMs(row.failedWaitMs)}</span>
|
|
43
|
+
) : (
|
|
44
|
+
<span className="dim">–</span>
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
key: "results",
|
|
49
|
+
header: "Results",
|
|
50
|
+
align: "right",
|
|
51
|
+
sort: row => row.results,
|
|
52
|
+
title: "Results Spotlight evaluated, held-out ones included",
|
|
53
|
+
render: row => <span className="num">{formatInteger(row.results)}</span>,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
key: "shape",
|
|
57
|
+
header: "Shaping p50 / p90",
|
|
58
|
+
align: "right",
|
|
59
|
+
sort: row => row.shapeMsP50 ?? -1,
|
|
60
|
+
title: "Wall time shaping added to a tool call",
|
|
61
|
+
render: row => <Percentiles p50={row.shapeMsP50} p90={row.shapeMsP90} />,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
key: "spotlight",
|
|
65
|
+
header: "Spotlight p50 / p90",
|
|
66
|
+
align: "right",
|
|
67
|
+
sort: row => row.spotlightMsP50 ?? -1,
|
|
68
|
+
title: "Spotlight's own request time, over results whose calls all answered",
|
|
69
|
+
render: row => <Percentiles p50={row.spotlightMsP50} p90={row.spotlightMsP90} />,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "usd",
|
|
73
|
+
header: "Spotlight $",
|
|
74
|
+
align: "right",
|
|
75
|
+
sort: row => row.spotlightUsd,
|
|
76
|
+
render: row => <span className="num">{formatCost(row.spotlightUsd)}</span>,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Failed Spotlight calls mean the cut came from the no-score fallback, not Spotlight's scorer: every comparison then
|
|
82
|
+
* partly measures the fallback. Called out above the table once the failure rate reaches the warning rate.
|
|
83
|
+
*/
|
|
84
|
+
function FailureCallout({ overhead }: { overhead: SpotlightOverhead }) {
|
|
85
|
+
const rate = overhead.failureRate;
|
|
86
|
+
if (rate === null) return null;
|
|
87
|
+
const tone = errorRateTone(rate);
|
|
88
|
+
if (tone === "ok") return null;
|
|
89
|
+
const waitedMs = overhead.failedWaitMs;
|
|
90
|
+
return (
|
|
91
|
+
<div className="spot-callout" data-tone={tone} role="note">
|
|
92
|
+
<TriangleAlert size={16} aria-hidden="true" />
|
|
93
|
+
<div>
|
|
94
|
+
<div className="spot-callout-head">
|
|
95
|
+
<span className="num">{formatShare(rate)}</span> of results that called Spotlight failed
|
|
96
|
+
</div>
|
|
97
|
+
<p className="spot-callout-copy">
|
|
98
|
+
A failed or timed-out call leaves the cut to the no-score fallback, so those results measure the fallback
|
|
99
|
+
rather than Spotlight's scorer. Read every comparison on this page with that share in mind.
|
|
100
|
+
{waitedMs > 0 && (
|
|
101
|
+
<>
|
|
102
|
+
{" "}
|
|
103
|
+
Tool calls waited <span className="num">{formatDurationMs(waitedMs)}</span> on those failures.
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What shaping costs in latency, reliability and Spotlight's bill. */
|
|
113
|
+
export function Overhead({ stats, stale }: { stats: SpotlightDashboardStats; stale: boolean }) {
|
|
114
|
+
const { overhead } = stats;
|
|
115
|
+
const rows = overhead.results > 0 ? [overhead] : [];
|
|
116
|
+
return (
|
|
117
|
+
<Card
|
|
118
|
+
index={3}
|
|
119
|
+
title="Overhead"
|
|
120
|
+
description="What shaping costs: Spotlight failures, added latency and its bill"
|
|
121
|
+
flush
|
|
122
|
+
stale={stale}
|
|
123
|
+
footer="Every evaluated result counts, held-out ones included: they were shaped too, just not shown."
|
|
124
|
+
>
|
|
125
|
+
{overhead.results > 0 && <FailureCallout overhead={overhead} />}
|
|
126
|
+
<Table
|
|
127
|
+
rows={rows}
|
|
128
|
+
rowKey={() => "overhead"}
|
|
129
|
+
columns={COLUMNS}
|
|
130
|
+
dense
|
|
131
|
+
empty={<div className="empty">No evaluated results in this range.</div>}
|
|
132
|
+
/>
|
|
133
|
+
</Card>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { DotWhisker, type DotWhiskerMark, type DotWhiskerRow } from "../charts";
|
|
2
|
+
import { formatCompact, formatInteger } from "../data/formatters";
|
|
3
|
+
import type { SpotlightDashboardStats, SpotlightHeatPosition, SpotlightRankingCell } from "../types";
|
|
4
|
+
import { Card } from "../ui";
|
|
5
|
+
import { GroupLegend } from "./chips";
|
|
6
|
+
import { CUT_GROUP, formatInterval } from "./meta";
|
|
7
|
+
|
|
8
|
+
type RankingGroup = SpotlightRankingCell["group"];
|
|
9
|
+
|
|
10
|
+
/** Scored cuts first (the figure), then what they are read against. */
|
|
11
|
+
const GROUPS: readonly RankingGroup[] = ["applied", "scoreFree", "holdout"];
|
|
12
|
+
|
|
13
|
+
const MARK_OFFSET: Record<RankingGroup, number> = { applied: -7, scoreFree: 0, holdout: 7 };
|
|
14
|
+
|
|
15
|
+
interface Measure {
|
|
16
|
+
key: "auc" | "topLift" | "keptLift";
|
|
17
|
+
title: string;
|
|
18
|
+
/** What the chance line means for this measure. */
|
|
19
|
+
chanceLabel: string;
|
|
20
|
+
chance: number;
|
|
21
|
+
rule: string;
|
|
22
|
+
format: (value: number) => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MEASURES: readonly Measure[] = [
|
|
26
|
+
{
|
|
27
|
+
key: "auc",
|
|
28
|
+
title: "Heat ranks needed lines higher",
|
|
29
|
+
chanceLabel: "AUC · chance 0.5",
|
|
30
|
+
chance: 0.5,
|
|
31
|
+
rule: "P(a needed line is hotter than an unneeded line of the same result), ties half",
|
|
32
|
+
format: v => v.toFixed(2),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: "topLift",
|
|
36
|
+
title: "Hottest tenth holds them",
|
|
37
|
+
chanceLabel: "lift · chance 1.0",
|
|
38
|
+
chance: 1,
|
|
39
|
+
rule: "Needed-line rate among each result's hottest 10% of lines over its rate among all lines",
|
|
40
|
+
format: v => `${v.toFixed(2)}×`,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: "keptLift",
|
|
44
|
+
title: "The cut keeps them",
|
|
45
|
+
chanceLabel: "lift · random cut 1.0",
|
|
46
|
+
chance: 1,
|
|
47
|
+
rule: "Share of needed lines the cut kept over the share of all lines it kept",
|
|
48
|
+
format: v => `${v.toFixed(2)}×`,
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const NEED_SOURCES = [
|
|
53
|
+
{ key: "reread", label: "Read again", rule: "A later read of the same file selected the lines" },
|
|
54
|
+
{ key: "edit", label: "Edited", rule: "A later edit's Find text matched the lines" },
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
/** Per-line ranking quality: Spotlight's heat and the cut against the lines the agent needed later. */
|
|
58
|
+
export function Ranking({ stats, stale }: { stats: SpotlightDashboardStats; stale: boolean }) {
|
|
59
|
+
const { ranking } = stats;
|
|
60
|
+
const cells: ReadonlyMap<RankingGroup, SpotlightRankingCell> = new Map(
|
|
61
|
+
ranking.cells.map(cell => [cell.group, cell]),
|
|
62
|
+
);
|
|
63
|
+
const measured = GROUPS.some(group => (cells.get(group)?.results ?? 0) > 0);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Card
|
|
67
|
+
index={4}
|
|
68
|
+
title="Does Spotlight keep what the agent needs?"
|
|
69
|
+
description="Lines of a shaped read the agent read again or edited later, against Spotlight's heat and the cut"
|
|
70
|
+
actions={<GroupLegend groups={GROUPS} />}
|
|
71
|
+
stale={stale}
|
|
72
|
+
footer="Shaped reads with at least one needed line, until the file's first edit or a later whole-file read. Scored and score-free cuts are biased alike (the agent re-reads what a cut hid), so compare them with each other; held-out would-be cuts, shown whole, are the unbiased measure. Score-free cuts have no heat, so only the cut's lift exists for them. Dashed lines are chance; whiskers are 90% session bootstrap intervals, from 3 sessions."
|
|
73
|
+
>
|
|
74
|
+
{!measured ? (
|
|
75
|
+
<div className="empty">No shaped read was needed again in this range.</div>
|
|
76
|
+
) : (
|
|
77
|
+
<div className="spot-rank">
|
|
78
|
+
<div className="spot-rank-panels">
|
|
79
|
+
{MEASURES.map(measure => (
|
|
80
|
+
<RankPanel key={measure.key} measure={measure} cells={cells} />
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
<div className="spot-rank-foot">
|
|
84
|
+
<SampleTable cells={cells} />
|
|
85
|
+
<PositionHeat positions={ranking.position} />
|
|
86
|
+
<section className="spot-rank-sources" aria-label="How the need was seen">
|
|
87
|
+
<h3 className="spot-panel-title">How the need was seen</h3>
|
|
88
|
+
<dl className="spot-facts spot-rank-facts">
|
|
89
|
+
{NEED_SOURCES.map(source => (
|
|
90
|
+
<div key={source.key} title={source.rule}>
|
|
91
|
+
<dt>{source.label}</dt>
|
|
92
|
+
<dd className="num">
|
|
93
|
+
{formatInteger(ranking.sources[source.key])}
|
|
94
|
+
<span className="dim"> results</span>
|
|
95
|
+
</dd>
|
|
96
|
+
</div>
|
|
97
|
+
))}
|
|
98
|
+
</dl>
|
|
99
|
+
</section>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</Card>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function RankPanel({ measure, cells }: { measure: Measure; cells: ReadonlyMap<RankingGroup, SpotlightRankingCell> }) {
|
|
108
|
+
const marks: DotWhiskerMark[] = GROUPS.flatMap(group => {
|
|
109
|
+
const cell = cells.get(group);
|
|
110
|
+
if (cell === undefined || cell.results === 0) return [];
|
|
111
|
+
const estimate = cell[measure.key];
|
|
112
|
+
const interval = formatInterval(estimate, measure.format);
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
key: group,
|
|
116
|
+
...estimate,
|
|
117
|
+
color: CUT_GROUP[group].color,
|
|
118
|
+
shape: CUT_GROUP[group].shape,
|
|
119
|
+
offset: MARK_OFFSET[group],
|
|
120
|
+
title: `${CUT_GROUP[group].label}: ${estimate.value === null ? "–" : measure.format(estimate.value)}${interval === "" ? "" : ` (${interval})`} over ${formatInteger(cell.results)} results, ${formatInteger(cell.neededLines)} needed lines`,
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
});
|
|
124
|
+
const applied = cells.get("applied")?.[measure.key].value ?? null;
|
|
125
|
+
const rows: DotWhiskerRow[] = [
|
|
126
|
+
{
|
|
127
|
+
key: "reads",
|
|
128
|
+
label: "Shaped reads",
|
|
129
|
+
marks,
|
|
130
|
+
figure: (
|
|
131
|
+
<span style={{ color: CUT_GROUP.applied.color }}>{applied === null ? "–" : measure.format(applied)}</span>
|
|
132
|
+
),
|
|
133
|
+
detail: (
|
|
134
|
+
<>
|
|
135
|
+
{(["scoreFree", "holdout"] as const).map((group, i) => {
|
|
136
|
+
const value = cells.get(group)?.[measure.key].value ?? null;
|
|
137
|
+
return (
|
|
138
|
+
<span key={group} title={CUT_GROUP[group].label}>
|
|
139
|
+
{i > 0 && " · "}
|
|
140
|
+
<span className="spot-fb-detail" data-group={group}>
|
|
141
|
+
{value === null ? "–" : measure.format(value)}
|
|
142
|
+
</span>
|
|
143
|
+
</span>
|
|
144
|
+
);
|
|
145
|
+
})}
|
|
146
|
+
</>
|
|
147
|
+
),
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
return (
|
|
151
|
+
<section className="spot-rank-panel" aria-label={measure.title}>
|
|
152
|
+
<h3 className="spot-panel-title" title={measure.rule}>
|
|
153
|
+
{measure.title}
|
|
154
|
+
<span className="spot-rank-chance">{measure.chanceLabel}</span>
|
|
155
|
+
</h3>
|
|
156
|
+
<DotWhisker
|
|
157
|
+
rows={rows}
|
|
158
|
+
guides={[{ key: "chance", value: measure.chance, color: "var(--ink-3)" }]}
|
|
159
|
+
format={measure.format}
|
|
160
|
+
formatTick={v => String(Number(v.toFixed(2)))}
|
|
161
|
+
/>
|
|
162
|
+
</section>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Results and needed lines behind every cell of the panels. */
|
|
167
|
+
function SampleTable({ cells }: { cells: ReadonlyMap<RankingGroup, SpotlightRankingCell> }) {
|
|
168
|
+
return (
|
|
169
|
+
<section aria-label="Sample per cell">
|
|
170
|
+
<h3 className="spot-panel-title">
|
|
171
|
+
Sample per cell
|
|
172
|
+
<span className="spot-rank-chance">results · needed lines</span>
|
|
173
|
+
</h3>
|
|
174
|
+
<table className="spot-rank-n">
|
|
175
|
+
<thead>
|
|
176
|
+
<tr>
|
|
177
|
+
{GROUPS.map(group => (
|
|
178
|
+
<th key={group} scope="col" title={CUT_GROUP[group].rule}>
|
|
179
|
+
{CUT_GROUP[group].label}
|
|
180
|
+
</th>
|
|
181
|
+
))}
|
|
182
|
+
</tr>
|
|
183
|
+
</thead>
|
|
184
|
+
<tbody>
|
|
185
|
+
<tr>
|
|
186
|
+
{GROUPS.map(group => {
|
|
187
|
+
const cell = cells.get(group);
|
|
188
|
+
return (
|
|
189
|
+
<td key={group} className="num" data-thin={cell !== undefined && cell.results < 20}>
|
|
190
|
+
{cell === undefined || cell.results === 0 ? (
|
|
191
|
+
<span className="dim">–</span>
|
|
192
|
+
) : (
|
|
193
|
+
<span title={`${formatInteger(cell.neededLines)} needed lines`}>
|
|
194
|
+
{formatInteger(cell.results)}
|
|
195
|
+
<span className="dim"> · {formatCompact(cell.neededLines)}</span>
|
|
196
|
+
</span>
|
|
197
|
+
)}
|
|
198
|
+
</td>
|
|
199
|
+
);
|
|
200
|
+
})}
|
|
201
|
+
</tr>
|
|
202
|
+
</tbody>
|
|
203
|
+
</table>
|
|
204
|
+
</section>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Mean heat by line position. */
|
|
209
|
+
function PositionHeat({ positions }: { positions: readonly SpotlightHeatPosition[] }) {
|
|
210
|
+
const top = Math.max(0, ...positions.map(p => p.meanHeat));
|
|
211
|
+
return (
|
|
212
|
+
<section className="spot-rank-pos" aria-label="Mean heat by position">
|
|
213
|
+
<h3 className="spot-panel-title">
|
|
214
|
+
Mean heat by position
|
|
215
|
+
<span className="spot-rank-chance">0–9, outputs ≥ 40 lines</span>
|
|
216
|
+
</h3>
|
|
217
|
+
{positions.length === 0 || top === 0 ? (
|
|
218
|
+
<div className="dim spot-gap-empty">No heat recorded</div>
|
|
219
|
+
) : (
|
|
220
|
+
<>
|
|
221
|
+
<div className="spot-rank-bars">
|
|
222
|
+
{positions.map(p => (
|
|
223
|
+
<div
|
|
224
|
+
key={p.label}
|
|
225
|
+
className="spot-rank-bar"
|
|
226
|
+
title={`${p.label}: mean heat ${p.meanHeat.toFixed(2)} over ${formatInteger(p.lines)} lines`}
|
|
227
|
+
>
|
|
228
|
+
<span className="spot-rank-bar-track">
|
|
229
|
+
<span className="spot-rank-bar-value num">{p.meanHeat.toFixed(1)}</span>
|
|
230
|
+
{/* The value's line (14px) stays clear above the tallest bar. */}
|
|
231
|
+
<span
|
|
232
|
+
className="spot-rank-bar-fill"
|
|
233
|
+
style={{ height: `calc((100% - 14px) * ${p.meanHeat / top})` }}
|
|
234
|
+
/>
|
|
235
|
+
</span>
|
|
236
|
+
{/* `line 1` → `L1`, `lines 2–3` → `L2–3`, `0–10%` → `0–10`: short enough to sit under a bar. */}
|
|
237
|
+
<span className="spot-rank-bar-label num">
|
|
238
|
+
{p.label.replace(/^lines? /, "L").replace(/%$/, "")}
|
|
239
|
+
</span>
|
|
240
|
+
</div>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
<div className="micro spot-rank-axis">L = line of the output, then each tenth of it (% into it)</div>
|
|
244
|
+
</>
|
|
245
|
+
)}
|
|
246
|
+
</section>
|
|
247
|
+
);
|
|
248
|
+
}
|