Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions ui/src/data-services/hooks/occurrences/stats/useModelAgreement.ts
Original file line number Diff line number Diff line change
@@ -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=<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<string, FilterValue>
) => {
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<ModelAgreementResponse>({
queryKey: [
API_ROUTES.OCCURRENCES,
'stats',
'model-agreement',
projectId,
queryString,
],
url: `${url}?${queryString}`,
})

return {
data,
isLoading,
isFetching,
error,
}
}
95 changes: 95 additions & 0 deletions ui/src/pages/occurrences/occurrence-stats.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-2">
<span className="body-overline font-bold text-muted-foreground">
{label}
</span>
<div className="flex items-center gap-3">
<div className="h-2 flex-1 rounded-full bg-muted">
<div
className="h-2 rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<span className="body-base tabular-nums">
{pct}%
{count !== undefined ? (
<span className="text-muted-foreground">
{' '}
({count.toLocaleString()})
</span>
) : null}
</span>
</div>
</div>
)
}

// 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<Record<string, string>>(
(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 (
<Box className="w-full h-min shrink-0 p-2 rounded-lg md:w-72 md:p-4 md:rounded-xl no-print">
<span className="body-overline font-bold">Stats</span>
<div className="mt-4 space-y-6">
{isLoading || !data ? (
<>
<div className="h-12 animate-pulse rounded-md bg-muted" />
<div className="h-12 animate-pulse rounded-md bg-muted" />
</>
) : (
<>
<StatBar
label="Verified occurrences"
value={data.verified_pct}
count={data.verified_count}
/>
<StatBar
label="Human-model agreement rate"
value={data.agreed_any_rank_pct}
/>
</>
)}
</div>
</Box>
)
}
2 changes: 2 additions & 0 deletions ui/src/pages/occurrences/occurrences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -94,6 +95,7 @@ export const Occurrences = () => {
<>
<div className="flex flex-col gap-6 md:flex-row">
<div className="space-y-6">
<OccurrenceStats projectId={projectId} filters={filters} />
<FilterSection defaultOpen>
<FilterControl field="detections__source_image" readonly />
<FilterControl field="event" readonly />
Expand Down