Skip to content

Commit ef2cf01

Browse files
mihowclaude
andcommitted
feat(ui): split agreement bars by match scope + integrate Wilson CI inline
Stats panel now renders three agreement bars side-by-side instead of one generic agreement row plus a separate CI range bar: - Agreement (exact taxon) — agreed_exact_* - Agreement (any rank) — agreed_any_rank_* (LCA at any real rank) - Agreement (≥ <rank>) — agreed_coarser_rank_* (only when the caller passes ?agreement_coarsest_rank=<RANK>; otherwise hidden) Wilson 95% CI is folded into each agreement bar instead of sitting on its own row. The bar is a single 0–100% track with: - a translucent CI band (bg-primary/40) from low to high - 2px-wide CI bound caps (whiskers) at low/high - a 3px tall dark vertical marker for the point estimate This puts the uncertainty visually adjacent to the number it qualifies — the bar IS the CI, the marker IS the point — so the CI is no longer easy to overlook. Each agreement row also surfaces raw counts ("90 of 100"). Cohen's κ keeps its existing signed bar. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2aa47de commit ef2cf01

1 file changed

Lines changed: 104 additions & 32 deletions

File tree

ui/src/pages/occurrences/occurrence-stats.tsx

Lines changed: 104 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ interface OccurrenceStatsProps {
66
filters: { field: string; value?: string; error?: string }[]
77
}
88

