From d54ea943bdba96680b4778b402ea52a9b07eb17b Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 21 Aug 2026 15:31:53 -0400 Subject: [PATCH 1/5] Separate track and group type renaming --- client/dive-common/components/Viewer.vue | 11 +--- client/dive-common/use/useModeManager.spec.ts | 6 +- client/src/BaseFilterControls.ts | 40 +++++-------- client/src/CameraStore.ts | 10 ---- client/src/GroupFilterControls.ts | 30 +++++++++- client/src/TrackFilterControls.spec.ts | 56 +++++++++++++------ client/src/TrackFilterControls.ts | 16 +----- client/src/components/LayerManager.spec.ts | 3 +- client/src/provides.ts | 11 +--- 9 files changed, 89 insertions(+), 94 deletions(-) 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/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/LayerManager.spec.ts b/client/src/components/LayerManager.spec.ts index c1d87881e..2a16068dc 100644 --- a/client/src/components/LayerManager.spec.ts +++ b/client/src/components/LayerManager.spec.ts @@ -253,7 +253,7 @@ function makeMultiCamFixture( sorted: cameraStore.sortedGroups, remove: () => undefined, markChangesPending: () => undefined, - setType: () => undefined, + setGroupType: () => undefined, removeTypes: () => [], }); const trackFilters = new TrackFilterControls({ @@ -266,7 +266,6 @@ function makeMultiCamFixture( cameraStore.renameTrackPair(id, currentType, newType) ), groupFilterControls, - setType: () => undefined, removeTypes: () => [], }); trackFilters.setTypeHierarchy(hierarchy); diff --git a/client/src/provides.ts b/client/src/provides.ts index 93546ebf4..283487e27 100644 --- a/client/src/provides.ts +++ b/client/src/provides.ts @@ -344,14 +344,6 @@ const markChangesPending = () => { }; */ function dummyState(): State { const cameraStore = new CameraStore({ markChangesPending }); - 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, @@ -367,7 +359,7 @@ function dummyState(): State { sorted: cameraStore.sortedGroups, remove: cameraStore.removeGroups, markChangesPending, - setType: setGroupType, + setGroupType, removeTypes: removeGroupTypes, }, ); @@ -381,7 +373,6 @@ function dummyState(): State { renameTrackPair: (id, currentType, newType) => ( cameraStore.renameTrackPair(id, currentType, newType) ), - setType: setTrackType, removeTypes, }); From 2abcfc8cc373a9ad0e18265739f5dc9578601daa Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 21 Aug 2026 15:32:16 -0400 Subject: [PATCH 2/5] Add unified hierarchical Type List model --- client/dive-common/typeHierarchy.ts | 2 +- client/src/typeListHierarchy.spec.ts | 383 +++++++++++++++++++++++++++ client/src/typeListHierarchy.ts | 226 ++++++++++++++++ 3 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 client/src/typeListHierarchy.spec.ts create mode 100644 client/src/typeListHierarchy.ts 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/src/typeListHierarchy.spec.ts b/client/src/typeListHierarchy.spec.ts new file mode 100644 index 000000000..d95b596f8 --- /dev/null +++ b/client/src/typeListHierarchy.spec.ts @@ -0,0 +1,383 @@ +/// +import { compileHierarchy } from 'dive-common/typeHierarchy'; +import { + buildTypeListModel, + BuildTypeListOptions, + countResolvedTypes, + updateHierarchyCheckedTypes, +} from './typeListHierarchy'; + +const hierarchyIndex = compileHierarchy({ + leaf: 'branch', + branch: 'root', + sibling: 'root', + otherLeaf: 'otherRoot', +}); + +function build(overrides: Partial = {}) { + return buildTypeListModel({ + hierarchyIndex, + allTypes: ['leaf', 'branch', 'root', 'sibling', 'otherLeaf', 'otherRoot', 'flat'], + usedTypes: ['leaf', 'flat'], + checkedTypes: ['leaf', 'branch', 'root', 'sibling', 'otherLeaf', 'otherRoot', 'flat'], + counts: new Map([ + ['root', 5], ['branch', 4], ['leaf', 4], ['sibling', 1], + ['otherRoot', 2], ['otherLeaf', 2], ['flat', 3], + ]), + frameCounts: new Map([ + ['root', 3], ['branch', 3], ['leaf', 3], ['sibling', 0], + ['otherRoot', 1], ['otherLeaf', 1], ['flat', 2], + ]), + showEmpty: true, + query: '', + filterTypesByFrame: false, + sort: 'a-z', + collapsed: new Set(), + ...overrides, + }); +} + +describe('typeListHierarchy', () => { + it('filters empty flat types and alphabetizes count ties with an empty hierarchy', () => { + const flatIndex = compileHierarchy({}); + const common = { + hierarchyIndex: flatIndex, + allTypes: ['zebra', 'alpha', 'configured'], + usedTypes: ['zebra', 'alpha'], + checkedTypes: ['zebra', 'alpha', 'configured'], + counts: new Map([['zebra', 1], ['alpha', 1]]), + frameCounts: new Map(), + query: '', + filterTypesByFrame: false, + sort: 'count' as const, + collapsed: new Set(), + }; + + const showEmpty = buildTypeListModel({ ...common, showEmpty: true }); + const hideEmpty = buildTypeListModel({ ...common, showEmpty: false }); + + expect(showEmpty.actionableTypes).toEqual(['alpha', 'zebra', 'configured']); + expect(showEmpty.rows.map(({ type }) => type)).toEqual(['alpha', 'zebra', 'configured']); + expect(showEmpty.rows.every( + ({ depth, hasChildren }) => depth === 0 && !hasChildren, + )).toBe(true); + expect(hideEmpty.actionableTypes).toEqual(['alpha', 'zebra']); + }); + + it('rolls resolved logical IDs into ancestors without double counting replicas or raw pairs', () => { + const counts = countResolvedTypes([ + { id: 1, type: 'leaf' }, + { id: 1, type: 'leaf' }, + { id: 2, type: 'branch' }, + { id: 3, type: 'otherLeaf' }, + { id: 4 }, + { id: 5, type: 'toString' }, + ], hierarchyIndex); + + expect(counts).toEqual(new Map([ + ['leaf', 1], + ['branch', 2], + ['root', 2], + ['otherLeaf', 1], + ['otherRoot', 1], + ['toString', 1], + ])); + }); + + it('derives a three-level forest, unrelated roots, depths, and subtrees', () => { + const model = build(); + + expect(model.subtree.get('branch')).toEqual(['branch', 'leaf']); + expect(model.rows.map(({ type }) => type)).toEqual([ + 'flat', 'otherRoot', 'otherLeaf', 'root', 'branch', 'leaf', 'sibling', + ]); + expect(model.rows.find(({ type }) => type === 'leaf')?.depth).toBe(2); + expect(model.rows.find(({ type }) => type === 'flat')?.depth).toBe(0); + }); + + it('hides disclosure controls when all children are filtered out', () => { + const model = build({ showEmpty: false }); + + expect(model.rows.find(({ type }) => type === 'otherRoot')).toEqual(expect.objectContaining({ + hasChildren: false, + })); + expect(model.rows.some(({ type }) => type === 'otherLeaf')).toBe(false); + }); + + it('sorts roots and siblings recursively in every mode with alphabetical ties', () => { + const alphabetical = build({ sort: 'a-z' }); + expect(alphabetical.rows.map(({ type }) => type)).toEqual([ + 'flat', 'otherRoot', 'otherLeaf', 'root', 'branch', 'leaf', 'sibling', + ]); + + const byCount = build({ + sort: 'count', + counts: new Map([ + ['root', 3], ['flat', 2], ['otherRoot', 1], ['branch', 1], ['sibling', 2], + ]), + }); + expect(byCount.rows.map(({ type }) => type)).toEqual([ + 'root', 'sibling', 'branch', 'leaf', 'flat', 'otherRoot', 'otherLeaf', + ]); + + const byFrameCount = build({ + sort: 'frame count', + frameCounts: new Map([ + ['root', 1], ['flat', 2], ['otherRoot', 3], ['branch', 2], ['sibling', 1], + ]), + }); + expect(byFrameCount.rows.map(({ type }) => type)).toEqual([ + 'otherRoot', 'otherLeaf', 'flat', 'root', 'branch', 'leaf', 'sibling', + ]); + }); + + it('applies every sort mode to siblings below the first hierarchy level', () => { + const deepIndex = compileHierarchy({ + leaf: 'branch', + bud: 'branch', + twig: 'branch', + branch: 'root', + }); + const shared = { + hierarchyIndex: deepIndex, + allTypes: ['root', 'branch', 'leaf', 'bud', 'twig'], + usedTypes: ['leaf', 'bud', 'twig'], + checkedTypes: ['root', 'branch', 'leaf', 'bud', 'twig'], + }; + + expect(build({ ...shared, sort: 'a-z' }).rows.filter(({ depth }) => depth === 2) + .map(({ type }) => type)) + .toEqual(['bud', 'leaf', 'twig']); + expect(build({ + ...shared, + sort: 'count', + counts: new Map([['leaf', 3], ['bud', 1], ['twig', 1]]), + }).rows.filter(({ depth }) => depth === 2).map(({ type }) => type)) + .toEqual(['leaf', 'bud', 'twig']); + expect(build({ + ...shared, + sort: 'frame count', + frameCounts: new Map([['twig', 4], ['leaf', 2], ['bud', 1]]), + }).rows.filter(({ depth }) => depth === 2).map(({ type }) => type)) + .toEqual(['twig', 'leaf', 'bud']); + }); + + it('keeps unused structural parents but hides empty leaves and configured flat types', () => { + const model = build({ + allTypes: [ + 'leaf', 'branch', 'root', 'sibling', 'otherLeaf', 'otherRoot', 'flat', 'configured', + ], + usedTypes: ['leaf', 'flat'], + showEmpty: false, + }); + + expect(model.rows.map(({ type }) => type)).toEqual(['flat', 'otherRoot', 'root', 'branch', 'leaf']); + expect(model.rows.map(({ type }) => type)).not.toContain('otherLeaf'); + expect(model.rows.map(({ type }) => type)).not.toContain('sibling'); + expect(model.rows.map(({ type }) => type)).not.toContain('configured'); + }); + + it('shows search matches with ancestor context but no unrelated descendants', () => { + const model = build({ query: 'leaf' }); + + expect(model.actionableTypes).toEqual(['otherLeaf', 'leaf']); + expect(model.rows.map(({ type }) => type)).toEqual([ + 'otherRoot', 'otherLeaf', 'root', 'branch', 'leaf', + ]); + expect(model.rows.map(({ type }) => type)).not.toContain('sibling'); + }); + + it('reveals search paths without mutating or applying saved collapse state', () => { + const collapsed = new Set(['root']); + const collapsedModel = build({ collapsed }); + const searchModel = build({ collapsed, query: 'leaf' }); + const restoredModel = build({ collapsed }); + + expect(collapsedModel.rows.map(({ type }) => type)).not.toContain('leaf'); + expect(searchModel.rows.map(({ type }) => type)).toContain('leaf'); + expect(searchModel.rows.find(({ type }) => type === 'root')?.expanded).toBe(true); + expect(restoredModel.rows.map(({ type }) => type)).not.toContain('leaf'); + expect(collapsed).toEqual(new Set(['root'])); + }); + + it('compacts a shared leading lineage and restores it during search or on request', () => { + const deepIndex = compileHierarchy({ + speciesA: 'genusA', + genusA: 'familyA', + familyA: 'orderA', + speciesB: 'genusB', + genusB: 'familyB', + familyB: 'orderB', + orderA: 'class', + orderB: 'class', + class: 'phylum', + phylum: 'kingdom', + kingdom: 'domain', + }); + const options = { + hierarchyIndex: deepIndex, + allTypes: [], + usedTypes: ['speciesA', 'speciesB'], + checkedTypes: [], + compactSharedLineage: true, + }; + + const compact = build(options); + expect(compact.sharedLineage).toEqual(['domain', 'kingdom', 'phylum']); + expect(compact.rows.slice(0, 3).map(({ type, depth }) => ({ type, depth }))).toEqual([ + { type: 'class', depth: 0 }, + { type: 'orderA', depth: 1 }, + { type: 'familyA', depth: 2 }, + ]); + + const shown = build({ ...options, compactSharedLineage: false }); + expect(shown.rows.slice(0, 4).map(({ type, depth }) => ({ type, depth }))).toEqual([ + { type: 'domain', depth: 0 }, + { type: 'kingdom', depth: 1 }, + { type: 'phylum', depth: 2 }, + { type: 'class', depth: 3 }, + ]); + + const searched = build({ ...options, query: 'domain' }); + expect(searched.rows.map(({ type }) => type)).toEqual(['domain']); + expect(searched.rows[0].depth).toBe(0); + }); + + it('preserves depth in unrelated trees while compacting a shared lineage', () => { + const forestIndex = compileHierarchy({ + species: 'genus', + genus: 'family', + family: 'root', + emptyLeaf: 'emptyBranch', + emptyBranch: 'emptyRoot', + }); + const model = build({ + hierarchyIndex: forestIndex, + allTypes: [], + usedTypes: ['species'], + checkedTypes: [], + showEmpty: true, + compactSharedLineage: true, + }); + + expect(model.sharedLineage).toEqual(['root', 'family', 'genus']); + expect(model.rows.filter(({ type }) => type.startsWith('empty')) + .map(({ type, depth }) => ({ type, depth }))).toEqual([ + { type: 'emptyRoot', depth: 0 }, + { type: 'emptyBranch', depth: 1 }, + { type: 'emptyLeaf', depth: 2 }, + ]); + expect(model.rows.find(({ type }) => type === 'species')?.depth).toBe(0); + }); + + it('does not compact past a branch or a directly used or configured type', () => { + const branchAtRoot = build({ + usedTypes: ['leaf'], + configuredTypes: [], + compactSharedLineage: true, + }); + expect(branchAtRoot.sharedLineage).toEqual([]); + + const deepIndex = compileHierarchy({ + leaf: 'configured', configured: 'middle', middle: 'top', + }); + const stopped = build({ + hierarchyIndex: deepIndex, + allTypes: [], + usedTypes: ['leaf'], + configuredTypes: ['configured'], + compactSharedLineage: true, + }); + expect(stopped.sharedLineage).toEqual(['top', 'middle']); + expect(stopped.rows[0]).toEqual(expect.objectContaining({ + type: 'configured', depth: 0, + })); + }); + + it('uses rolled frame counts before adding ancestor context', () => { + const model = build({ + frameCounts: new Map([['branch', 2], ['leaf', 2]]), + filterTypesByFrame: true, + }); + + expect(model.rows.map(({ type }) => type)).toEqual(['root', 'branch', 'leaf']); + expect(model.rows.map(({ type }) => type)).not.toContain('otherRoot'); + expect(model.rows.map(({ type }) => type)).not.toContain('sibling'); + expect(model.actionableTypes).toContain('flat'); + }); + + it('includes the parent bit when computing checked and indeterminate state', () => { + const model = build({ checkedTypes: ['leaf'] }); + + expect(model.checkState.get('leaf')).toBe('checked'); + expect(model.checkState.get('branch')).toBe('indeterminate'); + expect(model.checkState.get('root')).toBe('indeterminate'); + expect(model.checkState.get('otherRoot')).toBe('unchecked'); + expect(model.rows.find(({ type }) => type === 'branch')).toEqual(expect.objectContaining({ + checked: false, + indeterminate: true, + })); + }); + + it('represents the checked set produced when a used unchecked parent gains a new child', () => { + const model = build({ usedTypes: ['branch'], checkedTypes: ['leaf'] }); + + expect(model.checkState.get('branch')).toBe('indeterminate'); + expect(model.subtree.get('branch')).toEqual(['branch', 'leaf']); + }); + + it.each([ + ['collapse', { showEmpty: true, query: '', collapsed: new Set(['branch']) }], + ['Show Empty', { + showEmpty: false, usedTypes: ['flat'], query: '', collapsed: new Set(), + }], + ['search', { showEmpty: true, query: 'branch', collapsed: new Set() }], + ] as const)('updates descendants hidden by %s without mutating checked input', ( + _visibility, + overrides, + ) => { + const model = build(overrides); + const checked = Object.freeze(['flat', 'sibling']); + + expect(model.rows.map(({ type }) => type)).not.toContain('leaf'); + const enabled = updateHierarchyCheckedTypes(checked, model.subtree, 'branch', true); + const disabled = updateHierarchyCheckedTypes(enabled, model.subtree, 'branch', false); + + expect(enabled).toEqual(['flat', 'sibling', 'branch', 'leaf']); + expect(disabled).toEqual(['flat', 'sibling']); + expect(checked).toEqual(['flat', 'sibling']); + }); + + it('does not mutate hierarchy, arrays, sets, or count maps', () => { + const allTypes = Object.freeze(['leaf', 'branch', 'root', 'flat']); + const usedTypes = Object.freeze(['leaf']); + const checkedTypes = Object.freeze(['leaf']); + const counts = new Map([['leaf', 1]]); + const frameCounts = new Map([['leaf', 1]]); + const collapsed = new Set(['root']); + const hierarchy = Object.freeze({ leaf: 'branch', branch: 'root' }); + const index = compileHierarchy(hierarchy); + + buildTypeListModel({ + hierarchyIndex: index, + allTypes, + usedTypes, + checkedTypes, + counts, + frameCounts, + showEmpty: false, + query: '', + filterTypesByFrame: true, + sort: 'count', + collapsed, + }); + + expect(index.hierarchy).toEqual(hierarchy); + expect(allTypes).toEqual(['leaf', 'branch', 'root', 'flat']); + expect(usedTypes).toEqual(['leaf']); + expect(checkedTypes).toEqual(['leaf']); + expect(counts).toEqual(new Map([['leaf', 1]])); + expect(frameCounts).toEqual(new Map([['leaf', 1]])); + expect(collapsed).toEqual(new Set(['root'])); + }); +}); diff --git a/client/src/typeListHierarchy.ts b/client/src/typeListHierarchy.ts new file mode 100644 index 000000000..e6013e29d --- /dev/null +++ b/client/src/typeListHierarchy.ts @@ -0,0 +1,226 @@ +import { difference, union } from 'lodash'; +import { ancestorsOf, TypeHierarchyIndex } from 'dive-common/typeHierarchy'; + +export type TypeListSort = 'a-z' | 'count' | 'frame count'; +export type TypeListCheckState = 'checked' | 'unchecked' | 'indeterminate'; + +export interface TypeListRow { + type: string; + depth: number; + hasChildren: boolean; + expanded: boolean; + checked: boolean; + indeterminate: boolean; +} + +export interface TypeListModel { + subtree: ReadonlyMap; + checkState: ReadonlyMap; + actionableTypes: readonly string[]; + sharedLineage: readonly string[]; + rows: readonly TypeListRow[]; +} + +export interface BuildTypeListOptions { + hierarchyIndex: TypeHierarchyIndex; + allTypes: readonly string[]; + usedTypes: readonly string[]; + configuredTypes?: readonly string[]; + checkedTypes: readonly string[]; + counts: ReadonlyMap; + frameCounts: ReadonlyMap; + showEmpty: boolean; + query: string; + filterTypesByFrame: boolean; + sort: TypeListSort; + collapsed: ReadonlySet; + compactSharedLineage?: boolean; +} + +export interface ResolvedTypeCountEntry { + id: string | number; + type?: string; +} + +export function countResolvedTypes( + entries: readonly ResolvedTypeCountEntry[], + hierarchyIndex: TypeHierarchyIndex, +): Map { + const idsByType = new Map>(); + entries.forEach(({ id, type }) => { + if (type === undefined) return; + [type, ...ancestorsOf(hierarchyIndex, type)].forEach((rolledType) => { + const ids = idsByType.get(rolledType) || new Set(); + ids.add(id); + idsByType.set(rolledType, ids); + }); + }); + return new Map([...idsByType].map(([type, ids]) => [type, ids.size])); +} + +/** Descending by the active count, then alphabetical so ties are deterministic. */ +function typeComparator( + sort: TypeListSort, + counts: ReadonlyMap, + frameCounts: ReadonlyMap, +): (left: string, right: string) => number { + const rank = { 'a-z': undefined, count: counts, 'frame count': frameCounts }[sort]; + return (left, right) => { + const countDifference = rank ? (rank.get(right) || 0) - (rank.get(left) || 0) : 0; + if (countDifference !== 0) return countDifference; + if (left < right) return -1; + return left > right ? 1 : 0; + }; +} + +function withAncestorPath( + types: Iterable, + hierarchyIndex: TypeHierarchyIndex, +): Set { + const withAncestors = new Set(types); + [...withAncestors].forEach( + (type) => ancestorsOf(hierarchyIndex, type).forEach((ancestor) => withAncestors.add(ancestor)), + ); + return withAncestors; +} + +export function buildTypeListModel({ + hierarchyIndex, + allTypes, + usedTypes, + configuredTypes = [], + checkedTypes, + counts, + frameCounts, + showEmpty, + query, + filterTypesByFrame, + sort, + collapsed, + compactSharedLineage = false, +}: BuildTypeListOptions): TypeListModel { + const parent = new Map(); + const hierarchyMembers = new Set(); + Object.entries(hierarchyIndex.hierarchy).forEach(([child, parentType]) => { + parent.set(child, parentType); + hierarchyMembers.add(child); + hierarchyMembers.add(parentType); + }); + + const knownTypes = new Set([...allTypes, ...usedTypes, ...hierarchyMembers]); + const compare = typeComparator(sort, counts, frameCounts); + const children = new Map(); + knownTypes.forEach((type) => children.set(type, [])); + parent.forEach((parentType, child) => children.get(parentType)?.push(child)); + children.forEach((childTypes) => { + if (childTypes.length > 1) childTypes.sort(compare); + }); + const roots = [...knownTypes].filter((type) => !parent.has(type)).sort(compare); + + const directlyMeaningfulTypes = new Set([...usedTypes, ...configuredTypes]); + const hierarchyTargets = [...directlyMeaningfulTypes].filter((type) => hierarchyMembers.has(type)); + const targetRoots = new Set(hierarchyTargets.map((type) => { + const ancestors = ancestorsOf(hierarchyIndex, type); + return ancestors.length > 0 ? ancestors[ancestors.length - 1] : type; + })); + const unusedLeadingParents: string[] = []; + if (hierarchyTargets.length === directlyMeaningfulTypes.size && targetRoots.size === 1) { + let current = [...targetRoots][0]; + while (!directlyMeaningfulTypes.has(current)) { + const childTypes = children.get(current) || []; + if (childTypes.length !== 1) break; + unusedLeadingParents.push(current); + [current] = childTypes; + } + } + /* A single unused parent is not worth a breadcrumb; show it as a row instead. */ + const sharedLineage = unusedLeadingParents.length >= 2 ? unusedLeadingParents : []; + + const checkedSet = new Set(checkedTypes); + const subtree = new Map(); + const checkState = new Map(); + /* One pass yields each type's pre-order subtree and its rolled-up tri-state. */ + const visit = (type: string): { members: string[]; checkedCount: number } => { + let checkedCount = checkedSet.has(type) ? 1 : 0; + const members = [type]; + (children.get(type) || []).forEach((child) => { + const nested = visit(child); + members.push(...nested.members); + checkedCount += nested.checkedCount; + }); + subtree.set(type, members); + let state: TypeListCheckState = 'indeterminate'; + if (checkedCount === 0) state = 'unchecked'; + else if (checkedCount === members.length) state = 'checked'; + checkState.set(type, state); + return { members, checkedCount }; + }; + roots.forEach(visit); + + /* Every ancestor of a used type is itself a structural parent, so this set + already carries the full path back to each root. */ + const structuralParents = [...knownTypes].filter( + (type) => (children.get(type)?.length || 0) > 0, + ); + const normalizedQuery = query.toLowerCase(); + const showEmptyCandidates = showEmpty + ? knownTypes + : new Set([...usedTypes, ...structuralParents]); + const queryCandidates = normalizedQuery + ? [...showEmptyCandidates].filter((type) => type.toLowerCase().includes(normalizedQuery)) + : [...showEmptyCandidates]; + const actionableSet = new Set(queryCandidates); + const displayedCandidates = filterTypesByFrame + ? queryCandidates.filter((type) => (frameCounts.get(type) || 0) > 0) + : queryCandidates; + const displayedSet = withAncestorPath(displayedCandidates, hierarchyIndex); + const searchActive = normalizedQuery.length > 0; + const hiddenSharedLineage = !searchActive && compactSharedLineage + ? new Set(sharedLineage) + : new Set(); + const rows: TypeListRow[] = []; + const flatten = (type: string, rowDepth: number) => { + if (!displayedSet.has(type)) return; + const childTypes = children.get(type) || []; + if (hiddenSharedLineage.has(type)) { + childTypes.forEach((child) => flatten(child, rowDepth)); + return; + } + const expanded = searchActive || !collapsed.has(type); + const state = checkState.get(type) || 'unchecked'; + rows.push({ + type, + depth: rowDepth, + hasChildren: childTypes.some((child) => displayedSet.has(child)), + expanded, + checked: state === 'checked', + indeterminate: state === 'indeterminate', + }); + if (expanded) { + childTypes.forEach((child) => flatten(child, rowDepth + 1)); + } + }; + roots.forEach((root) => flatten(root, 0)); + + return { + subtree, + checkState, + sharedLineage, + actionableTypes: roots + .flatMap((root) => subtree.get(root) || [root]) + .filter((type) => actionableSet.has(type)), + rows, + }; +} + +export function updateHierarchyCheckedTypes( + checkedTypes: readonly string[], + subtree: ReadonlyMap, + type: string, + checked: boolean, +): string[] { + const members = subtree.get(type) || [type]; + return checked + ? union(checkedTypes, members) + : difference(checkedTypes, members); +} From 82b499deb4e409cd923fb8c153ee000f778c9e71 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 21 Aug 2026 15:32:34 -0400 Subject: [PATCH 3/5] Render hierarchy-aware Type List rows --- client/src/components/TypeItem.spec.ts | 118 ++++++++++++++++++ client/src/components/TypeItem.vue | 99 +++++++++++++-- .../src/components/styles/hover-reveal.scss | 16 +++ 3 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 client/src/components/TypeItem.spec.ts create mode 100644 client/src/components/styles/hover-reveal.scss diff --git a/client/src/components/TypeItem.spec.ts b/client/src/components/TypeItem.spec.ts new file mode 100644 index 000000000..8ab27084f --- /dev/null +++ b/client/src/components/TypeItem.spec.ts @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +/// +import { + defineComponent, h, reactive, +} from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import TypeItem from './TypeItem.vue'; + +function mountTypeItem(props: Record) { + const state = reactive(props); + const toggleExpanded = vi.fn(); + let child: InstanceType | undefined; + const Host = defineComponent({ + setup: () => () => h(TypeItem, { + props: state, + on: { toggleExpanded }, + ref: (instance) => { + if (instance && !(instance instanceof Element)) { + child = instance as InstanceType; + } + }, + }), + }); + const wrapper = shallowMount(Host, { + stubs: { + TypeItem: false, + VTooltip: { + template: '
', + }, + }, + }); + if (!child) { + throw new Error('TypeItem did not mount'); + } + return { wrapper, vm: child, toggleExpanded }; +} + +describe('TypeItem hierarchy row', () => { + it('exposes hierarchy semantics and keeps the type color active while indeterminate', async () => { + const { wrapper, vm, toggleExpanded } = mountTypeItem({ + type: 'fish', + displayText: '3 : 1\u00A0 fish', + confidenceFilterNum: 0.4, + color: '#abc', + checked: false, + indeterminate: true, + tree: true, + depth: 2, + hasChildren: true, + expanded: true, + width: 300, + displayMaxButton: true, + disabled: true, + isSuppressionType: true, + suppressionThreshold: 80, + }); + + const row = wrapper.find('v-row'); + expect(row.attributes()).toEqual(expect.objectContaining({ + role: 'listitem', + 'aria-level': '3', + })); + expect(row.attributes('aria-expanded')).toBeUndefined(); + const disclosure = wrapper.find('[aria-label="Collapse descendants of fish"]'); + expect(disclosure.exists()).toBe(true); + expect(disclosure.element.tagName).toBe('BUTTON'); + expect((disclosure.element as HTMLButtonElement).tabIndex).toBe(0); + expect(disclosure.attributes('type')).toBe('button'); + expect(disclosure.attributes('aria-expanded')).toBe('true'); + await disclosure.trigger('click'); + expect(toggleExpanded).toHaveBeenCalledTimes(1); + + const checkbox = wrapper.find('v-checkbox'); + expect(checkbox.attributes()).toEqual(expect.objectContaining({ + 'input-value': 'true', + indeterminate: 'true', + color: '#abc', + disabled: 'true', + })); + expect(vm.cssVars) + .toEqual(expect.objectContaining({ + '--content-width': '138px', + '--tree-depth': '32px', + })); + expect(wrapper.find('.row-help').exists()).toBe(false); + }); + + it('keeps flat rows free of hierarchy roles and disclosure controls', () => { + const { wrapper } = mountTypeItem({ + type: 'fish', + displayText: '3 : 1\u00A0 fish', + confidenceFilterNum: 0, + color: '#abc', + checked: true, + }); + + expect(wrapper.find('v-row').attributes('role')).toBeUndefined(); + expect(wrapper.find('[aria-label*="descendants of fish"]').exists()).toBe(false); + expect(wrapper.find('v-checkbox').attributes('indeterminate')).toBeUndefined(); + }); + + it('uses an indentation spacer while search forces a parent open', () => { + const { wrapper } = mountTypeItem({ + type: 'fish', + displayText: '3 : 1\u00A0 fish', + confidenceFilterNum: 0, + color: '#abc', + checked: true, + tree: true, + hasChildren: true, + expanded: true, + disclosureVisible: false, + }); + + expect(wrapper.find('[aria-label="Collapse descendants of fish"]').exists()).toBe(false); + expect(wrapper.find('.tree-disclosure-spacer').exists()).toBe(true); + }); +}); diff --git a/client/src/components/TypeItem.vue b/client/src/components/TypeItem.vue index 4fc2c9a29..33f212780 100644 --- a/client/src/components/TypeItem.vue +++ b/client/src/components/TypeItem.vue @@ -28,6 +28,30 @@ export default defineComponent({ type: Boolean, required: true, }, + indeterminate: { + type: Boolean, + default: false, + }, + tree: { + type: Boolean, + default: false, + }, + depth: { + type: Number, + default: 0, + }, + hasChildren: { + type: Boolean, + default: false, + }, + expanded: { + type: Boolean, + default: false, + }, + disclosureVisible: { + type: Boolean, + default: true, + }, width: { type: Number, default: 300, @@ -58,7 +82,14 @@ export default defineComponent({ } return 42 + 14 + 20 + 30; }); - const cssVars = computed(() => ({ '--content-width': `${props.width - HorizontalPadding.value}px` })); + const HierarchyPadding = computed(() => (props.tree ? (props.depth * 16) + 24 : 0)); + const cssVars = computed(() => ({ + '--content-width': `${Math.max( + 0, + props.width - HorizontalPadding.value - HierarchyPadding.value, + )}px`, + '--tree-depth': `${props.depth * 16}px`, + })); const effectiveOverlapPercent = computed(() => { const p = Number(props.suppressionThreshold); if (!Number.isFinite(p) || p <= 0 || p > 100) { @@ -83,10 +114,35 @@ export default defineComponent({ :style="cssVars" align="center" class="hover-show-parent" + :role="tree ? 'listitem' : undefined" + :aria-level="tree ? depth + 1 : undefined" > +
+ +
- {{ displayText }} + {{ displayText }}