jspsych-tangram 0.0.13 → 0.0.15
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/afc/index.browser.js +17751 -0
- package/dist/afc/index.browser.js.map +1 -0
- package/dist/afc/index.browser.min.js +42 -0
- package/dist/afc/index.browser.min.js.map +1 -0
- package/dist/afc/index.cjs +443 -0
- package/dist/afc/index.cjs.map +1 -0
- package/dist/afc/index.d.ts +169 -0
- package/dist/afc/index.js +441 -0
- package/dist/afc/index.js.map +1 -0
- package/dist/construct/index.browser.js +8 -2
- package/dist/construct/index.browser.js.map +1 -1
- package/dist/construct/index.browser.min.js +10 -10
- package/dist/construct/index.browser.min.js.map +1 -1
- package/dist/construct/index.cjs +8 -2
- package/dist/construct/index.cjs.map +1 -1
- package/dist/construct/index.js +8 -2
- package/dist/construct/index.js.map +1 -1
- package/dist/index.cjs +379 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +178 -12
- package/dist/index.js +379 -12
- package/dist/index.js.map +1 -1
- package/dist/nback/index.browser.js +6 -4
- package/dist/nback/index.browser.js.map +1 -1
- package/dist/nback/index.browser.min.js +8 -8
- package/dist/nback/index.browser.min.js.map +1 -1
- package/dist/nback/index.cjs +6 -4
- package/dist/nback/index.cjs.map +1 -1
- package/dist/nback/index.js +6 -4
- package/dist/nback/index.js.map +1 -1
- package/dist/prep/index.browser.js +8 -2
- package/dist/prep/index.browser.js.map +1 -1
- package/dist/prep/index.browser.min.js +10 -10
- package/dist/prep/index.browser.min.js.map +1 -1
- package/dist/prep/index.cjs +8 -2
- package/dist/prep/index.cjs.map +1 -1
- package/dist/prep/index.js +8 -2
- package/dist/prep/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/components/board/GameBoard.tsx +13 -42
- package/src/core/components/board/useGameBoard.ts +52 -0
- package/src/core/components/index.ts +4 -2
- package/src/core/components/pieces/BlueprintRing.tsx +0 -25
- package/src/core/components/pieces/useBlueprintRing.ts +39 -0
- package/src/index.ts +2 -1
- package/src/plugins/tangram-afc/AFCApp.tsx +341 -0
- package/src/plugins/tangram-afc/index.ts +140 -0
- package/src/plugins/tangram-nback/NBackApp.tsx +3 -3
- package/tangram-construct.min.js +10 -10
- package/tangram-nback.min.js +8 -8
- package/tangram-prep.min.js +10 -10
package/package.json
CHANGED
|
@@ -718,15 +718,25 @@ export default function GameBoard(props: GameBoardProps) {
|
|
|
718
718
|
textAlign: 'center'
|
|
719
719
|
};
|
|
720
720
|
|
|
721
|
-
// Timer style
|
|
721
|
+
// Timer style - positioned absolutely to align right edge with semicircle's right edge
|
|
722
|
+
// Independent of instructions content
|
|
723
|
+
const scaleX = svgDimensions.width / viewBox.w;
|
|
724
|
+
const rightEdgeOfSemicircleLogical = viewBox.w / 2 + layout.outerR;
|
|
725
|
+
const distanceFromRightEdgeLogical = viewBox.w - rightEdgeOfSemicircleLogical;
|
|
726
|
+
const distanceFromRightEdgePx = distanceFromRightEdgeLogical * scaleX;
|
|
727
|
+
|
|
728
|
+
// Shift left by 5 monospaced characters (monospace is typically 0.6em wide per char)
|
|
729
|
+
const charWidth = 24 * 0.6; // fontSize * typical monospace char width ratio
|
|
730
|
+
const offsetLeft = charWidth * 5;
|
|
731
|
+
|
|
722
732
|
const timerStyle: React.CSSProperties = {
|
|
723
733
|
position: 'absolute',
|
|
724
|
-
right:
|
|
734
|
+
right: `${distanceFromRightEdgePx + offsetLeft}px`,
|
|
725
735
|
fontSize: '24px',
|
|
726
736
|
fontWeight: 'bold',
|
|
727
737
|
fontFamily: 'monospace',
|
|
728
738
|
color: '#333',
|
|
729
|
-
|
|
739
|
+
whiteSpace: 'nowrap',
|
|
730
740
|
textAlign: 'right'
|
|
731
741
|
};
|
|
732
742
|
|
|
@@ -797,42 +807,3 @@ export default function GameBoard(props: GameBoardProps) {
|
|
|
797
807
|
</div>
|
|
798
808
|
);
|
|
799
809
|
}
|
|
800
|
-
|
|
801
|
-
/**
|
|
802
|
-
* Hook for managing GameBoard state externally
|
|
803
|
-
* Useful for plugins that need direct access to game state
|
|
804
|
-
*/
|
|
805
|
-
export function useGameBoard(config: GameBoardConfig) {
|
|
806
|
-
const controller = React.useMemo(() => {
|
|
807
|
-
const gameConfig = {
|
|
808
|
-
n: config.sectors.length,
|
|
809
|
-
layout: config.layout,
|
|
810
|
-
target: config.target,
|
|
811
|
-
input: config.input,
|
|
812
|
-
timeLimitMs: config.timeLimitMs,
|
|
813
|
-
maxQuickstashSlots: config.maxQuickstashSlots,
|
|
814
|
-
...(config.maxCompositeSize !== undefined && { maxCompositeSize: config.maxCompositeSize }),
|
|
815
|
-
mode: config.mode || "construction",
|
|
816
|
-
...(config.minPiecesPerMacro !== undefined && { minPiecesPerMacro: config.minPiecesPerMacro }),
|
|
817
|
-
...(config.requireAllSlots !== undefined && { requireAllSlots: config.requireAllSlots })
|
|
818
|
-
};
|
|
819
|
-
|
|
820
|
-
return new BaseGameController(
|
|
821
|
-
config.sectors,
|
|
822
|
-
config.quickstash,
|
|
823
|
-
config.primitives,
|
|
824
|
-
gameConfig
|
|
825
|
-
);
|
|
826
|
-
}, [config]);
|
|
827
|
-
|
|
828
|
-
const snapshot = React.useMemo(() =>
|
|
829
|
-
controller.snapshot(),
|
|
830
|
-
[controller.updateCount]
|
|
831
|
-
);
|
|
832
|
-
|
|
833
|
-
return {
|
|
834
|
-
controller,
|
|
835
|
-
snapshot,
|
|
836
|
-
isComplete: !!controller.state.endedAt
|
|
837
|
-
};
|
|
838
|
-
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for managing GameBoard state externally
|
|
3
|
+
*
|
|
4
|
+
* This hook provides external access to the GameBoard's internal state
|
|
5
|
+
* management, useful for plugins that need to track or control game state
|
|
6
|
+
* directly without rendering the full GameBoard component.
|
|
7
|
+
*
|
|
8
|
+
* REQUIRES: config contains valid GameBoardConfig properties
|
|
9
|
+
* EFFECTS: Creates and manages a BaseGameController instance
|
|
10
|
+
*
|
|
11
|
+
* @param config - GameBoard configuration object
|
|
12
|
+
* @returns Object containing controller, snapshot, and completion status
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React from "react";
|
|
16
|
+
import { BaseGameController } from "@/core/engine/state/BaseGameController";
|
|
17
|
+
import type { GameBoardConfig } from "./GameBoard";
|
|
18
|
+
|
|
19
|
+
export function useGameBoard(config: GameBoardConfig) {
|
|
20
|
+
const controller = React.useMemo(() => {
|
|
21
|
+
const gameConfig = {
|
|
22
|
+
n: config.sectors.length,
|
|
23
|
+
layout: config.layout,
|
|
24
|
+
target: config.target,
|
|
25
|
+
input: config.input,
|
|
26
|
+
timeLimitMs: config.timeLimitMs,
|
|
27
|
+
maxQuickstashSlots: config.maxQuickstashSlots,
|
|
28
|
+
...(config.maxCompositeSize !== undefined && { maxCompositeSize: config.maxCompositeSize }),
|
|
29
|
+
mode: config.mode || "construction",
|
|
30
|
+
...(config.minPiecesPerMacro !== undefined && { minPiecesPerMacro: config.minPiecesPerMacro }),
|
|
31
|
+
...(config.requireAllSlots !== undefined && { requireAllSlots: config.requireAllSlots })
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return new BaseGameController(
|
|
35
|
+
config.sectors,
|
|
36
|
+
config.quickstash,
|
|
37
|
+
config.primitives,
|
|
38
|
+
gameConfig
|
|
39
|
+
);
|
|
40
|
+
}, [config]);
|
|
41
|
+
|
|
42
|
+
const snapshot = React.useMemo(() =>
|
|
43
|
+
controller.snapshot(),
|
|
44
|
+
[controller.updateCount]
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
controller,
|
|
49
|
+
snapshot,
|
|
50
|
+
isComplete: !!controller.state.endedAt
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
// Core game board component
|
|
9
|
-
export { default as GameBoard
|
|
9
|
+
export { default as GameBoard } from './board/GameBoard';
|
|
10
|
+
export { useGameBoard } from './board/useGameBoard';
|
|
10
11
|
export type { GameBoardConfig, GameBoardProps } from './board/GameBoard';
|
|
11
12
|
|
|
12
13
|
// Blueprint ring component for plugin UI
|
|
13
|
-
export { default as BlueprintRing
|
|
14
|
+
export { default as BlueprintRing } from './pieces/BlueprintRing';
|
|
15
|
+
export { useBlueprintRing } from './pieces/useBlueprintRing';
|
|
14
16
|
export type { BlueprintRingProps } from './pieces/BlueprintRing';
|
|
15
17
|
|
|
16
18
|
// Legacy Board components (deprecated - use GameBoard instead)
|
|
@@ -268,29 +268,4 @@ export default function BlueprintRing(props: BlueprintRingProps) {
|
|
|
268
268
|
})}
|
|
269
269
|
</g>
|
|
270
270
|
);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Hook for managing blueprint ring state
|
|
275
|
-
* Useful for components that need more control over blueprint ring behavior
|
|
276
|
-
*/
|
|
277
|
-
export function useBlueprintRing(
|
|
278
|
-
primitives: PrimitiveBlueprint[],
|
|
279
|
-
quickstash: Blueprint[],
|
|
280
|
-
initialView: "primitives" | "quickstash" = "quickstash"
|
|
281
|
-
) {
|
|
282
|
-
const [currentView, setCurrentView] = React.useState<"primitives" | "quickstash">(initialView);
|
|
283
|
-
|
|
284
|
-
const switchView = React.useCallback(() => {
|
|
285
|
-
setCurrentView(prev => prev === "primitives" ? "quickstash" : "primitives");
|
|
286
|
-
}, []);
|
|
287
|
-
|
|
288
|
-
const blueprints = currentView === "primitives" ? primitives : quickstash;
|
|
289
|
-
|
|
290
|
-
return {
|
|
291
|
-
currentView,
|
|
292
|
-
blueprints,
|
|
293
|
-
switchView,
|
|
294
|
-
setCurrentView
|
|
295
|
-
};
|
|
296
271
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for managing BlueprintRing state externally
|
|
3
|
+
*
|
|
4
|
+
* This hook manages the state for switching between primitives and quickstash
|
|
5
|
+
* views in the blueprint ring. Useful for components that need more control
|
|
6
|
+
* over blueprint ring behavior.
|
|
7
|
+
*
|
|
8
|
+
* REQUIRES: primitives and quickstash are valid arrays of blueprints
|
|
9
|
+
* EFFECTS: Manages view state and provides toggle functionality
|
|
10
|
+
*
|
|
11
|
+
* @param primitives - Array of primitive piece blueprints
|
|
12
|
+
* @param quickstash - Array of quickstash macro blueprints
|
|
13
|
+
* @param initialView - Initial view to display (defaults to "quickstash")
|
|
14
|
+
* @returns Object with current view, blueprints, and control functions
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React from "react";
|
|
18
|
+
import type { Blueprint, PrimitiveBlueprint } from "@/core/domain/types";
|
|
19
|
+
|
|
20
|
+
export function useBlueprintRing(
|
|
21
|
+
primitives: PrimitiveBlueprint[],
|
|
22
|
+
quickstash: Blueprint[],
|
|
23
|
+
initialView: "primitives" | "quickstash" = "quickstash"
|
|
24
|
+
) {
|
|
25
|
+
const [currentView, setCurrentView] = React.useState<"primitives" | "quickstash">(initialView);
|
|
26
|
+
|
|
27
|
+
const switchView = React.useCallback(() => {
|
|
28
|
+
setCurrentView(prev => prev === "primitives" ? "quickstash" : "primitives");
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
const blueprints = currentView === "primitives" ? primitives : quickstash;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
currentView,
|
|
35
|
+
blueprints,
|
|
36
|
+
switchView,
|
|
37
|
+
setCurrentView
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { default as TangramConstructPlugin } from "./plugins/tangram-construct";
|
|
2
2
|
export { default as TangramPrepPlugin } from "./plugins/tangram-prep";
|
|
3
|
-
export { default as TangramNBackPlugin } from "./plugins/tangram-nback";
|
|
3
|
+
export { default as TangramNBackPlugin } from "./plugins/tangram-nback";
|
|
4
|
+
export { default as TangramAFCPlugin } from "./plugins/tangram-afc";
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AFCApp.tsx - React wrapper for tangram alternative forced choice (AFC) trials
|
|
3
|
+
*
|
|
4
|
+
* This component handles the React rendering logic for AFC trials,
|
|
5
|
+
* displaying two tangram silhouettes side-by-side with response buttons.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useRef, useState } from "react";
|
|
9
|
+
import { createRoot } from "react-dom/client";
|
|
10
|
+
import { JsPsych } from "jspsych";
|
|
11
|
+
import type { Poly, TanKind } from "../../core/domain/types";
|
|
12
|
+
import { placeSilhouetteGridAlignedAsPolys, inferUnitFromPolys } from "../../core/engine/geometry";
|
|
13
|
+
import { CONFIG } from "../../core/config/config";
|
|
14
|
+
|
|
15
|
+
export interface StartAFCTrialParams {
|
|
16
|
+
tangramLeft: any;
|
|
17
|
+
tangramRight: any;
|
|
18
|
+
instructions?: string;
|
|
19
|
+
buttonTextLeft?: string;
|
|
20
|
+
buttonTextRight?: string;
|
|
21
|
+
showTangramDecomposition?: boolean;
|
|
22
|
+
usePrimitiveColors?: boolean;
|
|
23
|
+
primitiveColorIndices?: number[];
|
|
24
|
+
onTrialEnd?: (data: any) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Start an AFC trial by rendering the AFCView component
|
|
29
|
+
*/
|
|
30
|
+
export function startAFCTrial(
|
|
31
|
+
display_element: HTMLElement,
|
|
32
|
+
params: StartAFCTrialParams,
|
|
33
|
+
_jsPsych: JsPsych
|
|
34
|
+
) {
|
|
35
|
+
// Create React root and render AFCView
|
|
36
|
+
const root = createRoot(display_element);
|
|
37
|
+
root.render(React.createElement(AFCView, { params }));
|
|
38
|
+
|
|
39
|
+
return { root, display_element, jsPsych: _jsPsych };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AFCViewProps {
|
|
43
|
+
params: StartAFCTrialParams;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function AFCView({ params }: AFCViewProps) {
|
|
47
|
+
const {
|
|
48
|
+
tangramLeft,
|
|
49
|
+
tangramRight,
|
|
50
|
+
instructions,
|
|
51
|
+
buttonTextLeft,
|
|
52
|
+
buttonTextRight,
|
|
53
|
+
showTangramDecomposition,
|
|
54
|
+
usePrimitiveColors,
|
|
55
|
+
primitiveColorIndices,
|
|
56
|
+
onTrialEnd
|
|
57
|
+
} = params;
|
|
58
|
+
|
|
59
|
+
// Timing and response tracking
|
|
60
|
+
const trialStartTime = useRef<number>(Date.now());
|
|
61
|
+
const [hasResponded, setHasResponded] = useState(false);
|
|
62
|
+
|
|
63
|
+
const handleResponse = (choice: "left" | "right") => {
|
|
64
|
+
if (hasResponded) return;
|
|
65
|
+
|
|
66
|
+
setHasResponded(true);
|
|
67
|
+
const rt = Date.now() - trialStartTime.current;
|
|
68
|
+
|
|
69
|
+
if (onTrialEnd) {
|
|
70
|
+
onTrialEnd({
|
|
71
|
+
rt,
|
|
72
|
+
response: choice,
|
|
73
|
+
show_tangram_decomposition: showTangramDecomposition,
|
|
74
|
+
use_primitive_colors: usePrimitiveColors,
|
|
75
|
+
primitive_color_indices: primitiveColorIndices,
|
|
76
|
+
button_text_left: buttonTextLeft,
|
|
77
|
+
button_text_right: buttonTextRight
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div style={{
|
|
84
|
+
display: "flex",
|
|
85
|
+
flexDirection: "column",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
justifyContent: "center",
|
|
88
|
+
padding: "20px",
|
|
89
|
+
background: "#fff7e0ff",
|
|
90
|
+
minHeight: "100vh"
|
|
91
|
+
}}>
|
|
92
|
+
{/* Instructions */}
|
|
93
|
+
{instructions && (
|
|
94
|
+
<div
|
|
95
|
+
style={{
|
|
96
|
+
maxWidth: "800px",
|
|
97
|
+
width: "100%",
|
|
98
|
+
marginBottom: "30px",
|
|
99
|
+
textAlign: "center",
|
|
100
|
+
fontSize: "18px",
|
|
101
|
+
lineHeight: "1.5"
|
|
102
|
+
}}
|
|
103
|
+
dangerouslySetInnerHTML={{ __html: instructions }}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{/* Container for Tangrams */}
|
|
108
|
+
<div style={{
|
|
109
|
+
display: "flex",
|
|
110
|
+
flexDirection: "row",
|
|
111
|
+
gap: "50px",
|
|
112
|
+
justifyContent: "center",
|
|
113
|
+
alignItems: "flex-start",
|
|
114
|
+
flexWrap: "wrap"
|
|
115
|
+
}}>
|
|
116
|
+
{/* Left Option */}
|
|
117
|
+
<TangramOption
|
|
118
|
+
tangram={tangramLeft}
|
|
119
|
+
buttonText={buttonTextLeft}
|
|
120
|
+
onClick={() => handleResponse("left")}
|
|
121
|
+
disabled={hasResponded}
|
|
122
|
+
showDecomposition={showTangramDecomposition}
|
|
123
|
+
usePrimitiveColors={usePrimitiveColors}
|
|
124
|
+
primitiveColorIndices={primitiveColorIndices}
|
|
125
|
+
/>
|
|
126
|
+
|
|
127
|
+
{/* Right Option */}
|
|
128
|
+
<TangramOption
|
|
129
|
+
tangram={tangramRight}
|
|
130
|
+
buttonText={buttonTextRight}
|
|
131
|
+
onClick={() => handleResponse("right")}
|
|
132
|
+
disabled={hasResponded}
|
|
133
|
+
showDecomposition={showTangramDecomposition}
|
|
134
|
+
usePrimitiveColors={usePrimitiveColors}
|
|
135
|
+
primitiveColorIndices={primitiveColorIndices}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface TangramOptionProps {
|
|
143
|
+
tangram: any;
|
|
144
|
+
buttonText: string;
|
|
145
|
+
onClick: () => void;
|
|
146
|
+
disabled: boolean;
|
|
147
|
+
showDecomposition?: boolean;
|
|
148
|
+
usePrimitiveColors?: boolean;
|
|
149
|
+
primitiveColorIndices?: number[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function TangramOption({
|
|
153
|
+
tangram,
|
|
154
|
+
buttonText,
|
|
155
|
+
onClick,
|
|
156
|
+
disabled,
|
|
157
|
+
showDecomposition,
|
|
158
|
+
usePrimitiveColors,
|
|
159
|
+
primitiveColorIndices
|
|
160
|
+
}: TangramOptionProps) {
|
|
161
|
+
|
|
162
|
+
if (!tangram) {
|
|
163
|
+
return <div style={{ width: 300, height: 300, background: "#eee" }}>No Tangram Data</div>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Canonical piece names
|
|
167
|
+
const CANON = new Set([
|
|
168
|
+
"square",
|
|
169
|
+
"smalltriangle",
|
|
170
|
+
"parallelogram",
|
|
171
|
+
"medtriangle",
|
|
172
|
+
"largetriangle",
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
// Convert TangramSpec to internal format
|
|
176
|
+
const filteredTans = tangram.solutionTans.filter((tan: any) => {
|
|
177
|
+
const tanName = tan.name ?? tan.kind;
|
|
178
|
+
return CANON.has(tanName);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const mask = filteredTans.map((tan: any) => {
|
|
182
|
+
const polygon = tan.vertices.map(([x, y]: number[]) => ({ x: x ?? 0, y: -(y ?? 0) }));
|
|
183
|
+
return polygon;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const primitiveDecomposition = filteredTans.map((tan: any) => ({
|
|
187
|
+
kind: (tan.name ?? tan.kind) as TanKind,
|
|
188
|
+
polygon: tan.vertices.map(([x, y]: number[]) => ({ x: x ?? 0, y: -(y ?? 0) }))
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
// Use FIXED viewport size
|
|
192
|
+
const DISPLAY_SIZE = 300;
|
|
193
|
+
const viewport = {
|
|
194
|
+
w: DISPLAY_SIZE,
|
|
195
|
+
h: DISPLAY_SIZE
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Compute scale factor
|
|
199
|
+
const scaleS = React.useMemo(() => {
|
|
200
|
+
const u = inferUnitFromPolys(mask);
|
|
201
|
+
return u ? (CONFIG.layout.grid.unitPx / u) : 1;
|
|
202
|
+
}, [mask]);
|
|
203
|
+
|
|
204
|
+
const centerPos = {
|
|
205
|
+
cx: viewport.w / 2,
|
|
206
|
+
cy: viewport.h / 2
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const pathD = (poly: Poly): string => {
|
|
210
|
+
if (!poly || poly.length === 0) return "";
|
|
211
|
+
const moves = poly.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`);
|
|
212
|
+
return moves.join(" ") + " Z";
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const renderSilhouette = () => {
|
|
216
|
+
if (showDecomposition) {
|
|
217
|
+
const rawPolys = primitiveDecomposition.map((primInfo: any) => primInfo.polygon);
|
|
218
|
+
const placedPolys = placeSilhouetteGridAlignedAsPolys(rawPolys, scaleS, centerPos);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<g key="sil-decomposed" pointerEvents="none">
|
|
222
|
+
{placedPolys.map((scaledPoly, i) => {
|
|
223
|
+
const primInfo = primitiveDecomposition[i];
|
|
224
|
+
let fillColor = CONFIG.color.silhouetteMask;
|
|
225
|
+
|
|
226
|
+
if (usePrimitiveColors && primInfo?.kind && primitiveColorIndices) {
|
|
227
|
+
const kindToIndex: Record<TanKind, number> = {
|
|
228
|
+
'square': 0,
|
|
229
|
+
'smalltriangle': 1,
|
|
230
|
+
'parallelogram': 2,
|
|
231
|
+
'medtriangle': 3,
|
|
232
|
+
'largetriangle': 4
|
|
233
|
+
};
|
|
234
|
+
const primitiveIndex = kindToIndex[primInfo.kind as TanKind];
|
|
235
|
+
if (primitiveIndex !== undefined && primitiveColorIndices[primitiveIndex] !== undefined) {
|
|
236
|
+
const colorIndex = primitiveColorIndices[primitiveIndex];
|
|
237
|
+
const color = CONFIG.color.primitiveColors[colorIndex];
|
|
238
|
+
if (color) {
|
|
239
|
+
fillColor = color;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<React.Fragment key={`prim-${i}`}>
|
|
246
|
+
<path
|
|
247
|
+
d={pathD(scaledPoly)}
|
|
248
|
+
fill={fillColor}
|
|
249
|
+
opacity={usePrimitiveColors ? CONFIG.opacity.piece.normal : CONFIG.opacity.silhouetteMask}
|
|
250
|
+
stroke="none"
|
|
251
|
+
/>
|
|
252
|
+
<path
|
|
253
|
+
d={pathD(scaledPoly)}
|
|
254
|
+
fill="none"
|
|
255
|
+
stroke={CONFIG.color.tangramDecomposition.stroke}
|
|
256
|
+
strokeWidth={CONFIG.size.stroke.tangramDecompositionPx}
|
|
257
|
+
/>
|
|
258
|
+
</React.Fragment>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
</g>
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
const placedPolys = placeSilhouetteGridAlignedAsPolys(mask, scaleS, centerPos);
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<g key="sil-unified" pointerEvents="none">
|
|
268
|
+
{placedPolys.map((scaledPoly, i) => {
|
|
269
|
+
const primInfo = primitiveDecomposition[i];
|
|
270
|
+
let fillColor = CONFIG.color.silhouetteMask;
|
|
271
|
+
|
|
272
|
+
if (usePrimitiveColors && primInfo?.kind && primitiveColorIndices) {
|
|
273
|
+
const kindToIndex: Record<TanKind, number> = {
|
|
274
|
+
'square': 0,
|
|
275
|
+
'smalltriangle': 1,
|
|
276
|
+
'parallelogram': 2,
|
|
277
|
+
'medtriangle': 3,
|
|
278
|
+
'largetriangle': 4
|
|
279
|
+
};
|
|
280
|
+
const primitiveIndex = kindToIndex[primInfo.kind as TanKind];
|
|
281
|
+
if (primitiveIndex !== undefined && primitiveColorIndices[primitiveIndex] !== undefined) {
|
|
282
|
+
const colorIndex = primitiveColorIndices[primitiveIndex];
|
|
283
|
+
const color = CONFIG.color.primitiveColors[colorIndex];
|
|
284
|
+
if (color) {
|
|
285
|
+
fillColor = color;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<path
|
|
292
|
+
key={`sil-${i}`}
|
|
293
|
+
d={pathD(scaledPoly)}
|
|
294
|
+
fill={fillColor}
|
|
295
|
+
opacity={usePrimitiveColors ? CONFIG.opacity.piece.normal : CONFIG.opacity.silhouetteMask}
|
|
296
|
+
stroke="none"
|
|
297
|
+
/>
|
|
298
|
+
);
|
|
299
|
+
})}
|
|
300
|
+
</g>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<div style={{
|
|
307
|
+
display: "flex",
|
|
308
|
+
flexDirection: "column",
|
|
309
|
+
alignItems: "center",
|
|
310
|
+
gap: "20px"
|
|
311
|
+
}}>
|
|
312
|
+
<svg
|
|
313
|
+
width={viewport.w}
|
|
314
|
+
height={viewport.h}
|
|
315
|
+
viewBox={`0 0 ${viewport.w} ${viewport.h}`}
|
|
316
|
+
style={{
|
|
317
|
+
display: "block",
|
|
318
|
+
background: CONFIG.color.bands.silhouette.fillEven,
|
|
319
|
+
border: `${CONFIG.size.stroke.bandPx}px solid ${CONFIG.color.bands.silhouette.stroke}`,
|
|
320
|
+
borderRadius: "8px"
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
{renderSilhouette()}
|
|
324
|
+
</svg>
|
|
325
|
+
|
|
326
|
+
<button
|
|
327
|
+
className="jspsych-btn"
|
|
328
|
+
onClick={onClick}
|
|
329
|
+
disabled={disabled}
|
|
330
|
+
style={{
|
|
331
|
+
padding: "12px 30px",
|
|
332
|
+
fontSize: "16px",
|
|
333
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
334
|
+
opacity: disabled ? 0.5 : 1
|
|
335
|
+
}}
|
|
336
|
+
>
|
|
337
|
+
{buttonText}
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|