diff --git a/client/dive-common/components/Attributes/AttributeCustomUI.vue b/client/dive-common/components/Attributes/AttributeCustomUI.vue new file mode 100644 index 00000000..a6ecd4b3 --- /dev/null +++ b/client/dive-common/components/Attributes/AttributeCustomUI.vue @@ -0,0 +1,900 @@ + + + + + diff --git a/client/dive-common/components/Attributes/AttributeEditor.vue b/client/dive-common/components/Attributes/AttributeEditor.vue index 1459108d..6c0fa0ef 100644 --- a/client/dive-common/components/Attributes/AttributeEditor.vue +++ b/client/dive-common/components/Attributes/AttributeEditor.vue @@ -3,12 +3,19 @@ import { computed, defineComponent, PropType, Ref, ref, watch, } from 'vue'; import { - Attribute, AttributeShortcut, MetadataLinkOptions, NumericAttributeEditorOptions, StringAttributeEditorOptions, + Attribute, AttributeCustomUI, AttributeShortcut, MetadataLinkOptions, NumericAttributeEditorOptions, StringAttributeEditorOptions, } from 'vue-media-annotator/use/AttributeTypes'; +import { + buildCustomUIPayload, + resolvedCustomUIToEditorValue, + resolveAttributeCustomUI, + stripLegacyDisplayValueFromShortcuts, +} from 'vue-media-annotator/use/attributeCustomUI'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { useTrackStyleManager } from 'vue-media-annotator/provides'; import AttributeShortcuts from './AttributeShortcuts.vue'; import AttributeRendering from './AttributeRendering.vue'; +import AttributeCustomUIEditor from './AttributeCustomUI.vue'; import AttributeValueColors from './AttributeValueColors.vue'; import AttributeNumberValueColors from './AttributeNumberColors.vue'; import AttributeMetadataLink from './AttributeMetadataLink.vue'; @@ -18,6 +25,7 @@ export default defineComponent({ components: { AttributeShortcuts, AttributeRendering, + AttributeCustomUIEditor, AttributeValueColors, AttributeNumberValueColors, AttributeMetadataLink, @@ -84,10 +92,19 @@ export default defineComponent({ metadataLinkFromAttribute(props.selectedAttribute), ); + const customUIFromAttribute = (attr: Attribute): AttributeCustomUI => ( + resolvedCustomUIToEditorValue(resolveAttributeCustomUI(attr)) + ); + + const customUI: Ref = ref( + customUIFromAttribute(props.selectedAttribute), + ); + watch( () => props.selectedAttribute.key, () => { metadataLink.value = metadataLinkFromAttribute(props.selectedAttribute); + customUI.value = customUIFromAttribute(props.selectedAttribute); }, ); let values: string[] = props.selectedAttribute.values ? props.selectedAttribute.values : []; @@ -121,6 +138,7 @@ export default defineComponent({ metadataLink.value = { key: '', updateValue: false, useDynamicKeyFromAttribute: false, dynamicKeyAttributeKey: undefined, }; + customUI.value = customUIFromAttribute({ shortcuts: [] }); } function add() { setDefaultValue(); @@ -146,11 +164,13 @@ export default defineComponent({ key: `${belongs.value}_${name.value}`, editor: editor.value, color: color.value ? color.value : tempColor.value, - shortcuts: shortcuts.value, + shortcuts: stripLegacyDisplayValueFromShortcuts(shortcuts.value), user: user.value ? true : undefined, render: renderingVals.value, lockedValues: lockedValues.value, }; + const customUIPayload = buildCustomUIPayload(customUI.value); + data.customUI = customUIPayload; if (valueOrder) { data.valueOrder = valueOrder; } @@ -329,6 +349,7 @@ export default defineComponent({ launchColorEditor, saveAttributeValueColors, metadataLink, + customUI, }; }, }); @@ -343,11 +364,12 @@ export default defineComponent({ Main Shortcuts + Custom UI Rendering - MetadataLink Value Colors + MetadataLink @@ -539,6 +561,9 @@ export default defineComponent({ :attribute-color="color || tempColor" /> + + + - - - + + + diff --git a/client/dive-common/components/Attributes/AttributeShortcuts.vue b/client/dive-common/components/Attributes/AttributeShortcuts.vue index 03978b69..9c71c725 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 @@ -474,7 +474,7 @@ export default defineComponent({ diff --git a/client/dive-common/components/CustomUI/ButtonShortcutEditor.vue b/client/dive-common/components/CustomUI/ButtonShortcutEditor.vue index 35d3ce2e..7ce39798 100644 --- a/client/dive-common/components/CustomUI/ButtonShortcutEditor.vue +++ b/client/dive-common/components/CustomUI/ButtonShortcutEditor.vue @@ -76,7 +76,7 @@ export default defineComponent({ const applyTypeDefaults = () => { syncingFromProps = true; - const { displayValue, buttonToolTip } = buttonShortcut.value; + const { buttonToolTip } = buttonShortcut.value; buttonShortcut.value = { ...defaultButtonForType( props.shortcutType, @@ -84,7 +84,6 @@ export default defineComponent({ props.attributeColor, ), ...(buttonToolTip !== undefined ? { buttonToolTip } : {}), - ...(displayValue !== undefined ? { displayValue } : {}), }; syncingFromProps = false; updateButtonShortcut(); @@ -188,7 +187,6 @@ export default defineComponent({ - diff --git a/client/dive-common/components/CustomUI/CustomUIAttributeValueDisplay.vue b/client/dive-common/components/CustomUI/CustomUIAttributeValueDisplay.vue new file mode 100644 index 00000000..cbf25103 --- /dev/null +++ b/client/dive-common/components/CustomUI/CustomUIAttributeValueDisplay.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/client/dive-common/components/CustomUI/CustomUIBase.vue b/client/dive-common/components/CustomUI/CustomUIBase.vue index 2f72bcfd..6eba3123 100644 --- a/client/dive-common/components/CustomUI/CustomUIBase.vue +++ b/client/dive-common/components/CustomUI/CustomUIBase.vue @@ -6,14 +6,29 @@ import { } from 'vue'; import StackedVirtualSidebarContainer from 'dive-common/components/StackedVirtualSidebarContainer.vue'; +import CustomUIAttributeValueDisplay from 'dive-common/components/CustomUI/CustomUIAttributeValueDisplay.vue'; import { useAttributes, useCameraStore, useConfiguration, useSelectedTrackId, useTime, - useHandler, + useHandler, useTrackStyleManager, } from 'vue-media-annotator/provides'; import AttributeSubsection from 'dive-common/components/Attributes/AttributesSubsection.vue'; import { useStore } from 'platform/web-girder/store/types'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { Attribute, AttributeShortcut } from 'vue-media-annotator/use/AttributeTypes'; +import { + formatAttributeDisplayValue, + getCustomUIDisplayValueColorStyle, + getStickyValueIndicatorStyle, + getStickyValueTooltip, + LONG_VALUE_EXPAND_THRESHOLD, + ResolvedAttributeCustomUI, + resolveAttributeCustomUI, + resolveCustomUIDisplayValueColor, + resolveStickyAttributeValue, + shouldShowAttributeInCustomUI, +} from 'vue-media-annotator/use/attributeCustomUI'; +import type { AttributeDisplayValueInfo } from 'vue-media-annotator/use/attributeCustomUI'; +import createGetAttributeValueColor from 'vue-media-annotator/use/attributeValueColor'; import { DIVEAction, DIVEMetadataAction } from 'dive-common/use/useActions'; import useMetadataLinkUpdater from 'dive-common/use/useMetadataLinkUpdater'; import type { MetadataLinkUpdateContext } from 'dive-common/use/useMetadataLinkUpdater'; @@ -26,7 +41,6 @@ interface AttributeDisplayButton { prependIcon?: string; appendIcon?: string; buttonToolTip?: string; - displayValue?: boolean; attrName: string; type: Attribute['belongs']; userAttribute: boolean; @@ -51,6 +65,7 @@ interface AttributeButtons { type: 'track' | 'detection'; description?: string; buttons: AttributeDisplayButton[]; + customUI: ResolvedAttributeCustomUI; } type AttributeButtonList = AttributeButtons[]; @@ -61,6 +76,7 @@ export default defineComponent({ components: { StackedVirtualSidebarContainer, AttributeSubsection, + CustomUIAttributeValueDisplay, }, props: { @@ -73,6 +89,7 @@ export default defineComponent({ setup(props) { const configMan = useConfiguration(); const attributes = useAttributes(); + const getAttributeValueColor = createGetAttributeValueColor(useTrackStyleManager()); const { inputValue } = usePrompt(); const { frame: frameRef, frameRate } = useTime(); const store = useStore(); @@ -404,11 +421,61 @@ export default defineComponent({ return handler; }; + const getAttributeDisplayValueInfo = ( + attribute: Attribute, + ): AttributeDisplayValueInfo => { + const customUI = resolveAttributeCustomUI(attribute); + let currentValue: unknown; + const hasSegmentShortcut = attribute.shortcuts?.some((shortcut) => shortcut.segment); + if (hasSegmentShortcut && selectedTrackIdRef.value !== null && frameRef.value !== undefined) { + const track = cameraStore.getAnyTrack(selectedTrackIdRef.value); + const rangeVals = track.getFrameAttributeRanges( + [attribute.name], + store.state.User.user?.login || null, + ); + const ranges = rangeVals[attribute.name]; + if (ranges && ranges.length > 0) { + for (let i = 0; i < ranges.length; i += 2) { + const start = ranges[i]; + const end = ranges[i + 1]; + if (frameRef.value >= start && frameRef.value <= end) { + const [real] = track.getFeature(start); + if (real?.attributes) { + if (attribute.user && real.attributes.userAttributes) { + const user = store.state.User.user?.login; + if (user && real.attributes.userAttributes[user]) { + currentValue = (real.attributes.userAttributes[user] as StringKeyObject)[attribute.name]; + } + } else { + currentValue = real.attributes[attribute.name]; + } + } + break; + } + } + } + } + if (currentValue === undefined) { + currentValue = getAttributeValue(attribute.name, attribute.belongs, !!attribute.user); + } + const track = selectedTrackIdRef.value !== null + ? cameraStore.getAnyTrack(selectedTrackIdRef.value) + : null; + return resolveStickyAttributeValue(attribute, { + frame: frameRef.value, + track, + userLogin: store.state.User.user?.login || null, + stickyValue: customUI.stickyValue, + currentValue, + }); + }; + const attributeButtons = computed(() => { const attributeButtonList: AttributeButtonList = []; attributes.value.forEach((attribute) => { - if (attribute.shortcuts && attribute.shortcuts.length > 0) { - const buttons: AttributeDisplayButton[] = []; + const customUI = resolveAttributeCustomUI(attribute); + const buttons: AttributeDisplayButton[] = []; + if (attribute.shortcuts?.length) { attribute.shortcuts.forEach((shortcut) => { if (shortcut.button) { const { disabled, tooltip } = getButtonDisabled(attribute, shortcut); @@ -418,7 +485,6 @@ export default defineComponent({ prependIcon: shortcut.button.iconPrepend, appendIcon: shortcut.button.iconAppend, buttonToolTip: tooltip, - displayValue: shortcut.button.displayValue, attrName: attribute.name, type: attribute.belongs, userAttribute: !!attribute.user, @@ -429,15 +495,16 @@ export default defineComponent({ }); } }); - if (buttons.length > 0) { - attributeButtonList.push({ - name: attribute.displayText || attribute.name, - attrName: attribute.name, - description: attribute.description, - type: attribute.belongs, - buttons, - }); - } + } + if (shouldShowAttributeInCustomUI(attribute, buttons.length)) { + attributeButtonList.push({ + name: attribute.displayText || attribute.name, + attrName: attribute.name, + description: attribute.description, + type: attribute.belongs, + buttons, + customUI, + }); } }); const order = configMan.configuration.value?.customUI?.attributeButtonOrder || []; @@ -568,55 +635,107 @@ export default defineComponent({ // return buttonMapping; // }); - const buttonValueMap: Ref> = ref({}); + const buttonValueMap: Ref; + tooltip: string; + valuePrepend?: string; + valueAppend?: string; + valueFontSizeScale: number; + valueAlign: ResolvedAttributeCustomUI['valueAlign']; + valueColorStyle: Record; + }>> = ref({}); const updateButtonMap = () => { - const buttonMapping: Record = {}; - attributeButtons.value.forEach((attribute) => attribute.buttons.forEach((button) => { - if (button.displayValue) { - if (button.segment && selectedTrackIdRef.value !== null) { - const track = cameraStore.getAnyTrack(selectedTrackIdRef.value); - const rangeVals = track.getFrameAttributeRanges([attribute.attrName], store.state.User.user?.login || null); - const ranges = rangeVals[attribute.attrName]; - if (ranges && ranges.length > 0) { - for (let i = 0; i < ranges.length; i += 2) { - const start = ranges[i]; - const end = ranges[i + 1]; - if (frameRef.value >= start && frameRef.value <= end) { - const [real] = track.getFeature(start); - if (real && real.attributes) { - if (button.userAttribute && real.attributes.userAttributes) { - const user = store.state.User.user?.login; - if (user && real.attributes.userAttributes[user]) { - const val = ((real.attributes.userAttributes[user] as StringKeyObject)[button.attrName] as string | boolean | number); - buttonMapping[button.attrName] = { - attribute: attribute.name, button: button.attrName, value: val, length: val ? (val as string | boolean | number).toString()?.length : 0, - }; - } - } else if (real.attributes) { - const val = (real.attributes[button.attrName] as string | boolean | number); - buttonMapping[button.attrName] = { - attribute: attribute.name, button: button.attrName, value: val, length: val ? (val as string | boolean | number).toString()?.length : 0, - }; - } - } - } - } - } - } else { - const val = getAttributeValue(button.attrName, button.type, button.userAttribute); - buttonMapping[button.attrName] = { - attribute: attribute.name, button: button.attrName, value: val, length: val ? (val as string | boolean | number).toString()?.length : 0, - }; - } + const buttonMapping: Record; + tooltip: string; + valuePrepend?: string; + valueAppend?: string; + valueFontSizeScale: number; + valueAlign: ResolvedAttributeCustomUI['valueAlign']; + valueColorStyle: Record; + }> = {}; + attributeButtons.value.forEach((attributeGroup) => { + if (!attributeGroup.customUI.displayValue) { + return; + } + const attribute = attributes.value.find( + (item) => item.name === attributeGroup.attrName && item.belongs === attributeGroup.type, + ); + if (!attribute) { + return; } - })); + const { value: rawValue, inherited } = getAttributeDisplayValueInfo(attribute); + const displayText = formatAttributeDisplayValue( + rawValue, + attributeGroup.customUI.emptyValueLabel, + ); + const resolvedValueColor = resolveCustomUIDisplayValueColor( + attributeGroup.customUI.valueColor, + rawValue, + attribute, + getAttributeValueColor, + ); + buttonMapping[attributeGroup.attrName] = { + attribute: attributeGroup.name, + value: displayText, + rawLength: displayText.length, + longValueMode: attributeGroup.customUI.longValueMode, + inherited, + indicatorStyle: getStickyValueIndicatorStyle( + attributeGroup.customUI.stickyValueIndicator, + attributeGroup.customUI.stickyValue && !inherited, + attribute.color, + ), + tooltip: getStickyValueTooltip(inherited, displayText), + valuePrepend: attributeGroup.customUI.valuePrepend, + valueAppend: attributeGroup.customUI.valueAppend, + valueFontSizeScale: attributeGroup.customUI.valueFontSizeScale, + valueAlign: attributeGroup.customUI.valueAlign, + valueColorStyle: getCustomUIDisplayValueColorStyle(resolvedValueColor), + }; + }); buttonValueMap.value = buttonMapping; }; watch([attributeButtons, frameRef, selectedTrackIdRef], () => { updateButtonMap(); - }); + }, { immediate: true }); + + const getDisplayValueEntry = (attributeGroup: AttributeButtons) => { + const existing = buttonValueMap.value[attributeGroup.attrName]; + if (existing) { + return existing; + } + return { + attribute: attributeGroup.name, + value: attributeGroup.customUI.emptyValueLabel ?? '', + rawLength: 0, + longValueMode: attributeGroup.customUI.longValueMode, + inherited: false, + indicatorStyle: {}, + tooltip: '', + valuePrepend: attributeGroup.customUI.valuePrepend, + valueAppend: attributeGroup.customUI.valueAppend, + valueFontSizeScale: attributeGroup.customUI.valueFontSizeScale, + valueAlign: attributeGroup.customUI.valueAlign, + valueColorStyle: {}, + }; + }; + + const shouldShowDisplayValue = (attributeGroup: AttributeButtons) => ( + attributeGroup.customUI.displayValue + ); const expandPanel = (buttonName: string) => { if (panelExpanded.value[buttonName] !== undefined) { @@ -640,6 +759,9 @@ export default defineComponent({ panelExpanded, expandPanel, getButtonDisabled, + getDisplayValueEntry, + shouldShowDisplayValue, + LONG_VALUE_EXPAND_THRESHOLD, }; }, }); @@ -687,58 +809,161 @@ export default defineComponent({

Attribute Buttons

- + - - -

{{ attribute.name }}

+

+ + {{ attribute.name }} + + + {{ attribute.customUI.headerValueSeparator }} + + +

+

+ {{ attribute.description }} +

+
+ +
+ + + + + {{ button.buttonToolTip }} + - -

{{ attribute.description }}

-
+
+ +
- - - - - {{ button.buttonToolTip }} - - - - - - - {{ buttonValueMap[attribute.attrName].value }} - - - - {{ attribute.name }} Value - - {{ buttonValueMap[attribute.attrName].value }} - - - - -
+ + diff --git a/client/dive-common/components/configurationEditors/UISettings/UIContextBar.vue b/client/dive-common/components/configurationEditors/UISettings/UIContextBar.vue index 07002cae..f98ea2e1 100644 --- a/client/dive-common/components/configurationEditors/UISettings/UIContextBar.vue +++ b/client/dive-common/components/configurationEditors/UISettings/UIContextBar.vue @@ -4,6 +4,7 @@ import { } from 'vue'; import draggable from 'vuedraggable'; import { useAttributes, useConfiguration } from 'vue-media-annotator/provides'; +import { shouldShowAttributeInCustomUI } from 'vue-media-annotator/use/attributeCustomUI'; interface AttributeButtonOrderItem { key: string; @@ -62,7 +63,10 @@ export default defineComponent({ function syncAttributeButtonOrderList() { const withButtons = attributes.value - .filter((attr) => attr.shortcuts?.some((shortcut) => !!shortcut.button)) + .filter((attr) => { + const buttonCount = attr.shortcuts?.filter((shortcut) => !!shortcut.button).length ?? 0; + return shouldShowAttributeInCustomUI(attr, buttonCount); + }) .map((attr) => ({ key: attr.key || `${attr.belongs}_${attr.name}`, label: attr.displayText || attr.name, diff --git a/client/package.json b/client/package.json index 7f43ef36..c4c3f6e2 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "dive-dsa", - "version": "1.11.39", + "version": "1.11.40", "author": { "name": "Kitware, Inc.", "email": "Bryon.Lewis@kitware.com" diff --git a/client/src/attributeCustomUI.spec.ts b/client/src/attributeCustomUI.spec.ts new file mode 100644 index 00000000..15c27317 --- /dev/null +++ b/client/src/attributeCustomUI.spec.ts @@ -0,0 +1,178 @@ +/// +import Track from './track'; +import { + resolveStickyAttributeValue, + resolveAttributeCustomUI, + buildCustomUIPayload, + getCustomUIValueDisplayContent, + getStickyValueIndicatorStyle, + getStickyValueTooltip, + getTruncatedCustomUIDisplayValue, + shouldShowAttributeInCustomUI, + CUSTOM_UI_VALUE_SPACE_PLACEHOLDER, +} from './use/attributeCustomUI'; +import { Attribute } from './use/AttributeTypes'; + +describe('attributeCustomUI', () => { + const stickyStatusAttr: Attribute = { + belongs: 'detection', + datatype: 'text', + name: 'Status', + key: 'detection_Status', + customUI: { + displayValue: true, + stickyValue: true, + stickyValueIndicator: { italic: true, underline: true }, + }, + }; + + function makeTrackWithStatusKeyframes() { + const track = Track.fromJSON({ + id: 0, + begin: 0, + end: 20, + confidencePairs: [['customUI', 1]], + attributes: {}, + features: [ + { + frame: 0, bounds: [0, 0, 100, 100], keyframe: true, attributes: { Status: 'idle' }, + }, + { + frame: 10, bounds: [0, 0, 100, 100], keyframe: true, attributes: { Status: 'tracking' }, + }, + { + frame: 20, bounds: [0, 0, 100, 100], keyframe: true, attributes: {}, + }, + ], + }); + return track; + } + + it('inherits sticky detection values from previous keyframes', () => { + const track = makeTrackWithStatusKeyframes(); + const frame5 = resolveStickyAttributeValue(stickyStatusAttr, { + frame: 5, + track, + userLogin: null, + stickyValue: true, + currentValue: undefined, + }); + expect(frame5.inherited).toBe(true); + expect(frame5.value).toBe('idle'); + + const frame15 = resolveStickyAttributeValue(stickyStatusAttr, { + frame: 15, + track, + userLogin: null, + stickyValue: true, + currentValue: undefined, + }); + expect(frame15.inherited).toBe(true); + expect(frame15.value).toBe('tracking'); + }); + + it('does not inherit when current value is set', () => { + const track = makeTrackWithStatusKeyframes(); + const result = resolveStickyAttributeValue(stickyStatusAttr, { + frame: 5, + track, + userLogin: null, + stickyValue: true, + currentValue: 'scanning', + }); + expect(result.inherited).toBe(false); + expect(result.value).toBe('scanning'); + }); + + it('disables sticky for track-level attributes', () => { + const trackAttr: Attribute = { + belongs: 'track', + datatype: 'text', + name: 'TrackLabel', + key: 'track_TrackLabel', + customUI: { displayValue: true, stickyValue: true }, + }; + const resolved = resolveAttributeCustomUI(trackAttr); + expect(resolved.stickyValue).toBe(false); + }); + + it('builds customUI payload with sticky indicator when configured', () => { + const payload = buildCustomUIPayload({ + displayValue: true, + stickyValue: true, + stickyValueIndicator: { italic: true, underline: true }, + valuePosition: 'above', + longValueMode: 'scroll', + }); + expect(payload?.stickyValue).toBe(true); + expect(payload?.stickyValueIndicator?.italic).toBe(true); + expect(payload?.valuePosition).toBe('above'); + expect(payload?.longValueMode).toBe('scroll'); + }); + + it('builds header value display options when valuePosition is header', () => { + const payload = buildCustomUIPayload({ + displayValue: true, + valuePosition: 'header', + headerValueSeparator: '-', + headerValueOffset: 10, + }); + expect(payload?.valuePosition).toBe('header'); + expect(payload?.headerValueSeparator).toBe('-'); + expect(payload?.headerValueOffset).toBe(10); + }); + + it('resolves header separator and offset defaults', () => { + const resolved = resolveAttributeCustomUI({ + belongs: 'detection', + customUI: { displayValue: true, valuePosition: 'header' }, + }); + expect(resolved.headerValueSeparator).toBe(':'); + expect(resolved.headerValueOffset).toBe(4); + }); + + it('shows attributes with buttons or showWithoutButtons in custom UI', () => { + const withButtons: Attribute = { + belongs: 'detection', + datatype: 'text', + name: 'Status', + key: 'detection_Status', + shortcuts: [{ type: 'set', value: 'idle', button: { buttonText: 'Idle' } }], + }; + const withoutButtons: Attribute = { + belongs: 'detection', + datatype: 'text', + name: 'Notes', + key: 'detection_Notes', + customUI: { showWithoutButtons: true }, + }; + expect(shouldShowAttributeInCustomUI(withButtons, 1)).toBe(true); + expect(shouldShowAttributeInCustomUI(withoutButtons, 0)).toBe(true); + }); + + it('applies sticky indicator styling on keyframe values, not inherited ones', () => { + const indicator = { + bold: true, + italic: false, + underline: false, + fontSizeScale: 1.5, + opacity: 1, + }; + expect(getStickyValueIndicatorStyle(indicator, true).fontWeight).toBe('bold'); + expect(getStickyValueIndicatorStyle(indicator, true).fontSize).toBe('150%'); + expect(getStickyValueIndicatorStyle(indicator, false)).toEqual({}); + }); + + it('formats sticky tooltip and truncated long values', () => { + expect(getStickyValueTooltip(true, 'idle')).toBe('idle (inherited from previous keyframe)'); + expect(getStickyValueTooltip(false, 'idle')).toBe('idle'); + const longText = 'x'.repeat(60); + expect(getTruncatedCustomUIDisplayValue(longText, longText.length, 'truncate')).toMatch(/\.\.\.$/); + }); + + it('reserves display space for empty values when configured', () => { + expect(getCustomUIValueDisplayContent('', true)).toBe(CUSTOM_UI_VALUE_SPACE_PLACEHOLDER); + expect(getCustomUIValueDisplayContent('', false)).toBe(''); + expect(getCustomUIValueDisplayContent('idle', true)).toBe('idle'); + }); +}); diff --git a/client/src/components/controls/LineChart.vue b/client/src/components/controls/LineChart.vue index 9ed2f40a..0dd2208e 100644 --- a/client/src/components/controls/LineChart.vue +++ b/client/src/components/controls/LineChart.vue @@ -176,7 +176,10 @@ export default Vue.extend({ .call((g) => g .selectAll('.tick text') .attr('x', -5) - .attr('dx', 13)); + .attr('dx', 13) + .style('user-select', 'none') + .style('-webkit-user-select', 'none') + .style('pointer-events', 'none')); let highlightedLine = null; let highlightedColor = null; @@ -416,6 +419,10 @@ export default Vue.extend({ } .line-chart { height: 100%; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + .line { fill: none; stroke-width: 1.5px; @@ -423,6 +430,16 @@ export default Vue.extend({ .axis-y { font-size: 12px; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + + .tick text { + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + } g:first-of-type, g:last-of-type { diff --git a/client/src/components/controls/Timeline.vue b/client/src/components/controls/Timeline.vue index 8bb18f71..4257b034 100644 --- a/client/src/components/controls/Timeline.vue +++ b/client/src/components/controls/Timeline.vue @@ -177,7 +177,9 @@ export default { this.g.call(this.axis).call((g) => g .selectAll('.tick text') .attr('y', 0) - .attr('dy', 13)); + .attr('dy', 13) + .style('user-select', 'none') + .style('-webkit-user-select', 'none')); }, update() { this.timelineScale.domain([this.startFrame, this.endFrame]); @@ -203,9 +205,9 @@ export default { } this.dragging = false; }, - workareaMousedown() { + workareaMousedown(e) { this.dragging = true; - // e.preventDefault(); + e.preventDefault(); }, workareaMousemove(e) { if (this.dragging) { @@ -314,6 +316,15 @@ export default { flex: 1; position: relative; overflow: visible; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + + svg { + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + } .hand { position: absolute; @@ -352,6 +363,16 @@ export default { font-size: 12px; stroke-opacity: 0.5; stroke-dasharray: 2, 2; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + + text { + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + } } } diff --git a/client/src/use/AttributeTypes.ts b/client/src/use/AttributeTypes.ts index b1675922..be735385 100644 --- a/client/src/use/AttributeTypes.ts +++ b/client/src/use/AttributeTypes.ts @@ -58,8 +58,51 @@ export interface ButtonShortcut { iconAppend?: string; iconPrepend?: string; buttonColor?: string; // 'auto' or can be overridden + /** @deprecated Use attribute customUI.displayValue. Kept for legacy configs. */ displayValue?: boolean; } + +export interface AttributeCustomUIStickyIndicator { + bold?: boolean; + italic?: boolean; + underline?: boolean; + /** Font color for inherited values. Use 'auto' for the attribute color. */ + highlightColor?: string; + /** Multiplier for font size when value is inherited (1 = same size). */ + fontSizeScale?: number; + /** Opacity when value is inherited (0–1). */ + opacity?: number; + } + +export interface AttributeCustomUI { + enabled?: boolean; + /** Show this attribute in Custom UI even when it has no button shortcuts. */ + showWithoutButtons?: boolean; + displayValue?: boolean; + /** Carry forward the last non-empty value from previous keyframes. */ + stickyValue?: boolean; + stickyValueIndicator?: AttributeCustomUIStickyIndicator; + valuePosition?: 'below' | 'above' | 'header'; + longValueMode?: 'truncate' | 'expand' | 'scroll'; + emptyValueLabel?: string; + /** When valuePosition is header, separator appended after the section title. */ + headerValueSeparator?: ':' | '-'; + /** Horizontal space (px) between the separator and the displayed value in header mode. */ + headerValueOffset?: number; + /** Text shown before the displayed attribute value. */ + valuePrepend?: string; + /** Text shown after the displayed attribute value. */ + valueAppend?: string; + /** Show the attribute name heading in the Custom UI panel. */ + showHeader?: boolean; + /** Font size multiplier for the displayed attribute value (1 = default). */ + valueFontSizeScale?: number; + /** Horizontal alignment for the displayed attribute value. */ + valueAlign?: 'left' | 'center' | 'right'; + /** Value text color. Use 'auto' for the attribute value color mapping. */ + valueColor?: 'auto' | string; + showDescription?: boolean; + } export interface AttributeShortcut { key?: string; type: 'set' | 'dialog' | 'remove'; @@ -155,6 +198,7 @@ export interface Attribute { lockedValues?: boolean; editor?: NumericAttributeEditorOptions | StringAttributeEditorOptions; shortcuts?: AttributeShortcut[]; + customUI?: AttributeCustomUI; render?: AttributeRendering; colorKey?: boolean; colorKeySettings?: {display: 'static' | 'selected'; trackFilter: string[] }; diff --git a/client/src/use/attributeCustomUI.ts b/client/src/use/attributeCustomUI.ts new file mode 100644 index 00000000..02fe9652 --- /dev/null +++ b/client/src/use/attributeCustomUI.ts @@ -0,0 +1,460 @@ +import { omit } from 'lodash'; +import { + Attribute, AttributeCustomUI, AttributeCustomUIStickyIndicator, AttributeShortcut, +} from './AttributeTypes'; + +export const LONG_VALUE_EXPAND_THRESHOLD = 50; +/** Non-breaking space used to preserve value row height when the display text is empty. */ +export const CUSTOM_UI_VALUE_SPACE_PLACEHOLDER = '\u00A0'; + +const FONT_SIZE_SCALE_MIN = 0.5; +const FONT_SIZE_SCALE_MAX = 3; +export const HEADER_VALUE_OFFSET_MIN = 0; +export const HEADER_VALUE_OFFSET_MAX = 32; +export const DEFAULT_HEADER_VALUE_OFFSET = 4; +export const DEFAULT_HEADER_VALUE_SEPARATOR = ':' as const; + +export function normalizeFontSizeScale(value: unknown, fallback = 1): number { + const num = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(num)) { + return fallback; + } + return Math.min(FONT_SIZE_SCALE_MAX, Math.max(FONT_SIZE_SCALE_MIN, num)); +} + +export function normalizeHeaderValueOffset(value: unknown, fallback = DEFAULT_HEADER_VALUE_OFFSET): number { + const num = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(num)) { + return fallback; + } + return Math.min(HEADER_VALUE_OFFSET_MAX, Math.max(HEADER_VALUE_OFFSET_MIN, Math.round(num))); +} + +export function shouldShowCustomUIValueTooltip( + inherited: boolean, + rawLength: number, + longValueMode: ResolvedAttributeCustomUI['longValueMode'], +): boolean { + if (inherited) { + return true; + } + return longValueMode === 'truncate' && rawLength >= LONG_VALUE_EXPAND_THRESHOLD; +} + +export interface ResolvedAttributeCustomUIStickyIndicator { + bold: boolean; + italic: boolean; + underline: boolean; + highlightColor?: string; + fontSizeScale: number; + opacity: number; +} + +export interface ResolvedAttributeCustomUI { + enabled: boolean; + showWithoutButtons: boolean; + displayValue: boolean; + stickyValue: boolean; + stickyValueIndicator: ResolvedAttributeCustomUIStickyIndicator; + valuePosition: NonNullable; + longValueMode: NonNullable; + headerValueSeparator: NonNullable; + headerValueOffset: number; + emptyValueLabel?: string; + valuePrepend?: string; + valueAppend?: string; + showHeader: boolean; + valueFontSizeScale: number; + valueAlign: NonNullable; + valueColor?: 'auto' | string; + showDescription: boolean; +} + +export interface AttributeDisplayValueInfo { + value: unknown; + inherited: boolean; +} + +interface StickyValueTrack { + getFeature(frame: number): unknown[]; + getPreviousKeyframe(frame: number): number | undefined; +} + +export function hadLegacyDisplayValue(shortcuts?: AttributeShortcut[]): boolean { + return !!shortcuts?.some((shortcut) => shortcut.button?.displayValue); +} + +function resolveStickyValueIndicator( + indicator?: AttributeCustomUIStickyIndicator, +): ResolvedAttributeCustomUIStickyIndicator { + return { + bold: indicator?.bold ?? true, + italic: indicator?.italic ?? false, + underline: indicator?.underline ?? false, + highlightColor: indicator?.highlightColor, + fontSizeScale: normalizeFontSizeScale(indicator?.fontSizeScale), + opacity: indicator?.opacity ?? 1, + }; +} + +export function resolveAttributeCustomUI( + attribute: Pick, +): ResolvedAttributeCustomUI { + const legacyDisplayValue = hadLegacyDisplayValue(attribute.shortcuts); + const { customUI } = attribute; + const supportsStickyValue = attribute.belongs === 'detection'; + return { + enabled: customUI?.enabled ?? true, + showWithoutButtons: customUI?.showWithoutButtons ?? false, + displayValue: customUI?.displayValue ?? legacyDisplayValue ?? false, + stickyValue: supportsStickyValue ? (customUI?.stickyValue ?? false) : false, + stickyValueIndicator: resolveStickyValueIndicator(customUI?.stickyValueIndicator), + valuePosition: customUI?.valuePosition ?? 'below', + longValueMode: customUI?.longValueMode ?? 'expand', + headerValueSeparator: customUI?.headerValueSeparator ?? DEFAULT_HEADER_VALUE_SEPARATOR, + headerValueOffset: normalizeHeaderValueOffset(customUI?.headerValueOffset), + emptyValueLabel: customUI?.emptyValueLabel, + valuePrepend: customUI?.valuePrepend, + valueAppend: customUI?.valueAppend, + showHeader: customUI?.showHeader ?? true, + valueFontSizeScale: normalizeFontSizeScale(customUI?.valueFontSizeScale), + valueAlign: customUI?.valueAlign ?? 'left', + valueColor: customUI?.valueColor, + showDescription: customUI?.showDescription ?? true, + }; +} + +export function resolvedCustomUIToEditorValue( + resolved: ResolvedAttributeCustomUI, +): AttributeCustomUI { + const value: AttributeCustomUI = { + enabled: resolved.enabled, + showWithoutButtons: resolved.showWithoutButtons, + displayValue: resolved.displayValue, + stickyValue: resolved.stickyValue, + valuePosition: resolved.valuePosition, + longValueMode: resolved.longValueMode, + showHeader: resolved.showHeader, + showDescription: resolved.showDescription, + }; + if (resolved.valueAlign !== 'left') { + value.valueAlign = resolved.valueAlign; + } + if (resolved.valueColor) { + value.valueColor = resolved.valueColor; + } + if (resolved.emptyValueLabel) { + value.emptyValueLabel = resolved.emptyValueLabel; + } + if (resolved.valuePrepend) { + value.valuePrepend = resolved.valuePrepend; + } + if (resolved.valueAppend) { + value.valueAppend = resolved.valueAppend; + } + const valueFontSizeScale = normalizeFontSizeScale(resolved.valueFontSizeScale); + if (valueFontSizeScale !== 1) { + value.valueFontSizeScale = valueFontSizeScale; + } + if (resolved.showHeader === false) { + value.showHeader = false; + } + if (resolved.stickyValue) { + value.stickyValueIndicator = { ...resolved.stickyValueIndicator }; + } + if (resolved.valuePosition === 'header') { + if (resolved.headerValueSeparator !== DEFAULT_HEADER_VALUE_SEPARATOR) { + value.headerValueSeparator = resolved.headerValueSeparator; + } + if (resolved.headerValueOffset !== DEFAULT_HEADER_VALUE_OFFSET) { + value.headerValueOffset = resolved.headerValueOffset; + } + } + return value; +} + +export function shouldShowAttributeInCustomUI( + attribute: Pick, + buttonCount: number, +): boolean { + const customUI = resolveAttributeCustomUI(attribute); + if (!customUI.enabled) { + return false; + } + return buttonCount > 0 || customUI.showWithoutButtons; +} + +function stickyIndicatorDiffersFromDefault( + indicator: AttributeCustomUIStickyIndicator, +): boolean { + return !!( + indicator.bold === false + || indicator.italic + || indicator.underline + || indicator.highlightColor + || (indicator.fontSizeScale !== undefined && indicator.fontSizeScale !== 1) + || (indicator.opacity !== undefined && indicator.opacity !== 1) + ); +} + +export function buildCustomUIPayload( + customUI: AttributeCustomUI, +): AttributeCustomUI | undefined { + const payload: AttributeCustomUI = {}; + if (customUI.enabled === false) { + payload.enabled = false; + } + if (customUI.showWithoutButtons) { + payload.showWithoutButtons = true; + } + if (customUI.displayValue) { + payload.displayValue = true; + if (customUI.stickyValue) { + payload.stickyValue = true; + if (customUI.stickyValueIndicator && stickyIndicatorDiffersFromDefault(customUI.stickyValueIndicator)) { + payload.stickyValueIndicator = { ...customUI.stickyValueIndicator }; + } + } + if (customUI.valuePosition && customUI.valuePosition !== 'below') { + payload.valuePosition = customUI.valuePosition; + if (customUI.valuePosition === 'header') { + if (customUI.headerValueSeparator && customUI.headerValueSeparator !== DEFAULT_HEADER_VALUE_SEPARATOR) { + payload.headerValueSeparator = customUI.headerValueSeparator; + } + const headerValueOffset = normalizeHeaderValueOffset(customUI.headerValueOffset); + if (headerValueOffset !== DEFAULT_HEADER_VALUE_OFFSET) { + payload.headerValueOffset = headerValueOffset; + } + } + } + if (customUI.longValueMode && customUI.longValueMode !== 'expand') { + payload.longValueMode = customUI.longValueMode; + } + if (customUI.emptyValueLabel?.length) { + payload.emptyValueLabel = customUI.emptyValueLabel; + } + if (customUI.valuePrepend?.length) { + payload.valuePrepend = customUI.valuePrepend; + } + if (customUI.valueAppend?.length) { + payload.valueAppend = customUI.valueAppend; + } + const valueFontSizeScale = normalizeFontSizeScale(customUI.valueFontSizeScale); + if (valueFontSizeScale !== 1) { + payload.valueFontSizeScale = valueFontSizeScale; + } + if (customUI.valueAlign && customUI.valueAlign !== 'left') { + payload.valueAlign = customUI.valueAlign; + } + if (customUI.valueColor) { + payload.valueColor = customUI.valueColor; + } + } + if (customUI.showHeader === false) { + payload.showHeader = false; + } + if (customUI.showDescription === false) { + payload.showDescription = false; + } + return Object.keys(payload).length ? payload : undefined; +} + +export function stripLegacyDisplayValueFromShortcuts( + shortcuts: AttributeShortcut[] | undefined, +): AttributeShortcut[] | undefined { + if (!shortcuts?.length) { + return shortcuts; + } + let changed = false; + const cleaned = shortcuts.map((shortcut) => { + if (shortcut.button?.displayValue === undefined) { + return shortcut; + } + changed = true; + const button = omit(shortcut.button, 'displayValue'); + return { ...shortcut, button }; + }); + return changed ? cleaned : shortcuts; +} + +export function isEmptyAttributeValue(value: unknown): boolean { + return value === undefined || value === null || value === ''; +} + +export function formatAttributeDisplayValue( + value: unknown, + emptyValueLabel?: string, +): string { + if (isEmptyAttributeValue(value)) { + return emptyValueLabel ?? ''; + } + return String(value); +} + +export function getCustomUIValueDisplayContent( + value: string, + reserveSpace: boolean, +): string { + if (value.length > 0) { + return value; + } + return reserveSpace ? CUSTOM_UI_VALUE_SPACE_PLACEHOLDER : ''; +} + +export function readAttributeFromFeatureAttributes( + attributes: { + userAttributes?: Record>; + [key: string]: unknown; + } | undefined, + attribute: Pick, + userLogin: string | null, +): unknown { + if (!attributes) { + return undefined; + } + if (attribute.user && userLogin && attributes.userAttributes?.[userLogin]) { + return attributes.userAttributes[userLogin][attribute.name]; + } + return attributes[attribute.name]; +} + +export function resolveStickyAttributeValue( + attribute: Attribute, + options: { + frame: number | undefined; + track: StickyValueTrack | null; + userLogin: string | null; + stickyValue: boolean; + currentValue: unknown; + }, +): AttributeDisplayValueInfo { + if (!options.stickyValue || !isEmptyAttributeValue(options.currentValue)) { + return { value: options.currentValue, inherited: false }; + } + if (attribute.belongs !== 'detection' || !options.track || options.frame === undefined) { + return { value: options.currentValue, inherited: false }; + } + let previousFrame = options.frame; + while (previousFrame >= 0) { + const previousKeyframe = options.track.getPreviousKeyframe(previousFrame); + if (previousKeyframe === undefined) { + break; + } + const [feature] = options.track.getFeature(previousKeyframe) as [{ + attributes?: { + userAttributes?: Record>; + [key: string]: unknown; + }; + } | null | undefined]; + const value = readAttributeFromFeatureAttributes(feature?.attributes ?? undefined, attribute, options.userLogin); + if (!isEmptyAttributeValue(value)) { + return { value, inherited: true }; + } + previousFrame = previousKeyframe - 1; + } + return { value: options.currentValue, inherited: false }; +} + +export function resolveCustomUIDisplayValueColor( + configuredColor: 'auto' | string | undefined, + rawValue: unknown, + attribute: Attribute, + getAttributeValueColor: (attr: Attribute, val?: string | number | boolean) => string, +): string | undefined { + if (!configuredColor) { + return undefined; + } + if (configuredColor === 'auto') { + if (isEmptyAttributeValue(rawValue)) { + return getAttributeValueColor(attribute); + } + return getAttributeValueColor(attribute, rawValue as string | number | boolean); + } + return configuredColor; +} + +export function getCustomUIDisplayValueColorStyle( + color?: string, +): Record { + if (!color) { + return {}; + } + return { color }; +} + +export function getCustomUIDisplayValueStyle( + fontSizeScale: number, + valueAlign: NonNullable, +): Record { + const normalizedScale = normalizeFontSizeScale(fontSizeScale); + const style: Record = { + textAlign: valueAlign, + }; + if (normalizedScale !== 1) { + style.fontSize = `${Math.round(normalizedScale * 100)}%`; + } + return style; +} + +export function getCustomUIDisplayValueFontSizeStyle( + fontSizeScale: number, +): Record { + return getCustomUIDisplayValueStyle(fontSizeScale, 'left'); +} + +export function getStickyValueIndicatorStyle( + indicator: ResolvedAttributeCustomUIStickyIndicator, + applyIndicator: boolean, + attributeColor?: string, +): Record { + if (!applyIndicator) { + return {}; + } + const style: Record = {}; + if (indicator.bold) { + style.fontWeight = 'bold'; + } + if (indicator.italic) { + style.fontStyle = 'italic'; + } + if (indicator.underline) { + style.textDecoration = 'underline'; + } + if (indicator.highlightColor) { + style.color = indicator.highlightColor === 'auto' + ? attributeColor || '#F57C00' + : indicator.highlightColor; + } + if (indicator.fontSizeScale !== 1) { + style.fontSize = `${Math.round(normalizeFontSizeScale(indicator.fontSizeScale) * 100)}%`; + } + if (indicator.opacity !== 1) { + style.opacity = String(indicator.opacity); + } + return style; +} + +export function getStickyValueTooltip(inherited: boolean, value: string): string { + if (!inherited) { + return value; + } + return value ? `${value} (inherited from previous keyframe)` : 'Inherited from previous keyframe'; +} + +export function getTruncatedCustomUIDisplayValue( + value: string, + rawLength: number, + longValueMode: ResolvedAttributeCustomUI['longValueMode'], +): string { + if (longValueMode === 'truncate' && rawLength >= LONG_VALUE_EXPAND_THRESHOLD) { + return `${value.slice(0, LONG_VALUE_EXPAND_THRESHOLD)}...`; + } + return value; +} + +export function shouldUseCustomUIValueExpansion( + rawLength: number, + longValueMode: ResolvedAttributeCustomUI['longValueMode'], +): boolean { + return longValueMode === 'expand' && rawLength >= LONG_VALUE_EXPAND_THRESHOLD; +} diff --git a/client/src/use/attributeValueColor.ts b/client/src/use/attributeValueColor.ts new file mode 100644 index 00000000..cab2b63c --- /dev/null +++ b/client/src/use/attributeValueColor.ts @@ -0,0 +1,32 @@ +import StyleManager from '../StyleManager'; +import { Attribute } from './AttributeTypes'; + +function getMissingValueColor(attribute?: Attribute) { + return attribute?.valueColors?.['']; +} + +export default function createGetAttributeValueColor(trackStyleManager: StyleManager) { + return (attribute: Attribute, val?: string | number | boolean) => { + if (val === undefined || val === null || val === '') { + if (attribute.noneColor) { + return attribute.noneColor; + } + return getMissingValueColor(attribute) + || attribute.color + || trackStyleManager.typeStyling.value.color(attribute.name); + } + if (attribute.datatype === 'text') { + if (attribute.staticColor) { + if (attribute.color) { + return attribute.color; + } + return trackStyleManager.typeStyling.value.color(attribute.name); + } + const strVal = val.toString(); + if (attribute.valueColors && attribute.valueColors[strVal]) { + return attribute.valueColors[strVal]; + } + } + return trackStyleManager.typeStyling.value.color(val.toString()); + }; +} diff --git a/client/src/use/useAttributes.ts b/client/src/use/useAttributes.ts index 57272e71..3c83968d 100644 --- a/client/src/use/useAttributes.ts +++ b/client/src/use/useAttributes.ts @@ -6,7 +6,8 @@ import { import { cloneDeep } from 'lodash'; import { StringKeyObject } from 'vue-media-annotator/BaseAnnotation'; import * as d3 from 'd3'; -import { StyleManager, Track } from '..'; +import StyleManager, { Track } from '..'; +import createGetAttributeValueColor from './attributeValueColor'; import CameraStore from '../CameraStore'; import { LineChartData } from './useLineChart'; import { @@ -494,29 +495,7 @@ export default function UseAttributes( return null; }); - const getAttributeValueColor = (attribute: Attribute, val?: string | number | boolean) => { - if (val === undefined || val === null || val === '') { - if (attribute.noneColor) { - return attribute.noneColor; - } - return getMissingValueColor(attribute) - || attribute.color - || trackStyleManager.typeStyling.value.color(attribute.name); - } - if (attribute.datatype === 'text') { - if (attribute.staticColor) { - if (attribute.color) { - return attribute.color; - } - return trackStyleManager.typeStyling.value.color(attribute.name); - } - const strVal = val.toString(); - if (attribute.valueColors && attribute.valueColors[strVal]) { - return attribute.valueColors[strVal]; - } - } - return trackStyleManager.typeStyling.value.color(val.toString()); - }; + const getAttributeValueColor = createGetAttributeValueColor(trackStyleManager); const numericalColorScaling = computed(() => { const autoColorIndex: Record string> = {}; diff --git a/docs/Annotation-User-Interface-Overview.md b/docs/Annotation-User-Interface-Overview.md index 9d84c2c5..fc47e8fa 100644 --- a/docs/Annotation-User-Interface-Overview.md +++ b/docs/Annotation-User-Interface-Overview.md @@ -15,3 +15,4 @@ This documentation section provides a reference guide to the annotation interfac * **Image Enhancement** - Adjust the image threshold range. * **[Group Manager](UI-Group-Manager.md)** - Controls for creating, managing, and filtering multi-annotation groups. * **Attributes Details Panel** - Attributes panel used to filter or generate graphs of attributes. + * **[Custom UI](UI-Attributes.md#custom-ui)** - Attribute button shortcuts and live value display for the selected track. diff --git a/docs/UI-Attributes.md b/docs/UI-Attributes.md index 20fd694e..3903edcf 100644 --- a/docs/UI-Attributes.md +++ b/docs/UI-Attributes.md @@ -138,17 +138,107 @@ A specific key shortcut can be assigned to setting the value of an attribute. W * *dialog* - a dialog pops open asking the user for input for the attribute value * *Description* - a text based description of the shortuct. This description is used in the Help dialog to show what all the keyboard shortcuts are. +### Button Shortcuts + +Each attribute shortcut can also expose a button in the **[Custom UI](#custom-ui)** panel on the right side of the screen. + +1. Add or edit a shortcut on the **Shortcuts** tab. +1. Enable **Enable Button Shortcut**. +1. Configure button text, tooltip, prepend/append icons, and button color. + +When a button shortcut is enabled, clicking the button in the Custom UI panel performs the same action as the keyboard shortcut. + ![Edit Attribute Panel](images/Attributes/attributekeyboard.png) In the upper right of the screen the keyboard icon is used to toggle on/off system and attribute shortcuts. The info icon next to it will display a list of possible shortcuts that are set and will use the Description to explain what a shortcut does. +## Custom UI + +The **Custom UI** panel is a context sidebar that shows attribute button shortcuts and, optionally, the current attribute value for the selected track. It is useful for workflows where annotators set attributes frequently without opening the Track Details panel. + +### Enabling the Custom UI Panel + +The panel itself is configured at the dataset level: + +1. Open **Configuration** → **UI Settings** → **Context Bar**. +1. Enable **Custom UI Enabled**. +1. Optionally set: + * *Title* - Panel heading (defaults to `Custom Actions`). + * *Width* - Panel width in pixels. + * *Information* - Markdown help pages shown in the panel. + * *Attribute button order* - Drag to reorder attribute groups in the panel. + +See [Context Bar](UI-Settings.md#contextbar) for more information on context sidebar settings. + +### Configuring an Attribute + +Open the attribute definition editor (==:material-cog:==) and select the **Custom UI** tab. + +#### Visibility + +* *Show in Custom UI* - When disabled, the attribute is hidden from the Custom UI panel even if it has button shortcuts. +* *Show Without Buttons* - Display this attribute in Custom UI even when it has no button shortcuts. Use this to show a read-only value display without assigning shortcut buttons. + +#### Display Value + +* *Display Value* - Show the current attribute value in the Custom UI panel for the selected track. When enabled, additional display options appear below. + +When **Display Value** is on, the value updates as you change the selected track or frame. For detection attributes, the value reflects the attribute on the current frame. + +#### Sticky Value + +Sticky value is available for **detection attributes only**. + +* *Sticky Value* - When the attribute is empty on the current frame, show the last non-empty value from an earlier keyframe on the same track. This is useful for sparse detection attribute data where you want the panel to keep showing the most recent value instead of going blank between keyframes. + +When sticky value is enabled, values inherited from a previous keyframe can be styled separately under **Inherited Value Indicator**: + +* *Bold*, *Italic*, *Underline* +* *Font Color* - Custom color for inherited values +* *Font Size* - Same size, smaller, or larger than the normal value +* *Opacity* - Fade inherited values to distinguish them from values set on the current frame + +Inherited values also show `(inherited from previous keyframe)` in the tooltip when you hover over the value. + +!!! info + + **Sticky Value** in Custom UI is separate from the **Sticky** option under the attribute **Rendering** tab. Rendering sticky affects how values are drawn next to tracks in the annotation view; Custom UI sticky affects the value shown in the Custom UI panel. + +#### Value Display + +These options appear when **Display Value** is enabled. + +* *Value Position* + * *Below buttons* - Value appears under the shortcut buttons (default). + * *Above buttons* - Value appears above the shortcut buttons. + * *In header (inline with title)* - Value appears on the same line as the attribute name. +* *Header Separator* - When value position is **In header**, the character placed between the attribute name and value (`:` or `-`). +* *Header Value Offset* - Horizontal space in pixels between the separator and the value in header mode. +* *Long Value Display* - How values longer than 50 characters are handled: + * *Expand* - Collapse into an expansion panel; click to view the full value. + * *Truncate* - Show the first 50 characters followed by `...`. Hover for the full value in a tooltip. + * *Scroll* - Show the value in a scrollable area. +* *Font Size Multiplier* - Scale the displayed value text size (1 is default). +* *Value Alignment* - Left, center, or right alignment for the value text. +* *Value Color* - Optional text color for the displayed value. When **Attribute Value Color** is enabled, the color comes from the **[Value Colors](#attribute-value-colors)** settings for that attribute (including per-value mappings and gradients). When disabled, choose a fixed custom color. +* *Value Prepend Text* / *Value Append Text* - Static text placed before or after the displayed value (for example, units like `cm` or labels like `Status:`). +* *Empty Value Label* - Text shown when the attribute has no value. Leave blank to show nothing. + +#### Header + +* *Show Header* - Show the attribute name as a heading in the Custom UI panel. +* *Show Description* - Show the attribute description above the buttons in the Custom UI panel. + +### Legacy configurations + +Older configurations may store `displayValue` on individual button shortcut objects. This setting is deprecated and has moved to the attribute's **Custom UI** tab. Existing datasets are migrated automatically when the configuration is loaded; the per-button `displayValue` field is removed on save. ## Attribute Value Colors ![Edit Attribute Value Colors](images/Attributes/AttributeValueColors.png) -Attributes of type **Text** and **Number** can have custom colors configured in the attribute editor under the **Value Colors** tab. These colors affect how attribute values appear in **[Attribute Rendering](UI-AttributeRendering.md)** and **[Attribute Swimlanes](UI-AttributeSwimlanes.md)**. +Attributes of type **Text** and **Number** can have custom colors configured in the attribute editor under the **Value Colors** tab. These colors affect how attribute values appear in **[Attribute Rendering](UI-AttributeRendering.md)**, **[Attribute Swimlanes](UI-AttributeSwimlanes.md)**, and the **[Custom UI](#custom-ui)** panel when value color is set to use attribute value colors. ### Text attributes diff --git a/docs/UI-Settings.md b/docs/UI-Settings.md index 83c037b9..4a4d20cf 100644 --- a/docs/UI-Settings.md +++ b/docs/UI-Settings.md @@ -50,6 +50,8 @@ The ToolBar is used for editing tracks and changing the visualization of tracks. Hide or enable the additional contextual menus on the right side of the screen. +The Context Bar settings also control the **Custom UI** panel, which shows attribute button shortcuts and live attribute values. Enable **Custom UI Enabled** to show the panel, then configure title, width, help text, and attribute group order. Per-attribute display options (value position, sticky value, colors, and more) are configured in the attribute editor under the **Custom UI** tab. See **[Attributes — Custom UI](UI-Attributes.md#custom-ui)** for details. + ## Track Details ![Track Details](images/Configuration/UISettings/TrackDetails.png) diff --git a/scripts/testing/setupAttributeVizTesting.py b/scripts/testing/setupAttributeVizTesting.py new file mode 100644 index 00000000..9a109a21 --- /dev/null +++ b/scripts/testing/setupAttributeVizTesting.py @@ -0,0 +1,160 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "click", +# "girder-client", +# "setuptools", +# ] +# /// +"""Upload sample tracks, attributes, swimlanes, and DiveConfig for attribute viz testing.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import click +import girder_client + +SCRIPT_DIR = Path(__file__).resolve().parent +DATA_DIR = SCRIPT_DIR / "attribute-viz-testing" +DEFAULT_FOLDER_ID = "6a93364715d175ac1169888e" +DEFAULT_VIDEO = Path.home() / "Downloads" / "t.2x.mp4" +FALLBACK_VIDEO = Path.home() / "Downloads" / "tx2.mp4" + + +def login(host: str, port: int, scheme: str, user: str | None, password: str | None, api_key: str | None): + gc = girder_client.GirderClient(host, port=port, apiRoot="girder/api/v1", scheme=scheme) + if api_key: + gc.authenticate(apiKey=api_key) + elif user and password: + gc.authenticate(username=user, password=password) + else: + gc.authenticate(interactive=True) + me = gc.get("/user/me") + if not me: + raise click.ClickException("Authentication failed") + click.echo(f"Authenticated as {me['login']}") + return gc + + +def resolve_video(video: Path | None) -> Path: + candidates = [video, DEFAULT_VIDEO, FALLBACK_VIDEO, Path.home() / "Downloads" / "SampleVideo.mp4"] + for candidate in candidates: + if candidate and candidate.exists(): + return candidate + raise click.ClickException( + "No video found. Pass --video or place t.2x.mp4 / tx2.mp4 in ~/Downloads" + ) + + +def upload_video_if_needed(gc: girder_client.GirderClient, folder_id: str, video: Path) -> None: + items = list(gc.listItem(folder_id)) + if items: + click.echo(f"Folder already has {len(items)} item(s); skipping video upload") + return + click.echo(f"Uploading video: {video}") + gc.uploadFileToFolder(folder_id, str(video), filename=video.name) + gc.addMetadataToFolder( + folder_id, + {"fps": 30, "annotate": True, "type": "video", "originalFPS": 30}, + ) + click.echo("Running postprocess…") + gc.post(f"dive_rpc/postprocess/{folder_id}", data={"skipTranscoding": True}) + + +def upload_tracks(gc: girder_client.GirderClient, folder_id: str, tracks_path: Path) -> None: + with tracks_path.open() as fh: + tracks = json.load(fh) + click.echo(f"Uploading tracks from {tracks_path.name}") + gc.sendRestRequest( + "POST", + "dive_annotation/process_json", + parameters={"folderId": folder_id, "additive": False}, + json=tracks, + jsonResp=False, + ) + + +def patch_json(gc: girder_client.GirderClient, route: str, payload: dict) -> dict: + return gc.sendRestRequest("PATCH", route.lstrip("/"), json=payload) + + +@click.command() +@click.option("--folder-id", default=DEFAULT_FOLDER_ID, show_default=True) +@click.option("--host", default="127.0.0.1", show_default=True) +@click.option("--port", default=8010, show_default=True, type=int) +@click.option("--scheme", default="http", show_default=True) +@click.option("--user", default="admin", show_default=True, help="Girder username (local default)") +@click.option("--password", default="letmein", show_default=True, help="Girder password (local default)") +@click.option("--api-key", default=None, help="Girder API key (overrides user/password)") +@click.option("--video", type=click.Path(path_type=Path), default=None, help="Video file to upload") +@click.option("--skip-video", is_flag=True, help="Skip video upload/postprocess") +def main( + folder_id: str, + host: str, + port: int, + scheme: str, + user: str | None, + password: str | None, + api_key: str | None, + video: Path | None, + skip_video: bool, +) -> None: + """Set up AttributeVizTesting dataset with sample annotations and DiveConfig.""" + folder = None + gc = login(host, port, scheme, user, password, api_key) + + try: + folder = gc.getFolder(folder_id) + except Exception as exc: + raise click.ClickException(f"Could not access folder {folder_id}: {exc}") from exc + click.echo(f"Target folder: {folder['name']} ({folder_id})") + + if not skip_video: + video_path = resolve_video(video) + if video_path != FALLBACK_VIDEO and not video_path.name.startswith("tx2"): + click.echo( + click.style( + f"Note: using {video_path.name} (tx2.mp4 was not found; t.2x.mp4 is likely the intended file)", + fg="yellow", + ) + ) + upload_video_if_needed(gc, folder_id, video_path) + + tracks_path = DATA_DIR / "tracks.json" + attributes_path = DATA_DIR / "attributes.json" + swimlanes_path = DATA_DIR / "swimlanes.json" + config_path = DATA_DIR / "dive-config.json" + + for path in (tracks_path, attributes_path, swimlanes_path, config_path): + if not path.exists(): + raise click.ClickException(f"Missing data file: {path}") + + upload_tracks(gc, folder_id, tracks_path) + + with attributes_path.open() as fh: + attributes = json.load(fh) + click.echo("Patching attribute definitions (customUI, value colors, buttons)…") + patch_json(gc, f"/dive_dataset/{folder_id}/attributes", attributes) + + with swimlanes_path.open() as fh: + swimlanes = json.load(fh) + click.echo("Patching swimlane graphs…") + patch_json(gc, f"/dive_dataset/{folder_id}/swimlanes", swimlanes) + + with config_path.open() as fh: + config = json.load(fh) + click.echo("Patching DiveConfig (UI visibility, customUI panel, timeline layout)…") + patch_json(gc, f"/dive_dataset/{folder_id}/configuration", config) + + click.echo( + click.style( + f"\nDone! Open http://{host}:{port}/dive?dataset={folder_id} to test attribute viz features.", + fg="green", + ) + ) + + +if __name__ == "__main__": + main() diff --git a/server/dive_utils/models.py b/server/dive_utils/models.py index 49f8fa9a..efb0e736 100644 --- a/server/dive_utils/models.py +++ b/server/dive_utils/models.py @@ -136,7 +136,36 @@ class ButtonShortcut(BaseModel): iconAppend: Optional[str] iconPrepend: Optional[str] buttonColor: Optional[str] + displayValue: Optional[bool] # deprecated: use AttributeCustomUI.displayValue + + +class AttributeCustomUIStickyIndicator(BaseModel): + bold: Optional[bool] + italic: Optional[bool] + underline: Optional[bool] + highlightColor: Optional[str] + fontSizeScale: Optional[float] + opacity: Optional[float] + + +class AttributeCustomUI(BaseModel): + enabled: Optional[bool] + showWithoutButtons: Optional[bool] displayValue: Optional[bool] + stickyValue: Optional[bool] + stickyValueIndicator: Optional[AttributeCustomUIStickyIndicator] + valuePosition: Optional[Literal['below', 'above', 'header']] + longValueMode: Optional[Literal['truncate', 'expand', 'scroll']] + emptyValueLabel: Optional[str] + headerValueSeparator: Optional[Literal[':', '-']] + headerValueOffset: Optional[float] + valuePrepend: Optional[str] + valueAppend: Optional[str] + showHeader: Optional[bool] + valueFontSizeScale: Optional[float] + valueAlign: Optional[Literal['left', 'center', 'right']] + valueColor: Optional[str] + showDescription: Optional[bool] class ShortcutAttributeOptions(BaseModel): @@ -241,6 +270,7 @@ class Attribute(BaseModel): valueOrder: Optional[Dict[str, int]] displayText: Optional[str] metadataLink: Optional[MetadataLinkSettings] + customUI: Optional[AttributeCustomUI] class AttributeNumberFilter(BaseModel):