@adriansteffan/reactive 0.0.43 → 0.0.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -1
- package/README.md +106 -3
- package/dist/{mod-D6W3wq3h.js → mod-D6DlS3ur.js} +6482 -6114
- package/dist/mod.d.ts +54 -2
- package/dist/reactive.es.js +42 -33
- package/dist/reactive.umd.js +38 -36
- package/dist/style.css +1 -1
- package/dist/{web-B1hJOwit.js → web-D7VcCd-t.js} +1 -1
- package/dist/{web-BYSmfdtR.js → web-o3I0sgwu.js} +1 -1
- package/package.json +1 -1
- package/src/components/canvasblock.tsx +112 -70
- package/src/components/checkdevice.tsx +15 -0
- package/src/components/enterfullscreen.tsx +5 -3
- package/src/components/exitfullscreen.tsx +4 -1
- package/src/components/experimentprovider.tsx +7 -2
- package/src/components/experimentrunner.tsx +66 -52
- package/src/components/microphonecheck.tsx +3 -0
- package/src/components/mobilefilepermission.tsx +3 -0
- package/src/components/plaininput.tsx +17 -0
- package/src/components/prolificending.tsx +3 -0
- package/src/components/quest.tsx +57 -0
- package/src/components/storeui.tsx +15 -11
- package/src/components/text.tsx +11 -0
- package/src/components/upload.tsx +56 -271
- package/src/mod.tsx +1 -0
- package/src/utils/bytecode.ts +50 -0
- package/src/utils/simulation.ts +268 -0
- package/src/utils/upload.ts +299 -0
- package/template/README.md +59 -0
- package/template/backend/src/backend.ts +1 -0
- package/template/package.json +2 -0
- package/template/simulate.ts +15 -0
- package/template/src/Experiment.tsx +58 -5
- package/template/src/main.tsx +1 -1
- package/template/tsconfig.json +2 -3
- package/tsconfig.json +1 -0
- package/vite.config.ts +1 -1
package/package.json
CHANGED
|
@@ -4,6 +4,8 @@ import { useRef, useEffect, useMemo, useCallback } from 'react';
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
compileTimeline,
|
|
7
|
+
advanceToNextContent,
|
|
8
|
+
applyMetadata,
|
|
7
9
|
TimelineItem,
|
|
8
10
|
UnifiedBytecodeInstruction,
|
|
9
11
|
ExecuteContentInstruction,
|
|
@@ -12,6 +14,18 @@ import {
|
|
|
12
14
|
CanvasResultData,
|
|
13
15
|
} from '../utils/bytecode';
|
|
14
16
|
import { BaseComponentProps, isFullscreen, now } from '../utils/common';
|
|
17
|
+
import { registerSimulation, ParticipantState } from '../utils/simulation';
|
|
18
|
+
|
|
19
|
+
export type SlideSimulatorResult = {
|
|
20
|
+
key: string | null;
|
|
21
|
+
reactionTime: number | null;
|
|
22
|
+
participantState: ParticipantState;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SlideSimulator = (
|
|
26
|
+
slide: Record<string, any>,
|
|
27
|
+
participant: ParticipantState,
|
|
28
|
+
) => SlideSimulatorResult;
|
|
15
29
|
|
|
16
30
|
interface CanvasSlide {
|
|
17
31
|
draw: (ctx: CanvasRenderingContext2D, width: number, height: number) => void;
|
|
@@ -25,6 +39,7 @@ interface CanvasSlide {
|
|
|
25
39
|
| Record<string, any>
|
|
26
40
|
| ((data?: RefinedTrialData[], store?: Store) => Record<string, any>);
|
|
27
41
|
nestMetadata?: boolean;
|
|
42
|
+
simulate?: SlideSimulator;
|
|
28
43
|
}
|
|
29
44
|
|
|
30
45
|
type DynamicCanvasSlideGenerator = (data: RefinedTrialData[], store: Store) => CanvasSlide;
|
|
@@ -227,74 +242,41 @@ export default function CanvasBlock({
|
|
|
227
242
|
}, [instructions, resolveSlideContent, clearCanvas]);
|
|
228
243
|
|
|
229
244
|
const processControlFlow = useCallback(() => {
|
|
230
|
-
let foundContentToExecute = false;
|
|
231
245
|
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
232
246
|
animationFrameRef.current = null;
|
|
233
247
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
instructionPointerRef.current = markerIndex;
|
|
243
|
-
} else {
|
|
244
|
-
console.error(`Marker ${currentInstruction.marker} not found`);
|
|
245
|
-
instructionPointerRef.current++;
|
|
246
|
-
}
|
|
247
|
-
} else {
|
|
248
|
-
instructionPointerRef.current++;
|
|
249
|
-
}
|
|
250
|
-
continue;
|
|
251
|
-
|
|
252
|
-
case 'UpdateStore': {
|
|
253
|
-
storeRef.current = {
|
|
254
|
-
...storeRef.current,
|
|
255
|
-
...currentInstruction.fun(storeRef.current, dataRef.current),
|
|
256
|
-
};
|
|
257
|
-
instructionPointerRef.current++;
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
case 'ExecuteContent': {
|
|
261
|
-
foundContentToExecute = true;
|
|
262
|
-
if (canvasRef.current) {
|
|
263
|
-
const currentSlide = resolveSlideContent(currentInstruction);
|
|
264
|
-
if (currentSlide) {
|
|
265
|
-
drawSlideInternal(currentSlide);
|
|
266
|
-
const displayDuration = currentSlide.displayDuration ?? Infinity;
|
|
267
|
-
const responseTimeLimit = currentSlide.responseTimeLimit ?? Infinity;
|
|
268
|
-
if (displayDuration !== Infinity || responseTimeLimit !== Infinity) {
|
|
269
|
-
animationFrameRef.current = requestAnimationFrame(tick);
|
|
270
|
-
}
|
|
271
|
-
} else {
|
|
272
|
-
console.error(
|
|
273
|
-
'Failed to resolve slide content during control flow:',
|
|
274
|
-
currentInstruction,
|
|
275
|
-
);
|
|
276
|
-
instructionPointerRef.current++;
|
|
277
|
-
foundContentToExecute = false;
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
} else {
|
|
281
|
-
console.error('Canvas element not found during control flow.');
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
break;
|
|
285
|
-
}
|
|
286
|
-
default:
|
|
287
|
-
console.warn('Unknown instruction type during control flow:', currentInstruction);
|
|
288
|
-
instructionPointerRef.current++;
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
248
|
+
const pointer = advanceToNextContent(
|
|
249
|
+
{ instructions, markers },
|
|
250
|
+
instructionPointerRef.current,
|
|
251
|
+
() => storeRef.current,
|
|
252
|
+
() => dataRef.current,
|
|
253
|
+
(s) => { storeRef.current = s; },
|
|
254
|
+
);
|
|
255
|
+
instructionPointerRef.current = pointer;
|
|
292
256
|
|
|
293
|
-
if (
|
|
294
|
-
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
295
|
-
animationFrameRef.current = null;
|
|
257
|
+
if (pointer >= instructions.length) {
|
|
296
258
|
updateStore(storeRef.current);
|
|
297
259
|
next(dataRef.current);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!canvasRef.current) {
|
|
264
|
+
console.error('Canvas element not found during control flow.');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const currentSlide = resolveSlideContent(instructions[pointer]);
|
|
269
|
+
if (currentSlide) {
|
|
270
|
+
drawSlideInternal(currentSlide);
|
|
271
|
+
const displayDuration = currentSlide.displayDuration ?? Infinity;
|
|
272
|
+
const responseTimeLimit = currentSlide.responseTimeLimit ?? Infinity;
|
|
273
|
+
if (displayDuration !== Infinity || responseTimeLimit !== Infinity) {
|
|
274
|
+
animationFrameRef.current = requestAnimationFrame(tick);
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
console.error('Failed to resolve slide content during control flow:', instructions[pointer]);
|
|
278
|
+
instructionPointerRef.current++;
|
|
279
|
+
processControlFlow();
|
|
298
280
|
}
|
|
299
281
|
}, [instructions, markers, resolveSlideContent, drawSlideInternal, tick, next]);
|
|
300
282
|
|
|
@@ -326,14 +308,7 @@ export default function CanvasBlock({
|
|
|
326
308
|
reactionTime: responseData ? responseData.reactionTime : null,
|
|
327
309
|
} as CanvasResultData;
|
|
328
310
|
|
|
329
|
-
|
|
330
|
-
typeof slide.metadata === 'function'
|
|
331
|
-
? slide.metadata(dataRef.current, storeRef.current)
|
|
332
|
-
: slide.metadata;
|
|
333
|
-
|
|
334
|
-
if (slide.nestMetadata) {
|
|
335
|
-
trialData = { ...trialData, metadata: metadata };
|
|
336
|
-
} else trialData = { ...metadata, ...trialData };
|
|
311
|
+
trialData = applyMetadata(trialData, slide, dataRef.current, storeRef.current);
|
|
337
312
|
|
|
338
313
|
dataRef.current.push(trialData);
|
|
339
314
|
}
|
|
@@ -524,3 +499,70 @@ export default function CanvasBlock({
|
|
|
524
499
|
</div>
|
|
525
500
|
);
|
|
526
501
|
}
|
|
502
|
+
|
|
503
|
+
// --- Default simulator ---
|
|
504
|
+
|
|
505
|
+
registerSimulation('CanvasBlock', (trialProps, experimentState, simulators, participant) => {
|
|
506
|
+
const timeline = trialProps.timeline;
|
|
507
|
+
if (!timeline) return { responseData: [], participantState: participant };
|
|
508
|
+
|
|
509
|
+
const bytecode = compileTimeline(timeline);
|
|
510
|
+
const innerData: CanvasResultData[] = [];
|
|
511
|
+
let innerStore: Store = { ...(experimentState.store || {}) };
|
|
512
|
+
let currentTime = 0;
|
|
513
|
+
let slideNumber = 0;
|
|
514
|
+
|
|
515
|
+
const getStore = () => innerStore;
|
|
516
|
+
const getData = () => innerData as RefinedTrialData[];
|
|
517
|
+
const onUpdateStore = (s: Store) => { innerStore = s; };
|
|
518
|
+
|
|
519
|
+
let pointer = advanceToNextContent(bytecode, 0, getStore, getData, onUpdateStore);
|
|
520
|
+
|
|
521
|
+
while (pointer < bytecode.instructions.length) {
|
|
522
|
+
let slide = (bytecode.instructions[pointer] as ExecuteContentInstruction).content;
|
|
523
|
+
if (typeof slide === 'function') slide = slide(innerData, innerStore);
|
|
524
|
+
|
|
525
|
+
const sim = slide.simulate || simulators.respondToSlide;
|
|
526
|
+
const result = sim(slide, participant);
|
|
527
|
+
participant = result.participantState;
|
|
528
|
+
|
|
529
|
+
if (!slide.ignoreData) {
|
|
530
|
+
const duration = slide.displayDuration || result.reactionTime || 1000;
|
|
531
|
+
|
|
532
|
+
let td: CanvasResultData = {
|
|
533
|
+
index: pointer,
|
|
534
|
+
trialNumber: slideNumber,
|
|
535
|
+
start: currentTime,
|
|
536
|
+
end: currentTime + duration,
|
|
537
|
+
duration,
|
|
538
|
+
key: result.key,
|
|
539
|
+
reactionTime: result.reactionTime,
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
td = applyMetadata(td, slide, innerData, innerStore);
|
|
543
|
+
|
|
544
|
+
innerData.push(td);
|
|
545
|
+
currentTime += duration;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
slideNumber++;
|
|
549
|
+
pointer = advanceToNextContent(bytecode, pointer + 1, getStore, getData, onUpdateStore);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return { responseData: innerData, participantState: participant, storeUpdates: innerStore, duration: currentTime };
|
|
553
|
+
}, {
|
|
554
|
+
respondToSlide: (slide: any, participant: any) => {
|
|
555
|
+
const keys = slide.allowedKeys;
|
|
556
|
+
if (keys === true) {
|
|
557
|
+
return { key: ' ', reactionTime: 200 + Math.random() * 600, participantState: participant };
|
|
558
|
+
}
|
|
559
|
+
if (Array.isArray(keys) && keys.length > 0) {
|
|
560
|
+
return {
|
|
561
|
+
key: keys[Math.floor(Math.random() * keys.length)],
|
|
562
|
+
reactionTime: 200 + Math.random() * 600,
|
|
563
|
+
participantState: participant,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return { key: null, reactionTime: null, participantState: participant };
|
|
567
|
+
},
|
|
568
|
+
});
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { BaseComponentProps } from '../mod';
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { registerSimulation } from '../utils/simulation';
|
|
4
|
+
|
|
5
|
+
registerSimulation('CheckDevice', (_trialProps, _experimentState, _simulators, participant) => {
|
|
6
|
+
const deviceInfo = {
|
|
7
|
+
windowWidth: 1920, windowHeight: 1080, screenWidth: 1920, screenHeight: 1080,
|
|
8
|
+
browser: 'Simulated', browserVersion: '1.0', isMobile: false,
|
|
9
|
+
operatingSystem: 'Simulated', hasWebAudio: true, hasFullscreen: true,
|
|
10
|
+
hasWebcam: true, hasMicrophone: true,
|
|
11
|
+
};
|
|
12
|
+
return {
|
|
13
|
+
responseData: deviceInfo,
|
|
14
|
+
participantState: participant,
|
|
15
|
+
storeUpdates: { _reactiveDeviceInfo: deviceInfo },
|
|
16
|
+
};
|
|
17
|
+
}, {});
|
|
3
18
|
|
|
4
19
|
interface DeviceInfo {
|
|
5
20
|
windowWidth: number;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
1
|
+
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
2
|
import { BaseComponentProps, getPlatform, isFullscreen } from '../utils/common';
|
|
3
|
+
import { registerSimulation, noopSimulate } from '../utils/simulation';
|
|
3
4
|
import Text from '../components/text';
|
|
4
|
-
|
|
5
5
|
import { StatusBar } from '@capacitor/status-bar';
|
|
6
6
|
import { ImmersiveMode } from '@adriansteffan/immersive-mode';
|
|
7
7
|
import { Capacitor } from '@capacitor/core';
|
|
8
8
|
|
|
9
|
+
registerSimulation('EnterFullscreen', noopSimulate, {});
|
|
10
|
+
|
|
9
11
|
export default function EnterFullscreen({
|
|
10
12
|
content,
|
|
11
13
|
buttonText,
|
|
@@ -28,7 +30,7 @@ export default function EnterFullscreen({
|
|
|
28
30
|
|
|
29
31
|
const [isWaiting, setIsWaiting] = useState(false);
|
|
30
32
|
const listenerFallbackActive = useRef(false);
|
|
31
|
-
const timeoutId = useRef<
|
|
33
|
+
const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
32
34
|
|
|
33
35
|
const cancelPendingTimeout = useCallback(() => {
|
|
34
36
|
if (timeoutId.current) {
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { useCallback, useRef, useEffect } from 'react';
|
|
2
2
|
import { BaseComponentProps, getPlatform, isFullscreen } from '../utils/common';
|
|
3
|
+
import { registerSimulation, noopSimulate } from '../utils/simulation';
|
|
3
4
|
import { StatusBar } from '@capacitor/status-bar';
|
|
4
5
|
import { ImmersiveMode } from '@adriansteffan/immersive-mode';
|
|
5
6
|
import { Capacitor } from '@capacitor/core';
|
|
6
7
|
|
|
8
|
+
registerSimulation('ExitFullscreen', noopSimulate, {});
|
|
9
|
+
|
|
7
10
|
export default function ExitFullscreen({
|
|
8
11
|
next,
|
|
9
12
|
delayMs = 0,
|
|
@@ -12,7 +15,7 @@ export default function ExitFullscreen({
|
|
|
12
15
|
} & BaseComponentProps) {
|
|
13
16
|
|
|
14
17
|
const listenerFallbackActive = useRef(false);
|
|
15
|
-
const timeoutId = useRef<
|
|
18
|
+
const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
16
19
|
|
|
17
20
|
const cancelPendingTimeout = useCallback(() => {
|
|
18
21
|
if (timeoutId.current) {
|
|
@@ -3,11 +3,14 @@ import { ToastContainer } from 'react-toastify';
|
|
|
3
3
|
|
|
4
4
|
const queryClient = new QueryClient();
|
|
5
5
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
6
|
-
import { ReactNode } from 'react';
|
|
6
|
+
import { createContext, ReactNode, useContext } from 'react';
|
|
7
7
|
import { SettingsScreen } from './settingsscreen';
|
|
8
8
|
import { Param } from '../utils/common';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
const HybridSimulationContext = createContext(false);
|
|
11
|
+
export const useHybridSimulationDisabled = () => useContext(HybridSimulationContext);
|
|
12
|
+
|
|
13
|
+
export default function ExperimentProvider({ children, disableSettings, disableHybridSimulation }: { children: ReactNode, disableSettings?: boolean, disableHybridSimulation?: boolean }) {
|
|
11
14
|
|
|
12
15
|
if (window.location.pathname.endsWith('/settings') && !disableSettings) {
|
|
13
16
|
return (
|
|
@@ -20,7 +23,9 @@ export default function ExperimentProvider({ children, disableSettings }: { chil
|
|
|
20
23
|
|
|
21
24
|
return (
|
|
22
25
|
<QueryClientProvider client={queryClient}>
|
|
26
|
+
<HybridSimulationContext.Provider value={!!disableHybridSimulation}>
|
|
23
27
|
{children}
|
|
28
|
+
</HybridSimulationContext.Provider>
|
|
24
29
|
<ToastContainer
|
|
25
30
|
position='top-center'
|
|
26
31
|
autoClose={3000}
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
|
|
3
3
|
import '../index.css';
|
|
4
|
-
import { ExperimentConfig, Store, Param, now } from '../utils/common';
|
|
4
|
+
import { ExperimentConfig, Store, Param, now, getParam } from '../utils/common';
|
|
5
5
|
import { useCallback, useEffect, useMemo, useRef, useState, ComponentType } from 'react';
|
|
6
6
|
import {
|
|
7
7
|
compileTimeline,
|
|
8
|
+
advanceToNextContent,
|
|
9
|
+
applyMetadata,
|
|
8
10
|
TimelineItem,
|
|
9
11
|
RefinedTrialData,
|
|
10
12
|
ComponentResultData,
|
|
11
13
|
} from '../utils/bytecode';
|
|
14
|
+
import {
|
|
15
|
+
ParticipantState,
|
|
16
|
+
SimulateFunction,
|
|
17
|
+
resolveSimulation,
|
|
18
|
+
} from '../utils/simulation';
|
|
19
|
+
import { useHybridSimulationDisabled } from './experimentprovider';
|
|
12
20
|
|
|
13
21
|
import Upload from './upload';
|
|
14
22
|
import Text from './text';
|
|
@@ -59,6 +67,8 @@ interface RuntimeComponentContent {
|
|
|
59
67
|
| ((data: RefinedTrialData[], store: Store) => Record<string, any>);
|
|
60
68
|
nestMetadata?: boolean;
|
|
61
69
|
props?: Record<string, any> | ((data: RefinedTrialData[], store: Store) => Record<string, any>);
|
|
70
|
+
simulate?: SimulateFunction | boolean;
|
|
71
|
+
simulators?: Record<string, any>;
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
function isRuntimeComponentContent(content: any): content is RuntimeComponentContent {
|
|
@@ -72,12 +82,15 @@ export default function ExperimentRunner({
|
|
|
72
82
|
},
|
|
73
83
|
components = {},
|
|
74
84
|
questions = {},
|
|
85
|
+
hybridParticipant,
|
|
75
86
|
}: {
|
|
76
87
|
timeline: TimelineItem[];
|
|
77
88
|
config?: ExperimentConfig;
|
|
78
89
|
components?: ComponentsMap;
|
|
79
90
|
questions?: ComponentsMap;
|
|
91
|
+
hybridParticipant?: ParticipantState;
|
|
80
92
|
}) {
|
|
93
|
+
const disableHybridSimulation = useHybridSimulationDisabled();
|
|
81
94
|
const trialByteCode = useMemo(() => {
|
|
82
95
|
return compileTimeline(timeline);
|
|
83
96
|
}, [timeline]);
|
|
@@ -137,6 +150,15 @@ export default function ExperimentRunner({
|
|
|
137
150
|
const lastTrialEndTimeRef = useRef(now());
|
|
138
151
|
const experimentStoreRef = useRef<Store>({});
|
|
139
152
|
|
|
153
|
+
const simulationMode =
|
|
154
|
+
(!disableHybridSimulation && getParam('hybridSimulation', false, 'boolean'))
|
|
155
|
+
? 'hybrid' as const
|
|
156
|
+
: 'none' as const;
|
|
157
|
+
|
|
158
|
+
const participantRef = useRef<ParticipantState>(hybridParticipant || {});
|
|
159
|
+
// Guards against duplicate simulation in React strict mode
|
|
160
|
+
const lastSimulatedPointerRef = useRef(-1);
|
|
161
|
+
|
|
140
162
|
const componentsMap = { ...defaultComponents, ...components };
|
|
141
163
|
const customQuestionsMap: ComponentsMap = { ...defaultCustomQuestions, ...questions };
|
|
142
164
|
|
|
@@ -180,14 +202,7 @@ export default function ExperimentRunner({
|
|
|
180
202
|
responseData: componentResponseData,
|
|
181
203
|
};
|
|
182
204
|
|
|
183
|
-
|
|
184
|
-
typeof content.metadata === 'function'
|
|
185
|
-
? content.metadata(dataRef.current, experimentStoreRef.current)
|
|
186
|
-
: content.metadata;
|
|
187
|
-
|
|
188
|
-
if (content.nestMetadata) {
|
|
189
|
-
trialData = { ...trialData, metadata: metadata };
|
|
190
|
-
} else trialData = { ...metadata, ...trialData };
|
|
205
|
+
trialData = applyMetadata(trialData, content, dataRef.current, experimentStoreRef.current);
|
|
191
206
|
|
|
192
207
|
dataRef.current = [...dataRef.current, trialData];
|
|
193
208
|
setTotalTrialsCompleted((prevCount) => prevCount + 1);
|
|
@@ -199,49 +214,17 @@ export default function ExperimentRunner({
|
|
|
199
214
|
}
|
|
200
215
|
}
|
|
201
216
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const markerIndex = trialByteCode.markers[nextInstruction.marker];
|
|
212
|
-
if (markerIndex !== undefined) {
|
|
213
|
-
nextPointer = markerIndex;
|
|
214
|
-
continue;
|
|
215
|
-
} else {
|
|
216
|
-
console.error(`Marker ${nextInstruction.marker} not found`);
|
|
217
|
-
nextPointer++;
|
|
218
|
-
}
|
|
219
|
-
} else {
|
|
220
|
-
nextPointer++;
|
|
221
|
-
}
|
|
222
|
-
break;
|
|
223
|
-
|
|
224
|
-
case 'UpdateStore':
|
|
225
|
-
updateStore(nextInstruction.fun(experimentStoreRef.current, dataRef.current));
|
|
226
|
-
nextPointer++;
|
|
227
|
-
break;
|
|
228
|
-
|
|
229
|
-
case 'ExecuteContent':
|
|
230
|
-
foundNextContent = true;
|
|
231
|
-
lastTrialEndTimeRef.current = now();
|
|
232
|
-
setInstructionPointer(nextPointer);
|
|
233
|
-
return;
|
|
234
|
-
|
|
235
|
-
default:
|
|
236
|
-
console.error('Unknown instruction type encountered:', nextInstruction);
|
|
237
|
-
nextPointer++;
|
|
238
|
-
break;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (!foundNextContent) {
|
|
243
|
-
setInstructionPointer(nextPointer);
|
|
217
|
+
const nextPointer = advanceToNextContent(
|
|
218
|
+
trialByteCode,
|
|
219
|
+
instructionPointer + 1,
|
|
220
|
+
() => experimentStoreRef.current,
|
|
221
|
+
() => dataRef.current,
|
|
222
|
+
(s) => updateStore(s),
|
|
223
|
+
);
|
|
224
|
+
if (nextPointer < trialByteCode.instructions.length) {
|
|
225
|
+
lastTrialEndTimeRef.current = now();
|
|
244
226
|
}
|
|
227
|
+
setInstructionPointer(nextPointer);
|
|
245
228
|
}
|
|
246
229
|
|
|
247
230
|
const collectRefreshRate = useCallback((callback: (refreshRate: number | null) => void) => {
|
|
@@ -341,8 +324,39 @@ export default function ExperimentRunner({
|
|
|
341
324
|
|
|
342
325
|
const currentInstruction = trialByteCode.instructions[instructionPointer];
|
|
343
326
|
|
|
327
|
+
const shouldSimulate = useMemo(() => {
|
|
328
|
+
if (simulationMode === 'none') return false;
|
|
329
|
+
if (currentInstruction?.type !== 'ExecuteContent') return false;
|
|
330
|
+
const content = currentInstruction.content;
|
|
331
|
+
if (!isRuntimeComponentContent(content)) return false;
|
|
332
|
+
if (content.simulate === false) return false;
|
|
333
|
+
return !!content.simulate || !!content.simulators;
|
|
334
|
+
}, [instructionPointer, simulationMode, currentInstruction]);
|
|
335
|
+
|
|
336
|
+
useEffect(() => {
|
|
337
|
+
if (!shouldSimulate) return;
|
|
338
|
+
if (lastSimulatedPointerRef.current === instructionPointer) return;
|
|
339
|
+
lastSimulatedPointerRef.current = instructionPointer;
|
|
340
|
+
|
|
341
|
+
(async () => {
|
|
342
|
+
const content = (currentInstruction as any).content;
|
|
343
|
+
const { trialProps, simulateFn, simulators } = resolveSimulation(content, dataRef.current, experimentStoreRef.current);
|
|
344
|
+
|
|
345
|
+
const result = await simulateFn(
|
|
346
|
+
trialProps,
|
|
347
|
+
{ data: dataRef.current, store: experimentStoreRef.current },
|
|
348
|
+
simulators,
|
|
349
|
+
participantRef.current,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
participantRef.current = result.participantState;
|
|
353
|
+
if (result.storeUpdates) updateStore(result.storeUpdates);
|
|
354
|
+
next(result.responseData);
|
|
355
|
+
})();
|
|
356
|
+
}, [shouldSimulate, instructionPointer]);
|
|
357
|
+
|
|
344
358
|
let componentToRender = null;
|
|
345
|
-
if (currentInstruction?.type === 'ExecuteContent') {
|
|
359
|
+
if (currentInstruction?.type === 'ExecuteContent' && !shouldSimulate) {
|
|
346
360
|
const content = currentInstruction.content;
|
|
347
361
|
if (isRuntimeComponentContent(content)) {
|
|
348
362
|
const Component = componentsMap[content.type];
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react';
|
|
2
2
|
import { VoiceRecorder } from './voicerecorder';
|
|
3
|
+
import { registerSimulation, noopSimulate } from '../utils/simulation';
|
|
4
|
+
|
|
5
|
+
registerSimulation('MicrophoneCheck', noopSimulate, {});
|
|
3
6
|
|
|
4
7
|
interface MicrophoneDevice {
|
|
5
8
|
deviceId: string;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { BaseComponentProps, getPlatform } from '../utils/common';
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
|
+
import { registerSimulation, noopSimulate } from '../utils/simulation';
|
|
3
4
|
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
|
4
5
|
import { Capacitor } from '@capacitor/core';
|
|
5
6
|
|
|
7
|
+
registerSimulation('RequestFilePermission', noopSimulate, {});
|
|
8
|
+
|
|
6
9
|
export default function RequestFilePermission({ next }: BaseComponentProps) {
|
|
7
10
|
const [permissionStatus, setPermissionStatus] = useState('checking');
|
|
8
11
|
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { BaseComponentProps } from '../mod';
|
|
2
2
|
import { useState } from 'react';
|
|
3
|
+
import { registerSimulation } from '../utils/simulation';
|
|
4
|
+
|
|
5
|
+
registerSimulation('PlainInput', (trialProps, _experimentState, simulators, participant) => {
|
|
6
|
+
const result = simulators.respond(trialProps, participant);
|
|
7
|
+
const typingDuration = String(result.value).length * (50 + Math.random() * 100);
|
|
8
|
+
return {
|
|
9
|
+
responseData: { value: result.value },
|
|
10
|
+
participantState: result.participantState,
|
|
11
|
+
storeUpdates: trialProps.storeupdate ? trialProps.storeupdate(result.value) : undefined,
|
|
12
|
+
duration: typingDuration,
|
|
13
|
+
};
|
|
14
|
+
}, {
|
|
15
|
+
respond: (_input: any, participant: any) => ({
|
|
16
|
+
value: 'simulated_input',
|
|
17
|
+
participantState: participant,
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
3
20
|
|
|
4
21
|
function PlainInput({
|
|
5
22
|
content,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { BaseComponentProps, getParam, registerComponentParams } from '../utils/common';
|
|
2
2
|
import Text from '../components/text';
|
|
3
|
+
import { registerSimulation, noopSimulate } from '../utils/simulation';
|
|
4
|
+
|
|
5
|
+
registerSimulation('ProlificEnding', noopSimulate, {});
|
|
3
6
|
|
|
4
7
|
registerComponentParams('ProlificEnding', [
|
|
5
8
|
{ name: 'cc', defaultValue: '', type: 'string', description: 'Completion code of the Profilic experiment (more commonly supplied via the code)' },
|
package/src/components/quest.tsx
CHANGED
|
@@ -3,6 +3,63 @@ import { defaultCss, Model, Question, Serializer } from 'survey-core';
|
|
|
3
3
|
import { ReactQuestionFactory, Survey, SurveyQuestionElementBase } from 'survey-react-ui';
|
|
4
4
|
import { ContrastLight } from 'survey-core/themes';
|
|
5
5
|
import 'survey-core/survey-core.min.css';
|
|
6
|
+
import { registerSimulation } from '../utils/simulation';
|
|
7
|
+
|
|
8
|
+
registerSimulation('Quest', (trialProps, _experimentState, simulators, participant) => {
|
|
9
|
+
const responseData: Record<string, any> = {};
|
|
10
|
+
let totalDuration = 0;
|
|
11
|
+
const pages = trialProps.surveyJson?.pages || [{ elements: trialProps.surveyJson?.elements || [] }];
|
|
12
|
+
for (const page of pages) {
|
|
13
|
+
for (const el of page.elements || []) {
|
|
14
|
+
if (!el.name) continue;
|
|
15
|
+
const result = simulators.answerQuestion(el, participant);
|
|
16
|
+
participant = result.participantState;
|
|
17
|
+
responseData[el.name] = result.value;
|
|
18
|
+
totalDuration += result.duration ?? 0;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { responseData, participantState: participant, duration: totalDuration };
|
|
22
|
+
}, {
|
|
23
|
+
answerQuestion: (question: any, participant: any) => {
|
|
24
|
+
let value;
|
|
25
|
+
switch (question.type) {
|
|
26
|
+
case 'rating': {
|
|
27
|
+
const min = question.rateMin ?? 1, max = question.rateMax ?? 5;
|
|
28
|
+
value = min + Math.floor(Math.random() * (max - min + 1));
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
case 'boolean': value = Math.random() > 0.5; break;
|
|
32
|
+
case 'text': case 'comment': value = 'simulated_response'; break;
|
|
33
|
+
case 'radiogroup': case 'dropdown': {
|
|
34
|
+
const c = question.choices?.[Math.floor(Math.random() * (question.choices?.length || 0))];
|
|
35
|
+
value = c !== undefined ? (typeof c === 'object' ? c.value : c) : null;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case 'checkbox': {
|
|
39
|
+
if (question.choices?.length) {
|
|
40
|
+
const n = 1 + Math.floor(Math.random() * question.choices.length);
|
|
41
|
+
value = [...question.choices]
|
|
42
|
+
.sort(() => Math.random() - 0.5).slice(0, n)
|
|
43
|
+
.map((c: any) => typeof c === 'object' ? c.value : c);
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case 'matrix': {
|
|
48
|
+
if (question.rows?.length && question.columns?.length) {
|
|
49
|
+
value = Object.fromEntries(
|
|
50
|
+
question.rows.map((r: any) => {
|
|
51
|
+
const col = question.columns[Math.floor(Math.random() * question.columns.length)];
|
|
52
|
+
return [typeof r === 'object' ? r.value : r, typeof col === 'object' ? col.value : col];
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
default: value = null;
|
|
59
|
+
}
|
|
60
|
+
return { value, participantState: participant, duration: 1000 + Math.random() * 4000 };
|
|
61
|
+
},
|
|
62
|
+
});
|
|
6
63
|
|
|
7
64
|
type ComponentsMap = {
|
|
8
65
|
[key: string]: ComponentType<any>;
|