jspsych-tangram 0.0.7 → 0.0.9

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 (40) hide show
  1. package/dist/construct/index.browser.js +187 -214
  2. package/dist/construct/index.browser.js.map +1 -1
  3. package/dist/construct/index.browser.min.js +13 -10
  4. package/dist/construct/index.browser.min.js.map +1 -1
  5. package/dist/construct/index.cjs +187 -214
  6. package/dist/construct/index.cjs.map +1 -1
  7. package/dist/construct/index.d.ts +24 -0
  8. package/dist/construct/index.js +187 -214
  9. package/dist/construct/index.js.map +1 -1
  10. package/dist/index.cjs +198 -215
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts +36 -0
  13. package/dist/index.js +198 -215
  14. package/dist/index.js.map +1 -1
  15. package/dist/prep/index.browser.js +173 -213
  16. package/dist/prep/index.browser.js.map +1 -1
  17. package/dist/prep/index.browser.min.js +14 -11
  18. package/dist/prep/index.browser.min.js.map +1 -1
  19. package/dist/prep/index.cjs +173 -213
  20. package/dist/prep/index.cjs.map +1 -1
  21. package/dist/prep/index.d.ts +12 -0
  22. package/dist/prep/index.js +173 -213
  23. package/dist/prep/index.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/assets/README.md +6 -0
  26. package/src/assets/images.d.ts +19 -0
  27. package/src/assets/locked.png +0 -0
  28. package/src/assets/unlocked.png +0 -0
  29. package/src/core/components/board/BoardView.tsx +121 -39
  30. package/src/core/components/board/GameBoard.tsx +123 -7
  31. package/src/core/config/config.ts +8 -4
  32. package/src/core/domain/types.ts +1 -0
  33. package/src/core/io/InteractionTracker.ts +23 -24
  34. package/src/core/io/data-tracking.ts +6 -7
  35. package/src/plugins/tangram-construct/ConstructionApp.tsx +16 -1
  36. package/src/plugins/tangram-construct/index.ts +14 -0
  37. package/src/plugins/tangram-prep/PrepApp.tsx +6 -0
  38. package/src/plugins/tangram-prep/index.ts +7 -0
  39. package/tangram-construct.min.js +13 -10
  40. package/tangram-prep.min.js +14 -11
@@ -46,6 +46,12 @@ declare const info: {
46
46
  default: string[];
47
47
  description: string;
48
48
  };
49
+ /** HTML content to display above the gameboard as instructions */
50
+ instructions: {
51
+ type: ParameterType;
52
+ default: undefined;
53
+ description: string;
54
+ };
49
55
  /** Callback fired after each interaction (optional analytics hook) */
