@adriansteffan/reactive 0.0.43 → 0.1.0
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 +14 -1
- package/README.md +232 -3
- package/dist/{mod-D6W3wq3h.js → mod-D9lwPIrH.js} +6739 -6389
- package/dist/mod.d.ts +70 -22
- package/dist/reactive.es.js +46 -36
- package/dist/reactive.umd.js +40 -38
- package/dist/style.css +1 -1
- package/dist/{web-B1hJOwit.js → web-DUIQX1PV.js} +1 -1
- package/dist/{web-BYSmfdtR.js → web-DXP3LAJm.js} +1 -1
- package/package.json +1 -1
- package/src/components/canvasblock.tsx +125 -74
- package/src/components/checkdevice.tsx +18 -0
- package/src/components/enterfullscreen.tsx +7 -3
- package/src/components/exitfullscreen.tsx +6 -1
- package/src/components/experimentprovider.tsx +7 -2
- package/src/components/experimentrunner.tsx +85 -58
- package/src/components/microphonecheck.tsx +6 -1
- package/src/components/mobilefilepermission.tsx +3 -0
- package/src/components/plaininput.tsx +20 -0
- package/src/components/prolificending.tsx +5 -0
- package/src/components/quest.tsx +60 -0
- package/src/components/storeui.tsx +18 -11
- package/src/components/text.tsx +14 -0
- package/src/components/upload.tsx +69 -286
- package/src/index.css +0 -20
- package/src/mod.tsx +2 -0
- package/src/utils/bytecode.ts +61 -9
- package/src/utils/common.ts +4 -1
- package/src/utils/simulation.ts +269 -0
- package/src/utils/upload.ts +201 -0
- package/template/README.md +59 -0
- package/template/backend/package-lock.json +280 -156
- package/template/backend/src/backend.ts +1 -0
- package/template/package-lock.json +1693 -771
- package/template/package.json +2 -0
- package/template/simulate.ts +15 -0
- package/template/src/Experiment.tsx +62 -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,14 +4,37 @@ 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,
|
|
10
12
|
Store,
|
|
11
|
-
|
|
13
|
+
TrialResult,
|
|
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
|
+
import { registerFlattener } from '../utils/upload';
|
|
19
|
+
|
|
20
|
+
registerFlattener('CanvasBlock', 'canvas', (item) => {
|
|
21
|
+
const responseData = item.responseData;
|
|
22
|
+
if (Array.isArray(responseData)) {
|
|
23
|
+
return responseData.map((i) => ({ block: item.name, ...i }));
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export type SlideSimulatorResult = {
|
|
29
|
+
key: string | null;
|
|
30
|
+
reactionTime: number | null;
|
|
31
|
+
participantState: ParticipantState;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type SlideSimulator = (
|
|
35
|
+
slide: Record<string, any>,
|
|
36
|
+
participant: ParticipantState,
|
|
37
|
+
) => SlideSimulatorResult;
|
|
15
38
|
|
|
16
39
|
interface CanvasSlide {
|
|
17
40
|
draw: (ctx: CanvasRenderingContext2D, width: number, height: number) => void;
|
|
@@ -23,11 +46,12 @@ interface CanvasSlide {
|
|
|
23
46
|
allowedKeys?: string[] | boolean;
|
|
24
47
|
metadata?:
|
|
25
48
|
| Record<string, any>
|
|
26
|
-
| ((data?:
|
|
49
|
+
| ((data?: TrialResult[], store?: Store) => Record<string, any>);
|
|
27
50
|
nestMetadata?: boolean;
|
|
51
|
+
simulate?: SlideSimulator;
|
|
28
52
|
}
|
|
29
53
|
|
|
30
|
-
type DynamicCanvasSlideGenerator = (data:
|
|
54
|
+
type DynamicCanvasSlideGenerator = (data: TrialResult[], store: Store) => CanvasSlide;
|
|
31
55
|
|
|
32
56
|
function isDynamicCanvasSlideGenerator(content: any): content is DynamicCanvasSlideGenerator {
|
|
33
57
|
return typeof content === 'function';
|
|
@@ -62,7 +86,7 @@ export default function CanvasBlock({
|
|
|
62
86
|
const isDrawingVisibleRef = useRef<boolean>(true);
|
|
63
87
|
const responseRegisteredRef = useRef<null | Record<string, any>>(null);
|
|
64
88
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
65
|
-
const dataRef = useRef<
|
|
89
|
+
const dataRef = useRef<TrialResult[]>([]);
|
|
66
90
|
const storeRef = useRef<Store>(store ?? {});
|
|
67
91
|
const animationFrameRef = useRef<number | null>(null);
|
|
68
92
|
const contentInstructionsCompletedRef = useRef(0);
|
|
@@ -227,74 +251,41 @@ export default function CanvasBlock({
|
|
|
227
251
|
}, [instructions, resolveSlideContent, clearCanvas]);
|
|
228
252
|
|
|
229
253
|
const processControlFlow = useCallback(() => {
|
|
230
|
-
let foundContentToExecute = false;
|
|
231
254
|
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
232
255
|
animationFrameRef.current = null;
|
|
233
256
|
|
|
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
|
-
}
|
|
257
|
+
const pointer = advanceToNextContent(
|
|
258
|
+
{ instructions, markers },
|
|
259
|
+
instructionPointerRef.current,
|
|
260
|
+
() => storeRef.current,
|
|
261
|
+
() => dataRef.current,
|
|
262
|
+
(s) => { storeRef.current = s; },
|
|
263
|
+
);
|
|
264
|
+
instructionPointerRef.current = pointer;
|
|
292
265
|
|
|
293
|
-
if (
|
|
294
|
-
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
295
|
-
animationFrameRef.current = null;
|
|
266
|
+
if (pointer >= instructions.length) {
|
|
296
267
|
updateStore(storeRef.current);
|
|
297
268
|
next(dataRef.current);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!canvasRef.current) {
|
|
273
|
+
console.error('Canvas element not found during control flow.');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const currentSlide = resolveSlideContent(instructions[pointer]);
|
|
278
|
+
if (currentSlide) {
|
|
279
|
+
drawSlideInternal(currentSlide);
|
|
280
|
+
const displayDuration = currentSlide.displayDuration ?? Infinity;
|
|
281
|
+
const responseTimeLimit = currentSlide.responseTimeLimit ?? Infinity;
|
|
282
|
+
if (displayDuration !== Infinity || responseTimeLimit !== Infinity) {
|
|
283
|
+
animationFrameRef.current = requestAnimationFrame(tick);
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
console.error('Failed to resolve slide content during control flow:', instructions[pointer]);
|
|
287
|
+
instructionPointerRef.current++;
|
|
288
|
+
processControlFlow();
|
|
298
289
|
}
|
|
299
290
|
}, [instructions, markers, resolveSlideContent, drawSlideInternal, tick, next]);
|
|
300
291
|
|
|
@@ -326,14 +317,7 @@ export default function CanvasBlock({
|
|
|
326
317
|
reactionTime: responseData ? responseData.reactionTime : null,
|
|
327
318
|
} as CanvasResultData;
|
|
328
319
|
|
|
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 };
|
|
320
|
+
trialData = applyMetadata(trialData, slide, dataRef.current, storeRef.current);
|
|
337
321
|
|
|
338
322
|
dataRef.current.push(trialData);
|
|
339
323
|
}
|
|
@@ -524,3 +508,70 @@ export default function CanvasBlock({
|
|
|
524
508
|
</div>
|
|
525
509
|
);
|
|
526
510
|
}
|
|
511
|
+
|
|
512
|
+
// --- Default simulator ---
|
|
513
|
+
|
|
514
|
+
registerSimulation('CanvasBlock', (trialProps, experimentState, simulators, participant) => {
|
|
515
|
+
const timeline = trialProps.timeline;
|
|
516
|
+
if (!timeline) return { responseData: [], participantState: participant };
|
|
517
|
+
|
|
518
|
+
const bytecode = compileTimeline(timeline);
|
|
519
|
+
const innerData: CanvasResultData[] = [];
|
|
520
|
+
let innerStore: Store = { ...(experimentState.store || {}) };
|
|
521
|
+
let currentTime = 0;
|
|
522
|
+
let slideNumber = 0;
|
|
523
|
+
|
|
524
|
+
const getStore = () => innerStore;
|
|
525
|
+
const getData = () => innerData as TrialResult[];
|
|
526
|
+
const onUpdateStore = (s: Store) => { innerStore = s; };
|
|
527
|
+
|
|
528
|
+
let pointer = advanceToNextContent(bytecode, 0, getStore, getData, onUpdateStore);
|
|
529
|
+
|
|
530
|
+
while (pointer < bytecode.instructions.length) {
|
|
531
|
+
let slide = (bytecode.instructions[pointer] as ExecuteContentInstruction).content;
|
|
532
|
+
if (typeof slide === 'function') slide = slide(innerData, innerStore);
|
|
533
|
+
|
|
534
|
+
const sim = slide.simulate || simulators.respondToSlide;
|
|
535
|
+
const result = sim(slide, participant);
|
|
536
|
+
participant = result.participantState;
|
|
537
|
+
|
|
538
|
+
if (!slide.ignoreData) {
|
|
539
|
+
const duration = slide.displayDuration || result.reactionTime || 1000;
|
|
540
|
+
|
|
541
|
+
let td: CanvasResultData = {
|
|
542
|
+
index: pointer,
|
|
543
|
+
trialNumber: slideNumber,
|
|
544
|
+
start: currentTime,
|
|
545
|
+
end: currentTime + duration,
|
|
546
|
+
duration,
|
|
547
|
+
key: result.key,
|
|
548
|
+
reactionTime: result.reactionTime,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
td = applyMetadata(td, slide, innerData, innerStore);
|
|
552
|
+
|
|
553
|
+
innerData.push(td);
|
|
554
|
+
currentTime += duration;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
slideNumber++;
|
|
558
|
+
pointer = advanceToNextContent(bytecode, pointer + 1, getStore, getData, onUpdateStore);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return { responseData: innerData, participantState: participant, storeUpdates: innerStore, duration: currentTime };
|
|
562
|
+
}, {
|
|
563
|
+
respondToSlide: (slide: any, participant: any) => {
|
|
564
|
+
const keys = slide.allowedKeys;
|
|
565
|
+
if (keys === true) {
|
|
566
|
+
return { key: ' ', reactionTime: 200 + Math.random() * 600, participantState: participant };
|
|
567
|
+
}
|
|
568
|
+
if (Array.isArray(keys) && keys.length > 0) {
|
|
569
|
+
return {
|
|
570
|
+
key: keys[Math.floor(Math.random() * keys.length)],
|
|
571
|
+
reactionTime: 200 + Math.random() * 600,
|
|
572
|
+
participantState: participant,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
return { key: null, reactionTime: null, participantState: participant };
|
|
576
|
+
},
|
|
577
|
+
});
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { BaseComponentProps } from '../mod';
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { registerSimulation } from '../utils/simulation';
|
|
4
|
+
import { registerFlattener } from '../utils/upload';
|
|
5
|
+
|
|
6
|
+
registerFlattener('CheckDevice', 'session');
|
|
7
|
+
|
|
8
|
+
registerSimulation('CheckDevice', (_trialProps, _experimentState, _simulators, participant) => {
|
|
9
|
+
const deviceInfo = {
|
|
10
|
+
windowWidth: 1920, windowHeight: 1080, screenWidth: 1920, screenHeight: 1080,
|
|
11
|
+
browser: 'Simulated', browserVersion: '1.0', isMobile: false,
|
|
12
|
+
operatingSystem: 'Simulated', hasWebAudio: true, hasFullscreen: true,
|
|
13
|
+
hasWebcam: true, hasMicrophone: true,
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
responseData: deviceInfo,
|
|
17
|
+
participantState: participant,
|
|
18
|
+
storeUpdates: { _reactiveDeviceInfo: deviceInfo },
|
|
19
|
+
};
|
|
20
|
+
}, {});
|
|
3
21
|
|
|
4
22
|
interface DeviceInfo {
|
|
5
23
|
windowWidth: number;
|
|
@@ -1,11 +1,15 @@
|
|
|
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';
|
|
4
|
+
import { registerFlattener } from '../utils/upload';
|
|
3
5
|
import Text from '../components/text';
|
|
4
|
-
|
|
5
6
|
import { StatusBar } from '@capacitor/status-bar';
|
|
6
7
|
import { ImmersiveMode } from '@adriansteffan/immersive-mode';
|
|
7
8
|
import { Capacitor } from '@capacitor/core';
|
|
8
9
|
|
|
10
|
+
registerFlattener('EnterFullscreen', 'session');
|
|
11
|
+
registerSimulation('EnterFullscreen', noopSimulate, {});
|
|
12
|
+
|
|
9
13
|
export default function EnterFullscreen({
|
|
10
14
|
content,
|
|
11
15
|
buttonText,
|
|
@@ -28,7 +32,7 @@ export default function EnterFullscreen({
|
|
|
28
32
|
|
|
29
33
|
const [isWaiting, setIsWaiting] = useState(false);
|
|
30
34
|
const listenerFallbackActive = useRef(false);
|
|
31
|
-
const timeoutId = useRef<
|
|
35
|
+
const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
32
36
|
|
|
33
37
|
const cancelPendingTimeout = useCallback(() => {
|
|
34
38
|
if (timeoutId.current) {
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { useCallback, useRef, useEffect } from 'react';
|
|
2
2
|
import { BaseComponentProps, getPlatform, isFullscreen } from '../utils/common';
|
|
3
|
+
import { registerSimulation, noopSimulate } from '../utils/simulation';
|
|
4
|
+
import { registerFlattener } from '../utils/upload';
|
|
3
5
|
import { StatusBar } from '@capacitor/status-bar';
|
|
4
6
|
import { ImmersiveMode } from '@adriansteffan/immersive-mode';
|
|
5
7
|
import { Capacitor } from '@capacitor/core';
|
|
6
8
|
|
|
9
|
+
registerFlattener('ExitFullscreen', 'session');
|
|
10
|
+
registerSimulation('ExitFullscreen', noopSimulate, {});
|
|
11
|
+
|
|
7
12
|
export default function ExitFullscreen({
|
|
8
13
|
next,
|
|
9
14
|
delayMs = 0,
|
|
@@ -12,7 +17,7 @@ export default function ExitFullscreen({
|
|
|
12
17
|
} & BaseComponentProps) {
|
|
13
18
|
|
|
14
19
|
const listenerFallbackActive = useRef(false);
|
|
15
|
-
const timeoutId = useRef<
|
|
20
|
+
const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
16
21
|
|
|
17
22
|
const cancelPendingTimeout = useCallback(() => {
|
|
18
23
|
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
|
+
TrialResult,
|
|
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';
|
|
@@ -52,13 +60,16 @@ const defaultCustomQuestions: ComponentsMap = {
|
|
|
52
60
|
interface RuntimeComponentContent {
|
|
53
61
|
name?: string;
|
|
54
62
|
type: string;
|
|
63
|
+
csv?: string | string[];
|
|
55
64
|
collectRefreshRate?: boolean;
|
|
56
65
|
hideSettings?: string[] | boolean;
|
|
57
66
|
metadata?:
|
|
58
67
|
| Record<string, any>
|
|
59
|
-
| ((data:
|
|
68
|
+
| ((data: TrialResult[], store: Store) => Record<string, any>);
|
|
60
69
|
nestMetadata?: boolean;
|
|
61
|
-
props?: Record<string, any> | ((data:
|
|
70
|
+
props?: Record<string, any> | ((data: TrialResult[], store: Store) => Record<string, any>);
|
|
71
|
+
simulate?: SimulateFunction | boolean;
|
|
72
|
+
simulators?: Record<string, any>;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
function isRuntimeComponentContent(content: any): content is RuntimeComponentContent {
|
|
@@ -72,18 +83,20 @@ export default function ExperimentRunner({
|
|
|
72
83
|
},
|
|
73
84
|
components = {},
|
|
74
85
|
questions = {},
|
|
86
|
+
hybridParticipant,
|
|
75
87
|
}: {
|
|
76
88
|
timeline: TimelineItem[];
|
|
77
89
|
config?: ExperimentConfig;
|
|
78
90
|
components?: ComponentsMap;
|
|
79
91
|
questions?: ComponentsMap;
|
|
92
|
+
hybridParticipant?: ParticipantState;
|
|
80
93
|
}) {
|
|
94
|
+
const disableHybridSimulation = useHybridSimulationDisabled();
|
|
81
95
|
const trialByteCode = useMemo(() => {
|
|
82
96
|
return compileTimeline(timeline);
|
|
83
97
|
}, [timeline]);
|
|
84
98
|
|
|
85
|
-
const
|
|
86
|
-
const dataRef = useRef<RefinedTrialData[]>((() => {
|
|
99
|
+
const dataRef = useRef<TrialResult[]>((() => {
|
|
87
100
|
const urlParams: Record<string, any> = {};
|
|
88
101
|
const searchParams = new URLSearchParams(window.location.search);
|
|
89
102
|
for (const [key, value] of searchParams.entries()) {
|
|
@@ -137,6 +150,25 @@ export default function ExperimentRunner({
|
|
|
137
150
|
const lastTrialEndTimeRef = useRef(now());
|
|
138
151
|
const experimentStoreRef = useRef<Store>({});
|
|
139
152
|
|
|
153
|
+
const [instructionPointer, setInstructionPointer] = useState(() =>
|
|
154
|
+
advanceToNextContent(
|
|
155
|
+
trialByteCode,
|
|
156
|
+
0,
|
|
157
|
+
() => experimentStoreRef.current,
|
|
158
|
+
() => dataRef.current,
|
|
159
|
+
(s) => { experimentStoreRef.current = { ...experimentStoreRef.current, ...s }; },
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const simulationMode =
|
|
164
|
+
(!disableHybridSimulation && getParam('hybridSimulation', false, 'boolean'))
|
|
165
|
+
? 'hybrid' as const
|
|
166
|
+
: 'none' as const;
|
|
167
|
+
|
|
168
|
+
const participantRef = useRef<ParticipantState>(hybridParticipant || {});
|
|
169
|
+
// Guards against duplicate simulation in React strict mode
|
|
170
|
+
const lastSimulatedPointerRef = useRef(-1);
|
|
171
|
+
|
|
140
172
|
const componentsMap = { ...defaultComponents, ...components };
|
|
141
173
|
const customQuestionsMap: ComponentsMap = { ...defaultCustomQuestions, ...questions };
|
|
142
174
|
|
|
@@ -177,17 +209,11 @@ export default function ExperimentRunner({
|
|
|
177
209
|
duration: endTime - startTime,
|
|
178
210
|
type: content.type,
|
|
179
211
|
name: content.name ?? '',
|
|
212
|
+
...(content.csv !== undefined ? { csv: content.csv } : {}),
|
|
180
213
|
responseData: componentResponseData,
|
|
181
214
|
};
|
|
182
215
|
|
|
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 };
|
|
216
|
+
trialData = applyMetadata(trialData, content, dataRef.current, experimentStoreRef.current);
|
|
191
217
|
|
|
192
218
|
dataRef.current = [...dataRef.current, trialData];
|
|
193
219
|
setTotalTrialsCompleted((prevCount) => prevCount + 1);
|
|
@@ -199,49 +225,17 @@ export default function ExperimentRunner({
|
|
|
199
225
|
}
|
|
200
226
|
}
|
|
201
227
|
|
|
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);
|
|
228
|
+
const nextPointer = advanceToNextContent(
|
|
229
|
+
trialByteCode,
|
|
230
|
+
instructionPointer + 1,
|
|
231
|
+
() => experimentStoreRef.current,
|
|
232
|
+
() => dataRef.current,
|
|
233
|
+
(s) => updateStore(s),
|
|
234
|
+
);
|
|
235
|
+
if (nextPointer < trialByteCode.instructions.length) {
|
|
236
|
+
lastTrialEndTimeRef.current = now();
|
|
244
237
|
}
|
|
238
|
+
setInstructionPointer(nextPointer);
|
|
245
239
|
}
|
|
246
240
|
|
|
247
241
|
const collectRefreshRate = useCallback((callback: (refreshRate: number | null) => void) => {
|
|
@@ -341,8 +335,39 @@ export default function ExperimentRunner({
|
|
|
341
335
|
|
|
342
336
|
const currentInstruction = trialByteCode.instructions[instructionPointer];
|
|
343
337
|
|
|
338
|
+
const shouldSimulate = useMemo(() => {
|
|
339
|
+
if (simulationMode === 'none') return false;
|
|
340
|
+
if (currentInstruction?.type !== 'ExecuteContent') return false;
|
|
341
|
+
const content = currentInstruction.content;
|
|
342
|
+
if (!isRuntimeComponentContent(content)) return false;
|
|
343
|
+
if (content.simulate === false) return false;
|
|
344
|
+
return !!content.simulate || !!content.simulators;
|
|
345
|
+
}, [instructionPointer, simulationMode, currentInstruction]);
|
|
346
|
+
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
if (!shouldSimulate) return;
|
|
349
|
+
if (lastSimulatedPointerRef.current === instructionPointer) return;
|
|
350
|
+
lastSimulatedPointerRef.current = instructionPointer;
|
|
351
|
+
|
|
352
|
+
(async () => {
|
|
353
|
+
const content = (currentInstruction as any).content;
|
|
354
|
+
const { trialProps, simulateFn, simulators } = resolveSimulation(content, dataRef.current, experimentStoreRef.current);
|
|
355
|
+
|
|
356
|
+
const result = await simulateFn(
|
|
357
|
+
trialProps,
|
|
358
|
+
{ data: dataRef.current, store: experimentStoreRef.current },
|
|
359
|
+
simulators,
|
|
360
|
+
participantRef.current,
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
participantRef.current = result.participantState;
|
|
364
|
+
if (result.storeUpdates) updateStore(result.storeUpdates);
|
|
365
|
+
next(result.responseData);
|
|
366
|
+
})();
|
|
367
|
+
}, [shouldSimulate, instructionPointer]);
|
|
368
|
+
|
|
344
369
|
let componentToRender = null;
|
|
345
|
-
if (currentInstruction?.type === 'ExecuteContent') {
|
|
370
|
+
if (currentInstruction?.type === 'ExecuteContent' && !shouldSimulate) {
|
|
346
371
|
const content = currentInstruction.content;
|
|
347
372
|
if (isRuntimeComponentContent(content)) {
|
|
348
373
|
const Component = componentsMap[content.type];
|
|
@@ -382,8 +407,9 @@ export default function ExperimentRunner({
|
|
|
382
407
|
<div
|
|
383
408
|
className={` ${
|
|
384
409
|
config.showProgressBar ? '' : 'hidden '
|
|
385
|
-
} px-4 mt-4 sm:mt-12 max-w-2xl mx-auto
|
|
410
|
+
} px-4 mt-4 sm:mt-12 max-w-2xl mx-auto`}
|
|
386
411
|
>
|
|
412
|
+
<div className='flex-1 h-6 bg-gray-200 rounded-full overflow-hidden'>
|
|
387
413
|
<div
|
|
388
414
|
className={`h-full bg-gray-200 rounded-full duration-300 ${
|
|
389
415
|
progress > 0 ? ' border-black border-2' : ''
|
|
@@ -396,6 +422,7 @@ export default function ExperimentRunner({
|
|
|
396
422
|
transition: 'width 300ms ease-in-out',
|
|
397
423
|
}}
|
|
398
424
|
/>
|
|
425
|
+
</div>
|
|
399
426
|
</div>
|
|
400
427
|
{componentToRender}
|
|
401
428
|
</div>
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react';
|
|
2
2
|
import { VoiceRecorder } from './voicerecorder';
|
|
3
|
+
import { registerSimulation, noopSimulate } from '../utils/simulation';
|
|
4
|
+
import { registerFlattener } from '../utils/upload';
|
|
5
|
+
|
|
6
|
+
registerFlattener('MicrophoneCheck', 'session');
|
|
7
|
+
registerSimulation('MicrophoneCheck', noopSimulate, {});
|
|
3
8
|
|
|
4
9
|
interface MicrophoneDevice {
|
|
5
10
|
deviceId: string;
|
|
@@ -153,7 +158,7 @@ const MicrophoneCheck = ({ next }: { next: (data: object) => void }) => {
|
|
|
153
158
|
<div className='mt-16 flex justify-center'>
|
|
154
159
|
<button
|
|
155
160
|
onClick={() => next({})}
|
|
156
|
-
className='bg-white px-8 py-3 border-2 border-black font-bold text-black text-lg rounded-xl shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
|
|
161
|
+
className='bg-white cursor-pointer px-8 py-3 border-2 border-black font-bold text-black text-lg rounded-xl shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
|
|
157
162
|
>
|
|
158
163
|
Next
|
|
159
164
|
</button>
|
|
@@ -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,25 @@
|
|
|
1
1
|
import { BaseComponentProps } from '../mod';
|
|
2
2
|
import { useState } from 'react';
|
|
3
|
+
import { registerSimulation } from '../utils/simulation';
|
|
4
|
+
import { registerFlattener } from '../utils/upload';
|
|
5
|
+
|
|
6
|
+
registerFlattener('PlainInput', 'session');
|
|
7
|
+
|
|
8
|
+
registerSimulation('PlainInput', (trialProps, _experimentState, simulators, participant) => {
|
|
9
|
+
const result = simulators.respond(trialProps, participant);
|
|
10
|
+
const typingDuration = String(result.value).length * (50 + Math.random() * 100);
|
|
11
|
+
return {
|
|
12
|
+
responseData: { value: result.value },
|
|
13
|
+
participantState: result.participantState,
|
|
14
|
+
storeUpdates: trialProps.storeupdate ? trialProps.storeupdate(result.value) : undefined,
|
|
15
|
+
duration: typingDuration,
|
|
16
|
+
};
|
|
17
|
+
}, {
|
|
18
|
+
respond: (_input: any, participant: any) => ({
|
|
19
|
+
value: 'simulated_input',
|
|
20
|
+
participantState: participant,
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
3
23
|
|
|
4
24
|
function PlainInput({
|
|
5
25
|
content,
|