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
106 changes: 76 additions & 30 deletions src/analysis/individualStudy/replay/AllTasksTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import {
} from 'react';
import * as d3 from 'd3';
import {
Center, Stack, Tooltip, Text,
Box, Stack, Tooltip, Text,
} from '@mantine/core';
import { useResizeObserver } from '@mantine/hooks';
import { ParticipantData } from '../../../storage/types';
import { SingleTaskLabelLines } from './SingleTaskLabelLines';
import { SingleTask } from './SingleTask';
Expand All @@ -17,6 +18,10 @@ import {
orderedReplayAnswerEntries,
ReplayTaskOrder,
} from './taskOrdering';
import {
getUniformTimelineMetrics,
TimelineMode,
} from './timelineLayout';

const LABEL_GAP = 25;
const CHARACTER_SIZE = 8;
Expand All @@ -26,36 +31,55 @@ const margin = {
};

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

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

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

const timelineWidth = useMemo(() => {
if (timelineMode === 'time') {
return availableWidth;
}

return getUniformTimelineMetrics({
availableWidth,
taskCount: Object.entries(participantData.answers || {}).length,
margin,
}).timelineWidth;
}, [availableWidth, participantData.answers, timelineMode]);

const xScale = useMemo(() => {
if (timelineMode === 'uniform') {
return d3.scaleLinear([margin.left, timelineWidth - margin.right]).domain([0, Math.max(Object.entries(participantData.answers || {}).length, 1)]).clamp(true);
}

const allStartTimes = Object.values(participantData.answers || {}).filter((answer) => answer.startTime).map((answer) => [answer.startTime, answer.endTime]).flat();

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

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

return scale;
}, [maxLength, participantData.answers, percentComplete, width]);
}, [maxLength, participantData.answers, percentComplete, timelineMode, timelineWidth]);

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

return scale;
}, [participantData.answers, percentComplete, width]);
}, [participantData.answers, percentComplete, timelineWidth]);

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

let currentHeight = 0;
let _maxHeight = 0;
Expand All @@ -65,10 +89,10 @@ export function AllTasksTimeline({

// Check if the previous entry overlaps with the current entry
const prev = i > 0 ? sortedEntries[i - currentHeight - 1] : null;
const prevScale = prev && prev[1].startTime ? xScale : incompleteXScale;
const prevStart = prev ? prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0;
const scale = answer.startTime === 0 ? incompleteXScale : xScale;
const scaleStart = answer.startTime ? answer.startTime : incompleteEntryIndexes.get(identifier) ?? 0;
const prevScale = timelineMode === 'uniform' || (prev && prev[1].startTime) ? xScale : incompleteXScale;
const prevStart = prev ? timelineMode === 'uniform' ? entryIndexes.get(prev[0]) ?? 0 : prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0;
const scale = timelineMode === 'uniform' || answer.startTime !== 0 ? xScale : incompleteXScale;
const scaleStart = timelineMode === 'uniform' ? entryIndexes.get(identifier) ?? 0 : answer.startTime ? answer.startTime : incompleteEntryIndexes.get(identifier) ?? 0;

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

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

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

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

const [identifier, answer] = entry;

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

const prevScale = prev && prev[1].startTime ? xScale : incompleteXScale;
const prevStart = prev ? prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0;
const prevScale = timelineMode === 'uniform' || (prev && prev[1].startTime) ? xScale : incompleteXScale;
const prevStart = prev ? timelineMode === 'uniform' ? entryIndexes.get(prev[0]) ?? 0 : prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0;
const incompleteEntryIndex = incompleteEntryIndexes.get(identifier) ?? 0;
const scaleStart = answer.startTime ? answer.startTime : incompleteEntryIndex;
const scaleEnd = answer.endTime > 0 ? answer.endTime : incompleteEntryIndex + 1;
const uniformEntryIndex = entryIndexes.get(identifier) ?? 0;
const scaleStart = timelineMode === 'uniform' ? uniformEntryIndex : answer.startTime ? answer.startTime : incompleteEntryIndex;
const scaleEnd = timelineMode === 'uniform' ? uniformEntryIndex + 1 : answer.endTime > 0 ? answer.endTime : incompleteEntryIndex + 1;

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

return allElements;
}, [participantData.answers, participantData.participantId, incompleteXScale, xScale, studyConfig, maxHeight, studyId, conditionParam, hoveredTaskIdentifier, taskOrder]);
}, [participantData.answers, participantData.participantId, incompleteXScale, xScale, studyConfig, maxHeight, studyId, conditionParam, hoveredTaskIdentifier, taskOrder, timelineMode]);

