jspsych-tangram 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/construct/index.browser.js +157 -222
  2. package/dist/construct/index.browser.js.map +1 -1
  3. package/dist/construct/index.browser.min.js +11 -11
  4. package/dist/construct/index.browser.min.js.map +1 -1
  5. package/dist/construct/index.cjs +157 -222
  6. package/dist/construct/index.cjs.map +1 -1
  7. package/dist/construct/index.d.ts +24 -0
  8. package/dist/construct/index.js +157 -222
  9. package/dist/construct/index.js.map +1 -1
  10. package/dist/index.cjs +168 -223
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts +36 -0
  13. package/dist/index.js +168 -223
  14. package/dist/index.js.map +1 -1
  15. package/dist/prep/index.browser.js +143 -221
  16. package/dist/prep/index.browser.js.map +1 -1
  17. package/dist/prep/index.browser.min.js +13 -13
  18. package/dist/prep/index.browser.min.js.map +1 -1
  19. package/dist/prep/index.cjs +143 -221
  20. package/dist/prep/index.cjs.map +1 -1
  21. package/dist/prep/index.d.ts +12 -0
  22. package/dist/prep/index.js +143 -221
  23. package/dist/prep/index.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/core/components/board/BoardView.tsx +53 -14
  26. package/src/core/components/board/GameBoard.tsx +123 -7
  27. package/src/core/config/config.ts +19 -14
  28. package/src/core/domain/types.ts +1 -0
  29. package/src/core/io/InteractionTracker.ts +7 -16
  30. package/src/core/io/data-tracking.ts +3 -7
  31. package/src/plugins/tangram-construct/ConstructionApp.tsx +16 -1
  32. package/src/plugins/tangram-construct/index.ts +14 -0
  33. package/src/plugins/tangram-prep/PrepApp.tsx +6 -0
  34. package/src/plugins/tangram-prep/index.ts +7 -0
  35. package/tangram-construct.min.js +11 -11
  36. package/tangram-prep.min.js +13 -13
@@ -8,31 +8,34 @@ var uuid = require('uuid');
8
8
  const CONFIG = {
9
9
  color: {
10
10
  bands: {
11
- silhouette: { fillEven: "#eef2ff", fillOdd: "#f6f7fb", stroke: "#c7d2fe" },
12
- workspace: { fillEven: "#f3f4f6", fillOdd: "#f9fafb", stroke: "#e5e7eb" }
11
+ silhouette: { fillEven: "#fff2ccff", fillOdd: "#fff2ccff", stroke: "#b1b1b1" },
12
+ workspace: { fillEven: "#fff2ccff", fillOdd: "#fff2ccff", stroke: "#b1b1b1" }
13
13
  },
14
- completion: { fill: "#dcfce7", stroke: "#86efac" },
15
- silhouetteMask: "#94a3b8",
14
+ completion: { fill: "#ccfff2", stroke: "#13da57" },
15
+ silhouetteMask: "#374151",
16
16
  anchors: { invalid: "#7dd3fc", valid: "#475569" },
17
- piece: { draggingFill: "#1d4ed8", validFill: "#60a5fa", invalidFill: "#ef4444", invalidStroke: "#dc2626", selectedStroke: "#111827", allGreenStroke: "#86efac", borderStroke: "#374151" },
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: "#eef2ff", labelFill: "#374151" }
19
+ blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" },
20
+ tangramDecomposition: { stroke: "#fef2cc" }
20
21
  },
21
22
  opacity: {
22
- blueprint: 0.95,
23
- silhouetteMask: 0.45,
24
- anchors: { valid: 0.8, invalid: 0.5 },
25
- piece: { invalid: 0.35, dragging: 0.6, locked: 0.7, normal: 0.95 }
23
+ blueprint: 0.4,
24
+ silhouetteMask: 0.25,
25
+ //anchors: { valid: 0.80, invalid: 0.50 },
26
+ anchors: { invalid: 0, valid: 0 },
27
+ piece: { invalid: 0.35, dragging: 0.75, locked: 1, normal: 1 }
26
28
  },
