@@ -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+
912const 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