Skip to content

Commit 3a10acd

Browse files
authored
Merge pull request #1342 from revisit-studies/codex/port-pr-1262
Restore uniform participant timeline mode
2 parents 0534a7a + a652410 commit 3a10acd

7 files changed

Lines changed: 228 additions & 37 deletions

File tree

src/analysis/individualStudy/replay/AllTasksTimeline.tsx

Lines changed: 76 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import {
33
} from 'react';
44
import * as d3 from 'd3';
55
import {
6-
Center, Stack, Tooltip, Text,
6+
Box, Stack, Tooltip, Text,
77
} from '@mantine/core';
8+
import { useResizeObserver } from '@mantine/hooks';
89
import { ParticipantData } from '../../../storage/types';
910
import { SingleTaskLabelLines } from './SingleTaskLabelLines';
1011
import { SingleTask } from './SingleTask';
@@ -17,6 +18,10 @@ import {
1718
orderedReplayAnswerEntries,
1819
ReplayTaskOrder,
1920
} from './taskOrdering';
21+
import {
22+
getUniformTimelineMetrics,
23+
TimelineMode,
24+
} from './timelineLayout';
2025

2126
const LABEL_GAP = 25;
2227
const CHARACTER_SIZE = 8;
@@ -26,36 +31,55 @@ const margin = {
2631
};
2732

2833
export function AllTasksTimeline({
29-
participantData, width, studyId, studyConfig, maxLength, taskOrder = 'sequence',
30-
}: { participantData: ParticipantData, width: number, studyId: string, studyConfig: StudyConfig | undefined, maxLength: number | undefined, taskOrder?: ReplayTaskOrder }) {
34+
participantData, width, studyId, studyConfig, maxLength, taskOrder = 'sequence', timelineMode = 'time',
35+
}: { participantData: ParticipantData, width: number, studyId: string, studyConfig: StudyConfig | undefined, maxLength: number | undefined, taskOrder?: ReplayTaskOrder, timelineMode?: TimelineMode }) {
3136
const [hoveredTaskIdentifier, setHoveredTaskIdentifier] = useState<string | null>(null);
37+
const [timelineContainerRef, { width: containerWidth }] = useResizeObserver();
38+
const availableWidth = Math.max(containerWidth || width, 0);
3239

3340
const percentComplete = useMemo(() => {
3441
const incompleteEntries = Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0);
3542

3643
return (Object.entries(participantData.answers).length - incompleteEntries.length) / (Object.entries(participantData.answers).length);
3744
}, [participantData.answers]);
3845

46+
const timelineWidth = useMemo(() => {
47+
if (timelineMode === 'time') {
48+
return availableWidth;
49+
}
50+
51+
return getUniformTimelineMetrics({
52+
availableWidth,
53+
taskCount: Object.entries(participantData.answers || {}).length,
54+
margin,
55+
}).timelineWidth;
56+
}, [availableWidth, participantData.answers, timelineMode]);
57+
3958
const xScale = useMemo(() => {
59+
if (timelineMode === 'uniform') {
60+
return d3.scaleLinear([margin.left, timelineWidth - margin.right]).domain([0, Math.max(Object.entries(participantData.answers || {}).length, 1)]).clamp(true);
61+
}
62+
4063
const allStartTimes = Object.values(participantData.answers || {}).filter((answer) => answer.startTime).map((answer) => [answer.startTime, answer.endTime]).flat();
4164

4265
const extent = d3.extent(allStartTimes) as [number, number];
4366

44-
const scale = d3.scaleLinear([margin.left, (width * percentComplete - (percentComplete !== 1 ? 0 : margin.right))]).domain([extent[0], maxLength ? extent[0] + maxLength : extent[1]]).clamp(true);
67+
const scale = d3.scaleLinear([margin.left, (timelineWidth * percentComplete - (percentComplete !== 1 ? 0 : margin.right))]).domain([extent[0], maxLength ? extent[0] + maxLength : extent[1]]).clamp(true);
4568

4669
return scale;
47-
}, [maxLength, participantData.answers, percentComplete, width]);
70+
}, [maxLength, participantData.answers, percentComplete, timelineMode, timelineWidth]);
4871

