diff --git a/client/dive-common/components/ControlsContainer.vue b/client/dive-common/components/ControlsContainer.vue index 191891f3..5c2cdea3 100644 --- a/client/dive-common/components/ControlsContainer.vue +++ b/client/dive-common/components/ControlsContainer.vue @@ -9,7 +9,6 @@ import { Controls, injectAggregateController, Timeline, - TimelineKey, } from 'vue-media-annotator/components'; import { TimelineDisplay, UISettingsKey } from 'vue-media-annotator/ConfigurationManager'; import TimelineCharts from 'vue-media-annotator/components/controls/TimelineCharts.vue'; @@ -24,7 +23,6 @@ export default defineComponent({ Controls, FileNameTimeDisplay, Timeline, - TimelineKey, TimelineButtons, TimelineCharts, }, @@ -57,6 +55,9 @@ export default defineComponent({ const attributesFilter = useAttributesFilters(); const { enabledTimelines: enabledFilterTimelines } = useTimelineFilters(); const getUISetting = (key: UISettingsKey) => (configMan.getUISetting(key)); + const isUISettingExplicitlyTrue = (key: UISettingsKey) => ( + configMan.getFlatUISettingMap()[key] === true + ); if (getUISetting('UIDetections') === false && getUISetting('UIEvents')) { currentView.value = 'Events'; } @@ -80,12 +81,20 @@ export default defineComponent({ currentView.value = timelines[0].name; } } - const enabledKey = ref(false); + const enabledKey = ref(isUISettingExplicitlyTrue('UILegendForceOpen')); + const chartAreaLeftInset = ref(0); + const chartAreaRightInset = ref(0); const dismissedButtons: Ref = ref([]); // buttons that have been dismissed from the timelineConfig; const dismissedHeights: Ref<{name: string; height: number}[]> = ref([]); - const { - attributeSwimlaneData, - } = useAttributesFilters(); + + const showLegendToggle = computed(() => ( + getUISetting('UILegendControls') + && !isUISettingExplicitlyTrue('UILegendHideToggle') + )); + + const toggleLegendKey = () => { + enabledKey.value = !enabledKey.value; + }; const timelineHeight = computed(() => { const activeConfig = configMan.getActiveTimelineConfig(); @@ -144,19 +153,11 @@ export default defineComponent({ maxFrame, frame, seek, volume, setVolume, setSpeed, speed, } = injectAggregateController().value; - // Timeline Key Sizing and Refs const timelineRef: Ref = ref(null); const controlsRef: Ref = ref(null); - const keyHeight = computed(() => ((timelineRef.value !== null) ? timelineRef.value.clientHeight : 0)); - const keyTop = computed(() => ((controlsRef.value !== null) ? controlsRef.value.$el.clientHeight : 0)); - const keyWidth = ref(0); - watch(() => timelineRef.value && timelineRef.value.$el.clientWidth, () => { - keyWidth.value = timelineRef.value?.$el.clientWidth || 0; - }); const updateSizes = () => { - keyWidth.value = timelineRef.value?.$el.clientWidth || 0; + // retained for Timeline @resize handler }; - const swimlaneOffset = ref(0); const addDismissedButton = ({ name, height }: {name: string; height: number}) => { dismissedButtons.value.push(name); @@ -173,6 +174,11 @@ export default defineComponent({ } }; + const onChartAreaInsets = ({ leftInset, rightInset }: { leftInset: number; rightInset: number }) => { + chartAreaLeftInset.value = leftInset; + chartAreaRightInset.value = rightInset; + }; + watch(timelineHeight, () => { emit('timeline-height', timelineHeight.value); }); @@ -190,21 +196,19 @@ export default defineComponent({ ticks, getUISetting, timelineDisabled, - // Timeline Ref controlsRef, timelineRef, - keyHeight, - keyTop, - keyWidth, enabledKey, updateSizes, - swimlaneOffset, - //Timeline Config + showLegendToggle, + toggleLegendKey, timelineHeight, - attributeSwimlaneData, dismissedButtons, addDismissedButton, removeDismissedButton, + chartAreaLeftInset, + chartAreaRightInset, + onChartAreaInsets, }; }, }); @@ -238,7 +242,7 @@ export default defineComponent({ Collapse/Expand Timeline @@ -248,7 +252,7 @@ export default defineComponent({ :color="enabledKey ? 'primary' : ''" class="ml-2" v-on="on" - @click="enabledKey = !enabledKey" + @click="toggleLegendKey" > mdi-key @@ -367,6 +371,8 @@ export default defineComponent({ :frame="frame" :display="!collapsed" :timeline-height="timelineHeight" + :key-inset="chartAreaLeftInset" + :chart-right-inset="chartAreaRightInset" @seek="seek" @resize="updateSizes" > @@ -386,6 +392,7 @@ export default defineComponent({ :group-chart-data="groupChartData" :current-view="currentView" :collapsed="collapsed" + :show-key="enabledKey" :start-frame="startFrame" :end-frame="endFrame" :child-max-frame="childMaxFrame" @@ -395,19 +402,10 @@ export default defineComponent({ :dismissed-buttons="dismissedButtons" @select-track="$emit('select-track', $event)" @dismiss="addDismissedButton($event)" + @chart-area-insets="onChartAreaInsets" /> - diff --git a/client/dive-common/components/configurationEditors/UISettings/UIControls.vue b/client/dive-common/components/configurationEditors/UISettings/UIControls.vue index 567aaedb..cf7cd94e 100644 --- a/client/dive-common/components/configurationEditors/UISettings/UIControls.vue +++ b/client/dive-common/components/configurationEditors/UISettings/UIControls.vue @@ -10,7 +10,6 @@ export default defineComponent({ }, setup() { const configMan = useConfiguration(); - const UILegendControls = ref(configMan.getUISetting('UILegendControls') as boolean); const UITimelineSelection = ref(configMan.getUISetting('UITimelineSelection') as boolean); const UIPlaybackControls = ref(configMan.getUISetting('UIPlaybackControls') as boolean); const UIAudioControls = ref(configMan.getUISetting('UIAudioControls') as boolean); @@ -21,10 +20,12 @@ export default defineComponent({ const UILockCamera = ref(configMan.getUISetting('UILockCamera') as boolean); const UIResetCamera = ref(configMan.getUISetting('UIResetCamera') as boolean); - watch([UILegendControls, UITimelineSelection, UIPlaybackControls, UIAudioControls, - UITimeDisplay, UIFrameDisplay, UIImageNameDisplay, UILockCamera, UISpeedControls, UIResetCamera], () => { + watch([ + UITimelineSelection, UIPlaybackControls, + UIAudioControls, UITimeDisplay, UIFrameDisplay, UIImageNameDisplay, UILockCamera, UISpeedControls, + UIResetCamera, + ], () => { const data = { - UILegendControls: UILegendControls.value ? undefined : false, UITimelineSelection: UITimelineSelection.value ? undefined : false, UIPlaybackControls: UIPlaybackControls.value ? undefined : false, UIAudioControls: UIAudioControls.value ? undefined : false, @@ -38,7 +39,6 @@ export default defineComponent({ configMan.setUISettings('UIControls', data); }); return { - UILegendControls, UITimelineSelection, UIPlaybackControls, UIAudioControls, @@ -59,12 +59,6 @@ export default defineComponent({ Playback Controls
- - - , + key: 'UILegendKeyMinWidth' | 'UILegendKeyMaxWidth', + fallback: number, +): number { + const value = flatMap[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +const KEY_WIDTH_INPUT_MIN = 50; + +function parseKeyWidth(value: number, fallback: number): number { + return Number.isFinite(value) ? Math.max(KEY_WIDTH_INPUT_MIN, Math.round(value)) : fallback; +} + export default defineComponent({ name: 'UITimeline', components: { }, setup() { const configMan = useConfiguration(); + if (configMan.configuration.value?.UISettings) { + configMan.configuration.value.UISettings = migrateLegendSettingsUISettings( + configMan.configuration.value.UISettings, + ) || configMan.configuration.value.UISettings; + } + const timelineSettings = configMan.getUISettingValue('UITimeline'); + const timelineMap = typeof timelineSettings === 'object' && timelineSettings + ? timelineSettings as Record + : {}; const UIDetections = ref(configMan.getUISetting('UIDetections') as boolean); const UIEvents = ref(configMan.getUISetting('UIEvents') as boolean); + const UILegendControls = ref(configMan.getUISetting('UILegendControls') as boolean); + const UILegendForceOpen = ref(timelineMap.UILegendForceOpen === true); + const UILegendHideToggle = ref(timelineMap.UILegendHideToggle === true); + const UILegendKeyMinWidth = ref(readNumberSetting(timelineMap, 'UILegendKeyMinWidth', KEY_PANEL_MIN_WIDTH)); + const UILegendKeyMaxWidth = ref(readNumberSetting(timelineMap, 'UILegendKeyMaxWidth', KEY_PANEL_MAX_WIDTH)); + + const saveTimelineSettings = () => { + const minWidth = parseKeyWidth(UILegendKeyMinWidth.value, KEY_PANEL_MIN_WIDTH); + const maxWidth = Math.max( + minWidth, + parseKeyWidth(UILegendKeyMaxWidth.value, KEY_PANEL_MAX_WIDTH), + ); - watch([UIDetections, UIEvents], () => { const data = { UIDetections: UIDetections.value ? undefined : false, UIEvents: UIEvents.value ? undefined : false, - + UILegendControls: UILegendControls.value ? undefined : false, + UILegendForceOpen: UILegendForceOpen.value ? true : undefined, + UILegendHideToggle: UILegendHideToggle.value ? true : undefined, + UILegendKeyMinWidth: minWidth !== KEY_PANEL_MIN_WIDTH ? minWidth : undefined, + UILegendKeyMaxWidth: maxWidth !== KEY_PANEL_MAX_WIDTH ? maxWidth : undefined, }; configMan.setUISettings('UITimeline', data); - }); + }; + + watch([ + UIDetections, + UIEvents, + UILegendControls, + UILegendForceOpen, + UILegendHideToggle, + ], saveTimelineSettings); + + const normalizeKeyWidthInputs = () => { + const minWidth = parseKeyWidth(UILegendKeyMinWidth.value, KEY_PANEL_MIN_WIDTH); + const maxWidth = Math.max( + minWidth, + parseKeyWidth(UILegendKeyMaxWidth.value, KEY_PANEL_MAX_WIDTH), + ); + UILegendKeyMinWidth.value = minWidth; + UILegendKeyMaxWidth.value = maxWidth; + saveTimelineSettings(); + }; + return { UIDetections, UIEvents, + UILegendControls, + UILegendForceOpen, + UILegendHideToggle, + UILegendKeyMinWidth, + UILegendKeyMaxWidth, + KEY_PANEL_MIN_WIDTH, + KEY_PANEL_MAX_WIDTH, + KEY_WIDTH_INPUT_MIN, + normalizeKeyWidthInputs, }; }, @@ -47,6 +120,56 @@ export default defineComponent({ label="Events Timeline" /> + +
+ Legend / Key +
+ + + + + + + + + + + + + + + + +
diff --git a/client/package.json b/client/package.json index c4c3f6e2..db4c39f7 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "dive-dsa", - "version": "1.11.40", + "version": "1.11.41", "author": { "name": "Kitware, Inc.", "email": "Bryon.Lewis@kitware.com" diff --git a/client/src/ConfigurationManager.ts b/client/src/ConfigurationManager.ts index 7505d22f..413a6ce3 100644 --- a/client/src/ConfigurationManager.ts +++ b/client/src/ConfigurationManager.ts @@ -1,6 +1,7 @@ import { ref, Ref } from 'vue'; import { DIVEAction, DIVEActionShortcut } from 'dive-common/use/useActions'; import { isArray } from 'lodash'; +import migrateLegendSettingsUISettings from './components/controls/migrateLegendSettings'; import type { FilterTimeline } from './use/useTimelineFilters'; import type { CustomStyle } from './StyleManager'; import type { Feature } from './track'; @@ -82,7 +83,6 @@ interface UITrackDetails { } interface UIControls { - UILegendControls?: boolean; UITimelineSelection?: boolean; UIPlaybackControls? : boolean; UIAudioControls? : boolean; @@ -97,6 +97,11 @@ interface UIControls { interface UITimeline { UIDetections? : boolean; UIEvents? : boolean; + UILegendControls?: boolean; + UILegendForceOpen?: boolean; + UILegendHideToggle?: boolean; + UILegendKeyMinWidth?: number; + UILegendKeyMaxWidth?: number; } interface UIInteractions { @@ -308,6 +313,10 @@ export default class ConfigurationManager { if (this.activeTimelineConfigIndex.value >= (normalizedData.timelineConfigs?.length || 0)) { this.activeTimelineConfigIndex.value = -1; // Reset to no selection if invalid } + if (normalizedData.UISettings) { + normalizedData.UISettings = migrateLegendSettingsUISettings(normalizedData.UISettings) + || normalizedData.UISettings; + } this.configuration.value = normalizedData; } } diff --git a/client/src/components/AttributeSwimlaneGraph.vue b/client/src/components/AttributeSwimlaneGraph.vue index 133223b4..1ebae59d 100644 --- a/client/src/components/AttributeSwimlaneGraph.vue +++ b/client/src/components/AttributeSwimlaneGraph.vue @@ -44,6 +44,7 @@ export default defineComponent({ const showGraphSettings = ref(false); const showRangeSettings = ref(false); const showDisplaySettings = ref(false); + const showTitleKeySettings = ref(false); const typeStylingRef = useTrackStyleManager().typeStyling; const trackFilterControls = useTrackFilters(); const types = computed(() => ['all', ...trackFilterControls.allTypes.value]); @@ -97,6 +98,30 @@ export default defineComponent({ }); }); + const appliedAttributeNames = computed(() => { + const applied = editSwimlaneFilter.value.appliedTo; + const detectionAttributes = attributesList.value.filter((item) => item.belongs === 'detection'); + if (applied.includes('all')) { + return detectionAttributes.map((attribute) => attribute.name); + } + return applied.filter((name) => name !== 'all'); + }); + + const getAttributeDisplayNameSetting = (name: string) => { + if (!editSwimlaneSettings.value[name]) { + editSwimlaneSettings.value[name] = { displayName: true }; + } + return editSwimlaneSettings.value[name].displayName !== false; + }; + + const setAttributeDisplayNameSetting = (name: string, value: boolean) => { + if (!editSwimlaneSettings.value[name]) { + editSwimlaneSettings.value[name] = { displayName: value }; + } else { + editSwimlaneSettings.value[name].displayName = value; + } + }; + const saveChanges = () => { if (editSwimlaneName.value !== originalName) { removeSwimlaneFilter(originalName); @@ -144,9 +169,13 @@ export default defineComponent({ showGraphSettings, showRangeSettings, showDisplaySettings, + showTitleKeySettings, dialogTitle, renderModeHelp, swimlaneBackgroundColors, + appliedAttributeNames, + getAttributeDisplayNameSetting, + setAttributeDisplayNameSetting, }; }, }); @@ -244,9 +273,74 @@ export default defineComponent({ class="mx-2" /> +
+

+ Title & Key + + {{ showTitleKeySettings ? 'mdi-chevron-up' : 'mdi-chevron-down' }} + +

+

+ Control the swimlane section title and legend column labels +

+
+ + + + + + + + + + + + Key Label Settings + + + + + +
+
(null); const tooltip: Ref - - - {{ tooltipComputed.name }} - - : - {{ tooltipComputed.subDisplay }} - - - - - {{ tooltipComputed.name }} - {{ tooltipComputed.subDisplay }} - + +
+
+ {{ tooltipComputed.name }} + + : + {{ tooltipComputed.subDisplay }} + +
+
+
+ +
+
+ {{ tooltipComputed.name }} +
+
+ {{ tooltipComputed.subDisplay }} +
+
+
@@ -783,18 +793,37 @@ export default defineComponent({ } .tooltip { - position: absolute; - background: black; + width: fit-content; + max-width: fit-content; + background-color: black; + color: white; border: 1px solid white; padding: 0px 5px; font-size: 20px; font-weight: bold; - z-index: 9999; +} + +.tooltip-content { + display: flex; + align-items: center; + gap: 5px; + padding: 2px 5px; + white-space: nowrap; +} + +.tooltip-long { + max-width: min(400px, 90vw); + white-space: normal; +} + +.tooltip-long-title, +.tooltip-long-text { + padding: 4px 8px; + word-break: break-word; } .type-color-box { - margin-right: 5px; - margin-top: 5px; + flex-shrink: 0; min-width: 10px; max-width: 10px; min-height: 10px; diff --git a/client/src/components/controls/BodyPortal.vue b/client/src/components/controls/BodyPortal.vue new file mode 100644 index 00000000..975c4150 --- /dev/null +++ b/client/src/components/controls/BodyPortal.vue @@ -0,0 +1,21 @@ + + + diff --git a/client/src/components/controls/EventChart.vue b/client/src/components/controls/EventChart.vue index 6dff82f9..a9950870 100644 --- a/client/src/components/controls/EventChart.vue +++ b/client/src/components/controls/EventChart.vue @@ -3,6 +3,8 @@ import Vue from 'vue'; import { throttle, debounce, sortBy } from 'lodash'; import * as d3 from 'd3'; import { useVuetify } from 'platform/web-girder/plugins/vuetify'; +import BodyPortal from './BodyPortal.vue'; +import { timelineTooltipStyle } from './timelineTooltip'; function intersect(range1, range2) { const min = range1[0] < range2[0] ? range1 : range2; @@ -15,6 +17,9 @@ function intersect(range1, range2) { export default Vue.extend({ name: 'EventChart', + components: { + BodyPortal, + }, props: { startFrame: { type: Number, @@ -58,10 +63,7 @@ export default Vue.extend({ tooltipComputed() { if (this.tooltip !== null) { return { - style: { - left: `${this.tooltip.left + 15}px`, - top: `${this.tooltip.top + 0}px`, - }, + style: timelineTooltipStyle({ x: this.tooltip.x, y: this.tooltip.y }), ...this.tooltip, }; } @@ -266,8 +268,8 @@ export default Vue.extend({ } this.hoverTrack = bar.id; this.tooltip = { - left: offsetX, - top: offsetY, + x: e.clientX, + y: e.clientY, content: `${bar.name} (${bar.type})`, }; }, @@ -287,13 +289,14 @@ export default Vue.extend({ @mouseout="mouseout" @mousedown="mousedown" /> -
- {{ tooltipComputed.content }} -
+ +
+ {{ tooltipComputed.content }} +
+
@@ -304,14 +307,16 @@ export default Vue.extend({ margin: 5px 0; overflow-y: auto; overflow-x: hidden; +} - .tooltip { - position: absolute; - background: black; - border: 1px solid white; - padding: 0px 5px; - font-size: 14px; - z-index: 9999; - } +.event-chart-tooltip { + background: black; + color: white; + border: 1px solid white; + padding: 0px 5px; + font-size: 14px; + width: fit-content; + max-width: fit-content; + white-space: nowrap; } diff --git a/client/src/components/controls/LineChart.vue b/client/src/components/controls/LineChart.vue index 0dd2208e..65fe9877 100644 --- a/client/src/components/controls/LineChart.vue +++ b/client/src/components/controls/LineChart.vue @@ -2,6 +2,11 @@ import Vue from 'vue'; import { throttle } from 'lodash'; import * as d3 from 'd3'; +import { + TIMELINE_TOOLTIP_BASE_CLASS, + TIMELINE_TOOLTIP_GAP_PX, + TIMELINE_TOOLTIP_Z_INDEX, +} from './timelineTooltip'; export default Vue.extend({ name: 'LineChart', @@ -111,18 +116,36 @@ export default Vue.extend({ this.chartTop = this.$refs.chart.offsetTop; } }, + beforeDestroy() { + if (this.lineChartTooltip) { + this.lineChartTooltip.remove(); + this.lineChartTooltip = null; + } + }, methods: { initialize() { this.currentRange = this.yRange; d3.select(this.$el) .select('svg') .remove(); - let tooltipTimeoutHandle = null; + if (this.lineChartTooltip) { + this.lineChartTooltip.remove(); + } const tooltip = d3 - .select(this.$el) + .select(document.body) .append('div') - .attr('class', 'tooltip') - .style('display', 'none'); + .attr('class', `${TIMELINE_TOOLTIP_BASE_CLASS} line-chart-tooltip`) + .style('display', 'none') + .style('background', 'black') + .style('color', 'white') + .style('border', '1px solid white') + .style('padding', '0px 5px') + .style('font-size', '14px') + .style('width', 'fit-content') + .style('max-width', 'fit-content') + .style('white-space', 'nowrap') + .style('pointer-events', 'none'); + this.lineChartTooltip = tooltip; const width = this.clientWidth; const height = this.clientHeight; const x = d3 @@ -183,6 +206,7 @@ export default Vue.extend({ let highlightedLine = null; let highlightedColor = null; + let tooltipTimeoutHandle = null; const path = svg .selectAll() .data(this.data) @@ -195,12 +219,13 @@ export default Vue.extend({ .style('opacity', (d) => (d.lineOpacity !== undefined ? d.lineOpacity : 1.0)) // Non-Arrow function to preserve the 'this' context for d3.pointer .on('mouseenter', function mouseEnterHandler(event, d) { - const [_x, _y] = d3.pointer(event, this); tooltipTimeoutHandle = setTimeout(() => { tooltip - .style('left', `${_x + 2}px`) - .style('top', `${_y + this.chartTop}px`) - .style('position', 'asbsolute') + .style('left', `${event.clientX}px`) + .style('top', `${event.clientY}px`) + .style('position', 'fixed') + .style('transform', `translate(-50%, calc(-100% - ${TIMELINE_TOOLTIP_GAP_PX}px))`) + .style('z-index', String(TIMELINE_TOOLTIP_Z_INDEX)) .text(d.name) .style('display', 'block'); d3.select(this).style('stroke', 'cyan').style('stroke-width', 3); @@ -447,13 +472,6 @@ export default Vue.extend({ } } - .tooltip { - position: absolute; - background: black; - border: 1px solid white; - padding: 0px 5px; - font-size: 14px; - } } .area { fill: rgba(234, 255, 0, 0.2); diff --git a/client/src/components/controls/Timeline.vue b/client/src/components/controls/Timeline.vue index 4257b034..398e2a7c 100644 --- a/client/src/components/controls/Timeline.vue +++ b/client/src/components/controls/Timeline.vue @@ -21,6 +21,14 @@ export default { type: Boolean, default: true, }, + keyInset: { + type: Number, + default: 0, + }, + chartRightInset: { + type: Number, + default: 0, + }, }, data() { return { @@ -50,8 +58,13 @@ export default { ) { return null; } + const chartLeft = this.getChartLeft(); + const chartWidth = this.getChartWidth(); + if (chartWidth <= 0) { + return null; + } return Math.round( - this.margin + (this.clientWidth - this.margin) + chartLeft + chartWidth * ((this.frame - this.startFrame) / (this.endFrame - this.startFrame)), ); }, @@ -77,10 +90,17 @@ export default { this.$refs.hand.style.left = `${value || '-10'}px`; }, frame(frame) { + const range = this.endFrame - this.startFrame; + if (range <= 0) { + return; + } + const edgePadding = Math.max(1, Math.round(range * 0.1)); if (frame > this.endFrame) { - this.endFrame = Math.min(frame + 200, this.maxFrame); + this.endFrame = Math.min(frame + edgePadding, this.maxFrame); + this.startFrame = Math.max(0, this.endFrame - range); } else if (frame < this.startFrame) { - this.startFrame = Math.max(frame - 100, 0); + this.startFrame = Math.max(frame - edgePadding, 0); + this.endFrame = Math.min(this.maxFrame, this.startFrame + range); } }, display(val) { @@ -90,6 +110,16 @@ export default { this.initialize(); } }, + keyInset() { + this.$nextTick(() => { + this.updateLayout(); + }); + }, + chartRightInset() { + this.$nextTick(() => { + this.updateLayout(); + }); + }, }, created() { this.update = throttle(this.update, 30); @@ -107,53 +137,62 @@ export default { this.initialize(); }, methods: { + getChartLeft() { + return this.margin + (this.keyInset || 0); + }, + getChartRight() { + return this.clientWidth + (this.chartRightInset || 0); + }, + getChartWidth() { + return Math.max(0, this.getChartRight() - this.getChartLeft()); + }, initialize() { if (!this.$refs.workarea) { return; } - const width = this.$refs.workarea?.clientWidth || 0; - const height = this.$refs.workarea?.clientHeight || 0; - if (this.$refs.workarea) { + if (this.$refs.workarea && !this.resizeObserver) { this.resizeObserver = new ResizeObserver(() => { this.resizeHandler(); }); this.resizeObserver.observe(this.$refs.workarea); } - // clientWidth and clientHeight are properties used to resize child elements - this.clientWidth = width - this.margin; - // Timeline height needs to offset so it doesn't overlap the frame number - this.clientHeight = height - 15; - const scale = d3 - .scaleLinear() - .domain([0, this.maxFrame]) - .range([this.margin, this.clientWidth]); - this.timelineScale = scale; - const axis = d3 - .axisTop() - .scale(scale) - .tickSize(height - 30) - .tickSizeOuter(0); - this.axis = axis; if (!this.svg) { this.svg = d3 .select(this.$refs.workarea) .append('svg'); + this.g = this.svg.append('g'); + this.timelineScale = d3.scaleLinear(); + this.axis = d3 + .axisTop() + .scale(this.timelineScale) + .tickSizeOuter(0); + } + this.updateLayout(); + this.mounted = true; + }, + updateLayout() { + if (!this.$refs.workarea || !this.timelineScale) { + return; } + const width = this.$refs.workarea.clientWidth || 0; + const height = this.$refs.workarea.clientHeight || 0; + // clientWidth and clientHeight are properties used to resize child elements + this.clientWidth = width - this.margin; + // Timeline height needs to offset so it doesn't overlap the frame number + this.clientHeight = height - 15; + const chartLeft = this.getChartLeft(); + this.timelineScale.range([chartLeft, this.getChartRight()]); + this.axis.tickSize(height - 30); this.svg.style('display', 'block') .attr('width', this.clientWidth) .attr('height', height); - if (!this.g) { - this.g = this.svg.append('g') - .attr('transform', `translate(0,${height - 15})`); - } - - this.updateAxis(); - this.mounted = true; + this.g.attr('transform', `translate(0,${height - 15})`); + this.update(); }, resizeHandler() { // Debounces resize to prevent it from be calling continuously. clearTimeout(this.resizeTimer); - this.resizeTimer = setTimeout(this.initialize, 200); + this.resizeTimer = setTimeout(this.updateLayout, 200); this.$nextTick(() => this.$emit('resize')); }, onwheel(e) { @@ -162,7 +201,13 @@ export default { } const extend = Math.round((this.endFrame - this.startFrame) * 0.2) * Math.sign(e.deltaY); - const ratio = (e.layerX - this.$el.offsetLeft) / this.clientWidth; + const chartLeft = this.getChartLeft(); + const chartWidth = this.getChartWidth(); + if (chartWidth <= 0) { + return; + } + const workareaLeft = this.$refs.workarea.getBoundingClientRect().left; + const ratio = Math.max(0, Math.min(1, (e.clientX - workareaLeft - chartLeft) / chartWidth)); let startFrame = this.startFrame - extend * ratio; let endFrame = this.endFrame + extend * (1 - ratio); startFrame = Math.max(0, startFrame); @@ -174,6 +219,9 @@ export default { this.endFrame = endFrame; }, updateAxis() { + if (!this.g || !this.axis) { + return; + } this.g.call(this.axis).call((g) => g .selectAll('.tick text') .attr('y', 0) @@ -182,17 +230,22 @@ export default { .style('-webkit-user-select', 'none')); }, update() { + if (!this.timelineScale || !this.axis || !this.g) { + return; + } this.timelineScale.domain([this.startFrame, this.endFrame]); this.axis.scale(this.timelineScale); this.updateAxis(); }, emitSeek(e) { - const leftBounds = (this.$refs.workarea.getBoundingClientRect().left + this.margin); - const rightBounds = (this.$refs.workarea.getBoundingClientRect().right - this.margin); + const chartLeft = this.getChartLeft(); + const workareaLeft = this.$refs.workarea.getBoundingClientRect().left; + const leftBounds = workareaLeft + chartLeft; + const rightBounds = workareaLeft + this.getChartRight(); if (e.clientX > leftBounds && e.clientX < rightBounds) { const frame = Math.round( ((e.clientX - leftBounds) - / (this.clientWidth - this.margin)) + / (rightBounds - leftBounds)) * (this.endFrame - this.startFrame) + this.startFrame, ); diff --git a/client/src/components/controls/TimelineCharts.vue b/client/src/components/controls/TimelineCharts.vue index 82e7d02c..32c95da4 100644 --- a/client/src/components/controls/TimelineCharts.vue +++ b/client/src/components/controls/TimelineCharts.vue @@ -12,9 +12,19 @@ import { } from 'vue-media-annotator/components'; import { LineChartData } from 'vue-media-annotator/use/useLineChart'; import { TimelineDisplay } from 'vue-media-annotator/ConfigurationManager'; +import TimelineKeySection from './TimelineKeySection.vue'; import { useAttributesFilters, useConfiguration, useSelectedTrackId, useTimelineFilters, } from '../../provides'; +import { + buildFilteredTimelineList, + computeKeyPanelWidth, + getSectionContentHeight, + getTimelineChartAreaInsets, + isDetectionsTimeline, + KeyPanelWidthOptions, + shouldHideTimelineSectionTitle, +} from './timelineLayout'; export default defineComponent({ components: { @@ -23,6 +33,7 @@ export default defineComponent({ Timeline, AttributeSwimlaneGraph, TooltipBtn, + TimelineKeySection, }, props: { dismissedButtons: { @@ -30,7 +41,7 @@ export default defineComponent({ required: true, }, lineChartData: { - type: Array as PropType, + type: Array as PropType, required: true, }, eventChartData: { @@ -49,6 +60,10 @@ export default defineComponent({ type: Boolean, default: false, }, + showKey: { + type: Boolean, + default: false, + }, startFrame: { type: Number, required: true, @@ -74,15 +89,14 @@ export default defineComponent({ required: true, }, }, - setup(props) { + emits: ['select-track', 'select-group', 'dismiss', 'chart-area-insets'], + setup(props, { emit }) { const configMan = useConfiguration(); - const enabledKey = ref(true); const { timelineEnabled, attributeTimelineData, - swimlaneEnabled, swimlaneDisplaySettings, attributeSwimlaneData, + swimlaneEnabled, swimlaneDisplaySettings, attributeSwimlaneData, swimlaneGraphs, } = useAttributesFilters(); const { eventChartDataMap: timelineFilterMap, enabledTimelines: enabledFilterTimelines } = useTimelineFilters(); - // Format the Attribute data if it is available const selectedTrackIdRef = useSelectedTrackId(); const enabledTimelines = computed(() => { @@ -122,18 +136,15 @@ export default defineComponent({ nudge.value += 1; }); const timelineList = computed(() => { - const list: TimelineDisplay[] = []; - const activeConfig = configMan.getActiveTimelineConfig(); - if (nudge.value !== null && activeConfig?.timelines) { - activeConfig.timelines.forEach((item) => { - if (checkTimelineEnabled(item)) { - list.push(item); - } - }); + // nudge forces recompute when timeline config changes + if (nudge.value === null) { + return []; } - list.sort((a, b) => (a.order - b.order)); - const updatedList = list.filter((item) => !props.dismissedButtons.includes(item.name)); - return updatedList; + return buildFilteredTimelineList( + configMan, + checkTimelineEnabled, + props.dismissedButtons, + ); }); const attributeDataTimeline = computed(() => { const data: { @@ -157,23 +168,109 @@ export default defineComponent({ return data; }); - const getTimelineHeight = (timeline: TimelineDisplay) => { - if (timeline.maxHeight === -1 && timelineList.value.length) { - // We really want the total height minus the defined heights - let definedHeights = 0; - let count = 1; - timelineList.value.forEach((item) => { - if (item.name !== timeline.name && item.maxHeight !== -1) { - definedHeights += item.maxHeight; - } else if (item.name !== timeline.name) { - count += 1; - } - }); - return ((props.clientHeight - definedHeights) / count) - 20; + const swimlaneGraphSettings = computed(() => { + const settings: Record> = {}; + Object.entries(swimlaneGraphs.value).forEach(([key, graph]) => { + settings[key] = graph.settings || {}; + }); + return settings; + }); + + const swimlaneScrollOffsets = ref>({}); + + const keyPanelWidthOptions = computed(() => { + const flatMap = configMan.getFlatUISettingMap(); + const options: KeyPanelWidthOptions = {}; + if (typeof flatMap.UILegendKeyMinWidth === 'number') { + options.minWidth = flatMap.UILegendKeyMinWidth; + } + if (typeof flatMap.UILegendKeyMaxWidth === 'number') { + options.maxWidth = flatMap.UILegendKeyMaxWidth; + } + return options; + }); + + const keyPanelWidth = computed(() => { + if (!props.showKey) { + return 0; } - return timeline.maxHeight - 20; + return computeKeyPanelWidth( + timelineList.value, + attributeSwimlaneData.value, + swimlaneDisplaySettings.value, + swimlaneGraphSettings.value, + props.currentView, + keyPanelWidthOptions.value, + ); + }); + + const chartAreaInsets = computed(() => getTimelineChartAreaInsets( + props.showKey, + keyPanelWidth.value, + timelineList.value.length > 0, + )); + + watch(chartAreaInsets, (insets) => { + emit('chart-area-insets', insets); + }, { immediate: true, deep: true }); + + watch(() => props.showKey, () => { + emit('chart-area-insets', chartAreaInsets.value); + }); + + const chartClientWidth = computed(() => ( + props.showKey ? Math.max(0, props.clientWidth - keyPanelWidth.value) : props.clientWidth + )); + + const getTimelineHeight = (timeline: TimelineDisplay) => getSectionContentHeight( + timeline, + timelineList.value, + props.clientHeight, + shouldHideTimelineSectionTitle(timeline, props.showKey, swimlaneDisplaySettings.value), + ); + + const shouldShowTimelineHeader = (timeline: TimelineDisplay) => { + if (!checkTimelineEnabled(timeline)) { + return false; + } + if (shouldHideTimelineSectionTitle(timeline, props.showKey, swimlaneDisplaySettings.value)) { + return false; + } + return true; + }; + + const onSwimlaneScroll = (timelineName: string, scrollTop: number) => { + swimlaneScrollOffsets.value = { + ...swimlaneScrollOffsets.value, + [timelineName]: scrollTop, + }; }; + const legacyKeyKind = computed((): 'detections' | 'events' | 'groups' | 'graph' | 'swimlane' | 'filter' | '' => { + if (timelineList.value.length) { + return ''; + } + if (props.currentView === 'Detections') { + return 'detections'; + } + if (props.currentView === 'Events') { + return 'events'; + } + if (props.currentView === 'Groups') { + return 'groups'; + } + if (enabledTimelines.value.includes(props.currentView)) { + return 'graph'; + } + if (enabledSwimlanes.value.includes(props.currentView)) { + return 'swimlane'; + } + if (enabledFilterTimelines.value.some((item) => item.name === props.currentView)) { + return 'filter'; + } + return ''; + }); + return { attributeDataTimeline, swimlaneEnabled, @@ -181,78 +278,239 @@ export default defineComponent({ swimlaneDisplaySettings, enabledSwimlanes, enabledTimelines, - // Timeline Ref - enabledKey, enabledFilterTimelines, timelineFilterMap, selectedTrackIdRef, timelineList, getTimelineHeight, checkTimelineEnabled, + shouldShowTimelineHeader, + keyPanelWidth, + chartClientWidth, + swimlaneScrollOffsets, + onSwimlaneScroll, + legacyKeyKind, + isDetectionsTimeline, }; }, }); diff --git a/client/src/components/controls/TimelineKey.vue b/client/src/components/controls/TimelineKey.vue index aa527a7a..6a022812 100644 --- a/client/src/components/controls/TimelineKey.vue +++ b/client/src/components/controls/TimelineKey.vue @@ -6,14 +6,22 @@ import { } from 'vue'; import { TimelineDisplay } from 'vue-media-annotator/ConfigurationManager'; import { - useAttributesFilters, useConfiguration, useSelectedTrackId, - useTimelineFilters, + useAttributesFilters, useConfiguration, useTimelineFilters, } from 'vue-media-annotator/provides'; -import { SwimlaneAttribute } from 'vue-media-annotator/use/AttributeTypes'; -import { EventChartData } from 'vue-media-annotator/use/useEventChart'; +import { LineChartData } from 'vue-media-annotator/use/useLineChart'; +import TimelineKeySection from './TimelineKeySection.vue'; +import { + buildFilteredTimelineList, + getSectionContentHeight, + shouldHideTimelineSectionTitle, +} from './timelineLayout'; +import { EventChartDataBundle } from './useTimelineKeyData'; export default defineComponent({ name: 'TimelineKey', + components: { + TimelineKeySection, + }, props: { dismissedButtons: { type: Array as PropType, @@ -23,36 +31,51 @@ export default defineComponent({ type: String, default: '', }, - hoveredButtons: { - type: Array as PropType, - required: false, - }, clientHeight: { type: Number, default: 0, }, - clientTop: { + keyPanelWidth: { type: Number, - default: 0, + default: 100, }, - clientWidth: { + offset: { type: Number, default: 0, }, - offset: { + startFrame: { type: Number, default: 0, }, + endFrame: { + type: Number, + default: Number.MAX_SAFE_INTEGER, + }, + lineChartData: { + type: Array as PropType, + default: () => [], + }, + eventChartData: { + type: Object as PropType, + default: () => ({ values: [] }), + }, + groupChartData: { + type: Object as PropType, + default: () => ({ values: [] }), + }, }, setup(props) { const configMan = useConfiguration(); - const { - timelineEnabled, attributeTimelineData, - swimlaneEnabled, attributeSwimlaneData, - } = useAttributesFilters(); - const { eventChartDataMap: timelineFilterMap, enabledTimelines: enabledFilterTimelines } = useTimelineFilters(); - // Format the Attribute data if it is available - const selectedTrackIdRef = useSelectedTrackId(); + const { timelineEnabled, swimlaneEnabled, swimlaneDisplaySettings } = useAttributesFilters(); + const { enabledTimelines: enabledFilterTimelines } = useTimelineFilters(); + const showKey = true; + const keyRef: Ref = ref(null); + + watch(() => props.offset, () => { + if (keyRef.value !== null) { + keyRef.value.scrollTop = props.offset; + } + }); const enabledTimelines = computed(() => { const list: string[] = []; @@ -64,76 +87,6 @@ export default defineComponent({ return list; }); - const baseMap: Record = { - Event: 'event', - Detection: 'detections', - Group: 'event', - }; - - const timelineList = computed(() => { - const list: TimelineDisplay[] = []; - const activeConfig = configMan.getActiveTimelineConfig(); - if (activeConfig?.timelines) { - activeConfig.timelines.forEach((item) => { - list.push(item); - }); - } else if (props.currentView !== '') { - let type: TimelineDisplay['type'] = 'event'; - if (baseMap[props.currentView]) { - type = baseMap[props.currentView]; - } - if (timelineEnabled.value[props.currentView]) { - type = 'graph'; - } - if (swimlaneEnabled.value[props.currentView]) { - type = 'swimlane'; - } - if (timelineFilterMap.value[props.currentView]) { - type = 'filter'; - } - const currentTimeline = { - maxHeight: props.clientHeight, - order: 0, - name: props.currentView, - dismissable: false, - type, - }; - - list.push(currentTimeline); - } - list.sort((a, b) => (a.order - b.order)); - const updatedList = list.filter((item) => !props.dismissedButtons.includes(item.name)); - return updatedList; - }); - const uniqueKeys = (data: SwimlaneAttribute['data'], order?: Record) => { - const vals: {value: string; color: string; order?: number}[] = []; - data.forEach((item) => { - if (vals.findIndex((findItem) => findItem.value === item.value) === -1) { - if (!order || (order && order[item.value.toString()] !== undefined)) { - vals.push({ value: item.value.toString(), color: item.color || 'white', order: order && order[item.value.toString()] }); - } - } - }); - if (order) { - vals.sort((a, b) => { - if (a.order !== undefined && b.order !== undefined) { - return a.order - b.order; - } - return 0; - }); - } - return vals; - }; - - const uniqueFilterItems = (data: EventChartData[]) => { - const vals: {value: string; color: string}[] = []; - data.forEach((item) => { - if (vals.findIndex((findItem) => findItem.value === item.type) === -1) { - vals.push({ value: item.type.toString(), color: item.color || 'white' }); - } - }); - return vals; - }; const enabledSwimlanes = computed(() => { const list: string[] = []; Object.entries(swimlaneEnabled.value).forEach(([key, enabled]) => { @@ -144,63 +97,58 @@ export default defineComponent({ return list; }); - const keyRef: Ref = ref(null); - watch(() => props.offset, () => { - if (keyRef.value !== null) { - keyRef.value.scrollTop = props.offset; - } - }); - const getTimelineHeight = (timeline: TimelineDisplay) => { - if (timeline.maxHeight === -1 && timelineList.value.length) { - return (props.clientHeight / timelineList.value.length) - 20; + const checkTimelineEnabled = (timeline: TimelineDisplay) => { + if (timeline.type === 'swimlane') { + return enabledSwimlanes.value.includes(timeline.name); + } if (timeline.type === 'graph') { + return enabledTimelines.value.includes(timeline.name); } - return timeline.maxHeight - 20; + return true; }; - const getTimelineByName = (name: string, type: TimelineDisplay['type']) => { - if (type === 'swimlane') { - if (attributeSwimlaneData.value[name] !== undefined) { - return attributeSwimlaneData.value[name]; - } + const timelineList = computed(() => buildFilteredTimelineList( + configMan, + checkTimelineEnabled, + props.dismissedButtons, + )); + + const getTimelineHeight = (timeline: TimelineDisplay) => getSectionContentHeight( + timeline, + timelineList.value, + props.clientHeight, + shouldHideTimelineSectionTitle(timeline, showKey, swimlaneDisplaySettings.value), + ); + + const legacyKeyKind = computed((): 'detections' | 'events' | 'groups' | 'graph' | 'swimlane' | 'filter' | '' => { + if (timelineList.value.length) { + return ''; } - if (type === 'graph') { - if (attributeTimelineData.value[name] !== undefined) { - return attributeTimelineData.value[name]; - } + if (props.currentView === 'Detections') { + return 'detections'; } - if (type === 'filter') { - if (timelineFilterMap.value[name] !== undefined) { - return timelineFilterMap.value[name]; - } + if (props.currentView === 'Events') { + return 'events'; } - return false; - }; - - const getMinMax = (data: SwimlaneAttribute['data']) => { - let min = Infinity; - let max = -Infinity; - data.forEach((item) => { - min = Math.min(min, item.value as number); - max = Math.max(max, item.value as number); - }); - return `Range from ${min.toFixed(2)} to ${max.toFixed(2)}`; - }; + if (props.currentView === 'Groups') { + return 'groups'; + } + if (enabledTimelines.value.includes(props.currentView)) { + return 'graph'; + } + if (enabledSwimlanes.value.includes(props.currentView)) { + return 'swimlane'; + } + if (enabledFilterTimelines.value.some((item) => item.name === props.currentView)) { + return 'filter'; + } + return ''; + }); return { - uniqueKeys, - getMinMax, - uniqueFilterItems, - getTimelineByName, keyRef, - attributeSwimlaneData, - attributeTimelineData, - enabledTimelines, - enabledFilterTimelines, - enabledSwimlanes, - timelineFilterMap, timelineList, getTimelineHeight, - selectedTrackIdRef, + legacyKeyKind, }; }, }); @@ -209,191 +157,72 @@ export default defineComponent({ diff --git a/client/src/components/controls/TimelineKeySection.vue b/client/src/components/controls/TimelineKeySection.vue new file mode 100644 index 00000000..64417e78 --- /dev/null +++ b/client/src/components/controls/TimelineKeySection.vue @@ -0,0 +1,837 @@ + + + + + + diff --git a/client/src/components/controls/migrateLegendSettings.ts b/client/src/components/controls/migrateLegendSettings.ts new file mode 100644 index 00000000..0c3b62af --- /dev/null +++ b/client/src/components/controls/migrateLegendSettings.ts @@ -0,0 +1,56 @@ +import type { UISettings } from '../../ConfigurationManager'; + +type LegendControlsSettings = { + UILegendControls?: boolean; + UILegendForceOpen?: boolean; + UILegendHideToggle?: boolean; +}; + +const LEGEND_SETTING_KEYS: (keyof LegendControlsSettings)[] = [ + 'UILegendControls', + 'UILegendForceOpen', + 'UILegendHideToggle', +]; + +function isSettingsObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Move legend UI settings from UIControls into UITimeline when loading legacy configs. */ +export default function migrateLegendSettingsUISettings(uiSettings?: UISettings): UISettings | undefined { + if (!uiSettings) { + return uiSettings; + } + + const controls = uiSettings.UIControls; + if (!isSettingsObject(controls)) { + return uiSettings; + } + + const legacyControls = controls as LegendControlsSettings & Record; + const hasLegacy = LEGEND_SETTING_KEYS.some((key) => legacyControls[key] !== undefined); + if (!hasLegacy) { + return uiSettings; + } + + const timeline = isSettingsObject(uiSettings.UITimeline) + ? { ...uiSettings.UITimeline } + : {}; + + LEGEND_SETTING_KEYS.forEach((key) => { + if (legacyControls[key] !== undefined && timeline[key] === undefined) { + timeline[key] = legacyControls[key]; + } + }); + + const restControls = { ...legacyControls }; + LEGEND_SETTING_KEYS.forEach((key) => { + delete restControls[key]; + }); + + return { + ...uiSettings, + UITimeline: timeline, + UIControls: restControls, + }; +} diff --git a/client/src/components/controls/timelineLayout.ts b/client/src/components/controls/timelineLayout.ts new file mode 100644 index 00000000..692037f1 --- /dev/null +++ b/client/src/components/controls/timelineLayout.ts @@ -0,0 +1,240 @@ +import { TimelineDisplay } from 'vue-media-annotator/ConfigurationManager'; +import { SwimlaneGraph, SwimlaneGraphSettings } from 'vue-media-annotator/use/AttributeTypes'; + +export const SWIMLANE_ROW_HEIGHT = 30; +export const SWIMLANE_BAR_HEIGHT = 20; +export const SWIMLANE_BAR_TOP_OFFSET = 3; +export const TIMELINE_SECTION_HEADER_HEIGHT = 20; +export const TIMELINE_SECTION_GAP = 4; +export const TIMELINE_CHART_BORDER = 1; +/** EventChart.vue applies vertical margin; AttributeSwimlaneGraph does not */ +export const EVENT_CHART_MARGIN_Y = 5; +export const EVENT_CHART_ROW_PITCH = 15; +export const EVENT_CHART_BOTTOM_RESERVE = 10; + +export function getChartBorderTopOffset(): number { + return TIMELINE_CHART_BORDER; +} + +/** Top offset of drawable chart content relative to the timeline row */ +export function getEventChartContentTopOffset(): number { + return TIMELINE_CHART_BORDER + EVENT_CHART_MARGIN_Y + SWIMLANE_BAR_TOP_OFFSET; +} + +export function getSwimlaneChartContentTopOffset(): number { + return TIMELINE_CHART_BORDER + SWIMLANE_BAR_TOP_OFFSET; +} + +/** Height of the inner chart area inside a bordered timeline row */ +export function getChartInnerHeight(sectionHeight: number): number { + return Math.max(0, sectionHeight - (TIMELINE_CHART_BORDER * 2)); +} + +/** Inner scroll/draw area used by EventChart and AttributeSwimlaneGraph roots */ +export function getEventChartDrawHeight(sectionHeight: number): number { + return Math.max(0, sectionHeight - EVENT_CHART_BOTTOM_RESERVE - (TIMELINE_CHART_BORDER * 2)); +} + +export const KEY_PANEL_MIN_WIDTH = 80; +export const KEY_PANEL_MAX_WIDTH = 150; +export const KEY_PANEL_CHAR_WIDTH = 8; +export const KEY_PANEL_PADDING = 20; + +export interface KeyPanelWidthOptions { + minWidth?: number; + maxWidth?: number; +} + +export function resolveKeyPanelWidthBounds(options?: KeyPanelWidthOptions): { + minWidth: number; + maxWidth: number; +} { + const minWidth = options?.minWidth ?? KEY_PANEL_MIN_WIDTH; + const maxWidth = options?.maxWidth ?? KEY_PANEL_MAX_WIDTH; + return { + minWidth: Math.min(minWidth, maxWidth), + maxWidth: Math.max(minWidth, maxWidth), + }; +} + +export function getTotalSectionGap(timelineList: TimelineDisplay[]): number { + return Math.max(0, timelineList.length - 1) * TIMELINE_SECTION_GAP; +} + +export function getSectionContentHeight( + timeline: TimelineDisplay, + timelineList: TimelineDisplay[], + clientHeight: number, + hideSectionTitle = false, +): number { + const headerDeduction = hideSectionTitle ? 0 : TIMELINE_SECTION_HEADER_HEIGHT; + const totalGap = getTotalSectionGap(timelineList); + const availableHeight = clientHeight - totalGap; + if (timeline.maxHeight === -1 && timelineList.length) { + let definedHeights = 0; + let count = 1; + timelineList.forEach((item) => { + if (item.name !== timeline.name && item.maxHeight !== -1) { + definedHeights += item.maxHeight; + } else if (item.name !== timeline.name) { + count += 1; + } + }); + return ((availableHeight - definedHeights) / count) - headerDeduction; + } + return timeline.maxHeight - headerDeduction; +} + +export function buildFilteredTimelineList( + configMan: { getActiveTimelineConfig: () => { timelines?: TimelineDisplay[] } | null }, + checkTimelineEnabled: (timeline: TimelineDisplay) => boolean, + dismissedButtons: string[], +): TimelineDisplay[] { + const list: TimelineDisplay[] = []; + const activeConfig = configMan.getActiveTimelineConfig(); + if (activeConfig?.timelines?.length) { + activeConfig.timelines.forEach((item) => { + if (checkTimelineEnabled(item)) { + list.push(item); + } + }); + list.sort((a, b) => (a.order - b.order)); + return list.filter((item) => !dismissedButtons.includes(item.name)); + } + return []; +} + +export function isDetectionsTimeline(timeline: TimelineDisplay): boolean { + return timeline.type === 'detections' || timeline.name === 'Detections'; +} + +export function isCustomTimelineType(timeline: TimelineDisplay): boolean { + return timeline.type === 'swimlane' || timeline.type === 'graph'; +} + +export function shouldHideTimelineSectionTitle( + timeline: TimelineDisplay, + showKey: boolean, + swimlaneDisplaySettings: Record, +): boolean { + if (showKey) { + return true; + } + if (timeline.type === 'swimlane') { + return swimlaneDisplaySettings[timeline.name]?.hideTitle === true; + } + return false; +} + +export function getSwimlaneChartHeight(sectionHeight: number): number { + return Math.max(0, sectionHeight - 10); +} + +/** @deprecated use shouldHideTimelineSectionTitle */ +export function shouldHideSwimlaneSectionTitle( + timeline: TimelineDisplay, + swimlaneDisplaySettings: Record, +): boolean { + return shouldHideTimelineSectionTitle(timeline, false, swimlaneDisplaySettings); +} + +export function shouldShowKeySectionHeader( + timelineName: string, + swimlaneData: Record | false, + displaySettings?: SwimlaneGraph['displaySettings'], +): boolean { + if (displaySettings?.hideKeyTitle) { + return false; + } + if (!swimlaneData || typeof swimlaneData !== 'object') { + return false; + } + const attrs = Object.keys(swimlaneData); + if (attrs.length === 1 && attrs[0] === timelineName) { + return false; + } + return attrs.length > 1; +} + +/** @deprecated use shouldShowKeySectionHeader */ +export function shouldShowKeyTitle( + timelineName: string, + swimlaneData: Record | false, + displaySettings?: SwimlaneGraph['displaySettings'], +): boolean { + return shouldShowKeySectionHeader(timelineName, swimlaneData, displaySettings); +} + +export function shouldShowAttributeLabel( + subKey: string, + timelineName: string, + attrs: string[], + displaySettings?: SwimlaneGraph['displaySettings'], + settings?: Record, +): boolean { + if (displaySettings?.hideKeyAttributeLabels) { + return false; + } + if (settings?.[subKey]?.displayName === false) { + return false; + } + if (attrs.length === 1 && subKey === timelineName && !displaySettings?.hideKeyTitle) { + return false; + } + return true; +} + +export function getTimelineChartAreaInsets( + showKey: boolean, + keyPanelWidth: number, + useChartBorder: boolean, +): { leftInset: number; rightInset: number } { + const leftInset = (showKey ? keyPanelWidth : 0) + + (useChartBorder ? TIMELINE_CHART_BORDER : 0); + const rightInset = useChartBorder ? TIMELINE_CHART_BORDER : 0; + return { leftInset, rightInset }; +} + +export function computeKeyPanelWidth( + timelineList: TimelineDisplay[], + attributeSwimlaneData: Record>, + swimlaneDisplaySettings: Record, + swimlaneGraphSettings: Record>, + legacyLabel?: string, + options?: KeyPanelWidthOptions, +): number { + const { minWidth, maxWidth } = resolveKeyPanelWidthBounds(options); + let maxLabelLength = 0; + if (!timelineList.length && legacyLabel) { + maxLabelLength = legacyLabel.length; + } + timelineList.forEach((timeline) => { + const displaySettings = swimlaneDisplaySettings[timeline.name]; + const swimlaneData = timeline.type === 'swimlane' + ? attributeSwimlaneData[timeline.name] + : undefined; + if (timeline.type === 'graph' || isDetectionsTimeline(timeline)) { + maxLabelLength = Math.max(maxLabelLength, timeline.name.length); + } else if (swimlaneData && shouldShowKeySectionHeader(timeline.name, swimlaneData, displaySettings)) { + maxLabelLength = Math.max(maxLabelLength, timeline.name.length); + } + if (swimlaneData) { + const attrs = Object.keys(swimlaneData); + const settings = swimlaneGraphSettings[timeline.name]; + attrs.forEach((subKey) => { + if (shouldShowAttributeLabel(subKey, timeline.name, attrs, displaySettings, settings)) { + maxLabelLength = Math.max(maxLabelLength, subKey.length); + } + }); + } else { + maxLabelLength = Math.max(maxLabelLength, timeline.name.length); + } + }); + if (maxLabelLength === 0) { + return minWidth; + } + return Math.min( + maxWidth, + Math.max(minWidth, maxLabelLength * KEY_PANEL_CHAR_WIDTH + KEY_PANEL_PADDING), + ); +} diff --git a/client/src/components/controls/timelineTooltip.ts b/client/src/components/controls/timelineTooltip.ts new file mode 100644 index 00000000..c17a3228 --- /dev/null +++ b/client/src/components/controls/timelineTooltip.ts @@ -0,0 +1,27 @@ +export const TIMELINE_TOOLTIP_Z_INDEX = 2147483646; +export const TIMELINE_TOOLTIP_GAP_PX = 10; + +export interface TimelineTooltipPosition { + x: number; + y: number; +} + +export function createTimelineTooltipPosition(event: MouseEvent): TimelineTooltipPosition { + return { + x: event.clientX, + y: event.clientY, + }; +} + +export function timelineTooltipStyle(position: TimelineTooltipPosition): Record { + return { + position: 'fixed', + left: `${position.x}px`, + top: `${position.y}px`, + transform: `translate(-50%, calc(-100% - ${TIMELINE_TOOLTIP_GAP_PX}px))`, + zIndex: TIMELINE_TOOLTIP_Z_INDEX, + pointerEvents: 'none', + }; +} + +export const TIMELINE_TOOLTIP_BASE_CLASS = 'timeline-chart-tooltip'; diff --git a/client/src/components/controls/useTimelineKeyData.ts b/client/src/components/controls/useTimelineKeyData.ts new file mode 100644 index 00000000..e4e9f75f --- /dev/null +++ b/client/src/components/controls/useTimelineKeyData.ts @@ -0,0 +1,232 @@ +import { computed, unref, Ref } from 'vue'; +import { TimelineDisplay } from 'vue-media-annotator/ConfigurationManager'; +import { + useAttributesFilters, useSelectedTrackId, useTimelineFilters, +} from 'vue-media-annotator/provides'; +import { SwimlaneAttribute, SwimlaneGraphSettings } from 'vue-media-annotator/use/AttributeTypes'; +import { EventChartData } from 'vue-media-annotator/use/useEventChart'; +import { LineChartData } from 'vue-media-annotator/use/useLineChart'; +import { SWIMLANE_BAR_HEIGHT } from './timelineLayout'; + +export interface EventChartDataBundle { + muted?: boolean; + values?: EventChartData[]; +} + +export interface TimelineKeyDataOptions { + lineChartData: Ref | LineChartData[]; + eventChartData: Ref | EventChartDataBundle; + groupChartData: Ref | EventChartDataBundle; + startFrame: Ref | number; + endFrame: Ref | number; +} + +export function uniqueKeys(data: SwimlaneAttribute['data'], order?: Record) { + const vals: { value: string; color: string; order?: number }[] = []; + data.forEach((item) => { + if (vals.findIndex((findItem) => findItem.value === item.value) === -1) { + if (!order || order[item.value.toString()] !== undefined) { + vals.push({ + value: item.value.toString(), + color: item.color || 'white', + order: order && order[item.value.toString()], + }); + } + } + }); + if (order) { + vals.sort((a, b) => { + if (a.order !== undefined && b.order !== undefined) { + return a.order - b.order; + } + return 0; + }); + } + return vals; +} + +export function uniqueFilterItems(data: EventChartData[]) { + const vals: { value: string; color: string }[] = []; + data.forEach((item) => { + if (vals.findIndex((findItem) => findItem.value === item.type) === -1) { + vals.push({ value: item.type.toString(), color: item.color || 'white' }); + } + }); + return vals; +} + +export function getMinMax(data: SwimlaneAttribute['data']) { + let min = Infinity; + let max = -Infinity; + data.forEach((item) => { + min = Math.min(min, item.value as number); + max = Math.max(max, item.value as number); + }); + return `Range from ${min.toFixed(2)} to ${max.toFixed(2)}`; +} + +export function isEventTimeline(timeline: TimelineDisplay) { + return timeline.type === 'event' + || timeline.name === 'Events' + || timeline.name === 'events' + || timeline.name === 'Groups'; +} + +export function frameRangesIntersect( + startA: number, + endA: number, + startB: number, + endB: number, +) { + const minEnd = Math.min(endA, endB); + const maxStart = Math.max(startA, startB); + return minEnd >= maxStart; +} + +export function getKeyRowStyle(color: string) { + return { + color, + border: `2px solid ${color}`, + height: `${SWIMLANE_BAR_HEIGHT}px`, + }; +} + +export function useTimelineKeyData(options: TimelineKeyDataOptions) { + const { + attributeTimelineData, + swimlaneDisplaySettings, swimlaneGraphs, attributeSwimlaneData, + } = useAttributesFilters(); + const { eventChartDataMap: timelineFilterMap } = useTimelineFilters(); + const selectedTrackIdRef = useSelectedTrackId(); + + const lineChartData = computed(() => unref(options.lineChartData)); + const eventChartData = computed(() => unref(options.eventChartData)); + const groupChartData = computed(() => unref(options.groupChartData)); + const startFrame = computed(() => unref(options.startFrame)); + const endFrame = computed(() => unref(options.endFrame)); + + const getTimelineByName = (name: string, type: TimelineDisplay['type']) => { + if (type === 'swimlane' && attributeSwimlaneData.value[name] !== undefined) { + return attributeSwimlaneData.value[name]; + } + if (type === 'graph' && attributeTimelineData.value[name] !== undefined) { + return attributeTimelineData.value[name]; + } + if (type === 'filter' && timelineFilterMap.value[name] !== undefined) { + return timelineFilterMap.value[name]; + } + return false; + }; + + const getSwimlaneSettings = (timelineName: string): Record => ( + swimlaneGraphs.value[timelineName]?.settings || {} + ); + + const getSwimlaneRowLabel = ( + subKey: string, + timelineName: string, + swimlaneData: Record, + ) => { + const attrs = Object.keys(swimlaneData); + const displaySettings = swimlaneDisplaySettings.value[timelineName]; + const settings = getSwimlaneSettings(timelineName); + if (displaySettings?.hideKeyAttributeLabels) { + return ''; + } + if (settings?.[subKey]?.displayName === false) { + return ''; + } + if (attrs.length === 1) { + if (!displaySettings?.hideKeyTitle && timelineName !== subKey) { + return timelineName; + } + return subKey; + } + return subKey; + }; + + const getSwimlaneTooltipTitle = ( + subKey: string, + timelineName: string, + swimlaneData: Record, + ) => { + const label = getSwimlaneRowLabel(subKey, timelineName, swimlaneData); + return label || subKey; + }; + + const getGraphAttributeItems = (timelineName: string) => { + const graphData = getTimelineByName(timelineName, 'graph'); + if (!graphData || typeof graphData !== 'object' || !('data' in graphData)) { + return [] as { name: string; color: string }[]; + } + return graphData.data.map((item: { data: LineChartData }) => ({ + name: item.data.name, + color: item.data.color, + })); + }; + + const getDetectionTypeItems = computed(() => ( + lineChartData.value + .filter((item) => item.name !== 'total') + .map((item) => ({ name: item.name, color: item.color })) + )); + + const getEventChartValues = (timeline: TimelineDisplay) => { + if (timeline.name === 'Groups') { + return groupChartData.value?.values || []; + } + return eventChartData.value?.values || []; + }; + + const getFilterTypeItems = (timelineName: string) => { + const data = getTimelineByName(timelineName, 'filter'); + if (!data || typeof data !== 'object' || !('values' in data)) { + return [] as { value: string; color: string }[]; + } + return uniqueFilterItems(data.values as EventChartData[]); + }; + + const getSwimlaneEntries = (timelineName: string) => { + const data = getTimelineByName(timelineName, 'swimlane'); + if (!data || typeof data !== 'object') { + return [] as [string, SwimlaneAttribute][]; + } + return Object.entries(data).filter(([, bar]) => ( + frameRangesIntersect(startFrame.value, endFrame.value, bar.start, bar.end) + )); + }; + + const getLegacySwimlaneEntries = (legacyView: string) => { + const data = legacyView ? attributeSwimlaneData.value[legacyView] : undefined; + if (!data) { + return [] as [string, SwimlaneAttribute][]; + } + return Object.entries(data).filter(([, bar]) => ( + frameRangesIntersect(startFrame.value, endFrame.value, bar.start, bar.end) + )); + }; + + const legacySwimlaneData = (legacyView: string) => ( + legacyView ? attributeSwimlaneData.value[legacyView] : undefined + ); + + return { + uniqueKeys, + uniqueFilterItems, + getMinMax, + getTimelineByName, + selectedTrackIdRef, + attributeSwimlaneData, + getSwimlaneRowLabel, + getSwimlaneTooltipTitle, + getGraphAttributeItems, + getDetectionTypeItems, + isEventTimeline, + getEventChartValues, + getFilterTypeItems, + getKeyRowStyle, + getSwimlaneEntries, + getLegacySwimlaneEntries, + legacySwimlaneData, + }; +} diff --git a/client/src/use/AttributeTypes.ts b/client/src/use/AttributeTypes.ts index be735385..3bd8af5e 100644 --- a/client/src/use/AttributeTypes.ts +++ b/client/src/use/AttributeTypes.ts @@ -14,7 +14,9 @@ export interface SwimlaneGraph { highlightSegments?: boolean; editSegments?: boolean; minSegmentSize?: number; - + hideTitle?: boolean; + hideKeyTitle?: boolean; + hideKeyAttributeLabels?: boolean; }; settings?: Record; } diff --git a/docs/UI-AttributeSwimlanes.md b/docs/UI-AttributeSwimlanes.md index 3a0a8b4e..7976bbf4 100644 --- a/docs/UI-AttributeSwimlanes.md +++ b/docs/UI-AttributeSwimlanes.md @@ -31,6 +31,18 @@ Other display options: * **Display Set Value Indicators** — show diamond markers on frames where values are set. * **Display Swimlane Tooltip** — show attribute name and value when hovering over the swimlane. +#### Title & Key + +Expand the **Title & Key** section in the swimlane settings dialog to control section titles and legend labels: + +* **Hide Swimlane Title** — removes the section title above the chart to save vertical space. +* **Hide Key Title** — hides the swimlane graph name in the legend column. +* **Hide Attribute Labels in Key** — hides attribute names in the legend column (colored borders remain). + +Under **Key Label Settings**, each applied attribute has a **Show '*name*' in key** checkbox to control whether that attribute name appears in the legend column. + +When a swimlane graph has a single attribute with the same name as the graph, the legend automatically shows that name only once. + ### Render Mode Each swimlane has a **Render Mode** that controls how value colors are drawn across frames. Use the help icon next to **Render Mode** in the swimlane settings dialog for a quick summary. @@ -62,5 +74,15 @@ For text attributes in swimlanes, color resolution follows the **[Attribute Valu ![Swimlane Key](images/AttributeTimeline/SwimlaneKey.png) -When viewing the swimlane graph a floating 'key' shows up on the left hand side of the graph. This is used to determine which attribute is being graphed. -Hovering over the attribute name will show the colors associated with the attribute and the value. Hovering over any color in the swimlane will show the Attribute name and the value as well as the color for that value. +When viewing the swimlane graph, use the key icon in the timeline controls to show or hide the legend column on the left side of the timeline charts. The legend is rendered inline inside the timeline area (to the left of the chart data), with rows aligned to each swimlane attribute. + +When the legend is open, section titles above custom swimlane and attribute graph charts are hidden automatically; labels appear in the legend column instead. Detection timelines show a vertically centered title in the legend—hover to see track type colors. Multi-attribute number graphs show a centered title with attribute names and colors on hover. Swimlane row labels align directly with their corresponding swimlane bars. + +Hovering over an attribute label in the legend shows the colors associated with that attribute and its values. Hovering over any color in the swimlane shows the attribute name, value, and color. + +Legend visibility can also be configured globally in [UI Settings → Timeline](UI-Settings.md#timeline) or via [Configuration JSON](UI-ConfigurationJSON.md#uitimeline-settings): + +* **Legend Controls** — show or hide the key toggle button (default: on). +* **Legend Force Open** — always show the legend and prevent closing it. +* **Legend Hide Toggle** — hide the key toggle button (useful with Force Open). +* **Key Min/Max Width** — constrain the legend column width (defaults: 80–150px, auto-sized from label text). diff --git a/docs/UI-AttributeTimeline.md b/docs/UI-AttributeTimeline.md index 2bff8359..4832c994 100644 --- a/docs/UI-AttributeTimeline.md +++ b/docs/UI-AttributeTimeline.md @@ -65,4 +65,10 @@ Besides setting the Y-Axis Range in the settings for the graph, the Y-Axis range ![Y-Axis Adjustment](images/AttributeTimeline/YAxisAdjust.png) -The Axis will update as you change the values and you can click Save to accept the new Axis range. NOTE: This will not change TimelineGraph Settings Y-Axis if they are set so reloading will not persist the new range. It is meant to adjust the range on the fly for viewing data. \ No newline at end of file +The Axis will update as you change the values and you can click Save to accept the new Axis range. NOTE: This will not change TimelineGraph Settings Y-Axis if they are set so reloading will not persist the new range. It is meant to adjust the range on the fly for viewing data. + +## Timeline Legend + +Attribute line graphs participate in the shared timeline legend column. When the legend is open, the graph title moves into the legend column—a centered title with attribute names and colors shown on hover for multi-attribute graphs. + +Toggle the legend with the key icon in the timeline control bar, or configure legend behavior under [UI Settings → Timeline](UI-Settings.md#timeline). See [Swimlane Key](UI-AttributeSwimlanes.md#swimlane-key) for full legend behavior across all timeline types. \ No newline at end of file diff --git a/docs/UI-ConfigurationJSON.md b/docs/UI-ConfigurationJSON.md index c04f0e8c..405711ea 100644 --- a/docs/UI-ConfigurationJSON.md +++ b/docs/UI-ConfigurationJSON.md @@ -85,7 +85,12 @@ Below is the configuration JSON for a view with all of the items turned off }, "UITimeline": { "UIDetections": false, - "UIEvents": false + "UIEvents": false, + "UILegendControls": false, + "UILegendForceOpen": true, + "UILegendHideToggle": true, + "UILegendKeyMinWidth": 100, + "UILegendKeyMaxWidth": 180 }, "UIInteractions": { "UISelection": false, @@ -93,4 +98,33 @@ Below is the configuration JSON for a view with all of the items turned off } } } -``` \ No newline at end of file +``` + +## UITimeline settings + +When `UITimeline` is expanded to an object, these keys control timeline visibility and the inline legend column: + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `UIDetections` | boolean | `true` | Show the Detections timeline view button. Set to `false` to hide. | +| `UIEvents` | boolean | `true` | Show the Events timeline view button. Set to `false` to hide. | +| `UILegendControls` | boolean | `true` | Show the legend/key toggle button in the timeline control bar. Set to `false` to hide. | +| `UILegendForceOpen` | boolean | `false` | When `true`, always show the legend and prevent closing it. | +| `UILegendHideToggle` | boolean | `false` | When `true`, hide the legend toggle button (often used with `UILegendForceOpen`). | +| `UILegendKeyMinWidth` | number | `80` | Minimum legend column width in pixels. | +| `UILegendKeyMaxWidth` | number | `150` | Maximum legend column width in pixels. | + +Example with legend forced open and a wider key column: + +```json +"UITimeline": { + "UILegendForceOpen": true, + "UILegendHideToggle": true, + "UILegendKeyMinWidth": 100, + "UILegendKeyMaxWidth": 180 +} +``` + +!!! note + + `UILegendControls`, `UILegendForceOpen`, and `UILegendHideToggle` previously lived under `UIControls`. Older configurations still work; the UI migrates those keys to `UITimeline` when Timeline settings are saved. \ No newline at end of file diff --git a/docs/UI-Settings.md b/docs/UI-Settings.md index 4a4d20cf..22abd7e0 100644 --- a/docs/UI-Settings.md +++ b/docs/UI-Settings.md @@ -65,5 +65,20 @@ Customize the interface on the playback controls to hide interfaces not needed. ## Timeline ![Timeline](images/Configuration/UISettings/TimelineSettings.png) +* **Detections Timeline** — show or hide the Detections timeline view button. +* **Events Timeline** — show or hide the Events timeline view button. +### Legend / Key + +These settings control the inline legend column rendered to the left of timeline charts. See [Swimlane Key](UI-AttributeSwimlanes.md#swimlane-key) for behavior across timeline types. + +* **Legend Controls** — show or hide the key toggle button in the timeline control bar (default: on). +* **Legend Force Open** — always show the legend and prevent users from closing it (default: off). +* **Legend Hide Toggle** — hide the key toggle button while the legend may still be shown (default: off; often used with Force Open). +* **Key Min Width (px)** — minimum legend column width (default: 80). +* **Key Max Width (px)** — maximum legend column width (default: 150). + +!!! note + + Legend settings were previously under Playback Controls. Saving Timeline settings in the UI migrates `UILegendControls`, `UILegendForceOpen`, and `UILegendHideToggle` from `UIControls` to `UITimeline` automatically. diff --git a/docs/UI-Timeline.md b/docs/UI-Timeline.md index a11f7d0f..4c16968e 100644 --- a/docs/UI-Timeline.md +++ b/docs/UI-Timeline.md @@ -18,6 +18,15 @@ The timeline provides a control bar and a few different temporal visualizations. * ==:material-lock-open:== will enable camera lock, which causes the annotation view to auto-zoom and pan to whatever annotation is currently selected. This is useful when reviewing the output of a pipeline. * ==:material-image-filter-center-focus:== or the ++r++ key will reset zoom/pan in the annotation view. * ==:material-contrast-box:== will open the image contrast adjustment panel. +* ==:material-key:== toggles the timeline legend/key column on the left side of chart data (when enabled in [UI Settings](UI-Settings.md#timeline)). + +## Timeline Legend + +The legend column is rendered inline inside the timeline chart area, aligned with each chart row. Use the key icon in the control bar to show or hide it, unless your configuration forces it open or hides the toggle. + +The legend applies to all timeline types: Detections, Events, attribute line graphs, and swimlane graphs. When the legend is open, section titles above custom charts are hidden automatically and labels appear in the legend column instead. + +See [Swimlane Key](UI-AttributeSwimlanes.md#swimlane-key) for legend behavior details and configuration options. ## Detection Count diff --git a/scripts/testing/createSwimlaneTesting.py b/scripts/testing/createSwimlaneTesting.py new file mode 100644 index 00000000..e159ad21 --- /dev/null +++ b/scripts/testing/createSwimlaneTesting.py @@ -0,0 +1,358 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "girder-client", +# ] +# /// +"""Create SwimlaneTesting dataset with a full-frame track and text swimlane attributes.""" + +from __future__ import annotations + +import json +import random +import sys +from pathlib import Path + +import girder_client + +HOST = "127.0.0.1" +PORT = 8010 +SCHEME = "http" +PARENT_FOLDER_ID = "63d3ca449e23cf44fa9cb1e0" # Public +DATASET_NAME = "SwimlaneTesting" +VIDEO_SOURCE = Path("/home/local/KHQ/bryon.lewis/Downloads/t.2x.mp4") +UPLOAD_FILENAME = "tx2.mp4" +TRACK_ID = 1 +TRACK_TYPE = "object" + +TEXT_ATTRIBUTES = [ + { + "name": "Activity", + "values": ["idle", "walking", "running"], + "color": "#2196F3", + "valueColors": {"idle": "#9E9E9E", "walking": "#FFC107", "running": "#F44336"}, + }, + { + "name": "Status", + "values": ["clear", "warning", "alert"], + "color": "#4CAF50", + "valueColors": {"clear": "#4CAF50", "warning": "#FF9800", "alert": "#E91E63"}, + }, + { + "name": "Zone", + "values": ["north", "south", "east", "west"], + "color": "#009688", + "valueColors": { + "north": "#009688", + "south": "#3F51B5", + "east": "#795548", + "west": "#607D8B", + }, + }, + { + "name": "Weather", + "values": ["sunny", "cloudy", "rain", "windy"], + "color": "#03A9F4", + "valueColors": { + "sunny": "#FFEB3B", + "cloudy": "#9E9E9E", + "rain": "#2196F3", + "windy": "#80CBC4", + }, + }, + { + "name": "Phase", + "values": ["alpha", "beta", "gamma"], + "color": "#673AB7", + "valueColors": {"alpha": "#673AB7", "beta": "#9C27B0", "gamma": "#E040FB"}, + }, + { + "name": "Priority", + "values": ["low", "medium", "high", "critical"], + "color": "#FF5722", + "valueColors": { + "low": "#8BC34A", + "medium": "#FFC107", + "high": "#FF9800", + "critical": "#F44336", + }, + }, + { + "name": "Label", + "values": ["A", "B", "C", "D"], + "color": "#795548", + "valueColors": {"A": "#E91E63", "B": "#3F51B5", "C": "#009688", "D": "#FF5722"}, + }, +] + + +def get_video_info(video_path: Path) -> tuple[int, int, int]: + import subprocess + + cmd = [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height,nb_frames", + "-of", + "json", + str(video_path), + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + data = json.loads(result.stdout) + stream = data["streams"][0] + width = int(stream["width"]) + height = int(stream["height"]) + frames = int(stream.get("nb_frames") or 0) + if frames <= 0: + duration_cmd = [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(video_path), + ] + duration = float(subprocess.run(duration_cmd, capture_output=True, text=True, check=True).stdout.strip()) + rate_cmd = [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=r_frame_rate", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(video_path), + ] + rate = subprocess.run(rate_cmd, capture_output=True, text=True, check=True).stdout.strip() + num, den = rate.split("/") + fps = float(num) / float(den) + frames = max(1, int(round(duration * fps))) + return width, height, frames + + +def build_attribute_keyframes(frames: int, seed: int = 42) -> dict[str, list[tuple[int, str]]]: + rng = random.Random(seed) + keyframes: dict[str, list[tuple[int, str]]] = {} + for attr in TEXT_ATTRIBUTES: + entries: list[tuple[int, str]] = [] + frame = 0 + value_idx = 0 + while frame < frames: + entries.append((frame, attr["values"][value_idx % len(attr["values"])])) + value_idx += 1 + frame += rng.randint(20, 30) + keyframes[attr["name"]] = entries + return keyframes + + +def build_annotations(width: int, height: int, frames: int) -> dict: + keyframes = build_attribute_keyframes(frames) + change_frames: dict[int, dict[str, str]] = {} + for name, entries in keyframes.items(): + for frame, value in entries: + change_frames.setdefault(frame, {})[name] = value + + features = [] + for frame in range(frames): + feature: dict = { + "frame": frame, + "bounds": [0, 0, width, height], + } + if frame in change_frames: + feature["attributes"] = change_frames[frame] + features.append(feature) + + return { + "version": 2, + "tracks": { + str(TRACK_ID): { + "id": TRACK_ID, + "begin": 0, + "end": frames - 1, + "attributes": {}, + "confidencePairs": [[TRACK_TYPE, 1.0]], + "features": features, + } + }, + "groups": {}, + } + + +def build_attribute_defs() -> list[dict]: + defs = [] + for attr in TEXT_ATTRIBUTES: + key = f"detection_{attr['name']}" + defs.append( + { + "belongs": "detection", + "datatype": "text", + "name": attr["name"], + "key": key, + "values": attr["values"], + "color": attr["color"], + "valueColors": attr["valueColors"], + "noneColor": "#EEEEEE", + } + ) + return defs + + +def build_swimlane_defs() -> list[dict]: + swimlanes = [] + for index, attr in enumerate(TEXT_ATTRIBUTES): + swimlanes.append( + { + "enabled": True, + "name": f"{attr['name']} Swimlane", + "default": index == 0, + "filter": { + "type": "key", + "active": True, + "value": True, + # Swimlane filters match annotation attribute names, not metadata keys. + "appliedTo": [attr["name"]], + }, + "displaySettings": { + "display": "selected", + "trackFilter": ["all"], + "renderMode": "classic", + "displayTooltip": True, + "displayFrameIndicators": True, + }, + } + ) + return swimlanes + + +def build_configuration() -> dict: + timeline_entries = [ + { + "name": "Detections", + "type": "detections", + "order": 0, + "maxHeight": 80, + "dismissable": True, + } + ] + for index, attr in enumerate(TEXT_ATTRIBUTES): + timeline_entries.append( + { + "name": f"{attr['name']} Swimlane", + "type": "swimlane", + "order": index + 1, + "maxHeight": 55, + "dismissable": False, + } + ) + + return { + "actions": [ + { + "action": { + "type": "TrackSelection", + "startTrack": 0, + "startFrame": 0, + "Nth": 0, + "direction": "next", + } + } + ], + "timelineConfigs": [ + { + "name": "SwimlaneTesting", + "maxHeight": 450, + "timelines": timeline_entries, + } + ], + "UISettings": { + "UITimeline": { + "UIDetections": True, + "UIEvents": True, + "UILegendForceOpen": True, + }, + }, + } + + +def login() -> girder_client.GirderClient: + gc = girder_client.GirderClient(HOST, port=PORT, apiRoot="girder/api/v1", scheme=SCHEME) + gc.authenticate("admin", "letmein") + return gc + + +def main() -> None: + if not VIDEO_SOURCE.exists(): + print(f"Video not found: {VIDEO_SOURCE}", file=sys.stderr) + sys.exit(1) + + width, height, frames = get_video_info(VIDEO_SOURCE) + print(f"Video: {width}x{height}, {frames} frames") + + annotations = build_annotations(width, height, frames) + attribute_defs = build_attribute_defs() + swimlane_defs = build_swimlane_defs() + configuration = build_configuration() + + gc = login() + parent = gc.getFolder(PARENT_FOLDER_ID) + print(f"Uploading to folder: {parent['name']} ({PARENT_FOLDER_ID})") + + existing = list(gc.listFolder(PARENT_FOLDER_ID, name=DATASET_NAME, limit=1)) + if existing: + folder_id = str(existing[0]["_id"]) + print(f"Reusing existing dataset folder: {folder_id}") + else: + folder = gc.createFolder(parentId=PARENT_FOLDER_ID, name=DATASET_NAME, reuseExisting=False) + folder_id = str(folder["_id"]) + print(f"Created dataset folder: {folder_id}") + + items = list(gc.listItem(folder_id)) + if not items: + gc.uploadFileToFolder(folder_id, str(VIDEO_SOURCE), filename=UPLOAD_FILENAME) + gc.addMetadataToFolder( + folder_id, + {"fps": 30, "annotate": True, "type": "video", "originalFPS": 30}, + ) + gc.post(f"dive_rpc/postprocess/{folder_id}", data={"skipTranscoding": True}) + print("Uploaded video and started postprocess") + + gc.post( + "dive_annotation/process_json", + json=annotations, + parameters={"folderId": folder_id}, + ) + print("Uploaded annotations") + + gc.patch( + f"dive_dataset/{folder_id}/attributes", + json={"upsert": attribute_defs, "delete": []}, + ) + print("Updated attributes") + + gc.patch( + f"dive_dataset/{folder_id}/swimlanes", + json={"upsert": swimlane_defs, "delete": []}, + ) + print("Updated swimlanes") + + gc.patch( + f"dive_dataset/{folder_id}/configuration", + json=configuration, + ) + print("Updated configuration") + + print(f"\nDone. Dataset: {DATASET_NAME}") + print(f"Folder ID: {folder_id}") + print(f"Launch: http://{HOST}:{PORT}/dive?folder={folder_id}") + + +if __name__ == "__main__": + main() diff --git a/server/dive_utils/models.py b/server/dive_utils/models.py index efb0e736..68acf323 100644 --- a/server/dive_utils/models.py +++ b/server/dive_utils/models.py @@ -422,7 +422,6 @@ class UITrackDetails(BaseModel): class UIControls(BaseModel): - UILegendControls: Optional[bool] UITimelineSelection: Optional[bool] UIPlaybackControls: Optional[bool] UIAudioControls: Optional[bool] @@ -437,6 +436,11 @@ class UIControls(BaseModel): class UITimeline(BaseModel): UIDetections: Optional[bool] UIEvents: Optional[bool] + UILegendControls: Optional[bool] + UILegendForceOpen: Optional[bool] + UILegendHideToggle: Optional[bool] + UILegendKeyMinWidth: Optional[int] + UILegendKeyMaxWidth: Optional[int] class UIInteractions(BaseModel):