9+
const clampPct = (value: number) =>
10+
Math.round(Math.min(Math.max(value, 0), 1) * 100)
11+
912
const StatBar = ({
1013
label,
1114
value,
@@ -17,7 +20,7 @@ const StatBar = ({
1720
// when the percentage rounds to 0 but the underlying count is non-zero.
1821
count?: number
1922
}) => {
20-
const pct = Math.round(Math.min(Math.max(value, 0), 1) * 100)
23+
const pct = clampPct(value)
2124

2225
return (
2326
<div className="space-y-2">
@@ -45,43 +48,93 @@ const StatBar = ({
4548
)
4649
}
4750

48-
// Horizontal range bar for a Wilson confidence interval. Draws a filled
49-
// segment between `low` and `high` over a 0–100% track, so a wide CI reads as
50-
// a wide bar (= shaky number) and a tight CI as a narrow one. Null bounds →
51-
// "—".
52-
const RangeBar = ({
51+
// Combined point estimate + Wilson 95% CI in one bar so the uncertainty is
52+
// adjacent to the number it qualifies (more visible than the previous
53+
// separate-row RangeBar). Layout per row:
54+
//
55+
// Label
56+
// [ ━━━━┃══════╋══════┃━━━━ ] pct% (k of n) low–high% CI
57+
// ^cap ^point ^cap
58+
//
59+
// The full track is 0–100%. The CI band is a translucent fill from low to
60+
// high. Small vertical caps mark the CI bounds (error-bar whiskers). A solid
61+
// vertical line marks the point estimate. When CI bounds are absent (e.g.
62+
// `agreed_coarser_rank` has no CI in the BE response), just the bar + point
63+
// render.
64+
const AgreementBar = ({
5365
label,
54-
low,
55-
high,
66+
value,
67+
count,
68+
total,
69+
ciLow,
70+
ciHigh,
5671
}: {
5772
label: string
58-
low: number | null
59-
high: number | null
73+
value: number
74+
count?: number
75+
total?: number
76+
ciLow?: number | null
77+
ciHigh?: number | null
6078
}) => {
61-
const hasData = low !== null && high !== null
62-
const lowPct = hasData ? Math.round(Math.min(Math.max(low, 0), 1) * 100) : 0
63-
const highPct = hasData ? Math.round(Math.min(Math.max(high, 0), 1) * 100) : 0
79+
const pct = clampPct(value)
80+
const hasCi =
81+
ciLow !== null &&
82+
ciLow !== undefined &&
83+
ciHigh !== null &&
84+
ciHigh !== undefined
85+
const lowPct = hasCi ? clampPct(ciLow as number) : 0
86+
const highPct = hasCi ? clampPct(ciHigh as number) : 0
6487

6588
return (
6689
<div className="space-y-2">
6790
<span className="body-overline font-bold text-muted-foreground">
6891
{label}
6992
</span>
70-
<div className="flex items-center gap-3">
71-
<div className="h-2 flex-1 rounded-full bg-muted relative">
72-
{hasData ? (
93+
<div className="space-y-1">
94+
<div className="flex items-center gap-3">
95+
<div className="h-3 flex-1 rounded-full bg-muted relative overflow-hidden">
96+
{hasCi ? (
97+
<>
98+
<div
99+
className="absolute top-0 h-3 bg-primary/40"
100+
style={{
101+
left: `${lowPct}%`,
102+
width: `${Math.max(highPct - lowPct, 0.5)}%`,
103+
}}
104+
aria-label="95% confidence interval"
105+
/>
106+
<div
107+
className="absolute top-0 h-3 w-[2px] bg-primary"
108+
style={{ left: `calc(${lowPct}% - 1px)` }}
109+
/>
110+
<div
111+
className="absolute top-0 h-3 w-[2px] bg-primary"
112+
style={{ left: `calc(${highPct}% - 1px)` }}
113+
/>
114+
</>
115+
) : null}
73116
<div
74-
className="absolute h-2 rounded-full bg-primary transition-all"
75-
style={{
76-
left: `${lowPct}%`,
77-
width: `${Math.max(highPct - lowPct, 1)}%`,
78-
}}
117+
className="absolute top-[-2px] h-[16px] w-[3px] rounded-sm bg-foreground transition-all"
118+
style={{ left: `calc(${pct}% - 1.5px)` }}
119+
aria-label="point estimate"
79120
/>
80-
) : null}
121+
</div>
122+
<span className="body-base tabular-nums whitespace-nowrap">
123+
{pct}%
124+
{count !== undefined ? (
125+
<span className="text-muted-foreground">
126+
{total !== undefined
127+
? ` (${count.toLocaleString()} of ${total.toLocaleString()})`
128+
: ` (${count.toLocaleString()})`}
129+
</span>
130+
) : null}
131+
</span>
81132
</div>
82-
<span className="body-base tabular-nums">
83-
{hasData ? `${lowPct}${highPct}%` : '—'}
84-
</span>
133+
{hasCi ? (
134+
<div className="body-small tabular-nums text-muted-foreground">
135+
95% CI {lowPct}{highPct}%
136+
</div>
137+
) : null}
85138
</div>
86139
</div>
87140
)
@@ -147,6 +200,10 @@ export const OccurrenceStats = ({
147200
return null
148201
}
149202

203+
const hasCoarser =
204+
data?.agreement_coarsest_rank != null &&
205+
data?.agreed_coarser_rank_pct !== null
206+
150207
return (
151208
<Box className="w-full h-min shrink-0 p-2 rounded-lg md:w-72 md:p-4 md:rounded-xl no-print">
152209
<span className="body-overline font-bold">Stats</span>
@@ -165,15 +222,30 @@ export const OccurrenceStats = ({
165222
value={data.verified_pct}
166223
count={data.verified_count}
167224
/>
168-
<StatBar
169-
label="Human-model agreement rate"
170-
value={data.agreed_any_rank_pct}
225+
<AgreementBar
226+
label="Agreement (exact taxon)"
227+
value={data.agreed_exact_pct}
228+
count={data.agreed_exact_count}
229+
total={data.verified_with_prediction_count}
230+
ciLow={data.agreed_exact_ci_low}
231+
ciHigh={data.agreed_exact_ci_high}
171232
/>
172-
<RangeBar
173-
label="Agreement 95% CI (Wilson)"
174-
low={data.agreed_any_rank_ci_low}
175-
high={data.agreed_any_rank_ci_high}
233+
<AgreementBar
234+
label="Agreement (any rank)"
235+
value={data.agreed_any_rank_pct}
236+
count={data.agreed_any_rank_count}
237+
total={data.verified_with_prediction_count}
238+
ciLow={data.agreed_any_rank_ci_low}
239+
ciHigh={data.agreed_any_rank_ci_high}
176240
/>
241+
{hasCoarser ? (
242+
<AgreementBar
243+
label={`Agreement (≥ ${data.agreement_coarsest_rank})`}
244+
value={data.agreed_coarser_rank_pct as number}
245+
count={data.agreed_coarser_rank_count as number}
246+
total={data.verified_with_prediction_count}
247+
/>
248+
) : null}
177249
<SignedBar
178250
label="Cohen's κ (beyond chance)"
179251
value={data.cohens_kappa}

0 commit comments

Comments
 (0)