4972
const incompleteXScale = useMemo(() => {
50-
const scale = d3.scaleLinear([width * percentComplete, width - margin.right]).domain([0, Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0).length]).clamp(true);
73+
const scale = d3.scaleLinear([timelineWidth * percentComplete, timelineWidth - margin.right]).domain([0, Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0).length]).clamp(true);
5174

5275
return scale;
53-
}, [participantData.answers, percentComplete, width]);
76+
}, [participantData.answers, percentComplete, timelineWidth]);
5477

5578
const maxHeight = useMemo(() => {
5679
const incompleteEntries = Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0).sort(compareReplayAnswerEntries);
5780
const incompleteEntryIndexes = new Map(incompleteEntries.map(([identifier], index) => [identifier, index]));
5881
const sortedEntries = orderedReplayAnswerEntries(participantData.answers, taskOrder);
82+
const entryIndexes = new Map(sortedEntries.map(([identifier], index) => [identifier, index]));
5983

6084
let currentHeight = 0;
6185
let _maxHeight = 0;
@@ -65,10 +89,10 @@ export function AllTasksTimeline({
6589

6690
// Check if the previous entry overlaps with the current entry
6791
const prev = i > 0 ? sortedEntries[i - currentHeight - 1] : null;
68-
const prevScale = prev && prev[1].startTime ? xScale : incompleteXScale;
69-
const prevStart = prev ? prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0;
70-
const scale = answer.startTime === 0 ? incompleteXScale : xScale;
71-
const scaleStart = answer.startTime ? answer.startTime : incompleteEntryIndexes.get(identifier) ?? 0;
92+
const prevScale = timelineMode === 'uniform' || (prev && prev[1].startTime) ? xScale : incompleteXScale;
93+
const prevStart = prev ? timelineMode === 'uniform' ? entryIndexes.get(prev[0]) ?? 0 : prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0;
94+
const scale = timelineMode === 'uniform' || answer.startTime !== 0 ? xScale : incompleteXScale;
95+
const scaleStart = timelineMode === 'uniform' ? entryIndexes.get(identifier) ?? 0 : answer.startTime ? answer.startTime : incompleteEntryIndexes.get(identifier) ?? 0;
7296

7397
// If the previous entry overlaps with the current entry , increase the height
7498
if (prev && prev[0].length * (CHARACTER_SIZE + 1) + prevScale(prevStart) > scale(scaleStart)) {
@@ -83,7 +107,7 @@ export function AllTasksTimeline({
83107
});
84108

85109
return (_maxHeight + 1) * LABEL_GAP + margin.top + margin.bottom;
86-
}, [incompleteXScale, participantData.answers, taskOrder, xScale]);
110+
}, [incompleteXScale, participantData.answers, taskOrder, timelineMode, xScale]);
87111

