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.
Files changed (36) hide show
  1. package/dist/construct/index.browser.js +146 -212
  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 +146 -212
  6. package/dist/construct/index.cjs.map +1 -1
  7. package/dist/construct/index.d.ts +24 -0
  8. package/dist/construct/index.js +146 -212
  9. package/dist/construct/index.js.map +1 -1
  10. package/dist/index.cjs +157 -213
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts +36 -0
  13. package/dist/index.js +157 -213
  14. package/dist/index.js.map +1 -1
  15. package/dist/prep/index.browser.js +132 -211
  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 +132 -211
  20. package/dist/prep/index.cjs.map +1 -1
  21. package/dist/prep/index.d.ts +12 -0
  22. package/dist/prep/index.js +132 -211
  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 +8 -4
  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
@@ -55,6 +55,18 @@ declare const info: {
55
55
  default: number;
56
56
  description: string;
57
57
  };
58
+ /** Whether to show tangram target shapes decomposed into individual primitives with borders */
59
+ show_tangram_decomposition: {
60
+ type: ParameterType;
61
+ default: boolean;
62
+ description: string;
63
+ };
64
+ /** HTML content to display above the gameboard as instructions */
65
+ instructions: {
66
+ type: ParameterType;
67
+ default: string;
68
+ description: string;
69
+ };
58
70
  /** Callback fired after each interaction (piece pickup + placedown) */
