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 ? (