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;
@@ -3556,17 +3468,22 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3556
3468
  const isCanonical = CANON.has(tanName);
3557
3469
  return isCanonical;
3558
3470
  });
3559
- const mask = filteredTans.map((tan, tanIndex) => {
3471
+ const mask = filteredTans.map((tan) => {
3560
3472
  const polygon = tan.vertices.map(([x, y]) => ({ x: x ?? 0, y: -(y ?? 0) }));
3561
3473
  return polygon;
3562
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
+ }));
3563
3479
  const sectorId = `sector${index}`;
3564
3480
  const sector = {
3565
3481
  id: sectorId,
3566
3482
  tangramId: tangramSpec.tangramID,
3567
3483
  silhouette: {
3568
3484
  id: sectorId,
3569
- mask
3485
+ mask,
3486
+ primitiveDecomposition
3570
3487
  }
3571
3488
  };
3572
3489
  return sector;
@@ -3585,6 +3502,7 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3585
3502
  quickstash = params.quickstash_macros;
3586
3503
  }
3587
3504
  }
3505
+ const { onInteraction, onTrialEnd, ...trialParams } = params;
3588
3506
  const gameBoardProps = {
3589
3507
  sectors,
3590
3508
  quickstash,
@@ -3595,6 +3513,9 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3595
3513
  timeLimitMs: params.time_limit_ms,
3596
3514
  maxQuickstashSlots: CONFIG.layout.defaults.maxQuickstashSlots,
3597
3515
  mode: "construction",
3516
+ showTangramDecomposition: params.show_tangram_decomposition ?? false,
3517
+ trialParams,
3518
+ ...params.instructions && { instructions: params.instructions },
3598
3519
  ...params.onInteraction && { onInteraction: params.onInteraction },
3599
3520
  ...params.onTrialEnd && { onTrialEnd: params.onTrialEnd }
3600
3521
  };
@@ -3658,6 +3579,18 @@ const info = {
3658
3579
  default: 18,
3659
3580
  description: "Snap radius for anchor-based piece placement"
3660
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
+ },
3661
3594
  /** Callback fired after each interaction (piece pickup + placedown) */
3662
3595
  onInteraction: {
3663
3596
  type: jspsych.ParameterType.FUNCTION,
@@ -3731,6 +3664,8 @@ class TangramConstructPlugin {
3731
3664
  input: trial.input,
3732
3665
  layout: trial.layout,
3733
3666
  time_limit_ms: trial.time_limit_ms,
3667
+ show_tangram_decomposition: trial.show_tangram_decomposition,
3668
+ instructions: trial.instructions,
3734
3669
  onInteraction: trial.onInteraction,
3735
3670
  onTrialEnd: wrappedOnTrialEnd
3736
3671
  };