jspsych 7.3.3 → 8.0.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.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/css/jspsych.css +19 -11
  3. package/dist/index.browser.js +3082 -3399
  4. package/dist/index.browser.js.map +1 -1
  5. package/dist/index.browser.min.js +6 -2
  6. package/dist/index.browser.min.js.map +1 -1
  7. package/dist/index.cjs +2464 -3327
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.ts +990 -12
  10. package/dist/index.js +2463 -3325
  11. package/dist/index.js.map +1 -1
  12. package/package.json +6 -5
  13. package/src/ExtensionManager.spec.ts +123 -0
  14. package/src/ExtensionManager.ts +81 -0
  15. package/src/JsPsych.ts +195 -690
  16. package/src/ProgressBar.spec.ts +60 -0
  17. package/src/ProgressBar.ts +60 -0
  18. package/src/index.scss +29 -8
  19. package/src/index.ts +4 -9
  20. package/src/modules/data/DataCollection.ts +1 -1
  21. package/src/modules/data/DataColumn.ts +12 -1
  22. package/src/modules/data/index.ts +92 -103
  23. package/src/modules/extensions.ts +4 -0
  24. package/src/modules/plugin-api/AudioPlayer.ts +101 -0
  25. package/src/modules/plugin-api/KeyboardListenerAPI.ts +1 -1
  26. package/src/modules/plugin-api/MediaAPI.ts +48 -106
  27. package/src/modules/plugin-api/__mocks__/AudioPlayer.ts +38 -0
  28. package/src/modules/plugin-api/index.ts +11 -14
  29. package/src/modules/plugins.ts +26 -27
  30. package/src/modules/randomization.ts +1 -1
  31. package/src/timeline/Timeline.spec.ts +921 -0
  32. package/src/timeline/Timeline.ts +342 -0
  33. package/src/timeline/TimelineNode.ts +174 -0
  34. package/src/timeline/Trial.spec.ts +897 -0
  35. package/src/timeline/Trial.ts +419 -0
  36. package/src/timeline/index.ts +232 -0
  37. package/src/timeline/util.spec.ts +124 -0
  38. package/src/timeline/util.ts +146 -0
  39. package/dist/JsPsych.d.ts +0 -112
  40. package/dist/TimelineNode.d.ts +0 -34
  41. package/dist/migration.d.ts +0 -3
  42. package/dist/modules/data/DataCollection.d.ts +0 -46
  43. package/dist/modules/data/DataColumn.d.ts +0 -15
  44. package/dist/modules/data/index.d.ts +0 -25
  45. package/dist/modules/data/utils.d.ts +0 -3
  46. package/dist/modules/extensions.d.ts +0 -22
  47. package/dist/modules/plugin-api/HardwareAPI.d.ts +0 -15
  48. package/dist/modules/plugin-api/KeyboardListenerAPI.d.ts +0 -34
  49. package/dist/modules/plugin-api/MediaAPI.d.ts +0 -32
  50. package/dist/modules/plugin-api/SimulationAPI.d.ts +0 -44
  51. package/dist/modules/plugin-api/TimeoutAPI.d.ts +0 -17
  52. package/dist/modules/plugin-api/index.d.ts +0 -8
  53. package/dist/modules/plugins.d.ts +0 -136
  54. package/dist/modules/randomization.d.ts +0 -42
  55. package/dist/modules/turk.d.ts +0 -40
  56. package/dist/modules/utils.d.ts +0 -13
  57. package/src/TimelineNode.ts +0 -544
  58. package/src/modules/plugin-api/HardwareAPI.ts +0 -32
