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..3b4265543 --- /dev/null +++ b/ui/src/pages/occurrences/occurrence-stats.tsx @@ -0,0 +1,327 @@ +import { ChevronsUpDown } from 'lucide-react' +import { useModelAgreement } from 'data-services/hooks/occurrences/stats/useModelAgreement' +import { Box, Button, Collapsible, InfoTooltip } from 'nova-ui-kit' +import { ReactNode } from 'react' + +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) + +// "<1%" reads better than "0%" when the count is non-zero but rounds down. +const pctText = (value: number, count?: number) => { + const pct = clampPct(value) + return pct === 0 && count ? '<1%' : `${pct}%` +} + +// Label + info tooltip. The tooltip carries the exact counts and the longer +// explanation so the row itself stays uncluttered. Text styles match the +// filter controls (body-overline-small). +const StatLabel = ({ label, tooltip }: { label: string; tooltip: string }) => ( +
+ + {label} + + +
+) + +// Simple progress bar: gray track + primary fill from the left. `fill` is the +// point estimate (0–1); `valueText` is the headline shown beside the bar (the +// CI range for agreement metrics, the raw percentage for verified). One shape +// for every non-signed metric — no separate CI whisker visualization. +const Bar = ({ + label, + tooltip, + fill, + valueText, +}: { + label: string + tooltip: string + fill: number + valueText: ReactNode +}) => ( +
+ +
+
+
+
+ + {valueText} + +
+
+) + +// Agreement bar: a solid "confident floor" fills up to the lower 95% CI bound, +// then a diagonal hatch covers the CI range (ciLow–ciHigh) — the uncertain zone +// where the true value sits. The hatch is drawn over the gray track, not over +// the solid fill, so it stays visible regardless of where the point estimate +// lands (a near-100% point estimate previously hid blue-on-blue hatching). +// currentColor + text-primary avoids hardcoding the theme color. With no CI +// (e.g. coarser-rank), it falls back to a plain solid fill to the point value. +const AgreementBar = ({ + label, + tooltip, + value, + ciLow, + ciHigh, + valueText, +}: { + label: string + tooltip: string + value: number + ciLow?: number | null + ciHigh?: number | null + valueText: ReactNode +}) => { + const hasCi = ciLow != null && ciHigh != null + const lowPct = hasCi ? clampPct(ciLow as number) : 0 + const highPct = hasCi ? clampPct(ciHigh as number) : 0 + + return ( +
+ +
+
+ {hasCi ? ( + <> + {/* Confident floor: solid up to the lower CI bound. */} +
+ {/* Uncertain zone: diagonal hatch across the CI range, over the + gray track so it stays visible at any point estimate. */} +
+ + ) : ( +
+ )} +
+ + {valueText} + +
+
+ ) +} + +// Signed bar for Cohen's kappa in [-1, 1]. 0 sits at the visual midpoint; +// positive fills rightward, negative leftward. Null → "—" (kappa is undefined +// for empty or single-category sets). +const SignedBar = ({ + label, + tooltip, + value, +}: { + label: string + tooltip: 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 ( +
+ +
+
+ {/* zero marker */} +
+ {v !== null ? ( +
+ ) : null} +
+ + {v === null ? '—' : v.toFixed(2)} + +
+
+ ) +} + +// Headline beside an agreement bar: the 95% CI range when present, otherwise +// the point estimate. Exact counts live in the tooltip. +const ciRangeText = ( + pct: number, + ciLow?: number | null, + ciHigh?: number | null +) => { + if ( + ciLow !== null && + ciLow !== undefined && + ciHigh !== null && + ciHigh !== undefined + ) { + return `${clampPct(ciLow)}–${clampPct(ciHigh)}%` + } + return `${clampPct(pct)}%` +} + +// CI suffix for tooltip text, e.g. " 95% CI 83–94%." Empty when absent. +const ciTooltip = (ciLow?: number | null, ciHigh?: number | null) => { + if ( + ciLow !== null && + ciLow !== undefined && + ciHigh !== null && + ciHigh !== undefined + ) { + return ` 95% CI ${clampPct(ciLow)}–${clampPct(ciHigh)}%.` + } + return '' +} + +const StatsShell = ({ children }: { children: ReactNode }) => ( + + Stats +
{children}
+
+) + +// 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 + } + + if (isLoading || !data) { + return ( + +
+
+ + ) + } + + const denom = data.verified_with_prediction_count.toLocaleString() + const hasCoarser = + data.agreement_coarsest_rank != null && + data.agreed_coarser_rank_pct !== null + + // Dynamic tooltip copy carrying the exact counts. + const verifiedTooltip = + `${data.verified_count.toLocaleString()} of ${data.total_occurrences.toLocaleString()} occurrences in the current filter are human-verified.` + + (data.no_prediction_count > 0 + ? ` ${denom} of those have a model prediction to compare against.` + : '') + const exactTooltip = + `The model's top prediction exactly matched the confirmed taxon for ` + + `${data.agreed_exact_count.toLocaleString()} of ${denom} verified occurrences with a prediction ` + + `(${clampPct(data.agreed_exact_pct)}%).` + + ciTooltip(data.agreed_exact_ci_low, data.agreed_exact_ci_high) + const anyRankTooltip = + `The model's prediction matched the confirmed taxon at any rank (e.g. the right genus, ` + + `even if the species differs) for ${data.agreed_any_rank_count.toLocaleString()} of ${denom} ` + + `(${clampPct(data.agreed_any_rank_pct)}%).` + + ciTooltip(data.agreed_any_rank_ci_low, data.agreed_any_rank_ci_high) + + return ( + + + + + + + + + + + + {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 = () => { <>
+