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
package/dist/index.cjs CHANGED
@@ -16,7 +16,8 @@ const CONFIG = {
16
16
  anchors: { invalid: "#7dd3fc", valid: "#475569" },
17
17
  piece: { draggingFill: "#8e7cc3ff", validFill: "#8e7cc3ff", invalidFill: "#ef4444", invalidStroke: "#dc2626", selectedStroke: "#674ea7", allGreenStroke: "#86efac", borderStroke: "#674ea7" },
18
18
  ui: { light: "#60a5fa", dark: "#1d4ed8" },
19
- blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" }
19
+ blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" },
20
+ tangramDecomposition: { stroke: "#fef2cc" }
20
21
  },
21
22
  opacity: {
22
23
  blueprint: 0.4,
@@ -26,7 +27,7 @@ const CONFIG = {
26
27
  piece: { invalid: 0.35, dragging: 0.75, locked: 1, normal: 1 }
27
28
  },
28
29
  size: {
29
- stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2 },
30
+ stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2, tangramDecompositionPx: 1 },
30
31
  anchorRadiusPx: { valid: 1, invalid: 1 },
31
32
  badgeFontPx: 16,
32
33
  centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 }
@@ -34,6 +35,7 @@ const CONFIG = {
34
35
  layout: {
35
36
  grid: { stepPx: 20, unitPx: 40 },
36
37
  paddingPx: 1,
38
+ viewportScale: 0.8,
37
39
  constraints: {
38
40
  workspaceDiamAnchors: 10,
39
41
  // num anchors req'd to be on diagonal
@@ -45,7 +47,7 @@ const CONFIG = {
45
47
  },
46
48
  game: {
47
49
  snapRadiusPx: 15,
48
- showBorders: true,
50
+ showBorders: false,
49
51
  hideTouchingBorders: true
50
52
  }
51
53
  };
@@ -816,150 +818,11 @@ function rectForBand(layout, sector, band, pad = 0.85) {
816
818
  return { cx, cy, w, h };
817
819
  }
818
820
 
819
- function generateEdgeStrokePaths(poly) {
820
- const strokePaths = [];
821
- for (let i = 0; i < poly.length; i++) {
822
- const current = poly[i];
823
- const next = poly[(i + 1) % poly.length];
824
- if (current && next) {
825
- strokePaths.push(`M ${current.x} ${current.y} L ${next.x} ${next.y}`);
826
- }
827
- }
828
- return strokePaths;
829
- }
830
- function edgeToUnitSegments$1(start, end, gridSize) {
831
- const segments = [];
832
- const startGrid = {
833
- x: Math.round(start.x / gridSize),
834
- y: Math.round(start.y / gridSize)
835
- };
836
- const endGrid = {
837
- x: Math.round(end.x / gridSize),
838
- y: Math.round(end.y / gridSize)
839
- };
840
- const dx = endGrid.x - startGrid.x;
841
- const dy = endGrid.y - startGrid.y;
842
- if (dx === 0 && dy === 0) return [];
843
- const steps = Math.max(Math.abs(dx), Math.abs(dy));
844
- const stepX = dx / steps;
845
- const stepY = dy / steps;
846
- for (let i = 0; i < steps; i++) {
847
- const aX = Math.round(startGrid.x + i * stepX);
848
- const aY = Math.round(startGrid.y + i * stepY);
849
- const bX = Math.round(startGrid.x + (i + 1) * stepX);
850
- const bY = Math.round(startGrid.y + (i + 1) * stepY);
851
- segments.push({
852
- a: { x: aX, y: aY },
853
- b: { x: bX, y: bY }
854
- });
855
- }
856
- return segments;
857
- }
858
- function getHiddenEdgesForPolygon(piece, polyIndex, allPiecesInSector, getBlueprint, getPrimitive) {
859
- let blueprint;
860
- try {
861
- blueprint = getBlueprint(piece.blueprintId);
862
- } catch (error) {
863
- console.warn("getBlueprint failed in getHiddenEdgesForPolygon:", error);
864
- return [];
865
- }
866
- if (!blueprint?.shape) {
867
- return [];
868
- }
869
- const poly = blueprint.shape[polyIndex];
870
- if (!poly) return [];
871
- const gridSize = CONFIG.layout.grid.stepPx;
872
- const bb = boundsOfBlueprint(blueprint, getPrimitive);
873
- const ox = piece.pos.x - bb.min.x;
874
- const oy = piece.pos.y - bb.min.y;
875
- const translatedPoly = poly.map((vertex) => ({
876
- x: vertex.x + ox,
877
- y: vertex.y + oy
878
- }));
879
- const hiddenEdges = [];
880
- for (let i = 0; i < translatedPoly.length; i++) {
881
- const current = translatedPoly[i];
882
- const next = translatedPoly[(i + 1) % translatedPoly.length];
883
- if (!current || !next) {
884
- hiddenEdges.push(false);
885
- continue;
886
- }
887
- const edgeSegments = edgeToUnitSegments$1(current, next, gridSize);
888
- let isTouching = false;
889
- if (piece.blueprintId.startsWith("comp:")) {
890
- for (let otherPolyIndex = 0; otherPolyIndex < blueprint.shape.length; otherPolyIndex++) {
891
- if (otherPolyIndex === polyIndex) continue;
892
- const otherPoly = blueprint.shape[otherPolyIndex];
893
- if (!otherPoly) continue;
894
- const otherTranslatedPoly = otherPoly.map((vertex) => ({
895
- x: vertex.x + ox,
896
- y: vertex.y + oy
897
- }));
898
- for (let j = 0; j < otherTranslatedPoly.length; j++) {
899
- const otherCurrent = otherTranslatedPoly[j];
900
- const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
901
- if (!otherCurrent || !otherNext) continue;
902
- const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
903
- for (const edgeSeg of edgeSegments) {
904
- const isShared = otherEdgeSegments.some(
905
- (otherSeg) => (
906
- // Check if segments share the same endpoints (identical or reversed)
907
- 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
908
- )
909
- );
910
- if (isShared) {
911
- isTouching = true;
912
- break;
913
- }
914
- }
915
- if (isTouching) break;
916
- }
917
- if (isTouching) break;
918
- }
919
- }
920
- if (!isTouching && CONFIG.game.hideTouchingBorders) {
921
- for (const otherPiece of allPiecesInSector) {
922
- if (otherPiece.id === piece.id) continue;
923
- const otherBlueprint = getBlueprint(otherPiece.blueprintId);
924
- if (!otherBlueprint?.shape) continue;
925
- const otherBb = boundsOfBlueprint(otherBlueprint, getPrimitive);
926
- const otherOx = otherPiece.pos.x - otherBb.min.x;
927
- const otherOy = otherPiece.pos.y - otherBb.min.y;
928
- for (const otherPoly of otherBlueprint.shape) {
929
- const otherTranslatedPoly = otherPoly.map((vertex) => ({
930
- x: vertex.x + otherOx,
931
- y: vertex.y + otherOy
932
- }));
933
- for (let j = 0; j < otherTranslatedPoly.length; j++) {
934
- const otherCurrent = otherTranslatedPoly[j];
935
- const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
936
- if (!otherCurrent || !otherNext) continue;
937
- const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
938
- for (const edgeSeg of edgeSegments) {
939
- const isShared = otherEdgeSegments.some(
940
- (otherSeg) => (
941
- // Check if segments share the same endpoints (identical or reversed)
942
- 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
943
- )
944
- );
945
- if (isShared) {
946
- isTouching = true;
947
- break;
948
- }
949
- }
950
- if (isTouching) break;
951
- }
952
- if (isTouching) break;
953
- }
954
- if (isTouching) break;
955
- }
956
- }
957
- hiddenEdges.push(isTouching);
958
- }
959
- return hiddenEdges;
821
+ function shouldShowBorders() {
822
+ return CONFIG.game.showBorders;
960
823
  }
961
824
  function shouldUseSelectiveBorders(blueprintId) {
962
- return (CONFIG.game.hideTouchingBorders);
825
+ return CONFIG.game.showBorders;
963
826
  }
964
827
 
965
828
  function pathD(poly) {
@@ -977,11 +840,13 @@ function BoardView(props) {
977
840
  placedSilBySector,
978
841
  anchorDots,
979
842
  pieces,
843
+ scaleS,
980
844
  clickMode,
981
845
  draggingId,
982
846
  selectedPieceId,
983
847
  dragInvalid,
984
848
  lockedPieceId,
849
+ showTangramDecomposition,
985
850
  svgRef,
986
851
  setPieceRef,
987
852
  onPiecePointerDown,
@@ -1047,7 +912,7 @@ function BoardView(props) {
1047
912
  onPointerDown: (e) => {
1048
913
  onRootPointerDown(e);
1049
914
  },
1050
- style: { background: "#fff", touchAction: "none", userSelect: "none" }
915
+ style: { background: "#f5f5f5", touchAction: "none", userSelect: "none" }
1051
916
  },
1052
917
  layout.sectors.map((s, i) => {
1053
918
  const done = !!controller.state.sectors[s.id].completedAt;
@@ -1067,11 +932,12 @@ function BoardView(props) {
1067
932
  const isDragging = draggingId === p.id;
1068
933
  const locked = p.sectorId && controller.isSectorCompleted(p.sectorId);
1069
934
  const isConnectivityLocked = lockedPieceId === p.id;
1070
- const isSelected = selectedPieceId === p.id;
935
+ selectedPieceId === p.id;
1071
936
  const isCarriedInvalid = isDragging && dragInvalid;
1072
937
  const translateX = p.x - bb.min.x;
1073
938
  const translateY = p.y - bb.min.y;
1074
939
  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) => {
940
+ const showBorders = shouldShowBorders();
1075
941
  shouldUseSelectiveBorders(p.blueprintId);
1076
942
  return /* @__PURE__ */ React.createElement(React.Fragment, { key: idx }, /* @__PURE__ */ React.createElement(
1077
943
  "path",
@@ -1082,51 +948,38 @@ function BoardView(props) {
1082
948
  stroke: "none",
1083
949
  onPointerDown: (e) => onPiecePointerDown(e, p)
1084
950
  }
1085
- ), ((
1086
- // For pieces with selective borders: render individual edge strokes with edge detection
1087
- (() => {
1088
- const allPiecesInSector = pieces.filter((piece) => piece.sectorId === p.sectorId);
1089
- const pieceAsPiece = { ...p, pos: { x: p.x, y: p.y } };
1090
- const allPiecesAsPieces = allPiecesInSector.map((piece) => ({ ...piece, pos: { x: piece.x, y: piece.y } }));
1091
- const hiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, allPiecesAsPieces, (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1092
- const draggedPiece = draggingId ? allPiecesInSector.find((piece) => piece.id === draggingId) : null;
1093
- let wasTouchingDraggedPiece;
1094
- if (p.blueprintId.startsWith("comp:")) {
1095
- const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1096
- 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);
1097
- wasTouchingDraggedPiece = externalHiddenEdges.map((external, i) => external && !internalHiddenEdges[i]);
1098
- } else {
1099
- 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);
1100
- }
1101
- return generateEdgeStrokePaths(poly).map((strokePath, strokeIdx) => {
1102
- const wasHiddenDueToDraggedPiece = wasTouchingDraggedPiece[strokeIdx] || false;
1103
- let isHidden;
1104
- if (isDragging && p.blueprintId.startsWith("comp:")) {
1105
- const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1106
- isHidden = internalHiddenEdges[strokeIdx] || false;
1107
- } else {
1108
- isHidden = isDragging ? false : (hiddenEdges[strokeIdx] || false) && !wasHiddenDueToDraggedPiece;
1109
- }
1110
- return /* @__PURE__ */ React.createElement(
1111
- "path",
1112
- {
1113
- key: `stroke-${idx}-${strokeIdx}`,
1114
- d: strokePath,
1115
- fill: "none",
1116
- stroke: isHidden ? "none" : isConnectivityLocked ? CONFIG.color.piece.invalidStroke : isCarriedInvalid ? CONFIG.color.piece.invalidStroke : isSelected || isDragging ? CONFIG.color.piece.selectedStroke : CONFIG.color.piece.borderStroke,
1117
- strokeWidth: isHidden ? 0 : isSelected || isDragging ? CONFIG.size.stroke.pieceSelectedPx : CONFIG.size.stroke.pieceBorderPx,
1118
- onPointerDown: (e) => onPiecePointerDown(e, p)
1119
- }
1120
- );
1121
- });
1122
- })()
1123
- ) ));
951
+ ), showBorders);
1124
952
  }));
1125
953
  }),