50
56
  onInteraction: {
51
57
  type: ParameterType;
@@ -121,6 +127,12 @@ declare class TangramPrepPlugin implements JsPsychPlugin<Info> {
121
127
  default: string[];
122
128
  description: string;
123
129
  };
130
+ /** HTML content to display above the gameboard as instructions */
131
+ instructions: {
132
+ type: ParameterType;
133
+ default: undefined;
134
+ description: string;
135
+ };
124
136
  /** Callback fired after each interaction (optional analytics hook) */
125
137
  onInteraction: {
126
138
  type: ParameterType;
@@ -14,7 +14,8 @@ const CONFIG = {
14
14
  anchors: { invalid: "#7dd3fc", valid: "#475569" },
15
15
  piece: { draggingFill: "#8e7cc3ff", validFill: "#8e7cc3ff", invalidFill: "#ef4444", invalidStroke: "#dc2626", selectedStroke: "#674ea7", allGreenStroke: "#86efac", borderStroke: "#674ea7" },
16
16
  ui: { light: "#60a5fa", dark: "#1d4ed8" },
17
- blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" }
17
+ blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" },
18
+ tangramDecomposition: { stroke: "#fef2cc" }
18
19
  },
19
20
  opacity: {
20
21
  blueprint: 0.4,
@@ -24,7 +25,7 @@ const CONFIG = {
24
25
  piece: { invalid: 0.35, dragging: 0.75, locked: 1, normal: 1 }
25
26
  },
26
27
  size: {
27
- stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2 },
28
+ stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2, tangramDecompositionPx: 1 },
28
29
  anchorRadiusPx: { valid: 1, invalid: 1 },
29
30
  badgeFontPx: 16,
30
31
  centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 }
@@ -32,6 +33,7 @@ const CONFIG = {
32
33
  layout: {
33
34
  grid: { stepPx: 20, unitPx: 40 },
34
35
  paddingPx: 1,
36
+ viewportScale: 0.8,
35
37
  constraints: {
36
38
  workspaceDiamAnchors: 10,
37
39
  // num anchors req'd to be on diagonal
@@ -43,7 +45,7 @@ const CONFIG = {
43
45
  },
44
46
  game: {
45
47
  snapRadiusPx: 15,
46
- showBorders: true,
48
+ showBorders: false,
47
49
  hideTouchingBorders: true
48
50
  }
49
51
  };
@@ -814,152 +816,17 @@ function rectForBand(layout, sector, band, pad = 0.85) {
814
816
  return { cx, cy, w, h };
815
817
  }
816
818
 
817
- function generateEdgeStrokePaths(poly) {
818
- const strokePaths = [];
819
- for (let i = 0; i < poly.length; i++) {
820
- const current = poly[i];
821
- const next = poly[(i + 1) % poly.length];
822
- if (current && next) {
823
- strokePaths.push(`M ${current.x} ${current.y} L ${next.x} ${next.y}`);
824
- }
825
- }
826
- return strokePaths;
827
- }
828
- function edgeToUnitSegments$1(start, end, gridSize) {
829
- const segments = [];
830
- const startGrid = {
831
- x: Math.round(start.x / gridSize),
832
- y: Math.round(start.y / gridSize)
833
- };
834
- const endGrid = {
835
- x: Math.round(end.x / gridSize),
836
- y: Math.round(end.y / gridSize)
837
- };
838
- const dx = endGrid.x - startGrid.x;
839
- const dy = endGrid.y - startGrid.y;
840
- if (dx === 0 && dy === 0) return [];
841
- const steps = Math.max(Math.abs(dx), Math.abs(dy));
842
- const stepX = dx / steps;
843
- const stepY = dy / steps;
844
- for (let i = 0; i < steps; i++) {
845
- const aX = Math.round(startGrid.x + i * stepX);
846
- const aY = Math.round(startGrid.y + i * stepY);
847
- const bX = Math.round(startGrid.x + (i + 1) * stepX);
848
- const bY = Math.round(startGrid.y + (i + 1) * stepY);
849
- segments.push({
850
- a: { x: aX, y: aY },
851
- b: { x: bX, y: bY }
852
- });
853
- }
854
- return segments;
855
- }
856
- function getHiddenEdgesForPolygon(piece, polyIndex, allPiecesInSector, getBlueprint, getPrimitive) {
857
- let blueprint;
858
- try {
859
- blueprint = getBlueprint(piece.blueprintId);
860
- } catch (error) {
861
- console.warn("getBlueprint failed in getHiddenEdgesForPolygon:", error);
862
- return [];
863
- }
864
- if (!blueprint?.shape) {
865
- return [];
866
- }
867
- const poly = blueprint.shape[polyIndex];
868
- if (!poly) return [];
869
- const gridSize = CONFIG.layout.grid.stepPx;
870
- const bb = boundsOfBlueprint(blueprint, getPrimitive);
871
- const ox = piece.pos.x - bb.min.x;
872
- const oy = piece.pos.y - bb.min.y;
873
- const translatedPoly = poly.map((vertex) => ({
874
- x: vertex.x + ox,
875
- y: vertex.y + oy
876
- }));
877
- const hiddenEdges = [];
878
- for (let i = 0; i < translatedPoly.length; i++) {
879
- const current = translatedPoly[i];
880
- const next = translatedPoly[(i + 1) % translatedPoly.length];
881
- if (!current || !next) {
882
- hiddenEdges.push(false);
883
- continue;
884
- }
885
- const edgeSegments = edgeToUnitSegments$1(current, next, gridSize);
886
- let isTouching = false;
887
- if (piece.blueprintId.startsWith("comp:")) {
888
- for (let otherPolyIndex = 0; otherPolyIndex < blueprint.shape.length; otherPolyIndex++) {
889
- if (otherPolyIndex === polyIndex) continue;
890
- const otherPoly = blueprint.shape[otherPolyIndex];
891
- if (!otherPoly) continue;
892
- const otherTranslatedPoly = otherPoly.map((vertex) => ({
893
- x: vertex.x + ox,
894
- y: vertex.y + oy
895
- }));
896
- for (let j = 0; j < otherTranslatedPoly.length; j++) {
897
- const otherCurrent = otherTranslatedPoly[j];
898
- const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
899
- if (!otherCurrent || !otherNext) continue;
900
- const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
901
- for (const edgeSeg of edgeSegments) {
902
- const isShared = otherEdgeSegments.some(
903
- (otherSeg) => (
904
- // Check if segments share the same endpoints (identical or reversed)
905
- edgeSeg.a.x === otherSeg.a.x && edgeSeg.a.y === otherSeg.a.y && edgeSeg.b.x === otherSeg.b.x && edgeSeg.b.y === otherSeg.b.y || edgeSeg.a.x === otherSeg.b.x && edgeSeg.a.y === otherSeg.b.y && edgeSeg.b.x === otherSeg.a.x && edgeSeg.b.y === otherSeg.a.y
906
- )
907
- );
908
- if (isShared) {
909
- isTouching = true;
910
- break;
911
- }
912
- }
913
- if (isTouching) break;
914
- }
915
- if (isTouching) break;
916
- }
917
- }
918
- if (!isTouching && CONFIG.game.hideTouchingBorders) {
919
- for (const otherPiece of allPiecesInSector) {
920
- if (otherPiece.id === piece.id) continue;
921
- const otherBlueprint = getBlueprint(otherPiece.blueprintId);
922
- if (!otherBlueprint?.shape) continue;
923
- const otherBb = boundsOfBlueprint(otherBlueprint, getPrimitive);
924
- const otherOx = otherPiece.pos.x - otherBb.min.x;
925
- const otherOy = otherPiece.pos.y - otherBb.min.y;
926
- for (const otherPoly of otherBlueprint.shape) {
927
- const otherTranslatedPoly = otherPoly.map((vertex) => ({
928
- x: vertex.x + otherOx,
929
- y: vertex.y + otherOy
930
- }));
931
- for (let j = 0; j < otherTranslatedPoly.length; j++) {
932
- const otherCurrent = otherTranslatedPoly[j];
933
- const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
934
- if (!otherCurrent || !otherNext) continue;
935
- const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
936
- for (const edgeSeg of edgeSegments) {
937
- const isShared = otherEdgeSegments.some(
938
- (otherSeg) => (
939
- // Check if segments share the same endpoints (identical or reversed)
940
- edgeSeg.a.x === otherSeg.a.x && edgeSeg.a.y === otherSeg.a.y && edgeSeg.b.x === otherSeg.b.x && edgeSeg.b.y === otherSeg.b.y || edgeSeg.a.x === otherSeg.b.x && edgeSeg.a.y === otherSeg.b.y && edgeSeg.b.x === otherSeg.a.x && edgeSeg.b.y === otherSeg.a.y
941
- )
942
- );
943
- if (isShared) {
944
- isTouching = true;
945
- break;
946
- }
947
- }
948
- if (isTouching) break;
949
- }
950
- if (isTouching) break;
951
- }
952
- if (isTouching) break;
953
- }
954
- }
955
- hiddenEdges.push(isTouching);
956
- }
957
- return hiddenEdges;
819
+ function shouldShowBorders() {
820
+ return CONFIG.game.showBorders;
958
821
  }
959
822
  function shouldUseSelectiveBorders(blueprintId) {
960
- return (CONFIG.game.hideTouchingBorders);
823
+ return CONFIG.game.showBorders;
961
824
  }
962
825
 
826
+ var lockedIcon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFoAAABaCAYAAAA4qEECAAAACXBIWXMAAAsSAAALEgHS3X78AAAFY0lEQVR4nO2d7XXrNgyGH/r0f7zB9QbXnaDqBt6gvhM0I2SE3A2UDXwnqLKBPEHdDewJ0B+kYieWZEoiQfrjPQcnjqOI0GuYAAFQMiJCLjDGLIEFsHQyB/7w/Pd397MCamAnInVgFUfDpCTaGLMACmDlfj4FHuKAJX4DVCKyC3x+b6gTbYyZA2sn31UHhy1QAqWI7FVHFhEVwVpsCUgmUgKF2vUrEVxlQGyXVBqE3zPBqoTHIHhOXlPEUCmBedZEY6OHfQZkTZU9sMqO6Buw4ujWHYLkJXaBkJqUWFIDy6REYx3eLUwVl2TPREc5heR1BgRoy1qV6DsleRLZD5KVyH6QrET2UMeX+uJyE28H6UvykvuILobKHs/Qz4fkObcdJ0+VGo9FzcV8tDGmBP7qPSg+DtgLqrFWBLYSs8C/AhMTbyKy7j3igjWvSPu1LPH4ajo9y4S6ChdyI5emjFTz8isjcgxYC98kNIxOnfuUTmEhk5e6Tvd1IiMpBxFNmlDOy6kMIDtVpNRqKK3O0BhToetk/sPOxZ0FU1cxX2CNAGxFZCc9lW3XvlARvrreh3cRKc7ezcSaOx0e1tH1hZc1PY6INA79zKrbFKuUlXrpccZDdKnomHqwzlXzmqpeotG35l0PyWMWSa3zPGkiqKKP6FJZmXUH0VVIa0pk1WUr0e5T11REaLe+dYwPEOtIk13fqSLPykp0Wd4uwLm7piTtnM1zM/aMI9boovr6hgvHvgU49zdjTNHyvnZ36bp5MYOPGFW74bDtoouA52871y7g+X3w3XH7YdGFsgJwzMKdYh55zCry+dtQwJHoVQIF7gUrSGvR94ICYOYckGYu4N7wZIxZzLDx5QNxsZxh04kPxMUS9JNINfBChB7kS4KNal4JsygaIhtNooNUTwKS/oxeoqky7kVsbB3JujuhLkCzMKBF9O+S0ebKU7il+j+xx5ldPmQyfuZKMoCIVMCv2ONoEP2qMMZUlLEHiD11HEQkdv4iCIwxUafQ2Bad7ZShjd9SK+CLkz3kTQJsQ4o93RMQNX4MFPN2NcN4t816jBE1ltZwhpPgLLmiPdZ9Aip3TNbInmjsdNG3oHhCvww3GDOOd27JFT5FidwLF+8z2ktKD4TFfkb+Idgm0DEpUV8L0Yeevx94ED0dYttyC9rJPmCzgjtFlcagnjkl+ywmOVxSasHn5M8vYJFzwsrhICK7JryrUmriA7cCPCW1vpJVYQXHODr3Oe6asYEj0VU6PW4eFTii3Ty9TajMrWLbOOrTJXiZRJXbRtm8eBAdF2Xz4oNo58HfUmhzo3g7jYq+Zu9KXV0Go+54nSPKT7+1JMArwiW86wiNL2sm3ERKKfF/VvA42zkbus9BREyoc8WC68r/N+Ap/3RtDB84S/y7A4LlqI0xueeKIWx/+PtXkoHzqeOkRpdV3TCmELbpsbWG2Td4yA2QwefUgCS/BLzO185xehSYB/6ksyObsLeX2zHmxihOkdB3Biixqc3UBC8If6eaom9Mn5tXvQJ/9x40HFtsGKmd5pxjHV/oPZU/ReS57wCvp1YYY2r0N3xeC7YicnF7im9fx4rMqzCJcMCz1cGLaDnW7R74jEI865XenUpia3M/xmp0g/ghQ+qViUOia5XBoWoO8ee1yaj1QC7B/rXIKJK94ug+JLqvXAo0jTqjc+CT2nbdwEtuu7C7xSaKphUaAi1pm62/qb/aoWXUzWiDztEdhD8e4aRB9I1YdzArjkr0CeGNo0xNnK9UBNp4pEr0CeFF5oRXXPODIzsILzMgtpFSg+BGHg/3VcLjcdVKSEr0Vzjil4x/AHvTrF5jm3d2wZUcif8BqSLxz8FiAOgAAAAASUVORK5CYII=";
827
+
828
+ var unlockedIcon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFoAAABaCAYAAAA4qEECAAAACXBIWXMAAAsSAAALEgHS3X78AAAFYklEQVR4nO1d0ZGjOBB9ct3/+iJYMlg2giODcwjeDCYEbwbeDNgMvBloIjgmAyYDHEHfh4SHsQUIaLVk7FfVNTblkZpHq1u0GqGICKlAKZUDyADkVrYA/vH891f7VwOoANREVDGrOBsqJtFKqQxAAWBn/35h7uIMQ/wJgCaimrl9b4gTrZTaAthb+SbaOfAGoARQElEj2jMRiQiMxZYAKBEpARRi5y9EsE6A2D7REoQ/MsGihIcgeIu0XMRUKQFskyYaZvbQJEDWUmkA7JIjegVWHNy6OUjOYW4QYpMSSioAeVSiYQLeGlzFmDRYGCiXkLxPgABp2YsS/aAkLyL7SbIQ2U+ShcieGvhin1xq4h0gvbJ3Nk+swZ/G9MEZZooFmKlkDB36cIYhezzv7WHJW8jOkxsAR5i7TOfNAszoOgKoBfXqk6pPz0muA3J3fDXmBBkTN2ITXi4i2lqVhKJHH6sYGXXHyGQP5kbGlJe465tsxSPWHYvoZshYYrsMNpITIbvXhfQpWwgo9eJB2AmfR1Vjjw1eoMhkF1OI1oGV0SNxofZoo8aAX7QXJAbRznOLZc1ZDzlzAtqxp60sEtEEh1XHsGanHwNwWNCm0w0h3mLEjVUnYc1M/d4k55GQVUtbQBUwJpx62q4jEV06iYaZN4fu/MafgtfqXKMlFtGEzrx6gw/sER6u5MuOsf1D94tS6gXAV8b2p2J/+dS58hKJo9tovCwIukTbNrXA+YxJ9cl1QC5ouIiONd+VkqzrOgrEQzI1zIFQALgQzeknh5AJ9ZMSdgDELTpzHDsJ9R0LRfshR4TgkNAUTMRPbyA7nL/ZxymuoQV1iIF8A2PRknDFg7W7j3wD+RnHTX9EdIJZUV4r8r8idPqvUmpLtw/rHBH+oscqV9gCskGhgblrY6+o9xWYmHSAfBWsWEenmAQ7CN9C9q5UpJMyNrEDhGsJDlTLdkC8wyTlZR+g9IR9wLRGYN+9Gf/JYhxSJRkArG7H0P1IWPTfKRMNXIo4/wvaB8IS/U5EWcD22aCUCmpwoV1HHbj9u4GEj2aBUmqnlKqUUmSlUkpJpXdZEHJao5mmYEOFNc4Cmhl9BJ9H65SJhl/NR5E40XoDcyuaMnzcwz60EgvRbJD+mp1PGjcLrcRCVPdAtM+IS31U3gXRPosCqS8cVG0gCJUy5Jp1DBX3ONchEwqGDdFHXYdmuGohUQD44zj+B3FrUnyggY8blqSHHhE1RLQD8LNz+CcR7VLPo8Byey8Wfc/QgCWazA6HbxGVWSveLLefch1lFFXWjbL98CQ6LMr2w4VoG1R+x9BmpfjdDdTXadJSVpdVo+x++UQ0EWl87MP8xHy8Wi4vcCX+DyKqzENbOnZG2nP/w/UB5w40SikN/53Ih8C+ZmgXUkHMu50zrhm+ElFx034P0Zyrwo+2Cv7dZQTONUP7w19MHd/Duh6Xjr96R9pANqut4FmavaqRUM1dz3lyZC8Hz7N3FdwO95cJV7MPXyFQCbQAJ/CUg+0HXaTHFefaq6hEQpYN3mrS0ZV4X6W4nqqtYRZS11Qf7bXw4LvBYGbJ5qy4fId8JRN3xf8ZplK2Hvuh93tYJAoB7xDOqZwL3iVhtsEfs1VaH374kgzAz0df+bg9eHzbPct+Mm8zA8ojkz2Z5NlEPzDZs0j2nnX0IfJ2x5Lw3764B4vqo23HOda9sPsGM4Vbli1kuglIYbfbELJoF2AWH91D+PMVThJEr8S62aw4KNEdwttAGZs4X9FgeFWTONEdwovECde45xdH9hBeJkBsK6UEwa08X+4rhOfrqoUQlehrWOJzzH8BewOTN69gEvI1u5Iz8T+TOcUtjzRRXwAAAABJRU5ErkJggg==";
829
+
963
830
  function pathD(poly) {
964
831
  return `M ${poly.map((pt) => `${pt.x} ${pt.y}`).join(" L ")} Z`;
965
832
  }
@@ -975,11 +842,13 @@ function BoardView(props) {
975
842
  placedSilBySector,
976
843
  anchorDots,
977
844
  pieces,
845
+ scaleS,
978
846
  clickMode,
979
847
  draggingId,
980
848
  selectedPieceId,
981
849
  dragInvalid,
982
850
  lockedPieceId,
851
+ showTangramDecomposition,
983
852
  svgRef,
984
853
  setPieceRef,
985
854
  onPiecePointerDown,
@@ -1045,7 +914,7 @@ function BoardView(props) {
1045
914
  onPointerDown: (e) => {
1046
915
  onRootPointerDown(e);
1047
916
  },
1048
- style: { background: "#fff", touchAction: "none", userSelect: "none" }
917
+ style: { background: "#f5f5f5", touchAction: "none", userSelect: "none" }
1049
918
  },
1050
919
  layout.sectors.map((s, i) => {
1051
920
  const done = !!controller.state.sectors[s.id].completedAt;
@@ -1065,11 +934,12 @@ function BoardView(props) {
1065
934
  const isDragging = draggingId === p.id;
1066
935
  const locked = p.sectorId && controller.isSectorCompleted(p.sectorId);
1067
936
  const isConnectivityLocked = lockedPieceId === p.id;
1068
- const isSelected = selectedPieceId === p.id;
937
+ selectedPieceId === p.id;
1069
938
  const isCarriedInvalid = isDragging && dragInvalid;
1070
939
  const translateX = p.x - bb.min.x;
1071
940
  const translateY = p.y - bb.min.y;
1072
941
  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) => {
942
+ const showBorders = shouldShowBorders();
1073
943
  shouldUseSelectiveBorders(p.blueprintId);
1074
944
  return /* @__PURE__ */ React.createElement(React.Fragment, { key: idx }, /* @__PURE__ */ React.createElement(
1075
945
  "path",
@@ -1080,51 +950,38 @@ function BoardView(props) {
1080
950
  stroke: "none",
1081
951
  onPointerDown: (e) => onPiecePointerDown(e, p)
1082
952
  }
1083
- ), ((
1084
- // For pieces with selective borders: render individual edge strokes with edge detection
1085
- (() => {
1086
- const allPiecesInSector = pieces.filter((piece) => piece.sectorId === p.sectorId);
1087
- const pieceAsPiece = { ...p, pos: { x: p.x, y: p.y } };
1088
- const allPiecesAsPieces = allPiecesInSector.map((piece) => ({ ...piece, pos: { x: piece.x, y: piece.y } }));
1089
- const hiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, allPiecesAsPieces, (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1090
- const draggedPiece = draggingId ? allPiecesInSector.find((piece) => piece.id === draggingId) : null;
1091
- let wasTouchingDraggedPiece;
1092
- if (p.blueprintId.startsWith("comp:")) {
1093
- const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1094
- const externalHiddenEdges = draggedPiece ? getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) : new Array(hiddenEdges.length).fill(false);
1095
- wasTouchingDraggedPiece = externalHiddenEdges.map((external, i) => external && !internalHiddenEdges[i]);
1096
- } else {
1097
- wasTouchingDraggedPiece = draggedPiece ? getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) : new Array(hiddenEdges.length).fill(false);
1098
- }
1099
- return generateEdgeStrokePaths(poly).map((strokePath, strokeIdx) => {
1100
- const wasHiddenDueToDraggedPiece = wasTouchingDraggedPiece[strokeIdx] || false;
1101
- let isHidden;
1102
- if (isDragging && p.blueprintId.startsWith("comp:")) {
1103
- const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1104
- isHidden = internalHiddenEdges[strokeIdx] || false;
1105
- } else {
1106
- isHidden = isDragging ? false : (hiddenEdges[strokeIdx] || false) && !wasHiddenDueToDraggedPiece;
1107
- }
1108
- return /* @__PURE__ */ React.createElement(
1109
- "path",
1110
- {
1111
- key: `stroke-${idx}-${strokeIdx}`,
1112
- d: strokePath,
1113
- fill: "none",
1114
- stroke: isHidden ? "none" : isConnectivityLocked ? CONFIG.color.piece.invalidStroke : isCarriedInvalid ? CONFIG.color.piece.invalidStroke : isSelected || isDragging ? CONFIG.color.piece.selectedStroke : CONFIG.color.piece.borderStroke,
1115
- strokeWidth: isHidden ? 0 : isSelected || isDragging ? CONFIG.size.stroke.pieceSelectedPx : CONFIG.size.stroke.pieceBorderPx,
1116
- onPointerDown: (e) => onPiecePointerDown(e, p)
1117
- }
1118
- );
1119
- });
1120
- })()
1121
- ) ));
953
+ ), showBorders);
1122
954
  }));
1123
955
  }),
