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