Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions ui/src/components/track/occurrence-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
MERGE_SCOPES,
} from 'data-services/hooks/occurrences/useMergeCandidates'
import {
addSortColumn,
getComparisonSides,
getCostLabel,
getDistanceLabel,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -285,6 +295,16 @@ export const OccurrencePicker = ({
<span>{translate(label)}</span>
)}
<DirectionIcon aria-hidden className="w-3 h-3" />
{tieBreakRank > 1 ? (
<span
className="tabular-nums text-foreground"
title={translate(STRING.TRACK_SORT_TIE_BREAK, {
rank: tieBreakRank,
})}
>
{tieBreakRank}
</span>
) : null}
</button>
</th>
)
Expand Down Expand Up @@ -344,7 +364,7 @@ export const OccurrencePicker = ({
className="h-8 body-small"
value={columnSort.column}
>
{translate(COLUMN_LABELS[columnSort.column])}
{columnSortLabel}
</Select.Item>
) : null}
</Select.Content>
Expand Down
74 changes: 74 additions & 0 deletions ui/src/data-services/models/merge-candidate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
MergeCandidate,
ServerMergeCandidate,
sortMergeCandidates,
addSortColumn,
} from './merge-candidate'

const serverCandidate = (
Expand Down Expand Up @@ -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: [],
})
})
})
74 changes: 65 additions & 9 deletions ui/src/data-services/models/merge-candidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
})
}
34 changes: 33 additions & 1 deletion ui/src/data-services/models/occurrence-details.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
28 changes: 27 additions & 1 deletion ui/src/data-services/models/occurrence-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down
Loading