jspsych-tangram 0.0.13 → 0.0.15

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 (51) hide show
  1. package/dist/afc/index.browser.js +17751 -0
  2. package/dist/afc/index.browser.js.map +1 -0
  3. package/dist/afc/index.browser.min.js +42 -0
  4. package/dist/afc/index.browser.min.js.map +1 -0
  5. package/dist/afc/index.cjs +443 -0
  6. package/dist/afc/index.cjs.map +1 -0
  7. package/dist/afc/index.d.ts +169 -0
  8. package/dist/afc/index.js +441 -0
  9. package/dist/afc/index.js.map +1 -0
  10. package/dist/construct/index.browser.js +8 -2
  11. package/dist/construct/index.browser.js.map +1 -1
  12. package/dist/construct/index.browser.min.js +10 -10
  13. package/dist/construct/index.browser.min.js.map +1 -1
  14. package/dist/construct/index.cjs +8 -2
  15. package/dist/construct/index.cjs.map +1 -1
  16. package/dist/construct/index.js +8 -2
  17. package/dist/construct/index.js.map +1 -1
  18. package/dist/index.cjs +379 -11
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.ts +178 -12
  21. package/dist/index.js +379 -12
  22. package/dist/index.js.map +1 -1
  23. package/dist/nback/index.browser.js +6 -4
  24. package/dist/nback/index.browser.js.map +1 -1
  25. package/dist/nback/index.browser.min.js +8 -8
  26. package/dist/nback/index.browser.min.js.map +1 -1
  27. package/dist/nback/index.cjs +6 -4
  28. package/dist/nback/index.cjs.map +1 -1
  29. package/dist/nback/index.js +6 -4
  30. package/dist/nback/index.js.map +1 -1
  31. package/dist/prep/index.browser.js +8 -2
  32. package/dist/prep/index.browser.js.map +1 -1
  33. package/dist/prep/index.browser.min.js +10 -10
  34. package/dist/prep/index.browser.min.js.map +1 -1
  35. package/dist/prep/index.cjs +8 -2
  36. package/dist/prep/index.cjs.map +1 -1
  37. package/dist/prep/index.js +8 -2
  38. package/dist/prep/index.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/core/components/board/GameBoard.tsx +13 -42
  41. package/src/core/components/board/useGameBoard.ts +52 -0
  42. package/src/core/components/index.ts +4 -2
  43. package/src/core/components/pieces/BlueprintRing.tsx +0 -25
  44. package/src/core/components/pieces/useBlueprintRing.ts +39 -0
  45. package/src/index.ts +2 -1
  46. package/src/plugins/tangram-afc/AFCApp.tsx +341 -0
  47. package/src/plugins/tangram-afc/index.ts +140 -0
  48. package/src/plugins/tangram-nback/NBackApp.tsx +3 -3
  49. package/tangram-construct.min.js +10 -10
  50. package/tangram-nback.min.js +8 -8
  51. package/tangram-prep.min.js +10 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jspsych-tangram",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "Tangram tasks for jsPsych: prep and construct.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -718,15 +718,25 @@ export default function GameBoard(props: GameBoardProps) {
718
718
  textAlign: 'center'
719
719
  };
720
720
 
721
- // Timer style (positioned absolutely to not affect centering)
721
+ // Timer style - positioned absolutely to align right edge with semicircle's right edge
722
+ // Independent of instructions content
723
+ const scaleX = svgDimensions.width / viewBox.w;
724
+ const rightEdgeOfSemicircleLogical = viewBox.w / 2 + layout.outerR;
725
+ const distanceFromRightEdgeLogical = viewBox.w - rightEdgeOfSemicircleLogical;
726
+ const distanceFromRightEdgePx = distanceFromRightEdgeLogical * scaleX;
727
+
728
+ // Shift left by 5 monospaced characters (monospace is typically 0.6em wide per char)
729
+ const charWidth = 24 * 0.6; // fontSize * typical monospace char width ratio
730
+ const offsetLeft = charWidth * 5;
731
+
722
732
  const timerStyle: React.CSSProperties = {
723
733
  position: 'absolute',
724
- right: 0,
734
+ right: `${distanceFromRightEdgePx + offsetLeft}px`,
725
735
  fontSize: '24px',
726
736
  fontWeight: 'bold',
727
737
  fontFamily: 'monospace',
728
738
  color: '#333',
729
- minWidth: '80px',
739
+ whiteSpace: 'nowrap',
730
740
  textAlign: 'right'
731
741
  };
732
742
 
@@ -797,42 +807,3 @@ export default function GameBoard(props: GameBoardProps) {
797
807
  </div>
798
808
  );
799
809
  }