88112
const conditionParam = useMemo(() => {
89113
const parsedConditions = parseConditionParam(participantData.conditions ?? participantData.searchParams?.condition);
@@ -97,19 +121,21 @@ export function AllTasksTimeline({
97121
const incompleteEntries = Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0).sort(compareReplayAnswerEntries);
98122
const incompleteEntryIndexes = new Map(incompleteEntries.map(([identifier], index) => [identifier, index]));
99123
const combined = orderedReplayAnswerEntries(participantData.answers, taskOrder);
124+
const entryIndexes = new Map(combined.map(([identifier], index) => [identifier, index]));
100125

101126
const allElements = combined.map((entry, i) => {
102-
const scale = entry[1].startTime === 0 ? incompleteXScale : xScale;
127+
const scale = timelineMode === 'uniform' || entry[1].startTime !== 0 ? xScale : incompleteXScale;
103128

104129
const [identifier, answer] = entry;
105130

106131
const prev = i > 0 ? combined[i - currentHeight - 1] : null;
107132

108-
const prevScale = prev && prev[1].startTime ? xScale : incompleteXScale;
109-
const prevStart = prev ? prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0;
133+
const prevScale = timelineMode === 'uniform' || (prev && prev[1].startTime) ? xScale : incompleteXScale;
134+
const prevStart = prev ? timelineMode === 'uniform' ? entryIndexes.get(prev[0]) ?? 0 : prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0;
110135
const incompleteEntryIndex = incompleteEntryIndexes.get(identifier) ?? 0;
111-
const scaleStart = answer.startTime ? answer.startTime : incompleteEntryIndex;
112-
const scaleEnd = answer.endTime > 0 ? answer.endTime : incompleteEntryIndex + 1;
136+
const uniformEntryIndex = entryIndexes.get(identifier) ?? 0;
137+
const scaleStart = timelineMode === 'uniform' ? uniformEntryIndex : answer.startTime ? answer.startTime : incompleteEntryIndex;
138+
const scaleEnd = timelineMode === 'uniform' ? uniformEntryIndex + 1 : answer.endTime > 0 ? answer.endTime : incompleteEntryIndex + 1;
113139

114140
if (prev && prev[0].length * (CHARACTER_SIZE + 1) + prevScale(prevStart) > scale(scaleStart)) {
115141
currentHeight += 1;
@@ -163,10 +189,14 @@ export function AllTasksTimeline({
163189
});
164190

165191
return allElements;
166-
}, [participantData.answers, participantData.participantId, incompleteXScale, xScale, studyConfig, maxHeight, studyId, conditionParam, hoveredTaskIdentifier, taskOrder]);
192+
}, [participantData.answers, participantData.participantId, incompleteXScale, xScale, studyConfig, maxHeight, studyId, conditionParam, hoveredTaskIdentifier, taskOrder, timelineMode]);
167193

168194
// Find entries of someone browsing away. Show them
169195
const browsedAway = useMemo(() => {
196+
if (timelineMode === 'uniform') {
197+
return [];
198+
}
199+
170200
const sortedEntries = Object.entries(participantData.answers || {}).sort((a, b) => a[1].startTime - b[1].startTime);
171201

172202
return sortedEntries.map((entry) => {
@@ -195,22 +225,38 @@ export function AllTasksTimeline({
195225
browsedAwayList.map((browse, i) => <Tooltip withinPortal key={i} label="Browsed away"><rect x={xScale(browse[0])} width={Math.max(0, xScale(browse[1]) - xScale(browse[0]))} y={maxHeight - 5} height={10} /></Tooltip>)
196226
);
197227
});
198-
}, [xScale, maxHeight, participantData.answers]);
228+
}, [xScale, maxHeight, participantData.answers, timelineMode]);
199229

200230
const hoveredTask = tasks.find((task) => task.identifier === hoveredTaskIdentifier);
201-
const nonHoveredTasks = tasks.filter((task) => task.identifier !== hoveredTaskIdentifier);
202231

203232
return (
204-
<Center>
205-
<Stack gap={15} style={{ width: '100%' }}>
206-
<svg onMouseLeave={() => setHoveredTaskIdentifier(null)} style={{ width, height: maxHeight, overflow: 'visible' }}>
207-
{tasks.map((t) => t.line)}
208-
{nonHoveredTasks.map((t) => t.label)}
209-
{hoveredTask?.label}
210-
{browsedAway}
211-
</svg>
212-
</Stack>
213-
</Center>
233+
<Box
234+
ref={timelineContainerRef}
235+
style={{
236+
width: '100%',
237+
maxWidth: '100%',
238+
minWidth: 0,
239+
overflowX: timelineMode === 'uniform' ? 'auto' : 'visible',
240+
overflowY: 'visible',
241+
}}
242+
>
243+
<svg
244+
onMouseLeave={() => setHoveredTaskIdentifier(null)}
245+
onPointerLeave={() => setHoveredTaskIdentifier(null)}
246+
onPointerCancel={() => setHoveredTaskIdentifier(null)}
247+
style={{
248+
width: timelineWidth,
249+
height: maxHeight,
250+
display: 'block',
251+
overflow: 'visible',
252+
}}
253+
>
254+
{tasks.map((t) => t.line)}
255+
{tasks.filter((t) => t.identifier !== hoveredTaskIdentifier).map((t) => t.label)}
256+
{browsedAway}
257+
{hoveredTask?.label}
258+
</svg>
259+
</Box>
214260

215261
);
216262
}

