jspsych-tangram 0.0.6 → 0.0.8

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 (36) hide show
  1. package/dist/construct/index.browser.js +157 -222
  2. package/dist/construct/index.browser.js.map +1 -1
  3. package/dist/construct/index.browser.min.js +11 -11
  4. package/dist/construct/index.browser.min.js.map +1 -1
  5. package/dist/construct/index.cjs +157 -222
  6. package/dist/construct/index.cjs.map +1 -1
  7. package/dist/construct/index.d.ts +24 -0
  8. package/dist/construct/index.js +157 -222
  9. package/dist/construct/index.js.map +1 -1
  10. package/dist/index.cjs +168 -223
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts +36 -0
  13. package/dist/index.js +168 -223
  14. package/dist/index.js.map +1 -1
  15. package/dist/prep/index.browser.js +143 -221
  16. package/dist/prep/index.browser.js.map +1 -1
  17. package/dist/prep/index.browser.min.js +13 -13
  18. package/dist/prep/index.browser.min.js.map +1 -1
  19. package/dist/prep/index.cjs +143 -221
  20. package/dist/prep/index.cjs.map +1 -1
  21. package/dist/prep/index.d.ts +12 -0
  22. package/dist/prep/index.js +143 -221
  23. package/dist/prep/index.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/core/components/board/BoardView.tsx +53 -14
  26. package/src/core/components/board/GameBoard.tsx +123 -7
  27. package/src/core/config/config.ts +19 -14
  28. package/src/core/domain/types.ts +1 -0
  29. package/src/core/io/InteractionTracker.ts +7 -16
  30. package/src/core/io/data-tracking.ts +3 -7
  31. package/src/plugins/tangram-construct/ConstructionApp.tsx +16 -1
  32. package/src/plugins/tangram-construct/index.ts +14 -0
  33. package/src/plugins/tangram-prep/PrepApp.tsx +6 -0
  34. package/src/plugins/tangram-prep/index.ts +7 -0
  35. package/tangram-construct.min.js +11 -11
  36. 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.6",
3
+ "version": "0.0.8",
4
4
  "description": "Tangram tasks for jsPsych: prep and construct.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import type { Poly, TanKind, Blueprint, PrimitiveBlueprint } from "@/core/domain/types";
3
- import { wedgePath, type CircleLayout } from "@/core/domain/layout";
4
- import { boundsOfBlueprint } from "@/core/engine/geometry";
3
+ import { wedgePath, rectForBand, type CircleLayout } from "@/core/domain/layout";
4
+ import { boundsOfBlueprint, placeSilhouetteGridAlignedAsPolys } from "@/core/engine/geometry";
5
5
  import { CONFIG } from "@/core/config/config";
6
6
  import { generateEdgeStrokePaths, shouldUseSelectiveBorders, shouldShowBorders, getHiddenEdgesForPolygon } from "@/core/engine/validation/border-rendering";
7
7
 
