@jspsych-contrib/plugin-trail-making 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/src/index.ts ADDED
@@ -0,0 +1,541 @@
1
+ import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
2
+
3
+ import { version } from "../package.json";
4
+
5
+ const info = <const>{
6
+ name: "trail-making",
7
+ version: version,
8
+ parameters: {
9
+ /**
10
+ * The type of trail making test to run.
11
+ * "A" = numbers only (1-2-3-4...)
12
+ * "B" = alternating numbers and letters (1-A-2-B-3-C...)
13
+ */
14
+ test_type: {
15
+ type: ParameterType.STRING,
16
+ default: "A",
17
+ },
18
+ /**
19
+ * The number of targets to display. For type "B", this should be an even number
20
+ * to have equal numbers and letters.
21
+ */
22
+ num_targets: {
23
+ type: ParameterType.INT,
24
+ default: 25,
25
+ },
26
+ /**
27
+ * The width of the display area in pixels.
28
+ */
29
+ canvas_width: {
30
+ type: ParameterType.INT,
31
+ default: 600,
32
+ },
33
+ /**
34
+ * The height of the display area in pixels.
35
+ */
36
+ canvas_height: {
37
+ type: ParameterType.INT,
38
+ default: 600,
39
+ },
40
+ /**
41
+ * The radius of each target circle in pixels.
42
+ */
43
+ target_radius: {
44
+ type: ParameterType.INT,
45
+ default: 25,
46
+ },
47
+ /**
48
+ * The minimum distance between target centers in pixels.
49
+ */
50
+ min_separation: {
51
+ type: ParameterType.INT,
52
+ default: 80,
53
+ },
54
+ /**
55
+ * The color of unvisited target circles.
56
+ */
57
+ target_color: {
58
+ type: ParameterType.STRING,
59
+ default: "#ffffff",
60
+ },
61
+ /**
62
+ * The border color of target circles.
63
+ */
64
+ target_border_color: {
65
+ type: ParameterType.STRING,
66
+ default: "#000000",
67
+ },
68
+ /**
69
+ * The color of visited (correctly clicked) target circles.
70
+ */
71
+ visited_color: {
72
+ type: ParameterType.STRING,
73
+ default: "#90EE90",
74
+ },
75
+ /**
76
+ * The color of the line connecting targets.
77
+ */
78
+ line_color: {
79
+ type: ParameterType.STRING,
80
+ default: "#000000",
81
+ },
82
+ /**
83
+ * The width of the connecting line in pixels.
84
+ */
85
+ line_width: {
86
+ type: ParameterType.INT,
87
+ default: 2,
88
+ },
89
+ /**
90
+ * The color to flash when an error is made.
91
+ */
92
+ error_color: {
93
+ type: ParameterType.STRING,
94
+ default: "#FF6B6B",
95
+ },
96
+ /**
97
+ * Duration in milliseconds to show error feedback.
98
+ */
99
+ error_duration: {
100
+ type: ParameterType.INT,
101
+ default: 500,
102
+ },
103
+ /**
104
+ * Optional array of {x, y, label} objects to specify exact target positions.
105
+ * If provided, overrides num_targets and random positioning.
106
+ * Coordinates are in pixels from top-left of canvas.
107
+ */
108
+ targets: {
109
+ type: ParameterType.COMPLEX,
110
+ default: null,
111
+ },
112
+ /**
113
+ * Text prompt displayed above the canvas.
114
+ */
115
+ prompt: {
116
+ type: ParameterType.HTML_STRING,
117
+ default: null,
118
+ },
119
+ /**
120
+ * Random seed for reproducible target layouts. If null, uses random seed.
121
+ */
122
+ seed: {
123
+ type: ParameterType.INT,
124
+ default: null,
125
+ },
126
+ },
127
+ data: {
128
+ /** The type of trail making test ("A" or "B"). */
129
+ test_type: {
130
+ type: ParameterType.STRING,
131
+ },
132
+ /** Array of target objects with x, y, and label properties. */
133
+ targets: {
134
+ type: ParameterType.COMPLEX,
135
+ array: true,
136
+ },
137
+ /** Array of click events with target_index, label, time, x, y, and correct properties. */
138
+ clicks: {
139
+ type: ParameterType.COMPLEX,
140
+ array: true,
141
+ },
142
+ /** Total time in milliseconds from first click to last correct click. */
143
+ completion_time: {
144
+ type: ParameterType.INT,
145
+ },
146
+ /** Number of errors (incorrect clicks). */
147
+ num_errors: {
148
+ type: ParameterType.INT,
149
+ },
150
+ /** Total path distance in pixels (sum of distances between consecutive correct targets). */
151
+ total_path_distance: {
152
+ type: ParameterType.FLOAT,
153
+ },
154
+ /** Array of response times between consecutive correct clicks. */
155
+ inter_click_times: {
156
+ type: ParameterType.INT,
157
+ array: true,
158
+ },
159
+ },
160
+ };
161
+
162
+ type Info = typeof info;
163
+
164
+ interface Target {
165
+ x: number;
166
+ y: number;
167
+ label: string;
168
+ }
169
+
170
+ interface ClickEvent {
171
+ target_index: number | null;
172
+ label: string | null;
173
+ time: number;
174
+ x: number;
175
+ y: number;
176
+ correct: boolean;
177
+ }
178
+
179
+ /**
180
+ * **trail-making**
181
+ *
182
+ * A jsPsych plugin for the Trail Making Test (TMT), a neuropsychological test of
183
+ * visual attention and task switching. Participants connect circles in sequence
184
+ * as quickly as possible.
185
+ *
186
+ * Part A: Connect numbers in order (1-2-3-4...)
187
+ * Part B: Alternate between numbers and letters (1-A-2-B-3-C...)
188
+ *
189
+ * @author Josh de Leeuw
190
+ * @see {@link https://github.com/jspsych/jspsych-contrib/tree/main/packages/plugin-trail-making}
191
+ */
192
+ class TrailMakingPlugin implements JsPsychPlugin<Info> {
193
+ static info = info;
194
+
195
+ constructor(private jsPsych: JsPsych) {}
196
+
197
+ trial(display_element: HTMLElement, trial: TrialType<Info>) {
198
+ // Generate or use provided targets
199
+ const targets = trial.targets ? (trial.targets as Target[]) : this.generateTargets(trial);
200
+
201
+ // Determine the correct sequence
202
+ const sequence = this.getCorrectSequence(trial.test_type, targets);
203
+
204
+ // State variables
205
+ let currentTargetIndex = 0;
206
+ let startTime: number | null = null;
207
+ let lastClickTime: number | null = null;
208
+ const clicks: ClickEvent[] = [];
209
+ const interClickTimes: number[] = [];
210
+ let numErrors = 0;
211
+ let isShowingError = false;
212
+
213
+ // Create display
214
+ const container = document.createElement("div");
215
+ container.style.cssText = "display: flex; flex-direction: column; align-items: center;";
216
+
217
+ if (trial.prompt) {
218
+ const promptDiv = document.createElement("div");
219
+ promptDiv.innerHTML = trial.prompt;
220
+ promptDiv.style.marginBottom = "10px";
221
+ container.appendChild(promptDiv);
222
+ }
223
+
224
+ const canvas = document.createElement("canvas");
225
+ canvas.width = trial.canvas_width;
226
+ canvas.height = trial.canvas_height;
227
+ canvas.style.cssText = "border: 1px solid #ccc; cursor: pointer; touch-action: none;";
228
+ container.appendChild(canvas);
229
+
230
+ display_element.appendChild(container);
231
+
232
+ const ctx = canvas.getContext("2d")!;
233
+
234
+ // Draw initial state
235
+ this.drawCanvas(ctx, targets, [], trial);
236
+
237
+ // Handle clicks/taps
238
+ const handleClick = (e: MouseEvent | TouchEvent) => {
239
+ if (isShowingError) return;
240
+
241
+ const rect = canvas.getBoundingClientRect();
242
+ let clientX: number, clientY: number;
243
+
244
+ if (e instanceof TouchEvent) {
245
+ clientX = e.changedTouches[0].clientX;
246
+ clientY = e.changedTouches[0].clientY;
247
+ } else {
248
+ clientX = e.clientX;
249
+ clientY = e.clientY;
250
+ }
251
+
252
+ const x = clientX - rect.left;
253
+ const y = clientY - rect.top;
254
+ const now = performance.now();
255
+
256
+ // Start timing on first click
257
+ if (startTime === null) {
258
+ startTime = now;
259
+ }
260
+
261
+ // Find clicked target
262
+ const clickedTargetIndex = this.findClickedTarget(x, y, targets, trial.target_radius);
263
+ const expectedLabel = sequence[currentTargetIndex];
264
+
265
+ const clickEvent: ClickEvent = {
266
+ target_index: clickedTargetIndex,
267
+ label: clickedTargetIndex !== null ? targets[clickedTargetIndex].label : null,
268
+ time: Math.round(now - startTime),
269
+ x: Math.round(x),
270
+ y: Math.round(y),
271
+ correct: false,
272
+ };
273
+
274
+ if (clickedTargetIndex !== null && targets[clickedTargetIndex].label === expectedLabel) {
275
+ // Correct click
276
+ clickEvent.correct = true;
277
+
278
+ if (lastClickTime !== null) {
279
+ interClickTimes.push(Math.round(now - lastClickTime));
280
+ }
281
+ lastClickTime = now;
282
+
283
+ currentTargetIndex++;
284
+ clicks.push(clickEvent);
285
+
286
+ // Redraw with updated visited targets
287
+ const visitedLabels = sequence.slice(0, currentTargetIndex);
288
+ this.drawCanvas(ctx, targets, visitedLabels, trial);
289
+
290
+ // Check if complete
291
+ if (currentTargetIndex >= sequence.length) {
292
+ this.endTrial(targets, clicks, startTime, now, numErrors, interClickTimes, trial);
293
+ }
294
+ } else {
295
+ // Error
296
+ clickEvent.correct = false;
297
+ clicks.push(clickEvent);
298
+ numErrors++;
299
+
300
+ // Show error feedback
301
+ isShowingError = true;
302
+ const visitedLabels = sequence.slice(0, currentTargetIndex);
303
+ this.drawCanvas(ctx, targets, visitedLabels, trial, true);
304
+
305
+ this.jsPsych.pluginAPI.setTimeout(() => {
306
+ isShowingError = false;
307
+ this.drawCanvas(ctx, targets, visitedLabels, trial);
308
+ }, trial.error_duration);
309
+ }
310
+ };
311
+
312
+ canvas.addEventListener("click", handleClick);
313
+ canvas.addEventListener("touchend", handleClick);
314
+ }
315
+
316
+ private generateTargets(trial: TrialType<Info>): Target[] {
317
+ const labels = this.generateLabels(trial.test_type, trial.num_targets);
318
+ const targets: Target[] = [];
319
+ const padding = trial.target_radius + 10;
320
+ const maxAttempts = 1000;
321
+
322
+ // Simple seeded random for reproducibility
323
+ let seed = trial.seed ?? Math.floor(Math.random() * 1000000);
324
+ const random = () => {
325
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
326
+ return seed / 0x7fffffff;
327
+ };
328
+
329
+ for (const label of labels) {
330
+ let placed = false;
331
+ let attempts = 0;
332
+
333
+ while (!placed && attempts < maxAttempts) {
334
+ const x = padding + random() * (trial.canvas_width - 2 * padding);
335
+ const y = padding + random() * (trial.canvas_height - 2 * padding);
336
+
337
+ // Check distance from all existing targets
338
+ let validPosition = true;
339
+ for (const target of targets) {
340
+ const dist = Math.sqrt((x - target.x) ** 2 + (y - target.y) ** 2);
341
+ if (dist < trial.min_separation) {
342
+ validPosition = false;
343
+ break;
344
+ }
345
+ }
346
+
347
+ if (validPosition) {
348
+ targets.push({ x: Math.round(x), y: Math.round(y), label });
349
+ placed = true;
350
+ }
351
+
352
+ attempts++;
353
+ }
354
+
355
+ if (!placed) {
356
+ // Fallback: place with reduced separation requirement
357
+ const x = padding + random() * (trial.canvas_width - 2 * padding);
358
+ const y = padding + random() * (trial.canvas_height - 2 * padding);
359
+ targets.push({ x: Math.round(x), y: Math.round(y), label });
360
+ }
361
+ }
362
+
363
+ return targets;
364
+ }
365
+
366
+ private generateLabels(type: string, numTargets: number): string[] {
367
+ const labels: string[] = [];
368
+
369
+ if (type === "A") {
370
+ for (let i = 1; i <= numTargets; i++) {
371
+ labels.push(i.toString());
372
+ }
373
+ } else if (type === "B") {
374
+ const numbers = [];
375
+ const letters = [];
376
+ const numPairs = Math.floor(numTargets / 2);
377
+
378
+ for (let i = 1; i <= numPairs; i++) {
379
+ numbers.push(i.toString());
380
+ }
381
+ for (let i = 0; i < numPairs; i++) {
382
+ letters.push(String.fromCharCode(65 + i)); // A, B, C, ...
383
+ }
384
+
385
+ // Interleave: 1, A, 2, B, 3, C, ...
386
+ for (let i = 0; i < numPairs; i++) {
387
+ labels.push(numbers[i]);
388
+ labels.push(letters[i]);
389
+ }
390
+
391
+ // If odd number, add one more number
392
+ if (numTargets % 2 === 1) {
393
+ labels.push((numPairs + 1).toString());
394
+ }
395
+ }
396
+
397
+ return labels;
398
+ }
399
+
400
+ private getCorrectSequence(type: string, targets: Target[]): string[] {
401
+ // The correct sequence is the labels in order
402
+ if (type === "A") {
403
+ // Sort by numeric value
404
+ return targets.map((t) => t.label).sort((a, b) => parseInt(a) - parseInt(b));
405
+ } else {
406
+ // For type B, sequence is 1, A, 2, B, 3, C, ...
407
+ const numbers = targets
408
+ .filter((t) => /^\d+$/.test(t.label))
409
+ .map((t) => t.label)
410
+ .sort((a, b) => parseInt(a) - parseInt(b));
411
+ const letters = targets
412
+ .filter((t) => /^[A-Z]$/.test(t.label))
413
+ .map((t) => t.label)
414
+ .sort();
415
+
416
+ const sequence: string[] = [];
417
+ const maxLen = Math.max(numbers.length, letters.length);
418
+ for (let i = 0; i < maxLen; i++) {
419
+ if (i < numbers.length) sequence.push(numbers[i]);
420
+ if (i < letters.length) sequence.push(letters[i]);
421
+ }
422
+ return sequence;
423
+ }
424
+ }
425
+
426
+ private findClickedTarget(
427
+ x: number,
428
+ y: number,
429
+ targets: Target[],
430
+ radius: number
431
+ ): number | null {
432
+ // Add a small cushion (5px) around each target to make clicking easier
433
+ const hitRadius = radius + 5;
434
+ for (let i = 0; i < targets.length; i++) {
435
+ const dist = Math.sqrt((x - targets[i].x) ** 2 + (y - targets[i].y) ** 2);
436
+ if (dist <= hitRadius) {
437
+ return i;
438
+ }
439
+ }
440
+ return null;
441
+ }
442
+
443
+ private drawCanvas(
444
+ ctx: CanvasRenderingContext2D,
445
+ targets: Target[],
446
+ visitedLabels: string[],
447
+ trial: TrialType<Info>,
448
+ showError: boolean = false
449
+ ) {
450
+ const width = trial.canvas_width;
451
+ const height = trial.canvas_height;
452
+
453
+ // Clear canvas
454
+ ctx.fillStyle = "#f5f5f5";
455
+ ctx.fillRect(0, 0, width, height);
456
+
457
+ // Draw lines between visited targets in sequence order
458
+ if (visitedLabels.length > 1) {
459
+ ctx.strokeStyle = trial.line_color;
460
+ ctx.lineWidth = trial.line_width;
461
+ ctx.beginPath();
462
+
463
+ for (let i = 0; i < visitedLabels.length; i++) {
464
+ const target = targets.find((t) => t.label === visitedLabels[i]);
465
+ if (target) {
466
+ if (i === 0) {
467
+ ctx.moveTo(target.x, target.y);
468
+ } else {
469
+ ctx.lineTo(target.x, target.y);
470
+ }
471
+ }
472
+ }
473
+ ctx.stroke();
474
+ }
475
+
476
+ // Draw targets
477
+ for (const target of targets) {
478
+ const isVisited = visitedLabels.includes(target.label);
479
+
480
+ // Set fill color before drawing
481
+ if (showError && !isVisited) {
482
+ ctx.fillStyle = trial.error_color;
483
+ } else if (isVisited) {
484
+ ctx.fillStyle = trial.visited_color;
485
+ } else {
486
+ ctx.fillStyle = trial.target_color;
487
+ }
488
+
489
+ // Circle
490
+ ctx.beginPath();
491
+ ctx.arc(target.x, target.y, trial.target_radius, 0, Math.PI * 2);
492
+ ctx.fill();
493
+ ctx.strokeStyle = trial.target_border_color;
494
+ ctx.lineWidth = 2;
495
+ ctx.stroke();
496
+
497
+ // Label
498
+ ctx.fillStyle = "#000000";
499
+ ctx.font = `bold ${Math.round(trial.target_radius * 0.8)}px Arial`;
500
+ ctx.textAlign = "center";
501
+ ctx.textBaseline = "middle";
502
+ ctx.fillText(target.label, target.x, target.y);
503
+ }
504
+ }
505
+
506
+ private endTrial(
507
+ targets: Target[],
508
+ clicks: ClickEvent[],
509
+ startTime: number,
510
+ endTime: number,
511
+ numErrors: number,
512
+ interClickTimes: number[],
513
+ trial: TrialType<Info>
514
+ ) {
515
+ // Calculate total path distance
516
+ const correctClicks = clicks.filter((c) => c.correct);
517
+ let totalPathDistance = 0;
518
+
519
+ for (let i = 1; i < correctClicks.length; i++) {
520
+ const prev = targets.find((t) => t.label === correctClicks[i - 1].label);
521
+ const curr = targets.find((t) => t.label === correctClicks[i].label);
522
+ if (prev && curr) {
523
+ totalPathDistance += Math.sqrt((curr.x - prev.x) ** 2 + (curr.y - prev.y) ** 2);
524
+ }
525
+ }
526
+
527
+ const trial_data = {
528
+ test_type: trial.test_type,
529
+ targets: targets,
530
+ clicks: clicks,
531
+ completion_time: Math.round(endTime - startTime),
532
+ num_errors: numErrors,
533
+ total_path_distance: Math.round(totalPathDistance * 100) / 100,
534
+ inter_click_times: interClickTimes,
535
+ };
536
+
537
+ this.jsPsych.finishTrial(trial_data);
538
+ }
539
+ }
540
+
541
+ export default TrailMakingPlugin;