1126
954
  layout.sectors.map((s) => {
1127
- const placedPolys = placedSilBySector.get(s.id) ?? [];
1128
- if (!placedPolys.length) return null;
1129
- 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 })));
955
+ const sectorCfg = controller.state.cfg.sectors.find((ss) => ss.id === s.id);
956
+ if (showTangramDecomposition && sectorCfg?.silhouette.primitiveDecomposition) {
957
+ const primitiveDecomposition = sectorCfg.silhouette.primitiveDecomposition;
958
+ const rect = rectForBand(layout, s, "silhouette", 1);
959
+ const rawPolys = primitiveDecomposition.map((primInfo) => primInfo.polygon);
960
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
961
+ 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(
962
+ "path",
963
+ {
964
+ d: pathD(scaledPoly),
965
+ fill: CONFIG.color.silhouetteMask,
966
+ opacity: CONFIG.opacity.silhouetteMask,
967
+ stroke: "none"
968
+ }
969
+ ), /* @__PURE__ */ React.createElement(
970
+ "path",
971
+ {
972
+ d: pathD(scaledPoly),
973
+ fill: "none",
974
+ stroke: CONFIG.color.tangramDecomposition.stroke,
975
+ strokeWidth: CONFIG.size.stroke.tangramDecompositionPx
976
+ }
977
+ ))));
978
+ } else {
979
+ const placedPolys = placedSilBySector.get(s.id) ?? [];
980
+ if (!placedPolys.length) return null;
981
+ 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 })));
982
+ }
1130
983
  }),
