@adriansteffan/reactive 0.1.1 → 0.1.3
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 +13 -1
- package/README.md +184 -5
- package/dist/{mod-Beb0Bz3s.js → mod-DRCLdWzq.js} +54565 -32232
- package/dist/mod.d.ts +170 -1
- package/dist/reactive.es.js +52 -41
- package/dist/reactive.umd.js +10207 -52
- package/dist/style.css +1 -1
- package/dist/{web-DOXm98lr.js → web-CWqttxrD.js} +1 -1
- package/dist/{web-aMUVS_EB.js → web-N9H86cSP.js} +1 -1
- package/package.json +9 -1
- package/rdk_doc.md +341 -0
- package/src/components/canvasblock.tsx +4 -3
- package/src/components/experimentrunner.tsx +5 -1
- package/src/components/index.ts +4 -0
- package/src/components/mobilefilepermission.tsx +2 -0
- package/src/components/plaininput.tsx +2 -1
- package/src/components/quest.tsx +8 -7
- package/src/components/randomdotkinetogram.tsx +1014 -0
- package/src/components/text.tsx +2 -1
- package/src/components/tutorial.tsx +232 -0
- package/src/components/upload.tsx +2 -1
- package/src/mod.tsx +2 -0
- package/src/utils/array.ts +4 -2
- package/src/utils/distributions.test.ts +209 -0
- package/src/utils/distributions.ts +191 -0
- package/src/utils/simulation.ts +9 -4
- package/template/simulate.ts +24 -4
- package/template/src/Experiment.tsx +7 -9
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
useCallback,
|
|
6
|
+
useMemo,
|
|
7
|
+
forwardRef,
|
|
8
|
+
useImperativeHandle,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import type { CSSProperties } from 'react';
|
|
11
|
+
import { BaseComponentProps, shuffle } from '../mod';
|
|
12
|
+
import { registerSimulation } from '../utils/simulation';
|
|
13
|
+
import { registerFlattener } from '../utils/upload';
|
|
14
|
+
import { uniform } from '../utils/distributions';
|
|
15
|
+
|
|
16
|
+
registerFlattener('RandomDotKinematogram', 'rdk');
|
|
17
|
+
|
|
18
|
+
// Single source of truth for all RDK parameter defaults.
|
|
19
|
+
// Used by RDKCanvas, the trial component, and the simulation.
|
|
20
|
+
const RDK_DEFAULTS = {
|
|
21
|
+
validKeys: [] as string[],
|
|
22
|
+
duration: 1000,
|
|
23
|
+
responseEndsTrial: true,
|
|
24
|
+
dotCount: 300,
|
|
25
|
+
dotSetCount: 1,
|
|
26
|
+
direction: 0,
|
|
27
|
+
coherence: 0.5,
|
|
28
|
+
opposite: 0,
|
|
29
|
+
speed: 60,
|
|
30
|
+
dotLifetime: -1,
|
|
31
|
+
dotRadius: 2,
|
|
32
|
+
dotColor: 'white',
|
|
33
|
+
backgroundColor: 'gray',
|
|
34
|
+
apertureShape: 'ellipse' as ApertureShape,
|
|
35
|
+
apertureWidth: 600,
|
|
36
|
+
apertureHeight: 400,
|
|
37
|
+
reinsertMode: 'opposite' as ReinsertType,
|
|
38
|
+
noiseMovement: 'randomDirection' as NoiseMovement,
|
|
39
|
+
fixationTime: 500,
|
|
40
|
+
showFixation: false,
|
|
41
|
+
fixationWidth: 15,
|
|
42
|
+
fixationHeight: 15,
|
|
43
|
+
fixationColor: 'white',
|
|
44
|
+
fixationThickness: 2,
|
|
45
|
+
showBorder: false,
|
|
46
|
+
borderWidth: 1,
|
|
47
|
+
borderColor: 'black',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
registerSimulation('RandomDotKinematogram', (trialProps, _experimentState, simulators, participant) => {
|
|
51
|
+
const result = simulators.respond(trialProps, participant);
|
|
52
|
+
const fixation = trialProps.fixationTime ?? RDK_DEFAULTS.fixationTime;
|
|
53
|
+
const trialDuration = trialProps.duration ?? RDK_DEFAULTS.duration;
|
|
54
|
+
const responseEndsTrial = trialProps.responseEndsTrial ?? RDK_DEFAULTS.responseEndsTrial;
|
|
55
|
+
const rt = result.value.rt;
|
|
56
|
+
// If the participant responded and responseEndsTrial is true, the trial ends at the response.
|
|
57
|
+
// Otherwise, the full duration plays out.
|
|
58
|
+
const elapsed = (responseEndsTrial && rt != null) ? rt : trialDuration;
|
|
59
|
+
return {
|
|
60
|
+
responseData: result.value,
|
|
61
|
+
participantState: result.participantState,
|
|
62
|
+
duration: fixation + elapsed,
|
|
63
|
+
};
|
|
64
|
+
}, {
|
|
65
|
+
respond: (trialProps: any, participant: any) => {
|
|
66
|
+
const merged = { ...RDK_DEFAULTS, ...trialProps };
|
|
67
|
+
const rawRt = uniform(200, 800);
|
|
68
|
+
const maxRt = merged.duration ?? RDK_DEFAULTS.duration;
|
|
69
|
+
const responded = merged.validKeys.length > 0 && rawRt <= maxRt;
|
|
70
|
+
const key = responded ? merged.validKeys[Math.floor(uniform(0, merged.validKeys.length))] : null;
|
|
71
|
+
const rt = responded ? rawRt : null;
|
|
72
|
+
const correctKeys = Array.isArray(merged.correctResponse)
|
|
73
|
+
? merged.correctResponse.map((c: string) => c.toLowerCase())
|
|
74
|
+
: merged.correctResponse ? [merged.correctResponse.toLowerCase()] : null;
|
|
75
|
+
return {
|
|
76
|
+
value: {
|
|
77
|
+
...merged,
|
|
78
|
+
rt,
|
|
79
|
+
response: key,
|
|
80
|
+
correct: key && correctKeys ? correctKeys.includes(key) : null,
|
|
81
|
+
framesDisplayed: 0,
|
|
82
|
+
measuredRefreshRate: null,
|
|
83
|
+
},
|
|
84
|
+
participantState: participant,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// this is assigned per dot
|
|
90
|
+
export type NoiseMovement = 'randomTeleport' | 'randomWalk' | 'randomDirection';
|
|
91
|
+
type FrameMovement = 'coherent' | 'opposite' | NoiseMovement;
|
|
92
|
+
|
|
93
|
+
type ApertureShape = 'circle' | 'ellipse' | 'square' | 'rectangle';
|
|
94
|
+
type ReinsertType = 'random' | 'opposite' | 'oppositeSimple' | 'wrap';
|
|
95
|
+
|
|
96
|
+
// Constants for refresh rate calibration
|
|
97
|
+
const CALIBRATION_FRAME_COUNT = 10;
|
|
98
|
+
const EMA_ALPHA = 0.1; // Smoothing factor for exponential moving average
|
|
99
|
+
|
|
100
|
+
// Generate shuffled assignments with exact counts
|
|
101
|
+
const generateShuffledAssignments = (
|
|
102
|
+
dotCount: number,
|
|
103
|
+
coherence: number,
|
|
104
|
+
opposite: number,
|
|
105
|
+
noiseMovement: FrameMovement,
|
|
106
|
+
): FrameMovement[] => {
|
|
107
|
+
const nCoherent = Math.floor(dotCount * coherence);
|
|
108
|
+
const nOpposite = Math.floor(dotCount * opposite);
|
|
109
|
+
const assignments: FrameMovement[] = [
|
|
110
|
+
...Array(nCoherent).fill('coherent' as FrameMovement),
|
|
111
|
+
...Array(nOpposite).fill('opposite' as FrameMovement),
|
|
112
|
+
...Array(dotCount - nCoherent - nOpposite).fill(noiseMovement),
|
|
113
|
+
];
|
|
114
|
+
return shuffle(assignments);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
interface Dot {
|
|
118
|
+
x: number;
|
|
119
|
+
y: number;
|
|
120
|
+
randomDirX: number; // x and y fields are only used when a dot currently has randomDirection movement
|
|
121
|
+
randomDirY: number;
|
|
122
|
+
lifeCount: number;
|
|
123
|
+
assignedMovement: FrameMovement;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface Aperture {
|
|
127
|
+
centerX: number;
|
|
128
|
+
centerY: number;
|
|
129
|
+
getRandomPosition(): [number, number];
|
|
130
|
+
isOutside(x: number, y: number, margin: number): boolean;
|
|
131
|
+
getOppositePosition(dot: Dot, dirX?: number, dirY?: number): [number, number];
|
|
132
|
+
getOppositePositionSimple(dot: Dot): [number, number];
|
|
133
|
+
wrap(x: number, y: number): [number, number];
|
|
134
|
+
clip(ctx: CanvasRenderingContext2D): void;
|
|
135
|
+
drawBorder(ctx: CanvasRenderingContext2D, color: string, lineWidth: number): void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const randomBetween = (min: number, max: number): number => min + Math.random() * (max - min);
|
|
139
|
+
|
|
140
|
+
const createAperture = (
|
|
141
|
+
shape: ApertureShape,
|
|
142
|
+
width: number,
|
|
143
|
+
height: number,
|
|
144
|
+
centerX: number,
|
|
145
|
+
centerY: number,
|
|
146
|
+
): Aperture => {
|
|
147
|
+
const horizontalAxis = width / 2;
|
|
148
|
+
const verticalAxis = shape === 'circle' || shape === 'square' ? horizontalAxis : height / 2;
|
|
149
|
+
|
|
150
|
+
// Toroidal wrap on bounding box - x and y wrap independently
|
|
151
|
+
const wrapOnBounds = (x: number, y: number): [number, number] => {
|
|
152
|
+
const w = horizontalAxis * 2;
|
|
153
|
+
const h = verticalAxis * 2;
|
|
154
|
+
const left = centerX - horizontalAxis;
|
|
155
|
+
const top = centerY - verticalAxis;
|
|
156
|
+
return [((((x - left) % w) + w) % w) + left, ((((y - top) % h) + h) % h) + top];
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (shape === 'circle' || shape === 'ellipse') {
|
|
160
|
+
return {
|
|
161
|
+
centerX,
|
|
162
|
+
centerY,
|
|
163
|
+
getRandomPosition() {
|
|
164
|
+
const phi = randomBetween(-Math.PI, Math.PI);
|
|
165
|
+
const rho = Math.sqrt(Math.random());
|
|
166
|
+
return [
|
|
167
|
+
Math.cos(phi) * rho * horizontalAxis + centerX,
|
|
168
|
+
Math.sin(phi) * rho * verticalAxis + centerY,
|
|
169
|
+
];
|
|
170
|
+
},
|
|
171
|
+
isOutside(x, y, margin) {
|
|
172
|
+
const effH = horizontalAxis + margin;
|
|
173
|
+
const effV = verticalAxis + margin;
|
|
174
|
+
const dx = (x - centerX) / effH;
|
|
175
|
+
const dy = (y - centerY) / effV;
|
|
176
|
+
return dx * dx + dy * dy > 1;
|
|
177
|
+
},
|
|
178
|
+
getOppositePosition(dot, dirX, dirY) {
|
|
179
|
+
// Ray-ellipse intersection: find where backward ray hits far side of boundary
|
|
180
|
+
if (dirX !== undefined && dirY !== undefined) {
|
|
181
|
+
const dirMagSq = dirX * dirX + dirY * dirY;
|
|
182
|
+
if (dirMagSq > 1e-10) {
|
|
183
|
+
// Normalize direction
|
|
184
|
+
const mag = Math.sqrt(dirMagSq);
|
|
185
|
+
const dx = dirX / mag;
|
|
186
|
+
const dy = dirY / mag;
|
|
187
|
+
|
|
188
|
+
// Position relative to center
|
|
189
|
+
const xRel = dot.x - centerX;
|
|
190
|
+
const yRel = dot.y - centerY;
|
|
191
|
+
|
|
192
|
+
// Ellipse: semi-axes squared
|
|
193
|
+
const a2 = horizontalAxis * horizontalAxis;
|
|
194
|
+
const b2 = verticalAxis * verticalAxis;
|
|
195
|
+
|
|
196
|
+
// Quadratic coefficients for ray-ellipse intersection
|
|
197
|
+
// Ray: P(t) = (dot.x - dx*t, dot.y - dy*t)
|
|
198
|
+
const A = (dx * dx) / a2 + (dy * dy) / b2;
|
|
199
|
+
const B = (xRel * dx) / a2 + (yRel * dy) / b2;
|
|
200
|
+
const C = (xRel * xRel) / a2 + (yRel * yRel) / b2 - 1;
|
|
201
|
+
|
|
202
|
+
const discriminant = B * B - A * C;
|
|
203
|
+
if (discriminant >= 0) {
|
|
204
|
+
// Larger root gives far intersection (entry point from other side)
|
|
205
|
+
const t = (B + Math.sqrt(discriminant)) / A;
|
|
206
|
+
if (t > 0 && Number.isFinite(t)) {
|
|
207
|
+
return [dot.x - dx * t, dot.y - dy * t];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Fallback: use simple center-mirror
|
|
213
|
+
return this.getOppositePositionSimple(dot);
|
|
214
|
+
},
|
|
215
|
+
getOppositePositionSimple(dot) {
|
|
216
|
+
// Mirror through center, clamp to boundary if outside
|
|
217
|
+
const mirroredX = 2 * centerX - dot.x;
|
|
218
|
+
const mirroredY = 2 * centerY - dot.y;
|
|
219
|
+
const mx = (mirroredX - centerX) / horizontalAxis;
|
|
220
|
+
const my = (mirroredY - centerY) / verticalAxis;
|
|
221
|
+
const dist = Math.sqrt(mx * mx + my * my);
|
|
222
|
+
if (dist > 1) {
|
|
223
|
+
return [centerX + (mx / dist) * horizontalAxis, centerY + (my / dist) * verticalAxis];
|
|
224
|
+
}
|
|
225
|
+
return [mirroredX, mirroredY];
|
|
226
|
+
},
|
|
227
|
+
wrap: wrapOnBounds,
|
|
228
|
+
clip(ctx) {
|
|
229
|
+
ctx.beginPath();
|
|
230
|
+
ctx.ellipse(centerX, centerY, horizontalAxis, verticalAxis, 0, 0, Math.PI * 2);
|
|
231
|
+
ctx.clip();
|
|
232
|
+
},
|
|
233
|
+
drawBorder(ctx, color, lineWidth) {
|
|
234
|
+
ctx.strokeStyle = color;
|
|
235
|
+
ctx.lineWidth = lineWidth;
|
|
236
|
+
ctx.beginPath();
|
|
237
|
+
ctx.ellipse(
|
|
238
|
+
centerX,
|
|
239
|
+
centerY,
|
|
240
|
+
horizontalAxis + lineWidth / 2,
|
|
241
|
+
verticalAxis + lineWidth / 2,
|
|
242
|
+
0,
|
|
243
|
+
0,
|
|
244
|
+
Math.PI * 2,
|
|
245
|
+
);
|
|
246
|
+
ctx.stroke();
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Rectangle or square
|
|
252
|
+
return {
|
|
253
|
+
centerX,
|
|
254
|
+
centerY,
|
|
255
|
+
getRandomPosition() {
|
|
256
|
+
return [
|
|
257
|
+
randomBetween(centerX - horizontalAxis, centerX + horizontalAxis),
|
|
258
|
+
randomBetween(centerY - verticalAxis, centerY + verticalAxis),
|
|
259
|
+
];
|
|
260
|
+
},
|
|
261
|
+
isOutside(x, y, margin) {
|
|
262
|
+
const effH = horizontalAxis + margin;
|
|
263
|
+
const effV = verticalAxis + margin;
|
|
264
|
+
return x < centerX - effH || x > centerX + effH || y < centerY - effV || y > centerY + effV;
|
|
265
|
+
},
|
|
266
|
+
getOppositePosition(dot, dirX, dirY) {
|
|
267
|
+
// Ray-rectangle intersection using slab method
|
|
268
|
+
if (dirX === undefined || dirY === undefined) {
|
|
269
|
+
return this.getOppositePositionSimple(dot);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const mag = Math.sqrt(dirX * dirX + dirY * dirY);
|
|
273
|
+
if (mag < 1e-10) {
|
|
274
|
+
return this.getOppositePositionSimple(dot);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Normalized backward direction (opposite of movement)
|
|
278
|
+
const dx = -dirX / mag;
|
|
279
|
+
const dy = -dirY / mag;
|
|
280
|
+
|
|
281
|
+
const left = centerX - horizontalAxis;
|
|
282
|
+
const right = centerX + horizontalAxis;
|
|
283
|
+
const top = centerY - verticalAxis;
|
|
284
|
+
const bottom = centerY + verticalAxis;
|
|
285
|
+
|
|
286
|
+
// Compute t for each edge (Infinity when parallel to that axis)
|
|
287
|
+
const tx1 = dx !== 0 ? (left - dot.x) / dx : -Infinity;
|
|
288
|
+
const tx2 = dx !== 0 ? (right - dot.x) / dx : -Infinity;
|
|
289
|
+
const ty1 = dy !== 0 ? (top - dot.y) / dy : -Infinity;
|
|
290
|
+
const ty2 = dy !== 0 ? (bottom - dot.y) / dy : -Infinity;
|
|
291
|
+
|
|
292
|
+
// Ray is inside rectangle when inside both slabs
|
|
293
|
+
// tEnter = latest entry, tExit = earliest exit
|
|
294
|
+
const tEnter = Math.max(Math.min(tx1, tx2), Math.min(ty1, ty2));
|
|
295
|
+
const tExit = Math.min(Math.max(tx1, tx2), Math.max(ty1, ty2));
|
|
296
|
+
|
|
297
|
+
// Use far intersection (tExit) for reinsertion
|
|
298
|
+
if (tExit > 0 && tEnter <= tExit && Number.isFinite(tExit)) {
|
|
299
|
+
return [dot.x + dx * tExit, dot.y + dy * tExit];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return this.getOppositePositionSimple(dot);
|
|
303
|
+
},
|
|
304
|
+
getOppositePositionSimple(dot) {
|
|
305
|
+
// Flip any out-of-bounds coordinate to the opposite edge
|
|
306
|
+
const left = centerX - horizontalAxis;
|
|
307
|
+
const right = centerX + horizontalAxis;
|
|
308
|
+
const top = centerY - verticalAxis;
|
|
309
|
+
const bottom = centerY + verticalAxis;
|
|
310
|
+
|
|
311
|
+
let x = dot.x,
|
|
312
|
+
y = dot.y;
|
|
313
|
+
|
|
314
|
+
if (dot.x < left) x = right;
|
|
315
|
+
else if (dot.x > right) x = left;
|
|
316
|
+
|
|
317
|
+
if (dot.y < top) y = bottom;
|
|
318
|
+
else if (dot.y > bottom) y = top;
|
|
319
|
+
|
|
320
|
+
return [x, y];
|
|
321
|
+
},
|
|
322
|
+
wrap: wrapOnBounds,
|
|
323
|
+
clip(ctx) {
|
|
324
|
+
ctx.beginPath();
|
|
325
|
+
ctx.rect(
|
|
326
|
+
centerX - horizontalAxis,
|
|
327
|
+
centerY - verticalAxis,
|
|
328
|
+
horizontalAxis * 2,
|
|
329
|
+
verticalAxis * 2,
|
|
330
|
+
);
|
|
331
|
+
ctx.clip();
|
|
332
|
+
},
|
|
333
|
+
drawBorder(ctx, color, lineWidth) {
|
|
334
|
+
ctx.strokeStyle = color;
|
|
335
|
+
ctx.lineWidth = lineWidth;
|
|
336
|
+
ctx.strokeRect(
|
|
337
|
+
centerX - horizontalAxis - lineWidth / 2,
|
|
338
|
+
centerY - verticalAxis - lineWidth / 2,
|
|
339
|
+
horizontalAxis * 2 + lineWidth,
|
|
340
|
+
verticalAxis * 2 + lineWidth,
|
|
341
|
+
);
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const createDot = (
|
|
347
|
+
assignedMovement: FrameMovement,
|
|
348
|
+
maxDotLife: number,
|
|
349
|
+
aperture: Aperture,
|
|
350
|
+
): Dot => {
|
|
351
|
+
const [x, y] = aperture.getRandomPosition();
|
|
352
|
+
|
|
353
|
+
// compute random direction for dots that need it
|
|
354
|
+
const theta = assignedMovement === 'randomDirection' ? randomBetween(-Math.PI, Math.PI) : 0;
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
x,
|
|
358
|
+
y,
|
|
359
|
+
randomDirX: theta ? Math.cos(theta) : 0,
|
|
360
|
+
randomDirY: theta ? -Math.sin(theta) : 0,
|
|
361
|
+
lifeCount: randomBetween(0, maxDotLife > 0 ? maxDotLife : 0),
|
|
362
|
+
assignedMovement,
|
|
363
|
+
};
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const updateDot = (
|
|
367
|
+
dot: Dot,
|
|
368
|
+
distance: number,
|
|
369
|
+
deltaTimeMs: number,
|
|
370
|
+
maxDotLife: number,
|
|
371
|
+
aperture: Aperture,
|
|
372
|
+
reinsertType: ReinsertType,
|
|
373
|
+
dotRadius: number,
|
|
374
|
+
coherentDir: [x: number, y: number],
|
|
375
|
+
reassignMovementTo?: FrameMovement,
|
|
376
|
+
): Dot => {
|
|
377
|
+
const updated = { ...dot };
|
|
378
|
+
updated.lifeCount += deltaTimeMs;
|
|
379
|
+
|
|
380
|
+
// Check if dot's life has expired - respawn and skip movement calculation
|
|
381
|
+
if (maxDotLife > 0 && updated.lifeCount >= maxDotLife) {
|
|
382
|
+
[updated.x, updated.y] = aperture.getRandomPosition();
|
|
383
|
+
updated.lifeCount = 0;
|
|
384
|
+
return updated;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Determine movement: use assigned method, or apply reassignment if provided
|
|
388
|
+
let method = dot.assignedMovement;
|
|
389
|
+
if (reassignMovementTo !== undefined) {
|
|
390
|
+
method = reassignMovementTo;
|
|
391
|
+
updated.assignedMovement = method;
|
|
392
|
+
|
|
393
|
+
// Regenerate random direction if assigned to randomDirection
|
|
394
|
+
if (method === 'randomDirection') {
|
|
395
|
+
const theta = randomBetween(-Math.PI, Math.PI);
|
|
396
|
+
updated.randomDirX = Math.cos(theta);
|
|
397
|
+
updated.randomDirY = -Math.sin(theta);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Track movement direction for direction-aware reinsertion
|
|
402
|
+
let moveDirX = 0;
|
|
403
|
+
let moveDirY = 0;
|
|
404
|
+
|
|
405
|
+
switch (method) {
|
|
406
|
+
case 'coherent':
|
|
407
|
+
moveDirX = coherentDir[0];
|
|
408
|
+
moveDirY = coherentDir[1];
|
|
409
|
+
break;
|
|
410
|
+
case 'opposite':
|
|
411
|
+
moveDirX = -coherentDir[0];
|
|
412
|
+
moveDirY = -coherentDir[1];
|
|
413
|
+
break;
|
|
414
|
+
case 'randomTeleport':
|
|
415
|
+
// Teleports to random position - no boundary check needed
|
|
416
|
+
[updated.x, updated.y] = aperture.getRandomPosition();
|
|
417
|
+
return updated;
|
|
418
|
+
case 'randomWalk': {
|
|
419
|
+
const theta = randomBetween(-Math.PI, Math.PI);
|
|
420
|
+
moveDirX = Math.cos(theta);
|
|
421
|
+
moveDirY = -Math.sin(theta);
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
case 'randomDirection':
|
|
425
|
+
moveDirX = updated.randomDirX;
|
|
426
|
+
moveDirY = updated.randomDirY;
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Apply movement
|
|
431
|
+
updated.x += moveDirX * distance;
|
|
432
|
+
updated.y += moveDirY * distance;
|
|
433
|
+
|
|
434
|
+
// Check bounds and reinsert with direction info
|
|
435
|
+
const outOfBounds = aperture.isOutside(updated.x, updated.y, dotRadius);
|
|
436
|
+
if (outOfBounds) {
|
|
437
|
+
if (reinsertType === 'random') {
|
|
438
|
+
[updated.x, updated.y] = aperture.getRandomPosition();
|
|
439
|
+
} else if (reinsertType === 'oppositeSimple') {
|
|
440
|
+
[updated.x, updated.y] = aperture.getOppositePositionSimple(updated);
|
|
441
|
+
} else if (reinsertType === 'opposite') {
|
|
442
|
+
[updated.x, updated.y] = aperture.getOppositePosition(updated, moveDirX, moveDirY);
|
|
443
|
+
} else if (reinsertType === 'wrap') {
|
|
444
|
+
[updated.x, updated.y] = aperture.wrap(updated.x, updated.y);
|
|
445
|
+
} else {
|
|
446
|
+
throw new Error(`Unknown reinsertType: ${reinsertType satisfies never}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return updated;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// ─── Standalone RDK Canvas ─────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
export interface RDKCanvasProps {
|
|
456
|
+
width: number;
|
|
457
|
+
height: number;
|
|
458
|
+
dotCount?: number;
|
|
459
|
+
dotSetCount?: number;
|
|
460
|
+
direction?: number;
|
|
461
|
+
coherence?: number;
|
|
462
|
+
opposite?: number;
|
|
463
|
+
speed?: number;
|
|
464
|
+
dotLifetime?: number;
|
|
465
|
+
updateRate?: number;
|
|
466
|
+
dotRadius?: number;
|
|
467
|
+
dotCharacter?: string;
|
|
468
|
+
dotColor?: string;
|
|
469
|
+
coherentDotColor?: string;
|
|
470
|
+
backgroundColor?: string;
|
|
471
|
+
apertureShape?: ApertureShape;
|
|
472
|
+
apertureWidth?: number;
|
|
473
|
+
apertureHeight?: number;
|
|
474
|
+
apertureCenterX?: number;
|
|
475
|
+
apertureCenterY?: number;
|
|
476
|
+
reinsertMode?: ReinsertType;
|
|
477
|
+
noiseMovement?: NoiseMovement;
|
|
478
|
+
reassignEveryMs?: number;
|
|
479
|
+
showFixation?: boolean;
|
|
480
|
+
fixationWidth?: number;
|
|
481
|
+
fixationHeight?: number;
|
|
482
|
+
fixationColor?: string;
|
|
483
|
+
fixationThickness?: number;
|
|
484
|
+
showBorder?: boolean;
|
|
485
|
+
borderWidth?: number;
|
|
486
|
+
borderColor?: string;
|
|
487
|
+
/** When true (default), dots are animated and visible. When false, only background (+ fixation if enabled) is shown. */
|
|
488
|
+
active?: boolean;
|
|
489
|
+
/** Seed the refresh-rate estimator (e.g. from a prior calibration). */
|
|
490
|
+
initialRefreshRate?: number;
|
|
491
|
+
style?: CSSProperties;
|
|
492
|
+
className?: string;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export interface RDKCanvasHandle {
|
|
496
|
+
getStats: () => { framesDisplayed: number; measuredRefreshRate: number | null };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export const RDKCanvas = forwardRef<RDKCanvasHandle, RDKCanvasProps>(
|
|
500
|
+
(rawProps, ref) => {
|
|
501
|
+
const {
|
|
502
|
+
width,
|
|
503
|
+
height,
|
|
504
|
+
dotCount, dotSetCount, direction, coherence, opposite, speed, dotLifetime,
|
|
505
|
+
updateRate, dotRadius, dotCharacter, dotColor, coherentDotColor, backgroundColor,
|
|
506
|
+
apertureShape, apertureWidth, apertureHeight, apertureCenterX, apertureCenterY,
|
|
507
|
+
reinsertMode, noiseMovement, reassignEveryMs,
|
|
508
|
+
showFixation, fixationWidth, fixationHeight, fixationColor, fixationThickness,
|
|
509
|
+
showBorder, borderWidth, borderColor,
|
|
510
|
+
active = true,
|
|
511
|
+
initialRefreshRate,
|
|
512
|
+
style,
|
|
513
|
+
className,
|
|
514
|
+
} = { ...RDK_DEFAULTS, ...rawProps };
|
|
515
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
516
|
+
const animationRef = useRef<number>();
|
|
517
|
+
const lastUpdateTimeRef = useRef<number>();
|
|
518
|
+
const lastFrameTimeRef = useRef<number>();
|
|
519
|
+
const timeSinceReassignRef = useRef(0);
|
|
520
|
+
const frameCountRef = useRef(0);
|
|
521
|
+
|
|
522
|
+
// Refresh rate estimation
|
|
523
|
+
const frameIntervalsRef = useRef<number[]>([]);
|
|
524
|
+
const estimatedFrameIntervalRef = useRef<number | null>(null);
|
|
525
|
+
const isCalibrated = useRef(false);
|
|
526
|
+
|
|
527
|
+
// Default aperture center to canvas center
|
|
528
|
+
const effectiveCenterX = apertureCenterX ?? width / 2;
|
|
529
|
+
const effectiveCenterY = apertureCenterY ?? height / 2;
|
|
530
|
+
|
|
531
|
+
const aperture = useMemo(
|
|
532
|
+
() =>
|
|
533
|
+
createAperture(
|
|
534
|
+
apertureShape,
|
|
535
|
+
apertureWidth,
|
|
536
|
+
apertureHeight,
|
|
537
|
+
effectiveCenterX,
|
|
538
|
+
effectiveCenterY,
|
|
539
|
+
),
|
|
540
|
+
[apertureShape, apertureWidth, apertureHeight, effectiveCenterX, effectiveCenterY],
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
// Unit vector for coherent direction (0=up, 90=right, 180=down, 270=left)
|
|
544
|
+
const coherentDir = useMemo((): [number, number] => {
|
|
545
|
+
const dirRad = ((90 - direction) * Math.PI) / 180;
|
|
546
|
+
return [Math.cos(dirRad), -Math.sin(dirRad)];
|
|
547
|
+
}, [direction]);
|
|
548
|
+
|
|
549
|
+
const dotSetsRef = useRef<Dot[][]>([]);
|
|
550
|
+
const currentSetRef = useRef(0);
|
|
551
|
+
|
|
552
|
+
// Initialize dots
|
|
553
|
+
useEffect(() => {
|
|
554
|
+
const nCoherent = Math.floor(dotCount * coherence);
|
|
555
|
+
const nOpposite = Math.floor(dotCount * opposite);
|
|
556
|
+
|
|
557
|
+
dotSetsRef.current = Array.from({ length: dotSetCount }, () =>
|
|
558
|
+
Array.from({ length: dotCount }, (_, i) => {
|
|
559
|
+
let assignedMovement: FrameMovement;
|
|
560
|
+
if (i < nCoherent) assignedMovement = 'coherent';
|
|
561
|
+
else if (i < nCoherent + nOpposite) assignedMovement = 'opposite';
|
|
562
|
+
else assignedMovement = noiseMovement;
|
|
563
|
+
|
|
564
|
+
return createDot(assignedMovement, dotLifetime, aperture);
|
|
565
|
+
}),
|
|
566
|
+
);
|
|
567
|
+
}, []);
|
|
568
|
+
|
|
569
|
+
// Seed refresh rate estimate
|
|
570
|
+
useEffect(() => {
|
|
571
|
+
if (
|
|
572
|
+
typeof initialRefreshRate === 'number' &&
|
|
573
|
+
initialRefreshRate >= 20 &&
|
|
574
|
+
initialRefreshRate <= 300
|
|
575
|
+
) {
|
|
576
|
+
estimatedFrameIntervalRef.current = 1000 / initialRefreshRate;
|
|
577
|
+
isCalibrated.current = true;
|
|
578
|
+
}
|
|
579
|
+
}, [initialRefreshRate]);
|
|
580
|
+
|
|
581
|
+
// Expose stats via ref
|
|
582
|
+
useImperativeHandle(ref, () => ({
|
|
583
|
+
getStats: () => ({
|
|
584
|
+
framesDisplayed: frameCountRef.current,
|
|
585
|
+
measuredRefreshRate: estimatedFrameIntervalRef.current
|
|
586
|
+
? Math.round(1000 / estimatedFrameIntervalRef.current)
|
|
587
|
+
: null,
|
|
588
|
+
}),
|
|
589
|
+
}));
|
|
590
|
+
|
|
591
|
+
// Drawing functions
|
|
592
|
+
const drawDots = useCallback(
|
|
593
|
+
(ctx: CanvasRenderingContext2D, dots: Dot[]) => {
|
|
594
|
+
dots.forEach((dot) => {
|
|
595
|
+
const color =
|
|
596
|
+
coherentDotColor && dot.assignedMovement === 'coherent' ? coherentDotColor : dotColor;
|
|
597
|
+
ctx.fillStyle = color;
|
|
598
|
+
|
|
599
|
+
if (dotCharacter) {
|
|
600
|
+
const fontSize = dotRadius * 2.5;
|
|
601
|
+
ctx.font = `${fontSize}px monospace`;
|
|
602
|
+
ctx.textAlign = 'center';
|
|
603
|
+
ctx.textBaseline = 'middle';
|
|
604
|
+
ctx.fillText(dotCharacter, dot.x, dot.y);
|
|
605
|
+
} else {
|
|
606
|
+
ctx.beginPath();
|
|
607
|
+
ctx.arc(dot.x, dot.y, dotRadius, 0, Math.PI * 2);
|
|
608
|
+
ctx.fill();
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
},
|
|
612
|
+
[dotColor, coherentDotColor, dotRadius, dotCharacter],
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
const drawFixation = useCallback(
|
|
616
|
+
(ctx: CanvasRenderingContext2D, cx: number, cy: number) => {
|
|
617
|
+
if (!showFixation) return;
|
|
618
|
+
|
|
619
|
+
ctx.fillStyle = fixationColor;
|
|
620
|
+
|
|
621
|
+
ctx.fillRect(
|
|
622
|
+
cx - fixationWidth,
|
|
623
|
+
cy - fixationThickness / 2,
|
|
624
|
+
fixationWidth * 2,
|
|
625
|
+
fixationThickness,
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
ctx.fillRect(
|
|
629
|
+
cx - fixationThickness / 2,
|
|
630
|
+
cy - fixationHeight,
|
|
631
|
+
fixationThickness,
|
|
632
|
+
fixationHeight * 2,
|
|
633
|
+
);
|
|
634
|
+
},
|
|
635
|
+
[showFixation, fixationColor, fixationThickness, fixationWidth, fixationHeight],
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
const drawBorder = useCallback(
|
|
639
|
+
(ctx: CanvasRenderingContext2D) => {
|
|
640
|
+
if (!showBorder) return;
|
|
641
|
+
aperture.drawBorder(ctx, borderColor, borderWidth);
|
|
642
|
+
},
|
|
643
|
+
[showBorder, borderColor, borderWidth, aperture],
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
// Animation loop
|
|
647
|
+
const animate = useCallback(
|
|
648
|
+
(timestamp: number) => {
|
|
649
|
+
const canvas = canvasRef.current;
|
|
650
|
+
const ctx = canvas?.getContext('2d');
|
|
651
|
+
if (!canvas || !ctx) return;
|
|
652
|
+
|
|
653
|
+
if (lastUpdateTimeRef.current === undefined) {
|
|
654
|
+
lastUpdateTimeRef.current = timestamp;
|
|
655
|
+
}
|
|
656
|
+
if (lastFrameTimeRef.current === undefined) {
|
|
657
|
+
lastFrameTimeRef.current = timestamp;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const frameDelta = timestamp - lastFrameTimeRef.current;
|
|
661
|
+
lastFrameTimeRef.current = timestamp;
|
|
662
|
+
frameCountRef.current++;
|
|
663
|
+
|
|
664
|
+
// Update refresh rate estimate
|
|
665
|
+
if (frameDelta > 0 && frameDelta < 500) {
|
|
666
|
+
if (!isCalibrated.current) {
|
|
667
|
+
frameIntervalsRef.current.push(frameDelta);
|
|
668
|
+
if (frameIntervalsRef.current.length >= CALIBRATION_FRAME_COUNT) {
|
|
669
|
+
const sorted = [...frameIntervalsRef.current].sort((a, b) => a - b);
|
|
670
|
+
const mid = Math.floor(sorted.length / 2);
|
|
671
|
+
estimatedFrameIntervalRef.current =
|
|
672
|
+
sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
673
|
+
isCalibrated.current = true;
|
|
674
|
+
}
|
|
675
|
+
} else {
|
|
676
|
+
estimatedFrameIntervalRef.current =
|
|
677
|
+
EMA_ALPHA * frameDelta + (1 - EMA_ALPHA) * estimatedFrameIntervalRef.current!;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const rawTimeSinceLastUpdate = timestamp - (lastUpdateTimeRef.current ?? timestamp);
|
|
682
|
+
// Cap delta to avoid massive jumps when returning from a backgrounded tab
|
|
683
|
+
const timeSinceLastUpdate = Math.min(rawTimeSinceLastUpdate, 100);
|
|
684
|
+
const updateInterval = updateRate && updateRate > 0 ? 1000 / updateRate : 0;
|
|
685
|
+
const shouldUpdate = !updateRate || updateRate <= 0 || timeSinceLastUpdate >= updateInterval;
|
|
686
|
+
|
|
687
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
688
|
+
|
|
689
|
+
// Fill only the aperture area with background color
|
|
690
|
+
ctx.save();
|
|
691
|
+
aperture.clip(ctx);
|
|
692
|
+
ctx.fillStyle = backgroundColor;
|
|
693
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
694
|
+
ctx.restore();
|
|
695
|
+
|
|
696
|
+
if (!active) {
|
|
697
|
+
drawFixation(ctx, aperture.centerX, aperture.centerY);
|
|
698
|
+
} else {
|
|
699
|
+
if (shouldUpdate) {
|
|
700
|
+
const distance = (speed * timeSinceLastUpdate) / 1000;
|
|
701
|
+
|
|
702
|
+
// Determine if we should reassign dots
|
|
703
|
+
let shouldReassign = false;
|
|
704
|
+
if (reassignEveryMs !== undefined) {
|
|
705
|
+
if (reassignEveryMs === 0) {
|
|
706
|
+
shouldReassign = true;
|
|
707
|
+
} else {
|
|
708
|
+
timeSinceReassignRef.current += timeSinceLastUpdate;
|
|
709
|
+
const halfFrameCorrection =
|
|
710
|
+
isCalibrated.current && estimatedFrameIntervalRef.current
|
|
711
|
+
? estimatedFrameIntervalRef.current * 0.5
|
|
712
|
+
: 0;
|
|
713
|
+
const correctedTime = timeSinceReassignRef.current + halfFrameCorrection;
|
|
714
|
+
shouldReassign = correctedTime >= reassignEveryMs;
|
|
715
|
+
if (shouldReassign) {
|
|
716
|
+
timeSinceReassignRef.current %= reassignEveryMs;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const reassignments = shouldReassign
|
|
722
|
+
? generateShuffledAssignments(dotCount, coherence, opposite, noiseMovement)
|
|
723
|
+
: null;
|
|
724
|
+
|
|
725
|
+
const currentSet = dotSetsRef.current[currentSetRef.current];
|
|
726
|
+
const updatedDots = currentSet.map((dot, i) =>
|
|
727
|
+
updateDot(
|
|
728
|
+
dot,
|
|
729
|
+
distance,
|
|
730
|
+
timeSinceLastUpdate,
|
|
731
|
+
dotLifetime,
|
|
732
|
+
aperture,
|
|
733
|
+
reinsertMode,
|
|
734
|
+
dotRadius,
|
|
735
|
+
coherentDir,
|
|
736
|
+
reassignments?.[i],
|
|
737
|
+
),
|
|
738
|
+
);
|
|
739
|
+
dotSetsRef.current[currentSetRef.current] = updatedDots;
|
|
740
|
+
|
|
741
|
+
currentSetRef.current = (currentSetRef.current + 1) % dotSetCount;
|
|
742
|
+
lastUpdateTimeRef.current = timestamp;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const currentDots = dotSetsRef.current[currentSetRef.current];
|
|
746
|
+
ctx.save();
|
|
747
|
+
aperture.clip(ctx);
|
|
748
|
+
drawDots(ctx, currentDots);
|
|
749
|
+
ctx.restore();
|
|
750
|
+
|
|
751
|
+
ctx.save();
|
|
752
|
+
ctx.beginPath();
|
|
753
|
+
drawFixation(ctx, aperture.centerX, aperture.centerY);
|
|
754
|
+
drawBorder(ctx);
|
|
755
|
+
ctx.restore();
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
759
|
+
},
|
|
760
|
+
[
|
|
761
|
+
active,
|
|
762
|
+
backgroundColor,
|
|
763
|
+
noiseMovement,
|
|
764
|
+
coherence,
|
|
765
|
+
opposite,
|
|
766
|
+
dotCount,
|
|
767
|
+
speed,
|
|
768
|
+
dotLifetime,
|
|
769
|
+
aperture,
|
|
770
|
+
reinsertMode,
|
|
771
|
+
dotSetCount,
|
|
772
|
+
dotRadius,
|
|
773
|
+
coherentDir,
|
|
774
|
+
updateRate,
|
|
775
|
+
drawDots,
|
|
776
|
+
drawFixation,
|
|
777
|
+
drawBorder,
|
|
778
|
+
reassignEveryMs,
|
|
779
|
+
],
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
// Start animation
|
|
783
|
+
useEffect(() => {
|
|
784
|
+
animationRef.current = requestAnimationFrame(animate);
|
|
785
|
+
return () => {
|
|
786
|
+
if (animationRef.current) {
|
|
787
|
+
cancelAnimationFrame(animationRef.current);
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
}, [animate]);
|
|
791
|
+
|
|
792
|
+
// Setup canvas with retina display support
|
|
793
|
+
useEffect(() => {
|
|
794
|
+
const canvas = canvasRef.current;
|
|
795
|
+
if (!canvas) return;
|
|
796
|
+
|
|
797
|
+
const dpr = window.devicePixelRatio || 1;
|
|
798
|
+
canvas.width = width * dpr;
|
|
799
|
+
canvas.height = height * dpr;
|
|
800
|
+
canvas.style.width = `${width}px`;
|
|
801
|
+
canvas.style.height = `${height}px`;
|
|
802
|
+
const ctx = canvas.getContext('2d');
|
|
803
|
+
if (ctx) {
|
|
804
|
+
ctx.scale(dpr, dpr);
|
|
805
|
+
}
|
|
806
|
+
}, [width, height]);
|
|
807
|
+
|
|
808
|
+
return (
|
|
809
|
+
<canvas
|
|
810
|
+
ref={canvasRef}
|
|
811
|
+
style={{ display: 'block', ...style }}
|
|
812
|
+
className={className}
|
|
813
|
+
/>
|
|
814
|
+
);
|
|
815
|
+
},
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
// ─── Trial Component ────────────────────────────────────────────────────────────
|
|
819
|
+
|
|
820
|
+
export interface RDKProps extends BaseComponentProps {
|
|
821
|
+
validKeys?: string[];
|
|
822
|
+
correctResponse?: string | string[];
|
|
823
|
+
duration?: number;
|
|
824
|
+
stimulusDuration?: number; // How long to show stimulus (defaults to duration)
|
|
825
|
+
responseEndsTrial?: boolean;
|
|
826
|
+
dotCount?: number;
|
|
827
|
+
dotSetCount?: number;
|
|
828
|
+
direction?: number;
|
|
829
|
+
coherence?: number;
|
|
830
|
+
opposite?: number;
|
|
831
|
+
speed?: number;
|
|
832
|
+
dotLifetime?: number;
|
|
833
|
+
updateRate?: number;
|
|
834
|
+
dotRadius?: number;
|
|
835
|
+
dotCharacter?: string;
|
|
836
|
+
dotColor?: string;
|
|
837
|
+
coherentDotColor?: string;
|
|
838
|
+
backgroundColor?: string;
|
|
839
|
+
apertureShape?: ApertureShape;
|
|
840
|
+
apertureWidth?: number;
|
|
841
|
+
apertureHeight?: number;
|
|
842
|
+
apertureCenterX?: number;
|
|
843
|
+
apertureCenterY?: number;
|
|
844
|
+
reinsertMode?: ReinsertType;
|
|
845
|
+
noiseMovement?: NoiseMovement;
|
|
846
|
+
reassignEveryMs?: number; // undefined = never, 0 = every update, > 0 = every X ms
|
|
847
|
+
showFixation?: boolean;
|
|
848
|
+
fixationTime?: number;
|
|
849
|
+
fixationWidth?: number;
|
|
850
|
+
fixationHeight?: number;
|
|
851
|
+
fixationColor?: string;
|
|
852
|
+
fixationThickness?: number;
|
|
853
|
+
showBorder?: boolean;
|
|
854
|
+
borderWidth?: number;
|
|
855
|
+
borderColor?: string;
|
|
856
|
+
responseHint?: string;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export const RandomDotKinematogram = (rawProps: RDKProps) => {
|
|
860
|
+
const {
|
|
861
|
+
store,
|
|
862
|
+
stimulusDuration, updateRate, dotCharacter, coherentDotColor,
|
|
863
|
+
apertureCenterX = window.innerWidth / 2,
|
|
864
|
+
apertureCenterY = window.innerHeight / 2,
|
|
865
|
+
reassignEveryMs, responseHint,
|
|
866
|
+
validKeys, duration, responseEndsTrial,
|
|
867
|
+
dotCount, dotSetCount, direction, coherence, opposite, speed, dotLifetime,
|
|
868
|
+
dotRadius, dotColor, backgroundColor,
|
|
869
|
+
apertureShape, apertureWidth, apertureHeight, reinsertMode, noiseMovement,
|
|
870
|
+
showFixation, fixationTime, fixationWidth, fixationHeight, fixationColor, fixationThickness,
|
|
871
|
+
showBorder, borderWidth, borderColor,
|
|
872
|
+
} = { ...RDK_DEFAULTS, ...rawProps };
|
|
873
|
+
// Keep a ref to the latest props so endTrial can read them without recreating on every render.
|
|
874
|
+
const propsRef = useRef(rawProps);
|
|
875
|
+
propsRef.current = rawProps;
|
|
876
|
+
|
|
877
|
+
const canvasHandle = useRef<RDKCanvasHandle>(null);
|
|
878
|
+
const startTimeRef = useRef<number>(performance.now());
|
|
879
|
+
const trialEndedRef = useRef(false);
|
|
880
|
+
const responseRef = useRef<string | null>(null);
|
|
881
|
+
const responseTimeRef = useRef<number | null>(null);
|
|
882
|
+
|
|
883
|
+
const [response, setResponse] = useState<string | null>(null);
|
|
884
|
+
const [fixationComplete, setFixationComplete] = useState(fixationTime <= 0);
|
|
885
|
+
const [stimulusVisible, setStimulusVisible] = useState(true);
|
|
886
|
+
|
|
887
|
+
const initialRefreshRate = store?._reactiveScreenRefreshRate;
|
|
888
|
+
|
|
889
|
+
const endTrial = useCallback((key: string | null, rt: number | null) => {
|
|
890
|
+
if (trialEndedRef.current) return;
|
|
891
|
+
trialEndedRef.current = true;
|
|
892
|
+
|
|
893
|
+
const { next: nextFn, data: _d, store: _s, updateStore: _u, ...rdkProps } = propsRef.current;
|
|
894
|
+
const stats = canvasHandle.current?.getStats();
|
|
895
|
+
const correctKeys = Array.isArray(rdkProps.correctResponse)
|
|
896
|
+
? rdkProps.correctResponse.map((c) => c.toLowerCase())
|
|
897
|
+
: rdkProps.correctResponse
|
|
898
|
+
? [rdkProps.correctResponse.toLowerCase()]
|
|
899
|
+
: null;
|
|
900
|
+
|
|
901
|
+
nextFn({
|
|
902
|
+
...RDK_DEFAULTS,
|
|
903
|
+
...rdkProps,
|
|
904
|
+
rt,
|
|
905
|
+
response: key,
|
|
906
|
+
correct: key && correctKeys ? correctKeys.includes(key) : null,
|
|
907
|
+
framesDisplayed: stats?.framesDisplayed ?? 0,
|
|
908
|
+
measuredRefreshRate: stats?.measuredRefreshRate ?? null,
|
|
909
|
+
});
|
|
910
|
+
}, []);
|
|
911
|
+
|
|
912
|
+
// Fixation duration delay before showing dots
|
|
913
|
+
useEffect(() => {
|
|
914
|
+
if (fixationTime <= 0) return;
|
|
915
|
+
const timer = setTimeout(() => setFixationComplete(true), fixationTime);
|
|
916
|
+
return () => clearTimeout(timer);
|
|
917
|
+
}, [fixationTime]);
|
|
918
|
+
|
|
919
|
+
// Stimulus duration timer
|
|
920
|
+
useEffect(() => {
|
|
921
|
+
const effectiveStimDur = stimulusDuration ?? duration;
|
|
922
|
+
if (effectiveStimDur <= 0) return;
|
|
923
|
+
const timer = setTimeout(() => setStimulusVisible(false), fixationTime + effectiveStimDur);
|
|
924
|
+
return () => clearTimeout(timer);
|
|
925
|
+
}, [fixationTime, stimulusDuration, duration]);
|
|
926
|
+
|
|
927
|
+
// Trial duration timer — ends trial with whatever response was collected
|
|
928
|
+
useEffect(() => {
|
|
929
|
+
if (duration <= 0) return;
|
|
930
|
+
const timer = setTimeout(() => {
|
|
931
|
+
endTrial(responseRef.current, responseTimeRef.current);
|
|
932
|
+
}, fixationTime + duration);
|
|
933
|
+
return () => clearTimeout(timer);
|
|
934
|
+
}, [fixationTime, duration, endTrial]);
|
|
935
|
+
|
|
936
|
+
// Handle keyboard response
|
|
937
|
+
useEffect(() => {
|
|
938
|
+
const handleKeyPress = (e: KeyboardEvent) => {
|
|
939
|
+
if (trialEndedRef.current || responseRef.current) return;
|
|
940
|
+
|
|
941
|
+
const key = e.key.toLowerCase();
|
|
942
|
+
const allowedKeys = validKeys.length > 0 ? validKeys.map((c) => c.toLowerCase()) : null;
|
|
943
|
+
|
|
944
|
+
if (!allowedKeys || allowedKeys.includes(key)) {
|
|
945
|
+
const rt = performance.now() - startTimeRef.current;
|
|
946
|
+
responseRef.current = key;
|
|
947
|
+
responseTimeRef.current = rt;
|
|
948
|
+
setResponse(key);
|
|
949
|
+
|
|
950
|
+
if (responseEndsTrial) {
|
|
951
|
+
endTrial(key, rt);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
window.addEventListener('keydown', handleKeyPress);
|
|
957
|
+
return () => window.removeEventListener('keydown', handleKeyPress);
|
|
958
|
+
}, [validKeys, responseEndsTrial, endTrial]);
|
|
959
|
+
|
|
960
|
+
return (
|
|
961
|
+
<div style={{ width: '100vw', height: '100vh', overflow: 'hidden', margin: 0, padding: 0, backgroundColor }}>
|
|
962
|
+
<RDKCanvas
|
|
963
|
+
ref={canvasHandle}
|
|
964
|
+
width={window.innerWidth}
|
|
965
|
+
height={window.innerHeight}
|
|
966
|
+
active={fixationComplete && stimulusVisible}
|
|
967
|
+
initialRefreshRate={initialRefreshRate}
|
|
968
|
+
dotCount={dotCount}
|
|
969
|
+
dotSetCount={dotSetCount}
|
|
970
|
+
direction={direction}
|
|
971
|
+
coherence={coherence}
|
|
972
|
+
opposite={opposite}
|
|
973
|
+
speed={speed}
|
|
974
|
+
dotLifetime={dotLifetime}
|
|
975
|
+
updateRate={updateRate}
|
|
976
|
+
dotRadius={dotRadius}
|
|
977
|
+
dotCharacter={dotCharacter}
|
|
978
|
+
dotColor={dotColor}
|
|
979
|
+
coherentDotColor={coherentDotColor}
|
|
980
|
+
backgroundColor={backgroundColor}
|
|
981
|
+
apertureShape={apertureShape}
|
|
982
|
+
apertureWidth={apertureWidth}
|
|
983
|
+
apertureHeight={apertureHeight}
|
|
984
|
+
apertureCenterX={apertureCenterX}
|
|
985
|
+
apertureCenterY={apertureCenterY}
|
|
986
|
+
reinsertMode={reinsertMode}
|
|
987
|
+
noiseMovement={noiseMovement}
|
|
988
|
+
reassignEveryMs={reassignEveryMs}
|
|
989
|
+
showFixation={showFixation}
|
|
990
|
+
fixationWidth={fixationWidth}
|
|
991
|
+
fixationHeight={fixationHeight}
|
|
992
|
+
fixationColor={fixationColor}
|
|
993
|
+
fixationThickness={fixationThickness}
|
|
994
|
+
showBorder={showBorder}
|
|
995
|
+
borderWidth={borderWidth}
|
|
996
|
+
borderColor={borderColor}
|
|
997
|
+
/>
|
|
998
|
+
{responseHint && !stimulusVisible && !response && (
|
|
999
|
+
<div
|
|
1000
|
+
style={{
|
|
1001
|
+
position: 'absolute',
|
|
1002
|
+
top: '60%',
|
|
1003
|
+
width: '100%',
|
|
1004
|
+
textAlign: 'center',
|
|
1005
|
+
color: 'white',
|
|
1006
|
+
fontSize: '1.25rem',
|
|
1007
|
+
}}
|
|
1008
|
+
>
|
|
1009
|
+
{responseHint}
|
|
1010
|
+
</div>
|
|
1011
|
+
)}
|
|
1012
|
+
</div>
|
|
1013
|
+
);
|
|
1014
|
+
};
|