src/analysis/individualStudy/replay/SingleTask.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,28 @@ const LABEL_MAX_WIDTH = 160;
1818
const ICON_SIZE = 14;
1919
const ICON_GAP = 2;
2020

21+
function taskFill({
22+
incomplete,
23+
answerStatus,
24+
}: {
25+
incomplete: boolean,
26+
answerStatus: ComponentAnswerStatus | null,
27+
}) {
28+
if (incomplete) {
29+
return '#e9ecef';
30+
}
31+
32+
if (answerStatus === 'correct') {
33+
return '#bfe8c6';
34+
}
35+
36+
if (answerStatus === 'incorrect') {
37+
return '#f3c1c1';
38+
}
39+
40+
return 'lightgray';
41+
}
42+
2143
export function SingleTask({
2244
xScale,
2345
identifier,
@@ -87,13 +109,16 @@ export function SingleTask({
87109
return (
88110
<g
89111
onClick={() => navigateToTrial(trialOrder, participantId, studyId, condition)}
90-
onMouseEnter={onHover}
91-
onMouseLeave={onHoverEnd}
112+
onPointerEnter={onHover}
113+
onPointerLeave={onHoverEnd}
114+
onPointerCancel={onHoverEnd}
92115
style={{ cursor: 'pointer' }}
93116
>
94117
<rect
95-
opacity={1}
96-
fill={isHovered ? 'cornflowerblue' : incomplete ? '#e9ecef' : 'lightgray'}
118+
opacity={isDimmed ? 0.45 : 1}
119+
fill={taskFill({ incomplete, answerStatus })}
120+
stroke={isHovered ? 'cornflowerblue' : undefined}
121+
strokeWidth={isHovered ? 3 : 0}
97122
x={xScale(scaleStart) + TASK_GAP}
98123
width={Math.max(0, xScale(scaleEnd) - xScale(scaleStart) - TASK_GAP * 2)}
99124
y={height - TIMELINE_HEIGHT}

src/analysis/individualStudy/replay/tests/AllTasksTimeline.spec.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { SingleTaskLabelLines } from '../SingleTaskLabelLines';
1616
// ── mocks ────────────────────────────────────────────────────────────────────
1717

1818
vi.mock('@mantine/core', () => ({
19+
Box: ({ children }: { children: ReactNode }) => <div>{children}</div>,
1920
Center: ({ children }: { children: ReactNode }) => <div>{children}</div>,
2021
Stack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
2122
Tooltip: ({ children, label }: { children: ReactNode; label?: ReactNode }) => (
@@ -137,13 +138,15 @@ describe('SingleTask', () => {
137138
<svg><SingleTask {...baseProps} answerStatus="correct" /></svg>,
138139
);
139140
expect(html).toContain('icon-check');
141+
expect(html).toContain('fill="#bfe8c6"');
140142
});
141143

142144
test('shows x icon for an incorrect response', () => {
143145
const html = renderToStaticMarkup(
144146
<svg><SingleTask {...baseProps} answerStatus="incorrect" /></svg>,
145147
);
146148
expect(html).toContain('icon-x');
149+
expect(html).toContain('fill="#f3c1c1"');
147150
});
148151

149152
test('shows an accessible grey checkmark for an unknown response', () => {
@@ -153,6 +156,7 @@ describe('SingleTask', () => {
153156
expect(html).toContain('icon-check');
154157
expect(html).toContain('var(--mantine-color-gray-6)');
155158
expect(html).toContain('Response recorded; correctness not configured.');
159+
expect(html).toContain('fill="lightgray"');
156160
});
157161

158162
test('shows microphone icon when hasAudio', () => {
@@ -277,6 +281,39 @@ describe('AllTasksTimeline', () => {
277281
expect(html).toContain('<rect');
278282
});
279283

284+
test('uses equal-width task slots and omits browsed-away intervals in uniform mode', () => {
285+
const participant = makeParticipant({
286+
answers: {
287+
trial1_0: makeAnswer({
288+
windowEvents: [
289+
[t0 + 1_000, 'visibility', 'hidden'],
290+
[t0 + 3_000, 'visibility', 'visible'],
291+
],
292+
}),
293+
trial2_1: makeAnswer({
294+
componentName: 'trial2',
295+
trialOrder: '1_0',
296+
startTime: t0 + 20_000,
297+
endTime: t0 + 40_000,
298+
}),
299+
},
300+
});
301+
302+
const html = renderToStaticMarkup(
303+
<AllTasksTimeline
304+
participantData={participant}
305+
width={600}
306+
studyId="test-study"
307+
studyConfig={emptyConfig}
308+
maxLength={undefined}
309+
timelineMode="uniform"
310+
/>,
311+
);
312+
313+
expect(html).toContain('width:136px');
314+
expect(html).not.toContain('Browsed away');
315+
});
316+
280317
test('handles all tracked window event types without error', () => {
281318
const participant = makeParticipant({
282319
answers: {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
describe,
3+
expect,
4+
test,
5+
} from 'vitest';
6+
import { getUniformTimelineMetrics } from './timelineLayout';
7+
8+
describe('getUniformTimelineMetrics', () => {
9+
test('fills available width when tasks can be wider than the minimum', () => {
10+
const metrics = getUniformTimelineMetrics({
11+
availableWidth: 640,
12+
taskCount: 5,
13+
margin: { left: 20, right: 20 },
14+
});
15+
16+
expect(metrics.timelineWidth).toBe(640);
17+
expect(metrics.taskWidth).toBe(120);
18+
});
19+
20+
test('uses the minimum task width and expands the timeline when needed', () => {
21+
const metrics = getUniformTimelineMetrics({
22+
availableWidth: 200,
23+
taskCount: 6,
24+
margin: { left: 20, right: 20 },
25+
});
26+
27+
expect(metrics.timelineWidth).toBe(328);
28+
expect(metrics.taskWidth).toBe(48);
29+
});
30+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export type TimelineMode = 'time' | 'uniform';
2+
3+
export const UNIFORM_TASK_MIN_WIDTH = 48;
4+
5+
type TimelineMargin = {
6+
left: number;
7+
right: number;
8+
};
9+
10+
export function getUniformTimelineMetrics({
11+
availableWidth,
12+
taskCount,
13+
margin,
14+
minTaskWidth = UNIFORM_TASK_MIN_WIDTH,
15+
}: {
16+
availableWidth: number;
17+
taskCount: number;
18+
margin: TimelineMargin;
19+
minTaskWidth?: number;
20+
}) {
21+
const safeTaskCount = Math.max(taskCount, 1);
22+
const availableInnerWidth = Math.max(0, availableWidth - margin.left - margin.right);
23+
const timelineInnerWidth = Math.max(availableInnerWidth, safeTaskCount * minTaskWidth);
24+
25+
return {
26+
taskWidth: timelineInnerWidth / safeTaskCount,
27+
timelineWidth: timelineInnerWidth + margin.left + margin.right,
28+
};
29+
}

0 commit comments

Comments
 (0)