jspsych-tangram 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/construct/index.browser.js +4538 -3889
  2. package/dist/construct/index.browser.js.map +1 -1
  3. package/dist/construct/index.browser.min.js +13 -13
  4. package/dist/construct/index.browser.min.js.map +1 -1
  5. package/dist/construct/index.cjs +4 -7
  6. package/dist/construct/index.cjs.map +1 -1
  7. package/dist/construct/index.js +4 -7
  8. package/dist/construct/index.js.map +1 -1
  9. package/dist/index.cjs +332 -11
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.ts +180 -8
  12. package/dist/index.js +333 -13
  13. package/dist/index.js.map +1 -1
  14. package/dist/nback/index.browser.js +17703 -0
  15. package/dist/nback/index.browser.js.map +1 -0
  16. package/dist/nback/index.browser.min.js +42 -0
  17. package/dist/nback/index.browser.min.js.map +1 -0
  18. package/dist/nback/index.cjs +395 -0
  19. package/dist/nback/index.cjs.map +1 -0
  20. package/dist/nback/index.d.ts +175 -0
  21. package/dist/nback/index.js +393 -0
  22. package/dist/nback/index.js.map +1 -0
  23. package/dist/prep/index.browser.js +4538 -3891
  24. package/dist/prep/index.browser.js.map +1 -1
  25. package/dist/prep/index.browser.min.js +13 -13
  26. package/dist/prep/index.browser.min.js.map +1 -1
  27. package/dist/prep/index.cjs +5 -10
  28. package/dist/prep/index.cjs.map +1 -1
  29. package/dist/prep/index.js +5 -10
  30. package/dist/prep/index.js.map +1 -1
  31. package/package.json +9 -3
  32. package/src/index.ts +2 -1
  33. package/src/plugins/tangram-nback/NBackApp.tsx +316 -0
  34. package/src/plugins/tangram-nback/index.ts +141 -0
  35. package/tangram-construct.min.js +13 -13
  36. package/tangram-nback.min.js +42 -0
  37. package/tangram-prep.min.js +13 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jspsych-tangram",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "Tangram tasks for jsPsych: prep and construct.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -22,18 +22,24 @@
22
22
  "types": "./dist/construct/index.d.ts",
23
23
  "import": "./dist/construct/index.js",
24
24
  "require": "./dist/construct/index.cjs"
25
+ },
26
+ "./nback": {
27
+ "types": "./dist/nback/index.d.ts",
28
+ "import": "./dist/nback/index.js",
29
+ "require": "./dist/nback/index.cjs"
25
30
  }
26
31
  },
27
32
  "files": [
28
33
  "src",
29
34
  "dist",
30
35
  "tangram-prep.min.js",
31
- "tangram-construct.min.js"
36
+ "tangram-construct.min.js",
37
+ "tangram-nback.min.js"
32
38
  ],
33
39
  "source": "src/index.ts",
