@adriansteffan/reactive 0.1.0 → 0.1.2

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.
@@ -1,4 +1,4 @@
1
- import { W as P, b as x, E } from "./mod-D9lwPIrH.js";
1
+ import { W as P, b as x, E } from "./mod-Bb_FAy0j.js";
2
2
  function m(w) {
3
3
  const e = w.split("/").filter((t) => t !== "."), r = [];
4
4
  return e.forEach((t) => {
@@ -1,4 +1,4 @@
1
- import { W as e } from "./mod-D9lwPIrH.js";
1
+ import { W as e } from "./mod-Bb_FAy0j.js";
2
2
  class s extends e {
3
3
  async enable() {
4
4
  console.log("Immersive mode is only available on Android");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adriansteffan/reactive",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -43,6 +43,7 @@
43
43
  "@tanstack/react-query": "^5.61.3",
44
44
  "@tanstack/react-query-devtools": "^5.61.3",
45
45
  "@zip.js/zip.js": "^2.7.53",
46
+ "motion": "^12.38.0",
46
47
  "react": "^18.2.0",
47
48
  "react-dom": "^18.2.0",
48
49
  "react-icons": "^5.1.0",
package/rdk_doc.md ADDED
@@ -0,0 +1,341 @@
1
+ # Random Dot Kinematogram (RDK) Component
2
+
3
+ Mostly AI generated reference so I dont have to look at the code in case I forget something minor.
4
+ Will have to have a manual go at all of this to create an actually decent reference.
5
+
6
+ ## Basic Usage
7
+
8
+ ```typescript
9
+ {
10
+ name: 'rdk_trial',
11
+ type: 'RandomDotKinematogram',
12
+ props: {
13
+ validKeys: ['arrowleft', 'arrowright'],
14
+ correctResponse: 'arrowright',
15
+ duration: 2000,
16
+ direction: 90,
17
+ coherence: 0.5,
18
+ },
19
+ }
20
+ ```
21
+
22
+ ## Parameters
23
+
24
+ ### Trial Control
25
+
26
+ | Parameter | Type | Default | Description |
27
+ |-----------|------|---------|-------------|
28
+ | `validKeys` | `string[]` | `[]` | Valid keyboard responses. Empty array = any key valid |
29
+ | `correctResponse` | `string \| string[]` | `undefined` | The correct response(s) for this trial |
30
+ | `duration` | `number` | `1000` | Duration in ms that dots are shown (response window). Total trial time = `fixationTime + duration`. Use `-1` for infinite duration |
31
+ | `stimulusDuration` | `number` | `undefined` | How long to show stimulus in ms. Defaults to `duration`. Set lower than `duration` to hide stimulus while still accepting responses |
32
+ | `responseEndsTrial` | `boolean` | `true` | Whether response ends the trial immediately |
33
+
34
+ ### Dot Motion
35
+
36
+ | Parameter | Type | Default | Description |
37
+ |-----------|------|---------|-------------|
38
+ | `dotCount` | `number` | `300` | Number of dots per frame |
39
+ | `dotSetCount` | `number` | `1` | Number of dot sets to cycle through (reduces tracking artifacts) |
40
+ | `direction` | `number` | `0` | Direction in degrees (0=up, 90=right, 180=down, 270=left) |
41
+ | `coherence` | `number` | `0.5` | Proportion of dots moving coherently (0-1) |
42
+ | `opposite` | `number` | `0` | Proportion moving in opposite direction (0 to 1-coherence) |
43
+ | `speed` | `number` | `60` | Pixels per second (frame-rate independent) |
44
+ | `dotLifetime` | `number` | `-1` | Milliseconds before dot is replaced (-1 = infinite) |
45
+ | `updateRate` | `number` | `undefined` | Update rate in Hz. `undefined` = update every frame |
46
+
47
+ ### Dot Appearance
48
+
49
+ | Parameter | Type | Default | Description |
50
+ |-----------|------|---------|-------------|
51
+ | `dotRadius` | `number` | `2` | Radius of dots in pixels (for circles) or base size for characters |
52
+ | `dotCharacter` | `string` | `undefined` | Optional character/emoji to display instead of circles. If set, dots are rendered as text |
53
+ | `dotColor` | `string` | `"white"` | Color of dots (any CSS color) |
54
+ | `coherentDotColor` | `string` | `undefined` | Optional color for coherent dots. If specified, coherent dots will use this color instead of `dotColor` |
55
+ | `backgroundColor` | `string` | `"gray"` | Background color (any CSS color) |
56
+
57
+ ### Aperture
58
+
59
+ | Parameter | Type | Default | Description |
60
+ |-----------|------|---------|-------------|
61
+ | `apertureShape` | `'circle' \| 'ellipse' \| 'square' \| 'rectangle'` | `'ellipse'` | Shape of the aperture |
62
+ | `apertureWidth` | `number` | `600` | Width in CSS pixels (diameter for circle). Automatically scales for retina displays |
63
+ | `apertureHeight` | `number` | `400` | Height in CSS pixels (ignored for circle/square). Automatically scales for retina displays |
64
+ | `apertureCenterX` | `number` | `window.innerWidth/2` | X-coordinate of aperture center in CSS pixels |
65
+ | `apertureCenterY` | `number` | `window.innerHeight/2` | Y-coordinate of aperture center in CSS pixels |
66
+ | `reinsertMode` | `'random' \| 'opposite' \| 'oppositeSimple' \| 'wrap'` | `'opposite'` | How to reinsert out-of-bounds dots (see below) |
67
+
68
+ ### Reinsertion Modes
69
+
70
+ When a dot exits the aperture boundary, `reinsertMode` controls where it reappears:
71
+
72
+ | Mode | Circle/Ellipse | Rectangle/Square |
73
+ |------|----------------|------------------|
74
+ | `'random'` | Random position inside aperture | Random position inside aperture |
75
+ | `'opposite'` (default) | Ray-cast: traces backward along movement trajectory to find entry point | Ray-cast: traces backward along movement trajectory to find entry point |
76
+ | `'oppositeSimple'` | Mirror through center (clamped to boundary) | Edge-based: appears at opposite edge, preserving the other coordinate |
77
+ | `'wrap'` | Toroidal wrap on bounding box (x/y wrap independently) | Toroidal wrap on bounding box (x/y wrap independently) |
78
+
79
+ **`'opposite'` mode** (recommended): Uses ray-casting to find where the dot would have entered if it had continued from the opposite side. This maintains the coherent motion direction across the aperture boundary, producing more natural-looking motion.
80
+
81
+ **`'oppositeSimple'` mode**: A simpler algorithm that doesn't account for movement direction:
82
+ - For circles/ellipses: mirrors the dot position through the center
83
+ - For rectangles: places the dot at the opposite edge while preserving the non-crossing coordinate
84
+
85
+ **`'wrap'` mode**: Toroidal wrapping on the bounding box. X and Y coordinates wrap independently via modulo arithmetic. Preserves relative dot positions for axis-aligned motion. For diagonal motion, the pattern tiles but trajectories appear discontinuous at wrap boundaries.
86
+
87
+ ### RDK Algorithm
88
+
89
+ | Parameter | Type | Default | Description |
90
+ |-----------|------|---------|-------------|
91
+ | `noiseMovement` | `'randomTeleport' \| 'randomWalk' \| 'randomDirection'` | `'randomDirection'` | How noise dots move (see below) |
92
+ | `reassignEveryMs` | `number` | `undefined` | Time-based reassignment of dot roles. `undefined` = never reassign (fixed), `0` = reassign every update, `> 0` = reassign every X milliseconds |
93
+
94
+ ### Fixation Cross
95
+
96
+ | Parameter | Type | Default | Description |
97
+ |-----------|------|---------|-------------|
98
+ | `showFixation` | `boolean` | `false` | Show fixation cross |
99
+ | `fixationTime` | `number` | `500` | Duration in ms to show fixation before dots appear. Added to `duration` for total trial time |
100
+ | `fixationWidth` | `number` | `15` | Width in pixels |
101
+ | `fixationHeight` | `number` | `15` | Height in pixels |
102
+ | `fixationColor` | `string` | `"white"` | Color |
103
+ | `fixationThickness` | `number` | `2` | Line thickness |
104
+
105
+ ### Border
106
+
107
+ | Parameter | Type | Default | Description |
108
+ |-----------|------|---------|-------------|
109
+ | `showBorder` | `boolean` | `false` | Show aperture border |
110
+ | `borderWidth` | `number` | `1` | Border width in pixels |
111
+ | `borderColor` | `string` | `"black"` | Border color |
112
+
113
+ ### Noise Movement & Dot Assignment
114
+
115
+ The RDK algorithm is controlled by two orthogonal parameters:
116
+
117
+ **`noiseMovement`** - How noise dots move:
118
+ - `'randomTeleport'`: Noise dots jump to random locations each frame
119
+ - `'randomWalk'`: Noise dots move in a new random direction each frame
120
+ - `'randomDirection'`: Each noise dot has its own consistent random direction (default)
121
+
122
+ **`reassignEveryMs`** - How often dot roles are reassigned:
123
+ - `undefined` (default): Each dot is permanently designated as signal (coherent/opposite) or noise
124
+ - `0`: Dots are randomly assigned to signal/noise each update
125
+ - `> 0`: Dots keep their assignment for the specified milliseconds, then get reassigned
126
+
127
+ **Timing consistency**: This parameter uses wall-clock time, independent of `updateRate` and screen refresh rate. This ensures consistent reassignment timing across all devices:
128
+ - `reassignEveryMs: 100` → reassigns every 100ms regardless of refresh rate
129
+ - Works correctly on 60Hz, 120Hz, or any screen refresh rate
130
+
131
+ | noiseMovement | reassignEveryMs | Behavior |
132
+ |---------------|-----------------|----------|
133
+ | `'randomTeleport'` | `undefined` | Fixed dots, noise jumps randomly |
134
+ | `'randomWalk'` | `undefined` | Fixed dots, noise walks randomly |
135
+ | `'randomDirection'` | `undefined` | Fixed dots, noise has consistent random direction |
136
+ | `'randomTeleport'` | `0` | Dynamic assignment every update, noise jumps randomly |
137
+ | `'randomWalk'` | `0` | Dynamic assignment every update, noise walks randomly |
138
+ | `'randomDirection'` | `0` | Dynamic assignment every update, noise has consistent random direction |
139
+ | Any | `100` | Reassign every 100 milliseconds |
140
+
141
+ ## Data Returned
142
+
143
+ The component returns the following data object when the trial ends:
144
+
145
+ ```typescript
146
+ {
147
+ rt: number | null, // Reaction time in ms (null if no response)
148
+ response: string | null, // Key pressed (null if no response)
149
+ correct: boolean | null, // Whether response was correct (null if no correctResponse specified)
150
+ duration: number, // Duration the trial was set to run
151
+ direction: number, // Direction of coherent motion
152
+ coherence: number, // Coherence level
153
+ framesDisplayed: number // Number of frames actually displayed
154
+ }
155
+ ```
156
+
157
+ ## Examples
158
+
159
+ ### Simple Left/Right Motion Discrimination
160
+
161
+ ```typescript
162
+ {
163
+ name: 'motion_discrimination',
164
+ type: 'RandomDotKinematogram',
165
+ props: {
166
+ validKeys: ['arrowleft', 'arrowright'],
167
+ correctResponse: 'arrowright',
168
+ duration: 2000,
169
+
170
+ direction: 90, // Rightward
171
+ coherence: 0.7, // 70% coherence
172
+ dotCount: 150,
173
+ dotRadius: 3,
174
+ speed: 120, // Pixels per second
175
+
176
+ apertureShape: 'circle',
177
+ apertureWidth: 500,
178
+
179
+ showFixation: true,
180
+ showBorder: true,
181
+ },
182
+ }
183
+ ```
184
+
185
+ ### High Difficulty Task
186
+
187
+ ```typescript
188
+ {
189
+ name: 'difficult_trial',
190
+ type: 'RandomDotKinematogram',
191
+ props: {
192
+ coherence: 0.1, // Only 10% coherent
193
+ direction: 180, // Downward
194
+ duration: 3000,
195
+
196
+ dotCount: 300,
197
+ speed: 60, // Slower movement (pixels per second)
198
+
199
+ noiseMovement: 'randomWalk',
200
+ reassignEveryMs: 0, // reassign every update (harder variant)
201
+ },
202
+ }
203
+ ```
204
+
205
+ ### Four-Direction Choice
206
+
207
+ ```typescript
208
+ {
209
+ name: 'four_direction',
210
+ type: 'RandomDotKinematogram',
211
+ props: {
212
+ validKeys: ['arrowup', 'arrowdown', 'arrowleft', 'arrowright'],
213
+ correctResponse: 'arrowup',
214
+
215
+ direction: 0, // Upward
216
+ coherence: 0.5,
217
+ duration: 2500,
218
+ },
219
+ }
220
+ ```
221
+
222
+ ### Opposite Motion
223
+
224
+ ```typescript
225
+ {
226
+ name: 'opposite_motion',
227
+ type: 'RandomDotKinematogram',
228
+ props: {
229
+ direction: 90, // Rightward
230
+ coherence: 0.4, // 40% rightward
231
+ opposite: 0.4, // 40% leftward
232
+ // 20% random
233
+
234
+ validKeys: ['arrowleft', 'arrowright'],
235
+ correctResponse: 'arrowright',
236
+ },
237
+ }
238
+ ```
239
+
240
+ ### Staircasing Coherence
241
+
242
+ ```typescript
243
+ // Example of varying coherence across trials
244
+ const coherenceLevels = [0.05, 0.1, 0.2, 0.4, 0.6, 0.8, 0.95];
245
+
246
+ coherenceLevels.forEach((coh, index) => ({
247
+ name: `rdk_${index}`,
248
+ type: 'RandomDotKinematogram',
249
+ props: {
250
+ coherence: coh,
251
+ direction: Math.random() < 0.5 ? 90 : 270, // Random left/right
252
+ duration: 2000,
253
+ validKeys: ['arrowleft', 'arrowright'],
254
+ },
255
+ }));
256
+ ```
257
+
258
+ ### Colored Coherent Dots
259
+
260
+ ```typescript
261
+ {
262
+ name: 'colored_coherent',
263
+ type: 'RandomDotKinematogram',
264
+ props: {
265
+ direction: 90, // Rightward
266
+ coherence: 0.3, // 30% coherent
267
+
268
+ dotColor: 'white', // Incoherent dots are white
269
+ coherentDotColor: 'red', // Coherent dots are red
270
+ backgroundColor: '#1a1a1a',
271
+
272
+ dotCount: 200,
273
+ speed: 120, // Pixels per second
274
+
275
+ validKeys: ['arrowleft', 'arrowright'],
276
+ correctResponse: 'arrowright',
277
+ duration: 3000,
278
+ },
279
+ }
280
+ ```
281
+
282
+ ### Late Responses (Post-Stimulus)
283
+
284
+ ```typescript
285
+ {
286
+ name: 'late_response_trial',
287
+ type: 'RandomDotKinematogram',
288
+ props: {
289
+ duration: 3000, // Total response window: 3 seconds
290
+ stimulusDuration: 1000, // Dots visible for 1 second, then blank
291
+
292
+ direction: 90,
293
+ coherence: 0.7,
294
+
295
+ validKeys: ['arrowleft', 'arrowright'],
296
+ correctResponse: 'arrowright',
297
+
298
+ showFixation: true, // Fixation remains visible
299
+ },
300
+ }
301
+ ```
302
+
303
+ ### Character & Emoji Dots
304
+
305
+ ```typescript
306
+ {
307
+ name: 'emoji_rdk',
308
+ type: 'RandomDotKinematogram',
309
+ props: {
310
+ direction: 90,
311
+ coherence: 0.5,
312
+
313
+ dotCharacter: '🔴', // Use red circle emoji instead of circles!
314
+ dotRadius: 4, // Controls size (font size = dotRadius * 2.5)
315
+
316
+ backgroundColor: '#1a1a1a',
317
+
318
+ validKeys: ['arrowleft', 'arrowright'],
319
+ duration: 2000,
320
+ },
321
+ }
322
+ ```
323
+
324
+ **More Examples:**
325
+ ```typescript
326
+ dotCharacter: '🔴' // Red circle emoji
327
+ dotCharacter: '●' // Filled circle character
328
+ dotCharacter: '■' // Square
329
+ dotCharacter: '★' // Star
330
+ dotCharacter: 'X' // Letter X
331
+ dotCharacter: '🐝' // Bee emoji
332
+ ```
333
+
334
+ **With Coherent Colors:**
335
+ ```typescript
336
+ {
337
+ dotCharacter: '●',
338
+ dotColor: 'white',
339
+ coherentDotColor: 'red', // Coherent characters will be red!
340
+ }
341
+ ```
@@ -15,15 +15,9 @@ import {
15
15
  } from '../utils/bytecode';
16
16
  import { BaseComponentProps, isFullscreen, now } from '../utils/common';
17
17
  import { registerSimulation, ParticipantState } from '../utils/simulation';
18
- import { registerFlattener } from '../utils/upload';
18
+ import { registerFlattener, arrayFlattener } from '../utils/upload';
19
19
 
20
- registerFlattener('CanvasBlock', 'canvas', (item) => {
21
- const responseData = item.responseData;
22
- if (Array.isArray(responseData)) {
23
- return responseData.map((i) => ({ block: item.name, ...i }));
24
- }
25
- return [];
26
- });
20
+ registerFlattener('CanvasBlock', 'canvas', arrayFlattener);
27
21
 
28
22
  export type SlideSimulatorResult = {
29
23
  key: string | null;
@@ -33,6 +33,8 @@ import CheckDevice from './checkdevice';
33
33
  import VoicerecorderQuestionComponent from './voicerecorder';
34
34
  import React from 'react';
35
35
  import StoreUI from './storeui';
36
+ import { Tutorial } from './tutorial';
37
+ import { RandomDotKinematogram } from './randomdotkinetogram';
36
38
 
37
39
  type ComponentsMap = {
38
40
  [key: string]: ComponentType<any>;
@@ -50,7 +52,9 @@ const defaultComponents: ComponentsMap = {
50
52
  RequestFilePermission,
51
53
  CanvasBlock,
52
54
  CheckDevice,
53
- StoreUI
55
+ StoreUI,
56
+ Tutorial,
57
+ RandomDotKinematogram,
54
58
  };
55
59
 
56
60
  const defaultCustomQuestions: ComponentsMap = {
@@ -11,3 +11,7 @@ export { default as ExperimentRunner } from './experimentrunner';
11
11
  export { default as RequestFilePermission } from './mobilefilepermission';
12
12
  export { default as CanvasBlock } from './canvasblock';
13
13
  export { default as CheckDevice } from './checkdevice';
14
+ export { Tutorial, useTutorialSlide } from './tutorial';
15
+ export type { TutorialProps } from './tutorial';
16
+ export { RandomDotKinematogram, RDKCanvas } from './randomdotkinetogram';
17
+ export type { RDKCanvasProps, RDKCanvasHandle, RDKProps, NoiseMovement } from './randomdotkinetogram';
@@ -1,9 +1,11 @@
1
1
  import { BaseComponentProps, getPlatform } from '../utils/common';
2
2
  import { useEffect, useState } from 'react';
3
3
  import { registerSimulation, noopSimulate } from '../utils/simulation';
4
+ import { registerFlattener } from '../utils/upload';
4
5
  import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
5
6
  import { Capacitor } from '@capacitor/core';
6
7
 
8
+ registerFlattener('RequestFilePermission', null);
7
9
  registerSimulation('RequestFilePermission', noopSimulate, {});
8
10
 
9
11
  export default function RequestFilePermission({ next }: BaseComponentProps) {