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
package/dist/index.js CHANGED
@@ -6,31 +6,34 @@ import { v4 } from 'uuid';
6
6
  const CONFIG = {
7
7
  color: {
8
8
  bands: {
9
- silhouette: { fillEven: "#eef2ff", fillOdd: "#f6f7fb", stroke: "#c7d2fe" },
10
- workspace: { fillEven: "#f3f4f6", fillOdd: "#f9fafb", stroke: "#e5e7eb" }
9
+ silhouette: { fillEven: "#fff2ccff", fillOdd: "#fff2ccff", stroke: "#b1b1b1" },
10
+ workspace: { fillEven: "#fff2ccff", fillOdd: "#fff2ccff", stroke: "#b1b1b1" }
11
11
  },
12
- completion: { fill: "#dcfce7", stroke: "#86efac" },
13
- silhouetteMask: "#94a3b8",
12
+ completion: { fill: "#ccfff2", stroke: "#13da57" },
13
+ silhouetteMask: "#374151",
14
14
  anchors: { invalid: "#7dd3fc", valid: "#475569" },
15
- piece: { draggingFill: "#1d4ed8", validFill: "#60a5fa", invalidFill: "#ef4444", invalidStroke: "#dc2626", selectedStroke: "#111827", allGreenStroke: "#86efac", borderStroke: "#374151" },
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: "#eef2ff", labelFill: "#374151" }
17
+ blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" },
18
+ tangramDecomposition: { stroke: "#fef2cc" }
18
19
  },
19
20
  opacity: {
20
- blueprint: 0.95,
21
- silhouetteMask: 0.45,
22
- anchors: { valid: 0.8, invalid: 0.5 },
23
- piece: { invalid: 0.35, dragging: 0.6, locked: 0.7, normal: 0.95 }
21
+ blueprint: 0.4,
22
+ silhouetteMask: 0.25,
23
+ //anchors: { valid: 0.80, invalid: 0.50 },
24
+ anchors: { invalid: 0, valid: 0 },
25
+ piece: { invalid: 0.35, dragging: 0.75, locked: 1, normal: 1 }
24
26
  },
25
27
  size: {
26
- stroke: { bandPx: 1, pieceSelectedPx: 1.5, allGreenStrokePx: 6, pieceBorderPx: 1 },
28
+ stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2, tangramDecompositionPx: 1 },
27
29
  anchorRadiusPx: { valid: 1, invalid: 1 },
28
- badgeFontPx: 12,
30
+ badgeFontPx: 16,
29
31
  centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 }
30
32
  },
