diff --git a/ui/src/data-services/hooks/occurrences/stats/useModelAgreement.ts b/ui/src/data-services/hooks/occurrences/stats/useModelAgreement.ts new file mode 100644 index 000000000..606961161 --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/stats/useModelAgreement.ts @@ -0,0 +1,78 @@ +import { API_ROUTES, API_URL } from 'data-services/constants' +import { useAuthorizedQuery } from '../../auth/useAuthorizedQuery' + +interface ModelAgreementResponse { + project_id: number + total_occurrences: number + verified_count: number + verified_pct: number + verified_with_prediction_count: number + no_prediction_count: number + agreed_exact_count: number + agreed_exact_pct: number + agreed_exact_ci_low: number | null + agreed_exact_ci_high: number | null + agreed_any_rank_count: number + agreed_any_rank_pct: number + agreed_any_rank_ci_low: number | null + agreed_any_rank_ci_high: number | null + // Cohen's kappa (exact-taxon) — agreement beyond chance. Range [-1, 1]; + // null when denominator is 0 or expected agreement is 1.0. + cohens_kappa: number | null + // Only populated when the caller passes ?agreement_coarsest_rank=. + agreement_coarsest_rank: string | null + agreed_coarser_rank_count: number | null + agreed_coarser_rank_pct: number | null +} + +type FilterPrimitive = string | number | boolean +type FilterValue = FilterPrimitive | FilterPrimitive[] | null | undefined + +// Accepts an arbitrary filter map so the occurrence list page's filter state +// can be threaded through unchanged (deployment, event, taxon, score +// thresholds, apply_defaults, etc). Arrays are appended as repeated query +// params so multi-select filters (e.g. `algorithm`, `not_algorithm`, which +// the backend reads via `request.query_params.getlist(...)`) survive. +export const useModelAgreement = ( + projectId?: string, + filters?: Record +) => { + const url = `${API_URL}/${API_ROUTES.OCCURRENCES}/stats/model-agreement/` + + const params = new URLSearchParams() + if (projectId) params.set('project_id', projectId) + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') return + if (Array.isArray(value)) { + value.forEach((item) => { + if (item !== undefined && item !== null && item !== '') { + params.append(key, String(item)) + } + }) + return + } + params.set(key, String(value)) + }) + } + const queryString = params.toString() + + const { data, isLoading, isFetching, error } = + useAuthorizedQuery({ + queryKey: [ + API_ROUTES.OCCURRENCES, + 'stats', + 'model-agreement', + projectId, + queryString, + ], + url: `${url}?${queryString}`, + }) + + return { + data, + isLoading, + isFetching, + error, + } +} diff --git a/ui/src/pages/occurrences/occurrence-stats.tsx b/ui/src/pages/occurrences/occurrence-stats.tsx new file mode 100644 index 000000000..cd1b38177 --- /dev/null +++ b/ui/src/pages/occurrences/occurrence-stats.tsx @@ -0,0 +1,258 @@ +import { useModelAgreement } from 'data-services/hooks/occurrences/stats/useModelAgreement' +import { Box } from 'nova-ui-kit' + +interface OccurrenceStatsProps { + projectId?: string + filters: { field: string; value?: string; error?: string }[] +} + +const clampPct = (value: number) => + Math.round(Math.min(Math.max(value, 0), 1) * 100) + +const StatBar = ({ + label, + value, + count, +}: { + label: string + value: number + // Optional raw count shown alongside the percentage, e.g. "0% (23)". Useful + // when the percentage rounds to 0 but the underlying count is non-zero. + count?: number +}) => { + const pct = clampPct(value) + + return ( +
+ + {label} + +
+
+
+
+ + {pct}% + {count !== undefined ? ( + + {' '} + ({count.toLocaleString()}) + + ) : null} + +
+
+ ) +} + +// Combined point estimate + Wilson 95% CI in one bar so the uncertainty is +// adjacent to the number it qualifies (more visible than the previous +// separate-row RangeBar). Layout per row: +// +// Label +// [ ━━━━┃══════╋══════┃━━━━ ] pct% (k of n) low–high% CI +// ^cap ^point ^cap +// +// The full track is 0–100%. The CI band is a translucent fill from low to +// high. Small vertical caps mark the CI bounds (error-bar whiskers). A solid +// vertical line marks the point estimate. When CI bounds are absent (e.g. +// `agreed_coarser_rank` has no CI in the BE response), just the bar + point +// render. +const AgreementBar = ({ + label, + value, + count, + total, + ciLow, + ciHigh, +}: { + label: string + value: number + count?: number + total?: number + ciLow?: number | null + ciHigh?: number | null +}) => { + const pct = clampPct(value) + const hasCi = + ciLow !== null && + ciLow !== undefined && + ciHigh !== null && + ciHigh !== undefined + const lowPct = hasCi ? clampPct(ciLow as number) : 0 + const highPct = hasCi ? clampPct(ciHigh as number) : 0 + + return ( +
+ + {label} + +
+
+
+ {hasCi ? ( + <> +
+
+
+ + ) : null} +
+
+ + {pct}% + {count !== undefined ? ( + + {total !== undefined + ? ` (${count.toLocaleString()} of ${total.toLocaleString()})` + : ` (${count.toLocaleString()})`} + + ) : null} + +
+ {hasCi ? ( +
+ 95% CI {lowPct}–{highPct}% +
+ ) : null} +
+
+ ) +} + +// Signed bar for a value in [-1, 1] (Cohen's kappa). 0 sits at the visual +// midpoint; positive values fill rightward, negative fill leftward. Null → +// "—" (kappa is undefined for empty or single-category sets). +const SignedBar = ({ + label, + value, +}: { + label: string + value: number | null +}) => { + const v = value === null ? null : Math.min(Math.max(value, -1), 1) + const widthPct = v === null ? 0 : Math.abs(v) * 50 + const leftPct = v === null ? 50 : v >= 0 ? 50 : 50 - widthPct + + return ( +
+ + {label} + +
+
+ {/* zero marker */} +
+ {v !== null ? ( +
+ ) : null} +
+ + {v === null ? '—' : v.toFixed(2)} + +
+
+ ) +} + +// Live verified / agreement stats for the occurrence list. Threads the same +// filter array the list view sends so the numbers always match the result set. +export const OccurrenceStats = ({ + projectId, + filters, +}: OccurrenceStatsProps) => { + const activeFilters = filters.reduce>( + (acc, { field, value, error }) => { + if (value?.length && !error) { + acc[field] = value + } + return acc + }, + {} + ) + + const { data, isLoading, error } = useModelAgreement(projectId, activeFilters) + + if (error || (!isLoading && !data)) { + return null + } + + const hasCoarser = + data?.agreement_coarsest_rank != null && + data?.agreed_coarser_rank_pct !== null + + return ( + + Stats +
+ {isLoading || !data ? ( + <> +
+
+
+
+ + ) : ( + <> + + + + {hasCoarser ? ( + + ) : null} + + + )} +
+ + ) +} diff --git a/ui/src/pages/occurrences/occurrences.tsx b/ui/src/pages/occurrences/occurrences.tsx index a527c04ed..345df8add 100644 --- a/ui/src/pages/occurrences/occurrences.tsx +++ b/ui/src/pages/occurrences/occurrences.tsx @@ -37,6 +37,7 @@ import { useSelectedView } from 'utils/useSelectedView' import { useSort } from 'utils/useSort' import { columns } from './occurrence-columns' import { OccurrenceGallery } from './occurrence-gallery' +import { OccurrenceStats } from './occurrence-stats' import { OccurrenceNavigation } from './occurrence-navigation' import { OccurrencesActions } from './occurrences-actions' @@ -96,6 +97,7 @@ export const Occurrences = () => { <>
+