34
40
  "scripts": {
35
41
  "build": "rollup --config",
36
- "postbuild": "cp dist/prep/index.browser.min.js tangram-prep.min.js && cp dist/construct/index.browser.min.js tangram-construct.min.js",
42
+ "postbuild": "cp dist/prep/index.browser.min.js tangram-prep.min.js && cp dist/construct/index.browser.min.js tangram-construct.min.js && cp dist/nback/index.browser.min.js tangram-nback.min.js",
37
43
  "build:watch": "npm run build -- --watch",
38
44
  "dev": "vite",
39
45
  "test": "jest",
package/src/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { default as TangramConstructPlugin } from "./plugins/tangram-construct";
2
- export { default as TangramPrepPlugin } from "./plugins/tangram-prep";
2
+ export { default as TangramPrepPlugin } from "./plugins/tangram-prep";
3
+ export { default as TangramNBackPlugin } from "./plugins/tangram-nback";
@@ -0,0 +1,316 @@
1
+ /**
2
+ * NBackApp.tsx - React wrapper for tangram n-back matching trials
3
+ *
4
+ * This component handles the React rendering logic for n-back trials,
5
+ * displaying a single tangram silhouette with a response button.
6
+ */
7
+
8
+ import React, { useRef, useEffect, useState } from "react";
9
+ import { createRoot } from "react-dom/client";
10
+ import { JsPsych } from "jspsych";
11
+ import type { Poly, TanKind } from "../../core/domain/types";
12
+ import { placeSilhouetteGridAlignedAsPolys, inferUnitFromPolys } from "../../core/engine/geometry";
13
+ import { CONFIG } from "../../core/config/config";
14
+
15
+ export interface StartNBackTrialParams {
16
+ tangram: any;
17
+ isMatch?: boolean;
18
+ show_tangram_decomposition?: boolean;
19
+ instructions?: string;
20
+ button_text?: string;
21
+ duration: number;
22
+ onTrialEnd?: (data: any) => void;
23
+ }
24
+
25
+ /**
26
+ * Start an n-back trial by rendering the NBackView component
27
+ */
28
+ export function startNBackTrial(
29
+ display_element: HTMLElement,
30
+ params: StartNBackTrialParams,
31
+ _jsPsych: JsPsych
32
+ ) {
33
+ // Create React root and render NBackView
34
+ const root = createRoot(display_element);
35
+ root.render(React.createElement(NBackView, { params }));
36
+
37
+ return { root, display_element, jsPsych: _jsPsych };
38
+ }
39
+
40
+ interface NBackViewProps {
41
+ params: StartNBackTrialParams;
42
+ }
43
+
44
+ function NBackView({ params }: NBackViewProps) {
45
+ const {
46
+ tangram,
47
+ isMatch,
48
+ show_tangram_decomposition,
49
+ instructions,
50
+ button_text,
51
+ duration,
52
+ onTrialEnd
53
+ } = params;
54
+
55
+ // Timing and response tracking
56
+ const trialStartTime = useRef<number>(Date.now());
57
+ const buttonEnabledRef = useRef<boolean>(true);
58
+ const timeoutIdRef = useRef<NodeJS.Timeout | null>(null);
59
+ const hasRespondedRef = useRef<boolean>(false);
60
+ const responseDataRef = useRef<any>(null);
61
+ const [buttonDisabled, setButtonDisabled] = useState(false);
62
+
63
+ // Canonical piece names
64
+ const CANON = new Set([
65
+ "square",
66
+ "smalltriangle",
67
+ "parallelogram",
68
+ "medtriangle",
69
+ "largetriangle",
70
+ ]);
71
+
72
+ // Convert TangramSpec to internal format
73
+ const filteredTans = tangram.solutionTans.filter((tan: any) => {
74
+ const tanName = tan.name ?? tan.kind;
75
+ return CANON.has(tanName);
76
+ });
77
+
78
+ const mask = filteredTans.map((tan: any) => {
79
+ const polygon = tan.vertices.map(([x, y]: number[]) => ({ x: x ?? 0, y: -(y ?? 0) }));
80
+ return polygon;
81
+ });
82
+
83
+ const primitiveDecomposition = filteredTans.map((tan: any) => ({
84
+ kind: (tan.name ?? tan.kind) as TanKind,
85
+ polygon: tan.vertices.map(([x, y]: number[]) => ({ x: x ?? 0, y: -(y ?? 0) }))
86
+ }));
87
+
88
+ // Use FIXED viewport size for n-back display (constant across all tangrams)
89
+ const DISPLAY_SIZE = 400;
90
+ const viewport = {
91
+ w: DISPLAY_SIZE,
92
+ h: DISPLAY_SIZE
93
+ };
94
+
95
+ // Compute scale factor to keep tangram pieces at constant physical size
96
+ // This matches the approach in TangramConstructPlugin/GameBoard
97
+ const scaleS = React.useMemo(() => {
98
+ const u = inferUnitFromPolys(mask);
99
+ return u ? (CONFIG.layout.grid.unitPx / u) : 1;
100
+ }, [mask]);
101
+
102
+ // For n-back display, we want tangrams centered in viewport
103
+ // Don't use computeCircleLayout's positioning - just center manually
104
+ const centerPos = {
105
+ cx: viewport.w / 2,
106
+ cy: viewport.h / 2
107
+ };
108
+
109
+ // Helper to convert polygon to SVG path
110
+ const pathD = (poly: Poly): string => {
111
+ if (!poly || poly.length === 0) return "";
112
+ const moves = poly.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`);
113
+ return moves.join(" ") + " Z";
114
+ };
115
+
116
+ // End trial with data
117
+ const endTrial = (data: {
118
+ responded_match: boolean;
119
+ rt: number;
120
+ responded_after_duration: boolean;
121
+ rt_after_duration: number;
122
+ }) => {
123
+ // Clear timeout if it exists
124
+ if (timeoutIdRef.current) {
125
+ clearTimeout(timeoutIdRef.current);
126
+ timeoutIdRef.current = null;
127
+ }
128
+
129
+ // Compute accuracy
130
+ const accuracy = isMatch !== undefined
131
+ ? (isMatch === data.responded_match ? 1 : 0)
132
+ : NaN;
133
+
134
+ const trialData = {
135
+ ...data,
136
+ accuracy,
137
+ tangram_id: tangram.tangramID,
138
+ is_match: isMatch
139
+ };
140
+
141
+ if (onTrialEnd) {
142
+ onTrialEnd(trialData);
143
+ }
144
+ };
145
+
146
+ // Handle button click
147
+ const handleButtonClick = () => {
148
+ if (!buttonEnabledRef.current) {
149
+ // Late response after duration expired
150
+ const rt_late = Date.now() - trialStartTime.current;
151
+ hasRespondedRef.current = true;
152
+ responseDataRef.current = {
153
+ responded_match: true,
154
+ rt: NaN,
155
+ responded_after_duration: true,
156
+ rt_after_duration: rt_late
157
+ };
158
+ endTrial(responseDataRef.current);
159
+ } else {
160
+ // Response before duration expired - disable button but continue showing trial
161
+ const rt = Date.now() - trialStartTime.current;
162
+ buttonEnabledRef.current = false;
163
+ setButtonDisabled(true);
164
+ hasRespondedRef.current = true;
165
+ responseDataRef.current = {
166
+ responded_match: true,
167
+ rt,
168
+ responded_after_duration: false,
169
+ rt_after_duration: NaN
170
+ };
171
+ // Don't end trial yet - wait for duration timeout
172
+ }
173
+ };
174
+
175
+ // Set up duration timeout
176
+ useEffect(() => {
177
+ timeoutIdRef.current = setTimeout(() => {
178
+ buttonEnabledRef.current = false;
179
+
180
+ // End trial with appropriate data
181
+ if (hasRespondedRef.current && responseDataRef.current) {
182
+ // User already responded - use saved response data
183
+ endTrial(responseDataRef.current);
184
+ } else {
185
+ // No response - end with no-response data
186
+ endTrial({
187
+ responded_match: false,
188
+ rt: NaN,
189
+ responded_after_duration: false,
190
+ rt_after_duration: NaN
191
+ });
192
+ }
193
+ }, duration);
194
+
195
+ return () => {
196
+ if (timeoutIdRef.current) {
197
+ clearTimeout(timeoutIdRef.current);
198
+ }
199
+ };
200
+ }, []);
201
+
202
+ // Render silhouette
203
+ const renderSilhouette = () => {
204
+ if (show_tangram_decomposition) {
205
+ // Render decomposed primitives with borders
206
+ const rawPolys = primitiveDecomposition.map((primInfo: any) => primInfo.polygon);
207
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, centerPos);
208
+
209
+ return (
210
+ <g key="sil-decomposed" pointerEvents="none">
211
+ {placedPolys.map((scaledPoly, i) => (
212
+ <React.Fragment key={`prim-${i}`}>
213
+ {/* Fill path */}
214
+ <path
215
+ d={pathD(scaledPoly)}
216
+ fill={CONFIG.color.silhouetteMask}
217
+ opacity={CONFIG.opacity.silhouetteMask}
218
+ stroke="none"
219
+ />
220
+
221
+ {/* Full perimeter border */}
222
+ <path
223
+ d={pathD(scaledPoly)}
224
+ fill="none"
225
+ stroke={CONFIG.color.tangramDecomposition.stroke}
226
+ strokeWidth={CONFIG.size.stroke.tangramDecompositionPx}
227
+ />
228
+ </React.Fragment>
229
+ ))}
230
+ </g>
231
+ );
232
+ } else {
233
+ // Render unified silhouette
234
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(mask, scaleS, centerPos);
235
+
236
+ return (
237
+ <g key="sil-unified" pointerEvents="none">
238
+ {placedPolys.map((scaledPoly, i) => (
239
+ <path
240
+ key={`sil-${i}`}
241
+ d={pathD(scaledPoly)}
242
+ fill={CONFIG.color.silhouetteMask}
243
+ opacity={CONFIG.opacity.silhouetteMask}
244
+ stroke="none"
245
+ />
246
+ ))}
247
+ </g>
248
+ );
249
+ }
250
+ };
251
+
252
+ return (
253
+ <div style={{
254
+ display: "flex",
255
+ flexDirection: "column",
256
+ alignItems: "center",
257
+ justifyContent: "flex-start",
258
+ minHeight: "100vh",
259
+ padding: "40px 20px",
260
+ background: "#f5f5f5"
261
+ }}>
262
+ {/* Instructions */}
263
+ {instructions && (
264
+ <div
265
+ style={{
266
+ maxWidth: "800px",
267
+ width: "100%",
268
+ marginBottom: "30px",
269
+ textAlign: "center",
270
+ fontSize: "18px",
271
+ lineHeight: "1.5"
272
+ }}
273
+ dangerouslySetInnerHTML={{ __html: instructions }}
274
+ />
275
+ )}
276
+
277
+ {/* Container for SVG and button */}
278
+ <div style={{
279
+ display: "flex",
280
+ flexDirection: "column",
281
+ alignItems: "center",
282
+ gap: "30px"
283
+ }}>
284
+ {/* SVG with tangram silhouette */}
285
+ <svg
286
+ width={viewport.w}
287
+ height={viewport.h}
288
+ viewBox={`0 0 ${viewport.w} ${viewport.h}`}
289
+ style={{
290
+ display: "block",
291
+ background: CONFIG.color.bands.silhouette.fillEven,
292
+ border: `${CONFIG.size.stroke.bandPx}px solid ${CONFIG.color.bands.silhouette.stroke}`,
293
+ borderRadius: "8px"
294
+ }}
295
+ >
296
+ {renderSilhouette()}
297
+ </svg>
298
+
299
+ {/* Response button */}
300
+ <button
301
+ className="jspsych-btn"
302
+ onClick={handleButtonClick}
303
+ disabled={buttonDisabled}
304
+ style={{
305
+ padding: "12px 30px",
306
+ fontSize: "16px",
307
+ cursor: buttonDisabled ? "not-allowed" : "pointer",
308
+ opacity: buttonDisabled ? 0.5 : 1
309
+ }}
310
+ >
311
+ {button_text}
312
+ </button>
313
+ </div>
314
+ </div>
315
+ );
316
+ }
@@ -0,0 +1,141 @@
1
+ import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
2
+ import { startNBackTrial, StartNBackTrialParams } from "./NBackApp";
3
+
4
+ const info = {
5
+ name: "tangram-nback",
6
+ version: "1.0.0",
7
+ parameters: {
8
+ /** Single tangram specification to display */
9
+ tangram: {
10
+ type: ParameterType.COMPLEX,
11
+ default: undefined,
12
+ description: "TangramSpec object defining target shape to display"
13
+ },
14
+ /** Whether this trial is a match (for computing accuracy) */
15
+ isMatch: {
16
+ type: ParameterType.BOOL,
17
+ default: undefined,
18
+ description: "Whether this tangram matches the previous one (optional)"
19
+ },
20
+ /** Whether to show tangram decomposed into individual primitives with borders */
21
+ show_tangram_decomposition: {
22
+ type: ParameterType.BOOL,
23
+ default: false,
24
+ description: "Whether to show tangram decomposed into individual primitives with borders"
25
+ },
26
+ /** HTML content to display above the tangram as instructions */
27
+ instructions: {
28
+ type: ParameterType.STRING,
29
+ default: "",
30
+ description: "HTML content to display above the tangram as instructions"
31
+ },
32
+ /** Text to display on response button */
33
+ button_text: {
34
+ type: ParameterType.STRING,
35
+ default: "Same as previous!",
36
+ description: "Text to display on response button"
37
+ },
38
+ /** Duration to display tangram and accept responses (milliseconds) */
39
+ duration: {
40
+ type: ParameterType.INT,
41
+ default: 3000,
42
+ description: "Duration in milliseconds to display tangram and accept responses"
43
+ },
44
+ /** Callback fired when trial ends */
45
+ onTrialEnd: {
46
+ type: ParameterType.FUNCTION,
47
+ default: undefined,
48
+ description: "Callback when trial completes with full data"
49
+ }
50
+ },
51
+ data: {
52
+ /** Whether participant clicked the response button before duration expired */
53
+ responded_match: {
54
+ type: ParameterType.BOOL,
55
+ description: "True if participant clicked response button, false otherwise"
56
+ },
57
+ /** Reaction time in milliseconds (NaN if no response or response after duration) */
58
+ rt: {
59
+ type: ParameterType.INT,
60
+ description: "Milliseconds between trial start and button click (NaN if no response or late response)"
61
+ },
62
+ /** Accuracy: 1 if correct, 0 if incorrect, NaN if isMatch not provided */
63
+ accuracy: {
64
+ type: ParameterType.FLOAT,
65
+ description: "1 if response matches isMatch parameter, 0 otherwise (NaN if isMatch not provided)"
66
+ },
67
+ /** Whether response occurred after duration expired */
68
+ responded_after_duration: {
69
+ type: ParameterType.BOOL,
70
+ description: "True if button clicked after duration expired, false otherwise"
71
+ },
72
+ /** Time of late response (NaN if no late response) */
73
+ rt_after_duration: {
74
+ type: ParameterType.INT,
75
+ description: "Milliseconds between trial start and late button click (NaN if no late response)"
76
+ }
77
+ },
78
+ citations: ""
79
+ };
80
+
81
+ type Info = typeof info;
82
+
83
+ /**
84
+ * **tangram-nback**
85
+ *
86
+ * A jsPsych plugin for n-back matching trials displaying a single tangram
87
+ * with a response button.
88
+ *
89
+ * @author Justin Yang & Sean Paul Anderson
90
+ * @see {@link https://github.com/cogtoolslab/tangram_construction.git/tree/main/experiments/jspsych-tangram-prep}
91
+ */
92
+ class TangramNBackPlugin implements JsPsychPlugin<Info> {
93
+ static info = info;
94
+
95
+ constructor(private jsPsych: JsPsych) {}
96
+
97
+ /**
98
+ * Launches the trial by invoking startNBackTrial
99
+ * with the display element, parameters, and jsPsych instance.
100
+ */
101
+ trial(display_element: HTMLElement, trial: TrialType<Info>) {
102
+ // Wrap onTrialEnd to handle React cleanup and jsPsych trial completion
103
+ const wrappedOnTrialEnd = (data: any) => {
104
+ // Call user-provided callback if exists
105
+ if (trial.onTrialEnd) {
106
+ trial.onTrialEnd(data);
107
+ }
108
+
109
+ // Clean up React first (before clearing DOM)
110
+ const reactContext = (display_element as any).__reactContext;
111
+ if (reactContext?.root) {
112
+ reactContext.root.unmount();
113
+ }
114
+
115
+ // Clear display after React cleanup
116
+ display_element.innerHTML = '';
117
+
118
+ // Finish jsPsych trial with data
119
+ this.jsPsych.finishTrial(data);
120
+ };
121
+
122
+ // Create parameter object for wrapper
123
+ const params: StartNBackTrialParams = {
124
+ tangram: trial.tangram,
125
+ isMatch: trial.isMatch,
126
+ show_tangram_decomposition: trial.show_tangram_decomposition,
127
+ instructions: trial.instructions,
128
+ button_text: trial.button_text,
129
+ duration: trial.duration,
130
+ onTrialEnd: wrappedOnTrialEnd
131
+ };
132
+
133
+ // Use React wrapper to start the trial
134
+ const { root, display_element: element, jsPsych } = startNBackTrial(display_element, params, this.jsPsych);
135
+
136
+ // Store React context for cleanup
137
+ (element as any).__reactContext = { root, jsPsych };
138
+ }
139
+ }
140
+
141
+ export default TangramNBackPlugin;