diff --git a/client/dive-common/components/GroupSidebar.spec.ts b/client/dive-common/components/GroupSidebar.spec.ts new file mode 100644 index 000000000..92d75b53f --- /dev/null +++ b/client/dive-common/components/GroupSidebar.spec.ts @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +/* eslint-disable vue/one-component-per-file -- harness components for shallow mounting */ +/// +import { + defineComponent, h, reactive, ref, +} from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { clientSettings } from 'dive-common/store/settings'; +import FilterList from 'vue-media-annotator/components/FilterList.vue'; +import GroupSidebar from './GroupSidebar.vue'; + +const provideMocks = vi.hoisted(() => ({ + groupFilterControls: undefined as unknown, + groupStyleManager: undefined as unknown, +})); + +vi.mock('dive-common/vue-utilities/prompt-service', () => ({ + usePrompt: () => ({ prompt: vi.fn(), visible: () => false }), +})); + +vi.mock('vue-media-annotator/provides', () => ({ + useCameraStore: () => ({ camMap: ref(new Map()) }), + useGroupFilterControls: () => provideMocks.groupFilterControls, + useGroupStyleManager: () => provideMocks.groupStyleManager, + useHandler: () => ({ seekFrame: vi.fn() }), + usePendingSaveCount: () => ref(0), + useReadOnlyMode: () => ref(false), + useSelectedCamera: () => ref(''), + useTime: () => ({ frame: ref(0) }), +})); + +describe('GroupSidebar type filter', () => { + it('wires the group FilterList to flat show-empty behavior', () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + clientSettings.typeSettings.suppressionType = ''; + const checkedTypes = ref(['school', 'pod']); + provideMocks.groupFilterControls = Object.freeze({ + allTypes: ref(['school', 'pod']), + usedTypes: ref(['school']), + configuredTypes: ref(['pod']), + checkedTypes, + filteredAnnotations: ref([]), + confidenceFilters: ref({ default: 0 }), + disableAnnotationFilters: ref(false), + updateCheckedTypes: (types: string[]) => { checkedTypes.value = types; }, + removeTypeAnnotations: vi.fn(), + }); + provideMocks.groupStyleManager = Object.freeze({ + typeStyling: ref({ + color: () => '#fff', + strokeWidth: () => 1, + fill: () => false, + opacity: () => 1, + }), + }); + + const ContainerStub = defineComponent({ + setup: (_props, { slots }) => () => h( + 'div', + slots.default?.({ topHeight: 240, bottomHeight: 120 }), + ), + }); + const props = reactive({ width: 320 }); + const Host = defineComponent({ + setup: () => () => h(GroupSidebar, { props }), + }); + const wrapper = shallowMount(Host, { + stubs: { + GroupSidebar: false, + FilterList: false, + StackedVirtualSidebarContainer: ContainerStub, + 'v-divider': true, + }, + }); + const filterList = wrapper.findComponent(FilterList); + + expect(filterList.exists()).toBe(true); + expect(filterList.props()).toEqual(expect.objectContaining({ + filterControls: provideMocks.groupFilterControls, + group: true, + showEmptyTypes: true, + width: 320, + })); + }); +}); diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index 3c0eb9855..21ae60442 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -639,14 +639,6 @@ export default defineComponent({ const removeGroups = (id: AnnotationId) => { cameraStore.removeGroups(id); }; - const setTrackType = ( - id: AnnotationId, - newType: string, - confidenceVal?: number, - currentType?: string, - ) => { - cameraStore.setTrackType(id, newType, confidenceVal, currentType); - }; const removeTypes = (id: AnnotationId, types: string[]) => cameraStore.removeTypes(id, types); const setGroupType = ( id: AnnotationId, @@ -664,7 +656,7 @@ export default defineComponent({ sorted: cameraStore.sortedGroups, markChangesPending: (markChangesPending as MarkChangesPendingFilter), remove: removeGroups, - setType: setGroupType, + setGroupType, removeTypes: removeGroupTypes, }); @@ -682,7 +674,6 @@ export default defineComponent({ cameraStore.renameTrackPair(id, currentType, newType) ), groupFilterControls: groupFilters, - setType: setTrackType, removeTypes, }); diff --git a/client/dive-common/typeHierarchy.ts b/client/dive-common/typeHierarchy.ts index a4adc13c3..0a24f98fa 100644 --- a/client/dive-common/typeHierarchy.ts +++ b/client/dive-common/typeHierarchy.ts @@ -257,7 +257,7 @@ export function compileHierarchy(hierarchy: TypeHierarchy): TypeHierarchyIndex { return { hierarchy: normalized, ancestors: Object.fromEntries(ancestorEntries) }; } -function ancestorsOf(index: TypeHierarchyIndex, type: string): readonly string[] { +export function ancestorsOf(index: TypeHierarchyIndex, type: string): readonly string[] { return Object.prototype.hasOwnProperty.call(index.ancestors, type) ? index.ancestors[type] : []; diff --git a/client/dive-common/use/useModeManager.spec.ts b/client/dive-common/use/useModeManager.spec.ts index d0340b368..5a8cae3ae 100644 --- a/client/dive-common/use/useModeManager.spec.ts +++ b/client/dive-common/use/useModeManager.spec.ts @@ -51,7 +51,7 @@ function makeHarness(markChangesPending: MarkChangesPending = () => undefined) { sorted: cameraStore.sortedGroups, remove: () => undefined, markChangesPending: () => undefined, - setType: () => undefined, + setGroupType: () => undefined, removeTypes: () => [], }); const trackFilterControls = new TrackFilterControls({ @@ -64,7 +64,6 @@ function makeHarness(markChangesPending: MarkChangesPending = () => undefined) { cameraStore.renameTrackPair(id, currentType, newType) ), groupFilterControls, - setType: () => undefined, removeTypes: () => [], }); @@ -190,7 +189,7 @@ function makeSingleCamHarness() { sorted: cameraStore.sortedGroups, remove: () => undefined, markChangesPending: () => undefined, - setType: () => undefined, + setGroupType: () => undefined, removeTypes: () => [], }); const trackFilterControls = new TrackFilterControls({ @@ -203,7 +202,6 @@ function makeSingleCamHarness() { cameraStore.renameTrackPair(id, currentType, newType) ), groupFilterControls, - setType: () => undefined, removeTypes: () => [], }); const modeManager = useModeManager({ diff --git a/client/src/BaseFilterControls.ts b/client/src/BaseFilterControls.ts index 9947360cc..a12d96920 100644 --- a/client/src/BaseFilterControls.ts +++ b/client/src/BaseFilterControls.ts @@ -32,8 +32,6 @@ export interface FilterControlsParams { sorted: Ref[]>; markChangesPending: MarkChangesPendingFilter; remove: (id: AnnotationId) => void; - setType: (id: AnnotationId, newType: string, - confidenceVal?: number, currentType?: string) => void; removeTypes: (id: AnnotationId, types: string[]) => ConfidencePair[]; getTrack?: (trackId: Readonly, cameraName?: string) => T; } @@ -81,9 +79,6 @@ export default abstract class BaseFilterControls { remove: (id: AnnotationId) => void; - setType: (id: AnnotationId, newType: string, - confidenceVal?: number, currentType?: string) => void; - removeTypes: (id: AnnotationId, types: string[]) => ConfidencePair[]; disableAnnotationFilters: Ref; @@ -101,8 +96,6 @@ export default abstract class BaseFilterControls { this.remove = params.remove; - this.setType = params.setType; - this.removeTypes = params.removeTypes; this.markChangesPending = params.markChangesPending; @@ -174,6 +167,19 @@ export default abstract class BaseFilterControls { } } + /** + * Carry a renamed type's confidence threshold over to its new name, unless + * the new name already carries one of its own. + */ + protected carryConfidenceFilter(currentType: string, newType: string) { + if (!(newType in this.confidenceFilters.value) && currentType in this.confidenceFilters.value) { + this.setConfidenceFilters({ + ...this.confidenceFilters.value, + [newType]: this.confidenceFilters.value[currentType], + }); + } + } + protected deleteTypeConfiguration(type: string) { if (this.configuredTypes.value.includes(type)) { this.configuredTypes.value.splice(this.configuredTypes.value.indexOf(type), 1); @@ -197,25 +203,7 @@ export default abstract class BaseFilterControls { this.timeFilters.value = val; } - updateTypeName({ currentType, newType }: { currentType: string; newType: string }) { - //Go through the entire list and replace the oldType with the new Type - this.sorted.value.forEach((annotation) => { - for (let i = 0; i < annotation.confidencePairs.length; i += 1) { - const [name, confidenceVal] = annotation.confidencePairs[i]; - if (name === currentType) { - this.setType(annotation.id, newType, confidenceVal, currentType); - break; - } - } - }); - if (!(newType in this.confidenceFilters.value) && currentType in this.confidenceFilters.value) { - this.setConfidenceFilters({ - ...this.confidenceFilters.value, - [newType]: this.confidenceFilters.value[currentType], - }); - } - this.deleteType(currentType); - } + abstract updateTypeName(params: { currentType: string; newType: string }): void; removeTypeAnnotations(types: string[]) { const processedIds = new Set(); diff --git a/client/src/CameraStore.ts b/client/src/CameraStore.ts index ca1e1dd8f..19438ca5d 100644 --- a/client/src/CameraStore.ts +++ b/client/src/CameraStore.ts @@ -364,16 +364,6 @@ export default class CameraStore { }); } - // Update all cameras to have the same track type - setTrackType(id: AnnotationId, newType: string, confidenceVal?: number, currentType?: string) { - this.camMap.value.forEach((camera) => { - const track = camera.trackStore.getPossible(id); - if (track !== undefined) { - track.setType(newType, confidenceVal, currentType); - } - }); - } - setGroupType(id: AnnotationId, newType: string, confidenceVal?: number, currentType?: string) { this.camMap.value.forEach((camera) => { const group = camera.groupStore.getPossible(id); diff --git a/client/src/GroupFilterControls.ts b/client/src/GroupFilterControls.ts index 3cbf334bd..7704c95cc 100644 --- a/client/src/GroupFilterControls.ts +++ b/client/src/GroupFilterControls.ts @@ -1,14 +1,28 @@ import { computed, ref, Ref } from 'vue'; import { cloneDeep } from 'lodash'; +import { AnnotationId } from './BaseAnnotation'; import BaseFilterControls, { AnnotationWithContext, FilterControlsParams } from './BaseFilterControls'; import type Group from './Group'; +export interface GroupFilterControlsParams extends FilterControlsParams { + setGroupType: ( + id: AnnotationId, + newType: string, + confidenceVal?: number, + currentType?: string, + ) => void; +} + export default class GroupFilterControls extends BaseFilterControls { filteredAnnotations: Ref[]>; - constructor(params: FilterControlsParams) { + private setGroupType: GroupFilterControlsParams['setGroupType']; + + constructor(params: GroupFilterControlsParams) { super(params); + this.setGroupType = params.setGroupType; + /** * Override default confidence filters. There is no UI to adjust this, * so filter nothing by default @@ -46,4 +60,18 @@ export default class GroupFilterControls extends BaseFilterControls { return resultsArr; }); } + + updateTypeName({ currentType, newType }: { currentType: string; newType: string }) { + this.sorted.value.forEach((annotation) => { + for (let i = 0; i < annotation.confidencePairs.length; i += 1) { + const [name, confidenceVal] = annotation.confidencePairs[i]; + if (name === currentType) { + this.setGroupType(annotation.id, newType, confidenceVal, currentType); + break; + } + } + }); + this.carryConfidenceFilter(currentType, newType); + this.deleteType(currentType); + } } diff --git a/client/src/TrackFilterControls.spec.ts b/client/src/TrackFilterControls.spec.ts index 9eeeb2f67..c71aefca7 100644 --- a/client/src/TrackFilterControls.spec.ts +++ b/client/src/TrackFilterControls.spec.ts @@ -67,7 +67,7 @@ function makeCameraStore() { } function makeGroupFilterControls(store: CameraStore) { - const setTrackType = ( + const setGroupType = ( id: AnnotationId, newType: string, confidenceVal?: number, @@ -83,7 +83,7 @@ function makeGroupFilterControls(store: CameraStore) { sorted: store.sortedGroups, remove, markChangesPending, - setType: setTrackType, + setGroupType, removeTypes, }); } @@ -91,14 +91,6 @@ function makeGroupFilterControls(store: CameraStore) { function makeTrackFilterControls(markPending: MarkChangesPendingFilter = markChangesPending) { const cameraStore = makeCameraStore(); const groupFilterControls = makeGroupFilterControls(cameraStore); - const setTrackType = ( - id: AnnotationId, - newType: string, - confidenceVal?: number, - currentType?: string, - ) => { - cameraStore.setTrackType(id, newType, confidenceVal, currentType); - }; const removeTypes = (id: AnnotationId, types: string[]) => cameraStore.removeTypes(id, types); const remove = (id: AnnotationId) => { @@ -115,7 +107,6 @@ function makeTrackFilterControls(markPending: MarkChangesPendingFilter = markCha renameTrackPair: (id, currentType, newType) => ( cameraStore.renameTrackPair(id, currentType, newType) ), - setType: setTrackType, removeTypes, }); } @@ -141,9 +132,6 @@ function makePairFixture( renameTrackPair: (id, currentType, newType) => ( cameraStore.renameTrackPair(id, currentType, newType) ), - setType: (id, type, confidence, current) => ( - cameraStore.setTrackType(id, type, confidence, current) - ), removeTypes: (id, types) => cameraStore.removeTypes(id, types), }); return { cameraStore, filters, markPending }; @@ -527,6 +515,20 @@ describe('useAnnotationFilters', () => { expect(withoutPrevent).toBe(1); }); + it('does not synthesize an unstored parent when the stored child cannot qualify', () => { + const { cameraStore, filters } = makePairFixture([[['leaf', 0.8]]]); + filters.setTypeHierarchy({ leaf: 'root' }); + + filters.updateCheckedTypes(['root']); + expect(filters.displayPairIndex(cameraStore.getTrack(0), 0)).toBe(-1); + expect(filters.filteredAnnotations.value).toEqual([]); + + filters.updateCheckedTypes(['root', 'leaf']); + filters.setConfidenceFilters({ leaf: 0.9, default: 0.1 }); + expect(filters.displayPairIndex(cameraStore.getTrack(0), 0)).toBe(-1); + expect(filters.filteredAnnotations.value).toEqual([]); + }); + it('keeps empty flat-mode annotations when Prevent Cascade is enabled', () => { const { filters } = makePairFixture([[]]); clientSettings.typeSettings.preventCascadeTypes = true; @@ -643,9 +645,6 @@ describe('useAnnotationFilters', () => { renameTrackPair: (id, currentType, newType) => ( cameraStore.renameTrackPair(id, currentType, newType) ), - setType: (id, type, confidence, current) => ( - cameraStore.setTrackType(id, type, confidence, current) - ), removeTypes: (id, types) => cameraStore.removeTypes(id, types), }); filters.loadTrackAttributesFilter([{ @@ -749,6 +748,29 @@ describe('useAnnotationFilters', () => { expect(groupFilters.configuredTypes.value).not.toContain('renamed group'); }); + it('renames assigned group pairs across camera replicas without collapsing the vector', () => { + const cameraStore = new CameraStore({ markChangesPending }); + cameraStore.removeCamera('singleCam'); + cameraStore.addCamera('left'); + cameraStore.addCamera('right'); + cameraStore.camMap.value.forEach(({ groupStore }) => { + groupStore.insert(new Group(7, { + confidencePairs: [['school', 0.7], ['other', 0.4]], + members: {}, + }), { imported: true }); + groupStore.setEnableSorting(); + }); + const groupFilters = makeGroupFilterControls(cameraStore); + + groupFilters.updateTypeName({ currentType: 'school', newType: 'shoal' }); + + cameraStore.camMap.value.forEach(({ groupStore }) => { + expect(groupStore.get(7).confidencePairs).toEqual([ + ['shoal', 0.7], ['other', 0.4], + ]); + }); + }); + it('renames a flat confidence-1 pair without collapsing the vector', () => { const { cameraStore, filters } = makePairFixture([ [['leaf', 1], ['other', 0.4]], diff --git a/client/src/TrackFilterControls.ts b/client/src/TrackFilterControls.ts index 85bcd3cc0..2efb8008c 100644 --- a/client/src/TrackFilterControls.ts +++ b/client/src/TrackFilterControls.ts @@ -256,13 +256,7 @@ export default class TrackFilterControls extends BaseFilterControls { this.renameTrackPair(annotation.id, currentType, newType); } }); - if (!(newType in this.confidenceFilters.value) - && currentType in this.confidenceFilters.value) { - this.setConfidenceFilters({ - ...this.confidenceFilters.value, - [newType]: this.confidenceFilters.value[currentType], - }); - } + this.carryConfidenceFilter(currentType, newType); this.deleteType(currentType); return; } @@ -290,13 +284,7 @@ export default class TrackFilterControls extends BaseFilterControls { this.renameTrackPair(annotation.id, currentType, newType); } }); - if (!(newType in this.confidenceFilters.value) - && currentType in this.confidenceFilters.value) { - this.setConfidenceFilters({ - ...this.confidenceFilters.value, - [newType]: this.confidenceFilters.value[currentType], - }); - } + this.carryConfidenceFilter(currentType, newType); if (this.configuredTypes.value.includes(currentType) && !this.configuredTypes.value.includes(newType)) { this.configuredTypes.value.push(newType); diff --git a/client/src/components/FilterList.spec.ts b/client/src/components/FilterList.spec.ts index 14ce75d3d..53d9a9675 100644 --- a/client/src/components/FilterList.spec.ts +++ b/client/src/components/FilterList.spec.ts @@ -5,12 +5,26 @@ import { } from 'vue'; import { shallowMount } from '@vue/test-utils'; import { clientSettings } from 'dive-common/store/settings'; +import TrackFilterControls from '../TrackFilterControls'; +import Track, { Feature } from '../track'; +import BaseFilterControls from '../BaseFilterControls'; +import Group from '../Group'; +import CameraStore from '../CameraStore'; import FilterList from './FilterList.vue'; vi.mock('dive-common/vue-utilities/prompt-service', () => ({ usePrompt: () => ({ prompt: vi.fn(), visible: () => false }), })); +const provideMocks = vi.hoisted(() => ({ + seekFrame: vi.fn(), + intervalSearch: vi.fn<(range: [number, number]) => string[]>(() => []), + getPossible: vi.fn(), + annotationMap: new Map(), + selectedCameraValue: 'singleCam', + selectedCameraRef: undefined as { value: string } | undefined, +})); + /** * `@vue/test-utils` types a mount target as a Vue 2 constructor, which a `defineComponent` * SFC is not, so the list is rendered from a host that captures the real instance. It is left @@ -45,21 +59,133 @@ vi.mock('../provides', () => ({ useCameraStore: () => ({ camMap: ref(new Map([['singleCam', { trackStore: { - annotationMap: new Map(), - intervalTree: { search: () => [] }, - getPossible: () => undefined, + annotationMap: provideMocks.annotationMap, + intervalTree: { search: provideMocks.intervalSearch }, + getPossible: provideMocks.getPossible, }, }]])), - getAnyPossibleTrack: () => undefined, }), - useHandler: () => ({ seekFrame: vi.fn() }), + useHandler: () => ({ seekFrame: provideMocks.seekFrame }), useReadOnlyMode: () => ref(false), - useSelectedCamera: () => ref('singleCam'), + useSelectedCamera: () => { + const selectedCamera = ref(provideMocks.selectedCameraValue); + provideMocks.selectedCameraRef = selectedCamera; + return selectedCamera; + }, useTime: () => ({ frame: ref(0) }), usePendingSaveCount: () => ref(0), })); +function makeFilterListFixture({ + tracks, + hierarchy = null, + checkedTypes, + confidenceFilters, +}: { + tracks: Track[]; + hierarchy?: Record | null; + checkedTypes: string[]; + confidenceFilters?: Record; +}) { + const cameraStore = new CameraStore({ markChangesPending: vi.fn() }); + const trackStore = cameraStore.camMap.value.get('singleCam')?.trackStore; + tracks.forEach((track) => trackStore?.insert(track)); + trackStore?.setEnableSorting(); + const filterControls = new TrackFilterControls({ + sorted: cameraStore.sortedTracks, + remove: vi.fn(), + markChangesPending: vi.fn(), + removeTypes: vi.fn(() => []), + lookupGroups: () => [], + groupFilterControls: { enabledAnnotations: ref([]) } as unknown as BaseFilterControls, + getTracks: (id) => tracks.filter((track) => track.id === id), + renameTrackPair: vi.fn(() => []), + }); + if (hierarchy) filterControls.setTypeHierarchy(hierarchy); + filterControls.setConfidenceFilters(confidenceFilters); + filterControls.updateCheckedTypes(checkedTypes); + const updateCheckedTypes = vi.spyOn(filterControls, 'updateCheckedTypes'); + Object.freeze(filterControls); + const styleManager = Object.freeze({ + customStyles: ref({}), + typeStyling: ref({ + color: (type: string) => `color:${type}`, + strokeWidth: () => 1, + fill: () => false, + opacity: () => 1, + }), + }); + return { + checkedTypes: filterControls.checkedTypes, + filterControls, + styleManager, + tracks, + updateCheckedTypes, + }; +} + +function makeHierarchyFixture(hierarchy: Record = { + leaf: 'branch', branch: 'root', sibling: 'root', +}) { + return makeFilterListFixture({ + tracks: [new Track(1, { + confidencePairs: [['leaf', 1]], + features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }], + })], + hierarchy, + checkedTypes: ['leaf'], + confidenceFilters: { root: 0.6, default: 0.1 }, + }); +} + +function featuresAt(frames: number[], suppressedFrames: readonly number[] = []) { + const features: Feature[] = []; + frames.forEach((frame) => { + features[frame] = { + frame, + bounds: [0, 0, 1, 1], + keyframe: true, + attributes: suppressedFrames.includes(frame) ? { Suppressed: true } : undefined, + }; + }); + return features; +} + +function makeCountHierarchyFixture({ + tracks = [ + new Track(1, { + confidencePairs: [['leaf', 1]], + features: featuresAt([0, 5]), + }), + new Track(2, { + confidencePairs: [['branch', 1]], + features: featuresAt([5]), + }), + new Track(3, { + confidencePairs: [['sibling', 1]], + features: featuresAt([5]), + }), + ], + hierarchy = { leaf: 'branch', branch: 'root', sibling: 'root' }, + checkedTypes = ['root', 'branch', 'leaf', 'sibling'], +}: { + tracks?: Track[]; + hierarchy?: Record | null; + checkedTypes?: string[]; +} = {}) { + return makeFilterListFixture({ tracks, hierarchy, checkedTypes }); +} + describe('FilterList hierarchy members', () => { + beforeEach(() => { + provideMocks.seekFrame.mockReset(); + provideMocks.intervalSearch.mockReset().mockReturnValue([]); + provideMocks.getPossible.mockReset(); + provideMocks.annotationMap.clear(); + provideMocks.selectedCameraValue = 'singleCam'; + provideMocks.selectedCameraRef = undefined; + }); + it('keeps members as ordinary, independently checked flat rows', async () => { clientSettings.typeSettings.trackSortDir = 'a-z'; clientSettings.typeSettings.filterTypesByFrame = false; @@ -68,6 +194,7 @@ describe('FilterList hierarchy members', () => { const filterControls = Object.freeze({ allTypes: ref(['leaf', 'heading']), usedTypes: ref(['leaf']), + configuredTypes: ref(['heading']), checkedTypes, filteredAnnotations: ref([]), confidenceFilters: ref({ default: 0.1 }), @@ -89,6 +216,7 @@ describe('FilterList hierarchy members', () => { showEmptyTypes: false, height: 240, headerHeight: 80, + group: false, }); expect(vm.visibleTypes).toEqual(['leaf']); expect(vm.virtualHeight).toBe(160); @@ -96,9 +224,11 @@ describe('FilterList hierarchy members', () => { await setProps({ showEmptyTypes: true }); expect(vm.visibleTypes).toEqual(['heading', 'leaf']); expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['heading', 'leaf']); - vm.updateCheckedType(false, 'heading'); + vm.updateCheckedType('heading'); expect(checkedTypes.value).toEqual(['leaf']); expect(vm.virtualTypes.find(({ type }) => type === 'heading')?.checked).toBe(false); + vm.updateCheckedType('heading'); + expect(checkedTypes.value).toEqual(['leaf', 'heading']); }); it('counts the type selected by each filtered annotation context', () => { @@ -109,6 +239,7 @@ describe('FilterList hierarchy members', () => { const filterControls = Object.freeze({ allTypes: ref(['root', 'leaf']), usedTypes: ref(['root', 'leaf']), + configuredTypes: ref([]), checkedTypes: ref(['root', 'leaf']), filteredAnnotations: ref([{ annotation: { @@ -148,4 +279,412 @@ describe('FilterList hierarchy members', () => { expect(vm.virtualTypes.find(({ type }) => type === 'root')?.displayText) .toBe('0 : 0\u00A0 root'); }); + + it('renders an expanded hierarchy with depth, tri-state, and structural parents', async () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + clientSettings.typeSettings.suppressionType = 'root'; + clientSettings.typeSettings.suppressionThreshold = 80; + const { filterControls, styleManager } = makeHierarchyFixture(); + const { wrapper, vm, setProps } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: false, + height: 240, + headerHeight: 80, + group: false, + }); + + expect(vm.hierarchyActive).toBe(true); + expect(wrapper.find('v-virtual-scroll').attributes()).toEqual(expect.objectContaining({ + role: 'list', + 'aria-label': 'Track type hierarchy', + })); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['root', 'branch', 'leaf']); + expect(vm.virtualTypes).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'root', depth: 0, hasChildren: true, expanded: true, indeterminate: true, + }), + expect.objectContaining({ + type: 'branch', depth: 1, hasChildren: true, expanded: true, indeterminate: true, + }), + expect.objectContaining({ + type: 'leaf', depth: 2, hasChildren: false, checked: true, + }), + ])); + expect(vm.virtualTypes.find(({ type }) => type === 'root')).toEqual(expect.objectContaining({ + confidenceFilterNum: 0.6, + color: 'color:root', + isSuppressionType: true, + suppressionThreshold: 80, + })); + + await setProps({ showEmptyTypes: true }); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual([ + 'root', 'branch', 'leaf', 'sibling', + ]); + + await setProps({ group: true }); + expect(vm.hierarchyActive).toBe(false); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual([ + 'branch', 'leaf', 'root', 'sibling', + ]); + expect(vm.virtualTypes.every(({ tree }) => !tree)).toBe(true); + }); + + it('shows and recompacts a shared lineage without changing branch collapse state', async () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + const { filterControls, styleManager } = makeHierarchyFixture({ + leaf: 'branch', + sibling: 'branch', + branch: 'class', + class: 'phylum', + phylum: 'kingdom', + kingdom: 'domain', + }); + const { wrapper, vm } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: false, + height: 240, + headerHeight: 80, + }); + + expect(vm.sharedLineage).toEqual(['domain', 'kingdom', 'phylum', 'class']); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['branch', 'leaf']); + expect(vm.virtualTypes[0].depth).toBe(0); + expect(vm.virtualHeight).toBe(130); + expect(wrapper.find('.shared-lineage-text').text()).toBe( + 'domain › kingdom › phylum › class', + ); + expect(wrapper.find('.shared-lineage-text').classes()).toEqual(expect.arrayContaining([ + 'text-body-2', 'grey--text', 'text--lighten-1', + ])); + expect(wrapper.find('.shared-lineage-action').text()).toBe('Expand Parents'); + + await wrapper.find('.shared-lineage-action').trigger('click'); + expect(vm.compactSharedLineage).toBe(false); + expect(wrapper.find('.shared-lineage-text').exists()).toBe(false); + expect(wrapper.find('.shared-lineage-action').text()).toBe('Compact Parents'); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual([ + 'domain', 'kingdom', 'phylum', 'class', 'branch', 'leaf', + ]); + + vm.toggleExpanded('branch'); + vm.toggleSharedLineage(); + await nextTick(); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['branch']); + + vm.toggleSharedLineage(); + await nextTick(); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual([ + 'domain', 'kingdom', 'phylum', 'class', 'branch', + ]); + }); + + it('restores collapse after search and keeps parent updates complete and atomic', async () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + clientSettings.typeSettings.suppressionType = ''; + const { + checkedTypes, filterControls, styleManager, updateCheckedTypes, + } = makeHierarchyFixture(); + const { vm } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: false, + height: 240, + headerHeight: 80, + }); + + vm.toggleExpanded('root'); + await nextTick(); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['root']); + + vm.data.filterText = 'leaf'; + await nextTick(); + expect(vm.visibleTypes).toEqual(['leaf']); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['root', 'branch', 'leaf']); + + vm.data.filterText = ''; + await nextTick(); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['root']); + + vm.updateCheckedType('root'); + expect(updateCheckedTypes).toHaveBeenCalledTimes(1); + expect(checkedTypes.value).toEqual(['leaf', 'root', 'branch', 'sibling']); + + vm.updateCheckedType('root'); + expect(updateCheckedTypes).toHaveBeenCalledTimes(2); + expect(checkedTypes.value).toEqual([]); + }); + + it('ignores hidden disclosure actions while search forces a path open', async () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + const { filterControls, styleManager } = makeHierarchyFixture(); + const { vm } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: false, + height: 240, + headerHeight: 80, + }); + + vm.data.filterText = 'leaf'; + await nextTick(); + vm.toggleExpanded('root'); + vm.data.filterText = ''; + await nextTick(); + + expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['root', 'branch', 'leaf']); + }); + + it('resets collapsed branches when a dataset hierarchy is loaded', async () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + const { filterControls, styleManager } = makeHierarchyFixture(); + const { vm } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: false, + height: 240, + headerHeight: 80, + }); + + vm.toggleExpanded('root'); + await nextTick(); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['root']); + + filterControls.typeHierarchy.value = { + leaf: 'branch', branch: 'root', sibling: 'root', + }; + await nextTick(); + + expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['root', 'branch', 'leaf']); + }); + + it('keeps the header query-scoped while a context parent owns its full subtree', async () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + const { + checkedTypes, filterControls, styleManager, updateCheckedTypes, + } = makeHierarchyFixture(); + checkedTypes.value = []; + const { vm } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: true, + height: 240, + headerHeight: 80, + }); + + vm.data.filterText = 'leaf'; + await nextTick(); + expect(vm.visibleTypes).toEqual(['leaf']); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['root', 'branch', 'leaf']); + + vm.headCheckClicked(); + expect(checkedTypes.value).toEqual(['leaf']); + expect(updateCheckedTypes).toHaveBeenCalledTimes(1); + + vm.updateCheckedType('root'); + expect(checkedTypes.value).toEqual(['leaf', 'root', 'branch', 'sibling']); + expect(updateCheckedTypes).toHaveBeenCalledTimes(2); + }); + + it('rolls total and frame counts into ancestors and keeps frame filtering header-independent', async () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = true; + clientSettings.typeSettings.suppressionType = ''; + provideMocks.intervalSearch.mockImplementation(([frame]: [number, number]) => ( + frame === 0 ? ['1'] : ['1', '2', '3'] + )); + const { filterControls, styleManager, tracks } = makeCountHierarchyFixture(); + provideMocks.getPossible.mockImplementation((id: number) => ( + tracks.find((track) => track.id === id) + )); + const { vm } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: false, + height: 240, + headerHeight: 80, + }); + + expect(vm.typeCounts).toEqual(new Map([ + ['leaf', 1], ['branch', 2], ['root', 3], ['sibling', 1], + ])); + expect(vm.virtualTypes.map(({ type }) => type)).toEqual(['root', 'branch', 'leaf']); + expect(vm.virtualTypes.map(({ displayText }) => displayText)).toEqual([ + '3 : 1\u00A0 root', + '2 : 1\u00A0 branch', + '1 : 1\u00A0 leaf', + ]); + expect(vm.visibleTypes).toEqual(['root', 'branch', 'leaf', 'sibling']); + + provideMocks.seekFrame.mockClear(); + vm.goToPeakTrackFrame('branch'); + expect(provideMocks.seekFrame).toHaveBeenLastCalledWith(5); + vm.goToPeakTrackFrame('root'); + expect(provideMocks.seekFrame).toHaveBeenLastCalledWith(5); + }); + + it.each([ + ['flat type', null, 'leaf'], + ['hierarchy parent', { leaf: 'root' }, 'root'], + ] as const)('jumps to the suppression-aware peak for a %s', ( + _view, + hierarchy, + targetType, + ) => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + clientSettings.typeSettings.suppressionType = 'Suppressed'; + provideMocks.intervalSearch.mockImplementation(([frame]: [number, number]) => ( + frame === 0 ? ['1', '2', '3'] : ['1', '2'] + )); + const tracks = [ + new Track(1, { + confidencePairs: [['leaf', 1]], features: featuresAt([0, 5], [0]), + }), + new Track(2, { + confidencePairs: [['leaf', 1]], features: featuresAt([0, 5], [0]), + }), + new Track(3, { + confidencePairs: [['leaf', 1]], features: featuresAt([0]), + }), + ]; + const { filterControls, styleManager } = makeCountHierarchyFixture({ + tracks, + hierarchy, + checkedTypes: hierarchy ? ['root', 'leaf'] : ['leaf'], + }); + provideMocks.getPossible.mockImplementation((id: number) => ( + tracks.find((track) => track.id === id) + )); + const { vm } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: false, + height: 240, + headerHeight: 80, + }); + + expect(vm.virtualTypes.find(({ type }) => type === targetType)?.displayText) + .toBe(`3 : 1\u00A0 ${targetType}`); + const intervalSearchCalls = provideMocks.intervalSearch.mock.calls.length; + + vm.goToPeakTrackFrame(targetType); + + expect(provideMocks.seekFrame).toHaveBeenCalledWith(5); + expect(provideMocks.intervalSearch).toHaveBeenCalledTimes(intervalSearchCalls); + }); + + it('finds a region-suppression-aware peak without rescanning each frame', () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + clientSettings.typeSettings.suppressionType = 'Suppressed'; + clientSettings.typeSettings.suppressionThreshold = 99; + provideMocks.intervalSearch.mockImplementation(([frame]: [number, number]) => ( + frame === 0 ? ['1', '2', '3', '4'] : ['1', '2'] + )); + const tracks = [ + new Track(1, { + confidencePairs: [['leaf', 1]], features: featuresAt([0, 5]), + }), + new Track(2, { + confidencePairs: [['leaf', 1]], features: featuresAt([0, 5]), + }), + new Track(3, { + confidencePairs: [['leaf', 1]], features: featuresAt([0]), + }), + new Track(4, { + confidencePairs: [['Suppressed', 1]], features: featuresAt([0]), + }), + ]; + tracks.forEach((track) => provideMocks.annotationMap.set(track.id, track)); + const { filterControls, styleManager } = makeCountHierarchyFixture({ + tracks, + hierarchy: null, + checkedTypes: ['leaf', 'Suppressed'], + }); + provideMocks.getPossible.mockImplementation((id: number) => ( + tracks.find((track) => track.id === id) + )); + const { vm } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: false, + height: 240, + headerHeight: 80, + }); + + expect(vm.virtualTypes.find(({ type }) => type === 'leaf')?.displayText) + .toBe('3 : 0\u00A0 leaf'); + const intervalSearchCalls = provideMocks.intervalSearch.mock.calls.length; + + vm.goToPeakTrackFrame('leaf'); + + expect(provideMocks.seekFrame).toHaveBeenCalledWith(5); + expect(provideMocks.intervalSearch).toHaveBeenCalledTimes(intervalSearchCalls); + }); + + it('starts current-frame counts when an asynchronous camera selection becomes available', async () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + clientSettings.typeSettings.suppressionType = ''; + provideMocks.selectedCameraValue = ''; + provideMocks.intervalSearch.mockReturnValue(['1']); + const { filterControls, styleManager, tracks } = makeCountHierarchyFixture(); + provideMocks.getPossible.mockImplementation((id: number) => ( + tracks.find((track) => track.id === id) + )); + const { vm } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: false, + height: 240, + headerHeight: 80, + }); + + expect(vm.virtualTypes.find(({ type }) => type === 'root')?.displayText) + .toBe('3 : 0\u00A0 root'); + + if (!provideMocks.selectedCameraRef) { + throw new Error('selected camera ref was not captured'); + } + provideMocks.selectedCameraRef.value = 'singleCam'; + await nextTick(); + + expect(vm.virtualTypes.find(({ type }) => type === 'root')?.displayText) + .toBe('3 : 1\u00A0 root'); + }); + + it('does not restore attribute-suppressed descendants through ancestor roll-up', () => { + clientSettings.typeSettings.trackSortDir = 'a-z'; + clientSettings.typeSettings.filterTypesByFrame = false; + clientSettings.typeSettings.suppressionType = 'Suppressed'; + const { filterControls, styleManager, tracks } = makeCountHierarchyFixture(); + tracks[0].attributes.Suppressed = true; + tracks.forEach((track) => provideMocks.annotationMap.set(track.id, track)); + const { vm } = mountFilterList({ + filterControls, + styleManager, + showEmptyTypes: false, + height: 240, + headerHeight: 80, + }); + + expect(vm.typeCounts).toEqual(new Map([ + ['branch', 1], ['root', 2], ['sibling', 1], + ])); + expect(vm.virtualTypes.map(({ displayText }) => displayText)).toEqual([ + '2 : 0\u00A0 root', + '1 : 0\u00A0 branch', + '0 : 0\u00A0 leaf', + '1 : 0\u00A0 sibling', + ]); + }); }); diff --git a/client/src/components/FilterList.vue b/client/src/components/FilterList.vue index 343bdd59e..4eed59c00 100644 --- a/client/src/components/FilterList.vue +++ b/client/src/components/FilterList.vue @@ -3,10 +3,13 @@ import { computed, defineComponent, onBeforeUnmount, PropType, reactive, ref, Ref, watch, } from 'vue'; -import { debounce, difference, union } from 'lodash'; +import { + debounce, difference, union, +} from 'lodash'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { clientSettings } from 'dive-common/store/settings'; +import { compileHierarchy } from 'dive-common/typeHierarchy'; import { useCameraStore, useHandler, useReadOnlyMode, useSelectedCamera, useTime, usePendingSaveCount, @@ -14,21 +17,30 @@ import { import TooltipBtn from './TooltipButton.vue'; import TypeEditor from './TypeEditor.vue'; import TypeItem from './TypeItem.vue'; -import BaseFilterControls from '../BaseFilterControls'; +import BaseFilterControls, { AnnotationWithContext } from '../BaseFilterControls'; import TrackFilterControls from '../TrackFilterControls'; import Track from '../track'; import Group from '../Group'; import StyleManager from '../StyleManager'; import { - getSuppressedTrackIds, hasSuppressionAttribute, suppressionTypeResolver, + createRegionSuppressionTester, getSuppressedTrackIds, hasSuppressionAttribute, + suppressionTypeResolver, } from '../use/suppression'; +import { + buildTypeListModel, countResolvedTypes, TypeListModel, TypeListRow, + updateHierarchyCheckedTypes, +} from '../typeListHierarchy'; + +/* Row height shared by the type rows, the shared-lineage breadcrumb, and the + scroller's height accounting. Mirrored by `$row-height` in the style block. */ +const ROW_HEIGHT = 30; +const EMPTY_HIERARCHY_INDEX = compileHierarchy({}); -interface VirtualTypeItem { - type: string; +interface VirtualTypeItem extends TypeListRow { confidenceFilterNum: number; displayText: string; color: string; - checked: boolean; + tree: boolean; isSuppressionType: boolean; suppressionThreshold: number; } @@ -81,7 +93,9 @@ export default defineComponent({ // counts so they re-evaluate suppression when a region is moved, since a // track's geometry is not itself a reactive dependency. const pendingSaveCount = usePendingSaveCount(); - const trackStore = cameraStore.camMap.value.get(selectedCamera.value)?.trackStore; + const trackStore = computed(() => ( + cameraStore.camMap.value.get(selectedCamera.value)?.trackStore + )); // Ordering of these lists should match const sortingMethods: ('a-z' | 'count' | 'frame count')[] = ['a-z', 'count', 'frame count']; const sortingMethodIcons = ['mdi-sort-alphabetical-ascending', 'mdi-sort-numeric-ascending', 'mdi-sort-clock-ascending-outline']; @@ -109,6 +123,20 @@ export default defineComponent({ const typeStylingRef = props.styleManager.typeStyling; const filteredTracksRef = trackFilters.filteredAnnotations; const confidenceFiltersRef = trackFilters.confidenceFilters; + const collapsedTypes: Ref> = ref(new Set()); + const compactSharedLineage = ref(true); + const hierarchyIndexRef = computed(() => ( + !props.group && trackFilters instanceof TrackFilterControls + ? trackFilters.hierarchyIndex.value + : undefined + )); + const hierarchyActive = computed(() => hierarchyIndexRef.value !== undefined); + if (trackFilters instanceof TrackFilterControls) { + watch(trackFilters.typeHierarchy, () => { + collapsedTypes.value = new Set(); + compactSharedLineage.value = true; + }); + } function clickEdit(type: string) { data.selectedType = type; @@ -224,38 +252,52 @@ export default defineComponent({ ); onBeforeUnmount(() => debouncedFullySuppressed.cancel()); + /** + * Tally displayed types. With a hierarchy installed each track also counts + * toward its ancestors, and a track spanning several cameras counts once. + */ + function countTypes(tracks: readonly AnnotationWithContext[]) { + const entries = tracks.map(({ annotation, context }) => ({ + id: annotation.id, + type: annotation.getType(context.confidencePairIndex), + })); + const hierarchyIndex = hierarchyIndexRef.value; + if (hierarchyIndex) { + return countResolvedTypes(entries, hierarchyIndex); + } + return entries.reduce( + (acc, { type }) => acc.set(type, (acc.get(type) || 0) + 1), + new Map(), + ); + } + const typeCounts = computed(() => { const excluded = fullySuppressedIds.value; - return filteredTracksRef.value.reduce((acc, filteredTrack) => { - if (excluded.has(filteredTrack.annotation.id)) { - return acc; - } - const confidencePair = filteredTrack.annotation - .getType(filteredTrack.context.confidencePairIndex); - const trackType = confidencePair; - acc.set(trackType, (acc.get(trackType) || 0) + 1); - - return acc; - }, new Map()); + return countTypes(filteredTracksRef.value + .filter(({ annotation }) => !excluded.has(annotation.id))); }); - const filteredTracksForFrame = computed(() => { + function countedTracksForFrame(targetFrame: number) { // Depend on the edit counter so moving/resizing a suppression region // (which mutates geometry, not the reactive track set) re-runs the count. // It is always >= 0, so this reads the dependency without changing logic. const editRevision = pendingSaveCount.value; - const trackIdsForFrame = trackStore?.intervalTree - .search([frame.value, frame.value]) + const activeTrackStore = trackStore.value; + if (!activeTrackStore) { + return []; + } + const trackIdsForFrame = activeTrackStore.intervalTree + .search([targetFrame, targetFrame]) .map((str) => parseInt(str, 10)); // Detections suppressed by a region on this frame are dropped so the // per-frame type counts read off the interface exclude them, and // attribute-suppressed detections (visible, real type retained) don't // count toward their own type either. const suppType = clientSettings.typeSettings.suppressionType; - const suppressedIds = (trackStore && editRevision >= 0) + const suppressedIds = editRevision >= 0 ? getSuppressedTrackIds( - trackStore, - frame.value, + activeTrackStore, + targetFrame, suppType, clientSettings.typeSettings.suppressionThreshold, { revision: editRevision, resolver: suppressionResolutionRef.value }, @@ -265,73 +307,68 @@ export default defineComponent({ if (suppressedIds.has(track.annotation.id)) { return false; } - const realTrack = trackStore?.getPossible(track.annotation.id); - if (realTrack && hasSuppressionAttribute(realTrack, frame.value, suppType)) { + const realTrack = activeTrackStore.getPossible(track.annotation.id); + if (realTrack && hasSuppressionAttribute(realTrack, targetFrame, suppType)) { return false; } - const keyframe = realTrack?.getFeature(frame.value)[0]; + const keyframe = realTrack?.getFeature(targetFrame)[0]; return !!keyframe?.keyframe; }); - return (filteredKeyFrameTracks.filter((track) => trackIdsForFrame?.includes(track.annotation.id))); - }); - - const currentFrameTrackTypes = computed(() => filteredTracksForFrame.value.reduce((acc, filteredTrack) => { - const confidencePair = filteredTrack.annotation - .getType(filteredTrack.context.confidencePairIndex); - const trackType = confidencePair; - acc.set(trackType, (acc.get(trackType) || 0) + 1); - - return acc; - }, new Map())); - - function sortAndFilterTypes(types: Ref) { - const filtered = types.value - .filter((t) => t.toLowerCase().includes(data.filterText.toLowerCase())); - switch (sortingMethods[data.sortingMethod]) { - case 'a-z': - return filtered.sort(); - case 'count': - return filtered.sort( - (a, b) => (typeCounts.value.get(b) || 0) - (typeCounts.value.get(a) || 0), - ); - case 'frame count': - return filtered.sort( - (a, b) => (currentFrameTrackTypes.value.get(b) || 0) - (currentFrameTrackTypes.value.get(a) || 0), - ); - default: - return filtered; - } + return filteredKeyFrameTracks.filter( + (track) => trackIdsForFrame.includes(track.annotation.id), + ); } - const visibleTypes = computed(() => { - if (props.showEmptyTypes) { - return sortAndFilterTypes(allTypesRef); - } - return sortAndFilterTypes(usedTypesRef); - }); + const filteredTracksForFrame = computed(() => countedTracksForFrame(frame.value)); + + const currentFrameTrackTypes = computed(() => countTypes(filteredTracksForFrame.value)); + const filterTypesByFrame = ref(clientSettings.typeSettings.filterTypesByFrame); watch(() => clientSettings.typeSettings.filterTypesByFrame, (newValue) => { filterTypesByFrame.value = newValue; }); + const noFrameCounts = new Map(); + const typeListModel: Ref = computed(() => { + const sort = sortingMethods[data.sortingMethod]; + const byFrame = filterTypesByFrame.value ?? false; + // The model needs frame counts only when they affect row visibility or order. + // Otherwise playback can update displayed counts without rebuilding the tree. + const usesFrameCounts = byFrame || sort === 'frame count'; + return buildTypeListModel({ + hierarchyIndex: hierarchyIndexRef.value || EMPTY_HIERARCHY_INDEX, + allTypes: allTypesRef.value, + usedTypes: usedTypesRef.value, + configuredTypes: trackFilters.configuredTypes.value, + checkedTypes: checkedTypesRef.value, + counts: typeCounts.value, + frameCounts: usesFrameCounts ? currentFrameTrackTypes.value : noFrameCounts, + showEmpty: props.showEmptyTypes, + query: data.filterText, + filterTypesByFrame: byFrame, + sort, + collapsed: collapsedTypes.value, + compactSharedLineage: compactSharedLineage.value, + }); + }); + const sharedLineage = computed(() => typeListModel.value.sharedLineage); + const sharedLineageText = computed(() => sharedLineage.value.join(' › ')); + const visibleTypes = computed(() => typeListModel.value.actionableTypes); const virtualTypes: Ref = computed(() => { const confidenceFiltersDeRef = confidenceFiltersRef.value; const typeCountsDeRef = typeCounts.value; const typeStylingDeRef = typeStylingRef.value; - const checkedTypesDeRef = checkedTypesRef.value; const frameTrackTypesDeRef = currentFrameTrackTypes.value; - let filteredTypeList = visibleTypes.value; - if (filterTypesByFrame.value) { - filteredTypeList = filteredTypeList.filter((item) => frameTrackTypesDeRef.get(item)); - } const { suppressionType, suppressionThreshold } = clientSettings.typeSettings; - return filteredTypeList.map((item) => ({ - type: item, - confidenceFilterNum: confidenceFiltersDeRef[item] || 0, - displayText: `${typeCountsDeRef.get(item) || 0} : ${frameTrackTypesDeRef.get(item) || 0}\u00A0 ${item}`, - color: typeStylingDeRef.color(item), - checked: checkedTypesDeRef.includes(item), - isSuppressionType: !!suppressionType && item === suppressionType, + const { rows } = typeListModel.value; + return rows.map(({ type, ...row }) => ({ + ...row, + type, + confidenceFilterNum: confidenceFiltersDeRef[type] || 0, + displayText: `${typeCountsDeRef.get(type) || 0} : ${frameTrackTypesDeRef.get(type) || 0}\u00A0 ${type}`, + color: typeStylingDeRef.color(type), + tree: hierarchyActive.value, + isSuppressionType: !!suppressionType && type === suppressionType, suppressionThreshold: suppressionThreshold ?? 99, })); }); @@ -362,36 +399,81 @@ export default defineComponent({ } } - function updateCheckedType(evt: boolean, type: string) { - if (evt) { - trackFilters.updateCheckedTypes(checkedTypesRef.value.concat([type])); + function updateCheckedType(type: string) { + const model = typeListModel.value; + const shouldCheck = model.checkState.get(type) !== 'checked'; + trackFilters.updateCheckedTypes(updateHierarchyCheckedTypes( + checkedTypesRef.value, + model.subtree, + type, + shouldCheck, + )); + } + + function toggleExpanded(type: string) { + if (data.filterText.length > 0) { + return; + } + const next = new Set(collapsedTypes.value); + if (next.has(type)) { + next.delete(type); } else { - trackFilters.updateCheckedTypes(difference(checkedTypesRef.value, [type])); + next.add(type); } + collapsedTypes.value = next; + } + + function toggleSharedLineage() { + compactSharedLineage.value = !compactSharedLineage.value; } - const virtualHeight = computed(() => props.height - props.headerHeight); + const showSharedLineageControl = computed(() => ( + sharedLineage.value.length > 0 && data.filterText.length === 0 + )); + const virtualHeight = computed(() => ( + props.height - props.headerHeight - (showSharedLineageControl.value ? ROW_HEIGHT : 0) + )); const goToPeakTrackFrame = (trackType: string) => { - const frameCounts = new Map(); - - const tracksFilteredByType = filteredTracksRef.value.filter((track) => track.annotation.getType(track.context.confidencePairIndex) === trackType); - tracksFilteredByType.forEach((track) => { - const trackObj = cameraStore.getAnyPossibleTrack(track.annotation.id); - if (trackObj) { - trackObj.features.filter((item) => item.keyframe).forEach((item) => { - const current = frameCounts.get(item.frame) || 0; - frameCounts.set(item.frame, current + 1); + const subtreeSet = new Set( + hierarchyActive.value ? typeListModel.value.subtree.get(trackType) : [trackType], + ); + const tracksFilteredByType = filteredTracksRef.value.filter(({ annotation, context }) => ( + subtreeSet.has(annotation.getType(context.confidencePairIndex)) + )); + const activeTrackStore = trackStore.value; + if (!activeTrackStore) { + handler.seekFrame(-1); + return; + } + /* The displayed frame counts are selected-camera scoped, so the peak is too. */ + const suppType = clientSettings.typeSettings.suppressionType; + const isRegionSuppressed = createRegionSuppressionTester( + activeTrackStore, + suppType, + clientSettings.typeSettings.suppressionThreshold, + suppressionResolutionRef.value, + ); + const countByFrame = new Map(); + tracksFilteredByType.forEach(({ annotation }) => { + const realTrack = activeTrackStore.getPossible(annotation.id); + realTrack?.features + .filter((item) => item?.keyframe) + .forEach((item) => { + if (hasSuppressionAttribute(realTrack, item.frame, suppType) + || isRegionSuppressed(realTrack, item.frame)) { + return; + } + countByFrame.set(item.frame, (countByFrame.get(item.frame) || 0) + 1); }); - } }); let maxFrame = -1; let maxCount = 0; - frameCounts.forEach((count, f) => { + countByFrame.forEach((count, candidateFrame) => { if (count > maxCount) { maxCount = count; - maxFrame = f; + maxFrame = candidateFrame; } }); handler.seekFrame(maxFrame); @@ -409,6 +491,11 @@ export default defineComponent({ return { data, + hierarchyActive, + compactSharedLineage, + sharedLineage, + sharedLineageText, + showSharedLineageControl, headCheckState, visibleTypes, usedTypesRef, @@ -420,6 +507,7 @@ export default defineComponent({ sortingMethodIcons, virtualHeight, virtualTypes, + rowHeight: ROW_HEIGHT, readOnlyMode, filteredTracksRef, disableAnnotationFilters, @@ -430,6 +518,8 @@ export default defineComponent({ headCheckClicked, setCheckedTypes: trackFilters.updateCheckedTypes, updateCheckedType, + toggleExpanded, + toggleSharedLineage, goToPeakTrackFrame, showMaxFrameButton, }; @@ -528,18 +618,47 @@ export default defineComponent({ placeholder="Search types" class="mx-2 mt-2 shrink input-box" > +
+ + {{ sharedLineageText }} + + + {{ compactSharedLineage ? 'Expand Parents' : 'Compact Parents' }} + +