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..d783103df --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/stats/useModelAgreement.ts @@ -0,0 +1,71 @@ +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_any_rank_count: number + agreed_any_rank_pct: number + // 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..b688c01e3 --- /dev/null +++ b/ui/src/pages/occurrences/occurrence-stats.tsx @@ -0,0 +1,95 @@ +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 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 = Math.round(Math.min(Math.max(value, 0), 1) * 100) + + return ( +
+ + {label} + +
+
+
+
+ + {pct}% + {count !== undefined ? ( + + {' '} + ({count.toLocaleString()}) + + ) : null} + +
+
+ ) +} + +// 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 + } + + return ( + + Stats +
+ {isLoading || !data ? ( + <> +
+
+ + ) : ( + <> + + + + )} +
+ + ) +} diff --git a/ui/src/pages/occurrences/occurrences.tsx b/ui/src/pages/occurrences/occurrences.tsx index 9a1071a3c..77da98ea6 100644 --- a/ui/src/pages/occurrences/occurrences.tsx +++ b/ui/src/pages/occurrences/occurrences.tsx @@ -35,6 +35,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' @@ -94,6 +95,7 @@ export const Occurrences = () => { <>
+