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
@@ -16,7 +16,8 @@ const CONFIG = {
16
16
  anchors: { invalid: "#7dd3fc", valid: "#475569" },
17
17
  piece: { draggingFill: "#8e7cc3ff", validFill: "#8e7cc3ff", invalidFill: "#ef4444", invalidStroke: "#dc2626", selectedStroke: "#674ea7", allGreenStroke: "#86efac", borderStroke: "#674ea7" },
18
18
  ui: { light: "#60a5fa", dark: "#1d4ed8" },
19
- blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" }
19
+ blueprint: { fill: "#374151", selectedStroke: "#111827", badgeFill: "#000000", labelFill: "#ffffff" },
20
+ tangramDecomposition: { stroke: "#fef2cc" }
20
21
  },
21
22
  opacity: {
22
23
  blueprint: 0.4,
@@ -26,7 +27,7 @@ const CONFIG = {
26
27
  piece: { invalid: 0.35, dragging: 0.75, locked: 1, normal: 1 }
27
28
  },
28
29
  size: {
29
- stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2 },
30
+ stroke: { bandPx: 5, pieceSelectedPx: 3, allGreenStrokePx: 10, pieceBorderPx: 2, tangramDecompositionPx: 1 },
30
31
  anchorRadiusPx: { valid: 1, invalid: 1 },
31
32
  badgeFontPx: 16,
32
33
  centerBadge: { fractionOfOuterR: 0.15, minPx: 20, marginPx: 4 }
@@ -34,6 +35,7 @@ const CONFIG = {
34
35
  layout: {
35
36
  grid: { stepPx: 20, unitPx: 40 },
36
37
  paddingPx: 1,
38
+ viewportScale: 0.8,
37
39
  constraints: {
38
40
  workspaceDiamAnchors: 10,
39
41
  // num anchors req'd to be on diagonal
@@ -45,7 +47,7 @@ const CONFIG = {
45
47
  },
46
48
  game: {
47
49
  snapRadiusPx: 15,
48
- showBorders: true,
50
+ showBorders: false,
49
51
  hideTouchingBorders: true
50
52
  }
51
53
  };
@@ -816,152 +818,17 @@ function rectForBand(layout, sector, band, pad = 0.85) {
816
818
  return { cx, cy, w, h };
817
819
  }
818
820
 
819
- function generateEdgeStrokePaths(poly) {
820
- const strokePaths = [];
821
- for (let i = 0; i < poly.length; i++) {
822
- const current = poly[i];
823
- const next = poly[(i + 1) % poly.length];
824
- if (current && next) {
825
- strokePaths.push(`M ${current.x} ${current.y} L ${next.x} ${next.y}`);
826
- }
827
- }
828
- return strokePaths;
829
- }
830
- function edgeToUnitSegments$1(start, end, gridSize) {
831
- const segments = [];
832
- const startGrid = {
833
- x: Math.round(start.x / gridSize),
834
- y: Math.round(start.y / gridSize)
835
- };
836
- const endGrid = {
837
- x: Math.round(end.x / gridSize),
838
- y: Math.round(end.y / gridSize)
839
- };
840
- const dx = endGrid.x - startGrid.x;
841
- const dy = endGrid.y - startGrid.y;
842
- if (dx === 0 && dy === 0) return [];
843
- const steps = Math.max(Math.abs(dx), Math.abs(dy));
844
- const stepX = dx / steps;
845
- const stepY = dy / steps;
846
- for (let i = 0; i < steps; i++) {
847
- const aX = Math.round(startGrid.x + i * stepX);
848
- const aY = Math.round(startGrid.y + i * stepY);
849
- const bX = Math.round(startGrid.x + (i + 1) * stepX);
850
- const bY = Math.round(startGrid.y + (i + 1) * stepY);
851
- segments.push({
852
- a: { x: aX, y: aY },
853
- b: { x: bX, y: bY }
854
- });
855
- }
856
- return segments;
857
- }
858
- function getHiddenEdgesForPolygon(piece, polyIndex, allPiecesInSector, getBlueprint, getPrimitive) {
859
- let blueprint;
860
- try {
861
- blueprint = getBlueprint(piece.blueprintId);
862
- } catch (error) {
863
- console.warn("getBlueprint failed in getHiddenEdgesForPolygon:", error);
864
- return [];
865
- }
866
- if (!blueprint?.shape) {
867
- return [];
868
- }
869
- const poly = blueprint.shape[polyIndex];
870
- if (!poly) return [];
871
- const gridSize = CONFIG.layout.grid.stepPx;
872
- const bb = boundsOfBlueprint(blueprint, getPrimitive);
873
- const ox = piece.pos.x - bb.min.x;
874
- const oy = piece.pos.y - bb.min.y;
875
- const translatedPoly = poly.map((vertex) => ({
876
- x: vertex.x + ox,
877
- y: vertex.y + oy
878
- }));
879
- const hiddenEdges = [];
880
- for (let i = 0; i < translatedPoly.length; i++) {
881
- const current = translatedPoly[i];
882
- const next = translatedPoly[(i + 1) % translatedPoly.length];
883
- if (!current || !next) {
884
- hiddenEdges.push(false);
885
- continue;
886
- }
887
- const edgeSegments = edgeToUnitSegments$1(current, next, gridSize);
888
- let isTouching = false;
889
- if (piece.blueprintId.startsWith("comp:")) {
890
- for (let otherPolyIndex = 0; otherPolyIndex < blueprint.shape.length; otherPolyIndex++) {
891
- if (otherPolyIndex === polyIndex) continue;
892
- const otherPoly = blueprint.shape[otherPolyIndex];
893
- if (!otherPoly) continue;
894
- const otherTranslatedPoly = otherPoly.map((vertex) => ({
895
- x: vertex.x + ox,
896
- y: vertex.y + oy
897
- }));
898
- for (let j = 0; j < otherTranslatedPoly.length; j++) {
899
- const otherCurrent = otherTranslatedPoly[j];
900
- const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
901
- if (!otherCurrent || !otherNext) continue;
902
- const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
903
- for (const edgeSeg of edgeSegments) {
904
- const isShared = otherEdgeSegments.some(
905
- (otherSeg) => (
906
- // Check if segments share the same endpoints (identical or reversed)
907
- edgeSeg.a.x === otherSeg.a.x && edgeSeg.a.y === otherSeg.a.y && edgeSeg.b.x === otherSeg.b.x && edgeSeg.b.y === otherSeg.b.y || edgeSeg.a.x === otherSeg.b.x && edgeSeg.a.y === otherSeg.b.y && edgeSeg.b.x === otherSeg.a.x && edgeSeg.b.y === otherSeg.a.y
908
- )
909
- );
910
- if (isShared) {
911
- isTouching = true;
912
- break;
913
- }
914
- }
915
- if (isTouching) break;
916
- }
917
- if (isTouching) break;
918
- }
919
- }
920
- if (!isTouching && CONFIG.game.hideTouchingBorders) {
921
- for (const otherPiece of allPiecesInSector) {
922
- if (otherPiece.id === piece.id) continue;
923
- const otherBlueprint = getBlueprint(otherPiece.blueprintId);
924
- if (!otherBlueprint?.shape) continue;
925
- const otherBb = boundsOfBlueprint(otherBlueprint, getPrimitive);
926
- const otherOx = otherPiece.pos.x - otherBb.min.x;
927
- const otherOy = otherPiece.pos.y - otherBb.min.y;
928
- for (const otherPoly of otherBlueprint.shape) {
929
- const otherTranslatedPoly = otherPoly.map((vertex) => ({
930
- x: vertex.x + otherOx,
931
- y: vertex.y + otherOy
932
- }));
933
- for (let j = 0; j < otherTranslatedPoly.length; j++) {
934
- const otherCurrent = otherTranslatedPoly[j];
935
- const otherNext = otherTranslatedPoly[(j + 1) % otherTranslatedPoly.length];
936
- if (!otherCurrent || !otherNext) continue;
937
- const otherEdgeSegments = edgeToUnitSegments$1(otherCurrent, otherNext, gridSize);
938
- for (const edgeSeg of edgeSegments) {
939
- const isShared = otherEdgeSegments.some(
940
- (otherSeg) => (
941
- // Check if segments share the same endpoints (identical or reversed)
942
- edgeSeg.a.x === otherSeg.a.x && edgeSeg.a.y === otherSeg.a.y && edgeSeg.b.x === otherSeg.b.x && edgeSeg.b.y === otherSeg.b.y || edgeSeg.a.x === otherSeg.b.x && edgeSeg.a.y === otherSeg.b.y && edgeSeg.b.x === otherSeg.a.x && edgeSeg.b.y === otherSeg.a.y
943
- )
944
- );
945
- if (isShared) {
946
- isTouching = true;
947
- break;
948
- }
949
- }
950
- if (isTouching) break;
951
- }
952
- if (isTouching) break;
953
- }
954
- if (isTouching) break;
955
- }
956
- }
957
- hiddenEdges.push(isTouching);
958
- }
959
- return hiddenEdges;
821
+ function shouldShowBorders() {
822
+ return CONFIG.game.showBorders;
960
823
  }
961
824
  function shouldUseSelectiveBorders(blueprintId) {
962
- return (CONFIG.game.hideTouchingBorders);
825
+ return CONFIG.game.showBorders;
963
826
  }
964
827
 
828
+ 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=";
829
+
830
+ 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==";
831
+
965
832
  function pathD(poly) {
966
833
  return `M ${poly.map((pt) => `${pt.x} ${pt.y}`).join(" L ")} Z`;
967
834
  }
@@ -977,11 +844,13 @@ function BoardView(props) {
977
844
  placedSilBySector,
978
845
  anchorDots,
979
846
  pieces,
847
+ scaleS,
980
848
  clickMode,
981
849
  draggingId,
982
850
  selectedPieceId,
983
851
  dragInvalid,
984
852
  lockedPieceId,
853
+ showTangramDecomposition,
985
854
  svgRef,
986
855
  setPieceRef,
987
856
  onPiecePointerDown,
@@ -1047,7 +916,7 @@ function BoardView(props) {
1047
916
  onPointerDown: (e) => {
1048
917
  onRootPointerDown(e);
1049
918
  },
1050
- style: { background: "#fff", touchAction: "none", userSelect: "none" }
919
+ style: { background: "#f5f5f5", touchAction: "none", userSelect: "none" }
1051
920
  },
1052
921
  layout.sectors.map((s, i) => {
1053
922
  const done = !!controller.state.sectors[s.id].completedAt;
@@ -1067,11 +936,12 @@ function BoardView(props) {
1067
936
  const isDragging = draggingId === p.id;
1068
937
  const locked = p.sectorId && controller.isSectorCompleted(p.sectorId);
1069
938
  const isConnectivityLocked = lockedPieceId === p.id;
1070
- const isSelected = selectedPieceId === p.id;
939
+ selectedPieceId === p.id;
1071
940
  const isCarriedInvalid = isDragging && dragInvalid;
1072
941
  const translateX = p.x - bb.min.x;
1073
942
  const translateY = p.y - bb.min.y;
1074
943
  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) => {
944
+ const showBorders = shouldShowBorders();
1075
945
  shouldUseSelectiveBorders(p.blueprintId);
1076
946
  return /* @__PURE__ */ React.createElement(React.Fragment, { key: idx }, /* @__PURE__ */ React.createElement(
1077
947
  "path",
@@ -1082,51 +952,38 @@ function BoardView(props) {
1082
952
  stroke: "none",
1083
953
  onPointerDown: (e) => onPiecePointerDown(e, p)
1084
954
  }
1085
- ), ((
1086
- // For pieces with selective borders: render individual edge strokes with edge detection
1087
- (() => {
1088
- const allPiecesInSector = pieces.filter((piece) => piece.sectorId === p.sectorId);
1089
- const pieceAsPiece = { ...p, pos: { x: p.x, y: p.y } };
1090
- const allPiecesAsPieces = allPiecesInSector.map((piece) => ({ ...piece, pos: { x: piece.x, y: piece.y } }));
1091
- const hiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, allPiecesAsPieces, (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1092
- const draggedPiece = draggingId ? allPiecesInSector.find((piece) => piece.id === draggingId) : null;
1093
- let wasTouchingDraggedPiece;
1094
- if (p.blueprintId.startsWith("comp:")) {
1095
- const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1096
- const externalHiddenEdges = draggedPiece ? getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) : new Array(hiddenEdges.length).fill(false);
1097
- wasTouchingDraggedPiece = externalHiddenEdges.map((external, i) => external && !internalHiddenEdges[i]);
1098
- } else {
1099
- wasTouchingDraggedPiece = draggedPiece ? getHiddenEdgesForPolygon(pieceAsPiece, idx, [{ ...draggedPiece, pos: { x: draggedPiece.x, y: draggedPiece.y } }], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind)) : new Array(hiddenEdges.length).fill(false);
1100
- }
1101
- return generateEdgeStrokePaths(poly).map((strokePath, strokeIdx) => {
1102
- const wasHiddenDueToDraggedPiece = wasTouchingDraggedPiece[strokeIdx] || false;
1103
- let isHidden;
1104
- if (isDragging && p.blueprintId.startsWith("comp:")) {
1105
- const internalHiddenEdges = getHiddenEdgesForPolygon(pieceAsPiece, idx, [], (id) => controller.getBlueprint(id), (kind) => controller.getPrimitive(kind));
1106
- isHidden = internalHiddenEdges[strokeIdx] || false;
1107
- } else {
1108
- isHidden = isDragging ? false : (hiddenEdges[strokeIdx] || false) && !wasHiddenDueToDraggedPiece;
1109
- }
1110
- return /* @__PURE__ */ React.createElement(
1111
- "path",
1112
- {
1113
- key: `stroke-${idx}-${strokeIdx}`,
1114
- d: strokePath,
1115
- fill: "none",
1116
- stroke: isHidden ? "none" : isConnectivityLocked ? CONFIG.color.piece.invalidStroke : isCarriedInvalid ? CONFIG.color.piece.invalidStroke : isSelected || isDragging ? CONFIG.color.piece.selectedStroke : CONFIG.color.piece.borderStroke,
1117
- strokeWidth: isHidden ? 0 : isSelected || isDragging ? CONFIG.size.stroke.pieceSelectedPx : CONFIG.size.stroke.pieceBorderPx,
1118
- onPointerDown: (e) => onPiecePointerDown(e, p)
1119
- }
1120
- );
1121
- });
1122
- })()
1123
- ) ));
955
+ ), showBorders);
1124
956
  }));
1125
957
  }),
