From 3df61f156dcfcdd9e11450708b198f409eea8215 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Mon, 28 Sep 2026 14:46:41 -0700 Subject: [PATCH 1/7] feat(ui): hide the crops along a track's path until the viewer asks for them A long track that barely moves stacks its crops on top of each other and hides the capture underneath. The path now draws outlines only by default, and the existing toggle is remembered per browser through the user preferences store, whose reads and writes now tolerate storage that throws. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../pages/session-details/session-details.tsx | 24 +++++-- ui/src/utils/userPreferences/constants.ts | 1 + ui/src/utils/userPreferences/types.ts | 2 + .../userPreferencesContext.test.tsx | 69 +++++++++++++++++++ .../userPreferencesContext.tsx | 24 ++++--- 5 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 ui/src/utils/userPreferences/userPreferencesContext.test.tsx diff --git a/ui/src/pages/session-details/session-details.tsx b/ui/src/pages/session-details/session-details.tsx index 50fd73b72..d5b0b379a 100644 --- a/ui/src/pages/session-details/session-details.tsx +++ b/ui/src/pages/session-details/session-details.tsx @@ -24,6 +24,7 @@ import { useProjectFeature, } from 'utils/project-features/useProjectFeature' import { useUser } from 'utils/user/userContext' +import { useUserPreferences } from 'utils/userPreferences/userPreferencesContext' import { ActivityPlot } from './activity-plot/lazy-activity-plot' import { OccurrenceTimelineMarkers } from './activity-plot/occurrence-timeline-markers' import { CaptureInfo } from './capture-info' @@ -94,11 +95,22 @@ const Content = ({ session }: { session: SessionDetails }) => { // Settings const [poll, setPoll] = useState(false) const transformRef = useRef(null) - const [settings, setSettings] = useState({ + const [viewSettings, setViewSettings] = useState({ defaultFilters: true, showDetections: true, - showPathCrops: true, }) + // Kept per browser: a reviewer who wants the crops along a path wants them every visit. + const { userPreferences, setUserPreferences } = useUserPreferences() + const settings: ViewSettingsValues = { + ...viewSettings, + showPathCrops: !!userPreferences.showPathCrops, + } + const setSettings = ({ showPathCrops, ...rest }: ViewSettingsValues) => { + setViewSettings(rest) + if (showPathCrops !== settings.showPathCrops) { + setUserPreferences({ ...userPreferences, showPathCrops }) + } + } // Data const { projectId } = useParams() @@ -210,10 +222,10 @@ const Content = ({ session }: { session: SessionDetails }) => { height={activeCapture?.height ?? session.firstCapture.height} trackingEnabled={trackingEnabled} onTogglePathCrops={() => - setSettings((current) => ({ - ...current, - showPathCrops: !current.showPathCrops, - })) + setSettings({ + ...settings, + showPathCrops: !settings.showPathCrops, + }) } showDetections={settings.showDetections} showPathCrops={settings.showPathCrops} diff --git a/ui/src/utils/userPreferences/constants.ts b/ui/src/utils/userPreferences/constants.ts index 742381e12..c85f6f31d 100644 --- a/ui/src/utils/userPreferences/constants.ts +++ b/ui/src/utils/userPreferences/constants.ts @@ -5,5 +5,6 @@ export const USER_PREFERENCES_STORAGE_KEY = 'ami-user-preferences' export const DEFAULT_PREFERENCES: UserPreferences = { columnSettings: {}, recentIdentifications: [], + showPathCrops: false, termsMessageSeen: false, } diff --git a/ui/src/utils/userPreferences/types.ts b/ui/src/utils/userPreferences/types.ts index ee1d8d23d..d667cfcd5 100644 --- a/ui/src/utils/userPreferences/types.ts +++ b/ui/src/utils/userPreferences/types.ts @@ -5,6 +5,8 @@ export interface UserPreferences { label: string value: string }[] + /** Fill the boxes along a track's path with each frame's crop, not just an outline. */ + showPathCrops?: boolean termsMessageSeen?: boolean } diff --git a/ui/src/utils/userPreferences/userPreferencesContext.test.tsx b/ui/src/utils/userPreferences/userPreferencesContext.test.tsx new file mode 100644 index 000000000..c9ba70f79 --- /dev/null +++ b/ui/src/utils/userPreferences/userPreferencesContext.test.tsx @@ -0,0 +1,69 @@ +import { act, renderHook } from '@testing-library/react' +import { ReactNode } from 'react' +import { CookieConsentContext } from 'utils/cookieConsent/cookieConsentContext' +import { DEFAULT_SETTINGS } from 'utils/cookieConsent/constants' +import { USER_PREFERENCES_STORAGE_KEY } from './constants' +import { + UserPreferencesContextProvider, + useUserPreferences, +} from './userPreferencesContext' + +const wrapper = ({ children }: { children: ReactNode }) => ( + {}, + }} + > + {children} + +) + +describe('useUserPreferences', () => { + beforeEach(() => localStorage.clear()) + afterEach(() => jest.restoreAllMocks()) + + test('path crops are off until the viewer turns them on, and stay on', () => { + const first = renderHook(() => useUserPreferences(), { wrapper }) + expect(first.result.current.userPreferences.showPathCrops).toBe(false) + + act(() => + first.result.current.setUserPreferences({ + ...first.result.current.userPreferences, + showPathCrops: true, + }) + ) + + const second = renderHook(() => useUserPreferences(), { wrapper }) + expect(second.result.current.userPreferences.showPathCrops).toBe(true) + }) + + test('storage that throws falls back to defaults and still applies changes', () => { + localStorage.setItem( + USER_PREFERENCES_STORAGE_KEY, + JSON.stringify({ showPathCrops: true }) + ) + jest + .spyOn(Object.getPrototypeOf(window.localStorage), 'getItem') + .mockImplementation(() => { + throw new Error('blocked') + }) + jest + .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') + .mockImplementation(() => { + throw new Error('blocked') + }) + + const { result } = renderHook(() => useUserPreferences(), { wrapper }) + expect(result.current.userPreferences.showPathCrops).toBe(false) + + act(() => + result.current.setUserPreferences({ + ...result.current.userPreferences, + showPathCrops: true, + }) + ) + expect(result.current.userPreferences.showPathCrops).toBe(true) + }) +}) diff --git a/ui/src/utils/userPreferences/userPreferencesContext.tsx b/ui/src/utils/userPreferences/userPreferencesContext.tsx index d58bd75e9..ff4e620a5 100644 --- a/ui/src/utils/userPreferences/userPreferencesContext.tsx +++ b/ui/src/utils/userPreferences/userPreferencesContext.tsx @@ -20,12 +20,14 @@ export const UserPreferencesContextProvider = ({ const [userPreferences, setUserPreferences] = useState( () => { // TODO: For logged in users, here we could check backend for stored preferences - const storedPreferences = - hasStorageConsent && localStorage.getItem(USER_PREFERENCES_STORAGE_KEY) - if (!storedPreferences) { - return DEFAULT_PREFERENCES - } + // Storage can throw (blocked site data, some private windows); defaults stand in. try { + const storedPreferences = + hasStorageConsent && + localStorage.getItem(USER_PREFERENCES_STORAGE_KEY) + if (!storedPreferences) { + return DEFAULT_PREFERENCES + } return { ...DEFAULT_PREFERENCES, ...JSON.parse(storedPreferences), @@ -43,10 +45,14 @@ export const UserPreferencesContextProvider = ({ setUserPreferences: (userPreferences: UserPreferences) => { // TODO: For logged in users, here we could sync preferences to backend if (hasStorageConsent) { - localStorage.setItem( - USER_PREFERENCES_STORAGE_KEY, - JSON.stringify(userPreferences) - ) + try { + localStorage.setItem( + USER_PREFERENCES_STORAGE_KEY, + JSON.stringify(userPreferences) + ) + } catch { + // The preference still applies for this visit. + } } setUserPreferences(userPreferences) }, From d6e273ce12b61112578440d662d94e8b47877c0f Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Mon, 28 Sep 2026 14:49:45 -0700 Subject: [PATCH 2/7] fix(ui): keep capture panels at full size and inside the page The panels on a capture's boxes rendered inside the zoomable image, so they grew with the zoom and were clipped by the image's edges. They now render in a portal at their normal size, flip or shift to stay within the viewport, and fade out without catching clicks when their box is panned out of view. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../capture/capture-ghost-trail.tsx | 32 +- .../capture/capture.module.scss | 6 + .../pages/session-details/capture/capture.tsx | 291 +++++++++--------- .../session-details/capture/panel-layer.ts | 3 + 4 files changed, 180 insertions(+), 152 deletions(-) create mode 100644 ui/src/pages/session-details/capture/panel-layer.ts diff --git a/ui/src/pages/session-details/capture/capture-ghost-trail.tsx b/ui/src/pages/session-details/capture/capture-ghost-trail.tsx index e8efeba2f..57518c84b 100644 --- a/ui/src/pages/session-details/capture/capture-ghost-trail.tsx +++ b/ui/src/pages/session-details/capture/capture-ghost-trail.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames' import { getOffsetLabel } from 'data-services/models/merge-candidate' import { Tooltip } from 'nova-ui-kit' import { useState } from 'react' @@ -5,6 +6,7 @@ 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' +import { PANEL_LAYER } from './panel-layer' // 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. @@ -132,18 +134,24 @@ export const CaptureGhostTrail = ({ ) : null} - - - + + + + + {gap ? ( setScale(state.scale)} + onTransform={(ref, state) => { + setScale(state.scale) + // The box panels live in a portal and reposition on scroll, not on transforms. + ref.instance.wrapperComponent?.dispatchEvent(new Event('scroll')) + }} ref={transformRef} > { - // 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. @@ -653,7 +655,7 @@ const CaptureDetections = ({ return ( <> -
+
{Object.entries(boxStyles).map(([id, style]) => { const detection = detections.find((d) => d.id === id) @@ -729,54 +731,57 @@ const CaptureDetections = ({ open={hoveredBox === detection.id || (plainLabel && isActive)} > {box} - hoverBox(detection.id)} - onMouseLeave={() => unhoverBox(detection.id)} - side="bottom" - > - {detailsHidden ? ( - - ) : ( -
- {plainLabel ? ( - - ) : ( - - {detection.label} - - )} - + hoverBox(detection.id)} + onMouseLeave={() => unhoverBox(detection.id)} + side="bottom" + > + {detailsHidden ? ( + -
- )} -
+ ) : ( +
+ {plainLabel ? ( + + ) : ( + + {detection.label} + + )} + +
+ )} + + ) @@ -785,95 +790,101 @@ const CaptureDetections = ({ return ( {box} - { - if (isActive) { - setDismissed(detection.occurrenceId as string, true) - } else { - setHoveredBox(undefined) - } - }} - onMouseEnter={() => hoverBox(detection.id)} - onMouseLeave={() => unhoverBox(detection.id)} - // The panel follows a selection or a hover rather than a deliberate - // open, so it must not pull focus off the capture. - onOpenAutoFocus={(event) => event.preventDefault()} - side="bottom" - > - + { + if (isActive) { + setDismissed(detection.occurrenceId as string, true) + } else { + setHoveredBox(undefined) + } }} - onExtend={() => - 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 - } - /> - + 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 + } + /> + + ) })} diff --git a/ui/src/pages/session-details/capture/panel-layer.ts b/ui/src/pages/session-details/capture/panel-layer.ts new file mode 100644 index 000000000..4b9571eda --- /dev/null +++ b/ui/src/pages/session-details/capture/panel-layer.ts @@ -0,0 +1,3 @@ +// Panels are portalled out of the zoomed image so they keep their size and can flip +// into the page when a box sits near the image's edge. Above the page, below dialogs. +export const PANEL_LAYER = 'z-40' From 6b0a329c5cd99ba6642742435d37bf295c3b2e04 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Mon, 28 Sep 2026 14:56:29 -0700 Subject: [PATCH 3/7] feat(ui): let a column clicked earlier break the ties of the merge candidate sort Reviewers asked to order candidates by species and then by frames, the way a spreadsheet sorts one column at a time. Clicking a column header now keeps the previous one or two columns as tie-breakers, numbers them in the header, and names the whole order in the order control. The server ranking stays the default until a header is clicked. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/components/track/occurrence-picker.tsx | 32 ++++++-- .../models/merge-candidate.test.ts | 74 +++++++++++++++++++ .../data-services/models/merge-candidate.ts | 74 ++++++++++++++++--- ui/src/utils/language.ts | 5 ++ 4 files changed, 170 insertions(+), 15 deletions(-) diff --git a/ui/src/components/track/occurrence-picker.tsx b/ui/src/components/track/occurrence-picker.tsx index 8987a8683..fa93981d2 100644 --- a/ui/src/components/track/occurrence-picker.tsx +++ b/ui/src/components/track/occurrence-picker.tsx @@ -4,6 +4,7 @@ import { MERGE_SCOPES, } from 'data-services/hooks/occurrences/useMergeCandidates' import { + addSortColumn, getComparisonSides, getCostLabel, getDistanceLabel, @@ -206,11 +207,18 @@ export const OccurrencePicker = ({ 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) } - ) + setSort(addSortColumn(sort, column, DESCENDING_FIRST.includes(column))) + + const sortKeys = columnSort + ? [columnSort, ...(columnSort.thenBy ?? [])].map((key) => key.column) + : [] + const columnSortLabel = sortKeys.length + ? sortKeys + .map((column) => translate(COLUMN_LABELS[column])) + .reduce((first, next) => + translate(STRING.TRACK_SORT_THEN, { first, next: next.toLowerCase() }) + ) + : undefined const chooseOrder = (value: string) => setSort(value === CUMULATIVE ? { cumulative: true } : undefined) @@ -250,6 +258,8 @@ export const OccurrencePicker = ({ const label = COLUMN_LABELS[column] const help = COLUMN_HELP[column] const active = columnSort?.column === column + // Columns clicked earlier break the ties of the last one and are numbered from 2. + const tieBreakRank = active ? 0 : sortKeys.indexOf(column) + 1 const DirectionIcon = !active ? ArrowUpDownIcon : columnSort.descending @@ -285,6 +295,16 @@ export const OccurrencePicker = ({ {translate(label)} )} + {tieBreakRank > 1 ? ( + + {tieBreakRank} + + ) : null} ) @@ -344,7 +364,7 @@ export const OccurrencePicker = ({ className="h-8 body-small" value={columnSort.column} > - {translate(COLUMN_LABELS[columnSort.column])} + {columnSortLabel} ) : null} diff --git a/ui/src/data-services/models/merge-candidate.test.ts b/ui/src/data-services/models/merge-candidate.test.ts index 5bc1cddac..ff20d679a 100644 --- a/ui/src/data-services/models/merge-candidate.test.ts +++ b/ui/src/data-services/models/merge-candidate.test.ts @@ -9,6 +9,7 @@ import { MergeCandidate, ServerMergeCandidate, sortMergeCandidates, + addSortColumn, } from './merge-candidate' const serverCandidate = ( @@ -269,4 +270,77 @@ describe('merge candidate sorting', () => { ids(sortMergeCandidates(rows, { column: 'similarity', descending: true })) ).toEqual(['3', '2', '1']) }) + + test('a second column breaks the ties of the column clicked after it', () => { + const table = [ + serverCandidate({ + id: 1, + determination: { id: 1, name: 'Pelosia muscerda' }, + detections_count: 2, + }), + serverCandidate({ + id: 2, + determination: { id: 2, name: 'Eilema lurideola' }, + detections_count: 3, + }), + serverCandidate({ + id: 3, + determination: { id: 1, name: 'Pelosia muscerda' }, + detections_count: 9, + }), + ].map(convertMergeCandidate) + + const byFrames = addSortColumn(undefined, 'frames', true) + const bySpeciesThenFrames = addSortColumn(byFrames, 'species', false) + + expect(ids(sortMergeCandidates(table, bySpeciesThenFrames))).toEqual([ + '2', + '3', + '1', + ]) + // Without the earlier click the tie keeps the server's order. + expect( + ids( + sortMergeCandidates(table, addSortColumn(undefined, 'species', false)) + ) + ).toEqual(['2', '1', '3']) + }) + + test('clicking the first column again flips it and keeps its tie-breakers', () => { + const sort = addSortColumn( + addSortColumn(undefined, 'frames', true), + 'species', + false + ) + + expect(addSortColumn(sort, 'species', false)).toEqual({ + column: 'species', + descending: true, + thenBy: [{ column: 'frames', descending: true }], + }) + }) + + test('a column appears once and only two earlier columns are kept', () => { + let sort = addSortColumn(undefined, 'when', false) + sort = addSortColumn(sort, 'distance', false) + sort = addSortColumn(sort, 'similarity', true) + sort = addSortColumn(sort, 'when', false) + + expect(sort).toEqual({ + column: 'when', + descending: false, + thenBy: [ + { column: 'similarity', descending: true }, + { column: 'distance', descending: false }, + ], + }) + }) + + test('switching from the cumulative order starts a fresh column sort', () => { + expect(addSortColumn({ cumulative: true }, 'frames', true)).toEqual({ + column: 'frames', + descending: true, + thenBy: [], + }) + }) }) diff --git a/ui/src/data-services/models/merge-candidate.ts b/ui/src/data-services/models/merge-candidate.ts index 59f99b162..22f7817c8 100644 --- a/ui/src/data-services/models/merge-candidate.ts +++ b/ui/src/data-services/models/merge-candidate.ts @@ -220,11 +220,54 @@ export type MergeCandidateSortColumn = | 'cost' | 'match' -/** One column the reviewer clicked, or all three applied in turn. */ +export interface MergeCandidateColumnSort { + column: MergeCandidateSortColumn + descending: boolean +} + +/** + * The column the reviewer clicked last, with the ones clicked before it breaking its + * ties, or time, distance and similarity applied in turn. + */ export type MergeCandidateSort = - | { column: MergeCandidateSortColumn; descending: boolean } + | (MergeCandidateColumnSort & { thenBy?: MergeCandidateColumnSort[] }) | { cumulative: true } +// Two earlier clicks are enough to say "species, then frames, then time". +const MAX_THEN_BY = 2 + +/** + * The order after a click on a column header: the same column flips its direction, + * another becomes the first key and the previous keys break its ties, as in a + * spreadsheet sorted one column at a time. + */ +export const addSortColumn = ( + sort: MergeCandidateSort | undefined, + column: MergeCandidateSortColumn, + descendingFirst: boolean +): MergeCandidateSort => { + const current = sort && 'column' in sort ? sort : undefined + + if (current?.column === column) { + return { ...current, descending: !current.descending } + } + + const previous = current + ? [ + { column: current.column, descending: current.descending }, + ...(current.thenBy ?? []), + ] + : [] + + return { + column, + descending: descendingFirst, + thenBy: previous + .filter((key) => key.column !== column) + .slice(0, MAX_THEN_BY), + } +} + const sortValue = ( candidate: MergeCandidate, column: MergeCandidateSortColumn @@ -299,11 +342,24 @@ export const sortMergeCandidates = ( }) } - return [...candidates].sort((a, b) => - compare( - sortValue(a, sort.column), - sortValue(b, sort.column), - sort.descending - ) - ) + const keys = [ + { column: sort.column, descending: sort.descending }, + ...(sort.thenBy ?? []), + ] + + return [...candidates].sort((a, b) => { + for (const key of keys) { + const result = compare( + sortValue(a, key.column), + sortValue(b, key.column), + key.descending + ) + + if (result !== 0) { + return result + } + } + + return 0 + }) } diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index dc66a1b3f..f443f6666 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -535,6 +535,8 @@ export enum STRING { TRACK_SORT_BY, TRACK_SORT_CUMULATIVE, TRACK_SORT_LABEL, + TRACK_SORT_THEN, + TRACK_SORT_TIE_BREAK, TRACK_SPLIT_HERE, TRACK_SPLIT_RESULT, TRACK_STAT_DISTINCT_TAXA, @@ -1216,6 +1218,9 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.TRACK_SORT_BY]: 'Sort by {{column}}', [STRING.TRACK_SORT_CUMULATIVE]: 'Time, then distance, then similarity', [STRING.TRACK_SORT_LABEL]: 'Order candidates', + [STRING.TRACK_SORT_THEN]: '{{first}}, then {{next}}', + [STRING.TRACK_SORT_TIE_BREAK]: + 'Sort key {{rank}}: breaks ties left by the keys before it', [STRING.TRACK_SPLIT_HERE]: 'Split here', [STRING.TRACK_SPLIT_RESULT]: 'Split. {{count}} frames moved to occurrence #{{id}}.', From acaf32c2e062717f5163353e18c044b2ed376af8 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Mon, 28 Sep 2026 14:58:37 -0700 Subject: [PATCH 4/7] feat(ui): list the names the classifier gave a track's frames on its session panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewers wanted to see what each detection was called before it joined a track, for example to spot a frame first labelled as another species. The panel on a track's box in the session view now lists those names with a count of frames each, such as "Species A ×7, Species B ×2", from the frame labels the occurrence details already carry. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../models/occurrence-details.test.ts | 18 ++++++++++++++++++ .../data-services/models/occurrence-details.ts | 18 ++++++++++++++++++ .../capture/occurrence-toolbar.tsx | 13 +++++++++++++ ui/src/utils/language.ts | 4 ++++ 4 files changed, 53 insertions(+) diff --git a/ui/src/data-services/models/occurrence-details.test.ts b/ui/src/data-services/models/occurrence-details.test.ts index a64bc2efc..fe927ff58 100644 --- a/ui/src/data-services/models/occurrence-details.test.ts +++ b/ui/src/data-services/models/occurrence-details.test.ts @@ -1,5 +1,6 @@ import { FrameLabel, + formatFrameNames, frameHasVector, getFrameClassification, getFrameNames, @@ -116,6 +117,23 @@ describe('frame names', () => { ['Xestia c-nigrum', 1, 0.9], ]) }) + + test('reads as one line of names and counts, unclassified frames included', () => { + const noctua = new Taxon(NOCTUA) + const xestia = new Taxon(XESTIA) + const names = getFrameNames([ + { taxon: noctua }, + { taxon: noctua }, + {}, + { taxon: xestia }, + ]) + + expect(formatFrameNames(names)).toBe( + 'Noctua pronuba ×2, No classification ×1, Xestia c-nigrum ×1' + ) + expect(formatFrameNames(names, 1)).toBe('Noctua pronuba ×2, 2 more') + expect(formatFrameNames([])).toBe('') + }) }) describe('occurrence details', () => { diff --git a/ui/src/data-services/models/occurrence-details.ts b/ui/src/data-services/models/occurrence-details.ts index 7d8430ca8..e33e903a4 100644 --- a/ui/src/data-services/models/occurrence-details.ts +++ b/ui/src/data-services/models/occurrence-details.ts @@ -165,6 +165,24 @@ export const getFrameNames = (labels: FrameLabel[]): FrameName[] => { ) } +/** "Pelosia muscerda ×7, No classification ×2", naming at most `limit` labels. */ +export const formatFrameNames = (names: FrameName[], limit = 4): string => { + const shown = names.slice(0, limit).map(({ frames, taxon }) => + translate(STRING.TRACK_FRAME_NAME_COUNT, { + count: frames, + name: taxon?.name ?? translate(STRING.TRACK_FRAME_NO_CLASSIFICATION), + }) + ) + + if (names.length > limit) { + shown.push( + translate(STRING.TRACK_FRAME_NAMES_MORE, { count: names.length - limit }) + ) + } + + return shown.join(', ') +} + /** 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 diff --git a/ui/src/pages/session-details/capture/occurrence-toolbar.tsx b/ui/src/pages/session-details/capture/occurrence-toolbar.tsx index 974a2068c..9c01a9dfb 100644 --- a/ui/src/pages/session-details/capture/occurrence-toolbar.tsx +++ b/ui/src/pages/session-details/capture/occurrence-toolbar.tsx @@ -1,6 +1,7 @@ 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 { formatFrameNames } from 'data-services/models/occurrence-details' import { PathFrame } from 'data-services/models/occurrence-path' import { Loader2Icon, RouteIcon, XIcon } from 'lucide-react' import { Button } from 'nova-ui-kit' @@ -107,6 +108,11 @@ export const OccurrenceToolbar = ({ const { canRestructure, canVerify } = getTrackEditRights( details?.userPermissions ) + // The classifier's own name for each frame, which a merge or a confirmed + // identification does not change: the evidence behind the track's name. + const frameNames = details?.frameNames.length + ? formatFrameNames(details.frameNames) + : undefined // 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. @@ -241,6 +247,13 @@ export const OccurrenceToolbar = ({
+ {frameNames ? ( + + {translate(STRING.TRACK_SUMMARY_FRAME_NAMES)}:{' '} + {frameNames} + + ) : null} + {framePosition ? {framePosition} : null} {shownFrames !== undefined && path && shownFrames < path.length ? ( diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index f443f6666..981029e02 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -550,6 +550,8 @@ export enum STRING { TRACK_SUMMARY_FRAMES, TRACK_SUMMARY_FRAMES_WITH_VECTORS, TRACK_SUMMARY_FRAME_NAMES, + TRACK_FRAME_NAME_COUNT, + TRACK_FRAME_NAMES_MORE, TRACK_SUMMARY_ID_AGREEMENT, TRACK_SUMMARY_SCORE_RANGE, TRACK_SUMMARY_TITLE, @@ -1236,6 +1238,8 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.TRACK_SUMMARY_FRAMES]: 'Frames grouped', [STRING.TRACK_SUMMARY_FRAMES_WITH_VECTORS]: 'Frames with a feature vector', [STRING.TRACK_SUMMARY_FRAME_NAMES]: 'Names given to frames', + [STRING.TRACK_FRAME_NAME_COUNT]: '{{name}} ×{{count}}', + [STRING.TRACK_FRAME_NAMES_MORE]: '{{count}} more', [STRING.TRACK_SUMMARY_ID_AGREEMENT]: 'Identification agreement', [STRING.TRACK_SUMMARY_SCORE_RANGE]: 'Score range', [STRING.TRACK_SUMMARY_TITLE]: 'Occurrence tracking', From 295273e02a60d74a08fbe3a8a6eb4c9989f5b468 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Mon, 28 Sep 2026 15:03:03 -0700 Subject: [PATCH 5/7] fix(ui): scroll a capture panel that is taller than the space beside its box When a box sits mid-image and the track panel is taller than the room above or below it, the panel now takes the available height and scrolls, so its top and its actions stay on screen. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- ui/src/pages/session-details/capture/capture.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/pages/session-details/capture/capture.tsx b/ui/src/pages/session-details/capture/capture.tsx index 64e4720cf..e041c0c67 100644 --- a/ui/src/pages/session-details/capture/capture.tsx +++ b/ui/src/pages/session-details/capture/capture.tsx @@ -794,7 +794,7 @@ const CaptureDetections = ({ Date: Mon, 28 Sep 2026 15:03:41 -0700 Subject: [PATCH 6/7] feat(ui): jump to a track's first or last frame and open a frame's full capture Reviewers checking a long track wanted to see what happened just before it started or after it ended, and to look at the original photo behind a crop. The track panel in the session view gains first and last frame buttons once its path is shown, and each frame in an occurrence's frame list links to the full capture it was cropped from. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01C7Xf6VPbwWtTumhjjF15g8 --- .../models/occurrence-details.test.ts | 15 ++++++++- .../models/occurrence-details.ts | 1 + .../occurrence-details/occurrence-details.tsx | 2 ++ .../track/frame-caption.tsx | 21 +++++++++++-- .../pages/session-details/capture/capture.tsx | 4 +++ .../capture/occurrence-toolbar.tsx | 26 ++++++++++++++++ .../capture/track-navigation.test.ts | 31 +++++++++++++++++++ .../capture/track-navigation.ts | 8 +++++ ui/src/utils/language.ts | 6 ++++ 9 files changed, 111 insertions(+), 3 deletions(-) diff --git a/ui/src/data-services/models/occurrence-details.test.ts b/ui/src/data-services/models/occurrence-details.test.ts index fe927ff58..690d9df59 100644 --- a/ui/src/data-services/models/occurrence-details.test.ts +++ b/ui/src/data-services/models/occurrence-details.test.ts @@ -139,7 +139,13 @@ describe('frame names', () => { describe('occurrence details', () => { const detection = (id: number, classifications: unknown[]) => ({ bbox: [0, 0, 10, 10], - capture: { height: 100, id: id + 100, width: 100 }, + capture: { + height: 100, + id: id + 100, + // Only the first frame's capture has an image stored. + url: id === 1 ? 'https://example.com/capture-101.jpg' : null, + width: 100, + }, classifications, height: null, id, @@ -181,6 +187,13 @@ describe('occurrence details', () => { ]) }) + test('each frame links to the full capture it was cropped from, when there is one', () => { + expect(occurrence.getDetectionInfo('1').captureUrl).toBe( + 'https://example.com/capture-101.jpg' + ) + expect(occurrence.getDetectionInfo('2').captureUrl).toBeUndefined() + }) + 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 e33e903a4..cf9ffe3ed 100644 --- a/ui/src/data-services/models/occurrence-details.ts +++ b/ui/src/data-services/models/occurrence-details.ts @@ -401,6 +401,7 @@ export class OccurrenceDetails extends Occurrence { detection.capture?.id !== undefined ? `${detection.capture.id}` : undefined, + captureUrl: (detection.capture?.url as string | undefined) || undefined, // The bounding box gives the crop's proportions when the crop itself is missing. image: { src: detection.url, diff --git a/ui/src/pages/occurrence-details/occurrence-details.tsx b/ui/src/pages/occurrence-details/occurrence-details.tsx index 7a3827ee1..2359ca6f5 100644 --- a/ui/src/pages/occurrence-details/occurrence-details.tsx +++ b/ui/src/pages/occurrence-details/occurrence-details.tsx @@ -125,6 +125,7 @@ export const OccurrenceDetails = ({ item ): item is BlueprintItem & { captureId: string + captureUrl: string | undefined frameLabel: FrameLabel hasVector: boolean | undefined } => !!item @@ -461,6 +462,7 @@ export const OccurrenceDetails = ({ caption={ trackingEnabled ? ( { * `hasVector` is undefined when the payload did not say, and only a definite no is marked. */ export const FrameCaption = ({ + captureUrl, detectionId, hasVector, label, timeLabel, }: { + /** The whole capture this frame was cropped from, opened at full size. */ + captureUrl?: string detectionId: string hasVector?: boolean label: FrameLabel @@ -57,7 +60,21 @@ export const FrameCaption = ({ ) : null}
- {timeLabel} +
+ {timeLabel} + {captureUrl ? ( + + + + ) : null} +
diff --git a/ui/src/pages/session-details/capture/capture.tsx b/ui/src/pages/session-details/capture/capture.tsx index e041c0c67..c7fcaa80c 100644 --- a/ui/src/pages/session-details/capture/capture.tsx +++ b/ui/src/pages/session-details/capture/capture.tsx @@ -351,6 +351,7 @@ export const Capture = ({ isLoadingPath={isLoadingPath} matches={matches} onHidePath={() => setPathOccurrenceId(undefined)} + onSelectCapture={setActiveCaptureId} onShowPath={(occurrenceId) => occurrenceId === shownPathId ? refetchPath() @@ -498,6 +499,7 @@ const CaptureDetections = ({ isLoadingPath, matches, onHidePath, + onSelectCapture, onShowPath, onTogglePathCrops, path, @@ -515,6 +517,7 @@ const CaptureDetections = ({ isLoadingPath?: boolean matches?: Record onHidePath: () => void + onSelectCapture: (captureId: string) => void onShowPath: (occurrenceId: string) => void /** Switch the path's boxes between the moth's own pixels and an outline. */ onTogglePathCrops?: () => void @@ -850,6 +853,7 @@ const CaptureDetections = ({ onOpenOccurrence={() => setActiveOccurrence(detection.occurrenceId) } + onSelectCapture={onSelectCapture} onShowPath={() => showPath(detection.occurrenceId as string) } diff --git a/ui/src/pages/session-details/capture/occurrence-toolbar.tsx b/ui/src/pages/session-details/capture/occurrence-toolbar.tsx index 9c01a9dfb..8e732d960 100644 --- a/ui/src/pages/session-details/capture/occurrence-toolbar.tsx +++ b/ui/src/pages/session-details/capture/occurrence-toolbar.tsx @@ -11,6 +11,7 @@ import { getFormatedDateTimeString } from 'utils/date/getFormatedDateTimeString/ import { getFormatedTimeString } from 'utils/date/getFormatedTimeString/getFormatedTimeString' import { STRING, translate } from 'utils/language' import { buildDetectionLink } from '../hooks/useActiveDetection' +import { getPathEnds } from './track-navigation' export interface ToolbarOccurrence { frameCount: number @@ -64,6 +65,7 @@ export const OccurrenceToolbar = ({ onHidePath, onMerge, onOpenOccurrence, + onSelectCapture, onShowPath, onSplit, onTogglePathCrops, @@ -85,6 +87,8 @@ export const OccurrenceToolbar = ({ onHidePath: () => void onMerge: () => void onOpenOccurrence: () => void + /** Steps the viewer to another capture, keeping this track selected. */ + onSelectCapture?: (captureId: string) => void onShowPath: () => void onSplit: () => void /** Switch the path's boxes between the moth's own pixels and an outline. */ @@ -128,6 +132,10 @@ export const OccurrenceToolbar = ({ }) : undefined + const ends = pathShown ? getPathEnds(path) : undefined + const currentCaptureId = + frameIndex !== -1 ? path?.[frameIndex].captureId : undefined + const subtitle = () => { if (!pathShown) { return singleFrame @@ -170,6 +178,24 @@ export const OccurrenceToolbar = ({ ) : null, + ends && onSelectCapture && path && path.length > 1 ? ( +
+ {[ + { frame: ends.first, label: STRING.TRACK_GO_FIRST_FRAME }, + { frame: ends.last, label: STRING.TRACK_GO_LAST_FRAME }, + ].map(({ frame, label }) => ( + + ))} +
+ ) : null, // A single frame has no path to draw, and the subtitle says so. !pathShown && !singleFrame ? (