1124
956
  layout.sectors.map((s) => {
1125
- const placedPolys = placedSilBySector.get(s.id) ?? [];
1126
- if (!placedPolys.length) return null;
1127
- return /* @__PURE__ */ React.createElement("g", { key: `sil-${s.id}`, pointerEvents: "none" }, placedPolys.map((poly, i) => /* @__PURE__ */ React.createElement("path", { key: i, d: pathD(poly), fill: CONFIG.color.silhouetteMask, opacity: CONFIG.opacity.silhouetteMask })));
957
+ const sectorCfg = controller.state.cfg.sectors.find((ss) => ss.id === s.id);
958
+ if (showTangramDecomposition && sectorCfg?.silhouette.primitiveDecomposition) {
959
+ const primitiveDecomposition = sectorCfg.silhouette.primitiveDecomposition;
960
+ const rect = rectForBand(layout, s, "silhouette", 1);
961
+ const rawPolys = primitiveDecomposition.map((primInfo) => primInfo.polygon);
962
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
963
+ 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(
964
+ "path",
965
+ {
966
+ d: pathD(scaledPoly),
967
+ fill: CONFIG.color.silhouetteMask,
968
+ opacity: CONFIG.opacity.silhouetteMask,
969
+ stroke: "none"
970
+ }
971
+ ), /* @__PURE__ */ React.createElement(
972
+ "path",
973
+ {
974
+ d: pathD(scaledPoly),
975
+ fill: "none",
976
+ stroke: CONFIG.color.tangramDecomposition.stroke,
977
+ strokeWidth: CONFIG.size.stroke.tangramDecompositionPx
978
+ }
979
+ ))));
980
+ } else {
981
+ const placedPolys = placedSilBySector.get(s.id) ?? [];
982
+ if (!placedPolys.length) return null;
983
+ 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 })));
984
+ }
1128
985
  }),
