jspsych-tangram 0.0.7 → 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.
- package/dist/construct/index.browser.js +146 -212
- package/dist/construct/index.browser.js.map +1 -1
- package/dist/construct/index.browser.min.js +11 -11
- package/dist/construct/index.browser.min.js.map +1 -1
- package/dist/construct/index.cjs +146 -212
- package/dist/construct/index.cjs.map +1 -1
- package/dist/construct/index.d.ts +24 -0
- package/dist/construct/index.js +146 -212
- package/dist/construct/index.js.map +1 -1
- package/dist/index.cjs +157 -213
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +36 -0
- package/dist/index.js +157 -213
- package/dist/index.js.map +1 -1
- package/dist/prep/index.browser.js +132 -211
- 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 +132 -211
- package/dist/prep/index.cjs.map +1 -1
- package/dist/prep/index.d.ts +12 -0
- package/dist/prep/index.js +132 -211
- package/dist/prep/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/components/board/BoardView.tsx +53 -14
- package/src/core/components/board/GameBoard.tsx +123 -7
- package/src/core/config/config.ts +8 -4
- package/src/core/domain/types.ts +1 -0
- package/src/core/io/InteractionTracker.ts +7 -16
- package/src/core/io/data-tracking.ts +3 -7
- package/src/plugins/tangram-construct/ConstructionApp.tsx +16 -1
- package/src/plugins/tangram-construct/index.ts +14 -0
- package/src/plugins/tangram-prep/PrepApp.tsx +6 -0
- package/src/plugins/tangram-prep/index.ts +7 -0
- package/tangram-construct.min.js +11 -11
- package/tangram-prep.min.js +13 -13
package/package.json
CHANGED
|
@@ -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: "#
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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: '
|
|
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 *
|
|
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 *
|
|
408
|
-
const maxHeight = Math.min(window.innerWidth *
|
|
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-
|
|
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;
|
|
@@ -53,7 +55,8 @@ export const CONFIG: Config = {
|
|
|
53
55
|
anchors: { invalid: "#7dd3fc", valid: "#475569" },
|
|
54
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: "#000000", labelFill: "#ffffff" }
|
|
58
|
+
blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" },
|
|
59
|
+
tangramDecomposition: { stroke: "#fef2cc" }
|
|
57
60
|
},
|
|
58
61
|
opacity: {
|
|
59
62
|
blueprint: 0.4,
|
|
@@ -63,7 +66,7 @@ export const CONFIG: Config = {
|
|
|
63
66
|
piece: { invalid: 0.35, dragging: 0.75, locked: 1, normal: 1 },
|
|
64
67
|
},
|
|
65
68
|
size: {
|
|
66
|
-
stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2 },
|
|
69
|
+
stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2, tangramDecompositionPx: 1 },
|
|
67
70
|
anchorRadiusPx: { valid: 1.0, invalid: 1.0 },
|
|
68
71
|
badgeFontPx: 16,
|
|
69
72
|
centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 }
|
|
@@ -71,6 +74,7 @@ export const CONFIG: Config = {
|
|
|
71
74
|
layout: {
|
|
72
75
|
grid: { stepPx: 20, unitPx: 40 },
|
|
73
76
|
paddingPx: 1,
|
|
77
|
+
viewportScale: 0.8,
|
|
74
78
|
constraints: {
|
|
75
79
|
workspaceDiamAnchors: 10, // num anchors req'd to be on diagonal
|
|
76
80
|
quickstashDiamAnchors: 7, // num anchors req'd to be in single quickstash slot
|
|
@@ -80,7 +84,7 @@ export const CONFIG: Config = {
|
|
|
80
84
|
},
|
|
81
85
|
game: {
|
|
82
86
|
snapRadiusPx: 15,
|
|
83
|
-
showBorders:
|
|
87
|
+
showBorders: false,
|
|
84
88
|
hideTouchingBorders: true
|
|
85
89
|
}
|
|
86
90
|
};
|
package/src/core/domain/types.ts
CHANGED
|
@@ -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
|
-
|
|
127
|
-
gameId?: string
|
|
125
|
+
trialParams?: any
|
|
128
126
|
) {
|
|
129
127
|
this.controller = controller;
|
|
130
128
|
this.callbacks = callbacks;
|
|
131
|
-
this.
|
|
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
|
|
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
|
};
|