diff --git a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx index 4ee3927254..817f207140 100644 --- a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx +++ b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx @@ -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'; @@ -17,6 +18,10 @@ import { orderedReplayAnswerEntries, ReplayTaskOrder, } from './taskOrdering'; +import { + getUniformTimelineMetrics, + TimelineMode, +} from './timelineLayout'; const LABEL_GAP = 25; const CHARACTER_SIZE = 8; @@ -26,9 +31,11 @@ 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(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); @@ -36,26 +43,43 @@ export function AllTasksTimeline({ 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; @@ -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)) { @@ -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); @@ -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; @@ -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) => { @@ -195,22 +225,38 @@ export function AllTasksTimeline({ browsedAwayList.map((browse, i) => ) ); }); - }, [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 ( -
- - setHoveredTaskIdentifier(null)} style={{ width, height: maxHeight, overflow: 'visible' }}> - {tasks.map((t) => t.line)} - {nonHoveredTasks.map((t) => t.label)} - {hoveredTask?.label} - {browsedAway} - - -
+ + 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} + + ); } diff --git a/src/analysis/individualStudy/replay/SingleTask.tsx b/src/analysis/individualStudy/replay/SingleTask.tsx index 6de5493ac2..a0e16c7180 100644 --- a/src/analysis/individualStudy/replay/SingleTask.tsx +++ b/src/analysis/individualStudy/replay/SingleTask.tsx @@ -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, @@ -87,13 +109,16 @@ export function SingleTask({ return ( navigateToTrial(trialOrder, participantId, studyId, condition)} - onMouseEnter={onHover} - onMouseLeave={onHoverEnd} + onPointerEnter={onHover} + onPointerLeave={onHoverEnd} + onPointerCancel={onHoverEnd} style={{ cursor: 'pointer' }} > ({ + Box: ({ children }: { children: ReactNode }) =>
{children}
, Center: ({ children }: { children: ReactNode }) =>
{children}
, Stack: ({ children }: { children: ReactNode }) =>
{children}
, Tooltip: ({ children, label }: { children: ReactNode; label?: ReactNode }) => ( @@ -137,6 +138,7 @@ describe('SingleTask', () => { , ); expect(html).toContain('icon-check'); + expect(html).toContain('fill="#bfe8c6"'); }); test('shows x icon for an incorrect response', () => { @@ -144,6 +146,7 @@ describe('SingleTask', () => { , ); expect(html).toContain('icon-x'); + expect(html).toContain('fill="#f3c1c1"'); }); test('shows an accessible grey checkmark for an unknown response', () => { @@ -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', () => { @@ -277,6 +281,39 @@ describe('AllTasksTimeline', () => { expect(html).toContain(' { + 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( + , + ); + + expect(html).toContain('width:136px'); + expect(html).not.toContain('Browsed away'); + }); + test('handles all tracked window event types without error', () => { const participant = makeParticipant({ answers: { diff --git a/src/analysis/individualStudy/replay/timelineLayout.spec.ts b/src/analysis/individualStudy/replay/timelineLayout.spec.ts new file mode 100644 index 0000000000..d183bd8515 --- /dev/null +++ b/src/analysis/individualStudy/replay/timelineLayout.spec.ts @@ -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); + }); +}); diff --git a/src/analysis/individualStudy/replay/timelineLayout.ts b/src/analysis/individualStudy/replay/timelineLayout.ts new file mode 100644 index 0000000000..147b10a4fc --- /dev/null +++ b/src/analysis/individualStudy/replay/timelineLayout.ts @@ -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, + }; +} diff --git a/src/analysis/individualStudy/table/TableView.tsx b/src/analysis/individualStudy/table/TableView.tsx index 1220a9329f..86ede4591e 100644 --- a/src/analysis/individualStudy/table/TableView.tsx +++ b/src/analysis/individualStudy/table/TableView.tsx @@ -20,6 +20,7 @@ import { ParticipantRejectModal } from '../ParticipantRejectModal'; import { participantName } from '../../../utils/participantName'; import { AllTasksTimeline } from '../replay/AllTasksTimeline'; import { ReplayTaskOrder } from '../replay/taskOrdering'; +import { TimelineMode } from '../replay/timelineLayout'; import { youtubeReadableDuration } from '../../../utils/humanReadableDuration'; import { getSequenceFlatMap } from '../../../utils/getSequenceFlatMap'; import { MetaCell } from './MetaCell'; @@ -56,6 +57,7 @@ export function TableView({ const { studyId } = useParams(); const [checked, setChecked] = useState({}); const [taskOrder, setTaskOrder] = useState('sequence'); + const [timelineMode, setTimelineMode] = useState('time'); useEffect(() => { const newSelectedParticipants = Object.keys(checked).filter((v) => checked[v]) @@ -268,6 +270,14 @@ export function TableView({ enablePagination: false, enableRowVirtualization: true, mantinePaperProps: { style: { maxHeight: '100%', display: 'flex', flexDirection: 'column' } }, + mantineDetailPanelProps: { + style: { + flex: '1 1 100%', + maxWidth: '100%', + minWidth: 0, + width: '100%', + }, + }, layoutMode: 'grid', renderDetailPanel: ({ row }) => { const r = row.original; @@ -277,7 +287,7 @@ export function TableView({ } return ( - + ); }, defaultColumn: { @@ -301,6 +311,15 @@ export function TableView({ ]} /> + setTimelineMode(value as TimelineMode)} + data={[ + { value: 'time', label: 'Time' }, + { value: 'uniform', label: 'Uniform' }, + ]} + /> ), diff --git a/src/analysis/individualStudy/table/tests/TableView.spec.tsx b/src/analysis/individualStudy/table/tests/TableView.spec.tsx index db8cd306b1..eb5583cd4c 100644 --- a/src/analysis/individualStudy/table/tests/TableView.spec.tsx +++ b/src/analysis/individualStudy/table/tests/TableView.spec.tsx @@ -58,7 +58,9 @@ vi.mock('@tabler/icons-react', () => ({ })); vi.mock('../../replay/AllTasksTimeline', () => ({ - AllTasksTimeline: ({ taskOrder }: { taskOrder: string }) =>
AllTasksTimeline
, + AllTasksTimeline: ({ taskOrder, timelineMode }: { taskOrder: string; timelineMode: string }) => ( +
AllTasksTimeline
+ ), })); vi.mock('../../ParticipantRejectModal', () => ({ @@ -142,9 +144,11 @@ describe('TableView', () => { expect(html).toContain('Order'); expect(html).toContain('Sequence'); expect(html).toContain('Answer time'); + expect(html).toContain('Time'); + expect(html).toContain('Uniform'); }); - test('uses sequence ordering by default for participant timelines', () => { + test('uses sequence ordering and time sizing by default for participant timelines', () => { const participant = makeParticipant(); renderToStaticMarkup( , @@ -152,6 +156,7 @@ describe('TableView', () => { const html = renderToStaticMarkup(capturedTableOptions!.renderDetailPanel({ row: { original: participant } })); expect(html).toContain('data-task-order="sequence"'); + expect(html).toContain('data-timeline-mode="time"'); }); // ── Status column ──────────────────────────────────────────────────────────