@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.
@@ -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
+ };