@adriansteffan/reactive 0.0.42 → 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 +11 -2
- package/README.md +106 -3
- package/dist/{mod-UqYdghJl.js → mod-D6DlS3ur.js} +6483 -6120
- 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-pL-YTTVv.js → web-D7VcCd-t.js} +1 -1
- package/dist/{web-eGzX65_f.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 +58 -8
- 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
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
compileTimeline,
|
|
5
|
+
advanceToNextContent,
|
|
6
|
+
applyMetadata,
|
|
7
|
+
RefinedTrialData,
|
|
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: RefinedTrialData[];
|
|
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: RefinedTrialData[], 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<RefinedTrialData[]> {
|
|
84
|
+
_initialParticipant = { ...participant };
|
|
85
|
+
let currentParticipantState = { ...participant };
|
|
86
|
+
const bytecode = compileTimeline(timeline);
|
|
87
|
+
let store: Store = {};
|
|
88
|
+
const data: RefinedTrialData[] = [
|
|
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
|
+
responseData: result.responseData,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
data.push(applyMetadata(trialData, content, data, store));
|
|
145
|
+
pointer = advanceToNextContent(bytecode, pointer + 1, getStore, getData, onUpdateStore);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return data;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
export interface RunSimulationConfig {
|
|
153
|
+
participants: ParticipantState[] | { generator: (index: number) => ParticipantState; count: number };
|
|
154
|
+
backendPort?: number;
|
|
155
|
+
concurrency?: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function orchestrateSimulation(config: RunSimulationConfig, scriptPath: string): Promise<void> {
|
|
159
|
+
const { spawn } = await import('child_process');
|
|
160
|
+
|
|
161
|
+
const port = config.backendPort ?? 8001;
|
|
162
|
+
const backendUrl = `http://localhost:${port}/backend`;
|
|
163
|
+
|
|
164
|
+
const participantCount = Array.isArray(config.participants)
|
|
165
|
+
? config.participants.length
|
|
166
|
+
: config.participants.count;
|
|
167
|
+
|
|
168
|
+
const backend = spawn('npx', ['tsx', 'src/backend.ts'], {
|
|
169
|
+
cwd: './backend',
|
|
170
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Wait for backend to signal readiness before spawning workers
|
|
174
|
+
await new Promise<void>((resolve, reject) => {
|
|
175
|
+
backend.on('error', reject);
|
|
176
|
+
|
|
177
|
+
backend.stdout?.on('data', (data: any) => {
|
|
178
|
+
const msg = data.toString();
|
|
179
|
+
if (msg.includes('REACTIVE_BACKEND_READY')) {
|
|
180
|
+
resolve();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
backend.stderr?.on('data', (data: any) => {
|
|
185
|
+
console.error(`[backend] ${data.toString().trim()}`);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
setTimeout(() => reject(new Error('Backend did not start within 10 seconds')), 10000);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
console.log(`Backend running on port ${port}`);
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const concurrency = config.concurrency ?? (await import('os')).cpus().length;
|
|
195
|
+
console.log(`Simulating ${participantCount} participants (concurrency: ${concurrency})...`);
|
|
196
|
+
|
|
197
|
+
let completed = 0;
|
|
198
|
+
const errors: { index: number; error: string }[] = [];
|
|
199
|
+
|
|
200
|
+
const writeProgress = () => {
|
|
201
|
+
const pct = Math.min(100, Math.round((completed / participantCount) * 100));
|
|
202
|
+
const filled = Math.floor(pct / 2);
|
|
203
|
+
const bar = '█'.repeat(filled) + '░'.repeat(50 - filled);
|
|
204
|
+
process.stdout.write(`\r ${bar} ${completed}/${participantCount} (${pct}%)`);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const spawnWorker = (i: number) => new Promise<void>((resolve) => {
|
|
208
|
+
let stderr = '';
|
|
209
|
+
const worker = spawn('npx', ['tsx', scriptPath], {
|
|
210
|
+
env: {
|
|
211
|
+
...process.env,
|
|
212
|
+
_REACTIVE_WORKER_INDEX: String(i),
|
|
213
|
+
_REACTIVE_BACKEND_URL: backendUrl,
|
|
214
|
+
},
|
|
215
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
worker.stderr?.on('data', (data: any) => { stderr += data.toString(); });
|
|
219
|
+
|
|
220
|
+
worker.on('close', (code: number) => {
|
|
221
|
+
if (code !== 0) {
|
|
222
|
+
errors.push({ index: i, error: stderr.trim() || `exited with code ${code}` });
|
|
223
|
+
}
|
|
224
|
+
completed++;
|
|
225
|
+
writeProgress();
|
|
226
|
+
resolve();
|
|
227
|
+
});
|
|
228
|
+
worker.on('error', (err: Error) => {
|
|
229
|
+
errors.push({ index: i, error: err.message });
|
|
230
|
+
completed++;
|
|
231
|
+
writeProgress();
|
|
232
|
+
resolve();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
writeProgress();
|
|
237
|
+
|
|
238
|
+
// Worker pool: always keep `concurrency` workers running
|
|
239
|
+
let nextIndex = 0;
|
|
240
|
+
await new Promise<void>((resolveAll) => {
|
|
241
|
+
const startNext = () => {
|
|
242
|
+
if (nextIndex >= participantCount) {
|
|
243
|
+
if (completed >= participantCount) resolveAll();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const i = nextIndex++;
|
|
247
|
+
spawnWorker(i).then(startNext);
|
|
248
|
+
};
|
|
249
|
+
for (let j = 0; j < Math.min(concurrency, participantCount); j++) {
|
|
250
|
+
startNext();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
process.stdout.write('\n');
|
|
255
|
+
|
|
256
|
+
if (errors.length > 0) {
|
|
257
|
+
console.error(`\n${errors.length} participant(s) failed:`);
|
|
258
|
+
for (const { index, error } of errors) {
|
|
259
|
+
console.error(` Participant ${index}: ${error}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
console.log(`Simulation complete. ${completed - errors.length}/${participantCount} participants simulated successfully.`);
|
|
264
|
+
} finally {
|
|
265
|
+
backend.kill();
|
|
266
|
+
console.log('Backend stopped.');
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
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
|
+
export function escapeCsvValue(value: any): string {
|
|
10
|
+
if (value === null || value === undefined) {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const stringValue = String(value);
|
|
15
|
+
|
|
16
|
+
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
|
|
17
|
+
const escapedValue = stringValue.replace(/"/g, '""');
|
|
18
|
+
return `"${escapedValue}"`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return stringValue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function convertArrayOfObjectsToCSV(data: DataObject[]): string {
|
|
25
|
+
if (!data || data.length === 0) {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const headerSet = new Set<string>();
|
|
30
|
+
data.forEach((obj) => {
|
|
31
|
+
Object.keys(obj).forEach((key) => {
|
|
32
|
+
headerSet.add(key);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
const headers = Array.from(headerSet);
|
|
36
|
+
|
|
37
|
+
const headerRow = headers.map((header) => escapeCsvValue(header)).join(',');
|
|
38
|
+
|
|
39
|
+
const dataRows = data.map((obj) => {
|
|
40
|
+
return headers
|
|
41
|
+
.map((header) => {
|
|
42
|
+
const value = obj[header];
|
|
43
|
+
return escapeCsvValue(value);
|
|
44
|
+
})
|
|
45
|
+
.join(',');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return [headerRow, ...dataRows].join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const defaultFlatteningFunctions: Record<
|
|
52
|
+
string,
|
|
53
|
+
(item: TrialData) => any[] | Record<string, any[]>
|
|
54
|
+
> = {
|
|
55
|
+
CanvasBlock: (item: TrialData) => {
|
|
56
|
+
const responseData = item.responseData;
|
|
57
|
+
if (Array.isArray(responseData)) {
|
|
58
|
+
return responseData.map((i) => ({
|
|
59
|
+
block: item.name,
|
|
60
|
+
...i,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
return [];
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const transform = ({ responseData, ...obj }: any) => ({
|
|
68
|
+
...obj,
|
|
69
|
+
...Object.entries(responseData || {}).reduce(
|
|
70
|
+
(acc, [k, v]) => ({ ...acc, [`data_${k}`]: v }),
|
|
71
|
+
{},
|
|
72
|
+
),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export type CSVBuilder = {
|
|
76
|
+
filename?: string;
|
|
77
|
+
trials?: string[];
|
|
78
|
+
fun?: (row: Record<string, any>) => Record<string, any>;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function combineTrialsToCsv(
|
|
82
|
+
data: any[],
|
|
83
|
+
filename: string,
|
|
84
|
+
names: string[],
|
|
85
|
+
flatteningFunctions: Record<string, (item: any) => any[] | Record<string, any[]>>,
|
|
86
|
+
fun?: (obj: any) => any,
|
|
87
|
+
): FileUpload | FileUpload[] {
|
|
88
|
+
// Collect all flattener results first, filtering out completely empty results
|
|
89
|
+
const allResults: (any[] | Record<string, any[]>)[] = names
|
|
90
|
+
.flatMap((name) => {
|
|
91
|
+
const matchingItems = data.filter((d) => d.name === name);
|
|
92
|
+
|
|
93
|
+
return matchingItems.map((item) => {
|
|
94
|
+
const flattener = item.type && flatteningFunctions[item.type];
|
|
95
|
+
const result = flattener ? flattener(item) : [transform(item)];
|
|
96
|
+
|
|
97
|
+
if (Array.isArray(result) && result.length === 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
102
|
+
const hasAnyData = Object.values(result).some(
|
|
103
|
+
(val) => Array.isArray(val) && val.length > 0,
|
|
104
|
+
);
|
|
105
|
+
if (!hasAnyData) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return result;
|
|
111
|
+
});
|
|
112
|
+
})
|
|
113
|
+
.filter((result) => result !== null);
|
|
114
|
+
|
|
115
|
+
const hasMultiTable = allResults.some(
|
|
116
|
+
(result) =>
|
|
117
|
+
result &&
|
|
118
|
+
typeof result === 'object' &&
|
|
119
|
+
!Array.isArray(result) &&
|
|
120
|
+
Object.keys(result).some((key) => Array.isArray(result[key])),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (!hasMultiTable) {
|
|
124
|
+
const processedData = allResults
|
|
125
|
+
.flatMap((result) => (Array.isArray(result) ? result : []))
|
|
126
|
+
.map((x) => (fun ? fun(x) : x));
|
|
127
|
+
|
|
128
|
+
if (processedData.length === 0) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
filename,
|
|
134
|
+
encoding: 'utf8' as const,
|
|
135
|
+
content: convertArrayOfObjectsToCSV(processedData),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const allTableKeys = new Set<string>();
|
|
140
|
+
allResults.forEach((result) => {
|
|
141
|
+
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
142
|
+
Object.keys(result).forEach((key) => {
|
|
143
|
+
if (Array.isArray(result[key])) {
|
|
144
|
+
allTableKeys.add(key);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const files: FileUpload[] = [];
|
|
151
|
+
|
|
152
|
+
for (const tableKey of allTableKeys) {
|
|
153
|
+
const tableData = allResults
|
|
154
|
+
.flatMap((result) => {
|
|
155
|
+
if (Array.isArray(result)) {
|
|
156
|
+
return result;
|
|
157
|
+
} else if (result && typeof result === 'object' && result[tableKey]) {
|
|
158
|
+
return result[tableKey];
|
|
159
|
+
}
|
|
160
|
+
return [];
|
|
161
|
+
})
|
|
162
|
+
.map((x) => (fun ? fun(x) : x));
|
|
163
|
+
|
|
164
|
+
if (tableData.length === 0) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const baseFilename = filename.replace(/\.csv$/, '');
|
|
169
|
+
|
|
170
|
+
files.push({
|
|
171
|
+
filename: `${baseFilename}_${tableKey}.csv`,
|
|
172
|
+
encoding: 'utf8' as const,
|
|
173
|
+
content: convertArrayOfObjectsToCSV(tableData),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (files.length === 0) {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return files.length === 1 ? files[0] : files;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function buildUploadFiles(config: {
|
|
185
|
+
sessionID: string;
|
|
186
|
+
data: any[];
|
|
187
|
+
store?: Store;
|
|
188
|
+
generateFiles?: (sessionID: string, data: any[], store?: Store) => FileUpload[];
|
|
189
|
+
sessionCSVBuilder?: CSVBuilder;
|
|
190
|
+
trialCSVBuilder?: {
|
|
191
|
+
flatteners: Record<string, (item: any) => any[] | Record<string, any[]>>;
|
|
192
|
+
builders: CSVBuilder[];
|
|
193
|
+
};
|
|
194
|
+
uploadRaw?: boolean;
|
|
195
|
+
}): FileUpload[] {
|
|
196
|
+
const {
|
|
197
|
+
sessionID,
|
|
198
|
+
data,
|
|
199
|
+
store,
|
|
200
|
+
generateFiles,
|
|
201
|
+
sessionCSVBuilder,
|
|
202
|
+
trialCSVBuilder,
|
|
203
|
+
uploadRaw = true,
|
|
204
|
+
} = config;
|
|
205
|
+
|
|
206
|
+
const files: FileUpload[] = generateFiles ? generateFiles(sessionID, data, store) : [];
|
|
207
|
+
|
|
208
|
+
if (uploadRaw) {
|
|
209
|
+
files.push({
|
|
210
|
+
filename: `${sessionID}.raw.json`,
|
|
211
|
+
content: JSON.stringify(data),
|
|
212
|
+
encoding: 'utf8',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (sessionCSVBuilder) {
|
|
217
|
+
type ParamDetails = {
|
|
218
|
+
value?: any;
|
|
219
|
+
defaultValue: any;
|
|
220
|
+
};
|
|
221
|
+
let paramsDict: Record<string, any> = {};
|
|
222
|
+
const paramsSource: Record<string, ParamDetails | any> | undefined =
|
|
223
|
+
data?.[0]?.responseData?.params;
|
|
224
|
+
if (paramsSource && typeof paramsSource === 'object' && paramsSource !== null) {
|
|
225
|
+
paramsDict = Object.entries(paramsSource).reduce(
|
|
226
|
+
(
|
|
227
|
+
accumulator: Record<string, any>,
|
|
228
|
+
[paramName, paramDetails]: [string, ParamDetails | any],
|
|
229
|
+
) => {
|
|
230
|
+
if (
|
|
231
|
+
paramDetails &&
|
|
232
|
+
typeof paramDetails === 'object' &&
|
|
233
|
+
'defaultValue' in paramDetails
|
|
234
|
+
) {
|
|
235
|
+
const chosenValue = paramDetails.value ?? paramDetails.defaultValue;
|
|
236
|
+
accumulator[paramName] = chosenValue;
|
|
237
|
+
}
|
|
238
|
+
return accumulator;
|
|
239
|
+
},
|
|
240
|
+
{} as Record<string, any>,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let content: Record<string, any> = {
|
|
245
|
+
sessionID,
|
|
246
|
+
userAgent: data?.[0]?.responseData?.userAgent,
|
|
247
|
+
...paramsDict,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
if (
|
|
251
|
+
sessionCSVBuilder.trials &&
|
|
252
|
+
Array.isArray(sessionCSVBuilder.trials) &&
|
|
253
|
+
sessionCSVBuilder.trials.length > 0
|
|
254
|
+
) {
|
|
255
|
+
for (const trialName of sessionCSVBuilder.trials) {
|
|
256
|
+
const matchingDataElement = data.find((element) => element.name === trialName);
|
|
257
|
+
|
|
258
|
+
if (matchingDataElement?.responseData) {
|
|
259
|
+
if (
|
|
260
|
+
typeof matchingDataElement.responseData === 'object' &&
|
|
261
|
+
matchingDataElement.responseData !== null
|
|
262
|
+
) {
|
|
263
|
+
content = { ...content, ...matchingDataElement.responseData };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
files.push({
|
|
270
|
+
content: convertArrayOfObjectsToCSV([
|
|
271
|
+
sessionCSVBuilder.fun ? sessionCSVBuilder.fun(content) : content,
|
|
272
|
+
]),
|
|
273
|
+
filename: `${sessionID}${sessionCSVBuilder.filename}.csv`,
|
|
274
|
+
encoding: 'utf8' as const,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (trialCSVBuilder) {
|
|
279
|
+
for (const builder of trialCSVBuilder.builders) {
|
|
280
|
+
const result = combineTrialsToCsv(
|
|
281
|
+
data,
|
|
282
|
+
`${sessionID}${builder.filename}.csv`,
|
|
283
|
+
builder.trials ?? [],
|
|
284
|
+
{ ...defaultFlatteningFunctions, ...trialCSVBuilder.flatteners },
|
|
285
|
+
builder.fun,
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
if (Array.isArray(result)) {
|
|
289
|
+
if (result.length > 0) {
|
|
290
|
+
files.push(...result);
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
files.push(result);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return files;
|
|
299
|
+
}
|
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
|
package/template/package.json
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"dev": "vite",
|
|
48
48
|
"backend": "npm run dev --prefix backend",
|
|
49
49
|
"dev:all": "concurrently -n \"FRONT,BACK\" -c \"blue,green\" \"npm run dev\" \"npm run backend\"",
|
|
50
|
+
"simulate": "npx tsx simulate.ts",
|
|
50
51
|
"build": "tsc && vite build",
|
|
51
52
|
"test": "vitest --silent=false",
|
|
52
53
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
|
@@ -85,6 +86,7 @@
|
|
|
85
86
|
"@typescript-eslint/parser": "^8.16.0",
|
|
86
87
|
"@vitejs/plugin-react": "^4.2.1",
|
|
87
88
|
"concurrently": "^9.1.0",
|
|
89
|
+
"tsx": "^4.19.2",
|
|
88
90
|
"electron": "^35.0.0",
|
|
89
91
|
"electron-builder": "^25.1.8",
|
|
90
92
|
"electron-vite": "^3.0.0",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { orchestrateSimulation, simulateParticipant, setBackendUrl } from '@adriansteffan/reactive';
|
|
2
|
+
import { experiment, simulationConfig } from './src/Experiment';
|
|
3
|
+
|
|
4
|
+
// Each simulated participant runs as a separate subprocess to get fresh module-level
|
|
5
|
+
// randomization (e.g., group assignment). The orchestrator spawns workers that re-run
|
|
6
|
+
// this script with _REACTIVE_WORKER_INDEX set.
|
|
7
|
+
if (process.env._REACTIVE_WORKER_INDEX) {
|
|
8
|
+
const index = parseInt(process.env._REACTIVE_WORKER_INDEX);
|
|
9
|
+
const participants = simulationConfig.participants;
|
|
10
|
+
const participant = Array.isArray(participants) ? participants[index] : participants.generator(index);
|
|
11
|
+
setBackendUrl(process.env._REACTIVE_BACKEND_URL!);
|
|
12
|
+
await simulateParticipant(experiment, participant);
|
|
13
|
+
} else {
|
|
14
|
+
await orchestrateSimulation(simulationConfig, import.meta.filename);
|
|
15
|
+
}
|