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/data-services/models/occurrence-details.test.ts b/ui/src/data-services/models/occurrence-details.test.ts index 4e6dc9cc2..a401abcbc 100644 --- a/ui/src/data-services/models/occurrence-details.test.ts +++ b/ui/src/data-services/models/occurrence-details.test.ts @@ -1,9 +1,12 @@ import { + FrameName, + formatFrameNames, frameHasVector, getFrameClassification, OccurrenceDetails, ServerFrameClassification, } from './occurrence-details' +import { Taxon } from './taxa' const serverTaxon = (id: string, name: string, rank = 'SPECIES') => ({ cover_image_url: null, @@ -88,10 +91,32 @@ describe('frame feature vectors', () => { }) }) +describe('frame names', () => { + test('reads as one line of names and counts, unclassified frames included', () => { + const names: FrameName[] = [ + { frames: 2, taxon: new Taxon(NOCTUA) }, + { frames: 1 }, + { frames: 1, taxon: new 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', () => { const detection = (id: number, classifications: unknown[]) => ({ bbox: [0, 0, 10, 20], - 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, frame_index: id - 1, height: null, @@ -163,6 +188,13 @@ describe('occurrence details', () => { }) }) + test('each frame links to the full capture it was cropped from, when there is one', () => { + const [first, second] = occurrence.firstFramesPage + + expect(first.captureUrl).toBe('https://example.com/capture-101.jpg') + expect(second.captureUrl).toBeUndefined() + }) + test('the track ends and label counts cover every frame, not just the first page', () => { expect(occurrence.lastFrame).toMatchObject({ captureId: '140', diff --git a/ui/src/data-services/models/occurrence-details.ts b/ui/src/data-services/models/occurrence-details.ts index f833ac2ab..07a1d4106 100644 --- a/ui/src/data-services/models/occurrence-details.ts +++ b/ui/src/data-services/models/occurrence-details.ts @@ -89,7 +89,12 @@ export interface TrackFrame { export interface ServerOccurrenceFrame { bbox: number[] | null - capture: { id: number; height: number | null; width: number | null } | null + capture: { + id: number + height: number | null + url?: string | null + width: number | null + } | null classifications: ServerFrameClassification[] | null frame_index: number height: number | null @@ -102,6 +107,8 @@ export interface ServerOccurrenceFrame { /** One detection of an occurrence, as a frame of the track strip. */ export interface OccurrenceFrame { captureId?: string + /** The full capture the crop was cut from, when its image is stored. */ + captureUrl?: string frameIndex: number frameLabel: FrameLabel hasVector?: boolean @@ -198,6 +205,24 @@ const timeLabelOf = (timestamp?: string | null) => }) : undefined +/** "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 @@ -217,6 +242,7 @@ export const convertOccurrenceFrame = ( return { captureId: frame.capture ? `${frame.capture.id}` : undefined, + captureUrl: frame.capture?.url || undefined, frameIndex: frame.frame_index, frameLabel, hasVector: frameHasVector(frame.classifications), diff --git a/ui/src/pages/occurrence-details/track/frame-caption.tsx b/ui/src/pages/occurrence-details/track/frame-caption.tsx index a3cd17cb4..744e775b7 100644 --- a/ui/src/pages/occurrence-details/track/frame-caption.tsx +++ b/ui/src/pages/occurrence-details/track/frame-caption.tsx @@ -1,7 +1,7 @@ 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 { BanIcon, ImageIcon } from 'lucide-react' import { cn } from 'nova-ui-kit/utils' import { STRING, translate } from 'utils/language' @@ -27,11 +27,14 @@ export const FrameTaxonName = ({ taxon }: { taxon?: Taxon }) => { * `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/occurrence-details/track/frame-strip.tsx b/ui/src/pages/occurrence-details/track/frame-strip.tsx index 754b1bf68..25f564a8b 100644 --- a/ui/src/pages/occurrence-details/track/frame-strip.tsx +++ b/ui/src/pages/occurrence-details/track/frame-strip.tsx @@ -177,6 +177,7 @@ export const FrameStrip = ({ caption={ trackingEnabled ? ( - - - + + + + + {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} > setPathOccurrenceId(undefined)} + onSelectCapture={setActiveCaptureId} onShowPath={(occurrenceId) => occurrenceId === shownPathId ? refetchPath() @@ -493,6 +499,7 @@ const CaptureDetections = ({ isLoadingPath, matches, onHidePath, + onSelectCapture, onShowPath, onTogglePathCrops, path, @@ -510,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 @@ -522,9 +530,6 @@ const CaptureDetections = ({ 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. - 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 +658,7 @@ const CaptureDetections = ({ return ( <> -
+
{Object.entries(boxStyles).map(([id, style]) => { const detection = detections.find((d) => d.id === id) @@ -729,54 +734,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 +793,102 @@ 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) + } + onSelectCapture={onSelectCapture} + 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/occurrence-toolbar.tsx b/ui/src/pages/session-details/capture/occurrence-toolbar.tsx index 974a2068c..8e732d960 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' @@ -10,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 @@ -63,6 +65,7 @@ export const OccurrenceToolbar = ({ onHidePath, onMerge, onOpenOccurrence, + onSelectCapture, onShowPath, onSplit, onTogglePathCrops, @@ -84,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. */ @@ -107,6 +112,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. @@ -122,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 @@ -164,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 ? (