// Find entries of someone browsing away. Show them
const browsedAway = useMemo(() => {
if (timelineMode === 'uniform') {
return [];
}

const sortedEntries = Object.entries(participantData.answers || {}).sort((a, b) => a[1].startTime - b[1].startTime);

return sortedEntries.map((entry) => {
Expand Down Expand Up @@ -195,22 +225,38 @@ export function AllTasksTimeline({
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>)
);
});
}, [xScale, maxHeight, participantData.answers]);
}, [xScale, maxHeight, participantData.answers, timelineMode]);

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

return (
<Center>
<Stack gap={15} style={{ width: '100%' }}>
<svg onMouseLeave={() => setHoveredTaskIdentifier(null)} style={{ width, height: maxHeight, overflow: 'visible' }}>
{tasks.map((t) => t.line)}
{nonHoveredTasks.map((t) => t.label)}
{hoveredTask?.label}
{browsedAway}
</svg>
</Stack>
</Center>
<Box
ref={timelineContainerRef}
style={{
width: '100%',
maxWidth: '100%',
minWidth: 0,
overflowX: timelineMode === 'uniform' ? 'auto' : 'visible',
overflowY: 'visible',
}}
>
<svg
onMouseLeave={() => setHoveredTaskIdentifier(null)}
onPointerLeave={() => setHoveredTaskIdentifier(null)}
onPointerCancel={() => setHoveredTaskIdentifier(null)}
style={{
width: timelineWidth,
height: maxHeight,
display: 'block',
overflow: 'visible',
}}
>
{tasks.map((t) => t.line)}
{tasks.filter((t) => t.identifier !== hoveredTaskIdentifier).map((t) => t.label)}
{browsedAway}
{hoveredTask?.label}
</svg>
</Box>

);
}
33 changes: 29 additions & 4 deletions src/analysis/individualStudy/replay/SingleTask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,28 @@ const LABEL_MAX_WIDTH = 160;
const ICON_SIZE = 14;
const ICON_GAP = 2;

function taskFill({
incomplete,
answerStatus,
}: {
incomplete: boolean,
answerStatus: ComponentAnswerStatus | null,
}) {
if (incomplete) {
return '#e9ecef';
}

if (answerStatus === 'correct') {
return '#bfe8c6';
}

if (answerStatus === 'incorrect') {
return '#f3c1c1';
}

return 'lightgray';
}

