diff --git a/ui/package.json b/ui/package.json index 454c0147c..83b6c6951 100644 --- a/ui/package.json +++ b/ui/package.json @@ -7,8 +7,8 @@ "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-checkbox": "^1.0.3", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.0.5", - "@radix-ui/react-popover": "^1.0.6", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-portal": "^1.1.1", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.1.1", diff --git a/ui/src/components/blueprint-collection/blueprint-collection.module.scss b/ui/src/components/blueprint-collection/blueprint-collection.module.scss index 4227378be..3526dcce8 100644 --- a/ui/src/components/blueprint-collection/blueprint-collection.module.scss +++ b/ui/src/components/blueprint-collection/blueprint-collection.module.scss @@ -24,35 +24,49 @@ align-items: center; gap: 40px; overflow-x: auto; + // The scroll box clips at its edge, so leave room for the last tile's focus ring. + padding-bottom: 4px; +} + +// A film strip: one item per row, each divided from the one above it. +.filmStrip { + align-items: stretch; + gap: 0; + + .blueprintItem + .blueprintItem { + border-top: 1px solid var(--color-border); + } } .blueprintItem { display: flex; - flex-direction: column; - align-items: center; + align-items: flex-start; + gap: 16px; max-width: 100%; + padding: 16px 0; + box-sizing: border-box; + + .crop { + flex: 0 0 96px; + display: flex; + align-items: center; + justify-content: center; + } img { max-width: 100%; - height: inherit; + max-height: 120px; + width: auto; + height: auto; vertical-align: middle; } - .blueprintInfo { - display: flex; - align-items: center; - justify-content: center; - gap: 16px; - max-width: 100%; - color: var(--color-foreground); - padding: 8px; + // Stands in for a crop that never existed or failed to load: the bounding box, empty. + .missingCrop { box-sizing: border-box; - @include body-small; - line-height: 12px; - - span { - white-space: nowrap; - } + border: 2px dashed var(--color-neutral-300); + border-radius: 2px; + background-color: var(--color-generic-white); } .count { diff --git a/ui/src/components/blueprint-collection/blueprint-collection.tsx b/ui/src/components/blueprint-collection/blueprint-collection.tsx index c12c3d7f5..bd89d75e8 100644 --- a/ui/src/components/blueprint-collection/blueprint-collection.tsx +++ b/ui/src/components/blueprint-collection/blueprint-collection.tsx @@ -2,10 +2,12 @@ import classNames from 'classnames' import { LicenseInfo } from 'components/license-info/license-info' import { ChevronRightIcon } from 'lucide-react' import { buttonVariants } from 'nova-ui-kit' +import { cn } from 'nova-ui-kit/utils' import { ReactNode, useState } from 'react' import { Link } from 'react-router-dom' import { STRING, translate } from 'utils/language' import styles from './blueprint-collection.module.scss' +import { missingCropSize } from './crop-size' export interface BlueprintItem { id: string @@ -18,9 +20,12 @@ export interface BlueprintItem { export const BlueprintCollection = ({ children, + filmStrip, showLicenseInfo, }: { children: ReactNode + /** Lay the children out as a divided vertical run; pick this for lists of frames. */ + filmStrip?: boolean showLicenseInfo?: boolean }) => (
@@ -29,13 +34,62 @@ export const BlueprintCollection = ({
) : null} -
{children}
+
+ {children} +
) +/** + * The frame's crop, or an empty box with the bounding box's proportions when the crop + * was never made or no longer loads, so the frame still reads as a detection. + */ +const CropImage = ({ + image, +}: { + image: { src?: string | null; width: number; height: number } +}) => { + const [failed, setFailed] = useState(false) + + if (image.src && !failed) { + return ( + setFailed(true)} + /> + ) + } + + return ( +
+ ) +} + export const BlueprintItem = ({ + actions, + caption, item, + onLinkClick, + selected, }: { + actions?: ReactNode + caption?: ReactNode + onLinkClick?: () => void + /** The frame a link pointed at. */ + selected?: boolean item: { id: string image: { src: string; width: number; height: number } @@ -44,42 +98,39 @@ export const BlueprintItem = ({ countLabel: string to?: string } -}) => { - const [size, setSize] = useState({ - width: item.image.width, - height: item.image.height, - }) - - return ( -
-
- +}) => ( +
+
+ +
+ {/* The details take the rest of the row, so their width never follows the crop's. */} +
+ {caption ?? ( + {item.timeLabel} -
-
- { - setSize({ - width: e.currentTarget.width, - height: e.currentTarget.height, - }) - }} - /> + )} +
{item.to ? ( {translate(STRING.VIEW_IN_SESSION)} ) : null} + {actions}
- ) -} +
+) diff --git a/ui/src/components/blueprint-collection/crop-size.test.ts b/ui/src/components/blueprint-collection/crop-size.test.ts new file mode 100644 index 000000000..f00bf4498 --- /dev/null +++ b/ui/src/components/blueprint-collection/crop-size.test.ts @@ -0,0 +1,16 @@ +import { missingCropSize } from './crop-size' + +describe('missingCropSize', () => { + test('a wide box fills the column width and keeps its proportions', () => { + expect(missingCropSize(300, 100)).toEqual({ width: 96, height: 32 }) + }) + + test('a tall box is capped by the column height instead', () => { + expect(missingCropSize(100, 300)).toEqual({ width: 40, height: 120 }) + }) + + test('unknown proportions give a square filling the column', () => { + expect(missingCropSize(0, 0)).toEqual({ width: 96, height: 96 }) + expect(missingCropSize(NaN, -5)).toEqual({ width: 96, height: 96 }) + }) +}) diff --git a/ui/src/components/blueprint-collection/crop-size.ts b/ui/src/components/blueprint-collection/crop-size.ts new file mode 100644 index 000000000..766d74284 --- /dev/null +++ b/ui/src/components/blueprint-collection/crop-size.ts @@ -0,0 +1,17 @@ +export const CROP_COLUMN_PX = 96 +export const CROP_MAX_HEIGHT_PX = 120 + +/** + * Pixel size of the empty box that stands in for a missing crop: the bounding box's + * proportions fitted into the crop column, or a square filling it when they are unknown. + */ +export const missingCropSize = ( + width: number, + height: number +): { width: number; height: number } => { + const w = width > 0 ? width : CROP_COLUMN_PX + const h = height > 0 ? height : CROP_COLUMN_PX + const scale = Math.min(CROP_COLUMN_PX / w, CROP_MAX_HEIGHT_PX / h) + + return { width: Math.round(w * scale), height: Math.round(h * scale) } +} diff --git a/ui/src/components/copy-link-button/copy-link-button.tsx b/ui/src/components/copy-link-button/copy-link-button.tsx new file mode 100644 index 000000000..72f90eee3 --- /dev/null +++ b/ui/src/components/copy-link-button/copy-link-button.tsx @@ -0,0 +1,41 @@ +import { SUCCESS_TIMEOUT } from 'data-services/constants' +import { CheckIcon, LinkIcon } from 'lucide-react' +import { BasicTooltip, Button } from 'nova-ui-kit' +import { useEffect, useState } from 'react' +import { STRING, translate } from 'utils/language' + +// Callers pass the text to put on the clipboard, usually a shareable page URL. +export const CopyLinkButton = ({ value }: { value: string }) => { + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (!copied) { + return + } + + const timeout = setTimeout(() => setCopied(false), SUCCESS_TIMEOUT) + + return () => clearTimeout(timeout) + }, [copied]) + + const label = copied ? translate(STRING.COPIED) : translate(STRING.COPY_LINK) + + return ( + + + + ) +} diff --git a/ui/src/components/filtering/filter-control.tsx b/ui/src/components/filtering/filter-control.tsx index 64dafd9cc..124067991 100644 --- a/ui/src/components/filtering/filter-control.tsx +++ b/ui/src/components/filtering/filter-control.tsx @@ -7,6 +7,7 @@ import { BooleanFilter } from './filters/boolean-filter' import { CaptureSetFilter } from './filters/capture-set-filter' import { DateFilter } from './filters/date-filter' import { DeviceFilter } from './filters/device-filter' +import { GroupingStatusFilter } from './filters/grouping-status-filter' import { ImageFilter } from './filters/image-filter' import { PipelineFilter } from './filters/pipeline-filter' import { SessionFilter } from './filters/session-filter' @@ -35,6 +36,7 @@ const ComponentMap: { deployment__research_site: SiteFilter, detections__source_image: ImageFilter, event: SessionFilter, + grouping_verified: GroupingStatusFilter, processed: ProcessingStatusFilter, include_unobserved: BooleanFilter, job_type_key: TypeFilter, diff --git a/ui/src/components/filtering/filters/grouping-status-filter.tsx b/ui/src/components/filtering/filters/grouping-status-filter.tsx new file mode 100644 index 000000000..a0bb6c775 --- /dev/null +++ b/ui/src/components/filtering/filters/grouping-status-filter.tsx @@ -0,0 +1,30 @@ +import { Select } from 'nova-ui-kit' +import { STRING, translate } from 'utils/language' +import { booleanToString, stringToBoolean } from '../utils' +import { FilterProps } from './types' + +export const GroupingStatusFilter = ({ value: string, onAdd }: FilterProps) => { + const value = stringToBoolean(string) + const options = [ + { value: true, label: translate(STRING.TRACK_GROUPING_FILTER_YES) }, + { value: false, label: translate(STRING.TRACK_GROUPING_FILTER_NO) }, + ] + + return ( + + + + + + {options.map((option) => ( + + {option.label} + + ))} + + + ) +} diff --git a/ui/src/components/taxon-details/taxon-details.tsx b/ui/src/components/taxon-details/taxon-details.tsx index 654f867e0..a75a9ddd6 100644 --- a/ui/src/components/taxon-details/taxon-details.tsx +++ b/ui/src/components/taxon-details/taxon-details.tsx @@ -2,6 +2,7 @@ import { ChevronRightIcon, MinusIcon } from 'lucide-react' import { Tooltip } from 'nova-ui-kit' import { cn } from 'nova-ui-kit/utils' import { ReactNode } from 'react' +import { STRING, translate } from 'utils/language' import { Taxon } from './types' import { getMainParent, isGenusOrBelow } from './utils' @@ -9,7 +10,8 @@ interface TaxonDetailsProps { compact?: boolean onTaxonClick?: (id: string) => void size?: 'default' | 'lg' - taxon: Taxon + /** Absent when nothing has identified the occurrence yet. */ + taxon?: Taxon } export const TaxonDetails = ({ @@ -18,6 +20,17 @@ export const TaxonDetails = ({ size = 'default', taxon, }: TaxonDetailsProps) => { + const nameClassName = cn('font-medium text-primary-500', { + 'body-large': size === 'default', + 'body-xlarge': size === 'lg', + }) + + if (!taxon) { + return ( + {translate(STRING.UNIDENTIFIED)} + ) + } + const mainParent = compact ? getMainParent(taxon) : undefined const parents = compact @@ -27,13 +40,7 @@ export const TaxonDetails = ({ return (
- + {taxon.name} diff --git a/ui/src/components/track/candidate-comparison.tsx b/ui/src/components/track/candidate-comparison.tsx new file mode 100644 index 000000000..4e1d30b39 --- /dev/null +++ b/ui/src/components/track/candidate-comparison.tsx @@ -0,0 +1,148 @@ +import { + ComparisonSide, + ComparisonSides, + getCostLabel, + getDistanceLabel, + getLikelihoodLabel, + getRatioLabel, + getSimilarityLabel, + MergeCandidate, +} from 'data-services/models/merge-candidate' +import { ArrowRightIcon, CheckIcon } from 'lucide-react' +import { CSSProperties } from 'react' +import { getFormatedTimeString } from 'utils/date/getFormatedTimeString/getFormatedTimeString' +import { STRING, translate } from 'utils/language' + +export const COMPARISON_PANEL_WIDTH = 400 +export const COMPARISON_PANEL_HEIGHT = 330 + +export type ComparisonScores = Pick< + MergeCandidate, + | 'displayName' + | 'distance' + | 'similarity' + | 'cost' + | 'iou' + | 'sizeRatio' + | 'likelihood' + | 'wouldLink' +> + +const Crop = ({ side, size }: { side: ComparisonSide; size: string }) => ( +
+
+ {side.src ? ( + + ) : null} +
+ + {side.timestamp + ? getFormatedTimeString({ + date: side.timestamp, + options: { second: true }, + }) + : translate(STRING.VALUE_NOT_AVAILABLE)} + +
+) + +const Metric = ({ label, value }: { label: STRING; value: string }) => ( + + {translate(label)} + {value} + +) + +/** + * Hover preview of one scored pair: the earlier crop on the left, the later on the + * right, every term of the tracking cost, and the tracker's own verdict on the pair. + * Decorative, so it never takes the pointer. + */ +export const CandidateComparison = ({ + candidate, + costThreshold, + requiresFeatures, + sides, + style, +}: { + candidate: ComparisonScores + /** Tracking links a pair only under this cost. */ + costThreshold?: number + /** Tracking skips a pair with no vector on both sides instead of matching it on geometry. */ + requiresFeatures?: boolean + sides: ComparisonSides + style: CSSProperties +}) => { + const paired = sides.left.src && sides.right.src + const single = sides.left.src ? sides.left : sides.right + const verdict = candidate.wouldLink + ? STRING.TRACK_MATCH_WOULD_LINK + : requiresFeatures && candidate.similarity === null + ? STRING.TRACK_MATCH_SKIPPED_NO_VECTOR + : undefined + + return ( +
+ + {candidate.displayName} + + {paired ? ( +
+ + + +
+ ) : ( +
+ +
+ )} +
+ + + +
+
+ + + +
+
+ + {verdict ? ( + + {candidate.wouldLink ? ( + + ) : null} + {translate(verdict)} + + ) : null} +
+
+ ) +} diff --git a/ui/src/components/track/candidate-session-route.test.ts b/ui/src/components/track/candidate-session-route.test.ts new file mode 100644 index 000000000..1591e3e44 --- /dev/null +++ b/ui/src/components/track/candidate-session-route.test.ts @@ -0,0 +1,13 @@ +import { getCandidateSessionRoute } from './candidate-session-route' + +describe('candidate session route', () => { + test('selects both occurrences and opens the candidate frame', () => { + expect( + getCandidateSessionRoute({ + captureId: '55', + occurrenceIds: ['1', '2'], + sessionRoute: '/projects/9/sessions/4', + }) + ).toBe('/projects/9/sessions/4?occurrence=1&occurrence=2&capture=55') + }) +}) diff --git a/ui/src/components/track/candidate-session-route.ts b/ui/src/components/track/candidate-session-route.ts new file mode 100644 index 000000000..54fb52549 --- /dev/null +++ b/ui/src/components/track/candidate-session-route.ts @@ -0,0 +1,21 @@ +/** + * The session view opened on a merge candidate's frame with both occurrences + * selected, so their boxes and paths can be compared on the capture itself. + * Built by hand because `getAppRoute` keeps one value per parameter. + */ +export const getCandidateSessionRoute = ({ + captureId, + occurrenceIds, + sessionRoute, +}: { + captureId: string + occurrenceIds: string[] + /** The session page's path, from `APP_ROUTES.SESSION_DETAILS`. */ + sessionRoute: string +}): string => { + const params = new URLSearchParams() + occurrenceIds.forEach((id) => params.append('occurrence', id)) + params.set('capture', captureId) + + return `${sessionRoute}?${params}` +} diff --git a/ui/src/components/track/occurrence-picker.tsx b/ui/src/components/track/occurrence-picker.tsx new file mode 100644 index 000000000..8987a8683 --- /dev/null +++ b/ui/src/components/track/occurrence-picker.tsx @@ -0,0 +1,624 @@ +import classNames from 'classnames' +import { + MergeScopeKey, + MERGE_SCOPES, +} from 'data-services/hooks/occurrences/useMergeCandidates' +import { + getComparisonSides, + getCostLabel, + getDistanceLabel, + getRatioLabel, + getSimilarityLabel, + getWhenLabel, + getWhenOffsetSeconds, + MergeCandidate, + MergeCandidateSort, + MergeCandidateSortColumn, + sortMergeCandidates, +} from 'data-services/models/merge-candidate' +import { + ArrowDownIcon, + ArrowLeftIcon, + ArrowRightIcon, + ArrowUpDownIcon, + ArrowUpIcon, + CheckIcon, + ExternalLinkIcon, +} from 'lucide-react' +import { BasicTooltip, LoadingSpinner, Select } from 'nova-ui-kit' +import { CSSProperties, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import { STRING, translate } from 'utils/language' +import { + CandidateComparison, + COMPARISON_PANEL_HEIGHT, + COMPARISON_PANEL_WIDTH, +} from './candidate-comparison' + +/** A row the picker can show. The ranking columns appear only when every row is ranked. */ +export type OccurrencePickerCandidate = Pick< + MergeCandidate, + 'id' | 'displayName' | 'images' | 'numDetections' +> & + Partial< + Pick< + MergeCandidate, + | 'speciesName' + | 'relation' + | 'timeOffsetSeconds' + | 'distance' + | 'similarity' + | 'cost' + | 'iou' + | 'sizeRatio' + | 'likelihood' + | 'cost' + | 'wouldLink' + | 'captureId' + | 'imageTimestamp' + | 'edgeImage' + | 'edgeTimestamp' + > + > + +const isRanked = ( + candidates: OccurrencePickerCandidate[] +): candidates is MergeCandidate[] => + candidates.length > 0 && + candidates.every((candidate) => candidate.relation !== undefined) + +// The first click on a column gives its natural order: A to Z, most frames first, +// earliest first, closest first, or most alike first. +const DESCENDING_FIRST: MergeCandidateSortColumn[] = [ + 'frames', + 'similarity', + 'match', +] + +const COLUMN_LABELS: Record = { + species: STRING.TRACK_COLUMN_SPECIES, + frames: STRING.TRACK_COLUMN_FRAMES, + when: STRING.TRACK_COLUMN_WHEN, + distance: STRING.TRACK_COLUMN_DISTANCE, + similarity: STRING.TRACK_COLUMN_SIMILARITY, + cost: STRING.TRACK_COLUMN_COST, + match: STRING.TRACK_COLUMN_MATCH, +} + +// Cost and the percentage derived from it rank rows differently, so each says what it is. +const COLUMN_HELP: Partial> = { + cost: STRING.TRACK_COLUMN_COST_HELP, + match: STRING.TRACK_COLUMN_MATCH_HELP, +} + +const SORT_COLUMNS: MergeCandidateSortColumn[] = [ + 'when', + 'distance', + 'similarity', + 'cost', + 'match', +] + +const BEST_MATCH = 'best' +const CUMULATIVE = 'cumulative' + +const PANEL_GAP = 8 + +const headerClassName = 'px-2 py-1 font-normal text-muted-foreground' +const cellClassName = 'px-2 py-1' +const numberClassName = 'text-right tabular-nums whitespace-nowrap' +const checkboxClassName = 'w-4 h-4 accent-primary-500 cursor-pointer' + +/** + * Beside the table when the viewport has room. Otherwise over the table's far side, + * below or above the row, so it never hides the first column's checkboxes. + */ +const getPanelStyle = ( + table: DOMRect | undefined, + row: HTMLElement +): CSSProperties | undefined => { + const rowRect = row.getBoundingClientRect() + const maxLeft = window.innerWidth - COMPARISON_PANEL_WIDTH - PANEL_GAP + const maxTop = window.innerHeight - COMPARISON_PANEL_HEIGHT - PANEL_GAP + const besideTop = Math.max(PANEL_GAP, Math.min(rowRect.top, maxTop)) + + if (!table) { + return { left: maxLeft, top: besideTop } + } + if (table.right + PANEL_GAP <= maxLeft) { + return { left: table.right + PANEL_GAP, top: besideTop } + } + if (table.left - PANEL_GAP - COMPARISON_PANEL_WIDTH >= PANEL_GAP) { + return { + left: table.left - PANEL_GAP - COMPARISON_PANEL_WIDTH, + top: besideTop, + } + } + + const clearOf = + (row.firstElementChild ?? row).getBoundingClientRect().right + PANEL_GAP + const left = Math.min( + Math.max(table.right - COMPARISON_PANEL_WIDTH, clearOf), + maxLeft + ) + if (left < clearOf) { + // Too narrow to show it without covering the first column. + return undefined + } + + const below = rowRect.bottom + PANEL_GAP + const above = rowRect.top - PANEL_GAP - COMPARISON_PANEL_HEIGHT + const top = below <= maxTop ? below : above >= PANEL_GAP ? above : besideTop + + return { left, top } +} + +export const OccurrencePicker = ({ + candidates, + costThreshold, + description, + emptyMessage, + isLoading, + onScopeChange, + onSelect, + onToggle, + requiresFeatures, + scope, + selectedId, + selectedIds = [], + sessionLink, + title = translate(STRING.TRACK_PICK_OCCURRENCE), +}: { + candidates: OccurrencePickerCandidate[] + /** Tracking links a pair only under this cost; shown beside each cost in the preview. */ + costThreshold?: number + description: string + emptyMessage: string + isLoading?: boolean + onScopeChange?: (key: MergeScopeKey) => void + /** Single-select: clicking a row makes it the one selection. */ + onSelect?: (id: string) => void + /** Multi-select: rows get checkboxes. Select-all calls this once per row, so update state functionally. */ + onToggle?: (id: string) => void + /** Tracking skips a pair with no vector on both sides instead of matching it on geometry. */ + requiresFeatures?: boolean + scope?: MergeScopeKey + selectedId?: string + selectedIds?: string[] + /** Where to open a candidate in its session; rows get a link when this returns a route. */ + sessionLink?: (candidate: MergeCandidate) => string | undefined + title?: string +}) => { + const [sort, setSort] = useState() + const [hovered, setHovered] = useState<{ + candidate: MergeCandidate + style: CSSProperties + }>() + const tableRef = useRef(null) + + const multi = !!onToggle + const ranked = isRanked(candidates) + const rows = ranked ? sortMergeCandidates(candidates, sort) : candidates + const numSelectedShown = rows.filter((row) => + selectedIds.includes(row.id) + ).length + + const columnSort = sort && 'column' in sort ? sort : undefined + + const toggleSort = (column: MergeCandidateSortColumn) => + setSort( + columnSort?.column === column + ? { column, descending: !columnSort.descending } + : { column, descending: DESCENDING_FIRST.includes(column) } + ) + + const chooseOrder = (value: string) => + setSort(value === CUMULATIVE ? { cumulative: true } : undefined) + + const pick = (candidate: OccurrencePickerCandidate) => + multi ? onToggle?.(candidate.id) : onSelect?.(candidate.id) + + const toggleAll = () => + rows + .filter((row) => + numSelectedShown === rows.length + ? selectedIds.includes(row.id) + : !selectedIds.includes(row.id) + ) + .forEach((row) => onToggle?.(row.id)) + + const showComparison = ( + candidate: OccurrencePickerCandidate, + row: HTMLElement + ) => { + if (!ranked || !candidate.images[0]) { + return + } + + const style = getPanelStyle(tableRef.current?.getBoundingClientRect(), row) + setHovered( + style ? { candidate: candidate as MergeCandidate, style } : undefined + ) + } + + const hideComparison = () => setHovered(undefined) + + const renderSortHeader = ( + column: MergeCandidateSortColumn, + className?: string + ) => { + const label = COLUMN_LABELS[column] + const help = COLUMN_HELP[column] + const active = columnSort?.column === column + const DirectionIcon = !active + ? ArrowUpDownIcon + : columnSort.descending + ? ArrowDownIcon + : ArrowUpIcon + + return ( + + + + ) + } + + return ( +
+ {title} + {description} + {(scope && onScopeChange) || ranked ? ( +
+ {scope && onScopeChange ? ( + onScopeChange(value as MergeScopeKey)} + > + + + + + {MERGE_SCOPES.map((option) => ( + + {translate(option.label)} + + ))} + + + ) : null} + {ranked ? ( + + + + + + + {translate(STRING.TRACK_SORT_BEST)} + + + {translate(STRING.TRACK_SORT_CUMULATIVE)} + + {/* Named so the control still reads true after a column header sets the order. */} + {columnSort ? ( + + {translate(COLUMN_LABELS[columnSort.column])} + + ) : null} + + + ) : null} +
+ ) : null} + {isLoading ? ( +
+ +
+ ) : !candidates.length ? ( + {emptyMessage} + ) : ( +
+ + + + {multi ? ( + + ) : null} + + {ranked ? ( + renderSortHeader('species') + ) : ( + + )} + {ranked ? ( + renderSortHeader('frames', numberClassName) + ) : ( + + )} + {ranked && + SORT_COLUMNS.map((column) => + renderSortHeader(column, numberClassName) + )} + {ranked && sessionLink ? ( + + ) : null} + + + + {rows.map((candidate) => { + const selected = multi + ? selectedIds.includes(candidate.id) + : candidate.id === selectedId + const whenOffset = + candidate.relation !== undefined + ? getWhenOffsetSeconds(candidate as MergeCandidate) + : undefined + const RelationIcon = + whenOffset === undefined + ? undefined + : whenOffset < 0 + ? ArrowLeftIcon + : ArrowRightIcon + const whenLabel = ( + + {RelationIcon ? ( + + ) : null} + + {whenOffset !== undefined + ? getWhenLabel(whenOffset) + : null} + + + ) + + return ( + pick(candidate)} + onMouseEnter={(e) => + showComparison(candidate, e.currentTarget) + } + > + {multi ? ( + + ) : null} + + {/* One line per row; long names are cut and shown in full on hover. */} + + + {ranked && candidate.relation !== undefined ? ( + <> + + + + + + {sessionLink ? ( + + ) : null} + + ) : null} + + ) + })} + +
+ 0 && numSelectedShown === rows.length + } + className={checkboxClassName} + onChange={toggleAll} + ref={(input) => { + if (input) { + input.indeterminate = + numSelectedShown > 0 && + numSelectedShown < rows.length + } + }} + type="checkbox" + /> + + + {translate(STRING.TRACK_COLUMN_IMAGE)} + + + {translate(STRING.TRACK_COLUMN_SPECIES)} + + {translate(STRING.TRACK_COLUMN_FRAMES)} + + + {translate(STRING.VIEW_IN_SESSION)} + +
e.stopPropagation()} + > + pick(candidate)} + type="checkbox" + /> + + {candidate.images[0] ? ( + (e.currentTarget.hidden = true)} + src={candidate.images[0].src} + /> + ) : null} + + + + {candidate.numDetections} + + {candidate.relation === 'gap' ? ( + + {whenLabel} + + ) : ( + whenLabel + )} + + {getDistanceLabel(candidate.distance ?? null)} + + {getSimilarityLabel(candidate.similarity ?? null)} + + {getCostLabel(candidate.cost ?? null)} + + + {candidate.wouldLink ? ( + + + + ) : null} + + {getRatioLabel(candidate.likelihood ?? null)} + + + + {(() => { + const route = sessionLink( + candidate as MergeCandidate + ) + + return route ? ( + e.stopPropagation()} + rel="noreferrer" + target="_blank" + title={translate(STRING.VIEW_IN_SESSION)} + to={route} + > + + + ) : null + })()} +
+
+ )} + {hovered ? ( + + ) : null} +
+ ) +} diff --git a/ui/src/components/track/track-edit-dialog.tsx b/ui/src/components/track/track-edit-dialog.tsx new file mode 100644 index 000000000..902227430 --- /dev/null +++ b/ui/src/components/track/track-edit-dialog.tsx @@ -0,0 +1,142 @@ +import classNames from 'classnames' +import { FormError, FormMessage } from 'components/form/layout/layout' +import { ChevronRightIcon, Loader2Icon } from 'lucide-react' +import { BasicTooltip, Button, buttonVariants, Dialog } from 'nova-ui-kit' +import { ReactNode } from 'react' +import { Link } from 'react-router-dom' +import { STRING, translate } from 'utils/language' +import { parseServerError } from 'utils/parseServerError/parseServerError' + +/** Says why a button is disabled; a disabled button takes no pointer events itself. */ +export const DisabledReason = ({ + children, + reason, +}: { + children: ReactNode + reason?: string +}) => + reason ? ( + + {children} + + ) : ( + <>{children} + ) + +/** + * Confirmation step shared by the track edits. Passing `result` replaces the + * confirm button with what happened, so an edit never completes silently. + */ +export const TrackEditDialog = ({ + children, + confirmDisabled, + confirmDisabledReason, + confirmLabel, + description, + error, + isLoading, + isWide, + onConfirm, + onOpenChange, + open, + result, + resultLink, + title, +}: { + children?: ReactNode + confirmDisabled?: boolean + /** Shown on the confirm button while it is disabled. */ + confirmDisabledReason?: string + confirmLabel: string + description: string + error?: unknown + isLoading?: boolean + /** For content that needs more than the compact dialog's width, such as a table. */ + isWide?: boolean + onConfirm: () => void + onOpenChange: (open: boolean) => void + open: boolean + result?: string + /** Where the occurrence the edit created can be opened, once there is a result. */ + resultLink?: string + title: string +}) => { + // Track edits have no form fields, so a field error from the server is the reason itself. + const parsedError = error ? parseServerError(error) : undefined + const errorMessage = parsedError + ? parsedError.fieldErrors + .map((fieldError) => fieldError.message) + .join(' ') || parsedError.message + : undefined + + return ( + + div]:max-h-[inherit]' + : undefined + } + isCompact={!isWide} + > + +
+ + {description} + + {children} + {errorMessage ? : null} + {result ? ( + + {resultLink ? ( + + {translate(STRING.TRACK_OPEN_NEW_OCCURRENCE)} + + + ) : null} + + ) : null} +
+ + {!result && ( + + + + )} +
+
+
+
+ ) +} diff --git a/ui/src/components/track/use-merge-selection.test.ts b/ui/src/components/track/use-merge-selection.test.ts new file mode 100644 index 000000000..d337e38e3 --- /dev/null +++ b/ui/src/components/track/use-merge-selection.test.ts @@ -0,0 +1,76 @@ +import { act, renderHook } from '@testing-library/react' +import { keepOfferedIds, useMergeSelection } from './use-merge-selection' + +// The adjacent-capture scope offers two rows; a wider scope also offers a third. +const ADJACENT = [{ id: 'o1' }, { id: 'o2' }] +const WIDER = [{ id: 'o1' }, { id: 'o2' }, { id: 'o3' }] +const NARROWER = [{ id: 'o1' }] + +const renderSelection = () => + renderHook( + (props: { candidates: { id: string }[]; isLoading: boolean }) => + useMergeSelection(props), + { initialProps: { candidates: ADJACENT, isLoading: false } } + ) + +describe('keepOfferedIds', () => { + test('drops a tick the candidates no longer offer', () => { + expect(keepOfferedIds(['o1', 'o2'], NARROWER)).toEqual(['o1']) + }) + + test('returns the same ids when every tick still stands', () => { + const selectedIds = ['o1', 'o2'] + + expect(keepOfferedIds(selectedIds, WIDER)).toBe(selectedIds) + }) +}) + +describe('useMergeSelection', () => { + test('a wider list of candidates keeps every tick', () => { + const { rerender, result } = renderSelection() + act(() => { + result.current.toggle('o1') + result.current.toggle('o2') + }) + + rerender({ candidates: WIDER, isLoading: false }) + + expect(result.current.selectedIds).toEqual(['o1', 'o2']) + }) + + test('candidates still being fetched keep every tick', () => { + const { rerender, result } = renderSelection() + act(() => { + result.current.toggle('o1') + result.current.toggle('o2') + }) + + rerender({ candidates: [], isLoading: true }) + + expect(result.current.selectedIds).toEqual(['o1', 'o2']) + }) + + test('a narrower list drops the ticks it no longer offers', () => { + const { rerender, result } = renderSelection() + act(() => { + result.current.toggle('o1') + result.current.toggle('o2') + }) + + rerender({ candidates: NARROWER, isLoading: false }) + + expect(result.current.selectedIds).toEqual(['o1']) + }) + + test('clearing empties the selection', () => { + const { result } = renderSelection() + act(() => { + result.current.toggle('o1') + }) + act(() => { + result.current.clear() + }) + + expect(result.current.selectedIds).toEqual([]) + }) +}) diff --git a/ui/src/components/track/use-merge-selection.ts b/ui/src/components/track/use-merge-selection.ts new file mode 100644 index 000000000..71120c186 --- /dev/null +++ b/ui/src/components/track/use-merge-selection.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react' + +/** A row the picker can offer. Only the id decides whether a tick still stands. */ +type OfferedCandidate = { id: string } + +/** + * The ticked ids the given candidates still offer. + * + * Returns the ids unchanged when all of them survive, so a caller holding the + * result in state re-renders only when something was really dropped. + */ +export const keepOfferedIds = ( + selectedIds: string[], + candidates: OfferedCandidate[] +): string[] => { + const offered = selectedIds.filter((id) => + candidates.some((candidate) => candidate.id === id) + ) + + return offered.length === selectedIds.length ? selectedIds : offered +} + +/** + * The merge candidates a reviewer has ticked, kept across a change of search scope. + * + * Widening keeps every tick; narrowing drops the rows it stops offering, since + * merging an occurrence the reviewer cannot see would be unsafe. Only an arrived + * list says which rows those are, so a list still being fetched prunes nothing. + */ +export const useMergeSelection = ({ + candidates, + isLoading, +}: { + candidates: OfferedCandidate[] + /** True while the candidates are in flight, when they cannot say what is offered. */ + isLoading?: boolean +}): { + clear: () => void + selectedIds: string[] + toggle: (id: string) => void +} => { + const [selectedIds, setSelectedIds] = useState([]) + + useEffect(() => { + if (isLoading) { + return + } + + setSelectedIds((ids) => keepOfferedIds(ids, candidates)) + }, [candidates, isLoading]) + + return { + clear: () => setSelectedIds([]), + selectedIds, + toggle: (id: string) => + setSelectedIds((ids) => + ids.includes(id) ? ids.filter((other) => other !== id) : [...ids, id] + ), + } +} diff --git a/ui/src/data-services/hooks/auth/useAuthorizedQuery.ts b/ui/src/data-services/hooks/auth/useAuthorizedQuery.ts index e080788c4..995ddd1df 100644 --- a/ui/src/data-services/hooks/auth/useAuthorizedQuery.ts +++ b/ui/src/data-services/hooks/auth/useAuthorizedQuery.ts @@ -1,10 +1,11 @@ -import { QueryKey, useQuery } from '@tanstack/react-query' +import { QueryKey, useQuery, UseQueryOptions } from '@tanstack/react-query' import axios from 'axios' import { getAuthHeader } from 'data-services/utils' import { useUser } from 'utils/user/userContext' export const useAuthorizedQuery = ({ enabled, + keepPreviousData, onError, queryKey = [], refetchInterval, @@ -13,16 +14,18 @@ export const useAuthorizedQuery = ({ url, }: { enabled?: boolean + keepPreviousData?: boolean onError?: (error: unknown) => void queryKey?: QueryKey refetchInterval?: number - retry?: number + retry?: UseQueryOptions['retry'] staleTime?: number url: string }) => { const { user } = useUser() - const { data, isLoading, isFetching, isSuccess, error } = useQuery({ + const { data, isLoading, isFetching, isSuccess, error, refetch } = useQuery({ enabled, + keepPreviousData, onError, queryKey, queryFn: () => @@ -36,5 +39,5 @@ export const useAuthorizedQuery = ({ staleTime, }) - return { data, isLoading, isFetching, isSuccess, error } + return { data, isLoading, isFetching, isSuccess, error, refetch } } diff --git a/ui/src/data-services/hooks/captures/useFetchCaptureDetails.ts b/ui/src/data-services/hooks/captures/useFetchCaptureDetails.ts new file mode 100644 index 000000000..5bf00e876 --- /dev/null +++ b/ui/src/data-services/hooks/captures/useFetchCaptureDetails.ts @@ -0,0 +1,40 @@ +import { useQueryClient } from '@tanstack/react-query' +import axios from 'axios' +import { API_ROUTES } from 'data-services/constants' +import { + CaptureDetails, + ServerCaptureDetails, +} from 'data-services/models/capture-details' +import { getAuthHeader, getFetchDetailsUrl } from 'data-services/utils' +import { useCallback } from 'react' +import { useUser } from 'utils/user/userContext' + +/** + * Reads one capture on demand, for callers that only learn which capture they need + * after an edit has landed. Shares its cache entry with `useCaptureDetails`. + */ +export const useFetchCaptureDetails = (projectId: string) => { + const { user } = useUser() + const queryClient = useQueryClient() + + return useCallback( + (id: string) => + queryClient + .fetchQuery({ + queryKey: [API_ROUTES.CAPTURES, id, projectId], + queryFn: () => + axios + .get( + getFetchDetailsUrl({ + collection: API_ROUTES.CAPTURES, + itemId: id, + projectId, + }), + { headers: getAuthHeader(user) } + ) + .then((res) => res.data), + }) + .then((record) => new CaptureDetails(record)), + [projectId, queryClient, user] + ) +} diff --git a/ui/src/data-services/hooks/jobs/useCreateTrackingJob.ts b/ui/src/data-services/hooks/jobs/useCreateTrackingJob.ts new file mode 100644 index 000000000..69a5d2ca4 --- /dev/null +++ b/ui/src/data-services/hooks/jobs/useCreateTrackingJob.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import axios from 'axios' +import { API_ROUTES, API_URL, SUCCESS_TIMEOUT } from 'data-services/constants' +import { + buildTrackingJobPayload, + TrackingJobFieldValues, +} from 'data-services/models/tracking-job' +import { getAuthHeader } from 'data-services/utils' +import { useUser } from 'utils/user/userContext' + +export const useCreateTrackingJob = (onSuccess?: (id: string) => void) => { + const { user } = useUser() + const queryClient = useQueryClient() + + const { mutateAsync, isLoading, isSuccess, reset, error } = useMutation({ + mutationFn: ({ + startNow, + ...fieldValues + }: TrackingJobFieldValues & { startNow?: boolean }) => + axios.post<{ id: number }>( + `${API_URL}/${API_ROUTES.JOBS}/${startNow ? '?start_now' : ''}`, + buildTrackingJobPayload(fieldValues), + { headers: getAuthHeader(user) } + ), + onSuccess: ({ data }) => { + queryClient.invalidateQueries([API_ROUTES.JOBS]) + onSuccess?.(`${data.id}`) + setTimeout(reset, SUCCESS_TIMEOUT) + }, + }) + + return { createTrackingJob: mutateAsync, isLoading, isSuccess, error } +} diff --git a/ui/src/data-services/hooks/occurrences/track/useAddDetections.ts b/ui/src/data-services/hooks/occurrences/track/useAddDetections.ts new file mode 100644 index 000000000..f4bd4c3ed --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/track/useAddDetections.ts @@ -0,0 +1,15 @@ +import { OccurrenceGroupingResult, useTrackAction } from './useTrackAction' + +/** Move detections into this occurrence from wherever they are now. */ +export const useAddDetections = (occurrenceId: string) => { + const { mutateAsync, ...rest } = useTrackAction< + { detection_ids: number[] }, + OccurrenceGroupingResult + >(occurrenceId, 'add-detections') + + return { + addDetections: (detectionIds: string[]) => + mutateAsync({ detection_ids: detectionIds.map(Number) }), + ...rest, + } +} diff --git a/ui/src/data-services/hooks/occurrences/track/useMergeOccurrences.ts b/ui/src/data-services/hooks/occurrences/track/useMergeOccurrences.ts new file mode 100644 index 000000000..1ebbfb04a --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/track/useMergeOccurrences.ts @@ -0,0 +1,15 @@ +import { OccurrenceGroupingResult, useTrackAction } from './useTrackAction' + +/** Absorb other occurrences into this one. They are deleted; their detections and identifications move here. */ +export const useMergeOccurrences = (occurrenceId: string) => { + const { mutateAsync, ...rest } = useTrackAction< + { occurrence_ids: number[] }, + OccurrenceGroupingResult + >(occurrenceId, 'merge') + + return { + mergeOccurrences: (occurrenceIds: string[]) => + mutateAsync({ occurrence_ids: occurrenceIds.map(Number) }), + ...rest, + } +} diff --git a/ui/src/data-services/hooks/occurrences/track/useRemoveDetection.ts b/ui/src/data-services/hooks/occurrences/track/useRemoveDetection.ts new file mode 100644 index 000000000..81d3f6a7f --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/track/useRemoveDetection.ts @@ -0,0 +1,15 @@ +import { TrackEditResult, useTrackAction } from './useTrackAction' + +/** Pull one detection out of an occurrence into an occurrence of its own. */ +export const useRemoveDetection = (occurrenceId: string) => { + const { mutateAsync, ...rest } = useTrackAction< + { detection_id: number }, + TrackEditResult + >(occurrenceId, 'remove-detection') + + return { + removeDetection: (detectionId: string) => + mutateAsync({ detection_id: Number(detectionId) }), + ...rest, + } +} diff --git a/ui/src/data-services/hooks/occurrences/track/useSetGroupingVerified.ts b/ui/src/data-services/hooks/occurrences/track/useSetGroupingVerified.ts new file mode 100644 index 000000000..f4d938321 --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/track/useSetGroupingVerified.ts @@ -0,0 +1,27 @@ +import { OccurrenceGroupingResult, useTrackAction } from './useTrackAction' + +/** + * Record or withdraw a person's confirmation that an occurrence holds the right detections. + * + * Two endpoints behind one control, because callers only ever offer the operator a + * toggle. Callers get the state of whichever direction they last ran. + */ +export const useSetGroupingVerified = (occurrenceId: string) => { + const verify = useTrackAction( + occurrenceId, + 'verify-grouping' + ) + const unverify = useTrackAction( + occurrenceId, + 'unverify-grouping' + ) + + return { + setGroupingVerified: (verified: boolean) => + verified + ? verify.mutateAsync(undefined) + : unverify.mutateAsync(undefined), + error: verify.error ?? unverify.error, + isLoading: verify.isLoading || unverify.isLoading, + } +} diff --git a/ui/src/data-services/hooks/occurrences/track/useSplitTrack.ts b/ui/src/data-services/hooks/occurrences/track/useSplitTrack.ts new file mode 100644 index 000000000..220922883 --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/track/useSplitTrack.ts @@ -0,0 +1,15 @@ +import { TrackEditResult, useTrackAction } from './useTrackAction' + +/** Split an occurrence at a detection: that detection and every later one move to a new occurrence. */ +export const useSplitTrack = (occurrenceId: string) => { + const { mutateAsync, ...rest } = useTrackAction< + { detection_id: number }, + TrackEditResult + >(occurrenceId, 'split-track') + + return { + splitTrack: (detectionId: string) => + mutateAsync({ detection_id: Number(detectionId) }), + ...rest, + } +} diff --git a/ui/src/data-services/hooks/occurrences/track/useTrackAction.ts b/ui/src/data-services/hooks/occurrences/track/useTrackAction.ts new file mode 100644 index 000000000..d077c93b0 --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/track/useTrackAction.ts @@ -0,0 +1,60 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import axios from 'axios' +import { API_ROUTES, API_URL } from 'data-services/constants' +import { getAuthHeader } from 'data-services/utils' +import { useUser } from 'utils/user/userContext' + +/** + * Shared plumbing for the track editing actions on an occurrence. + * + * Every one of them can change which detections the occurrence holds, and any such + * change clears the grouping confirmation server-side, so all of them invalidate rather + * than patching the cache. Captures are invalidated alongside occurrences because a + * capture payload carries the occurrence behind each box, which an edit can change. + */ +export const useTrackAction = ( + occurrenceId: string, + action: string +) => { + const { user } = useUser() + const queryClient = useQueryClient() + + const { mutateAsync, data, error, isLoading, isSuccess, reset } = useMutation( + { + mutationFn: (body: Body) => + axios.post( + `${API_URL}/${API_ROUTES.OCCURRENCES}/${occurrenceId}/${action}/`, + body, + { headers: getAuthHeader(user) } + ), + onSuccess: () => { + queryClient.invalidateQueries([API_ROUTES.OCCURRENCES]) + queryClient.invalidateQueries([API_ROUTES.CAPTURES]) + }, + } + ) + + return { + mutateAsync, + result: data?.data, + error, + isLoading, + isSuccess, + reset, + } +} + +export interface TrackEditResult { + occurrence_id: number + occurrence_detections_count: number + new_occurrence_id: number + new_occurrence_detections_count: number +} + +export interface OccurrenceGroupingResult { + occurrence_id: number + detections_count: number + grouping_verified: boolean + grouping_verified_at: string | null + grouping_verified_by: string | null +} diff --git a/ui/src/data-services/hooks/occurrences/useCaptureMatches.ts b/ui/src/data-services/hooks/occurrences/useCaptureMatches.ts new file mode 100644 index 000000000..72e9e71f0 --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/useCaptureMatches.ts @@ -0,0 +1,43 @@ +import { API_ROUTES, API_URL } from 'data-services/constants' +import { + CaptureMatch, + convertCaptureMatches, + ServerCaptureMatches, +} from 'data-services/models/capture-match' +import { useMemo } from 'react' +import { useAuthorizedQuery } from '../auth/useAuthorizedQuery' + +/** + * How likely each box on a capture is the animal of the track being extended, keyed by + * detection id. Keyed under the occurrences route so a completed track edit refreshes it. + */ +export const useCaptureMatches = ({ + captureId, + enabled, + occurrenceId, +}: { + captureId?: string + enabled?: boolean + occurrenceId?: string +}): { matches?: Record } => { + const { data } = useAuthorizedQuery({ + enabled: !!occurrenceId && !!captureId && !!enabled, + queryKey: [ + API_ROUTES.OCCURRENCES, + occurrenceId, + 'capture-matches', + captureId, + ], + // The boxes fall back to neutral without scores, so a failure is not worth retrying. + retry: 0, + staleTime: Infinity, + url: `${API_URL}/${API_ROUTES.OCCURRENCES}/${occurrenceId}/capture-matches/?capture_id=${captureId}`, + }) + + const matches = useMemo( + () => (data ? convertCaptureMatches(data) : undefined), + [data] + ) + + return { matches } +} diff --git a/ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts b/ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts new file mode 100644 index 000000000..2b52bb2fe --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts @@ -0,0 +1,119 @@ +import { + getCandidatesEmptyMessage, + getDetectionError, + getMergeCandidatesParams, + getServerMessage, + shouldRetryCandidates, +} from './useMergeCandidates' + +describe('getMergeCandidatesParams', () => { + test('searches minutes around the occurrence by default', () => { + expect(`${getMergeCandidatesParams({ projectId: '5' })}`).toBe( + 'project_id=5&minutes=5' + ) + }) + + test('a capture scope replaces the minutes window', () => { + expect( + `${getMergeCandidatesParams({ + captures: 3, + minutes: 30, + projectId: '5', + })}` + ).toBe('project_id=5&captures=3') + }) + + test('ranks against a detection when one is given', () => { + expect( + `${getMergeCandidatesParams({ + captures: 3, + detectionId: '42', + projectId: '5', + })}` + ).toBe('project_id=5&detection=42&captures=3') + }) +}) + +describe('getDetectionError', () => { + const badRequest = (detection: unknown) => ({ + response: { data: { detection } }, + }) + + test('reads the reason as a string or the first of a list', () => { + expect(getDetectionError(badRequest('Not in this occurrence.'))).toBe( + 'Not in this occurrence.' + ) + expect(getDetectionError(badRequest(['Has no box.', 'Other.']))).toBe( + 'Has no box.' + ) + }) + + test('is undefined for other errors', () => { + expect(getDetectionError(undefined)).toBeUndefined() + expect(getDetectionError({ response: { data: { detail: 'x' } } })).toBe( + undefined + ) + expect(getDetectionError(badRequest([]))).toBeUndefined() + }) +}) + +describe('getCandidatesEmptyMessage', () => { + test('tells a failed request apart from an empty scope', () => { + const empty = getCandidatesEmptyMessage(undefined) + const failed = getCandidatesEmptyMessage({ response: { status: 500 } }) + + expect(failed).not.toBe(empty) + expect( + getCandidatesEmptyMessage({ + response: { data: { detection: 'Has no box.' } }, + }) + ).toBe('Has no box.') + }) +}) + +describe('shouldRetryCandidates', () => { + const failed = (status?: number) => ({ response: { status } }) + + test('does not retry an answer that asking again cannot change', () => { + for (const status of [401, 403, 404]) { + expect(shouldRetryCandidates(0, failed(status))).toBe(false) + } + }) + + test('retries other failures a few times', () => { + expect(shouldRetryCandidates(0, failed(500))).toBe(true) + expect(shouldRetryCandidates(0, new Error('Network Error'))).toBe(true) + expect(shouldRetryCandidates(3, failed(500))).toBe(false) + }) +}) + +describe('getServerMessage', () => { + test("shows the server's reason", () => { + const error = { + message: 'Request failed with status code 403', + response: { data: { detail: 'Tracking is not enabled.' } }, + } + + expect(getServerMessage(error)).toBe('Tracking is not enabled.') + expect(getCandidatesEmptyMessage(error)).toBe('Tracking is not enabled.') + }) + + test('reads a field error when there is no general one', () => { + expect( + getServerMessage({ + message: 'Request failed with status code 400', + response: { data: { minutes: ['Must be positive.'] } }, + }) + ).toBe('Must be positive.') + }) + + test('is undefined when the response carries no reason', () => { + expect(getServerMessage(undefined)).toBeUndefined() + expect( + getServerMessage({ + message: 'Request failed with status code 500', + response: { data: 'Server error' }, + }) + ).toBeUndefined() + }) +}) diff --git a/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts b/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts new file mode 100644 index 000000000..2b5c80374 --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts @@ -0,0 +1,175 @@ +import { API_ROUTES, API_URL } from 'data-services/constants' +import { + convertMergeCandidate, + MergeCandidate, + ServerMergeCandidates, +} from 'data-services/models/merge-candidate' +import { useMemo } from 'react' +import { STRING, translate } from 'utils/language' +import { parseServerError } from 'utils/parseServerError/parseServerError' +import { useAuthorizedQuery } from '../auth/useAuthorizedQuery' + +const DEFAULT_WINDOW_MINUTES = 5 +const MAX_RETRIES = 3 +const FINAL_STATUSES = [401, 403, 404] + +export type MergeScopeKey = 'next' | 'near' | 'minutes5' | 'minutes30' + +/** How far from the occurrence to look: a number of captures either side of it, or a time window. */ +export type MergeScope = { key: MergeScopeKey; label: STRING } & ( + | { captures: number; minutes?: undefined } + | { minutes: number; captures?: undefined } +) + +export const MERGE_SCOPES: MergeScope[] = [ + { key: 'next', label: STRING.TRACK_SCOPE_NEXT, captures: 1 }, + { key: 'near', label: STRING.TRACK_SCOPE_NEAR, captures: 3 }, + { key: 'minutes5', label: STRING.TRACK_SCOPE_MINUTES_5, minutes: 5 }, + { key: 'minutes30', label: STRING.TRACK_SCOPE_MINUTES_30, minutes: 30 }, +] + +/** The query string of a candidates request: a capture scope replaces the minutes window. */ +export const getMergeCandidatesParams = ({ + captures, + detectionId, + minutes = DEFAULT_WINDOW_MINUTES, + projectId, +}: { + captures?: number + detectionId?: string + minutes?: number + projectId: string +}) => { + const params = new URLSearchParams({ project_id: projectId }) + + if (detectionId) { + params.set('detection', detectionId) + } + if (captures !== undefined) { + params.set('captures', `${captures}`) + } else { + params.set('minutes', `${minutes}`) + } + + return params +} + +/** The server's reason a frame cannot be scored, sent as a string or a list of them. */ +export const getDetectionError = (error: unknown): string | undefined => { + const detection = (error as { response?: { data?: { detection?: unknown } } }) + ?.response?.data?.detection + const message = + detection !== undefined ? ([] as unknown[]).concat(detection)[0] : undefined + + return message ? `${message}` : undefined +} + +/** Retry a failed request unless the answer cannot change by asking again. */ +export const shouldRetryCandidates = (failureCount: number, error: unknown) => { + const status = (error as { response?: { status?: number } })?.response?.status + + return ( + failureCount < MAX_RETRIES && + (status === undefined || !FINAL_STATUSES.includes(status)) + ) +} + +/** The reason the server gave for a failed request, if it gave one. */ +export const getServerMessage = (error: unknown): string | undefined => { + const axiosError = error as { + message?: string + response?: { data?: unknown } + } + + if (!axiosError?.response?.data) { + return undefined + } + + const { fieldErrors, message } = parseServerError(error) + + return message && message !== axiosError.message + ? message + : fieldErrors[0]?.message +} + +/** A failed request must not read as an empty scope, or the operator widens it for nothing. */ +export const getCandidatesEmptyMessage = (error: unknown): string => + getDetectionError(error) ?? + getServerMessage(error) ?? + translate( + error + ? STRING.TRACK_MERGE_CANDIDATES_LOAD_FAILED + : STRING.TRACK_NO_MERGE_CANDIDATES_SCOPE + ) + +/** + * Occurrences this one could be merged with, ranked by the tracking method. Given a + * detection, they are candidates to move that frame to, measured from it instead. + * + * Fetched only while `enabled`: a merge dialog opens for one occurrence at a time, + * and the list is keyed under the occurrences route so a completed track edit + * refreshes it along with everything else. + */ +export const useMergeCandidates = ({ + captures, + detectionId, + enabled, + minutes = DEFAULT_WINDOW_MINUTES, + occurrenceId, + projectId, +}: { + /** Captures either side of the occurrence to search. Given, it replaces the `minutes` window. */ + captures?: number + /** A frame of the occurrence to rank candidates against, for moving it elsewhere. */ + detectionId?: string + enabled?: boolean + minutes?: number + occurrenceId: string + projectId: string +}): { + candidates: MergeCandidate[] + /** Tracking links a pair only under this cost; undefined until the list has loaded. */ + costThreshold?: number + /** Tracking skips a pair with no vector on both sides instead of matching it on geometry. */ + requiresFeatures?: boolean + isLoading: boolean + error?: unknown + captures?: number + /** The window searched when no `captures` scope is given. */ + minutes: number +} => { + const params = getMergeCandidatesParams({ + captures, + detectionId, + minutes, + projectId, + }) + + const { data, isLoading, error } = useAuthorizedQuery({ + enabled: !!occurrenceId && !!enabled, + queryKey: [ + API_ROUTES.OCCURRENCES, + occurrenceId, + 'merge-candidates', + { captures, detectionId, minutes, projectId }, + ], + retry: shouldRetryCandidates, + url: `${API_URL}/${API_ROUTES.OCCURRENCES}/${occurrenceId}/merge-candidates/?${params}`, + }) + + const candidates = useMemo( + () => data?.candidates.map(convertMergeCandidate) ?? [], + [data] + ) + + return { + candidates, + costThreshold: data?.cost_threshold, + requiresFeatures: data?.requires_features, + // A disabled query still reports itself as loading, so guard on the inputs. + isLoading: !!occurrenceId && !!enabled && isLoading, + error, + captures, + minutes, + } +} diff --git a/ui/src/data-services/hooks/occurrences/useOccurrenceDetails.ts b/ui/src/data-services/hooks/occurrences/useOccurrenceDetails.ts index cbd5a714e..9bb5fffca 100644 --- a/ui/src/data-services/hooks/occurrences/useOccurrenceDetails.ts +++ b/ui/src/data-services/hooks/occurrences/useOccurrenceDetails.ts @@ -4,23 +4,43 @@ import { ServerOccurrenceDetails, } from 'data-services/models/occurrence-details' import { useMemo } from 'react' +import { useSearchParams } from 'react-router-dom' import { useAuthorizedQuery } from '../auth/useAuthorizedQuery' const convertServerRecord = (record: ServerOccurrenceDetails) => new OccurrenceDetails(record) export const useOccurrenceDetails = ( - id: string + id: string, + { + skipDefaultFilters, + }: { + /** Load the occurrence even when the project's default filters would hide it. */ + skipDefaultFilters?: boolean + } = {} ): { occurrence?: OccurrenceDetails isLoading: boolean isFetching: boolean error?: unknown } => { + // Carried through from the URL so a link to an occurrence a track edit just created + // opens it. Such an occurrence can score under the project's default threshold, which + // hides machine guesses but should not hide a record a person deliberately made. + const [searchParams] = useSearchParams() + const applyDefaults = skipDefaultFilters + ? 'false' + : searchParams.get('apply_defaults') + const url = + applyDefaults === 'false' + ? `${API_URL}/${API_ROUTES.OCCURRENCES}/${id}/?apply_defaults=false` + : `${API_URL}/${API_ROUTES.OCCURRENCES}/${id}/` + const { data, isLoading, isFetching, error } = useAuthorizedQuery({ - queryKey: [API_ROUTES.OCCURRENCES, id], - url: `${API_URL}/${API_ROUTES.OCCURRENCES}/${id}/`, + enabled: !!id, + queryKey: [API_ROUTES.OCCURRENCES, id, applyDefaults], + url, }) const occurrence = useMemo( diff --git a/ui/src/data-services/hooks/occurrences/useOccurrenceFrames.ts b/ui/src/data-services/hooks/occurrences/useOccurrenceFrames.ts new file mode 100644 index 000000000..c7ec45cec --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/useOccurrenceFrames.ts @@ -0,0 +1,76 @@ +import { API_ROUTES, API_URL } from 'data-services/constants' +import { + convertOccurrenceFrame, + OccurrenceFrame, +} from 'data-services/models/occurrence-details' +import { + FramePageRequest, + getFramePageQuery, + getLinkedFramePage, + ServerOccurrenceFramesPage, +} from 'data-services/models/occurrence-frames-page' +import { useMemo } from 'react' +import { useSearchParams } from 'react-router-dom' +import { useAuthorizedQuery } from '../auth/useAuthorizedQuery' + +/** + * One page of an occurrence's frames, earliest first. Pages share the occurrence's + * query key, so every track edit that refreshes the occurrence refreshes them too. + */ +export const useOccurrenceFrames = ({ + enabled = true, + occurrenceId, + request, +}: { + /** Off while the detail's own first page is on screen. */ + enabled?: boolean + occurrenceId: string + request: FramePageRequest +}): { + error?: unknown + frames?: OccurrenceFrame[] + isFetching: boolean + next?: FramePageRequest + previous?: FramePageRequest + total?: number +} => { + // The same bypass as the occurrence itself, or a page of an occurrence opened past + // the project's default filters would not be found. + const [searchParams] = useSearchParams() + const applyDefaults = searchParams.get('apply_defaults') + const query = getFramePageQuery(request) + const url = `${API_URL}/${ + API_ROUTES.OCCURRENCES + }/${occurrenceId}/detections/?${query}${ + applyDefaults === 'false' ? '&apply_defaults=false' : '' + }` + + const { data, error, isFetching } = + useAuthorizedQuery({ + enabled: enabled && !!occurrenceId, + keepPreviousData: true, + queryKey: [ + API_ROUTES.OCCURRENCES, + occurrenceId, + 'detections', + applyDefaults, + query, + ], + retry: false, + url, + }) + + const frames = useMemo( + () => data?.results.map(convertOccurrenceFrame), + [data] + ) + + return { + error, + frames, + isFetching, + next: data ? getLinkedFramePage(data.next) : undefined, + previous: data ? getLinkedFramePage(data.previous) : undefined, + total: data?.count, + } +} diff --git a/ui/src/data-services/hooks/occurrences/useOccurrencePath.ts b/ui/src/data-services/hooks/occurrences/useOccurrencePath.ts new file mode 100644 index 000000000..748de66d2 --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/useOccurrencePath.ts @@ -0,0 +1,46 @@ +import { API_ROUTES, API_URL } from 'data-services/constants' +import { + convertPathFrame, + PathFrame, + ServerOccurrencePathFrame, +} from 'data-services/models/occurrence-path' +import { useMemo } from 'react' +import { useAuthorizedQuery } from '../auth/useAuthorizedQuery' + +/** + * Where an occurrence was in every frame it appears in, earliest first. + * + * `enabled` is the point of this hook: a session holds thousands of occurrences and + * a path is only worth fetching when a person asks for one. Once fetched it is kept, + * so stepping between captures redraws the same path rather than requesting it again. + */ +export const useOccurrencePath = ( + occurrenceId?: string, + enabled?: boolean +): { + path?: PathFrame[] + isLoading: boolean + /** Also true while a track edit refreshes an already shown path. */ + isFetching: boolean + error?: unknown + refetch: () => void +} => { + const { data, isLoading, isFetching, error, refetch } = useAuthorizedQuery< + ServerOccurrencePathFrame[] + >({ + enabled: !!occurrenceId && !!enabled, + queryKey: [API_ROUTES.OCCURRENCES, occurrenceId, 'path'], + staleTime: Infinity, + url: `${API_URL}/${API_ROUTES.OCCURRENCES}/${occurrenceId}/path/`, + }) + + const path = useMemo(() => data?.map(convertPathFrame), [data]) + + return { + path, + isLoading: !!occurrenceId && !!enabled && isLoading, + isFetching, + error, + refetch, + } +} diff --git a/ui/src/data-services/models/capture-details.ts b/ui/src/data-services/models/capture-details.ts index cd7affa2c..7755963bc 100644 --- a/ui/src/data-services/models/capture-details.ts +++ b/ui/src/data-services/models/capture-details.ts @@ -4,6 +4,9 @@ import { Job } from './job' export type ServerCaptureDetails = ServerCapture & any // TODO: Update this type +const toOptionalId = (id?: number | null) => + id === undefined || id === null ? id : `${id}` + export class CaptureDetails extends Capture { private readonly _jobs: Job[] = [] @@ -32,6 +35,16 @@ export class CaptureDetails extends Capture { })[0] } + /** Valid detections with a classification that stored a feature embedding; detail endpoint only. */ + get detectionsWithFeatures(): number | undefined { + return this._capture.detections_with_features ?? undefined + } + + /** The total detectionsWithFeatures is out of, counted over the same detections. */ + get detectionsValid(): number | undefined { + return this._capture.detections_valid ?? undefined + } + get hasJobInProgress(): boolean { return this._jobs.some( (job) => @@ -52,11 +65,27 @@ export class CaptureDetails extends Capture { } get nextCaptureId(): string | undefined { - return this._capture.event_next_capture_id + return this._capture.event_next_capture_id !== null && + this._capture.event_next_capture_id !== undefined + ? `${this._capture.event_next_capture_id}` + : undefined + } + + /** Null when no later capture in the session has a detection; undefined if not sent. */ + get nextCaptureWithDetectionsId(): string | null | undefined { + return toOptionalId(this._capture.event_next_capture_with_detections_id) } get prevCaptureId(): string | undefined { - return this._capture.event_prev_capture_id + return this._capture.event_prev_capture_id !== null && + this._capture.event_prev_capture_id !== undefined + ? `${this._capture.event_prev_capture_id}` + : undefined + } + + /** Null when no earlier capture in the session has a detection; undefined if not sent. */ + get prevCaptureWithDetectionsId(): string | null | undefined { + return toOptionalId(this._capture.event_prev_capture_with_detections_id) } get sizeLabel(): string { diff --git a/ui/src/data-services/models/capture-match.ts b/ui/src/data-services/models/capture-match.ts new file mode 100644 index 000000000..c5607c30d --- /dev/null +++ b/ui/src/data-services/models/capture-match.ts @@ -0,0 +1,90 @@ +import { STRING } from 'utils/language' + +/** Where the track frame the scores compare against sits relative to the capture. */ +export type CaptureMatchRelation = 'same' | 'before' | 'after' | 'gap' + +// Best guess from local data: unrelated boxes scored up to about 0.5, continuations above 0.9. +export const MATCH_GRAY_UNTIL = 0.5 +export const MATCH_POSSIBLE_FROM = 0.6 +export const MATCH_LIKELY_FROM = 0.8 + +/** The band a likelihood falls in, shared by the extend view and the merge picker. */ +export const getMatchLevel = (likelihood: number): STRING => + likelihood >= MATCH_LIKELY_FROM + ? STRING.TRACK_MATCH_LIKELY + : likelihood >= MATCH_POSSIBLE_FROM + ? STRING.TRACK_MATCH_POSSIBLE + : STRING.TRACK_MATCH_UNLIKELY + +export interface ServerCaptureMatch { + detection_id: number + occurrence_id: number | null + in_track: boolean + likelihood: number | null + cost: number | null + distance: number | null + iou: number | null + size_ratio: number | null + similarity: number | null + time_offset_seconds: number | null + would_link: boolean + skipped_reason: 'no_vector' | null +} + +export interface ServerCaptureMatches { + capture_id: number + cost_threshold: number + feature_algorithm_id: number | null + requires_features: boolean + reference: { + detection_id: number + capture_id: number + timestamp: string + relation: CaptureMatchRelation + capture_offset?: number | null + skipped_reason: 'no_vector' | null + } | null + detections: ServerCaptureMatch[] +} + +/** How likely one box on a capture is the animal of the track being extended. */ +export interface CaptureMatch { + detectionId: string + inTrack: boolean + /** 0 to 1, 1 the best match; null for the track's own frames or with nothing to compare. */ + likelihood: number | null + /** Centre-to-centre gap as a fraction of the frame diagonal. */ + distance: number | null + /** Null when either box has no feature vector. */ + similarity: number | null + timeOffsetSeconds: number | null + /** The one box the tracker's own matcher would link the reference frame to. */ + wouldLink: boolean + /** Why the tracker would leave the box out, even though it is scored. */ + skippedReason: 'no_vector' | null + /** Captures from the reference frame to this one, signed like the time offset; 1 is adjacent. */ + referenceCaptureOffset: number | null + /** Tracking would skip the whole session: it requires feature vectors and the session has none. */ + skipsSession: boolean +} + +export const convertCaptureMatches = ( + data: ServerCaptureMatches +): Record => + data.detections.reduce((result: Record, row) => { + result[`${row.detection_id}`] = { + detectionId: `${row.detection_id}`, + inTrack: row.in_track, + likelihood: row.likelihood, + distance: row.distance, + similarity: row.similarity, + timeOffsetSeconds: row.time_offset_seconds, + wouldLink: !!row.would_link, + skippedReason: row.skipped_reason ?? null, + referenceCaptureOffset: data.reference?.capture_offset ?? null, + skipsSession: + data.feature_algorithm_id === null && !!data.requires_features, + } + + return result + }, {}) diff --git a/ui/src/data-services/models/capture.ts b/ui/src/data-services/models/capture.ts index 22f786d5f..a226f05be 100644 --- a/ui/src/data-services/models/capture.ts +++ b/ui/src/data-services/models/capture.ts @@ -6,21 +6,31 @@ export type ServerCapture = any // TODO: Update this type export type DetectionOccurrence = { id: string + detections_count: number | null determination: { name: string - } - determination_score: number + } | null + determination_score: number | null + grouping_verified: boolean + grouping_verified_at: string | null + grouping_verified_by: string | null } export type CaptureDetection = { bbox: number[] + /** Frames this detection's occurrence spans, so the toolbar can offer a path or say there is none. */ + frameCount: number + groupingVerified: boolean + groupingVerifiedAt: Date | null + groupingVerifiedBy: string | null id: string label: string occurrence?: DetectionOccurrence occurrenceId?: string occurrenceMeetsCriteria: boolean score: number - scoreLabel: string + /** Undefined when the detection's occurrence has no determination. */ + scoreLabel?: string } const getDetectionLabel = (detection: CaptureDetection) => { @@ -28,6 +38,10 @@ const getDetectionLabel = (detection: CaptureDetection) => { return detection.occurrence.determination.name } + if (detection.occurrence) { + return translate(STRING.UNIDENTIFIED) + } + return detection.id } @@ -44,6 +58,10 @@ const getDetectionScore = (detection: CaptureDetection) => { } const getDetectionScoreLabel = (detection: CaptureDetection) => { + if (detection.occurrence && !detection.occurrence.determination) { + return undefined + } + const score = getDetectionScore(detection) if (score === 1) { @@ -65,6 +83,14 @@ export class Capture { (detection: any): CaptureDetection => { return { bbox: detection.bbox, + frameCount: detection.occurrence?.detections_count ?? 0, + groupingVerified: !!detection.occurrence?.grouping_verified, + groupingVerifiedAt: detection.occurrence?.grouping_verified_at + ? new Date(detection.occurrence.grouping_verified_at) + : null, + // Accounts without a display name serialize as an empty string. + groupingVerifiedBy: + detection.occurrence?.grouping_verified_by || null, id: `${detection.id}`, label: getDetectionLabel(detection), occurrenceId: detection.occurrence diff --git a/ui/src/data-services/models/export.test.ts b/ui/src/data-services/models/export.test.ts new file mode 100644 index 000000000..7f7889df2 --- /dev/null +++ b/ui/src/data-services/models/export.test.ts @@ -0,0 +1,22 @@ +import { getExportTypes } from './export' + +// The kit's entry point pulls in stylesheets, which Jest does not load. +jest.mock('nova-ui-kit', () => ({ + CONSTANTS: jest.requireActual('nova-ui-kit/constants').CONSTANTS, +})) + +describe('getExportTypes', () => { + test('offers the tracks export only when tracking is on', () => { + expect(getExportTypes({ trackingEnabled: false })).not.toContain( + 'tracks_csv' + ) + expect(getExportTypes({ trackingEnabled: true })).toContain('tracks_csv') + }) + + test('always offers the occurrence exports', () => { + expect(getExportTypes({ trackingEnabled: false })).toEqual([ + 'occurrences_simple_csv', + 'occurrences_api_json', + ]) + }) +}) diff --git a/ui/src/data-services/models/export.ts b/ui/src/data-services/models/export.ts index d96ee2e39..baca5395a 100644 --- a/ui/src/data-services/models/export.ts +++ b/ui/src/data-services/models/export.ts @@ -6,10 +6,19 @@ import { JobDetails } from './job-details' export const SERVER_EXPORT_TYPES = [ 'occurrences_simple_csv', 'occurrences_api_json', + 'tracks_csv', ] as const export type ServerExportType = (typeof SERVER_EXPORT_TYPES)[number] +/** The formats a project can export; tracks need the tracking feature. */ +export const getExportTypes = ({ + trackingEnabled, +}: { + trackingEnabled: boolean +}): ServerExportType[] => + SERVER_EXPORT_TYPES.filter((key) => key !== 'tracks_csv' || trackingEnabled) + export type ServerExport = any // TODO: Update this type export class Export extends Entity { @@ -27,6 +36,7 @@ export class Export extends Entity { const label = { occurrences_simple_csv: 'Occurrences (simple CSV)', occurrences_api_json: 'Occurrences (API JSON)', + tracks_csv: 'Tracks (CSV, one row per detection)', }[key] return { diff --git a/ui/src/data-services/models/job.test.ts b/ui/src/data-services/models/job.test.ts new file mode 100644 index 000000000..7bd094292 --- /dev/null +++ b/ui/src/data-services/models/job.test.ts @@ -0,0 +1,19 @@ +import { Job, ServerJobType } from './job' + +// The kit's entry point pulls in stylesheets, which Jest does not load. +jest.mock('nova-ui-kit', () => ({ + CONSTANTS: jest.requireActual('nova-ui-kit/constants').CONSTANTS, +})) + +describe('Job.getJobTypeInfo', () => { + test('labels a post-processing job such as tracking', () => { + expect(Job.getJobTypeInfo('post_processing').label).toBe('Post-processing') + }) + + test("falls back to the server's name for a type it does not know", () => { + const key = 'future_type' as ServerJobType + + expect(Job.getJobTypeInfo(key, 'Future type').label).toBe('Future type') + expect(Job.getJobTypeInfo(key).label).toBe('future_type') + }) +}) diff --git a/ui/src/data-services/models/job.ts b/ui/src/data-services/models/job.ts index 15c59ea53..4dbeb4b3f 100644 --- a/ui/src/data-services/models/job.ts +++ b/ui/src/data-services/models/job.ts @@ -21,6 +21,7 @@ export const SERVER_JOB_TYPES = [ 'data_storage_sync', 'populate_captures_collection', 'data_export', + 'post_processing', 'unknown', ] as const @@ -116,7 +117,7 @@ export class Job extends Entity { key: ServerJobType label: string } { - return Job.getJobTypeInfo(this._job.job_type.key) + return Job.getJobTypeInfo(this._job.job_type.key, this._job.job_type.name) } get deployment(): { id: string; name: string } | undefined { @@ -158,14 +159,19 @@ export class Job extends Entity { ) } - static getJobTypeInfo(key: ServerJobType) { - const label = { - ml: 'ML pipeline', - data_storage_sync: 'Data storage sync', - populate_captures_collection: 'Populate captures collection', - data_export: 'Data export', - unknown: 'Unknown', - }[key] + /** A type this list does not know yet shows the server's name for it. */ + static getJobTypeInfo(key: ServerJobType, serverName?: string) { + const label = + { + ml: 'ML pipeline', + data_storage_sync: 'Data storage sync', + populate_captures_collection: 'Populate captures collection', + data_export: 'Data export', + post_processing: 'Post-processing', + unknown: 'Unknown', + }[key] ?? + serverName ?? + key return { key, diff --git a/ui/src/data-services/models/merge-candidate.test.ts b/ui/src/data-services/models/merge-candidate.test.ts new file mode 100644 index 000000000..5bc1cddac --- /dev/null +++ b/ui/src/data-services/models/merge-candidate.test.ts @@ -0,0 +1,272 @@ +import { + convertMergeCandidate, + getComparisonSides, + getCostLabel, + getDistanceLabel, + getLikelihoodLabel, + getSimilarityLabel, + getWhenLabel, + MergeCandidate, + ServerMergeCandidate, + sortMergeCandidates, +} from './merge-candidate' + +const serverCandidate = ( + overrides: Partial +): ServerMergeCandidate => ({ + id: 7, + determination: { id: 3, name: 'Noctua pronuba' }, + detections_count: 4, + first_appearance_timestamp: '2026-09-09T02:00:00', + last_appearance_timestamp: '2026-09-09T02:03:00', + relation: 'after', + time_offset_seconds: 120, + distance: 0.0123, + similarity: 0.987, + cost: 0.5, + iou: 0.8, + size_ratio: 0.9, + likelihood: 0.875, + would_link: false, + image: 'https://example.com/crop.jpg', + capture_id: 11, + image_timestamp: '2026-09-09T02:03:00', + edge_image: 'https://example.com/edge.jpg', + edge_timestamp: '2026-09-09T02:01:00', + ...overrides, +}) + +describe('merge candidate conversion', () => { + test('names the row after its determination and carries one crop', () => { + const candidate = convertMergeCandidate(serverCandidate({})) + + expect(candidate.id).toBe('7') + expect(candidate.displayName).toBe('Noctua pronuba #7') + expect(candidate.images).toEqual([{ src: 'https://example.com/crop.jpg' }]) + expect(candidate.numDetections).toBe(4) + }) + + test('a candidate without a determination or crop still renders', () => { + const candidate = convertMergeCandidate( + serverCandidate({ determination: null, image: null }) + ) + + expect(candidate.displayName).toBe('#7') + expect(candidate.images).toEqual([]) + }) +}) + +describe('comparison sides', () => { + const candidateCrop = 'https://example.com/crop.jpg' + const edgeCrop = 'https://example.com/edge.jpg' + const srcs = (server: Partial) => { + const sides = getComparisonSides( + convertMergeCandidate(serverCandidate(server)) + ) + return [sides.left.src, sides.right.src, sides.gapLabel] + } + + test('a "before" candidate sits on the left with a negative gap', () => { + expect(srcs({ relation: 'before', time_offset_seconds: -40 })).toEqual([ + candidateCrop, + edgeCrop, + '−40 s', + ]) + }) + + test('an "after" candidate sits on the right with a positive gap', () => { + expect(srcs({ relation: 'after', time_offset_seconds: 120 })).toEqual([ + edgeCrop, + candidateCrop, + '+2 min', + ]) + }) + + test('a gap candidate sits on the side its frame falls, with the time between the frames', () => { + const gap: Partial = { + relation: 'gap', + time_offset_seconds: 0, + edge_timestamp: '2026-09-09T02:01:00', + } + + expect(srcs({ ...gap, image_timestamp: '2026-09-09T02:00:30' })).toEqual([ + candidateCrop, + edgeCrop, + '−30 s', + ]) + expect(srcs({ ...gap, image_timestamp: '2026-09-09T02:01:40' })).toEqual([ + edgeCrop, + candidateCrop, + '+40 s', + ]) + }) + + test('a missing edge crop leaves that side empty rather than failing', () => { + expect(srcs({ relation: 'after', edge_image: null })).toEqual([ + null, + candidateCrop, + '+2 min', + ]) + }) +}) + +describe('merge candidate labels', () => { + test('when reads as a direction in time', () => { + expect(getWhenLabel(-240)).toBe('4 min earlier') + expect(getWhenLabel(20)).toBe('20 s later') + expect(getWhenLabel(3900)).toBe('1 h 5 min later') + expect(getWhenLabel(-4)).toBe('4 s earlier') + }) + + test('distance and similarity are percentages, or n/a without a value', () => { + expect(getDistanceLabel(0.0123)).toBe('1.2%') + expect(getDistanceLabel(null)).toBe('n/a') + expect(getSimilarityLabel(0.987)).toBe('99%') + expect(getSimilarityLabel(null)).toBe('n/a') + }) + + test('the match reads as the band and percentage the extend view uses', () => { + expect(getLikelihoodLabel(0.875)).toBe('Likely (88%)') + expect(getLikelihoodLabel(0.61)).toBe('Possible (61%)') + expect(getLikelihoodLabel(null)).toBe('n/a') + }) + + test('the cost is read against the threshold tracking links under', () => { + expect(getCostLabel(0.1234, 0.2)).toBe('0.12 of 0.20') + expect(getCostLabel(0.1234)).toBe('0.12') + expect(getCostLabel(null, 0.2)).toBe('n/a') + }) + + test('the tracker verdict and terms are carried as given', () => { + const candidate = convertMergeCandidate( + serverCandidate({ would_link: true, iou: null }) + ) + + expect(candidate.wouldLink).toBe(true) + expect(candidate.iou).toBeNull() + expect(candidate.sizeRatio).toBe(0.9) + expect(candidate.likelihood).toBe(0.875) + }) +}) + +describe('merge candidate sorting', () => { + const rows: MergeCandidate[] = [ + serverCandidate({ id: 1, time_offset_seconds: 60, similarity: null }), + serverCandidate({ + id: 2, + relation: 'before', + time_offset_seconds: -30, + distance: 0.5, + similarity: 0.7, + }), + serverCandidate({ + id: 3, + relation: 'gap', + time_offset_seconds: 0, + distance: null, + similarity: 0.9, + // Ten seconds after the track frame it is paired with. + image_timestamp: '2024-06-01T22:00:10', + edge_timestamp: '2024-06-01T22:00:00', + }), + ].map(convertMergeCandidate) + + const ids = (sorted: MergeCandidate[]) => sorted.map((row) => row.id) + + test('no sort keeps the order the server ranked by cost', () => { + expect(ids(sortMergeCandidates(rows))).toEqual(['1', '2', '3']) + }) + + test('when runs earliest first and flips when descending', () => { + expect( + ids(sortMergeCandidates(rows, { column: 'when', descending: false })) + ).toEqual(['2', '3', '1']) + expect( + ids(sortMergeCandidates(rows, { column: 'when', descending: true })) + ).toEqual(['1', '3', '2']) + }) + + test('the cumulative order ranks on time, then distance, then similarity', () => { + // Row 2 is 30 s away, row 3 is 10 s away, row 1 is 60 s away. + expect(ids(sortMergeCandidates(rows, { cumulative: true }))).toEqual([ + '3', + '2', + '1', + ]) + }) + + // All four are the same distance in time, so the later keys decide the order. + test('each cumulative key only breaks the tie the one before it left', () => { + const tied = [ + serverCandidate({ id: 1, distance: 0.2, similarity: 0.6 }), + serverCandidate({ id: 2, distance: 0.2, similarity: 0.9 }), + serverCandidate({ id: 3, distance: 0.3, similarity: 0.99 }), + serverCandidate({ id: 4, distance: null, similarity: 0.99 }), + ].map(convertMergeCandidate) + + expect(ids(sortMergeCandidates(tied, { cumulative: true }))).toEqual([ + '2', + '1', + '3', + '4', + ]) + }) + + test('match sorts best first on the first click and keeps unscored rows last', () => { + const scored = [ + serverCandidate({ id: 1, likelihood: 0.6 }), + serverCandidate({ id: 2, likelihood: null }), + serverCandidate({ id: 3, likelihood: 0.95 }), + ].map(convertMergeCandidate) + + expect( + ids(sortMergeCandidates(scored, { column: 'match', descending: true })) + ).toEqual(['3', '1', '2']) + expect( + ids(sortMergeCandidates(scored, { column: 'match', descending: false })) + ).toEqual(['1', '3', '2']) + }) + + test('species sorts by name alone, keeps the server order on ties and undetermined rows last', () => { + const named = [ + serverCandidate({ id: 1, determination: { id: 1, name: 'Xestia' } }), + serverCandidate({ id: 2, determination: null }), + serverCandidate({ id: 3, determination: { id: 2, name: 'agrotis' } }), + serverCandidate({ id: 4, determination: { id: 1, name: 'Xestia' } }), + ].map(convertMergeCandidate) + + expect( + ids(sortMergeCandidates(named, { column: 'species', descending: false })) + ).toEqual(['3', '1', '4', '2']) + expect( + ids(sortMergeCandidates(named, { column: 'species', descending: true })) + ).toEqual(['1', '4', '3', '2']) + }) + + test('frames sorts by count as a number, not as text', () => { + const counted = [ + serverCandidate({ id: 1, detections_count: 9 }), + serverCandidate({ id: 2, detections_count: 10 }), + serverCandidate({ id: 3, detections_count: 2 }), + ].map(convertMergeCandidate) + + expect( + ids(sortMergeCandidates(counted, { column: 'frames', descending: true })) + ).toEqual(['2', '1', '3']) + expect( + ids(sortMergeCandidates(counted, { column: 'frames', descending: false })) + ).toEqual(['3', '1', '2']) + }) + + test('rows without a value go last in either direction', () => { + expect( + ids(sortMergeCandidates(rows, { column: 'distance', descending: false })) + ).toEqual(['1', '2', '3']) + expect( + ids(sortMergeCandidates(rows, { column: 'distance', descending: true })) + ).toEqual(['2', '1', '3']) + expect( + ids(sortMergeCandidates(rows, { column: 'similarity', descending: true })) + ).toEqual(['3', '2', '1']) + }) +}) diff --git a/ui/src/data-services/models/merge-candidate.ts b/ui/src/data-services/models/merge-candidate.ts new file mode 100644 index 000000000..59f99b162 --- /dev/null +++ b/ui/src/data-services/models/merge-candidate.ts @@ -0,0 +1,309 @@ +import { STRING, translate } from 'utils/language' +import { getMatchLevel } from './capture-match' + +export type MergeRelation = 'before' | 'after' | 'gap' + +export interface ServerMergeCandidate { + id: number + determination: { id: number; name: string } | null + detections_count: number + first_appearance_timestamp: string | null + last_appearance_timestamp: string | null + relation: MergeRelation + time_offset_seconds: number + distance: number | null + similarity: number | null + cost: number | null + iou: number | null + size_ratio: number | null + likelihood: number | null + would_link: boolean + image: string | null + capture_id: number | null + image_timestamp: string | null + edge_image: string | null + edge_timestamp: string | null +} + +/** The candidates plus the tracker settings they were judged by. */ +export interface ServerMergeCandidates { + candidates: ServerMergeCandidate[] + cost_threshold: number + requires_features: boolean +} + +/** An occurrence that could be merged with another, scored against it by the tracking method. */ +export interface MergeCandidate { + id: string + displayName: string + /** The determination's name alone, without the id the display name carries; null when undetermined. */ + speciesName: string | null + images: { src: string }[] + numDetections: number + relation: MergeRelation + /** Negative when the candidate ends first, positive when it starts later, 0 in a gap of the track. */ + timeOffsetSeconds: number + /** Centre-to-centre gap of the two nearest frames as a fraction of the frame; null without a box. */ + distance: number | null + /** Cosine similarity of the two nearest frames; null when either has no feature vector. */ + similarity: number | null + /** The tracking method's matching cost for the nearest pair; lower fits better. */ + cost: number | null + /** Overlap of the two nearest boxes, intersection over union. */ + iou: number | null + /** Area of the smaller box over the larger. */ + sizeRatio: number | null + /** 0 to 1, 1 the best match: one minus the mean term of the cost. */ + likelihood: number | null + /** The pair passes the tracker's own rule: under its threshold, with vectors where it requires them. */ + wouldLink: boolean + /** The capture holding the candidate's nearest frame, the one its crop is cut from. */ + captureId: string | null + imageTimestamp: Date | null + /** Crop of this occurrence's own frame in the scored pair: its first frame for a "before" candidate, its last for an "after" one. */ + edgeImage: string | null + edgeTimestamp: Date | null +} + +const toDate = (timestamp: string | null | undefined): Date | null => + timestamp ? new Date(timestamp) : null + +export const convertMergeCandidate = ( + candidate: ServerMergeCandidate +): MergeCandidate => ({ + id: `${candidate.id}`, + displayName: candidate.determination + ? `${candidate.determination.name} #${candidate.id}` + : `#${candidate.id}`, + speciesName: candidate.determination?.name ?? null, + images: candidate.image ? [{ src: candidate.image }] : [], + numDetections: candidate.detections_count, + relation: candidate.relation, + timeOffsetSeconds: candidate.time_offset_seconds, + distance: candidate.distance, + similarity: candidate.similarity, + cost: candidate.cost, + iou: candidate.iou ?? null, + sizeRatio: candidate.size_ratio ?? null, + likelihood: candidate.likelihood ?? null, + wouldLink: !!candidate.would_link, + captureId: candidate.capture_id != null ? `${candidate.capture_id}` : null, + imageTimestamp: toDate(candidate.image_timestamp), + edgeImage: candidate.edge_image ?? null, + edgeTimestamp: toDate(candidate.edge_timestamp), +}) + +export const getOffsetLabel = (seconds: number): string => { + const total = Math.round(Math.abs(seconds)) + + if (total < 60) { + return translate(STRING.TRACK_OFFSET_SECONDS, { count: total }) + } + + const minutes = Math.round(total / 60) + + if (minutes < 60) { + return translate(STRING.TRACK_OFFSET_MINUTES, { count: minutes }) + } + + return translate(STRING.TRACK_OFFSET_HOURS, { + hours: Math.floor(minutes / 60), + minutes: minutes % 60, + }) +} + +export const getWhenLabel = (timeOffsetSeconds: number): string => + translate( + timeOffsetSeconds < 0 ? STRING.TRACK_WHEN_EARLIER : STRING.TRACK_WHEN_LATER, + { time: getOffsetLabel(timeOffsetSeconds) } + ) + +export interface ComparisonSide { + src: string | null + timestamp: Date | null +} + +/** The two crops of a scored pair in time order, with the gap between them as a signed label. */ +export interface ComparisonSides { + left: ComparisonSide + right: ComparisonSide + gapLabel: string +} + +/** + * Seconds from the track frame a candidate is scored against to the candidate's own + * frame: negative when the candidate is earlier. A gap candidate is measured from the + * nearest track frame, which can lie on either side of it. + */ +export const getWhenOffsetSeconds = ( + candidate: Pick< + MergeCandidate, + 'relation' | 'timeOffsetSeconds' | 'imageTimestamp' | 'edgeTimestamp' + > +): number => + candidate.relation === 'gap' && + candidate.imageTimestamp && + candidate.edgeTimestamp + ? (candidate.imageTimestamp.getTime() - candidate.edgeTimestamp.getTime()) / + 1000 + : candidate.timeOffsetSeconds + +export const getComparisonSides = ( + candidate: MergeCandidate +): ComparisonSides => { + const candidateSide: ComparisonSide = { + src: candidate.images[0]?.src ?? null, + timestamp: candidate.imageTimestamp, + } + const edgeSide: ComparisonSide = { + src: candidate.edgeImage, + timestamp: candidate.edgeTimestamp, + } + + const offsetSeconds = getWhenOffsetSeconds(candidate) + const time = getOffsetLabel(offsetSeconds) + + return offsetSeconds < 0 + ? { + left: candidateSide, + right: edgeSide, + gapLabel: translate(STRING.TRACK_GAP_BEFORE, { time }), + } + : { + left: edgeSide, + right: candidateSide, + gapLabel: translate(STRING.TRACK_GAP_AFTER, { time }), + } +} + +export const getDistanceLabel = (distance: number | null): string => + distance === null + ? translate(STRING.VALUE_NOT_AVAILABLE) + : translate(STRING.TRACK_PERCENT, { percent: (distance * 100).toFixed(1) }) + +export const getSimilarityLabel = (similarity: number | null): string => + similarity === null + ? translate(STRING.VALUE_NOT_AVAILABLE) + : translate(STRING.TRACK_PERCENT, { + percent: `${Math.round(similarity * 100)}`, + }) + +/** Overlap and size ratio read as whole percentages, like similarity. */ +export const getRatioLabel = getSimilarityLabel + +/** The match band and percentage the extend view shows for the same score. */ +export const getLikelihoodLabel = (likelihood: number | null): string => + likelihood === null + ? translate(STRING.VALUE_NOT_AVAILABLE) + : translate(STRING.TRACK_MATCH_SCORE, { + level: translate(getMatchLevel(likelihood)), + percent: `${Math.round(likelihood * 100)}`, + }) + +/** The cost against the threshold tracking links under, so a reader sees how close the pair came. */ +export const getCostLabel = (cost: number | null, threshold?: number): string => + cost === null + ? translate(STRING.VALUE_NOT_AVAILABLE) + : threshold === undefined + ? cost.toFixed(2) + : translate(STRING.TRACK_COST_OF_THRESHOLD, { + cost: cost.toFixed(2), + threshold: threshold.toFixed(2), + }) + +export type MergeCandidateSortColumn = + | 'species' + | 'frames' + | 'when' + | 'distance' + | 'similarity' + | 'cost' + | 'match' + +/** One column the reviewer clicked, or all three applied in turn. */ +export type MergeCandidateSort = + | { column: MergeCandidateSortColumn; descending: boolean } + | { cumulative: true } + +const sortValue = ( + candidate: MergeCandidate, + column: MergeCandidateSortColumn +): number | string | null => + column === 'species' + ? candidate.speciesName + : column === 'frames' + ? candidate.numDetections + : column === 'when' + ? getWhenOffsetSeconds(candidate) + : column === 'distance' + ? candidate.distance + : column === 'similarity' + ? candidate.similarity + : column === 'cost' + ? candidate.cost + : candidate.likelihood + +const compare = ( + valueA: number | string | null, + valueB: number | string | null, + descending: boolean +): number => { + if (valueA === null || valueB === null) { + return valueA === valueB ? 0 : valueA === null ? 1 : -1 + } + + const result = + typeof valueA === 'string' || typeof valueB === 'string' + ? String(valueA).localeCompare(String(valueB), undefined, { + sensitivity: 'base', + }) + : valueA - valueB + + return result * (descending ? -1 : 1) +} + +// Nearest in time first, then closest, then most alike. Time is unsigned here: +// a candidate half a minute either side of the track is equally near it. +const CUMULATIVE_KEYS: { + descending: boolean + value: (candidate: MergeCandidate) => number | null +}[] = [ + { descending: false, value: (c) => Math.abs(getWhenOffsetSeconds(c)) }, + { descending: false, value: (c) => c.distance }, + { descending: true, value: (c) => c.similarity }, +] + +/** + * Reorder loaded candidates, keeping the server's cost order when no sort is chosen + * and for rows a sort cannot separate. Rows without a value go last on every key. + */ +export const sortMergeCandidates = ( + candidates: MergeCandidate[], + sort?: MergeCandidateSort +): MergeCandidate[] => { + if (!sort) { + return candidates + } + + if ('cumulative' in sort) { + return [...candidates].sort((a, b) => { + for (const key of CUMULATIVE_KEYS) { + const result = compare(key.value(a), key.value(b), key.descending) + + if (result !== 0) { + return result + } + } + + return 0 + }) + } + + return [...candidates].sort((a, b) => + compare( + sortValue(a, sort.column), + sortValue(b, sort.column), + sort.descending + ) + ) +} diff --git a/ui/src/data-services/models/occurrence-details.test.ts b/ui/src/data-services/models/occurrence-details.test.ts new file mode 100644 index 000000000..4e6dc9cc2 --- /dev/null +++ b/ui/src/data-services/models/occurrence-details.test.ts @@ -0,0 +1,179 @@ +import { + frameHasVector, + getFrameClassification, + OccurrenceDetails, + ServerFrameClassification, +} from './occurrence-details' + +const serverTaxon = (id: string, name: string, rank = 'SPECIES') => ({ + cover_image_url: null, + id, + name, + rank, +}) + +const MOTH = serverTaxon('11532', 'Moth', 'SUPERFAMILY') +const NOCTUA = serverTaxon('3', 'Noctua pronuba') +const XESTIA = serverTaxon('4', 'Xestia c-nigrum') + +const classification = ( + overrides: Partial = {} +): ServerFrameClassification => ({ + created_at: '2026-04-29T18:00:00', + score: 0.5, + taxon: NOCTUA, + terminal: true, + ...overrides, +}) + +describe('frame classification', () => { + test('a species-level terminal label wins over a higher-scoring moth filter', () => { + const picked = getFrameClassification([ + classification({ score: 0.95, taxon: MOTH, terminal: false }), + classification({ score: 0.12 }), + classification({ score: 0.4, taxon: XESTIA }), + ]) + + expect(picked?.taxon?.name).toBe('Xestia c-nigrum') + }) + + test('a score tie goes to the most recent classification', () => { + const older = classification({ created_at: '2026-04-29T18:00:00' }) + const newer = classification({ + created_at: '2026-04-29T18:05:00', + taxon: XESTIA, + }) + + expect(getFrameClassification([older, newer])).toBe(newer) + expect(getFrameClassification([newer, older])).toBe(newer) + }) + + test('with no terminal classification, the intermediate one names the frame', () => { + const picked = getFrameClassification([ + classification({ score: 0.1, taxon: null }), + classification({ score: 0.8, taxon: MOTH, terminal: false }), + ]) + + expect(picked?.taxon?.name).toBe('Moth') + }) + + test('a frame with no classifications has no label', () => { + expect(getFrameClassification([])).toBeUndefined() + expect(getFrameClassification(undefined)).toBeUndefined() + }) +}) + +describe('frame feature vectors', () => { + test('one classification with a vector makes the frame comparable', () => { + expect( + frameHasVector([ + classification({ has_features: false }), + classification({ has_features: true }), + ]) + ).toBe(true) + }) + + test('a frame nothing stored a vector for is marked, even with no classifications', () => { + expect(frameHasVector([classification({ has_features: false })])).toBe( + false + ) + expect(frameHasVector([])).toBe(false) + }) + + test('an unset flag stays unknown, so a payload without it marks nothing', () => { + expect( + frameHasVector([classification({ has_features: null })]) + ).toBeUndefined() + expect(frameHasVector([classification()])).toBeUndefined() + }) +}) + +describe('occurrence details', () => { + const detection = (id: number, classifications: unknown[]) => ({ + bbox: [0, 0, 10, 20], + capture: { height: 100, id: id + 100, width: 100 }, + classifications, + frame_index: id - 1, + height: null, + id, + timestamp: `2026-09-09T02:0${id}:00`, + url: `https://example.com/${id}.jpg`, + width: null, + }) + + const occurrence = new OccurrenceDetails({ + created_at: '2026-09-09T02:00:00', + detection_images: [], + detections: [ + detection(1, [ + classification({ score: 0.95, taxon: MOTH, terminal: false }), + classification({ has_features: true, score: 0.12 }), + ]), + detection(2, []), + ], + detections_count: 40, + determination: { id: 3, name: 'Noctua pronuba' }, + determination_details: { taxon: NOCTUA }, + first_appearance_timestamp: '2026-09-09T02:00:00', + first_detection: { + capture_id: 101, + frame_index: 0, + id: 1, + timestamp: '2026-09-09T02:01:00', + }, + grouping_summary: { + frame_names: [ + { + frames: 30, + score_max: 0.4, + taxon: { id: 3, name: 'Noctua pronuba', rank: 'SPECIES' }, + }, + { frames: 10, score_max: null, taxon: null }, + ], + }, + id: 12, + identifications: [], + last_detection: { + capture_id: 140, + frame_index: 39, + id: 40, + timestamp: '2026-09-09T02:40:00', + }, + predictions: [], + track_stats: null, + user_permissions: [], + }) + + test('each frame of the first page carries its own label and position', () => { + const [first, second] = occurrence.firstFramesPage + + expect(first).toMatchObject({ + captureId: '101', + frameIndex: 0, + frameLabel: { score: 0.12, taxon: { name: 'Noctua pronuba' } }, + hasVector: true, + }) + expect(second.label).toBe('No classification') + expect(second.hasVector).toBe(false) + // A missing crop keeps the bounding box's proportions. + expect(second.image).toEqual({ + height: 20, + src: 'https://example.com/2.jpg', + width: 10, + }) + }) + + test('the track ends and label counts cover every frame, not just the first page', () => { + expect(occurrence.lastFrame).toMatchObject({ + captureId: '140', + frameIndex: 39, + id: '40', + }) + expect( + occurrence.frameNames.map(({ frames, taxon }) => [taxon?.name, frames]) + ).toEqual([ + ['Noctua pronuba', 30], + [undefined, 10], + ]) + }) +}) diff --git a/ui/src/data-services/models/occurrence-details.ts b/ui/src/data-services/models/occurrence-details.ts index 39bcbfb39..f833ac2ab 100644 --- a/ui/src/data-services/models/occurrence-details.ts +++ b/ui/src/data-services/models/occurrence-details.ts @@ -3,15 +3,52 @@ import { STRING, translate } from 'utils/language' import { UserPermission } from 'utils/user/types' import { Algorithm } from './algorithm' import { Occurrence, ServerOccurrence } from './occurrence' -import { Taxon } from './taxa' +import { ServerTaxon, Taxon } from './taxa' +import { TrackStats } from './track-stats' export type ServerOccurrenceDetails = ServerOccurrence & any // TODO: Update this type +export interface ServerFrameName { + frames: number + score_max: number | null + taxon: { id: number; name: string; rank: string } | null +} + +export interface ServerGroupingSummary { + algorithm: { id: number; key: string; name: string } | null + derived: boolean + distinct_taxa: number + duration_seconds: number | null + frame_names?: ServerFrameName[] + frames: number + frames_with_vectors?: number + id_agreement: number | null + linked_detections: number + motion: number + score_max: number | null + score_mean: number | null + score_min: number | null + size_ratio: number +} + +/** Track stats recomputed for the detail view, never stored server-side. */ +export interface GroupingSummary extends TrackStats { + algorithm?: { id: string; key: string; name: string } + durationSeconds: number | null + /** Frames with a classification that stored a feature embedding. */ + framesWithVectors?: number + linkedDetections: number + scoreMax: number | null + scoreMean: number | null + scoreMin: number | null +} + export interface Identification { applied?: boolean id: string overridden?: boolean - taxon: Taxon + /** Absent on a comment-only identification. */ + taxon?: Taxon comment?: string algorithm?: Algorithm score?: number @@ -31,19 +68,197 @@ export interface HumanIdentification extends Identification { export interface MachinePrediction extends Identification { algorithm: Algorithm + /** Whether a feature embedding was stored; null when the API did not say. */ + hasFeatures?: boolean | null score: number + taxon: Taxon terminal: boolean } +export interface TrackFrame { + bbox: number[] + /** Stored dimensions of this frame's own capture, which its bbox is measured in. */ + captureHeight?: number + captureId?: string + captureWidth?: number + cropUrl?: string + id: string + timestamp: Date + timeLabel: string +} + +export interface ServerOccurrenceFrame { + bbox: number[] | null + capture: { id: number; height: number | null; width: number | null } | null + classifications: ServerFrameClassification[] | null + frame_index: number + height: number | null + id: number + timestamp: string | null + url: string | null + width: number | null +} + +/** One detection of an occurrence, as a frame of the track strip. */ +export interface OccurrenceFrame { + captureId?: string + frameIndex: number + frameLabel: FrameLabel + hasVector?: boolean + id: string + image: { src: string; width: number; height: number } + label: string + timeLabel: string +} + +export interface ServerFrameSummary { + capture_id: number + frame_index: number + id: number + timestamp: string | null +} + +/** The first or last frame of a track, carried by the detail so no page is needed to reach it. */ +export interface FrameSummary { + captureId: string + frameIndex: number + id: string + timeLabel?: string +} + +export interface ServerFrameClassification { + created_at?: string + /** Null when the endpoint did not annotate the flag, which is not the same as no vector. */ + has_features?: boolean | null + score?: number | null + taxon?: ServerTaxon | null + terminal?: boolean | null +} + +/** The machine's own label for one frame; it stays with the detection through a merge. */ +export interface FrameLabel { + score?: number + taxon?: Taxon +} + +/** One distinct frame label in a track. No taxon means the frames have no classification. */ +export interface FrameName { + frames: number + scoreMax?: number + taxon?: Taxon +} + +const createdAtTime = (classification: ServerFrameClassification) => + classification.created_at ? new Date(classification.created_at).getTime() : 0 + +/** Terminal classifications outrank intermediate ones such as a moth filter; then score, then recency. */ +export const getFrameClassification = ( + classifications: T[] | null | undefined +): T | undefined => { + const named = (classifications ?? []).filter((c) => !!c.taxon) + const terminal = named.filter((c) => c.terminal === true) + + return (terminal.length ? terminal : named).reduce( + (best, c) => { + if (!best) { + return c + } + const score = c.score ?? -1 + const bestScore = best.score ?? -1 + if (score !== bestScore) { + return score > bestScore ? c : best + } + return createdAtTime(c) > createdAtTime(best) ? c : best + }, + undefined + ) +} + +/** + * Whether tracking can compare this frame: true if any classification stored a vector, + * undefined when every classification left the flag unset, false otherwise. + */ +export const frameHasVector = ( + classifications: ServerFrameClassification[] | null | undefined +): boolean | undefined => { + const flags = (classifications ?? []).map((c) => c.has_features) + + if (flags.some((flag) => flag === true)) { + return true + } + + return flags.length && flags.every((flag) => flag == null) ? undefined : false +} + +const timeLabelOf = (timestamp?: string | null) => + timestamp + ? getFormatedTimeString({ + date: new Date(timestamp), + options: { second: true }, + }) + : undefined + +/** Width and height of a `[x1, y1, x2, y2]` box, 0 when the box is malformed. */ +const bboxSize = (bbox?: number[]): [number, number] => + bbox?.length === 4 + ? [Math.max(bbox[2] - bbox[0], 0), Math.max(bbox[3] - bbox[1], 0)] + : [0, 0] + +export const convertOccurrenceFrame = ( + frame: ServerOccurrenceFrame +): OccurrenceFrame => { + const classification = getFrameClassification(frame.classifications) + const frameLabel: FrameLabel = classification?.taxon + ? { + score: classification.score ?? undefined, + taxon: new Taxon(classification.taxon), + } + : {} + + return { + captureId: frame.capture ? `${frame.capture.id}` : undefined, + frameIndex: frame.frame_index, + frameLabel, + hasVector: frameHasVector(frame.classifications), + id: `${frame.id}`, + // The bounding box gives the crop's proportions when the crop itself is missing. + image: { + src: frame.url ?? '', + width: frame.width ?? bboxSize(frame.bbox ?? undefined)[0], + height: frame.height ?? bboxSize(frame.bbox ?? undefined)[1], + }, + label: frameLabel.taxon + ? `${frameLabel.taxon.name} (${ + frameLabel.score?.toFixed(2) ?? translate(STRING.VALUE_NOT_AVAILABLE) + })` + : translate(STRING.TRACK_FRAME_NO_CLASSIFICATION), + timeLabel: timeLabelOf(frame.timestamp) ?? '', + } +} + +const convertFrameSummary = ( + summary?: ServerFrameSummary | null +): FrameSummary | undefined => + summary + ? { + captureId: `${summary.capture_id}`, + frameIndex: summary.frame_index, + id: `${summary.id}`, + timeLabel: timeLabelOf(summary.timestamp), + } + : undefined + export class OccurrenceDetails extends Occurrence { - private readonly _detections: string[] = [] + private readonly _firstPage: OccurrenceFrame[] private readonly _humanIdentifications: HumanIdentification[] private readonly _machinePredictions: MachinePrediction[] public constructor(occurrence: ServerOccurrenceDetails) { super(occurrence) - this._detections = this._occurrence.detections.map((d: any) => `${d.id}`) + this._firstPage = (this._occurrence.detections ?? []).map( + convertOccurrenceFrame + ) const sortByDate = (i1: any, i2: any) => { const date1 = new Date(i1.created_at) @@ -55,9 +270,9 @@ export class OccurrenceDetails extends Occurrence { this._humanIdentifications = this._occurrence.identifications .sort(sortByDate) .map((i: any) => { - const taxon = new Taxon(i.taxon) + const taxon = i.taxon ? new Taxon(i.taxon) : undefined const overridden = i.withdrawn - const applied = taxon.id === this.determinationTaxon.id + const applied = !!taxon && taxon.id === this.determinationTaxon?.id const identification: HumanIdentification = { id: `${i.id}`, @@ -85,14 +300,15 @@ export class OccurrenceDetails extends Occurrence { .sort(sortByDate) .map((p: any) => { const taxon = new Taxon(p.taxon) - const overridden = taxon.id !== this.determinationTaxon.id - const applied = taxon.id === this.determinationTaxon.id + const overridden = taxon.id !== this.determinationTaxon?.id + const applied = taxon.id === this.determinationTaxon?.id const prediction: MachinePrediction = { id: `${p.id}`, applied, overridden, taxon, + hasFeatures: p.has_features, score: p.score, terminal: p.terminal, algorithm: p.algorithm, @@ -108,52 +324,103 @@ export class OccurrenceDetails extends Occurrence { return this._occurrence.details } - get detections(): string[] { - return this._detections + /** The first page of frames, earliest first; the frames endpoint serves the rest. */ + get firstFramesPage(): OccurrenceFrame[] { + return this._firstPage } - get humanIdentifications(): HumanIdentification[] { - return this._humanIdentifications + get firstFrame(): FrameSummary | undefined { + return convertFrameSummary(this._occurrence.first_detection) } - get machinePredictions(): MachinePrediction[] { - return this._machinePredictions + get lastFrame(): FrameSummary | undefined { + return convertFrameSummary(this._occurrence.last_detection) } - get rawData(): string { - return JSON.stringify(this._occurrence, null, 4) + /** Distinct labels across every frame, most frames first. */ + get frameNames(): FrameName[] { + const names: ServerFrameName[] = + this._occurrence.grouping_summary?.frame_names ?? [] + + return names.map((name) => ({ + frames: name.frames, + scoreMax: name.score_max ?? undefined, + taxon: name.taxon + ? new Taxon({ + cover_image_url: null, + id: `${name.taxon.id}`, + name: name.taxon.name, + rank: name.taxon.rank, + }) + : undefined, + })) } - getDetectionInfo(id: string) { - const detection = this._occurrence.detections.find( - (d: any) => `${d.id}` === id - ) + get groupingVerified(): boolean { + return !!this._occurrence.grouping_verified + } - const classification = detection?.classifications?.[0] - let label = 'No classification' + get groupingVerifiedAt(): Date | undefined { + return this._occurrence.grouping_verified_at + ? new Date(this._occurrence.grouping_verified_at) + : undefined + } - if (classification) { - label = `${classification.taxon.name} (${classification.score.toFixed( - 2 - )})` + get groupingVerifiedBy(): + | { id: string; image?: string; name: string } + | undefined { + const user = this._occurrence.grouping_verified_by + + if (!user) { + return undefined } return { - id, - captureId: - detection.capture?.id !== undefined - ? `${detection.capture.id}` - : undefined, - image: { - src: detection.url, - width: detection.width, - height: detection.height, - }, - label: label, - timeLabel: getFormatedTimeString({ - date: new Date(detection.timestamp), - options: { second: true }, - }), + id: `${user.id}`, + image: user.image ?? undefined, + name: user.name?.length ? user.name : translate(STRING.ANONYMOUS_USER), } } + + get groupingSummary(): GroupingSummary | undefined { + const summary: ServerGroupingSummary | null | undefined = + this._occurrence.grouping_summary + + if (!summary) { + return undefined + } + + return { + algorithm: summary.algorithm + ? { + id: `${summary.algorithm.id}`, + key: summary.algorithm.key, + name: summary.algorithm.name, + } + : undefined, + distinctTaxa: summary.distinct_taxa, + durationSeconds: summary.duration_seconds, + frames: summary.frames, + framesWithVectors: summary.frames_with_vectors, + idAgreement: summary.id_agreement, + linkedDetections: summary.linked_detections, + motion: summary.motion, + scoreMax: summary.score_max, + scoreMean: summary.score_mean, + scoreMin: summary.score_min, + sizeRatio: summary.size_ratio, + } + } + + get humanIdentifications(): HumanIdentification[] { + return this._humanIdentifications + } + + get machinePredictions(): MachinePrediction[] { + return this._machinePredictions + } + + get rawData(): string { + return JSON.stringify(this._occurrence, null, 4) + } } diff --git a/ui/src/data-services/models/occurrence-frames-page.test.ts b/ui/src/data-services/models/occurrence-frames-page.test.ts new file mode 100644 index 000000000..2716b7607 --- /dev/null +++ b/ui/src/data-services/models/occurrence-frames-page.test.ts @@ -0,0 +1,45 @@ +import { OccurrenceFrame } from './occurrence-details' +import { + getFramePageQuery, + getFrameRange, + getLinkedFramePage, + isFirstFramePage, +} from './occurrence-frames-page' + +const frame = (frameIndex: number) => + ({ frameIndex, id: `${frameIndex + 1}` } as OccurrenceFrame) + +describe('frame pages', () => { + test('a neighbouring page is read off the link, and a link without an offset is the first page', () => { + expect( + getLinkedFramePage( + 'http://localhost/api/v2/occurrences/1/detections/?limit=20&offset=40' + ) + ).toEqual({ offset: 40 }) + expect( + getLinkedFramePage( + 'http://localhost/api/v2/occurrences/1/detections/?limit=20' + ) + ).toEqual({ offset: 0 }) + expect(getLinkedFramePage(null)).toBeUndefined() + }) + + test('only offset 0 is the page the detail already carries', () => { + expect(isFirstFramePage({ offset: 0 })).toBe(true) + expect(isFirstFramePage({ offset: 20 })).toBe(false) + expect(isFirstFramePage({ around: '7' })).toBe(false) + }) + + test('a page is requested by offset or by the detection it must hold', () => { + expect(getFramePageQuery({ offset: 20 })).toBe('offset=20') + expect(getFramePageQuery({ around: '7' })).toBe('around=7') + }) + + test('the range counts frames from 1 across the whole track', () => { + expect(getFrameRange([frame(20), frame(21), frame(39)])).toEqual({ + end: 40, + start: 21, + }) + expect(getFrameRange([])).toBeUndefined() + }) +}) diff --git a/ui/src/data-services/models/occurrence-frames-page.ts b/ui/src/data-services/models/occurrence-frames-page.ts new file mode 100644 index 000000000..81576a2a5 --- /dev/null +++ b/ui/src/data-services/models/occurrence-frames-page.ts @@ -0,0 +1,43 @@ +import { OccurrenceFrame, ServerOccurrenceFrame } from './occurrence-details' + +export interface ServerOccurrenceFramesPage { + count: number + next: string | null + previous: string | null + results: ServerOccurrenceFrame[] +} + +/** A page by position, or the page holding one detection. */ +export type FramePageRequest = { offset: number } | { around: string } + +export const FIRST_FRAME_PAGE: FramePageRequest = { offset: 0 } + +export const isFirstFramePage = (request: FramePageRequest) => + 'offset' in request && request.offset === 0 + +export const getFramePageQuery = (request: FramePageRequest) => + 'around' in request + ? `around=${encodeURIComponent(request.around)}` + : `offset=${request.offset}` + +/** The page a `next` or `previous` link points at; offset 0 when the link omits it. */ +export const getLinkedFramePage = ( + link: string | null +): FramePageRequest | undefined => { + if (!link) { + return undefined + } + + const offset = Number(new URL(link).searchParams.get('offset') ?? 0) + + return { offset: Number.isInteger(offset) && offset > 0 ? offset : 0 } +} + +/** 1-based positions of a page's first and last frames, for "frames i–j of N". */ +export const getFrameRange = (frames: OccurrenceFrame[]) => + frames.length + ? { + start: frames[0].frameIndex + 1, + end: frames[frames.length - 1].frameIndex + 1, + } + : undefined diff --git a/ui/src/data-services/models/occurrence-path.test.ts b/ui/src/data-services/models/occurrence-path.test.ts new file mode 100644 index 000000000..1e126c93d --- /dev/null +++ b/ui/src/data-services/models/occurrence-path.test.ts @@ -0,0 +1,37 @@ +import { getTrackFrames, PathFrame } from './occurrence-path' + +const pathFrame = (id: string, timestamp: string | null): PathFrame => ({ + bbox: [0, 0, 10, 10], + captureHeight: 100, + captureId: `c${id}`, + captureWidth: null, + cropUrl: `https://example.com/${id}.jpg`, + detectionId: id, + timestamp: timestamp ? new Date(timestamp) : null, +}) + +describe('track frames from a path', () => { + test('run newest first and keep the crop and capture of each frame', () => { + const frames = getTrackFrames([ + pathFrame('1', '2026-09-09T02:01:00'), + pathFrame('2', '2026-09-09T02:03:00'), + pathFrame('3', '2026-09-09T02:02:00'), + ]) + + expect(frames.map((frame) => frame.id)).toEqual(['2', '3', '1']) + expect(frames[0]).toMatchObject({ + captureId: 'c2', + captureWidth: undefined, + cropUrl: 'https://example.com/2.jpg', + }) + }) + + test('an undated frame sorts as the oldest', () => { + const frames = getTrackFrames([ + pathFrame('1', null), + pathFrame('2', '2026-09-09T02:01:00'), + ]) + + expect(frames.map((frame) => frame.id)).toEqual(['2', '1']) + }) +}) diff --git a/ui/src/data-services/models/occurrence-path.ts b/ui/src/data-services/models/occurrence-path.ts new file mode 100644 index 000000000..185ea7a4e --- /dev/null +++ b/ui/src/data-services/models/occurrence-path.ts @@ -0,0 +1,64 @@ +import { getFormatedTimeString } from 'utils/date/getFormatedTimeString/getFormatedTimeString' +import { TrackFrame } from './occurrence-details' + +export interface ServerOccurrencePathFrame { + detection_id: number + bbox: number[] | null + crop_url: string | null + capture: { + id: number + timestamp: string | null + width: number | null + height: number | null + } +} + +/** + * One frame of an occurrence's path. The capture's dimensions travel with the box + * because the box is measured in that capture's pixel space, which need not match + * the capture being drawn on. + */ +export interface PathFrame { + bbox: number[] + captureHeight: number | null + captureId: string + captureWidth: number | null + /** The detection's crop, absent until one has been generated. */ + cropUrl?: string + detectionId: string + timestamp: Date | null +} + +export const convertPathFrame = ( + frame: ServerOccurrencePathFrame +): PathFrame => ({ + bbox: frame.bbox ?? [], + captureHeight: frame.capture.height, + captureId: `${frame.capture.id}`, + captureWidth: frame.capture.width, + cropUrl: frame.crop_url ?? undefined, + detectionId: `${frame.detection_id}`, + timestamp: frame.capture.timestamp ? new Date(frame.capture.timestamp) : null, +}) + +/** A path as the track navigation reads it: newest first, undated frames at the epoch. */ +export const getTrackFrames = (path: PathFrame[]): TrackFrame[] => + path + .map((frame) => { + const timestamp = frame.timestamp ?? new Date(0) + + return { + bbox: frame.bbox, + captureHeight: frame.captureHeight ?? undefined, + captureId: frame.captureId, + captureWidth: frame.captureWidth ?? undefined, + cropUrl: frame.cropUrl, + id: frame.detectionId, + timestamp, + timeLabel: getFormatedTimeString({ + date: timestamp, + options: { second: true }, + }), + } + }) + .sort((f1, f2) => f2.timestamp.getTime() - f1.timestamp.getTime()) diff --git a/ui/src/data-services/models/occurrence.test.ts b/ui/src/data-services/models/occurrence.test.ts new file mode 100644 index 000000000..f14a09195 --- /dev/null +++ b/ui/src/data-services/models/occurrence.test.ts @@ -0,0 +1,66 @@ +import { Occurrence, ServerOccurrence } from './occurrence' + +const serverOccurrence = ( + overrides: Record = {} +): ServerOccurrence => ({ + id: 12, + created_at: '2026-09-09T02:00:00', + detection_images: ['https://example.com/crop.jpg'], + detections_count: 1, + determination: { id: 3, name: 'Noctua pronuba' }, + determination_details: { + identification: null, + prediction: { id: 5 }, + score: 0.91, + taxon: { + id: 3, + name: 'Noctua pronuba', + rank: 'SPECIES', + cover_image_url: null, + }, + }, + first_appearance_timestamp: '2026-09-09T02:00:00', + identifications: [], + track_stats: null, + user_permissions: [], + ...overrides, +}) + +describe('occurrence conversion', () => { + test('names a determined occurrence after its taxon', () => { + const occurrence = new Occurrence(serverOccurrence()) + + expect(occurrence.determinationTaxon?.id).toBe('3') + expect(occurrence.determinationScore).toBe(0.91) + expect(occurrence.displayName).toBe('Noctua pronuba #12') + }) + + test('an occurrence nothing has identified still converts', () => { + const occurrence = new Occurrence( + serverOccurrence({ + determination: null, + determination_details: { + identification: null, + prediction: null, + score: null, + taxon: null, + }, + }) + ) + + expect(occurrence.determinationTaxon).toBeUndefined() + expect(occurrence.determinationId).toBeUndefined() + expect(occurrence.determinationScore).toBeUndefined() + expect(occurrence.determinationVerified).toBe(false) + expect(occurrence.displayName).toBe('Unidentified #12') + }) + + test('an occurrence without determination details still converts', () => { + const occurrence = new Occurrence( + serverOccurrence({ determination: null, determination_details: null }) + ) + + expect(occurrence.determinationTaxon).toBeUndefined() + expect(occurrence.displayName).toBe('Unidentified #12') + }) +}) diff --git a/ui/src/data-services/models/occurrence.ts b/ui/src/data-services/models/occurrence.ts index 468f670f1..64750712f 100644 --- a/ui/src/data-services/models/occurrence.ts +++ b/ui/src/data-services/models/occurrence.ts @@ -3,22 +3,35 @@ import { getFormatedTimeString } from 'utils/date/getFormatedTimeString/getForma import { STRING, translate } from 'utils/language' import { UserPermission } from 'utils/user/types' import { Taxon } from './taxa' +import { + convertTrackStats, + getIdAgreementLabel, + getMotionLabel, + getSizeChangeLabel, + TrackStats, +} from './track-stats' export type ServerOccurrence = any // TODO: Update this type export class Occurrence { protected readonly _occurrence: ServerOccurrence - private readonly _determinationTaxon: Taxon + private readonly _determinationTaxon?: Taxon private readonly _images: { src: string }[] = [] + private readonly _trackStats?: TrackStats public constructor(occurrence: ServerOccurrence) { this._occurrence = occurrence - this._determinationTaxon = new Taxon(occurrence.determination_details.taxon) + const determinationTaxon = occurrence.determination_details?.taxon + this._determinationTaxon = determinationTaxon + ? new Taxon(determinationTaxon) + : undefined this._images = occurrence.detection_images .filter((src: string) => !!src.length) .map((src: string) => ({ src })) + + this._trackStats = convertTrackStats(occurrence.track_stats) } get createdAt(): Date { @@ -49,8 +62,10 @@ export class Occurrence { return this._occurrence.deployment?.name } - get determinationId(): string { - return `${this._occurrence.determination.id}` + get determinationId(): string | undefined { + return this._occurrence.determination + ? `${this._occurrence.determination.id}` + : undefined } get determinationIdentificationId(): string | undefined { @@ -62,6 +77,10 @@ export class Occurrence { : undefined } + get determinationLabel(): string { + return this._determinationTaxon?.name ?? translate(STRING.UNIDENTIFIED) + } + get determinationPredictionId(): string | undefined { const determinationPrediction = this._occurrence.determination_details?.prediction @@ -70,15 +89,7 @@ export class Occurrence { } get determinationScore(): number | undefined { - const score = this._occurrence.determination_details.score - - if (score || score === 0) { - return score - } - - if (!score) { - return undefined - } + return this._occurrence.determination_details?.score ?? undefined } get determinationScoreLabel(): string | undefined { @@ -89,17 +100,17 @@ export class Occurrence { return undefined } - get determinationTaxon(): Taxon { + get determinationTaxon(): Taxon | undefined { return this._determinationTaxon } get determinationVerified(): boolean { - return !!this._occurrence.determination_details.identification + return !!this._occurrence.determination_details?.identification } get determinationVerifiedBy() { const verifiedBy = - this._occurrence.determination_details.identification?.user + this._occurrence.determination_details?.identification?.user return verifiedBy ? { @@ -118,7 +129,7 @@ export class Occurrence { } get displayName(): string { - return `${this.determinationTaxon.name} #${this.id}` + return `${this.determinationLabel} #${this.id}` } get firstAppearanceTimestamp(): string { @@ -130,10 +141,18 @@ export class Occurrence { return `${this._occurrence.id}` } + get idAgreementLabel(): string | undefined { + return getIdAgreementLabel(this._trackStats) + } + get images(): { src: string }[] { return this._images } + get motionLabel(): string | undefined { + return getMotionLabel(this._trackStats) + } + get numDetections(): number { return this._occurrence.detections_count } @@ -154,6 +173,10 @@ export class Occurrence { return this._occurrence.event.name } + get sizeChangeLabel(): string | undefined { + return getSizeChangeLabel(this._trackStats) + } + get timeLabel(): string { return getFormatedTimeString({ date: new Date(this.firstAppearanceTimestamp), @@ -161,6 +184,11 @@ export class Occurrence { }) } + /** Undefined until tracking, a track edit or a backfill has stored them. */ + get trackStats(): TrackStats | undefined { + return this._trackStats + } + get userPermissions(): UserPermission[] { return this._occurrence.user_permissions } @@ -171,7 +199,7 @@ export class Occurrence { return false } - if (identification.withdrawn) { + if (identification.withdrawn || !identification.taxon) { return false } @@ -183,7 +211,7 @@ export class Occurrence { } return ( - identificationTaxonId === this.determinationTaxon.id && + identificationTaxonId === this.determinationTaxon?.id && identificationUserId === userId ) }) diff --git a/ui/src/data-services/models/track-stats.test.ts b/ui/src/data-services/models/track-stats.test.ts new file mode 100644 index 000000000..2478475c6 --- /dev/null +++ b/ui/src/data-services/models/track-stats.test.ts @@ -0,0 +1,56 @@ +import { + convertTrackStats, + getDurationLabel, + getIdAgreementLabel, + getMotionLabel, + getSizeChangeLabel, +} from './track-stats' + +const stats = convertTrackStats({ + distinct_taxa: 1, + frames: 5, + id_agreement: 0.8, + motion: 0.1234, + size_ratio: 1.5, +}) + +describe('track stats labels', () => { + test('a missing payload gives no labels', () => { + expect(convertTrackStats(null)).toBeUndefined() + expect(getMotionLabel(undefined)).toBeUndefined() + expect(getSizeChangeLabel(undefined)).toBeUndefined() + expect(getIdAgreementLabel(undefined)).toBeUndefined() + }) + + test('motion and size change need at least two frames', () => { + const single = convertTrackStats({ + distinct_taxa: 1, + frames: 1, + id_agreement: 1, + motion: 0, + size_ratio: 1, + }) + + expect(getMotionLabel(single)).toBeUndefined() + expect(getSizeChangeLabel(single)).toBeUndefined() + expect(getMotionLabel(stats)).toBe('12.3% of frame') + expect(getSizeChangeLabel(stats)).toBe('×1.50') + }) + + test('agreement mentions the taxa only when more than one', () => { + expect(getIdAgreementLabel(stats)).toBe('80%') + expect(getIdAgreementLabel({ ...stats!, distinctTaxa: 3 })).toBe( + '80% · 3 taxa' + ) + expect( + getIdAgreementLabel({ ...stats!, idAgreement: null }) + ).toBeUndefined() + }) + + test('durations read as minutes and seconds, with hours when needed', () => { + expect(getDurationLabel(null)).toBeUndefined() + expect(getDurationLabel(0)).toBe('0:00') + expect(getDurationLabel(65.4)).toBe('1:05') + expect(getDurationLabel(3725)).toBe('1:02:05') + }) +}) diff --git a/ui/src/data-services/models/track-stats.ts b/ui/src/data-services/models/track-stats.ts new file mode 100644 index 000000000..fcaeea75b --- /dev/null +++ b/ui/src/data-services/models/track-stats.ts @@ -0,0 +1,87 @@ +import { STRING, translate } from 'utils/language' + +export interface ServerTrackStats { + distinct_taxa: number + frames: number + id_agreement: number | null + motion: number + size_ratio: number +} + +/** How an occurrence's detections move, grow and agree across its frames. */ +export interface TrackStats { + distinctTaxa: number + frames: number + /** Share of terminal classifications agreeing with the determination; null without any. */ + idAgreement: number | null + /** Distance travelled as a fraction of the frame diagonal; 0 is stationary. */ + motion: number + /** Largest box area over the smallest; 1 is a constant size. */ + sizeRatio: number +} + +export const convertTrackStats = ( + stats?: ServerTrackStats | null +): TrackStats | undefined => + stats + ? { + distinctTaxa: stats.distinct_taxa, + frames: stats.frames, + idAgreement: stats.id_agreement, + motion: stats.motion, + sizeRatio: stats.size_ratio, + } + : undefined + +// Movement and growth only mean something once there are two frames to compare. +const hasTrack = (stats?: TrackStats): stats is TrackStats => + !!stats && stats.frames >= 2 + +export const getMotionLabel = (stats?: TrackStats): string | undefined => + hasTrack(stats) + ? translate(STRING.TRACK_STAT_MOTION, { + percent: (stats.motion * 100).toFixed(1), + }) + : undefined + +export const getSizeChangeLabel = (stats?: TrackStats): string | undefined => + hasTrack(stats) + ? translate(STRING.TRACK_STAT_SIZE_RATIO, { + ratio: stats.sizeRatio.toFixed(2), + }) + : undefined + +export const getIdAgreementLabel = (stats?: TrackStats): string | undefined => { + if (!stats || stats.idAgreement === null) { + return undefined + } + + const agreement = translate(STRING.TRACK_STAT_ID_AGREEMENT, { + percent: `${Math.round(stats.idAgreement * 100)}`, + }) + + if (stats.distinctTaxa > 1) { + return `${agreement} · ${translate(STRING.TRACK_STAT_DISTINCT_TAXA, { + count: stats.distinctTaxa, + })}` + } + + return agreement +} + +export const getDurationLabel = ( + seconds: number | null +): string | undefined => { + if (seconds === null || !Number.isFinite(seconds)) { + return undefined + } + + const total = Math.round(seconds) + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + const remainder = `${total % 60}`.padStart(2, '0') + + return hours > 0 + ? `${hours}:${`${minutes}`.padStart(2, '0')}:${remainder}` + : `${minutes}:${remainder}` +} diff --git a/ui/src/data-services/models/tracking-job.test.ts b/ui/src/data-services/models/tracking-job.test.ts new file mode 100644 index 000000000..dc1db90a4 --- /dev/null +++ b/ui/src/data-services/models/tracking-job.test.ts @@ -0,0 +1,115 @@ +import { buildTrackingJobPayload, toTrackingScope } from './tracking-job' + +describe('buildTrackingJobPayload', () => { + test('tracks one session through the post-processing job type', () => { + expect( + buildTrackingJobPayload({ + name: 'Night one', + projectId: '3', + requireFeatures: true, + scope: { type: 'session', sessionId: '42' }, + }) + ).toEqual({ + delay: 0, + job_type_key: 'post_processing', + name: 'Night one', + params: { + task: 'tracking', + config: { event_ids: [42], require_features: true }, + }, + project_id: 3, + }) + }) + + test('tracks a capture set and passes a threshold typed into the form', () => { + expect( + buildTrackingJobPayload({ + costThreshold: '0.35', + name: 'Survey', + projectId: '3', + requireFeatures: false, + scope: { type: 'captureSet', captureSetId: '7' }, + }).params.config + ).toEqual({ + cost_threshold: 0.35, + require_features: false, + source_image_collection_id: 7, + }) + }) + + test('leaves the threshold to the server when the field is empty', () => { + const { config } = buildTrackingJobPayload({ + costThreshold: '', + projectId: '3', + requireFeatures: true, + scope: { type: 'session', sessionId: '42' }, + }).params + + expect(config).not.toHaveProperty('cost_threshold') + }) + + test('keeps the server skipping identified and already tracked sessions, as the form says', () => { + const { config } = buildTrackingJobPayload({ + projectId: '3', + requireFeatures: true, + scope: { type: 'session', sessionId: '42' }, + }).params + + expect(config).not.toHaveProperty('skip_if_human_identifications') + expect(config).not.toHaveProperty('require_fresh_event') + }) + + test('names the job after its scope when no name is given', () => { + expect( + buildTrackingJobPayload({ + name: ' ', + projectId: '3', + requireFeatures: true, + scope: { type: 'session', sessionId: '42' }, + }).name + ).toBe('Tracking session 42') + }) + + test('refuses an id or threshold the server would reject', () => { + const base = { projectId: '3', requireFeatures: true } + + expect(() => + buildTrackingJobPayload({ + ...base, + scope: { type: 'session', sessionId: 'abc' }, + }) + ).toThrow() + expect(() => + buildTrackingJobPayload({ + ...base, + costThreshold: '-1', + scope: { type: 'session', sessionId: '42' }, + }) + ).toThrow() + }) +}) + +describe('toTrackingScope', () => { + test('reads only the field for the chosen scope', () => { + expect( + toTrackingScope({ + captureSetId: '7', + scopeType: 'session', + sessionId: '42', + }) + ).toEqual({ type: 'session', sessionId: '42' }) + expect( + toTrackingScope({ + captureSetId: '7', + scopeType: 'captureSet', + sessionId: '42', + }) + ).toEqual({ type: 'captureSet', captureSetId: '7' }) + }) + + test('is undefined until the chosen field is filled in', () => { + expect( + toTrackingScope({ scopeType: 'captureSet', sessionId: '42' }) + ).toBeUndefined() + }) +}) diff --git a/ui/src/data-services/models/tracking-job.ts b/ui/src/data-services/models/tracking-job.ts new file mode 100644 index 000000000..75c468e18 --- /dev/null +++ b/ui/src/data-services/models/tracking-job.ts @@ -0,0 +1,112 @@ +import { STRING, translate } from 'utils/language' + +export type TrackingScope = + | { type: 'session'; sessionId: string } + | { type: 'captureSet'; captureSetId: string } + +export interface TrackingJobFieldValues { + /** Left empty, the server's default threshold applies. */ + costThreshold?: string | number + name?: string + projectId: string + requireFeatures: boolean + scope: TrackingScope +} + +export interface ServerTrackingConfig { + cost_threshold?: number + event_ids?: number[] + require_features: boolean + source_image_collection_id?: number +} + +export interface ServerTrackingJobPayload { + /** Required by the jobs API for every job type; tracking runs never wait. */ + delay: 0 + job_type_key: 'post_processing' + name: string + params: { task: 'tracking'; config: ServerTrackingConfig } + project_id: number +} + +const toId = (value: string, label: string) => { + const id = Number(value) + + if (!Number.isInteger(id) || id <= 0) { + throw new Error(`${label} must be a positive whole number, got "${value}"`) + } + + return id +} + +const toCostThreshold = (value?: string | number) => { + if (value === undefined || value === '') { + return undefined + } + + const threshold = Number(value) + + if (!Number.isFinite(threshold) || threshold < 0) { + throw new Error(`Cost threshold must be zero or more, got "${value}"`) + } + + return threshold +} + +export const getDefaultTrackingJobName = (scope: TrackingScope) => + scope.type === 'session' + ? translate(STRING.TRACKING_JOB_NAME_SESSION, { id: scope.sessionId }) + : translate(STRING.TRACKING_JOB_NAME_CAPTURE_SET, { + id: scope.captureSetId, + }) + +export const buildTrackingJobPayload = ({ + costThreshold, + name, + projectId, + requireFeatures, + scope, +}: TrackingJobFieldValues): ServerTrackingJobPayload => { + const threshold = toCostThreshold(costThreshold) + const config: ServerTrackingConfig = { + ...(scope.type === 'session' + ? { event_ids: [toId(scope.sessionId, 'Session ID')] } + : { + source_image_collection_id: toId( + scope.captureSetId, + 'Capture set ID' + ), + }), + ...(threshold !== undefined ? { cost_threshold: threshold } : {}), + require_features: requireFeatures, + } + + return { + delay: 0, + job_type_key: 'post_processing', + name: name?.trim() || getDefaultTrackingJobName(scope), + params: { task: 'tracking', config }, + project_id: toId(projectId, 'Project ID'), + } +} + +export interface TrackingScopeFormValues { + captureSetId?: string + scopeType: TrackingScope['type'] + sessionId?: string +} + +/** The scope a form describes, or undefined until its chosen field is filled in. */ +export const toTrackingScope = ({ + captureSetId, + scopeType, + sessionId, +}: TrackingScopeFormValues): TrackingScope | undefined => { + if (scopeType === 'session') { + return sessionId + ? { type: 'session', sessionId: `${sessionId}` } + : undefined + } + + return captureSetId ? { type: 'captureSet', captureSetId } : undefined +} diff --git a/ui/src/nova-ui-kit/components/dialog/dialog.tsx b/ui/src/nova-ui-kit/components/dialog/dialog.tsx index 1b9fac17b..abb6e9390 100644 --- a/ui/src/nova-ui-kit/components/dialog/dialog.tsx +++ b/ui/src/nova-ui-kit/components/dialog/dialog.tsx @@ -22,6 +22,8 @@ const Root = ({ const Trigger = Dialog.Trigger +const Description = Dialog.Description + const Content = ({ ariaCloselabel, children, @@ -94,4 +96,4 @@ const Header = ({
) -export { Content, Header, Root, Trigger } +export { Content, Description, Header, Root, Trigger } diff --git a/ui/src/nova-ui-kit/components/identifications/identification-card.tsx b/ui/src/nova-ui-kit/components/identifications/identification-card.tsx index 294a6921d..5666b87f2 100644 --- a/ui/src/nova-ui-kit/components/identifications/identification-card.tsx +++ b/ui/src/nova-ui-kit/components/identifications/identification-card.tsx @@ -12,6 +12,8 @@ interface IdentificationCard { open?: boolean subTitle?: string title: string + /** Rendered inline after the title, e.g. a badge qualifying it. */ + titleAddon?: ReactNode onTitleClick?: () => void } @@ -24,6 +26,7 @@ export const IdentificationCard = ({ open, subTitle, title, + titleAddon, onTitleClick, }: IdentificationCard) => (
@@ -33,16 +36,19 @@ export const IdentificationCard = ({ {avatar}
- - {title} - +
+ + {title} + + {titleAddon} +
{subTitle?.length ? ( {subTitle} diff --git a/ui/src/pages/job-details/new-job-dialog.tsx b/ui/src/pages/job-details/new-job-dialog.tsx index be41fa364..fa76d7326 100644 --- a/ui/src/pages/job-details/new-job-dialog.tsx +++ b/ui/src/pages/job-details/new-job-dialog.tsx @@ -1,22 +1,31 @@ +import { FormRow, FormSection } from 'components/form/layout/layout' import { useCreateJob } from 'data-services/hooks/jobs/useCreateJob' +import { useCreateTrackingJob } from 'data-services/hooks/jobs/useCreateTrackingJob' import { PlusIcon } from 'lucide-react' -import { Button, Dialog } from 'nova-ui-kit' +import { Button, Dialog, InputContent, Select } from 'nova-ui-kit' import { useState } from 'react' import { useParams } from 'react-router-dom' import { STRING, translate } from 'utils/language' +import { useCanStartTracking } from 'utils/project-features/useProjectFeature' import { JobDetailsForm } from './job-details-form/job-details-form' import styles from './job-details.module.scss' +import { TrackingJobForm } from './tracking-job-form/tracking-job-form' const CLOSE_TIMEOUT = 1000 +type NewJobType = 'processing' | 'tracking' + export const NewJobDialog = () => { const { projectId } = useParams() const [isOpen, setIsOpen] = useState(false) - const { createJob, isLoading, isSuccess, error } = useCreateJob(() => + const [jobType, setJobType] = useState('processing') + const canStartTracking = useCanStartTracking() + const closeLater = () => setTimeout(() => { setIsOpen(false) }, CLOSE_TIMEOUT) - ) + const { createJob, isLoading, isSuccess, error } = useCreateJob(closeLater) + const tracking = useCreateTrackingJob(closeLater) const label = translate(STRING.ENTITY_CREATE, { type: translate(STRING.ENTITY_TYPE_JOB), @@ -33,17 +42,55 @@ export const NewJobDialog = () => {
- { - createJob({ - ...data, - projectId: projectId as string, - }) - }} - /> + {canStartTracking ? ( + + + + setJobType(value as NewJobType)} + value={jobType} + > + + + + + + {translate(STRING.TRACKING_JOB_TYPE_PROCESSING)} + + + {translate(STRING.TRACKING_JOB_TYPE_TRACKING)} + + + + + + + ) : null} + {canStartTracking && jobType === 'tracking' ? ( + + tracking.createTrackingJob({ + ...values, + projectId: projectId as string, + }) + } + /> + ) : ( + { + createJob({ + ...data, + projectId: projectId as string, + }) + }} + /> + )}
diff --git a/ui/src/pages/job-details/tracking-job-form/tracking-job-form.tsx b/ui/src/pages/job-details/tracking-job-form/tracking-job-form.tsx new file mode 100644 index 000000000..3adb8321f --- /dev/null +++ b/ui/src/pages/job-details/tracking-job-form/tracking-job-form.tsx @@ -0,0 +1,240 @@ +import { FormController } from 'components/form/form-controller' +import { FormField } from 'components/form/form-field' +import { + FormActions, + FormError, + FormRow, + FormSection, +} from 'components/form/layout/layout' +import { FormConfig } from 'components/form/types' +import { + toTrackingScope, + TrackingScope, +} from 'data-services/models/tracking-job' +import { Checkbox, InputContent, SaveButton, Select } from 'nova-ui-kit' +import { CaptureSetPicker } from 'nova-ui-kit/components/select/capture-set-picker' +import { useForm } from 'react-hook-form' +import { STRING, translate } from 'utils/language' +import { useFormError } from 'utils/useFormError' + +interface TrackingJobFormValues { + captureSetId?: string + costThreshold?: string + name: string + requireFeatures: boolean + scopeType: TrackingScope['type'] + sessionId?: string + startNow: boolean +} + +export interface TrackingJobSubmitValues { + costThreshold?: string + name: string + requireFeatures: boolean + scope: TrackingScope + startNow: boolean +} + +const config: FormConfig = { + captureSetId: { + label: translate(STRING.FIELD_LABEL_CAPTURE_SET), + rules: { required: true }, + }, + costThreshold: { + label: translate(STRING.TRACKING_JOB_COST_THRESHOLD), + description: translate(STRING.TRACKING_JOB_COST_THRESHOLD_DESCRIPTION), + rules: { min: 0 }, + }, + name: { + label: translate(STRING.FIELD_LABEL_NAME), + description: translate(STRING.TRACKING_JOB_NAME_DESCRIPTION), + }, + requireFeatures: { + label: translate(STRING.TRACKING_JOB_REQUIRE_FEATURES), + description: translate(STRING.TRACKING_JOB_REQUIRE_FEATURES_DESCRIPTION), + }, + scopeType: { + label: translate(STRING.TRACKING_JOB_SCOPE), + }, + sessionId: { + label: translate(STRING.FIELD_LABEL_SESSION_NUMBER), + rules: { required: true, min: 1 }, + }, + startNow: { + label: translate(STRING.TRACKING_JOB_START_NOW), + }, +} + +export const TrackingJobForm = ({ + error, + isLoading, + isSuccess, + onSubmit, + scope, +}: { + error?: unknown + isLoading?: boolean + isSuccess?: boolean + onSubmit: (values: TrackingJobSubmitValues) => void + /** Fixes what is tracked, hiding the scope fields; left out, the user picks one. */ + scope?: TrackingScope +}) => { + const { + control, + handleSubmit, + setError: setFieldError, + watch, + } = useForm({ + defaultValues: { + name: '', + requireFeatures: true, + scopeType: scope?.type ?? 'session', + startNow: true, + }, + mode: 'onChange', + }) + const scopeType = watch('scopeType') + // Only the name maps onto a server field; errors about the params, which carry the + // scope and settings, are shown above the form instead of on a hidden field. + const errorMessage = useFormError({ + error, + fields: ['name'], + setFieldError, + }) + + return ( +
{ + const chosenScope = + scope ?? toTrackingScope({ captureSetId, scopeType, sessionId }) + + if (chosenScope) { + onSubmit({ ...values, scope: chosenScope }) + } + } + )} + > + {errorMessage ? ( + + ) : null} + + {/* The payload leaves the server defaults skip_if_human_identifications and require_fresh_event on. */} +

+ {translate(STRING.TRACKING_JOB_SKIPPED_SESSIONS)} +

+ {scope ? null : ( + + ( + + + + + + + + {translate(STRING.TRACKING_JOB_SCOPE_SESSION)} + + + {translate(STRING.TRACKING_JOB_SCOPE_CAPTURE_SET)} + + + + + )} + /> + {scopeType === 'session' ? ( + + ) : ( + ( + + + + )} + /> + )} + + )} + + + + + + +
+ ( + + )} + /> + ( + + )} + /> +
+
+
+
+ + + + + ) +} diff --git a/ui/src/pages/occurrence-details/agree/agree.tsx b/ui/src/pages/occurrence-details/agree/agree.tsx index 92542e423..410810cca 100644 --- a/ui/src/pages/occurrence-details/agree/agree.tsx +++ b/ui/src/pages/occurrence-details/agree/agree.tsx @@ -13,6 +13,7 @@ interface AgreeProps { applied?: boolean compact?: boolean occurrenceId: string + onSuccess?: (occurrenceId: string) => void taxonId: string } @@ -22,10 +23,11 @@ export const Agree = ({ applied, compact, occurrenceId, + onSuccess, taxonId, }: AgreeProps) => { const { createIdentification, isLoading, isSuccess, error, reset } = - useCreateIdentification() + useCreateIdentification(() => onSuccess?.(occurrenceId)) useEffect(() => { reset() diff --git a/ui/src/pages/occurrence-details/identification-card/grouping-confirmation.tsx b/ui/src/pages/occurrence-details/identification-card/grouping-confirmation.tsx new file mode 100644 index 000000000..6c5a660c5 --- /dev/null +++ b/ui/src/pages/occurrence-details/identification-card/grouping-confirmation.tsx @@ -0,0 +1,38 @@ +import { OccurrenceDetails as Occurrence } from 'data-services/models/occurrence-details' +import { UserIcon } from 'lucide-react' +import { IdentificationCard } from 'nova-ui-kit' +import { getFormatedDateTimeString } from 'utils/date/getFormatedDateTimeString/getFormatedDateTimeString' +import { STRING, translate } from 'utils/language' + +export const GroupingConfirmation = ({ + occurrence, +}: { + occurrence: Occurrence +}) => { + const user = occurrence.groupingVerifiedBy + const formattedTime = occurrence.groupingVerifiedAt + ? getFormatedDateTimeString({ date: occurrence.groupingVerifiedAt }) + : translate(STRING.VALUE_NOT_AVAILABLE) + + return ( +
+ + {formattedTime} + + + ) : ( + + ) + } + subTitle={translate(STRING.TRACK_GROUPING_CONFIRMED_BY, { + date: formattedTime, + name: user?.name ?? translate(STRING.ANONYMOUS_USER), + })} + title={translate(STRING.TRACK_GROUPING_CONFIRMED)} + /> +
+ ) +} diff --git a/ui/src/pages/occurrence-details/identification-card/grouping-summary.tsx b/ui/src/pages/occurrence-details/identification-card/grouping-summary.tsx new file mode 100644 index 000000000..9549a828c --- /dev/null +++ b/ui/src/pages/occurrence-details/identification-card/grouping-summary.tsx @@ -0,0 +1,149 @@ +import { + FrameName, + GroupingSummary as Summary, +} from 'data-services/models/occurrence-details' +import { + getDurationLabel, + getIdAgreementLabel, + getMotionLabel, + getSizeChangeLabel, +} from 'data-services/models/track-stats' +import { RouteIcon } from 'lucide-react' +import { BasicTooltip, IdentificationCard } from 'nova-ui-kit' +import { cn } from 'nova-ui-kit/utils' +import { Fragment } from 'react' +import { STRING, translate } from 'utils/language' +import { FrameTaxonName } from '../track/frame-caption' + +export const GroupingSummary = ({ + frameNames, + summary, +}: { + frameNames: FrameName[] + summary: Summary +}) => { + const notAvailable = translate(STRING.VALUE_NOT_AVAILABLE) + const { scoreMax, scoreMean, scoreMin } = summary + const frames = + summary.linkedDetections > 0 + ? `${summary.frames} · ${translate(STRING.TRACK_STAT_LINKED, { + count: summary.linkedDetections, + })}` + : `${summary.frames}` + + const stats: { + isWarning?: boolean + label: string + tooltip?: string + value: string + }[] = [ + { label: translate(STRING.TRACK_SUMMARY_FRAMES), value: frames }, + ...(summary.framesWithVectors !== undefined + ? [ + { + isWarning: summary.framesWithVectors < summary.frames, + label: translate(STRING.TRACK_SUMMARY_FRAMES_WITH_VECTORS), + tooltip: translate(STRING.TRACK_SUMMARY_VECTORS_TOOLTIP), + value: translate(STRING.VALUE_COUNT_OF_TOTAL, { + count: summary.framesWithVectors, + total: summary.frames, + }), + }, + ] + : []), + { + label: translate(STRING.FIELD_LABEL_DURATION), + value: getDurationLabel(summary.durationSeconds) ?? notAvailable, + }, + { + label: translate(STRING.FIELD_LABEL_MOTION), + value: getMotionLabel(summary) ?? notAvailable, + }, + { + label: translate(STRING.FIELD_LABEL_SIZE_CHANGE), + value: getSizeChangeLabel(summary) ?? notAvailable, + }, + { + label: translate(STRING.TRACK_SUMMARY_ID_AGREEMENT), + value: getIdAgreementLabel(summary) ?? notAvailable, + }, + { + label: translate(STRING.TRACK_SUMMARY_SCORE_RANGE), + value: + scoreMax !== null && scoreMean !== null && scoreMin !== null + ? translate(STRING.TRACK_STAT_SCORE_RANGE, { + max: scoreMax.toFixed(2), + mean: scoreMean.toFixed(2), + min: scoreMin.toFixed(2), + }) + : notAvailable, + }, + ] + + return ( + } + title={summary.algorithm?.name ?? translate(STRING.TRACK_SUMMARY_TITLE)} + titleAddon={ + + + {translate(STRING.TRACK_SUMMARY_DERIVED)} + + + } + > +
+ {stats.map(({ isWarning, label, tooltip, value }) => ( + + + {tooltip ? ( + + + {label} + + + ) : ( + label + )} + + + {value} + + + ))} +
+ {frameNames.length ? ( +
+ + {translate(STRING.TRACK_SUMMARY_FRAME_NAMES)} + + + {translate(STRING.FIELD_LABEL_TAXON)} + + + {translate(STRING.TRACK_COLUMN_FRAMES)} + + + {translate(STRING.FIELD_LABEL_BEST_SCORE)} + + {frameNames.map(({ frames, scoreMax, taxon }) => ( + + + + {frames} + + + {scoreMax?.toFixed(2) ?? notAvailable} + + + ))} +
+ ) : null} +
+ ) +} diff --git a/ui/src/pages/occurrence-details/identification-card/human-identification.tsx b/ui/src/pages/occurrence-details/identification-card/human-identification.tsx index 35185a1de..7023f33ca 100644 --- a/ui/src/pages/occurrence-details/identification-card/human-identification.tsx +++ b/ui/src/pages/occurrence-details/identification-card/human-identification.tsx @@ -75,24 +75,26 @@ export const HumanIdentification = ({ className="border-border border-t" >
- - - + {identification.taxon ? ( + + + + ) : null} {identification.comment ? (

"{identification.comment}"

) : null}
- {showAgree && ( + {showAgree && identification.taxon && ( {topN?.map(({ score, taxon }) => { - const applied = taxon.id === occurrence.determinationTaxon.id + const applied = taxon.id === occurrence.determinationTaxon?.id return ( void + /** Called when a frame's link is followed, so a dialog around these details can close. */ + onNavigate?: () => void selectedTab?: string setSelectedTab: (selectedTab?: string) => void }) => { @@ -57,38 +68,13 @@ export const OccurrenceDetails = ({ const navigate = useNavigate() const location = useLocation() const [suggestIdOpen, setSuggestIdOpen] = useState(false) + const [pendingFrameAction, setPendingFrameAction] = + useState() const canUpdate = occurrence.userPermissions.includes(UserPermission.Update) - - const blueprintItems = useMemo( - () => - occurrence.detections.length - ? occurrence.detections - .map((id) => occurrence.getDetectionInfo(id)) - .filter( - (item): item is BlueprintItem & { captureId: string } => !!item - ) - .map((item) => ({ - ...item, - to: - !occurrence.sessionId || - pathname.includes( - APP_ROUTES.SESSIONS({ projectId: projectId as string }) - ) - ? undefined - : getAppRoute({ - to: APP_ROUTES.SESSION_DETAILS({ - projectId: projectId as string, - sessionId: occurrence.sessionId, - }), - filters: { - occurrence: occurrence.id, - capture: item.captureId, - }, - }), - })) - : [], - [occurrence] + const { canRestructure, canVerify: canVerifyGrouping } = getTrackEditRights( + occurrence.userPermissions ) + const trackingEnabled = useProjectFeature('tracking') const fields = [ { @@ -169,16 +155,21 @@ export const OccurrenceDetails = ({ ) : null} {canUpdate && ( <> - + {occurrence.determinationTaxon ? ( + + ) : null} )} @@ -242,6 +237,14 @@ export const OccurrenceDetails = ({ /> ))} + +
+ + +
+
@@ -256,6 +259,17 @@ export const OccurrenceDetails = ({ )} + {trackingEnabled && occurrence.groupingVerifiedAt ? ( + + ) : null} + + {trackingEnabled && occurrence.groupingSummary ? ( + + ) : null} + {occurrence.humanIdentifications.map((i) => (
- 0}> - {blueprintItems.map((item) => ( - - ))} - + {trackingEnabled && (canRestructure || canVerifyGrouping) && ( + + )} + + setPendingFrameAction(undefined)} + pending={pendingFrameAction} + />
diff --git a/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx b/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx new file mode 100644 index 000000000..18224a394 --- /dev/null +++ b/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx @@ -0,0 +1,200 @@ +import { useAddDetections } from 'data-services/hooks/occurrences/track/useAddDetections' +import { useRemoveDetection } from 'data-services/hooks/occurrences/track/useRemoveDetection' +import { useSplitTrack } from 'data-services/hooks/occurrences/track/useSplitTrack' +import { + getCandidatesEmptyMessage, + MERGE_SCOPES, + MergeScopeKey, + useMergeCandidates, +} from 'data-services/hooks/occurrences/useMergeCandidates' +import { MergeCandidate } from 'data-services/models/merge-candidate' +import { OccurrenceDetails } from 'data-services/models/occurrence-details' +import { useState } from 'react' +import { useParams } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' +import { STRING, translate } from 'utils/language' +import { getCandidateSessionRoute } from 'components/track/candidate-session-route' +import { OccurrencePicker } from 'components/track/occurrence-picker' +import { TrackEditDialog } from 'components/track/track-edit-dialog' +import { PendingFrameAction } from './types' + +const DEFAULT_SCOPE: MergeScopeKey = 'near' + +export const FrameActionDialogs = ({ + occurrence, + onClose, + pending, +}: { + occurrence: OccurrenceDetails + onClose: () => void + pending?: PendingFrameAction +}) => { + const { projectId } = useParams() + const [targetId, setTargetId] = useState() + const [scope, setScope] = useState(DEFAULT_SCOPE) + + const split = useSplitTrack(occurrence.id) + const remove = useRemoveDetection(occurrence.id) + const move = useAddDetections(targetId ?? occurrence.id) + const moveScope = + MERGE_SCOPES.find((option) => option.key === scope) ?? MERGE_SCOPES[0] + const { + candidates, + costThreshold, + error: candidatesError, + isLoading: candidatesLoading, + requiresFeatures, + } = useMergeCandidates({ + captures: moveScope.captures, + detectionId: pending?.detectionId, + enabled: pending?.action === 'move' && !move.result, + minutes: moveScope.minutes, + occurrenceId: occurrence.id, + projectId: projectId as string, + }) + + const sessionRoute = occurrence.sessionId + ? APP_ROUTES.SESSION_DETAILS({ + projectId: projectId as string, + sessionId: occurrence.sessionId, + }) + : undefined + + const candidateSessionLink = (candidate: MergeCandidate) => + sessionRoute && candidate.captureId + ? getCandidateSessionRoute({ + captureId: candidate.captureId, + occurrenceIds: [occurrence.id, candidate.id], + sessionRoute, + }) + : undefined + + // A split or a removal can leave the new occurrence scoring under the project's + // default threshold, which hides it from every lookup. Carry the bypass on the link + // so the operator can still open — and undo — what they just made. + const openNewOccurrence = (occurrenceId: number) => + getAppRoute({ + to: APP_ROUTES.OCCURRENCE_DETAILS({ + projectId: projectId as string, + occurrenceId: `${occurrenceId}`, + }), + filters: { apply_defaults: 'false' }, + }) + + const close = () => { + setTargetId(undefined) + setScope(DEFAULT_SCOPE) + split.reset() + remove.reset() + move.reset() + onClose() + } + + if (!pending) { + return null + } + + return ( + <> + split.splitTrack(pending.detectionId)} + onOpenChange={(open) => (open ? undefined : close())} + open={pending.action === 'split'} + result={ + split.result + ? translate(STRING.TRACK_SPLIT_RESULT, { + count: split.result.new_occurrence_detections_count, + id: `${split.result.new_occurrence_id}`, + }) + : undefined + } + resultLink={ + split.result + ? openNewOccurrence(split.result.new_occurrence_id) + : undefined + } + title={translate(STRING.TRACK_SPLIT_HERE)} + /> + + remove.removeDetection(pending.detectionId)} + onOpenChange={(open) => (open ? undefined : close())} + open={pending.action === 'remove'} + result={ + remove.result + ? translate(STRING.TRACK_REMOVE_RESULT, { + id: `${remove.result.new_occurrence_id}`, + }) + : undefined + } + resultLink={ + remove.result + ? openNewOccurrence(remove.result.new_occurrence_id) + : undefined + } + title={translate(STRING.TRACK_REMOVE_FRAME)} + /> + + move.addDetections([pending.detectionId])} + onOpenChange={(open) => (open ? undefined : close())} + isWide + open={pending.action === 'move'} + result={ + move.result + ? translate(STRING.TRACK_MOVE_RESULT, { + count: move.result.detections_count, + id: `${move.result.occurrence_id}`, + }) + : undefined + } + title={translate(STRING.TRACK_MOVE_FRAME)} + > + {move.result ? null : ( + { + setScope(key) + setTargetId(undefined) + }} + onSelect={setTargetId} + requiresFeatures={requiresFeatures} + scope={scope} + selectedId={targetId} + sessionLink={candidateSessionLink} + /> + )} + + + ) +} diff --git a/ui/src/pages/occurrence-details/track/frame-caption.tsx b/ui/src/pages/occurrence-details/track/frame-caption.tsx new file mode 100644 index 000000000..a3cd17cb4 --- /dev/null +++ b/ui/src/pages/occurrence-details/track/frame-caption.tsx @@ -0,0 +1,74 @@ +import { isGenusOrBelow } from 'components/taxon-details/utils' +import { FrameLabel } from 'data-services/models/occurrence-details' +import { Taxon } from 'data-services/models/taxa' +import { BanIcon } from 'lucide-react' +import { cn } from 'nova-ui-kit/utils' +import { STRING, translate } from 'utils/language' + +export const FrameTaxonName = ({ taxon }: { taxon?: Taxon }) => { + const name = taxon?.name ?? translate(STRING.TRACK_FRAME_NO_CLASSIFICATION) + + return ( + + {name} + + ) +} + +/** + * Beside a frame's crop: which detection and when, then the classifier's name for it. + * `hasVector` is undefined when the payload did not say, and only a definite no is marked. + */ +export const FrameCaption = ({ + detectionId, + hasVector, + label, + timeLabel, +}: { + detectionId: string + hasVector?: boolean + label: FrameLabel + timeLabel: string +}) => ( +
+
+
+ + #{detectionId} + + {hasVector === false ? ( + + + + ) : null} +
+ {timeLabel} +
+ + + {label.taxon || label.score !== undefined ? ( + <> + {' '} + + ({label.score?.toFixed(2) ?? translate(STRING.VALUE_NOT_AVAILABLE)}) + + + ) : null} + +
+) diff --git a/ui/src/pages/occurrence-details/track/frame-menu.tsx b/ui/src/pages/occurrence-details/track/frame-menu.tsx new file mode 100644 index 000000000..e6451180e --- /dev/null +++ b/ui/src/pages/occurrence-details/track/frame-menu.tsx @@ -0,0 +1,70 @@ +import { PencilIcon } from 'lucide-react' +import { Button, Popover } from 'nova-ui-kit' +import { useState } from 'react' +import { STRING, translate } from 'utils/language' +import { FrameAction } from './types' + +export const FrameMenu = ({ + isFirstInTime, + isOnlyFrame, + onAction, +}: { + isFirstInTime: boolean + isOnlyFrame: boolean + onAction: (action: FrameAction) => void +}) => { + const [open, setOpen] = useState(false) + + const items: { action: FrameAction; disabled: boolean; label: string }[] = [ + { + action: 'split', + // Splitting at the earliest frame would move the whole occurrence, which the + // API rejects. + disabled: isFirstInTime, + label: translate(STRING.TRACK_SPLIT_HERE), + }, + { + action: 'move', + disabled: isOnlyFrame, + label: translate(STRING.TRACK_MOVE_FRAME), + }, + { + action: 'remove', + disabled: isOnlyFrame, + label: translate(STRING.TRACK_REMOVE_FRAME), + }, + ] + + return ( + + + + + +
+ {items.map((item) => ( + + ))} +
+
+
+ ) +} diff --git a/ui/src/pages/occurrence-details/track/frame-strip.tsx b/ui/src/pages/occurrence-details/track/frame-strip.tsx new file mode 100644 index 000000000..754b1bf68 --- /dev/null +++ b/ui/src/pages/occurrence-details/track/frame-strip.tsx @@ -0,0 +1,281 @@ +import { + BlueprintCollection, + BlueprintItem, +} from 'components/blueprint-collection/blueprint-collection' +import { useOccurrenceFrames } from 'data-services/hooks/occurrences/useOccurrenceFrames' +import { + OccurrenceDetails, + OccurrenceFrame, +} from 'data-services/models/occurrence-details' +import { + FIRST_FRAME_PAGE, + FramePageRequest, + getFrameRange, + isFirstFramePage, +} from 'data-services/models/occurrence-frames-page' +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeftIcon, + ChevronsRightIcon, +} from 'lucide-react' +import { Button, buttonVariants } from 'nova-ui-kit' +import { cn } from 'nova-ui-kit/utils' +import { ReactNode, useEffect, useState } from 'react' +import { Link, useLocation, useSearchParams } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' +import { STRING, translate } from 'utils/language' +import { FrameCaption } from './frame-caption' +import { FrameMenu } from './frame-menu' +import { PendingFrameAction } from './types' + +const JumpToFrame = ({ + label, + onClick, + to, +}: { + label: string + onClick?: () => void + to?: string +}) => + to ? ( + + {label} + + ) : null + +const PageButton = ({ + children, + label, + onClick, +}: { + children: ReactNode + label: string + onClick?: () => void +}) => ( + +) + +/** The track's frames one page at a time, earliest first, opened on the linked detection. */ +export const FrameStrip = ({ + canRestructure, + occurrence, + onAction, + onNavigate, + projectId, + trackingEnabled, +}: { + canRestructure: boolean + occurrence: OccurrenceDetails + onAction: (action: PendingFrameAction) => void + onNavigate?: () => void + projectId: string + trackingEnabled: boolean +}) => { + const { pathname, search } = useLocation() + const [searchParams] = useSearchParams() + const selectedId = searchParams.get('detection') ?? undefined + const [request, setRequest] = useState(() => + selectedId ? { around: selectedId } : FIRST_FRAME_PAGE + ) + const onFirstPage = isFirstFramePage(request) + const page = useOccurrenceFrames({ + enabled: !onFirstPage, + occurrenceId: occurrence.id, + request, + }) + + // A detection link can name a frame of another occurrence, which has no page here, + // and a track edit can leave the open page past the end. + useEffect(() => { + if ( + ('around' in request && page.error) || + (page.frames && !page.frames.length && page.total) + ) { + setRequest(FIRST_FRAME_PAGE) + } + }, [page.error, page.frames, page.total, request]) + + const total = onFirstPage ? occurrence.numDetections : page.total ?? 0 + const frames = (onFirstPage ? occurrence.firstFramesPage : page.frames) ?? [] + const range = getFrameRange(frames) + const next = onFirstPage + ? frames.length < total + ? { offset: frames.length } + : undefined + : page.next + const previous = onFirstPage ? undefined : page.previous + const lastFrame = occurrence.lastFrame + const onLastPage = !next + + const sessionRoute = occurrence.sessionId + ? APP_ROUTES.SESSION_DETAILS({ + projectId, + sessionId: occurrence.sessionId, + }) + : undefined + + const sessionLink = (captureId?: string) => { + if (!sessionRoute || !captureId) { + return undefined + } + + // On the session page itself, keep the other selected occurrences and only move + // the capture. + if (pathname === sessionRoute) { + const params = new URLSearchParams(search) + if (!params.getAll('occurrence').includes(occurrence.id)) { + params.append('occurrence', occurrence.id) + } + params.set('capture', captureId) + + return `${sessionRoute}?${params}` + } + + return getAppRoute({ + to: sessionRoute, + filters: { occurrence: occurrence.id, capture: captureId }, + }) + } + + const renderFrame = (frame: OccurrenceFrame) => ( + + onAction({ + action, + detectionId: frame.id, + movedBySplit: total - frame.frameIndex, + timeLabel: frame.timeLabel, + total, + }) + } + /> + ) : undefined + } + caption={ + trackingEnabled ? ( + + ) : undefined + } + item={{ + ...frame, + countLabel: '', + to: sessionLink(frame.captureId), + }} + key={frame.id} + onLinkClick={onNavigate} + selected={frame.id === selectedId} + /> + ) + + return ( + 0} + > + {trackingEnabled && total ? ( +
+ + {total === 1 + ? translate(STRING.TRACK_FRAMES_ONE) + : translate(STRING.TRACK_FRAMES_COUNT, { count: total })} + +
+ {total === 1 ? ( + + ) : ( + <> + + + + )} +
+
+ ) : null} + {range && (previous || next) ? ( +
+ + {translate(STRING.TRACK_FRAMES_RANGE, { ...range, total })} + +
+ setRequest(FIRST_FRAME_PAGE) : undefined + } + > + + + setRequest(previous) : undefined} + > + + + setRequest(next) : undefined} + > + + + setRequest({ around: lastFrame.id }) + : undefined + } + > + + +
+
+ ) : null} + {page.error && !('around' in request) ? ( +

+ {translate(STRING.TRACK_FRAMES_PAGE_ERROR)} +

+ ) : null} + {frames.map(renderFrame)} +
+ ) +} diff --git a/ui/src/pages/occurrence-details/track/grouping-actions.tsx b/ui/src/pages/occurrence-details/track/grouping-actions.tsx new file mode 100644 index 000000000..22d4295e1 --- /dev/null +++ b/ui/src/pages/occurrence-details/track/grouping-actions.tsx @@ -0,0 +1,213 @@ +import { FormError } from 'components/form/layout/layout' +import { useMergeOccurrences } from 'data-services/hooks/occurrences/track/useMergeOccurrences' +import { useSetGroupingVerified } from 'data-services/hooks/occurrences/track/useSetGroupingVerified' +import { + MERGE_SCOPES, + MergeScopeKey, + getCandidatesEmptyMessage, + useMergeCandidates, +} from 'data-services/hooks/occurrences/useMergeCandidates' +import { MergeCandidate } from 'data-services/models/merge-candidate' +import { OccurrenceDetails } from 'data-services/models/occurrence-details' +import { GitMergeIcon, Loader2Icon, PencilIcon } from 'lucide-react' +import { Badge, Button, buttonVariants } from 'nova-ui-kit' +import { useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getFormatedDateTimeString } from 'utils/date/getFormatedDateTimeString/getFormatedDateTimeString' +import { getAppRoute } from 'utils/getAppRoute' +import { STRING, translate } from 'utils/language' +import { parseServerError } from 'utils/parseServerError/parseServerError' +import { getCandidateSessionRoute } from 'components/track/candidate-session-route' +import { OccurrencePicker } from 'components/track/occurrence-picker' +import { TrackEditDialog } from 'components/track/track-edit-dialog' +import { useMergeSelection } from 'components/track/use-merge-selection' + +const DEFAULT_SCOPE: MergeScopeKey = 'next' + +export const GroupingActions = ({ + canRestructure, + canVerify, + occurrence, +}: { + canRestructure: boolean + canVerify: boolean + occurrence: OccurrenceDetails +}) => { + const { projectId } = useParams() + const [mergeOpen, setMergeOpen] = useState(false) + const [scope, setScope] = useState(DEFAULT_SCOPE) + + const merge = useMergeOccurrences(occurrence.id) + const { + setGroupingVerified, + error: verifyError, + isLoading: verifyLoading, + } = useSetGroupingVerified(occurrence.id) + + const mergeScope = + MERGE_SCOPES.find((option) => option.key === scope) ?? MERGE_SCOPES[0] + + const { + candidates, + costThreshold, + error: candidatesError, + isLoading: candidatesLoading, + requiresFeatures, + } = useMergeCandidates({ + captures: mergeScope.captures, + enabled: mergeOpen, + minutes: mergeScope.minutes, + occurrenceId: occurrence.id, + projectId: projectId as string, + }) + + const { + clear: clearSelection, + selectedIds: sourceIds, + toggle: toggleSource, + } = useMergeSelection({ candidates, isLoading: candidatesLoading }) + + const sessionRoute = occurrence.sessionId + ? APP_ROUTES.SESSION_DETAILS({ + projectId: projectId as string, + sessionId: occurrence.sessionId, + }) + : undefined + + // Extending opens the session view on the track's newest frame, where the next + // capture is a click away. + const lastCaptureId = occurrence.lastFrame?.captureId + const extendRoute = + sessionRoute && lastCaptureId + ? getAppRoute({ + to: sessionRoute, + filters: { capture: lastCaptureId, extend: occurrence.id }, + }) + : undefined + + const candidateSessionLink = (candidate: MergeCandidate) => + sessionRoute && candidate.captureId + ? getCandidateSessionRoute({ + captureId: candidate.captureId, + occurrenceIds: [occurrence.id, candidate.id], + sessionRoute, + }) + : undefined + + const verifiedBy = occurrence.groupingVerifiedBy + const verifiedAt = occurrence.groupingVerifiedAt + const verifyErrorMessage = verifyError + ? parseServerError(verifyError).message + : undefined + + const closeMerge = () => { + setMergeOpen(false) + clearSelection() + merge.reset() + } + + return ( +
+ {occurrence.groupingVerified ? ( +
+ + + {translate(STRING.TRACK_GROUPING_CONFIRMED_BY, { + date: verifiedAt + ? getFormatedDateTimeString({ date: verifiedAt }) + : translate(STRING.VALUE_NOT_AVAILABLE), + name: verifiedBy?.name ?? translate(STRING.ANONYMOUS_USER), + })} + +
+ ) : ( + + {translate(STRING.TRACK_GROUPING_NOT_CONFIRMED)} + + )} +
+ {canRestructure && ( + + )} + {canRestructure && extendRoute ? ( + + + {translate(STRING.TRACK_EXTEND_IN_SESSION)} + + ) : null} + {canVerify && ( + + )} +
+ {verifyErrorMessage ? : null} + + merge.mergeOccurrences(sourceIds)} + onOpenChange={(open) => (open ? undefined : closeMerge())} + open={mergeOpen} + result={ + merge.result + ? translate(STRING.TRACK_MERGE_RESULT, { + count: merge.result.detections_count, + }) + : undefined + } + title={translate(STRING.TRACK_MERGE)} + > + {merge.result ? null : ( + + )} + +
+ ) +} diff --git a/ui/src/pages/occurrence-details/track/track-edit-rights.test.ts b/ui/src/pages/occurrence-details/track/track-edit-rights.test.ts new file mode 100644 index 000000000..82f9e99b4 --- /dev/null +++ b/ui/src/pages/occurrence-details/track/track-edit-rights.test.ts @@ -0,0 +1,29 @@ +import { UserPermission } from 'utils/user/types' +import { getTrackEditRights } from './track-edit-rights' + +describe('getTrackEditRights', () => { + test('grants nothing without occurrence rights, as for an anonymous visitor', () => { + expect(getTrackEditRights([])).toEqual({ + canRestructure: false, + canVerify: false, + }) + expect(getTrackEditRights(undefined)).toEqual({ + canRestructure: false, + canVerify: false, + }) + }) + + test('lets the update right confirm a grouping but not restructure it', () => { + expect(getTrackEditRights([UserPermission.Update])).toEqual({ + canRestructure: false, + canVerify: true, + }) + }) + + test('lets the delete right restructure and confirm', () => { + expect(getTrackEditRights([UserPermission.Delete])).toEqual({ + canRestructure: true, + canVerify: true, + }) + }) +}) diff --git a/ui/src/pages/occurrence-details/track/track-edit-rights.ts b/ui/src/pages/occurrence-details/track/track-edit-rights.ts new file mode 100644 index 000000000..e33255221 --- /dev/null +++ b/ui/src/pages/occurrence-details/track/track-edit-rights.ts @@ -0,0 +1,24 @@ +import { UserPermission } from 'utils/user/types' + +export interface TrackEditRights { + /** Split, merge, move and extend a track. */ + canRestructure: boolean + /** Confirm or withdraw a track's grouping. */ + canVerify: boolean +} + +/** + * Mirrors the server: restructuring needs the occurrence delete right, confirming + * either right. See #1272. + */ +export const getTrackEditRights = ( + userPermissions: UserPermission[] = [] +): TrackEditRights => { + const canRestructure = userPermissions.includes(UserPermission.Delete) + + return { + canRestructure, + canVerify: + canRestructure || userPermissions.includes(UserPermission.Update), + } +} diff --git a/ui/src/pages/occurrence-details/track/types.ts b/ui/src/pages/occurrence-details/track/types.ts new file mode 100644 index 000000000..9df8974b3 --- /dev/null +++ b/ui/src/pages/occurrence-details/track/types.ts @@ -0,0 +1,14 @@ +export type FrameAction = 'split' | 'remove' | 'move' + +/** + * A frame action described in full when it was asked for: the dialog outlives the + * frame, since the edit moves that frame off this occurrence. + */ +export interface PendingFrameAction { + action: FrameAction + detectionId: string + /** Frames a split would move: this frame and every later one. */ + movedBySplit: number + timeLabel: string + total: number +} diff --git a/ui/src/pages/occurrences/adjacent-occurrences.test.ts b/ui/src/pages/occurrences/adjacent-occurrences.test.ts new file mode 100644 index 000000000..a9b4c7d8e --- /dev/null +++ b/ui/src/pages/occurrences/adjacent-occurrences.test.ts @@ -0,0 +1,21 @@ +import { getAdjacentIds } from './adjacent-occurrences' + +const items = [{ id: '1' }, { id: '2' }, { id: '3' }] + +describe('getAdjacentIds', () => { + test('returns both neighbours in the middle of the list', () => { + expect(getAdjacentIds(items, '2')).toEqual({ prevId: '1', nextId: '3' }) + }) + + test('has no next id on the last item', () => { + expect(getAdjacentIds(items, '3')).toEqual({ + prevId: '2', + nextId: undefined, + }) + }) + + test('returns nothing when the current item is not in the list', () => { + expect(getAdjacentIds(items, '9')).toEqual({}) + expect(getAdjacentIds(undefined, '1')).toEqual({}) + }) +}) diff --git a/ui/src/pages/occurrences/adjacent-occurrences.ts b/ui/src/pages/occurrences/adjacent-occurrences.ts new file mode 100644 index 000000000..bbdb2aa59 --- /dev/null +++ b/ui/src/pages/occurrences/adjacent-occurrences.ts @@ -0,0 +1,12 @@ +/** The items either side of the current one, or undefined at an end or when it is not listed. */ +export const getAdjacentIds = ( + items: { id: string }[] | undefined, + currentId: string | undefined +): { prevId?: string; nextId?: string } => { + const index = items?.findIndex((item) => item.id === currentId) ?? -1 + if (!items || index < 0) { + return {} + } + + return { prevId: items[index - 1]?.id, nextId: items[index + 1]?.id } +} diff --git a/ui/src/pages/occurrences/occurrence-actions.tsx b/ui/src/pages/occurrences/occurrence-actions.tsx index 44eb79c1a..579794759 100644 --- a/ui/src/pages/occurrences/occurrence-actions.tsx +++ b/ui/src/pages/occurrences/occurrence-actions.tsx @@ -42,21 +42,25 @@ export const OccurrenceActions = ({ ) : null} {showActions && canUpdate ? ( <> - + {item.determinationTaxon ? ( + + ) : null} ) : null} diff --git a/ui/src/pages/occurrences/occurrence-columns.tsx b/ui/src/pages/occurrences/occurrence-columns.tsx index 2d8ea97b9..a1a3f8b39 100644 --- a/ui/src/pages/occurrences/occurrence-columns.tsx +++ b/ui/src/pages/occurrences/occurrence-columns.tsx @@ -144,6 +144,14 @@ export const columns = ({ name: translate(STRING.FIELD_LABEL_TIME_OBSERVED), renderCell: (item: Occurrence) => , }, + { + id: 'detections', + name: translate(STRING.FIELD_LABEL_DETECTIONS), + sortField: 'detections_count', + renderCell: (item: Occurrence) => ( + + ), + }, { id: 'duration', name: translate(STRING.FIELD_LABEL_DURATION), @@ -154,6 +162,36 @@ export const columns = ({ /> ), }, + { + id: 'motion', + name: translate(STRING.FIELD_LABEL_MOTION), + sortField: 'track_motion', + renderCell: (item: Occurrence) => ( + + ), + }, + { + id: 'size-change', + name: translate(STRING.FIELD_LABEL_SIZE_CHANGE), + sortField: 'track_size_ratio', + renderCell: (item: Occurrence) => ( + + ), + }, + { + id: 'id-agreement', + name: translate(STRING.FIELD_LABEL_ID_AGREEMENT), + sortField: 'track_id_agreement', + renderCell: (item: Occurrence) => ( + + ), + }, { id: 'created-at', name: translate(STRING.FIELD_LABEL_CREATED_AT), diff --git a/ui/src/pages/occurrences/occurrence-details-dialog.tsx b/ui/src/pages/occurrences/occurrence-details-dialog.tsx index 8362cdfd9..6d4e2dfcd 100644 --- a/ui/src/pages/occurrences/occurrence-details-dialog.tsx +++ b/ui/src/pages/occurrences/occurrence-details-dialog.tsx @@ -1,4 +1,5 @@ import { useOccurrenceDetails } from 'data-services/hooks/occurrences/useOccurrenceDetails' +import _ from 'lodash' import { Dialog } from 'nova-ui-kit' import { OccurrenceDetails, @@ -9,18 +10,32 @@ import { useLocation } from 'react-router-dom' import { BreadcrumbContext } from 'utils/breadcrumbContext' import { STRING, translate } from 'utils/language' import { useSelectedView } from 'utils/useSelectedView' -import { OccurrenceNavigation } from './occurrence-navigation' +import { + OccurrenceNavigation, + useOccurrenceNavigation, +} from './occurrence-navigation' +import { useAdvanceOnConfirm } from './use-advance-on-confirm' +import { useListSnapshot } from './use-list-snapshot' // Occurrence identification modal. Rendered over a list (occurrences or taxa); // the parent owns which occurrence is shown and how closing updates the URL. export const OccurrenceDetailsDialog = ({ + advanceOnConfirm, id, + listKey, occurrences, onClose, onNavigate, defaultTab = TABS.FIELDS, }: { + // Confirming the determination moves on to the next occurrence, or closes the + // dialog after the last one, so reviewing a list is one click per occurrence. + advanceOnConfirm?: boolean id: string + // Identifies the list's page, filters and sort. When set, prev/next and advance follow the + // order the list had when opened or last changed by the user, not a background refetch. + // Omit it only when the list's ids are meant to change under the dialog, as on the taxa list. + listKey?: string // Ordered items the prev/next buttons page through. Only the id is used. occurrences?: { id: string }[] onClose: () => void @@ -35,6 +50,22 @@ export const OccurrenceDetailsDialog = ({ const { selectedView, setSelectedView } = useSelectedView(defaultTab, 'tab') const { setDetailBreadcrumb } = useContext(BreadcrumbContext) const { occurrence, isLoading, error } = useOccurrenceDetails(id) + const navItems = useListSnapshot(occurrences, listKey) + const navigation = useOccurrenceNavigation(navItems, id, onNavigate) + // Clears ?tab= too, so the next occurrence opened from the list starts on the default tab. + const handleClose = () => { + setSelectedView(undefined) + onClose() + } + const advance = useAdvanceOnConfirm({ + close: handleClose, + currentId: id, + goTo: navigation.goTo, + items: navItems, + }) + const detailsLabel = translate(STRING.ENTITY_DETAILS, { + type: _.capitalize(translate(STRING.ENTITY_TYPE_OCCURRENCE)), + }) useEffect(() => { // If a default tab is set from router state, set this as active @@ -58,8 +89,7 @@ export const OccurrenceDetailsDialog = ({ open={!!id} onOpenChange={(open) => { if (!open) { - setSelectedView(undefined) - onClose() + handleClose() } }} > @@ -68,18 +98,20 @@ export const OccurrenceDetailsDialog = ({ error={error} isLoading={isLoading} > +
+ + {detailsLabel} + +
{occurrence ? ( ) : null} - + ) diff --git a/ui/src/pages/occurrences/occurrence-gallery.tsx b/ui/src/pages/occurrences/occurrence-gallery.tsx index d57aeb957..bc949264e 100644 --- a/ui/src/pages/occurrences/occurrence-gallery.tsx +++ b/ui/src/pages/occurrences/occurrence-gallery.tsx @@ -136,11 +136,13 @@ export const OccurrenceGallery = ({ className={classNames( 'body-base font-medium text-primary', { - italic: isGenusOrBelow(item.determinationTaxon), + italic: + !!item.determinationTaxon && + isGenusOrBelow(item.determinationTaxon), } )} > - {item.determinationTaxon.name} + {item.determinationLabel}
+ +export const useOccurrenceNavigation = ( items?: NavItem[], currentId?: string, onNavigate?: (id: string) => void @@ -19,10 +24,7 @@ const useOccurrenceNavigation = ( const { projectId, id: routeId } = useParams() const navigate = useNavigate() const activeId = currentId ?? routeId - const currentIndex = items?.findIndex((o) => o.id === activeId) - const hasCurrent = currentIndex !== undefined && currentIndex >= 0 - const prevId = hasCurrent ? items?.[currentIndex - 1]?.id : undefined - const nextId = hasCurrent ? items?.[currentIndex + 1]?.id : undefined + const { prevId, nextId } = getAdjacentIds(items, activeId) const goTo = useCallback( (targetId?: string) => { @@ -54,26 +56,17 @@ const useOccurrenceNavigation = ( return { prevId, nextId, + goTo, goToPrev, goToNext, } } export const OccurrenceNavigation = ({ - occurrences, - currentId, - onNavigate, + navigation: { prevId, nextId, goToPrev, goToNext }, }: { - occurrences?: NavItem[] - currentId?: string - onNavigate?: (id: string) => void + navigation: OccurrenceNavigationState }) => { - const { prevId, nextId, goToPrev, goToNext } = useOccurrenceNavigation( - occurrences, - currentId, - onNavigate - ) - // Listen to key down events useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { diff --git a/ui/src/pages/occurrences/occurrences-actions.tsx b/ui/src/pages/occurrences/occurrences-actions.tsx index c3bfae4d7..de8600f8c 100644 --- a/ui/src/pages/occurrences/occurrences-actions.tsx +++ b/ui/src/pages/occurrences/occurrences-actions.tsx @@ -16,8 +16,16 @@ export const OccurrencesActions = ({ occurrences?: Occurrence[] }) => { const { userInfo } = useUserInfo() + const determined = occurrences.filter( + (occurrence) => !!occurrence.determinationTaxon + ) + const taxa = occurrences.flatMap((occurrence) => + occurrence.determinationTaxon ? [occurrence.determinationTaxon] : [] + ) + // An unidentified occurrence shares no rank with the others, so no common ID applies. + const commonTaxa = taxa.length === occurrences.length ? taxa : [] - const allAgreed = !occurrences.some((occurrence) => { + const allAgreed = !determined.some((occurrence) => { const agreed = userInfo ? occurrence.userAgreed(userInfo.id) : false return !agreed @@ -33,15 +41,15 @@ export const OccurrencesActions = ({ return (
- + {determined.length ? ( + + ) : null} occurrence.id)} /> occurrence.id)} - occurrenceTaxa={occurrences.map( - (occurrence) => occurrence.determinationTaxon - )} + occurrenceTaxa={commonTaxa} />
) @@ -60,22 +68,26 @@ const Agree = ({ const agreeParams: IdentificationFieldValues[] = useMemo( () => - occurrences - .filter((occurrences) => { - const agreed = userInfo?.id - ? userInfo.id === occurrences.determinationVerifiedBy?.id - : false + occurrences.flatMap((occurrence) => { + const agreed = userInfo?.id + ? userInfo.id === occurrence.determinationVerifiedBy?.id + : false + + if (agreed || !occurrence.determinationTaxon) { + return [] + } - return !agreed - }) - .map((occurrence) => ({ - agreeWith: { - identificationId: occurrence.determinationIdentificationId, - predictionId: occurrence.determinationPredictionId, + return [ + { + agreeWith: { + identificationId: occurrence.determinationIdentificationId, + predictionId: occurrence.determinationPredictionId, + }, + occurrenceId: occurrence.id, + taxonId: occurrence.determinationTaxon.id, }, - occurrenceId: occurrence.id, - taxonId: occurrence.determinationTaxon.id, - })), + ] + }), [occurrences, userInfo?.id] ) diff --git a/ui/src/pages/occurrences/occurrences.tsx b/ui/src/pages/occurrences/occurrences.tsx index af53cb098..1a3add0da 100644 --- a/ui/src/pages/occurrences/occurrences.tsx +++ b/ui/src/pages/occurrences/occurrences.tsx @@ -23,6 +23,8 @@ import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' import { useColumnSettings } from 'utils/useColumnSettings' import { useFilters } from 'utils/useFilters' +import { withoutTrackingColumns } from 'utils/project-features/project-features' +import { useProjectFeature } from 'utils/project-features/useProjectFeature' import { usePagination } from 'utils/usePagination' import { useUser } from 'utils/user/userContext' import { useSelectedView } from 'utils/useSelectedView' @@ -40,13 +42,15 @@ export const Occurrences = () => { const { columnSettings, setColumnSettings } = useColumnSettings( 'occurrences', { - batch: true, snapshots: true, id: true, date: true, deployment: true, duration: false, detections: true, + motion: false, + ['size-change']: false, + ['id-agreement']: false, score: true, ['updated-at']: true, } @@ -69,10 +73,14 @@ export const Occurrences = () => { ) const { selectedView, setSelectedView } = useSelectedView('table') const { taxaLists = [] } = useTaxaLists({ projectId: projectId as string }) - const tableColumns = columns({ - projectId: projectId as string, - showActions: selectedItems.length === 0, - }) + const trackingEnabled = useProjectFeature('tracking') + const tableColumns = withoutTrackingColumns( + columns({ + projectId: projectId as string, + showActions: selectedItems.length === 0, + }), + trackingEnabled + ) useEffect(() => { document.getElementById('app')?.scrollTo({ top: 0 }) @@ -103,12 +111,15 @@ export const Occurrences = () => { )} {user.loggedIn && } + {trackingEnabled && } { {id ? ( navigate( diff --git a/ui/src/pages/occurrences/use-advance-on-confirm.test.ts b/ui/src/pages/occurrences/use-advance-on-confirm.test.ts new file mode 100644 index 000000000..683c1ce69 --- /dev/null +++ b/ui/src/pages/occurrences/use-advance-on-confirm.test.ts @@ -0,0 +1,66 @@ +import { renderHook } from '@testing-library/react' +import { useAdvanceOnConfirm } from './use-advance-on-confirm' + +const items = [{ id: '1' }, { id: '2' }] + +const renderAdvance = (currentId: string) => { + const close = jest.fn() + const goTo = jest.fn() + const hook = renderHook( + (props: { currentId: string }) => + useAdvanceOnConfirm({ close, currentId: props.currentId, goTo, items }), + { initialProps: { currentId } } + ) + + return { close, goTo, ...hook } +} + +describe('useAdvanceOnConfirm', () => { + test('moves to the next item after a confirm', () => { + const { close, goTo, result } = renderAdvance('1') + + result.current('1') + + expect(goTo).toHaveBeenCalledWith('2') + expect(close).not.toHaveBeenCalled() + }) + + test('closes after a confirm on the last item', () => { + const { close, goTo, result } = renderAdvance('2') + + result.current('2') + + expect(close).toHaveBeenCalled() + expect(goTo).not.toHaveBeenCalled() + }) + + test('stays on a confirmed item that is not in the list, as from a deep link', () => { + const { close, goTo, result } = renderAdvance('9') + + result.current('9') + + expect(close).not.toHaveBeenCalled() + expect(goTo).not.toHaveBeenCalled() + }) + + test('ignores a confirm that finishes after the user moved to another item', () => { + const { close, goTo, rerender, result } = renderAdvance('1') + + rerender({ currentId: '2' }) + result.current('1') + + expect(goTo).not.toHaveBeenCalled() + expect(close).not.toHaveBeenCalled() + }) + + test('ignores a confirm that finishes after the dialog closed', () => { + const { close, goTo, result, unmount } = renderAdvance('1') + const onConfirmed = result.current + + unmount() + onConfirmed('1') + + expect(goTo).not.toHaveBeenCalled() + expect(close).not.toHaveBeenCalled() + }) +}) diff --git a/ui/src/pages/occurrences/use-advance-on-confirm.ts b/ui/src/pages/occurrences/use-advance-on-confirm.ts new file mode 100644 index 000000000..b1c64a465 --- /dev/null +++ b/ui/src/pages/occurrences/use-advance-on-confirm.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useRef } from 'react' +import { getAdjacentIds } from './adjacent-occurrences' + +// Returns a handler for a finished confirm that moves to the next item, or closes +// after the last one. An unlisted item, or one the user has left, stays put. +export const useAdvanceOnConfirm = ({ + close, + currentId, + goTo, + items, +}: { + close: () => void + currentId: string + goTo: (id: string) => void + items?: { id: string }[] +}) => { + const currentIdRef = useRef() + const latest = useRef({ close, goTo, items }) + latest.current = { close, goTo, items } + + useEffect(() => { + currentIdRef.current = currentId + + return () => { + currentIdRef.current = undefined + } + }, [currentId]) + + return useCallback((confirmedId: string) => { + if (confirmedId !== currentIdRef.current) { + return + } + const { items } = latest.current + const { nextId } = getAdjacentIds(items, confirmedId) + if (nextId) { + latest.current.goTo(nextId) + } else if (items?.[items.length - 1]?.id === confirmedId) { + latest.current.close() + } + }, []) +} diff --git a/ui/src/pages/occurrences/use-list-snapshot.test.ts b/ui/src/pages/occurrences/use-list-snapshot.test.ts new file mode 100644 index 000000000..61d710177 --- /dev/null +++ b/ui/src/pages/occurrences/use-list-snapshot.test.ts @@ -0,0 +1,55 @@ +import { renderHook } from '@testing-library/react' +import { useListSnapshot } from './use-list-snapshot' + +const renderSnapshot = ( + items: { id: string }[] | undefined, + listKey: string | undefined +) => + renderHook( + (props: { items?: { id: string }[]; listKey?: string }) => + useListSnapshot(props.items, props.listKey), + { initialProps: { items, listKey } } + ) + +const ids = (items?: { id: string }[]) => items?.map(({ id }) => id) + +describe('useListSnapshot', () => { + test('keeps the first order when a refetch reorders the same list', () => { + const { result, rerender } = renderSnapshot( + [{ id: '1' }, { id: '2' }, { id: '3' }], + 'a' + ) + + rerender({ items: [{ id: '2' }, { id: '1' }, { id: '3' }], listKey: 'a' }) + + expect(ids(result.current)).toEqual(['1', '2', '3']) + }) + + test('takes a new order when the page, filters or sort change', () => { + const { result, rerender } = renderSnapshot([{ id: '1' }, { id: '2' }], 'a') + + rerender({ items: undefined, listKey: 'b' }) + expect(result.current).toBeUndefined() + + rerender({ items: [{ id: '5' }, { id: '4' }], listKey: 'b' }) + expect(ids(result.current)).toEqual(['5', '4']) + }) + + test('waits for the list to load before taking the order', () => { + const { result, rerender } = renderSnapshot(undefined, 'a') + + rerender({ items: [{ id: '1' }, { id: '2' }], listKey: 'a' }) + + expect(ids(result.current)).toEqual(['1', '2']) + }) + + test('follows the live list when no key is given', () => { + const items = [{ id: '1' }, { id: '2' }] + const { result, rerender } = renderSnapshot(items, undefined) + expect(result.current).toBe(items) + + const reordered = [{ id: '2' }, { id: '1' }] + rerender({ items: reordered, listKey: undefined }) + expect(result.current).toBe(reordered) + }) +}) diff --git a/ui/src/pages/occurrences/use-list-snapshot.ts b/ui/src/pages/occurrences/use-list-snapshot.ts new file mode 100644 index 000000000..75dc64c93 --- /dev/null +++ b/ui/src/pages/occurrences/use-list-snapshot.ts @@ -0,0 +1,25 @@ +import { useState } from 'react' + +// Returns the list order as it was when first loaded for this key. A background refetch +// can reorder the list (a confirmed occurrence jumps to the top of "recently updated"), +// which would otherwise send Previous somewhere other than the item just left. +// Without a key the live list is returned as is and nothing is copied. +export const useListSnapshot = ( + items: { id: string }[] | undefined, + listKey: string | undefined +) => { + const [snapshot, setSnapshot] = useState<{ + items: { id: string }[] + listKey: string + }>() + + if (listKey === undefined) { + return items + } + + if (items && snapshot?.listKey !== listKey) { + setSnapshot({ items: items.map(({ id }) => ({ id })), listKey }) + } + + return snapshot?.listKey === listKey ? snapshot.items : items +} diff --git a/ui/src/pages/project/entities/details-form/export-details-form.tsx b/ui/src/pages/project/entities/details-form/export-details-form.tsx index 4b35918af..a77058fe9 100644 --- a/ui/src/pages/project/entities/details-form/export-details-form.tsx +++ b/ui/src/pages/project/entities/details-form/export-details-form.tsx @@ -6,13 +6,14 @@ import { FormSection, } from 'components/form/layout/layout' import { FormConfig } from 'components/form/types' -import { Export, SERVER_EXPORT_TYPES } from 'data-services/models/export' +import { Export, getExportTypes } from 'data-services/models/export' import { InputContent, SaveButton, Select } from 'nova-ui-kit' import { CaptureSetPicker } from 'nova-ui-kit/components/select/capture-set-picker' import { useForm } from 'react-hook-form' import { useParams } from 'react-router-dom' import { APP_ROUTES } from 'utils/constants' import { STRING, translate } from 'utils/language' +import { useProjectFeature } from 'utils/project-features/useProjectFeature' import { useFormError } from 'utils/useFormError' import { DetailsFormProps, FormValues } from './types' @@ -134,17 +135,21 @@ export const TypePicker = ({ }: { value?: string onValueChange: (value?: string) => void -}) => ( - - - - - - {SERVER_EXPORT_TYPES.map((key) => ( - - {Export.getExportTypeInfo(key).label} - - ))} - - -) +}) => { + const trackingEnabled = useProjectFeature('tracking') + + return ( + + + + + + {getExportTypes({ trackingEnabled }).map((key) => ( + + {Export.getExportTypeInfo(key).label} + + ))} + + + ) +} diff --git a/ui/src/pages/project/summary/summary.tsx b/ui/src/pages/project/summary/summary.tsx index 54af9fd09..1214f1a6d 100644 --- a/ui/src/pages/project/summary/summary.tsx +++ b/ui/src/pages/project/summary/summary.tsx @@ -152,7 +152,7 @@ const LatestOccurrences = ({ projectId }: { projectId: string }) => { item={{ image: { src: occurrence.images[0]?.src }, text: occurrence.dateLabel, - title: occurrence.determinationTaxon.name, + title: occurrence.determinationLabel, }} /> diff --git a/ui/src/pages/session-details/activity-plot/activity-plot.tsx b/ui/src/pages/session-details/activity-plot/activity-plot.tsx index 711c11a1b..6c65d6119 100644 --- a/ui/src/pages/session-details/activity-plot/activity-plot.tsx +++ b/ui/src/pages/session-details/activity-plot/activity-plot.tsx @@ -1,9 +1,10 @@ import { SessionDetails } from 'data-services/models/session-details' import { TimelineTick } from 'data-services/models/timeline-tick' import { CONSTANTS } from 'nova-ui-kit' -import { useRef } from 'react' +import { ReactNode, useId, useRef } from 'react' import Plot from 'react-plotly.js' import { getCompactTimespanString } from 'utils/date/getCompactTimespanString/getCompactTimespanString' +import { STRING, translate } from 'utils/language' import { findClosestCaptureId } from '../utils' import { useDynamicPlotWidth } from './useDynamicPlotWidth' @@ -17,17 +18,21 @@ const tooltipBgColor = CONSTANTS.COLOR_THEME.background const tooltipBorderColor = CONSTANTS.COLOR_THEME.border export interface ActivityPlotProps { + /** Drawn over the plot, which spans the session from start to end. */ + children?: ReactNode session: SessionDetails setActiveCaptureId: (captureId: string) => void timeline: TimelineTick[] } export const ActivityPlot = ({ + children, session, timeline, setActiveCaptureId, }: ActivityPlotProps) => { const containerRef = useRef(null) + const descriptionId = useId() const width = useDynamicPlotWidth(containerRef) // Calculate the average number of captures @@ -44,8 +49,16 @@ export const ActivityPlot = ({ const yAxisMax = avgCaptures + maxDeviation return ( -
-
+
+ + {translate(STRING.TIMELINE_ACTIVITY_DESCRIPTION)} + +
+ {children}
) diff --git a/ui/src/pages/session-details/activity-plot/occurrence-timeline-markers.tsx b/ui/src/pages/session-details/activity-plot/occurrence-timeline-markers.tsx new file mode 100644 index 000000000..8a6494709 --- /dev/null +++ b/ui/src/pages/session-details/activity-plot/occurrence-timeline-markers.tsx @@ -0,0 +1,180 @@ +import { useOccurrencePath } from 'data-services/hooks/occurrences/useOccurrencePath' +import { SessionDetails } from 'data-services/models/session-details' +import { TimelineTick } from 'data-services/models/timeline-tick' +import { BasicTooltip, CONSTANTS } from 'nova-ui-kit' +import { MouseEvent, useMemo, useState } from 'react' +import { getFormatedTimeString } from 'utils/date/getFormatedTimeString/getFormatedTimeString' +import { STRING, translate } from 'utils/language' +import { dateToValue } from '../utils' +import { + buildOccurrenceTimeline, + getDurationLabel, +} from './occurrence-timeline' + +// Nova's Emerald first: the plot's detection spikes are blue, so a blue lane would blend in. +const LANE_COLORS = [ + CONSTANTS.COLORS.success[500], + CONSTANTS.COLORS.warning[600], + CONSTANTS.COLORS.alert[700], + CONSTANTS.COLORS.primary[500], +] + +// Each block reaches this far past its outer spikes, so a lone frame stays visible. +const BLOCK_PADDING_PX = 3 + +interface OccurrenceTimelineMarkersProps { + occurrenceIds: string[] + session: SessionDetails + setActiveCaptureId: (captureId: string) => void + timeline: TimelineTick[] +} + +/** One lane per occurrence showing the captures it appears in, over the activity plot. */ +export const OccurrenceTimelineMarkers = ({ + occurrenceIds, + ...props +}: OccurrenceTimelineMarkersProps) => { + // Activating a marker moves the capture view while focus stays on the marker, so + // nothing else would tell a screen reader where it landed. + const [announcement, setAnnouncement] = useState('') + + return ( +
+ + {announcement} + + {occurrenceIds.map((occurrenceId, index) => ( + + ))} +
+ ) +} + +interface BlockFrame { + captureId: string + left: number + time: string +} + +// A click goes to the frame nearest the pointer; a key press goes to the first frame. +const getClickedFrame = ( + frames: BlockFrame[], + event: MouseEvent +) => { + const lane = event.currentTarget.parentElement?.getBoundingClientRect() + + if (!lane || event.detail === 0) { + return frames[0] + } + + const pointer = ((event.clientX - lane.left) / lane.width) * 100 + + return frames.reduce((nearest, frame) => + Math.abs(frame.left - pointer) < Math.abs(nearest.left - pointer) + ? frame + : nearest + ) +} + +const OccurrenceLane = ({ + color, + occurrenceId, + onAnnounce, + session, + setActiveCaptureId, + timeline, +}: Omit & { + color: string + occurrenceId: string + onAnnounce: (announcement: string) => void +}) => { + const { path } = useOccurrencePath(occurrenceId, true) + + const blocks = useMemo(() => { + if (!path) { + return undefined + } + + const toPercent = (date: Date) => + dateToValue({ + date, + startDate: session.startDate, + endDate: session.endDate, + }) + const formatTime = (date: Date) => + getFormatedTimeString({ date, options: { second: true } }) + + return buildOccurrenceTimeline(path, timeline).map((span) => { + const first = span.frames[0] + const last = span.frames[span.frames.length - 1] + + return { + frames: span.frames.map((frame) => ({ + captureId: frame.captureId, + left: toPercent(frame.date), + time: formatTime(frame.timestamp), + })), + key: first.captureId, + label: + span.frames.length === 1 + ? translate(STRING.TIMELINE_OCCURRENCE_FRAME, { + id: occurrenceId, + time: formatTime(first.timestamp), + }) + : translate(STRING.TIMELINE_OCCURRENCE_SPAN, { + count: String(span.frames.length), + duration: getDurationLabel( + last.timestamp.getTime() - first.timestamp.getTime() + ), + id: occurrenceId, + }), + left: toPercent(span.start), + right: toPercent(span.end), + } + }) + }, [path, timeline, session, occurrenceId]) + + if (!blocks?.length) { + return null + } + + return ( +
+ {blocks.map((block) => ( + +
+ ) +} diff --git a/ui/src/pages/session-details/activity-plot/occurrence-timeline.test.ts b/ui/src/pages/session-details/activity-plot/occurrence-timeline.test.ts new file mode 100644 index 000000000..5cf6532d2 --- /dev/null +++ b/ui/src/pages/session-details/activity-plot/occurrence-timeline.test.ts @@ -0,0 +1,103 @@ +import { TimelineTick } from 'data-services/models/timeline-tick' +import { + buildOccurrenceTimeline, + TimelineSpan, + getDurationLabel, +} from './occurrence-timeline' + +const at = (minute: number, second = 0) => + new Date(2026, 8, 1, 22, minute, second) + +// One-minute ticks from 22:00, the resolution the session timeline uses by default. +const buildTimeline = (capturesPerMinute: number[]) => + capturesPerMinute.map( + (captures, minute) => + new TimelineTick({ + start: at(minute).toISOString(), + end: at(minute + 1).toISOString(), + first_capture: captures ? { id: minute } : null, + top_capture: null, + captures_count: captures, + detections_count: 0, + detections_avg: 0, + }) + ) + +// A capture half-way through the tick for its minute. +const frame = (minute: number) => ({ + captureId: `c${minute}`, + timestamp: at(minute, 30), +}) + +// A capture every other minute from 22:00 to 22:10. +const TIMELINE = buildTimeline([1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]) + +const bounds = (spans: TimelineSpan[]) => + spans.map(({ start, end }) => ({ start, end })) + +describe('buildOccurrenceTimeline', () => { + test('a lone frame is a span on its own capture spike', () => { + expect(buildOccurrenceTimeline([frame(4)], TIMELINE)).toEqual([ + { + start: at(4), + end: at(4), + frames: [{ captureId: 'c4', date: at(4), timestamp: at(4, 30) }], + }, + ]) + }) + + test('frames in consecutive captures share one span across empty ticks', () => { + const spans = buildOccurrenceTimeline( + [frame(2), frame(4), frame(6)], + TIMELINE + ) + + expect(bounds(spans)).toEqual([{ start: at(2), end: at(6) }]) + expect(spans[0].frames.map((f) => f.captureId)).toEqual(['c2', 'c4', 'c6']) + }) + + test('a capture outside the occurrence splits its frames into two spans', () => { + const spans = buildOccurrenceTimeline( + [frame(0), frame(2), frame(6), frame(8)], + TIMELINE + ) + + expect(bounds(spans)).toEqual([ + { start: at(0), end: at(2) }, + { start: at(6), end: at(8) }, + ]) + }) + + test('another capture in a tick that holds a frame does not split the span', () => { + const spans = buildOccurrenceTimeline( + [frame(0), frame(1)], + buildTimeline([2, 2]) + ) + + expect(bounds(spans)).toEqual([{ start: at(0), end: at(1) }]) + }) + + test('frames outside the timeline are left off', () => { + const spans = buildOccurrenceTimeline( + [ + { captureId: 'before', timestamp: new Date(2026, 8, 1, 21, 50) }, + frame(0), + frame(2), + { captureId: 'after', timestamp: at(30) }, + { captureId: 'undated', timestamp: null }, + ], + TIMELINE + ) + + expect(bounds(spans)).toEqual([{ start: at(0), end: at(2) }]) + expect(spans[0].frames.map((f) => f.captureId)).toEqual(['c0', 'c2']) + }) +}) + +describe('getDurationLabel', () => { + test('rounds to seconds, minutes, or hours and minutes', () => { + expect(getDurationLabel(2_000)).toBe('2 s') + expect(getDurationLabel((8 * 60 + 22) * 1000)).toBe('8 min') + expect(getDurationLabel(72 * 60 * 1000)).toBe('1 h 12 min') + }) +}) diff --git a/ui/src/pages/session-details/activity-plot/occurrence-timeline.ts b/ui/src/pages/session-details/activity-plot/occurrence-timeline.ts new file mode 100644 index 000000000..3b8519f66 --- /dev/null +++ b/ui/src/pages/session-details/activity-plot/occurrence-timeline.ts @@ -0,0 +1,120 @@ +import { PathFrame } from 'data-services/models/occurrence-path' +import { TimelineTick } from 'data-services/models/timeline-tick' +import { STRING, translate } from 'utils/language' + +export interface TimelineFrame { + captureId: string + /** Start of the tick holding the capture, which is where the plot draws its spike. */ + date: Date + timestamp: Date +} + +/** A run of frames drawn as one block, from the first frame's spike to the last one's. */ +export interface TimelineSpan { + end: Date + frames: TimelineFrame[] + start: Date +} + +// A tick holds the captures after its start up to and including its end; the first +// tick also holds its start. +const findTickIndex = (timeline: TimelineTick[], date: Date) => { + const time = date.getTime() + const last = timeline[timeline.length - 1] + + if ( + !last || + time < timeline[0].startDate.getTime() || + time > last.endDate.getTime() + ) { + return -1 + } + + let low = 0 + let high = timeline.length - 1 + + while (low < high) { + const middle = Math.floor((low + high) / 2) + + if (timeline[middle].endDate.getTime() < time) { + low = middle + 1 + } else { + high = middle + } + } + + return low +} + +/** + * Where an occurrence's frames fall on the session timeline, grouped into runs. A run + * continues across ticks with no captures and breaks at a tick that holds captures but + * none of the occurrence's frames. + */ +export const buildOccurrenceTimeline = ( + frames: Pick[], + timeline: TimelineTick[] +): TimelineSpan[] => { + const byCapture = new Map() + + frames.forEach(({ captureId, timestamp }) => { + const tick = timestamp ? findTickIndex(timeline, timestamp) : -1 + + if (timestamp && tick !== -1 && !byCapture.has(captureId)) { + byCapture.set(captureId, { + frame: { captureId, date: timeline[tick].startDate, timestamp }, + tick, + }) + } + }) + + const placed = [...byCapture.values()].sort( + (a, b) => a.frame.timestamp.getTime() - b.frame.timestamp.getTime() + ) + + // Ticks only count their captures, so a tick that also holds a frame never breaks a run. + const hasCaptureBetween = (fromTick: number, toTick: number) => + timeline.slice(fromTick + 1, toTick).some((tick) => tick.numCaptures > 0) + + const spans: TimelineSpan[] = [] + let previousTick = -1 + + placed.forEach(({ frame, tick }) => { + const current = spans[spans.length - 1] + + if (current && !hasCaptureBetween(previousTick, tick)) { + current.frames.push(frame) + current.end = frame.date + } else { + spans.push({ end: frame.date, frames: [frame], start: frame.date }) + } + + previousTick = tick + }) + + return spans +} + +/** A short, rounded length of time, such as "42 s", "8 min" or "1 h 12 min". */ +export const getDurationLabel = (milliseconds: number) => { + const seconds = Math.round(milliseconds / 1000) + + if (seconds < 60) { + return translate(STRING.TIMELINE_DURATION_SECONDS, { + seconds: String(seconds), + }) + } + + const minutes = Math.round(seconds / 60) + + if (minutes < 60) { + return translate(STRING.TIMELINE_DURATION_MINUTES, { + minutes: String(minutes), + }) + } + + return translate(STRING.TIMELINE_DURATION_HOURS, { + hours: String(Math.floor(minutes / 60)), + minutes: String(minutes % 60), + }) +} diff --git a/ui/src/pages/session-details/activity-plot/timeline-navigation.test.ts b/ui/src/pages/session-details/activity-plot/timeline-navigation.test.ts new file mode 100644 index 000000000..f713ef7fd --- /dev/null +++ b/ui/src/pages/session-details/activity-plot/timeline-navigation.test.ts @@ -0,0 +1,100 @@ +import { TimelineTick } from 'data-services/models/timeline-tick' +import { + getCaptureIndex, + getKeyStep, + getNavigableCaptures, + getStepTarget, +} from './timeline-navigation' + +const at = (minute: number, second = 0) => + new Date(2026, 8, 1, 22, minute, second) + +const tick = (minute: number, captureId?: string) => + ({ + startDate: at(minute), + endDate: at(minute + 1), + representativeCaptureId: captureId, + } as TimelineTick) + +// A capture every other minute, with an empty minute in between. +const TIMELINE = [tick(0, 'c0'), tick(1), tick(2, 'c2'), tick(3, 'c3')] +const CAPTURES = getNavigableCaptures(TIMELINE) + +describe('getNavigableCaptures', () => { + test('a tick holding no capture is not a stop', () => { + expect(CAPTURES.map((capture) => capture.captureId)).toEqual([ + 'c0', + 'c2', + 'c3', + ]) + }) +}) + +describe('getCaptureIndex', () => { + test('a capture just past a stop keeps that position', () => { + expect( + getCaptureIndex({ + captureId: 'c2-10', + captures: CAPTURES, + date: at(2, 10), + }) + ).toBe(1) + }) + + // Taking the stop it is nearest, rather than the one it follows, keeps a step back + // on the stop it just passed instead of skipping over it. + test('a capture nearer the next stop takes that position', () => { + expect( + getCaptureIndex({ + captureId: 'c2-40', + captures: CAPTURES, + date: at(2, 40), + }) + ).toBe(2) + }) + + test('an unknown capture with no date has no position', () => { + expect(getCaptureIndex({ captureId: 'gone', captures: CAPTURES })).toBe(-1) + }) +}) + +describe('getStepTarget', () => { + test('stepping stops at the ends rather than running off them', () => { + expect( + getStepTarget({ captures: CAPTURES, index: 0, step: -1 })?.captureId + ).toBe('c0') + expect( + getStepTarget({ captures: CAPTURES, index: 2, step: 1 })?.captureId + ).toBe('c3') + }) + + test('home and end reach the first and last capture in one press', () => { + expect( + getStepTarget({ + captures: CAPTURES, + index: 2, + step: getKeyStep('Home') as number, + })?.captureId + ).toBe('c0') + expect( + getStepTarget({ + captures: CAPTURES, + index: 0, + step: getKeyStep('End') as number, + })?.captureId + ).toBe('c3') + }) + + test('an unknown position leaves the capture alone', () => { + expect( + getStepTarget({ captures: CAPTURES, index: -1, step: 1 }) + ).toBeUndefined() + }) +}) + +describe('getKeyStep', () => { + test('a key the timeline does not claim is left to the browser', () => { + expect(getKeyStep('Enter')).toBeUndefined() + expect(getKeyStep('Tab')).toBeUndefined() + }) +}) diff --git a/ui/src/pages/session-details/activity-plot/timeline-navigation.ts b/ui/src/pages/session-details/activity-plot/timeline-navigation.ts new file mode 100644 index 000000000..657827b1a --- /dev/null +++ b/ui/src/pages/session-details/activity-plot/timeline-navigation.ts @@ -0,0 +1,86 @@ +import { TimelineTick } from 'data-services/models/timeline-tick' + +/** A capture a keyboard user can step to, in session order. */ +export interface NavigableCapture { + captureId: string + date: Date +} + +// Page keys cross a long night in a handful of presses. +const CAPTURES_PER_PAGE = 10 + +const STEP_BY_KEY: { [key: string]: number } = { + ArrowDown: -1, + ArrowLeft: -1, + ArrowRight: 1, + ArrowUp: 1, + End: Infinity, + Home: -Infinity, + PageDown: -CAPTURES_PER_PAGE, + PageUp: CAPTURES_PER_PAGE, +} + +/** How many captures a key moves, or undefined for a key the timeline leaves alone. */ +export const getKeyStep = (key: string): number | undefined => STEP_BY_KEY[key] + +/** The timeline keeps one capture per tick, so those are the stops along the session. */ +export const getNavigableCaptures = ( + timeline: TimelineTick[] +): NavigableCapture[] => + timeline.flatMap((tick) => + tick.representativeCaptureId + ? [{ captureId: tick.representativeCaptureId, date: tick.startDate }] + : [] + ) + +/** + * Where the active capture sits among the stops. A capture reached with the previous + * and next buttons is often not a stop itself, so it falls back to the nearest stop in + * time and reports -1 only when there is nothing to match on. + */ +export const getCaptureIndex = ({ + captureId, + captures, + date, +}: { + captureId?: string + captures: NavigableCapture[] + date?: Date +}) => { + const exact = captures.findIndex((capture) => capture.captureId === captureId) + + if (exact !== -1 || !date) { + return exact + } + + let closest = -1 + let smallestDifference = Infinity + + captures.forEach((capture, index) => { + const difference = Math.abs(capture.date.getTime() - date.getTime()) + + if (difference < smallestDifference) { + smallestDifference = difference + closest = index + } + }) + + return closest +} + +/** The capture a step lands on, held inside the ends of the session. */ +export const getStepTarget = ({ + captures, + index, + step, +}: { + captures: NavigableCapture[] + index: number + step: number +}): NavigableCapture | undefined => { + if (index === -1 || !captures.length) { + return undefined + } + + return captures[Math.min(Math.max(index + step, 0), captures.length - 1)] +} diff --git a/ui/src/pages/session-details/capture-info.tsx b/ui/src/pages/session-details/capture-info.tsx index 30638bd65..0f469ee64 100644 --- a/ui/src/pages/session-details/capture-info.tsx +++ b/ui/src/pages/session-details/capture-info.tsx @@ -4,9 +4,26 @@ import { useParams } from 'react-router-dom' import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' +import { useProjectFeature } from 'utils/project-features/useProjectFeature' export const CaptureInfo = ({ capture }: { capture: CaptureDetails }) => { const { projectId } = useParams() + const trackingEnabled = useProjectFeature('tracking') + const { detectionsValid, detectionsWithFeatures } = capture + // Only worth a row when a box is missing a vector; tracking skips those boxes. + const vectorField = + trackingEnabled && + detectionsValid !== undefined && + detectionsWithFeatures !== undefined && + detectionsWithFeatures < detectionsValid + ? { + label: translate(STRING.FIELD_LABEL_BOXES_WITH_VECTORS), + value: translate(STRING.VALUE_COUNT_OF_TOTAL, { + count: detectionsWithFeatures, + total: detectionsValid, + }), + } + : undefined const fields = [ { @@ -41,6 +58,7 @@ export const CaptureInfo = ({ capture }: { capture: CaptureDetails }) => { label: translate(STRING.FIELD_LABEL_TAXA), value: capture.numTaxa, }, + ...(vectorField ? [vectorField] : []), ] return diff --git a/ui/src/pages/session-details/capture-navigation.tsx b/ui/src/pages/session-details/capture-navigation.tsx index d918d628a..e3f1ba64a 100644 --- a/ui/src/pages/session-details/capture-navigation.tsx +++ b/ui/src/pages/session-details/capture-navigation.tsx @@ -7,9 +7,36 @@ import { ChevronsRightIcon, } from 'lucide-react' import { BasicTooltip, Button } from 'nova-ui-kit' -import { useEffect, useState } from 'react' +import { ReactNode, useEffect, useState } from 'react' import { STRING, translate } from 'utils/language' -import { findClosestCaptureId } from './utils' +import { + getNextCaptureWithDetectionsId, + getPrevCaptureWithDetectionsId, +} from './utils' + +const NavigationButton = ({ + children, + disabled, + label, + onClick, +}: { + children: ReactNode + disabled: boolean + label: string + onClick: () => void +}) => ( + + + +) export const CaptureNavigation = ({ activeCapture, @@ -46,12 +73,8 @@ export const CaptureNavigation = ({ } const prevCaptureId = - findClosestCaptureId({ - maxDate: activeCapture.date, - snapToDetections: true, - targetDate: activeCapture.date, - timeline, - }) ?? activeCapture.prevCaptureId + getPrevCaptureWithDetectionsId({ capture: activeCapture, timeline }) ?? + activeCapture.prevCaptureId if (prevCaptureId) { setActiveCaptureId(prevCaptureId) @@ -72,12 +95,8 @@ export const CaptureNavigation = ({ } const nextCaptureId = - findClosestCaptureId({ - minDate: activeCapture.date, - snapToDetections: true, - targetDate: activeCapture.date, - timeline, - }) ?? activeCapture.nextCaptureId + getNextCaptureWithDetectionsId({ capture: activeCapture, timeline }) ?? + activeCapture.nextCaptureId if (nextCaptureId) { setActiveCaptureId(nextCaptureId) @@ -103,49 +122,37 @@ export const CaptureNavigation = ({ return (
- - - - + {currentIndex?.toLocaleString()} / {totalCaptures?.toLocaleString()} - - - - + + + +
) } diff --git a/ui/src/pages/session-details/capture/bbox.ts b/ui/src/pages/session-details/capture/bbox.ts new file mode 100644 index 000000000..501437547 --- /dev/null +++ b/ui/src/pages/session-details/capture/bbox.ts @@ -0,0 +1,48 @@ +export interface BoxStyle { + width: string + height: string + top: string + left: string +} + +/** + * Place a detection box over its capture, as percentages. The dimensions must be + * the stored ones for the box's own capture: a box from a neighbouring frame + * scaled by the current capture's dimensions lands in the wrong place. + */ +export const bboxToPercentStyle = ( + bbox: number[], + width?: number | null, + height?: number | null +): BoxStyle | undefined => { + const [boxLeft, boxTop, boxRight, boxBottom] = bbox + + if (!width || !height) { + return undefined + } + + return { + width: `${((boxRight - boxLeft) / width) * 100}%`, + height: `${((boxBottom - boxTop) / height) * 100}%`, + top: `${(boxTop / height) * 100}%`, + left: `${(boxLeft / width) * 100}%`, + } +} + +/** Centre of a detection box in the same percentage space, for drawing a path. */ +export const bboxToPercentCentre = ( + bbox: number[], + width?: number | null, + height?: number | null +): { x: number; y: number } | undefined => { + const [boxLeft, boxTop, boxRight, boxBottom] = bbox + + if (!width || !height) { + return undefined + } + + return { + x: ((boxLeft + boxRight) / 2 / width) * 100, + y: ((boxTop + boxBottom) / 2 / height) * 100, + } +} diff --git a/ui/src/pages/session-details/capture/capture-ghost-trail.tsx b/ui/src/pages/session-details/capture/capture-ghost-trail.tsx new file mode 100644 index 000000000..e8efeba2f --- /dev/null +++ b/ui/src/pages/session-details/capture/capture-ghost-trail.tsx @@ -0,0 +1,193 @@ +import { getOffsetLabel } from 'data-services/models/merge-candidate' +import { Tooltip } from 'nova-ui-kit' +import { useState } from 'react' +import { STRING, translate } from 'utils/language' +import styles from './capture.module.scss' +import { getGhostFrame } from './ghost-frame' +import { Ghost, GHOST_COLOR, MAX_GHOST_BOXES, Trail } from './ghost-trail' + +// A hovered frame comes forward of the other path frames and is drawn in full, which +// is how one is picked out of a stack. It stays under the live boxes all the same. +const HOVERED_GHOST_Z = MAX_GHOST_BOXES + 1 +const HOVERED_GHOST_OPACITY = 1 +// Only the frame either side of the capture on screen is labelled with its gap. Up to +// MAX_GHOST_BOXES frames are drawn and a box is often narrower than the label, so +// labelling them all would rebuild the clutter the distance fade exists to remove. +const LABELLED_DISTANCE = 1 + +/** How far from the capture on screen a frame was taken, as "+20 s" or "−20 s". */ +const gapLabel = (ghost: Ghost) => + ghost.offsetSeconds === null + ? undefined + : translate( + ghost.offsetSeconds < 0 + ? STRING.TRACK_GAP_BEFORE + : STRING.TRACK_GAP_AFTER, + { time: getOffsetLabel(ghost.offsetSeconds) } + ) + +/** Which frame this is, when it was captured, and what clicking it does. */ +const GhostReading = ({ + cropMissing, + ghost, + time, + total, +}: { + /** The crop was offered but could not be fetched, so only the outline is drawn. */ + cropMissing?: boolean + ghost: Ghost + time: string + total: number +}) => ( +
+ + {translate(STRING.TRACK_POSITION_FRAME, { + index: ghost.position, + total, + })} + + + {translate( + ghost.isEarlier ? STRING.TRACK_GHOST_EARLIER : STRING.TRACK_GHOST_LATER, + { time } + )} + + {cropMissing ? ( + + {translate(STRING.TRACK_FRAME_NO_CROP)} + + ) : null} + + {translate(STRING.TRACK_GHOST_HINT)} + +
+) + +export const CaptureGhostTrail = ({ + onSelectFrame, + showCrops, + trail, +}: { + /** Steps the viewer to the frame a ghost box was measured in, keeping the selection. */ + onSelectFrame: (captureId: string) => void + showCrops?: boolean + trail: Trail +}) => { + const [hovered, setHovered] = useState() + // A crop can be offered by the API and still be missing from storage. Falling back to + // the outline keeps the path readable; a broken image icon reads as a broken feature. + const [missingCrops, setMissingCrops] = useState([]) + + const leave = (id: string) => + setHovered((current) => (current === id ? undefined : current)) + + const markMissing = (id: string) => + setMissingCrops((ids) => (ids.includes(id) ? ids : [...ids, id])) + + return ( + <> +
+ {trail.ghosts.map((ghost) => { + const frame = getGhostFrame(ghost.frame, !!showCrops) + const isHovered = hovered === ghost.id + const cropMissing = missingCrops.includes(ghost.id) + const opacity = isHovered ? HOVERED_GHOST_OPACITY : ghost.opacity + const zIndex = isHovered ? HOVERED_GHOST_Z : ghost.zIndex + const gap = + ghost.distance <= LABELLED_DISTANCE ? gapLabel(ghost) : undefined + + return ( + + + + + + + + + + {gap ? ( + + {gap} + + ) : null} + + ) + })} +
+ + + + + + ) +} diff --git a/ui/src/pages/session-details/capture/capture-match-tooltip.tsx b/ui/src/pages/session-details/capture/capture-match-tooltip.tsx new file mode 100644 index 000000000..3cff937e7 --- /dev/null +++ b/ui/src/pages/session-details/capture/capture-match-tooltip.tsx @@ -0,0 +1,165 @@ +import classNames from 'classnames' +import { CaptureDetection } from 'data-services/models/capture' +import { CaptureMatch } from 'data-services/models/capture-match' +import { + getDistanceLabel, + getSimilarityLabel, + getWhenLabel, +} from 'data-services/models/merge-candidate' +import { + ArrowLeftRightIcon, + BanIcon, + LucideIcon, + MinusIcon, + PlusIcon, +} from 'lucide-react' +import { STRING, translate } from 'utils/language' +import { + countSameSpecies, + getMatchLevel, + getMatchNotes, + isIdentified, +} from './capture-match' +import { + ExtendClick, + ExtendClickIndicator, + getExtendClickHint, +} from './extend-click' + +const INDICATORS: Record< + ExtendClickIndicator, + { className: string; Icon: LucideIcon } +> = { + add: { className: 'bg-success text-success-foreground', Icon: PlusIcon }, + blocked: { className: 'bg-neutral-200 text-neutral-600', Icon: BanIcon }, + remove: { + className: 'bg-destructive text-destructive-foreground', + Icon: MinusIcon, + }, + replace: { + className: 'bg-warning text-warning-foreground', + Icon: ArrowLeftRightIcon, + }, +} + +const Row = ({ label, value }: { label: string; value: string }) => ( + <> +
{label}
+
{value}
+ +) + +/** + * A box's identity, how well it matches the track, and what a click on it will do. + * + * It also says that the box is on the capture being viewed, which the path's own + * frames deny in the same words: with their crops drawn, the two look alike. + */ +export const CaptureMatchTooltip = ({ + click, + detection, + detections, + isTrackFrame, + match, +}: { + click?: ExtendClick + detection: CaptureDetection + detections: CaptureDetection[] + isTrackFrame: boolean + match?: CaptureMatch +}) => { + const sameSpecies = countSameSpecies(detection, detections) + const hint = click ? getExtendClickHint(click) : undefined + const indicator = hint ? INDICATORS[hint.indicator] : undefined + const likelihood = match?.likelihood ?? null + const offset = match?.timeOffsetSeconds ?? null + const notes = isTrackFrame ? [] : getMatchNotes(match) + + return ( +
+ {isIdentified(detection) ? ( +
+ {detection.label} + + {detection.scoreLabel} + +
+ ) : ( + + {translate(STRING.TRACK_FRAME_NO_CLASSIFICATION)} + + )} + + {translate( + isTrackFrame + ? STRING.TRACK_FRAME_IN_TRACK + : STRING.TRACK_FRAME_THIS_CAPTURE + )} + + {!isTrackFrame ? ( +
+ + {match?.distance != null ? ( + + ) : null} + {match?.similarity != null ? ( + + ) : null} + {offset !== null ? ( + + ) : null} +
+ ) : null} + {notes.map((note) => ( + + {translate(note.string, note.values)} + + ))} + {sameSpecies > 0 ? ( + + {translate(STRING.TRACK_MATCH_SAME_SPECIES, { count: sameSpecies })} + + ) : null} + {hint && indicator ? ( +
+ + + + {translate(hint.string)} +
+ ) : null} +
+ ) +} diff --git a/ui/src/pages/session-details/capture/capture-match.test.ts b/ui/src/pages/session-details/capture/capture-match.test.ts new file mode 100644 index 000000000..6acf97b46 --- /dev/null +++ b/ui/src/pages/session-details/capture/capture-match.test.ts @@ -0,0 +1,205 @@ +import { CaptureMatch } from 'data-services/models/capture-match' +import { CONSTANTS } from 'nova-ui-kit/constants' +import { STRING } from 'utils/language' +import { + countSameSpecies, + getMatchBoxStyle, + getMatchLevel, + getMatchNotes, +} from './capture-match' + +type MatchFields = Pick< + CaptureMatch, + | 'likelihood' + | 'referenceCaptureOffset' + | 'skippedReason' + | 'skipsSession' + | 'wouldLink' +> + +const match = (fields: Partial = {}): MatchFields => ({ + likelihood: null, + referenceCaptureOffset: 1, + skippedReason: null, + skipsSession: false, + wouldLink: false, + ...fields, +}) + +const rgbOf = (hex: string) => + `rgb(${parseInt(hex.slice(1, 3), 16)} ${parseInt( + hex.slice(3, 5), + 16 + )} ${parseInt(hex.slice(5, 7), 16)})` + +describe('getMatchBoxStyle', () => { + test('the best match is the palette emerald with the strongest, widest glow and no rim', () => { + const style = getMatchBoxStyle(match({ likelihood: 1 })) + + expect(style.outlineColor).toBe(rgbOf(CONSTANTS.COLORS.success[500])) + expect(style.boxShadow).toBe( + '0 0 0 3px rgb(23 24 32 / 0.00), 0 0 10px rgb(0 174 135 / 0.80)' + ) + }) + + test('an unlikely match is the palette gray with a dark rim and no glow', () => { + const style = getMatchBoxStyle(match({ likelihood: 0.5 })) + + expect(style.outlineColor).toBe(rgbOf(CONSTANTS.COLORS.neutral[400])) + expect(style.boxShadow).toBe( + '0 0 0 3px rgb(23 24 32 / 0.50), 0 0 6px rgb(159 162 171 / 0.00)' + ) + }) + + test('a Possible match is already half way to emerald, with the narrow glow', () => { + // neutral-400 #9FA2AB towards success-500 #00AE87, channel by channel. + const style = getMatchBoxStyle(match({ likelihood: 0.6 })) + + expect(style.outlineColor).toBe('rgb(80 168 153)') + expect(style.boxShadow).toBe( + '0 0 0 3px rgb(23 24 32 / 0.25), 0 0 6px rgb(80 168 153 / 0.40)' + ) + }) + + test('a Likely match is nearly emerald, with the wide glow', () => { + const style = getMatchBoxStyle(match({ likelihood: 0.8 })) + + expect(style.outlineColor).toBe('rgb(24 172 140)') + expect(style.boxShadow).toBe( + '0 0 0 3px rgb(23 24 32 / 0.08), 0 0 10px rgb(24 172 140 / 0.68)' + ) + }) + + test('the colour keeps climbing inside a band', () => { + const possibleLow = getMatchBoxStyle(match({ likelihood: 0.6 })) + const possibleHigh = getMatchBoxStyle(match({ likelihood: 0.79 })) + const likelyLow = getMatchBoxStyle(match({ likelihood: 0.8 })) + + expect(possibleHigh.outlineColor).not.toBe(possibleLow.outlineColor) + expect(possibleHigh.outlineColor).not.toBe(likelyLow.outlineColor) + }) + + test('a likelihood below the floor or above 1 keeps the end colours', () => { + expect(getMatchBoxStyle(match({ likelihood: 0.3 }))).toEqual( + getMatchBoxStyle(match({ likelihood: 0.5 })) + ) + expect(getMatchBoxStyle(match({ likelihood: 1.4 }))).toEqual( + getMatchBoxStyle(match({ likelihood: 1 })) + ) + }) + + test('a box without a score looks like an unlikely match', () => { + const unlikely = getMatchBoxStyle(match({ likelihood: 0.5 })) + + expect(getMatchBoxStyle(match())).toEqual(unlikely) + expect(getMatchBoxStyle(undefined)).toEqual(unlikely) + }) + + test("the tracker's pick gets a second emerald ring and a stronger glow", () => { + const pick = getMatchBoxStyle(match({ likelihood: 0.95, wouldLink: true })) + + expect(pick.outlineColor).toBe(rgbOf(CONSTANTS.COLORS.success[500])) + expect(pick.boxShadow).toBe( + '0 0 0 4px rgb(23 24 32 / 0.50), 0 0 0 6px rgb(0 174 135), 0 0 12px rgb(0 174 135 / 0.80)' + ) + expect(pick).not.toEqual(getMatchBoxStyle(match({ likelihood: 0.95 }))) + }) + + test('a box the tracker would skip keeps its colour, with a dotted outline', () => { + const scored = getMatchBoxStyle(match({ likelihood: 0.95 })) + + expect( + getMatchBoxStyle(match({ likelihood: 0.95, skippedReason: 'no_vector' })) + ).toEqual({ ...scored, outlineStyle: 'dotted' }) + }) +}) + +describe('getMatchLevel', () => { + test('names the likelihood in three bands', () => { + expect(getMatchLevel(0.9)).toBe(STRING.TRACK_MATCH_LIKELY) + expect(getMatchLevel(0.7)).toBe(STRING.TRACK_MATCH_POSSIBLE) + expect(getMatchLevel(0.5)).toBe(STRING.TRACK_MATCH_UNLIKELY) + }) +}) + +describe('getMatchNotes', () => { + test("the tracker's pick says automatic tracking would link it", () => { + expect(getMatchNotes(match({ likelihood: 0.95, wouldLink: true }))).toEqual( + [{ isEmphasis: true, string: STRING.TRACK_MATCH_WOULD_LINK }] + ) + }) + + test('a track frame more than one capture away is named, with how many', () => { + expect(getMatchNotes(match({ referenceCaptureOffset: 3 }))).toEqual([ + { + isEmphasis: false, + string: STRING.TRACK_MATCH_REFERENCE_EARLIER, + values: { count: 3 }, + }, + ]) + expect(getMatchNotes(match({ referenceCaptureOffset: -4 }))).toEqual([ + { + isEmphasis: false, + string: STRING.TRACK_MATCH_REFERENCE_LATER, + values: { count: 4 }, + }, + ]) + }) + + test('a skipped box says why, naming the whole session when it has no vectors', () => { + expect(getMatchNotes(match({ skippedReason: 'no_vector' }))).toEqual([ + { isEmphasis: false, string: STRING.TRACK_MATCH_SKIPPED_NO_VECTOR }, + ]) + expect( + getMatchNotes(match({ skippedReason: 'no_vector', skipsSession: true })) + ).toEqual([ + { isEmphasis: false, string: STRING.TRACK_MATCH_SKIPPED_SESSION }, + ]) + }) + + test('the adjacent capture, or an unknown distance, adds no note', () => { + expect(getMatchNotes(match({ referenceCaptureOffset: 1 }))).toEqual([]) + expect(getMatchNotes(match({ referenceCaptureOffset: -1 }))).toEqual([]) + expect(getMatchNotes(match({ referenceCaptureOffset: null }))).toEqual([]) + expect(getMatchNotes(undefined)).toEqual([]) + }) + + test('a box without a feature vector says tracking would skip it', () => { + expect( + getMatchNotes(match({ likelihood: 0.7, skippedReason: 'no_vector' })) + ).toEqual([ + { isEmphasis: false, string: STRING.TRACK_MATCH_SKIPPED_NO_VECTOR }, + ]) + }) +}) + +describe('countSameSpecies', () => { + const moth = (id: string, label: string, scoreLabel?: string) => ({ + id, + label, + occurrenceId: `o${id}`, + scoreLabel, + }) + + test('counts the other boxes with the same determination', () => { + const detections = [ + moth('1', 'Noctua', '0.80'), + moth('2', 'Noctua', '0.60'), + moth('3', 'Noctua', '0.70'), + moth('4', 'Catocala', '0.90'), + ] + + expect(countSameSpecies(detections[0], detections)).toBe(2) + }) + + test('boxes without a determination are not a species', () => { + const detections = [ + moth('1', 'Unidentified'), + moth('2', 'Unidentified'), + { id: '3', label: '3', occurrenceId: undefined, scoreLabel: '0.00' }, + ] + + expect(countSameSpecies(detections[0], detections)).toBe(0) + expect(countSameSpecies(detections[2], detections)).toBe(0) + }) +}) diff --git a/ui/src/pages/session-details/capture/capture-match.ts b/ui/src/pages/session-details/capture/capture-match.ts new file mode 100644 index 000000000..499fba0a2 --- /dev/null +++ b/ui/src/pages/session-details/capture/capture-match.ts @@ -0,0 +1,166 @@ +import { CaptureDetection } from 'data-services/models/capture' +import { + CaptureMatch, + getMatchLevel, + MATCH_GRAY_UNTIL as GRAY_UNTIL, + MATCH_LIKELY_FROM as LIKELY_FROM, + MATCH_POSSIBLE_FROM as POSSIBLE_FROM, +} from 'data-services/models/capture-match' +import { CONSTANTS } from 'nova-ui-kit/constants' +import { STRING } from 'utils/language' + +export { getMatchLevel } + +const BEST_MATCH_COLOR = CONSTANTS.COLORS.success[500] +const UNLIKELY_MATCH_COLOR = CONSTANTS.COLORS.neutral[400] +const GLOW_BLUR_PX = 6 +const LIKELY_GLOW_BLUR_PX = 10 +const MAX_GLOW_ALPHA = 0.8 +const RIM_COLOR = CONSTANTS.COLORS.neutral[900] +const RIM_SPREAD_PX = 3 +const MAX_RIM_ALPHA = 0.5 +const LINK_GAP_PX = 4 +const LINK_RING_PX = 6 +const LINK_GLOW_PX = 12 +// How far from gray to emerald each band starts, so a Possible box already reads green. +const COLOR_STOPS: [likelihood: number, amount: number][] = [ + [GRAY_UNTIL, 0], + [POSSIBLE_FROM, 0.5], + [LIKELY_FROM, 0.85], + [1, 1], +] + +type Rgb = [number, number, number] + +const greenAmount = (likelihood: number): number => { + const clamped = Math.min(Math.max(likelihood, GRAY_UNTIL), 1) + const index = COLOR_STOPS.findIndex(([stop]) => clamped <= stop) + if (index <= 0) return 0 + const [fromStop, fromAmount] = COLOR_STOPS[index - 1] + const [toStop, toAmount] = COLOR_STOPS[index] + return ( + fromAmount + + ((clamped - fromStop) / (toStop - fromStop)) * (toAmount - fromAmount) + ) +} + +type MatchFields = Pick< + CaptureMatch, + | 'likelihood' + | 'referenceCaptureOffset' + | 'skippedReason' + | 'skipsSession' + | 'wouldLink' +> + +const hexToRgb = (hex: string): Rgb => [ + parseInt(hex.slice(1, 3), 16), + parseInt(hex.slice(3, 5), 16), + parseInt(hex.slice(5, 7), 16), +] + +const mixRgb = (from: Rgb, to: Rgb, amount: number): Rgb => + from.map((channel, index) => + Math.round(channel + (to[index] - channel) * amount) + ) as Rgb + +const toCss = ([r, g, b]: Rgb, alpha?: number) => + alpha === undefined + ? `rgb(${r} ${g} ${b})` + : `rgb(${r} ${g} ${b} / ${alpha.toFixed(2)})` + +/** + * Box outline for a candidate in extend mode: gray up to `GRAY_UNTIL`, then through to + * emerald for the best match, with a wider glow from the Likely band up. The tracker's own + * pick gets a second ring and a stronger glow, and a box it would skip keeps its colour with + * a dotted outline. No score counts as 0. + */ +export const getMatchBoxStyle = ( + match?: Omit +): { outlineColor: string; outlineStyle?: 'dotted'; boxShadow: string } => { + const gray = hexToRgb(UNLIKELY_MATCH_COLOR) + const green = hexToRgb(BEST_MATCH_COLOR) + const fullRim = toCss(hexToRgb(RIM_COLOR), MAX_RIM_ALPHA) + + if (match?.wouldLink) { + return { + outlineColor: toCss(green), + boxShadow: `0 0 0 ${LINK_GAP_PX}px ${fullRim}, 0 0 0 ${LINK_RING_PX}px ${toCss( + green + )}, 0 0 ${LINK_GLOW_PX}px ${toCss(green, MAX_GLOW_ALPHA)}`, + } + } + + const likelihood = match?.likelihood ?? 0 + const amount = greenAmount(likelihood) + const color = mixRgb(gray, green, amount) + const rim = toCss(hexToRgb(RIM_COLOR), (1 - amount) * MAX_RIM_ALPHA) + const glow = toCss(color, amount * MAX_GLOW_ALPHA) + const glowBlur = + likelihood >= LIKELY_FROM ? LIKELY_GLOW_BLUR_PX : GLOW_BLUR_PX + + return { + outlineColor: toCss(color), + ...(match?.skippedReason ? { outlineStyle: 'dotted' as const } : {}), + // The rim sits just beyond the 2px outline so a gray box still shows on a light sheet. + boxShadow: `0 0 0 ${RIM_SPREAD_PX}px ${rim}, 0 0 ${glowBlur}px ${glow}`, + } +} + +/** Lines under a candidate's scores: what the tracker itself would do, and when the preview is only indicative. */ +export const getMatchNotes = ( + match?: MatchFields +): { isEmphasis: boolean; string: STRING; values?: { count: number } }[] => { + const offset = match?.referenceCaptureOffset ?? null + + return [ + ...(match?.wouldLink + ? [{ isEmphasis: true, string: STRING.TRACK_MATCH_WOULD_LINK }] + : []), + ...(offset !== null && Math.abs(offset) > 1 + ? [ + { + isEmphasis: false, + string: + offset > 0 + ? STRING.TRACK_MATCH_REFERENCE_EARLIER + : STRING.TRACK_MATCH_REFERENCE_LATER, + values: { count: Math.abs(offset) }, + }, + ] + : []), + ...(match?.skippedReason === 'no_vector' + ? [ + { + isEmphasis: false, + string: match.skipsSession + ? STRING.TRACK_MATCH_SKIPPED_SESSION + : STRING.TRACK_MATCH_SKIPPED_NO_VECTOR, + }, + ] + : []), + ] +} + +type SpeciesFields = Pick< + CaptureDetection, + 'id' | 'label' | 'occurrenceId' | 'scoreLabel' +> + +// Without a determination the label is "Unidentified" or the detection id, not a species. +export const isIdentified = (detection: SpeciesFields) => + !!detection.occurrenceId && detection.scoreLabel !== undefined + +/** Other boxes on the capture identified as the same species as this one. */ +export const countSameSpecies = ( + detection: SpeciesFields, + detections: SpeciesFields[] +): number => + isIdentified(detection) + ? detections.filter( + (other) => + other.id !== detection.id && + isIdentified(other) && + other.label === detection.label + ).length + : 0 diff --git a/ui/src/pages/session-details/capture/capture-preload.test.ts b/ui/src/pages/session-details/capture/capture-preload.test.ts new file mode 100644 index 000000000..c911c06e8 --- /dev/null +++ b/ui/src/pages/session-details/capture/capture-preload.test.ts @@ -0,0 +1,57 @@ +import { CaptureNeighbourIds, demandBand, planPreload } from './capture-preload' + +// A session of six captures where 1, 3 and 5 carry detections. Nothing is +// known about 6, standing in for a capture that has not been read yet. +const SESSION: { [id: string]: CaptureNeighbourIds } = { + '1': { nextCaptureId: '2', nextCaptureWithDetectionsId: '3' }, + '2': { nextCaptureId: '3', prevCaptureId: '1' }, + '3': { + nextCaptureId: '4', + nextCaptureWithDetectionsId: '5', + prevCaptureId: '2', + prevCaptureWithDetectionsId: '1', + }, + '4': { nextCaptureId: '5', prevCaptureId: '3' }, + '5': { + nextCaptureId: '6', + prevCaptureId: '4', + prevCaptureWithDetectionsId: '3', + }, +} + +const lookup = (id: string) => SESSION[id] + +describe('planPreload', () => { + test('plans both directions, detections neighbours first', () => { + expect(planPreload({ startId: '3', lookup })).toEqual(['5', '1', '4', '2']) + }) + + test('plans a capture once, however many directions reach it', () => { + // From 1 the detections chain reaches 3 then 5, and the plain chain reaches + // 2 then 3 — a second claim on 3 adds nothing to fetch. + expect(planPreload({ startId: '1', lookup })).toEqual(['3', '2', '5']) + }) + + test('stops every direction at the requested depth', () => { + expect(planPreload({ startId: '1', depth: 1, lookup })).toEqual(['3', '2']) + }) + + test('stops a direction whose neighbour has not been read yet', () => { + // 6 is planned, but the capture after it stays unknown until 6 is read. + expect(planPreload({ startId: '5', lookup })).toEqual(['3', '6', '4', '1']) + }) +}) + +describe('demandBand', () => { + test('reads the same until the demand crosses a thumbnail size', () => { + // The medium thumbnail is 1024px wide and covers a 20% upscale. + expect(demandBand(600)).toBe(demandBand(1228)) + expect(demandBand(1229)).not.toBe(demandBand(1228)) + }) + + test('rises once per thumbnail size the demand outgrows', () => { + expect([demandBand(600), demandBand(2000), demandBand(4000)]).toEqual([ + 0, 1, 2, + ]) + }) +}) diff --git a/ui/src/pages/session-details/capture/capture-preload.ts b/ui/src/pages/session-details/capture/capture-preload.ts new file mode 100644 index 000000000..9236459c1 --- /dev/null +++ b/ui/src/pages/session-details/capture/capture-preload.ts @@ -0,0 +1,69 @@ +import { THUMBNAIL_WIDTHS, TIER_UPSCALE_TOLERANCE } from './capture-tiers' + +/** How many captures ahead of the one on screen each navigation direction warms. */ +export const PRELOAD_DEPTH = 2 + +/** + * Coarse reading of a pixel demand, for callers that should react to a zoom + * only when it crosses a thumbnail size and so changes which image the next + * captures need. A gesture within one size reads the same throughout. + */ +export const demandBand = (demand: number) => + Object.values(THUMBNAIL_WIDTHS).filter( + (width) => demand > width * TIER_UPSCALE_TOLERANCE + ).length + +export interface CaptureNeighbourIds { + nextCaptureId?: string + nextCaptureWithDetectionsId?: string | null + prevCaptureId?: string + prevCaptureWithDetectionsId?: string | null +} + +type Direction = keyof CaptureNeighbourIds + +// Reviewers step by detections most of the time, so those neighbours are +// warmed before the plain ones at the same distance. +const DIRECTIONS: Direction[] = [ + 'nextCaptureWithDetectionsId', + 'prevCaptureWithDetectionsId', + 'nextCaptureId', + 'prevCaptureId', +] + +/** + * Ids to warm around the capture on screen, nearest step first and without + * repeats. A direction stops where its next id is unknown, so callers get the + * step after that only once the step before it has been read. + */ +export const planPreload = ({ + depth = PRELOAD_DEPTH, + lookup, + startId, +}: { + depth?: number + lookup: (id: string) => CaptureNeighbourIds | undefined + startId: string +}): string[] => { + const planned: string[] = [] + const seen = new Set([startId]) + const cursors = new Map( + DIRECTIONS.map((direction) => [direction, startId]) + ) + + for (let step = 0; step < depth; step++) { + DIRECTIONS.forEach((direction) => { + const cursor = cursors.get(direction) + const next = + (cursor ? lookup(cursor)?.[direction] : undefined) ?? undefined + cursors.set(direction, next) + + if (next && !seen.has(next)) { + seen.add(next) + planned.push(next) + } + }) + } + + return planned +} diff --git a/ui/src/pages/session-details/capture/capture.module.scss b/ui/src/pages/session-details/capture/capture.module.scss index a07d80c29..f0f9bd15d 100644 --- a/ui/src/pages/session-details/capture/capture.module.scss +++ b/ui/src/pages/session-details/capture/capture.module.scss @@ -1,12 +1,78 @@ .overlay, .details, -.detections { +.detections, +.ghostFrames, +.ghostTrail { position: absolute; width: 100%; height: 100%; vertical-align: middle; } +// Only boxes and frame buttons take clicks: the full-size layers are transparent to the +// pointer, so a ghost below the live boxes is still reachable where no live box covers it. +.detections, +.ghostFrames, +.ghostTrail { + pointer-events: none; +} + +.detection { + pointer-events: auto; +} + +// The live boxes sit above every path frame, so a detection on the capture being +// viewed is what a click lands on even where a neighbouring frame's crop covers it. +// The path frames layer stakes no claim of its own, leaving each box to place itself. +.detections { + z-index: 30; +} + +.ghostTrail { + z-index: 1; +} + +// The gap to a neighbouring frame, drawn as its own layer rather than inside the frame +// button, which clips its contents to hold the crop and is often narrower than this text. +.ghostGap { + position: absolute; + transform: translate(-50%, 3px); + padding: 0 4px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.62); + color: var(--color-generic-white); + font-size: 10px; + line-height: 15px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + pointer-events: none; +} + +.ghostFrame { + position: absolute; + box-sizing: border-box; + padding: 0; + border: 1.5px dashed transparent; + border-radius: 2px; + background: none; + overflow: hidden; + cursor: pointer; + pointer-events: auto; + transition: opacity 150ms ease-out; + + &:focus-visible { + outline: 2px solid var(--color-secondary-400); + outline-offset: 1px; + } + + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } +} + .details { .overlay { opacity: 0; @@ -23,6 +89,9 @@ .detection { position: absolute; box-sizing: border-box; + padding: 0; + border: 0; + background: none; outline: 2px solid var(--color-success); border-radius: 2px; @@ -42,9 +111,20 @@ outline: 2px solid var(--color-secondary-400); } + &.extended { + box-shadow: 0 0 8px 2px + color-mix(in srgb, var(--color-secondary-400) 80%, transparent); + } + &.clickable { &:hover { cursor: pointer; } } + + // The same ring as a path ghost frame, so focus reads the same over the capture. + &:focus-visible { + outline: 2px solid var(--color-secondary-400); + outline-offset: 1px; + } } diff --git a/ui/src/pages/session-details/capture/capture.tsx b/ui/src/pages/session-details/capture/capture.tsx index aa27f47d3..69d35521c 100644 --- a/ui/src/pages/session-details/capture/capture.tsx +++ b/ui/src/pages/session-details/capture/capture.tsx @@ -1,8 +1,13 @@ import classNames from 'classnames' import { DeterminationScore } from 'components/determination-score' +import { useCaptureMatches } from 'data-services/hooks/occurrences/useCaptureMatches' import { useOccurrenceDetails } from 'data-services/hooks/occurrences/useOccurrenceDetails' +import { useOccurrencePath } from 'data-services/hooks/occurrences/useOccurrencePath' import { CaptureDetection } from 'data-services/models/capture' -import { Dialog, LoadingSpinner, Tooltip } from 'nova-ui-kit' +import { CaptureMatch } from 'data-services/models/capture-match' +import { PathFrame } from 'data-services/models/occurrence-path' +import _ from 'lodash' +import { Dialog, LoadingSpinner, Popover, Tooltip } from 'nova-ui-kit' import { OccurrenceDetails, TABS, @@ -14,10 +19,28 @@ import { TransformWrapper, } from 'react-zoom-pan-pinch' import { SCORE_THRESHOLDS } from 'utils/constants' +import { getFormatedTimeString } from 'utils/date/getFormatedTimeString/getFormatedTimeString' import { STRING, translate } from 'utils/language' import { useActiveOccurrences } from '../hooks/useActiveOccurrences' +import { getNearestPathFrame } from './track-navigation' +import { useActiveCaptureId } from '../hooks/useActiveCapture' +import { useActiveDetection } from '../hooks/useActiveDetection' +import { BoxStyle, bboxToPercentStyle } from './bbox' +import { CaptureGhostTrail } from './capture-ghost-trail' +import { getMatchBoxStyle, getMatchLevel, isIdentified } from './capture-match' +import { CaptureMatchTooltip } from './capture-match-tooltip' import { TierSources } from './capture-tiers' +import { ExtendClick, getExtendClickHint } from './extend-click' +import { ExtendTrackDialog, ExtendTrackState } from './extend-track' +import { buildTrail } from './ghost-trail' +import { OccurrenceToolbar } from './occurrence-toolbar' +import { + SessionPathStatus, + SessionTrackEdit, + SessionTrackEdits, +} from './session-track-edits' import styles from './capture.module.scss' +import { useCapturePreload } from './useCapturePreload' import { useCaptureTiers } from './useCaptureTiers' const FALLBACK_RATIO = 16 / 9 @@ -27,32 +50,124 @@ const FALLBACK_RATIO = 16 / 9 const DEFAULT_MAX_SCALE = 8 const MAX_OVERZOOM = 2 -interface BoxStyle { - width: string - height: string - top: string - left: string -} +// Long enough for the pointer to cross the gap from a box to the panel under it. +const HOVER_CLOSE_DELAY_MS = 120 interface CaptureProps { + captureDate?: Date + captureId?: string defaultFilters: boolean detections: CaptureDetection[] + extend: ExtendTrackState height: number | null + onTogglePathCrops?: () => void showDetections?: boolean + showPathCrops?: boolean sources?: TierSources + /** Off, the capture shows boxes and their labels only: no paths or track edits. */ + trackingEnabled?: boolean transformRef: React.RefObject width: number | null } export const Capture = ({ + captureDate, + captureId, defaultFilters, detections, + extend, height, + onTogglePathCrops, showDetections, + showPathCrops, sources, + trackingEnabled, transformRef, width, }: CaptureProps) => { + const { activeOccurrences, setActiveOccurrences } = useActiveOccurrences() + const { setActiveCaptureId } = useActiveCaptureId() + const { activeDetectionId } = useActiveDetection() + // A link can point at a detection rather than an occurrence, because regrouping moves + // a detection between occurrences while the detection's own id never changes. Follow + // one and it selects whichever track holds that detection now, which is the thing the + // reader needs to see: where it ended up. + const linkedOccurrenceId = activeDetectionId + ? detections.find((detection) => detection.id === activeDetectionId) + ?.occurrenceId + : undefined + + useEffect(() => { + if (!linkedOccurrenceId || activeOccurrences.includes(linkedOccurrenceId)) { + return + } + + setActiveOccurrences([...activeOccurrences, linkedOccurrenceId]) + }, [linkedOccurrenceId, activeOccurrences, setActiveOccurrences]) + + // Which occurrence the operator asked to see the path of. Kept while that + // occurrence stays selected, so stepping between captures redraws the same path. + const [pathOccurrenceId, setPathOccurrenceId] = useState() + const shownPathId = + trackingEnabled && + pathOccurrenceId && + activeOccurrences.includes(pathOccurrenceId) + ? pathOccurrenceId + : undefined + const { + path, + isLoading: pathLoading, + isFetching: pathFetching, + error: pathError, + refetch: refetchPath, + } = useOccurrencePath(shownPathId, !!shownPathId) + const isLoadingPath = pathLoading || pathFetching + const { matches } = useCaptureMatches({ + captureId, + enabled: !!extend.occurrenceId, + occurrenceId: extend.occurrenceId, + }) + + // Set while extending, holding the path shown before it started, so ending extend + // mode does not leave behind a path nobody asked for. + const pathBeforeExtend = useRef<{ occurrenceId?: string }>() + + useEffect(() => { + // The occurrence being extended is the only one selected, with its path drawn, + // so every capture stepped through shows where the track has reached and no + // other selection competes with the match colours. + if (!extend.occurrenceId) { + if (pathBeforeExtend.current) { + setPathOccurrenceId(pathBeforeExtend.current.occurrenceId) + pathBeforeExtend.current = undefined + } + + return + } + + if (!pathBeforeExtend.current) { + pathBeforeExtend.current = { occurrenceId: pathOccurrenceId } + } + + setPathOccurrenceId(extend.occurrenceId) + + if ( + activeOccurrences.length !== 1 || + activeOccurrences[0] !== extend.occurrenceId + ) { + setActiveOccurrences([extend.occurrenceId]) + } + }, [extend.occurrenceId, activeOccurrences, setActiveOccurrences]) + + const nearestFrame = useMemo( + () => (path?.length ? getNearestPathFrame(path, captureDate) : undefined), + [path, captureDate] + ) + + const trail = useMemo( + () => (path?.length ? buildTrail(path, captureId, captureDate) : undefined), + [path, captureId, captureDate] + ) const wrapperRef = useRef(null) const [naturalSize, setNaturalSize] = useState<{ width: number @@ -88,11 +203,13 @@ export const Capture = ({ return () => observer.disconnect() }, []) + const demand = containerWidth * dpr * scale + useEffect(() => { if (containerWidth) { - updateDemand(containerWidth * dpr * scale) + updateDemand(demand) } - }, [containerWidth, dpr, scale, updateDemand]) + }, [containerWidth, demand, updateDemand]) useEffect(() => { // Show the spinner whenever the active capture changes; the previous @@ -101,6 +218,12 @@ export const Capture = ({ setNaturalSize(undefined) }, [sources?.original]) + useCapturePreload({ + captureId, + demand, + enabled: isLoading === false && !!displayed, + }) + useLayoutEffect(() => { // Ugly hack to make overlay correct on first render setRenderOverlay(true) @@ -109,21 +232,10 @@ export const Capture = ({ const boxStyles = useMemo( () => detections.reduce((result: { [key: string]: BoxStyle }, detection) => { - const [boxLeft, boxTop, boxRight, boxBottom] = detection.bbox - const boxWidth = boxRight - boxLeft - const boxHeight = boxBottom - boxTop - - // Boxes are in the original image's pixel space and the rendered image - // may be a downscaled thumbnail, so only stored dimensions can scale them. - if (!width || !height) { - return result - } + const style = bboxToPercentStyle(detection.bbox, width, height) - result[detection.id] = { - width: `${(boxWidth / width) * 100}%`, - height: `${(boxHeight / height) * 100}%`, - top: `${(boxTop / height) * 100}%`, - left: `${(boxLeft / width) * 100}%`, + if (style) { + result[detection.id] = style } return result @@ -217,15 +329,58 @@ export const Capture = ({ })} > {renderOverlay ? : null} + {trail ? ( + + ) : null} setPathOccurrenceId(undefined)} + onShowPath={(occurrenceId) => + occurrenceId === shownPathId + ? refetchPath() + : setPathOccurrenceId(occurrenceId) + } + path={path} + pathError={!!pathError} + pathOccurrenceId={shownPathId} + shownFrames={trail?.shownCount} showDetections={showDetections} + trackingEnabled={trackingEnabled} />
+ {shownPathId && !extend.occurrenceId ? ( + setPathOccurrenceId(undefined)} + onShowFrame={ + nearestFrame + ? () => setActiveCaptureId(nearestFrame.captureId) + : undefined + } + /> + ) : null} + {extend.occurrenceId && extend.choice ? ( + + ) : null} {zoomPercent !== null ? ( {zoomPercent}% @@ -280,23 +435,203 @@ const CaptureOverlay = ({ ) +/** + * What a screen reader hears on a detection box. In extend mode it also carries the + * match reading and what activating the box does, which the pointer gets from the + * preview card and a keyboard never sees. + */ +const getBoxLabel = ({ + click, + detection, + isExtending, + match, +}: { + click?: ExtendClick + detection: CaptureDetection + isExtending: boolean + match?: CaptureMatch +}) => { + const name = isIdentified(detection) + ? translate( + detection.score === 1 + ? STRING.TRACK_BOX_LABEL_VERIFIED + : STRING.TRACK_BOX_LABEL, + { name: detection.label, score: detection.scoreLabel as string } + ) + : translate(STRING.TRACK_FRAME_DETECTION, { id: detection.id }) + + if (!isExtending) { + return name + } + + const likelihood = match?.likelihood ?? null + const label = translate(STRING.TRACK_BOX_EXTEND_LABEL, { + match: + likelihood !== null + ? translate(STRING.TRACK_MATCH_SCORE, { + level: translate(getMatchLevel(likelihood)), + percent: Math.round(likelihood * 100), + }) + : translate(STRING.VALUE_NOT_AVAILABLE), + name, + }) + const hint = click ? getExtendClickHint(click) : undefined + + return hint + ? translate(STRING.TRACK_BOX_EXTEND_ACTION, { + action: translate(hint.string), + label, + }) + : label +} + const CaptureDetections = ({ boxStyles, defaultFilters, detections, + extend, + isLoadingPath, + matches, + onHidePath, + onShowPath, + onTogglePathCrops, + path, + pathError, + pathOccurrenceId, showDetections, + showPathCrops, + shownFrames, + trackingEnabled, }: { boxStyles: { [key: number]: BoxStyle } defaultFilters: boolean detections: CaptureDetection[] + extend: ExtendTrackState + isLoadingPath?: boolean + matches?: Record + onHidePath: () => void + onShowPath: (occurrenceId: string) => void + /** Switch the path's boxes between the moth's own pixels and an outline. */ + onTogglePathCrops?: () => void + path?: PathFrame[] + pathError?: boolean + pathOccurrenceId?: string showDetections?: boolean + /** The path's boxes are filled with each frame's crop rather than left empty. */ + showPathCrops?: boolean + shownFrames?: number + trackingEnabled?: boolean }) => { - const containerRef = useRef(null) + // Held in state, not a ref: Radix needs the element itself to keep a panel inside + // the image, and a ref assignment does not re-render to hand it over. + const [container, setContainer] = useState(null) const [activeOccurrence, setActiveOccurrence] = useState() + const [trackEdit, setTrackEdit] = useState() + // Selected occurrences whose panel was closed with Escape; they stay selected. + const [dismissedToolbars, setDismissedToolbars] = useState([]) + const [hoveredBox, setHoveredBox] = useState() + const hoverTimeout = useRef() const { activeOccurrences, setActiveOccurrences } = useActiveOccurrences() + const isExtending = !!extend.occurrenceId + const detailsHidden = isExtending && !extend.showDetails + + useEffect(() => { + setDismissedToolbars([]) + }, [extend.showDetails]) + + useEffect(() => { + setHoveredBox(undefined) + }, [detailsHidden]) + + useEffect(() => () => window.clearTimeout(hoverTimeout.current), []) + + const hoverBox = (detectionId: string) => { + window.clearTimeout(hoverTimeout.current) + setHoveredBox(detectionId) + } + + const unhoverBox = (detectionId: string) => { + window.clearTimeout(hoverTimeout.current) + hoverTimeout.current = window.setTimeout( + () => setHoveredBox((box) => (box === detectionId ? undefined : box)), + HOVER_CLOSE_DELAY_MS + ) + } + + /** How a box is drawn, and whether the panel anchored to it is on screen. */ + const describeBox = (detection: CaptureDetection) => { + const isExtended = + !!detection.occurrenceId && detection.occurrenceId === extend.occurrenceId + const isActive = + isExtended || + (detection.occurrenceId + ? activeOccurrences.includes(detection.occurrenceId) + : false) + const isDismissed = + !!detection.occurrenceId && + dismissedToolbars.includes(detection.occurrenceId) + // A selected box keeps its panel; any other box borrows it while hovered. + const hasPanel = + !detailsHidden && !!detection.occurrenceId && (showDetections || isActive) + + return { + isActive, + isDismissed, + isExtended, + panelOpen: + hasPanel && (isActive ? !isDismissed : hoveredBox === detection.id), + } + } + + // Escape closes the innermost thing on screen: a dialog, then an open panel, then + // extend mode. It never changes which occurrences are selected. + const escapeHandledElsewhere = + !!trackEdit || + !!activeOccurrence || + !!extend.choice || + detections.some((detection) => describeBox(detection).panelOpen) + + useEffect(() => { + if (!isExtending || escapeHandledElsewhere) { + return + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + extend.stop() + } + } + + document.addEventListener('keydown', onKeyDown) + + return () => document.removeEventListener('keydown', onKeyDown) + }, [escapeHandledElsewhere, extend, isExtending]) + + // Worded while the path is still the one on screen: the split moves the boundary + // frame off this occurrence, so the refreshed path can no longer describe it. + const describeSplit = (detectionId: string) => { + const index = + path?.findIndex((frame) => frame.detectionId === detectionId) ?? -1 + const timestamp = index >= 0 ? path?.[index].timestamp : undefined + + return { + movedBySplit: path && index >= 0 ? path.length - index : 0, + timeLabel: timestamp + ? getFormatedTimeString({ date: timestamp, options: { second: true } }) + : translate(STRING.VALUE_NOT_AVAILABLE), + total: path?.length ?? 0, + } + } + + const setDismissed = (occurrenceId: string, dismissed: boolean) => + setDismissedToolbars((ids) => [ + ...ids.filter((id) => id !== occurrenceId), + ...(dismissed ? [occurrenceId] : []), + ]) const toggleActiveState = (occurrenceId: string) => { const isActive = activeOccurrences.includes(occurrenceId) + setDismissed(occurrenceId, false) if (isActive) { setActiveOccurrences( @@ -307,67 +642,239 @@ const CaptureDetections = ({ } } + // A path is only fetched for a selected occurrence, so asking for one from a + // hovered box selects it first. + const showPath = (occurrenceId: string) => { + if (!activeOccurrences.includes(occurrenceId)) { + setActiveOccurrences([...activeOccurrences, occurrenceId]) + } + onShowPath(occurrenceId) + } + return ( <> -
+
{Object.entries(boxStyles).map(([id, style]) => { const detection = detections.find((d) => d.id === id) - const isActive = detection?.occurrenceId - ? activeOccurrences.includes(detection.occurrenceId) - : false + if (!detection) { + return null + } + + const { isActive, isDismissed, isExtended, panelOpen } = + describeBox(detection) - if (!detection || (!showDetections && !isActive)) { + if (!showDetections && !isActive) { return null } - return ( - - - -
{ - if (detection.occurrenceId) { - toggleActiveState(detection?.occurrenceId) - } - }} - /> - - { + if (extend.occurrenceId) { + extend.clickBox(detection) + } else if (isActive && isDismissed) { + setDismissed(detection.occurrenceId as string, false) + } else if (detection.occurrenceId) { + toggleActiveState(detection.occurrenceId) + } + }} + onMouseEnter={() => hoverBox(detection.id)} + onMouseLeave={() => unhoverBox(detection.id)} + style={ + isExtending && !isExtended + ? { ...style, ...getMatchBoxStyle(match) } + : style + } + // A box a click does nothing to is read where it sits, not tabbed to. + tabIndex={isClickable ? 0 : -1} + type="button" + /> + ) + + // Without tracking, a selected box keeps the plain label and score it has + // always had, whose name opens the occurrence, instead of the track panel. + const plainLabel = !trackingEnabled && !detailsHidden + + // A reading of the box, which a tooltip is the right primitive for: Radix + // repeats it for assistive technology, and text survives being read twice. + if (detailsHidden || !detection.occurrenceId || plainLabel) { + return ( + + -
- - -
-
- - + {box} + hoverBox(detection.id)} + onMouseLeave={() => unhoverBox(detection.id)} + side="bottom" + > + {detailsHidden ? ( + + ) : ( +
+ {plainLabel ? ( + + ) : ( + + {detection.label} + + )} + +
+ )} +
+ + + ) + } + + return ( + + {box} + { + if (isActive) { + setDismissed(detection.occurrenceId as string, true) + } else { + setHoveredBox(undefined) + } + }} + onMouseEnter={() => hoverBox(detection.id)} + onMouseLeave={() => unhoverBox(detection.id)} + // The panel follows a selection or a hover rather than a deliberate + // open, so it must not pull focus off the capture. + onOpenAutoFocus={(event) => event.preventDefault()} + side="bottom" + > + + extend.start(detection.occurrenceId as string) + } + onDismiss={() => + setDismissed(detection.occurrenceId as string, true) + } + onHidePath={onHidePath} + onTogglePathCrops={onTogglePathCrops} + showPathCrops={showPathCrops} + onMerge={() => + setTrackEdit({ + action: 'merge', + detectionId: detection.id, + occurrenceId: detection.occurrenceId as string, + }) + } + onOpenOccurrence={() => + setActiveOccurrence(detection.occurrenceId) + } + onShowPath={() => showPath(detection.occurrenceId as string)} + onSplit={() => + setTrackEdit({ + action: 'split', + detectionId: detection.id, + occurrenceId: detection.occurrenceId as string, + ...describeSplit(detection.id), + }) + } + onVerify={() => + setTrackEdit({ + action: 'verify', + detectionId: detection.id, + occurrenceId: detection.occurrenceId as string, + verified: detection.groupingVerified, + }) + } + path={ + pathOccurrenceId === detection.occurrenceId + ? path + : undefined + } + pathError={ + pathError && pathOccurrenceId === detection.occurrenceId + } + shownFrames={ + pathOccurrenceId === detection.occurrenceId + ? shownFrames + : undefined + } + /> + + ) })} {activeOccurrence ? ( @@ -376,6 +883,10 @@ const CaptureDetections = ({ onClose={() => setActiveOccurrence(undefined)} /> ) : null} + setTrackEdit(undefined)} + />
) @@ -392,6 +903,9 @@ const OccurrenceDetailsDialog = ({ TABS.FIELDS ) const { occurrence, isLoading, error } = useOccurrenceDetails(id) + const detailsLabel = translate(STRING.ENTITY_DETAILS, { + type: _.capitalize(translate(STRING.ENTITY_TYPE_OCCURRENCE)), + }) return ( +
+ + {detailsLabel} + +
{occurrence ? ( diff --git a/ui/src/pages/session-details/capture/extend-access.test.ts b/ui/src/pages/session-details/capture/extend-access.test.ts new file mode 100644 index 000000000..24f1307de --- /dev/null +++ b/ui/src/pages/session-details/capture/extend-access.test.ts @@ -0,0 +1,61 @@ +import { UserPermission } from 'utils/user/types' +import { getExtendOccurrenceId } from './extend-access' + +const track = { + id: '10', + sessionId: '5', + userPermissions: [UserPermission.Update, UserPermission.Delete], +} + +const opens = { + enabled: true, + requestedOccurrenceId: '10', + sessionId: '5', + track, +} + +describe('getExtendOccurrenceId', () => { + test('opens on an editable occurrence from this session', () => { + expect(getExtendOccurrenceId(opens)).toBe('10') + }) + + test('stays closed while tracking is off or the occurrence is loading', () => { + expect(getExtendOccurrenceId({ ...opens, enabled: false })).toBeUndefined() + expect( + getExtendOccurrenceId({ ...opens, track: undefined }) + ).toBeUndefined() + }) + + test('ignores an occurrence the viewer may not restructure', () => { + expect( + getExtendOccurrenceId({ + ...opens, + track: { ...track, userPermissions: [UserPermission.Update] }, + }) + ).toBeUndefined() + expect( + getExtendOccurrenceId({ + ...opens, + track: { ...track, userPermissions: [] }, + }) + ).toBeUndefined() + }) + + test('ignores an occurrence from another session', () => { + expect( + getExtendOccurrenceId({ ...opens, track: { ...track, sessionId: '6' } }) + ).toBeUndefined() + expect( + getExtendOccurrenceId({ + ...opens, + track: { ...track, sessionId: undefined }, + }) + ).toBeUndefined() + }) + + test('ignores a loaded occurrence that is not the one the link names', () => { + expect( + getExtendOccurrenceId({ ...opens, requestedOccurrenceId: '11' }) + ).toBeUndefined() + }) +}) diff --git a/ui/src/pages/session-details/capture/extend-access.ts b/ui/src/pages/session-details/capture/extend-access.ts new file mode 100644 index 000000000..45580932f --- /dev/null +++ b/ui/src/pages/session-details/capture/extend-access.ts @@ -0,0 +1,37 @@ +import { getTrackEditRights } from 'pages/occurrence-details/track/track-edit-rights' +import { UserPermission } from 'utils/user/types' + +interface RequestedTrack { + id: string + sessionId?: string + userPermissions: UserPermission[] +} + +/** + * The occurrence extend mode may open on, or undefined. A link can name an occurrence + * the viewer may not edit, or one from another session, and both are ignored. + */ +export const getExtendOccurrenceId = ({ + enabled, + requestedOccurrenceId, + sessionId, + track, +}: { + enabled: boolean + requestedOccurrenceId?: string + sessionId?: string + /** The requested occurrence, once loaded. */ + track?: RequestedTrack +}) => { + if (!enabled || !requestedOccurrenceId || !track || !sessionId) { + return undefined + } + + if (track.id !== requestedOccurrenceId || track.sessionId !== sessionId) { + return undefined + } + + return getTrackEditRights(track.userPermissions).canRestructure + ? requestedOccurrenceId + : undefined +} diff --git a/ui/src/pages/session-details/capture/extend-click.test.ts b/ui/src/pages/session-details/capture/extend-click.test.ts new file mode 100644 index 000000000..1f15ca56e --- /dev/null +++ b/ui/src/pages/session-details/capture/extend-click.test.ts @@ -0,0 +1,138 @@ +import { STRING } from 'utils/language' +import { getExtendClick, getExtendClickHint } from './extend-click' + +const TRACK_ID = 't1' + +// The track holds d1 on capture c1 and d2 on capture c2. +const FRAMES = [ + { captureId: 'c1', id: 'd1' }, + { captureId: 'c2', id: 'd2' }, +] + +const box = ( + id: string, + occurrenceId?: string, + frameCount = occurrenceId ? 1 : 0 +) => ({ frameCount, id, label: 'Moth', occurrenceId }) + +describe('getExtendClick', () => { + test('a box on a capture the track skips is added to the track', () => { + expect( + getExtendClick({ + captureId: 'c3', + detection: box('d3', 'o3'), + frames: FRAMES, + occurrenceId: TRACK_ID, + }) + ).toEqual({ kind: 'add', detectionId: 'd3' }) + }) + + test('a box of a multi-frame occurrence asks whether to merge or move', () => { + expect( + getExtendClick({ + captureId: 'c3', + detection: box('d3', 'o3', 4), + frames: FRAMES, + occurrenceId: TRACK_ID, + }) + ).toEqual({ + kind: 'choose', + choice: { + detectionId: 'd3', + frameCount: 4, + label: 'Moth #o3', + occurrenceId: 'o3', + }, + }) + }) + + test('a box already in the track asks to remove it', () => { + expect( + getExtendClick({ + captureId: 'c2', + detection: box('d2', TRACK_ID, 2), + frames: FRAMES, + occurrenceId: TRACK_ID, + }) + ).toEqual({ kind: 'remove', detectionId: 'd2' }) + }) + + test('another box on a covered capture asks to replace the frame, even from a longer track', () => { + expect( + getExtendClick({ + captureId: 'c2', + detection: box('d9', 'o9', 5), + frames: FRAMES, + occurrenceId: TRACK_ID, + }) + ).toEqual({ + kind: 'replace', + addDetectionId: 'd9', + removeDetectionId: 'd2', + }) + }) + + test("the track's only frame is neither removed nor replaced", () => { + const frames = [FRAMES[0]] + + expect( + getExtendClick({ + captureId: 'c1', + detection: box('d1', TRACK_ID), + frames, + occurrenceId: TRACK_ID, + }) + ).toEqual({ kind: 'only-frame' }) + expect( + getExtendClick({ + captureId: 'c1', + detection: box('d9'), + frames, + occurrenceId: TRACK_ID, + }) + ).toEqual({ kind: 'only-frame' }) + }) +}) + +describe('getExtendClickHint', () => { + const hintFor = ( + captureId: string, + detection: ReturnType, + frames = FRAMES + ) => + getExtendClickHint( + getExtendClick({ captureId, detection, frames, occurrenceId: TRACK_ID }) + ) + + test('a box that would join the track is marked with a plus', () => { + expect(hintFor('c3', box('d3', 'o3'))).toEqual({ + indicator: 'add', + string: STRING.TRACK_MATCH_CLICK_ADD, + }) + expect(hintFor('c3', box('d3', 'o3', 4))).toEqual({ + indicator: 'add', + string: STRING.TRACK_MATCH_CLICK_CHOOSE, + }) + }) + + test("the track's own box is marked with a minus", () => { + expect(hintFor('c2', box('d2', TRACK_ID, 2))).toEqual({ + indicator: 'remove', + string: STRING.TRACK_MATCH_CLICK_REMOVE, + }) + }) + + test('another box on a covered capture offers to replace the frame', () => { + expect(hintFor('c2', box('d9', 'o9'))).toEqual({ + indicator: 'replace', + string: STRING.TRACK_MATCH_CLICK_REPLACE, + }) + }) + + test("the track's only frame is marked as blocked", () => { + expect(hintFor('c1', box('d1', TRACK_ID), [FRAMES[0]])).toEqual({ + indicator: 'blocked', + string: STRING.TRACK_EXTEND_ONLY_FRAME, + }) + }) +}) diff --git a/ui/src/pages/session-details/capture/extend-click.ts b/ui/src/pages/session-details/capture/extend-click.ts new file mode 100644 index 000000000..524fa5eb5 --- /dev/null +++ b/ui/src/pages/session-details/capture/extend-click.ts @@ -0,0 +1,100 @@ +import { CaptureDetection } from 'data-services/models/capture' +import { TrackFrame } from 'data-services/models/occurrence-details' +import { STRING } from 'utils/language' + +/** A clicked frame whose own occurrence spans several frames, so the reviewer picks what to move. */ +export interface ExtendChoice { + detectionId: string + frameCount: number + /** How the clicked occurrence is named in the dialog: its determination and its id. */ + label: string + occurrenceId: string +} + +export type ExtendClick = + | { kind: 'add'; detectionId: string } + | { kind: 'choose'; choice: ExtendChoice } + | { kind: 'only-frame' } + | { kind: 'remove'; detectionId: string } + | { kind: 'replace'; addDetectionId: string; removeDetectionId: string } + +/** A correction that waits for the reviewer to confirm it in the bar. */ +export type ExtendPending = Extract + +/** + * What a box click in extend mode asks for. One animal cannot appear twice in a + * capture, so a click on a capture the track covers swaps its frame rather than adding. + */ +export const getExtendClick = ({ + captureId, + detection, + frames, + occurrenceId, +}: { + captureId?: string + detection: Pick< + CaptureDetection, + 'frameCount' | 'id' | 'label' | 'occurrenceId' + > + /** The frames of the track being extended. */ + frames: Pick[] + occurrenceId: string +}): ExtendClick => { + const inTrack = + detection.occurrenceId === occurrenceId || + frames.some((frame) => frame.id === detection.id) + const frameOnCapture = captureId + ? frames.find((frame) => frame.captureId === captureId) + : undefined + + // The server refuses to empty an occurrence, and a swap removes before it adds. + if ((inTrack || frameOnCapture) && frames.length <= 1) { + return { kind: 'only-frame' } + } + + if (inTrack) { + return { kind: 'remove', detectionId: detection.id } + } + + if (frameOnCapture) { + return { + kind: 'replace', + addDetectionId: detection.id, + removeDetectionId: frameOnCapture.id, + } + } + + if (detection.occurrenceId && detection.frameCount > 1) { + return { + kind: 'choose', + choice: { + detectionId: detection.id, + frameCount: detection.frameCount, + label: `${detection.label} #${detection.occurrenceId}`, + occurrenceId: detection.occurrenceId, + }, + } + } + + return { kind: 'add', detectionId: detection.id } +} + +export type ExtendClickIndicator = 'add' | 'blocked' | 'remove' | 'replace' + +const CLICK_HINTS: Record< + ExtendClick['kind'], + { indicator: ExtendClickIndicator; string: STRING } +> = { + add: { indicator: 'add', string: STRING.TRACK_MATCH_CLICK_ADD }, + choose: { indicator: 'add', string: STRING.TRACK_MATCH_CLICK_CHOOSE }, + 'only-frame': { + indicator: 'blocked', + string: STRING.TRACK_EXTEND_ONLY_FRAME, + }, + remove: { indicator: 'remove', string: STRING.TRACK_MATCH_CLICK_REMOVE }, + replace: { indicator: 'replace', string: STRING.TRACK_MATCH_CLICK_REPLACE }, +} + +/** What a box's tooltip says a click will do, read from the decision the click acts on. */ +export const getExtendClickHint = (click: ExtendClick) => + CLICK_HINTS[click.kind] diff --git a/ui/src/pages/session-details/capture/extend-track.tsx b/ui/src/pages/session-details/capture/extend-track.tsx new file mode 100644 index 000000000..8ca463190 --- /dev/null +++ b/ui/src/pages/session-details/capture/extend-track.tsx @@ -0,0 +1,729 @@ +import { + DisabledReason, + TrackEditDialog, +} from 'components/track/track-edit-dialog' +import { useFetchCaptureDetails } from 'data-services/hooks/captures/useFetchCaptureDetails' +import { useAddDetections } from 'data-services/hooks/occurrences/track/useAddDetections' +import { useMergeOccurrences } from 'data-services/hooks/occurrences/track/useMergeOccurrences' +import { useRemoveDetection } from 'data-services/hooks/occurrences/track/useRemoveDetection' +import { useSetGroupingVerified } from 'data-services/hooks/occurrences/track/useSetGroupingVerified' +import { useOccurrenceDetails } from 'data-services/hooks/occurrences/useOccurrenceDetails' +import { useOccurrencePath } from 'data-services/hooks/occurrences/useOccurrencePath' +import { CaptureDetection } from 'data-services/models/capture' +import { TrackFrame } from 'data-services/models/occurrence-details' +import { getTrackFrames } from 'data-services/models/occurrence-path' +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeftIcon, + ChevronsRightIcon, + InfoIcon, + Loader2Icon, + RouteIcon, +} from 'lucide-react' +import { BasicTooltip, Button, LoadingSpinner } from 'nova-ui-kit' +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' +import { getTrackEditRights } from 'pages/occurrence-details/track/track-edit-rights' +import { STRING, translate } from 'utils/language' +import { parseServerError } from 'utils/parseServerError/parseServerError' +import { useExtendOccurrenceId } from '../hooks/useExtendOccurrenceId' +import { getExtendOccurrenceId } from './extend-access' +import { + ExtendChoice, + ExtendClick, + ExtendPending, + getExtendClick, +} from './extend-click' +import { + getMergedTrackExtent, + getTrackNavigation, + MergedTrackExtent, + TrackPosition, +} from './track-navigation' + +/** Why the last box click changed nothing, or why a successful edit did not move on. */ +export type ExtendNote = 'no-later-capture' | 'only-frame' + +// A track under construction often scores below the project's default threshold. +const EXTEND_FETCH_OPTIONS = { skipDefaultFilters: true } + +// Every frame of a track, from the lightweight path rather than the paged detail. +const useTrackFrames = (occurrenceId?: string) => { + const { path, isLoading, error } = useOccurrencePath( + occurrenceId, + !!occurrenceId + ) + const frames = useMemo( + () => (path ? getTrackFrames(path) : undefined), + [path] + ) + + return { frames, isLoading, error } +} + +const NOTES: Record = { + 'no-later-capture': { string: STRING.TRACK_EXTEND_NO_LATER_CAPTURE }, + 'only-frame': { isError: true, string: STRING.TRACK_EXTEND_ONLY_FRAME }, +} + +export interface ExtendTrackState { + cancelChoice: () => void + cancelPending: () => void + choice?: ExtendChoice + clickBox: (detection: CaptureDetection) => void + confirmPending: () => void + error?: unknown + isLoading: boolean + mergeChoice: () => void + moveChoice: () => void + note?: ExtendNote + occurrenceId?: string + /** Where a replaced frame went when moving the clicked frame in then failed. */ + orphanOccurrenceId?: string + pending?: ExtendPending + /** What a click on this box would do; undefined while a click would be ignored. */ + previewClick: (detection: CaptureDetection) => ExtendClick | undefined + /** Whether the box popovers show; off by default so they never cover the boxes. */ + showDetails: boolean + start: (occurrenceId: string) => void + stop: () => void + toggleDetails: () => void +} + +/** + * Extend mode: a box click adds that frame to one occurrence and moves on, instead of + * selecting the occurrence behind it. It is how a track gets built by hand on a project + * where no classifier ran. See #1418. + */ +export const useExtendTrack = ({ + captureId, + enabled, + nextCaptureId, + onSelectCapture, + sessionId, +}: { + captureId?: string + /** Off, an `extend` link is ignored rather than opening the mode. */ + enabled: boolean + /** Where a successful edit moves on to: the next capture with detections, if any. */ + nextCaptureId?: string + onSelectCapture: (captureId: string) => void + /** The session on screen; an `extend` link to another session's occurrence is ignored. */ + sessionId: string +}): ExtendTrackState => { + const { projectId } = useParams() + const { extendOccurrenceId: requestedOccurrenceId, setExtendOccurrenceId } = + useExtendOccurrenceId() + const { occurrence: requestedTrack } = useOccurrenceDetails( + enabled ? requestedOccurrenceId ?? '' : '', + EXTEND_FETCH_OPTIONS + ) + const extendOccurrenceId = getExtendOccurrenceId({ + enabled, + requestedOccurrenceId, + sessionId, + track: requestedTrack, + }) + const { frames: trackFrames } = useTrackFrames(extendOccurrenceId) + const [choice, setChoice] = useState() + // The merge deletes the clicked occurrence, so its frames are read while it still exists. + const { frames: clickedFrames } = useTrackFrames(choice?.occurrenceId) + const fetchCaptureDetails = useFetchCaptureDetails(projectId as string) + const [note, setNote] = useState() + const [pending, setPending] = useState() + const [orphanOccurrenceId, setOrphanOccurrenceId] = useState() + const [showDetails, setShowDetails] = useState(false) + const add = useAddDetections(extendOccurrenceId ?? '') + const merge = useMergeOccurrences(extendOccurrenceId ?? '') + const remove = useRemoveDetection(extendOccurrenceId ?? '') + const isLoading = add.isLoading || merge.isLoading || remove.isLoading + // An edit can land after the reviewer has stepped to another capture. + const latest = useRef({ captureId, nextCaptureId }) + latest.current = { captureId, nextCaptureId } + + const clearFeedback = () => { + setNote(undefined) + setOrphanOccurrenceId(undefined) + setPending(undefined) + add.reset() + merge.reset() + remove.reset() + } + + useEffect(() => { + // Each capture is its own decision, so a note, a failure or an unconfirmed + // correction from the one before it never carries over into the banner. + clearFeedback() + }, [captureId]) + + useEffect(() => { + setChoice(undefined) + setNote(undefined) + setPending(undefined) + setShowDetails(false) + }, [extendOccurrenceId]) + + const finish = (editedCaptureId?: string) => { + setChoice(undefined) + + if (latest.current.captureId !== editedCaptureId) { + return + } + + if (latest.current.nextCaptureId) { + onSelectCapture(latest.current.nextCaptureId) + } else { + setNote('no-later-capture') + } + } + + /** + * A merge adds a whole track at once, so it carries on from the merged track's far + * end instead of the clicked frame, which the reviewer has already seen. + */ + const finishMerge = async ( + editedCaptureId: string | undefined, + extent: MergedTrackExtent + ) => { + setChoice(undefined) + + if (latest.current.captureId !== editedCaptureId) { + return + } + + const { boundaryCaptureId, direction } = extent + const boundary = boundaryCaptureId + ? // A failure here only costs the smarter landing spot. + await fetchCaptureDetails(boundaryCaptureId).catch(() => undefined) + : undefined + + if (latest.current.captureId !== editedCaptureId) { + return + } + + if (!boundary || !boundaryCaptureId) { + finish(editedCaptureId) + return + } + + const neighbourId = + direction === 'backward' + ? boundary.prevCaptureWithDetectionsId + : boundary.nextCaptureWithDetectionsId + + if (neighbourId) { + onSelectCapture(neighbourId) + } else if (boundaryCaptureId !== editedCaptureId) { + onSelectCapture(boundaryCaptureId) + } else if (direction === 'forward') { + setNote('no-later-capture') + } + } + + const addFrame = (detectionId: string) => + add + .addDetections([detectionId]) + .then(() => finish(captureId)) + // The rejection is reported through the mutation's error state. + .catch(() => undefined) + + // Every branch reads the track's frames, so a click waits for them to load. + const previewClick = (detection: CaptureDetection) => + extendOccurrenceId && trackFrames && !isLoading + ? getExtendClick({ + captureId, + detection, + frames: trackFrames, + occurrenceId: extendOccurrenceId, + }) + : undefined + + const clickBox = (detection: CaptureDetection) => { + const click = previewClick(detection) + + if (!click) { + return + } + + clearFeedback() + + switch (click.kind) { + case 'add': + addFrame(click.detectionId) + break + case 'choose': + setChoice(click.choice) + break + case 'only-frame': + setNote('only-frame') + break + case 'remove': + case 'replace': + setPending(click) + break + } + } + + // Corrections stay on this capture, so the reviewer can click the right box next. + const confirmPending = () => { + if (!pending || isLoading) { + return + } + + const request = + pending.kind === 'remove' + ? remove.removeDetection(pending.detectionId) + : remove.removeDetection(pending.removeDetectionId).then(({ data }) => + add.addDetections([pending.addDetectionId]).catch((error) => { + setOrphanOccurrenceId(`${data.new_occurrence_id}`) + throw error + }) + ) + + request + // The rejection is reported through the mutation's error state. + .catch(() => undefined) + .then(() => + setPending((current) => (current === pending ? undefined : current)) + ) + } + + return { + cancelChoice: () => setChoice(undefined), + cancelPending: () => setPending(undefined), + choice, + clickBox, + confirmPending, + error: add.error ?? merge.error ?? remove.error, + isLoading, + mergeChoice: () => { + if (choice) { + const extent = getMergedTrackExtent({ + addedFrames: clickedFrames ?? [], + clickedDetectionId: choice.detectionId, + trackFrames: trackFrames ?? [], + }) + + merge + .mergeOccurrences([choice.occurrenceId]) + .then(() => finishMerge(captureId, extent)) + .catch(() => undefined) + } + }, + moveChoice: () => { + if (choice) { + addFrame(choice.detectionId) + } + }, + note, + occurrenceId: extendOccurrenceId, + orphanOccurrenceId, + pending, + previewClick, + showDetails, + start: setExtendOccurrenceId, + stop: () => setExtendOccurrenceId(undefined), + toggleDetails: () => setShowDetails((shown) => !shown), + } +} + +const describePosition = (position: TrackPosition, total: number) => { + switch (position.kind) { + case 'frame': + return translate(STRING.TRACK_POSITION_FRAME, { + index: position.index, + total, + }) + case 'between': + return translate(STRING.TRACK_POSITION_BETWEEN, { + after: position.before + 1, + before: position.before, + }) + case 'before-first': + return translate(STRING.TRACK_POSITION_BEFORE_FIRST) + case 'after-last': + return translate(STRING.TRACK_POSITION_AFTER_LAST) + } +} + +const TrackNavButton = ({ + children, + label, + onSelectCapture, + targetCaptureId, +}: { + children: ReactNode + label: string + onSelectCapture: (captureId: string) => void + targetCaptureId?: string +}) => ( + + + +) + +/** + * What extend mode is doing, above the capture so it never covers a box. The track + * buttons step between the track's own frames rather than every capture. + */ +export const ExtendTrackBanner = ({ + captureDate, + captureId, + extend, + occurrenceId, + onSelectCapture, +}: { + captureDate?: Date + captureId?: string + extend: ExtendTrackState + occurrenceId: string + onSelectCapture: (captureId: string) => void +}) => { + const { projectId } = useParams() + const { occurrence } = useOccurrenceDetails( + occurrenceId, + EXTEND_FETCH_OPTIONS + ) + const verify = useSetGroupingVerified(occurrenceId) + const { frames } = useTrackFrames(occurrenceId) + const navigation = getTrackNavigation({ + captureDate, + captureId, + frames: frames ?? [], + }) + const { first, last, position, total } = navigation + const { canVerify } = getTrackEditRights(occurrence?.userPermissions) + const error = extend.error ?? verify.error + const errorMessage = error ? parseServerError(error).message : undefined + const noteInfo = extend.note ? NOTES[extend.note] : undefined + const occurrenceRoute = getAppRoute({ + to: APP_ROUTES.OCCURRENCE_DETAILS({ + projectId: projectId as string, + occurrenceId, + }), + filters: { apply_defaults: 'false' }, + }) + + // A one-frame track still gets buttons back to its frame from another capture. + const targetOf = (frame?: TrackFrame) => + frame?.captureId !== captureId ? frame?.captureId : undefined + + let timeSpan: string | undefined + if (first && last) { + timeSpan = + first === last + ? first.timeLabel + : translate(STRING.TRACK_TIME_SPAN, { + end: last.timeLabel, + start: first.timeLabel, + }) + } + + return ( +
+
+ + + {translate(STRING.TRACK_EXTEND_LABEL)} + + + {occurrence?.displayName ?? `#${occurrenceId}`} + + {occurrence ? ( + <> + + {total === 1 + ? translate(STRING.TRACK_FRAMES_ONE) + : translate(STRING.TRACK_FRAMES_COUNT, { count: total })} + + {timeSpan ? ( + + {timeSpan} + + ) : null} + {position ? ( + + {describePosition(position, total)} + + ) : null} + + {occurrence.groupingVerified + ? translate(STRING.TRACK_GROUPING_CONFIRMED) + : translate(STRING.TRACK_GROUPING_UNCONFIRMED)} + + + ) : null} + {extend.isLoading ? ( + + ) : null} + {noteInfo ? ( + + {translate(noteInfo.string)} + + ) : null} + {errorMessage ? ( + + {errorMessage} + {extend.orphanOccurrenceId + ? ` ${translate(STRING.TRACK_EXTEND_REPLACE_ORPHAN, { + id: extend.orphanOccurrenceId, + })}` + : null} + + ) : null} +
+ {extend.pending ? ( +
+ + {extend.pending.kind === 'remove' + ? translate(STRING.TRACK_EXTEND_REMOVE_PROMPT) + : translate(STRING.TRACK_EXTEND_REPLACE_PROMPT)} + + + +
+ ) : null} +
+
+ + + + + + + + + + + + +
+ {occurrence && canVerify ? ( + + ) : null} + + + + + + +
+
+ ) +} + +const ExtendCrop = ({ + label, + src, + timeLabel, +}: { + label: string + src?: string + timeLabel?: string +}) => ( +
+
+ {src ? ( + + ) : null} +
+ {label} + + {timeLabel ?? translate(STRING.VALUE_NOT_AVAILABLE)} + +
+) + +/** + * The choice a multi-frame box forces: the whole track joins the one being extended, + * or only the clicked frame moves across. The two crops are what the decision rests on. + */ +export const ExtendTrackDialog = ({ + choice, + extend, + occurrenceId, +}: { + choice: ExtendChoice + extend: ExtendTrackState + occurrenceId: string +}) => { + const { + frames: clicked, + isLoading: clickedLoading, + error: clickedError, + } = useTrackFrames(choice.occurrenceId) + const { + frames: extended, + isLoading: extendedLoading, + error: extendedError, + } = useTrackFrames(occurrenceId) + + // One animal cannot appear twice in a capture, so nothing may land on a capture + // the extended track already covers. + const coveredCaptureIds = new Set(extended?.map((frame) => frame.captureId)) + const isCovered = (captureId?: string) => + !!captureId && coveredCaptureIds.has(captureId) + const clickedFrame = clicked?.find((frame) => frame.id === choice.detectionId) + const mergeBlocked = !!clicked?.some((frame) => isCovered(frame.captureId)) + const moveBlocked = isCovered(clickedFrame?.captureId) + + const latestFrame = extended?.[0] + + return ( + (open ? undefined : extend.cancelChoice())} + open + title={translate(STRING.TRACK_EXTEND_TITLE)} + > + {clickedLoading || extendedLoading ? ( +
+ +
+ ) : ( + <> +
+ + +
+
+ + + +
+ + )} +
+ ) +} diff --git a/ui/src/pages/session-details/capture/ghost-frame.test.ts b/ui/src/pages/session-details/capture/ghost-frame.test.ts new file mode 100644 index 000000000..cc898941f --- /dev/null +++ b/ui/src/pages/session-details/capture/ghost-frame.test.ts @@ -0,0 +1,39 @@ +import { PathFrame } from 'data-services/models/occurrence-path' +import { getGhostFrame } from './ghost-frame' + +const frame = (overrides: Partial = {}): PathFrame => ({ + bbox: [10, 10, 40, 40], + captureHeight: 200, + captureId: 'c1', + captureWidth: 300, + cropUrl: 'https://example.test/crop.jpg', + detectionId: 'd1', + timestamp: new Date(2026, 8, 1, 22, 46, 32), + ...overrides, +}) + +describe('getGhostFrame', () => { + it('withholds the crop until crops are asked for', () => { + expect(getGhostFrame(frame(), false).cropUrl).toBeUndefined() + expect(getGhostFrame(frame(), true).cropUrl).toBe( + 'https://example.test/crop.jpg' + ) + }) + + it('has no crop to offer for a frame whose crop has not been generated', () => { + expect( + getGhostFrame(frame({ cropUrl: undefined }), true).cropUrl + ).toBeUndefined() + }) + + it('names the frame by its time, down to the second that separates captures', () => { + expect(getGhostFrame(frame(), false).label).toContain('46:32') + }) + + it('stays labelled when the frame has no timestamp', () => { + const label = getGhostFrame(frame({ timestamp: null }), false).label + + expect(label).not.toBe('') + expect(label).not.toContain('undefined') + }) +}) diff --git a/ui/src/pages/session-details/capture/ghost-frame.ts b/ui/src/pages/session-details/capture/ghost-frame.ts new file mode 100644 index 000000000..f61dbc6f8 --- /dev/null +++ b/ui/src/pages/session-details/capture/ghost-frame.ts @@ -0,0 +1,32 @@ +import { PathFrame } from 'data-services/models/occurrence-path' +import { getFormatedTimeString } from 'utils/date/getFormatedTimeString/getFormatedTimeString' +import { STRING, translate } from 'utils/language' + +/** What a box drawn from a neighbouring frame offers: the crop to fill it and the frame it steps to. */ +export interface GhostFrame { + captureId: string + /** Absent unless crops were asked for and this frame has one. */ + cropUrl?: string + label: string + /** When the frame was captured, to the second that separates two captures. */ + time: string +} + +export const getGhostFrame = ( + frame: PathFrame, + showCrops: boolean +): GhostFrame => { + const time = frame.timestamp + ? getFormatedTimeString({ + date: frame.timestamp, + options: { second: true }, + }) + : translate(STRING.VALUE_NOT_AVAILABLE) + + return { + captureId: frame.captureId, + cropUrl: showCrops ? frame.cropUrl : undefined, + label: translate(STRING.TRACK_PATH_GO_TO_FRAME, { time }), + time, + } +} diff --git a/ui/src/pages/session-details/capture/ghost-trail.test.ts b/ui/src/pages/session-details/capture/ghost-trail.test.ts new file mode 100644 index 000000000..cfe052b00 --- /dev/null +++ b/ui/src/pages/session-details/capture/ghost-trail.test.ts @@ -0,0 +1,130 @@ +import { PathFrame } from 'data-services/models/occurrence-path' +import { buildTrail, MAX_GHOST_BOXES } from './ghost-trail' + +const path = (length: number): PathFrame[] => + Array.from({ length }, (_, index) => ({ + bbox: [10 + index, 10, 40 + index, 40], + captureHeight: 200, + captureId: `c${index}`, + captureWidth: 300, + cropUrl: `https://example.test/${index}.jpg`, + detectionId: `d${index}`, + timestamp: new Date(2026, 8, 1, 22, index), + })) + +const byId = (frames: PathFrame[], captureId: string) => + buildTrail(frames, captureId) + +describe('buildTrail', () => { + it('never draws a path frame solid, so only the capture on screen looks live', () => { + const { ghosts } = byId(path(20), 'c10') + + expect(ghosts.length).toBeGreaterThan(0) + ghosts.forEach((ghost) => expect(ghost.opacity).toBeLessThan(1)) + }) + + it('fades a frame further from the capture on screen than its neighbour', () => { + const { ghosts } = byId(path(20), 'c10') + const near = ghosts.find((ghost) => ghost.distance === 1) + const far = ghosts.find((ghost) => ghost.distance === 5) + + expect(near && far && near.opacity > far.opacity).toBe(true) + }) + + it('holds the fade at a floor, so the furthest frame drawn is still visible', () => { + const { ghosts } = byId(path(20), 'c10') + + ghosts.forEach((ghost) => expect(ghost.opacity).toBeGreaterThanOrEqual(0.2)) + }) + + it('stacks a nearer frame over a further one', () => { + const { ghosts } = byId(path(20), 'c10') + const near = ghosts.find((ghost) => ghost.distance === 1) + const far = ghosts.find((ghost) => ghost.distance === 5) + + expect(near && far && near.zIndex > far.zIndex).toBe(true) + }) + + it('leaves the frame on screen to its own live box but still counts it as shown', () => { + const { ghosts, shownCount } = byId(path(20), 'c10') + + expect(ghosts.map((ghost) => ghost.id)).not.toContain('d10') + expect(shownCount).toBe(ghosts.length + 1) + }) + + it('draws no more neighbours than the cap either side of the capture on screen', () => { + const { ghosts } = byId(path(40), 'c20') + + expect(ghosts).toHaveLength(MAX_GHOST_BOXES) + ghosts.forEach((ghost) => + expect(ghost.distance).toBeLessThanOrEqual(MAX_GHOST_BOXES / 2) + ) + }) + + it('numbers a frame by its place in the whole track, not in the part drawn', () => { + const { ghosts, total } = byId(path(40), 'c20') + const first = ghosts[0] + + expect(total).toBe(40) + // The first box drawn is eight frames back, so it is the thirteenth of forty. + expect(first.position).toBe(13) + }) + + it('separates the frames before the capture on screen from the ones after', () => { + const { ghosts } = byId(path(20), 'c10') + + expect(ghosts.find((ghost) => ghost.id === 'd9')?.isEarlier).toBe(true) + expect(ghosts.find((ghost) => ghost.id === 'd11')?.isEarlier).toBe(false) + }) + + it('treats every frame as a ghost when the capture on screen holds none', () => { + const { ghosts, shownCount } = byId(path(6), 'elsewhere') + + expect(ghosts.map((ghost) => ghost.id)).toContain('d0') + expect(shownCount).toBe(ghosts.length) + }) + + it('reads direction from the clock when no frame sits on the capture viewed', () => { + // Extending a track puts the viewer on captures the track does not cover, so the + // track's order cannot say which frames come before the one on screen. + const viewed = new Date(2026, 8, 1, 22, 2, 30) + const { ghosts } = buildTrail(path(5), 'elsewhere', viewed) + + expect(ghosts.find((ghost) => ghost.id === 'd1')?.isEarlier).toBe(true) + expect(ghosts.find((ghost) => ghost.id === 'd4')?.isEarlier).toBe(false) + }) + + it('measures the gap from the capture being viewed, signed by direction', () => { + const viewed = new Date(2026, 8, 1, 22, 2) + const { ghosts } = buildTrail(path(5), 'c2', viewed) + + // The helper spaces frames a minute apart, so index 1 is a minute before the + // capture on screen and index 4 is two minutes after it. + expect(ghosts.find((ghost) => ghost.id === 'd1')?.offsetSeconds).toBe(-60) + expect(ghosts.find((ghost) => ghost.id === 'd4')?.offsetSeconds).toBe(120) + }) + + it('reports no gap for a frame with no timestamp', () => { + const frames = path(3) + frames[0] = { ...frames[0], timestamp: null } + const { ghosts } = buildTrail(frames, 'c1', new Date(2026, 8, 1, 22, 1)) + + expect(ghosts.find((ghost) => ghost.id === 'd0')?.offsetSeconds).toBeNull() + }) + + it('reports no gap when the capture being viewed has no timestamp of its own', () => { + const { ghosts } = byId(path(4), 'c1') + + ghosts.forEach((ghost) => expect(ghost.offsetSeconds).toBeNull()) + }) + + it('keeps the fade under its ceiling when no frame sits on the capture viewed', () => { + const { ghosts } = byId(path(6), 'elsewhere') + + expect(ghosts.length).toBeGreaterThan(0) + ghosts.forEach((ghost) => { + expect(ghost.opacity).toBeLessThanOrEqual(0.75) + expect(ghost.distance).toBeGreaterThanOrEqual(1) + }) + }) +}) diff --git a/ui/src/pages/session-details/capture/ghost-trail.ts b/ui/src/pages/session-details/capture/ghost-trail.ts new file mode 100644 index 000000000..d144a0bd3 --- /dev/null +++ b/ui/src/pages/session-details/capture/ghost-trail.ts @@ -0,0 +1,144 @@ +import { PathFrame } from 'data-services/models/occurrence-path' +import { CONSTANTS } from 'nova-ui-kit/constants' +import { bboxToPercentCentre, bboxToPercentStyle } from './bbox' + +// Enough neighbouring frames to read the animal's movement, few enough that a long +// occurrence does not smear the capture. The path line runs through every frame +// regardless, since one thin line stays legible. +export const MAX_GHOST_BOXES = 16 + +// One colour for every path frame, whether it comes before or after the capture on +// screen. It is not the selection blue: that belongs to the box of the occurrence +// being viewed, and a path frame wearing it read as the live box rather than a past one. +// It is the same amber a low-scoring live box outlines itself in, which the dashes and +// the fade are what separate it from. +export const GHOST_COLOR = CONSTANTS.COLORS.warning[500] + +// No path frame is drawn solid. Solid is how the capture on screen looks, and telling +// the two apart is the reason the others fade at all. +const MAX_GHOST_OPACITY = 0.75 +// Far frames stay faint enough to read as context, dark enough to still be seen. +const MIN_GHOST_OPACITY = 0.2 +const OPACITY_FALLOFF = 0.08 + +/** One frame of the path drawn over a capture it was not measured in. */ +export interface Ghost { + /** Frames between this one and the capture on screen; the neighbour is 1. */ + distance: number + frame: PathFrame + height: number + id: string + /** This frame was captured before the one on screen. */ + isEarlier: boolean + /** + * Seconds from the capture being viewed to this frame, negative for an earlier one. + * Null when either timestamp is missing, which is the only honest answer then. + */ + offsetSeconds: number | null + opacity: number + /** Where the frame sits along the whole track, counted from 1. */ + position: number + width: number + x: number + y: number + /** Nearer frames stack over further ones, the way onion skins do. */ + zIndex: number +} + +export interface Trail { + ghosts: Ghost[] + points: string + /** Ghost boxes drawn, which the cap can hold below the path's length. */ + shownCount: number + /** Frames in the whole path, which is more than the trail draws. */ + total: number +} + +const frameBox = (frame: PathFrame) => { + const style = bboxToPercentStyle( + frame.bbox, + frame.captureWidth, + frame.captureHeight + ) + + return style + ? { + height: parseFloat(style.height), + width: parseFloat(style.width), + x: parseFloat(style.left), + y: parseFloat(style.top), + } + : undefined +} + +/** Turn a path into the boxes and line to draw over the capture being viewed. */ +export const buildTrail = ( + path: PathFrame[], + activeCaptureId?: string, + /** When the capture on screen holds no frame of the track, the only thing that can say + * whether a frame comes before or after it is its timestamp. */ + activeTimestamp?: Date | null +): Trail => { + const anchor = path.findIndex((frame) => frame.captureId === activeCaptureId) + + const points = path.reduce((collected: string[], frame) => { + const centre = bboxToPercentCentre( + frame.bbox, + frame.captureWidth, + frame.captureHeight + ) + + if (centre) { + collected.push(`${centre.x},${centre.y}`) + } + + return collected + }, []) + + const ghosts = path.reduce((collected: Ghost[], frame, index) => { + const box = frameBox(frame) + // With no frame of the track on the capture being viewed there is nothing to measure + // from, so the track's own order stands in. It starts at one either way: a distance of + // zero would draw a frame more solidly than the ceiling allows. + const distance = anchor === -1 ? index + 1 : Math.abs(index - anchor) + + if (!box || index === anchor || distance > MAX_GHOST_BOXES / 2) { + return collected + } + + collected.push({ + ...box, + distance, + frame, + id: frame.detectionId, + isEarlier: + anchor !== -1 + ? index < anchor + : !!activeTimestamp && + !!frame.timestamp && + frame.timestamp < activeTimestamp, + offsetSeconds: + activeTimestamp && frame.timestamp + ? Math.round( + (frame.timestamp.getTime() - activeTimestamp.getTime()) / 1000 + ) + : null, + opacity: Math.max( + MIN_GHOST_OPACITY, + MAX_GHOST_OPACITY - (distance - 1) * OPACITY_FALLOFF + ), + position: index + 1, + zIndex: MAX_GHOST_BOXES - distance, + }) + + return collected + }, []) + + return { + ghosts, + points: points.join(' '), + // The frame being viewed is drawn by its own live box, so it counts as shown. + shownCount: ghosts.length + (anchor === -1 ? 0 : 1), + total: path.length, + } +} diff --git a/ui/src/pages/session-details/capture/occurrence-toolbar.tsx b/ui/src/pages/session-details/capture/occurrence-toolbar.tsx new file mode 100644 index 000000000..974a2068c --- /dev/null +++ b/ui/src/pages/session-details/capture/occurrence-toolbar.tsx @@ -0,0 +1,322 @@ +import { CopyLinkButton } from 'components/copy-link-button/copy-link-button' +import { DeterminationScore } from 'components/determination-score' +import { useOccurrenceDetails } from 'data-services/hooks/occurrences/useOccurrenceDetails' +import { PathFrame } from 'data-services/models/occurrence-path' +import { Loader2Icon, RouteIcon, XIcon } from 'lucide-react' +import { Button } from 'nova-ui-kit' +import { getTrackEditRights } from 'pages/occurrence-details/track/track-edit-rights' +import { ReactNode } from 'react' +import { getFormatedDateTimeString } from 'utils/date/getFormatedDateTimeString/getFormatedDateTimeString' +import { getFormatedTimeString } from 'utils/date/getFormatedTimeString/getFormatedTimeString' +import { STRING, translate } from 'utils/language' +import { buildDetectionLink } from '../hooks/useActiveDetection' + +export interface ToolbarOccurrence { + frameCount: number + groupingVerified: boolean + groupingVerifiedAt: Date | null + groupingVerifiedBy: string | null + id: string + label: string + score: number + scoreLabel?: string +} + +const describeFrames = (count: number) => + count === 1 + ? translate(STRING.TRACK_FRAMES_ONE) + : translate(STRING.TRACK_FRAMES_COUNT, { count }) + +/** One labelled identifier, with the value set apart so it can be read off quickly. */ +const IdRow = ({ + children, + label, + value, +}: { + children?: ReactNode + label: string + value: string +}) => ( +
+ {label} + {value} + {children} +
+) + +/** + * The control anchored under a selected occurrence's box. + * + * It is laid out in three bands, because it carries more than a single row of buttons + * can hold legibly: what this is, which frame of it you are on, and what you can do to + * it. Editing actions appear only once the path is on screen, so nobody restructures an + * occurrence on evidence they have not seen. Merge is the exception and stays available + * throughout: a stray single frame joins a chain that way. + */ +export const OccurrenceToolbar = ({ + detectionId, + isExtended, + isLoadingPath, + occurrence, + onDismiss, + onExtend, + onHidePath, + onMerge, + onOpenOccurrence, + onShowPath, + onSplit, + onTogglePathCrops, + onVerify, + path, + pathError, + showPathCrops, + shownFrames, +}: { + /** The detection this panel is anchored to, which is this track's frame on the capture. */ + detectionId?: string + /** This occurrence is the one extend mode is already adding frames to. */ + isExtended?: boolean + isLoadingPath?: boolean + occurrence: ToolbarOccurrence + /** Put the panel away while keeping the occurrence selected; Escape does the same. */ + onDismiss?: () => void + onExtend: () => void + onHidePath: () => void + onMerge: () => void + onOpenOccurrence: () => void + onShowPath: () => void + onSplit: () => void + /** Switch the path's boxes between the moth's own pixels and an outline. */ + onTogglePathCrops?: () => void + onVerify: () => void + path?: PathFrame[] + /** The last request for this occurrence's path failed; the show button retries. */ + pathError?: boolean + /** The path's boxes are filled with each frame's crop rather than left empty. */ + showPathCrops?: boolean + /** Ghost boxes actually drawn, which the trail caps below the path's length. */ + shownFrames?: number +}) => { + const singleFrame = occurrence.frameCount <= 1 + const pathShown = !!path?.length + // Capture boxes carry no permissions, so the occurrence's own are read; a track + // under the default threshold still needs them. + const { occurrence: details } = useOccurrenceDetails(occurrence.id, { + skipDefaultFilters: true, + }) + const { canRestructure, canVerify } = getTrackEditRights( + details?.userPermissions + ) + + // Matched on the detection rather than the capture: one capture can hold two frames + // of the same track, and they are different positions along it. + const frameIndex = + path && detectionId + ? path.findIndex((frame) => frame.detectionId === detectionId) + : -1 + const framePosition = + path && frameIndex !== -1 + ? translate(STRING.TRACK_POSITION_FRAME, { + index: frameIndex + 1, + total: path.length, + }) + : undefined + + const subtitle = () => { + if (!pathShown) { + return singleFrame + ? translate(STRING.TRACK_SINGLE_FRAME_NO_PATH) + : describeFrames(occurrence.frameCount) + } + + const times = path + .map((frame) => frame.timestamp) + .filter((timestamp): timestamp is Date => !!timestamp) + + if (!times.length) { + return describeFrames(path.length) + } + + return translate(STRING.TRACK_PATH_RANGE, { + end: getFormatedTimeString({ date: times[times.length - 1] }), + frames: describeFrames(path.length), + start: getFormatedTimeString({ date: times[0] }), + }) + } + + const pathActions = [ + pathShown ? ( + + ) : null, + pathShown && onTogglePathCrops ? ( + + ) : null, + // A single frame has no path to draw, and the subtitle says so. + !pathShown && !singleFrame ? ( + + ) : null, + ].filter(Boolean) + + const editActions = canRestructure + ? [ + pathShown ? ( + + ) : null, + , + !isExtended ? ( + + ) : null, + ].filter(Boolean) + : [] + const showVerify = pathShown && canVerify + + return ( +
+
+
+ + + {onDismiss ? ( + + ) : null} +
+ +
+ + {subtitle()} +
+
+ +
+ {framePosition ? {framePosition} : null} + + {shownFrames !== undefined && path && shownFrames < path.length ? ( + + {translate(STRING.TRACK_SHOWING_PART_OF_PATH, { + shown: shownFrames, + total: path.length, + })} + + ) : null} + + {occurrence.groupingVerified ? ( + + {translate(STRING.TRACK_GROUPING_CONFIRMED_BY, { + date: occurrence.groupingVerifiedAt + ? getFormatedDateTimeString({ + date: occurrence.groupingVerifiedAt, + }) + : translate(STRING.VALUE_NOT_AVAILABLE), + name: + occurrence.groupingVerifiedBy ?? + translate(STRING.ANONYMOUS_USER), + })} + + ) : null} + + {pathError && !pathShown && !isLoadingPath ? ( + + {translate(STRING.TRACK_PATH_ERROR)} + + ) : null} + + + + {detectionId ? ( + + {/* A detection keeps its id through every merge and split, so this link + still finds the box after the track it belongs to has changed. */} + + + ) : null} +
+ + {pathActions.length || editActions.length || showVerify ? ( +
+ {pathActions.length ? ( +
+ {pathActions} +
+ ) : null} + + {editActions.length ? ( +
+ {editActions} +
+ ) : null} + + {showVerify ? ( + + ) : null} +
+ ) : null} +
+ ) +} diff --git a/ui/src/pages/session-details/capture/session-track-edits.tsx b/ui/src/pages/session-details/capture/session-track-edits.tsx new file mode 100644 index 000000000..bfdb9be20 --- /dev/null +++ b/ui/src/pages/session-details/capture/session-track-edits.tsx @@ -0,0 +1,302 @@ +import { getCandidateSessionRoute } from 'components/track/candidate-session-route' +import { OccurrencePicker } from 'components/track/occurrence-picker' +import { TrackEditDialog } from 'components/track/track-edit-dialog' +import { useMergeSelection } from 'components/track/use-merge-selection' +import { useMergeOccurrences } from 'data-services/hooks/occurrences/track/useMergeOccurrences' +import { useSetGroupingVerified } from 'data-services/hooks/occurrences/track/useSetGroupingVerified' +import { useSplitTrack } from 'data-services/hooks/occurrences/track/useSplitTrack' +import { + MERGE_SCOPES, + MergeScopeKey, + getCandidatesEmptyMessage, + useMergeCandidates, +} from 'data-services/hooks/occurrences/useMergeCandidates' +import { MergeCandidate } from 'data-services/models/merge-candidate' +import { AlertCircleIcon, Loader2Icon, RouteIcon, XIcon } from 'lucide-react' +import { useState } from 'react' +import { useParams } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' +import { STRING, translate } from 'utils/language' + +/** + * Where the shown path stands, pinned to the capture's corner. The toolbar that + * requested it closes with its box, so this is what outlives a hover. The occurrence + * links back to its nearest frame, for when the path is drawn over a capture it skips. + */ +export const SessionPathStatus = ({ + error, + isLoading, + occurrenceId, + onDismiss, + onShowFrame, +}: { + error?: boolean + isLoading?: boolean + occurrenceId: string + onDismiss?: () => void + onShowFrame?: () => void +}) => { + const status = () => { + if (isLoading) { + return { + Icon: Loader2Icon, + iconClassName: 'animate-spin', + label: translate(STRING.TRACK_PATH_STATUS_LOADING, { + id: occurrenceId, + }), + } + } + if (error) { + return { + Icon: AlertCircleIcon, + iconClassName: 'text-destructive', + label: translate(STRING.TRACK_PATH_STATUS_ERROR, { id: occurrenceId }), + } + } + return { + Icon: RouteIcon, + iconClassName: '', + label: translate(STRING.TRACK_PATH_STATUS_SHOWN, { id: occurrenceId }), + } + } + const { Icon, iconClassName, label } = status() + const canShowFrame = !isLoading && !error && onShowFrame + + return ( + + + {canShowFrame ? ( + + {translate(STRING.TRACK_PATH_STATUS_SHOWN_FOR)}{' '} + + + ) : ( + {label} + )} + {onDismiss ? ( + + ) : null} + + ) +} + +export interface SessionTrackEdit { + action: 'split' | 'merge' | 'verify' + /** The frame the toolbar was anchored to; the split boundary. */ + detectionId: string + /** Frames a split would move: this frame and every later one. */ + movedBySplit?: number + occurrenceId: string + /** Time of the boundary frame, as the operator saw it when they opened the dialog. */ + timeLabel?: string + total?: number + verified?: boolean +} + +/** + * The confirmation dialogs the session toolbar opens. + * + * Rendered once for the capture rather than per box: a split moves the clicked frame + * onto another occurrence, so a dialog owned by that box would be unmounted by the + * refresh before it could report what happened. For the same reason the split's + * description arrives already worded, rather than being derived from the live path. + */ +export const SessionTrackEdits = ({ + edit, + onClose, +}: { + edit?: SessionTrackEdit + onClose: () => void +}) => { + const { id: sessionId, projectId } = useParams() + const [scope, setScope] = useState('next') + + const occurrenceId = edit?.occurrenceId ?? '' + const split = useSplitTrack(occurrenceId) + const merge = useMergeOccurrences(occurrenceId) + const { + setGroupingVerified, + error: verifyError, + isLoading: verifyLoading, + } = useSetGroupingVerified(occurrenceId) + const [verifyDone, setVerifyDone] = useState(false) + + const mergeScope = + MERGE_SCOPES.find((option) => option.key === scope) ?? MERGE_SCOPES[0] + const { + candidates, + costThreshold, + error: candidatesError, + isLoading: candidatesLoading, + requiresFeatures, + } = useMergeCandidates({ + captures: mergeScope.captures, + enabled: edit?.action === 'merge', + minutes: mergeScope.minutes, + occurrenceId, + projectId: projectId as string, + }) + + const { + clear: clearSelection, + selectedIds: sourceIds, + toggle: toggleSource, + } = useMergeSelection({ candidates, isLoading: candidatesLoading }) + + // Opened in a new tab, so the current selection is left alone. + const candidateSessionLink = (candidate: MergeCandidate) => + sessionId && candidate.captureId + ? getCandidateSessionRoute({ + captureId: candidate.captureId, + occurrenceIds: [occurrenceId, candidate.id], + sessionRoute: APP_ROUTES.SESSION_DETAILS({ + projectId: projectId as string, + sessionId, + }), + }) + : undefined + + const close = () => { + clearSelection() + setVerifyDone(false) + split.reset() + merge.reset() + onClose() + } + + if (!edit) { + return null + } + + return ( + <> + split.splitTrack(edit.detectionId)} + onOpenChange={(open) => (open ? undefined : close())} + open={edit.action === 'split'} + result={ + split.result + ? translate(STRING.TRACK_SPLIT_RESULT, { + count: split.result.new_occurrence_detections_count, + id: `${split.result.new_occurrence_id}`, + }) + : undefined + } + resultLink={ + split.result + ? getAppRoute({ + to: APP_ROUTES.OCCURRENCE_DETAILS({ + projectId: projectId as string, + occurrenceId: `${split.result.new_occurrence_id}`, + }), + filters: { apply_defaults: 'false' }, + }) + : undefined + } + title={translate(STRING.TRACK_SPLIT_HERE)} + /> + + merge.mergeOccurrences(sourceIds)} + onOpenChange={(open) => (open ? undefined : close())} + open={edit.action === 'merge'} + result={ + merge.result + ? translate(STRING.TRACK_MERGE_RESULT, { + count: merge.result.detections_count, + }) + : undefined + } + title={translate(STRING.TRACK_MERGE)} + > + {merge.result ? null : ( + + )} + + + + setGroupingVerified(!edit.verified).then(() => setVerifyDone(true)) + } + onOpenChange={(open) => (open ? undefined : close())} + open={edit.action === 'verify'} + result={ + verifyDone + ? edit.verified + ? translate(STRING.TRACK_UNDO_CONFIRMATION_RESULT) + : translate(STRING.TRACK_CONFIRM_GROUPING_RESULT) + : undefined + } + title={ + edit.verified + ? translate(STRING.TRACK_UNDO_CONFIRMATION) + : translate(STRING.TRACK_CONFIRM_GROUPING) + } + /> + + ) +} diff --git a/ui/src/pages/session-details/capture/track-navigation.test.ts b/ui/src/pages/session-details/capture/track-navigation.test.ts new file mode 100644 index 000000000..248e8f228 --- /dev/null +++ b/ui/src/pages/session-details/capture/track-navigation.test.ts @@ -0,0 +1,134 @@ +import { TrackFrame } from 'data-services/models/occurrence-details' +import { + getMergedTrackExtent, + getNearestPathFrame, + getTrackNavigation, +} from './track-navigation' + +const at = (minute: number) => new Date(2026, 8, 1, 22, minute) + +const frame = (minute: number): TrackFrame => ({ + bbox: [], + captureId: `c${minute}`, + id: `d${minute}`, + timestamp: at(minute), + timeLabel: '', +}) + +// Newest first, the order getTrackFrames returns them in. +const FRAMES = [frame(30), frame(20), frame(10)] + +describe('getTrackNavigation', () => { + test('a capture holding a frame steps to its neighbours in the track', () => { + const navigation = getTrackNavigation({ + captureDate: at(20), + captureId: 'c20', + frames: FRAMES, + }) + + expect(navigation.position).toEqual({ kind: 'frame', index: 2 }) + expect(navigation.previous?.id).toBe('d10') + expect(navigation.next?.id).toBe('d30') + expect(navigation.first?.id).toBe('d10') + expect(navigation.last?.id).toBe('d30') + }) + + test('a capture the track skips steps to the nearest frame on either side', () => { + const navigation = getTrackNavigation({ + captureDate: at(25), + captureId: 'c25', + frames: FRAMES, + }) + + expect(navigation.position).toEqual({ kind: 'between', before: 2 }) + expect(navigation.previous?.id).toBe('d20') + expect(navigation.next?.id).toBe('d30') + }) + + test('a capture before the first frame has no earlier frame to step to', () => { + const navigation = getTrackNavigation({ + captureDate: at(5), + captureId: 'c5', + frames: FRAMES, + }) + + expect(navigation.position).toEqual({ kind: 'before-first' }) + expect(navigation.previous).toBeUndefined() + expect(navigation.next?.id).toBe('d10') + }) + + test('a capture after the last frame has no later frame to step to', () => { + const navigation = getTrackNavigation({ + captureDate: at(40), + captureId: 'c40', + frames: FRAMES, + }) + + expect(navigation.position).toEqual({ kind: 'after-last' }) + expect(navigation.previous?.id).toBe('d30') + expect(navigation.next).toBeUndefined() + }) +}) + +describe('getMergedTrackExtent', () => { + test('a track added after the last frame leaves off at its own last frame', () => { + expect( + getMergedTrackExtent({ + addedFrames: [frame(40), frame(50)], + clickedDetectionId: 'd40', + trackFrames: FRAMES, + }) + ).toEqual({ boundaryCaptureId: 'c50', direction: 'forward' }) + }) + + test('a track added before the first frame leaves off at its own first frame', () => { + expect( + getMergedTrackExtent({ + addedFrames: [frame(1), frame(5)], + clickedDetectionId: 'd5', + trackFrames: FRAMES, + }) + ).toEqual({ boundaryCaptureId: 'c1', direction: 'backward' }) + }) + + test('a merge with nothing to read the direction from goes forwards', () => { + expect( + getMergedTrackExtent({ + addedFrames: [frame(40)], + clickedDetectionId: 'unknown', + trackFrames: [], + }) + ).toEqual({ boundaryCaptureId: 'c40', direction: 'forward' }) + }) +}) + +describe('getNearestPathFrame', () => { + const pathFrame = (captureId: string, timestamp: Date | null) => ({ + bbox: [0, 0, 1, 1], + captureHeight: null, + captureId, + captureWidth: null, + detectionId: `d-${captureId}`, + timestamp, + }) + const frames = [ + pathFrame('early', new Date(2026, 8, 1, 22, 0)), + pathFrame('late', new Date(2026, 8, 1, 23, 0)), + ] + + test('picks the frame closest in time to the capture', () => { + expect( + getNearestPathFrame(frames, new Date(2026, 8, 1, 22, 40))?.captureId + ).toBe('late') + expect( + getNearestPathFrame(frames, new Date(2026, 8, 1, 21, 0))?.captureId + ).toBe('early') + }) + + test('falls back to the first frame when there is no time to compare', () => { + expect(getNearestPathFrame(frames)?.captureId).toBe('early') + expect( + getNearestPathFrame([pathFrame('undated', null)], new Date())?.captureId + ).toBe('undated') + }) +}) diff --git a/ui/src/pages/session-details/capture/track-navigation.ts b/ui/src/pages/session-details/capture/track-navigation.ts new file mode 100644 index 000000000..c390b186c --- /dev/null +++ b/ui/src/pages/session-details/capture/track-navigation.ts @@ -0,0 +1,145 @@ +import { TrackFrame } from 'data-services/models/occurrence-details' +import { PathFrame } from 'data-services/models/occurrence-path' + +export type TrackPosition = + | { kind: 'frame'; index: number } + | { kind: 'between'; before: number } + | { kind: 'before-first' } + | { kind: 'after-last' } + +export interface TrackNavigation { + first?: TrackFrame + last?: TrackFrame + next?: TrackFrame + position?: TrackPosition + previous?: TrackFrame + total: number +} + +/** + * Where a capture sits in a track, and the frames the track buttons step to from it. + * Previous and next go by time, so they also work from a capture the track skips. + */ +export const getTrackNavigation = ({ + captureDate, + captureId, + frames, +}: { + captureDate?: Date + captureId?: string + frames: TrackFrame[] +}): TrackNavigation => { + const ordered = [...frames].sort( + (f1, f2) => f1.timestamp.getTime() - f2.timestamp.getTime() + ) + const total = ordered.length + const first = ordered[0] + const last = ordered[total - 1] + const index = captureId + ? ordered.findIndex((frame) => frame.captureId === captureId) + : -1 + + if (index >= 0) { + return { + first, + last, + next: ordered[index + 1], + position: { kind: 'frame', index: index + 1 }, + previous: ordered[index - 1], + total, + } + } + + if (!captureDate || !total) { + return { first, last, total } + } + + const time = captureDate.getTime() + const before = ordered.filter( + (frame) => frame.timestamp.getTime() < time + ).length + let position: TrackPosition = { kind: 'between', before } + + if (before === 0) { + position = { kind: 'before-first' } + } else if (before === total) { + position = { kind: 'after-last' } + } + + return { + first, + last, + next: ordered.find((frame) => frame.timestamp.getTime() > time), + position, + previous: before > 0 ? ordered[before - 1] : undefined, + total, + } +} + +export interface MergedTrackExtent { + /** The merged track's outermost frame in the direction of travel. */ + boundaryCaptureId?: string + direction: 'backward' | 'forward' +} + +const nearestFrame = (frames: TrackFrame[], date: Date) => { + const distance = (frame: TrackFrame) => + Math.abs(frame.timestamp.getTime() - date.getTime()) + + return frames.reduce( + (nearest, frame) => + !nearest || distance(frame) < distance(nearest) ? frame : nearest, + undefined + ) +} + +/** + * Where a merge leaves the reviewer: past the merged track's last frame when the click + * carried on forwards in time, before its first frame when they were working backwards. + * Absorbed frames are read before the merge, which deletes the occurrence holding them. + */ +export const getMergedTrackExtent = ({ + addedFrames, + clickedDetectionId, + trackFrames, +}: { + addedFrames: TrackFrame[] + clickedDetectionId: string + trackFrames: TrackFrame[] +}): MergedTrackExtent => { + const clicked = addedFrames.find((frame) => frame.id === clickedDetectionId) + const reference = clicked + ? nearestFrame(trackFrames, clicked.timestamp) + : undefined + const direction = + clicked && + reference && + clicked.timestamp.getTime() < reference.timestamp.getTime() + ? 'backward' + : 'forward' + const ordered = [...trackFrames, ...addedFrames].sort( + (f1, f2) => f1.timestamp.getTime() - f2.timestamp.getTime() + ) + const boundary = + direction === 'backward' ? ordered[0] : ordered[ordered.length - 1] + + return { boundaryCaptureId: boundary?.captureId, direction } +} + +/** The path frame closest in time to a capture, so the operator can jump back onto the track. */ +export const getNearestPathFrame = (frames: PathFrame[], date?: Date) => { + const dated = frames.filter( + (frame): frame is PathFrame & { timestamp: Date } => !!frame.timestamp + ) + + if (!date || !dated.length) { + return frames[0] + } + + const distance = (frame: { timestamp: Date }) => + Math.abs(frame.timestamp.getTime() - date.getTime()) + + return dated.reduce((nearest, frame) => + distance(frame) < distance(nearest) ? frame : nearest + ) +} diff --git a/ui/src/pages/session-details/capture/useCapturePreload.ts b/ui/src/pages/session-details/capture/useCapturePreload.ts new file mode 100644 index 000000000..3c450f951 --- /dev/null +++ b/ui/src/pages/session-details/capture/useCapturePreload.ts @@ -0,0 +1,130 @@ +import { useQueryClient } from '@tanstack/react-query' +import { API_ROUTES } from 'data-services/constants' +import { useFetchCaptureDetails } from 'data-services/hooks/captures/useFetchCaptureDetails' +import { + CaptureDetails, + ServerCaptureDetails, +} from 'data-services/models/capture-details' +import { useEffect, useRef } from 'react' +import { useParams } from 'react-router-dom' +import { CaptureNeighbourIds, demandBand, planPreload } from './capture-preload' +import { buildTierLadder, pickTier } from './capture-tiers' + +/** + * Warms what the next steps of a review need: the capture detail queries of the + * neighbouring captures, and their image at the tier this view would render. + * + * @param demand Device pixels the view asks of an image, so a warmed image is + * the one the ladder will pick rather than a size nothing requests. Zooming + * past a thumbnail size warms the neighbours again at the new tier. + * @param enabled Pass false until the capture on screen has loaded, so a + * preload never competes with it for bandwidth. + */ +export const useCapturePreload = ({ + captureId, + demand, + enabled, +}: { + captureId?: string + demand: number + enabled: boolean +}) => { + const { projectId } = useParams() + const queryClient = useQueryClient() + const fetchCapture = useFetchCaptureDetails(projectId as string) + const demandRef = useRef(demand) + demandRef.current = demand + const warmedRef = useRef(new Set()) + + useEffect(() => { + if (!enabled || !captureId || !projectId) { + return + } + + let cancelled = false + const attempted = new Set() + + const warm = (capture: CaptureDetails) => { + const tier = pickTier( + buildTierLadder( + { ...capture.thumbnailSizes, original: capture.src }, + capture.width + ), + demandRef.current + ) + + if (!tier || warmedRef.current.has(tier.src)) { + return + } + + warmedRef.current.add(tier.src) + new Image().src = tier.src + } + + const walk = async () => { + const read = new Map() + const detail = (id: string) => { + if (!read.has(id)) { + const record = queryClient.getQueryData([ + API_ROUTES.CAPTURES, + id, + projectId, + ]) + read.set(id, record ? new CaptureDetails(record) : undefined) + } + + return read.get(id) + } + + const pending: string[] = [] + + planPreload({ + startId: captureId, + lookup: (id): CaptureNeighbourIds | undefined => detail(id), + }).forEach((id) => { + const capture = detail(id) + + if (capture) { + warm(capture) + } else if (!attempted.has(id)) { + attempted.add(id) + pending.push(id) + } + }) + + if (!pending.length) { + return + } + + const fetched = await Promise.all( + pending.map((id) => fetchCapture(id).catch(() => undefined)) + ) + + if (cancelled) { + return + } + + fetched.forEach((capture) => { + if (capture) { + warm(capture) + } + }) + + // The captures just read name the step after them, so walk again. + walk() + } + + walk() + + return () => { + cancelled = true + } + }, [ + captureId, + demandBand(demand), + enabled, + fetchCapture, + projectId, + queryClient, + ]) +} diff --git a/ui/src/pages/session-details/hooks/useActiveDetection.test.ts b/ui/src/pages/session-details/hooks/useActiveDetection.test.ts new file mode 100644 index 000000000..5a8368e89 --- /dev/null +++ b/ui/src/pages/session-details/hooks/useActiveDetection.test.ts @@ -0,0 +1,32 @@ +import { buildDetectionLink } from './useActiveDetection' + +const BASE = 'http://example.test/projects/1/sessions/2' + +describe('buildDetectionLink', () => { + it('keeps the capture already in the link, so the detection can be found', () => { + const link = buildDetectionLink(`${BASE}?capture=55`, '901') + + expect(link).toContain('capture=55') + expect(link).toContain('detection=901') + }) + + it('points an existing detection link at a different detection', () => { + const link = buildDetectionLink(`${BASE}?capture=55&detection=111`, '222') + + expect(link).toContain('detection=222') + expect(link).not.toContain('detection=111') + }) + + it('adds the detection to a link that carries none', () => { + expect(buildDetectionLink(BASE, '901')).toContain('detection=901') + }) + + it('leaves the rest of the link alone', () => { + // The occurrence stays on the link: it says which track held the detection when the + // link was made, which is worth keeping even though the detection is what resolves it. + const link = buildDetectionLink(`${BASE}?capture=55&occurrence=77`, '901') + + expect(link).toContain('occurrence=77') + expect(link).toContain(`${BASE}`) + }) +}) diff --git a/ui/src/pages/session-details/hooks/useActiveDetection.ts b/ui/src/pages/session-details/hooks/useActiveDetection.ts new file mode 100644 index 000000000..5a2be9c20 --- /dev/null +++ b/ui/src/pages/session-details/hooks/useActiveDetection.ts @@ -0,0 +1,45 @@ +import { useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' + +const SEARCH_PARAM_KEY = 'detection' + +/** + * The detection a link points at, which is the one handle that survives regrouping. + * + * A link built on an occurrence rots as soon as that detection is merged, split or + * extended into a different occurrence. A detection's id never changes, so a link built + * on one still opens the right box on the right capture, whatever track it now belongs to. + */ +/** + * The page you are on, pointed at one detection. + * + * Built from the current URL so the capture travels with it, which is what makes the + * link resolve without the reader having to know which capture the detection sits on. + */ +export const buildDetectionLink = (href: string, detectionId: string) => { + const url = new URL(href) + url.searchParams.set(SEARCH_PARAM_KEY, detectionId) + + return url.toString() +} + +export const useActiveDetection = () => { + const [searchParams, setSearchParams] = useSearchParams() + + const activeDetectionId = searchParams.get(SEARCH_PARAM_KEY) ?? undefined + + const setActiveDetectionId = useCallback( + (detectionId?: string) => { + if (detectionId) { + searchParams.set(SEARCH_PARAM_KEY, detectionId) + } else { + searchParams.delete(SEARCH_PARAM_KEY) + } + + setSearchParams(searchParams, { replace: true }) + }, + [searchParams, setSearchParams] + ) + + return { activeDetectionId, setActiveDetectionId } +} diff --git a/ui/src/pages/session-details/hooks/useExtendOccurrenceId.ts b/ui/src/pages/session-details/hooks/useExtendOccurrenceId.ts new file mode 100644 index 000000000..6fa534e43 --- /dev/null +++ b/ui/src/pages/session-details/hooks/useExtendOccurrenceId.ts @@ -0,0 +1,29 @@ +import { useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' + +const SEARCH_PARAM_KEY = 'extend' + +/** + * The occurrence the capture view is adding frames to. Held in the URL so the mode + * survives stepping between captures and the link can be handed to someone else. + */ +export const useExtendOccurrenceId = () => { + const [searchParams, setSearchParams] = useSearchParams() + + const extendOccurrenceId = searchParams.get(SEARCH_PARAM_KEY) ?? undefined + + const setExtendOccurrenceId = useCallback( + (occurrenceId?: string) => { + searchParams.delete(SEARCH_PARAM_KEY) + + if (occurrenceId) { + searchParams.set(SEARCH_PARAM_KEY, occurrenceId) + } + + setSearchParams(searchParams, { replace: true }) + }, + [searchParams, setSearchParams] + ) + + return { extendOccurrenceId, setExtendOccurrenceId } +} diff --git a/ui/src/pages/session-details/run-tracking/run-tracking-dialog.tsx b/ui/src/pages/session-details/run-tracking/run-tracking-dialog.tsx new file mode 100644 index 000000000..af91cb55f --- /dev/null +++ b/ui/src/pages/session-details/run-tracking/run-tracking-dialog.tsx @@ -0,0 +1,48 @@ +import { useCreateTrackingJob } from 'data-services/hooks/jobs/useCreateTrackingJob' +import { SessionDetails } from 'data-services/models/session-details' +import { RouteIcon } from 'lucide-react' +import { Button, Dialog } from 'nova-ui-kit' +import { TrackingJobForm } from 'pages/job-details/tracking-job-form/tracking-job-form' +import { useState } from 'react' +import { useParams } from 'react-router-dom' +import { STRING, translate } from 'utils/language' + +const CLOSE_TIMEOUT = 1000 + +export const RunTrackingDialog = ({ session }: { session: SessionDetails }) => { + const { projectId } = useParams() + const [isOpen, setIsOpen] = useState(false) + const { createTrackingJob, isLoading, isSuccess, error } = + useCreateTrackingJob(() => + setTimeout(() => setIsOpen(false), CLOSE_TIMEOUT) + ) + const label = translate(STRING.TRACKING_JOB_RUN_ON_SESSION) + + return ( + + + + + + +
+ + createTrackingJob({ + ...values, + projectId: projectId as string, + }) + } + scope={{ type: 'session', sessionId: session.id }} + /> +
+
+
+ ) +} diff --git a/ui/src/pages/session-details/session-details.tsx b/ui/src/pages/session-details/session-details.tsx index 00ddf0594..50fd73b72 100644 --- a/ui/src/pages/session-details/session-details.tsx +++ b/ui/src/pages/session-details/session-details.tsx @@ -19,19 +19,27 @@ import { useParams } from 'react-router-dom' import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch' import { BreadcrumbContext } from 'utils/breadcrumbContext' import { STRING, translate } from 'utils/language' +import { + useCanStartTracking, + useProjectFeature, +} from 'utils/project-features/useProjectFeature' import { useUser } from 'utils/user/userContext' import { ActivityPlot } from './activity-plot/lazy-activity-plot' +import { OccurrenceTimelineMarkers } from './activity-plot/occurrence-timeline-markers' import { CaptureInfo } from './capture-info' import { CaptureNavigation } from './capture-navigation' import { Capture } from './capture/capture' +import { ExtendTrackBanner, useExtendTrack } from './capture/extend-track' import { useActiveCaptureId } from './hooks/useActiveCapture' import { useActiveOccurrences } from './hooks/useActiveOccurrences' import { Process } from './process/process' +import { RunTrackingDialog } from './run-tracking/run-tracking-dialog' import { SessionInfo } from './session-info' import { SessionPlots } from './session-plots' import { StarButton } from './star-button' import { TimelineSlider } from './timeline-slider/timeline-slider' -import { ViewSettings } from './view-settings' +import { getNextCaptureWithDetectionsId, showSessionTimeline } from './utils' +import { ViewSettings, ViewSettingsValues } from './view-settings' import { ZoomSettings } from './zoom-settings' const TABS = { @@ -86,14 +94,16 @@ const Content = ({ session }: { session: SessionDetails }) => { // Settings const [poll, setPoll] = useState(false) const transformRef = useRef(null) - const [settings, setSettings] = useState({ + const [settings, setSettings] = useState({ defaultFilters: true, showDetections: true, + showPathCrops: true, }) // Data const { projectId } = useParams() const { user } = useUser() + const { activeOccurrences } = useActiveOccurrences() const { activeCaptureId, setActiveCaptureId } = useActiveCaptureId( session.firstCapture?.id ) @@ -103,6 +113,21 @@ const Content = ({ session }: { session: SessionDetails }) => { projectId: projectId as string, }) const { timeline = [] } = useSessionTimeline(session.id) + const trackingEnabled = useProjectFeature('tracking') + const canStartTracking = useCanStartTracking() + const extend = useExtendTrack({ + captureId: activeCaptureId, + enabled: trackingEnabled, + nextCaptureId: activeCapture + ? getNextCaptureWithDetectionsId({ capture: activeCapture, timeline }) + : undefined, + onSelectCapture: setActiveCaptureId, + sessionId: session.id, + }) + // While extending, the track being built is the only selection. + const timelineOccurrenceIds = extend.occurrenceId + ? [extend.occurrenceId] + : activeOccurrences useEffect(() => { // If the active capture has a job in progress, we want to poll the endpoint so we can show job updates @@ -126,6 +151,9 @@ const Content = ({ session }: { session: SessionDetails }) => { })} tooltip={translate(STRING.TOOLTIP_SESSION)} > + {user.loggedIn && canStartTracking ? ( + + ) : null} {user.loggedIn ? : null}
@@ -163,12 +191,32 @@ const Content = ({ session }: { session: SessionDetails }) => {
+ {extend.occurrenceId ? ( + + ) : null}
+ setSettings((current) => ({ + ...current, + showPathCrops: !current.showPathCrops, + })) + } showDetections={settings.showDetections} + showPathCrops={settings.showPathCrops} sources={ activeCapture ? { @@ -226,23 +274,35 @@ const Content = ({ session }: { session: SessionDetails }) => {
-
- - -
+ {showSessionTimeline(session) ? ( +
+ + {trackingEnabled && timelineOccurrenceIds.length ? ( + + ) : null} + + +
+ ) : null}
) diff --git a/ui/src/pages/session-details/session-info.tsx b/ui/src/pages/session-details/session-info.tsx index 325ae8831..ccae0ceae 100644 --- a/ui/src/pages/session-details/session-info.tsx +++ b/ui/src/pages/session-details/session-info.tsx @@ -1,5 +1,6 @@ +import { CopyLinkButton } from 'components/copy-link-button/copy-link-button' import { SessionDetails } from 'data-services/models/session-details' -import { InfoBlock } from 'nova-ui-kit' +import { InfoBlock, InfoBlockField, InfoBlockFieldValue } from 'nova-ui-kit' import { useParams } from 'react-router-dom' import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' @@ -8,11 +9,8 @@ import { STRING, translate } from 'utils/language' export const SessionInfo = ({ session }: { session: SessionDetails }) => { const { projectId } = useParams() + // The id is the last row, beside the copy-link button. const fields = [ - { - label: translate(STRING.FIELD_LABEL_ID), - value: session.id, - }, { label: translate(STRING.FIELD_LABEL_DEPLOYMENT), value: session.deploymentLabel, @@ -59,5 +57,15 @@ export const SessionInfo = ({ session }: { session: SessionDetails }) => { : []), ] - return + return ( +
+ + +
+ + +
+
+
+ ) } diff --git a/ui/src/pages/session-details/timeline-slider/timeline-slider.tsx b/ui/src/pages/session-details/timeline-slider/timeline-slider.tsx index 8be496ab7..f312fdefc 100644 --- a/ui/src/pages/session-details/timeline-slider/timeline-slider.tsx +++ b/ui/src/pages/session-details/timeline-slider/timeline-slider.tsx @@ -3,8 +3,16 @@ import { Capture } from 'data-services/models/capture' import { SessionDetails } from 'data-services/models/session-details' import { TimelineTick } from 'data-services/models/timeline-tick' import { TriangleIcon } from 'lucide-react' -import { useEffect, useState } from 'react' +import { KeyboardEvent, useEffect, useMemo, useState } from 'react' import { getFormatedTimeString } from 'utils/date/getFormatedTimeString/getFormatedTimeString' +import { STRING, translate } from 'utils/language' +import { + getCaptureIndex, + getKeyStep, + getNavigableCaptures, + getStepTarget, + NavigableCapture, +} from '../activity-plot/timeline-navigation' import { dateToValue, findClosestCaptureId, valueToDate } from '../utils' import styles from './styles.module.scss' @@ -25,6 +33,12 @@ export const TimelineSlider = ({ const startDate = session.startDate const endDate = session.endDate const showLabels = session.startDate.getTime() !== session.endDate.getTime() + const captures = useMemo(() => getNavigableCaptures(timeline), [timeline]) + const captureIndex = getCaptureIndex({ + captureId: activeCapture?.id, + captures, + date: activeCapture?.date, + }) useEffect(() => { if (activeCapture) { @@ -32,8 +46,33 @@ export const TimelineSlider = ({ } }, [activeCapture]) + // Radix would step the handle by a hundredth of a percent, and the page-wide arrow + // shortcut would move a capture of its own, so a key this can answer is claimed from + // both. A key it cannot answer is left alone, keeping that shortcut working. + const onKeyDown = (event: KeyboardEvent) => { + const step = getKeyStep(event.key) + const target = + step === undefined + ? undefined + : getStepTarget({ captures, index: captureIndex, step }) + + if (!target) { + return + } + + event.preventDefault() + event.stopPropagation() + + if (target.captureId !== activeCapture?.id) { + setActiveCaptureId(target.captureId) + } + } + return ( { + if (!activeCapture) { + return undefined + } + + const time = getFormatedTimeString({ date: activeCapture.date }) + + if (captureIndex === -1) { + return time + } + + return translate(STRING.TIMELINE_POSITION_VALUE, { + index: captureIndex + 1, + time, + total: captures.length, + }) +} + const Slider = ({ + ariaLabel, + ariaValueText, labels, + onKeyDown, value, valueLabel, onValueChange, onValueCommit, }: { + ariaLabel: string + ariaValueText?: string labels: string[] + onKeyDown: (event: KeyboardEvent) => void value: number valueLabel?: string onValueChange: (value: number) => void @@ -83,6 +156,7 @@ const Slider = ({ className={styles.sliderRoot} min={0} max={100} + onKeyDown={onKeyDown} step={0.01} value={[value]} onValueChange={(values) => onValueChange(values[0])} @@ -91,7 +165,11 @@ const Slider = ({ <_Slider.Track className={styles.sliderTrack}> <_Slider.Range className={styles.sliderRange} /> - <_Slider.Thumb className={styles.sliderThumb}> + <_Slider.Thumb + aria-label={ariaLabel} + aria-valuetext={ariaValueText} + className={`${styles.sliderThumb} rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`} + > {valueLabel && {valueLabel}} diff --git a/ui/src/pages/session-details/utils.test.ts b/ui/src/pages/session-details/utils.test.ts new file mode 100644 index 000000000..169d2323b --- /dev/null +++ b/ui/src/pages/session-details/utils.test.ts @@ -0,0 +1,86 @@ +import { TimelineTick } from 'data-services/models/timeline-tick' +import { + getNextCaptureWithDetectionsId, + getPrevCaptureWithDetectionsId, + showSessionTimeline, +} from './utils' + +const at = (minute: number, second = 0) => + new Date(2026, 8, 1, 0, minute, second) + +const tick = (minute: number, captureId: string, numDetections = 1) => + ({ + startDate: at(minute), + endDate: at(minute + 1), + numDetections, + representativeCaptureId: captureId, + } as TimelineTick) + +// One tick per minute: the 12:03 tick stands for a burst of captures seconds apart. +const TIMELINE = [tick(2, 'c2'), tick(3, 'c3'), tick(9, 'c9')] + +describe('capture with detections on either side', () => { + test('the server answer wins, so a burst inside one minute is not skipped', () => { + const capture = { + date: at(3, 31), + nextCaptureWithDetectionsId: 'c3-33', + prevCaptureWithDetectionsId: 'c3-29', + } + + expect( + getNextCaptureWithDetectionsId({ capture, timeline: TIMELINE }) + ).toBe('c3-33') + expect( + getPrevCaptureWithDetectionsId({ capture, timeline: TIMELINE }) + ).toBe('c3-29') + }) + + test('null from the server means there is none, without asking the timeline', () => { + const capture = { + date: at(3, 31), + nextCaptureWithDetectionsId: null, + prevCaptureWithDetectionsId: null, + } + + expect( + getNextCaptureWithDetectionsId({ capture, timeline: TIMELINE }) + ).toBeUndefined() + expect( + getPrevCaptureWithDetectionsId({ capture, timeline: TIMELINE }) + ).toBeUndefined() + }) + + test('without the server answer the timeline stands in', () => { + const capture = { + date: at(3, 31), + nextCaptureWithDetectionsId: undefined, + prevCaptureWithDetectionsId: undefined, + } + + expect( + getNextCaptureWithDetectionsId({ capture, timeline: TIMELINE }) + ).toBe('c9') + expect( + getPrevCaptureWithDetectionsId({ capture, timeline: TIMELINE }) + ).toBe('c2') + }) +}) + +describe('session timeline visibility', () => { + const startDate = at(0) + + test('a session spanning less than a minute has no timeline', () => { + expect(showSessionTimeline({ startDate, endDate: at(0, 59) })).toBe(false) + }) + + test('a session spanning a full minute keeps its timeline', () => { + expect(showSessionTimeline({ startDate, endDate: at(1) })).toBe(true) + }) + + test('an unknown span keeps the timeline rather than hiding it', () => { + expect(showSessionTimeline({ startDate, endDate: undefined })).toBe(true) + expect(showSessionTimeline({ startDate, endDate: new Date(NaN) })).toBe( + true + ) + }) +}) diff --git a/ui/src/pages/session-details/utils.tsx b/ui/src/pages/session-details/utils.tsx index 137c5fc5a..e7914de06 100644 --- a/ui/src/pages/session-details/utils.tsx +++ b/ui/src/pages/session-details/utils.tsx @@ -1,3 +1,4 @@ +import { CaptureDetails } from 'data-services/models/capture-details' import { TimelineTick } from 'data-services/models/timeline-tick' export const findClosestCaptureId = ({ @@ -46,6 +47,88 @@ export const findClosestCaptureId = ({ return closestCaptureId } +const findNextCaptureWithDetections = ({ + date, + timeline, +}: { + date: Date + timeline: TimelineTick[] +}) => + findClosestCaptureId({ + minDate: date, + snapToDetections: true, + targetDate: date, + timeline, + }) + +const findPrevCaptureWithDetections = ({ + date, + timeline, +}: { + date: Date + timeline: TimelineTick[] +}) => + findClosestCaptureId({ + maxDate: date, + snapToDetections: true, + targetDate: date, + timeline, + }) + +type CaptureNeighbours = Pick< + CaptureDetails, + 'date' | 'nextCaptureWithDetectionsId' | 'prevCaptureWithDetectionsId' +> + +// The server sees every capture; the timeline keeps one per minute, so it only +// stands in when the capture was fetched without the server's answer. +export const getNextCaptureWithDetectionsId = ({ + capture, + timeline, +}: { + capture: CaptureNeighbours + timeline: TimelineTick[] +}) => + capture.nextCaptureWithDetectionsId === undefined + ? findNextCaptureWithDetections({ date: capture.date, timeline }) + : capture.nextCaptureWithDetectionsId ?? undefined + +export const getPrevCaptureWithDetectionsId = ({ + capture, + timeline, +}: { + capture: CaptureNeighbours + timeline: TimelineTick[] +}) => + capture.prevCaptureWithDetectionsId === undefined + ? findPrevCaptureWithDetections({ date: capture.date, timeline }) + : capture.prevCaptureWithDetectionsId ?? undefined + +// The timeline resolves to one tick per minute, so a shorter session collapses +// into a single tick with nothing to scrub. An unknown span stays visible. +const MIN_TIMELINE_SPAN_MS = 60 * 1000 + +export const showSessionTimeline = ({ + startDate, + endDate, +}: { + startDate?: Date + endDate?: Date +}) => { + const start = startDate?.getTime() + const end = endDate?.getTime() + + if (start === undefined || end === undefined) { + return true + } + + if (Number.isNaN(start) || Number.isNaN(end)) { + return true + } + + return end - start >= MIN_TIMELINE_SPAN_MS +} + export const dateToValue = ({ date, startDate, diff --git a/ui/src/pages/session-details/view-settings.tsx b/ui/src/pages/session-details/view-settings.tsx index 909c3dded..5bdd1b135 100644 --- a/ui/src/pages/session-details/view-settings.tsx +++ b/ui/src/pages/session-details/view-settings.tsx @@ -5,18 +5,20 @@ import { BasicTooltip, Button, Checkbox, Popover } from 'nova-ui-kit' import { useParams } from 'react-router-dom' import { STRING, translate } from 'utils/language' +export interface ViewSettingsValues { + defaultFilters: boolean + showDetections: boolean + showPathCrops: boolean +} + export const ViewSettings = ({ onSettingsChange, settings, + showTrackingSettings, }: { - onSettingsChange: (settings: { - defaultFilters: boolean - showDetections: boolean - }) => void - settings: { - defaultFilters: boolean - showDetections: boolean - } + onSettingsChange: (settings: ViewSettingsValues) => void + settings: ViewSettingsValues + showTrackingSettings?: boolean }) => { const { projectId } = useParams() const { project } = useProjectDetails(projectId as string, true) @@ -50,6 +52,19 @@ export const ViewSettings = ({ }) } /> + {showTrackingSettings ? ( + + onSettingsChange({ + ...settings, + showPathCrops: !settings.showPathCrops, + }) + } + /> + ) : null}
{ {verifyOccurrenceId ? ( { diff --git a/ui/src/utils/getAppRoute.ts b/ui/src/utils/getAppRoute.ts index bafc8290c..461830291 100644 --- a/ui/src/utils/getAppRoute.ts +++ b/ui/src/utils/getAppRoute.ts @@ -8,6 +8,7 @@ type FilterType = | 'deployment__research_site' | 'detections__source_image' | 'event' + | 'extend' | 'include_unobserved' | 'occurrence' | 'source_image_collection' diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index feb83bed6..2b3f6908a 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -12,6 +12,8 @@ export enum STRING { COLLAPSE, CONFIRM, CONFIRMED, + COPIED, + COPY_LINK, CREATE_NEW, CURRENT_LOCATION, DELETE, @@ -26,8 +28,12 @@ export enum STRING { MORE, MORE_FILTERS, NEXT, + NEXT_CAPTURE, + NEXT_CAPTURE_WITH_DETECTIONS, POPULATE, PREVIOUS, + PREVIOUS_CAPTURE, + PREVIOUS_CAPTURE_WITH_DETECTIONS, PROCESS_NOW, QUEUED, REFRESH, @@ -72,6 +78,7 @@ export enum STRING { ENTITY_TYPE_IDENTIFICATION, ENTITY_TYPE_JOB, ENTITY_TYPE_MEMBER, + ENTITY_TYPE_OCCURRENCE, ENTITY_TYPE_PIPELINE, ENTITY_TYPE_PROCESSING_SERVICE, ENTITY_TYPE_PROJECT_DETAILS, @@ -85,6 +92,7 @@ export enum STRING { FIELD_LABEL_ALGORITHM_URI, FIELD_LABEL_AVG_TEMP, FIELD_LABEL_BEST_SCORE, + FIELD_LABEL_BOXES_WITH_VECTORS, FIELD_LABEL_CAPTURE_SET, FIELD_LABEL_CAPTURE, FIELD_LABEL_CAPTURES_WITH_DETECTIONS, @@ -102,6 +110,7 @@ export enum STRING { FIELD_LABEL_DELAY, FIELD_LABEL_DEPLOYMENT, FIELD_LABEL_DESCRIPTION, + FIELD_LABEL_DETECTIONS, FIELD_LABEL_DEVICE, FIELD_LABEL_DIRECT_OCCURRENCES, FIELD_LABEL_DURATION, @@ -118,6 +127,7 @@ export enum STRING { FIELD_LABEL_GENERAL, FIELD_LABEL_ICON, FIELD_LABEL_ID, + FIELD_LABEL_ID_AGREEMENT, FIELD_LABEL_IMAGE, FIELD_LABEL_JOB_STATUS, FIELD_LABEL_JOB, @@ -133,9 +143,12 @@ export enum STRING { FIELD_LABEL_LOGS, FIELD_LABEL_LONGITUDE, FIELD_LABEL_MOST_RECENT, + FIELD_LABEL_MOTION, FIELD_LABEL_NAME, FIELD_LABEL_NEW_PASSWORD, FIELD_LABEL_NUM_PIPELINES_REGISTERED, + FIELD_LABEL_DETECTION_ID, + FIELD_LABEL_OCCURRENCE_NUMBER, FIELD_LABEL_OCCURRENCES, FIELD_LABEL_PASSWORD_CURRENT, FIELD_LABEL_PASSWORD_NEW, @@ -152,9 +165,11 @@ export enum STRING { FIELD_LABEL_SAMPLING_METHOD, FIELD_LABEL_SCORE, FIELD_LABEL_SESSION, + FIELD_LABEL_SESSION_NUMBER, FIELD_LABEL_SESSIONS, FIELD_LABEL_SITE, FIELD_LABEL_SIZE, + FIELD_LABEL_SIZE_CHANGE, FIELD_LABEL_SLUG, FIELD_LABEL_SNAPSHOTS, FIELD_LABEL_STARTED_AT, @@ -356,7 +371,7 @@ export enum STRING { SET_PASSWORD, SETTINGS, SHOW_DETECTIONS, - SNAP_TO_DETECTIONS, + SHOW_PATH_CROPS, SORT_BY, SORT_JOBS_ACTIVITY, SORT_OCCURRENCE_UPDATES, @@ -366,12 +381,215 @@ export enum STRING { SUMMARY, TABLE_COLUMNS, TERMINAL_CLASSIFICATION, + TIMELINE_OCCURRENCE_FRAME, + TIMELINE_OCCURRENCE_SPAN, + TIMELINE_DURATION_SECONDS, + TIMELINE_DURATION_MINUTES, + TIMELINE_DURATION_HOURS, + TIMELINE_ACTIVITY_DESCRIPTION, + TIMELINE_ACTIVITY_LABEL, + TIMELINE_OCCURRENCE_ACTIVATE, + TIMELINE_OCCURRENCE_SHOWING, + TIMELINE_POSITION_LABEL, + TIMELINE_POSITION_VALUE, TIP, + TRACK_BOX_EXTEND_ACTION, + TRACK_BOX_EXTEND_LABEL, + TRACK_BOX_LABEL, + TRACK_BOX_LABEL_VERIFIED, + TRACK_CANDIDATE_IN_GAP, + TRACK_CLEARS_CONFIRMATION, + TRACK_COLUMN_COST, + TRACK_COLUMN_COST_HELP, + TRACK_COLUMN_DISTANCE, + TRACK_COLUMN_FRAMES, + TRACK_COLUMN_IMAGE, + TRACK_COLUMN_MATCH, + TRACK_COLUMN_MATCH_HELP, + TRACK_COLUMN_OVERLAP, + TRACK_COLUMN_SIMILARITY, + TRACK_COLUMN_SIZE, + TRACK_COLUMN_SPECIES, + TRACK_COLUMN_WHEN, + TRACK_COST_OF_THRESHOLD, + TRACK_VIEW_CANDIDATE_IN_SESSION, + TRACK_CONFIRM_GROUPING_DESCRIPTION, + TRACK_CONFIRM_GROUPING_RESULT, + TRACK_CONFIRM_GROUPING, + TRACK_GROUPING_FILTER_NO, + TRACK_GROUPING_FILTER_TOOLTIP, + TRACK_GROUPING_FILTER_YES, + TRACK_EXTEND_CAPTURE_COVERED, + TRACK_EXTEND_CHOICE_DESCRIPTION, + TRACK_EXTEND_CLICKED_FRAME, + TRACK_EXTEND_DETAILS, + TRACK_EXTEND_HERE, + TRACK_EXTEND_HIDE_DETAILS, + TRACK_EXTEND_IN_SESSION, + TRACK_EXTEND_LABEL, + TRACK_EXTEND_LATEST_FRAME, + TRACK_EXTEND_MERGE_OVERLAPS, + TRACK_EXTEND_MERGE_WHOLE, + TRACK_EXTEND_MOVE_FRAME, + TRACK_EXTEND_NO_LATER_CAPTURE, + TRACK_EXTEND_ONLY_FRAME, + TRACK_EXTEND_REMOVE, + TRACK_EXTEND_REMOVE_PROMPT, + TRACK_EXTEND_REPLACE, + TRACK_EXTEND_REPLACE_ORPHAN, + TRACK_EXTEND_REPLACE_PROMPT, + TRACK_EXTEND_SHOW_DETAILS, + TRACK_EXTEND_STOP, + TRACK_EXTEND_TITLE, + TRACK_FIRST_FRAME, + TRACK_FRAMES_COUNT, + TRACK_FRAMES_ONE, + TRACK_FRAMES_PAGE_EARLIER, + TRACK_FRAMES_PAGE_ERROR, + TRACK_FRAMES_PAGE_FIRST, + TRACK_FRAMES_PAGE_LAST, + TRACK_FRAMES_PAGE_LATER, + TRACK_FRAMES_RANGE, + TRACK_FRAME_DETECTION, + TRACK_FRAME_IN_TRACK, + TRACK_FRAME_MENU, + TRACK_FRAME_NO_CLASSIFICATION, + TRACK_FRAME_NO_CROP, + TRACK_FRAME_NO_VECTOR, + TRACK_FRAME_THIS_CAPTURE, + TRACK_HIDE_PANEL, + TRACK_HIDE_PATH, + TRACK_JUMP_FIRST_FRAME, + TRACK_JUMP_LAST_FRAME, + TRACK_JUMP_ONLY_FRAME, + TRACK_LAST_FRAME, + TRACK_LOADING_PATH, + TRACK_GAP_AFTER, + TRACK_GAP_BEFORE, + TRACK_GHOST_EARLIER, + TRACK_GHOST_HINT, + TRACK_GHOST_LATER, + TRACK_GROUPING_CONFIRMED_BY, + TRACK_GROUPING_CONFIRMED, + TRACK_GROUPING_NOT_CONFIRMED, + TRACK_GROUPING_UNCONFIRMED, + TRACK_MATCH_CLICK_ADD, + TRACK_MATCH_CLICK_CHOOSE, + TRACK_MATCH_CLICK_REMOVE, + TRACK_MATCH_CLICK_REPLACE, + TRACK_MATCH_LABEL, + TRACK_MATCH_LIKELY, + TRACK_MATCH_POSSIBLE, + TRACK_MATCH_REFERENCE_EARLIER, + TRACK_MATCH_REFERENCE_LATER, + TRACK_MATCH_SAME_SPECIES, + TRACK_MATCH_SCORE, + TRACK_MATCH_SKIPPED_NO_VECTOR, + TRACK_MATCH_SKIPPED_SESSION, + TRACK_MATCH_UNLIKELY, + TRACK_MATCH_WOULD_LINK, + TRACK_MERGE_CANDIDATES_LOAD_FAILED, + TRACK_MERGE_CANDIDATES_TITLE, + TRACK_MERGE_COUNT, + TRACK_MERGE_MANY_DESCRIPTION, + TRACK_MERGE_ONE, + TRACK_MERGE_SCOPE_DESCRIPTION, + TRACK_MERGE_RESULT, + TRACK_MERGE, + TRACK_MOVE_DESCRIPTION, + TRACK_MOVE_SCOPE_DESCRIPTION, + TRACK_MOVE_FRAME, + TRACK_MOVE_RESULT, + TRACK_NEXT_FRAME, + TRACK_NO_MERGE_CANDIDATES_SCOPE, + TRACK_OFFSET_HOURS, + TRACK_OFFSET_MINUTES, + TRACK_OFFSET_SECONDS, + TRACK_OPEN_NEW_OCCURRENCE, + TRACK_PERCENT, + TRACK_PATH_ERROR, + TRACK_PATH_RANGE, + TRACK_PATH_STATUS_ERROR, + TRACK_PATH_STATUS_LOADING, + TRACK_PATH_STATUS_SHOWN, + TRACK_PATH_STATUS_SHOWN_FOR, + TRACK_PATH_OCCURRENCE, + TRACK_PATH_GO_TO_FRAME, + TRACK_PATH_GO_TO_NEAREST, + TRACK_PICK_OCCURRENCE, + TRACK_POSITION_AFTER_LAST, + TRACK_POSITION_BEFORE_FIRST, + TRACK_POSITION_BETWEEN, + TRACK_POSITION_FRAME, + TRACK_PREVIOUS_FRAME, + TRACK_REMOVE_DESCRIPTION, + TRACK_REMOVE_FRAME, + TRACK_REMOVE_RESULT, + TRACK_SCOPE_LABEL, + TRACK_SCOPE_MINUTES_30, + TRACK_SCOPE_MINUTES_5, + TRACK_SCOPE_NEAR, + TRACK_SCOPE_NEXT, + TRACK_SELECT_ALL, + TRACK_SELECT_ROW, + TRACK_SPLIT_DESCRIPTION, + TRACK_SHOWING_PART_OF_PATH, + TRACK_SHOW_BOXES_ONLY, + TRACK_SHOW_PATH, + TRACK_SINGLE_FRAME_NO_PATH, + TRACK_SORT_BEST, + TRACK_SORT_BY, + TRACK_SORT_CUMULATIVE, + TRACK_SORT_LABEL, + TRACK_SPLIT_HERE, + TRACK_SPLIT_RESULT, + TRACK_STAT_DISTINCT_TAXA, + TRACK_STAT_ID_AGREEMENT, + TRACK_STAT_LINKED, + TRACK_STAT_MOTION, + TRACK_STAT_SCORE_RANGE, + TRACK_STAT_SIZE_RATIO, + TRACK_SUMMARY_DERIVED_TOOLTIP, + TRACK_SUMMARY_DERIVED, + TRACK_SUMMARY_FRAMES, + TRACK_SUMMARY_FRAMES_WITH_VECTORS, + TRACK_SUMMARY_FRAME_NAMES, + TRACK_SUMMARY_ID_AGREEMENT, + TRACK_SUMMARY_SCORE_RANGE, + TRACK_SUMMARY_TITLE, + TRACK_SUMMARY_VECTORS_TOOLTIP, + TRACK_TIME_SPAN, + TRACK_UNDO_CONFIRMATION_DESCRIPTION, + TRACK_UNDO_CONFIRMATION_RESULT, + TRACK_UNDO_CONFIRMATION, + TRACK_WHEN_EARLIER, + TRACK_WHEN_LATER, + TRACKING_JOB_COST_THRESHOLD, + TRACKING_JOB_COST_THRESHOLD_DESCRIPTION, + TRACKING_JOB_DESCRIPTION, + TRACKING_JOB_NAME_CAPTURE_SET, + TRACKING_JOB_NAME_DESCRIPTION, + TRACKING_JOB_NAME_SESSION, + TRACKING_JOB_OPTIONS, + TRACKING_JOB_REQUIRE_FEATURES, + TRACKING_JOB_REQUIRE_FEATURES_DESCRIPTION, + TRACKING_JOB_RUN, + TRACKING_JOB_RUN_ON_SESSION, + TRACKING_JOB_SCOPE, + TRACKING_JOB_SCOPE_CAPTURE_SET, + TRACKING_JOB_SCOPE_SESSION, + TRACKING_JOB_SKIPPED_SESSIONS, + TRACKING_JOB_START_NOW, + TRACKING_JOB_TYPE, + TRACKING_JOB_TYPE_PROCESSING, + TRACKING_JOB_TYPE_TRACKING, + UNIDENTIFIED, UNKNOWN_ERROR, UNKNOWN, UPDATING_DATA, UPLOAD_CAPTURES, USER_INFO, + VALUE_COUNT_OF_TOTAL, VALUE_NOT_AVAILABLE, VERIFIED_BY, VERIFIED_OCCURRENCES, @@ -395,6 +613,8 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.COLLAPSE]: 'Collapse', [STRING.CONFIRM]: 'Confirm', [STRING.CONFIRMED]: 'Confirmed', + [STRING.COPIED]: 'Copied', + [STRING.COPY_LINK]: 'Copy link', [STRING.CREATE_NEW]: 'Create new', [STRING.CURRENT_LOCATION]: 'Use current location', [STRING.DELETE]: 'Delete', @@ -409,8 +629,12 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.MORE]: 'More', [STRING.MORE_FILTERS]: 'More filters', [STRING.NEXT]: 'Next', + [STRING.NEXT_CAPTURE]: 'Next capture', + [STRING.NEXT_CAPTURE_WITH_DETECTIONS]: 'Next capture with detections', [STRING.POPULATE]: 'Populate', [STRING.PREVIOUS]: 'Previous', + [STRING.PREVIOUS_CAPTURE]: 'Previous capture', + [STRING.PREVIOUS_CAPTURE_WITH_DETECTIONS]: 'Previous capture with detections', [STRING.PROCESS_NOW]: 'Process now', [STRING.PROCESS]: 'Process', [STRING.QUEUED]: 'Queued', @@ -447,6 +671,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_ALGORITHM_URI]: 'Algorithm source', [STRING.FIELD_LABEL_AVG_TEMP]: 'Avg temp', [STRING.FIELD_LABEL_BEST_SCORE]: 'Best score', + [STRING.FIELD_LABEL_BOXES_WITH_VECTORS]: 'Boxes with a feature vector', [STRING.FIELD_LABEL_CAPTURE_SET]: 'Capture set', [STRING.FIELD_LABEL_CAPTURE]: 'Capture', [STRING.FIELD_LABEL_CAPTURES_WITH_DETECTIONS]: 'Captures with detections', @@ -464,6 +689,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_DELAY]: 'Delay', [STRING.FIELD_LABEL_DEPLOYMENT]: 'Station', [STRING.FIELD_LABEL_DESCRIPTION]: 'Description', + [STRING.FIELD_LABEL_DETECTIONS]: 'Detections', [STRING.FIELD_LABEL_DEVICE]: 'Device type', [STRING.FIELD_LABEL_DIRECT_OCCURRENCES]: 'Direct occurrences', [STRING.FIELD_LABEL_DURATION]: 'Duration', @@ -480,6 +706,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_GENERAL]: 'General', [STRING.FIELD_LABEL_ICON]: 'Icon', [STRING.FIELD_LABEL_ID]: 'ID', + [STRING.FIELD_LABEL_ID_AGREEMENT]: 'ID agreement', [STRING.FIELD_LABEL_IMAGE]: 'Cover image', [STRING.FIELD_LABEL_JOB_STATUS]: 'Job status', [STRING.FIELD_LABEL_JOB]: 'Job', @@ -495,9 +722,12 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_LOGS]: 'Logs', [STRING.FIELD_LABEL_LONGITUDE]: 'Longitude', [STRING.FIELD_LABEL_MOST_RECENT]: 'Most recent', + [STRING.FIELD_LABEL_MOTION]: 'Motion', [STRING.FIELD_LABEL_NAME]: 'Name', [STRING.FIELD_LABEL_NEW_PASSWORD]: 'New password', [STRING.FIELD_LABEL_NUM_PIPELINES_REGISTERED]: 'Pipelines registered', + [STRING.FIELD_LABEL_DETECTION_ID]: 'Detection ID', + [STRING.FIELD_LABEL_OCCURRENCE_NUMBER]: 'Occurrence ID', [STRING.FIELD_LABEL_OCCURRENCES]: 'Occurrences', [STRING.FIELD_LABEL_PASSWORD_CURRENT]: 'Current password', [STRING.FIELD_LABEL_PASSWORD_NEW]: 'New password', @@ -514,9 +744,11 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_SAMPLING_METHOD]: 'Sampling method', [STRING.FIELD_LABEL_SCORE]: 'Score', [STRING.FIELD_LABEL_SESSION]: 'Session', + [STRING.FIELD_LABEL_SESSION_NUMBER]: 'Session ID', [STRING.FIELD_LABEL_SESSIONS]: 'Sessions', [STRING.FIELD_LABEL_SITE]: 'Site', [STRING.FIELD_LABEL_SIZE]: 'Size', + [STRING.FIELD_LABEL_SIZE_CHANGE]: 'Size change', [STRING.FIELD_LABEL_SLUG]: 'Slug', [STRING.FIELD_LABEL_SNAPSHOTS]: 'Snapshots', [STRING.FIELD_LABEL_STARTED_AT]: 'Started at', @@ -554,6 +786,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.ENTITY_TYPE_IDENTIFICATION]: 'identification', [STRING.ENTITY_TYPE_JOB]: 'job', [STRING.ENTITY_TYPE_MEMBER]: 'member', + [STRING.ENTITY_TYPE_OCCURRENCE]: 'occurrence', [STRING.ENTITY_TYPE_PIPELINE]: 'pipeline', [STRING.ENTITY_TYPE_PROCESSING_SERVICE]: 'processing service', [STRING.ENTITY_TYPE_PROJECT_DETAILS]: 'project details', @@ -594,7 +827,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { 'Are you sure you want to delete this {{type}}?', [STRING.MESSAGE_DRAFTS]: 'Drafts are private and limited to one user.', [STRING.MESSAGE_EXPORT_TIP]: - 'We support two export formats: one compact and easy to use, and one that includes all raw data. To include all data in the export, skip "Capture set".', + 'Occurrences export as a compact CSV that is easy to use, or as JSON that includes all raw data. Projects that use tracking can also export their tracks. To include all data in the export, skip "Capture set".', [STRING.MESSAGE_HAS_ACCOUNT]: 'Already have an account?', [STRING.MESSAGE_IDENTIFICATION_REJECTED]: 'The update was rejected, please retry.', @@ -788,7 +1021,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.SET_PASSWORD]: 'Set password', [STRING.SETTINGS]: 'Settings', [STRING.SHOW_DETECTIONS]: 'Show detections', - [STRING.SNAP_TO_DETECTIONS]: 'Snap to captures with detections', + [STRING.SHOW_PATH_CROPS]: 'Show crops in path', [STRING.SORT_BY]: 'Sort by', [STRING.SORT_JOBS_ACTIVITY]: 'Jobs activity', [STRING.SORT_OCCURRENCE_UPDATES]: 'Occurrence updates', @@ -798,12 +1031,261 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.SUMMARY]: 'Summary', [STRING.TABLE_COLUMNS]: 'Table columns', [STRING.TERMINAL_CLASSIFICATION]: 'Terminal classification', + [STRING.TIMELINE_OCCURRENCE_FRAME]: + 'Occurrence #{{id}}: 1 capture at {{time}}', + [STRING.TIMELINE_OCCURRENCE_SPAN]: + 'Occurrence #{{id}}: {{count}} captures over {{duration}}', + [STRING.TIMELINE_DURATION_SECONDS]: '{{seconds}} s', + [STRING.TIMELINE_DURATION_MINUTES]: '{{minutes}} min', + [STRING.TIMELINE_DURATION_HOURS]: '{{hours}} h {{minutes}} min', + [STRING.TIMELINE_ACTIVITY_DESCRIPTION]: + 'Captures and average detections through the session. Use the capture time slider to move between captures.', + [STRING.TIMELINE_ACTIVITY_LABEL]: 'Session activity', + [STRING.TIMELINE_OCCURRENCE_ACTIVATE]: 'Go to this capture', + [STRING.TIMELINE_OCCURRENCE_SHOWING]: + 'Showing occurrence #{{id}} at {{time}}', + [STRING.TIMELINE_POSITION_LABEL]: 'Capture time', + [STRING.TIMELINE_POSITION_VALUE]: '{{time}}, capture {{index}} of {{total}}', [STRING.TIP]: 'Tip', + [STRING.TRACK_BOX_EXTEND_ACTION]: '{{label}}. {{action}}', + [STRING.TRACK_BOX_EXTEND_LABEL]: '{{name}}, match {{match}}', + [STRING.TRACK_BOX_LABEL]: '{{name}}, score {{score}}', + [STRING.TRACK_BOX_LABEL_VERIFIED]: '{{name}}, verified', + [STRING.TRACK_CANDIDATE_IN_GAP]: + "Falls inside this track's time span, in a gap between its frames. The time is measured from the nearest track frame.", + [STRING.TRACK_CLEARS_CONFIRMATION]: + 'This clears the mark that the track is complete and accurate.', + [STRING.TRACK_COLUMN_COST]: 'Cost', + [STRING.TRACK_COLUMN_COST_HELP]: + "The tracking algorithm's own score for the nearest pair of frames, and the order it ranks by. Lower fits better, and a pair links when it falls under the threshold.", + [STRING.TRACK_COLUMN_DISTANCE]: 'Distance', + [STRING.TRACK_COLUMN_FRAMES]: 'Frames', + [STRING.TRACK_COLUMN_IMAGE]: 'Image', + [STRING.TRACK_COLUMN_MATCH]: 'Match', + [STRING.TRACK_COLUMN_MATCH_HELP]: + 'The same cost read on a 0 to 100 scale. A pair with no feature vector is scored on one term fewer, so its percentage is not comparable with a pair that has one. Sort by Cost for the order tracking itself uses.', + [STRING.TRACK_COLUMN_OVERLAP]: 'Overlap', + [STRING.TRACK_COLUMN_SIMILARITY]: 'Similarity', + [STRING.TRACK_COLUMN_SIZE]: 'Size match', + [STRING.TRACK_COLUMN_SPECIES]: 'Species', + [STRING.TRACK_COLUMN_WHEN]: 'When', + [STRING.TRACK_COST_OF_THRESHOLD]: '{{cost}} of {{threshold}}', + [STRING.TRACK_VIEW_CANDIDATE_IN_SESSION]: 'View {{name}} in session', + [STRING.TRACK_CONFIRM_GROUPING_DESCRIPTION]: + 'Records that you checked this track holds every frame of this animal, and no frames of any other. This is a separate judgement from the species it was identified as, and any later change to its frames clears it.', + [STRING.TRACK_CONFIRM_GROUPING_RESULT]: 'Track marked complete and accurate.', + [STRING.TRACK_CONFIRM_GROUPING]: 'Mark track as complete and accurate', + [STRING.TRACK_GROUPING_FILTER_NO]: 'Not marked', + [STRING.TRACK_GROUPING_FILTER_TOOLTIP]: + 'Whether a person has marked this track complete and accurate: it holds every frame of the animal and no frames of any other. A separate judgement from the species it was identified as.', + [STRING.TRACK_GROUPING_FILTER_YES]: 'Complete and accurate', + [STRING.TRACK_EXTEND_CAPTURE_COVERED]: + 'This capture already has a frame of this track', + [STRING.TRACK_EXTEND_CHOICE_DESCRIPTION]: + 'This frame is part of {{name}} with {{count}} frames. Merge the whole track into the one you are editing, or move only this frame across.', + [STRING.TRACK_EXTEND_CLICKED_FRAME]: 'Frame you clicked', + [STRING.TRACK_EXTEND_DETAILS]: 'Details', + [STRING.TRACK_EXTEND_HERE]: 'Edit this track', + [STRING.TRACK_EXTEND_HIDE_DETAILS]: 'Hide box details', + [STRING.TRACK_EXTEND_IN_SESSION]: 'Edit in session view', + [STRING.TRACK_EXTEND_LABEL]: 'Editing', + [STRING.TRACK_EXTEND_LATEST_FRAME]: 'Latest frame in the track', + [STRING.TRACK_EXTEND_MERGE_OVERLAPS]: + 'Both tracks have a frame on the same capture', + [STRING.TRACK_EXTEND_MERGE_WHOLE]: 'Merge the whole track', + [STRING.TRACK_EXTEND_MOVE_FRAME]: 'Move only this frame', + [STRING.TRACK_EXTEND_NO_LATER_CAPTURE]: 'No later capture with detections', + [STRING.TRACK_EXTEND_ONLY_FRAME]: + "The track's only frame cannot be removed or replaced", + [STRING.TRACK_EXTEND_REMOVE]: 'Remove', + [STRING.TRACK_EXTEND_REMOVE_PROMPT]: 'Remove this frame from the track?', + [STRING.TRACK_EXTEND_REPLACE]: 'Replace', + [STRING.TRACK_EXTEND_REPLACE_ORPHAN]: + 'The removed frame is now occurrence #{{id}}.', + [STRING.TRACK_EXTEND_REPLACE_PROMPT]: + "Replace this capture's frame with this one?", + [STRING.TRACK_EXTEND_SHOW_DETAILS]: 'Show box details', + [STRING.TRACK_EXTEND_STOP]: 'Stop editing this track', + [STRING.TRACK_EXTEND_TITLE]: 'Add this frame to the track', + [STRING.TRACK_FIRST_FRAME]: 'First track frame', + [STRING.TRACK_FRAMES_COUNT]: '{{count}} frames', + [STRING.TRACK_FRAMES_ONE]: '1 frame', + [STRING.TRACK_FRAMES_PAGE_EARLIER]: 'Earlier frames', + [STRING.TRACK_FRAMES_PAGE_ERROR]: 'These frames could not be loaded.', + [STRING.TRACK_FRAMES_PAGE_FIRST]: 'First frames of the track', + [STRING.TRACK_FRAMES_PAGE_LAST]: 'Last frames of the track', + [STRING.TRACK_FRAMES_PAGE_LATER]: 'Later frames', + [STRING.TRACK_FRAMES_RANGE]: 'Frames {{start}}–{{end}} of {{total}}', + [STRING.TRACK_FRAME_DETECTION]: 'Detection #{{id}}', + [STRING.TRACK_FRAME_IN_TRACK]: 'On this capture, already in the track', + [STRING.TRACK_FRAME_MENU]: 'Edit this frame', + [STRING.TRACK_FRAME_NO_CLASSIFICATION]: 'No classification', + [STRING.TRACK_FRAME_NO_CROP]: 'Crop image not available', + [STRING.TRACK_FRAME_NO_VECTOR]: 'No feature vector', + [STRING.TRACK_FRAME_THIS_CAPTURE]: 'On the capture you are viewing', + [STRING.TRACK_HIDE_PANEL]: 'Hide this panel', + [STRING.TRACK_HIDE_PATH]: 'Hide path', + [STRING.TRACK_JUMP_FIRST_FRAME]: 'First frame in session', + [STRING.TRACK_JUMP_LAST_FRAME]: 'Last frame in session', + [STRING.TRACK_JUMP_ONLY_FRAME]: 'Open in session', + [STRING.TRACK_LAST_FRAME]: 'Last track frame', + [STRING.TRACK_LOADING_PATH]: 'Loading path', + [STRING.TRACK_GAP_AFTER]: '+{{time}}', + [STRING.TRACK_GAP_BEFORE]: '−{{time}}', + [STRING.TRACK_GHOST_EARLIER]: 'Earlier frame · {{time}}', + [STRING.TRACK_GHOST_HINT]: 'Click to go to this frame', + [STRING.TRACK_GHOST_LATER]: 'Later frame · {{time}}', + [STRING.TRACK_GROUPING_CONFIRMED_BY]: 'by {{name}} on {{date}}', + [STRING.TRACK_GROUPING_CONFIRMED]: 'Complete and accurate', + [STRING.TRACK_GROUPING_NOT_CONFIRMED]: + 'Not yet marked complete and accurate by a person', + [STRING.TRACK_GROUPING_UNCONFIRMED]: 'Not marked complete and accurate', + [STRING.TRACK_MATCH_CLICK_ADD]: 'Click to add to the track', + [STRING.TRACK_MATCH_CLICK_CHOOSE]: + 'Click to choose: merge its whole track, or move just this frame', + [STRING.TRACK_MATCH_CLICK_REMOVE]: 'Click to remove from the track', + [STRING.TRACK_MATCH_CLICK_REPLACE]: + "Click to replace the track's frame on this capture", + [STRING.TRACK_MATCH_LABEL]: 'Match', + [STRING.TRACK_MATCH_LIKELY]: 'Likely', + [STRING.TRACK_MATCH_POSSIBLE]: 'Possible', + [STRING.TRACK_MATCH_REFERENCE_EARLIER]: + "Compared with the track's frame {{count}} captures earlier", + [STRING.TRACK_MATCH_REFERENCE_LATER]: + "Compared with the track's frame {{count}} captures later", + [STRING.TRACK_MATCH_SAME_SPECIES]: + '{{count}} more of this species on this capture', + [STRING.TRACK_MATCH_SCORE]: '{{level}} ({{percent}}%)', + [STRING.TRACK_MATCH_SKIPPED_NO_VECTOR]: + 'Tracking would skip this: no feature vector', + [STRING.TRACK_MATCH_SKIPPED_SESSION]: + 'Automatic tracking would skip this session: no feature vectors. Colors use box geometry only.', + [STRING.TRACK_MATCH_UNLIKELY]: 'Unlikely', + [STRING.TRACK_MATCH_WOULD_LINK]: 'Automatic tracking would link this', + [STRING.TRACK_MERGE_CANDIDATES_LOAD_FAILED]: + 'Could not load the candidates. Close this dialog and try again.', + [STRING.TRACK_MERGE_CANDIDATES_TITLE]: 'Candidates to merge', + [STRING.TRACK_MERGE_COUNT]: 'Merge {{count}} occurrences', + [STRING.TRACK_MERGE_MANY_DESCRIPTION]: + 'The detections and identifications of the occurrences you tick move here, and those occurrences are deleted. Only occurrences from this session can be merged.', + [STRING.TRACK_MERGE_ONE]: 'Merge 1 occurrence', + [STRING.TRACK_MERGE_SCOPE_DESCRIPTION]: + 'Searching {{scope}}. Lists occurrences seen just before or after this one, or in a gap in its track, best match by the tracking method first. Distance and similarity compare the two frames nearest in time.', + [STRING.TRACK_MERGE_RESULT]: + 'Merged. This occurrence now holds {{count}} frames.', + [STRING.TRACK_MERGE]: 'Merge with another occurrence', + [STRING.TRACK_MOVE_DESCRIPTION]: + 'The frame at {{time}} moves to the occurrence you pick. The rest of this occurrence stays together.', + [STRING.TRACK_MOVE_FRAME]: 'Move to another occurrence', + [STRING.TRACK_MOVE_SCOPE_DESCRIPTION]: + 'Searching {{scope}}. Lists occurrences this frame could belong to, best match to it by the tracking method first. Occurrences with a box on the same capture are left out.', + [STRING.TRACK_MOVE_RESULT]: + 'Moved. Occurrence #{{id}} now holds {{count}} frames.', + [STRING.TRACK_NEXT_FRAME]: 'Next track frame', + [STRING.TRACK_NO_MERGE_CANDIDATES_SCOPE]: + 'No other occurrence of this session was found in this scope. Try a wider scope.', + [STRING.TRACK_OFFSET_HOURS]: '{{hours}} h {{minutes}} min', + [STRING.TRACK_OFFSET_MINUTES]: '{{count}} min', + [STRING.TRACK_OFFSET_SECONDS]: '{{count}} s', + [STRING.TRACK_OPEN_NEW_OCCURRENCE]: 'Open it', + [STRING.TRACK_PATH_ERROR]: 'Could not load the path', + [STRING.TRACK_PATH_RANGE]: '{{start}}–{{end}} · {{frames}}', + [STRING.TRACK_PATH_STATUS_ERROR]: + 'Could not load the path for occurrence #{{id}}', + [STRING.TRACK_PATH_STATUS_LOADING]: 'Loading path for occurrence #{{id}}', + [STRING.TRACK_PATH_STATUS_SHOWN]: 'Path shown for occurrence #{{id}}', + [STRING.TRACK_PATH_STATUS_SHOWN_FOR]: 'Path shown for', + [STRING.TRACK_PATH_OCCURRENCE]: 'occurrence #{{id}}', + [STRING.TRACK_PATH_GO_TO_FRAME]: 'Go to frame at {{time}}', + [STRING.TRACK_PATH_GO_TO_NEAREST]: + 'Go to the nearest capture with this occurrence', + [STRING.TRACK_PERCENT]: '{{percent}}%', + [STRING.TRACK_PICK_OCCURRENCE]: 'Candidates near this frame', + [STRING.TRACK_POSITION_AFTER_LAST]: 'After the last frame', + [STRING.TRACK_POSITION_BEFORE_FIRST]: 'Before the first frame', + [STRING.TRACK_POSITION_BETWEEN]: 'Between frames {{before}} and {{after}}', + [STRING.TRACK_POSITION_FRAME]: 'Frame {{index}} of {{total}}', + [STRING.TRACK_PREVIOUS_FRAME]: 'Previous track frame', + [STRING.TRACK_REMOVE_DESCRIPTION]: + 'The frame at {{time}} becomes an occurrence of its own, leaving {{remaining}} of {{total}} frames here. The rest of the track is stitched back together.', + [STRING.TRACK_REMOVE_FRAME]: 'Remove this frame', + [STRING.TRACK_REMOVE_RESULT]: + 'Removed. The frame is now occurrence #{{id}} on its own.', + [STRING.TRACK_SCOPE_LABEL]: 'Search scope', + [STRING.TRACK_SCOPE_MINUTES_30]: '±30 minutes', + [STRING.TRACK_SCOPE_MINUTES_5]: '±5 minutes', + [STRING.TRACK_SCOPE_NEAR]: '3 captures either side', + [STRING.TRACK_SCOPE_NEXT]: 'Adjacent captures', + [STRING.TRACK_SELECT_ALL]: 'Select all shown', + [STRING.TRACK_SELECT_ROW]: 'Select {{name}}', + [STRING.TRACK_SPLIT_DESCRIPTION]: + 'Everything from {{time}} onwards — {{moved}} of {{total}} frames — becomes a new occurrence. The earlier frames stay here.', + [STRING.TRACK_SHOWING_PART_OF_PATH]: 'Showing {{shown}} of {{total}} frames', + [STRING.TRACK_SHOW_BOXES_ONLY]: 'Show boxes only', + [STRING.TRACK_SHOW_PATH]: 'Show path', + [STRING.TRACK_SINGLE_FRAME_NO_PATH]: 'No path — one frame only', + [STRING.TRACK_SORT_BEST]: 'Best match', + [STRING.TRACK_SORT_BY]: 'Sort by {{column}}', + [STRING.TRACK_SORT_CUMULATIVE]: 'Time, then distance, then similarity', + [STRING.TRACK_SORT_LABEL]: 'Order candidates', + [STRING.TRACK_SPLIT_HERE]: 'Split here', + [STRING.TRACK_SPLIT_RESULT]: + 'Split. {{count}} frames moved to occurrence #{{id}}.', + [STRING.TRACK_STAT_DISTINCT_TAXA]: '{{count}} taxa', + [STRING.TRACK_STAT_ID_AGREEMENT]: '{{percent}}%', + [STRING.TRACK_STAT_LINKED]: '{{count}} linked', + [STRING.TRACK_STAT_MOTION]: '{{percent}}% of frame', + [STRING.TRACK_STAT_SCORE_RANGE]: '{{min}}–{{max}} · mean {{mean}}', + [STRING.TRACK_STAT_SIZE_RATIO]: '×{{ratio}}', + [STRING.TRACK_SUMMARY_DERIVED_TOOLTIP]: + 'Computed from the current detections when this page loads; not saved and not included in exports', + [STRING.TRACK_SUMMARY_DERIVED]: 'Derived, not stored', + [STRING.TRACK_SUMMARY_FRAMES]: 'Frames grouped', + [STRING.TRACK_SUMMARY_FRAMES_WITH_VECTORS]: 'Frames with a feature vector', + [STRING.TRACK_SUMMARY_FRAME_NAMES]: 'Names given to frames', + [STRING.TRACK_SUMMARY_ID_AGREEMENT]: 'Identification agreement', + [STRING.TRACK_SUMMARY_SCORE_RANGE]: 'Score range', + [STRING.TRACK_SUMMARY_TITLE]: 'Occurrence tracking', + [STRING.TRACK_SUMMARY_VECTORS_TOOLTIP]: + 'Tracking and the merge picker compare frames by these feature vectors.', + [STRING.TRACK_TIME_SPAN]: '{{start}}–{{end}}', + [STRING.TRACK_UNDO_CONFIRMATION_DESCRIPTION]: + 'Withdraws the mark that this track is complete and accurate. The frames themselves are left untouched.', + [STRING.TRACK_UNDO_CONFIRMATION_RESULT]: 'Mark withdrawn.', + [STRING.TRACK_UNDO_CONFIRMATION]: 'Undo this mark', + [STRING.UNIDENTIFIED]: 'Unidentified', [STRING.UNKNOWN_ERROR]: 'Unknown error', [STRING.UNKNOWN]: 'Unknown', [STRING.UPDATING_DATA]: 'Updating data', + [STRING.TRACK_WHEN_EARLIER]: '{{time}} earlier', + [STRING.TRACK_WHEN_LATER]: '{{time}} later', + [STRING.TRACKING_JOB_COST_THRESHOLD]: 'Cost threshold', + [STRING.TRACKING_JOB_COST_THRESHOLD_DESCRIPTION]: + 'The highest matching cost at which two detections are still linked. Lower links fewer, closer matches. Leave empty for the default.', + [STRING.TRACKING_JOB_DESCRIPTION]: + 'Tracking links detections of the same individual in consecutive captures into one occurrence.', + [STRING.TRACKING_JOB_NAME_CAPTURE_SET]: 'Tracking capture set {{id}}', + [STRING.TRACKING_JOB_NAME_DESCRIPTION]: + 'Left empty, the job is named after what it tracks.', + [STRING.TRACKING_JOB_NAME_SESSION]: 'Tracking session {{id}}', + [STRING.TRACKING_JOB_OPTIONS]: 'Options', + [STRING.TRACKING_JOB_REQUIRE_FEATURES]: 'Require feature vectors', + [STRING.TRACKING_JOB_REQUIRE_FEATURES_DESCRIPTION]: + 'Only link detections that have a feature vector. Turn this off for captures processed before vectors were stored: matching then relies on position and size alone and links more loosely.', + [STRING.TRACKING_JOB_RUN]: 'Run tracking', + [STRING.TRACKING_JOB_RUN_ON_SESSION]: 'Run tracking on this session', + [STRING.TRACKING_JOB_SCOPE]: 'Track', + [STRING.TRACKING_JOB_SCOPE_CAPTURE_SET]: 'A capture set', + [STRING.TRACKING_JOB_SCOPE_SESSION]: 'One session', + [STRING.TRACKING_JOB_SKIPPED_SESSIONS]: + 'Tracking skips a session if it has human identifications, or if any of its occurrences already groups several detections (from an earlier tracking run or a manual merge). It can also skip sessions that are not fully processed or lack feature vectors.', + [STRING.TRACKING_JOB_START_NOW]: 'Start immediately', + [STRING.TRACKING_JOB_TYPE]: 'Job type', + [STRING.TRACKING_JOB_TYPE_PROCESSING]: 'Process images', + [STRING.TRACKING_JOB_TYPE_TRACKING]: 'Track occurrences', [STRING.UPLOAD_CAPTURES]: 'Upload captures', [STRING.USER_INFO]: 'User info', + [STRING.VALUE_COUNT_OF_TOTAL]: '{{count}} of {{total}}', [STRING.VALUE_NOT_AVAILABLE]: 'n/a', [STRING.VERIFIED_BY]: 'Verified by\n{{name}}', [STRING.VERIFIED_OCCURRENCES]: 'Verified occurrences', diff --git a/ui/src/utils/parseServerError/parseServerError.test.ts b/ui/src/utils/parseServerError/parseServerError.test.ts index 8741764d6..31c860cbb 100644 --- a/ui/src/utils/parseServerError/parseServerError.test.ts +++ b/ui/src/utils/parseServerError/parseServerError.test.ts @@ -21,6 +21,24 @@ describe('parseServerError', () => { }) }) + test('keeps the whole message when a field error is a bare string', () => { + const EXAMPLE_ERROR = { + message: 'Request failed with status code 400', + response: { + data: { + occurrence_ids: 'Capture(s) [12] would hold two detections.', + }, + }, + } + + expect(parseServerError(EXAMPLE_ERROR).fieldErrors).toEqual([ + { + key: 'occurrence_ids', + message: 'Capture(s) [12] would hold two detections.', + }, + ]) + }) + test('returns max one message per field', () => { const EXAMPLE_ERROR = { response: { diff --git a/ui/src/utils/parseServerError/parseServerError.ts b/ui/src/utils/parseServerError/parseServerError.ts index a149038de..ec0f2d474 100644 --- a/ui/src/utils/parseServerError/parseServerError.ts +++ b/ui/src/utils/parseServerError/parseServerError.ts @@ -18,7 +18,12 @@ export const parseServerError = (error: any) => { if (key === 'non_field_errors' || key === 'detail') { message = details as string } else { - fieldErrors.push({ key, message: `${(details as string[])[0]}` }) + // DRF sends a field error as a list of strings, or as a bare string when a + // view raises ValidationError({field: "message"}); take the whole message either way. + fieldErrors.push({ + key, + message: `${Array.isArray(details) ? details[0] : details}`, + }) } } }) diff --git a/ui/src/utils/project-features/project-features.test.ts b/ui/src/utils/project-features/project-features.test.ts new file mode 100644 index 000000000..81d0d09b8 --- /dev/null +++ b/ui/src/utils/project-features/project-features.test.ts @@ -0,0 +1,63 @@ +import { + canStartTracking, + hasProjectFeature, + withoutTrackingColumns, +} from './project-features' +import { UserPermission } from 'utils/user/types' + +describe('hasProjectFeature', () => { + test('is on only when the flag is true', () => { + expect(hasProjectFeature({ tracking: true }, 'tracking')).toBe(true) + expect(hasProjectFeature({ tracking: false }, 'tracking')).toBe(false) + expect(hasProjectFeature({ tags: true }, 'tracking')).toBe(false) + }) + + test('is off while the project has not loaded', () => { + expect(hasProjectFeature(undefined, 'tracking')).toBe(false) + expect(hasProjectFeature({}, 'tracking')).toBe(false) + }) +}) + +describe('withoutTrackingColumns', () => { + const columns = [ + { id: 'id' }, + { id: 'detections' }, + { id: 'duration' }, + { id: 'motion' }, + { id: 'size-change' }, + { id: 'id-agreement' }, + ] + + test('drops the track statistic columns when tracking is off', () => { + expect(withoutTrackingColumns(columns, false).map((c) => c.id)).toEqual([ + 'id', + 'duration', + ]) + }) + + test('keeps every column when tracking is on', () => { + expect(withoutTrackingColumns(columns, true)).toEqual(columns) + }) +}) + +describe('canStartTracking', () => { + const run = [UserPermission.RunTracking] + + test('needs both the flag and the run tracking permission', () => { + const flags = { tracking: true } + expect( + canStartTracking({ userPermissions: run, featureFlags: flags }) + ).toBe(true) + expect( + canStartTracking({ + userPermissions: [UserPermission.Update], + featureFlags: flags, + }) + ).toBe(false) + expect(canStartTracking({ featureFlags: flags })).toBe(false) + expect(canStartTracking({ userPermissions: run, featureFlags: {} })).toBe( + false + ) + expect(canStartTracking(undefined)).toBe(false) + }) +}) diff --git a/ui/src/utils/project-features/project-features.ts b/ui/src/utils/project-features/project-features.ts new file mode 100644 index 000000000..510b45ded --- /dev/null +++ b/ui/src/utils/project-features/project-features.ts @@ -0,0 +1,37 @@ +import { UserPermission } from 'utils/user/types' + +export type ProjectFeature = 'tags' | 'tracking' + +/** Flags a project owner has not switched on read as off, including while loading. */ +export const hasProjectFeature = ( + featureFlags: { [key: string]: boolean } | undefined, + feature: ProjectFeature +) => featureFlags?.[feature] === true + +/** The server grants run_tracking only while tracking is on, so the flag check is a safeguard. */ +export const canStartTracking = ( + project: + | { + featureFlags?: { [key: string]: boolean } + userPermissions?: UserPermission[] + } + | undefined +) => + !!project?.userPermissions?.includes(UserPermission.RunTracking) && + hasProjectFeature(project.featureFlags, 'tracking') + +// Occurrence list columns that only mean something once occurrences are tracks. +export const TRACKING_COLUMN_IDS = [ + 'detections', + 'motion', + 'size-change', + 'id-agreement', +] + +export const withoutTrackingColumns = ( + columns: T[], + trackingEnabled: boolean +) => + trackingEnabled + ? columns + : columns.filter((column) => !TRACKING_COLUMN_IDS.includes(column.id)) diff --git a/ui/src/utils/project-features/useProjectFeature.ts b/ui/src/utils/project-features/useProjectFeature.ts new file mode 100644 index 000000000..8954e4719 --- /dev/null +++ b/ui/src/utils/project-features/useProjectFeature.ts @@ -0,0 +1,21 @@ +import { useProjectDetails } from 'data-services/hooks/projects/useProjectDetails' +import { useParams } from 'react-router-dom' +import { + canStartTracking, + hasProjectFeature, + ProjectFeature, +} from './project-features' + +export const useProjectFeature = (feature: ProjectFeature) => { + const { projectId } = useParams() + const { project } = useProjectDetails(projectId as string, true) + + return hasProjectFeature(project?.featureFlags, feature) +} + +export const useCanStartTracking = () => { + const { projectId } = useParams() + const { project } = useProjectDetails(projectId as string, true) + + return canStartTracking(project) +} diff --git a/ui/src/utils/useFilters.ts b/ui/src/utils/useFilters.ts index 8e9dd3336..c9779997b 100644 --- a/ui/src/utils/useFilters.ts +++ b/ui/src/utils/useFilters.ts @@ -146,6 +146,13 @@ export const AVAILABLE_FILTERS = (projectId: string): FilterConfig[] => [ }, }, }, + { + label: translate(STRING.TRACK_GROUPING_CONFIRMED), + field: 'grouping_verified', + tooltip: { + text: translate(STRING.TRACK_GROUPING_FILTER_TOOLTIP), + }, + }, { label: 'Processing status', field: 'processed', diff --git a/ui/src/utils/user/types.ts b/ui/src/utils/user/types.ts index 66cc5a53b..10392d23a 100644 --- a/ui/src/utils/user/types.ts +++ b/ui/src/utils/user/types.ts @@ -16,6 +16,7 @@ export enum UserPermission { Populate = 'populate', // Custom collection permission Run = 'run', // Custom job permission RunSingleImage = 'run_single_image_ml_job', // Custom job permission + RunTracking = 'run_tracking', // Custom project permission Star = 'star', Sync = 'sync', // Custom deployment permission (sync_deployment) Update = 'update', diff --git a/ui/yarn.lock b/ui/yarn.lock index 188e5ec68..c705cf33b 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -1825,26 +1825,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-arrow@npm:1.0.3": - version: 1.0.3 - resolution: "@radix-ui/react-arrow@npm:1.0.3" - dependencies: - "@babel/runtime": "npm:^7.13.10" - "@radix-ui/react-primitive": "npm:1.0.3" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: c931f6d7e0bac50fd1654a0303a303aff74a68a13a33a851a43a7c88677b53a92ca6557920b9105144a3002f899ce888437d20ddd7803a5c716edac99587626d - languageName: node - linkType: hard - "@radix-ui/react-arrow@npm:1.1.7": version: 1.1.7 resolution: "@radix-ui/react-arrow@npm:1.1.7" @@ -2225,30 +2205,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dismissable-layer@npm:1.0.4": - version: 1.0.4 - resolution: "@radix-ui/react-dismissable-layer@npm:1.0.4" - dependencies: - "@babel/runtime": "npm:^7.13.10" - "@radix-ui/primitive": "npm:1.0.1" - "@radix-ui/react-compose-refs": "npm:1.0.1" - "@radix-ui/react-primitive": "npm:1.0.3" - "@radix-ui/react-use-callback-ref": "npm:1.0.1" - "@radix-ui/react-use-escape-keydown": "npm:1.0.3" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: a7b9695092cd4109a7b4a4a66b7f634c42d4f39aa0893621a8ee5e8bc90f8ae27e741df66db726c341a60d2115e3f813520fee1f5cc4fb05d77914b4ade3819f - languageName: node - linkType: hard - "@radix-ui/react-dismissable-layer@npm:1.1.11": version: 1.1.11 resolution: "@radix-ui/react-dismissable-layer@npm:1.1.11" @@ -2272,29 +2228,28 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dropdown-menu@npm:^2.0.5": - version: 2.0.5 - resolution: "@radix-ui/react-dropdown-menu@npm:2.0.5" +"@radix-ui/react-dropdown-menu@npm:2.1.16": + version: 2.1.16 + resolution: "@radix-ui/react-dropdown-menu@npm:2.1.16" dependencies: - "@babel/runtime": "npm:^7.13.10" - "@radix-ui/primitive": "npm:1.0.1" - "@radix-ui/react-compose-refs": "npm:1.0.1" - "@radix-ui/react-context": "npm:1.0.1" - "@radix-ui/react-id": "npm:1.0.1" - "@radix-ui/react-menu": "npm:2.0.5" - "@radix-ui/react-primitive": "npm:1.0.3" - "@radix-ui/react-use-controllable-state": "npm:1.0.1" + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-menu": "npm:2.1.16" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" peerDependencies: "@types/react": "*" "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true "@types/react-dom": optional: true - checksum: fdc163b07894101a88bd2b0a6a425838b5e4164fb0dcf306e215120ebe034c7f2c114f7d4c2b20eb48d90402fb1caefb61a22b2c2084b40d63fa551429fe6e86 + checksum: 8caaa8dd791ccb284568720adafa59855e13860aa29eb20e10a04ba671cbbfa519a4c5d3a339a4d9fb08009eeb1065f4a8b5c3c8ef45e9753161cc560106b935 languageName: node linkType: hard @@ -2309,21 +2264,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-focus-guards@npm:1.0.1": - version: 1.0.1 - resolution: "@radix-ui/react-focus-guards@npm:1.0.1" - dependencies: - "@babel/runtime": "npm:^7.13.10" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: d5fd4e5aa9d9a87c8ad490b3b4992d6f1d9eddf18e56df2a2bcf8744c4332b275d73377fd193df3e6ba0ad9608dc497709beca5c64de2b834d5f5350b3c9a272 - languageName: node - linkType: hard - "@radix-ui/react-focus-guards@npm:1.1.3": version: 1.1.3 resolution: "@radix-ui/react-focus-guards@npm:1.1.3" @@ -2352,28 +2292,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-focus-scope@npm:1.0.3": - version: 1.0.3 - resolution: "@radix-ui/react-focus-scope@npm:1.0.3" - dependencies: - "@babel/runtime": "npm:^7.13.10" - "@radix-ui/react-compose-refs": "npm:1.0.1" - "@radix-ui/react-primitive": "npm:1.0.3" - "@radix-ui/react-use-callback-ref": "npm:1.0.1" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: bfff46919666c122f5b812ee427494ae8408c0eebee30337bd2ce0eedf539f0feaa242f790304ef9df15425b837010ffc6061ce467bedd2c5fd9373bee2b95da - languageName: node - linkType: hard - "@radix-ui/react-focus-scope@npm:1.1.7": version: 1.1.7 resolution: "@radix-ui/react-focus-scope@npm:1.1.7" @@ -2453,103 +2371,72 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-menu@npm:2.0.5": - version: 2.0.5 - resolution: "@radix-ui/react-menu@npm:2.0.5" - dependencies: - "@babel/runtime": "npm:^7.13.10" - "@radix-ui/primitive": "npm:1.0.1" - "@radix-ui/react-collection": "npm:1.0.3" - "@radix-ui/react-compose-refs": "npm:1.0.1" - "@radix-ui/react-context": "npm:1.0.1" - "@radix-ui/react-direction": "npm:1.0.1" - "@radix-ui/react-dismissable-layer": "npm:1.0.4" - "@radix-ui/react-focus-guards": "npm:1.0.1" - "@radix-ui/react-focus-scope": "npm:1.0.3" - "@radix-ui/react-id": "npm:1.0.1" - "@radix-ui/react-popper": "npm:1.1.2" - "@radix-ui/react-portal": "npm:1.0.3" - "@radix-ui/react-presence": "npm:1.0.1" - "@radix-ui/react-primitive": "npm:1.0.3" - "@radix-ui/react-roving-focus": "npm:1.0.4" - "@radix-ui/react-slot": "npm:1.0.2" - "@radix-ui/react-use-callback-ref": "npm:1.0.1" - aria-hidden: "npm:^1.1.1" - react-remove-scroll: "npm:2.5.5" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 9b21a5fb159dc106ea7b45c64cf08487fb4cc1bba2e8696d70320f3e4173f2a0098eeeef16bc846391f569fd2abb5fe7b2ef40de929b9625e18a27f702743705 - languageName: node - linkType: hard - -"@radix-ui/react-popover@npm:^1.0.6": - version: 1.0.6 - resolution: "@radix-ui/react-popover@npm:1.0.6" +"@radix-ui/react-menu@npm:2.1.16": + version: 2.1.16 + resolution: "@radix-ui/react-menu@npm:2.1.16" dependencies: - "@babel/runtime": "npm:^7.13.10" - "@radix-ui/primitive": "npm:1.0.1" - "@radix-ui/react-compose-refs": "npm:1.0.1" - "@radix-ui/react-context": "npm:1.0.1" - "@radix-ui/react-dismissable-layer": "npm:1.0.4" - "@radix-ui/react-focus-guards": "npm:1.0.1" - "@radix-ui/react-focus-scope": "npm:1.0.3" - "@radix-ui/react-id": "npm:1.0.1" - "@radix-ui/react-popper": "npm:1.1.2" - "@radix-ui/react-portal": "npm:1.0.3" - "@radix-ui/react-presence": "npm:1.0.1" - "@radix-ui/react-primitive": "npm:1.0.3" - "@radix-ui/react-slot": "npm:1.0.2" - "@radix-ui/react-use-controllable-state": "npm:1.0.1" - aria-hidden: "npm:^1.1.1" - react-remove-scroll: "npm:2.5.5" + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-collection": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-dismissable-layer": "npm:1.1.11" + "@radix-ui/react-focus-guards": "npm:1.1.3" + "@radix-ui/react-focus-scope": "npm:1.1.7" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-popper": "npm:1.2.8" + "@radix-ui/react-portal": "npm:1.1.9" + "@radix-ui/react-presence": "npm:1.1.5" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-roving-focus": "npm:1.1.11" + "@radix-ui/react-slot": "npm:1.2.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.6.3" peerDependencies: "@types/react": "*" "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true "@types/react-dom": optional: true - checksum: 1b9fd21f6b73a74af88c5cd151f7de2a8dcc46a4d7ac7bee1104d5a45869f170bcb2490c3205b18597457b9153e71016bc1f71077938317e07eca5cf45fca7ae + checksum: 27516b2b987fa9181c4da8645000af8f60691866a349d7a46b9505fa7d2e9d92b9e364db4f7305d08e9e57d0e1afc8df8354f8ee3c12aa05c0100c16b0e76c27 languageName: node linkType: hard -"@radix-ui/react-popper@npm:1.1.2": - version: 1.1.2 - resolution: "@radix-ui/react-popper@npm:1.1.2" +"@radix-ui/react-popover@npm:1.1.15": + version: 1.1.15 + resolution: "@radix-ui/react-popover@npm:1.1.15" dependencies: - "@babel/runtime": "npm:^7.13.10" - "@floating-ui/react-dom": "npm:^2.0.0" - "@radix-ui/react-arrow": "npm:1.0.3" - "@radix-ui/react-compose-refs": "npm:1.0.1" - "@radix-ui/react-context": "npm:1.0.1" - "@radix-ui/react-primitive": "npm:1.0.3" - "@radix-ui/react-use-callback-ref": "npm:1.0.1" - "@radix-ui/react-use-layout-effect": "npm:1.0.1" - "@radix-ui/react-use-rect": "npm:1.0.1" - "@radix-ui/react-use-size": "npm:1.0.1" - "@radix-ui/rect": "npm:1.0.1" + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-dismissable-layer": "npm:1.1.11" + "@radix-ui/react-focus-guards": "npm:1.1.3" + "@radix-ui/react-focus-scope": "npm:1.1.7" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-popper": "npm:1.2.8" + "@radix-ui/react-portal": "npm:1.1.9" + "@radix-ui/react-presence": "npm:1.1.5" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-slot": "npm:1.2.3" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.6.3" peerDependencies: "@types/react": "*" "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: "@types/react": optional: true "@types/react-dom": optional: true - checksum: 4bd069b79f7046af2c0967b8e43f727cd09834cbd6df1e3d5a943c4f83428ff8b646882737fdf7593c22e261a1d13768a5c020138d79503862ae2e1729081bba + checksum: c1c76b5e5985b128d03b621424fb453f769931d497759a1977734d303007da9f970570cf3ea1f6968ab609ab4a97f384168bff056197bd2d3d422abea0e3614b languageName: node linkType: hard @@ -2594,26 +2481,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-portal@npm:1.0.3": - version: 1.0.3 - resolution: "@radix-ui/react-portal@npm:1.0.3" - dependencies: - "@babel/runtime": "npm:^7.13.10" - "@radix-ui/react-primitive": "npm:1.0.3" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: baf295bbbf09ead37b64ee1dc025a6a540960f5e60552766d78f6065504c67d4bcf49fad5e2073617d9a3011daafad625aa3bd1da7a886c704833b22a49e888f - languageName: node - linkType: hard - "@radix-ui/react-portal@npm:1.1.9": version: 1.1.9 resolution: "@radix-ui/react-portal@npm:1.1.9" @@ -2814,34 +2681,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-roving-focus@npm:1.0.4": - version: 1.0.4 - resolution: "@radix-ui/react-roving-focus@npm:1.0.4" - dependencies: - "@babel/runtime": "npm:^7.13.10" - "@radix-ui/primitive": "npm:1.0.1" - "@radix-ui/react-collection": "npm:1.0.3" - "@radix-ui/react-compose-refs": "npm:1.0.1" - "@radix-ui/react-context": "npm:1.0.1" - "@radix-ui/react-direction": "npm:1.0.1" - "@radix-ui/react-id": "npm:1.0.1" - "@radix-ui/react-primitive": "npm:1.0.3" - "@radix-ui/react-use-callback-ref": "npm:1.0.1" - "@radix-ui/react-use-controllable-state": "npm:1.0.1" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 61e3ddfd1647e64fba855434ff41e8e7ba707244fe8841f78c450fbdce525383b64259279475615d030dbf1625cbffd8eeebee72d91bf6978794f5dbcf887fc0 - languageName: node - linkType: hard - "@radix-ui/react-roving-focus@npm:1.1.0": version: 1.1.0 resolution: "@radix-ui/react-roving-focus@npm:1.1.0" @@ -2869,6 +2708,33 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-roving-focus@npm:1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-roving-focus@npm:1.1.11" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-collection": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 2cd43339c36e89a3bf1db8aab34b939113dfbde56bf3a33df2d74757c78c9489b847b1962f1e2441c67e41817d120cb6177943e0f655f47bc1ff8e44fd55b1a2 + languageName: node + linkType: hard + "@radix-ui/react-select@npm:^2.2.6": version: 2.2.6 resolution: "@radix-ui/react-select@npm:2.2.6" @@ -3260,22 +3126,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-use-escape-keydown@npm:1.0.3": - version: 1.0.3 - resolution: "@radix-ui/react-use-escape-keydown@npm:1.0.3" - dependencies: - "@babel/runtime": "npm:^7.13.10" - "@radix-ui/react-use-callback-ref": "npm:1.0.1" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 3c94c78902dcb40b60083ee2184614f45c95a189178f52d89323b467bd04bcf5fdb1bc4d43debecd7f0b572c3843c7e04edbcb56f40a4b4b43936fb2770fb8ad - languageName: node - linkType: hard - "@radix-ui/react-use-escape-keydown@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/react-use-escape-keydown@npm:1.1.1" @@ -3367,22 +3217,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-use-rect@npm:1.0.1": - version: 1.0.1 - resolution: "@radix-ui/react-use-rect@npm:1.0.1" - dependencies: - "@babel/runtime": "npm:^7.13.10" - "@radix-ui/rect": "npm:1.0.1" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 94c5ab31dfd3678c0cb77a30025e82b3a287577c1a8674b0d703a36d27434bc9c59790e0bebf57ed153f0b8e0d8c3b9675fc9787b9eac525a09abcda8fa9e7eb - languageName: node - linkType: hard - "@radix-ui/react-use-rect@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/react-use-rect@npm:1.1.1" @@ -3410,22 +3244,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-use-size@npm:1.0.1": - version: 1.0.1 - resolution: "@radix-ui/react-use-size@npm:1.0.1" - dependencies: - "@babel/runtime": "npm:^7.13.10" - "@radix-ui/react-use-layout-effect": "npm:1.0.1" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: b109a4b3781781c4dc641a1173f0a6fcb0b0f7b2d7cdba5848a46070c9fb4e518909a46c20a3c2efbc78737c64859c59ead837f2940e8c8394d1c503ef58773b - languageName: node - linkType: hard - "@radix-ui/react-use-size@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/react-use-size@npm:1.1.1" @@ -3460,15 +3278,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/rect@npm:1.0.1": - version: 1.0.1 - resolution: "@radix-ui/rect@npm:1.0.1" - dependencies: - "@babel/runtime": "npm:^7.13.10" - checksum: 4c5159661340acc31b11e1f2ebd87a1521d39bfa287544dd2cd75b399539a4b625d38a1501c90ceae21fcca18ed164b0c3735817ff140ae334098192c110e571 - languageName: node - linkType: hard - "@radix-ui/rect@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/rect@npm:1.1.1" @@ -4704,8 +4513,8 @@ __metadata: "@radix-ui/react-accordion": "npm:^1.1.2" "@radix-ui/react-checkbox": "npm:^1.0.3" "@radix-ui/react-dialog": "npm:^1.1.15" - "@radix-ui/react-dropdown-menu": "npm:^2.0.5" - "@radix-ui/react-popover": "npm:^1.0.6" + "@radix-ui/react-dropdown-menu": "npm:2.1.16" + "@radix-ui/react-popover": "npm:1.1.15" "@radix-ui/react-portal": "npm:^1.1.1" "@radix-ui/react-select": "npm:^2.2.6" "@radix-ui/react-slider": "npm:^1.1.1" @@ -11302,25 +11111,6 @@ __metadata: languageName: node linkType: hard -"react-remove-scroll@npm:2.5.5": - version: 2.5.5 - resolution: "react-remove-scroll@npm:2.5.5" - dependencies: - react-remove-scroll-bar: "npm:^2.3.3" - react-style-singleton: "npm:^2.2.1" - tslib: "npm:^2.1.0" - use-callback-ref: "npm:^1.3.0" - use-sidecar: "npm:^1.1.2" - peerDependencies: - "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 4952657e6a7b9d661d4ad4dfcef81b9c7fa493e35164abff99c35c0b27b3d172ef7ad70c09416dc44dd14ff2e6b38a5ec7da27e27e90a15cbad36b8fd2fd8054 - languageName: node - linkType: hard - "react-remove-scroll@npm:^2.6.3": version: 2.7.2 resolution: "react-remove-scroll@npm:2.7.2"