1131
984
  anchorDots.map(({ sectorId, valid, invalid }) => {
1132
985
  const isInnerRing = sectorId === "inner-ring";
@@ -2588,7 +2441,7 @@ function useClickController(controller, layout, pieces, clickMode, draggingId, s
2588
2441
  }
2589
2442
 
2590
2443
  class InteractionTracker {
2591
- constructor(controller, callbacks, trialId, gameId) {
2444
+ constructor(controller, callbacks, trialParams) {
2592
2445
  this.gridStep = CONFIG.layout.grid.stepPx;
2593
2446
  // Interaction state
2594
2447
  this.interactionIndex = 0;
@@ -2605,8 +2458,7 @@ class InteractionTracker {
2605
2458
  this.createdMacros = [];
2606
2459
  this.controller = controller;
2607
2460
  this.callbacks = callbacks;
2608
- this.trialId = trialId || uuid.v4();
2609
- this.gameId = gameId || uuid.v4();
2461
+ this.trialParams = trialParams;
2610
2462
  this.trialStartTime = Date.now();
2611
2463
  this.controller.setTrackingCallbacks({
2612
2464
  onSectorCompleted: (sectorId) => this.recordSectorCompletion(sectorId)
@@ -2654,8 +2506,6 @@ class InteractionTracker {
2654
2506
  const event = {
2655
2507
  // Metadata
2656
2508
  interactionId: uuid.v4(),
2657
- trialId: this.trialId,
2658
- gameId: this.gameId,
2659
2509
  interactionIndex: this.interactionIndex++,
2660
2510
  // Interaction type
2661
2511
  interactionType,
@@ -2772,13 +2622,10 @@ class InteractionTracker {
2772
2622
  }));
2773
2623
  const data = {
2774
2624
  trialType: "construction",
2775
- trialId: this.trialId,
2776
- gameId: this.gameId,
2777
- trialNum: 0,
2778
- // TODO: Plugin should provide this
2779
2625
  trialStartTime: this.trialStartTime,
2780
2626
  trialEndTime,
2781
2627
  totalDuration,
2628
+ trialParams: this.trialParams,
2782
2629
  endReason,
2783
2630
  completionTimes: this.completionTimes,
2784
2631
  finalBlueprintState,
@@ -2797,13 +2644,10 @@ class InteractionTracker {
2797
2644
  }));
2798
2645
  const data = {
2799
2646
  trialType: "prep",
2800
- trialId: this.trialId,
2801
- gameId: this.gameId,
2802
- trialNum: 0,
2803
- // TODO: Plugin should provide this
2804
2647
  trialStartTime: this.trialStartTime,
2805
2648
  trialEndTime,
2806
2649
  totalDuration,
2650
+ trialParams: this.trialParams,
2807
2651
  endReason: "submit",
2808
2652
  createdMacros: finalMacros,
2809
2653
  quickstashMacros,
@@ -3061,6 +2905,9 @@ function GameBoard(props) {
3061
2905
  maxQuickstashSlots,
3062
2906
  maxCompositeSize,
3063
2907
  mode,
2908
+ showTangramDecomposition,
2909
+ instructions,
2910
+ trialParams,
3064
2911
  width: _width,
3065
2912
  height: _height,
3066
2913
  onSectorComplete,
@@ -3070,6 +2917,7 @@ function GameBoard(props) {
3070
2917
  onTrialEnd,
3071
2918
  onControllerReady
3072
2919
  } = props;
2920
+ const [timeRemaining, setTimeRemaining] = React.useState(timeLimitMs > 0 ? Math.floor(timeLimitMs / 1e3) : 0);
3073
2921
  const controller = React.useMemo(() => {
3074
2922
  const gameConfig = {
3075
2923
  n: sectors.length,
@@ -3096,8 +2944,8 @@ function GameBoard(props) {
3096
2944
  const callbacks = {};
3097
2945
  if (onInteraction) callbacks.onInteraction = onInteraction;
3098
2946
  if (onTrialEnd) callbacks.onTrialEnd = onTrialEnd;
3099
- return new InteractionTracker(controller, callbacks);
3100
- }, [controller, onInteraction, onTrialEnd]);
2947
+ return new InteractionTracker(controller, callbacks, trialParams);
2948
+ }, [controller, onInteraction, onTrialEnd, trialParams]);
3101
2949
  React.useEffect(() => {
3102
2950
  if (onControllerReady) {
3103
2951
  onControllerReady(controller);
@@ -3177,22 +3025,22 @@ function GameBoard(props) {
3177
3025
  }), [handleSectorComplete, onPiecePlace, onPieceRemove]);
3178
3026
  const getGameboardStyle = () => {
3179
3027
  const baseStyle = {
3180
- margin: "0 auto",
3028
+ margin: "10px",
3181
3029
  display: "flex",
3182
3030
  alignItems: "center",
3183
3031
  justifyContent: "center",
3184
3032
  position: "relative"
3185
3033
  };
3186
3034
  if (layoutMode === "circle") {
3187
- const size = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96);
3035
+ const size = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale);
3188
3036
  return {
3189
3037
  ...baseStyle,
3190
3038
  width: `${size}px`,
3191
3039
  height: `${size}px`
3192
3040
  };
3193
3041
  } else {
3194
- const maxWidth = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96 * 2);
3195
- const maxHeight = Math.min(window.innerWidth * 0.96 / 2, window.innerHeight * 0.96);
3042
+ const maxWidth = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale * 2);
3043
+ const maxHeight = Math.min(window.innerWidth * CONFIG.layout.viewportScale / 2, window.innerHeight * CONFIG.layout.viewportScale);
3196
3044
  return {
3197
3045
  ...baseStyle,
3198
3046
  width: `${maxWidth}px`,
@@ -3361,7 +3209,68 @@ function GameBoard(props) {
3361
3209
  force();
3362
3210
  e.stopPropagation();
3363
3211
  };
3364
- return /* @__PURE__ */ React.createElement("div", { className: "tangram-gameboard", style: getGameboardStyle() }, /* @__PURE__ */ React.createElement(
3212
+ React.useEffect(() => {
3213
+ if (timeLimitMs === 0) return;
3214
+ const interval = setInterval(() => {
3215
+ setTimeRemaining((prev) => {
3216
+ if (prev <= 1) {
3217
+ clearInterval(interval);
3218
+ return 0;
3219
+ }
3220
+ return prev - 1;
3221
+ });
3222
+ }, 1e3);
3223
+ return () => clearInterval(interval);
3224
+ }, [timeLimitMs]);
3225
+ const formatTime = (seconds) => {
3226
+ const mins = Math.floor(seconds / 60);
3227
+ const secs = Math.floor(seconds % 60);
3228
+ return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
3229
+ };
3230
+ const containerStyle = {
3231
+ display: "flex",
3232
+ flexDirection: "column",
3233
+ width: "100%"
3234
+ // minHeight: '100vh'
3235
+ };
3236
+ const headerStyle = {
3237
+ display: "flex",
3238
+ flexDirection: "row",
3239
+ justifyContent: "space-between",
3240
+ alignItems: "center",
3241
+ padding: "20px",
3242
+ background: "#f5f5f5",
3243
+ flex: "0 0 auto"
3244
+ };
3245
+ const instructionsStyle = {
3246
+ flexGrow: 1,
3247
+ fontSize: "20px",
3248
+ lineHeight: 1.5,
3249
+ marginRight: "20px"
3250
+ };
3251
+ const timerStyle = {
3252
+ fontSize: "24px",
3253
+ fontWeight: "bold",
3254
+ fontFamily: "monospace",
3255
+ color: "#333",
3256
+ minWidth: "80px",
3257
+ textAlign: "right"
3258
+ };
3259
+ const gameboardWrapperStyle = {
3260
+ flex: "1 1 auto",
3261
+ display: "flex",
3262
+ alignItems: "center",
3263
+ justifyContent: "center",
3264
+ overflow: "hidden"
3265
+ };
3266
+ 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(
3267
+ "div",
3268
+ {
3269
+ className: "tangram-instructions",
3270
+ style: instructionsStyle,
3271
+ dangerouslySetInnerHTML: { __html: instructions }
3272
+ }
3273
+ ), 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(
3365
3274
  BoardView,
3366
3275
  {
3367
3276
  controller,
@@ -3387,9 +3296,11 @@ function GameBoard(props) {
3387
3296
  onPointerMove,
3388
3297
  onPointerUp,
3389
3298
  onCenterBadgePointerDown,
3299
+ showTangramDecomposition: showTangramDecomposition ?? false,
3300
+ scaleS,
3390
3301
  ...eventCallbacks
3391
3302
  }
3392
- ));
3303
+ ))));
3393
3304
  }
3394
3305
 
3395
3306
  const U = 40;
@@ -3557,17 +3468,22 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3557
3468
  const isCanonical = CANON.has(tanName);
3558
3469
  return isCanonical;
3559
3470
  });
3560
- const mask = filteredTans.map((tan, tanIndex) => {
3471
+ const mask = filteredTans.map((tan) => {
3561
3472
  const polygon = tan.vertices.map(([x, y]) => ({ x: x ?? 0, y: -(y ?? 0) }));
3562
3473
  return polygon;
3563
3474
  });
3475
+ const primitiveDecomposition = filteredTans.map((tan) => ({
3476
+ kind: tan.name ?? tan.kind,
3477
+ polygon: tan.vertices.map(([x, y]) => ({ x: x ?? 0, y: -(y ?? 0) }))
3478
+ }));
3564
3479
  const sectorId = `sector${index}`;
3565
3480
  const sector = {
3566
3481
  id: sectorId,
3567
3482
  tangramId: tangramSpec.tangramID,
3568
3483
  silhouette: {
3569
3484
  id: sectorId,
3570
- mask
3485
+ mask,
3486
+ primitiveDecomposition
3571
3487
  }
3572
3488
  };
3573
3489
  return sector;
@@ -3586,6 +3502,7 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3586
3502
  quickstash = params.quickstash_macros;
3587
3503
  }
3588
3504
  }
3505
+ const { onInteraction, onTrialEnd, ...trialParams } = params;
3589
3506
  const gameBoardProps = {
3590
3507
  sectors,
3591
3508
  quickstash,
@@ -3596,6 +3513,9 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3596
3513
  timeLimitMs: params.time_limit_ms,
3597
3514
  maxQuickstashSlots: CONFIG.layout.defaults.maxQuickstashSlots,
3598
3515
  mode: "construction",
3516
+ showTangramDecomposition: params.show_tangram_decomposition ?? false,
3517
+ trialParams,
3518
+ ...params.instructions && { instructions: params.instructions },
3599
3519
  ...params.onInteraction && { onInteraction: params.onInteraction },
3600
3520
  ...params.onTrialEnd && { onTrialEnd: params.onTrialEnd }
3601
3521
  };
@@ -3659,6 +3579,18 @@ const info$1 = {
3659
3579
  default: 18,
3660
3580
  description: "Snap radius for anchor-based piece placement"
3661
3581
  },
3582
+ /** Whether to show tangram target shapes decomposed into individual primitives with borders */
3583
+ show_tangram_decomposition: {
3584
+ type: jspsych.ParameterType.BOOL,
3585
+ default: false,
3586
+ description: "Whether to show tangram target shapes decomposed into individual primitives with borders"
3587
+ },
3588
+ /** HTML content to display above the gameboard as instructions */
3589
+ instructions: {
3590
+ type: jspsych.ParameterType.STRING,
3591
+ default: "",
3592
+ description: "HTML content to display above the gameboard as instructions"
3593
+ },
3662
3594
  /** Callback fired after each interaction (piece pickup + placedown) */
3663
3595
  onInteraction: {
3664
3596
  type: jspsych.ParameterType.FUNCTION,
@@ -3732,6 +3664,8 @@ class TangramConstructPlugin {
3732
3664
  input: trial.input,
3733
3665
  layout: trial.layout,
3734
3666
  time_limit_ms: trial.time_limit_ms,
3667
+ show_tangram_decomposition: trial.show_tangram_decomposition,
3668
+ instructions: trial.instructions,
3735
3669
  onInteraction: trial.onInteraction,
3736
3670
  onTrialEnd: wrappedOnTrialEnd
3737
3671
  };
@@ -3753,8 +3687,9 @@ function startPrepTrial(display_element, params, jsPsych) {
3753
3687
  onInteraction,
3754
3688
  onTrialEnd
3755
3689
  } = params;
3690
+ const { onInteraction: _, onTrialEnd: __, ...trialParams } = params;
3756
3691
  const PRIMITIVE_BLUEPRINTS_ORDERED = [...PRIMITIVE_BLUEPRINTS].sort((a, b) => primitiveOrder.indexOf(a.kind) - primitiveOrder.indexOf(b.kind));
3757
- const prepSectors = Array.from({ length: numQuickstashSlots }, (_, i) => ({
3692
+ const prepSectors = Array.from({ length: numQuickstashSlots }, (_2, i) => ({
3758
3693
  id: `prep-sector-${i}`,
3759
3694
  tangramId: `prep-sector-${i}`,
3760
3695
  // dummy value since prep mode doesn't have tangrams
@@ -3838,6 +3773,8 @@ function startPrepTrial(display_element, params, jsPsych) {
3838
3773
  // Enable prep-specific behavior
3839
3774
  minPiecesPerMacro,
3840
3775
  requireAllSlots,
3776
+ trialParams,
3777
+ ...params.instructions && { instructions: params.instructions },
3841
3778
  onControllerReady: handleControllerReady,
3842
3779
  ...onInteraction && { onInteraction },
3843
3780
  ...onTrialEnd && { onTrialEnd }
@@ -3891,6 +3828,12 @@ const info = {
3891
3828
  default: ["square", "smalltriangle", "parallelogram", "medtriangle", "largetriangle"],
3892
3829
  description: "Array of primitive names in the order they should be displayed"
3893
3830
  },
3831
+ /** HTML content to display above the gameboard as instructions */
3832
+ instructions: {
3833
+ type: jspsych.ParameterType.STRING,
3834
+ default: void 0,
3835
+ description: "HTML content to display above the gameboard as instructions"
3836
+ },
3894
3837
  /** Callback fired after each interaction (optional analytics hook) */
3895
3838
  onInteraction: {
3896
3839
  type: jspsych.ParameterType.FUNCTION,
@@ -3942,6 +3885,7 @@ class TangramPrepPlugin {
3942
3885
  requireAllSlots: trial.require_all_slots,
3943
3886
  quickstashMacros: trial.quickstash_macros,
3944
3887
  primitiveOrder: trial.primitive_order,
3888
+ instructions: trial.instructions,
3945
3889
  onInteraction: trial.onInteraction,
3946
3890
  onTrialEnd: wrappedOnTrialEnd
3947
3891
  };