1129
986
  anchorDots.map(({ sectorId, valid, invalid }) => {
1130
987
  const isInnerRing = sectorId === "inner-ring";
@@ -1140,10 +997,20 @@ function BoardView(props) {
1140
997
  }
1141
998
  )));
1142
999
  }),
1000
+ /* @__PURE__ */ React.createElement("defs", null, /* @__PURE__ */ React.createElement("filter", { id: "invert-to-white" }, /* @__PURE__ */ React.createElement(
1001
+ "feColorMatrix",
1002
+ {
1003
+ type: "matrix",
1004
+ values: "-1 0 0 0 1\n 0 -1 0 0 1\n 0 0 -1 0 1\n 0 0 0 1 0"
1005
+ }
1006
+ ))),
1143
1007
  (() => {
1144
1008
  const isPrep = controller.state.cfg.mode === "prep";
1145
1009
  const isSubmitEnabled = isPrep ? controller.isSubmitEnabled() : true;
1146
1010
  const isClickable = !draggingId && (!isPrep || isSubmitEnabled);
1011
+ const [imageError, setImageError] = React.useState(false);
1012
+ const iconSize = badgeR * 1.6;
1013
+ const iconOffset = iconSize / 2;
1147
1014
  return /* @__PURE__ */ React.createElement(
1148
1015
  "g",
1149
1016
  {
@@ -1159,7 +1026,7 @@ function BoardView(props) {
1159
1026
  opacity: isSubmitEnabled ? 1 : 0.5
1160
1027
  }
1161
1028
  ),
1162
- /* @__PURE__ */ React.createElement(
1029
+ isPrep ? /* @__PURE__ */ React.createElement(
1163
1030
  "text",
1164
1031
  {
1165
1032
  textAnchor: "middle",
@@ -1168,7 +1035,29 @@ function BoardView(props) {
1168
1035
  fill: isSubmitEnabled ? CONFIG.color.blueprint.labelFill : "#888",
1169
1036
  pointerEvents: "none"
1170
1037
  },
1171
- isPrep ? "Submit" : controller.state.blueprintView
1038
+ "Submit"
1039
+ ) : imageError ? /* @__PURE__ */ React.createElement(
1040
+ "text",
1041
+ {
1042
+ textAnchor: "middle",
1043
+ dominantBaseline: "middle",
1044
+ fontSize: CONFIG.size.badgeFontPx,
1045
+ fill: CONFIG.color.blueprint.labelFill,
1046
+ pointerEvents: "none"
1047
+ },
1048
+ "inventory"
1049
+ ) : /* @__PURE__ */ React.createElement(
1050
+ "image",
1051
+ {
1052
+ href: controller.state.blueprintView === "quickstash" ? lockedIcon : unlockedIcon,
1053
+ x: -iconOffset,
1054
+ y: -iconOffset,
1055
+ width: iconSize,
1056
+ height: iconSize,
1057
+ pointerEvents: "none",
1058
+ onError: () => setImageError(true),
1059
+ filter: "url(#invert-to-white)"
1060
+ }
1172
1061
  )
1173
1062
  );
1174
1063
  })(),
@@ -2586,7 +2475,7 @@ function useClickController(controller, layout, pieces, clickMode, draggingId, s
2586
2475
  }
2587
2476
 
2588
2477
  class InteractionTracker {
2589
- constructor(controller, callbacks, trialId, gameId) {
2478
+ constructor(controller, callbacks, trialParams) {
2590
2479
  this.gridStep = CONFIG.layout.grid.stepPx;
2591
2480
  // Interaction state
2592
2481
  this.interactionIndex = 0;
@@ -2603,8 +2492,7 @@ class InteractionTracker {
2603
2492
  this.createdMacros = [];
2604
2493
  this.controller = controller;
2605
2494
  this.callbacks = callbacks;
2606
- this.trialId = trialId || v4();
2607
- this.gameId = gameId || v4();
2495
+ this.trialParams = trialParams;
2608
2496
  this.trialStartTime = Date.now();
2609
2497
  this.controller.setTrackingCallbacks({
2610
2498
  onSectorCompleted: (sectorId) => this.recordSectorCompletion(sectorId)
@@ -2652,8 +2540,6 @@ class InteractionTracker {
2652
2540
  const event = {
2653
2541
  // Metadata
2654
2542
  interactionId: v4(),
2655
- trialId: this.trialId,
2656
- gameId: this.gameId,
2657
2543
  interactionIndex: this.interactionIndex++,
2658
2544
  // Interaction type
2659
2545
  interactionType,
@@ -2759,6 +2645,7 @@ class InteractionTracker {
2759
2645
  const trialEndTime = Date.now();
2760
2646
  const totalDuration = trialEndTime - this.trialStartTime;
2761
2647
  const finalSnapshot = this.buildStateSnapshot();
2648
+ const anchorToStimuliRatio = CONFIG.layout.grid.stepPx / CONFIG.layout.grid.unitPx;
2762
2649
  const mode = this.controller.state.cfg.mode;
2763
2650
  if (mode === "construction") {
2764
2651
  const finalBlueprintState = this.buildFinalBlueprintState(finalSnapshot);
@@ -2770,13 +2657,11 @@ class InteractionTracker {
2770
2657
  }));
2771
2658
  const data = {
2772
2659
  trialType: "construction",
2773
- trialId: this.trialId,
2774
- gameId: this.gameId,
2775
- trialNum: 0,
2776
- // TODO: Plugin should provide this
2777
2660
  trialStartTime: this.trialStartTime,
2778
2661
  trialEndTime,
2779
2662
  totalDuration,
2663
+ anchorToStimuliRatio,
2664
+ trialParams: this.trialParams,
2780
2665
  endReason,
2781
2666
  completionTimes: this.completionTimes,
2782
2667
  finalBlueprintState,
@@ -2795,13 +2680,11 @@ class InteractionTracker {
2795
2680
  }));
2796
2681
  const data = {
2797
2682
  trialType: "prep",
2798
- trialId: this.trialId,
2799
- gameId: this.gameId,
2800
- trialNum: 0,
2801
- // TODO: Plugin should provide this
2802
2683
  trialStartTime: this.trialStartTime,
2803
2684
  trialEndTime,
2804
2685
  totalDuration,
2686
+ anchorToStimuliRatio,
2687
+ trialParams: this.trialParams,
2805
2688
  endReason: "submit",
2806
2689
  createdMacros: finalMacros,
2807
2690
  quickstashMacros,
@@ -3059,6 +2942,9 @@ function GameBoard(props) {
3059
2942
  maxQuickstashSlots,
3060
2943
  maxCompositeSize,
3061
2944
  mode,
2945
+ showTangramDecomposition,
2946
+ instructions,
2947
+ trialParams,
3062
2948
  width: _width,
3063
2949
  height: _height,
3064
2950
  onSectorComplete,
@@ -3068,6 +2954,7 @@ function GameBoard(props) {
3068
2954
  onTrialEnd,
3069
2955
  onControllerReady
3070
2956
  } = props;
2957
+ const [timeRemaining, setTimeRemaining] = React.useState(timeLimitMs > 0 ? Math.floor(timeLimitMs / 1e3) : 0);
3071
2958
  const controller = React.useMemo(() => {
3072
2959
  const gameConfig = {
3073
2960
  n: sectors.length,
@@ -3094,8 +2981,8 @@ function GameBoard(props) {
3094
2981
  const callbacks = {};
3095
2982
  if (onInteraction) callbacks.onInteraction = onInteraction;
3096
2983
  if (onTrialEnd) callbacks.onTrialEnd = onTrialEnd;
3097
- return new InteractionTracker(controller, callbacks);
3098
- }, [controller, onInteraction, onTrialEnd]);
2984
+ return new InteractionTracker(controller, callbacks, trialParams);
2985
+ }, [controller, onInteraction, onTrialEnd, trialParams]);
3099
2986
  React.useEffect(() => {
3100
2987
  if (onControllerReady) {
3101
2988
  onControllerReady(controller);
@@ -3175,22 +3062,22 @@ function GameBoard(props) {
3175
3062
  }), [handleSectorComplete, onPiecePlace, onPieceRemove]);
3176
3063
  const getGameboardStyle = () => {
3177
3064
  const baseStyle = {
3178
- margin: "0 auto",
3065
+ margin: "10px",
3179
3066
  display: "flex",
3180
3067
  alignItems: "center",
3181
3068
  justifyContent: "center",
3182
3069
  position: "relative"
3183
3070
  };
3184
3071
  if (layoutMode === "circle") {
3185
- const size = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96);
3072
+ const size = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale);
3186
3073
  return {
3187
3074
  ...baseStyle,
3188
3075
  width: `${size}px`,
3189
3076
  height: `${size}px`
3190
3077
  };
3191
3078
  } else {
3192
- const maxWidth = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96 * 2);
3193
- const maxHeight = Math.min(window.innerWidth * 0.96 / 2, window.innerHeight * 0.96);
3079
+ const maxWidth = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale * 2);
3080
+ const maxHeight = Math.min(window.innerWidth * CONFIG.layout.viewportScale / 2, window.innerHeight * CONFIG.layout.viewportScale);
3194
3081
  return {
3195
3082
  ...baseStyle,
3196
3083
  width: `${maxWidth}px`,
@@ -3359,7 +3246,68 @@ function GameBoard(props) {
3359
3246
  force();
3360
3247
  e.stopPropagation();
3361
3248
  };
3362
- return /* @__PURE__ */ React.createElement("div", { className: "tangram-gameboard", style: getGameboardStyle() }, /* @__PURE__ */ React.createElement(
3249
+ React.useEffect(() => {
3250
+ if (timeLimitMs === 0) return;
3251
+ const interval = setInterval(() => {
3252
+ setTimeRemaining((prev) => {
3253
+ if (prev <= 1) {
3254
+ clearInterval(interval);
3255
+ return 0;
3256
+ }
3257
+ return prev - 1;
3258
+ });
3259
+ }, 1e3);
3260
+ return () => clearInterval(interval);
3261
+ }, [timeLimitMs]);
3262
+ const formatTime = (seconds) => {
3263
+ const mins = Math.floor(seconds / 60);
3264
+ const secs = Math.floor(seconds % 60);
3265
+ return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
3266
+ };
3267
+ const containerStyle = {
3268
+ display: "flex",
3269
+ flexDirection: "column",
3270
+ width: "100%"
3271
+ // minHeight: '100vh'
3272
+ };
3273
+ const headerStyle = {
3274
+ display: "flex",
3275
+ flexDirection: "row",
3276
+ justifyContent: "space-between",
3277
+ alignItems: "center",
3278
+ padding: "20px",
3279
+ background: "#f5f5f5",
3280
+ flex: "0 0 auto"
3281
+ };
3282
+ const instructionsStyle = {
3283
+ flexGrow: 1,
3284
+ fontSize: "20px",
3285
+ lineHeight: 1.5,
3286
+ marginRight: "20px"
3287
+ };
3288
+ const timerStyle = {
3289
+ fontSize: "24px",
3290
+ fontWeight: "bold",
3291
+ fontFamily: "monospace",
3292
+ color: "#333",
3293
+ minWidth: "80px",
3294
+ textAlign: "right"
3295
+ };
3296
+ const gameboardWrapperStyle = {
3297
+ flex: "1 1 auto",
3298
+ display: "flex",
3299
+ alignItems: "center",
3300
+ justifyContent: "center",
3301
+ overflow: "hidden"
3302
+ };
3303
+ 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(
3304
+ "div",
3305
+ {
3306
+ className: "tangram-instructions",
3307
+ style: instructionsStyle,
3308
+ dangerouslySetInnerHTML: { __html: instructions }
3309
+ }
3310
+ ), timeLimitMs > 0 && /* @__PURE__ */ React.createElement("div", { className: "tangram-timer", style: timerStyle }, formatTime(timeRemaining))), /* @__PURE__ */ React.createElement("div", { className: "tangram-gameboard-wrapper", style: gameboardWrapperStyle }, /* @__PURE__ */ React.createElement("div", { className: "tangram-gameboard", style: getGameboardStyle() }, /* @__PURE__ */ React.createElement(
3363
3311
  BoardView,
3364
3312
  {
3365
3313
  controller,
@@ -3385,9 +3333,11 @@ function GameBoard(props) {
3385
3333
  onPointerMove,
3386
3334
  onPointerUp,
3387
3335
  onCenterBadgePointerDown,
3336
+ showTangramDecomposition: showTangramDecomposition ?? false,
3337
+ scaleS,
3388
3338
  ...eventCallbacks
3389
3339
  }
3390
- ));
3340
+ ))));
3391
3341
  }
3392
3342
 
3393
3343
  const U = 40;
@@ -3553,8 +3503,9 @@ function startPrepTrial(display_element, params, jsPsych) {
3553
3503
  onInteraction,
3554
3504
  onTrialEnd
3555
3505
  } = params;
3506
+ const { onInteraction: _, onTrialEnd: __, ...trialParams } = params;
3556
3507
  const PRIMITIVE_BLUEPRINTS_ORDERED = [...PRIMITIVE_BLUEPRINTS].sort((a, b) => primitiveOrder.indexOf(a.kind) - primitiveOrder.indexOf(b.kind));
3557
- const prepSectors = Array.from({ length: numQuickstashSlots }, (_, i) => ({
3508
+ const prepSectors = Array.from({ length: numQuickstashSlots }, (_2, i) => ({
3558
3509
  id: `prep-sector-${i}`,
3559
3510
  tangramId: `prep-sector-${i}`,
3560
3511
  // dummy value since prep mode doesn't have tangrams
@@ -3638,6 +3589,8 @@ function startPrepTrial(display_element, params, jsPsych) {
3638
3589
  // Enable prep-specific behavior
3639
3590
  minPiecesPerMacro,
3640
3591
  requireAllSlots,
3592
+ trialParams,
3593
+ ...params.instructions && { instructions: params.instructions },
3641
3594
  onControllerReady: handleControllerReady,
3642
3595
  ...onInteraction && { onInteraction },
3643
3596
  ...onTrialEnd && { onTrialEnd }
@@ -3691,6 +3644,12 @@ const info = {
3691
3644
  default: ["square", "smalltriangle", "parallelogram", "medtriangle", "largetriangle"],
3692
3645
  description: "Array of primitive names in the order they should be displayed"
3693
3646
  },
3647
+ /** HTML content to display above the gameboard as instructions */
3648
+ instructions: {
3649
+ type: ParameterType.STRING,
3650
+ default: void 0,
3651
+ description: "HTML content to display above the gameboard as instructions"
3652
+ },
3694
3653
  /** Callback fired after each interaction (optional analytics hook) */
3695
3654
  onInteraction: {
3696
3655
  type: ParameterType.FUNCTION,
@@ -3742,6 +3701,7 @@ class TangramPrepPlugin {
3742
3701
  requireAllSlots: trial.require_all_slots,
3743
3702
  quickstashMacros: trial.quickstash_macros,
3744
3703
  primitiveOrder: trial.primitive_order,
3704
+ instructions: trial.instructions,
3745
3705
  onInteraction: trial.onInteraction,
3746
3706
  onTrialEnd: wrappedOnTrialEnd
3747
3707
  };