@@ -0,0 +1,342 @@
1
+ import { DataCollection } from "../modules/data/DataCollection";
2
+ import {
3
+ repeat,
4
+ sampleWithReplacement,
5
+ sampleWithoutReplacement,
6
+ shuffle,
7
+ shuffleAlternateGroups,
8
+ } from "../modules/randomization";
9
+ import { TimelineNode } from "./TimelineNode";
10
+ import { Trial } from "./Trial";
11
+ import { PromiseWrapper } from "./util";
12
+ import {
13
+ TimelineArray,
14
+ TimelineDescription,
15
+ TimelineNodeDependencies,
16
+ TimelineNodeStatus,
17
+ TimelineVariable,
18
+ TrialDescription,
19
+ TrialResult,
20
+ isTimelineDescription,
21
+ isTrialDescription,
22
+ } from ".";
23
+
24
+ export class Timeline extends TimelineNode {
25
+ public readonly children: TimelineNode[] = [];
26
+ public readonly description: TimelineDescription;
27
+
28
+ constructor(
29
+ dependencies: TimelineNodeDependencies,
30
+ description: TimelineDescription | TimelineArray,
31
+ public readonly parent?: Timeline
32
+ ) {
33
+ super(dependencies);
34
+ this.description = Array.isArray(description) ? { timeline: description } : description;
35
+ this.initializeParameterValueCache();
36
+ }
37
+
38
+ private currentChild?: TimelineNode;
39
+ private shouldAbort = false;
40
+
41
+ public async run() {
42
+ if (typeof this.index === "undefined") {
43
+ // We're the first timeline node to run. Otherwise, another node would have set our index
44
+ // right before running us.
45
+ this.index = 0;
46
+ }
47
+
48
+ this.status = TimelineNodeStatus.RUNNING;
49
+
50
+ const { conditional_function, loop_function, repetitions = 1 } = this.description;
51
+
52
+ // Generate initial timeline variable order so the first set of timeline variables is already
53
+ // available to the `on_timeline_start` and `conditional_function` callbacks
54
+ let timelineVariableOrder = this.generateTimelineVariableOrder();
55
+ this.setCurrentTimelineVariablesByIndex(timelineVariableOrder[0]);
56
+ let isInitialTimelineVariableOrder = true; // So we don't regenerate the order in the first iteration
57
+
58
+ let currentLoopIterationResults: TrialResult[];
59
+
60
+ if (!conditional_function || conditional_function()) {
61
+ this.onStart();
62
+
63
+ for (let repetition = 0; repetition < repetitions; repetition++) {
64
+ do {
65
+ currentLoopIterationResults = [];
66
+
67
+ // Generate a new timeline variable order in each iteration except for the first one where
68
+ // it has been done before
69
+ if (isInitialTimelineVariableOrder) {
70
+ isInitialTimelineVariableOrder = false;
71
+ } else {
72
+ timelineVariableOrder = this.generateTimelineVariableOrder();
73
+ }
74
+
75
+ for (const timelineVariableIndex of timelineVariableOrder) {
76
+ this.setCurrentTimelineVariablesByIndex(timelineVariableIndex);
77
+
78
+ for (const childNode of this.instantiateChildNodes()) {
79
+ const previousChild = this.currentChild;
80
+ this.currentChild = childNode;
81
+ childNode.index = previousChild
82
+ ? previousChild.getLatestNode().index + 1
83
+ : this.index;
84
+
85
+ await childNode.run();
86
+ // @ts-expect-error TS thinks `this.status` must be `RUNNING` now, but it might have
87
+ // changed while `await`ing
88
+ if (this.status === TimelineNodeStatus.PAUSED) {
89
+ await this.resumePromise.get();
90
+ }
91
+ if (this.shouldAbort) {
92
+ this.status = TimelineNodeStatus.ABORTED;
93
+ return;
94
+ }
95
+
96
+ currentLoopIterationResults.push(...this.currentChild.getResults());
97
+ }
98
+ }
99
+ } while (loop_function && loop_function(new DataCollection(currentLoopIterationResults)));
100
+ }
101
+
102
+ this.onFinish();
103
+ }
104
+
105
+ this.status = TimelineNodeStatus.COMPLETED;
106
+ }
107
+
108
+ private onStart() {
109
+ if (this.description.on_timeline_start) {
110
+ this.description.on_timeline_start();
111
+ }
112
+ }
113
+
114
+ private onFinish() {
115
+ if (this.description.on_timeline_finish) {
116
+ this.description.on_timeline_finish();
117
+ }
118
+ }
119
+
120
+ pause() {
121
+ if (this.currentChild instanceof Timeline) {
122
+ this.currentChild.pause();
123
+ }
124
+ this.status = TimelineNodeStatus.PAUSED;
125
+ }
126
+
127
+ private resumePromise = new PromiseWrapper();
128
+ resume() {
129
+ if (this.status == TimelineNodeStatus.PAUSED) {
130
+ if (this.currentChild instanceof Timeline) {
131
+ this.currentChild.resume();
132
+ }
133
+ this.status = TimelineNodeStatus.RUNNING;
134
+ this.resumePromise.resolve();
135
+ }
136
+ }
137
+
138
+ /**
139
+ * If the timeline is running or paused, aborts the timeline after the current trial has completed
140
+ */
141
+ abort() {
142
+ if (this.status === TimelineNodeStatus.RUNNING || this.status === TimelineNodeStatus.PAUSED) {
143
+ if (this.currentChild instanceof Timeline) {
144
+ this.currentChild.abort();
145
+ }
146
+
147
+ this.shouldAbort = true;
148
+ if (this.status === TimelineNodeStatus.PAUSED) {
149
+ this.resume();
150
+ }
151
+ }
152
+ }
153
+
154
+ private instantiateChildNodes() {
155
+ const newChildNodes = this.description.timeline.map((childDescription) => {
156
+ return isTimelineDescription(childDescription)
157
+ ? new Timeline(this.dependencies, childDescription, this)
158
+ : new Trial(this.dependencies, childDescription, this);
159
+ });
160
+ this.children.push(...newChildNodes);
161
+ return newChildNodes;
162
+ }
163
+
164
+ private currentTimelineVariables: Record<string, any>;
165
+ private setCurrentTimelineVariablesByIndex(index: number | null) {
166
+ this.currentTimelineVariables = {
167
+ ...this.parent?.getAllTimelineVariables(),
168
+ ...(index === null ? undefined : this.description.timeline_variables[index]),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * If the timeline has timeline variables, returns the order of `timeline_variables` array indices
174
+ * to be used, according to the timeline's `sample` setting. If the timeline has no timeline
175
+ * variables, returns `[null]`.
176
+ */
177
+ private generateTimelineVariableOrder() {
178
+ const timelineVariableLength = this.description.timeline_variables?.length;
179
+ if (!timelineVariableLength) {
180
+ return [null];
181
+ }
182
+
183
+ let order = [...Array(timelineVariableLength).keys()];
184
+
185
+ const sample = this.description.sample;
186
+
187
+ if (sample) {
188
+ switch (sample.type) {
189
+ case "custom":
190
+ order = sample.fn(order);
191
+ break;
192
+
193
+ case "with-replacement":
194
+ order = sampleWithReplacement(order, sample.size, sample.weights);
195
+ break;
196
+
197
+ case "without-replacement":
198
+ order = sampleWithoutReplacement(order, sample.size);
199
+ break;
200
+
201
+ case "fixed-repetitions":
202
+ order = repeat(order, sample.size);
203
+ break;
204
+
205
+ case "alternate-groups":
206
+ order = shuffleAlternateGroups(sample.groups, sample.randomize_group_order);
207
+ break;
208
+
209
+ default:
210
+ throw new Error(
211
+ `Invalid type "${
212
+ // @ts-expect-error TS doesn't have a type for `sample` in this case
213
+ sample.type
214
+ }" in timeline sample parameters. Valid options for type are "custom", "with-replacement", "without-replacement", "fixed-repetitions", and "alternate-groups"`
215
+ );
216
+ }
217
+ }
218
+
219
+ if (this.description.randomize_order) {
220
+ order = shuffle(order);
221
+ }
222
+
223
+ return order;
224
+ }
225
+
226
+ /**
227
+ * Returns the current values of all timeline variables, including those from parent timelines
228
+ */
229
+ public getAllTimelineVariables() {
230
+ return this.currentTimelineVariables;
231
+ }
232
+
233
+ public evaluateTimelineVariable(variable: TimelineVariable) {
234
+ if (this.currentTimelineVariables?.hasOwnProperty(variable.name)) {
235
+ return this.currentTimelineVariables[variable.name];
236
+ }
237
+ throw new Error(`Timeline variable ${variable.name} not found.`);
238
+ }
239
+
240
+ public getResults() {
241
+ const results: TrialResult[] = [];
242
+ for (const child of this.children) {
243
+ if (child instanceof Trial) {
244
+ const childResult = child.getResult();
245
+ if (childResult) {
246
+ results.push(childResult);
247
+ }
248
+ } else if (child instanceof Timeline) {
249
+ results.push(...child.getResults());
250
+ }
251
+ }
252
+
253
+ return results;
254
+ }
255
+
256
+ /**
257
+ * Returns the naive progress of the timeline (as a fraction), without considering conditional or
258
+ * loop functions.
259
+ */
260
+ public getNaiveProgress() {
261
+ if (this.status === TimelineNodeStatus.PENDING) {
262
+ return 0;
263
+ }
264
+
265
+ const activeNode = this.getLatestNode();
266
+ if (!activeNode) {
267
+ return 1;
268
+ }
269
+
270
+ let completedTrials = activeNode.index;
271
+ if (activeNode.getStatus() === TimelineNodeStatus.COMPLETED) {
272
+ completedTrials++;
273
+ }
274
+
275
+ return Math.min(completedTrials / this.getNaiveTrialCount(), 1);
276
+ }
277
+
278
+ /**
279
+ * Recursively computes the naive number of trials in the timeline, without considering
280
+ * conditional or loop functions.
281
+ */
282
+ public getNaiveTrialCount() {
283
+ // Since child timeline nodes are instantiated lazily, we cannot rely on them but instead have
284
+ // to recurse the description programmatically.
285
+
286
+ const getTrialCount = (description: TimelineArray | TimelineDescription | TrialDescription) => {
287
+ const getTimelineArrayTrialCount = (description: TimelineArray) =>
288
+ description
289
+ .map((childDescription) => getTrialCount(childDescription))
290
+ .reduce((a, b) => a + b);
291
+
292
+ if (Array.isArray(description)) {
293
+ return getTimelineArrayTrialCount(description);
294
+ }
295
+
296
+ if (isTrialDescription(description)) {
297
+ return 1;
298
+ }
299
+ if (isTimelineDescription(description)) {
300
+ let conditionCount = description.timeline_variables?.length || 1;
301
+
302
+ switch (description.sample?.type) {
303
+ case "with-replacement":
304
+ case "without-replacement":
305
+ conditionCount = description.sample.size;
306
+ break;
307
+
308
+ case "fixed-repetitions":
309
+ conditionCount *= description.sample.size;
310
+ break;
311
+
312
+ case "alternate-groups":
313
+ conditionCount = description.sample.groups
314
+ .map((group) => group.length)
315
+ .reduce((a, b) => a + b, 0);
316
+ break;
317
+ }
318
+
319
+ return (
320
+ getTimelineArrayTrialCount(description.timeline) *
321
+ (description.repetitions ?? 1) *
322
+ conditionCount
323
+ );
324
+ }
325
+ return 0;
326
+ };
327
+
328
+ return getTrialCount(this.description);
329
+ }
330
+
331
+ public getLatestNode() {
332
+ return this.currentChild?.getLatestNode() ?? this;
333
+ }
334
+
335
+ public getActiveTimelineByName(name: string) {
336
+ if (this.description.name === name) {
337
+ return this;
338
+ }
339
+
340
+ return this.currentChild?.getActiveTimelineByName(name);
341
+ }
342
+ }
@@ -0,0 +1,174 @@
1
+ import type { Timeline } from "./Timeline";
2
+ import { ParameterObjectPathCache } from "./util";
3
+ import {
4
+ TimelineArray,
5
+ TimelineDescription,
6
+ TimelineNodeDependencies,
7
+ TimelineNodeStatus,
8
+ TimelineVariable,
9
+ TrialDescription,
10
+ TrialResult,
11
+ } from ".";
12
+
13
+ export type GetParameterValueOptions = {
14
+ /**
15
+ * If true, and the retrieved parameter value is a function, invoke the function and return its
16
+ * return value (defaults to `true`)
17
+ */
18
+ evaluateFunctions?: boolean;
19
+
20
+ /**
21
+ * Whether to fall back to parent timeline node parameters (defaults to `true`)
22
+ */
23
+ recursive?: boolean;
24
+
25
+ /**
26
+ * Whether the timeline node should cache the parameter lookup result for successive lookups,
27
+ * including those of nested properties or array elements (defaults to `true`)
28
+ */
29
+ cacheResult?: boolean;
30
+
31
+ /**
32
+ * A function that will be invoked with the original result of the parameter value lookup.
33
+ * Whatever it returns will subsequently be used instead of the original result. This allows to
34
+ * modify results before they are cached.
35
+ */
36
+ replaceResult?: (originalResult: any) => any;
37
+ };
38
+
39
+ export abstract class TimelineNode {
40
+ public abstract readonly description: TimelineDescription | TrialDescription | TimelineArray;
41
+
42
+ /**
43
+ * The globally unique trial index of this node. It is set when the node is run. Timeline nodes
44
+ * have the same trial index as their first trial.
45
+ */
46
+ public index?: number;
47
+
48
+ public abstract readonly parent?: Timeline;
49
+
50
+ abstract run(): Promise<void>;
51
+
52
+ /**
53
+ * Returns a flat array of all currently available results of this node
54
+ */
55
+ abstract getResults(): TrialResult[];
56
+
57
+ /**
58
+ * Recursively evaluates the given timeline variable, starting at the current timeline node.
59
+ * Returns the result, or `undefined` if the variable is neither specified in the timeline
60
+ * description of this node, nor in the description of any parent node.
61
+ */
62
+ abstract evaluateTimelineVariable(variable: TimelineVariable): any;
63
+
64
+ /**
65
+ * Returns the most recent (child) TimelineNode. For trial nodes, this is always the trial node
66
+ * itself since trial nodes do not have child nodes. For timeline nodes, the return value is a
67
+ * Trial object most of the time, but it may also be a Timeline object when a timeline hasn't yet
68
+ * instantiated its children (e.g. during initial timeline callback functions).
69
+ */
70
+ abstract getLatestNode(): TimelineNode;
71
+
72
+ /**
73
+ * Returns an active child timeline (or itself) that matches the given name, or `undefined` if no such child exists.
74
+ */
75
+ abstract getActiveTimelineByName(name: string): Timeline | undefined;
76
+
77
+ protected status = TimelineNodeStatus.PENDING;
78
+
79
+ constructor(protected readonly dependencies: TimelineNodeDependencies) {}
80
+
81
+ getStatus() {
82
+ return this.status;
83
+ }
84
+
85
+ private parameterValueCache = new ParameterObjectPathCache();
86
+
87
+ /**
88
+ * Initializes the parameter value cache with `this.description`. To be called by subclass
89
+ * constructors after setting `this.description`.
90
+ */
91
+ protected initializeParameterValueCache() {
92
+ this.parameterValueCache.initialize(this.description);
93
+ }
94
+
95
+ /**
96
+ * Resets all cached parameter values in this timeline node and all of its parents. This is
97
+ * necessary to re-evaluate function parameters and timeline variables at each new trial.
98
+ */
99
+ protected resetParameterValueCache() {
100
+ this.parameterValueCache.reset();
101
+ this.parent?.resetParameterValueCache();
102
+ }
103
+
104
+ /**
105
+ * Retrieves a parameter value from the description of this timeline node, recursively falling
106
+ * back to the description of each parent timeline node unless `recursive` is set to `false`. If
107
+ * the parameter...
108
+ *
109
+ * * is a timeline variable, evaluates the variable and returns the result.
110
+ * * is not specified, returns `undefined`.
111
+ * * is a function and `evaluateFunctions` is not set to `false`, invokes the function and returns
112
+ * its return value
113
+ * * has previously been looked up, return the cached result of the previous lookup
114
+ *
115
+ * @param parameterPath The path of the respective parameter in the timeline node description. If
116
+ * the path is an array, nested object properties or array items will be looked up.
117
+ * @param options See {@link GetParameterValueOptions}
118
+ */
119
+ public getParameterValue(
120
+ parameterPath: string | string[],
121
+ options: GetParameterValueOptions = {}
122
+ ): any {
123
+ const {
124
+ evaluateFunctions = true,
125
+ recursive = true,
126
+ cacheResult = true,
127
+ replaceResult,
128
+ } = options;
129
+
130
+ if (typeof parameterPath === "string") {
131
+ parameterPath = [parameterPath];
132
+ }
133
+
134
+ let { doesPathExist, value: result } = this.parameterValueCache.lookup(parameterPath);
135
+ if (!doesPathExist && recursive && this.parent) {
136
+ result = this.parent.getParameterValue(parameterPath, options);
137
+ }
138
+
139
+ if (typeof result === "function" && evaluateFunctions) {
140
+ result = result();
141
+ }
142
+ if (result instanceof TimelineVariable) {
143
+ result = this.evaluateTimelineVariable(result);
144
+ }
145
+
146
+ if (typeof replaceResult === "function") {
147
+ result = replaceResult(result);
148
+ }
149
+
150
+ if (cacheResult) {
151
+ this.parameterValueCache.set(parameterPath, result);
152
+ }
153
+
154
+ return result;
155
+ }
156
+
157
+ /**
158
+ * Retrieves and evaluates the `data` parameter. It is different from other parameters in that
159
+ * it's properties may be functions that have to be evaluated, and parent nodes' data parameter
160
+ * properties are merged into the result.
161
+ */
162
+ public getDataParameter(): Record<string, any> | undefined {
163
+ const data = this.getParameterValue("data", { recursive: false });
164
+
165
+ return {
166
+ ...Object.fromEntries(
167
+ typeof data === "object"
168
+ ? Object.keys(data).map((key) => [key, this.getParameterValue(["data", key])])
169
+ : []
170
+ ),
171
+ ...this.parent?.getDataParameter(),
172
+ };
173
+ }
174
+ }