1126
958
  layout.sectors.map((s) => {
1127
- const placedPolys = placedSilBySector.get(s.id) ?? [];
1128
- if (!placedPolys.length) return null;
1129
- return /* @__PURE__ */ React.createElement("g", { key: `sil-${s.id}`, pointerEvents: "none" }, placedPolys.map((poly, i) => /* @__PURE__ */ React.createElement("path", { key: i, d: pathD(poly), fill: CONFIG.color.silhouetteMask, opacity: CONFIG.opacity.silhouetteMask })));
959
+ const sectorCfg = controller.state.cfg.sectors.find((ss) => ss.id === s.id);
960
+ if (showTangramDecomposition && sectorCfg?.silhouette.primitiveDecomposition) {
961
+ const primitiveDecomposition = sectorCfg.silhouette.primitiveDecomposition;
962
+ const rect = rectForBand(layout, s, "silhouette", 1);
963
+ const rawPolys = primitiveDecomposition.map((primInfo) => primInfo.polygon);
964
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, { cx: rect.cx, cy: rect.cy });
965
+ 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(
966
+ "path",
967
+ {
968
+ d: pathD(scaledPoly),
969
+ fill: CONFIG.color.silhouetteMask,
970
+ opacity: CONFIG.opacity.silhouetteMask,
971
+ stroke: "none"
972
+ }
973
+ ), /* @__PURE__ */ React.createElement(
974
+ "path",
975
+ {
976
+ d: pathD(scaledPoly),
977
+ fill: "none",
978
+ stroke: CONFIG.color.tangramDecomposition.stroke,
979
+ strokeWidth: CONFIG.size.stroke.tangramDecompositionPx
980
+ }
981
+ ))));
982
+ } else {
983
+ const placedPolys = placedSilBySector.get(s.id) ?? [];
984
+ if (!placedPolys.length) return null;
985
+ 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 })));
986
+ }
1130
987
  }),