export function SingleTask({
xScale,
identifier,
Expand Down Expand Up @@ -87,13 +109,16 @@ export function SingleTask({
return (
<g
onClick={() => navigateToTrial(trialOrder, participantId, studyId, condition)}
onMouseEnter={onHover}
onMouseLeave={onHoverEnd}
onPointerEnter={onHover}
onPointerLeave={onHoverEnd}
onPointerCancel={onHoverEnd}
style={{ cursor: 'pointer' }}
>
<rect
opacity={1}
fill={isHovered ? 'cornflowerblue' : incomplete ? '#e9ecef' : 'lightgray'}
opacity={isDimmed ? 0.45 : 1}
fill={taskFill({ incomplete, answerStatus })}
stroke={isHovered ? 'cornflowerblue' : undefined}
strokeWidth={isHovered ? 3 : 0}
x={xScale(scaleStart) + TASK_GAP}
width={Math.max(0, xScale(scaleEnd) - xScale(scaleStart) - TASK_GAP * 2)}
y={height - TIMELINE_HEIGHT}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { SingleTaskLabelLines } from '../SingleTaskLabelLines';
// ── mocks ────────────────────────────────────────────────────────────────────

vi.mock('@mantine/core', () => ({
Box: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Center: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Stack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Tooltip: ({ children, label }: { children: ReactNode; label?: ReactNode }) => (
Expand Down Expand Up @@ -137,13 +138,15 @@ describe('SingleTask', () => {
<svg><SingleTask {...baseProps} answerStatus="correct" /></svg>,
);
expect(html).toContain('icon-check');
expect(html).toContain('fill="#bfe8c6"');
});

test('shows x icon for an incorrect response', () => {
const html = renderToStaticMarkup(
<svg><SingleTask {...baseProps} answerStatus="incorrect" /></svg>,
);
expect(html).toContain('icon-x');
expect(html).toContain('fill="#f3c1c1"');
});

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

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

test('uses equal-width task slots and omits browsed-away intervals in uniform mode', () => {
const participant = makeParticipant({
answers: {
trial1_0: makeAnswer({
windowEvents: [
[t0 + 1_000, 'visibility', 'hidden'],
[t0 + 3_000, 'visibility', 'visible'],
],
}),
trial2_1: makeAnswer({
componentName: 'trial2',
trialOrder: '1_0',
startTime: t0 + 20_000,
endTime: t0 + 40_000,
}),
},
});

const html = renderToStaticMarkup(
<AllTasksTimeline
participantData={participant}
width={600}
studyId="test-study"
studyConfig={emptyConfig}
maxLength={undefined}
timelineMode="uniform"
/>,
);

expect(html).toContain('width:136px');
expect(html).not.toContain('Browsed away');
});

test('handles all tracked window event types without error', () => {
const participant = makeParticipant({
answers: {
Expand Down
30 changes: 30 additions & 0 deletions src/analysis/individualStudy/replay/timelineLayout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {
describe,
expect,
test,
} from 'vitest';
import { getUniformTimelineMetrics } from './timelineLayout';

describe('getUniformTimelineMetrics', () => {
test('fills available width when tasks can be wider than the minimum', () => {
const metrics = getUniformTimelineMetrics({
availableWidth: 640,
taskCount: 5,
margin: { left: 20, right: 20 },
});

expect(metrics.timelineWidth).toBe(640);
expect(metrics.taskWidth).toBe(120);
});

test('uses the minimum task width and expands the timeline when needed', () => {
const metrics = getUniformTimelineMetrics({
availableWidth: 200,
taskCount: 6,
margin: { left: 20, right: 20 },
});

expect(metrics.timelineWidth).toBe(328);
expect(metrics.taskWidth).toBe(48);
});
});
29 changes: 29 additions & 0 deletions src/analysis/individualStudy/replay/timelineLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export type TimelineMode = 'time' | 'uniform';

export const UNIFORM_TASK_MIN_WIDTH = 48;

type TimelineMargin = {
left: number;
right: number;
};

export function getUniformTimelineMetrics({
availableWidth,
taskCount,
margin,
minTaskWidth = UNIFORM_TASK_MIN_WIDTH,
}: {
availableWidth: number;
taskCount: number;
margin: TimelineMargin;
minTaskWidth?: number;
}) {
const safeTaskCount = Math.max(taskCount, 1);
const availableInnerWidth = Math.max(0, availableWidth - margin.left - margin.right);
const timelineInnerWidth = Math.max(availableInnerWidth, safeTaskCount * minTaskWidth);

return {
taskWidth: timelineInnerWidth / safeTaskCount,
timelineWidth: timelineInnerWidth + margin.left + margin.right,
};
}
Loading
Loading