33} from 'react' ;
44import * as d3 from 'd3' ;
55import {
6- Center , Stack , Tooltip , Text ,
6+ Box , Stack , Tooltip , Text ,
77} from '@mantine/core' ;
8+ import { useResizeObserver } from '@mantine/hooks' ;
89import { ParticipantData } from '../../../storage/types' ;
910import { SingleTaskLabelLines } from './SingleTaskLabelLines' ;
1011import { 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
2126const LABEL_GAP = 25 ;
2227const CHARACTER_SIZE = 8 ;
@@ -26,36 +31,55 @@ const margin = {
2631} ;
2732
2833export 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}
0 commit comments