@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/src/utils/bytecode.ts
CHANGED
|
@@ -11,6 +11,8 @@ type BaseTrialData = {
|
|
|
11
11
|
export type ComponentResultData = BaseTrialData & {
|
|
12
12
|
type: string;
|
|
13
13
|
name: string;
|
|
14
|
+
/** Per-trial override for which CSV file(s) this trial's data goes into. When set on a timeline item, overrides the component type's default from the flattener registry. */
|
|
15
|
+
csv?: string | string[];
|
|
14
16
|
responseData?: any;
|
|
15
17
|
metadata?: Record<string, any>;
|
|
16
18
|
};
|
|
@@ -21,16 +23,16 @@ export type CanvasResultData = BaseTrialData & {
|
|
|
21
23
|
reactionTime: number | null;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
|
-
export type
|
|
26
|
+
export type TrialResult = ComponentResultData | CanvasResultData;
|
|
25
27
|
|
|
26
28
|
export interface MarkerItem {
|
|
27
29
|
type: 'MARKER';
|
|
28
30
|
id: string;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
export type ConditionalFunction = (data?:
|
|
33
|
+
export type ConditionalFunction = (data?: TrialResult[], store?: Store) => boolean;
|
|
32
34
|
|
|
33
|
-
export type StoreUpdateFunction = (data?:
|
|
35
|
+
export type StoreUpdateFunction = (data?: TrialResult[], store?: Store) => Record<string, any>;
|
|
34
36
|
|
|
35
37
|
export interface IfGotoItem {
|
|
36
38
|
type: 'IF_GOTO';
|
|
@@ -65,12 +67,12 @@ export interface ExecuteContentInstruction {
|
|
|
65
67
|
}
|
|
66
68
|
export interface IfGotoInstruction {
|
|
67
69
|
type: 'IfGoto';
|
|
68
|
-
cond: (store: Store, data:
|
|
70
|
+
cond: (store: Store, data: TrialResult[]) => boolean;
|
|
69
71
|
marker: string;
|
|
70
72
|
}
|
|
71
73
|
export interface UpdateStoreInstruction {
|
|
72
74
|
type: 'UpdateStore';
|
|
73
|
-
fun: (store: Store, data:
|
|
75
|
+
fun: (store: Store, data: TrialResult[]) => Store;
|
|
74
76
|
}
|
|
75
77
|
export type UnifiedBytecodeInstruction =
|
|
76
78
|
| ExecuteContentInstruction
|
|
@@ -95,16 +97,16 @@ export function compileTimeline(timeline: TimelineItem[]): {
|
|
|
95
97
|
|
|
96
98
|
function adaptCondition(
|
|
97
99
|
userCondition: ConditionalFunction,
|
|
98
|
-
): (store: Store, data:
|
|
99
|
-
return (runtimeStore: Store, runtimeData:
|
|
100
|
+
): (store: Store, data: TrialResult[]) => boolean {
|
|
101
|
+
return (runtimeStore: Store, runtimeData: TrialResult[]): boolean => {
|
|
100
102
|
return userCondition(runtimeData, runtimeStore);
|
|
101
103
|
};
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
function adaptUpdate(
|
|
105
107
|
userUpdateFunction: StoreUpdateFunction,
|
|
106
|
-
): (store: Store, data:
|
|
107
|
-
return (runtimeStore: Store, runtimeData:
|
|
108
|
+
): (store: Store, data: TrialResult[]) => Store {
|
|
109
|
+
return (runtimeStore: Store, runtimeData: TrialResult[]): Store => {
|
|
108
110
|
const updates = userUpdateFunction(runtimeData, runtimeStore);
|
|
109
111
|
if (typeof updates === 'object' && updates !== null) {
|
|
110
112
|
return {
|
|
@@ -207,3 +209,53 @@ export function compileTimeline(timeline: TimelineItem[]): {
|
|
|
207
209
|
|
|
208
210
|
return { instructions, markers };
|
|
209
211
|
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Walks bytecode from `fromPointer`, processing IfGoto and UpdateStore instructions,
|
|
215
|
+
* and returns the pointer of the next ExecuteContent (or past-end if none found).
|
|
216
|
+
*/
|
|
217
|
+
export function applyMetadata<T extends Record<string, any>>(
|
|
218
|
+
trialData: T,
|
|
219
|
+
content: { metadata?: any; nestMetadata?: boolean },
|
|
220
|
+
data: TrialResult[],
|
|
221
|
+
store: Store,
|
|
222
|
+
): T {
|
|
223
|
+
const metadata = typeof content.metadata === 'function' ? content.metadata(data, store) : content.metadata;
|
|
224
|
+
if (content.nestMetadata) return { ...trialData, metadata };
|
|
225
|
+
if (metadata) return { ...metadata, ...trialData };
|
|
226
|
+
return trialData;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function advanceToNextContent(
|
|
230
|
+
bytecode: { instructions: UnifiedBytecodeInstruction[]; markers: Record<string, number> },
|
|
231
|
+
fromPointer: number,
|
|
232
|
+
getStore: () => Store,
|
|
233
|
+
getData: () => TrialResult[],
|
|
234
|
+
onUpdateStore: (newStore: Store) => void,
|
|
235
|
+
): number {
|
|
236
|
+
let pointer = fromPointer;
|
|
237
|
+
while (pointer < bytecode.instructions.length) {
|
|
238
|
+
const instr = bytecode.instructions[pointer];
|
|
239
|
+
switch (instr.type) {
|
|
240
|
+
case 'ExecuteContent':
|
|
241
|
+
return pointer;
|
|
242
|
+
case 'IfGoto':
|
|
243
|
+
if (instr.cond(getStore(), getData())) {
|
|
244
|
+
const target = bytecode.markers[instr.marker];
|
|
245
|
+
if (target !== undefined) {
|
|
246
|
+
pointer = target;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
pointer++;
|
|
251
|
+
break;
|
|
252
|
+
case 'UpdateStore':
|
|
253
|
+
onUpdateStore(instr.fun(getStore(), getData()));
|
|
254
|
+
pointer++;
|
|
255
|
+
break;
|
|
256
|
+
default:
|
|
257
|
+
pointer++;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return pointer;
|
|
261
|
+
}
|
package/src/utils/common.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface TrialData {
|
|
|
24
24
|
trialNumber: number;
|
|
25
25
|
type: string;
|
|
26
26
|
name: string;
|
|
27
|
+
/** Populated from the timeline item's csv field. Overrides the component type's default CSV target from the flattener registry. */
|
|
28
|
+
csv?: string | string[];
|
|
27
29
|
responseData: any;
|
|
28
30
|
start: number;
|
|
29
31
|
end: number;
|
|
@@ -68,13 +70,14 @@ export function getParam<T extends ParamType>(
|
|
|
68
70
|
defaultValue: ParamValue<T>,
|
|
69
71
|
type: T = 'string' as T,
|
|
70
72
|
description?: string,
|
|
73
|
+
uiDefault?: string,
|
|
71
74
|
): ParamValue<T> {
|
|
72
75
|
let registryEntry = sharedRegistry.find((p) => p.name === name);
|
|
73
76
|
|
|
74
77
|
if (!registryEntry) {
|
|
75
78
|
registryEntry = {
|
|
76
79
|
name,
|
|
77
|
-
defaultValue,
|
|
80
|
+
defaultValue: uiDefault !== undefined ? uiDefault : defaultValue,
|
|
78
81
|
type,
|
|
79
82
|
description,
|
|
80
83
|
value: undefined,
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
compileTimeline,
|
|
5
|
+
advanceToNextContent,
|
|
6
|
+
applyMetadata,
|
|
7
|
+
TrialResult,
|
|
8
|
+
ComponentResultData,
|
|
9
|
+
TimelineItem,
|
|
10
|
+
Store,
|
|
11
|
+
} from './bytecode';
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export type ParticipantState = Record<string, any>;
|
|
15
|
+
|
|
16
|
+
export type SimulatorResult = {
|
|
17
|
+
responseData: any;
|
|
18
|
+
participantState: ParticipantState;
|
|
19
|
+
storeUpdates?: Record<string, any>;
|
|
20
|
+
duration?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SimulateFunction = (
|
|
24
|
+
trialProps: Record<string, any>,
|
|
25
|
+
experimentState: {
|
|
26
|
+
data: TrialResult[];
|
|
27
|
+
store: Store;
|
|
28
|
+
},
|
|
29
|
+
simulators: Record<string, any>,
|
|
30
|
+
participant: ParticipantState,
|
|
31
|
+
) => SimulatorResult | Promise<SimulatorResult>;
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
interface ComponentSimulation {
|
|
35
|
+
simulate: SimulateFunction;
|
|
36
|
+
defaultSimulators: Record<string, any>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const simulationRegistry: Record<string, ComponentSimulation> = {};
|
|
40
|
+
|
|
41
|
+
export function registerSimulation(
|
|
42
|
+
type: string,
|
|
43
|
+
simulate: SimulateFunction,
|
|
44
|
+
defaultSimulators: Record<string, any>,
|
|
45
|
+
) {
|
|
46
|
+
simulationRegistry[type] = { simulate, defaultSimulators };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getSimulation(type: string): ComponentSimulation | undefined {
|
|
50
|
+
return simulationRegistry[type];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const noopSimulate: SimulateFunction = (_trialProps, _experimentState, _simulators, participant) => ({
|
|
54
|
+
responseData: {},
|
|
55
|
+
participantState: participant,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export function resolveSimulation(content: any, data: TrialResult[], store: Store) {
|
|
59
|
+
const trialProps =
|
|
60
|
+
typeof content.props === 'function' ? content.props(data, store) : content.props || {};
|
|
61
|
+
const registration = simulationRegistry[content.type];
|
|
62
|
+
const simulateFn = typeof content.simulate === 'function' ? content.simulate : registration?.simulate;
|
|
63
|
+
|
|
64
|
+
if (!simulateFn) {
|
|
65
|
+
throw new Error(`No simulation registered for trial type '${content.type}' (name: '${content.name ?? 'unnamed'}'). Register one with registerSimulation() or add a simulate function to the timeline item.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const simulators = { ...registration?.defaultSimulators, ...content.simulators };
|
|
69
|
+
return { trialProps, simulateFn, simulators };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let _backendUrl = 'http://localhost:8001/backend';
|
|
73
|
+
export function setBackendUrl(url: string) { _backendUrl = url; }
|
|
74
|
+
export function getBackendUrl() { return _backendUrl; }
|
|
75
|
+
|
|
76
|
+
let _initialParticipant: ParticipantState | undefined;
|
|
77
|
+
export function getInitialParticipant() { return _initialParticipant; }
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
export async function simulateParticipant(
|
|
81
|
+
timeline: TimelineItem[],
|
|
82
|
+
participant: ParticipantState,
|
|
83
|
+
): Promise<TrialResult[]> {
|
|
84
|
+
_initialParticipant = { ...participant };
|
|
85
|
+
let currentParticipantState = { ...participant };
|
|
86
|
+
const bytecode = compileTimeline(timeline);
|
|
87
|
+
let store: Store = {};
|
|
88
|
+
const data: TrialResult[] = [
|
|
89
|
+
{
|
|
90
|
+
index: -1,
|
|
91
|
+
trialNumber: -1,
|
|
92
|
+
start: 0,
|
|
93
|
+
end: 0,
|
|
94
|
+
duration: 0,
|
|
95
|
+
type: '',
|
|
96
|
+
name: '',
|
|
97
|
+
responseData: { userAgent: 'simulated', params: {} },
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const getStore = () => store;
|
|
102
|
+
const getData = () => data;
|
|
103
|
+
const onUpdateStore = (s: Store) => { store = s; };
|
|
104
|
+
|
|
105
|
+
let trialNumber = 0;
|
|
106
|
+
let currentTime = 0;
|
|
107
|
+
|
|
108
|
+
let pointer = advanceToNextContent(bytecode, 0, getStore, getData, onUpdateStore);
|
|
109
|
+
|
|
110
|
+
while (pointer < bytecode.instructions.length) {
|
|
111
|
+
const content = (bytecode.instructions[pointer] as any).content;
|
|
112
|
+
if (typeof content !== 'object' || content === null || typeof content.type !== 'string') {
|
|
113
|
+
pointer = advanceToNextContent(bytecode, pointer + 1, getStore, getData, onUpdateStore);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { trialProps, simulateFn, simulators } = resolveSimulation(content, data, store);
|
|
118
|
+
|
|
119
|
+
const result = await simulateFn(
|
|
120
|
+
trialProps,
|
|
121
|
+
{ data, store },
|
|
122
|
+
simulators,
|
|
123
|
+
currentParticipantState,
|
|
124
|
+
);
|
|
125
|
+
currentParticipantState = result.participantState;
|
|
126
|
+
if (result.storeUpdates) store = { ...store, ...result.storeUpdates };
|
|
127
|
+
|
|
128
|
+
trialNumber++;
|
|
129
|
+
const duration = result.duration ?? 0;
|
|
130
|
+
const startTime = currentTime;
|
|
131
|
+
currentTime += duration;
|
|
132
|
+
|
|
133
|
+
let trialData: ComponentResultData = {
|
|
134
|
+
index: pointer,
|
|
135
|
+
trialNumber,
|
|
136
|
+
start: startTime,
|
|
137
|
+
end: currentTime,
|
|
138
|
+
duration,
|
|
139
|
+
type: content.type,
|
|
140
|
+
name: content.name ?? '',
|
|
141
|
+
...(content.csv !== undefined ? { csv: content.csv } : {}),
|
|
142
|
+
responseData: result.responseData,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
data.push(applyMetadata(trialData, content, data, store));
|
|
146
|
+
pointer = advanceToNextContent(bytecode, pointer + 1, getStore, getData, onUpdateStore);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return data;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
export interface RunSimulationConfig {
|
|
154
|
+
participants: ParticipantState[] | { generator: (index: number) => ParticipantState; count: number };
|
|
155
|
+
backendPort?: number;
|
|
156
|
+
concurrency?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function orchestrateSimulation(config: RunSimulationConfig, scriptPath: string): Promise<void> {
|
|
160
|
+
const { spawn } = await import('child_process');
|
|
161
|
+
|
|
162
|
+
const port = config.backendPort ?? 8001;
|
|
163
|
+
const backendUrl = `http://localhost:${port}/backend`;
|
|
164
|
+
|
|
165
|
+
const participantCount = Array.isArray(config.participants)
|
|
166
|
+
? config.participants.length
|
|
167
|
+
: config.participants.count;
|
|
168
|
+
|
|
169
|
+
const backend = spawn('npx', ['tsx', 'src/backend.ts'], {
|
|
170
|
+
cwd: './backend',
|
|
171
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Wait for backend to signal readiness before spawning workers
|
|
175
|
+
await new Promise<void>((resolve, reject) => {
|
|
176
|
+
backend.on('error', reject);
|
|
177
|
+
|
|
178
|
+
backend.stdout?.on('data', (data: any) => {
|
|
179
|
+
const msg = data.toString();
|
|
180
|
+
if (msg.includes('REACTIVE_BACKEND_READY')) {
|
|
181
|
+
resolve();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
backend.stderr?.on('data', (data: any) => {
|
|
186
|
+
console.error(`[backend] ${data.toString().trim()}`);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
setTimeout(() => reject(new Error('Backend did not start within 10 seconds')), 10000);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
console.log(`Backend running on port ${port}`);
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const concurrency = config.concurrency ?? (await import('os')).cpus().length;
|
|
196
|
+
console.log(`Simulating ${participantCount} participants (concurrency: ${concurrency})...`);
|
|
197
|
+
|
|
198
|
+
let completed = 0;
|
|
199
|
+
const errors: { index: number; error: string }[] = [];
|
|
200
|
+
|
|
201
|
+
const writeProgress = () => {
|
|
202
|
+
const pct = Math.min(100, Math.round((completed / participantCount) * 100));
|
|
203
|
+
const filled = Math.floor(pct / 2);
|
|
204
|
+
const bar = '█'.repeat(filled) + '░'.repeat(50 - filled);
|
|
205
|
+
process.stdout.write(`\r ${bar} ${completed}/${participantCount} (${pct}%)`);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const spawnWorker = (i: number) => new Promise<void>((resolve) => {
|
|
209
|
+
let stderr = '';
|
|
210
|
+
const worker = spawn('npx', ['tsx', scriptPath], {
|
|
211
|
+
env: {
|
|
212
|
+
...process.env,
|
|
213
|
+
_REACTIVE_WORKER_INDEX: String(i),
|
|
214
|
+
_REACTIVE_BACKEND_URL: backendUrl,
|
|
215
|
+
},
|
|
216
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
worker.stderr?.on('data', (data: any) => { stderr += data.toString(); });
|
|
220
|
+
|
|
221
|
+
worker.on('close', (code: number) => {
|
|
222
|
+
if (code !== 0) {
|
|
223
|
+
errors.push({ index: i, error: stderr.trim() || `exited with code ${code}` });
|
|
224
|
+
}
|
|
225
|
+
completed++;
|
|
226
|
+
writeProgress();
|
|
227
|
+
resolve();
|
|
228
|
+
});
|
|
229
|
+
worker.on('error', (err: Error) => {
|
|
230
|
+
errors.push({ index: i, error: err.message });
|
|
231
|
+
completed++;
|
|
232
|
+
writeProgress();
|
|
233
|
+
resolve();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
writeProgress();
|
|
238
|
+
|
|
239
|
+
// Worker pool: always keep `concurrency` workers running
|
|
240
|
+
let nextIndex = 0;
|
|
241
|
+
await new Promise<void>((resolveAll) => {
|
|
242
|
+
const startNext = () => {
|
|
243
|
+
if (nextIndex >= participantCount) {
|
|
244
|
+
if (completed >= participantCount) resolveAll();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const i = nextIndex++;
|
|
248
|
+
spawnWorker(i).then(startNext);
|
|
249
|
+
};
|
|
250
|
+
for (let j = 0; j < Math.min(concurrency, participantCount); j++) {
|
|
251
|
+
startNext();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
process.stdout.write('\n');
|
|
256
|
+
|
|
257
|
+
if (errors.length > 0) {
|
|
258
|
+
console.error(`\n${errors.length} participant(s) failed:`);
|
|
259
|
+
for (const { index, error } of errors) {
|
|
260
|
+
console.error(` Participant ${index}: ${error}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
console.log(`Simulation complete. ${completed - errors.length}/${participantCount} participants simulated successfully.`);
|
|
265
|
+
} finally {
|
|
266
|
+
backend.kill();
|
|
267
|
+
console.log('Backend stopped.');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
|
|
3
|
+
import { FileUpload, Store, TrialData } from './common';
|
|
4
|
+
|
|
5
|
+
type DataObject = {
|
|
6
|
+
[key: string]: string | number | boolean | null | undefined;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const flattenerRegistry: Record<string, {
|
|
11
|
+
csv: string | null;
|
|
12
|
+
flatten?: (item: TrialData) => any[];
|
|
13
|
+
}[]> = {};
|
|
14
|
+
|
|
15
|
+
export function registerFlattener(
|
|
16
|
+
type: string,
|
|
17
|
+
csv: string | null,
|
|
18
|
+
flatten?: (item: TrialData) => any[],
|
|
19
|
+
) {
|
|
20
|
+
if (!flattenerRegistry[type]) flattenerRegistry[type] = [];
|
|
21
|
+
flattenerRegistry[type].push({ csv, flatten });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function escapeCsvValue(value: any): string {
|
|
25
|
+
if (value === null || value === undefined) {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const stringValue = String(value);
|
|
30
|
+
|
|
31
|
+
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
|
|
32
|
+
const escapedValue = stringValue.replace(/"/g, '""');
|
|
33
|
+
return `"${escapedValue}"`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return stringValue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function convertArrayOfObjectsToCSV(data: DataObject[]): string {
|
|
40
|
+
if (!data || data.length === 0) {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const headerSet = new Set<string>();
|
|
45
|
+
data.forEach((obj) => {
|
|
46
|
+
Object.keys(obj).forEach((key) => {
|
|
47
|
+
headerSet.add(key);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
const headers = Array.from(headerSet);
|
|
51
|
+
|
|
52
|
+
const headerRow = headers.map((header) => escapeCsvValue(header)).join(',');
|
|
53
|
+
|
|
54
|
+
const dataRows = data.map((obj) => {
|
|
55
|
+
return headers
|
|
56
|
+
.map((header) => {
|
|
57
|
+
const value = obj[header];
|
|
58
|
+
return escapeCsvValue(value);
|
|
59
|
+
})
|
|
60
|
+
.join(',');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return [headerRow, ...dataRows].join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Extracts experiment parameter values (URL params / registered params) from the initial metadata trial.
|
|
67
|
+
// Each param entry has { value, defaultValue, ... } — we pick value if set, otherwise the default.
|
|
68
|
+
function extractParams(data: any[]): Record<string, any> {
|
|
69
|
+
const paramsSource = data?.[0]?.responseData?.params;
|
|
70
|
+
if (!paramsSource || typeof paramsSource !== 'object') return {};
|
|
71
|
+
const result: Record<string, any> = {};
|
|
72
|
+
for (const [name, details] of Object.entries(paramsSource) as [string, any][]) {
|
|
73
|
+
if (details && typeof details === 'object') {
|
|
74
|
+
result[name] = details.value ?? details.defaultValue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Built-in fields on every trial data object — not user data.
|
|
81
|
+
// Used to distinguish user-provided metadata from framework fields when building CSVs.
|
|
82
|
+
const trialBuiltinKeys = new Set(['index', 'trialNumber', 'start', 'end', 'duration', 'type', 'name', 'csv', 'responseData', 'metadata']);
|
|
83
|
+
// Subset of builtins that represent trial identity/timing — included in non-session CSVs with trial_ prefix
|
|
84
|
+
const trialInfoKeys = new Set(['index', 'trialNumber', 'start', 'end', 'duration', 'type', 'name']);
|
|
85
|
+
|
|
86
|
+
// Automatically builds CSV files from trial data using the flattener registry.
|
|
87
|
+
// Each trial is routed to a CSV file based on: item.csv override > registry default > skipped.
|
|
88
|
+
// The 'session' group is special: all trials merge into a single row, with keys namespaced
|
|
89
|
+
// by trial name (e.g. 'nickname_value', 'devicecheck_browser') to avoid collisions.
|
|
90
|
+
// All other groups produce multi-row CSVs, one row per trial (or more if the flattener expands them).
|
|
91
|
+
function autoBuildCSVs(sessionID: string, data: any[], sessionData?: Record<string, any>): FileUpload[] {
|
|
92
|
+
const files: FileUpload[] = [];
|
|
93
|
+
|
|
94
|
+
// Group trials by their target CSV file name.
|
|
95
|
+
// A component can register multiple targets, so one trial may appear in multiple groups.
|
|
96
|
+
// A per-item csv override replaces all registered targets.
|
|
97
|
+
const groups: Record<string, { item: any; flatten?: (item: TrialData) => any[] }[]> = {};
|
|
98
|
+
for (const item of data) {
|
|
99
|
+
if (item.index === -1) continue; // skip initial metadata entry
|
|
100
|
+
const csvOverride = item.csv;
|
|
101
|
+
const targets = csvOverride
|
|
102
|
+
? (Array.isArray(csvOverride) ? csvOverride : [csvOverride]).map((csv: string) => ({ csv }))
|
|
103
|
+
: flattenerRegistry[item.type] ?? [];
|
|
104
|
+
for (const target of targets) {
|
|
105
|
+
if (!target.csv) continue;
|
|
106
|
+
if (!groups[target.csv]) groups[target.csv] = [];
|
|
107
|
+
groups[target.csv].push({ item, flatten: 'flatten' in target ? target.flatten : undefined });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 'session' group: one row per participant with metadata + responseData namespaced by trial name
|
|
112
|
+
if (groups['session']) {
|
|
113
|
+
const row: Record<string, any> = {
|
|
114
|
+
sessionID,
|
|
115
|
+
userAgent: data?.[0]?.responseData?.userAgent,
|
|
116
|
+
...extractParams(data),
|
|
117
|
+
...sessionData,
|
|
118
|
+
};
|
|
119
|
+
for (const { item } of groups['session']) {
|
|
120
|
+
const prefix = item.name || item.type;
|
|
121
|
+
// Add user-provided metadata (non-builtin top-level keys), namespaced by trial name
|
|
122
|
+
for (const [key, value] of Object.entries(item)) {
|
|
123
|
+
if (!trialBuiltinKeys.has(key)) row[`${prefix}_${key}`] = value;
|
|
124
|
+
}
|
|
125
|
+
// Add responseData fields, namespaced by trial name
|
|
126
|
+
if (item.responseData && typeof item.responseData === 'object' && !Array.isArray(item.responseData)) {
|
|
127
|
+
for (const [key, value] of Object.entries(item.responseData)) {
|
|
128
|
+
row[`${prefix}_${key}`] = value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
files.push({
|
|
133
|
+
filename: `session.${sessionID}.${Date.now()}.csv`,
|
|
134
|
+
content: convertArrayOfObjectsToCSV([row]),
|
|
135
|
+
encoding: 'utf8' as const,
|
|
136
|
+
});
|
|
137
|
+
delete groups['session'];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// All other groups: multi-row CSVs using registered flatteners (or raw responseData spread).
|
|
141
|
+
// Each row is prepended with standard trial fields (prefixed trial_) plus any extra
|
|
142
|
+
// metadata fields (unprefixed). The flattener's output overwrites these if keys collide.
|
|
143
|
+
for (const [csvName, entries] of Object.entries(groups)) {
|
|
144
|
+
const rows = entries.flatMap(({ item, flatten }) => {
|
|
145
|
+
const base: Record<string, any> = {};
|
|
146
|
+
for (const [key, value] of Object.entries(item)) {
|
|
147
|
+
if (trialBuiltinKeys.has(key)) {
|
|
148
|
+
if (trialInfoKeys.has(key)) base[`trial_${key}`] = value;
|
|
149
|
+
} else {
|
|
150
|
+
base[key] = value;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// When no flatten function is registered, spread responseData directly.
|
|
154
|
+
const flatRows = flatten
|
|
155
|
+
? flatten(item)
|
|
156
|
+
: [item.responseData && typeof item.responseData === 'object' ? { ...item.responseData } : {}];
|
|
157
|
+
return flatRows.map((row: any) => ({ ...base, ...row }));
|
|
158
|
+
});
|
|
159
|
+
if (rows.length > 0) {
|
|
160
|
+
files.push({
|
|
161
|
+
filename: `${csvName}.${sessionID}.${Date.now()}.csv`,
|
|
162
|
+
content: convertArrayOfObjectsToCSV(rows),
|
|
163
|
+
encoding: 'utf8' as const,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return files;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function buildUploadFiles(config: {
|
|
172
|
+
sessionID: string;
|
|
173
|
+
data: any[];
|
|
174
|
+
store?: Store;
|
|
175
|
+
generateFiles?: (sessionID: string, data: any[], store?: Store) => FileUpload[];
|
|
176
|
+
sessionData?: Record<string, any>;
|
|
177
|
+
uploadRaw?: boolean;
|
|
178
|
+
}): FileUpload[] {
|
|
179
|
+
const {
|
|
180
|
+
sessionID,
|
|
181
|
+
data,
|
|
182
|
+
store,
|
|
183
|
+
generateFiles,
|
|
184
|
+
sessionData,
|
|
185
|
+
uploadRaw = true,
|
|
186
|
+
} = config;
|
|
187
|
+
|
|
188
|
+
const files: FileUpload[] = generateFiles ? generateFiles(sessionID, data, store) : [];
|
|
189
|
+
|
|
190
|
+
if (uploadRaw) {
|
|
191
|
+
files.push({
|
|
192
|
+
filename: `${sessionID}.raw.json`,
|
|
193
|
+
content: JSON.stringify(data),
|
|
194
|
+
encoding: 'utf8',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
files.push(...autoBuildCSVs(sessionID, data, sessionData));
|
|
199
|
+
|
|
200
|
+
return files;
|
|
201
|
+
}
|
package/template/README.md
CHANGED
|
@@ -85,6 +85,65 @@ To update the app, simply stop the running containers, run a `git pull` and buil
|
|
|
85
85
|
The server will create a "data" directory in the root directory of the cloned repo,
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
## Simulation
|
|
89
|
+
|
|
90
|
+
You can simulate your experiment headlessly to generate synthetic data, test your data pipeline, or plan sample sizes.
|
|
91
|
+
|
|
92
|
+
### Setup
|
|
93
|
+
|
|
94
|
+
Define your participant generator and simulation config in `Experiment.tsx`:
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
export const simulationConfig = {
|
|
98
|
+
participants: {
|
|
99
|
+
generator: (i) => ({ id: i, nickname: `participant_${i}` }),
|
|
100
|
+
count: 10,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Running
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
npm run simulate
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
This starts the backend, simulates all participants through the experiment, and shuts down. The simulated data ends up in `backend/data/`.
|
|
112
|
+
|
|
113
|
+
### Customizing participant behavior
|
|
114
|
+
|
|
115
|
+
Each trial component has default decision functions that model participant behavior. Override them per-trial using the `simulators` property on a timeline item:
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
{
|
|
119
|
+
type: 'PlainInput',
|
|
120
|
+
props: { content: <p>What is your name?</p> },
|
|
121
|
+
simulators: {
|
|
122
|
+
respond: (_trialProps, participant) => ({
|
|
123
|
+
value: participant.nickname,
|
|
124
|
+
participantState: participant,
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Custom components
|
|
131
|
+
|
|
132
|
+
Register simulations for your custom trial components using `registerSimulation`. See the `CustomTrial` example in `Experiment.tsx` and the [reactive README](https://github.com/adriansteffan/reactive) for details.
|
|
133
|
+
|
|
134
|
+
### Hybrid mode
|
|
135
|
+
|
|
136
|
+
During development, auto-advance simulated trials while manually interacting with others:
|
|
137
|
+
|
|
138
|
+
Add `?hybridSimulation=true` to the URL:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
http://localhost:5173?hybridSimulation=true
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Trials with `simulators` or `simulate: true` defined will auto-advance. Others render normally for human interaction. Hybrid mode is enabled by default during development. For production, set `VITE_DISABLE_HYBRID_SIMULATION=true` to disable it.
|
|
145
|
+
|
|
146
|
+
|
|
88
147
|
## Target: Windows or MacOS
|
|
89
148
|
|
|
90
149
|
### Development
|