27
29
  size: {
28
- stroke: { bandPx: 1, pieceSelectedPx: 1.5, allGreenStrokePx: 6, pieceBorderPx: 1 },
30
+ stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2, tangramDecompositionPx: 1 },
29
31
  anchorRadiusPx: { valid: 1, invalid: 1 },
30
- badgeFontPx: 12,
32
+ badgeFontPx: 16,
31
33
  centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 }
32
34
  },
33
35
  layout: {
34
36
  grid: { stepPx: 20, unitPx: 40 },
35
37
  paddingPx: 1,
38
+ viewportScale: 0.8,
36
39
  constraints: {
37
40
  workspaceDiamAnchors: 10,
38
41
  // num anchors req'd to be on diagonal
@@ -44,7 +47,7 @@ const CONFIG = {
44
47
  },
45
48
  game: {
46
49
  snapRadiusPx: 15,
47
- showBorders: true,
50
+ showBorders: false,
48
51
  hideTouchingBorders: true
49
52
  }
50
53
  };
@@ -815,150 +818,11 @@ function rectForBand(layout, sector, band, pad = 0.85) {
815
818
  return { cx, cy, w, h };
816
819
  }
817
820
 
818
- function generateEdgeStrokePaths(poly) {
819
- const strokePaths = [];
820
- for (let i = 0; i < poly.length; i++) {
821
- const current = poly[i];
822
- const next = poly[(i + 1) % poly.length];
823
- if (current && next) {
824
- strokePaths.push(`M ${current.x} ${current.y} L ${next.x} ${next.y}`);
825
- }
826
- }
827
- return strokePaths;
828
- }
829
- function edgeToUnitSegments$1(start, end, gridSize) {
830
- const segments = [];
831
- const startGrid = {
832
- x: Math.round(start.x / gridSize),
833
- y: Math.round(start.y / gridSize)
834
- };
835
- const endGrid = {
836
- x: Math.round(end.x / gridSize),
837
- y: Math.round(end.y / gridSize)
838
- };
839
- const dx = endGrid.x - startGrid.x;
840
- const dy = endGrid.y - startGrid.y;
841
- if (dx === 0 && dy === 0) return [];
842
- const steps = Math.max(Math.abs(dx), Math.abs(dy));
843
- const stepX = dx / steps;
844
- const stepY = dy / steps;
845
- for (let i = 0; i < steps; i++) {
846
- const aX = Math.round(startGrid.x + i * stepX);
847
- const aY = Math.round(startGrid.y + i * stepY);
848
- const bX = Math.round(startGrid.x + (i + 1) * stepX);
849
- const bY = Math.round(startGrid.y + (i + 1) * stepY);
850
- segments.push({
851
- a: { x: aX, y: aY },
852
- b: { x: bX, y: bY }
853
- });
854
- }
855
- return segments;
856
- }
857
- function getHiddenEdgesForPolygon(piece, polyIndex, allPiecesInSector, getBlueprint, getPrimitive) {
858
- let blueprint;
859
- try {
860
- blueprint = getBlueprint(piece.blueprintId);
861
- } catch (error) {
862
- console.warn("getBlueprint failed in getHiddenEdgesForPolygon:", error);
863
- return [];
864
- }
865
- if (!blueprint?.shape) {
866
- return [];
867
- }
868
- const poly = blueprint.shape[polyIndex];
869
- if (!poly) return [];
870
- const gridSize = CONFIG.layout.grid.stepPx;
871
- const bb = boundsOfBlueprint(blueprint, getPrimitive);
872
- const ox = piece.pos.x - bb.min.x;
873
- const oy = piece.pos.y - bb.min.y;
874
- const translatedPoly = poly.map((vertex) => ({
875
- x: vertex.x + ox,
876
- y: vertex.y + oy
877
- }));
878
- const hiddenEdges = [];
879
- for (let i = 0; i < translatedPoly.length; i++) {
880
- const current = translatedPoly[i];
881
- const next = translatedPoly[(i + 1) % translatedPoly.length];
882
- if (!current || !next) {
883
- hiddenEdges.push(false);
884
- continue;
885
- }
886
- const edgeSegments = edgeToUnitSegments$1(current, next, gridSize);
887
- let isTouching = false;
888
- if (piece.blueprintId.startsWith("comp:")) {
889
- for (let otherPolyIndex = 0; otherPolyIndex < blueprint.shape.length; otherPolyIndex++) {
890
- if (otherPolyIndex === polyIndex) continue;
891
- const otherPoly = blueprint.shape[otherPolyIndex];
892
- if (!otherPoly) continue;
893
- const otherTranslatedPoly = otherPoly.map((vertex) => ({
894
- x: vertex.x + ox,
895
- y: vertex.y + oy
896
- }));
897
- for (let j = 0; j < otherTranslatedPoly.length; j++) {
898
- const otherCurrent = otherTranslatedPoly[j];
899
- const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
900
- if (!otherCurrent || !otherNext) continue;
901
- const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
902
- for (const edgeSeg of edgeSegments) {
903
- const isShared = otherEdgeSegments.some(
904
- (otherSeg) => (
905
- // Check if segments share the same endpoints (identical or reversed)
906
- 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
907
- )
908
- );
909
- if (isShared) {
910
- isTouching = true;
911
- break;
912
- }
913
- }
914
- if (isTouching) break;
915
- }
916
- if (isTouching) break;
917
- }
918
- }
919
- if (!isTouching && CONFIG.game.hideTouchingBorders) {
920
- for (const otherPiece of allPiecesInSector) {
921
- if (otherPiece.id === piece.id) continue;
922
- const otherBlueprint = getBlueprint(otherPiece.blueprintId);
923
- if (!otherBlueprint?.shape) continue;
924
- const otherBb = boundsOfBlueprint(otherBlueprint, getPrimitive);
925
- const otherOx = otherPiece.pos.x - otherBb.min.x;
926
- const otherOy = otherPiece.pos.y - otherBb.min.y;
927
- for (const otherPoly of otherBlueprint.shape) {
928
- const otherTranslatedPoly = otherPoly.map((vertex) => ({
929
- x: vertex.x + otherOx,
930
- y: vertex.y + otherOy
931
- }));
932
- for (let j = 0; j < otherTranslatedPoly.length; j++) {
933
- const otherCurrent = otherTranslatedPoly[j];
934
- const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
935
- if (!otherCurrent || !otherNext) continue;
936
- const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
937
- for (const edgeSeg of edgeSegments) {
938
- const isShared = otherEdgeSegments.some(
939
- (otherSeg) => (
940
- // Check if segments share the same endpoints (identical or reversed)
941
- 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
942
- )
943
- );
944
- if (isShared) {
945
- isTouching = true;
946
- break;
947
- }
948
- }
949
- if (isTouching) break;
950
- }
951
- if (isTouching) break;
952
- }
953
- if (isTouching) break;
954
- }
955
- }
956
- hiddenEdges.push(isTouching);
957
- }
958
- return hiddenEdges;
821
+ function shouldShowBorders() {
822
+ return CONFIG.game.showBorders;
959
823
  }
960
824
  function shouldUseSelectiveBorders(blueprintId) {
961
- return (CONFIG.game.hideTouchingBorders);
825
+ return CONFIG.game.showBorders;
962
826
  }
963
827
 
964
828
  function pathD(poly) {
@@ -976,11 +840,13 @@ function BoardView(props) {
976
840
  placedSilBySector,
977
841
  anchorDots,
978
842
  pieces,
843
+ scaleS,
979
844
  clickMode,
980
845
  draggingId,
981
846
  selectedPieceId,
982
847
  dragInvalid,
983
848
  lockedPieceId,
849
+ showTangramDecomposition,
984
850
  svgRef,
985
851
  setPieceRef,
986
852
  onPiecePointerDown,
@@ -1046,7 +912,7 @@ function BoardView(props) {
1046
912
  onPointerDown: (e) => {
1047
913
  onRootPointerDown(e);
1048
914
  },
1049
- style: { background: "#fff", touchAction: "none", userSelect: "none" }
915
+ style: { background: "#f5f5f5", touchAction: "none", userSelect: "none" }
1050
916
  },
1051
917
  layout.sectors.map((s, i) => {
1052
918
  const done = !!controller.state.sectors[s.id].completedAt;
@@ -1066,11 +932,12 @@ function BoardView(props) {
1066
932
  const isDragging = draggingId === p.id;
1067
933
  const locked = p.sectorId && controller.isSectorCompleted(p.sectorId);
1068
934
  const isConnectivityLocked = lockedPieceId === p.id;
1069
- const isSelected = selectedPieceId === p.id;
935
+ selectedPieceId === p.id;
1070
936
  const isCarriedInvalid = isDragging && dragInvalid;
1071
937
  const translateX = p.x - bb.min.x;
1072
938
  const translateY = p.y - bb.min.y;
1073
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();
1074
941
  shouldUseSelectiveBorders(p.blueprintId);
1075
942
  return /* @__PURE__ */ React.createElement(React.Fragment, { key: idx }, /* @__PURE__ */ React.createElement(
1076
943
  "path",
@@ -1081,51 +948,38 @@ function BoardView(props) {
1081
948
  stroke: "none",
1082
949
  onPointerDown: (e) => onPiecePointerDown(e, p)
1083
950
  }
1084
- ), ((
1085
- // For pieces with selective borders: render individual edge strokes with edge detection
1086
- (() => {
1087
- const allPiecesInSector = pieces.filter((piece) => piece.sectorId === p.sectorId);
1088
- const pieceAsPiece = { ...p, pos: { x: p.x, y: p.y } };
1089
- const allPiecesAsPieces = allPiecesInSector.map((piece) => ({ ...piece, pos: { x: piece.x, y: piece.y } }));
1090
- const hiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, allPiecesAsPieces, (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1091
- const draggedPiece = draggingId ? allPiecesInSector.find((piece) => piece.id === draggingId) : null;
1092
- let wasTouchingDraggedPiece;
1093
- if (p.blueprintId.startsWith("comp:")) {
1094
- const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1095
- 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);
1096
- wasTouchingDraggedPiece = externalHiddenEdges.map((external, i) => external && !internalHiddenEdges[i]);
1097
- } else {
1098
- 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);
1099
- }
1100
- return generateEdgeStrokePaths(poly).map((strokePath, strokeIdx) => {
1101
- const wasHiddenDueToDraggedPiece = wasTouchingDraggedPiece[strokeIdx] || false;
1102
- let isHidden;
1103
- if (isDragging && p.blueprintId.startsWith("comp:")) {
1104
- const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1105
- isHidden = internalHiddenEdges[strokeIdx] || false;
1106
- } else {
1107
- isHidden = isDragging ? false : (hiddenEdges[strokeIdx] || false) && !wasHiddenDueToDraggedPiece;
1108
- }
1109
- return /* @__PURE__ */ React.createElement(
1110
- "path",
1111
- {
1112
- key: `stroke-${idx}-${strokeIdx}`,
1113
- d: strokePath,
1114
- fill: "none",
1115
- stroke: isHidden ? "none" : isConnectivityLocked ? CONFIG.color.piece.invalidStroke : isCarriedInvalid ? CONFIG.color.piece.invalidStroke : isSelected || isDragging ? CONFIG.color.piece.selectedStroke : CONFIG.color.piece.borderStroke,
1116
- strokeWidth: isHidden ? 0 : isSelected || isDragging ? CONFIG.size.stroke.pieceSelectedPx : CONFIG.size.stroke.pieceBorderPx,
1117
- onPointerDown: (e) => onPiecePointerDown(e, p)
1118
- }
1119
- );
1120
- });
1121
- })()
1122
- ) ));
951
+ ), showBorders);
1123
952
  }));
1124
953
  }),
