From 84c6b56672394267b379615bf713fa5aa8bc19d6 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Sat, 29 Aug 2026 17:39:25 -0400 Subject: [PATCH 01/12] restructure timeline layouts to embed the swimlane key and align values --- client/src/components/controls/Timeline.vue | 119 ++- .../components/controls/TimelineCharts.vue | 634 ++++++----- .../src/components/controls/TimelineKey.vue | 989 ++++++++++++++---- .../controls/TimelineKeySection.vue | 985 +++++++++++++++++ .../src/components/controls/timelineLayout.ts | 240 +++++ 5 files changed, 2489 insertions(+), 478 deletions(-) create mode 100644 client/src/components/controls/TimelineKeySection.vue create mode 100644 client/src/components/controls/timelineLayout.ts diff --git a/client/src/components/controls/Timeline.vue b/client/src/components/controls/Timeline.vue index 8bb18f71..9df62671 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,23 +219,31 @@ 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) .attr('dy', 13)); }, 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..a90d64b4 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..a4c41c99 100644 --- a/client/src/components/controls/TimelineKey.vue +++ b/client/src/components/controls/TimelineKey.vue @@ -9,8 +9,25 @@ import { useAttributesFilters, useConfiguration, useSelectedTrackId, useTimelineFilters, } from 'vue-media-annotator/provides'; -import { SwimlaneAttribute } from 'vue-media-annotator/use/AttributeTypes'; +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 { + buildFilteredTimelineList, + getSectionContentHeight, + isDetectionsTimeline, + shouldHideTimelineSectionTitle, + SWIMLANE_BAR_HEIGHT, + SWIMLANE_BAR_TOP_OFFSET, + SWIMLANE_ROW_HEIGHT, + TIMELINE_SECTION_GAP, + TIMELINE_SECTION_HEADER_HEIGHT, +} from './timelineLayout'; + +interface EventChartDataBundle { + muted?: boolean; + values?: EventChartData[]; +} export default defineComponent({ name: 'TimelineKey', @@ -23,36 +40,40 @@ export default defineComponent({ type: String, default: '', }, - hoveredButtons: { - type: Array as PropType, - required: false, - }, clientHeight: { type: Number, default: 0, }, - clientTop: { - type: Number, - default: 0, - }, - clientWidth: { + keyPanelWidth: { type: Number, - default: 0, + default: 100, }, offset: { type: Number, default: 0, }, + 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, + swimlaneEnabled, attributeSwimlaneData, swimlaneDisplaySettings, swimlaneGraphs, } = useAttributesFilters(); const { eventChartDataMap: timelineFilterMap, enabledTimelines: enabledFilterTimelines } = useTimelineFilters(); - // Format the Attribute data if it is available const selectedTrackIdRef = useSelectedTrackId(); + const showKey = true; const enabledTimelines = computed(() => { const list: string[] = []; @@ -64,47 +85,33 @@ 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 enabledSwimlanes = computed(() => { + const list: string[] = []; + Object.entries(swimlaneEnabled.value).forEach(([key, enabled]) => { + if (enabled) { + list.push(key); } - 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; + }); + return list; }); + + 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 true; + }; + + const timelineList = computed(() => buildFilteredTimelineList( + configMan, + checkTimelineEnabled, + props.dismissedButtons, + )); + + const useLegacyKeyLayout = computed(() => timelineList.value.length === 0); + const uniqueKeys = (data: SwimlaneAttribute['data'], order?: Record) => { const vals: {value: string; color: string; order?: number}[] = []; data.forEach((item) => { @@ -134,15 +141,6 @@ export default defineComponent({ }); return vals; }; - const enabledSwimlanes = computed(() => { - const list: string[] = []; - Object.entries(swimlaneEnabled.value).forEach(([key, enabled]) => { - if (enabled) { - list.push(key); - } - }); - return list; - }); const keyRef: Ref = ref(null); watch(() => props.offset, () => { @@ -150,12 +148,13 @@ export default defineComponent({ keyRef.value.scrollTop = props.offset; } }); - const getTimelineHeight = (timeline: TimelineDisplay) => { - if (timeline.maxHeight === -1 && timelineList.value.length) { - return (props.clientHeight / timelineList.value.length) - 20; - } - return timeline.maxHeight - 20; - }; + + const getTimelineHeight = (timeline: TimelineDisplay) => getSectionContentHeight( + timeline, + timelineList.value, + props.clientHeight, + shouldHideTimelineSectionTitle(timeline, showKey, swimlaneDisplaySettings.value), + ); const getTimelineByName = (name: string, type: TimelineDisplay['type']) => { if (type === 'swimlane') { @@ -186,6 +185,121 @@ export default defineComponent({ return `Range from ${min.toFixed(2)} to ${max.toFixed(2)}`; }; + 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(() => ( + props.lineChartData + .filter((item) => item.name !== 'total') + .map((item) => ({ name: item.name, color: item.color })) + )); + + const showLegacyDetectionsKey = computed(() => ( + useLegacyKeyLayout.value && props.currentView === 'Detections' + )); + + const showLegacyEventsKey = computed(() => ( + useLegacyKeyLayout.value && props.currentView === 'Events' + )); + + const showLegacyGroupsKey = computed(() => ( + useLegacyKeyLayout.value && props.currentView === 'Groups' + )); + + const showLegacyGraphKey = computed(() => ( + useLegacyKeyLayout.value && enabledTimelines.value.includes(props.currentView) + )); + + const showLegacySwimlaneKey = computed(() => ( + useLegacyKeyLayout.value && enabledSwimlanes.value.includes(props.currentView) + )); + + const isEventTimeline = (timeline: TimelineDisplay) => ( + timeline.type === 'event' + || timeline.name === 'Events' + || timeline.name === 'events' + || timeline.name === 'Groups' + ); + + const getEventChartValues = (timeline: TimelineDisplay) => { + if (timeline.name === 'Groups') { + return props.groupChartData?.values || []; + } + return props.eventChartData?.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 getKeyRowStyle = (color: string) => ({ + color, + border: `2px solid ${color}`, + height: `${SWIMLANE_BAR_HEIGHT}px`, + }); + + const sectionHeightStyle = (timeline: TimelineDisplay) => ({ + minHeight: `${getTimelineHeight(timeline)}px`, + height: `${getTimelineHeight(timeline)}px`, + }); + + const swimlaneSectionStyle = (timeline: TimelineDisplay) => { + const height = getTimelineHeight(timeline); + return { + minHeight: `${height}px`, + height: `${height}px`, + paddingTop: `${SWIMLANE_BAR_TOP_OFFSET}px`, + }; + }; + return { uniqueKeys, getMinMax, @@ -196,11 +310,34 @@ export default defineComponent({ attributeTimelineData, enabledTimelines, enabledFilterTimelines, - enabledSwimlanes, timelineFilterMap, timelineList, getTimelineHeight, selectedTrackIdRef, + swimlaneDisplaySettings, + getSwimlaneSettings, + getSwimlaneRowLabel, + getSwimlaneTooltipTitle, + getGraphAttributeItems, + getDetectionTypeItems, + showLegacyDetectionsKey, + showLegacyEventsKey, + showLegacyGroupsKey, + showLegacyGraphKey, + showLegacySwimlaneKey, + useLegacyKeyLayout, + enabledSwimlanes, + isEventTimeline, + getEventChartValues, + getFilterTypeItems, + getKeyRowStyle, + sectionHeightStyle, + swimlaneSectionStyle, + isDetectionsTimeline, + SWIMLANE_ROW_HEIGHT, + SWIMLANE_BAR_TOP_OFFSET, + TIMELINE_SECTION_GAP, + TIMELINE_SECTION_HEADER_HEIGHT, }; }, }); @@ -209,191 +346,633 @@ 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..553fcfe6 --- /dev/null +++ b/client/src/components/controls/TimelineKeySection.vue @@ -0,0 +1,985 @@ + + + + + + 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), + ); +} From fd134be728a414f91e17f30b45bf3317a930f7d6 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Sat, 29 Aug 2026 17:40:03 -0400 Subject: [PATCH 02/12] add configuration section to the swimlane for attribute titles and key views --- .../src/components/AttributeSwimlaneGraph.vue | 105 ++++++++++++++++++ client/src/use/AttributeTypes.ts | 4 +- 2 files changed, 108 insertions(+), 1 deletion(-) 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 + + + + + +
+
; } From e72c5555d0f64c17c1f787ca2116adadae48d633 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Sat, 29 Aug 2026 17:41:08 -0400 Subject: [PATCH 03/12] UI settings for timeline swimlane keys --- .../Attributes/AttributeShortcuts.vue | 6 +- .../components/ControlsContainer.vue | 69 +++++----- .../UISettings/UIControls.vue | 16 +-- .../UISettings/UITimeline.vue | 125 +++++++++++++++++- client/src/ConfigurationManager.ts | 6 +- server/dive_utils/models.py | 6 +- 6 files changed, 176 insertions(+), 52 deletions(-) diff --git a/client/dive-common/components/Attributes/AttributeShortcuts.vue b/client/dive-common/components/Attributes/AttributeShortcuts.vue index 03978b69..9925c5f2 100644 --- a/client/dive-common/components/Attributes/AttributeShortcuts.vue +++ b/client/dive-common/components/Attributes/AttributeShortcuts.vue @@ -336,9 +336,9 @@ export default defineComponent({ Value - - Button - + + Button + Info diff --git a/client/dive-common/components/ControlsContainer.vue b/client/dive-common/components/ControlsContainer.vue index 191891f3..4dff2cb1 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,23 @@ 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') + && !isUISettingExplicitlyTrue('UILegendForceOpen') + )); + + const toggleLegendKey = () => { + if (!isUISettingExplicitlyTrue('UILegendForceOpen')) { + enabledKey.value = !enabledKey.value; + } + }; const timelineHeight = computed(() => { const activeConfig = configMan.getActiveTimelineConfig(); @@ -144,19 +156,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 +177,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 +199,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 +245,7 @@ export default defineComponent({ Collapse/Expand Timeline @@ -248,7 +255,7 @@ export default defineComponent({ :color="enabledKey ? 'primary' : ''" class="ml-2" v-on="on" - @click="enabledKey = !enabledKey" + @click="toggleLegendKey" > mdi-key @@ -367,6 +374,8 @@ export default defineComponent({ :frame="frame" :display="!collapsed" :timeline-height="timelineHeight" + :key-inset="chartAreaLeftInset" + :chart-right-inset="chartAreaRightInset" @seek="seek" @resize="updateSizes" > @@ -386,6 +395,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 +405,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; +} + +function parseKeyWidth(value: number, fallback: number): number { + return Number.isFinite(value) ? Math.max(40, Math.round(value)) : fallback; +} + export default defineComponent({ name: 'UITimeline', components: { }, setup() { const configMan = useConfiguration(); + const flatMap = configMan.getFlatUISettingMap(); 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(flatMap.UILegendForceOpen === true); + const UILegendHideToggle = ref(flatMap.UILegendHideToggle === true); + const UILegendKeyMinWidth = ref(readNumberSetting(flatMap, 'UILegendKeyMinWidth', KEY_PANEL_MIN_WIDTH)); + const UILegendKeyMaxWidth = ref(readNumberSetting(flatMap, 'UILegendKeyMaxWidth', KEY_PANEL_MAX_WIDTH)); + + const migrateLegendSettingsFromControls = () => { + const controls = configMan.getUISettingValue('UIControls'); + if (typeof controls !== 'object' || !controls) { + return; + } + const { + UILegendControls: legacyLegendControls, + UILegendForceOpen: legacyLegendForceOpen, + UILegendHideToggle: legacyLegendHideToggle, + ...rest + } = controls as UIControls & { + UILegendControls?: boolean; + UILegendForceOpen?: boolean; + UILegendHideToggle?: boolean; + }; + if ( + legacyLegendControls !== undefined + || legacyLegendForceOpen !== undefined + || legacyLegendHideToggle !== undefined + ) { + configMan.setUISettings('UIControls', rest); + } + }; + + watch([ + UIDetections, + UIEvents, + UILegendControls, + UILegendForceOpen, + UILegendHideToggle, + UILegendKeyMinWidth, + UILegendKeyMaxWidth, + ], () => { + 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; - 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); + migrateLegendSettingsFromControls(); }); return { UIDetections, UIEvents, + UILegendControls, + UILegendForceOpen, + UILegendHideToggle, + UILegendKeyMinWidth, + UILegendKeyMaxWidth, + KEY_PANEL_MIN_WIDTH, + KEY_PANEL_MAX_WIDTH, }; }, @@ -47,6 +120,54 @@ export default defineComponent({ label="Events Timeline" /> + +
+ Legend / Key +
+ + + + + + + + + + + + + + + + +
diff --git a/client/src/ConfigurationManager.ts b/client/src/ConfigurationManager.ts index 7505d22f..98027029 100644 --- a/client/src/ConfigurationManager.ts +++ b/client/src/ConfigurationManager.ts @@ -82,7 +82,6 @@ interface UITrackDetails { } interface UIControls { - UILegendControls?: boolean; UITimelineSelection?: boolean; UIPlaybackControls? : boolean; UIAudioControls? : boolean; @@ -97,6 +96,11 @@ interface UIControls { interface UITimeline { UIDetections? : boolean; UIEvents? : boolean; + UILegendControls?: boolean; + UILegendForceOpen?: boolean; + UILegendHideToggle?: boolean; + UILegendKeyMinWidth?: number; + UILegendKeyMaxWidth?: number; } interface UIInteractions { diff --git a/server/dive_utils/models.py b/server/dive_utils/models.py index 49f8fa9a..68a0e024 100644 --- a/server/dive_utils/models.py +++ b/server/dive_utils/models.py @@ -392,7 +392,6 @@ class UITrackDetails(BaseModel): class UIControls(BaseModel): - UILegendControls: Optional[bool] UITimelineSelection: Optional[bool] UIPlaybackControls: Optional[bool] UIAudioControls: Optional[bool] @@ -407,6 +406,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): From bcaa6ef537bbb8adcf66688dae83cff82918fc6a Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Sat, 29 Aug 2026 18:44:06 -0400 Subject: [PATCH 04/12] timeline swimlane key tooltip adjustments --- client/src/components/controls/TimelineKey.vue | 1 + client/src/components/controls/TimelineKeySection.vue | 1 + 2 files changed, 2 insertions(+) diff --git a/client/src/components/controls/TimelineKey.vue b/client/src/components/controls/TimelineKey.vue index a4c41c99..6a3af4f0 100644 --- a/client/src/components/controls/TimelineKey.vue +++ b/client/src/components/controls/TimelineKey.vue @@ -961,6 +961,7 @@ export default defineComponent({ .customTooltip { background: black; border: 1px solid white; + padding: 6px 8px 10px; } .key-tooltip-title { diff --git a/client/src/components/controls/TimelineKeySection.vue b/client/src/components/controls/TimelineKeySection.vue index 553fcfe6..82f48137 100644 --- a/client/src/components/controls/TimelineKeySection.vue +++ b/client/src/components/controls/TimelineKeySection.vue @@ -968,6 +968,7 @@ export default defineComponent({ .customTooltip { background: black; border: 1px solid white; + padding: 6px 8px 10px; } .key-tooltip-title { From a668dfa6b808b548b03c08522739b6439c27fc93 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Sat, 29 Aug 2026 18:49:28 -0400 Subject: [PATCH 05/12] testing script --- scripts/testing/createSwimlaneTesting.py | 358 +++++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 scripts/testing/createSwimlaneTesting.py 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() From 6addba01556ab5b20deaa513f4459d33c80cdd52 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Sat, 29 Aug 2026 18:50:00 -0400 Subject: [PATCH 06/12] documentation updates --- docs/UI-AttributeSwimlanes.md | 26 ++++++++++++++++++++++-- docs/UI-AttributeTimeline.md | 8 +++++++- docs/UI-ConfigurationJSON.md | 38 +++++++++++++++++++++++++++++++++-- docs/UI-Settings.md | 15 ++++++++++++++ docs/UI-Timeline.md | 9 +++++++++ 5 files changed, 91 insertions(+), 5 deletions(-) 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 83c037b9..c5f76179 100644 --- a/docs/UI-Settings.md +++ b/docs/UI-Settings.md @@ -63,5 +63,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 From 6da80e877bdb9dd6185771c3f0486ce6f18fcda6 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 1 Sep 2026 10:14:44 -0400 Subject: [PATCH 07/12] swimlane key settings for force open and hide updated --- client/dive-common/components/ControlsContainer.vue | 5 +---- .../configurationEditors/UISettings/UITimeline.vue | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/client/dive-common/components/ControlsContainer.vue b/client/dive-common/components/ControlsContainer.vue index 4dff2cb1..5c2cdea3 100644 --- a/client/dive-common/components/ControlsContainer.vue +++ b/client/dive-common/components/ControlsContainer.vue @@ -90,13 +90,10 @@ export default defineComponent({ const showLegendToggle = computed(() => ( getUISetting('UILegendControls') && !isUISettingExplicitlyTrue('UILegendHideToggle') - && !isUISettingExplicitlyTrue('UILegendForceOpen') )); const toggleLegendKey = () => { - if (!isUISettingExplicitlyTrue('UILegendForceOpen')) { - enabledKey.value = !enabledKey.value; - } + enabledKey.value = !enabledKey.value; }; const timelineHeight = computed(() => { diff --git a/client/dive-common/components/configurationEditors/UISettings/UITimeline.vue b/client/dive-common/components/configurationEditors/UISettings/UITimeline.vue index 618566ac..3b189ce1 100644 --- a/client/dive-common/components/configurationEditors/UISettings/UITimeline.vue +++ b/client/dive-common/components/configurationEditors/UISettings/UITimeline.vue @@ -134,7 +134,7 @@ export default defineComponent({
@@ -142,7 +142,7 @@ export default defineComponent({ From c1ee672a5434a9145a4368f2338d5cbd879247f4 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 1 Sep 2026 10:15:12 -0400 Subject: [PATCH 08/12] prevent highlighting text for swimlane key titles --- client/src/components/controls/TimelineKey.vue | 6 ++++++ client/src/components/controls/TimelineKeySection.vue | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/client/src/components/controls/TimelineKey.vue b/client/src/components/controls/TimelineKey.vue index 6a3af4f0..168ce915 100644 --- a/client/src/components/controls/TimelineKey.vue +++ b/client/src/components/controls/TimelineKey.vue @@ -922,6 +922,9 @@ export default defineComponent({ .key-swimlane-body { overflow: hidden; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; } .key-section-body-scroll { @@ -956,6 +959,9 @@ export default defineComponent({ text-overflow: ellipsis; white-space: nowrap; line-height: 1; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; } .customTooltip { diff --git a/client/src/components/controls/TimelineKeySection.vue b/client/src/components/controls/TimelineKeySection.vue index 82f48137..ff1ba0c4 100644 --- a/client/src/components/controls/TimelineKeySection.vue +++ b/client/src/components/controls/TimelineKeySection.vue @@ -921,6 +921,9 @@ export default defineComponent({ .key-swimlane-body { overflow-y: auto; overflow-x: hidden; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; -ms-overflow-style: none; scrollbar-width: none; @@ -963,6 +966,9 @@ export default defineComponent({ text-overflow: ellipsis; white-space: nowrap; line-height: 1; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; } .customTooltip { From c14e54e2577c18b19192439ef15db3fc539cb215 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 1 Sep 2026 10:29:15 -0400 Subject: [PATCH 09/12] update tooltip styling and positioning --- .../controls/AttributeSwimlaneGraph.vue | 93 ++++++++++++------- client/src/components/controls/BodyPortal.vue | 21 +++++ client/src/components/controls/EventChart.vue | 47 +++++----- client/src/components/controls/LineChart.vue | 48 +++++++--- .../components/controls/timelineTooltip.ts | 27 ++++++ 5 files changed, 168 insertions(+), 68 deletions(-) create mode 100644 client/src/components/controls/BodyPortal.vue create mode 100644 client/src/components/controls/timelineTooltip.ts diff --git a/client/src/components/controls/AttributeSwimlaneGraph.vue b/client/src/components/controls/AttributeSwimlaneGraph.vue index ae9c3aea..c4982cff 100644 --- a/client/src/components/controls/AttributeSwimlaneGraph.vue +++ b/client/src/components/controls/AttributeSwimlaneGraph.vue @@ -17,6 +17,8 @@ import { SwimlaneAttribute, SwimlaneData, SwimlaneGraph } from 'vue-media-annota import { mdiArrowLeftRightBold } from '@mdi/js'; import { useStore } from 'platform/web-girder/store/types'; import { injectAggregateController } from '../annotators/useMediaController'; +import BodyPortal from './BodyPortal.vue'; +import { timelineTooltipStyle } from './timelineTooltip'; function intersect(range1: number[], range2: number[]): number[] | null { const min = range1[0] < range2[0] ? range1 : range2; @@ -39,6 +41,9 @@ export interface DragData { } export default defineComponent({ name: 'AttributeSwimlaneGraph', + components: { + BodyPortal, + }, props: { startFrame: { type: Number, required: true }, endFrame: { type: Number, required: true }, @@ -68,8 +73,8 @@ export default defineComponent({ const chartTop = ref(0); const x = ref(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 9ed2f40a..5627b763 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 @@ -180,6 +203,7 @@ export default Vue.extend({ let highlightedLine = null; let highlightedColor = null; + let tooltipTimeoutHandle = null; const path = svg .selectAll() .data(this.data) @@ -192,12 +216,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); @@ -430,13 +455,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/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'; From 9fc380b519f8fc03d6523841d8e27815c4f2c8f7 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 1 Sep 2026 12:31:29 -0400 Subject: [PATCH 10/12] restructure and fix minor issues --- .../UISettings/UITimeline.vue | 45 +- client/src/ConfigurationManager.ts | 5 + .../components/controls/TimelineCharts.vue | 12 +- .../src/components/controls/TimelineKey.vue | 889 ++---------------- .../controls/TimelineKeySection.vue | 233 +---- .../controls/migrateLegendSettings.ts | 56 ++ .../components/controls/useTimelineKeyData.ts | 232 +++++ 7 files changed, 418 insertions(+), 1054 deletions(-) create mode 100644 client/src/components/controls/migrateLegendSettings.ts create mode 100644 client/src/components/controls/useTimelineKeyData.ts diff --git a/client/dive-common/components/configurationEditors/UISettings/UITimeline.vue b/client/dive-common/components/configurationEditors/UISettings/UITimeline.vue index 3b189ce1..1949fc9a 100644 --- a/client/dive-common/components/configurationEditors/UISettings/UITimeline.vue +++ b/client/dive-common/components/configurationEditors/UISettings/UITimeline.vue @@ -2,11 +2,11 @@ import { defineComponent, ref, watch, } from 'vue'; -import type { UIControls } from 'vue-media-annotator/ConfigurationManager'; import { KEY_PANEL_MAX_WIDTH, KEY_PANEL_MIN_WIDTH, } from 'vue-media-annotator/components/controls/timelineLayout'; +import migrateLegendSettingsUISettings from 'vue-media-annotator/components/controls/migrateLegendSettings'; import { useConfiguration } from 'vue-media-annotator/provides'; function readNumberSetting( @@ -28,38 +28,22 @@ export default defineComponent({ }, setup() { const configMan = useConfiguration(); - const flatMap = configMan.getFlatUISettingMap(); + 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(flatMap.UILegendForceOpen === true); - const UILegendHideToggle = ref(flatMap.UILegendHideToggle === true); - const UILegendKeyMinWidth = ref(readNumberSetting(flatMap, 'UILegendKeyMinWidth', KEY_PANEL_MIN_WIDTH)); - const UILegendKeyMaxWidth = ref(readNumberSetting(flatMap, 'UILegendKeyMaxWidth', KEY_PANEL_MAX_WIDTH)); - - const migrateLegendSettingsFromControls = () => { - const controls = configMan.getUISettingValue('UIControls'); - if (typeof controls !== 'object' || !controls) { - return; - } - const { - UILegendControls: legacyLegendControls, - UILegendForceOpen: legacyLegendForceOpen, - UILegendHideToggle: legacyLegendHideToggle, - ...rest - } = controls as UIControls & { - UILegendControls?: boolean; - UILegendForceOpen?: boolean; - UILegendHideToggle?: boolean; - }; - if ( - legacyLegendControls !== undefined - || legacyLegendForceOpen !== undefined - || legacyLegendHideToggle !== undefined - ) { - configMan.setUISettings('UIControls', rest); - } - }; + 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)); watch([ UIDetections, @@ -85,7 +69,6 @@ export default defineComponent({ UILegendKeyMaxWidth: maxWidth !== KEY_PANEL_MAX_WIDTH ? maxWidth : undefined, }; configMan.setUISettings('UITimeline', data); - migrateLegendSettingsFromControls(); }); return { UIDetections, diff --git a/client/src/ConfigurationManager.ts b/client/src/ConfigurationManager.ts index 98027029..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'; @@ -312,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/controls/TimelineCharts.vue b/client/src/components/controls/TimelineCharts.vue index a90d64b4..32c95da4 100644 --- a/client/src/components/controls/TimelineCharts.vue +++ b/client/src/components/controls/TimelineCharts.vue @@ -498,7 +498,7 @@ export default defineComponent({ :max-frame="childMaxFrame" :data="eventChartData" :client-width="chartClientWidth" - :client-height="clientHeight / timelineList.length" + :client-height="clientHeight" :margin="margin" @select-track="$emit('select-track', $event)" /> @@ -509,7 +509,7 @@ export default defineComponent({ :max-frame="childMaxFrame" :data="groupChartData" :client-width="chartClientWidth" - :client-height="clientHeight / timelineList.length" + :client-height="clientHeight" :margin="margin" @select-track="$emit('select-group', $event)" /> @@ -519,19 +519,19 @@ export default defineComponent({ :key="`Swimlane_${index}`" > - +

No Data to Graph

@@ -588,7 +588,7 @@ export default defineComponent({ :max-frame="childMaxFrame" :data="timelineFilterMap[item.name]" :client-width="chartClientWidth" - :client-height="clientHeight / timelineList.length" + :client-height="clientHeight" :margin="margin" @select-track="$emit('select-group', $event)" /> diff --git a/client/src/components/controls/TimelineKey.vue b/client/src/components/controls/TimelineKey.vue index 168ce915..6a022812 100644 --- a/client/src/components/controls/TimelineKey.vue +++ b/client/src/components/controls/TimelineKey.vue @@ -6,31 +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, SwimlaneGraphSettings } 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, - isDetectionsTimeline, shouldHideTimelineSectionTitle, - SWIMLANE_BAR_HEIGHT, - SWIMLANE_BAR_TOP_OFFSET, - SWIMLANE_ROW_HEIGHT, - TIMELINE_SECTION_GAP, - TIMELINE_SECTION_HEADER_HEIGHT, } from './timelineLayout'; - -interface EventChartDataBundle { - muted?: boolean; - values?: EventChartData[]; -} +import { EventChartDataBundle } from './useTimelineKeyData'; export default defineComponent({ name: 'TimelineKey', + components: { + TimelineKeySection, + }, props: { dismissedButtons: { type: Array as PropType, @@ -52,6 +43,14 @@ export default defineComponent({ type: Number, default: 0, }, + startFrame: { + type: Number, + default: 0, + }, + endFrame: { + type: Number, + default: Number.MAX_SAFE_INTEGER, + }, lineChartData: { type: Array as PropType, default: () => [], @@ -67,13 +66,16 @@ export default defineComponent({ }, setup(props) { const configMan = useConfiguration(); - const { - timelineEnabled, attributeTimelineData, - swimlaneEnabled, attributeSwimlaneData, swimlaneDisplaySettings, swimlaneGraphs, - } = useAttributesFilters(); - const { eventChartDataMap: timelineFilterMap, enabledTimelines: enabledFilterTimelines } = useTimelineFilters(); - 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[] = []; @@ -110,45 +112,6 @@ export default defineComponent({ props.dismissedButtons, )); - const useLegacyKeyLayout = computed(() => timelineList.value.length === 0); - - 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 keyRef: Ref = ref(null); - watch(() => props.offset, () => { - if (keyRef.value !== null) { - keyRef.value.scrollTop = props.offset; - } - }); - const getTimelineHeight = (timeline: TimelineDisplay) => getSectionContentHeight( timeline, timelineList.value, @@ -156,188 +119,36 @@ export default defineComponent({ shouldHideTimelineSectionTitle(timeline, showKey, swimlaneDisplaySettings.value), ); - const getTimelineByName = (name: string, type: TimelineDisplay['type']) => { - if (type === 'swimlane') { - if (attributeSwimlaneData.value[name] !== undefined) { - return attributeSwimlaneData.value[name]; - } - } - if (type === 'graph') { - if (attributeTimelineData.value[name] !== undefined) { - return attributeTimelineData.value[name]; - } - } - if (type === 'filter') { - if (timelineFilterMap.value[name] !== undefined) { - return timelineFilterMap.value[name]; - } - } - 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)}`; - }; - - 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) { + const legacyKeyKind = computed((): 'detections' | 'events' | 'groups' | 'graph' | 'swimlane' | 'filter' | '' => { + if (timelineList.value.length) { return ''; } - if (settings?.[subKey]?.displayName === false) { - return ''; + if (props.currentView === 'Detections') { + return 'detections'; } - if (attrs.length === 1) { - if (!displaySettings?.hideKeyTitle && timelineName !== subKey) { - return timelineName; - } - return subKey; + if (props.currentView === 'Events') { + return 'events'; } - 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 }[]; + if (props.currentView === 'Groups') { + return 'groups'; } - return graphData.data.map((item: { data: LineChartData }) => ({ - name: item.data.name, - color: item.data.color, - })); - }; - - const getDetectionTypeItems = computed(() => ( - props.lineChartData - .filter((item) => item.name !== 'total') - .map((item) => ({ name: item.name, color: item.color })) - )); - - const showLegacyDetectionsKey = computed(() => ( - useLegacyKeyLayout.value && props.currentView === 'Detections' - )); - - const showLegacyEventsKey = computed(() => ( - useLegacyKeyLayout.value && props.currentView === 'Events' - )); - - const showLegacyGroupsKey = computed(() => ( - useLegacyKeyLayout.value && props.currentView === 'Groups' - )); - - const showLegacyGraphKey = computed(() => ( - useLegacyKeyLayout.value && enabledTimelines.value.includes(props.currentView) - )); - - const showLegacySwimlaneKey = computed(() => ( - useLegacyKeyLayout.value && enabledSwimlanes.value.includes(props.currentView) - )); - - const isEventTimeline = (timeline: TimelineDisplay) => ( - timeline.type === 'event' - || timeline.name === 'Events' - || timeline.name === 'events' - || timeline.name === 'Groups' - ); - - const getEventChartValues = (timeline: TimelineDisplay) => { - if (timeline.name === 'Groups') { - return props.groupChartData?.values || []; + if (enabledTimelines.value.includes(props.currentView)) { + return 'graph'; } - return props.eventChartData?.values || []; - }; - - const getFilterTypeItems = (timelineName: string) => { - const data = getTimelineByName(timelineName, 'filter'); - if (!data || typeof data !== 'object' || !('values' in data)) { - return [] as { value: string; color: string }[]; + if (enabledSwimlanes.value.includes(props.currentView)) { + return 'swimlane'; } - return uniqueFilterItems(data.values as EventChartData[]); - }; - - const getKeyRowStyle = (color: string) => ({ - color, - border: `2px solid ${color}`, - height: `${SWIMLANE_BAR_HEIGHT}px`, - }); - - const sectionHeightStyle = (timeline: TimelineDisplay) => ({ - minHeight: `${getTimelineHeight(timeline)}px`, - height: `${getTimelineHeight(timeline)}px`, + if (enabledFilterTimelines.value.some((item) => item.name === props.currentView)) { + return 'filter'; + } + return ''; }); - const swimlaneSectionStyle = (timeline: TimelineDisplay) => { - const height = getTimelineHeight(timeline); - return { - minHeight: `${height}px`, - height: `${height}px`, - paddingTop: `${SWIMLANE_BAR_TOP_OFFSET}px`, - }; - }; - return { - uniqueKeys, - getMinMax, - uniqueFilterItems, - getTimelineByName, keyRef, - attributeSwimlaneData, - attributeTimelineData, - enabledTimelines, - enabledFilterTimelines, - timelineFilterMap, timelineList, getTimelineHeight, - selectedTrackIdRef, - swimlaneDisplaySettings, - getSwimlaneSettings, - getSwimlaneRowLabel, - getSwimlaneTooltipTitle, - getGraphAttributeItems, - getDetectionTypeItems, - showLegacyDetectionsKey, - showLegacyEventsKey, - showLegacyGroupsKey, - showLegacyGraphKey, - showLegacySwimlaneKey, - useLegacyKeyLayout, - enabledSwimlanes, - isEventTimeline, - getEventChartValues, - getFilterTypeItems, - getKeyRowStyle, - sectionHeightStyle, - swimlaneSectionStyle, - isDetectionsTimeline, - SWIMLANE_ROW_HEIGHT, - SWIMLANE_BAR_TOP_OFFSET, - TIMELINE_SECTION_GAP, - TIMELINE_SECTION_HEADER_HEIGHT, + legacyKeyKind, }; }, }); @@ -351,500 +162,37 @@ export default defineComponent({ @wheel.prevent @touchmove.prevent > -
- - -
- Detections -
- - {{ item.name }} - -
-
-
- - -
- Events -
- - {{ item.value }} - -
-
-
- - -
- Groups -
- - {{ item.value }} - -
-
-
- - -
- {{ currentView }} -
- - {{ item.name }} - -
- - - {{ currentView }} - -
-
- - - - {{ currentView }} - -
- - +
- -
- - -
- {{ timeline.name }} -
- - {{ item.name }} - -
-
- - -
- - -
- {{ timeline.name }} -
- - {{ item.name }} - -
- - - {{ timeline.name }} - -
- - -
- - -
- {{ timeline.name }} -
- - {{ item.value }} - -
-
- - -
- - -
- {{ timeline.name }} -
- - {{ item.value }} - -
- - - {{ timeline.name }} - -
- - -
- - - - {{ timeline.name }} - -
- - + +
+ + @@ -869,37 +217,6 @@ export default defineComponent({ } } -.key-section-header, -.key-swimlane-header { - display: flex; - align-items: center; - justify-content: center; - white-space: nowrap; - overflow: hidden; -} - -.key-section-title, -.key-centered-title, -.key-centered-title-row { - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; - white-space: nowrap; -} - -.key-centered-title-row { - display: inline-flex; - align-items: center; - justify-content: center; - max-width: 100%; - cursor: pointer; -} - -.key-info-icon { - flex-shrink: 0; - opacity: 0.85; -} - .timeline-key-section { display: block; margin-bottom: 4px; @@ -908,78 +225,4 @@ export default defineComponent({ .timeline-key-section-last { margin-bottom: 0; } - -.key-section-body { - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -.key-section-centered { - align-items: center; - justify-content: center; -} - -.key-swimlane-body { - overflow: hidden; - -webkit-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.key-section-body-scroll { - overflow-y: auto; -} - -.key-row { - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - flex-shrink: 0; -} - -.key-item { - padding: 0 3px; - width: 100%; - text-align: center; - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - - &:hover { - cursor: pointer; - } -} - -.key-text { - width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: 1; - -webkit-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.customTooltip { - background: black; - border: 1px solid white; - padding: 6px 8px 10px; -} - -.key-tooltip-title { - text-align: center; - font-weight: bolder; - margin-bottom: 4px; - white-space: nowrap; -} - -.key-subitem { - width: 100%; - padding: 0 3px; - text-align: center; -} diff --git a/client/src/components/controls/TimelineKeySection.vue b/client/src/components/controls/TimelineKeySection.vue index ff1ba0c4..64417e78 100644 --- a/client/src/components/controls/TimelineKeySection.vue +++ b/client/src/components/controls/TimelineKeySection.vue @@ -2,32 +2,21 @@