diff --git a/client/dive-common/components/ControlsContainer.vue b/client/dive-common/components/ControlsContainer.vue index bdc10af6c..b6739f8b6 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,12 +154,15 @@ export default defineComponent({ + - + +import { + defineComponent, + ref, + computed, + onMounted, + onBeforeUnmount, +} from 'vue'; + +export default defineComponent({ + name: 'FrameScrubber', + props: { + maxFrame: { + type: Number, + default: 0, + }, + frame: { + type: Number, + default: 0, + }, + }, + emits: ['seek'], + setup(props, { emit }) { + const scrubberEl = ref(null); + const dragging = ref(false); + const clientWidth = ref(0); + const resizeObserver = ref(null); + + const handLeftPercent = computed(() => { + if (props.maxFrame === 0) return 0; + return (props.frame / props.maxFrame) * 100; + }); + + function updateWidth() { + if (scrubberEl.value) { + clientWidth.value = scrubberEl.value.clientWidth; + } + } + + function calculateFrame(clientX: number): number { + if (!scrubberEl.value || props.maxFrame === 0) return 0; + const rect = scrubberEl.value.getBoundingClientRect(); + const x = clientX - rect.left; + const percent = Math.max(0, Math.min(1, x / rect.width)); + return Math.round(percent * props.maxFrame); + } + + function handleMousedown(e: MouseEvent) { + dragging.value = true; + const frame = calculateFrame(e.clientX); + emit('seek', frame); + } + + function handleMousemove(e: MouseEvent) { + if (!dragging.value) return; + const frame = calculateFrame(e.clientX); + emit('seek', frame); + } + + function handleMouseup() { + dragging.value = false; + } + + function handleMouseleave() { + dragging.value = false; + } + + onMounted(() => { + updateWidth(); + if (scrubberEl.value) { + resizeObserver.value = new ResizeObserver(updateWidth); + resizeObserver.value.observe(scrubberEl.value); + } + document.addEventListener('mouseup', handleMouseup); + }); + + onBeforeUnmount(() => { + if (resizeObserver.value && scrubberEl.value) { + resizeObserver.value.unobserve(scrubberEl.value); + resizeObserver.value.disconnect(); + } + document.removeEventListener('mouseup', handleMouseup); + }); + + return { + scrubberEl, + handLeftPercent, + handleMousedown, + handleMousemove, + handleMouseleave, + }; + }, +}); + + + + + diff --git a/client/dive-common/components/Sidebar.vue b/client/dive-common/components/Sidebar.vue index d7b277dc9..46ae2bcf5 100644 --- a/client/dive-common/components/Sidebar.vue +++ b/client/dive-common/components/Sidebar.vue @@ -18,6 +18,7 @@ import { } from 'vue-media-annotator/provides'; import { clientSettings } from 'dive-common/store/settings'; +import ConfidenceFilter from 'dive-common/components/ConfidenceFilter.vue'; import TrackDetailsPanel from 'dive-common/components/TrackDetailsPanel.vue'; import TrackSettingsPanel from 'dive-common/components/TrackSettingsPanel.vue'; import TypeSettingsPanel from 'dive-common/components/TypeSettingsPanel.vue'; @@ -27,6 +28,7 @@ import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; export default defineComponent({ components: { + ConfidenceFilter, StackedVirtualSidebarContainer, TrackDetailsPanel, TrackSettingsPanel, @@ -43,6 +45,10 @@ export default defineComponent({ type: Boolean, default: true, }, + horizontal: { + type: Boolean, + default: false, + }, }, setup() { @@ -63,7 +69,9 @@ export default defineComponent({ const styleManager = useTrackStyleManager(); const data = reactive({ - currentTab: 'tracks' as 'tracks' | 'attributes', + currentTab: 'tracks' as 'tracks' | 'attributes' | 'types', + // For horizontal mode, cycle through 3 tabs + horizontalTab: 'tracks' as 'tracks' | 'attributes' | 'types', }); function swapTabs() { @@ -74,6 +82,28 @@ export default defineComponent({ } } + function cycleHorizontalTabs() { + if (data.horizontalTab === 'tracks') { + data.horizontalTab = 'attributes'; + } else if (data.horizontalTab === 'attributes') { + data.horizontalTab = 'types'; + } else { + data.horizontalTab = 'tracks'; + } + } + + const horizontalTabIcon = computed(() => { + if (data.horizontalTab === 'tracks') return 'mdi-format-list-bulleted'; + if (data.horizontalTab === 'attributes') return 'mdi-card-text'; + return 'mdi-filter-variant'; + }); + + const horizontalTabTooltip = computed(() => { + if (data.horizontalTab === 'tracks') return 'Detection List (click to cycle)'; + if (data.horizontalTab === 'attributes') return 'Detection Details (click to cycle)'; + return 'Type Filters (click to cycle)'; + }); + function doToggleMerge() { if (toggleMerge().length) { data.currentTab = 'attributes'; @@ -121,17 +151,23 @@ export default defineComponent({ readOnlyMode, styleManager, disableAnnotationFilters: trackFilterControls.disableAnnotationFilters, + confidenceFilters: trackFilterControls.confidenceFilters, visible, + horizontalTabIcon, + horizontalTabTooltip, /* methods */ doToggleMerge, swapTabs, + cycleHorizontalTabs, }; }, }); + + +
+ + + {{ horizontalTabTooltip }} + + +
+ +
+ +
+ +
+ + + +
+
+ +
+ +
+ +
+ + + +
+
diff --git a/client/dive-common/components/SidebarContext.vue b/client/dive-common/components/SidebarContext.vue index 0f799433b..cd298c42b 100644 --- a/client/dive-common/components/SidebarContext.vue +++ b/client/dive-common/components/SidebarContext.vue @@ -8,13 +8,36 @@ export default defineComponent({ type: Number, default: 300, }, + bottomMode: { + type: Boolean, + default: false, + }, }, - setup() { + setup(props) { const options = computed(() => Object.entries(context.componentMap).map(([value, entry]) => ({ text: entry.description, value, }))); - return { context, options }; + const sidebarStyle = computed(() => { + if (props.bottomMode) { + // In bottom mode, use fixed positioning to overlay on the right side + // Position above the bottom bar (260px) and below the top bar + visibility controls (~112px) + return { + position: 'fixed', + top: '112px', + right: '0', + height: 'calc(100vh - 112px - 260px)', + overflowY: 'hidden', + zIndex: 10, + }; + } + return { + height: 'calc(100vh - 112px)', + overflowY: 'hidden', + zIndex: 1, + }; + }); + return { context, options, sidebarStyle }; }, }); @@ -26,8 +49,8 @@ export default defineComponent({ :width="width" tile outlined - class="d-flex flex-column sidebar" - style="z-index:1;" + class="d-flex flex-column" + :style="sidebarStyle" >
diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index b3da641be..446d4d16b 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -28,6 +28,8 @@ import { LargeImageAnnotator, LayerManager, useMediaController, + TrackList, + FilterList, } from 'vue-media-annotator/components'; import type { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; import { getResponseError } from 'vue-media-annotator/utils'; @@ -38,7 +40,15 @@ import HeadTail from 'dive-common/recipes/headtail'; import EditorMenu from 'dive-common/components/EditorMenu.vue'; import ConfidenceFilter from 'dive-common/components/ConfidenceFilter.vue'; import UserGuideButton from 'dive-common/components/UserGuideButton.vue'; +import TypeSettingsPanel from 'dive-common/components/TypeSettingsPanel.vue'; +import TrackSettingsPanel from 'dive-common/components/TrackSettingsPanel.vue'; +import TrackListColumnSettings from 'dive-common/components/TrackListColumnSettings.vue'; +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'; @@ -71,6 +81,15 @@ export default defineComponent({ EditorMenu, MultiCamToolbar, PrimaryAttributeTrackFilter, + TrackList, + FilterList, + TypeSettingsPanel, + TrackSettingsPanel, + TrackListColumnSettings, + TrackDetailsPanel, + ConfidenceSubsection, + AttributeSubsection, + AttributeEditor, }, // TODO: remove this in vue 3 @@ -97,7 +116,7 @@ export default defineComponent({ }, }, setup(props, { emit }) { - const { prompt } = usePrompt(); + const { prompt, visible } = usePrompt(); const loadError = ref(''); const baseMulticamDatasetId = ref(null as string | null); const datasetId = toRef(props, 'id'); @@ -137,7 +156,35 @@ export default defineComponent({ const controlsRef = ref(); const controlsHeight = ref(0); const controlsCollapsed = ref(false); - const sideBarCollapsed = ref(false); + // Sidebar mode: 'left', 'bottom', or 'collapsed' + const sidebarMode = ref(clientSettings.layoutSettings.sidebarPosition as 'left' | 'bottom' | 'collapsed'); + // Right panel view in bottom mode: 'filters' or 'details' + const bottomRightPanelView = ref<'filters' | 'details'>('filters'); + const toggleBottomRightPanel = () => { + bottomRightPanelView.value = bottomRightPanelView.value === 'filters' ? 'details' : 'filters'; + }; + const cycleSidebarMode = () => { + if (sidebarMode.value === 'left') { + sidebarMode.value = 'bottom'; + clientSettings.layoutSettings.sidebarPosition = 'bottom'; + } else if (sidebarMode.value === 'bottom') { + sidebarMode.value = 'collapsed'; + // Keep setting as 'bottom' when collapsed (collapsed is a temporary state) + } else { + sidebarMode.value = 'left'; + clientSettings.layoutSettings.sidebarPosition = 'left'; + } + }; + const sidebarModeIcon = computed(() => { + if (sidebarMode.value === 'left') return 'mdi-page-layout-sidebar-left'; + if (sidebarMode.value === 'bottom') return 'mdi-page-layout-footer'; + return 'mdi-checkbox-blank-outline'; + }); + const sidebarModeTooltip = computed(() => { + if (sidebarMode.value === 'left') return 'Sidebar: Left (click to cycle)'; + if (sidebarMode.value === 'bottom') return 'Sidebar: Bottom (click to cycle)'; + return 'Sidebar: Hidden (click to cycle)'; + }); const progressValue = computed(() => { if (progress.total > 0 && (progress.progress !== progress.total)) { @@ -761,7 +808,7 @@ export default defineComponent({ if (previous) observer.unobserve(previous.$el); if (controlsRef.value) observer.observe(controlsRef.value.$el); }); - watch([controlsCollapsed, sideBarCollapsed], async () => { + watch([controlsCollapsed, sidebarMode], async () => { await nextTick(); handleResize(); }); @@ -833,6 +880,116 @@ export default defineComponent({ trackFilters.disableAnnotationFilters.value )); + // For bottom panel details view + const selectedTrackForDetails = computed(() => { + if (selectedTrackId.value !== null) { + return cameraStore.getAnyTrack(selectedTrackId.value); + } + return null; + }); + + // Determine if confidence should be shown first (multiple types) or last (0-1 types) + const showConfidenceFirst = computed(() => { + if (selectedTrackForDetails.value) { + return selectedTrackForDetails.value.confidencePairs.length > 1; + } + return false; + }); + + // Check if track has any track-level attributes set + const hasTrackAttributes = computed(() => { + if (selectedTrackForDetails.value && selectedTrackForDetails.value.attributes) { + const attrs = selectedTrackForDetails.value.attributes; + // Check if any non-userAttributes keys exist with values + return Object.keys(attrs).some( + (key) => key !== 'userAttributes' && attrs[key] !== undefined, + ); + } + return false; + }); + + // Determine attribute order: true = track first, false = detection first + // 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, @@ -841,7 +998,12 @@ export default defineComponent({ controlsRef, controlsHeight, controlsCollapsed, - sideBarCollapsed, + sidebarMode, + cycleSidebarMode, + sidebarModeIcon, + sidebarModeTooltip, + bottomRightPanelView, + toggleBottomRightPanel, colorBy, clientSettings, datasetName, @@ -875,6 +1037,23 @@ export default defineComponent({ imageEnhancementOutputs, isDefaultImage, disableAnnotationFilters, + trackStyleManager, + visible, + selectedTrackForDetails, + showConfidenceFirst, + showTrackAttributesFirst, + attributes, + /* Attribute editing for bottom panel */ + editIndividual, + editingAttribute, + editingError, + setEditIndividual, + resetEditIndividual, + addAttribute, + editAttribute, + closeAttributeEditor, + saveAttributeHandler, + deleteAttributeHandler, /* large image methods */ getTiles, getTileURL, @@ -982,12 +1161,12 @@ export default defineComponent({ - Collapse Side Panel + {{ sidebarModeTooltip }} + @@ -1041,6 +1226,7 @@ export default defineComponent({ /> @@ -1236,4 +1723,48 @@ html { text-align: center !important; } +.bottom-panel-section { + background-color: #1e1e1e; + border: 1px solid #555; + border-radius: 4px; + margin: 4px; +} + +.confidence-row-bottom { + background-color: #262626; + border-top: 1px solid #444; + flex-shrink: 0; + padding-top: 4px !important; + padding-bottom: 4px !important; + + /* Match title styling with Tracks header */ + .text-body-2 { + font-size: 14px !important; + font-weight: 600; + color: white !important; + } +} + +.bottom-filter-list { + /* Match title styling with Tracks header */ + #type-header b { + font-size: 14px; + font-weight: 600; + color: white; + } +} + +.right-panel-header { + background-color: #262626; + border-bottom: 1px solid #444; + flex-shrink: 0; + min-height: 28px; +} + +.right-panel-title { + font-size: 14px; + font-weight: 600; + color: white; +} + diff --git a/client/dive-common/store/settings.ts b/client/dive-common/store/settings.ts index adab2e24f..a75d3705b 100644 --- a/client/dive-common/store/settings.ts +++ b/client/dive-common/store/settings.ts @@ -2,6 +2,17 @@ import { Ref, watch, reactive } from 'vue'; import { cloneDeep, merge } from 'lodash'; import { AnnotatorPreferences } from 'vue-media-annotator/types'; +interface ColumnVisibilitySettings { + type: boolean; + confidence: boolean; + startFrame: boolean; + endFrame: boolean; + startTimestamp: boolean; + endTimestamp: boolean; + notes: boolean; + attributeColumns: string[]; // Array of attribute keys to show as columns +} + interface AnnotationSettings { typeSettings: { trackSortDir: 'a-z' | 'count' | 'frame count'; @@ -31,6 +42,7 @@ interface AnnotationSettings { trackListSettings: { autoZoom?: boolean; filterDetectionsByFrame?: boolean; + columnVisibility?: ColumnVisibilitySettings; } }; groupSettings: { @@ -48,6 +60,9 @@ interface AnnotationSettings { multiCamSettings: { showToolbar: boolean; }; + layoutSettings: { + sidebarPosition: 'left' | 'bottom'; + }; } const defaultSettings: AnnotationSettings = { @@ -71,6 +86,16 @@ const defaultSettings: AnnotationSettings = { trackListSettings: { autoZoom: false, filterDetectionsByFrame: false, + columnVisibility: { + type: true, + confidence: true, + startFrame: true, + endFrame: true, + startTimestamp: false, + endTimestamp: false, + notes: true, + attributeColumns: [], + }, }, }, groupSettings: { @@ -105,6 +130,9 @@ const defaultSettings: AnnotationSettings = { multiCamSettings: { showToolbar: true, }, + layoutSettings: { + sidebarPosition: 'left', + }, }; // Utility to safely load from localStorage @@ -150,4 +178,5 @@ watch(clientSettings, saveSettings, { deep: true }); export { clientSettings, AnnotationSettings, + ColumnVisibilitySettings, }; diff --git a/client/platform/desktop/backend/serializers/viame.ts b/client/platform/desktop/backend/serializers/viame.ts index b9e55c2c9..5242a30af 100644 --- a/client/platform/desktop/backend/serializers/viame.ts +++ b/client/platform/desktop/backend/serializers/viame.ts @@ -27,12 +27,14 @@ const TailRegex = /^\(kp\) tail (-?[0-9]+\.*-?[0-9]*) (-?[0-9]+\.*-?[0-9]*)/g; const AttrRegex = /^\(atr\) (.*?)\s(.+)/g; const TrackAttrRegex = /^\(trk-atr\) (.*?)\s(.+)/g; const PolyRegex = /^(\(poly\)) ((?:-?[0-9]+\.*-?[0-9]*\s*)+)/g; +const NoteRegex = /^\(note\)\s*(.+)/g; const FpsRegex = /fps:\s*(\d+(\.\d+)?)/ig; const ExecTimeRegEx = /exec_time:\s*(\d+(\.\d+)?)/ig; const AtrToken = '(atr)'; const TrackAtrToken = '(trk-atr)'; const PolyToken = '(poly)'; const KeypointToken = '(kp)'; +const NoteToken = '(note)'; export interface AnnotationFileData { tracks: MultiTrackRecord; @@ -160,6 +162,7 @@ function _parseRow(row: string[]) { }; let attributes: StringKeyObject | undefined; const trackAttributes: StringKeyObject = {}; + const notes: string[] = []; const cpStarti = 9; // Confidence pairs start at i=9 const confidencePairs: ConfidencePair[] = row .slice(cpStarti, row.length) @@ -221,6 +224,12 @@ function _parseRow(row: string[]) { }); geoFeatureCollection.features.push(_createGeoJsonFeature('Polygon', coords)); } + + /* Note */ + const note = getCaptureGroups(NoteRegex, value); + if (note !== null) { + notes.push(note[1]); + } }); if (headTail[0] !== undefined && headTail[1] !== undefined) { @@ -236,7 +245,7 @@ function _parseRow(row: string[]) { } return { - attributes, trackAttributes, confidencePairs, geoFeatureCollection, + attributes, trackAttributes, confidencePairs, geoFeatureCollection, notes, }; } @@ -256,6 +265,9 @@ function _parseFeature(row: string[]) { if (rowData.geoFeatureCollection.features.length > 0) { feature.geometry = rowData.geoFeatureCollection; } + if (rowData.notes.length > 0) { + feature.notes = rowData.notes; + } return { rowInfo, feature, @@ -617,6 +629,13 @@ async function serialize( /* TODO support for multiple GeoJSON Objects of the same type */ }); } + + /* Notes */ + if (feature.notes && feature.notes.length > 0) { + feature.notes.forEach((noteText) => { + row.push(`${NoteToken} ${noteText}`); + }); + } stringify.write(row); }); }); diff --git a/client/platform/desktop/frontend/components/ViewerLoader.vue b/client/platform/desktop/frontend/components/ViewerLoader.vue index 94ca70d8c..90d594b19 100644 --- a/client/platform/desktop/frontend/components/ViewerLoader.vue +++ b/client/platform/desktop/frontend/components/ViewerLoader.vue @@ -172,8 +172,8 @@ export default defineComponent({ :button-options="buttonOptions" /> -