From e48319eafcd06627a2ae3b16581c444c1ae722a5 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 22 Sep 2026 13:54:40 -0700 Subject: [PATCH 01/32] Bring back the tracking interface on top of the server-side branch The review, correction and extend-mode interface for occurrence tracking, split out of the server-side pull request so each can be reviewed alone. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/package.json | 4 +- .../blueprint-collection.module.scss | 48 +- .../blueprint-collection.tsx | 99 ++- .../blueprint-collection/crop-size.test.ts | 16 + .../blueprint-collection/crop-size.ts | 17 + .../copy-link-button/copy-link-button.tsx | 41 + .../components/filtering/filter-control.tsx | 2 + .../filters/grouping-status-filter.tsx | 30 + .../taxon-details/taxon-details.tsx | 23 +- .../components/track/candidate-comparison.tsx | 148 ++++ .../track/candidate-session-route.test.ts | 13 + .../track/candidate-session-route.ts | 21 + ui/src/components/track/occurrence-picker.tsx | 604 +++++++++++++++ ui/src/components/track/track-edit-dialog.tsx | 136 ++++ .../track/use-merge-selection.test.ts | 76 ++ .../components/track/use-merge-selection.ts | 60 ++ ui/src/components/track/useTrackCandidates.ts | 72 ++ .../hooks/auth/useAuthorizedQuery.ts | 4 +- .../hooks/captures/useFetchCaptureDetails.ts | 40 + .../occurrences/track/useAddDetections.ts | 15 + .../occurrences/track/useMergeOccurrences.ts | 15 + .../occurrences/track/useRemoveDetection.ts | 15 + .../track/useSetGroupingVerified.ts | 27 + .../hooks/occurrences/track/useSplitTrack.ts | 15 + .../hooks/occurrences/track/useTrackAction.ts | 60 ++ .../hooks/occurrences/useCaptureMatches.ts | 43 ++ .../hooks/occurrences/useMergeCandidates.ts | 94 +++ .../hooks/occurrences/useOccurrenceDetails.ts | 26 +- .../hooks/occurrences/useOccurrencePath.ts | 46 ++ .../occurrences/useOccurrencesInCaptures.ts | 77 ++ .../data-services/models/capture-details.ts | 33 +- ui/src/data-services/models/capture-match.ts | 90 +++ ui/src/data-services/models/capture.ts | 32 +- .../models/merge-candidate.test.ts | 241 ++++++ .../data-services/models/merge-candidate.ts | 293 +++++++ .../models/occurrence-details.test.ts | 170 +++++ .../models/occurrence-details.ts | 282 ++++++- .../data-services/models/occurrence-path.ts | 39 + .../data-services/models/occurrence.test.ts | 66 ++ ui/src/data-services/models/occurrence.ts | 66 +- .../data-services/models/track-stats.test.ts | 56 ++ ui/src/data-services/models/track-stats.ts | 87 +++ .../nova-ui-kit/components/dialog/dialog.tsx | 4 +- .../identifications/identification-card.tsx | 26 +- .../grouping-confirmation.tsx | 38 + .../identification-card/grouping-summary.tsx | 148 ++++ .../human-identification.tsx | 26 +- .../machine-prediction.tsx | 2 +- .../occurrence-details.module.scss | 3 + .../occurrence-details/occurrence-details.tsx | 251 +++++- .../track/frame-action-dialogs.tsx | 150 ++++ .../track/frame-caption.tsx | 74 ++ .../occurrence-details/track/frame-menu.tsx | 70 ++ .../track/grouping-actions.tsx | 211 ++++++ .../pages/occurrence-details/track/types.ts | 15 + .../pages/occurrences/occurrence-actions.tsx | 28 +- .../pages/occurrences/occurrence-columns.tsx | 38 + .../occurrences/occurrence-details-dialog.tsx | 9 + .../pages/occurrences/occurrence-gallery.tsx | 6 +- .../pages/occurrences/occurrences-actions.tsx | 50 +- ui/src/pages/occurrences/occurrences.tsx | 5 +- ui/src/pages/project/summary/summary.tsx | 2 +- .../activity-plot/activity-plot.tsx | 20 +- .../occurrence-timeline-markers.tsx | 180 +++++ .../activity-plot/occurrence-timeline.test.ts | 103 +++ .../activity-plot/occurrence-timeline.ts | 120 +++ .../activity-plot/timeline-navigation.test.ts | 100 +++ .../activity-plot/timeline-navigation.ts | 86 +++ ui/src/pages/session-details/capture-info.tsx | 15 + .../session-details/capture-navigation.tsx | 99 +-- ui/src/pages/session-details/capture/bbox.ts | 48 ++ .../capture/capture-ghost-trail.tsx | 193 +++++ .../capture/capture-match-tooltip.tsx | 165 ++++ .../capture/capture-match.test.ts | 205 +++++ .../session-details/capture/capture-match.ts | 166 ++++ .../capture/capture-preload.test.ts | 57 ++ .../capture/capture-preload.ts | 69 ++ .../capture/capture.module.scss | 82 +- .../pages/session-details/capture/capture.tsx | 638 ++++++++++++++-- .../capture/extend-click.test.ts | 138 ++++ .../session-details/capture/extend-click.ts | 100 +++ .../session-details/capture/extend-track.tsx | 714 ++++++++++++++++++ .../capture/ghost-frame.test.ts | 39 + .../session-details/capture/ghost-frame.ts | 32 + .../capture/ghost-trail.test.ts | 130 ++++ .../session-details/capture/ghost-trail.ts | 144 ++++ .../capture/occurrence-toolbar.tsx | 301 ++++++++ .../capture/session-track-edits.tsx | 300 ++++++++ .../capture/track-navigation.test.ts | 134 ++++ .../capture/track-navigation.ts | 145 ++++ .../capture/useCapturePreload.ts | 130 ++++ .../hooks/useActiveDetection.test.ts | 32 + .../hooks/useActiveDetection.ts | 45 ++ .../hooks/useExtendOccurrenceId.ts | 29 + .../pages/session-details/session-details.tsx | 76 +- ui/src/pages/session-details/session-info.tsx | 20 +- .../timeline-slider/timeline-slider.tsx | 82 +- ui/src/pages/session-details/utils.test.ts | 86 +++ ui/src/pages/session-details/utils.tsx | 83 ++ .../pages/session-details/view-settings.tsx | 27 +- ui/src/utils/getAppRoute.ts | 1 + ui/src/utils/language.ts | 431 ++++++++++- .../parseServerError/parseServerError.test.ts | 18 + .../parseServerError/parseServerError.ts | 7 +- ui/src/utils/useFilters.ts | 7 + ui/yarn.lock | 384 +++------- 106 files changed, 9822 insertions(+), 657 deletions(-) create mode 100644 ui/src/components/blueprint-collection/crop-size.test.ts create mode 100644 ui/src/components/blueprint-collection/crop-size.ts create mode 100644 ui/src/components/copy-link-button/copy-link-button.tsx create mode 100644 ui/src/components/filtering/filters/grouping-status-filter.tsx create mode 100644 ui/src/components/track/candidate-comparison.tsx create mode 100644 ui/src/components/track/candidate-session-route.test.ts create mode 100644 ui/src/components/track/candidate-session-route.ts create mode 100644 ui/src/components/track/occurrence-picker.tsx create mode 100644 ui/src/components/track/track-edit-dialog.tsx create mode 100644 ui/src/components/track/use-merge-selection.test.ts create mode 100644 ui/src/components/track/use-merge-selection.ts create mode 100644 ui/src/components/track/useTrackCandidates.ts create mode 100644 ui/src/data-services/hooks/captures/useFetchCaptureDetails.ts create mode 100644 ui/src/data-services/hooks/occurrences/track/useAddDetections.ts create mode 100644 ui/src/data-services/hooks/occurrences/track/useMergeOccurrences.ts create mode 100644 ui/src/data-services/hooks/occurrences/track/useRemoveDetection.ts create mode 100644 ui/src/data-services/hooks/occurrences/track/useSetGroupingVerified.ts create mode 100644 ui/src/data-services/hooks/occurrences/track/useSplitTrack.ts create mode 100644 ui/src/data-services/hooks/occurrences/track/useTrackAction.ts create mode 100644 ui/src/data-services/hooks/occurrences/useCaptureMatches.ts create mode 100644 ui/src/data-services/hooks/occurrences/useMergeCandidates.ts create mode 100644 ui/src/data-services/hooks/occurrences/useOccurrencePath.ts create mode 100644 ui/src/data-services/hooks/occurrences/useOccurrencesInCaptures.ts create mode 100644 ui/src/data-services/models/capture-match.ts create mode 100644 ui/src/data-services/models/merge-candidate.test.ts create mode 100644 ui/src/data-services/models/merge-candidate.ts create mode 100644 ui/src/data-services/models/occurrence-details.test.ts create mode 100644 ui/src/data-services/models/occurrence-path.ts create mode 100644 ui/src/data-services/models/occurrence.test.ts create mode 100644 ui/src/data-services/models/track-stats.test.ts create mode 100644 ui/src/data-services/models/track-stats.ts create mode 100644 ui/src/pages/occurrence-details/identification-card/grouping-confirmation.tsx create mode 100644 ui/src/pages/occurrence-details/identification-card/grouping-summary.tsx create mode 100644 ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx create mode 100644 ui/src/pages/occurrence-details/track/frame-caption.tsx create mode 100644 ui/src/pages/occurrence-details/track/frame-menu.tsx create mode 100644 ui/src/pages/occurrence-details/track/grouping-actions.tsx create mode 100644 ui/src/pages/occurrence-details/track/types.ts create mode 100644 ui/src/pages/session-details/activity-plot/occurrence-timeline-markers.tsx create mode 100644 ui/src/pages/session-details/activity-plot/occurrence-timeline.test.ts create mode 100644 ui/src/pages/session-details/activity-plot/occurrence-timeline.ts create mode 100644 ui/src/pages/session-details/activity-plot/timeline-navigation.test.ts create mode 100644 ui/src/pages/session-details/activity-plot/timeline-navigation.ts create mode 100644 ui/src/pages/session-details/capture/bbox.ts create mode 100644 ui/src/pages/session-details/capture/capture-ghost-trail.tsx create mode 100644 ui/src/pages/session-details/capture/capture-match-tooltip.tsx create mode 100644 ui/src/pages/session-details/capture/capture-match.test.ts create mode 100644 ui/src/pages/session-details/capture/capture-match.ts create mode 100644 ui/src/pages/session-details/capture/capture-preload.test.ts create mode 100644 ui/src/pages/session-details/capture/capture-preload.ts create mode 100644 ui/src/pages/session-details/capture/extend-click.test.ts create mode 100644 ui/src/pages/session-details/capture/extend-click.ts create mode 100644 ui/src/pages/session-details/capture/extend-track.tsx create mode 100644 ui/src/pages/session-details/capture/ghost-frame.test.ts create mode 100644 ui/src/pages/session-details/capture/ghost-frame.ts create mode 100644 ui/src/pages/session-details/capture/ghost-trail.test.ts create mode 100644 ui/src/pages/session-details/capture/ghost-trail.ts create mode 100644 ui/src/pages/session-details/capture/occurrence-toolbar.tsx create mode 100644 ui/src/pages/session-details/capture/session-track-edits.tsx create mode 100644 ui/src/pages/session-details/capture/track-navigation.test.ts create mode 100644 ui/src/pages/session-details/capture/track-navigation.ts create mode 100644 ui/src/pages/session-details/capture/useCapturePreload.ts create mode 100644 ui/src/pages/session-details/hooks/useActiveDetection.test.ts create mode 100644 ui/src/pages/session-details/hooks/useActiveDetection.ts create mode 100644 ui/src/pages/session-details/hooks/useExtendOccurrenceId.ts create mode 100644 ui/src/pages/session-details/utils.test.ts 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..584f2eb77 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,59 @@ 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, }: { + actions?: ReactNode + caption?: ReactNode + onLinkClick?: () => void item: { id: string image: { src: string; width: number; height: number } @@ -44,42 +95,34 @@ 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..61f072286 --- /dev/null +++ b/ui/src/components/track/occurrence-picker.tsx @@ -0,0 +1,604 @@ +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, + | '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: earliest first, closest +// first, or most alike first. +const DESCENDING_FIRST: MergeCandidateSortColumn[] = ['similarity', 'match'] + +const COLUMN_LABELS: Record = { + 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 = translate(STRING.TRACK_PICK_OCCURRENCE_SCOPE), + emptyMessage = translate(STRING.TRACK_NO_OTHER_OCCURRENCES), + 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) + + 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 && + SORT_COLUMNS.map((column) => { + const label = COLUMN_LABELS[column] + const help = COLUMN_HELP[column] + const active = columnSort?.column === column + const DirectionIcon = !active + ? ArrowUpDownIcon + : columnSort.descending + ? ArrowDownIcon + : ArrowUpIcon + + return ( + + ) + })} + {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} + + + + {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..6c2d69ddf --- /dev/null +++ b/ui/src/components/track/track-edit-dialog.tsx @@ -0,0 +1,136 @@ +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 ( + + + +
+ + {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/components/track/useTrackCandidates.ts b/ui/src/components/track/useTrackCandidates.ts new file mode 100644 index 000000000..046bd0be5 --- /dev/null +++ b/ui/src/components/track/useTrackCandidates.ts @@ -0,0 +1,72 @@ +import { useCaptureDetails } from 'data-services/hooks/captures/useCaptureDetails' +import { useOccurrencesInCaptures } from 'data-services/hooks/occurrences/useOccurrencesInCaptures' +import { useMemo } from 'react' + +/** + * Occurrences worth merging with, or moving a frame to: the ones sharing a capture + * with the edit or with its neighbours. A track that tracking cut short continues + * in the capture straight after the last frame, and a session holds thousands. + */ +export const useTrackCandidates = ({ + captureIds, + excludeIds, + projectId, +}: { + /** Captures at the edges of the edit: the first and last frame in question. */ + captureIds: (string | undefined)[] + excludeIds: string[] + projectId: string +}) => { + const [firstCaptureId, lastCaptureId] = captureIds + + const first = useCaptureDetails({ + id: firstCaptureId ?? '', + projectId, + }) + const last = useCaptureDetails({ + id: lastCaptureId ?? '', + projectId, + }) + + const neighbourhood = useMemo( + () => + [ + first.capture?.prevCaptureId, + firstCaptureId, + lastCaptureId, + last.capture?.nextCaptureId, + ].filter((id): id is string => !!id), + [ + first.capture?.prevCaptureId, + firstCaptureId, + lastCaptureId, + last.capture?.nextCaptureId, + ] + ) + + const { occurrences, isLoading } = useOccurrencesInCaptures({ + captureIds: neighbourhood, + projectId, + }) + + const candidates = useMemo( + () => + occurrences + .filter((occurrence) => !excludeIds.includes(occurrence.id)) + .sort( + (o1, o2) => + new Date(o1.firstAppearanceTimestamp).getTime() - + new Date(o2.firstAppearanceTimestamp).getTime() + ), + [occurrences, excludeIds.join()] + ) + + return { + candidates, + // A disabled query still reports itself as loading, so guard on the ids. + isLoading: + (!!firstCaptureId && first.isLoading) || + (!!lastCaptureId && last.isLoading) || + isLoading, + } +} diff --git a/ui/src/data-services/hooks/auth/useAuthorizedQuery.ts b/ui/src/data-services/hooks/auth/useAuthorizedQuery.ts index e080788c4..8eedb0018 100644 --- a/ui/src/data-services/hooks/auth/useAuthorizedQuery.ts +++ b/ui/src/data-services/hooks/auth/useAuthorizedQuery.ts @@ -21,7 +21,7 @@ export const useAuthorizedQuery = ({ url: string }) => { const { user } = useUser() - const { data, isLoading, isFetching, isSuccess, error } = useQuery({ + const { data, isLoading, isFetching, isSuccess, error, refetch } = useQuery({ enabled, onError, queryKey, @@ -36,5 +36,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/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.ts b/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts new file mode 100644 index 000000000..f61b54165 --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts @@ -0,0 +1,94 @@ +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 } from 'utils/language' +import { useAuthorizedQuery } from '../auth/useAuthorizedQuery' + +const DEFAULT_WINDOW_MINUTES = 5 + +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 }, +] + +/** + * Occurrences this one could be merged with, ranked by the tracking method. + * + * 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, + enabled, + minutes = DEFAULT_WINDOW_MINUTES, + occurrenceId, + projectId, +}: { + /** Captures either side of the occurrence to search. Given, it replaces the `minutes` window. */ + captures?: number + 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 = new URLSearchParams({ project_id: projectId }) + + if (captures !== undefined) { + params.set('captures', `${captures}`) + } else { + params.set('minutes', `${minutes}`) + } + + const { data, isLoading, error } = useAuthorizedQuery({ + enabled: !!occurrenceId && !!enabled, + queryKey: [ + API_ROUTES.OCCURRENCES, + occurrenceId, + 'merge-candidates', + { captures, minutes, projectId }, + ], + 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/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/hooks/occurrences/useOccurrencesInCaptures.ts b/ui/src/data-services/hooks/occurrences/useOccurrencesInCaptures.ts new file mode 100644 index 000000000..9b1539892 --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/useOccurrencesInCaptures.ts @@ -0,0 +1,77 @@ +import { useQueries } from '@tanstack/react-query' +import axios from 'axios' +import { API_ROUTES } from 'data-services/constants' +import { Occurrence, ServerOccurrence } from 'data-services/models/occurrence' +import { getAuthHeader, getFetchUrl } from 'data-services/utils' +import { useMemo } from 'react' +import { useUser } from 'utils/user/userContext' + +const OCCURRENCES_PER_CAPTURE = 25 + +/** + * Occurrences that have a detection in any of the given captures, as one list. + * + * One request per capture, so callers pass a handful of captures rather than a + * session's worth. + */ +export const useOccurrencesInCaptures = ({ + captureIds, + projectId, +}: { + captureIds: string[] + projectId: string +}): { + occurrences: Occurrence[] + isLoading: boolean +} => { + const { user } = useUser() + + // An id that is not a non-empty string would be dropped from the query string, + // turning one capture's occurrences into every occurrence in the project. + const requested = captureIds.filter((id) => typeof id === 'string' && !!id) + + const results = useQueries({ + queries: requested.map((captureId) => { + const params = { + projectId, + pagination: { page: 0, perPage: OCCURRENCES_PER_CAPTURE }, + filters: [ + { field: 'detections__source_image', value: captureId }, + // An occurrence a track edit created can score under the project's default + // threshold, which would keep it out of this list and make that edit + // impossible to undo from here. + { field: 'apply_defaults', value: 'false' }, + ], + } + + return { + queryKey: [API_ROUTES.OCCURRENCES, params], + queryFn: () => + axios + .get<{ results: ServerOccurrence[] }>( + getFetchUrl({ collection: API_ROUTES.OCCURRENCES, params }), + { headers: getAuthHeader(user) } + ) + .then((res) => res.data), + } + }), + }) + + const occurrences = useMemo(() => { + const byId = new Map() + + results.forEach((result) => { + result.data?.results.forEach((record) => { + const occurrence = new Occurrence(record) + byId.set(occurrence.id, occurrence) + }) + }) + + return [...byId.values()] + }, [results.map((result) => result.dataUpdatedAt).join()]) + + return { + occurrences, + isLoading: results.some((result) => result.isLoading), + } +} 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/merge-candidate.test.ts b/ui/src/data-services/models/merge-candidate.test.ts new file mode 100644 index 000000000..b0e76ff2f --- /dev/null +++ b/ui/src/data-services/models/merge-candidate.test.ts @@ -0,0 +1,241 @@ +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('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..2d7434697 --- /dev/null +++ b/ui/src/data-services/models/merge-candidate.ts @@ -0,0 +1,293 @@ +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 + 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}`, + 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 = + | '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 | null => + column === 'when' + ? getWhenOffsetSeconds(candidate) + : column === 'distance' + ? candidate.distance + : column === 'similarity' + ? candidate.similarity + : column === 'cost' + ? candidate.cost + : candidate.likelihood + +const compare = ( + valueA: number | null, + valueB: number | null, + descending: boolean +): number => { + if (valueA === null || valueB === null) { + return valueA === valueB ? 0 : valueA === null ? 1 : -1 + } + + return (valueA - valueB) * (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..a64bc2efc --- /dev/null +++ b/ui/src/data-services/models/occurrence-details.test.ts @@ -0,0 +1,170 @@ +import { + FrameLabel, + frameHasVector, + getFrameClassification, + getFrameNames, + OccurrenceDetails, + ServerFrameClassification, +} from './occurrence-details' +import { Taxon } from './taxa' + +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('frame names', () => { + test('counts frames per label, most frames first, with the highest score', () => { + const noctua = new Taxon(NOCTUA) + const xestia = new Taxon(XESTIA) + const labels: FrameLabel[] = [ + { score: 0.2, taxon: noctua }, + { score: 0.9, taxon: xestia }, + {}, + { score: 0.6, taxon: noctua }, + {}, + { score: 0.4, taxon: noctua }, + ] + + expect( + getFrameNames(labels).map(({ frames, scoreMax, taxon }) => [ + taxon?.name, + frames, + scoreMax, + ]) + ).toEqual([ + ['Noctua pronuba', 3, 0.6], + [undefined, 2, undefined], + ['Xestia c-nigrum', 1, 0.9], + ]) + }) +}) + +describe('occurrence details', () => { + const detection = (id: number, classifications: unknown[]) => ({ + bbox: [0, 0, 10, 10], + capture: { height: 100, id: id + 100, width: 100 }, + classifications, + 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: 2, + determination: { id: 3, name: 'Noctua pronuba' }, + determination_details: { taxon: NOCTUA }, + first_appearance_timestamp: '2026-09-09T02:00:00', + id: 12, + identifications: [], + predictions: [], + track_stats: null, + user_permissions: [], + }) + + test('each frame carries its own label, and the track lists every label', () => { + expect(occurrence.getDetectionInfo('1').frameLabel).toMatchObject({ + score: 0.12, + taxon: { name: 'Noctua pronuba' }, + }) + expect(occurrence.getDetectionInfo('2').label).toBe('No classification') + expect(occurrence.frameNames.map(({ taxon }) => taxon?.name)).toEqual([ + 'Noctua pronuba', + undefined, + ]) + }) + + test('each frame reports whether the payload stored a vector for it', () => { + expect(occurrence.getDetectionInfo('1').hasVector).toBe(true) + expect(occurrence.getDetectionInfo('2').hasVector).toBe(false) + }) +}) diff --git a/ui/src/data-services/models/occurrence-details.ts b/ui/src/data-services/models/occurrence-details.ts index 39bcbfb39..7d8430ca8 100644 --- a/ui/src/data-services/models/occurrence-details.ts +++ b/ui/src/data-services/models/occurrence-details.ts @@ -3,15 +3,45 @@ 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 ServerGroupingSummary { + algorithm: { id: number; key: string; name: string } | null + derived: boolean + distinct_taxa: number + duration_seconds: number | null + frames: number + frames_with_vectors?: number + id_agreement: number | null + linked_detections: number + motion: number + score_max: number + score_mean: number + score_min: number + 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 + scoreMean: number + scoreMin: number +} + 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 +61,163 @@ 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 + id: string + timestamp: Date + 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 +} + +export const getFrameNames = (labels: FrameLabel[]): FrameName[] => { + const names = new Map() + + labels.forEach(({ score, taxon }) => { + const key = taxon?.id ?? '' + const name = names.get(key) ?? { frames: 0, taxon } + name.frames += 1 + if ( + score !== undefined && + (name.scoreMax === undefined || score > name.scoreMax) + ) { + name.scoreMax = score + } + names.set(key, name) + }) + + return Array.from(names.values()).sort( + (n1, n2) => + n2.frames - n1.frames || (n2.scoreMax ?? -1) - (n1.scoreMax ?? -1) + ) +} + +/** 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 class OccurrenceDetails extends Occurrence { - private readonly _detections: string[] = [] + private readonly _frameLabels: Map + private readonly _frames: TrackFrame[] = [] private readonly _humanIdentifications: HumanIdentification[] private readonly _machinePredictions: MachinePrediction[] public constructor(occurrence: ServerOccurrenceDetails) { super(occurrence) - this._detections = this._occurrence.detections.map((d: any) => `${d.id}`) + // Sorted here rather than taken in payload order: the track editing actions + // describe a split as "this frame and everything later in time", so the order + // the frames are listed in has to be a property of this model, not of whatever + // ordering the endpoint happens to prefetch. See #1272. + this._frames = this._occurrence.detections + .map((d: any) => ({ + bbox: d.bbox ?? [], + captureHeight: d.capture?.height ?? undefined, + captureId: d.capture?.id !== undefined ? `${d.capture.id}` : undefined, + captureWidth: d.capture?.width ?? undefined, + id: `${d.id}`, + timestamp: new Date(d.timestamp), + timeLabel: getFormatedTimeString({ + date: new Date(d.timestamp), + options: { second: true }, + }), + })) + .sort( + (f1: TrackFrame, f2: TrackFrame) => + f2.timestamp.getTime() - f1.timestamp.getTime() + ) + + this._frameLabels = new Map( + this._occurrence.detections.map((d: any): [string, FrameLabel] => { + const classification = + getFrameClassification(d.classifications) + + return [ + `${d.id}`, + classification?.taxon + ? { + score: classification.score ?? undefined, + taxon: new Taxon(classification.taxon), + } + : {}, + ] + }) + ) const sortByDate = (i1: any, i2: any) => { const date1 = new Date(i1.created_at) @@ -55,9 +229,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 +259,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, @@ -109,7 +284,73 @@ export class OccurrenceDetails extends Occurrence { } get detections(): string[] { - return this._detections + return this._frames.map((frame) => frame.id) + } + + /** Detections of this occurrence, newest first — the order the strip renders them in. */ + get frames(): TrackFrame[] { + return this._frames + } + + /** Distinct labels across the frames, most frames first. */ + get frameNames(): FrameName[] { + return getFrameNames(Array.from(this._frameLabels.values())) + } + + get groupingVerified(): boolean { + return !!this._occurrence.grouping_verified + } + + get groupingVerifiedAt(): Date | undefined { + return this._occurrence.grouping_verified_at + ? new Date(this._occurrence.grouping_verified_at) + : undefined + } + + get groupingVerifiedBy(): + | { id: string; image?: string; name: string } + | undefined { + const user = this._occurrence.grouping_verified_by + + if (!user) { + return undefined + } + + return { + 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[] { @@ -129,14 +370,12 @@ export class OccurrenceDetails extends Occurrence { (d: any) => `${d.id}` === id ) - const classification = detection?.classifications?.[0] - let label = 'No classification' - - if (classification) { - label = `${classification.taxon.name} (${classification.score.toFixed( - 2 - )})` - } + const frameLabel = this._frameLabels.get(id) ?? {} + const label = frameLabel.taxon + ? `${frameLabel.taxon.name} (${ + frameLabel.score?.toFixed(2) ?? translate(STRING.VALUE_NOT_AVAILABLE) + })` + : translate(STRING.TRACK_FRAME_NO_CLASSIFICATION) return { id, @@ -144,12 +383,15 @@ export class OccurrenceDetails extends Occurrence { detection.capture?.id !== undefined ? `${detection.capture.id}` : undefined, + // The bounding box gives the crop's proportions when the crop itself is missing. image: { src: detection.url, - width: detection.width, - height: detection.height, + width: detection.width ?? bboxSize(detection.bbox)[0], + height: detection.height ?? bboxSize(detection.bbox)[1], }, - label: label, + frameLabel, + hasVector: frameHasVector(detection.classifications), + label, timeLabel: getFormatedTimeString({ date: new Date(detection.timestamp), options: { second: true }, 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..009273674 --- /dev/null +++ b/ui/src/data-services/models/occurrence-path.ts @@ -0,0 +1,39 @@ +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, +}) 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/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/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..2290a9a0a --- /dev/null +++ b/ui/src/pages/occurrence-details/identification-card/grouping-summary.tsx @@ -0,0 +1,148 @@ +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 scores = [summary.scoreMin, summary.scoreMax, summary.scoreMean] + 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: scores.every((score) => typeof score === 'number') + ? translate(STRING.TRACK_STAT_SCORE_RANGE, { + max: summary.scoreMax.toFixed(2), + mean: summary.scoreMean.toFixed(2), + min: summary.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 + to?: string +}) => + to ? ( + + {label} + + ) : null + export const OccurrenceDetails = ({ occurrence, + onNavigate, selectedTab, setSelectedTab, }: { occurrence: Occurrence + /** Called when a frame's link is followed, so a dialog around these details can close. */ + onNavigate?: () => void selectedTab?: string setSelectedTab: (selectedTab?: string) => void }) => { @@ -52,12 +90,27 @@ export const OccurrenceDetails = ({ user: { loggedIn }, } = useUser() const { userInfo } = useUserInfo() - const { pathname } = useLocation() + const { pathname, search } = useLocation() const { projectId } = useParams() const navigate = useNavigate() const location = useLocation() const [suggestIdOpen, setSuggestIdOpen] = useState(false) + const [pendingFrameAction, setPendingFrameAction] = + useState() const canUpdate = occurrence.userPermissions.includes(UserPermission.Update) + // Restructuring a grouping is gated on the occurrence delete right, confirming one + // on either right — the same split the API makes. See #1272. + const canRestructure = occurrence.userPermissions.includes( + UserPermission.Delete + ) + const canVerifyGrouping = canUpdate || canRestructure + + const sessionRoute = occurrence.sessionId + ? APP_ROUTES.SESSION_DETAILS({ + projectId: projectId as string, + sessionId: occurrence.sessionId, + }) + : undefined const blueprintItems = useMemo( () => @@ -65,31 +118,50 @@ export const OccurrenceDetails = ({ ? occurrence.detections .map((id) => occurrence.getDetectionInfo(id)) .filter( - (item): item is BlueprintItem & { captureId: string } => !!item + ( + item + ): item is BlueprintItem & { + captureId: string + frameLabel: FrameLabel + hasVector: boolean | undefined + } => !!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, - }, - }), - })) + .map((item) => { + if (!sessionRoute) { + return { ...item, to: 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', item.captureId) + + return { ...item, to: `${sessionRoute}?${params}` } + } + + return { + ...item, + to: getAppRoute({ + to: sessionRoute, + filters: { + occurrence: occurrence.id, + capture: item.captureId, + }, + }), + } + }) : [], - [occurrence] + [occurrence, pathname, search, sessionRoute] ) + // The strip runs newest first, so the track's earliest frame is its last row. + const newestFrame = blueprintItems[0] + const earliestFrame = blueprintItems[blueprintItems.length - 1] + const fields = [ { label: translate(STRING.FIELD_LABEL_DEPLOYMENT), @@ -169,16 +241,18 @@ export const OccurrenceDetails = ({ ) : null} {canUpdate && ( <> - + {occurrence.determinationTaxon ? ( + + ) : null} )} @@ -242,6 +320,14 @@ export const OccurrenceDetails = ({ /> ))} + +
+ + +
+
@@ -256,6 +342,17 @@ export const OccurrenceDetails = ({ )} + {occurrence.groupingVerifiedAt ? ( + + ) : null} + + {occurrence.groupingSummary ? ( + + ) : null} + {occurrence.humanIdentifications.map((i) => (
- 0}> - {blueprintItems.map((item) => ( - + {(canRestructure || canVerifyGrouping) && ( + + )} + 0} + > + {blueprintItems.length ? ( +
+ + {blueprintItems.length === 1 + ? translate(STRING.TRACK_FRAMES_ONE) + : translate(STRING.TRACK_FRAMES_COUNT, { + count: blueprintItems.length, + })} + +
+ {blueprintItems.length === 1 ? ( + + ) : ( + <> + + + + )} +
+
+ ) : null} + {blueprintItems.map((item, index) => ( + + setPendingFrameAction({ + action, + captureId: item.captureId, + detectionId: item.id, + movedBySplit: index + 1, + timeLabel: item.timeLabel, + total: blueprintItems.length, + }) + } + /> + ) : undefined + } + caption={ + + } + key={item.id} + item={item} + onLinkClick={onNavigate} + /> ))}
+ 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..8564e2363 --- /dev/null +++ b/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx @@ -0,0 +1,150 @@ +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 { 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 { OccurrencePicker } from 'components/track/occurrence-picker' +import { TrackEditDialog } from 'components/track/track-edit-dialog' +import { PendingFrameAction } from './types' +import { useTrackCandidates } from 'components/track/useTrackCandidates' + +export const FrameActionDialogs = ({ + occurrence, + onClose, + pending, +}: { + occurrence: OccurrenceDetails + onClose: () => void + pending?: PendingFrameAction +}) => { + const { projectId } = useParams() + const [targetId, setTargetId] = useState() + + const split = useSplitTrack(occurrence.id) + const remove = useRemoveDetection(occurrence.id) + const move = useAddDetections(targetId ?? occurrence.id) + const { candidates, isLoading: candidatesLoading } = useTrackCandidates({ + captureIds: + pending?.action === 'move' ? [pending.captureId, pending.captureId] : [], + excludeIds: [occurrence.id], + projectId: projectId as string, + }) + + // 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) + 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())} + 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 : ( + + )} + + + ) +} 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/grouping-actions.tsx b/ui/src/pages/occurrence-details/track/grouping-actions.tsx new file mode 100644 index 000000000..f47712eda --- /dev/null +++ b/ui/src/pages/occurrence-details/track/grouping-actions.tsx @@ -0,0 +1,211 @@ +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, + 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, PlusIcon } 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, + 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.frames[0]?.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/types.ts b/ui/src/pages/occurrence-details/track/types.ts new file mode 100644 index 000000000..5598748da --- /dev/null +++ b/ui/src/pages/occurrence-details/track/types.ts @@ -0,0 +1,15 @@ +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 + captureId?: string + 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/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..60b2b0480 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, @@ -35,6 +36,9 @@ export const OccurrenceDetailsDialog = ({ const { selectedView, setSelectedView } = useSelectedView(defaultTab, 'tab') const { setDetailBreadcrumb } = useContext(BreadcrumbContext) const { occurrence, isLoading, error } = useOccurrenceDetails(id) + 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 @@ -68,6 +72,11 @@ export const OccurrenceDetailsDialog = ({ error={error} isLoading={isLoading} > +
+ + {detailsLabel} + +
{occurrence ? ( - {item.determinationTaxon.name} + {item.determinationLabel}
{ 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..704262a2f 100644 --- a/ui/src/pages/occurrences/occurrences.tsx +++ b/ui/src/pages/occurrences/occurrences.tsx @@ -40,13 +40,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, } @@ -103,6 +105,7 @@ export const Occurrences = () => { )} {user.loggedIn && } + { 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..7d85f3734 100644 --- a/ui/src/pages/session-details/capture-info.tsx +++ b/ui/src/pages/session-details/capture-info.tsx @@ -7,6 +7,20 @@ import { STRING, translate } from 'utils/language' export const CaptureInfo = ({ capture }: { capture: CaptureDetails }) => { const { projectId } = useParams() + const { detectionsValid, detectionsWithFeatures } = capture + // Only worth a row when a box is missing a vector; tracking skips those boxes. + const vectorField = + 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 +55,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..a009ed71c 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,119 @@ 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 transformRef: React.RefObject width: number | null } export const Capture = ({ + captureDate, + captureId, defaultFilters, detections, + extend, height, + onTogglePathCrops, showDetections, + showPathCrops, sources, 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 = + 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 +198,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 +213,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 +227,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 +324,57 @@ 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} />
+ {shownPathId && !extend.occurrenceId ? ( + setPathOccurrenceId(undefined)} + onShowFrame={ + nearestFrame + ? () => setActiveCaptureId(nearestFrame.captureId) + : undefined + } + /> + ) : null} + {extend.occurrenceId && extend.choice ? ( + + ) : null} {zoomPercent !== null ? ( {zoomPercent}% @@ -280,23 +429,201 @@ 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, }: { 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 }) => { - 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 +634,215 @@ 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 } + const isClickable = !!detection.occurrenceId || isExtending + const match = matches?.[detection.id] + const click = isExtending ? extend.previewClick(detection) : undefined + const box = ( + - -
- - - + + {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 +851,10 @@ const CaptureDetections = ({ onClose={() => setActiveOccurrence(undefined)} /> ) : null} + setTrackEdit(undefined)} + />
) @@ -392,6 +871,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-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..8dbccee53 --- /dev/null +++ b/ui/src/pages/session-details/capture/extend-track.tsx @@ -0,0 +1,714 @@ +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 { CaptureDetection } from 'data-services/models/capture' +import { TrackFrame } from 'data-services/models/occurrence-details' +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeftIcon, + ChevronsRightIcon, + InfoIcon, + Loader2Icon, + RouteIcon, +} from 'lucide-react' +import { BasicTooltip, Button, LoadingSpinner } from 'nova-ui-kit' +import { ReactNode, useEffect, useRef, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' +import { STRING, translate } from 'utils/language' +import { parseServerError } from 'utils/parseServerError/parseServerError' +import { UserPermission } from 'utils/user/types' +import { useExtendOccurrenceId } from '../hooks/useExtendOccurrenceId' +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 } + +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, + nextCaptureId, + onSelectCapture, +}: { + captureId?: string + /** Where a successful edit moves on to: the next capture with detections, if any. */ + nextCaptureId?: string + onSelectCapture: (captureId: string) => void +}): ExtendTrackState => { + const { projectId } = useParams() + const { extendOccurrenceId, setExtendOccurrenceId } = useExtendOccurrenceId() + const { occurrence: track } = useOccurrenceDetails( + extendOccurrenceId ?? '', + EXTEND_FETCH_OPTIONS + ) + const [choice, setChoice] = useState() + // The merge deletes the clicked occurrence, so its frames are read while it still exists. + const { occurrence: clickedTrack } = useOccurrenceDetails( + choice?.occurrenceId ?? '', + EXTEND_FETCH_OPTIONS + ) + 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 && track && !isLoading + ? getExtendClick({ + captureId, + detection, + frames: track.frames, + 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: clickedTrack?.frames ?? [], + clickedDetectionId: choice.detectionId, + trackFrames: track?.frames ?? [], + }) + + 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 navigation = getTrackNavigation({ + captureDate, + captureId, + frames: occurrence?.frames ?? [], + }) + const { first, last, position, total } = navigation + const canVerify = + !!occurrence?.userPermissions.includes(UserPermission.Update) || + !!occurrence?.userPermissions.includes(UserPermission.Delete) + 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 { + occurrence: clicked, + isLoading: clickedLoading, + error: clickedError, + } = useOccurrenceDetails(choice.occurrenceId, EXTEND_FETCH_OPTIONS) + const { + occurrence: extended, + isLoading: extendedLoading, + error: extendedError, + } = useOccurrenceDetails(occurrenceId, EXTEND_FETCH_OPTIONS) + + // 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?.frames.map((frame) => frame.captureId) + ) + const isCovered = (captureId?: string) => + !!captureId && coveredCaptureIds.has(captureId) + const clickedFrame = clicked?.frames.find( + (frame) => frame.id === choice.detectionId + ) + const mergeBlocked = !!clicked?.frames.some((frame) => + isCovered(frame.captureId) + ) + const moveBlocked = isCovered(clickedFrame?.captureId) + + const clickedCrop = clickedFrame + ? clicked?.getDetectionInfo(choice.detectionId) + : undefined + const latestFrameId = extended?.frames[0]?.id + const latestCrop = latestFrameId + ? extended?.getDetectionInfo(latestFrameId) + : undefined + + 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..cceb7df0d --- /dev/null +++ b/ui/src/pages/session-details/capture/occurrence-toolbar.tsx @@ -0,0 +1,301 @@ +import { CopyLinkButton } from 'components/copy-link-button/copy-link-button' +import { DeterminationScore } from 'components/determination-score' +import { PathFrame } from 'data-services/models/occurrence-path' +import { Loader2Icon, RouteIcon, XIcon } from 'lucide-react' +import { Button } from 'nova-ui-kit' +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 + + // 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 = [ + pathShown ? ( + + ) : null, + , + !isExtended ? ( + + ) : null, + ].filter(Boolean) + + 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 ? ( +
{pathActions}
+ ) : null} + +
{editActions}
+ + {pathShown ? ( + + ) : 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..64541a6e1 --- /dev/null +++ b/ui/src/pages/session-details/capture/session-track-edits.tsx @@ -0,0 +1,300 @@ +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, + 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, + 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..9c693b7d4 --- /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 OccurrenceDetails.frames holds 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/session-details.tsx b/ui/src/pages/session-details/session-details.tsx index 00ddf0594..98ba1cd14 100644 --- a/ui/src/pages/session-details/session-details.tsx +++ b/ui/src/pages/session-details/session-details.tsx @@ -21,9 +21,11 @@ import { BreadcrumbContext } from 'utils/breadcrumbContext' import { STRING, translate } from 'utils/language' 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' @@ -31,7 +33,8 @@ 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 +89,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 +108,17 @@ const Content = ({ session }: { session: SessionDetails }) => { projectId: projectId as string, }) const { timeline = [] } = useSessionTimeline(session.id) + const extend = useExtendTrack({ + captureId: activeCaptureId, + nextCaptureId: activeCapture + ? getNextCaptureWithDetectionsId({ capture: activeCapture, timeline }) + : undefined, + onSelectCapture: setActiveCaptureId, + }) + // 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 @@ -163,12 +179,31 @@ const Content = ({ session }: { session: SessionDetails }) => {
+ {extend.occurrenceId ? ( + + ) : null}
+ setSettings((current) => ({ + ...current, + showPathCrops: !current.showPathCrops, + })) + } showDetections={settings.showDetections} + showPathCrops={settings.showPathCrops} sources={ activeCapture ? { @@ -230,19 +265,30 @@ const Content = ({ session }: { session: SessionDetails }) => {
-
- - -
+ {showSessionTimeline(session) ? ( +
+ + {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..28d07df27 100644 --- a/ui/src/pages/session-details/view-settings.tsx +++ b/ui/src/pages/session-details/view-settings.tsx @@ -5,18 +5,18 @@ 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, }: { - onSettingsChange: (settings: { - defaultFilters: boolean - showDetections: boolean - }) => void - settings: { - defaultFilters: boolean - showDetections: boolean - } + onSettingsChange: (settings: ViewSettingsValues) => void + settings: ViewSettingsValues }) => { const { projectId } = useParams() const { project } = useProjectDetails(projectId as string, true) @@ -50,6 +50,17 @@ export const ViewSettings = ({ }) } /> + + onSettingsChange({ + ...settings, + showPathCrops: !settings.showPathCrops, + }) + } + />
{ }) }) + 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/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/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" From acb0d4eb83b649396f2fa46a2d7a01888611c026 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 22 Sep 2026 14:01:07 -0700 Subject: [PATCH 02/32] feat(ui): show the tracking interface only on projects that switch it on The track panel, film strip captions and grouping actions on occurrence details, the path, extend mode and track edits in the session capture view, the grouping filter and the track statistic columns all read the project's tracking feature flag through one hook, useProjectFeature. With the flag off, an extend link opens the session normally. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../occurrence-details/occurrence-details.tsx | 28 ++-- ui/src/pages/occurrences/occurrences.tsx | 16 +- ui/src/pages/session-details/capture-info.tsx | 3 + .../pages/session-details/capture/capture.tsx | 11 +- .../session-details/capture/extend-track.tsx | 7 +- .../capture/occurrence-toolbar.tsx | 137 ++++++++++-------- .../pages/session-details/session-details.tsx | 7 +- .../pages/session-details/view-settings.tsx | 26 ++-- .../project-features/project-features.test.ts | 36 +++++ .../project-features/project-features.ts | 23 +++ .../project-features/useProjectFeature.ts | 10 ++ 11 files changed, 212 insertions(+), 92 deletions(-) create mode 100644 ui/src/utils/project-features/project-features.test.ts create mode 100644 ui/src/utils/project-features/project-features.ts create mode 100644 ui/src/utils/project-features/useProjectFeature.ts diff --git a/ui/src/pages/occurrence-details/occurrence-details.tsx b/ui/src/pages/occurrence-details/occurrence-details.tsx index da8897cee..2a3580ac4 100644 --- a/ui/src/pages/occurrence-details/occurrence-details.tsx +++ b/ui/src/pages/occurrence-details/occurrence-details.tsx @@ -28,6 +28,7 @@ import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' import { UserPermission } from 'utils/user/types' +import { useProjectFeature } from 'utils/project-features/useProjectFeature' import { useUser } from 'utils/user/userContext' import { useUserInfo } from 'utils/user/userInfoContext' import { Agree } from './agree/agree' @@ -104,6 +105,7 @@ export const OccurrenceDetails = ({ UserPermission.Delete ) const canVerifyGrouping = canUpdate || canRestructure + const trackingEnabled = useProjectFeature('tracking') const sessionRoute = occurrence.sessionId ? APP_ROUTES.SESSION_DETAILS({ @@ -342,11 +344,11 @@ export const OccurrenceDetails = ({ )} - {occurrence.groupingVerifiedAt ? ( + {trackingEnabled && occurrence.groupingVerifiedAt ? ( ) : null} - {occurrence.groupingSummary ? ( + {trackingEnabled && occurrence.groupingSummary ? (
- {(canRestructure || canVerifyGrouping) && ( + {trackingEnabled && (canRestructure || canVerifyGrouping) && ( )} 0} > - {blueprintItems.length ? ( + {trackingEnabled && blueprintItems.length ? (
{blueprintItems.length === 1 @@ -436,7 +438,7 @@ export const OccurrenceDetails = ({ {blueprintItems.map((item, index) => ( + trackingEnabled ? ( + + ) : undefined } key={item.id} item={item} diff --git a/ui/src/pages/occurrences/occurrences.tsx b/ui/src/pages/occurrences/occurrences.tsx index 704262a2f..02406fd02 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' @@ -71,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 }) @@ -105,7 +111,7 @@ export const Occurrences = () => { )} {user.loggedIn && } - + {trackingEnabled && } { 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 diff --git a/ui/src/pages/session-details/capture/capture.tsx b/ui/src/pages/session-details/capture/capture.tsx index a009ed71c..1dea5f6bc 100644 --- a/ui/src/pages/session-details/capture/capture.tsx +++ b/ui/src/pages/session-details/capture/capture.tsx @@ -64,6 +64,8 @@ interface CaptureProps { 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 } @@ -79,6 +81,7 @@ export const Capture = ({ showDetections, showPathCrops, sources, + trackingEnabled, transformRef, width, }: CaptureProps) => { @@ -106,7 +109,9 @@ export const Capture = ({ // occurrence stays selected, so stepping between captures redraws the same path. const [pathOccurrenceId, setPathOccurrenceId] = useState() const shownPathId = - pathOccurrenceId && activeOccurrences.includes(pathOccurrenceId) + trackingEnabled && + pathOccurrenceId && + activeOccurrences.includes(pathOccurrenceId) ? pathOccurrenceId : undefined const { @@ -351,6 +356,7 @@ export const Capture = ({ pathOccurrenceId={shownPathId} shownFrames={trail?.shownCount} showDetections={showDetections} + trackingEnabled={trackingEnabled} />
@@ -495,6 +501,7 @@ const CaptureDetections = ({ showDetections, showPathCrops, shownFrames, + trackingEnabled, }: { boxStyles: { [key: number]: BoxStyle } defaultFilters: boolean @@ -513,6 +520,7 @@ const CaptureDetections = ({ /** The path's boxes are filled with each frame's crop rather than left empty. */ showPathCrops?: boolean shownFrames?: number + trackingEnabled?: boolean }) => { // 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. @@ -777,6 +785,7 @@ const CaptureDetections = ({ > void }): ExtendTrackState => { const { projectId } = useParams() - const { extendOccurrenceId, setExtendOccurrenceId } = useExtendOccurrenceId() + const { extendOccurrenceId: requestedOccurrenceId, setExtendOccurrenceId } = + useExtendOccurrenceId() + const extendOccurrenceId = enabled ? requestedOccurrenceId : undefined const { occurrence: track } = useOccurrenceDetails( extendOccurrenceId ?? '', EXTEND_FETCH_OPTIONS diff --git a/ui/src/pages/session-details/capture/occurrence-toolbar.tsx b/ui/src/pages/session-details/capture/occurrence-toolbar.tsx index cceb7df0d..1313a43ae 100644 --- a/ui/src/pages/session-details/capture/occurrence-toolbar.tsx +++ b/ui/src/pages/session-details/capture/occurrence-toolbar.tsx @@ -69,6 +69,7 @@ export const OccurrenceToolbar = ({ pathError, showPathCrops, shownFrames, + trackingEnabled, }: { /** The detection this panel is anchored to, which is this track's frame on the capture. */ detectionId?: string @@ -94,6 +95,8 @@ export const OccurrenceToolbar = ({ showPathCrops?: boolean /** Ghost boxes actually drawn, which the trail caps below the path's length. */ shownFrames?: number + /** Off, the panel names the occurrence and its score, with no track details or edits. */ + trackingEnabled?: boolean }) => { const singleFrame = occurrence.frameCount <= 1 const pathShown = !!path?.length @@ -223,79 +226,91 @@ export const OccurrenceToolbar = ({ scoreLabel={occurrence.scoreLabel} verified={occurrence.score === 1} /> - {subtitle()} + {trackingEnabled ? ( + + {subtitle()} + + ) : null}
-
- {framePosition ? {framePosition} : null} + {trackingEnabled ? ( + <> +
+ {framePosition ? {framePosition} : null} - {shownFrames !== undefined && path && shownFrames < path.length ? ( - - {translate(STRING.TRACK_SHOWING_PART_OF_PATH, { - shown: shownFrames, - total: path.length, - })} - - ) : 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} + {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} + {pathError && !pathShown && !isLoadingPath ? ( + + {translate(STRING.TRACK_PATH_ERROR)} + + ) : null} - + - {detectionId ? ( - - {/* A detection keeps its id through every merge and split, so this link + {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} -
+ + + ) : null} +
-
- {pathActions.length ? ( -
{pathActions}
- ) : null} +
+ {pathActions.length ? ( +
+ {pathActions} +
+ ) : null} -
{editActions}
+
+ {editActions} +
- {pathShown ? ( - - ) : null} -
+ {pathShown ? ( + + ) : null} +
+ + ) : null}
) } diff --git a/ui/src/pages/session-details/session-details.tsx b/ui/src/pages/session-details/session-details.tsx index 98ba1cd14..850180168 100644 --- a/ui/src/pages/session-details/session-details.tsx +++ b/ui/src/pages/session-details/session-details.tsx @@ -19,6 +19,7 @@ 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 { 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' @@ -108,8 +109,10 @@ const Content = ({ session }: { session: SessionDetails }) => { projectId: projectId as string, }) const { timeline = [] } = useSessionTimeline(session.id) + const trackingEnabled = useProjectFeature('tracking') const extend = useExtendTrack({ captureId: activeCaptureId, + enabled: trackingEnabled, nextCaptureId: activeCapture ? getNextCaptureWithDetectionsId({ capture: activeCapture, timeline }) : undefined, @@ -196,6 +199,7 @@ const Content = ({ session }: { session: SessionDetails }) => { detections={activeCapture?.detections ?? []} extend={extend} height={activeCapture?.height ?? session.firstCapture.height} + trackingEnabled={trackingEnabled} onTogglePathCrops={() => setSettings((current) => ({ ...current, @@ -261,6 +265,7 @@ const Content = ({ session }: { session: SessionDetails }) => {
@@ -272,7 +277,7 @@ const Content = ({ session }: { session: SessionDetails }) => { setActiveCaptureId={setActiveCaptureId} timeline={timeline} > - {timelineOccurrenceIds.length ? ( + {trackingEnabled && timelineOccurrenceIds.length ? ( void settings: ViewSettingsValues + showTrackingSettings?: boolean }) => { const { projectId } = useParams() const { project } = useProjectDetails(projectId as string, true) @@ -50,17 +52,19 @@ export const ViewSettings = ({ }) } /> - - onSettingsChange({ - ...settings, - showPathCrops: !settings.showPathCrops, - }) - } - /> + {showTrackingSettings ? ( + + onSettingsChange({ + ...settings, + showPathCrops: !settings.showPathCrops, + }) + } + /> + ) : null}
{ + 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) + }) +}) 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..7c7697b75 --- /dev/null +++ b/ui/src/utils/project-features/project-features.ts @@ -0,0 +1,23 @@ +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 + +// 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..4251635f3 --- /dev/null +++ b/ui/src/utils/project-features/useProjectFeature.ts @@ -0,0 +1,10 @@ +import { useProjectDetails } from 'data-services/hooks/projects/useProjectDetails' +import { useParams } from 'react-router-dom' +import { 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) +} From e9564e58fcfe9d87786a355ddb213818fcbb8022 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 22 Sep 2026 14:06:06 -0700 Subject: [PATCH 03/32] feat(ui): let project members start a tracking run from the jobs page and a session On projects with tracking switched on, the new job dialog offers a tracking job next to image processing: pick one session or a capture set, optionally set a cost threshold, and choose whether feature vectors are required. The session page gets a Run tracking button that fixes the scope to that session. Both post a post_processing job with the tracking task and start it with start_now, the same way processing jobs start. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../hooks/jobs/useCreateTrackingJob.ts | 33 +++ .../data-services/models/tracking-job.test.ts | 103 ++++++++ ui/src/data-services/models/tracking-job.ts | 109 ++++++++ ui/src/pages/job-details/new-job-dialog.tsx | 75 ++++-- .../tracking-job-form/tracking-job-form.tsx | 236 ++++++++++++++++++ .../run-tracking/run-tracking-dialog.tsx | 48 ++++ .../pages/session-details/session-details.tsx | 4 + ui/src/utils/language.ts | 40 +++ 8 files changed, 634 insertions(+), 14 deletions(-) create mode 100644 ui/src/data-services/hooks/jobs/useCreateTrackingJob.ts create mode 100644 ui/src/data-services/models/tracking-job.test.ts create mode 100644 ui/src/data-services/models/tracking-job.ts create mode 100644 ui/src/pages/job-details/tracking-job-form/tracking-job-form.tsx create mode 100644 ui/src/pages/session-details/run-tracking/run-tracking-dialog.tsx 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/models/tracking-job.test.ts b/ui/src/data-services/models/tracking-job.test.ts new file mode 100644 index 000000000..9271efa1e --- /dev/null +++ b/ui/src/data-services/models/tracking-job.test.ts @@ -0,0 +1,103 @@ +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({ + 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('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..3efed6ed3 --- /dev/null +++ b/ui/src/data-services/models/tracking-job.ts @@ -0,0 +1,109 @@ +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 { + 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 { + 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/pages/job-details/new-job-dialog.tsx b/ui/src/pages/job-details/new-job-dialog.tsx index be41fa364..eb22fc189 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 { useProjectFeature } 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 trackingEnabled = useProjectFeature('tracking') + 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, - }) - }} - /> + {trackingEnabled ? ( + + + + setJobType(value as NewJobType)} + value={jobType} + > + + + + + + {translate(STRING.TRACKING_JOB_TYPE_PROCESSING)} + + + {translate(STRING.TRACKING_JOB_TYPE_TRACKING)} + + + + + + + ) : null} + {trackingEnabled && 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..2a91b55b3 --- /dev/null +++ b/ui/src/pages/job-details/tracking-job-form/tracking-job-form.tsx @@ -0,0 +1,236 @@ +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} + + {scope ? null : ( + + ( + + + + + + + + {translate(STRING.TRACKING_JOB_SCOPE_SESSION)} + + + {translate(STRING.TRACKING_JOB_SCOPE_CAPTURE_SET)} + + + + + )} + /> + {scopeType === 'session' ? ( + + ) : ( + ( + + + + )} + /> + )} + + )} + + + + + + +
+ ( + + )} + /> + ( + + )} + /> +
+
+
+
+ + + + + ) +} 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 850180168..8a4934880 100644 --- a/ui/src/pages/session-details/session-details.tsx +++ b/ui/src/pages/session-details/session-details.tsx @@ -30,6 +30,7 @@ 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' @@ -145,6 +146,9 @@ const Content = ({ session }: { session: SessionDetails }) => { })} tooltip={translate(STRING.TOOLTIP_SESSION)} > + {user.loggedIn && trackingEnabled ? ( + + ) : null} {user.loggedIn ? : null}
diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index fb9e8e483..b308b1f93 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -558,6 +558,24 @@ export enum STRING { 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_START_NOW, + TRACKING_JOB_TYPE, + TRACKING_JOB_TYPE_PROCESSING, + TRACKING_JOB_TYPE_TRACKING, UNIDENTIFIED, UNKNOWN_ERROR, UNKNOWN, @@ -1228,6 +1246,28 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [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_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}}', From 3b12b9f32311e6c64ebb539be30020c6f009f579 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 22 Sep 2026 14:13:00 -0700 Subject: [PATCH 04/32] fix(ui): keep the plain box label on captures when tracking is off With the flag off, clicking a box on a capture shows the label and score tooltip whose name opens the occurrence, as before tracking, instead of the new track panel with its tracking sections hidden. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../pages/session-details/capture/capture.tsx | 39 ++++- .../capture/occurrence-toolbar.tsx | 137 ++++++++---------- 2 files changed, 92 insertions(+), 84 deletions(-) diff --git a/ui/src/pages/session-details/capture/capture.tsx b/ui/src/pages/session-details/capture/capture.tsx index 1dea5f6bc..69d35521c 100644 --- a/ui/src/pages/session-details/capture/capture.tsx +++ b/ui/src/pages/session-details/capture/capture.tsx @@ -712,24 +712,35 @@ const CaptureDetections = ({ /> ) + // 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) { + if (detailsHidden || !detection.occurrenceId || plainLabel) { return ( - + {box} hoverBox(detection.id)} + onMouseLeave={() => unhoverBox(detection.id)} side="bottom" > {detailsHidden ? ( @@ -742,9 +753,22 @@ const CaptureDetections = ({ /> ) : (
- - {detection.label} - + {plainLabel ? ( + + ) : ( + + {detection.label} + + )} { const singleFrame = occurrence.frameCount <= 1 const pathShown = !!path?.length @@ -226,91 +223,79 @@ export const OccurrenceToolbar = ({ scoreLabel={occurrence.scoreLabel} verified={occurrence.score === 1} /> - {trackingEnabled ? ( - - {subtitle()} - - ) : null} + {subtitle()}
- {trackingEnabled ? ( - <> -
- {framePosition ? {framePosition} : null} +
+ {framePosition ? {framePosition} : null} - {shownFrames !== undefined && path && shownFrames < path.length ? ( - - {translate(STRING.TRACK_SHOWING_PART_OF_PATH, { - shown: shownFrames, - total: path.length, - })} - - ) : 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} + {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} + {pathError && !pathShown && !isLoadingPath ? ( + + {translate(STRING.TRACK_PATH_ERROR)} + + ) : null} - + - {detectionId ? ( - - {/* A detection keeps its id through every merge and split, so this link + {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} -
+ + + ) : null} +
-
- {pathActions.length ? ( -
- {pathActions} -
- ) : null} +
+ {pathActions.length ? ( +
{pathActions}
+ ) : null} -
- {editActions} -
+
{editActions}
- {pathShown ? ( - - ) : null} -
- - ) : null} + {pathShown ? ( + + ) : null} +
) } From 307cdcb3f4c4ffd0025b8dd0be12d794a650de4a Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 22 Sep 2026 14:13:00 -0700 Subject: [PATCH 05/32] fix(ui): offer a tracking run only to people who can change the project The session button and the tracking job type are shown when the project has tracking on and the viewer may update the project. A member who can only create jobs would otherwise get the job saved and then refused at start. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/pages/job-details/new-job-dialog.tsx | 8 ++++---- .../pages/session-details/session-details.tsx | 8 ++++++-- .../project-features/project-features.test.ts | 20 ++++++++++++++++++- .../project-features/project-features.ts | 10 ++++++++++ .../project-features/useProjectFeature.ts | 13 +++++++++++- 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/ui/src/pages/job-details/new-job-dialog.tsx b/ui/src/pages/job-details/new-job-dialog.tsx index eb22fc189..fa76d7326 100644 --- a/ui/src/pages/job-details/new-job-dialog.tsx +++ b/ui/src/pages/job-details/new-job-dialog.tsx @@ -6,7 +6,7 @@ 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 { useProjectFeature } from 'utils/project-features/useProjectFeature' +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' @@ -19,7 +19,7 @@ export const NewJobDialog = () => { const { projectId } = useParams() const [isOpen, setIsOpen] = useState(false) const [jobType, setJobType] = useState('processing') - const trackingEnabled = useProjectFeature('tracking') + const canStartTracking = useCanStartTracking() const closeLater = () => setTimeout(() => { setIsOpen(false) @@ -42,7 +42,7 @@ export const NewJobDialog = () => {
- {trackingEnabled ? ( + {canStartTracking ? ( @@ -66,7 +66,7 @@ export const NewJobDialog = () => { ) : null} - {trackingEnabled && jobType === 'tracking' ? ( + {canStartTracking && jobType === 'tracking' ? ( { }) const { timeline = [] } = useSessionTimeline(session.id) const trackingEnabled = useProjectFeature('tracking') + const canStartTracking = useCanStartTracking() const extend = useExtendTrack({ captureId: activeCaptureId, enabled: trackingEnabled, @@ -146,7 +150,7 @@ const Content = ({ session }: { session: SessionDetails }) => { })} tooltip={translate(STRING.TOOLTIP_SESSION)} > - {user.loggedIn && trackingEnabled ? ( + {user.loggedIn && canStartTracking ? ( ) : null} {user.loggedIn ? : null} diff --git a/ui/src/utils/project-features/project-features.test.ts b/ui/src/utils/project-features/project-features.test.ts index 171c89d3b..9c061f048 100644 --- a/ui/src/utils/project-features/project-features.test.ts +++ b/ui/src/utils/project-features/project-features.test.ts @@ -1,4 +1,8 @@ -import { hasProjectFeature, withoutTrackingColumns } from './project-features' +import { + canStartTracking, + hasProjectFeature, + withoutTrackingColumns, +} from './project-features' describe('hasProjectFeature', () => { test('is on only when the flag is true', () => { @@ -34,3 +38,17 @@ describe('withoutTrackingColumns', () => { expect(withoutTrackingColumns(columns, true)).toEqual(columns) }) }) + +describe('canStartTracking', () => { + test('needs both the flag and the right to change the project', () => { + const flags = { tracking: true } + expect(canStartTracking({ canUpdate: true, featureFlags: flags })).toBe( + true + ) + expect(canStartTracking({ canUpdate: false, featureFlags: flags })).toBe( + false + ) + expect(canStartTracking({ canUpdate: true, 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 index 7c7697b75..1e32a5683 100644 --- a/ui/src/utils/project-features/project-features.ts +++ b/ui/src/utils/project-features/project-features.ts @@ -6,6 +6,16 @@ export const hasProjectFeature = ( feature: ProjectFeature ) => featureFlags?.[feature] === true +/** + * Starting a tracking run is offered to those who can change the project, since a + * member who can only create jobs would have the job saved and then refused. + */ +export const canStartTracking = ( + project: + | { canUpdate: boolean; featureFlags?: { [key: string]: boolean } } + | undefined +) => !!project?.canUpdate && hasProjectFeature(project.featureFlags, 'tracking') + // Occurrence list columns that only mean something once occurrences are tracks. export const TRACKING_COLUMN_IDS = [ 'detections', diff --git a/ui/src/utils/project-features/useProjectFeature.ts b/ui/src/utils/project-features/useProjectFeature.ts index 4251635f3..8954e4719 100644 --- a/ui/src/utils/project-features/useProjectFeature.ts +++ b/ui/src/utils/project-features/useProjectFeature.ts @@ -1,6 +1,10 @@ import { useProjectDetails } from 'data-services/hooks/projects/useProjectDetails' import { useParams } from 'react-router-dom' -import { hasProjectFeature, ProjectFeature } from './project-features' +import { + canStartTracking, + hasProjectFeature, + ProjectFeature, +} from './project-features' export const useProjectFeature = (feature: ProjectFeature) => { const { projectId } = useParams() @@ -8,3 +12,10 @@ export const useProjectFeature = (feature: ProjectFeature) => { return hasProjectFeature(project?.featureFlags, feature) } + +export const useCanStartTracking = () => { + const { projectId } = useParams() + const { project } = useProjectDetails(projectId as string, true) + + return canStartTracking(project) +} From ad31b9fbee71cae490dfb0f687c36c91ebd75a99 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 22 Sep 2026 14:48:22 -0700 Subject: [PATCH 06/32] fix(ui): send the delay field the jobs API requires when starting a tracking run The jobs API rejects a job without delay, so a tracking run started from the interface failed with a 400 before this. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/data-services/models/tracking-job.test.ts | 1 + ui/src/data-services/models/tracking-job.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/ui/src/data-services/models/tracking-job.test.ts b/ui/src/data-services/models/tracking-job.test.ts index 9271efa1e..e202dfd97 100644 --- a/ui/src/data-services/models/tracking-job.test.ts +++ b/ui/src/data-services/models/tracking-job.test.ts @@ -10,6 +10,7 @@ describe('buildTrackingJobPayload', () => { scope: { type: 'session', sessionId: '42' }, }) ).toEqual({ + delay: 0, job_type_key: 'post_processing', name: 'Night one', params: { diff --git a/ui/src/data-services/models/tracking-job.ts b/ui/src/data-services/models/tracking-job.ts index 3efed6ed3..75c468e18 100644 --- a/ui/src/data-services/models/tracking-job.ts +++ b/ui/src/data-services/models/tracking-job.ts @@ -21,6 +21,8 @@ export interface ServerTrackingConfig { } 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 } @@ -80,6 +82,7 @@ export const buildTrackingJobPayload = ({ } return { + delay: 0, job_type_key: 'post_processing', name: name?.trim() || getDefaultTrackingJobName(scope), params: { task: 'tracking', config }, From eb4c78022bd7a6ba7f58f68045aeda734c2ff94b Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 22 Sep 2026 14:52:51 -0700 Subject: [PATCH 07/32] feat(ui): offer the tracks CSV export Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/data-services/models/export.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/src/data-services/models/export.ts b/ui/src/data-services/models/export.ts index d96ee2e39..c999efcb3 100644 --- a/ui/src/data-services/models/export.ts +++ b/ui/src/data-services/models/export.ts @@ -6,6 +6,7 @@ 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] @@ -27,6 +28,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 { From b92bb5fb53c1b8282fdaca324790b10d2ae080f4 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 00:48:03 -0700 Subject: [PATCH 08/32] fix(ui): call the track editing mode "Edit track" instead of "Extend track" The mode opened from an occurrence's track lets people add, move, remove and replace frames, so "extend" undersold it. Only the visible copy changes; the extend URL parameter and internal identifiers stay as they are. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/utils/language.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index b308b1f93..c8f243772 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -1075,13 +1075,13 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [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 extending, or move only this frame across.', + '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]: 'Extend this track', + [STRING.TRACK_EXTEND_HERE]: 'Edit this track', [STRING.TRACK_EXTEND_HIDE_DETAILS]: 'Hide box details', - [STRING.TRACK_EXTEND_IN_SESSION]: 'Extend in session view', - [STRING.TRACK_EXTEND_LABEL]: 'Extending', + [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', @@ -1098,7 +1098,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [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 extending this track', + [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', From 6ee560466974014655e8c184568c38e023ca8c4d Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 00:49:30 -0700 Subject: [PATCH 09/32] feat(ui): sort merge candidates by species and by frame count Reviewers can now click the Species and Frames headers in the merge candidate list to reorder it, alongside the existing when, distance, similarity, cost and match columns. Species sorts alphabetically on the determination name alone (without the occurrence id), ignoring case, with undetermined rows last in either direction. Frames sorts by detection count as a number and puts the most frames first on the first click. Ties keep the server's ranking, which stays the initial order. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/components/track/occurrence-picker.tsx | 130 ++++++++++-------- .../models/merge-candidate.test.ts | 31 +++++ .../data-services/models/merge-candidate.ts | 26 +++- 3 files changed, 126 insertions(+), 61 deletions(-) diff --git a/ui/src/components/track/occurrence-picker.tsx b/ui/src/components/track/occurrence-picker.tsx index 61f072286..f2a4d49b6 100644 --- a/ui/src/components/track/occurrence-picker.tsx +++ b/ui/src/components/track/occurrence-picker.tsx @@ -43,6 +43,7 @@ export type OccurrencePickerCandidate = Pick< Partial< Pick< MergeCandidate, + | 'speciesName' | 'relation' | 'timeOffsetSeconds' | 'distance' @@ -66,11 +67,17 @@ const isRanked = ( candidates.length > 0 && candidates.every((candidate) => candidate.relation !== undefined) -// The first click on a column gives its natural order: earliest first, closest -// first, or most alike first. -const DESCENDING_FIRST: MergeCandidateSortColumn[] = ['similarity', 'match'] +// 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, @@ -236,6 +243,53 @@ export const OccurrencePicker = ({ 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} @@ -339,60 +393,24 @@ export const OccurrencePicker = ({ {translate(STRING.TRACK_COLUMN_IMAGE)} - - {translate(STRING.TRACK_COLUMN_SPECIES)} - - - {translate(STRING.TRACK_COLUMN_FRAMES)} - + {ranked ? ( + renderSortHeader('species') + ) : ( + + {translate(STRING.TRACK_COLUMN_SPECIES)} + + )} + {ranked ? ( + renderSortHeader('frames', numberClassName) + ) : ( + + {translate(STRING.TRACK_COLUMN_FRAMES)} + + )} {ranked && - SORT_COLUMNS.map((column) => { - const label = COLUMN_LABELS[column] - const help = COLUMN_HELP[column] - const active = columnSort?.column === column - const DirectionIcon = !active - ? ArrowUpDownIcon - : columnSort.descending - ? ArrowDownIcon - : ArrowUpIcon - - return ( - - - - ) - })} + SORT_COLUMNS.map((column) => + renderSortHeader(column, numberClassName) + )} {ranked && sessionLink ? ( diff --git a/ui/src/data-services/models/merge-candidate.test.ts b/ui/src/data-services/models/merge-candidate.test.ts index b0e76ff2f..5bc1cddac 100644 --- a/ui/src/data-services/models/merge-candidate.test.ts +++ b/ui/src/data-services/models/merge-candidate.test.ts @@ -227,6 +227,37 @@ describe('merge candidate sorting', () => { ).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 })) diff --git a/ui/src/data-services/models/merge-candidate.ts b/ui/src/data-services/models/merge-candidate.ts index 2d7434697..59f99b162 100644 --- a/ui/src/data-services/models/merge-candidate.ts +++ b/ui/src/data-services/models/merge-candidate.ts @@ -36,6 +36,8 @@ export interface ServerMergeCandidates { 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 @@ -73,6 +75,7 @@ export const convertMergeCandidate = ( 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, @@ -209,6 +212,8 @@ export const getCostLabel = (cost: number | null, threshold?: number): string => }) export type MergeCandidateSortColumn = + | 'species' + | 'frames' | 'when' | 'distance' | 'similarity' @@ -223,8 +228,12 @@ export type MergeCandidateSort = const sortValue = ( candidate: MergeCandidate, column: MergeCandidateSortColumn -): number | null => - column === 'when' +): number | string | null => + column === 'species' + ? candidate.speciesName + : column === 'frames' + ? candidate.numDetections + : column === 'when' ? getWhenOffsetSeconds(candidate) : column === 'distance' ? candidate.distance @@ -235,15 +244,22 @@ const sortValue = ( : candidate.likelihood const compare = ( - valueA: number | null, - valueB: number | null, + valueA: number | string | null, + valueB: number | string | null, descending: boolean ): number => { if (valueA === null || valueB === null) { return valueA === valueB ? 0 : valueA === null ? 1 : -1 } - return (valueA - valueB) * (descending ? -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: From 8016f6a0c674cf3706f610a10d31ca776bacc8bf Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 00:50:37 -0700 Subject: [PATCH 10/32] feat(ui): move to the next occurrence after confirming one from the occurrences list When the details dialog is opened from the occurrences list, the Confirm button in its header now moves on to the next occurrence on the page, or closes the dialog after the last one, so validating a list takes one click per occurrence. The agree buttons on individual identifications, Suggest ID, the quick ID buttons, the taxa list's dialog and the standalone views keep their behaviour. The header Confirm button is keyed by occurrence so its confirmed state does not carry over to the next occurrence, and the prev/next lookup is a pure helper with its own tests. Part of #1415. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../pages/occurrence-details/agree/agree.tsx | 4 ++- .../occurrence-details/occurrence-details.tsx | 6 +++++ .../occurrences/adjacent-occurrences.test.ts | 21 +++++++++++++++ .../pages/occurrences/adjacent-occurrences.ts | 12 +++++++++ .../occurrences/occurrence-details-dialog.tsx | 21 ++++++++++----- .../occurrences/occurrence-navigation.tsx | 26 +++++++------------ ui/src/pages/occurrences/occurrences.tsx | 1 + 7 files changed, 67 insertions(+), 24 deletions(-) create mode 100644 ui/src/pages/occurrences/adjacent-occurrences.test.ts create mode 100644 ui/src/pages/occurrences/adjacent-occurrences.ts diff --git a/ui/src/pages/occurrence-details/agree/agree.tsx b/ui/src/pages/occurrence-details/agree/agree.tsx index 92542e423..f6655889b 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?: () => 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) useEffect(() => { reset() diff --git a/ui/src/pages/occurrence-details/occurrence-details.tsx b/ui/src/pages/occurrence-details/occurrence-details.tsx index 2a3580ac4..fb0ce9eed 100644 --- a/ui/src/pages/occurrence-details/occurrence-details.tsx +++ b/ui/src/pages/occurrence-details/occurrence-details.tsx @@ -76,11 +76,14 @@ const JumpToFrame = ({ export const OccurrenceDetails = ({ occurrence, + onConfirmed, onNavigate, selectedTab, setSelectedTab, }: { occurrence: Occurrence + /** Called after the header Confirm button confirms the determination. */ + onConfirmed?: () => void /** Called when a frame's link is followed, so a dialog around these details can close. */ onNavigate?: () => void selectedTab?: string @@ -245,6 +248,8 @@ export const OccurrenceDetails = ({ <> {occurrence.determinationTaxon ? ( ) : null} 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..c3aed2553 --- /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, so confirming there closes the dialog', () => { + 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-details-dialog.tsx b/ui/src/pages/occurrences/occurrence-details-dialog.tsx index 60b2b0480..ee4aa7109 100644 --- a/ui/src/pages/occurrences/occurrence-details-dialog.tsx +++ b/ui/src/pages/occurrences/occurrence-details-dialog.tsx @@ -10,17 +10,24 @@ 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' // 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, 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 // Ordered items the prev/next buttons page through. Only the id is used. occurrences?: { id: string }[] @@ -36,6 +43,7 @@ export const OccurrenceDetailsDialog = ({ const { selectedView, setSelectedView } = useSelectedView(defaultTab, 'tab') const { setDetailBreadcrumb } = useContext(BreadcrumbContext) const { occurrence, isLoading, error } = useOccurrenceDetails(id) + const navigation = useOccurrenceNavigation(occurrences, id, onNavigate) const detailsLabel = translate(STRING.ENTITY_DETAILS, { type: _.capitalize(translate(STRING.ENTITY_TYPE_OCCURRENCE)), }) @@ -80,15 +88,16 @@ export const OccurrenceDetailsDialog = ({ {occurrence ? ( (navigation.nextId ? navigation.goToNext() : onClose()) + : undefined + } selectedTab={selectedView} setSelectedTab={setSelectedView} /> ) : null} - + ) diff --git a/ui/src/pages/occurrences/occurrence-navigation.tsx b/ui/src/pages/occurrences/occurrence-navigation.tsx index ce56a3eeb..e39f9cad5 100644 --- a/ui/src/pages/occurrences/occurrence-navigation.tsx +++ b/ui/src/pages/occurrences/occurrence-navigation.tsx @@ -5,13 +5,18 @@ import { useNavigate, useParams } from 'react-router-dom' import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' +import { getAdjacentIds } from './adjacent-occurrences' // Ordered items the modal can page through. Only the id is needed: on the occurrences // list these are Occurrence models; on the taxa list they are the per-row example // occurrences, so paging steps to the next taxon's example. type NavItem = { id: string } -const useOccurrenceNavigation = ( +export type OccurrenceNavigationState = ReturnType< + typeof useOccurrenceNavigation +> + +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) => { @@ -60,20 +62,10 @@ const useOccurrenceNavigation = ( } 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.tsx b/ui/src/pages/occurrences/occurrences.tsx index 02406fd02..fdfcd66ef 100644 --- a/ui/src/pages/occurrences/occurrences.tsx +++ b/ui/src/pages/occurrences/occurrences.tsx @@ -235,6 +235,7 @@ export const Occurrences = () => { {id ? ( From 0eb5f5e4a552d5ba0c28172b0bae029476a9cfe5 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 00:53:47 -0700 Subject: [PATCH 11/32] fix(ui): only advance after a confirm if the dialog still shows that occurrence The success callback of an identification request runs even after the Confirm button that sent it has unmounted, so a slow request could reopen the dialog after the user closed it, pull them back from an occurrence they had moved on to, or close the dialog under them. The Confirm button now reports which occurrence it confirmed, and the dialog advances only when it is still open on that occurrence, looking up the next one at that moment rather than from the render where the button was clicked. The advance logic lives in a small hook with tests for moving on, closing after the last item, and ignoring a confirm that finishes after the user moved away or closed the dialog. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../pages/occurrence-details/agree/agree.tsx | 4 +- .../occurrence-details/occurrence-details.tsx | 4 +- .../occurrences/occurrence-details-dialog.tsx | 13 +++-- .../occurrences/occurrence-navigation.tsx | 1 + .../use-advance-on-confirm.test.ts | 57 +++++++++++++++++++ .../occurrences/use-advance-on-confirm.ts | 40 +++++++++++++ 6 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 ui/src/pages/occurrences/use-advance-on-confirm.test.ts create mode 100644 ui/src/pages/occurrences/use-advance-on-confirm.ts diff --git a/ui/src/pages/occurrence-details/agree/agree.tsx b/ui/src/pages/occurrence-details/agree/agree.tsx index f6655889b..410810cca 100644 --- a/ui/src/pages/occurrence-details/agree/agree.tsx +++ b/ui/src/pages/occurrence-details/agree/agree.tsx @@ -13,7 +13,7 @@ interface AgreeProps { applied?: boolean compact?: boolean occurrenceId: string - onSuccess?: () => void + onSuccess?: (occurrenceId: string) => void taxonId: string } @@ -27,7 +27,7 @@ export const Agree = ({ taxonId, }: AgreeProps) => { const { createIdentification, isLoading, isSuccess, error, reset } = - useCreateIdentification(onSuccess) + useCreateIdentification(() => onSuccess?.(occurrenceId)) useEffect(() => { reset() diff --git a/ui/src/pages/occurrence-details/occurrence-details.tsx b/ui/src/pages/occurrence-details/occurrence-details.tsx index fb0ce9eed..cf9bdc9b0 100644 --- a/ui/src/pages/occurrence-details/occurrence-details.tsx +++ b/ui/src/pages/occurrence-details/occurrence-details.tsx @@ -82,8 +82,8 @@ export const OccurrenceDetails = ({ setSelectedTab, }: { occurrence: Occurrence - /** Called after the header Confirm button confirms the determination. */ - onConfirmed?: () => void + /** Called with the occurrence id after the header Confirm button confirms the determination. */ + onConfirmed?: (occurrenceId: string) => void /** Called when a frame's link is followed, so a dialog around these details can close. */ onNavigate?: () => void selectedTab?: string diff --git a/ui/src/pages/occurrences/occurrence-details-dialog.tsx b/ui/src/pages/occurrences/occurrence-details-dialog.tsx index ee4aa7109..b7de34a11 100644 --- a/ui/src/pages/occurrences/occurrence-details-dialog.tsx +++ b/ui/src/pages/occurrences/occurrence-details-dialog.tsx @@ -14,6 +14,7 @@ import { OccurrenceNavigation, useOccurrenceNavigation, } from './occurrence-navigation' +import { useAdvanceOnConfirm } from './use-advance-on-confirm' // Occurrence identification modal. Rendered over a list (occurrences or taxa); // the parent owns which occurrence is shown and how closing updates the URL. @@ -44,6 +45,12 @@ export const OccurrenceDetailsDialog = ({ const { setDetailBreadcrumb } = useContext(BreadcrumbContext) const { occurrence, isLoading, error } = useOccurrenceDetails(id) const navigation = useOccurrenceNavigation(occurrences, id, onNavigate) + const advance = useAdvanceOnConfirm({ + close: onClose, + currentId: id, + goTo: navigation.goTo, + items: occurrences, + }) const detailsLabel = translate(STRING.ENTITY_DETAILS, { type: _.capitalize(translate(STRING.ENTITY_TYPE_OCCURRENCE)), }) @@ -88,11 +95,7 @@ export const OccurrenceDetailsDialog = ({ {occurrence ? ( (navigation.nextId ? navigation.goToNext() : onClose()) - : undefined - } + onConfirmed={advanceOnConfirm ? advance : undefined} selectedTab={selectedView} setSelectedTab={setSelectedView} /> diff --git a/ui/src/pages/occurrences/occurrence-navigation.tsx b/ui/src/pages/occurrences/occurrence-navigation.tsx index e39f9cad5..54d28c3e4 100644 --- a/ui/src/pages/occurrences/occurrence-navigation.tsx +++ b/ui/src/pages/occurrences/occurrence-navigation.tsx @@ -56,6 +56,7 @@ export const useOccurrenceNavigation = ( return { prevId, nextId, + goTo, goToPrev, goToNext, } 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..982a71db4 --- /dev/null +++ b/ui/src/pages/occurrences/use-advance-on-confirm.test.ts @@ -0,0 +1,57 @@ +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('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..edb1d4897 --- /dev/null +++ b/ui/src/pages/occurrences/use-advance-on-confirm.ts @@ -0,0 +1,40 @@ +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. A confirm that lands after the user has moved on is ignored. +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 { nextId } = getAdjacentIds(latest.current.items, confirmedId) + if (nextId) { + latest.current.goTo(nextId) + } else { + latest.current.close() + } + }, []) +} From 49b3e602d0132fe2a840f192f958704d3708bfe6 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 00:54:38 -0700 Subject: [PATCH 12/32] fix(ui): clear the selected tab when the dialog closes after the last confirm Closing the occurrence dialog by hand clears ?tab= from the list URL, but closing it automatically after confirming the last occurrence did not, so the next occurrence opened from the list started on the leftover tab. Both paths now go through one close handler. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/pages/occurrences/occurrence-details-dialog.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/occurrences/occurrence-details-dialog.tsx b/ui/src/pages/occurrences/occurrence-details-dialog.tsx index b7de34a11..4fc97a604 100644 --- a/ui/src/pages/occurrences/occurrence-details-dialog.tsx +++ b/ui/src/pages/occurrences/occurrence-details-dialog.tsx @@ -45,8 +45,13 @@ export const OccurrenceDetailsDialog = ({ const { setDetailBreadcrumb } = useContext(BreadcrumbContext) const { occurrence, isLoading, error } = useOccurrenceDetails(id) const navigation = useOccurrenceNavigation(occurrences, 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: onClose, + close: handleClose, currentId: id, goTo: navigation.goTo, items: occurrences, @@ -77,8 +82,7 @@ export const OccurrenceDetailsDialog = ({ open={!!id} onOpenChange={(open) => { if (!open) { - setSelectedView(undefined) - onClose() + handleClose() } }} > From 7dd6593cf21d652be583e1807ce05cbcf2b9a6b1 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 00:54:38 -0700 Subject: [PATCH 13/32] test(ui): name the last-item adjacency test for what it checks The test covers only the prev/next lookup, so its name no longer claims dialog behaviour; the advance-and-close behaviour is covered by the advance hook tests. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/pages/occurrences/adjacent-occurrences.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/pages/occurrences/adjacent-occurrences.test.ts b/ui/src/pages/occurrences/adjacent-occurrences.test.ts index c3aed2553..a9b4c7d8e 100644 --- a/ui/src/pages/occurrences/adjacent-occurrences.test.ts +++ b/ui/src/pages/occurrences/adjacent-occurrences.test.ts @@ -7,7 +7,7 @@ describe('getAdjacentIds', () => { expect(getAdjacentIds(items, '2')).toEqual({ prevId: '1', nextId: '3' }) }) - test('has no next id on the last item, so confirming there closes the dialog', () => { + test('has no next id on the last item', () => { expect(getAdjacentIds(items, '3')).toEqual({ prevId: '2', nextId: undefined, From 72b5c6e819927d1b42757406fc7c4c0c6d8bdfe6 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 01:15:33 -0700 Subject: [PATCH 14/32] feat(ui): offer Run tracking to everyone the server lets run it The Run tracking action now follows the run_tracking permission the project API returns, so ML data managers see it alongside project managers while basic members do not. The tracking flag is still checked so the action stays hidden when tracking is off. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../project-features/project-features.test.ts | 21 +++++++++++++------ .../project-features/project-features.ts | 16 ++++++++------ ui/src/utils/user/types.ts | 1 + 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/ui/src/utils/project-features/project-features.test.ts b/ui/src/utils/project-features/project-features.test.ts index 9c061f048..81d0d09b8 100644 --- a/ui/src/utils/project-features/project-features.test.ts +++ b/ui/src/utils/project-features/project-features.test.ts @@ -3,6 +3,7 @@ import { hasProjectFeature, withoutTrackingColumns, } from './project-features' +import { UserPermission } from 'utils/user/types' describe('hasProjectFeature', () => { test('is on only when the flag is true', () => { @@ -40,15 +41,23 @@ describe('withoutTrackingColumns', () => { }) describe('canStartTracking', () => { - test('needs both the flag and the right to change the project', () => { + const run = [UserPermission.RunTracking] + + test('needs both the flag and the run tracking permission', () => { const flags = { tracking: true } - expect(canStartTracking({ canUpdate: true, featureFlags: flags })).toBe( - true - ) - expect(canStartTracking({ canUpdate: false, featureFlags: flags })).toBe( + 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({ canUpdate: true, 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 index 1e32a5683..510b45ded 100644 --- a/ui/src/utils/project-features/project-features.ts +++ b/ui/src/utils/project-features/project-features.ts @@ -1,3 +1,5 @@ +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. */ @@ -6,15 +8,17 @@ export const hasProjectFeature = ( feature: ProjectFeature ) => featureFlags?.[feature] === true -/** - * Starting a tracking run is offered to those who can change the project, since a - * member who can only create jobs would have the job saved and then refused. - */ +/** The server grants run_tracking only while tracking is on, so the flag check is a safeguard. */ export const canStartTracking = ( project: - | { canUpdate: boolean; featureFlags?: { [key: string]: boolean } } + | { + featureFlags?: { [key: string]: boolean } + userPermissions?: UserPermission[] + } | undefined -) => !!project?.canUpdate && hasProjectFeature(project.featureFlags, 'tracking') +) => + !!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 = [ 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', From 11674865ee479947527df9bcbbba91c242c4addf Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 01:18:47 -0700 Subject: [PATCH 15/32] feat(ui): rank the occurrences a frame can be moved to by how well they match it The "Move to another occurrence" dialog now asks the server for merge candidates measured from the moved frame, so the best match by the tracking method comes first, and it shows the same sortable columns, comparison preview and search scope control as the merge picker (three captures either side by default). Occurrences with a box on the same capture are left out by the server, and its reason is shown when a frame cannot be scored. Removing a frame into a new occurrence is unchanged. The candidate query string is built by a tested helper that takes an optional detection id. The unranked neighbourhood hook and the captures lookup only it used are removed, and the picker no longer carries default texts that described that neighbourhood. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/components/track/occurrence-picker.tsx | 8 +- ui/src/components/track/useTrackCandidates.ts | 72 ----------------- .../occurrences/useMergeCandidates.test.ts | 55 +++++++++++++ .../hooks/occurrences/useMergeCandidates.ts | 57 +++++++++++--- .../occurrences/useOccurrencesInCaptures.ts | 77 ------------------- .../track/frame-action-dialogs.tsx | 61 +++++++++++++-- ui/src/utils/language.ts | 9 +-- 7 files changed, 166 insertions(+), 173 deletions(-) delete mode 100644 ui/src/components/track/useTrackCandidates.ts create mode 100644 ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts delete mode 100644 ui/src/data-services/hooks/occurrences/useOccurrencesInCaptures.ts diff --git a/ui/src/components/track/occurrence-picker.tsx b/ui/src/components/track/occurrence-picker.tsx index f2a4d49b6..9d062ddf3 100644 --- a/ui/src/components/track/occurrence-picker.tsx +++ b/ui/src/components/track/occurrence-picker.tsx @@ -156,8 +156,8 @@ const getPanelStyle = ( export const OccurrencePicker = ({ candidates, costThreshold, - description = translate(STRING.TRACK_PICK_OCCURRENCE_SCOPE), - emptyMessage = translate(STRING.TRACK_NO_OTHER_OCCURRENCES), + description, + emptyMessage, isLoading, onScopeChange, onSelect, @@ -172,8 +172,8 @@ export const OccurrencePicker = ({ candidates: OccurrencePickerCandidate[] /** Tracking links a pair only under this cost; shown beside each cost in the preview. */ costThreshold?: number - description?: string - emptyMessage?: string + description: string + emptyMessage: string isLoading?: boolean onScopeChange?: (key: MergeScopeKey) => void /** Single-select: clicking a row makes it the one selection. */ diff --git a/ui/src/components/track/useTrackCandidates.ts b/ui/src/components/track/useTrackCandidates.ts deleted file mode 100644 index 046bd0be5..000000000 --- a/ui/src/components/track/useTrackCandidates.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useCaptureDetails } from 'data-services/hooks/captures/useCaptureDetails' -import { useOccurrencesInCaptures } from 'data-services/hooks/occurrences/useOccurrencesInCaptures' -import { useMemo } from 'react' - -/** - * Occurrences worth merging with, or moving a frame to: the ones sharing a capture - * with the edit or with its neighbours. A track that tracking cut short continues - * in the capture straight after the last frame, and a session holds thousands. - */ -export const useTrackCandidates = ({ - captureIds, - excludeIds, - projectId, -}: { - /** Captures at the edges of the edit: the first and last frame in question. */ - captureIds: (string | undefined)[] - excludeIds: string[] - projectId: string -}) => { - const [firstCaptureId, lastCaptureId] = captureIds - - const first = useCaptureDetails({ - id: firstCaptureId ?? '', - projectId, - }) - const last = useCaptureDetails({ - id: lastCaptureId ?? '', - projectId, - }) - - const neighbourhood = useMemo( - () => - [ - first.capture?.prevCaptureId, - firstCaptureId, - lastCaptureId, - last.capture?.nextCaptureId, - ].filter((id): id is string => !!id), - [ - first.capture?.prevCaptureId, - firstCaptureId, - lastCaptureId, - last.capture?.nextCaptureId, - ] - ) - - const { occurrences, isLoading } = useOccurrencesInCaptures({ - captureIds: neighbourhood, - projectId, - }) - - const candidates = useMemo( - () => - occurrences - .filter((occurrence) => !excludeIds.includes(occurrence.id)) - .sort( - (o1, o2) => - new Date(o1.firstAppearanceTimestamp).getTime() - - new Date(o2.firstAppearanceTimestamp).getTime() - ), - [occurrences, excludeIds.join()] - ) - - return { - candidates, - // A disabled query still reports itself as loading, so guard on the ids. - isLoading: - (!!firstCaptureId && first.isLoading) || - (!!lastCaptureId && last.isLoading) || - isLoading, - } -} 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..62b9002dc --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts @@ -0,0 +1,55 @@ +import { + getDetectionError, + getMergeCandidatesParams, +} 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() + }) +}) diff --git a/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts b/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts index f61b54165..7938bfe4a 100644 --- a/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts +++ b/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts @@ -25,8 +25,45 @@ export const MERGE_SCOPES: MergeScope[] = [ { 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 +} + /** - * Occurrences this one could be merged with, ranked by the tracking method. + * 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 @@ -34,6 +71,7 @@ export const MERGE_SCOPES: MergeScope[] = [ */ export const useMergeCandidates = ({ captures, + detectionId, enabled, minutes = DEFAULT_WINDOW_MINUTES, occurrenceId, @@ -41,6 +79,8 @@ export const useMergeCandidates = ({ }: { /** 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 @@ -57,13 +97,12 @@ export const useMergeCandidates = ({ /** The window searched when no `captures` scope is given. */ minutes: number } => { - const params = new URLSearchParams({ project_id: projectId }) - - if (captures !== undefined) { - params.set('captures', `${captures}`) - } else { - params.set('minutes', `${minutes}`) - } + const params = getMergeCandidatesParams({ + captures, + detectionId, + minutes, + projectId, + }) const { data, isLoading, error } = useAuthorizedQuery({ enabled: !!occurrenceId && !!enabled, @@ -71,7 +110,7 @@ export const useMergeCandidates = ({ API_ROUTES.OCCURRENCES, occurrenceId, 'merge-candidates', - { captures, minutes, projectId }, + { captures, detectionId, minutes, projectId }, ], url: `${API_URL}/${API_ROUTES.OCCURRENCES}/${occurrenceId}/merge-candidates/?${params}`, }) diff --git a/ui/src/data-services/hooks/occurrences/useOccurrencesInCaptures.ts b/ui/src/data-services/hooks/occurrences/useOccurrencesInCaptures.ts deleted file mode 100644 index 9b1539892..000000000 --- a/ui/src/data-services/hooks/occurrences/useOccurrencesInCaptures.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useQueries } from '@tanstack/react-query' -import axios from 'axios' -import { API_ROUTES } from 'data-services/constants' -import { Occurrence, ServerOccurrence } from 'data-services/models/occurrence' -import { getAuthHeader, getFetchUrl } from 'data-services/utils' -import { useMemo } from 'react' -import { useUser } from 'utils/user/userContext' - -const OCCURRENCES_PER_CAPTURE = 25 - -/** - * Occurrences that have a detection in any of the given captures, as one list. - * - * One request per capture, so callers pass a handful of captures rather than a - * session's worth. - */ -export const useOccurrencesInCaptures = ({ - captureIds, - projectId, -}: { - captureIds: string[] - projectId: string -}): { - occurrences: Occurrence[] - isLoading: boolean -} => { - const { user } = useUser() - - // An id that is not a non-empty string would be dropped from the query string, - // turning one capture's occurrences into every occurrence in the project. - const requested = captureIds.filter((id) => typeof id === 'string' && !!id) - - const results = useQueries({ - queries: requested.map((captureId) => { - const params = { - projectId, - pagination: { page: 0, perPage: OCCURRENCES_PER_CAPTURE }, - filters: [ - { field: 'detections__source_image', value: captureId }, - // An occurrence a track edit created can score under the project's default - // threshold, which would keep it out of this list and make that edit - // impossible to undo from here. - { field: 'apply_defaults', value: 'false' }, - ], - } - - return { - queryKey: [API_ROUTES.OCCURRENCES, params], - queryFn: () => - axios - .get<{ results: ServerOccurrence[] }>( - getFetchUrl({ collection: API_ROUTES.OCCURRENCES, params }), - { headers: getAuthHeader(user) } - ) - .then((res) => res.data), - } - }), - }) - - const occurrences = useMemo(() => { - const byId = new Map() - - results.forEach((result) => { - result.data?.results.forEach((record) => { - const occurrence = new Occurrence(record) - byId.set(occurrence.id, occurrence) - }) - }) - - return [...byId.values()] - }, [results.map((result) => result.dataUpdatedAt).join()]) - - return { - occurrences, - isLoading: results.some((result) => result.isLoading), - } -} diff --git a/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx b/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx index 8564e2363..fb4963ed9 100644 --- a/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx +++ b/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx @@ -1,16 +1,25 @@ 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 { + getDetectionError, + 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' -import { useTrackCandidates } from 'components/track/useTrackCandidates' + +const DEFAULT_SCOPE: MergeScopeKey = 'near' export const FrameActionDialogs = ({ occurrence, @@ -23,17 +32,44 @@ export const FrameActionDialogs = ({ }) => { 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 { candidates, isLoading: candidatesLoading } = useTrackCandidates({ - captureIds: - pending?.action === 'move' ? [pending.captureId, pending.captureId] : [], - excludeIds: [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', + 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. @@ -139,9 +175,24 @@ export const FrameActionDialogs = ({ {move.result ? null : ( { + setScope(key) + setTargetId(undefined) + }} onSelect={setTargetId} + requiresFeatures={requiresFeatures} + scope={scope} selectedId={targetId} + sessionLink={candidateSessionLink} /> )} diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index c8f243772..151db18c1 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -490,6 +490,7 @@ export enum STRING { TRACK_MERGE_RESULT, TRACK_MERGE, TRACK_MOVE_DESCRIPTION, + TRACK_MOVE_SCOPE_DESCRIPTION, TRACK_MOVE_FRAME, TRACK_MOVE_RESULT, TRACK_NEXT_FRAME, @@ -498,7 +499,6 @@ export enum STRING { TRACK_OFFSET_MINUTES, TRACK_OFFSET_SECONDS, TRACK_OPEN_NEW_OCCURRENCE, - TRACK_NO_OTHER_OCCURRENCES, TRACK_PERCENT, TRACK_PATH_ERROR, TRACK_PATH_RANGE, @@ -510,7 +510,6 @@ export enum STRING { TRACK_PATH_GO_TO_FRAME, TRACK_PATH_GO_TO_NEAREST, TRACK_PICK_OCCURRENCE, - TRACK_PICK_OCCURRENCE_SCOPE, TRACK_POSITION_AFTER_LAST, TRACK_POSITION_BEFORE_FIRST, TRACK_POSITION_BETWEEN, @@ -1162,6 +1161,8 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [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', @@ -1171,8 +1172,6 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.TRACK_OFFSET_MINUTES]: '{{count}} min', [STRING.TRACK_OFFSET_SECONDS]: '{{count}} s', [STRING.TRACK_OPEN_NEW_OCCURRENCE]: 'Open it', - [STRING.TRACK_NO_OTHER_OCCURRENCES]: - 'No other occurrence shares a capture with this frame or the ones either side of it.', [STRING.TRACK_PATH_ERROR]: 'Could not load the path', [STRING.TRACK_PATH_RANGE]: '{{start}}–{{end}} · {{frames}}', [STRING.TRACK_PATH_STATUS_ERROR]: @@ -1186,8 +1185,6 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { 'Go to the nearest capture with this occurrence', [STRING.TRACK_PERCENT]: '{{percent}}%', [STRING.TRACK_PICK_OCCURRENCE]: 'Candidates near this frame', - [STRING.TRACK_PICK_OCCURRENCE_SCOPE]: - 'Occurrences sharing a capture with this frame or the ones either side of it. Occurrences further away in time are not listed.', [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}}', From e0345ccb0872a169f3bac86c64de3d79d83487a1 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 01:22:52 -0700 Subject: [PATCH 16/32] fix(ui): say when the candidate list failed to load instead of asking for a wider scope The merge and move pickers showed the normal empty-scope message whenever the candidates request failed for any reason other than an unusable frame, so a server error or a missing occurrence told the operator to widen the search. All three pickers now share one helper that shows the frame's reason, a load-failure message, or the empty-scope message, in that order. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../hooks/occurrences/useMergeCandidates.test.ts | 15 +++++++++++++++ .../hooks/occurrences/useMergeCandidates.ts | 11 ++++++++++- .../track/frame-action-dialogs.tsx | 7 ++----- .../occurrence-details/track/grouping-actions.tsx | 4 +++- .../capture/session-track-edits.tsx | 4 +++- ui/src/utils/language.ts | 3 +++ 6 files changed, 36 insertions(+), 8 deletions(-) diff --git a/ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts b/ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts index 62b9002dc..0e383e4a4 100644 --- a/ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts +++ b/ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts @@ -1,4 +1,5 @@ import { + getCandidatesEmptyMessage, getDetectionError, getMergeCandidatesParams, } from './useMergeCandidates' @@ -53,3 +54,17 @@ describe('getDetectionError', () => { 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.') + }) +}) diff --git a/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts b/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts index 7938bfe4a..ea9511599 100644 --- a/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts +++ b/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts @@ -5,7 +5,7 @@ import { ServerMergeCandidates, } from 'data-services/models/merge-candidate' import { useMemo } from 'react' -import { STRING } from 'utils/language' +import { STRING, translate } from 'utils/language' import { useAuthorizedQuery } from '../auth/useAuthorizedQuery' const DEFAULT_WINDOW_MINUTES = 5 @@ -61,6 +61,15 @@ export const getDetectionError = (error: unknown): string | undefined => { return message ? `${message}` : undefined } +/** 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) ?? + 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. diff --git a/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx b/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx index fb4963ed9..8b82eb026 100644 --- a/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx +++ b/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx @@ -2,7 +2,7 @@ import { useAddDetections } from 'data-services/hooks/occurrences/track/useAddDe import { useRemoveDetection } from 'data-services/hooks/occurrences/track/useRemoveDetection' import { useSplitTrack } from 'data-services/hooks/occurrences/track/useSplitTrack' import { - getDetectionError, + getCandidatesEmptyMessage, MERGE_SCOPES, MergeScopeKey, useMergeCandidates, @@ -179,10 +179,7 @@ export const FrameActionDialogs = ({ description={translate(STRING.TRACK_MOVE_SCOPE_DESCRIPTION, { scope: translate(moveScope.label).toLowerCase(), })} - emptyMessage={ - getDetectionError(candidatesError) ?? - translate(STRING.TRACK_NO_MERGE_CANDIDATES_SCOPE) - } + emptyMessage={getCandidatesEmptyMessage(candidatesError)} isLoading={candidatesLoading} onScopeChange={(key) => { setScope(key) diff --git a/ui/src/pages/occurrence-details/track/grouping-actions.tsx b/ui/src/pages/occurrence-details/track/grouping-actions.tsx index f47712eda..d0d496a13 100644 --- a/ui/src/pages/occurrence-details/track/grouping-actions.tsx +++ b/ui/src/pages/occurrence-details/track/grouping-actions.tsx @@ -4,6 +4,7 @@ import { useSetGroupingVerified } from 'data-services/hooks/occurrences/track/us import { MERGE_SCOPES, MergeScopeKey, + getCandidatesEmptyMessage, useMergeCandidates, } from 'data-services/hooks/occurrences/useMergeCandidates' import { MergeCandidate } from 'data-services/models/merge-candidate' @@ -50,6 +51,7 @@ export const GroupingActions = ({ const { candidates, costThreshold, + error: candidatesError, isLoading: candidatesLoading, requiresFeatures, } = useMergeCandidates({ @@ -194,7 +196,7 @@ export const GroupingActions = ({ description={translate(STRING.TRACK_MERGE_SCOPE_DESCRIPTION, { scope: translate(mergeScope.label).toLowerCase(), })} - emptyMessage={translate(STRING.TRACK_NO_MERGE_CANDIDATES_SCOPE)} + emptyMessage={getCandidatesEmptyMessage(candidatesError)} isLoading={candidatesLoading} onScopeChange={setScope} onToggle={toggleSource} diff --git a/ui/src/pages/session-details/capture/session-track-edits.tsx b/ui/src/pages/session-details/capture/session-track-edits.tsx index 64541a6e1..bfdb9be20 100644 --- a/ui/src/pages/session-details/capture/session-track-edits.tsx +++ b/ui/src/pages/session-details/capture/session-track-edits.tsx @@ -8,6 +8,7 @@ import { useSplitTrack } from 'data-services/hooks/occurrences/track/useSplitTra import { MERGE_SCOPES, MergeScopeKey, + getCandidatesEmptyMessage, useMergeCandidates, } from 'data-services/hooks/occurrences/useMergeCandidates' import { MergeCandidate } from 'data-services/models/merge-candidate' @@ -144,6 +145,7 @@ export const SessionTrackEdits = ({ const { candidates, costThreshold, + error: candidatesError, isLoading: candidatesLoading, requiresFeatures, } = useMergeCandidates({ @@ -251,7 +253,7 @@ export const SessionTrackEdits = ({ description={translate(STRING.TRACK_MERGE_SCOPE_DESCRIPTION, { scope: translate(mergeScope.label).toLowerCase(), })} - emptyMessage={translate(STRING.TRACK_NO_MERGE_CANDIDATES_SCOPE)} + emptyMessage={getCandidatesEmptyMessage(candidatesError)} isLoading={candidatesLoading} onScopeChange={setScope} onToggle={toggleSource} diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index 151db18c1..3105af49f 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -482,6 +482,7 @@ export enum STRING { 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, @@ -1148,6 +1149,8 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { '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]: From 172bfd89265cd6bd0bc0b71b4af768372fbe36ee Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 03:07:38 -0700 Subject: [PATCH 17/32] fix(ui): show track edit actions on a capture only to people who may edit the track The panel under a selected box offered Merge, Split here, Edit this track and Mark track as complete to everyone, including basic members and anonymous visitors on a public project, who then met a permission error. The panel now reads the occurrence's own permissions and applies the rule the occurrence page and the server already use: restructuring needs the occurrence delete right, confirming a grouping needs either the update or the delete right. Showing and hiding the path stay available to everyone. The rule lives in one helper so the occurrence page, the extend banner and the capture panel cannot drift apart. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../occurrence-details/occurrence-details.tsx | 8 +- .../track/track-edit-rights.test.ts | 29 +++++++ .../track/track-edit-rights.ts | 24 ++++++ .../session-details/capture/extend-track.tsx | 6 +- .../capture/occurrence-toolbar.tsx | 81 ++++++++++++------- 5 files changed, 109 insertions(+), 39 deletions(-) create mode 100644 ui/src/pages/occurrence-details/track/track-edit-rights.test.ts create mode 100644 ui/src/pages/occurrence-details/track/track-edit-rights.ts diff --git a/ui/src/pages/occurrence-details/occurrence-details.tsx b/ui/src/pages/occurrence-details/occurrence-details.tsx index cf9bdc9b0..ba298a96d 100644 --- a/ui/src/pages/occurrence-details/occurrence-details.tsx +++ b/ui/src/pages/occurrence-details/occurrence-details.tsx @@ -44,6 +44,7 @@ import { FrameActionDialogs } from './track/frame-action-dialogs' import { FrameCaption } from './track/frame-caption' import { FrameMenu } from './track/frame-menu' import { GroupingActions } from './track/grouping-actions' +import { getTrackEditRights } from './track/track-edit-rights' import { PendingFrameAction } from './track/types' export const TABS = { @@ -102,12 +103,9 @@ export const OccurrenceDetails = ({ const [pendingFrameAction, setPendingFrameAction] = useState() const canUpdate = occurrence.userPermissions.includes(UserPermission.Update) - // Restructuring a grouping is gated on the occurrence delete right, confirming one - // on either right — the same split the API makes. See #1272. - const canRestructure = occurrence.userPermissions.includes( - UserPermission.Delete + const { canRestructure, canVerify: canVerifyGrouping } = getTrackEditRights( + occurrence.userPermissions ) - const canVerifyGrouping = canUpdate || canRestructure const trackingEnabled = useProjectFeature('tracking') const sessionRoute = occurrence.sessionId 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/session-details/capture/extend-track.tsx b/ui/src/pages/session-details/capture/extend-track.tsx index 5760bdced..6b71c81eb 100644 --- a/ui/src/pages/session-details/capture/extend-track.tsx +++ b/ui/src/pages/session-details/capture/extend-track.tsx @@ -24,9 +24,9 @@ import { ReactNode, useEffect, 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 { UserPermission } from 'utils/user/types' import { useExtendOccurrenceId } from '../hooks/useExtendOccurrenceId' import { ExtendChoice, @@ -383,9 +383,7 @@ export const ExtendTrackBanner = ({ frames: occurrence?.frames ?? [], }) const { first, last, position, total } = navigation - const canVerify = - !!occurrence?.userPermissions.includes(UserPermission.Update) || - !!occurrence?.userPermissions.includes(UserPermission.Delete) + 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 diff --git a/ui/src/pages/session-details/capture/occurrence-toolbar.tsx b/ui/src/pages/session-details/capture/occurrence-toolbar.tsx index cceb7df0d..974a2068c 100644 --- a/ui/src/pages/session-details/capture/occurrence-toolbar.tsx +++ b/ui/src/pages/session-details/capture/occurrence-toolbar.tsx @@ -1,8 +1,10 @@ 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' @@ -97,6 +99,14 @@ export const OccurrenceToolbar = ({ }) => { 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. @@ -177,21 +187,24 @@ export const OccurrenceToolbar = ({ ) : null, ].filter(Boolean) - const editActions = [ - pathShown ? ( - - ) : null, - , - !isExtended ? ( - - ) : null, - ].filter(Boolean) + const editActions = canRestructure + ? [ + pathShown ? ( + + ) : null, + , + !isExtended ? ( + + ) : null, + ].filter(Boolean) + : [] + const showVerify = pathShown && canVerify return (
@@ -279,23 +292,31 @@ export const OccurrenceToolbar = ({ ) : null}
-
- {pathActions.length ? ( -
{pathActions}
- ) : null} + {pathActions.length || editActions.length || showVerify ? ( +
+ {pathActions.length ? ( +
+ {pathActions} +
+ ) : null} -
{editActions}
+ {editActions.length ? ( +
+ {editActions} +
+ ) : null} - {pathShown ? ( - - ) : null} -
+ {showVerify ? ( + + ) : null} +
+ ) : null}
) } From cf36244b6d321fa4797917843e6a392fd7086f01 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 03:08:26 -0700 Subject: [PATCH 18/32] fix(ui): open edit-track mode only for an editable occurrence in this session A link carrying ?extend= opened edit-track mode for anyone, including viewers who cannot edit the occurrence, and it also drew an occurrence from another session over the one on screen. The mode now waits for the occurrence to load and opens only when the viewer holds the right to restructure it and it belongs to the session being viewed. Otherwise the parameter is ignored and the capture view behaves as if it were absent. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../capture/extend-access.test.ts | 61 +++++++++++++++++++ .../session-details/capture/extend-access.ts | 37 +++++++++++ .../session-details/capture/extend-track.tsx | 16 ++++- .../pages/session-details/session-details.tsx | 1 + 4 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 ui/src/pages/session-details/capture/extend-access.test.ts create mode 100644 ui/src/pages/session-details/capture/extend-access.ts 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-track.tsx b/ui/src/pages/session-details/capture/extend-track.tsx index 6b71c81eb..8f4e4e9cf 100644 --- a/ui/src/pages/session-details/capture/extend-track.tsx +++ b/ui/src/pages/session-details/capture/extend-track.tsx @@ -28,6 +28,7 @@ import { getTrackEditRights } from 'pages/occurrence-details/track/track-edit-ri 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, @@ -86,6 +87,7 @@ export const useExtendTrack = ({ enabled, nextCaptureId, onSelectCapture, + sessionId, }: { captureId?: string /** Off, an `extend` link is ignored rather than opening the mode. */ @@ -93,15 +95,23 @@ export const useExtendTrack = ({ /** 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 extendOccurrenceId = enabled ? requestedOccurrenceId : undefined - const { occurrence: track } = useOccurrenceDetails( - extendOccurrenceId ?? '', + const { occurrence: requestedTrack } = useOccurrenceDetails( + enabled ? requestedOccurrenceId ?? '' : '', EXTEND_FETCH_OPTIONS ) + const extendOccurrenceId = getExtendOccurrenceId({ + enabled, + requestedOccurrenceId, + sessionId, + track: requestedTrack, + }) + const track = extendOccurrenceId ? requestedTrack : undefined const [choice, setChoice] = useState() // The merge deletes the clicked occurrence, so its frames are read while it still exists. const { occurrence: clickedTrack } = useOccurrenceDetails( diff --git a/ui/src/pages/session-details/session-details.tsx b/ui/src/pages/session-details/session-details.tsx index f3c422bcf..50fd73b72 100644 --- a/ui/src/pages/session-details/session-details.tsx +++ b/ui/src/pages/session-details/session-details.tsx @@ -122,6 +122,7 @@ const Content = ({ session }: { session: SessionDetails }) => { ? getNextCaptureWithDetectionsId({ capture: activeCapture, timeline }) : undefined, onSelectCapture: setActiveCaptureId, + sessionId: session.id, }) // While extending, the track being built is the only selection. const timelineOccurrenceIds = extend.occurrenceId From b77e9e8d10dddcbad499ecdc020d18f66900a26c Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 03:09:13 -0700 Subject: [PATCH 19/32] fix(ui): give the move-frame dialog room for its ranked columns The Move to another occurrence dialog rendered at the narrow default width, so the ranked candidate columns were clipped. It now uses the wide layout the merge dialogs already use. Closing the dialog also returns the scope to its default, so the next frame starts from the same place, and the candidate list stops refetching once the move has succeeded and only the result is shown. The unused capture id on a pending frame action is removed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/pages/occurrence-details/occurrence-details.tsx | 1 - .../pages/occurrence-details/track/frame-action-dialogs.tsx | 4 +++- ui/src/pages/occurrence-details/track/types.ts | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/occurrence-details/occurrence-details.tsx b/ui/src/pages/occurrence-details/occurrence-details.tsx index ba298a96d..7a3827ee1 100644 --- a/ui/src/pages/occurrence-details/occurrence-details.tsx +++ b/ui/src/pages/occurrence-details/occurrence-details.tsx @@ -449,7 +449,6 @@ export const OccurrenceDetails = ({ onAction={(action) => setPendingFrameAction({ action, - captureId: item.captureId, detectionId: item.id, movedBySplit: index + 1, timeLabel: item.timeLabel, diff --git a/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx b/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx index 8b82eb026..18224a394 100644 --- a/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx +++ b/ui/src/pages/occurrence-details/track/frame-action-dialogs.tsx @@ -48,7 +48,7 @@ export const FrameActionDialogs = ({ } = useMergeCandidates({ captures: moveScope.captures, detectionId: pending?.detectionId, - enabled: pending?.action === 'move', + enabled: pending?.action === 'move' && !move.result, minutes: moveScope.minutes, occurrenceId: occurrence.id, projectId: projectId as string, @@ -84,6 +84,7 @@ export const FrameActionDialogs = ({ const close = () => { setTargetId(undefined) + setScope(DEFAULT_SCOPE) split.reset() remove.reset() move.reset() @@ -161,6 +162,7 @@ export const FrameActionDialogs = ({ isLoading={move.isLoading} onConfirm={() => move.addDetections([pending.detectionId])} onOpenChange={(open) => (open ? undefined : close())} + isWide open={pending.action === 'move'} result={ move.result diff --git a/ui/src/pages/occurrence-details/track/types.ts b/ui/src/pages/occurrence-details/track/types.ts index 5598748da..9df8974b3 100644 --- a/ui/src/pages/occurrence-details/track/types.ts +++ b/ui/src/pages/occurrence-details/track/types.ts @@ -6,7 +6,6 @@ export type FrameAction = 'split' | 'remove' | 'move' */ export interface PendingFrameAction { action: FrameAction - captureId?: string detectionId: string /** Frames a split would move: this frame and every later one. */ movedBySplit: number From 866e532fe8801c9dc172da8a7b9e8643aa24c9fd Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 03:10:27 -0700 Subject: [PATCH 20/32] fix(ui): explain why merge candidates failed to load and stop retrying refusals A candidates request the server refused (not signed in, no permission, or no such occurrence) was retried three times before the picker gave up, and then showed a generic load failure. Those refusals are now final on the first answer, while other failures still retry. When the server explains a failure, the picker shows that explanation instead of the generic message. The authorized query hook now accepts a retry function as well as a count. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../hooks/auth/useAuthorizedQuery.ts | 4 +- .../occurrences/useMergeCandidates.test.ts | 49 +++++++++++++++++++ .../hooks/occurrences/useMergeCandidates.ts | 33 +++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/ui/src/data-services/hooks/auth/useAuthorizedQuery.ts b/ui/src/data-services/hooks/auth/useAuthorizedQuery.ts index 8eedb0018..c61a5eae3 100644 --- a/ui/src/data-services/hooks/auth/useAuthorizedQuery.ts +++ b/ui/src/data-services/hooks/auth/useAuthorizedQuery.ts @@ -1,4 +1,4 @@ -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' @@ -16,7 +16,7 @@ export const useAuthorizedQuery = ({ onError?: (error: unknown) => void queryKey?: QueryKey refetchInterval?: number - retry?: number + retry?: UseQueryOptions['retry'] staleTime?: number url: string }) => { diff --git a/ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts b/ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts index 0e383e4a4..2b52bb2fe 100644 --- a/ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts +++ b/ui/src/data-services/hooks/occurrences/useMergeCandidates.test.ts @@ -2,6 +2,8 @@ import { getCandidatesEmptyMessage, getDetectionError, getMergeCandidatesParams, + getServerMessage, + shouldRetryCandidates, } from './useMergeCandidates' describe('getMergeCandidatesParams', () => { @@ -68,3 +70,50 @@ describe('getCandidatesEmptyMessage', () => { ).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 index ea9511599..2b5c80374 100644 --- a/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts +++ b/ui/src/data-services/hooks/occurrences/useMergeCandidates.ts @@ -6,9 +6,12 @@ import { } 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' @@ -61,9 +64,38 @@ export const getDetectionError = (error: unknown): string | 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 @@ -121,6 +153,7 @@ export const useMergeCandidates = ({ 'merge-candidates', { captures, detectionId, minutes, projectId }, ], + retry: shouldRetryCandidates, url: `${API_URL}/${API_ROUTES.OCCURRENCES}/${occurrenceId}/merge-candidates/?${params}`, }) From 08fc072586d2f38d0b31eb156ee8853dafd19e50 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 03:11:53 -0700 Subject: [PATCH 21/32] fix(ui): name tracking and other post-processing jobs in the jobs list Tracking runs as a post-processing job, a type the interface did not know, so the jobs list showed a blank type and the job page showed n/a. Post-processing now has its own label and appears in the type filter, and any job type the interface has not learned yet shows the name the server gives it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/data-services/models/job.test.ts | 19 +++++++++++++++++++ ui/src/data-services/models/job.ts | 24 +++++++++++++++--------- 2 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 ui/src/data-services/models/job.test.ts 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, From 81b3309731bed8e6fc872180ea95d52c492acbea Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 03:12:26 -0700 Subject: [PATCH 22/32] fix(ui): keep an occurrence open after confirming it when it is not in the list Confirming an occurrence moves on to the next one in the list, and closes the dialog after the last. An occurrence opened from a link that the current filters leave out of the list also closed the dialog, which hid the result of the confirmation. The dialog now closes only when the confirmed occurrence is the last one listed and otherwise stays on it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/pages/occurrences/use-advance-on-confirm.test.ts | 9 +++++++++ ui/src/pages/occurrences/use-advance-on-confirm.ts | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/occurrences/use-advance-on-confirm.test.ts b/ui/src/pages/occurrences/use-advance-on-confirm.test.ts index 982a71db4..683c1ce69 100644 --- a/ui/src/pages/occurrences/use-advance-on-confirm.test.ts +++ b/ui/src/pages/occurrences/use-advance-on-confirm.test.ts @@ -34,6 +34,15 @@ describe('useAdvanceOnConfirm', () => { 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') diff --git a/ui/src/pages/occurrences/use-advance-on-confirm.ts b/ui/src/pages/occurrences/use-advance-on-confirm.ts index edb1d4897..b1c64a465 100644 --- a/ui/src/pages/occurrences/use-advance-on-confirm.ts +++ b/ui/src/pages/occurrences/use-advance-on-confirm.ts @@ -2,7 +2,7 @@ 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. A confirm that lands after the user has moved on is ignored. +// after the last one. An unlisted item, or one the user has left, stays put. export const useAdvanceOnConfirm = ({ close, currentId, @@ -30,10 +30,11 @@ export const useAdvanceOnConfirm = ({ if (confirmedId !== currentIdRef.current) { return } - const { nextId } = getAdjacentIds(latest.current.items, confirmedId) + const { items } = latest.current + const { nextId } = getAdjacentIds(items, confirmedId) if (nextId) { latest.current.goTo(nextId) - } else { + } else if (items?.[items.length - 1]?.id === confirmedId) { latest.current.close() } }, []) From 7c3f8a04c400e47f0e4ae7b20b9e14517da8dfd6 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 03:14:52 -0700 Subject: [PATCH 23/32] fix(ui): offer the tracks export only on projects that use tracking The export form listed the tracks CSV format on every project, although a project with tracking turned off has no tracks to export. The format is now listed only when the project's tracking feature is on. The help text above the form claimed there were exactly two formats; it now describes the occurrence formats and mentions that projects using tracking can also export tracks. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/data-services/models/export.test.ts | 22 ++++++++++++ ui/src/data-services/models/export.ts | 8 +++++ .../details-form/export-details-form.tsx | 35 +++++++++++-------- ui/src/utils/language.ts | 2 +- 4 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 ui/src/data-services/models/export.test.ts 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 c999efcb3..baca5395a 100644 --- a/ui/src/data-services/models/export.ts +++ b/ui/src/data-services/models/export.ts @@ -11,6 +11,14 @@ export const SERVER_EXPORT_TYPES = [ 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 { 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/utils/language.ts b/ui/src/utils/language.ts index 3105af49f..3621ccb22 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -820,7 +820,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.', From f2d93219d1aebc7882c5a633eebaa4076e04a1a6 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 03:15:22 -0700 Subject: [PATCH 24/32] fix(ui): mark Edit in session view with a pencil icon The button that opens the track in the session view for editing still carried the plus icon from when it only added frames. It now uses the pencil icon the other track edit controls use. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/pages/occurrence-details/track/grouping-actions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/occurrence-details/track/grouping-actions.tsx b/ui/src/pages/occurrence-details/track/grouping-actions.tsx index d0d496a13..b66e5a55b 100644 --- a/ui/src/pages/occurrence-details/track/grouping-actions.tsx +++ b/ui/src/pages/occurrence-details/track/grouping-actions.tsx @@ -9,7 +9,7 @@ import { } 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, PlusIcon } from 'lucide-react' +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' @@ -142,7 +142,7 @@ export const GroupingActions = ({ className={buttonVariants({ size: 'small', variant: 'outline' })} to={extendRoute} > - + {translate(STRING.TRACK_EXTEND_IN_SESSION)} ) : null} From 2d10fe40942a1e89190171b70e745874e5c14ebf Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 23 Sep 2026 05:11:41 -0700 Subject: [PATCH 25/32] fix(ui): keep the move dialog compact with species names on one line Species names in the occurrence picker stay on one line, cut with an ellipsis past a sensible width and shown in full on hover. The wide track edit dialogs, such as move and merge, are wider so every ranked column fits beside the name, and they size to their content up to the viewport height instead of always filling it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/components/track/occurrence-picker.tsx | 6 ++++-- ui/src/components/track/track-edit-dialog.tsx | 8 +++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ui/src/components/track/occurrence-picker.tsx b/ui/src/components/track/occurrence-picker.tsx index 9d062ddf3..8987a8683 100644 --- a/ui/src/components/track/occurrence-picker.tsx +++ b/ui/src/components/track/occurrence-picker.tsx @@ -491,10 +491,11 @@ export const OccurrencePicker = ({ /> ) : null} - + {/* One line per row; long names are cut and shown in full on hover. */} + +) + +/** 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 index b66e5a55b..22d4295e1 100644 --- a/ui/src/pages/occurrence-details/track/grouping-actions.tsx +++ b/ui/src/pages/occurrence-details/track/grouping-actions.tsx @@ -77,7 +77,7 @@ export const GroupingActions = ({ // Extending opens the session view on the track's newest frame, where the next // capture is a click away. - const lastCaptureId = occurrence.frames[0]?.captureId + const lastCaptureId = occurrence.lastFrame?.captureId const extendRoute = sessionRoute && lastCaptureId ? getAppRoute({ diff --git a/ui/src/pages/session-details/capture/extend-track.tsx b/ui/src/pages/session-details/capture/extend-track.tsx index 8f4e4e9cf..8ca463190 100644 --- a/ui/src/pages/session-details/capture/extend-track.tsx +++ b/ui/src/pages/session-details/capture/extend-track.tsx @@ -8,8 +8,10 @@ import { useMergeOccurrences } from 'data-services/hooks/occurrences/track/useMe 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, @@ -20,7 +22,7 @@ import { RouteIcon, } from 'lucide-react' import { BasicTooltip, Button, LoadingSpinner } from 'nova-ui-kit' -import { ReactNode, useEffect, useRef, useState } from 'react' +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' @@ -48,6 +50,20 @@ 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 }, @@ -111,13 +127,10 @@ export const useExtendTrack = ({ sessionId, track: requestedTrack, }) - const track = extendOccurrenceId ? requestedTrack : undefined + 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 { occurrence: clickedTrack } = useOccurrenceDetails( - choice?.occurrenceId ?? '', - EXTEND_FETCH_OPTIONS - ) + const { frames: clickedFrames } = useTrackFrames(choice?.occurrenceId) const fetchCaptureDetails = useFetchCaptureDetails(projectId as string) const [note, setNote] = useState() const [pending, setPending] = useState() @@ -219,11 +232,11 @@ export const useExtendTrack = ({ // Every branch reads the track's frames, so a click waits for them to load. const previewClick = (detection: CaptureDetection) => - extendOccurrenceId && track && !isLoading + extendOccurrenceId && trackFrames && !isLoading ? getExtendClick({ captureId, detection, - frames: track.frames, + frames: trackFrames, occurrenceId: extendOccurrenceId, }) : undefined @@ -289,9 +302,9 @@ export const useExtendTrack = ({ mergeChoice: () => { if (choice) { const extent = getMergedTrackExtent({ - addedFrames: clickedTrack?.frames ?? [], + addedFrames: clickedFrames ?? [], clickedDetectionId: choice.detectionId, - trackFrames: track?.frames ?? [], + trackFrames: trackFrames ?? [], }) merge @@ -387,10 +400,11 @@ export const ExtendTrackBanner = ({ EXTEND_FETCH_OPTIONS ) const verify = useSetGroupingVerified(occurrenceId) + const { frames } = useTrackFrames(occurrenceId) const navigation = getTrackNavigation({ captureDate, captureId, - frames: occurrence?.frames ?? [], + frames: frames ?? [], }) const { first, last, position, total } = navigation const { canVerify } = getTrackEditRights(occurrence?.userPermissions) @@ -632,38 +646,26 @@ export const ExtendTrackDialog = ({ occurrenceId: string }) => { const { - occurrence: clicked, + frames: clicked, isLoading: clickedLoading, error: clickedError, - } = useOccurrenceDetails(choice.occurrenceId, EXTEND_FETCH_OPTIONS) + } = useTrackFrames(choice.occurrenceId) const { - occurrence: extended, + frames: extended, isLoading: extendedLoading, error: extendedError, - } = useOccurrenceDetails(occurrenceId, EXTEND_FETCH_OPTIONS) + } = 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?.frames.map((frame) => frame.captureId) - ) + const coveredCaptureIds = new Set(extended?.map((frame) => frame.captureId)) const isCovered = (captureId?: string) => !!captureId && coveredCaptureIds.has(captureId) - const clickedFrame = clicked?.frames.find( - (frame) => frame.id === choice.detectionId - ) - const mergeBlocked = !!clicked?.frames.some((frame) => - isCovered(frame.captureId) - ) + const clickedFrame = clicked?.find((frame) => frame.id === choice.detectionId) + const mergeBlocked = !!clicked?.some((frame) => isCovered(frame.captureId)) const moveBlocked = isCovered(clickedFrame?.captureId) - const clickedCrop = clickedFrame - ? clicked?.getDetectionInfo(choice.detectionId) - : undefined - const latestFrameId = extended?.frames[0]?.id - const latestCrop = latestFrameId - ? extended?.getDetectionInfo(latestFrameId) - : undefined + const latestFrame = extended?.[0] return (
diff --git a/ui/src/pages/session-details/capture/track-navigation.test.ts b/ui/src/pages/session-details/capture/track-navigation.test.ts index 9c693b7d4..248e8f228 100644 --- a/ui/src/pages/session-details/capture/track-navigation.test.ts +++ b/ui/src/pages/session-details/capture/track-navigation.test.ts @@ -15,7 +15,7 @@ const frame = (minute: number): TrackFrame => ({ timeLabel: '', }) -// Newest first, the order OccurrenceDetails.frames holds them in. +// Newest first, the order getTrackFrames returns them in. const FRAMES = [frame(30), frame(20), frame(10)] describe('getTrackNavigation', () => { diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index dc66a1b3f..2b3f6908a 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -444,6 +444,12 @@ export enum STRING { 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, @@ -1104,6 +1110,12 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [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',