jspsych-tangram 0.0.7 → 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 +146 -212
- 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 +146 -212
- package/dist/construct/index.cjs.map +1 -1
- package/dist/construct/index.d.ts +24 -0
- package/dist/construct/index.js +146 -212
- package/dist/construct/index.js.map +1 -1
- package/dist/index.cjs +157 -213
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +36 -0
- package/dist/index.js +157 -213
- package/dist/index.js.map +1 -1
- package/dist/prep/index.browser.js +132 -211
- 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 +132 -211
- package/dist/prep/index.cjs.map +1 -1
- package/dist/prep/index.d.ts +12 -0
- package/dist/prep/index.js +132 -211
- 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 +8 -4
- 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
|
@@ -55,6 +55,18 @@ declare const info: {
|
|
|
55
55
|
default: number;
|
|
56
56
|
description: string;
|
|
57
57
|
};
|
|
58
|
+
/** Whether to show tangram target shapes decomposed into individual primitives with borders */
|
|
59
|
+
show_tangram_decomposition: {
|
|
60
|
+
type: ParameterType;
|
|
61
|
+
default: boolean;
|
|
62
|
+
description: string;
|
|
63
|
+
};
|
|
64
|
+
/** HTML content to display above the gameboard as instructions */
|
|
65
|
+
instructions: {
|
|
66
|
+
type: ParameterType;
|
|
67
|
+
default: string;
|
|
68
|
+
description: string;
|
|
69
|
+
};
|
|
58
70
|
/** Callback fired after each interaction (piece pickup + placedown) */
|
|
59
71
|
onInteraction: {
|
|
60
72
|
type: ParameterType;
|
|
@@ -163,6 +175,18 @@ declare class TangramConstructPlugin implements JsPsychPlugin<Info> {
|
|
|
163
175
|
default: number;
|
|
164
176
|
description: string;
|
|
165
177
|
};
|
|
178
|
+
/** Whether to show tangram target shapes decomposed into individual primitives with borders */
|
|
179
|
+
show_tangram_decomposition: {
|
|
180
|
+
type: ParameterType;
|
|
181
|
+
default: boolean;
|
|
182
|
+
description: string;
|
|
183
|
+
};
|
|
184
|
+
/** HTML content to display above the gameboard as instructions */
|
|
185
|
+
instructions: {
|
|
186
|
+
type: ParameterType;
|
|
187
|
+
default: string;
|
|
188
|
+
description: string;
|
|
189
|
+
};
|
|
166
190
|
/** Callback fired after each interaction (piece pickup + placedown) */
|
|
167
191
|
onInteraction: {
|
|
168
192
|
type: ParameterType;
|
package/dist/construct/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:
|
|
48
|
+
showBorders: false,
|
|
47
49
|
hideTouchingBorders: true
|
|
48
50
|
}
|
|
49
51
|
};
|
|
@@ -814,150 +816,11 @@ function rectForBand(layout, sector, band, pad = 0.85) {
|
|
|
814
816
|
return { cx, cy, w, h };
|
|
815
817
|
}
|
|
816
818
|
|
|
817
|
-
function
|
|
818
|
-
|
|
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
|
|
823
|
+
return CONFIG.game.showBorders;
|
|
961
824
|
}
|
|
962
825
|
|
|
963
826
|
function pathD(poly) {
|
|
@@ -975,11 +838,13 @@ function BoardView(props) {
|
|
|
975
838
|
placedSilBySector,
|
|
976
839
|
anchorDots,
|
|
977
840
|
pieces,
|
|
841
|
+
scaleS,
|
|
978
842
|
clickMode,
|
|
979
843
|
draggingId,
|
|
980
844
|
selectedPieceId,
|
|
981
845
|
dragInvalid,
|
|
982
846
|
lockedPieceId,
|
|
847
|
+
showTangramDecomposition,
|
|
983
848
|
svgRef,
|
|
984
849
|
setPieceRef,
|
|
985
850
|
onPiecePointerDown,
|
|
@@ -1045,7 +910,7 @@ function BoardView(props) {
|
|
|
1045
910
|
onPointerDown: (e) => {
|
|
1046
911
|
onRootPointerDown(e);
|
|
1047
912
|
},
|
|
1048
|
-
style: { background: "#
|
|
913
|
+
style: { background: "#f5f5f5", touchAction: "none", userSelect: "none" }
|
|
1049
914
|
},
|
|
1050
915
|
layout.sectors.map((s, i) => {
|
|
1051
916
|
const done = !!controller.state.sectors[s.id].completedAt;
|
|
@@ -1065,11 +930,12 @@ function BoardView(props) {
|
|
|
1065
930
|
const isDragging = draggingId === p.id;
|
|
1066
931
|
const locked = p.sectorId && controller.isSectorCompleted(p.sectorId);
|
|
1067
932
|
const isConnectivityLocked = lockedPieceId === p.id;
|
|
1068
|
-
|
|
933
|
+
selectedPieceId === p.id;
|
|
1069
934
|
const isCarriedInvalid = isDragging && dragInvalid;
|
|
1070
935
|
const translateX = p.x - bb.min.x;
|
|
1071
936
|
const translateY = p.y - bb.min.y;
|
|
1072
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();
|
|
1073
939
|
shouldUseSelectiveBorders(p.blueprintId);
|
|
1074
940
|
return /* @__PURE__ */ React.createElement(React.Fragment, { key: idx }, /* @__PURE__ */ React.createElement(
|
|
1075
941
|
"path",
|
|
@@ -1080,51 +946,38 @@ function BoardView(props) {
|
|
|
1080
946
|
stroke: "none",
|
|
1081
947
|
onPointerDown: (e) => onPiecePointerDown(e, p)
|
|
1082
948
|
}
|
|
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
|
-
) ));
|
|
949
|
+
), showBorders);
|
|
1122
950
|
}));
|
|
1123
951
|
}),
|
|
1124
952
|
layout.sectors.map((s) => {
|
|
1125
|
-
const
|
|
1126
|
-
if (
|
|
1127
|
-
|
|
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
|
+
}
|
|
1128
981
|
}),
|
|
1129
982
|
anchorDots.map(({ sectorId, valid, invalid }) => {
|
|
1130
983
|
const isInnerRing = sectorId === "inner-ring";
|
|
@@ -2586,7 +2439,7 @@ function useClickController(controller, layout, pieces, clickMode, draggingId, s
|
|
|
2586
2439
|
}
|
|
2587
2440
|
|
|
2588
2441
|
class InteractionTracker {
|
|
2589
|
-
constructor(controller, callbacks,
|
|
2442
|
+
constructor(controller, callbacks, trialParams) {
|
|
2590
2443
|
this.gridStep = CONFIG.layout.grid.stepPx;
|
|
2591
2444
|
// Interaction state
|
|
2592
2445
|
this.interactionIndex = 0;
|
|
@@ -2603,8 +2456,7 @@ class InteractionTracker {
|
|
|
2603
2456
|
this.createdMacros = [];
|
|
2604
2457
|
this.controller = controller;
|
|
2605
2458
|
this.callbacks = callbacks;
|
|
2606
|
-
this.
|
|
2607
|
-
this.gameId = gameId || v4();
|
|
2459
|
+
this.trialParams = trialParams;
|
|
2608
2460
|
this.trialStartTime = Date.now();
|
|
2609
2461
|
this.controller.setTrackingCallbacks({
|
|
2610
2462
|
onSectorCompleted: (sectorId) => this.recordSectorCompletion(sectorId)
|
|
@@ -2652,8 +2504,6 @@ class InteractionTracker {
|
|
|
2652
2504
|
const event = {
|
|
2653
2505
|
// Metadata
|
|
2654
2506
|
interactionId: v4(),
|
|
2655
|
-
trialId: this.trialId,
|
|
2656
|
-
gameId: this.gameId,
|
|
2657
2507
|
interactionIndex: this.interactionIndex++,
|
|
2658
2508
|
// Interaction type
|
|
2659
2509
|
interactionType,
|
|
@@ -2770,13 +2620,10 @@ class InteractionTracker {
|
|
|
2770
2620
|
}));
|
|
2771
2621
|
const data = {
|
|
2772
2622
|
trialType: "construction",
|
|
2773
|
-
trialId: this.trialId,
|
|
2774
|
-
gameId: this.gameId,
|
|
2775
|
-
trialNum: 0,
|
|
2776
|
-
// TODO: Plugin should provide this
|
|
2777
2623
|
trialStartTime: this.trialStartTime,
|
|
2778
2624
|
trialEndTime,
|
|
2779
2625
|
totalDuration,
|
|
2626
|
+
trialParams: this.trialParams,
|
|
2780
2627
|
endReason,
|
|
2781
2628
|
completionTimes: this.completionTimes,
|
|
2782
2629
|
finalBlueprintState,
|
|
@@ -2795,13 +2642,10 @@ class InteractionTracker {
|
|
|
2795
2642
|
}));
|
|
2796
2643
|
const data = {
|
|
2797
2644
|
trialType: "prep",
|
|
2798
|
-
trialId: this.trialId,
|
|
2799
|
-
gameId: this.gameId,
|
|
2800
|
-
trialNum: 0,
|
|
2801
|
-
// TODO: Plugin should provide this
|
|
2802
2645
|
trialStartTime: this.trialStartTime,
|
|
2803
2646
|
trialEndTime,
|
|
2804
2647
|
totalDuration,
|
|
2648
|
+
trialParams: this.trialParams,
|
|
2805
2649
|
endReason: "submit",
|
|
2806
2650
|
createdMacros: finalMacros,
|
|
2807
2651
|
quickstashMacros,
|
|
@@ -3059,6 +2903,9 @@ function GameBoard(props) {
|
|
|
3059
2903
|
maxQuickstashSlots,
|
|
3060
2904
|
maxCompositeSize,
|
|
3061
2905
|
mode,
|
|
2906
|
+
showTangramDecomposition,
|
|
2907
|
+
instructions,
|
|
2908
|
+
trialParams,
|
|
3062
2909
|
width: _width,
|
|
3063
2910
|
height: _height,
|
|
3064
2911
|
onSectorComplete,
|
|
@@ -3068,6 +2915,7 @@ function GameBoard(props) {
|
|
|
3068
2915
|
onTrialEnd,
|
|
3069
2916
|
onControllerReady
|
|
3070
2917
|
} = props;
|
|
2918
|
+
const [timeRemaining, setTimeRemaining] = React.useState(timeLimitMs > 0 ? Math.floor(timeLimitMs / 1e3) : 0);
|
|
3071
2919
|
const controller = React.useMemo(() => {
|
|
3072
2920
|
const gameConfig = {
|
|
3073
2921
|
n: sectors.length,
|
|
@@ -3094,8 +2942,8 @@ function GameBoard(props) {
|
|
|
3094
2942
|
const callbacks = {};
|
|
3095
2943
|
if (onInteraction) callbacks.onInteraction = onInteraction;
|
|
3096
2944
|
if (onTrialEnd) callbacks.onTrialEnd = onTrialEnd;
|
|
3097
|
-
return new InteractionTracker(controller, callbacks);
|
|
3098
|
-
}, [controller, onInteraction, onTrialEnd]);
|
|
2945
|
+
return new InteractionTracker(controller, callbacks, trialParams);
|
|
2946
|
+
}, [controller, onInteraction, onTrialEnd, trialParams]);
|
|
3099
2947
|
React.useEffect(() => {
|
|
3100
2948
|
if (onControllerReady) {
|
|
3101
2949
|
onControllerReady(controller);
|
|
@@ -3175,22 +3023,22 @@ function GameBoard(props) {
|
|
|
3175
3023
|
}), [handleSectorComplete, onPiecePlace, onPieceRemove]);
|
|
3176
3024
|
const getGameboardStyle = () => {
|
|
3177
3025
|
const baseStyle = {
|
|
3178
|
-
margin: "
|
|
3026
|
+
margin: "10px",
|
|
3179
3027
|
display: "flex",
|
|
3180
3028
|
alignItems: "center",
|
|
3181
3029
|
justifyContent: "center",
|
|
3182
3030
|
position: "relative"
|
|
3183
3031
|
};
|
|
3184
3032
|
if (layoutMode === "circle") {
|
|
3185
|
-
const size = Math.min(window.innerWidth *
|
|
3033
|
+
const size = Math.min(window.innerWidth * CONFIG.layout.viewportScale, window.innerHeight * CONFIG.layout.viewportScale);
|
|
3186
3034
|
return {
|
|
3187
3035
|
...baseStyle,
|
|
3188
3036
|
width: `${size}px`,
|
|
3189
3037
|
height: `${size}px`
|
|
3190
3038
|
};
|
|
3191
3039
|
} else {
|
|
3192
|
-
const maxWidth = Math.min(window.innerWidth *
|
|
3193
|
-
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);
|
|
3194
3042
|
return {
|
|
3195
3043
|
...baseStyle,
|
|
3196
3044
|
width: `${maxWidth}px`,
|
|
@@ -3359,7 +3207,68 @@ function GameBoard(props) {
|
|
|
3359
3207
|
force();
|
|
3360
3208
|
e.stopPropagation();
|
|
3361
3209
|
};
|
|
3362
|
-
|
|
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(
|
|
3363
3272
|
BoardView,
|
|
3364
3273
|
{
|
|
3365
3274
|
controller,
|
|
@@ -3385,9 +3294,11 @@ function GameBoard(props) {
|
|
|
3385
3294
|
onPointerMove,
|
|
3386
3295
|
onPointerUp,
|
|
3387
3296
|
onCenterBadgePointerDown,
|
|
3297
|
+
showTangramDecomposition: showTangramDecomposition ?? false,
|
|
3298
|
+
scaleS,
|
|
3388
3299
|
...eventCallbacks
|
|
3389
3300
|
}
|
|
3390
|
-
));
|
|
3301
|
+
))));
|
|
3391
3302
|
}
|
|
3392
3303
|
|
|
3393
3304
|
const U = 40;
|
|
@@ -3555,17 +3466,22 @@ function startConstructionTrial(display_element, params, _jsPsych) {
|
|
|
3555
3466
|
const isCanonical = CANON.has(tanName);
|
|
3556
3467
|
return isCanonical;
|
|
3557
3468
|
});
|
|
3558
|
-
const mask = filteredTans.map((tan
|
|
3469
|
+
const mask = filteredTans.map((tan) => {
|
|
3559
3470
|
const polygon = tan.vertices.map(([x, y]) => ({ x: x ?? 0, y: -(y ?? 0) }));
|
|
3560
3471
|
return polygon;
|
|
3561
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
|
+
}));
|
|
3562
3477
|
const sectorId = `sector${index}`;
|
|
3563
3478
|
const sector = {
|
|
3564
3479
|
id: sectorId,
|
|
3565
3480
|
tangramId: tangramSpec.tangramID,
|
|
3566
3481
|
silhouette: {
|
|
3567
3482
|
id: sectorId,
|
|
3568
|
-
mask
|
|
3483
|
+
mask,
|
|
3484
|
+
primitiveDecomposition
|
|
3569
3485
|
}
|
|
3570
3486
|
};
|
|
3571
3487
|
return sector;
|
|
@@ -3584,6 +3500,7 @@ function startConstructionTrial(display_element, params, _jsPsych) {
|
|
|
3584
3500
|
quickstash = params.quickstash_macros;
|
|
3585
3501
|
}
|
|
3586
3502
|
}
|
|
3503
|
+
const { onInteraction, onTrialEnd, ...trialParams } = params;
|
|
3587
3504
|
const gameBoardProps = {
|
|
3588
3505
|
sectors,
|
|
3589
3506
|
quickstash,
|
|
@@ -3594,6 +3511,9 @@ function startConstructionTrial(display_element, params, _jsPsych) {
|
|
|
3594
3511
|
timeLimitMs: params.time_limit_ms,
|
|
3595
3512
|
maxQuickstashSlots: CONFIG.layout.defaults.maxQuickstashSlots,
|
|
3596
3513
|
mode: "construction",
|
|
3514
|
+
showTangramDecomposition: params.show_tangram_decomposition ?? false,
|
|
3515
|
+
trialParams,
|
|
3516
|
+
...params.instructions && { instructions: params.instructions },
|
|
3597
3517
|
...params.onInteraction && { onInteraction: params.onInteraction },
|
|
3598
3518
|
...params.onTrialEnd && { onTrialEnd: params.onTrialEnd }
|
|
3599
3519
|
};
|
|
@@ -3657,6 +3577,18 @@ const info = {
|
|
|
3657
3577
|
default: 18,
|
|
3658
3578
|
description: "Snap radius for anchor-based piece placement"
|
|
3659
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
|
+
},
|
|
3660
3592
|
/** Callback fired after each interaction (piece pickup + placedown) */
|
|
3661
3593
|
onInteraction: {
|
|
3662
3594
|
type: ParameterType.FUNCTION,
|
|
@@ -3730,6 +3662,8 @@ class TangramConstructPlugin {
|
|
|
3730
3662
|
input: trial.input,
|
|
3731
3663
|
layout: trial.layout,
|
|
3732
3664
|
time_limit_ms: trial.time_limit_ms,
|
|
3665
|
+
show_tangram_decomposition: trial.show_tangram_decomposition,
|
|
3666
|
+
instructions: trial.instructions,
|
|
3733
3667
|
onInteraction: trial.onInteraction,
|
|
3734
3668
|
onTrialEnd: wrappedOnTrialEnd
|
|
3735
3669
|
};
|