jspsych-tangram 0.0.14 → 0.0.16
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/dist/afc/index.browser.js +17751 -0
- package/dist/afc/index.browser.js.map +1 -0
- package/dist/afc/index.browser.min.js +42 -0
- package/dist/afc/index.browser.min.js.map +1 -0
- package/dist/afc/index.cjs +443 -0
- package/dist/afc/index.cjs.map +1 -0
- package/dist/afc/index.d.ts +169 -0
- package/dist/afc/index.js +441 -0
- package/dist/afc/index.js.map +1 -0
- package/dist/construct/index.browser.js +4538 -3890
- package/dist/construct/index.browser.js.map +1 -1
- package/dist/construct/index.browser.min.js +13 -13
- package/dist/construct/index.browser.min.js.map +1 -1
- package/dist/construct/index.cjs +4 -8
- package/dist/construct/index.cjs.map +1 -1
- package/dist/construct/index.js +4 -8
- package/dist/construct/index.js.map +1 -1
- package/dist/index.cjs +374 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +178 -12
- package/dist/index.js +374 -17
- package/dist/index.js.map +1 -1
- package/dist/nback/index.browser.js +4536 -3919
- package/dist/nback/index.browser.js.map +1 -1
- package/dist/nback/index.browser.min.js +12 -12
- package/dist/nback/index.browser.min.js.map +1 -1
- package/dist/nback/index.cjs +6 -41
- package/dist/nback/index.cjs.map +1 -1
- package/dist/nback/index.js +6 -41
- package/dist/nback/index.js.map +1 -1
- package/dist/prep/index.browser.js +4538 -3892
- package/dist/prep/index.browser.js.map +1 -1
- package/dist/prep/index.browser.min.js +13 -13
- package/dist/prep/index.browser.min.js.map +1 -1
- package/dist/prep/index.cjs +5 -11
- package/dist/prep/index.cjs.map +1 -1
- package/dist/prep/index.js +5 -11
- package/dist/prep/index.js.map +1 -1
- package/package.json +9 -3
- package/src/index.ts +2 -1
- package/src/plugins/tangram-afc/AFCApp.tsx +341 -0
- package/src/plugins/tangram-afc/index.ts +140 -0
- package/src/plugins/tangram-nback/NBackApp.tsx +2 -2
- package/tangram-afc.min.js +42 -0
- package/tangram-construct.min.js +13 -13
- package/tangram-nback.min.js +12 -12
- 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.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"description": "Tangram tasks for jsPsych: prep and construct.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
"types": "./dist/nback/index.d.ts",
|
|
28
28
|
"import": "./dist/nback/index.js",
|
|
29
29
|
"require": "./dist/nback/index.cjs"
|
|
30
|
+
},
|
|
31
|
+
"./afc": {
|
|
32
|
+
"types": "./dist/afc/index.d.ts",
|
|
33
|
+
"import": "./dist/afc/index.js",
|
|
34
|
+
"require": "./dist/afc/index.cjs"
|
|
30
35
|
}
|
|
31
36
|
},
|
|
32
37
|
"files": [
|
|
@@ -34,12 +39,13 @@
|
|
|
34
39
|
"dist",
|
|
35
40
|
"tangram-prep.min.js",
|
|
36
41
|
"tangram-construct.min.js",
|
|
37
|
-
"tangram-nback.min.js"
|
|
42
|
+
"tangram-nback.min.js",
|
|
43
|
+
"tangram-afc.min.js"
|
|
38
44
|
],
|
|
39
45
|
"source": "src/index.ts",
|
|
40
46
|
"scripts": {
|
|
41
47
|
"build": "rollup --config",
|
|
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",
|
|
48
|
+
"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 && cp dist/afc/index.browser.min.js tangram-afc.min.js",
|
|
43
49
|
"build:watch": "npm run build -- --watch",
|
|
44
50
|
"dev": "vite",
|
|
45
51
|
"test": "jest",
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { default as TangramConstructPlugin } from "./plugins/tangram-construct";
|
|
2
2
|
export { default as TangramPrepPlugin } from "./plugins/tangram-prep";
|
|
3
|
-
export { default as TangramNBackPlugin } from "./plugins/tangram-nback";
|
|
3
|
+
export { default as TangramNBackPlugin } from "./plugins/tangram-nback";
|
|
4
|
+
export { default as TangramAFCPlugin } from "./plugins/tangram-afc";
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AFCApp.tsx - React wrapper for tangram alternative forced choice (AFC) trials
|
|
3
|
+
*
|
|
4
|
+
* This component handles the React rendering logic for AFC trials,
|
|
5
|
+
* displaying two tangram silhouettes side-by-side with response buttons.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useRef, 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 StartAFCTrialParams {
|
|
16
|
+
tangramLeft: any;
|
|
17
|
+
tangramRight: any;
|
|
18
|
+
instructions?: string;
|
|
19
|
+
buttonTextLeft?: string;
|
|
20
|
+
buttonTextRight?: string;
|
|
21
|
+
showTangramDecomposition?: boolean;
|
|
22
|
+
usePrimitiveColors?: boolean;
|
|
23
|
+
primitiveColorIndices?: number[];
|
|
24
|
+
onTrialEnd?: (data: any) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Start an AFC trial by rendering the AFCView component
|
|
29
|
+
*/
|
|
30
|
+
export function startAFCTrial(
|
|
31
|
+
display_element: HTMLElement,
|
|
32
|
+
params: StartAFCTrialParams,
|
|
33
|
+
_jsPsych: JsPsych
|
|
34
|
+
) {
|
|
35
|
+
// Create React root and render AFCView
|
|
36
|
+
const root = createRoot(display_element);
|
|
37
|
+
root.render(React.createElement(AFCView, { params }));
|
|
38
|
+
|
|
39
|
+
return { root, display_element, jsPsych: _jsPsych };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AFCViewProps {
|
|
43
|
+
params: StartAFCTrialParams;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function AFCView({ params }: AFCViewProps) {
|
|
47
|
+
const {
|
|
48
|
+
tangramLeft,
|
|
49
|
+
tangramRight,
|
|
50
|
+
instructions,
|
|
51
|
+
buttonTextLeft,
|
|
52
|
+
buttonTextRight,
|
|
53
|
+
showTangramDecomposition,
|
|
54
|
+
usePrimitiveColors,
|
|
55
|
+
primitiveColorIndices,
|
|
56
|
+
onTrialEnd
|
|
57
|
+
} = params;
|
|
58
|
+
|
|
59
|
+
// Timing and response tracking
|
|
60
|
+
const trialStartTime = useRef<number>(Date.now());
|
|
61
|
+
const [hasResponded, setHasResponded] = useState(false);
|
|
62
|
+
|
|
63
|
+
const handleResponse = (choice: "left" | "right") => {
|
|
64
|
+
if (hasResponded) return;
|
|
65
|
+
|
|
66
|
+
setHasResponded(true);
|
|
67
|
+
const rt = Date.now() - trialStartTime.current;
|
|
68
|
+
|
|
69
|
+
if (onTrialEnd) {
|
|
70
|
+
onTrialEnd({
|
|
71
|
+
rt,
|
|
72
|
+
response: choice,
|
|
73
|
+
show_tangram_decomposition: showTangramDecomposition,
|
|
74
|
+
use_primitive_colors: usePrimitiveColors,
|
|
75
|
+
primitive_color_indices: primitiveColorIndices,
|
|
76
|
+
button_text_left: buttonTextLeft,
|
|
77
|
+
button_text_right: buttonTextRight
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div style={{
|
|
84
|
+
display: "flex",
|
|
85
|
+
flexDirection: "column",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
justifyContent: "center",
|
|
88
|
+
padding: "20px",
|
|
89
|
+
background: "#fff7e0ff",
|
|
90
|
+
minHeight: "100vh"
|
|
91
|
+
}}>
|
|
92
|
+
{/* Instructions */}
|
|
93
|
+
{instructions && (
|
|
94
|
+
<div
|
|
95
|
+
style={{
|
|
96
|
+
maxWidth: "800px",
|
|
97
|
+
width: "100%",
|
|
98
|
+
marginBottom: "30px",
|
|
99
|
+
textAlign: "center",
|
|
100
|
+
fontSize: "18px",
|
|
101
|
+
lineHeight: "1.5"
|
|
102
|
+
}}
|
|
103
|
+
dangerouslySetInnerHTML={{ __html: instructions }}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{/* Container for Tangrams */}
|
|
108
|
+
<div style={{
|
|
109
|
+
display: "flex",
|
|
110
|
+
flexDirection: "row",
|
|
111
|
+
gap: "50px",
|
|
112
|
+
justifyContent: "center",
|
|
113
|
+
alignItems: "flex-start",
|
|
114
|
+
flexWrap: "wrap"
|
|
115
|
+
}}>
|
|
116
|
+
{/* Left Option */}
|
|
117
|
+
<TangramOption
|
|
118
|
+
tangram={tangramLeft}
|
|
119
|
+
buttonText={buttonTextLeft}
|
|
120
|
+
onClick={() => handleResponse("left")}
|
|
121
|
+
disabled={hasResponded}
|
|
122
|
+
showDecomposition={showTangramDecomposition}
|
|
123
|
+
usePrimitiveColors={usePrimitiveColors}
|
|
124
|
+
primitiveColorIndices={primitiveColorIndices}
|
|
125
|
+
/>
|
|
126
|
+
|
|
127
|
+
{/* Right Option */}
|
|
128
|
+
<TangramOption
|
|
129
|
+
tangram={tangramRight}
|
|
130
|
+
buttonText={buttonTextRight}
|
|
131
|
+
onClick={() => handleResponse("right")}
|
|
132
|
+
disabled={hasResponded}
|
|
133
|
+
showDecomposition={showTangramDecomposition}
|
|
134
|
+
usePrimitiveColors={usePrimitiveColors}
|
|
135
|
+
primitiveColorIndices={primitiveColorIndices}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface TangramOptionProps {
|
|
143
|
+
tangram: any;
|
|
144
|
+
buttonText: string;
|
|
145
|
+
onClick: () => void;
|
|
146
|
+
disabled: boolean;
|
|
147
|
+
showDecomposition?: boolean;
|
|
148
|
+
usePrimitiveColors?: boolean;
|
|
149
|
+
primitiveColorIndices?: number[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function TangramOption({
|
|
153
|
+
tangram,
|
|
154
|
+
buttonText,
|
|
155
|
+
onClick,
|
|
156
|
+
disabled,
|
|
157
|
+
showDecomposition,
|
|
158
|
+
usePrimitiveColors,
|
|
159
|
+
primitiveColorIndices
|
|
160
|
+
}: TangramOptionProps) {
|
|
161
|
+
|
|
162
|
+
if (!tangram) {
|
|
163
|
+
return <div style={{ width: 300, height: 300, background: "#eee" }}>No Tangram Data</div>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Canonical piece names
|
|
167
|
+
const CANON = new Set([
|
|
168
|
+
"square",
|
|
169
|
+
"smalltriangle",
|
|
170
|
+
"parallelogram",
|
|
171
|
+
"medtriangle",
|
|
172
|
+
"largetriangle",
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
// Convert TangramSpec to internal format
|
|
176
|
+
const filteredTans = tangram.solutionTans.filter((tan: any) => {
|
|
177
|
+
const tanName = tan.name ?? tan.kind;
|
|
178
|
+
return CANON.has(tanName);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const mask = filteredTans.map((tan: any) => {
|
|
182
|
+
const polygon = tan.vertices.map(([x, y]: number[]) => ({ x: x ?? 0, y: -(y ?? 0) }));
|
|
183
|
+
return polygon;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const primitiveDecomposition = filteredTans.map((tan: any) => ({
|
|
187
|
+
kind: (tan.name ?? tan.kind) as TanKind,
|
|
188
|
+
polygon: tan.vertices.map(([x, y]: number[]) => ({ x: x ?? 0, y: -(y ?? 0) }))
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
// Use FIXED viewport size
|
|
192
|
+
const DISPLAY_SIZE = 300;
|
|
193
|
+
const viewport = {
|
|
194
|
+
w: DISPLAY_SIZE,
|
|
195
|
+
h: DISPLAY_SIZE
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Compute scale factor
|
|
199
|
+
const scaleS = React.useMemo(() => {
|
|
200
|
+
const u = inferUnitFromPolys(mask);
|
|
201
|
+
return u ? (CONFIG.layout.grid.unitPx / u) : 1;
|
|
202
|
+
}, [mask]);
|
|
203
|
+
|
|
204
|
+
const centerPos = {
|
|
205
|
+
cx: viewport.w / 2,
|
|
206
|
+
cy: viewport.h / 2
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const pathD = (poly: Poly): string => {
|
|
210
|
+
if (!poly || poly.length === 0) return "";
|
|
211
|
+
const moves = poly.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`);
|
|
212
|
+
return moves.join(" ") + " Z";
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const renderSilhouette = () => {
|
|
216
|
+
if (showDecomposition) {
|
|
217
|
+
const rawPolys = primitiveDecomposition.map((primInfo: any) => primInfo.polygon);
|
|
218
|
+
const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, centerPos);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<g key="sil-decomposed" pointerEvents="none">
|
|
222
|
+
{placedPolys.map((scaledPoly, i) => {
|
|
223
|
+
const primInfo = primitiveDecomposition[i];
|
|
224
|
+
let fillColor = CONFIG.color.silhouetteMask;
|
|
225
|
+
|
|
226
|
+
if (usePrimitiveColors && primInfo?.kind && primitiveColorIndices) {
|
|
227
|
+
const kindToIndex: Record<TanKind, number> = {
|
|
228
|
+
'square': 0,
|
|
229
|
+
'smalltriangle': 1,
|
|
230
|
+
'parallelogram': 2,
|
|
231
|
+
'medtriangle': 3,
|
|
232
|
+
'largetriangle': 4
|
|
233
|
+
};
|
|
234
|
+
const primitiveIndex = kindToIndex[primInfo.kind as TanKind];
|
|
235
|
+
if (primitiveIndex !== undefined && primitiveColorIndices[primitiveIndex] !== undefined) {
|
|
236
|
+
const colorIndex = primitiveColorIndices[primitiveIndex];
|
|
237
|
+
const color = CONFIG.color.primitiveColors[colorIndex];
|
|
238
|
+
if (color) {
|
|
239
|
+
fillColor = color;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<React.Fragment key={`prim-${i}`}>
|
|
246
|
+
<path
|
|
247
|
+
d={pathD(scaledPoly)}
|
|
248
|
+
fill={fillColor}
|
|
249
|
+
opacity={usePrimitiveColors ? CONFIG.opacity.piece.normal : CONFIG.opacity.silhouetteMask}
|
|
250
|
+
stroke="none"
|
|
251
|
+
/>
|
|
252
|
+
<path
|
|
253
|
+
d={pathD(scaledPoly)}
|
|
254
|
+
fill="none"
|
|
255
|
+
stroke={CONFIG.color.tangramDecomposition.stroke}
|
|
256
|
+
strokeWidth={CONFIG.size.stroke.tangramDecompositionPx}
|
|
257
|
+
/>
|
|
258
|
+
</React.Fragment>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
</g>
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
const placedPolys = placeSilhouetteGridAlignedAsPolys(mask, scaleS, centerPos);
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<g key="sil-unified" pointerEvents="none">
|
|
268
|
+
{placedPolys.map((scaledPoly, i) => {
|
|
269
|
+
const primInfo = primitiveDecomposition[i];
|
|
270
|
+
let fillColor = CONFIG.color.silhouetteMask;
|
|
271
|
+
|
|
272
|
+
if (usePrimitiveColors && primInfo?.kind && primitiveColorIndices) {
|
|
273
|
+
const kindToIndex: Record<TanKind, number> = {
|
|
274
|
+
'square': 0,
|
|
275
|
+
'smalltriangle': 1,
|
|
276
|
+
'parallelogram': 2,
|
|
277
|
+
'medtriangle': 3,
|
|
278
|
+
'largetriangle': 4
|
|
279
|
+
};
|
|
280
|
+
const primitiveIndex = kindToIndex[primInfo.kind as TanKind];
|
|
281
|
+
if (primitiveIndex !== undefined && primitiveColorIndices[primitiveIndex] !== undefined) {
|
|
282
|
+
const colorIndex = primitiveColorIndices[primitiveIndex];
|
|
283
|
+
const color = CONFIG.color.primitiveColors[colorIndex];
|
|
284
|
+
if (color) {
|
|
285
|
+
fillColor = color;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<path
|
|
292
|
+
key={`sil-${i}`}
|
|
293
|
+
d={pathD(scaledPoly)}
|
|
294
|
+
fill={fillColor}
|
|
295
|
+
opacity={usePrimitiveColors ? CONFIG.opacity.piece.normal : CONFIG.opacity.silhouetteMask}
|
|
296
|
+
stroke="none"
|
|
297
|
+
/>
|
|
298
|
+
);
|
|
299
|
+
})}
|
|
300
|
+
</g>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<div style={{
|
|
307
|
+
display: "flex",
|
|
308
|
+
flexDirection: "column",
|
|
309
|
+
alignItems: "center",
|
|
310
|
+
gap: "20px"
|
|
311
|
+
}}>
|
|
312
|
+
<svg
|
|
313
|
+
width={viewport.w}
|
|
314
|
+
height={viewport.h}
|
|
315
|
+
viewBox={`0 0 ${viewport.w} ${viewport.h}`}
|
|
316
|
+
style={{
|
|
317
|
+
display: "block",
|
|
318
|
+
background: CONFIG.color.bands.silhouette.fillEven,
|
|
319
|
+
border: `${CONFIG.size.stroke.bandPx}px solid ${CONFIG.color.bands.silhouette.stroke}`,
|
|
320
|
+
borderRadius: "8px"
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
{renderSilhouette()}
|
|
324
|
+
</svg>
|
|
325
|
+
|
|
326
|
+
<button
|
|
327
|
+
className="jspsych-btn"
|
|
328
|
+
onClick={onClick}
|
|
329
|
+
disabled={disabled}
|
|
330
|
+
style={{
|
|
331
|
+
padding: "12px 30px",
|
|
332
|
+
fontSize: "16px",
|
|
333
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
334
|
+
opacity: disabled ? 0.5 : 1
|
|
335
|
+
}}
|
|
336
|
+
>
|
|
337
|
+
{buttonText}
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
|
|
2
|
+
import { startAFCTrial, StartAFCTrialParams } from "./AFCApp";
|
|
3
|
+
|
|
4
|
+
const info = {
|
|
5
|
+
name: "tangram-afc",
|
|
6
|
+
version: "1.0.0",
|
|
7
|
+
parameters: {
|
|
8
|
+
/** Left tangram specification to display */
|
|
9
|
+
tangram_left: {
|
|
10
|
+
type: ParameterType.COMPLEX,
|
|
11
|
+
default: undefined,
|
|
12
|
+
description: "TangramSpec object defining left target shape to display"
|
|
13
|
+
},
|
|
14
|
+
/** Right tangram specification to display */
|
|
15
|
+
tangram_right: {
|
|
16
|
+
type: ParameterType.COMPLEX,
|
|
17
|
+
default: undefined,
|
|
18
|
+
description: "TangramSpec object defining right target shape to display"
|
|
19
|
+
},
|
|
20
|
+
/** HTML content to display above the tangrams as instructions */
|
|
21
|
+
instructions: {
|
|
22
|
+
type: ParameterType.STRING,
|
|
23
|
+
default: "",
|
|
24
|
+
description: "HTML content to display above the tangrams as instructions"
|
|
25
|
+
},
|
|
26
|
+
/** Text to display on left response button */
|
|
27
|
+
button_text_left: {
|
|
28
|
+
type: ParameterType.STRING,
|
|
29
|
+
default: "Select Left",
|
|
30
|
+
description: "Text to display on left response button"
|
|
31
|
+
},
|
|
32
|
+
/** Text to display on right response button */
|
|
33
|
+
button_text_right: {
|
|
34
|
+
type: ParameterType.STRING,
|
|
35
|
+
default: "Select Right",
|
|
36
|
+
description: "Text to display on right response button"
|
|
37
|
+
},
|
|
38
|
+
/** Whether to show tangram decomposed into individual primitives with borders */
|
|
39
|
+
show_tangram_decomposition: {
|
|
40
|
+
type: ParameterType.BOOL,
|
|
41
|
+
default: false,
|
|
42
|
+
description: "Whether to show tangram decomposed into individual primitives with borders"
|
|
43
|
+
},
|
|
44
|
+
/** Whether to use distinct colors for each primitive shape type */
|
|
45
|
+
use_primitive_colors: {
|
|
46
|
+
type: ParameterType.BOOL,
|
|
47
|
+
default: false,
|
|
48
|
+
description: "Whether each primitive shape type should have its own distinct color in the displayed tangram"
|
|
49
|
+
},
|
|
50
|
+
/** Indices mapping primitives to colors from the color palette */
|
|
51
|
+
primitive_color_indices: {
|
|
52
|
+
type: ParameterType.OBJECT,
|
|
53
|
+
default: [0, 1, 2, 3, 4],
|
|
54
|
+
description: "Array of 5 integers indexing into primitiveColors array, mapping [square, smalltriangle, parallelogram, medtriangle, largetriangle] to colors"
|
|
55
|
+
},
|
|
56
|
+
/** Callback fired when trial ends */
|
|
57
|
+
onTrialEnd: {
|
|
58
|
+
type: ParameterType.FUNCTION,
|
|
59
|
+
default: undefined,
|
|
60
|
+
description: "Callback when trial completes with full data"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
data: {
|
|
64
|
+
/** Reaction time in milliseconds */
|
|
65
|
+
rt: {
|
|
66
|
+
type: ParameterType.INT,
|
|
67
|
+
description: "Milliseconds between trial start and button click"
|
|
68
|
+
},
|
|
69
|
+
/** Response choice */
|
|
70
|
+
response: {
|
|
71
|
+
type: ParameterType.STRING,
|
|
72
|
+
description: "Which button was clicked: 'left' or 'right'"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
citations: ""
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type Info = typeof info;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* **tangram-afc**
|
|
82
|
+
*
|
|
83
|
+
* A jsPsych plugin for alternative forced choice (AFC) trials displaying two tangrams
|
|
84
|
+
* side-by-side with response buttons.
|
|
85
|
+
*
|
|
86
|
+
* @author Justin Yang & Sean Paul Anderson
|
|
87
|
+
* @see {@link https://github.com/cogtoolslab/tangram_construction.git/tree/main/experiments/jspsych-tangram-prep}
|
|
88
|
+
*/
|
|
89
|
+
class TangramAFCPlugin implements JsPsychPlugin<Info> {
|
|
90
|
+
static info = info;
|
|
91
|
+
|
|
92
|
+
constructor(private jsPsych: JsPsych) {}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Launches the trial by invoking startAFCTrial
|
|
96
|
+
* with the display element, parameters, and jsPsych instance.
|
|
97
|
+
*/
|
|
98
|
+
trial(display_element: HTMLElement, trial: TrialType<Info>) {
|
|
99
|
+
// Wrap onTrialEnd to handle React cleanup and jsPsych trial completion
|
|
100
|
+
const wrappedOnTrialEnd = (data: any) => {
|
|
101
|
+
// Call user-provided callback if exists
|
|
102
|
+
if (trial.onTrialEnd) {
|
|
103
|
+
trial.onTrialEnd(data);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Clean up React first (before clearing DOM)
|
|
107
|
+
const reactContext = (display_element as any).__reactContext;
|
|
108
|
+
if (reactContext?.root) {
|
|
109
|
+
reactContext.root.unmount();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Clear display after React cleanup
|
|
113
|
+
display_element.innerHTML = '';
|
|
114
|
+
|
|
115
|
+
// Finish jsPsych trial with data
|
|
116
|
+
this.jsPsych.finishTrial(data);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Create parameter object for wrapper
|
|
120
|
+
const params: StartAFCTrialParams = {
|
|
121
|
+
tangramLeft: trial.tangram_left,
|
|
122
|
+
tangramRight: trial.tangram_right,
|
|
123
|
+
instructions: trial.instructions,
|
|
124
|
+
buttonTextLeft: trial.button_text_left,
|
|
125
|
+
buttonTextRight: trial.button_text_right,
|
|
126
|
+
showTangramDecomposition: trial.show_tangram_decomposition,
|
|
127
|
+
usePrimitiveColors: trial.use_primitive_colors,
|
|
128
|
+
primitiveColorIndices: trial.primitive_color_indices,
|
|
129
|
+
onTrialEnd: wrappedOnTrialEnd
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Use React wrapper to start the trial
|
|
133
|
+
const { root, display_element: element, jsPsych } = startAFCTrial(display_element, params, this.jsPsych);
|
|
134
|
+
|
|
135
|
+
// Store React context for cleanup
|
|
136
|
+
(element as any).__reactContext = { root, jsPsych };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default TangramAFCPlugin;
|
|
@@ -247,7 +247,7 @@ function NBackView({ params }: NBackViewProps) {
|
|
|
247
247
|
<path
|
|
248
248
|
d={pathD(scaledPoly)}
|
|
249
249
|
fill={fillColor}
|
|
250
|
-
opacity={CONFIG.opacity.silhouetteMask}
|
|
250
|
+
opacity={usePrimitiveColors ? CONFIG.opacity.piece.normal : CONFIG.opacity.silhouetteMask}
|
|
251
251
|
stroke="none"
|
|
252
252
|
/>
|
|
253
253
|
|
|
@@ -298,7 +298,7 @@ function NBackView({ params }: NBackViewProps) {
|
|
|
298
298
|
key={`sil-${i}`}
|
|
299
299
|
d={pathD(scaledPoly)}
|
|
300
300
|
fill={fillColor}
|
|
301
|
-
opacity={CONFIG.opacity.silhouetteMask}
|
|
301
|
+
opacity={usePrimitiveColors ? CONFIG.opacity.piece.normal : CONFIG.opacity.silhouetteMask}
|
|
302
302
|
stroke="none"
|
|
303
303
|
/>
|
|
304
304
|
);
|