59
71
  onInteraction: {
60
72
  type: ParameterType;
@@ -163,6 +175,18 @@ declare class TangramConstructPlugin implements JsPsychPlugin<Info> {
163
175
  default: number;
164
176
  description: string;
165
177
  };
178
+ /** Whether to show tangram target shapes decomposed into individual primitives with borders */
179
+ show_tangram_decomposition: {
180
+ type: ParameterType;
181
+ default: boolean;
182
+ description: string;
183
+ };
184
+ /** HTML content to display above the gameboard as instructions */
185
+ instructions: {
186
+ type: ParameterType;
187
+ default: string;
188
+ description: string;
189
+ };
166
190
  /** Callback fired after each interaction (piece pickup + placedown) */
167
191
  onInteraction: {
168
192
  type: ParameterType;
@@ -14,7 +14,8 @@ const CONFIG = {
14
14
  anchors: { invalid: "#7dd3fc", valid: "#475569" },
15
15
  piece: { draggingFill: "#8e7cc3ff", validFill: "#8e7cc3ff", invalidFill: "#ef4444", invalidStroke: "#dc2626", selectedStroke: "#674ea7", allGreenStroke: "#86efac", borderStroke: "#674ea7" },
16
16
  ui: { light: "#60a5fa", dark: "#1d4ed8" },
17
- blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" }
17
+ blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" },
18
+ tangramDecomposition: { stroke: "#fef2cc" }
18
19
  },
19
20
  opacity: {
20
21
  blueprint: 0.4,
@@ -24,7 +25,7 @@ const CONFIG = {
24
25
  piece: { invalid: 0.35, dragging: 0.75, locked: 1, normal: 1 }
25
26
  },
26
27
  size: {
27
- stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2 },
28
+ stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2, tangramDecompositionPx: 1 },
28
29
  anchorRadiusPx: { valid: 1, invalid: 1 },
29
30
  badgeFontPx: 16,
30
31
  centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 }
@@ -32,6 +33,7 @@ const CONFIG = {
32
33
  layout: {
33
34
  grid: { stepPx: 20, unitPx: 40 },
34
35
  paddingPx: 1,
36
+ viewportScale: 0.8,
35
37
  constraints: {
36
38
  workspaceDiamAnchors: 10,
37
39
  // num anchors req'd to be on diagonal
@@ -43,7 +45,7 @@ const CONFIG = {
43
45
  },
44
46
  game: {
45
47
  snapRadiusPx: 15,
46
- showBorders: true,
48
+ showBorders: false,
47
49
  hideTouchingBorders: true
48
50
  }
49
51
  };
@@ -814,150 +816,11 @@ function rectForBand(layout, sector, band, pad = 0.85) {
814
816
  return { cx, cy, w, h };
815
817
  }
816
818
 
817
- function generateEdgeStrokePaths(poly) {
818
- const strokePaths = [];
819
- for (let i = 0; i < poly.length; i++) {
820
- const current = poly[i];
821
- const next = poly[(i + 1) % poly.length];
822
- if (current && next) {
823
- strokePaths.push(`M ${current.x} ${current.y} L ${next.x} ${next.y}`);
824
- }
825
- }
826
- return strokePaths;
827
- }
828
- function edgeToUnitSegments$1(start, end, gridSize) {
829
- const segments = [];
830
- const startGrid = {
831
- x: Math.round(start.x / gridSize),
832
- y: Math.round(start.y / gridSize)
833
- };
834
- const endGrid = {
835
- x: Math.round(end.x / gridSize),
836
- y: Math.round(end.y / gridSize)
837
- };
838
- const dx = endGrid.x - startGrid.x;
839
- const dy = endGrid.y - startGrid.y;
840
- if (dx === 0 && dy === 0) return [];
841
- const steps = Math.max(Math.abs(dx), Math.abs(dy));
842
- const stepX = dx / steps;
843
- const stepY = dy / steps;
844
- for (let i = 0; i < steps; i++) {
845
- const aX = Math.round(startGrid.x + i * stepX);
846
- const aY = Math.round(startGrid.y + i * stepY);
847
- const bX = Math.round(startGrid.x + (i + 1) * stepX);
848
- const bY = Math.round(startGrid.y + (i + 1) * stepY);
849
- segments.push({
850
- a: { x: aX, y: aY },
851
- b: { x: bX, y: bY }
852
- });
853
- }
854
- return segments;
855
- }
856
- function getHiddenEdgesForPolygon(piece, polyIndex, allPiecesInSector, getBlueprint, getPrimitive) {
857
- let blueprint;
858
- try {
859
- blueprint = getBlueprint(piece.blueprintId);
860
- } catch (error) {
861
- console.warn("getBlueprint failed in getHiddenEdgesForPolygon:", error);
862
- return [];
863
- }
864
- if (!blueprint?.shape) {
865
- return [];
866
- }
867
- const poly = blueprint.shape[polyIndex];
868
- if (!poly) return [];
869
- const gridSize = CONFIG.layout.grid.stepPx;
870
- const bb = boundsOfBlueprint(blueprint, getPrimitive);
871
- const ox = piece.pos.x - bb.min.x;
872
- const oy = piece.pos.y - bb.min.y;
873
- const translatedPoly = poly.map((vertex) => ({
874
- x: vertex.x + ox,
875
- y: vertex.y + oy
876
- }));
877
- const hiddenEdges = [];
878
- for (let i = 0; i < translatedPoly.length; i++) {
879
- const current = translatedPoly[i];
880
- const next = translatedPoly[(i + 1) % translatedPoly.length];
881
- if (!current || !next) {
882
- hiddenEdges.push(false);
883
- continue;
884
- }
885
- const edgeSegments = edgeToUnitSegments$1(current, next, gridSize);
886
- let isTouching = false;
887
- if (piece.blueprintId.startsWith("comp:")) {
888
- for (let otherPolyIndex = 0; otherPolyIndex < blueprint.shape.length; otherPolyIndex++) {
889
- if (otherPolyIndex === polyIndex) continue;
890
- const otherPoly = blueprint.shape[otherPolyIndex];
891
- if (!otherPoly) continue;
892
- const otherTranslatedPoly = otherPoly.map((vertex) => ({
893
- x: vertex.x + ox,
894
- y: vertex.y + oy
895
- }));
896
- for (let j = 0; j < otherTranslatedPoly.length; j++) {
897
- const otherCurrent = otherTranslatedPoly[j];
898
- const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
899
- if (!otherCurrent || !otherNext) continue;
900
- const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
901
- for (const edgeSeg of edgeSegments) {
902
- const isShared = otherEdgeSegments.some(
903
- (otherSeg) => (
904
- // Check if segments share the same endpoints (identical or reversed)
905
- edgeSeg.a.x === otherSeg.a.x && edgeSeg.a.y === otherSeg.a.y && edgeSeg.b.x === otherSeg.b.x && edgeSeg.b.y === otherSeg.b.y || edgeSeg.a.x === otherSeg.b.x && edgeSeg.a.y === otherSeg.b.y && edgeSeg.b.x === otherSeg.a.x && edgeSeg.b.y === otherSeg.a.y
906
- )
907
- );
908
- if (isShared) {
909
- isTouching = true;
910
- break;
911
- }
912
- }
913
- if (isTouching) break;
914
- }
915
- if (isTouching) break;
916
- }
917
- }
918
- if (!isTouching && CONFIG.game.hideTouchingBorders) {
919
- for (const otherPiece of allPiecesInSector) {
920
- if (otherPiece.id === piece.id) continue;
921
- const otherBlueprint = getBlueprint(otherPiece.blueprintId);
922
- if (!otherBlueprint?.shape) continue;
923
- const otherBb = boundsOfBlueprint(otherBlueprint, getPrimitive);
924
- const otherOx = otherPiece.pos.x - otherBb.min.x;
925
- const otherOy = otherPiece.pos.y - otherBb.min.y;
926
- for (const otherPoly of otherBlueprint.shape) {
927
- const otherTranslatedPoly = otherPoly.map((vertex) => ({
928
- x: vertex.x + otherOx,
929
- y: vertex.y + otherOy
930
- }));
931
- for (let j = 0; j < otherTranslatedPoly.length; j++) {
932
- const otherCurrent = otherTranslatedPoly[j];
933
- const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
934
- if (!otherCurrent || !otherNext) continue;
935
- const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
936
- for (const edgeSeg of edgeSegments) {
937
- const isShared = otherEdgeSegments.some(
938
- (otherSeg) => (
939
- // Check if segments share the same endpoints (identical or reversed)
940
- edgeSeg.a.x === otherSeg.a.x && edgeSeg.a.y === otherSeg.a.y && edgeSeg.b.x === otherSeg.b.x && edgeSeg.b.y === otherSeg.b.y || edgeSeg.a.x === otherSeg.b.x && edgeSeg.a.y === otherSeg.b.y && edgeSeg.b.x === otherSeg.a.x && edgeSeg.b.y === otherSeg.a.y
941
- )
942
- );
943
- if (isShared) {
944
- isTouching = true;
945
- break;
946
- }
947
- }
948
- if (isTouching) break;
949
- }
950
- if (isTouching) break;
951
- }
952
- if (isTouching) break;
953
- }
954
- }
955
- hiddenEdges.push(isTouching);
956
- }
957
- return hiddenEdges;
819
+ function shouldShowBorders() {
820
+ return CONFIG.game.showBorders;
958
821
  }
959
822
  function shouldUseSelectiveBorders(blueprintId) {
960
- return (CONFIG.game.hideTouchingBorders);
823
+ return CONFIG.game.showBorders;
961
824
  }
962
825
 
963
826
  function pathD(poly) {
@@ -975,11 +838,13 @@ function BoardView(props) {
975
838
  placedSilBySector,
976
839
  anchorDots,
977
840
  pieces,
841
+ scaleS,
978
842
  clickMode,
979
843
  draggingId,
980
844
  selectedPieceId,
981
845
  dragInvalid,
982
846
  lockedPieceId,
847
+ showTangramDecomposition,
983
848
  svgRef,
984
849
  setPieceRef,
985
850
  onPiecePointerDown,
@@ -1045,7 +910,7 @@ function BoardView(props) {
1045
910
  onPointerDown: (e) => {
1046
911
  onRootPointerDown(e);
1047
912
  },
1048
- style: { background: "#fff", touchAction: "none", userSelect: "none" }
913
+ style: { background: "#f5f5f5", touchAction: "none", userSelect: "none" }
1049
914
  },
1050
915
  layout.sectors.map((s, i) => {
1051
916
  const done = !!controller.state.sectors[s.id].completedAt;
@@ -1065,11 +930,12 @@ function BoardView(props) {
1065
930
  const isDragging = draggingId === p.id;
1066
931
  const locked = p.sectorId && controller.isSectorCompleted(p.sectorId);
1067
932
  const isConnectivityLocked = lockedPieceId === p.id;
1068
- const isSelected = selectedPieceId === p.id;
933
+ selectedPieceId === p.id;
1069
934
  const isCarriedInvalid = isDragging && dragInvalid;
1070
935
  const translateX = p.x - bb.min.x;
1071
936
  const translateY = p.y - bb.min.y;
1072
937
  return /* @__PURE__ */ React.createElement("g", { key: p.id, ref: setPieceRef(p.id), transform: `translate(${translateX}, ${translateY})`, style: { cursor: locked ? "default" : clickMode ? "pointer" : "grab" }, pointerEvents: clickMode && isDragging ? "none" : "auto" }, ("shape" in bp ? bp.shape : []).map((poly, idx) => {
938
+ const showBorders = shouldShowBorders();
1073
939
  shouldUseSelectiveBorders(p.blueprintId);
1074
940
  return /* @__PURE__ */ React.createElement(React.Fragment, { key: idx }, /* @__PURE__ */ React.createElement(
1075
941
  "path",
@@ -1080,51 +946,38 @@ function BoardView(props) {
1080
946
  stroke: "none",
1081
947
  onPointerDown: (e) => onPiecePointerDown(e, p)
1082
948
  }
1083
- ), ((
1084
- // For pieces with selective borders: render individual edge strokes with edge detection
1085
- (() => {
1086
- const allPiecesInSector = pieces.filter((piece) => piece.sectorId === p.sectorId);
1087
- const pieceAsPiece = { ...p, pos: { x: p.x, y: p.y } };
1088
- const allPiecesAsPieces = allPiecesInSector.map((piece) => ({ ...piece, pos: { x: piece.x, y: piece.y } }));
1089
- const hiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, allPiecesAsPieces, (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1090
- const draggedPiece = draggingId ? allPiecesInSector.find((piece) => piece.id === draggingId) : null;
1091
- let wasTouchingDraggedPiece;
1092
- if (p.blueprintId.startsWith("comp:")) {
1093
- const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1094
- const externalHiddenEdges = draggedPiece ? getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) : new Array(hiddenEdges.length).fill(false);
1095
- wasTouchingDraggedPiece = externalHiddenEdges.map((external, i) => external && !internalHiddenEdges[i]);
1096
- } else {
1097
- wasTouchingDraggedPiece = draggedPiece ? getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) : new Array(hiddenEdges.length).fill(false);
1098
- }
1099
- return generateEdgeStrokePaths(poly).map((strokePath, strokeIdx) => {
1100
- const wasHiddenDueToDraggedPiece = wasTouchingDraggedPiece[strokeIdx] || false;
1101
- let isHidden;
1102
- if (isDragging && p.blueprintId.startsWith("comp:")) {
1103
- const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1104
- isHidden = internalHiddenEdges[strokeIdx] || false;
1105
- } else {
1106
- isHidden = isDragging ? false : (hiddenEdges[strokeIdx] || false) && !wasHiddenDueToDraggedPiece;
1107
- }
1108
- return /* @__PURE__ */ React.createElement(
1109
- "path",
1110
- {
1111
- key: `stroke-${idx}-${strokeIdx}`,
1112
- d: strokePath,
1113
- fill: "none",
1114
- stroke: isHidden ? "none" : isConnectivityLocked ? CONFIG.color.piece.invalidStroke : isCarriedInvalid ? CONFIG.color.piece.invalidStroke : isSelected || isDragging ? CONFIG.color.piece.selectedStroke : CONFIG.color.piece.borderStroke,
1115
- strokeWidth: isHidden ? 0 : isSelected || isDragging ? CONFIG.size.stroke.pieceSelectedPx : CONFIG.size.stroke.pieceBorderPx,
1116
- onPointerDown: (e) => onPiecePointerDown(e, p)
1117
- }
1118
- );
1119
- });
1120
- })()
1121
- ) ));
949
+ ), showBorders);
1122
950
  }));
1123
951
  }),
1124
952
  layout.sectors.map((s) => {
1125
- const placedPolys = placedSilBySector.get(s.id) ?? [];
1126
- if (!placedPolys.length) return null;
1127
- return /* @__PURE__ */ React.createElement("g", { key: `sil-${s.id}`, pointerEvents: "none" }, placedPolys.map((poly, i) => /* @__PURE__ */ React.createElement("path", { key: i, d: pathD(poly), fill: CONFIG.color.silhouetteMask, opacity: CONFIG.opacity.silhouetteMask })));
953
+ const sectorCfg = controller.state.cfg.sectors.find((ss) => ss.id === s.id);
954
+ if (showTangramDecomposition && sectorCfg?.silhouette.primitiveDecomposition) {
955
+ const primitiveDecomposition = sectorCfg.silhouette.primitiveDecomposition;
956
+ const rect = rectForBand(layout, s, "silhouette", 1);
957
+ const rawPolys = primitiveDecomposition.map((primInfo) => primInfo.polygon);
958
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
959
+ return /* @__PURE__ */ React.createElement("g", { key: `sil-decomposed-${s.id}`, pointerEvents: "none" }, placedPolys.map((scaledPoly, i) => /* @__PURE__ */ React.createElement(React.Fragment, { key: `prim-${i}` }, /* @__PURE__ */ React.createElement(
960
+ "path",
961
+ {
962
+ d: pathD(scaledPoly),
963
+ fill: CONFIG.color.silhouetteMask,
964
+ opacity: CONFIG.opacity.silhouetteMask,
965
+ stroke: "none"
966
+ }
967
+ ), /* @__PURE__ */ React.createElement(
968
+ "path",
969
+ {
970
+ d: pathD(scaledPoly),
971
+ fill: "none",
972
+ stroke: CONFIG.color.tangramDecomposition.stroke,
973
+ strokeWidth: CONFIG.size.stroke.tangramDecompositionPx
974
+ }
975
+ ))));
976
+ } else {
977
+ const placedPolys = placedSilBySector.get(s.id) ?? [];
978
+ if (!placedPolys.length) return null;
979
+ return /* @__PURE__ */ React.createElement("g", { key: `sil-${s.id}`, pointerEvents: "none" }, placedPolys.map((poly, i) => /* @__PURE__ */ React.createElement("path", { key: i, d: pathD(poly), fill: CONFIG.color.silhouetteMask, opacity: CONFIG.opacity.silhouetteMask })));
980
+ }
1128
981
  }),
1129
982
  anchorDots.map(({ sectorId, valid, invalid }) => {
1130
983
  const isInnerRing = sectorId === "inner-ring";
@@ -2586,7 +2439,7 @@ function useClickController(controller, layout, pieces, clickMode, draggingId, s
2586
2439
  }
2587
2440
 
2588
2441
  class InteractionTracker {
2589
- constructor(controller, callbacks, trialId, gameId) {
2442
+ constructor(controller, callbacks, trialParams) {
2590
2443
  this.gridStep = CONFIG.layout.grid.stepPx;
2591
2444
  // Interaction state
2592
2445
  this.interactionIndex = 0;
@@ -2603,8 +2456,7 @@ class InteractionTracker {
2603
2456
  this.createdMacros = [];
2604
2457
  this.controller = controller;
2605
2458
  this.callbacks = callbacks;
2606
- this.trialId = trialId || v4();
2607
- this.gameId = gameId || v4();
2459
+ this.trialParams = trialParams;
2608
2460
  this.trialStartTime = Date.now();
2609
2461
  this.controller.setTrackingCallbacks({
2610
2462
  onSectorCompleted: (sectorId) => this.recordSectorCompletion(sectorId)
@@ -2652,8 +2504,6 @@ class InteractionTracker {
2652
2504
  const event = {
2653
2505
  // Metadata
2654
2506
  interactionId: v4(),
2655
- trialId: this.trialId,
2656
- gameId: this.gameId,
2657
2507
  interactionIndex: this.interactionIndex++,
2658
2508
  // Interaction type
2659
2509
  interactionType,
@@ -2770,13 +2620,10 @@ class InteractionTracker {
2770
2620
  }));
2771
2621
  const data = {
2772
2622
  trialType: "construction",
2773
- trialId: this.trialId,
2774
- gameId: this.gameId,
2775
- trialNum: 0,
2776
- // TODO: Plugin should provide this
2777
2623
  trialStartTime: this.trialStartTime,
2778
2624
  trialEndTime,
2779
2625
  totalDuration,
2626
+ trialParams: this.trialParams,
2780
2627
  endReason,
2781
2628
  completionTimes: this.completionTimes,
2782
2629
  finalBlueprintState,
@@ -2795,13 +2642,10 @@ class InteractionTracker {
2795
2642
  }));
2796
2643
  const data = {
2797
2644
  trialType: "prep",
2798
- trialId: this.trialId,
2799
- gameId: this.gameId,
2800
- trialNum: 0,
2801
- // TODO: Plugin should provide this
2802
2645
  trialStartTime: this.trialStartTime,
2803
2646
  trialEndTime,
2804
2647
  totalDuration,
2648
+ trialParams: this.trialParams,
2805
2649
  endReason: "submit",
2806
2650
  createdMacros: finalMacros,
2807
2651
  quickstashMacros,
@@ -3059,6 +2903,9 @@ function GameBoard(props) {
3059
2903
  maxQuickstashSlots,
3060
2904
  maxCompositeSize,
3061
2905
  mode,
2906
+ showTangramDecomposition,
2907
+ instructions,
2908
+ trialParams,
3062
2909
  width: _width,
3063
2910
  height: _height,
3064
2911
  onSectorComplete,
@@ -3068,6 +2915,7 @@ function GameBoard(props) {
3068
2915
  onTrialEnd,
3069
2916
  onControllerReady
3070
2917
  } = props;
2918
+ const [timeRemaining, setTimeRemaining] = React.useState(timeLimitMs > 0 ? Math.floor(timeLimitMs / 1e3) : 0);
3071
2919
  const controller = React.useMemo(() => {
3072
2920
  const gameConfig = {
3073
2921
  n: sectors.length,
@@ -3094,8 +2942,8 @@ function GameBoard(props) {
3094
2942
  const callbacks = {};
3095
2943
  if (onInteraction) callbacks.onInteraction = onInteraction;
3096
2944
  if (onTrialEnd) callbacks.onTrialEnd = onTrialEnd;
3097
- return new InteractionTracker(controller, callbacks);
3098
- }, [controller, onInteraction, onTrialEnd]);
2945
+ return new InteractionTracker(controller, callbacks, trialParams);
2946
+ }, [controller, onInteraction, onTrialEnd, trialParams]);
3099
2947
  React.useEffect(() => {
3100
2948
  if (onControllerReady) {
3101
2949
  onControllerReady(controller);
@@ -3175,22 +3023,22 @@ function GameBoard(props) {
3175
3023
  }), [handleSectorComplete, onPiecePlace, onPieceRemove]);
3176
3024
  const getGameboardStyle = () => {
3177
3025
  const baseStyle = {
3178
- margin: "0 auto",
3026
+ margin: "10px",
3179
3027
  display: "flex",
3180
3028
  alignItems: "center",
3181
3029
  justifyContent: "center",
3182
3030
  position: "relative"
3183
3031
  };
3184
3032
  if (layoutMode === "circle") {
3185
- const size = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96);
3033
+ const size = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale);
3186
3034
  return {
3187
3035
  ...baseStyle,
3188
3036
  width: `${size}px`,
3189
3037
  height: `${size}px`
3190
3038
  };
3191
3039
  } else {
3192
- const maxWidth = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96 * 2);
3193
- const maxHeight = Math.min(window.innerWidth * 0.96 / 2, window.innerHeight * 0.96);
3040
+ const maxWidth = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale * 2);
3041
+ const maxHeight = Math.min(window.innerWidth * CONFIG.layout.viewportScale / 2, window.innerHeight * CONFIG.layout.viewportScale);
3194
3042
  return {
3195
3043
  ...baseStyle,
3196
3044
  width: `${maxWidth}px`,
@@ -3359,7 +3207,68 @@ function GameBoard(props) {
3359
3207
  force();
3360
3208
  e.stopPropagation();
3361
3209
  };
3362
- return /* @__PURE__ */ React.createElement("div", { className: "tangram-gameboard", style: getGameboardStyle() }, /* @__PURE__ */ React.createElement(
3210
+ React.useEffect(() => {
3211
+ if (timeLimitMs === 0) return;
3212
+ const interval = setInterval(() => {
3213
+ setTimeRemaining((prev) => {
3214
+ if (prev <= 1) {
3215
+ clearInterval(interval);
3216
+ return 0;
3217
+ }
3218
+ return prev - 1;
3219
+ });
3220
+ }, 1e3);
3221
+ return () => clearInterval(interval);
3222
+ }, [timeLimitMs]);
3223
+ const formatTime = (seconds) => {
3224
+ const mins = Math.floor(seconds / 60);
3225
+ const secs = Math.floor(seconds % 60);
3226
+ return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
3227
+ };
3228
+ const containerStyle = {
3229
+ display: "flex",
3230
+ flexDirection: "column",
3231
+ width: "100%"
3232
+ // minHeight: '100vh'
3233
+ };
3234
+ const headerStyle = {
3235
+ display: "flex",
3236
+ flexDirection: "row",
3237
+ justifyContent: "space-between",
3238
+ alignItems: "center",
3239
+ padding: "20px",
3240
+ background: "#f5f5f5",
3241
+ flex: "0 0 auto"
3242
+ };
3243
+ const instructionsStyle = {
3244
+ flexGrow: 1,
3245
+ fontSize: "20px",
3246
+ lineHeight: 1.5,
3247
+ marginRight: "20px"
3248
+ };
3249
+ const timerStyle = {
3250
+ fontSize: "24px",
3251
+ fontWeight: "bold",
3252
+ fontFamily: "monospace",
3253
+ color: "#333",
3254
+ minWidth: "80px",
3255
+ textAlign: "right"
3256
+ };
3257
+ const gameboardWrapperStyle = {
3258
+ flex: "1 1 auto",
3259
+ display: "flex",
3260
+ alignItems: "center",
3261
+ justifyContent: "center",
3262
+ overflow: "hidden"
3263
+ };
3264
+ return /* @__PURE__ */ React.createElement("div", { className: "tangram-trial-container", style: containerStyle }, (instructions || timeLimitMs > 0) && /* @__PURE__ */ React.createElement("div", { className: "tangram-header", style: headerStyle }, instructions && /* @__PURE__ */ React.createElement(
3265
+ "div",
3266
+ {
3267
+ className: "tangram-instructions",
3268
+ style: instructionsStyle,
3269
+ dangerouslySetInnerHTML: { __html: instructions }
3270
+ }
3271
+ ), timeLimitMs > 0 && /* @__PURE__ */ React.createElement("div", { className: "tangram-timer", style: timerStyle }, formatTime(timeRemaining))), /* @__PURE__ */ React.createElement("div", { className: "tangram-gameboard-wrapper", style: gameboardWrapperStyle }, /* @__PURE__ */ React.createElement("div", { className: "tangram-gameboard", style: getGameboardStyle() }, /* @__PURE__ */ React.createElement(
3363
3272
  BoardView,
3364
3273
  {
3365
3274
  controller,
@@ -3385,9 +3294,11 @@ function GameBoard(props) {
3385
3294
  onPointerMove,
3386
3295
  onPointerUp,
3387
3296
  onCenterBadgePointerDown,
3297
+ showTangramDecomposition: showTangramDecomposition ?? false,
3298
+ scaleS,
3388
3299
  ...eventCallbacks
3389
3300
  }
3390
- ));
3301
+ ))));
3391
3302
  }
3392
3303
 
3393
3304
  const U = 40;
@@ -3555,17 +3466,22 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3555
3466
  const isCanonical = CANON.has(tanName);
3556
3467
  return isCanonical;
3557
3468
  });
3558
- const mask = filteredTans.map((tan, tanIndex) => {
3469
+ const mask = filteredTans.map((tan) => {
3559
3470
  const polygon = tan.vertices.map(([x, y]) => ({ x: x ?? 0, y: -(y ?? 0) }));
3560
3471
  return polygon;
3561
3472
  });
3473
+ const primitiveDecomposition = filteredTans.map((tan) => ({
3474
+ kind: tan.name ?? tan.kind,
3475
+ polygon: tan.vertices.map(([x, y]) => ({ x: x ?? 0, y: -(y ?? 0) }))
3476
+ }));
3562
3477
  const sectorId = `sector${index}`;
3563
3478
  const sector = {
3564
3479
  id: sectorId,
3565
3480
  tangramId: tangramSpec.tangramID,
3566
3481
  silhouette: {
3567
3482
  id: sectorId,
3568
- mask
3483
+ mask,
3484
+ primitiveDecomposition
3569
3485
  }
3570
3486
  };
3571
3487
  return sector;
@@ -3584,6 +3500,7 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3584
3500
  quickstash = params.quickstash_macros;
3585
3501
  }
3586
3502
  }
3503
+ const { onInteraction, onTrialEnd, ...trialParams } = params;
3587
3504
  const gameBoardProps = {
3588
3505
  sectors,
3589
3506
  quickstash,
@@ -3594,6 +3511,9 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3594
3511
  timeLimitMs: params.time_limit_ms,
3595
3512
  maxQuickstashSlots: CONFIG.layout.defaults.maxQuickstashSlots,
3596
3513
  mode: "construction",
3514
+ showTangramDecomposition: params.show_tangram_decomposition ?? false,
3515
+ trialParams,
3516
+ ...params.instructions && { instructions: params.instructions },
3597
3517
  ...params.onInteraction && { onInteraction: params.onInteraction },
3598
3518
  ...params.onTrialEnd && { onTrialEnd: params.onTrialEnd }
3599
3519
  };
@@ -3657,6 +3577,18 @@ const info = {
3657
3577
  default: 18,
3658
3578
  description: "Snap radius for anchor-based piece placement"
3659
3579
  },
3580
+ /** Whether to show tangram target shapes decomposed into individual primitives with borders */
3581
+ show_tangram_decomposition: {
3582
+ type: ParameterType.BOOL,
3583
+ default: false,
3584
+ description: "Whether to show tangram target shapes decomposed into individual primitives with borders"
3585
+ },
3586
+ /** HTML content to display above the gameboard as instructions */
3587
+ instructions: {
3588
+ type: ParameterType.STRING,
3589
+ default: "",
3590
+ description: "HTML content to display above the gameboard as instructions"
3591
+ },
3660
3592
  /** Callback fired after each interaction (piece pickup + placedown) */
3661
3593
  onInteraction: {
3662
3594
  type: ParameterType.FUNCTION,
@@ -3730,6 +3662,8 @@ class TangramConstructPlugin {
3730
3662
  input: trial.input,
3731
3663
  layout: trial.layout,
3732
3664
  time_limit_ms: trial.time_limit_ms,
3665
+ show_tangram_decomposition: trial.show_tangram_decomposition,
3666
+ instructions: trial.instructions,
3733
3667
  onInteraction: trial.onInteraction,
3734
3668
  onTrialEnd: wrappedOnTrialEnd
3735
3669
  };