From fdb0047bb8f8352a2b711d040ba17260eee59315 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Fri, 18 Sep 2026 12:33:18 -0400 Subject: [PATCH] Ask which side of the confidence threshold a delete of every listed track reaches The track list only shows what passes the confidence thresholds, so its delete button asks: above (the listed tracks), below (hidden ones), or all. --- client/src/BaseFilterControls.ts | 28 ++++++++ client/src/TrackFilterControls.spec.ts | 29 ++++++++ .../src/components/DeleteAllScopeDialog.vue | 67 +++++++++++++++++++ .../src/components/Tracks/TrackList.spec.ts | 48 ++++++++++++- client/src/components/Tracks/TrackList.vue | 29 +++++++- .../bottombar/BottomBarTrackListView.vue | 10 ++- .../Tracks/sidebar/SideBarTrackListView.vue | 10 ++- docs/UI-Track-List.md | 2 +- 8 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 client/src/components/DeleteAllScopeDialog.vue diff --git a/client/src/BaseFilterControls.ts b/client/src/BaseFilterControls.ts index f779e198d..137d124e8 100644 --- a/client/src/BaseFilterControls.ts +++ b/client/src/BaseFilterControls.ts @@ -1,6 +1,7 @@ import { ref, computed, Ref, watch, } from 'vue'; +import { resolveConfidenceThreshold } from 'dive-common/typeHierarchy'; import type { AnnotationId, ConfidencePair } from './BaseAnnotation'; import { SortedAnnotation } from './BaseAnnotationStore'; import type Group from './Group'; @@ -15,6 +16,9 @@ interface MarkChangesPendingData { export type MarkChangesPendingFilter = (data?: MarkChangesPendingData) => void; export const DefaultConfidence = 0.1; + +/** Which annotations a delete-all reaches, relative to each type's threshold. */ +export type ThresholdScope = 'above' | 'below' | 'all'; /** * AnnotationWithContext wraps an annotation with additional information * such as why the annotation was included or returned by a system @@ -235,6 +239,30 @@ export default abstract class BaseFilterControls { }); } + /** + * Like removeTypeAnnotations, but reaching past the visible list: 'below' + * removes `types` only where the annotation's confidence for the type is + * under that type's threshold (the annotations the list hides), 'all' + * removes them everywhere. 'above' is the visible list itself. + */ + removeTypeAnnotationsByThreshold(types: string[], scope: ThresholdScope) { + if (scope === 'above') { + this.removeTypeAnnotations(types); + return; + } + const wanted = new Set(types); + const filters = this.confidenceFilters.value; + [...this.sorted.value].forEach((annotation) => { + const matching = annotation.confidencePairs.filter(([type, confidence]) => wanted.has(type) + && (scope === 'all' || confidence < resolveConfidenceThreshold(filters, type))); + if (matching.length === 0) return; + const remaining = this.removeTypes(annotation.id, matching.map(([type]) => type)); + if (remaining.length === 0) { + this.remove(annotation.id); + } + }); + } + updateCheckedTypes(types: string[]) { this.checkedTypes.value = types; } diff --git a/client/src/TrackFilterControls.spec.ts b/client/src/TrackFilterControls.spec.ts index 58b901374..10a98a13d 100644 --- a/client/src/TrackFilterControls.spec.ts +++ b/client/src/TrackFilterControls.spec.ts @@ -449,6 +449,35 @@ describe('useAnnotationFilters', () => { expect(cameraStore.getTrack(1).confidencePairs).toEqual([['baz', 0.7]]); }); + it('deletes all of a type above, below or regardless of its threshold', () => { + const scoped = () => makePairFixture([ + [['bar', 0.9]], + [['bar', 0.05]], + [['bar', 0.05], ['baz', 0.7]], + [['baz', 0.7]], + ]); + let { cameraStore, filters } = scoped(); + filters.setConfidenceFilters({ default: 0.1, bar: 0.5 }); + filters.removeTypeAnnotationsByThreshold(['bar'], 'below'); + expect(cameraStore.getPossibleTrack(0)?.confidencePairs).toEqual([['bar', 0.9]]); + expect(cameraStore.getPossibleTrack(1)).toBeUndefined(); + expect(cameraStore.getTrack(2).confidencePairs).toEqual([['baz', 0.7]]); + expect(cameraStore.getTrack(3).confidencePairs).toEqual([['baz', 0.7]]); + + ({ cameraStore, filters } = scoped()); + filters.setConfidenceFilters({ default: 0.1, bar: 0.5 }); + filters.removeTypeAnnotationsByThreshold(['bar'], 'all'); + expect(cameraStore.getPossibleTrack(0)).toBeUndefined(); + expect(cameraStore.getPossibleTrack(1)).toBeUndefined(); + expect(cameraStore.getTrack(2).confidencePairs).toEqual([['baz', 0.7]]); + + ({ cameraStore, filters } = scoped()); + filters.setConfidenceFilters({ default: 0.1, bar: 0.5 }); + filters.removeTypeAnnotationsByThreshold(['bar'], 'above'); + expect(cameraStore.getPossibleTrack(0)).toBeUndefined(); + expect(cameraStore.getPossibleTrack(1)?.confidencePairs).toEqual([['bar', 0.05]]); + }); + it('returns the caller fallback without recomputing flat pair selection', () => { const { cameraStore, filters } = makePairFixture([ [['root', 0.1], ['leaf', 0.9]], diff --git a/client/src/components/DeleteAllScopeDialog.vue b/client/src/components/DeleteAllScopeDialog.vue new file mode 100644 index 000000000..e48ca5d51 --- /dev/null +++ b/client/src/components/DeleteAllScopeDialog.vue @@ -0,0 +1,67 @@ + + + diff --git a/client/src/components/Tracks/TrackList.spec.ts b/client/src/components/Tracks/TrackList.spec.ts index a7249b19f..47fc9f554 100644 --- a/client/src/components/Tracks/TrackList.spec.ts +++ b/client/src/components/Tracks/TrackList.spec.ts @@ -19,11 +19,14 @@ interface MockTrackFilters { context: { confidencePairIndex: number }; }[]>; hierarchyActive: Ref; + checkedTypes: Ref; + removeTypeAnnotationsByThreshold: ReturnType; } const state = vi.hoisted(() => ({ cameraStore: null as unknown as MockCameraStore, trackFilters: null as unknown as MockTrackFilters, + removeTrack: vi.fn(), })); vi.mock('dive-common/vue-utilities/prompt-service', () => ({ @@ -38,7 +41,7 @@ vi.mock('../../provides', () => ({ useEditingMode: () => ref(false), useHandler: () => ({ trackSplit: vi.fn(), - removeTrack: vi.fn(), + removeTrack: state.removeTrack, trackAdd: vi.fn(), trackSelect: vi.fn(), trackSelectNext: vi.fn(), @@ -80,6 +83,8 @@ function mountList( checkedIDs: ref(tracks.map(({ id }) => id)), filteredAnnotations: ref(filtered), hierarchyActive: ref(hierarchyActive), + checkedTypes: ref(['root', 'child']), + removeTypeAnnotationsByThreshold: vi.fn(), }; state.cameraStore = { camMap: ref(new Map([['singleCam', { trackStore: undefined }]])), @@ -175,3 +180,44 @@ describe('TrackList hierarchy display', () => { expect(getType).not.toHaveBeenCalled(); }); }); + +describe('TrackList delete of every listed track', () => { + function mountTwo() { + const tracks = [1, 2].map((id) => new Track(id, { + confidencePairs: [['root', 0.9]], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + })); + state.removeTrack.mockClear(); + return mountList(tracks, [0, 0], false); + } + type ListVm = { + data: { showDeleteAll: boolean; deleteAllScope: string }; + multiDelete: () => Promise; + confirmDeleteAll: () => void; + }; + + it.each([ + ['above', [[1, 2]], 0], + ['below', [], 1], + ['all', [[1, 2]], 1], + ] as [string, number[][], number][])('asks for the threshold scope and applies %s', async (scope, removed, belowCalls) => { + const vm = mountTwo().vm as unknown as ListVm; + await vm.multiDelete(); + expect(vm.data.showDeleteAll).toBe(true); + expect(vm.data.deleteAllScope).toBe('above'); + vm.data.deleteAllScope = scope; + vm.confirmDeleteAll(); + expect(vm.data.showDeleteAll).toBe(false); + expect(state.removeTrack.mock.calls.map(([ids]) => ids)).toEqual(removed); + const below = state.trackFilters.removeTypeAnnotationsByThreshold; + expect(below).toHaveBeenCalledTimes(belowCalls); + if (belowCalls) expect(below).toHaveBeenCalledWith(['root', 'child'], 'below'); + }); + + it('keeps the plain confirmation for a partial selection', async () => { + const vm = mountTwo().vm as unknown as ListVm; + state.trackFilters.checkedIDs.value = [1]; + await vm.multiDelete(); + expect(vm.data.showDeleteAll).toBe(false); + }); +}); diff --git a/client/src/components/Tracks/TrackList.vue b/client/src/components/Tracks/TrackList.vue index f3282fb78..a005093f7 100644 --- a/client/src/components/Tracks/TrackList.vue +++ b/client/src/components/Tracks/TrackList.vue @@ -6,7 +6,7 @@ import Vue, { import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; -import { TrackWithContext } from 'vue-media-annotator/BaseFilterControls'; +import { TrackWithContext, ThresholdScope } from 'vue-media-annotator/BaseFilterControls'; import type { TrackProjection } from 'vue-media-annotator/TrackProjection'; import { clientSettings } from 'dive-common/store/settings'; @@ -98,6 +98,8 @@ export default defineComponent({ itemHeight: props.compact ? 50 : 70, // in pixels settingsActive: false, columnSettingsActive: false, + showDeleteAll: false, + deleteAllScope: 'above' as ThresholdScope, }); const sortKey = ref('id'); @@ -363,7 +365,29 @@ export default defineComponent({ }; } + function checkedDisplayedTracks() { + return virtualListItems.value + .map((item) => item.filteredTrack.annotation.id) + .filter((id) => checkedTrackIdsRef.value.includes(id)); + } + + /* 'above' is the listed tracks themselves; 'below' reaches the tracks of + the enabled types that the confidence thresholds hide from the list. */ + function confirmDeleteAll() { + data.showDeleteAll = false; + const scope = data.deleteAllScope; + const types = [...trackFilters.checkedTypes.value]; + if (scope !== 'below') removeTrack(checkedDisplayedTracks(), true); + if (scope !== 'above') trackFilters.removeTypeAnnotationsByThreshold(types, 'below'); + } + async function multiDelete() { + if (virtualListItems.value.length > 0 + && checkedDisplayedTracks().length === virtualListItems.value.length) { + data.deleteAllScope = 'above'; + data.showDeleteAll = true; + return; + } const tracksDisplayed: number[] = []; const text = ['Do you want to delete the following tracks:']; let count = 0; @@ -455,6 +479,7 @@ export default defineComponent({ virtualListItems, setVirtualListRef, multiDelete, + confirmDeleteAll, sortKey, sortDirection, handleSort, @@ -473,6 +498,7 @@ export default defineComponent({ :new-track-type="newTrackType" :track-add="trackAdd" :multi-delete="multiDelete" + :confirm-delete-all="confirmDeleteAll" :virtual-list-items="virtualListItems" :get-item-props="getItemProps" :lock-types="lockTypes" @@ -504,6 +530,7 @@ export default defineComponent({ :new-track-type="newTrackType" :track-add="trackAdd" :multi-delete="multiDelete" + :confirm-delete-all="confirmDeleteAll" :virtual-list-items="virtualListItems" :get-item-props="getItemProps" :lock-types="lockTypes" diff --git a/client/src/components/Tracks/bottombar/BottomBarTrackListView.vue b/client/src/components/Tracks/bottombar/BottomBarTrackListView.vue index 934eaea6b..a7fe8552e 100644 --- a/client/src/components/Tracks/bottombar/BottomBarTrackListView.vue +++ b/client/src/components/Tracks/bottombar/BottomBarTrackListView.vue @@ -2,11 +2,12 @@ import { defineComponent, computed } from 'vue'; import { clientSettings } from 'dive-common/store/settings'; import TrackItem from '../TrackItem.vue'; +import DeleteAllScopeDialog from '../../DeleteAllScopeDialog.vue'; import { useReadOnlyMode, useTrackFilters, useTrackStyleManager } from '../../../provides'; export default defineComponent({ name: 'BottomBarTrackListView', - components: { TrackItem }, + components: { TrackItem, DeleteAllScopeDialog }, props: { data: { type: Object, required: true }, filteredTracks: { type: Array, required: true }, @@ -14,6 +15,7 @@ export default defineComponent({ newTrackType: { type: String, required: true }, trackAdd: { type: Function, required: true }, multiDelete: { type: Function, required: true }, + confirmDeleteAll: { type: Function, required: true }, virtualListItems: { type: Array, required: true }, getItemProps: { type: Function, required: true }, lockTypes: { type: Boolean, required: true }, @@ -305,5 +307,11 @@ export default defineComponent({ /> + diff --git a/client/src/components/Tracks/sidebar/SideBarTrackListView.vue b/client/src/components/Tracks/sidebar/SideBarTrackListView.vue index 5a9819a23..5940e168d 100644 --- a/client/src/components/Tracks/sidebar/SideBarTrackListView.vue +++ b/client/src/components/Tracks/sidebar/SideBarTrackListView.vue @@ -2,11 +2,12 @@ import { defineComponent, computed } from 'vue'; import { clientSettings } from 'dive-common/store/settings'; import TrackItem from '../TrackItem.vue'; +import DeleteAllScopeDialog from '../../DeleteAllScopeDialog.vue'; import { useReadOnlyMode, useTrackFilters, useTrackStyleManager } from '../../../provides'; export default defineComponent({ name: 'SideBarTrackListView', - components: { TrackItem }, + components: { TrackItem, DeleteAllScopeDialog }, props: { data: { type: Object, required: true }, filteredTracks: { type: Array, required: true }, @@ -14,6 +15,7 @@ export default defineComponent({ newTrackType: { type: String, required: true }, trackAdd: { type: Function, required: true }, multiDelete: { type: Function, required: true }, + confirmDeleteAll: { type: Function, required: true }, virtualListItems: { type: Array, required: true }, getItemProps: { type: Function, required: true }, lockTypes: { type: Boolean, required: true }, @@ -153,5 +155,11 @@ export default defineComponent({ /> + diff --git a/docs/UI-Track-List.md b/docs/UI-Track-List.md index 3b0903847..6e687a131 100644 --- a/docs/UI-Track-List.md +++ b/docs/UI-Track-List.md @@ -9,7 +9,7 @@ The track list allows for selecting and editing tracks. A selected track will look different depending on whether it's a single detection or a multi-frame track. * ==:material-cog:== opens track creation settings -* ==:material-delete:=={ .error } deletes all tracks in the track list +* ==:material-delete:=={ .error } deletes the checked tracks in the track list. When every listed track is checked, DIVE asks whether to delete the tracks above the current confidence threshold (what the list shows), the ones below it (hidden from the list), or all of them. * ==:material-plus: Track/Detection== begins creation of a new annotation. When [suppression](UI-Suppression.md) is enabled, detections covered by a suppression region on the current frame are omitted from this list (attribute-flagged detections remain listed).