31
33
  layout: {
32
34
  grid: { stepPx: 20, unitPx: 40 },
33
35
  paddingPx: 1,
36
+ viewportScale: 0.8,
34
37
  constraints: {
35
38
  workspaceDiamAnchors: 10,
36
39
  // num anchors req'd to be on diagonal
@@ -42,7 +45,7 @@ const CONFIG = {
42
45
  },
43
46
  game: {
44
47
  snapRadiusPx: 15,
45
- showBorders: true,
48
+ showBorders: false,
46
49
  hideTouchingBorders: true
47
50
  }
48
51
  };
@@ -813,150 +816,11 @@ function rectForBand(layout, sector, band, pad = 0.85) {
813
816
  return { cx, cy, w, h };
814
817
  }
815
818
 
816
- function generateEdgeStrokePaths(poly) {
817
- const strokePaths = [];
818
- for (let i = 0; i < poly.length; i++) {
819
- const current = poly[i];
820
- const next = poly[(i + 1) % poly.length];
821
- if (current && next) {
822
- strokePaths.push(`M ${current.x} ${current.y} L ${next.x} ${next.y}`);
823
- }
824
- }
825
- return strokePaths;
826
- }
827
- function edgeToUnitSegments$1(start, end, gridSize) {
828
- const segments = [];
829
- const startGrid = {
830
- x: Math.round(start.x / gridSize),
831
- y: Math.round(start.y / gridSize)
832
- };
833
- const endGrid = {
834
- x: Math.round(end.x / gridSize),
835
- y: Math.round(end.y / gridSize)
836
- };
837
- const dx = endGrid.x - startGrid.x;
838
- const dy = endGrid.y - startGrid.y;
839
- if (dx === 0 && dy === 0) return [];
840
- const steps = Math.max(Math.abs(dx), Math.abs(dy));
841
- const stepX = dx / steps;
842
- const stepY = dy / steps;
843
- for (let i = 0; i < steps; i++) {
844
- const aX = Math.round(startGrid.x + i * stepX);
845
- const aY = Math.round(startGrid.y + i * stepY);
846
- const bX = Math.round(startGrid.x + (i + 1) * stepX);
847
- const bY = Math.round(startGrid.y + (i + 1) * stepY);
848
- segments.push({
849
- a: { x: aX, y: aY },
850
- b: { x: bX, y: bY }
851
- });
852
- }
853
- return segments;
854
- }
855
- function getHiddenEdgesForPolygon(piece, polyIndex, allPiecesInSector, getBlueprint, getPrimitive) {
856
- let blueprint;
857
- try {
858
- blueprint = getBlueprint(piece.blueprintId);
859
- } catch (error) {
860
- console.warn("getBlueprint failed in getHiddenEdgesForPolygon:", error);
861
- return [];
862
- }
863
- if (!blueprint?.shape) {
864
- return [];
865
- }
866
- const poly = blueprint.shape[polyIndex];
867
- if (!poly) return [];
868
- const gridSize = CONFIG.layout.grid.stepPx;
869
- const bb = boundsOfBlueprint(blueprint, getPrimitive);
870
- const ox = piece.pos.x - bb.min.x;
871
- const oy = piece.pos.y - bb.min.y;
872
- const translatedPoly = poly.map((vertex) => ({
873
- x: vertex.x + ox,
874
- y: vertex.y + oy
875
- }));
876
- const hiddenEdges = [];
877
- for (let i = 0; i < translatedPoly.length; i++) {
878
- const current = translatedPoly[i];
879
- const next = translatedPoly[(i + 1) % translatedPoly.length];
880
- if (!current || !next) {
881
- hiddenEdges.push(false);
882
- continue;
883
- }
884
- const edgeSegments = edgeToUnitSegments$1(current, next, gridSize);
885
- let isTouching = false;
886
- if (piece.blueprintId.startsWith("comp:")) {
887
- for (let otherPolyIndex = 0; otherPolyIndex < blueprint.shape.length; otherPolyIndex++) {
888
- if (otherPolyIndex === polyIndex) continue;
889
- const otherPoly = blueprint.shape[otherPolyIndex];
890
- if (!otherPoly) continue;
891
- const otherTranslatedPoly = otherPoly.map((vertex) => ({
892
- x: vertex.x + ox,
893
- y: vertex.y + oy
894
- }));
895
- for (let j = 0; j < otherTranslatedPoly.length; j++) {
896
- const otherCurrent = otherTranslatedPoly[j];
897
- const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
898
- if (!otherCurrent || !otherNext) continue;
899
- const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
900
- for (const edgeSeg of edgeSegments) {
901
- const isShared = otherEdgeSegments.some(
902
- (otherSeg) => (
903
- // Check if segments share the same endpoints (identical or reversed)
904
- 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
905
- )
906
- );
907
- if (isShared) {
908
- isTouching = true;
909
- break;
910
- }
911
- }
912
- if (isTouching) break;
913
- }
914
- if (isTouching) break;
915
- }
916
- }
917
- if (!isTouching && CONFIG.game.hideTouchingBorders) {
918
- for (const otherPiece of allPiecesInSector) {
919
- if (otherPiece.id === piece.id) continue;
920
- const otherBlueprint = getBlueprint(otherPiece.blueprintId);
921
- if (!otherBlueprint?.shape) continue;
922
- const otherBb = boundsOfBlueprint(otherBlueprint, getPrimitive);
923
- const otherOx = otherPiece.pos.x - otherBb.min.x;
924
- const otherOy = otherPiece.pos.y - otherBb.min.y;
925
- for (const otherPoly of otherBlueprint.shape) {
926
- const otherTranslatedPoly = otherPoly.map((vertex) => ({
927
- x: vertex.x + otherOx,
928
- y: vertex.y + otherOy
929
- }));
930
- for (let j = 0; j < otherTranslatedPoly.length; j++) {
931
- const otherCurrent = otherTranslatedPoly[j];
932
- const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
933
- if (!otherCurrent || !otherNext) continue;
934
- const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
935
- for (const edgeSeg of edgeSegments) {
936
- const isShared = otherEdgeSegments.some(
937
- (otherSeg) => (
938
- // Check if segments share the same endpoints (identical or reversed)
939
- 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
940
- )
941
- );
942
- if (isShared) {
943
- isTouching = true;
944
- break;
945
- }
946
- }
947
- if (isTouching) break;
948
- }
949
- if (isTouching) break;
950
- }
951
- if (isTouching) break;
952
- }
953
- }
954
- hiddenEdges.push(isTouching);
955
- }
956
- return hiddenEdges;
819
+ function shouldShowBorders() {
820
+ return CONFIG.game.showBorders;
957
821
  }
958
822
  function shouldUseSelectiveBorders(blueprintId) {
959
- return (CONFIG.game.hideTouchingBorders);
823
+ return CONFIG.game.showBorders;
960
824
  }
961
825
 
962
826
  function pathD(poly) {
@@ -974,11 +838,13 @@ function BoardView(props) {
974
838
  placedSilBySector,
975
839
  anchorDots,
976
840
  pieces,
841
+ scaleS,
977
842
  clickMode,
978
843
  draggingId,
979
844
  selectedPieceId,
980
845
  dragInvalid,
981
846
  lockedPieceId,
847
+ showTangramDecomposition,
982
848
  svgRef,
983
849
  setPieceRef,
984
850
  onPiecePointerDown,
@@ -1044,7 +910,7 @@ function BoardView(props) {
1044
910
  onPointerDown: (e) => {
1045
911
  onRootPointerDown(e);
1046
912
  },
1047
- style: { background: "#fff", touchAction: "none", userSelect: "none" }
913
+ style: { background: "#f5f5f5", touchAction: "none", userSelect: "none" }
1048
914
  },
1049
915
  layout.sectors.map((s, i) => {
1050
916
  const done = !!controller.state.sectors[s.id].completedAt;
@@ -1064,11 +930,12 @@ function BoardView(props) {
1064
930
  const isDragging = draggingId === p.id;
1065
931
  const locked = p.sectorId && controller.isSectorCompleted(p.sectorId);
1066
932
  const isConnectivityLocked = lockedPieceId === p.id;
1067
- const isSelected = selectedPieceId === p.id;
933
+ selectedPieceId === p.id;
1068
934
  const isCarriedInvalid = isDragging && dragInvalid;
1069
935
  const translateX = p.x - bb.min.x;
1070
936
  const translateY = p.y - bb.min.y;
1071
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();
1072
939
  shouldUseSelectiveBorders(p.blueprintId);
1073
940
  return /* @__PURE__ */ React.createElement(React.Fragment, { key: idx }, /* @__PURE__ */ React.createElement(
1074
941
  "path",
@@ -1079,51 +946,38 @@ function BoardView(props) {
1079
946
  stroke: "none",
1080
947
  onPointerDown: (e) => onPiecePointerDown(e, p)
1081
948
  }
1082
- ), ((
1083
- // For pieces with selective borders: render individual edge strokes with edge detection
1084
- (() => {
1085
- const allPiecesInSector = pieces.filter((piece) => piece.sectorId === p.sectorId);
1086
- const pieceAsPiece = { ...p, pos: { x: p.x, y: p.y } };
1087
- const allPiecesAsPieces = allPiecesInSector.map((piece) => ({ ...piece, pos: { x: piece.x, y: piece.y } }));
1088
- const hiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, allPiecesAsPieces, (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1089
- const draggedPiece = draggingId ? allPiecesInSector.find((piece) => piece.id === draggingId) : null;
1090
- let wasTouchingDraggedPiece;
1091
- if (p.blueprintId.startsWith("comp:")) {
1092
- const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1093
- 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);
1094
- wasTouchingDraggedPiece = externalHiddenEdges.map((external, i) => external && !internalHiddenEdges[i]);
1095
- } else {
1096
- 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);
1097
- }
1098
- return generateEdgeStrokePaths(poly).map((strokePath, strokeIdx) => {
1099
- const wasHiddenDueToDraggedPiece = wasTouchingDraggedPiece[strokeIdx] || false;
1100
- let isHidden;
1101
- if (isDragging && p.blueprintId.startsWith("comp:")) {
1102
- const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1103
- isHidden = internalHiddenEdges[strokeIdx] || false;
1104
- } else {
1105
- isHidden = isDragging ? false : (hiddenEdges[strokeIdx] || false) && !wasHiddenDueToDraggedPiece;
1106
- }
1107
- return /* @__PURE__ */ React.createElement(
1108
- "path",
1109
- {
1110
- key: `stroke-${idx}-${strokeIdx}`,
1111
- d: strokePath,
1112
- fill: "none",
1113
- stroke: isHidden ? "none" : isConnectivityLocked ? CONFIG.color.piece.invalidStroke : isCarriedInvalid ? CONFIG.color.piece.invalidStroke : isSelected || isDragging ? CONFIG.color.piece.selectedStroke : CONFIG.color.piece.borderStroke,
1114
- strokeWidth: isHidden ? 0 : isSelected || isDragging ? CONFIG.size.stroke.pieceSelectedPx : CONFIG.size.stroke.pieceBorderPx,
1115
- onPointerDown: (e) => onPiecePointerDown(e, p)
1116
- }
1117
- );
1118
- });
1119
- })()
1120
- ) ));
949
+ ), showBorders);
1121
950
  }));
1122
951
  }),
