diff --git a/client/dive-common/components/BottomPanel.vue b/client/dive-common/components/BottomPanel.vue new file mode 100644 index 000000000..e3c4b7b11 --- /dev/null +++ b/client/dive-common/components/BottomPanel.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/client/dive-common/components/ControlsContainer.vue b/client/dive-common/components/ControlsContainer.vue index bdc10af6c..eb84eee57 100644 --- a/client/dive-common/components/ControlsContainer.vue +++ b/client/dive-common/components/ControlsContainer.vue @@ -54,6 +54,14 @@ export default defineComponent({ type: Boolean as PropType, required: true, }, + bottomLayout: { + type: Boolean, + default: false, + }, + wrapBottomControls: { + type: Boolean, + default: false, + }, }, setup(_, { emit }) { const handler = useHandler(); @@ -150,12 +158,20 @@ export default defineComponent({ + - + { + 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..700e360ce 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, }, + sidebarMode: { + type: String, + default: 'left', + }, }, - 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.sidebarMode === 'bottom') { + // 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 context-sidebar-panel" + :style="sidebarStyle" >
diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index 1f8305173..195fd03eb 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -5,6 +5,7 @@ import { } from 'vue'; import type { Vue } from 'vue/types/vue'; import type Vuetify from 'vuetify/lib'; +import type { AxiosError } from 'axios'; import { cloneDeep, debounce } from 'lodash'; /* VUE MEDIA ANNOTATOR */ @@ -28,6 +29,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,9 +41,18 @@ 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 BottomPanel from 'dive-common/components/BottomPanel.vue'; import { useModeManager, useSave } from 'dive-common/use'; import clientSettingsSetup, { clientSettings } from 'dive-common/store/settings'; import { useApi, FrameImage, DatasetType } from 'dive-common/apispec'; @@ -58,9 +70,12 @@ export interface ImageDataItem { filename: string; } +const SIDEBAR_MODE_STORAGE_KEY = 'dive.viewer.sidebarMode'; + export default defineComponent({ components: { ControlsContainer, + BottomPanel, DeleteControls, Sidebar, LayerManager, @@ -73,6 +88,15 @@ export default defineComponent({ EditorMenu, MultiCamToolbar, PrimaryAttributeTrackFilter, + TrackList, + FilterList, + TypeSettingsPanel, + TrackSettingsPanel, + TrackListColumnSettings, + TrackDetailsPanel, + ConfidenceSubsection, + AttributeSubsection, + AttributeEditor, }, // TODO: remove this in vue 3 @@ -99,7 +123,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'); @@ -139,9 +163,65 @@ 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 getInitialSidebarMode = (): 'left' | 'bottom' | 'collapsed' => { + const defaultMode = clientSettings.layoutSettings.sidebarPosition as 'left' | 'bottom' | 'collapsed'; + if (typeof window === 'undefined') { + return defaultMode; + } + try { + const saved = window.localStorage.getItem(SIDEBAR_MODE_STORAGE_KEY); + if (saved === 'left' || saved === 'bottom' || saved === 'collapsed') { + return saved; + } + } catch { + // Ignore localStorage read failures and use default. + } + return defaultMode; + }; + const sidebarMode = ref<'left' | 'bottom' | 'collapsed'>(getInitialSidebarMode()); + // 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 showUserSettingsDialog = ref(false); + watch(sidebarMode, (mode) => { + if (mode === 'left' || mode === 'bottom') { + clientSettings.layoutSettings.sidebarPosition = mode; + } + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(SIDEBAR_MODE_STORAGE_KEY, mode); + } catch { + // Ignore localStorage write failures. + } + } + }, { immediate: true }); + const progressValue = computed(() => { if (progress.total > 0 && (progress.progress !== progress.total)) { return Math.round((progress.progress / progress.total) * 100); @@ -441,7 +521,8 @@ export default defineComponent({ }, saveSet); } catch (err) { let text = 'Unable to Save Data'; - if (err.response && err.response.status === 403) { + const saveErr = err as { response?: { status?: number } }; + if (saveErr.response && saveErr.response.status === 403) { text = 'You do not have permission to Save Data to this Folder.'; } await prompt({ @@ -812,7 +893,7 @@ export default defineComponent({ progress.loaded = false; console.error(err); const errorEl = document.createElement('div'); - errorEl.innerHTML = getResponseError(err); + errorEl.innerHTML = getResponseError(err as AxiosError); loadError.value = errorEl.innerText .concat(". If you don't know how to resolve this, please contact the server administrator."); throw err; @@ -847,7 +928,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(); }); @@ -938,9 +1019,134 @@ export default defineComponent({ && 'diveDesktop' in window && multiCamList.value.length > 1 && clientSettings.multiCamSettings.showToolbar - && selectedCamera.value !== multiCamList.value[0] )); + function seekToFrame(frame: number) { + try { + aggregateController.value.seek(frame); + } catch { + // Ignore seek requests while controllers are initializing. + } + } + + function resetAggregateZoom() { + try { + aggregateController.value.resetZoom(); + } catch { + // Ignore reset requests while controllers are initializing. + } + } + + // 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, @@ -949,7 +1155,12 @@ export default defineComponent({ controlsRef, controlsHeight, controlsCollapsed, - sideBarCollapsed, + sidebarMode, + cycleSidebarMode, + sidebarModeIcon, + sidebarModeTooltip, + bottomRightPanelView, + toggleBottomRightPanel, colorBy, clientSettings, datasetName, @@ -984,8 +1195,27 @@ 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, saveTooltipText, showMultiCamToolbar, + seekToFrame, + resetAggregateZoom, /* large image methods */ getTiles, getTileURL, @@ -1093,12 +1323,12 @@ export default defineComponent({ - Collapse Side Panel + {{ sidebarModeTooltip }} + -