1125
954
  layout.sectors.map((s) => {
1126
- const placedPolys = placedSilBySector.get(s.id) ?? [];
1127
- if (!placedPolys.length) return null;
1128
- 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
+ }
1129
983
  }),
1130
984
  anchorDots.map(({ sectorId, valid, invalid }) => {
1131
985
  const isInnerRing = sectorId === "inner-ring";
@@ -2587,7 +2441,7 @@ function useClickController(controller, layout, pieces, clickMode, draggingId, s
2587
2441
  }
2588
2442
 
2589
2443
  class InteractionTracker {
2590
- constructor(controller, callbacks, trialId, gameId) {
2444
+ constructor(controller, callbacks, trialParams) {
2591
2445
  this.gridStep = CONFIG.layout.grid.stepPx;
2592
2446
  // Interaction state
2593
2447
  this.interactionIndex = 0;
@@ -2604,8 +2458,7 @@ class InteractionTracker {
2604
2458
  this.createdMacros = [];
2605
2459
  this.controller = controller;
2606
2460
  this.callbacks = callbacks;
2607
- this.trialId = trialId || uuid.v4();
2608
- this.gameId = gameId || uuid.v4();
2461
+ this.trialParams = trialParams;
2609
2462
  this.trialStartTime = Date.now();
2610
2463
  this.controller.setTrackingCallbacks({
2611
2464
  onSectorCompleted: (sectorId) => this.recordSectorCompletion(sectorId)
@@ -2653,8 +2506,6 @@ class InteractionTracker {
2653
2506
  const event = {
2654
2507
  // Metadata
2655
2508
  interactionId: uuid.v4(),
2656
- trialId: this.trialId,
2657
- gameId: this.gameId,
2658
2509
  interactionIndex: this.interactionIndex++,
2659
2510
  // Interaction type
2660
2511
  interactionType,
@@ -2771,13 +2622,10 @@ class InteractionTracker {
2771
2622
  }));
2772
2623
  const data = {
2773
2624
  trialType: "construction",
2774
- trialId: this.trialId,
2775
- gameId: this.gameId,
2776
- trialNum: 0,
2777
- // TODO: Plugin should provide this
2778
2625
  trialStartTime: this.trialStartTime,
2779
2626
  trialEndTime,
2780
2627
  totalDuration,
2628
+ trialParams: this.trialParams,
2781
2629
  endReason,
2782
2630
  completionTimes: this.completionTimes,
2783
2631
  finalBlueprintState,
@@ -2796,13 +2644,10 @@ class InteractionTracker {
2796
2644
  }));
2797
2645
  const data = {
2798
2646
  trialType: "prep",
2799
- trialId: this.trialId,
2800
- gameId: this.gameId,
2801
- trialNum: 0,
2802
- // TODO: Plugin should provide this
2803
2647
  trialStartTime: this.trialStartTime,
2804
2648
  trialEndTime,
2805
2649
  totalDuration,
2650
+ trialParams: this.trialParams,
2806
2651
  endReason: "submit",
2807
2652
  createdMacros: finalMacros,
2808
2653
  quickstashMacros,
@@ -3060,6 +2905,9 @@ function GameBoard(props) {
3060
2905
  maxQuickstashSlots,
3061
2906
  maxCompositeSize,
3062
2907
  mode,
2908
+ showTangramDecomposition,
2909
+ instructions,
2910
+ trialParams,
3063
2911
  width: _width,
3064
2912
  height: _height,
3065
2913
  onSectorComplete,
@@ -3069,6 +2917,7 @@ function GameBoard(props) {
3069
2917
  onTrialEnd,
3070
2918
  onControllerReady
3071
2919
  } = props;
2920
+ const [timeRemaining, setTimeRemaining] = React.useState(timeLimitMs > 0 ? Math.floor(timeLimitMs / 1e3) : 0);
3072
2921
  const controller = React.useMemo(() => {
3073
2922
  const gameConfig = {
3074
2923
  n: sectors.length,
@@ -3095,8 +2944,8 @@ function GameBoard(props) {
3095
2944
  const callbacks = {};
3096
2945
  if (onInteraction) callbacks.onInteraction = onInteraction;
3097
2946
  if (onTrialEnd) callbacks.onTrialEnd = onTrialEnd;
3098
- return new InteractionTracker(controller, callbacks);
3099
- }, [controller, onInteraction, onTrialEnd]);
2947
+ return new InteractionTracker(controller, callbacks, trialParams);
2948
+ }, [controller, onInteraction, onTrialEnd, trialParams]);
3100
2949
  React.useEffect(() => {
3101
2950
  if (onControllerReady) {
3102
2951
  onControllerReady(controller);
@@ -3176,22 +3025,22 @@ function GameBoard(props) {
3176
3025
  }), [handleSectorComplete, onPiecePlace, onPieceRemove]);
3177
3026
  const getGameboardStyle = () => {
3178
3027
  const baseStyle = {
3179
- margin: "0 auto",
3028
+ margin: "10px",
3180
3029
  display: "flex",
3181
3030
  alignItems: "center",
3182
3031
  justifyContent: "center",
3183
3032
  position: "relative"
3184
3033
  };
3185
3034
  if (layoutMode === "circle") {
3186
- 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);
3187
3036
  return {
3188
3037
  ...baseStyle,
3189
3038
  width: `${size}px`,
3190
3039
  height: `${size}px`
3191
3040
  };
3192
3041
  } else {
3193
- const maxWidth = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96 * 2);
3194
- 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);
3195
3044
  return {
3196
3045
  ...baseStyle,
3197
3046
  width: `${maxWidth}px`,
@@ -3360,7 +3209,68 @@ function GameBoard(props) {
3360
3209
  force();
3361
3210
  e.stopPropagation();
3362
3211
  };
3363
- 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(
3364
3274
  BoardView,
3365
3275
  {
3366
3276
  controller,
@@ -3386,9 +3296,11 @@ function GameBoard(props) {
3386
3296
  onPointerMove,
3387
3297
  onPointerUp,
3388
3298
  onCenterBadgePointerDown,
3299
+ showTangramDecomposition: showTangramDecomposition ?? false,
3300
+ scaleS,
3389
3301
  ...eventCallbacks
3390
3302
  }
3391
- ));
3303
+ ))));
3392
3304
  }
3393
3305
 
3394
3306
  const U = 40;
@@ -3554,8 +3466,9 @@ function startPrepTrial(display_element, params, jsPsych) {
3554
3466
  onInteraction,
3555
3467
  onTrialEnd
3556
3468
  } = params;
3469
+ const { onInteraction: _, onTrialEnd: __, ...trialParams } = params;
3557
3470
  const PRIMITIVE_BLUEPRINTS_ORDERED = [...PRIMITIVE_BLUEPRINTS].sort((a, b) => primitiveOrder.indexOf(a.kind) - primitiveOrder.indexOf(b.kind));
3558
- const prepSectors = Array.from({ length: numQuickstashSlots }, (_, i) => ({
3471
+ const prepSectors = Array.from({ length: numQuickstashSlots }, (_2, i) => ({
3559
3472
  id: `prep-sector-${i}`,
3560
3473
  tangramId: `prep-sector-${i}`,
3561
3474
  // dummy value since prep mode doesn't have tangrams
@@ -3639,6 +3552,8 @@ function startPrepTrial(display_element, params, jsPsych) {
3639
3552
  // Enable prep-specific behavior
3640
3553
  minPiecesPerMacro,
3641
3554
  requireAllSlots,
3555
+ trialParams,
3556
+ ...params.instructions && { instructions: params.instructions },
3642
3557
  onControllerReady: handleControllerReady,
3643
3558
  ...onInteraction && { onInteraction },
3644
3559
  ...onTrialEnd && { onTrialEnd }
@@ -3692,6 +3607,12 @@ const info = {
3692
3607
  default: ["square", "smalltriangle", "parallelogram", "medtriangle", "largetriangle"],
3693
3608
  description: "Array of primitive names in the order they should be displayed"
3694
3609
  },
3610
+ /** HTML content to display above the gameboard as instructions */
3611
+ instructions: {
3612
+ type: jspsych.ParameterType.STRING,
3613
+ default: void 0,
3614
+ description: "HTML content to display above the gameboard as instructions"
3615
+ },
3695
3616
  /** Callback fired after each interaction (optional analytics hook) */
3696
3617
  onInteraction: {
3697
3618
  type: jspsych.ParameterType.FUNCTION,
@@ -3743,6 +3664,7 @@ class TangramPrepPlugin {
3743
3664
  requireAllSlots: trial.require_all_slots,
3744
3665
  quickstashMacros: trial.quickstash_macros,
3745
3666
  primitiveOrder: trial.primitive_order,
3667
+ instructions: trial.instructions,
3746
3668
  onInteraction: trial.onInteraction,
3747
3669
  onTrialEnd: wrappedOnTrialEnd
3748
3670
  };