@@ -21,6 +21,7 @@ export type BoardViewProps = {
21
21
  placedSilBySector: Map<string, Poly[]>;
22
22
  anchorDots: AnchorDots[];
23
23
  pieces: PieceView[];
24
+ scaleS: number; // scaling factor for silhouettes
24
25
 
25
26
  // UI state flags
26
27
  clickMode: boolean;
@@ -28,6 +29,7 @@ export type BoardViewProps = {
28
29
  selectedPieceId: string | null;
29
30
  dragInvalid: boolean;
30
31
  lockedPieceId: string | null;
32
+ showTangramDecomposition?: boolean; // whether to show tangram decomposed into primitives
31
33
 
32
34
  // refs + handlers wired by the container
33
35
  svgRef: React.RefObject<SVGSVGElement>;
@@ -55,8 +57,8 @@ export default function BoardView(props: BoardViewProps) {
55
57
  const {
56
58
  controller, layout, viewBox, width, height,
57
59
  badgeR, badgeCenter,
58
- placedSilBySector, anchorDots, pieces,
59
- clickMode, draggingId, selectedPieceId, dragInvalid, lockedPieceId,
60
+ placedSilBySector, anchorDots, pieces, scaleS,
61
+ clickMode, draggingId, selectedPieceId, dragInvalid, lockedPieceId, showTangramDecomposition,
60
62
  svgRef, setPieceRef,
61
63
  onPiecePointerDown, onBlueprintPointerDown, onRootPointerDown, onPointerMove, onPointerUp, onCenterBadgePointerDown,
62
64
  } = props;
@@ -136,7 +138,7 @@ export default function BoardView(props: BoardViewProps) {
136
138
  onPointerDown={(e) => {
137
139
  onRootPointerDown(e);
138
140
  }}
139
- style={{ background: "#fff", touchAction: "none", userSelect: "none" }}
141
+ style={{ background: "#f5f5f5", touchAction: "none", userSelect: "none" }}
140
142
  >
141
143
  {/* bands as wedges */}
142
144
  {layout.sectors.map((s, i) => {
@@ -272,15 +274,52 @@ export default function BoardView(props: BoardViewProps) {
272
274
 
273
275
  {/* silhouettes (use pre-placed polys) */}
274
276
  {layout.sectors.map((s) => {
275
- const placedPolys = placedSilBySector.get(s.id) ?? [];
276
- if (!placedPolys.length) return null;
277
- return (
278
- <g key={`sil-${s.id}`} pointerEvents="none">
279
- {placedPolys.map((poly, i) => (
280
- <path key={i} d={pathD(poly)} fill={CONFIG.color.silhouetteMask} opacity={CONFIG.opacity.silhouetteMask} />
281
- ))}
282
- </g>
283
- );
277
+ const sectorCfg = controller.state.cfg.sectors.find((ss: any) => ss.id === s.id);
278
+
279
+ // Check if we should render decomposed primitives
280
+ if (showTangramDecomposition && sectorCfg?.silhouette.primitiveDecomposition) {
281
+ const primitiveDecomposition = sectorCfg.silhouette.primitiveDecomposition;
282
+
283
+ // Use same positioning logic as unified silhouette
284
+ const rect = rectForBand(layout, s, "silhouette", 1.0);
285
+ const rawPolys = primitiveDecomposition.map((primInfo: any) => primInfo.polygon);
286
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
287
+
288
+ return (
289
+ <g key={`sil-decomposed-${s.id}`} pointerEvents="none">
290
+ {placedPolys.map((scaledPoly, i) => (
291
+ <React.Fragment key={`prim-${i}`}>
292
+ {/* Fill path */}
293
+ <path
294
+ d={pathD(scaledPoly)}
295
+ fill={CONFIG.color.silhouetteMask}
296
+ opacity={CONFIG.opacity.silhouetteMask}
297
+ stroke="none"
298
+ />
299
+
300
+ {/* Full perimeter border */}
301
+ <path
302
+ d={pathD(scaledPoly)}
303
+ fill="none"
304
+ stroke={CONFIG.color.tangramDecomposition.stroke}
305
+ strokeWidth={CONFIG.size.stroke.tangramDecompositionPx}
306
+ />
307
+ </React.Fragment>
308
+ ))}
309
+ </g>
310
+ );
311
+ } else {
312
+ // Default: render unified silhouette
313
+ const placedPolys = placedSilBySector.get(s.id) ?? [];
314
+ if (!placedPolys.length) return null;
315
+ return (
316
+ <g key={`sil-${s.id}`} pointerEvents="none">
317
+ {placedPolys.map((poly, i) => (
318
+ <path key={i} d={pathD(poly)} fill={CONFIG.color.silhouetteMask} opacity={CONFIG.opacity.silhouetteMask} />
319
+ ))}
320
+ </g>
321
+ );
322
+ }
284
323
  })}
285
324
 
286
325
  {/* anchor grid */}
@@ -149,6 +149,24 @@ export interface GameBoardConfig {
149
149
  */
150
150
  requireAllSlots?: boolean;
151
151
 
152
+ /**
153
+ * Whether to show tangram target shapes decomposed into individual primitives with borders
154
+ * Independent from showBorders/hideTouchingBorders (which control spawned pieces)
155
+ */
156
+ showTangramDecomposition?: boolean;
157
+
158
+ /**
159
+ * HTML content to display above the gameboard as instructions
160
+ * Allows rich formatting via HTML
161
+ */
162
+ instructions?: string;
163
+
164
+ /**
165
+ * Cleaned jsPsych plugin parameters (excluding callbacks)
166
+ * Saved to trial data for analysis
167
+ */
168
+ trialParams?: any;
169
+
152
170
  /** Optional CSS width for the game board SVG */
153
171
  width?: number;
154
172
 
@@ -238,6 +256,9 @@ export default function GameBoard(props: GameBoardProps) {
238
256
  maxQuickstashSlots,
239
257
  maxCompositeSize,
240
258
  mode,
259
+ showTangramDecomposition,
260
+ instructions,
261
+ trialParams,
241
262
  width: _width,
242
263
  height: _height,
243
264
  onSectorComplete,
@@ -248,6 +269,9 @@ export default function GameBoard(props: GameBoardProps) {
248
269
  onControllerReady
249
270
  } = props;
250
271
 
272
+ // Timer state for countdown
273
+ const [timeRemaining, setTimeRemaining] = React.useState(timeLimitMs > 0 ? Math.floor(timeLimitMs / 1000) : 0);
274
+
251
275
  // Initialize game controller with injected data
252
276
  const controller = React.useMemo(() => {
253
277
  const gameConfig = {
@@ -279,8 +303,8 @@ export default function GameBoard(props: GameBoardProps) {
279
303
  if (onInteraction) callbacks.onInteraction = onInteraction;
280
304
  if (onTrialEnd) callbacks.onTrialEnd = onTrialEnd;
281
305
 
282
- return new InteractionTracker(controller, callbacks);
283
- }, [controller, onInteraction, onTrialEnd]);
306
+ return new InteractionTracker(controller, callbacks, trialParams);
307
+ }, [controller, onInteraction, onTrialEnd, trialParams]);
284
308
 
285
309
  // Call onControllerReady when controller is ready
286
310
  React.useEffect(() => {
@@ -387,7 +411,7 @@ export default function GameBoard(props: GameBoardProps) {
387
411
  // Calculate sizing based on layout mode
388
412
  const getGameboardStyle = () => {
389
413
  const baseStyle = {
390
- margin: '0 auto',
414
+ margin: '10px',
391
415
  display: 'flex',
392
416
  alignItems: 'center',
393
417
  justifyContent: 'center',
@@ -396,7 +420,7 @@ export default function GameBoard(props: GameBoardProps) {
396
420
 
397
421
  if (layoutMode === 'circle') {
398
422
  // Circle mode: square aspect ratio (height == width)
399
- const size = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96);
423
+ const size = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale);
400
424
  return {
401
425
  ...baseStyle,
402
426
  width: `${size}px`,
@@ -404,8 +428,8 @@ export default function GameBoard(props: GameBoardProps) {
404
428
  };
405
429
  } else {
406
430
  // Semicircle mode: width is 2x height
407
- const maxWidth = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96 * 2);
408
- const maxHeight = Math.min(window.innerWidth * 0.96 / 2, window.innerHeight * 0.96);
431
+ const maxWidth = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale * 2);
432
+ const maxHeight = Math.min(window.innerWidth * CONFIG.layout.viewportScale / 2, window.innerHeight * CONFIG.layout.viewportScale);
409
433
  return {
410
434
  ...baseStyle,
411
435
  width: `${maxWidth}px`,
@@ -613,8 +637,96 @@ export default function GameBoard(props: GameBoardProps) {
613
637
  e.stopPropagation();
614
638
  };
615
639
 
640
+ // Timer effect
641
+ React.useEffect(() => {
642
+ if (timeLimitMs === 0) return;
643
+
644
+ const interval = setInterval(() => {
645
+ setTimeRemaining(prev => {
646
+ if (prev <= 1) {
647
+ clearInterval(interval);
648
+ return 0;
649
+ }
650
+ return prev - 1;
651
+ });
652
+ }, 1000);
653
+
654
+ return () => clearInterval(interval);
655
+ }, [timeLimitMs]);
656
+
657
+ // Format time helper
658
+ const formatTime = (seconds: number): string => {
659
+ const mins = Math.floor(seconds / 60);
660
+ const secs = Math.floor(seconds % 60);
661
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
662
+ };
663
+
664
+ // Container style
665
+ const containerStyle: React.CSSProperties = {
666
+ display: 'flex',
667
+ flexDirection: 'column',
668
+ width: '100%',
669
+ // minHeight: '100vh'
670
+ };
671
+
672
+ // Header style
673
+ const headerStyle: React.CSSProperties = {
674
+ display: 'flex',
675
+ flexDirection: 'row',
676
+ justifyContent: 'space-between',
677
+ alignItems: 'center',
678
+ padding: '20px',
679
+ background: '#f5f5f5',
680
+ flex: '0 0 auto'
681
+ };
682
+
683
+ // Instructions style
684
+ const instructionsStyle: React.CSSProperties = {
685
+ flexGrow: 1,
686
+ fontSize: '20px',
687
+ lineHeight: 1.5,
688
+ marginRight: '20px'
689
+ };
690
+
691
+ // Timer style
692
+ const timerStyle: React.CSSProperties = {
693
+ fontSize: '24px',
694
+ fontWeight: 'bold',
695
+ fontFamily: 'monospace',
696
+ color: '#333',
697
+ minWidth: '80px',
698
+ textAlign: 'right'
699
+ };
700
+
701
+ // Gameboard wrapper style (to center the gameboard in remaining space)
702
+ const gameboardWrapperStyle: React.CSSProperties = {
703
+ flex: '1 1 auto',
704
+ display: 'flex',
705
+ alignItems: 'center',
706
+ justifyContent: 'center',
707
+ overflow: 'hidden'
708
+ };
709
+
616
710
  return (
617
- <div className="tangram-gameboard" style={getGameboardStyle()}>
711
+ <div className="tangram-trial-container" style={containerStyle}>
712
+ {(instructions || timeLimitMs > 0) && (
713
+ <div className="tangram-header" style={headerStyle}>
714
+ {instructions && (
715
+ <div
716
+ className="tangram-instructions"
717
+ style={instructionsStyle}
718
+ dangerouslySetInnerHTML={{ __html: instructions }}
719
+ />
720
+ )}
721
+ {timeLimitMs > 0 && (
722
+ <div className="tangram-timer" style={timerStyle}>
723
+ {formatTime(timeRemaining)}
724
+ </div>
725
+ )}
726
+ </div>
727
+ )}
728
+ <div className="tangram-gameboard-wrapper" style={gameboardWrapperStyle}>
729
+ <div className="tangram-gameboard" style={getGameboardStyle()}>
618
730
  <BoardView
619
731
  controller={controller}
620
732
  layout={layout}
@@ -639,8 +751,12 @@ export default function GameBoard(props: GameBoardProps) {
639
751
  onPointerMove={onPointerMove}
640
752
  onPointerUp={onPointerUp}
641
753
  onCenterBadgePointerDown={onCenterBadgePointerDown}
754
+ showTangramDecomposition={showTangramDecomposition ?? false}
755
+ scaleS={scaleS}
642
756
  {...eventCallbacks}
643
757
  />
758
+ </div>
759
+ </div>
644
760
  </div>
645
761
  );
646
762
  }
@@ -11,6 +11,7 @@ export type Config = {
11
11
  piece: { draggingFill: string; validFill: string; invalidFill: string; invalidStroke: string; selectedStroke: string; allGreenStroke: string; borderStroke: string };
12
12
  ui: { light: string; dark: string };
13
13
  blueprint: { fill: string; selectedStroke: string; badgeFill: string; labelFill: string };
14
+ tangramDecomposition: { stroke: string };
14
15
  };
15
16
  opacity: {
16
17
  blueprint: number;
@@ -19,7 +20,7 @@ export type Config = {
19
20
  piece: { invalid: number; dragging: number; locked: number; normal: number };
20
21
  };
21
22
  size: {
22
- stroke: { bandPx: number; pieceSelectedPx: number; allGreenStrokePx: number; pieceBorderPx: number };
23
+ stroke: { bandPx: number; pieceSelectedPx: number; allGreenStrokePx: number; pieceBorderPx: number; tangramDecompositionPx: number };
23
24
  anchorRadiusPx: { valid: number; invalid: number };
24
25
  badgeFontPx: number;
25
26
  centerBadge: { fractionOfOuterR: number; minPx: number; marginPx: number };
@@ -27,6 +28,7 @@ export type Config = {
27
28
  layout: {
28
29
  grid: { stepPx: number; unitPx: number };
29
30
  paddingPx: number;
31
+ viewportScale: number;
30
32
  /** renamed from capacity → constraints */
31
33
  constraints: {
32
34
  workspaceDiamAnchors: number;
@@ -45,31 +47,34 @@ export type Config = {
45
47
  export const CONFIG: Config = {
46
48
  color: {
47
49
  bands: {
48
- silhouette: { fillEven: "#eef2ff", fillOdd: "#f6f7fb", stroke: "#c7d2fe" },
49
- workspace: { fillEven: "#f3f4f6", fillOdd: "#f9fafb", stroke: "#e5e7eb" }
50
+ silhouette: { fillEven: "#fff2ccff", fillOdd: "#fff2ccff", stroke: "#b1b1b1" },
51
+ workspace: { fillEven: "#fff2ccff", fillOdd: "#fff2ccff", stroke: "#b1b1b1" }
50
52
  },
51
- completion: { fill: "#dcfce7", stroke: "#86efac" },
52
- silhouetteMask: "#94a3b8",
53
+ completion: { fill: "#ccfff2", stroke: "#13da57" },
54
+ silhouetteMask: "#374151",
53
55
  anchors: { invalid: "#7dd3fc", valid: "#475569" },
54
- piece: { draggingFill: "#1d4ed8", validFill: "#60a5fa", invalidFill: "#ef4444", invalidStroke: "#dc2626", selectedStroke: "#111827", allGreenStroke: "#86efac", borderStroke: "#374151" },
56
+ piece: { draggingFill: "#8e7cc3ff", validFill: "#8e7cc3ff", invalidFill: "#ef4444", invalidStroke: "#dc2626", selectedStroke: "#674ea7", allGreenStroke: "#86efac", borderStroke: "#674ea7" },
55
57
  ui: { light: "#60a5fa", dark: "#1d4ed8" },
56
- blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#eef2ff", labelFill: "#374151" }
58
+ blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" },
59
+ tangramDecomposition: { stroke: "#fef2cc" }
57
60
  },
58
61
  opacity: {
59
- blueprint: 0.95,
60
- silhouetteMask: 0.45,
61
- anchors: { valid: 0.80, invalid: 0.50 },
62
- piece: { invalid: 0.35, dragging: 0.60, locked: 0.70, normal: 0.95 },
62
+ blueprint: 0.4,
63
+ silhouetteMask: 0.25,
64
+ //anchors: { valid: 0.80, invalid: 0.50 },
65
+ anchors: { invalid: 0.0, valid: 0.0 },
66
+ piece: { invalid: 0.35, dragging: 0.75, locked: 1, normal: 1 },
63
67
  },
64
68
  size: {
65
- stroke: { bandPx: 1, pieceSelectedPx: 1.5, allGreenStrokePx: 6, pieceBorderPx: 1 },
69
+ stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2, tangramDecompositionPx: 1 },
66
70
  anchorRadiusPx: { valid: 1.0, invalid: 1.0 },
67
- badgeFontPx: 12,
71
+ badgeFontPx: 16,
68
72
  centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 }
69
73
  },
70
74
  layout: {
71
75
  grid: { stepPx: 20, unitPx: 40 },
72
76
  paddingPx: 1,
77
+ viewportScale: 0.8,
73
78
  constraints: {
74
79
  workspaceDiamAnchors: 10, // num anchors req'd to be on diagonal
75
80
  quickstashDiamAnchors: 7, // num anchors req'd to be in single quickstash slot
@@ -79,7 +84,7 @@ export const CONFIG: Config = {
79
84
  },
80
85
  game: {
81
86
  snapRadiusPx: 15,
82
- showBorders: true,
87
+ showBorders: false,
83
88
  hideTouchingBorders: true
84
89
  }
85
90
  };
@@ -51,6 +51,7 @@ export type SilhouetteSpec = {
51
51
  id: string;
52
52
  anchors?: Anchor[];
53
53
  mask?: Poly[]; // polygon masks for silhouette matching
54
+ primitiveDecomposition?: Array<{ kind: TanKind; polygon: Poly }>; // individual primitive pieces for decomposition view
54
55
  };
55
56
 
56
57
  export type Sector = {
@@ -88,16 +88,15 @@ interface MouseTrackingData {
88
88
  * InteractionTracker - Main tracking class
89
89
  */
90
90
  export class InteractionTracker {
91
- // IDs
92
- private gameId: string;
93
- private trialId: string;
94
-
95
91
  // Callbacks
96
92
  private callbacks: DataTrackingCallbacks;
97
93
 
98
94
  // Controller reference
99
95
  private controller: BaseGameController;
100
96
 
97
+ // Trial parameters
98
+ private trialParams: any;
99
+
101
100
  // Trial timing
102
101
  private trialStartTime: number;
103
102
  private readonly gridStep: number = CONFIG.layout.grid.stepPx;
@@ -123,13 +122,11 @@ export class InteractionTracker {
123
122
  constructor(
124
123
  controller: BaseGameController,
125
124
  callbacks: DataTrackingCallbacks,
126
- trialId?: string,
127
- gameId?: string
125
+ trialParams?: any
128
126
  ) {
129
127
  this.controller = controller;
130
128
  this.callbacks = callbacks;
131
- this.trialId = trialId || uuidv4();
132
- this.gameId = gameId || uuidv4();
129
+ this.trialParams = trialParams;
133
130
  this.trialStartTime = Date.now();
134
131
 
135
132
  // Register tracking callbacks with controller
@@ -211,8 +208,6 @@ export class InteractionTracker {
211
208
  const event: InteractionEvent = {
212
209
  // Metadata
213
210
  interactionId: uuidv4(),
214
- trialId: this.trialId,
215
- gameId: this.gameId,
216
211
  interactionIndex: this.interactionIndex++,
217
212
 
218
213
  // Interaction type
@@ -372,12 +367,10 @@ export class InteractionTracker {
372
367
 
373
368
  const data: ConstructionTrialData = {
374
369
  trialType: 'construction',
375
- trialId: this.trialId,
376
- gameId: this.gameId,
377
- trialNum: 0, // TODO: Plugin should provide this
378
370
  trialStartTime: this.trialStartTime,
379
371
  trialEndTime,
380
372
  totalDuration,
373
+ trialParams: this.trialParams,
381
374
  endReason: endReason as 'timeout' | 'auto_complete',
382
375
  completionTimes: this.completionTimes,
383
376
  finalBlueprintState,
@@ -403,12 +396,10 @@ export class InteractionTracker {
403
396
 
404
397
  const data: PrepTrialData = {
405
398
  trialType: 'prep',
406
- trialId: this.trialId,
407
- gameId: this.gameId,
408
- trialNum: 0, // TODO: Plugin should provide this
409
399
  trialStartTime: this.trialStartTime,
410
400
  trialEndTime,
411
401
  totalDuration,
402
+ trialParams: this.trialParams,
412
403
  endReason: 'submit',
413
404
  createdMacros: finalMacros,
414
405
  quickstashMacros,
@@ -16,8 +16,6 @@
16
16
  export interface InteractionEvent {
17
17
  // === Event Metadata ===
18
18
  interactionId: string; // UUID for this interaction
19
- trialId: string; // UUID for the trial
20
- gameId: string; // UUID for the game session
21
19
  interactionIndex: number; // Sequential counter (0, 1, 2, ...) within the trial
22
20
 
23
21
  // === Interaction Type ===
@@ -118,16 +116,14 @@ export interface StateSnapshot {
118
116
  * Base trial data shared between construction and prep trials
119
117
  */
120
118
  export interface BaseTrialData {
121
- // Trial identifiers
122
- trialId: string; // UUID for this trial
123
- gameId: string; // UUID for game session
124
- trialNum: number; // Trial number in experiment sequence
125
-
126
119
  // Timing
127
120
  trialStartTime: number; // Absolute timestamp (Date.now())
128
121
  trialEndTime: number; // Absolute timestamp (Date.now())
129
122
  totalDuration: number; // duration in ms (trialEndTime - trialStartTime)
130
123
 
124
+ // Parameters passed to the trial
125
+ trialParams: any; // Cleaned jsPsych plugin parameters (excluding callbacks)
126
+
131
127
  // Final state snapshot
132
128
  finalSnapshot: StateSnapshot;
133
129
  }
@@ -30,6 +30,8 @@ export interface StartConstructionTrialParams {
30
30
  input?: InputMode;
31
31
  layout?: LayoutMode;
32
32
  time_limit_ms: number;
33
+ show_tangram_decomposition?: boolean;
34
+ instructions?: string;
33
35
  onInteraction?: (event: any) => void;
34
36
  onTrialEnd?: (data: any) => void;
35
37
  }
@@ -72,11 +74,17 @@ export function startConstructionTrial(
72
74
  return isCanonical;
73
75
  });
74
76
 
75
- const mask = filteredTans.map((tan: any, tanIndex: number) => {
77
+ const mask = filteredTans.map((tan: any) => {
76
78
  const polygon = tan.vertices.map(([x, y]: number[]) => ({ x: x ?? 0, y: -(y ?? 0) }));
77
79
  return polygon;
78
80
  });
79
81
 
82
+ // Store primitive decomposition for optional decomposition view
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
+
80
88
  // Assign sector ID from alphabetical sequence
81
89
  const sectorId = `sector${index}`;
82
90
 
@@ -86,6 +94,7 @@ export function startConstructionTrial(
86
94
  silhouette: {
87
95
  id: sectorId,
88
96
  mask,
97
+ primitiveDecomposition,
89
98
  },
90
99
  };
91
100
 
@@ -116,6 +125,9 @@ export function startConstructionTrial(
116
125
  } else {
117
126
  }
118
127
 
128
+ // Extract non-callback params for trial data
129
+ const { onInteraction, onTrialEnd, ...trialParams } = params;
130
+
119
131
  // Create React root and render GameBoard
120
132
  const gameBoardProps = {
121
133
  sectors,
@@ -127,6 +139,9 @@ export function startConstructionTrial(
127
139
  timeLimitMs: params.time_limit_ms,
128
140
  maxQuickstashSlots: CONFIG.layout.defaults.maxQuickstashSlots,
129
141
  mode: 'construction' as const,
142
+ showTangramDecomposition: params.show_tangram_decomposition ?? false,
143
+ trialParams,
144
+ ...(params.instructions && { instructions: params.instructions }),
130
145
  ...(params.onInteraction && { onInteraction: params.onInteraction }),
131
146
  ...(params.onTrialEnd && { onTrialEnd: params.onTrialEnd })
132
147
  };
@@ -56,6 +56,18 @@ const info = {
56
56
  default: 18,
57
57
  description: "Snap radius for anchor-based piece placement"
58
58
  },
59
+ /** Whether to show tangram target shapes decomposed into individual primitives with borders */
60
+ show_tangram_decomposition: {
61
+ type: ParameterType.BOOL,
62
+ default: false,
63
+ description: "Whether to show tangram target shapes decomposed into individual primitives with borders"
64
+ },
65
+ /** HTML content to display above the gameboard as instructions */
66
+ instructions: {
67
+ type: ParameterType.STRING,
68
+ default: "",
69
+ description: "HTML content to display above the gameboard as instructions"
70
+ },
59
71
  /** Callback fired after each interaction (piece pickup + placedown) */
60
72
  onInteraction: {
61
73
  type: ParameterType.FUNCTION,
@@ -148,6 +160,8 @@ class TangramConstructPlugin implements JsPsychPlugin<Info> {
148
160
  input: trial.input,
149
161
  layout: trial.layout,
150
162
  time_limit_ms: trial.time_limit_ms,
163
+ show_tangram_decomposition: trial.show_tangram_decomposition,
164
+ instructions: trial.instructions,
151
165
  onInteraction: trial.onInteraction,
152
166
  onTrialEnd: wrappedOnTrialEnd
153
167
  };
@@ -33,6 +33,7 @@ export interface StartPrepTrialParams {
33
33
  requireAllSlots: boolean;
34
34
  quickstashMacros?: AnchorComposite[];
35
35
  primitiveOrder: string[];
36
+ instructions?: string;
36
37
  onInteraction?: (event: any) => void;
37
38
  onTrialEnd?: (data: any) => void;
38
39
  }
@@ -59,6 +60,9 @@ export function startPrepTrial(
59
60
  onTrialEnd,
60
61
  } = params;
61
62
 
63
+ // Extract non-callback params for trial data
64
+ const { onInteraction: _, onTrialEnd: __, ...trialParams } = params;
65
+
62
66
  // make copy of PRIMITIVE_BLUEPRINTS sorted by primitiveOrder
63
67
  const PRIMITIVE_BLUEPRINTS_ORDERED = [...PRIMITIVE_BLUEPRINTS].sort((a, b) => primitiveOrder.indexOf(a.kind) - primitiveOrder.indexOf(b.kind));
64
68
 
@@ -180,6 +184,8 @@ export function startPrepTrial(
180
184
  mode: 'prep', // Enable prep-specific behavior
181
185
  minPiecesPerMacro: minPiecesPerMacro,
182
186
  requireAllSlots: requireAllSlots,
187
+ trialParams,
188
+ ...(params.instructions && { instructions: params.instructions }),
183
189
  onControllerReady: handleControllerReady,
184
190
  ...(onInteraction && { onInteraction }),
185
191
  ...(onTrialEnd && { onTrialEnd })
@@ -47,6 +47,12 @@ const info = {
47
47
  default: ["square", "smalltriangle", "parallelogram", "medtriangle", "largetriangle"],
48
48
  description: "Array of primitive names in the order they should be displayed"
49
49
  },
50
+ /** HTML content to display above the gameboard as instructions */
51
+ instructions: {
52
+ type: ParameterType.STRING,
53
+ default: undefined,
54
+ description: "HTML content to display above the gameboard as instructions"
55
+ },
50
56
  /** Callback fired after each interaction (optional analytics hook) */
51
57
  onInteraction: {
52
58
  type: ParameterType.FUNCTION,
@@ -115,6 +121,7 @@ class TangramPrepPlugin implements JsPsychPlugin<Info> {
115
121
  requireAllSlots: trial.require_all_slots,
116
122
  quickstashMacros: trial.quickstash_macros,
117
123
  primitiveOrder: trial.primitive_order,
124
+ instructions: trial.instructions,
118
125
  onInteraction: trial.onInteraction,
119
126
  onTrialEnd: wrappedOnTrialEnd,
120
127
  };