jspsych-tangram 0.0.7 → 0.0.9

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 (40) hide show
  1. package/dist/construct/index.browser.js +187 -214
  2. package/dist/construct/index.browser.js.map +1 -1
  3. package/dist/construct/index.browser.min.js +13 -10
  4. package/dist/construct/index.browser.min.js.map +1 -1
  5. package/dist/construct/index.cjs +187 -214
  6. package/dist/construct/index.cjs.map +1 -1
  7. package/dist/construct/index.d.ts +24 -0
  8. package/dist/construct/index.js +187 -214
  9. package/dist/construct/index.js.map +1 -1
  10. package/dist/index.cjs +198 -215
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts +36 -0
  13. package/dist/index.js +198 -215
  14. package/dist/index.js.map +1 -1
  15. package/dist/prep/index.browser.js +173 -213
  16. package/dist/prep/index.browser.js.map +1 -1
  17. package/dist/prep/index.browser.min.js +14 -11
  18. package/dist/prep/index.browser.min.js.map +1 -1
  19. package/dist/prep/index.cjs +173 -213
  20. package/dist/prep/index.cjs.map +1 -1
  21. package/dist/prep/index.d.ts +12 -0
  22. package/dist/prep/index.js +173 -213
  23. package/dist/prep/index.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/assets/README.md +6 -0
  26. package/src/assets/images.d.ts +19 -0
  27. package/src/assets/locked.png +0 -0
  28. package/src/assets/unlocked.png +0 -0
  29. package/src/core/components/board/BoardView.tsx +121 -39
  30. package/src/core/components/board/GameBoard.tsx +123 -7
  31. package/src/core/config/config.ts +8 -4
  32. package/src/core/domain/types.ts +1 -0
  33. package/src/core/io/InteractionTracker.ts +23 -24
  34. package/src/core/io/data-tracking.ts +6 -7
  35. package/src/plugins/tangram-construct/ConstructionApp.tsx +16 -1
  36. package/src/plugins/tangram-construct/index.ts +14 -0
  37. package/src/plugins/tangram-prep/PrepApp.tsx +6 -0
  38. package/src/plugins/tangram-prep/index.ts +7 -0
  39. package/tangram-construct.min.js +13 -10
  40. package/tangram-prep.min.js +14 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jspsych-tangram",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "Tangram tasks for jsPsych: prep and construct.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -0,0 +1,6 @@
1
+ # Assets Directory
2
+
3
+ - `locked.png` - Icon displayed when quickstash view is active (locked to quickstash only)
4
+ - `unlocked.png` - Icon displayed when primitives view is active (unlocked/full inventory)
5
+
6
+ These icons appear in the center badge button that switches between blueprint views.
@@ -0,0 +1,19 @@
1
+ declare module "*.png" {
2
+ const value: string;
3
+ export default value;
4
+ }
5
+
6
+ declare module "*.jpg" {
7
+ const value: string;
8
+ export default value;
9
+ }
10
+
11
+ declare module "*.jpeg" {
12
+ const value: string;
13
+ export default value;
14
+ }
15
+
16
+ declare module "*.svg" {
17
+ const value: string;
18
+ export default value;
19
+ }
Binary file
Binary file
@@ -1,10 +1,14 @@
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
 
8
+ // Import lock/unlock icons
9
+ import lockedIcon from "@/assets/locked.png";
10
+ import unlockedIcon from "@/assets/unlocked.png";
11
+
8
12
  export type PieceView = { id: string; blueprintId: string; x: number; y: number; sectorId?: string };
9
13
  export type AnchorDots = { sectorId: string; valid: {x:number;y:number}[]; invalid: {x:number;y:number}[] };
10
14
 