1123
952
  layout.sectors.map((s) => {
1124
- const placedPolys = placedSilBySector.get(s.id) ?? [];
1125
- if (!placedPolys.length) return null;
1126
- 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
+ }
1127
981
  }),
1128
982
  anchorDots.map(({ sectorId, valid, invalid }) => {
1129
983
  const isInnerRing = sectorId === "inner-ring";
@@ -2585,7 +2439,7 @@ function useClickController(controller, layout, pieces, clickMode, draggingId, s
2585
2439
  }
2586
2440
 
2587
2441
  class InteractionTracker {
2588
- constructor(controller, callbacks, trialId, gameId) {
2442
+ constructor(controller, callbacks, trialParams) {
2589
2443
  this.gridStep = CONFIG.layout.grid.stepPx;
2590
2444
  // Interaction state
2591
2445
  this.interactionIndex = 0;
@@ -2602,8 +2456,7 @@ class InteractionTracker {
2602
2456
  this.createdMacros = [];
2603
2457
  this.controller = controller;
2604
2458
  this.callbacks = callbacks;
2605
- this.trialId = trialId || v4();
2606
- this.gameId = gameId || v4();
2459
+ this.trialParams = trialParams;
2607
2460
  this.trialStartTime = Date.now();
2608
2461
  this.controller.setTrackingCallbacks({
2609
2462
  onSectorCompleted: (sectorId) => this.recordSectorCompletion(sectorId)
@@ -2651,8 +2504,6 @@ class InteractionTracker {
2651
2504
  const event = {
2652
2505
  // Metadata
2653
2506
  interactionId: v4(),
2654
- trialId: this.trialId,
2655
- gameId: this.gameId,
2656
2507
  interactionIndex: this.interactionIndex++,
2657
2508
  // Interaction type
2658
2509
  interactionType,
@@ -2769,13 +2620,10 @@ class InteractionTracker {
2769
2620
  }));
2770
2621
  const data = {
2771
2622
  trialType: "construction",
2772
- trialId: this.trialId,
2773
- gameId: this.gameId,
2774
- trialNum: 0,
2775
- // TODO: Plugin should provide this
2776
2623
  trialStartTime: this.trialStartTime,
2777
2624
  trialEndTime,
2778
2625
  totalDuration,
2626
+ trialParams: this.trialParams,
2779
2627
  endReason,
2780
2628
  completionTimes: this.completionTimes,
2781
2629
  finalBlueprintState,
@@ -2794,13 +2642,10 @@ class InteractionTracker {
2794
2642
  }));
2795
2643
  const data = {
2796
2644
  trialType: "prep",
2797
- trialId: this.trialId,
2798
- gameId: this.gameId,
2799
- trialNum: 0,
2800
- // TODO: Plugin should provide this
2801
2645
  trialStartTime: this.trialStartTime,
2802
2646
  trialEndTime,
2803
2647
  totalDuration,
2648
+ trialParams: this.trialParams,
2804
2649
  endReason: "submit",
2805
2650
  createdMacros: finalMacros,
2806
2651
  quickstashMacros,
@@ -3058,6 +2903,9 @@ function GameBoard(props) {
3058
2903
  maxQuickstashSlots,
3059
2904
  maxCompositeSize,
3060
2905
  mode,
2906
+ showTangramDecomposition,
2907
+ instructions,
2908
+ trialParams,
3061
2909
  width: _width,
3062
2910
  height: _height,
3063
2911
  onSectorComplete,
@@ -3067,6 +2915,7 @@ function GameBoard(props) {
3067
2915
  onTrialEnd,
3068
2916
  onControllerReady
3069
2917
  } = props;
2918
+ const [timeRemaining, setTimeRemaining] = React.useState(timeLimitMs > 0 ? Math.floor(timeLimitMs / 1e3) : 0);
3070
2919
  const controller = React.useMemo(() => {
3071
2920
  const gameConfig = {
3072
2921
  n: sectors.length,
@@ -3093,8 +2942,8 @@ function GameBoard(props) {
3093
2942
  const callbacks = {};
3094
2943
  if (onInteraction) callbacks.onInteraction = onInteraction;
3095
2944
  if (onTrialEnd) callbacks.onTrialEnd = onTrialEnd;
3096
- return new InteractionTracker(controller, callbacks);
3097
- }, [controller, onInteraction, onTrialEnd]);
2945
+ return new InteractionTracker(controller, callbacks, trialParams);
2946
+ }, [controller, onInteraction, onTrialEnd, trialParams]);
3098
2947
  React.useEffect(() => {
3099
2948
  if (onControllerReady) {
3100
2949
  onControllerReady(controller);
@@ -3174,22 +3023,22 @@ function GameBoard(props) {
3174
3023
  }), [handleSectorComplete, onPiecePlace, onPieceRemove]);
3175
3024
  const getGameboardStyle = () => {
3176
3025
  const baseStyle = {
3177
- margin: "0 auto",
3026
+ margin: "10px",
3178
3027
  display: "flex",
3179
3028
  alignItems: "center",
3180
3029
  justifyContent: "center",
3181
3030
  position: "relative"
3182
3031
  };
3183
3032
  if (layoutMode === "circle") {
3184
- 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);
3185
3034
  return {
3186
3035
  ...baseStyle,
3187
3036
  width: `${size}px`,
3188
3037
  height: `${size}px`
3189
3038
  };
3190
3039
  } else {
3191
- const maxWidth = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96 * 2);
3192
- 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);
3193
3042
  return {
3194
3043
  ...baseStyle,
3195
3044
  width: `${maxWidth}px`,
@@ -3358,7 +3207,68 @@ function GameBoard(props) {
3358
3207
  force();
3359
3208
  e.stopPropagation();
3360
3209
  };
3361
- 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(
3362
3272
  BoardView,
3363
3273
  {
3364
3274
  controller,
@@ -3384,9 +3294,11 @@ function GameBoard(props) {
3384
3294
  onPointerMove,
3385
3295
  onPointerUp,
3386
3296
  onCenterBadgePointerDown,
3297
+ showTangramDecomposition: showTangramDecomposition ?? false,
3298
+ scaleS,
3387
3299
  ...eventCallbacks
3388
3300
  }
3389
- ));
3301
+ ))));
3390
3302
  }
3391
3303
 
3392
3304
  const U = 40;
@@ -3554,17 +3466,22 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3554
3466
  const isCanonical = CANON.has(tanName);
3555
3467
  return isCanonical;
3556
3468
  });
3557
- const mask = filteredTans.map((tan, tanIndex) => {
3469
+ const mask = filteredTans.map((tan) => {
3558
3470
  const polygon = tan.vertices.map(([x, y]) => ({ x: x ?? 0, y: -(y ?? 0) }));
3559
3471
  return polygon;
3560
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
+ }));
3561
3477
  const sectorId = `sector${index}`;
3562
3478
  const sector = {
3563
3479
  id: sectorId,
3564
3480
  tangramId: tangramSpec.tangramID,
3565
3481
  silhouette: {
3566
3482
  id: sectorId,
3567
- mask
3483
+ mask,
3484
+ primitiveDecomposition
3568
3485
  }
3569
3486
  };
3570
3487
  return sector;
@@ -3583,6 +3500,7 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3583
3500
  quickstash = params.quickstash_macros;
3584
3501
  }
3585
3502
  }
3503
+ const { onInteraction, onTrialEnd, ...trialParams } = params;
3586
3504
  const gameBoardProps = {
3587
3505
  sectors,
3588
3506
  quickstash,
@@ -3593,6 +3511,9 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3593
3511
  timeLimitMs: params.time_limit_ms,
3594
3512
  maxQuickstashSlots: CONFIG.layout.defaults.maxQuickstashSlots,
3595
3513
  mode: "construction",
3514
+ showTangramDecomposition: params.show_tangram_decomposition ?? false,
3515
+ trialParams,
3516
+ ...params.instructions && { instructions: params.instructions },
3596
3517
  ...params.onInteraction && { onInteraction: params.onInteraction },
3597
3518
  ...params.onTrialEnd && { onTrialEnd: params.onTrialEnd }
3598
3519
  };
@@ -3656,6 +3577,18 @@ const info$1 = {
3656
3577
  default: 18,
3657
3578
  description: "Snap radius for anchor-based piece placement"
3658
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
+ },
3659
3592
  /** Callback fired after each interaction (piece pickup + placedown) */
3660
3593
  onInteraction: {
3661
3594
  type: ParameterType.FUNCTION,
@@ -3729,6 +3662,8 @@ class TangramConstructPlugin {
3729
3662
  input: trial.input,
3730
3663
  layout: trial.layout,
3731
3664
  time_limit_ms: trial.time_limit_ms,
3665
+ show_tangram_decomposition: trial.show_tangram_decomposition,
3666
+ instructions: trial.instructions,
3732
3667
  onInteraction: trial.onInteraction,
3733
3668
  onTrialEnd: wrappedOnTrialEnd
3734
3669
  };
@@ -3750,8 +3685,9 @@ function startPrepTrial(display_element, params, jsPsych) {
3750
3685
  onInteraction,
3751
3686
  onTrialEnd
3752
3687
  } = params;
3688
+ const { onInteraction: _, onTrialEnd: __, ...trialParams } = params;
3753
3689
  const PRIMITIVE_BLUEPRINTS_ORDERED = [...PRIMITIVE_BLUEPRINTS].sort((a, b) => primitiveOrder.indexOf(a.kind) - primitiveOrder.indexOf(b.kind));
3754
- const prepSectors = Array.from({ length: numQuickstashSlots }, (_, i) => ({
3690
+ const prepSectors = Array.from({ length: numQuickstashSlots }, (_2, i) => ({
3755
3691
  id: `prep-sector-${i}`,
3756
3692
  tangramId: `prep-sector-${i}`,
3757
3693
  // dummy value since prep mode doesn't have tangrams
@@ -3835,6 +3771,8 @@ function startPrepTrial(display_element, params, jsPsych) {
3835
3771
  // Enable prep-specific behavior
3836
3772
  minPiecesPerMacro,
3837
3773
  requireAllSlots,
3774
+ trialParams,
3775
+ ...params.instructions && { instructions: params.instructions },
3838
3776
  onControllerReady: handleControllerReady,
3839
3777
  ...onInteraction && { onInteraction },
3840
3778
  ...onTrialEnd && { onTrialEnd }
@@ -3888,6 +3826,12 @@ const info = {
3888
3826
  default: ["square", "smalltriangle", "parallelogram", "medtriangle", "largetriangle"],
3889
3827
  description: "Array of primitive names in the order they should be displayed"
3890
3828
  },
3829
+ /** HTML content to display above the gameboard as instructions */
3830
+ instructions: {
3831
+ type: ParameterType.STRING,
3832
+ default: void 0,
3833
+ description: "HTML content to display above the gameboard as instructions"
3834
+ },
3891
3835
  /** Callback fired after each interaction (optional analytics hook) */
3892
3836
  onInteraction: {
3893
3837
  type: ParameterType.FUNCTION,
@@ -3939,6 +3883,7 @@ class TangramPrepPlugin {
3939
3883
  requireAllSlots: trial.require_all_slots,
3940
3884
  quickstashMacros: trial.quickstash_macros,
3941
3885
  primitiveOrder: trial.primitive_order,
3886
+ instructions: trial.instructions,
3942
3887
  onInteraction: trial.onInteraction,
3943
3888
  onTrialEnd: wrappedOnTrialEnd
3944
3889
  };