800
-
801
- /**
802
- * Hook for managing GameBoard state externally
803
- * Useful for plugins that need direct access to game state
804
- */
805
- export function useGameBoard(config: GameBoardConfig) {
806
- const controller = React.useMemo(() => {
807
- const gameConfig = {
808
- n: config.sectors.length,
809
- layout: config.layout,
810
- target: config.target,
811
- input: config.input,
812
- timeLimitMs: config.timeLimitMs,
813
- maxQuickstashSlots: config.maxQuickstashSlots,
814
- ...(config.maxCompositeSize !== undefined && { maxCompositeSize: config.maxCompositeSize }),
815
- mode: config.mode || "construction",
816
- ...(config.minPiecesPerMacro !== undefined && { minPiecesPerMacro: config.minPiecesPerMacro }),
817
- ...(config.requireAllSlots !== undefined && { requireAllSlots: config.requireAllSlots })
818
- };
819
-
820
- return new BaseGameController(
821
- config.sectors,
822
- config.quickstash,
823
- config.primitives,
824
- gameConfig
825
- );
826
- }, [config]);
827
-
828
- const snapshot = React.useMemo(() =>
829
- controller.snapshot(),
830
- [controller.updateCount]
831
- );
832
-
833
- return {
834
- controller,
835
- snapshot,
836
- isComplete: !!controller.state.endedAt
837
- };
838
- }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Hook for managing GameBoard state externally
3
+ *
4
+ * This hook provides external access to the GameBoard's internal state
5
+ * management, useful for plugins that need to track or control game state
6
+ * directly without rendering the full GameBoard component.
7
+ *
8
+ * REQUIRES: config contains valid GameBoardConfig properties
9
+ * EFFECTS: Creates and manages a BaseGameController instance
10
+ *
11
+ * @param config - GameBoard configuration object
12
+ * @returns Object containing controller, snapshot, and completion status
13
+ */
14
+
15
+ import React from "react";
16
+ import { BaseGameController } from "@/core/engine/state/BaseGameController";
17
+ import type { GameBoardConfig } from "./GameBoard";
18
+
19
+ export function useGameBoard(config: GameBoardConfig) {
20
+ const controller = React.useMemo(() => {
21
+ const gameConfig = {
22
+ n: config.sectors.length,
23
+ layout: config.layout,
24
+ target: config.target,
25
+ input: config.input,
26
+ timeLimitMs: config.timeLimitMs,
27
+ maxQuickstashSlots: config.maxQuickstashSlots,
28
+ ...(config.maxCompositeSize !== undefined && { maxCompositeSize: config.maxCompositeSize }),
29
+ mode: config.mode || "construction",
30
+ ...(config.minPiecesPerMacro !== undefined && { minPiecesPerMacro: config.minPiecesPerMacro }),
31
+ ...(config.requireAllSlots !== undefined && { requireAllSlots: config.requireAllSlots })
32
+ };
33
+
34
+ return new BaseGameController(
35
+ config.sectors,
36
+ config.quickstash,
37
+ config.primitives,
38
+ gameConfig
39
+ );
40
+ }, [config]);
41
+
42
+ const snapshot = React.useMemo(() =>
43
+ controller.snapshot(),
44
+ [controller.updateCount]
45
+ );
46
+
47
+ return {
48
+ controller,
49
+ snapshot,
50
+ isComplete: !!controller.state.endedAt
51
+ };
52
+ }
@@ -6,11 +6,13 @@
6
6
  */
7
7
 
8
8
  // Core game board component
9
- export { default as GameBoard, useGameBoard } from './board/GameBoard';
9
+ export { default as GameBoard } from './board/GameBoard';
10
+ export { useGameBoard } from './board/useGameBoard';
10
11
  export type { GameBoardConfig, GameBoardProps } from './board/GameBoard';
11
12
 
12
13
  // Blueprint ring component for plugin UI
13
- export { default as BlueprintRing, useBlueprintRing } from './pieces/BlueprintRing';
14
+ export { default as BlueprintRing } from './pieces/BlueprintRing';
15
+ export { useBlueprintRing } from './pieces/useBlueprintRing';
14
16
  export type { BlueprintRingProps } from './pieces/BlueprintRing';
15
17
 
16
18
  // Legacy Board components (deprecated - use GameBoard instead)
@@ -268,29 +268,4 @@ export default function BlueprintRing(props: BlueprintRingProps) {
268
268
  })}
269
269
  </g>
270
270
  );
271
- }
272
-
273
- /**
274
- * Hook for managing blueprint ring state
275
- * Useful for components that need more control over blueprint ring behavior
276
- */
277
- export function useBlueprintRing(
278
- primitives: PrimitiveBlueprint[],
279
- quickstash: Blueprint[],
280
- initialView: "primitives" | "quickstash" = "quickstash"
281
- ) {
282
- const [currentView, setCurrentView] = React.useState<"primitives" | "quickstash">(initialView);
283
-
284
- const switchView = React.useCallback(() => {
285
- setCurrentView(prev => prev === "primitives" ? "quickstash" : "primitives");
286
- }, []);
287
-
288
- const blueprints = currentView === "primitives" ? primitives : quickstash;
289
-
290
- return {
291
- currentView,
292
- blueprints,
293
- switchView,
294
- setCurrentView
295
- };
296
271
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Hook for managing BlueprintRing state externally
3
+ *
4
+ * This hook manages the state for switching between primitives and quickstash
5
+ * views in the blueprint ring. Useful for components that need more control
6
+ * over blueprint ring behavior.
7
+ *
8
+ * REQUIRES: primitives and quickstash are valid arrays of blueprints
9
+ * EFFECTS: Manages view state and provides toggle functionality
10
+ *
11
+ * @param primitives - Array of primitive piece blueprints
12
+ * @param quickstash - Array of quickstash macro blueprints
13
+ * @param initialView - Initial view to display (defaults to "quickstash")
14
+ * @returns Object with current view, blueprints, and control functions
15
+ */
16
+
17
+ import React from "react";
18
+ import type { Blueprint, PrimitiveBlueprint } from "@/core/domain/types";
19
+
20
+ export function useBlueprintRing(
21
+ primitives: PrimitiveBlueprint[],
22
+ quickstash: Blueprint[],
23
+ initialView: "primitives" | "quickstash" = "quickstash"
24
+ ) {
25
+ const [currentView, setCurrentView] = React.useState<"primitives" | "quickstash">(initialView);
26
+
27
+ const switchView = React.useCallback(() => {
28
+ setCurrentView(prev => prev === "primitives" ? "quickstash" : "primitives");
29
+ }, []);
30
+
31
+ const blueprints = currentView === "primitives" ? primitives : quickstash;
32
+
33
+ return {
34
+ currentView,
35
+ blueprints,
36
+ switchView,
37
+ setCurrentView
38
+ };
39
+ }
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
+ }