Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dive-dsa",
"version": "1.11.49",
"version": "1.11.50",
"author": {
"name": "Kitware, Inc.",
"email": "Bryon.Lewis@kitware.com"
Expand Down
79 changes: 6 additions & 73 deletions client/src/components/controls/AttributeSwimlaneGraph.vue
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,7 @@ export default defineComponent({

/**
* Frames whose appearance depends on the playhead. Covers both ends of every subsection
* because a boundary landing on the current frame both highlights its icon and forces
* that icon to be drawn even when it would otherwise be skipped as part of a cluster.
* because a boundary landing on the current frame highlights its icon.
*/
const highlightFrames = computed(() => {
const frames = new Set<number>();
Expand Down Expand Up @@ -346,25 +345,13 @@ export default defineComponent({
const scale = x.value;
const renderMode = props.displaySettings?.renderMode;
const drawSymbols = showSymbols.value;
const currentFrame = frame.value;
const hovered = hoveredZone.value;
const visibleStart = startFrame_.value;
const visibleEnd = endFrame_.value;
// Discrete boxes are widened to minWidth, so keep a margin before culling
const cullPadding = Math.max(1, (visibleEnd - visibleStart) * 0.02);
const cullStart = visibleStart - cullPadding;
const cullEnd = visibleEnd + cullPadding;

// Frame indicators are queued and drawn after every segment fill so that neither a
// neighbouring subsection nor a deferred merged fill can paint over them.
const pendingIcons: {
xFrame: number;
xPosition: number;
yPosition: number;
symbol: 'diamond' | 'arrows';
highlighted: boolean;
}[] = [];

barList.forEach((bar) => {
const barWidth = Math.max(bar.right - bar.left, bar.minWidth);
ctx.strokeStyle = bar.color;
Expand All @@ -375,20 +362,6 @@ export default defineComponent({
ctx.fillRect(bar.left, bar.top, barWidth, barHeight);
}

const iconMidline = bar.top + barHeight / 2;
let lastIconEdge = -Infinity;
// Adjacent subsections resolving to the same color are merged into one fillRect
let pendingColor: string | null = null;
let pendingLeft = 0;
let pendingRight = 0;
const flushPending = () => {
if (pendingColor !== null && pendingRight > pendingLeft) {
ctx.fillStyle = pendingColor;
ctx.fillRect(pendingLeft, bar.top, pendingRight - pendingLeft, barHeight);
}
pendingColor = null;
};

bar.subSections.forEach((sub, index) => {
const dragging = dragData.isDragging
&& index === dragData.draggedSubsectionIndex
Expand All @@ -408,59 +381,19 @@ export default defineComponent({
const width = renderMode === 'discrete'
? Math.max(right - left, bar.minWidth)
: right - left;
const fillColor = sub.color || 'white';
if (pendingColor === fillColor && left <= pendingRight + 0.5) {
pendingRight = Math.max(pendingRight, left + width);
} else {
flushPending();
pendingColor = fillColor;
pendingLeft = left;
pendingRight = left + width;
}
ctx.fillStyle = sub.color || 'white';
ctx.fillRect(left, bar.top, width, barHeight);
if (drawSymbols) {
// Icons are ~10px wide, so only draw one per cluster unless it marks the
// playhead or the hovered zone, which must always stay visible. Begin and end
// markers are gated independently so a dense begin cluster cannot drop an end
// diamond that sits further right.
const beginHighlighted = sub.begin === currentFrame || sub.begin === hovered;
const endHighlighted = sub.end === currentFrame || sub.end === hovered;
const tryQueueIcon = (
xFrame: number,
xPosition: number,
symbol: 'diamond' | 'arrows',
highlighted: boolean,
) => {
if (!highlighted && xPosition < lastIconEdge) {
return;
}
pendingIcons.push({
xFrame, xPosition, yPosition: iconMidline, symbol, highlighted,
});
lastIconEdge = Math.max(lastIconEdge, xPosition + 6);
};
const symbol = dragData.isDragging && sub.begin === dragData.draggedFrame ? 'arrows' : 'diamond';
if (!sub.singleVal || renderMode !== 'segments') {
const symbol = dragData.isDragging && sub.begin === dragData.draggedFrame ? 'arrows' : 'diamond';
tryQueueIcon(sub.begin, left, symbol, beginHighlighted);
drawIcon(ctx, sub.begin, left, bar.top + barHeight / 2, symbol);
}
if (renderMode === 'segments') {
const endSymbol = dragData.isDragging && sub.end === dragData.draggedFrame ? 'arrows' : 'diamond';
tryQueueIcon(sub.end, right, endSymbol, endHighlighted);
drawIcon(ctx, sub.end, right, bar.top + barHeight / 2, endSymbol);
}
}
});
flushPending();
});

// Highlighted indicators go last so the playhead and hovered markers sit above their neighbours
pendingIcons.forEach((icon) => {
if (!icon.highlighted) {
drawIcon(ctx, icon.xFrame, icon.xPosition, icon.yPosition, icon.symbol);
}
});
pendingIcons.forEach((icon) => {
if (icon.highlighted) {
drawIcon(ctx, icon.xFrame, icon.xPosition, icon.yPosition, icon.symbol);
}
});
};

Expand Down
112 changes: 112 additions & 0 deletions client/src/use/useAttributes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/// <reference types="jest" />
import { ref } from 'vue';
import type Vuetify from 'vuetify/lib';
import StyleManager from '../StyleManager';
import Track from '../track';
import type CameraStore from '../CameraStore';
import UseAttributes from './useAttributes';
import { Attribute, DisplayTrackFilterSettings } from './AttributeTypes';

const vuetify = {
preset: {
theme: {
themes: {
dark: {
accent: 'blue',
},
},
},
},
} as Vuetify;

const regionAttribute: Attribute = {
belongs: 'detection',
datatype: 'text',
name: 'Region',
key: 'detection_Region',
values: ['Scolon', 'Rectum'],
valueColors: { Scolon: '#FFA628', Rectum: '#F6E428' },
};

function makeTrack(keyframes: [number, string | undefined][]) {
const track = new Track(0, {
meta: {},
begin: 0,
end: 0,
confidencePairs: [['fullframe', 1]],
});
keyframes.forEach(([frame, region]) => {
track.setFeature({
frame,
bounds: [0, 0, 640, 510],
keyframe: true,
interpolate: false,
});
if (region !== undefined) {
track.setFeatureAttribute(frame, 'Region', region);
}
});
return track;
}

function getRegionSwimlane(track: Track, renderMode?: DisplayTrackFilterSettings['renderMode']) {
const attributes = UseAttributes({
markChangesPending: jest.fn(),
trackStyleManager: new StyleManager({ markChangesPending: jest.fn(), vuetify }),
selectedTrackId: ref(0),
cameraStore: { getAnyPossibleTrack: () => track } as unknown as CameraStore,
pendingSaveCount: ref(0),
login: 'admin',
});
attributes.loadAttributes({ detection_Region: regionAttribute });
attributes.loadSwimlanes({
Region: {
enabled: true,
name: 'Region',
default: false,
filter: {
type: 'key',
active: true,
value: true,
appliedTo: ['Region'],
},
displaySettings: {
display: 'static',
trackFilter: ['all'],
displayFrameIndicators: true,
renderMode,
},
settings: {},
},
});
return attributes.attributeSwimlaneData.value.Region.Region.data;
}

describe('useAttributes swimlane data', () => {
it('starts a new subsection for a keyframe that repeats the previous value', () => {
const track = makeTrack([
[0, undefined],
[4500, 'Scolon'],
[16000, 'Rectum'],
[18623, 'Rectum'],
[23600, 'Rectum'],
[24133, undefined],
]);
const data = getRegionSwimlane(track);
expect(data.map((sub) => sub.begin)).toEqual([4500, 16000, 18623, 23600]);
expect(data.map((sub) => sub.end)).toEqual([16000, 18623, 23600, 23601]);
expect(data.map((sub) => sub.value)).toEqual(['Scolon', 'Rectum', 'Rectum', 'Rectum']);
expect(data[1].color).toBe('#F6E428');
expect(data[2].color).toBe('#F6E428');
});

it('keeps one subsection per keyframe in segments mode', () => {
const track = makeTrack([
[10, 'Scolon'],
[20, 'Scolon'],
[30, 'Rectum'],
]);
const data = getRegionSwimlane(track, 'segments');
expect(data.map((sub) => [sub.begin, sub.end])).toEqual([[10, 20], [20, 30], [30, 31]]);
});
});
14 changes: 5 additions & 9 deletions client/src/use/useAttributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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';
Expand Down Expand Up @@ -601,7 +601,7 @@ export default function UseAttributes(
baseAttribute?: Attribute,
settings?: Record<string, SwimlaneGraphSettings>,
colorScalingNumbers?: Record<string, (data: string | number | boolean) => string>,
renderMode?: SwimlaneGraph['displaySettings']['renderMode'],
renderMode?: DisplayTrackFilterSettings['renderMode'],
): string | boolean | number | undefined | null {
if (key === 'userAttributes') {
return null;
Expand Down Expand Up @@ -667,12 +667,8 @@ export default function UseAttributes(
}
const subSections = valueMap[key].data;
const openSection = subSections.length ? subSections[subSections.length - 1] : undefined;
if (openSection !== undefined && openSection.value === val) {
// Same value continues, so grow the open run rather than starting a new subsection
openSection.end = frame + 1;
openSection.singleVal = (openSection.end - openSection.begin) === 1 ? true : undefined;
return val;
}
// Every keyframe starts its own subsection, even when the value repeats, so each one
// gets a frame indicator. Same-colored neighbours are merged into one fill when drawn.
if (openSection !== undefined) {
openSection.end = frame;
openSection.singleVal = (openSection.end - openSection.begin) === 1 ? true : undefined;
Expand All @@ -695,7 +691,7 @@ export default function UseAttributes(
filter: SwimlaneFilter,
settings?: Record<string, SwimlaneGraphSettings>,
colorScalingNumbers?: Record<string, (data: string | number | boolean) => string>,
renderMode?: SwimlaneGraph['displaySettings']['renderMode'],
renderMode?: DisplayTrackFilterSettings['renderMode'],
) {
// So we need to generate a list of all of the attributres for the length of the track
const valueMap: Record<string, SwimlaneAttribute> = { };
Expand Down
Loading