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
package/dist/index.js CHANGED
@@ -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;
@@ -3555,17 +3505,22 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3555
3505
  const isCanonical = CANON.has(tanName);
3556
3506
  return isCanonical;
3557
3507
  });
3558
- const mask = filteredTans.map((tan, tanIndex) => {
3508
+ const mask = filteredTans.map((tan) => {
3559
3509
  const polygon = tan.vertices.map(([x, y]) => ({ x: x ?? 0, y: -(y ?? 0) }));
3560
3510
  return polygon;
3561
3511
  });
3512
+ const primitiveDecomposition = filteredTans.map((tan) => ({
3513
+ kind: tan.name ?? tan.kind,
3514
+ polygon: tan.vertices.map(([x, y]) => ({ x: x ?? 0, y: -(y ?? 0) }))
3515
+ }));
3562
3516
  const sectorId = `sector${index}`;
3563
3517
  const sector = {
3564
3518
  id: sectorId,
3565
3519
  tangramId: tangramSpec.tangramID,
3566
3520
  silhouette: {
3567
3521
  id: sectorId,
3568
- mask
3522
+ mask,
3523
+ primitiveDecomposition
3569
3524
  }
3570
3525
  };
3571
3526
  return sector;
@@ -3584,6 +3539,7 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3584
3539
  quickstash = params.quickstash_macros;
3585
3540
  }
3586
3541
  }
3542
+ const { onInteraction, onTrialEnd, ...trialParams } = params;
3587
3543
  const gameBoardProps = {
3588
3544
  sectors,
3589
3545
  quickstash,
@@ -3594,6 +3550,9 @@ function startConstructionTrial(display_element, params, _jsPsych) {
3594
3550
  timeLimitMs: params.time_limit_ms,
3595
3551
  maxQuickstashSlots: CONFIG.layout.defaults.maxQuickstashSlots,
3596
3552
  mode: "construction",
3553
+ showTangramDecomposition: params.show_tangram_decomposition ?? false,
3554
+ trialParams,
3555
+ ...params.instructions && { instructions: params.instructions },
3597
3556
  ...params.onInteraction && { onInteraction: params.onInteraction },
3598
3557
  ...params.onTrialEnd && { onTrialEnd: params.onTrialEnd }
3599
3558
  };
@@ -3657,6 +3616,18 @@ const info$1 = {
3657
3616
  default: 18,
3658
3617
  description: "Snap radius for anchor-based piece placement"
3659
3618
  },
3619
+ /** Whether to show tangram target shapes decomposed into individual primitives with borders */
3620
+ show_tangram_decomposition: {
3621
+ type: ParameterType.BOOL,
3622
+ default: false,
3623
+ description: "Whether to show tangram target shapes decomposed into individual primitives with borders"
3624
+ },
3625
+ /** HTML content to display above the gameboard as instructions */
3626
+ instructions: {
3627
+ type: ParameterType.STRING,
3628
+ default: "",
3629
+ description: "HTML content to display above the gameboard as instructions"
3630
+ },
3660
3631
  /** Callback fired after each interaction (piece pickup + placedown) */
3661
3632
  onInteraction: {
3662
3633
  type: ParameterType.FUNCTION,
@@ -3730,6 +3701,8 @@ class TangramConstructPlugin {
3730
3701
  input: trial.input,
3731
3702
  layout: trial.layout,
3732
3703
  time_limit_ms: trial.time_limit_ms,
3704
+ show_tangram_decomposition: trial.show_tangram_decomposition,
3705
+ instructions: trial.instructions,
3733
3706
  onInteraction: trial.onInteraction,
3734
3707
  onTrialEnd: wrappedOnTrialEnd
3735
3708
  };
@@ -3751,8 +3724,9 @@ function startPrepTrial(display_element, params, jsPsych) {
3751
3724
  onInteraction,
3752
3725
  onTrialEnd
3753
3726
  } = params;
3727
+ const { onInteraction: _, onTrialEnd: __, ...trialParams } = params;
3754
3728
  const PRIMITIVE_BLUEPRINTS_ORDERED = [...PRIMITIVE_BLUEPRINTS].sort((a, b) => primitiveOrder.indexOf(a.kind) - primitiveOrder.indexOf(b.kind));
3755
- const prepSectors = Array.from({ length: numQuickstashSlots }, (_, i) => ({
3729
+ const prepSectors = Array.from({ length: numQuickstashSlots }, (_2, i) => ({
3756
3730
  id: `prep-sector-${i}`,
3757
3731
  tangramId: `prep-sector-${i}`,
3758
3732
  // dummy value since prep mode doesn't have tangrams
@@ -3836,6 +3810,8 @@ function startPrepTrial(display_element, params, jsPsych) {
3836
3810
  // Enable prep-specific behavior
3837
3811
  minPiecesPerMacro,
3838
3812
  requireAllSlots,
3813
+ trialParams,
3814
+ ...params.instructions && { instructions: params.instructions },
3839
3815
  onControllerReady: handleControllerReady,
3840
3816
  ...onInteraction && { onInteraction },
3841
3817
  ...onTrialEnd && { onTrialEnd }
@@ -3889,6 +3865,12 @@ const info = {
3889
3865
  default: ["square", "smalltriangle", "parallelogram", "medtriangle", "largetriangle"],
3890
3866
  description: "Array of primitive names in the order they should be displayed"
3891
3867
  },
3868
+ /** HTML content to display above the gameboard as instructions */
3869
+ instructions: {
3870
+ type: ParameterType.STRING,
3871
+ default: void 0,
3872
+ description: "HTML content to display above the gameboard as instructions"
3873
+ },
3892
3874
  /** Callback fired after each interaction (optional analytics hook) */
3893
3875
  onInteraction: {
3894
3876
  type: ParameterType.FUNCTION,
@@ -3940,6 +3922,7 @@ class TangramPrepPlugin {
3940
3922
  requireAllSlots: trial.require_all_slots,
3941
3923
  quickstashMacros: trial.quickstash_macros,
3942
3924
  primitiveOrder: trial.primitive_order,
3925
+ instructions: trial.instructions,
3943
3926
  onInteraction: trial.onInteraction,
3944
3927
  onTrialEnd: wrappedOnTrialEnd
3945
3928
  };