@@ -21,6 +25,7 @@ export type BoardViewProps = {
21
25
  placedSilBySector: Map<string, Poly[]>;
22
26
  anchorDots: AnchorDots[];
23
27
  pieces: PieceView[];
28
+ scaleS: number; // scaling factor for silhouettes
24
29
 
25
30
  // UI state flags
26
31
  clickMode: boolean;
@@ -28,6 +33,7 @@ export type BoardViewProps = {
28
33
  selectedPieceId: string | null;
29
34
  dragInvalid: boolean;
30
35
  lockedPieceId: string | null;
36
+ showTangramDecomposition?: boolean; // whether to show tangram decomposed into primitives
31
37
 
32
38
  // refs + handlers wired by the container
33
39
  svgRef: React.RefObject<SVGSVGElement>;
@@ -55,8 +61,8 @@ export default function BoardView(props: BoardViewProps) {
55
61
  const {
56
62
  controller, layout, viewBox, width, height,
57
63
  badgeR, badgeCenter,
58
- placedSilBySector, anchorDots, pieces,
59
- clickMode, draggingId, selectedPieceId, dragInvalid, lockedPieceId,
64
+ placedSilBySector, anchorDots, pieces, scaleS,
65
+ clickMode, draggingId, selectedPieceId, dragInvalid, lockedPieceId, showTangramDecomposition,
60
66
  svgRef, setPieceRef,
61
67
  onPiecePointerDown, onBlueprintPointerDown, onRootPointerDown, onPointerMove, onPointerUp, onCenterBadgePointerDown,
62
68
  } = props;
@@ -87,12 +93,12 @@ export default function BoardView(props: BoardViewProps) {
87
93
  : CONFIG.layout.constraints.quickstashDiamAnchors;
88
94
  const D_slot = anchorsDiameterToPx(reqAnchors);
89
95
  const R_needed = D_slot / (2 * Math.max(1e-9, Math.sin(delta / 2)));
90
-
96
+
91
97
  // Minimum radius to avoid overlapping with badge
92
98
  // Badge takes up: badgeR + margin, plus we need half the slot diameter for clearance
93
99
  const R_min = badgeR + CONFIG.size.centerBadge.marginPx + D_slot / 2;
94
100
  const ringMax = layout.innerR - (badgeR + CONFIG.size.centerBadge.marginPx);
95
-
101
+
96
102
  // Clamp to [R_min, ringMax]
97
103
  const blueprintRingR = Math.min(Math.max(R_needed, R_min), ringMax);
98
104
 
@@ -136,7 +142,7 @@ export default function BoardView(props: BoardViewProps) {
136
142
  onPointerDown={(e) => {
137
143
  onRootPointerDown(e);
138
144
  }}
139
- style={{ background: "#fff", touchAction: "none", userSelect: "none" }}
145
+ style={{ background: "#f5f5f5", touchAction: "none", userSelect: "none" }}
140
146
  >
141
147
  {/* bands as wedges */}
142
148
  {layout.sectors.map((s, i) => {
@@ -193,7 +199,7 @@ export default function BoardView(props: BoardViewProps) {
193
199
  stroke="none"
194
200
  onPointerDown={(e) => onPiecePointerDown(e, p)}
195
201
  />
196
-
202
+
197
203
  {/* Border paths - only rendered if borders are enabled */}
198
204
  {showBorders && (
199
205
  useSelectiveBorders ? (
@@ -203,32 +209,32 @@ export default function BoardView(props: BoardViewProps) {
203
209
  const pieceAsPiece = { ...p, pos: { x: p.x, y: p.y } };
204
210
  const allPiecesAsPieces = allPiecesInSector.map(piece => ({ ...piece, pos: { x: piece.x, y: piece.y } }));
205
211
  const hiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, allPiecesAsPieces, (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
206
-
212
+
207
213
  // Check if this piece's edges were hidden due to touching the dragged piece
208
214
  const draggedPiece = draggingId ? allPiecesInSector.find(piece => piece.id === draggingId) : null;
209
-
215
+
210
216
  // For composites, we need to distinguish between internal edges (always hidden) and external edges (shown when dragging)
211
217
  let wasTouchingDraggedPiece: boolean[];
212
218
  if (p.blueprintId.startsWith('comp:')) {
213
219
  // For composites, check internal edges separately from external edges
214
220
  const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
215
- const externalHiddenEdges = draggedPiece ?
216
- getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
221
+ const externalHiddenEdges = draggedPiece ?
222
+ getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
217
223
  new Array(hiddenEdges.length).fill(false);
218
-
224
+
219
225
  // Only consider external edges as "touching dragged piece"
220
226
  wasTouchingDraggedPiece = externalHiddenEdges.map((external, i) => external && !internalHiddenEdges[i]);
221
227
  } else {
222
228
  // For primitives, all hidden edges are external
223
- wasTouchingDraggedPiece = draggedPiece ?
224
- getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
229
+ wasTouchingDraggedPiece = draggedPiece ?
230
+ getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) :
225
231
  new Array(hiddenEdges.length).fill(false);
226
232
  }
227
-
233
+
228
234
  return generateEdgeStrokePaths(poly).map((strokePath, strokeIdx) => {
229
235
  // Show border if: 1) it wasn't hidden originally, OR 2) it was hidden due to touching the dragged piece
230
236
  const wasHiddenDueToDraggedPiece = wasTouchingDraggedPiece[strokeIdx] || false;
231
-
237
+
232
238
  // Special case: if this piece is being dragged and it's a composite, keep internal edges hidden
233
239
  let isHidden: boolean;
234
240
  if (isDragging && p.blueprintId.startsWith('comp:')) {
@@ -239,7 +245,7 @@ export default function BoardView(props: BoardViewProps) {
239
245
  // Normal logic for non-dragged pieces or primitives
240
246
  isHidden = isDragging ? false : ((hiddenEdges[strokeIdx] || false) && !wasHiddenDueToDraggedPiece);
241
247
  }
242
-
248
+
243
249
  return (
244
250
  <path
245
251
  key={`stroke-${idx}-${strokeIdx}`}
@@ -272,15 +278,52 @@ export default function BoardView(props: BoardViewProps) {
272
278
 
273
279
  {/* silhouettes (use pre-placed polys) */}
274
280
  {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
- );
281
+ const sectorCfg = controller.state.cfg.sectors.find((ss: any) => ss.id === s.id);
282
+
283
+ // Check if we should render decomposed primitives
284
+ if (showTangramDecomposition && sectorCfg?.silhouette.primitiveDecomposition) {
285
+ const primitiveDecomposition = sectorCfg.silhouette.primitiveDecomposition;
286
+
287
+ // Use same positioning logic as unified silhouette
288
+ const rect = rectForBand(layout, s, "silhouette", 1.0);
289
+ const rawPolys = primitiveDecomposition.map((primInfo: any) => primInfo.polygon);
290
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
291
+
292
+ return (
293
+ <g key={`sil-decomposed-${s.id}`} pointerEvents="none">
294
+ {placedPolys.map((scaledPoly, i) => (
295
+ <React.Fragment key={`prim-${i}`}>
296
+ {/* Fill path */}
297
+ <path
298
+ d={pathD(scaledPoly)}
299
+ fill={CONFIG.color.silhouetteMask}
300
+ opacity={CONFIG.opacity.silhouetteMask}
301
+ stroke="none"
302
+ />
303
+
304
+ {/* Full perimeter border */}
305
+ <path
306
+ d={pathD(scaledPoly)}
307
+ fill="none"
308
+ stroke={CONFIG.color.tangramDecomposition.stroke}
309
+ strokeWidth={CONFIG.size.stroke.tangramDecompositionPx}
310
+ />
311
+ </React.Fragment>
312
+ ))}
313
+ </g>
314
+ );
315
+ } else {
316
+ // Default: render unified silhouette
317
+ const placedPolys = placedSilBySector.get(s.id) ?? [];
318
+ if (!placedPolys.length) return null;
319
+ return (
320
+ <g key={`sil-${s.id}`} pointerEvents="none">
321
+ {placedPolys.map((poly, i) => (
322
+ <path key={i} d={pathD(poly)} fill={CONFIG.color.silhouetteMask} opacity={CONFIG.opacity.silhouetteMask} />
323
+ ))}
324
+ </g>
325
+ );
326
+ }
284
327
  })}
285
328
 
286
329
  {/* anchor grid */}
@@ -305,26 +348,65 @@ export default function BoardView(props: BoardViewProps) {
305
348
  );
306
349
  })}
307
350
 
351
+ {/* SVG filter for inverting icon colors to white */}
352
+ <defs>
353
+ <filter id="invert-to-white">
354
+ <feColorMatrix
355
+ type="matrix"
356
+ values="-1 0 0 0 1
357
+ 0 -1 0 0 1
358
+ 0 0 -1 0 1
359
+ 0 0 0 1 0"
360
+ />
361
+ </filter>
362
+ </defs>
363
+
308
364
  {/* center badge */}
309
365
  {(() => {
310
366
  const isPrep = controller.state.cfg.mode === "prep";
311
367
  const isSubmitEnabled = isPrep ? controller.isSubmitEnabled() : true;
312
368
  const isClickable = !draggingId && (!isPrep || isSubmitEnabled);
313
-
369
+ const [imageError, setImageError] = React.useState(false);
370
+
371
+ // Size the image to fill most of the badge (80% of diameter)
372
+ const iconSize = badgeR * 1.6;
373
+ const iconOffset = iconSize / 2;
374
+
314
375
  return (
315
- <g transform={`translate(${badgeCenter.x}, ${badgeCenter.y})`}
316
- style={{ cursor: isClickable ? "pointer" : "default" }}
376
+ <g transform={`translate(${badgeCenter.x}, ${badgeCenter.y})`}
377
+ style={{ cursor: isClickable ? "pointer" : "default" }}
317
378
  onPointerDown={isClickable ? onCenterBadgePointerDown : undefined}>
318
- <circle r={badgeR}
319
- fill={isSubmitEnabled ? CONFIG.color.blueprint.badgeFill : "#ccc"}
379
+ <circle r={badgeR}
380
+ fill={isSubmitEnabled ? CONFIG.color.blueprint.badgeFill : "#ccc"}
320
381
  opacity={isSubmitEnabled ? 1.0 : 0.5} />
321
- <text textAnchor="middle"
322
- dominantBaseline="middle"
323
- fontSize={CONFIG.size.badgeFontPx}
324
- fill={isSubmitEnabled ? CONFIG.color.blueprint.labelFill : "#888"}
325
- pointerEvents="none">
326
- {isPrep ? "Submit" : controller.state.blueprintView}
327
- </text>
382
+ {isPrep ? (
383
+ <text textAnchor="middle"
384
+ dominantBaseline="middle"
385
+ fontSize={CONFIG.size.badgeFontPx}
386
+ fill={isSubmitEnabled ? CONFIG.color.blueprint.labelFill : "#888"}
387
+ pointerEvents="none">
388
+ Submit
389
+ </text>
390
+ ) : imageError ? (
391
+ <text textAnchor="middle"
392
+ dominantBaseline="middle"
393
+ fontSize={CONFIG.size.badgeFontPx}
394
+ fill={CONFIG.color.blueprint.labelFill}
395
+ pointerEvents="none">
396
+ inventory
397
+ </text>
398
+ ) : (
399
+ <image
400
+ href={controller.state.blueprintView === "quickstash" ? lockedIcon : unlockedIcon}
401
+ x={-iconOffset}
402
+ y={-iconOffset}
403
+ width={iconSize}
404
+ height={iconSize}
405
+ pointerEvents="none"
406
+ onError={() => setImageError(true)}
407
+ filter="url(#invert-to-white)"
408
+ />
409
+ )}
328
410
  </g>
329
411
  );
330
412
  })()}
@@ -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;
@@ -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: true,
87
+ showBorders: false,
84
88
  hideTouchingBorders: true
85
89
  }
86
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 = {