From e616dd9e9745787265a0cbea89453bd171905315 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 7 Jan 2026 22:52:44 -0500 Subject: [PATCH 01/30] Add multi-cam top buttons option --- client/dive-common/components/EditorMenu.vue | 3 ++- client/dive-common/components/Viewer.vue | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/client/dive-common/components/EditorMenu.vue b/client/dive-common/components/EditorMenu.vue index 13b74443a..99447836d 100644 --- a/client/dive-common/components/EditorMenu.vue +++ b/client/dive-common/components/EditorMenu.vue @@ -319,8 +319,9 @@ export default defineComponent({ + - + + From 9fe7badb6d81cd177b1cb66d71f18c23a014af59 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Thu, 8 Jan 2026 00:16:49 -0500 Subject: [PATCH 02/30] Option to switch between no, side, and bottom track lists --- .../components/ControlsContainer.vue | 8 +- client/dive-common/components/Sidebar.vue | 207 ++++++++++- client/dive-common/components/Viewer.vue | 159 ++++++++- client/dive-common/store/settings.ts | 6 + client/src/components/TrackItem.vue | 321 +++++++++++++++++- client/src/components/TrackList.vue | 96 +++++- 6 files changed, 784 insertions(+), 13 deletions(-) diff --git a/client/dive-common/components/ControlsContainer.vue b/client/dive-common/components/ControlsContainer.vue index bdc10af6c..67ca18f0b 100644 --- a/client/dive-common/components/ControlsContainer.vue +++ b/client/dive-common/components/ControlsContainer.vue @@ -54,6 +54,10 @@ export default defineComponent({ type: Boolean as PropType, required: true, }, + bottomLayout: { + type: Boolean, + default: false, + }, }, setup(_, { emit }) { const handler = useHandler(); @@ -150,7 +154,9 @@ export default defineComponent({ diff --git a/client/dive-common/store/settings.ts b/client/dive-common/store/settings.ts index adab2e24f..f67812fce 100644 --- a/client/dive-common/store/settings.ts +++ b/client/dive-common/store/settings.ts @@ -48,6 +48,9 @@ interface AnnotationSettings { multiCamSettings: { showToolbar: boolean; }; + layoutSettings: { + sidebarPosition: 'left' | 'bottom'; + }; } const defaultSettings: AnnotationSettings = { @@ -105,6 +108,9 @@ const defaultSettings: AnnotationSettings = { multiCamSettings: { showToolbar: true, }, + layoutSettings: { + sidebarPosition: 'left', + }, }; // Utility to safely load from localStorage diff --git a/client/src/components/TrackItem.vue b/client/src/components/TrackItem.vue index 9b1a6fcdd..f93bdd526 100644 --- a/client/src/components/TrackItem.vue +++ b/client/src/components/TrackItem.vue @@ -1,6 +1,6 @@ @@ -409,15 +409,15 @@ export default defineComponent({ > - mdi-view-column + mdi-cog Date: Sat, 24 Jan 2026 17:59:36 -0500 Subject: [PATCH 25/30] Fix attribute panels functionality in bottom view mode Add event handlers and state management for attribute editing in the bottom panel details view. This includes adding the AttributeEditor dialog, edit-individual state for inline editing, and all necessary event handlers (edit-attribute, set-edit-individual, add-attribute). Co-Authored-By: Claude Opus 4.5 --- client/dive-common/components/Viewer.vue | 130 ++++++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index 37cb2f6d5..446d4d16b 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -46,7 +46,9 @@ import TrackListColumnSettings from 'dive-common/components/TrackListColumnSetti import TrackDetailsPanel from 'dive-common/components/TrackDetailsPanel.vue'; import ConfidenceSubsection from 'dive-common/components/ConfidenceSubsection.vue'; import AttributeSubsection from 'dive-common/components/Attributes/AttributesSubsection.vue'; +import AttributeEditor from 'dive-common/components/Attributes/AttributeEditor.vue'; import DeleteControls from 'dive-common/components/DeleteControls.vue'; +import type { Attribute } from 'vue-media-annotator/use/AttributeTypes'; import ControlsContainer from 'dive-common/components/ControlsContainer.vue'; import Sidebar from 'dive-common/components/Sidebar.vue'; import { useModeManager, useSave } from 'dive-common/use'; @@ -87,6 +89,7 @@ export default defineComponent({ TrackDetailsPanel, ConfidenceSubsection, AttributeSubsection, + AttributeEditor, }, // TODO: remove this in vue 3 @@ -909,6 +912,84 @@ export default defineComponent({ // If track has attributes, show track first; otherwise show detection first const showTrackAttributesFirst = computed(() => hasTrackAttributes.value); + // Attribute editing state for bottom panel + const editIndividual: Ref = ref(null); + const editingAttribute: Ref = ref(null); + const editingError: Ref = ref(null); + + function setEditIndividual(attribute: Attribute | null) { + editIndividual.value = attribute; + } + + function resetEditIndividual(event: MouseEvent) { + if (editIndividual.value) { + const path = event.composedPath() as HTMLElement[]; + const inputs = ['INPUT', 'SELECT']; + if ( + path.find( + (item: HTMLElement) => (item.classList && item.classList.contains('v-input')) + || inputs.includes(item.nodeName), + ) + ) { + return; + } + editIndividual.value = null; + } + } + + function addAttribute(type: 'Track' | 'Detection') { + const belongs = type.toLowerCase() as 'track' | 'detection'; + editingAttribute.value = { + belongs, + datatype: 'text', + name: `New${type}Attribute`, + key: '', + }; + } + + function editAttribute(attribute: Attribute) { + editingAttribute.value = attribute; + } + + async function closeAttributeEditor() { + editingAttribute.value = null; + editingError.value = null; + } + + async function saveAttributeHandler({ data, oldAttribute, close }: { + oldAttribute?: Attribute; + data: Attribute; + close: boolean; + }) { + editingError.value = null; + if (!oldAttribute && attributes.value.some((attribute) => ( + attribute.name === data.name + && attribute.belongs === data.belongs))) { + editingError.value = 'Attribute with that name exists'; + return; + } + try { + await setAttribute({ data, oldAttribute }); + } catch (err) { + editingError.value = (err as Error).message; + } + if (!editingError.value && close) { + closeAttributeEditor(); + } + } + + async function deleteAttributeHandler(data: Attribute) { + editingError.value = null; + try { + await deleteAttribute({ data }); + } catch (err) { + editingError.value = (err as Error).message; + } + if (!editingError.value) { + closeAttributeEditor(); + } + } + return { /* props */ aggregateController, @@ -962,6 +1043,17 @@ export default defineComponent({ showConfidenceFirst, showTrackAttributesFirst, attributes, + /* Attribute editing for bottom panel */ + editIndividual, + editingAttribute, + editingError, + setEditIndividual, + resetEditIndividual, + addAttribute, + editAttribute, + closeAttributeEditor, + saveAttributeHandler, + deleteAttributeHandler, /* large image methods */ getTiles, getTileURL, @@ -1490,7 +1582,11 @@ export default defineComponent({ @@ -1569,6 +1681,22 @@ export default defineComponent({ :sidebar-mode="sidebarMode" /> + + + + From d981cf5d3981fd2fc480178371f0b53a51516dfd Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Sat, 24 Jan 2026 18:05:50 -0500 Subject: [PATCH 26/30] Add sorting by attributes and timestamps in track list - Move attribute columns before notes in column settings UI and track list - Add sortable timestamp columns (Start Time, End Time) - Add sortable attribute columns (track and detection level) - Timestamps sort by underlying frame number for accuracy Co-Authored-By: Claude Opus 4.5 --- .../components/TrackListColumnSettings.vue | 22 ++--- client/src/components/TrackItem.vue | 12 +-- client/src/components/TrackList.vue | 86 ++++++++++++++++--- 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/client/dive-common/components/TrackListColumnSettings.vue b/client/dive-common/components/TrackListColumnSettings.vue index 6899b0493..14073dd98 100644 --- a/client/dive-common/components/TrackListColumnSettings.vue +++ b/client/dive-common/components/TrackListColumnSettings.vue @@ -109,17 +109,6 @@ export default defineComponent({ Timestamps require FPS metadata -
- Other Columns -
- - + +
+ Other Columns +
+ diff --git a/client/src/components/TrackItem.vue b/client/src/components/TrackItem.vue index 3c1907f76..7709caa19 100644 --- a/client/src/components/TrackItem.vue +++ b/client/src/components/TrackItem.vue @@ -500,6 +500,12 @@ export default defineComponent({ v-if="columnVisibility?.endTimestamp" class="track-timestamp" >{{ endTimestamp }} + + {{ getAttributeValue(attrKey) || '-' }} - - {{ getAttributeValue(attrKey) || '-' }}
diff --git a/client/src/components/TrackList.vue b/client/src/components/TrackList.vue index 0585b689e..57c661185 100644 --- a/client/src/components/TrackList.vue +++ b/client/src/components/TrackList.vue @@ -25,7 +25,7 @@ import TrackItem from './TrackItem.vue'; /* Magic numbers involved in height calculation */ const TrackListHeaderHeight = 52; -type SortKey = 'id' | 'start' | 'end' | 'confidence' | 'type' | 'notes'; +type SortKey = 'id' | 'start' | 'end' | 'startTime' | 'endTime' | 'confidence' | 'type' | 'notes' | string; type SortDirection = 'asc' | 'desc'; export default defineComponent({ @@ -133,6 +133,30 @@ export default defineComponent({ return ''; } + // Helper to get attribute value from a track + function getTrackAttributeValue( + track: ReturnType, + attrKey: string, + ): string | number | undefined { + // Check if it's a track attribute (track_*) or detection attribute (detection_*) + const isTrackAttr = attrKey.startsWith('track_'); + const actualKey = attrKey.replace(/^(track_|detection_)/, ''); + + if (isTrackAttr) { + // Track-level attribute + if (track.attributes && track.attributes[actualKey] !== undefined) { + return track.attributes[actualKey] as string | number; + } + } else { + // Detection-level attribute - get from first keyframe + const feature = track.features[track.begin]; + if (feature && feature.attributes && feature.attributes[actualKey] !== undefined) { + return feature.attributes[actualKey] as string | number; + } + } + return undefined; + } + // Apply sorting const sorted = [...tracks]; const direction = sortDirection.value === 'asc' ? 1 : -1; @@ -147,12 +171,34 @@ export default defineComponent({ return 0; } - switch (sortKey.value) { + const key = sortKey.value; + + // Check if sorting by an attribute column + if (key.startsWith('track_') || key.startsWith('detection_')) { + const valA = getTrackAttributeValue(trackA, key); + const valB = getTrackAttributeValue(trackB, key); + const emptyA = valA === undefined || valA === ''; + const emptyB = valB === undefined || valB === ''; + // Empty values go last in ascending, first in descending + if (emptyA && !emptyB) return direction; + if (!emptyA && emptyB) return -direction; + if (emptyA && emptyB) return 0; + // Numeric comparison if both are numbers + if (typeof valA === 'number' && typeof valB === 'number') { + return (valA - valB) * direction; + } + // String comparison otherwise + return String(valA).localeCompare(String(valB)) * direction; + } + + switch (key) { case 'id': return (trackA.trackId - trackB.trackId) * direction; case 'start': + case 'startTime': return (trackA.begin - trackB.begin) * direction; case 'end': + case 'endTime': return (trackA.end - trackB.end) * direction; case 'confidence': { const confA = trackA.confidencePairs?.[0]?.[1] ?? 0; @@ -527,15 +573,40 @@ export default defineComponent({ Start Time + {{ sortIcon('startTime') }} End Time + {{ sortIcon('endTime') }} + + + {{ attrKey.split('_').pop() }} + {{ sortIcon(attrKey) }} {{ sortIcon('notes') }} - - {{ attrKey.split('_').pop() }} - Actions
From 83ef8f60cef1473eee395a1d8ecb0b3436d18326 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Sat, 24 Jan 2026 20:35:49 -0500 Subject: [PATCH 27/30] Improve track list column interactions - Make start/end timestamp columns clickable to seek to that frame - Remove redundant seek to start/end buttons (clicking frames already does this) - Add editable attribute values directly in the track list columns - Show orphaned attribute columns in settings so they can be disabled Co-Authored-By: Claude Opus 4.5 --- .../components/TrackListColumnSettings.vue | 30 ++++ client/src/components/TrackItem.vue | 152 ++++++++++++++---- 2 files changed, 151 insertions(+), 31 deletions(-) diff --git a/client/dive-common/components/TrackListColumnSettings.vue b/client/dive-common/components/TrackListColumnSettings.vue index 14073dd98..155282cf9 100644 --- a/client/dive-common/components/TrackListColumnSettings.vue +++ b/client/dive-common/components/TrackListColumnSettings.vue @@ -29,6 +29,14 @@ export default defineComponent({ const trackAttributes = computed(() => props.attributes.filter((a) => a.belongs === 'track')); const detectionAttributes = computed(() => props.attributes.filter((a) => a.belongs === 'detection')); + // Find orphaned columns (enabled but no longer in dataset) + const orphanedColumns = computed(() => { + if (!columnVisibility.value) return []; + const enabledKeys = columnVisibility.value.attributeColumns; + const existingKeys = props.attributes.map((a) => a.key); + return enabledKeys.filter((key) => !existingKeys.includes(key)); + }); + const isAttributeEnabled = (key: string) => columnVisibility.value?.attributeColumns.includes(key) ?? false; const toggleAttribute = (key: string) => { @@ -46,6 +54,7 @@ export default defineComponent({ columnVisibility, trackAttributes, detectionAttributes, + orphanedColumns, isAttributeEnabled, toggleAttribute, }; @@ -151,6 +160,27 @@ export default defineComponent({ + + +
Other Columns
diff --git a/client/src/components/TrackItem.vue b/client/src/components/TrackItem.vue index 7709caa19..d21c714a9 100644 --- a/client/src/components/TrackItem.vue +++ b/client/src/components/TrackItem.vue @@ -98,10 +98,17 @@ export default defineComponent({ const typeInputRef = ref(null); const confidenceInputRef = ref(null); const notesInputRef = ref(null); + // Attribute editing state + const editingAttributeKey = ref(null); + const editAttributeValue = ref(''); + const attributeInputRef = ref(null); + const localAttributeDisplay = ref>({}); - // Reset local notes display when track changes (component recycling in virtual scroll) + // Reset local displays when track changes (component recycling in virtual scroll) watch(() => props.track.id, () => { localNotesDisplay.value = ''; + localAttributeDisplay.value = {}; + editingAttributeKey.value = null; }); /** * Use of revision is safe because it will only create a @@ -200,6 +207,11 @@ export default defineComponent({ /* Get attribute value for display */ const getAttributeValue = (attrKey: string) => { + // Use local display value if set (for immediate UI feedback) + if (localAttributeDisplay.value[attrKey]) { + return localAttributeDisplay.value[attrKey]; + } + // Access revision.value for reactivity if (props.track.revision.value === undefined) return ''; @@ -349,6 +361,42 @@ export default defineComponent({ editingNotes.value = false; } + function startEditAttribute(attrKey: string, event: MouseEvent) { + if (readOnlyMode.value) return; + event.stopPropagation(); + editAttributeValue.value = getAttributeValue(attrKey); + editingAttributeKey.value = attrKey; + nextTick(() => { + attributeInputRef.value?.focus(); + attributeInputRef.value?.select(); + }); + } + + function saveAttribute() { + const attrKey = editingAttributeKey.value; + if (!attrKey) return; + + const newValue = editAttributeValue.value.trim(); + const isTrackAttr = attrKey.startsWith('track_'); + const actualKey = attrKey.replace(/^(track_|detection_)/, ''); + + if (isTrackAttr) { + // Set track-level attribute + props.track.setAttribute(actualKey, newValue || undefined); + } else { + // Set detection-level attribute on first keyframe + props.track.setFeatureAttribute(props.track.begin, actualKey, newValue || undefined); + } + + // Update local display immediately for UI responsiveness + localAttributeDisplay.value[attrKey] = newValue; + editingAttributeKey.value = null; + } + + function cancelEditAttribute() { + editingAttributeKey.value = null; + } + return { /* data */ feature, @@ -376,6 +424,13 @@ export default defineComponent({ startTimestamp, endTimestamp, getAttributeValue, + /* attribute editing */ + editingAttributeKey, + editAttributeValue, + attributeInputRef, + startEditAttribute, + saveAttribute, + cancelEditAttribute, /* methods */ gotoNext, gotoPrevious, @@ -490,22 +545,42 @@ export default defineComponent({ class="track-frame-end clickable" @click.stop="$emit('seek', track.end)" >{{ track.end }} - + {{ startTimestamp }} - + {{ endTimestamp }} - - + - - - {{ topConfidence !== null ? topConfidence.toFixed(2) : '' }} - + {{ sortIcon('id') }} {{ sortIcon('type') }} Date: Wed, 28 Jan 2026 23:16:51 -0500 Subject: [PATCH 29/30] Fix annotations not displaying after switching view modes When switching between side and bottom view modes, the annotator component is destroyed and recreated. Previously, the old media controller remained in the subControllers array, causing getController() to return stale references to destroyed geoViewers. - Clean up old controller state when re-initializing the same camera - Add resizeTrigger to notify LayerManager to redraw after resize Co-Authored-By: Claude Opus 4.5 --- client/src/components/LayerManager.vue | 20 ++++++++++++- .../annotators/mediaControllerType.ts | 2 ++ .../annotators/useMediaController.ts | 30 ++++++++++++++++++- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index 8b88d363b..8e95270e8 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -89,7 +89,8 @@ export default defineComponent({ return trackStyleManager.typeStyling.value; }); - const annotator = injectAggregateController().value.getController(props.camera); + const aggregateController = injectAggregateController(); + const annotator = aggregateController.value.getController(props.camera); const frameNumberRef = annotator.frame; const flickNumberRef = annotator.flick; @@ -433,6 +434,23 @@ export default defineComponent({ ); }); + /** Watch for resize events to redraw layers after view mode changes */ + watch( + () => aggregateController.value.resizeTrigger.value, + () => { + updateLayers( + frameNumberRef.value, + editingModeRef.value, + selectedTrackIdRef.value, + multiSeletListRef.value, + enabledTracksRef.value, + visibleModesRef.value, + selectedKeyRef.value, + props.colorBy, + ); + }, + ); + const Clicked = (trackId: number, editing: boolean, modifiers?: {ctrl: boolean}) => { // If the camera isn't selected yet we ignore the click if (selectedCamera.value !== props.camera) { diff --git a/client/src/components/annotators/mediaControllerType.ts b/client/src/components/annotators/mediaControllerType.ts index 15d11fce2..a9a5379ec 100644 --- a/client/src/components/annotators/mediaControllerType.ts +++ b/client/src/components/annotators/mediaControllerType.ts @@ -15,6 +15,8 @@ export interface AggregateMediaController { volume: Readonly>; cameras: Readonly>; cameraSync: Readonly>; + /** Incremented when the viewer is resized, used to trigger layer redraws */ + resizeTrigger: Readonly>; pause: () => void; play: () => void; diff --git a/client/src/components/annotators/useMediaController.ts b/client/src/components/annotators/useMediaController.ts index 210ae3291..8cb69254b 100644 --- a/client/src/components/annotators/useMediaController.ts +++ b/client/src/components/annotators/useMediaController.ts @@ -83,6 +83,7 @@ export function useMediaController() { let state: Record> = {}; let cameraControllerSymbols: Record = {}; const synchronizeCameras: Ref = ref(false); + const resizeTrigger: Ref = ref(0); function clear() { geoViewers = {}; containers = {}; @@ -111,6 +112,7 @@ export function useMediaController() { * onResize resets the zoom of a camera when its window size changes. */ function onResize() { + let resized = false; subControllers.forEach((mc) => { const camera = cameraControllerSymbols[mc.cameraName.value].toString(); const geoViewerRef = geoViewers[camera]; @@ -121,12 +123,19 @@ export function useMediaController() { const size = containerRef.value.getBoundingClientRect(); const mapSize = geoViewerRef.value.size(); if (size.width !== mapSize.width || size.height !== mapSize.height) { + resized = true; window.requestAnimationFrame(() => { geoViewerRef.value.size(size); mc.resetZoom(); }); } }); + // Trigger layer redraw after resize + if (resized) { + window.requestAnimationFrame(() => { + resizeTrigger.value += 1; + }); + } } function toggleSynchronizeCameras(val: boolean) { @@ -172,6 +181,24 @@ export function useMediaController() { setVolume(level: number): void; setSpeed(level: number): void; }) { + // Clean up existing controller for this camera if it exists (e.g., when view mode switches) + const existingIndex = subControllers.findIndex((c) => c.cameraName.value === cameraName); + if (existingIndex !== -1) { + subControllers.splice(existingIndex, 1); + const existingSymbol = cameraControllerSymbols[cameraName]; + if (existingSymbol) { + const existingCamera = existingSymbol.toString(); + const cameraIndex = cameras.value.indexOf(existingSymbol); + if (cameraIndex !== -1) { + cameras.value.splice(cameraIndex, 1); + } + delete geoViewers[existingCamera]; + delete containers[existingCamera]; + delete imageCursors[existingCamera]; + delete state[existingCamera]; + } + } + const cameraSymbol = Symbol(`media-controller-${cameraName}`); cameraControllerSymbols[cameraName] = cameraSymbol; const camera = cameraSymbol.toString(); @@ -423,7 +450,7 @@ export function useMediaController() { resetMapDimensions, toggleSynchronizeCameras, cameraSync: synchronizeCameras, - + resizeTrigger, }; subControllers.push(mediaController); @@ -461,6 +488,7 @@ export function useMediaController() { getController, toggleSynchronizeCameras, cameraSync: synchronizeCameras, + resizeTrigger, }; }); From 4887da29db4cb2a71fbf0d78fcc04757a0e65414 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 11 Feb 2026 00:15:05 -0500 Subject: [PATCH 30/30] Center sort, settings, and delete buttons in FilterList header Use two v-spacers (before and after the button group) to center the sort, settings, and delete buttons between the Type Filter title and the right edge. This matches the main branch pattern and keeps the buttons clear of the absolutely-positioned swap button in the horizontal sidebar layout. Co-Authored-By: Claude Opus 4.6 --- client/src/components/FilterList.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/components/FilterList.vue b/client/src/components/FilterList.vue index e832853b3..4c3d171fd 100644 --- a/client/src/components/FilterList.vue +++ b/client/src/components/FilterList.vue @@ -395,6 +395,7 @@ export default defineComponent({ Delete visible items
+