1131
988
  anchorDots.map(({ sectorId, valid, invalid }) => {
1132
989
  const isInnerRing = sectorId === "inner-ring";
@@ -1142,10 +999,20 @@ function BoardView(props) {
1142
999
  }
1143
1000
  )));
1144
1001
  }),
1002
+ /* @__PURE__ */ React.createElement("defs", null, /* @__PURE__ */ React.createElement("filter", { id: "invert-to-white" }, /* @__PURE__ */ React.createElement(
1003
+ "feColorMatrix",
1004
+ {
1005
+ type: "matrix",
1006
+ values: "-1 0 0 0 1\n 0 -1 0 0 1\n 0 0 -1 0 1\n 0 0 0 1 0"
1007
+ }
1008
+ ))),
1145
1009
  (() => {
1146
1010
  const isPrep = controller.state.cfg.mode === "prep";
1147
1011
  const isSubmitEnabled = isPrep ? controller.isSubmitEnabled() : true;
1148
1012
  const isClickable = !draggingId && (!isPrep || isSubmitEnabled);
1013
+ const [imageError, setImageError] = React.useState(false);
1014
+ const iconSize = badgeR * 1.6;
1015
+ const iconOffset = iconSize / 2;
1149
1016
  return /* @__PURE__ */ React.createElement(
1150
1017
  "g",
1151
1018
  {
@@ -1161,7 +1028,7 @@ function BoardView(props) {
1161
1028
  opacity: isSubmitEnabled ? 1 : 0.5
1162
1029
  }
1163
1030
  ),
1164
- /* @__PURE__ */ React.createElement(
1031
+ isPrep ? /* @__PURE__ */ React.createElement(
1165
1032
  "text",
1166
1033
  {
1167
1034
  textAnchor: "middle",
@@ -1170,7 +1037,29 @@ function BoardView(props) {
1170
1037
  fill: isSubmitEnabled ? CONFIG.color.blueprint.labelFill : "#888",
1171
1038
  pointerEvents: "none"
1172
1039
  },
1173
- isPrep ? "Submit" : controller.state.blueprintView
1040
+ "Submit"
1041
+ ) : imageError ? /* @__PURE__ */ React.createElement(
1042
+ "text",
1043
+ {
1044
+ textAnchor: "middle",
1045
+ dominantBaseline: "middle",
1046
+ fontSize: CONFIG.size.badgeFontPx,
1047
+ fill: CONFIG.color.blueprint.labelFill,
1048
+ pointerEvents: "none"
1049
+ },
1050
+ "inventory"
1051
+ ) : /* @__PURE__ */ React.createElement(
1052
+ "image",
1053
+ {
1054
+ href: controller.state.blueprintView === "quickstash" ? lockedIcon : unlockedIcon,
1055
+ x: -iconOffset,
1056
+ y: -iconOffset,
1057
+ width: iconSize,
1058
+ height: iconSize,
1059
+ pointerEvents: "none",
1060
+ onError: () => setImageError(true),
1061
+ filter: "url(#invert-to-white)"
1062
+ }
1174
1063
  )
1175
1064
  );
1176
1065
  })(),
@@ -2588,7 +2477,7 @@ function useClickController(controller, layout, pieces, clickMode, draggingId, s
2588
2477
  }
2589
2478
 
2590
2479
  class InteractionTracker {
2591
- constructor(controller, callbacks, trialId, gameId) {
2480
+ constructor(controller, callbacks, trialParams) {
2592
2481
  this.gridStep = CONFIG.layout.grid.stepPx;
2593
2482
  // Interaction state
2594
2483
  this.interactionIndex = 0;
@@ -2605,8 +2494,7 @@ class InteractionTracker {
2605
2494
  this.createdMacros = [];
2606
2495
  this.controller = controller;
2607
2496
  this.callbacks = callbacks;
2608
- this.trialId = trialId || uuid.v4();
2609
- this.gameId = gameId || uuid.v4();
2497
+ this.trialParams = trialParams;
2610
2498
  this.trialStartTime = Date.now();
2611
2499
  this.controller.setTrackingCallbacks({
2612
2500
  onSectorCompleted: (sectorId) => this.recordSectorCompletion(sectorId)
@@ -2654,8 +2542,6 @@ class InteractionTracker {
2654
2542
  const event = {
2655
2543
  // Metadata
2656
2544
  interactionId: uuid.v4(),
2657
- trialId: this.trialId,
2658
- gameId: this.gameId,
2659
2545
  interactionIndex: this.interactionIndex++,
2660
2546
  // Interaction type
2661
2547
  interactionType,
@@ -2761,6 +2647,7 @@ class InteractionTracker {
2761
2647
  const trialEndTime = Date.now();
2762
2648
  const totalDuration = trialEndTime - this.trialStartTime;
2763
2649
  const finalSnapshot = this.buildStateSnapshot();
2650
+ const anchorToStimuliRatio = CONFIG.layout.grid.stepPx / CONFIG.layout.grid.unitPx;
2764
2651
  const mode = this.controller.state.cfg.mode;
2765
2652
  if (mode === "construction") {
2766
2653
  const finalBlueprintState = this.buildFinalBlueprintState(finalSnapshot);
@@ -2772,13 +2659,11 @@ class InteractionTracker {
2772
2659
  }));
2773
2660
  const data = {
2774
2661
  trialType: "construction",
2775
- trialId: this.trialId,
2776
- gameId: this.gameId,
2777
- trialNum: 0,
2778
- // TODO: Plugin should provide this
2779
2662
  trialStartTime: this.trialStartTime,
2780
2663
  trialEndTime,
2781
2664
  totalDuration,
2665
+ anchorToStimuliRatio,
2666
+ trialParams: this.trialParams,
2782
2667
  endReason,
2783
2668
  completionTimes: this.completionTimes,
2784
2669
  finalBlueprintState,
@@ -2797,13 +2682,11 @@ class InteractionTracker {
2797
2682
  }));
2798
2683
  const data = {
2799
2684
  trialType: "prep",
2800
- trialId: this.trialId,
2801
- gameId: this.gameId,
2802
- trialNum: 0,
2803
- // TODO: Plugin should provide this
2804
2685
  trialStartTime: this.trialStartTime,
2805
2686
  trialEndTime,
2806
2687
  totalDuration,
2688
+ anchorToStimuliRatio,
2689
+ trialParams: this.trialParams,
2807
2690
  endReason: "submit",
2808
2691
  createdMacros: finalMacros,
2809
2692
  quickstashMacros,
@@ -3061,6 +2944,9 @@ function GameBoard(props) {
3061
2944
  maxQuickstashSlots,
3062
2945
  maxCompositeSize,
3063
2946
  mode,
2947
+ showTangramDecomposition,
2948
+ instructions,
2949
+ trialParams,
3064
2950
  width: _width,
3065
2951
  height: _height,
3066
2952
  onSectorComplete,
@@ -3070,6 +2956,7 @@ function GameBoard(props) {
3070
2956
  onTrialEnd,
3071
2957
  onControllerReady
3072
2958
  } = props;
2959
+ const [timeRemaining, setTimeRemaining] = React.useState(timeLimitMs > 0 ? Math.floor(timeLimitMs / 1e3) : 0);
3073
2960
  const controller = React.useMemo(() => {
3074
2961
  const gameConfig = {
3075
2962
  n: sectors.length,
@@ -3096,8 +2983,8 @@ function GameBoard(props) {
3096
2983
  const callbacks = {};
3097
2984
  if (onInteraction) callbacks.onInteraction = onInteraction;
3098
2985
  if (onTrialEnd) callbacks.onTrialEnd = onTrialEnd;
3099
- return new InteractionTracker(controller, callbacks);
3100
- }, [controller, onInteraction, onTrialEnd]);
2986
+ return new InteractionTracker(controller, callbacks, trialParams);
2987
+ }, [controller, onInteraction, onTrialEnd, trialParams]);
3101
2988
  React.useEffect(() => {
3102
2989
  if (onControllerReady) {
3103
2990
  onControllerReady(controller);
@@ -3177,22 +3064,22 @@ function GameBoard(props) {
3177
3064
  }), [handleSectorComplete, onPiecePlace, onPieceRemove]);
3178
3065
  const getGameboardStyle = () => {
3179
3066
  const baseStyle = {
3180
- margin: "0 auto",
3067
+ margin: "10px",
3181
3068
  display: "flex",
3182
3069
  alignItems: "center",
3183
3070
  justifyContent: "center",
3184
3071
  position: "relative"
3185
3072
  };
3186
3073
  if (layoutMode === "circle") {
3187
- const size = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96);
3074
+ const size = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale);
3188
3075
  return {
3189
3076
  ...baseStyle,
3190
3077
  width: `${size}px`,
3191
3078
  height: `${size}px`
3192
3079
  };
3193
3080
  } else {
3194
- const maxWidth = Math.min(window.innerWidth * 0.96, window.innerHeight * 0.96 * 2);
3195
- const maxHeight = Math.min(window.innerWidth * 0.96 / 2, window.innerHeight * 0.96);
3081
+ const maxWidth = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale * 2);
3082
+ const maxHeight = Math.min(window.innerWidth * CONFIG.layout.viewportScale / 2, window.innerHeight * CONFIG.layout.viewportScale);
3196
3083
  return {
3197
3084
  ...baseStyle,
3198
3085
  width: `${maxWidth}px`,
@@ -3361,7 +3248,68 @@ function GameBoard(props) {
3361
3248
  force();
3362
3249
  e.stopPropagation();
3363
3250
  };
3364
- return /* @__PURE__ */ React.createElement("div", { className: "tangram-gameboard", style: getGameboardStyle() }, /* @__PURE__ */ React.createElement(
3251
+ React.useEffect(() => {
3252
+ if (timeLimitMs === 0) return;
3253
+ const interval = setInterval(() => {
3254
+ setTimeRemaining((prev) => {
3255
+ if (prev <= 1) {
3256
+ clearInterval(interval);
3257
+ return 0;
3258
+ }
3259
+ return prev - 1;
3260
+ });
3261
+ }, 1e3);
3262
+ return () => clearInterval(interval);
3263
+ }, [timeLimitMs]);
3264
+ const formatTime = (seconds) => {
3265
+ const mins = Math.floor(seconds / 60);
3266
+ const secs = Math.floor(seconds % 60);
3267
+ return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
3268
+ };
3269
+ const containerStyle = {
3270
+ display: "flex",
3271
+ flexDirection: "column",
3272
+ width: "100%"
3273
+ // minHeight: '100vh'
3274
+ };
3275
+ const headerStyle = {
3276
+ display: "flex",
3277
+ flexDirection: "row",
3278
+ justifyContent: "space-between",
3279
+ alignItems: "center",
3280
+ padding: "20px",
3281
+ background: "#f5f5f5",
3282
+ flex: "0 0 auto"
3283
+ };
3284
+ const instructionsStyle = {
3285
+ flexGrow: 1,
3286
+ fontSize: "20px",
3287
+ lineHeight: 1.5,
3288
+ marginRight: "20px"
3289
+ };
3290
+ const timerStyle = {
3291
+ fontSize: "24px",
3292
+ fontWeight: "bold",
3293
+ fontFamily: "monospace",
3294
+ color: "#333",
3295
+ minWidth: "80px",
3296
+ textAlign: "right"
3297
+ };
3298
+ const gameboardWrapperStyle = {
3299
+ flex: "1 1 auto",
3300
+ display: "flex",
3301
+ alignItems: "center",
3302
+ justifyContent: "center",
3303
+ overflow: "hidden"
3304
+ };
3305
+ 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(
3306
+ "div",
3307
+ {
3308
+ className: "tangram-instructions",
3309
+ style: instructionsStyle,
3310
+ dangerouslySetInnerHTML: { __html: instructions }
3311
+ }
3312
+ ), timeLimitMs > 0 && /* @__PURE__ */ React.createElement("div", { className: "tangram-timer", style: timerStyle }, formatTime(timeRemaining))), /* @__PURE__ */ React.createElement("div", { className: "tangram-gameboard-wrapper", style: gameboardWrapperStyle }, /* @__PURE__ */ React.createElement("div", { className: "tangram-gameboard", style: getGameboardStyle() }, /* @__PURE__ */ React.createElement(
3365
3313
  BoardView,
3366
3314
  {
3367
3315
  controller,
@@ -3387,9 +3335,11 @@ function GameBoard(props) {
3387
3335
  onPointerMove,
3388
3336
  onPointerUp,
3389
3337
  onCenterBadgePointerDown,
3338
+ showTangramDecomposition: showTangramDecomposition ?? false,
3339
+ scaleS,
3390
3340
  ...eventCallbacks
3391
3341
  }
3392
- ));
3342
+ ))));
3393
3343
  }
3394
3344
 
3395
3345
  const U = 40;
@@ -3557,17 +3507,22 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3557
3507
  const isCanonical = CANON.has(tanName);
3558
3508
  return isCanonical;
3559
3509
  });
3560
- const mask = filteredTans.map((tan, tanIndex) => {
3510
+ const mask = filteredTans.map((tan) => {
3561
3511
  const polygon = tan.vertices.map(([x, y]) => ({ x: x ?? 0, y: -(y ?? 0) }));
3562
3512
  return polygon;
3563
3513
  });
3514
+ const primitiveDecomposition = filteredTans.map((tan) => ({
3515
+ kind: tan.name ?? tan.kind,
3516
+ polygon: tan.vertices.map(([x, y]) => ({ x: x ?? 0, y: -(y ?? 0) }))
3517
+ }));
3564
3518
  const sectorId = `sector${index}`;
3565
3519
  const sector = {
3566
3520
  id: sectorId,
3567
3521
  tangramId: tangramSpec.tangramID,
3568
3522
  silhouette: {
3569
3523
  id: sectorId,
3570
- mask
3524
+ mask,
3525
+ primitiveDecomposition
3571
3526
  }
3572
3527
  };
3573
3528
  return sector;
@@ -3586,6 +3541,7 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3586
3541
  quickstash = params.quickstash_macros;
3587
3542
  }
3588
3543
  }
3544
+ const { onInteraction, onTrialEnd, ...trialParams } = params;
3589
3545
  const gameBoardProps = {
3590
3546
  sectors,
3591
3547
  quickstash,
@@ -3596,6 +3552,9 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3596
3552
  timeLimitMs: params.time_limit_ms,
3597
3553
  maxQuickstashSlots: CONFIG.layout.defaults.maxQuickstashSlots,
3598
3554
  mode: "construction",
3555
+ showTangramDecomposition: params.show_tangram_decomposition ?? false,
3556
+ trialParams,
3557
+ ...params.instructions && { instructions: params.instructions },
3599
3558
  ...params.onInteraction && { onInteraction: params.onInteraction },
3600
3559
  ...params.onTrialEnd && { onTrialEnd: params.onTrialEnd }
3601
3560
  };
@@ -3659,6 +3618,18 @@ const info = {
3659
3618
  default: 18,
3660
3619
  description: "Snap radius for anchor-based piece placement"
3661
3620
  },
3621
+ /** Whether to show tangram target shapes decomposed into individual primitives with borders */
3622
+ show_tangram_decomposition: {
3623
+ type: jspsych.ParameterType.BOOL,
3624
+ default: false,
3625
+ description: "Whether to show tangram target shapes decomposed into individual primitives with borders"
3626
+ },
3627
+ /** HTML content to display above the gameboard as instructions */
3628
+ instructions: {
3629
+ type: jspsych.ParameterType.STRING,
3630
+ default: "",
3631
+ description: "HTML content to display above the gameboard as instructions"
3632
+ },
3662
3633
  /** Callback fired after each interaction (piece pickup + placedown) */
3663
3634
  onInteraction: {
3664
3635
  type: jspsych.ParameterType.FUNCTION,
@@ -3732,6 +3703,8 @@ class TangramConstructPlugin {
3732
3703
  input: trial.input,
3733
3704
  layout: trial.layout,
3734
3705
  time_limit_ms: trial.time_limit_ms,
3706
+ show_tangram_decomposition: trial.show_tangram_decomposition,
3707
+ instructions: trial.instructions,
3735
3708
  onInteraction: trial.onInteraction,
3736
3709
  onTrialEnd: wrappedOnTrialEnd
3737
3710
  };