jspsych-tangram 0.0.1
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/README.md +25 -0
- package/dist/construct/index.browser.js +20431 -0
- package/dist/construct/index.browser.js.map +1 -0
- package/dist/construct/index.browser.min.js +42 -0
- package/dist/construct/index.browser.min.js.map +1 -0
- package/dist/construct/index.cjs +3720 -0
- package/dist/construct/index.cjs.map +1 -0
- package/dist/construct/index.d.ts +204 -0
- package/dist/construct/index.js +3718 -0
- package/dist/construct/index.js.map +1 -0
- package/dist/index.cjs +3920 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +340 -0
- package/dist/index.js +3917 -0
- package/dist/index.js.map +1 -0
- package/dist/prep/index.browser.js +20455 -0
- package/dist/prep/index.browser.js.map +1 -0
- package/dist/prep/index.browser.min.js +42 -0
- package/dist/prep/index.browser.min.js.map +1 -0
- package/dist/prep/index.cjs +3744 -0
- package/dist/prep/index.cjs.map +1 -0
- package/dist/prep/index.d.ts +139 -0
- package/dist/prep/index.js +3742 -0
- package/dist/prep/index.js.map +1 -0
- package/package.json +77 -0
- package/src/core/components/README.md +249 -0
- package/src/core/components/board/BoardView.tsx +352 -0
- package/src/core/components/board/GameBoard.tsx +682 -0
- package/src/core/components/board/index.ts +70 -0
- package/src/core/components/board/useAnchorGrid.ts +110 -0
- package/src/core/components/board/useClickController.ts +436 -0
- package/src/core/components/board/useDragController.ts +1051 -0
- package/src/core/components/board/usePieceState.ts +178 -0
- package/src/core/components/board/utils.ts +76 -0
- package/src/core/components/index.ts +33 -0
- package/src/core/components/pieces/BlueprintRing.tsx +238 -0
- package/src/core/config/config.ts +85 -0
- package/src/core/domain/blueprints.ts +25 -0
- package/src/core/domain/layout.ts +159 -0
- package/src/core/domain/primitives.ts +159 -0
- package/src/core/domain/solve.ts +184 -0
- package/src/core/domain/types.ts +111 -0
- package/src/core/engine/collision/grid-snapping.ts +283 -0
- package/src/core/engine/collision/index.ts +4 -0
- package/src/core/engine/collision/sat-collision.ts +46 -0
- package/src/core/engine/collision/validation.ts +166 -0
- package/src/core/engine/geometry/bounds.ts +91 -0
- package/src/core/engine/geometry/collision.ts +64 -0
- package/src/core/engine/geometry/index.ts +19 -0
- package/src/core/engine/geometry/math.ts +101 -0
- package/src/core/engine/geometry/pieces.ts +290 -0
- package/src/core/engine/geometry/polygons.ts +43 -0
- package/src/core/engine/state/BaseGameController.ts +368 -0
- package/src/core/engine/validation/border-rendering.ts +318 -0
- package/src/core/engine/validation/complete.ts +102 -0
- package/src/core/engine/validation/face-to-face.ts +217 -0
- package/src/core/index.ts +3 -0
- package/src/core/io/InteractionTracker.ts +742 -0
- package/src/core/io/data-tracking.ts +271 -0
- package/src/core/io/json-to-tangram-spec.ts +110 -0
- package/src/core/io/quickstash.ts +141 -0
- package/src/core/io/stims.ts +110 -0
- package/src/core/types/index.ts +5 -0
- package/src/core/types/plugin-interfaces.ts +101 -0
- package/src/index.spec.ts +19 -0
- package/src/index.ts +2 -0
- package/src/plugins/tangram-construct/ConstructionApp.tsx +105 -0
- package/src/plugins/tangram-construct/index.ts +156 -0
- package/src/plugins/tangram-prep/PrepApp.tsx +182 -0
- package/src/plugins/tangram-prep/index.ts +122 -0
- package/tangram-construct.min.js +42 -0
- package/tangram-prep.min.js +42 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Clean jsPsych plugin interfaces - JSON input/output contracts
|
|
2
|
+
import { Sector, Blueprint, ActionEvent, LayoutMode, PlacementTarget, InputMode, BlueprintView, PrimitiveBlueprint, SectorState } from '../domain/types';
|
|
3
|
+
|
|
4
|
+
// CRITICAL: These interfaces define the plugin boundaries
|
|
5
|
+
// NO hardcoded data, NO default values - everything injected
|
|
6
|
+
|
|
7
|
+
// Plugin 1: Construction Trial (main game)
|
|
8
|
+
export interface ConstructionTrialParams {
|
|
9
|
+
tangrams: TangramSpec[]; // Target tangrams to construct (sectors in UI)
|
|
10
|
+
quickstash_macros: MacroSpec[]; // Pre-made macros from prep trial
|
|
11
|
+
time_limit_ms: number;
|
|
12
|
+
target: PlacementTarget; // "workspace" | "silhouette" - CRITICAL
|
|
13
|
+
input: InputMode; // "click" | "drag" - CRITICAL
|
|
14
|
+
layout: LayoutMode; // "circle" | "semicircle"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ConstructionTrialData {
|
|
18
|
+
events: ActionEvent[]; // All interactions
|
|
19
|
+
completion_times: number[]; // Time per sector
|
|
20
|
+
final_state: CompletionState[]; // Final state of each sector
|
|
21
|
+
macros_used: MacroUsageData[]; // Macro usage tracking
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Plugin 2: Prep Trial (macro creation)
|
|
25
|
+
export interface PrepTrialParams {
|
|
26
|
+
num_quickstash_slots: number; // How many macros user can create
|
|
27
|
+
time_limit_ms?: number; // Optional time limit
|
|
28
|
+
instructions?: string; // Custom instructions
|
|
29
|
+
// NOTE: primitives are NOT passed in - they are unchanging/hardcoded
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PrepTrialData {
|
|
33
|
+
created_macros: MacroSpec[]; // The macros user created
|
|
34
|
+
creation_events: MacroEvent[]; // Step-by-step creation process
|
|
35
|
+
completion_time_ms: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Supporting types for plugin interfaces
|
|
39
|
+
export interface TangramSpec {
|
|
40
|
+
id: string;
|
|
41
|
+
silhouette: {
|
|
42
|
+
mask: number[][][]; // Polygon arrays as numbers
|
|
43
|
+
requiredCount: number;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MacroSpec {
|
|
48
|
+
id: string;
|
|
49
|
+
parts: Array<{
|
|
50
|
+
kind: string; // TanKind as string
|
|
51
|
+
offset: { x: number; y: number };
|
|
52
|
+
}>;
|
|
53
|
+
shape: number[][][]; // Union shape as number arrays
|
|
54
|
+
colorHint?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// NOTE: PrimitiveSpec removed - primitives are unchanging and not passed to plugins
|
|
58
|
+
|
|
59
|
+
export interface CompletionState {
|
|
60
|
+
sectorId: string;
|
|
61
|
+
completed: boolean;
|
|
62
|
+
completedAt?: number;
|
|
63
|
+
pieceCount: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MacroUsageData {
|
|
67
|
+
macroId: string;
|
|
68
|
+
usedAt: number;
|
|
69
|
+
sectorId: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MacroEvent {
|
|
73
|
+
t: number;
|
|
74
|
+
type: "start_macro" | "add_piece" | "complete_macro" | "discard_macro";
|
|
75
|
+
macroId?: string;
|
|
76
|
+
pieceId?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Legacy configuration interface (will be removed during refactoring)
|
|
80
|
+
export type RoundConfig = {
|
|
81
|
+
n: number;
|
|
82
|
+
layout: LayoutMode;
|
|
83
|
+
target: PlacementTarget;
|
|
84
|
+
input: InputMode;
|
|
85
|
+
maxQuickstashSlots: number;
|
|
86
|
+
timeLimitMs: number;
|
|
87
|
+
snapRadiusPx?: number;
|
|
88
|
+
maxCompositeSize: number;
|
|
89
|
+
sectors: Sector[]; // REMOVE: Should be injected via plugin params
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Legacy state interface (will be refactored)
|
|
93
|
+
export type RoundState = {
|
|
94
|
+
cfg: RoundConfig; // REMOVE: Config should not contain data
|
|
95
|
+
blueprintView: BlueprintView;
|
|
96
|
+
primitives: PrimitiveBlueprint[]; // REMOVE: Should be injected
|
|
97
|
+
quickstash: Blueprint[]; // REMOVE: Should be injected
|
|
98
|
+
sectors: Record<string, SectorState>;
|
|
99
|
+
startedAt: number;
|
|
100
|
+
endedAt?: number;
|
|
101
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { startTimeline } from "@jspsych/test-utils";
|
|
2
|
+
|
|
3
|
+
import { TangramConstructPlugin } from ".";
|
|
4
|
+
|
|
5
|
+
jest.useFakeTimers();
|
|
6
|
+
|
|
7
|
+
describe("my plugin", () => {
|
|
8
|
+
it("should load", async () => {
|
|
9
|
+
const { expectFinished } = await startTimeline([
|
|
10
|
+
{
|
|
11
|
+
type: TangramConstructPlugin,
|
|
12
|
+
parameter_name: 1,
|
|
13
|
+
parameter_name2: "img.png",
|
|
14
|
+
},
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
await expectFinished();
|
|
18
|
+
});
|
|
19
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConstructionApp.tsx - React wrapper for tangram construction trials
|
|
3
|
+
*
|
|
4
|
+
* This component handles the React rendering logic for construction trials,
|
|
5
|
+
* separating it from the pure jsPsych plugin class.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { createRoot } from "react-dom/client";
|
|
10
|
+
import { JsPsych } from "jspsych";
|
|
11
|
+
import { GameBoard } from "../../core/components";
|
|
12
|
+
import { PRIMITIVE_BLUEPRINTS } from "../../core/domain/primitives";
|
|
13
|
+
import { convertAnchorCompositeToPixels } from "../../core/io/quickstash";
|
|
14
|
+
import type {
|
|
15
|
+
Sector,
|
|
16
|
+
Blueprint,
|
|
17
|
+
PlacementTarget,
|
|
18
|
+
InputMode,
|
|
19
|
+
LayoutMode,
|
|
20
|
+
TanKind,
|
|
21
|
+
PrimitiveBlueprint
|
|
22
|
+
} from "../../core/domain/types";
|
|
23
|
+
import { CONFIG } from "../../core/config/config";
|
|
24
|
+
|
|
25
|
+
export interface StartConstructionTrialParams {
|
|
26
|
+
tangrams: any[];
|
|
27
|
+
quickstash_macros?: Blueprint[] | AnchorComposite[];
|
|
28
|
+
target?: PlacementTarget;
|
|
29
|
+
input?: InputMode;
|
|
30
|
+
layout?: LayoutMode;
|
|
31
|
+
time_limit_ms: number;
|
|
32
|
+
onInteraction?: (event: any) => void;
|
|
33
|
+
onTrialEnd?: (data: any) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Type for anchor-based composite definitions
|
|
37
|
+
type AnchorComposite = {
|
|
38
|
+
id: string;
|
|
39
|
+
parts: Array<{ kind: TanKind; anchorOffset: { x: number; y: number } }>;
|
|
40
|
+
label?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Start a construction trial by rendering the GameBoard component
|
|
45
|
+
*/
|
|
46
|
+
export function startConstructionTrial(
|
|
47
|
+
display_element: HTMLElement,
|
|
48
|
+
params: StartConstructionTrialParams,
|
|
49
|
+
_jsPsych: JsPsych
|
|
50
|
+
) {
|
|
51
|
+
// Convert JSON plugin parameters to internal Sector[] format
|
|
52
|
+
const sectors: Sector[] = params.tangrams.map(tangramSpec => ({
|
|
53
|
+
id: tangramSpec.id,
|
|
54
|
+
silhouette: {
|
|
55
|
+
id: tangramSpec.id,
|
|
56
|
+
mask: tangramSpec.silhouette.mask.map((polygonArray: number[][]) =>
|
|
57
|
+
polygonArray.map(([x, y]: number[]) => ({ x: x ?? 0, y: -(y ?? 0) })) // Convert to SVG coords (flip Y)
|
|
58
|
+
),
|
|
59
|
+
requiredCount: tangramSpec.silhouette.requiredCount,
|
|
60
|
+
},
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
// Convert quickstash_macros to Blueprint[] format
|
|
64
|
+
// Handle both anchor-based composites and pre-converted blueprints
|
|
65
|
+
let quickstash: Blueprint[] = [];
|
|
66
|
+
|
|
67
|
+
if (params.quickstash_macros && params.quickstash_macros.length > 0) {
|
|
68
|
+
// Check if the first item has anchorOffset (anchor-based) or offset (pixel-based)
|
|
69
|
+
const firstMacro = params.quickstash_macros[0];
|
|
70
|
+
if (firstMacro && 'parts' in firstMacro && firstMacro.parts && firstMacro.parts[0] && 'anchorOffset' in firstMacro.parts[0]) {
|
|
71
|
+
|
|
72
|
+
// Create primitive map for conversion
|
|
73
|
+
const primsByKind = new Map<TanKind, PrimitiveBlueprint>();
|
|
74
|
+
PRIMITIVE_BLUEPRINTS.forEach(p => primsByKind.set(p.kind, p));
|
|
75
|
+
|
|
76
|
+
// Convert each anchor composite to pixel-based blueprint
|
|
77
|
+
quickstash = (params.quickstash_macros as AnchorComposite[]).map(anchorComposite =>
|
|
78
|
+
convertAnchorCompositeToPixels(anchorComposite, primsByKind, CONFIG.layout.grid.stepPx) // Use current CONFIG grid step
|
|
79
|
+
);
|
|
80
|
+
} else {
|
|
81
|
+
// Already pixel-based blueprints
|
|
82
|
+
quickstash = params.quickstash_macros as Blueprint[];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Create React root and render GameBoard
|
|
87
|
+
const root = createRoot(display_element);
|
|
88
|
+
root.render(
|
|
89
|
+
React.createElement(GameBoard, {
|
|
90
|
+
sectors,
|
|
91
|
+
quickstash,
|
|
92
|
+
primitives: PRIMITIVE_BLUEPRINTS,
|
|
93
|
+
layout: (params.layout || "semicircle") as LayoutMode,
|
|
94
|
+
target: (params.target || "silhouette") as PlacementTarget,
|
|
95
|
+
input: (params.input || "drag") as InputMode,
|
|
96
|
+
timeLimitMs: params.time_limit_ms || 0,
|
|
97
|
+
maxQuickstashSlots: CONFIG.layout.defaults.maxQuickstashSlots,
|
|
98
|
+
mode: 'construction', // Explicit construction mode
|
|
99
|
+
...(params.onInteraction && { onInteraction: params.onInteraction }),
|
|
100
|
+
...(params.onTrialEnd && { onTrialEnd: params.onTrialEnd })
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return { root, display_element, jsPsych: _jsPsych };
|
|
105
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
|
|
2
|
+
import { startConstructionTrial, StartConstructionTrialParams } from "./ConstructionApp";
|
|
3
|
+
|
|
4
|
+
const info = {
|
|
5
|
+
name: "tangram-construct",
|
|
6
|
+
version: "1.0.0",
|
|
7
|
+
parameters: {
|
|
8
|
+
/** Array of tangram specifications defining the puzzles to solve */
|
|
9
|
+
tangrams: {
|
|
10
|
+
type: ParameterType.COMPLEX,
|
|
11
|
+
default: undefined,
|
|
12
|
+
description: "Array of TangramSpec objects defining target shapes to construct"
|
|
13
|
+
},
|
|
14
|
+
/** Array of pre-made macro pieces from prep plugin */
|
|
15
|
+
quickstash_macros: {
|
|
16
|
+
type: ParameterType.COMPLEX,
|
|
17
|
+
default: [],
|
|
18
|
+
description: "Array of MacroSpec objects created in prep trial"
|
|
19
|
+
},
|
|
20
|
+
/** Whether to place pieces in workspace or directly on silhouette */
|
|
21
|
+
target: {
|
|
22
|
+
type: ParameterType.SELECT,
|
|
23
|
+
options: ["workspace", "silhouette"],
|
|
24
|
+
default: "silhouette",
|
|
25
|
+
description: "Placement target: workspace (free placement) or silhouette (onto target shape)"
|
|
26
|
+
},
|
|
27
|
+
/** Input method for piece manipulation */
|
|
28
|
+
input: {
|
|
29
|
+
type: ParameterType.SELECT,
|
|
30
|
+
options: ["click", "drag"],
|
|
31
|
+
default: "drag",
|
|
32
|
+
description: "Input mode: drag (mouse/touch drag) or click (click to select/place)"
|
|
33
|
+
},
|
|
34
|
+
/** Layout style for piece arrangement */
|
|
35
|
+
layout: {
|
|
36
|
+
type: ParameterType.SELECT,
|
|
37
|
+
options: ["circle", "semicircle"],
|
|
38
|
+
default: "semicircle",
|
|
39
|
+
description: "Layout mode: full circle or semicircle piece arrangement"
|
|
40
|
+
},
|
|
41
|
+
/** Maximum time allowed for trial in milliseconds */
|
|
42
|
+
time_limit_ms: {
|
|
43
|
+
type: ParameterType.INT,
|
|
44
|
+
default: 0,
|
|
45
|
+
description: "Time limit in milliseconds (0 = no limit)"
|
|
46
|
+
},
|
|
47
|
+
/** Snap radius for piece placement in pixels */
|
|
48
|
+
snap_radius_px: {
|
|
49
|
+
type: ParameterType.INT,
|
|
50
|
+
default: 18,
|
|
51
|
+
description: "Snap radius for anchor-based piece placement"
|
|
52
|
+
},
|
|
53
|
+
/** Callback fired after each interaction (piece pickup + placedown) */
|
|
54
|
+
onInteraction: {
|
|
55
|
+
type: ParameterType.FUNCTION,
|
|
56
|
+
default: undefined,
|
|
57
|
+
description: "Callback for each interaction event (e.g., socket.emit)"
|
|
58
|
+
},
|
|
59
|
+
/** Callback fired when trial ends */
|
|
60
|
+
onTrialEnd: {
|
|
61
|
+
type: ParameterType.FUNCTION,
|
|
62
|
+
default: undefined,
|
|
63
|
+
description: "Callback when trial completes with full data"
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
data: {
|
|
67
|
+
/** Array of all interaction events during trial */
|
|
68
|
+
events: {
|
|
69
|
+
type: ParameterType.COMPLEX,
|
|
70
|
+
description: "Chronological log of all user interactions and game events"
|
|
71
|
+
},
|
|
72
|
+
/** Completion time for each sector/tangram */
|
|
73
|
+
completion_times: {
|
|
74
|
+
type: ParameterType.COMPLEX,
|
|
75
|
+
description: "Array of completion times in milliseconds for each tangram"
|
|
76
|
+
},
|
|
77
|
+
/** Final completion state of each sector */
|
|
78
|
+
final_state: {
|
|
79
|
+
type: ParameterType.COMPLEX,
|
|
80
|
+
description: "Array describing completion status of each tangram sector"
|
|
81
|
+
},
|
|
82
|
+
/** Usage statistics for macro pieces */
|
|
83
|
+
macros_used: {
|
|
84
|
+
type: ParameterType.COMPLEX,
|
|
85
|
+
description: "Array tracking which macros were used and when"
|
|
86
|
+
},
|
|
87
|
+
/** Total trial duration in milliseconds */
|
|
88
|
+
trial_duration_ms: {
|
|
89
|
+
type: ParameterType.INT,
|
|
90
|
+
description: "Total time from trial start to completion"
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
citations: ""
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type Info = typeof info;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* **tangram-construct**
|
|
100
|
+
*
|
|
101
|
+
* A jsPsych wrapper around the React-based construction interface.
|
|
102
|
+
*
|
|
103
|
+
* @author Justin Yang & Sean Paul Anderson
|
|
104
|
+
* @see {@link https://github.com/cogtoolslab/tangram_construction.git/tree/main/experiments/jspsych-tangram-prep}
|
|
105
|
+
*/
|
|
106
|
+
class TangramConstructPlugin implements JsPsychPlugin<Info> {
|
|
107
|
+
static info = info;
|
|
108
|
+
|
|
109
|
+
constructor(private jsPsych: JsPsych) {}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Launches the trial by invoking startConstructionTrial
|
|
113
|
+
* with the display element, parameters, and jsPsych instance.
|
|
114
|
+
*/
|
|
115
|
+
trial(display_element: HTMLElement, trial: TrialType<Info>) {
|
|
116
|
+
// Wrap onTrialEnd to handle React cleanup and jsPsych trial completion
|
|
117
|
+
const wrappedOnTrialEnd = (data: any) => {
|
|
118
|
+
// Call user-provided callback if exists
|
|
119
|
+
if (trial.onTrialEnd) {
|
|
120
|
+
trial.onTrialEnd(data);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Clean up React first (before clearing DOM)
|
|
124
|
+
const reactContext = (display_element as any).__reactContext;
|
|
125
|
+
if (reactContext?.root) {
|
|
126
|
+
reactContext.root.unmount();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Clear display after React cleanup
|
|
130
|
+
display_element.innerHTML = '';
|
|
131
|
+
|
|
132
|
+
// Finish jsPsych trial with data
|
|
133
|
+
this.jsPsych.finishTrial(data);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Create parameter object for wrapper
|
|
137
|
+
const params: StartConstructionTrialParams = {
|
|
138
|
+
tangrams: trial.tangrams,
|
|
139
|
+
quickstash_macros: trial.quickstash_macros,
|
|
140
|
+
target: trial.target,
|
|
141
|
+
input: trial.input,
|
|
142
|
+
layout: trial.layout,
|
|
143
|
+
time_limit_ms: trial.time_limit_ms || 0,
|
|
144
|
+
onInteraction: trial.onInteraction,
|
|
145
|
+
onTrialEnd: wrappedOnTrialEnd
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Use React wrapper to start the trial
|
|
149
|
+
const { root, display_element: element, jsPsych } = startConstructionTrial(display_element, params, this.jsPsych);
|
|
150
|
+
|
|
151
|
+
// Store React context for cleanup
|
|
152
|
+
(element as any).__reactContext = { root, jsPsych };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export default TangramConstructPlugin;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PrepApp.tsx - React wrapper for tangram prep trials
|
|
3
|
+
*
|
|
4
|
+
* This component handles the React rendering logic for prep trials,
|
|
5
|
+
* separating it from the pure jsPsych plugin class.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { createRoot } from "react-dom/client";
|
|
10
|
+
import { JsPsych } from "jspsych";
|
|
11
|
+
import { GameBoard } from "../../core/components";
|
|
12
|
+
import { PRIMITIVE_BLUEPRINTS } from "../../core/domain/primitives";
|
|
13
|
+
import { convertAnchorCompositeToPixels } from "../../core/io/quickstash";
|
|
14
|
+
import type { Sector, TanKind, PrimitiveBlueprint } from "../../core/domain/types";
|
|
15
|
+
import { BaseGameController } from "../../core/engine/state/BaseGameController";
|
|
16
|
+
import { CONFIG } from "../../core/config/config";
|
|
17
|
+
import { rectForBand } from "../../core/domain/layout";
|
|
18
|
+
import { boundsOfBlueprint } from "../../core/engine/geometry";
|
|
19
|
+
|
|
20
|
+
// Type for anchor-based composite definitions (matches what convertAnchorCompositeToPixels expects)
|
|
21
|
+
type AnchorComposite = {
|
|
22
|
+
id: string;
|
|
23
|
+
parts: Array<{ kind: TanKind; anchorOffset: { x: number; y: number } }>;
|
|
24
|
+
label?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface StartPrepTrialParams {
|
|
28
|
+
numQuickstashSlots: number;
|
|
29
|
+
maxPiecesPerMacro: number;
|
|
30
|
+
minPiecesPerMacro: number;
|
|
31
|
+
inputMode: "click" | "drag";
|
|
32
|
+
layoutMode: "circle" | "semicircle";
|
|
33
|
+
requireAllSlots: boolean;
|
|
34
|
+
quickstashMacros?: AnchorComposite[];
|
|
35
|
+
onInteraction?: (event: any) => void;
|
|
36
|
+
onTrialEnd?: (data: any) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start a prep trial by rendering the GameBoard component in prep mode
|
|
41
|
+
*/
|
|
42
|
+
export function startPrepTrial(
|
|
43
|
+
display_element: HTMLElement,
|
|
44
|
+
params: StartPrepTrialParams,
|
|
45
|
+
jsPsych: JsPsych
|
|
46
|
+
) {
|
|
47
|
+
// Extract parameters (defaults handled at plugin level)
|
|
48
|
+
const {
|
|
49
|
+
numQuickstashSlots,
|
|
50
|
+
maxPiecesPerMacro,
|
|
51
|
+
minPiecesPerMacro,
|
|
52
|
+
inputMode,
|
|
53
|
+
layoutMode,
|
|
54
|
+
requireAllSlots,
|
|
55
|
+
quickstashMacros,
|
|
56
|
+
onInteraction,
|
|
57
|
+
onTrialEnd,
|
|
58
|
+
} = params;
|
|
59
|
+
|
|
60
|
+
// Create blank prep sectors (no silhouettes)
|
|
61
|
+
const prepSectors: Sector[] = Array.from({ length: numQuickstashSlots }, (_, i) => ({
|
|
62
|
+
id: `prep-sector-${i}`,
|
|
63
|
+
silhouette: {
|
|
64
|
+
id: `prep-silhouette-${i}`,
|
|
65
|
+
mask: []
|
|
66
|
+
}
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
// Always create a fresh root (don't try to reuse unmounted roots)
|
|
70
|
+
const root = createRoot(display_element);
|
|
71
|
+
|
|
72
|
+
// Callback to spawn macro pieces when controller is ready
|
|
73
|
+
const handleControllerReady = (controller: BaseGameController, layout?: any, force?: () => void) => {
|
|
74
|
+
if (quickstashMacros && quickstashMacros.length > 0 && layout) {
|
|
75
|
+
// Spawn pieces immediately when controller and layout are ready
|
|
76
|
+
const primsByKind = new Map<TanKind, PrimitiveBlueprint>();
|
|
77
|
+
PRIMITIVE_BLUEPRINTS.forEach(p => primsByKind.set(p.kind, p));
|
|
78
|
+
|
|
79
|
+
quickstashMacros.forEach((anchorComposite, macroIndex) => {
|
|
80
|
+
const sectorId = `prep-sector-${macroIndex}`;
|
|
81
|
+
|
|
82
|
+
// Convert anchor composite to pixel-based composite
|
|
83
|
+
const compositeBlueprint = convertAnchorCompositeToPixels(
|
|
84
|
+
anchorComposite,
|
|
85
|
+
primsByKind,
|
|
86
|
+
CONFIG.layout.grid.stepPx
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Get the sector center coordinates using rectForBand
|
|
90
|
+
const sectorGeom = layout.sectors.find((s: any) => s.id === sectorId);
|
|
91
|
+
if (!sectorGeom) return;
|
|
92
|
+
|
|
93
|
+
const sectorRect = rectForBand(layout, sectorGeom, "workspace", 1.0);
|
|
94
|
+
|
|
95
|
+
// Calculate the composite's overall bounding box
|
|
96
|
+
// part.offset is where the primitive's bb.min ends up in the composite (see convertAnchorCompositeToPixels)
|
|
97
|
+
// So we just need to find the bounds of all pieces at their offsets
|
|
98
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
99
|
+
compositeBlueprint.parts.forEach(part => {
|
|
100
|
+
const primitiveBlueprint = primsByKind.get(part.kind);
|
|
101
|
+
if (!primitiveBlueprint) return;
|
|
102
|
+
const bb = boundsOfBlueprint(primitiveBlueprint, controller.getPrimitive);
|
|
103
|
+
|
|
104
|
+
// part.offset + bb gives us the piece's position in the composite
|
|
105
|
+
// (since part.offset is added to all vertices, including bb.min)
|
|
106
|
+
const pieceMinX = part.offset.x + bb.min.x;
|
|
107
|
+
const pieceMinY = part.offset.y + bb.min.y;
|
|
108
|
+
const pieceMaxX = part.offset.x + bb.max.x;
|
|
109
|
+
const pieceMaxY = part.offset.y + bb.max.y;
|
|
110
|
+
|
|
111
|
+
minX = Math.min(minX, pieceMinX);
|
|
112
|
+
minY = Math.min(minY, pieceMinY);
|
|
113
|
+
maxX = Math.max(maxX, pieceMaxX);
|
|
114
|
+
maxY = Math.max(maxY, pieceMaxY);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const compositeCenterX = minX + (maxX - minX) / 2;
|
|
118
|
+
const compositeCenterY = minY + (maxY - minY) / 2;
|
|
119
|
+
|
|
120
|
+
// Spawn each piece in the composite
|
|
121
|
+
compositeBlueprint.parts.forEach((part) => {
|
|
122
|
+
const primitiveBlueprint = primsByKind.get(part.kind);
|
|
123
|
+
if (!primitiveBlueprint) {
|
|
124
|
+
console.warn(`Unknown primitive kind: ${part.kind}`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Get the piece's bounding box
|
|
129
|
+
const bb = boundsOfBlueprint(primitiveBlueprint, controller.getPrimitive);
|
|
130
|
+
|
|
131
|
+
// In the composite, the piece's bb.min is at (part.offset + bb.min)
|
|
132
|
+
const compositeBBoxMinX = part.offset.x + bb.min.x;
|
|
133
|
+
const compositeBBoxMinY = part.offset.y + bb.min.y;
|
|
134
|
+
|
|
135
|
+
// Position relative to composite center, then place at sector center
|
|
136
|
+
// piece.pos is where bb.min should be, so we use compositeBBoxMinX
|
|
137
|
+
const worldPosX = sectorRect.cx + (compositeBBoxMinX - compositeCenterX);
|
|
138
|
+
const worldPosY = sectorRect.cy + (compositeBBoxMinY - compositeCenterY);
|
|
139
|
+
|
|
140
|
+
// Align to grid (like silhouettes do)
|
|
141
|
+
const gridStep = CONFIG.layout.grid.stepPx;
|
|
142
|
+
const alignedX = Math.round(worldPosX / gridStep) * gridStep;
|
|
143
|
+
const alignedY = Math.round(worldPosY / gridStep) * gridStep;
|
|
144
|
+
|
|
145
|
+
// Spawn primitive piece at the grid-aligned position
|
|
146
|
+
const pieceId = controller.spawnFromBlueprint(
|
|
147
|
+
primitiveBlueprint,
|
|
148
|
+
{ x: alignedX, y: alignedY }
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Place the piece in the prep sector
|
|
152
|
+
controller.drop(pieceId, sectorId);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Force a re-render to show the spawned pieces
|
|
157
|
+
if (force) {
|
|
158
|
+
force();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
root.render(React.createElement(GameBoard, {
|
|
164
|
+
sectors: prepSectors,
|
|
165
|
+
quickstash: [], // No pre-made macros
|
|
166
|
+
primitives: PRIMITIVE_BLUEPRINTS,
|
|
167
|
+
layout: layoutMode,
|
|
168
|
+
target: 'workspace', // Pieces go in sectors
|
|
169
|
+
input: inputMode,
|
|
170
|
+
timeLimitMs: 0, // No time limit for prep
|
|
171
|
+
maxQuickstashSlots: 0, // Primitives only in center
|
|
172
|
+
maxCompositeSize: maxPiecesPerMacro,
|
|
173
|
+
mode: 'prep', // Enable prep-specific behavior
|
|
174
|
+
minPiecesPerMacro: minPiecesPerMacro,
|
|
175
|
+
requireAllSlots: requireAllSlots,
|
|
176
|
+
onControllerReady: handleControllerReady,
|
|
177
|
+
...(onInteraction && { onInteraction }),
|
|
178
|
+
...(onTrialEnd && { onTrialEnd })
|
|
179
|
+
}));
|
|
180
|
+
|
|
181
|
+
return { root, display_element, jsPsych };
|
|
182
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
|
|
2
|
+
import { startPrepTrial, StartPrepTrialParams } from "./PrepApp";
|
|
3
|
+
|
|
4
|
+
const info = {
|
|
5
|
+
name: "tangram-prep",
|
|
6
|
+
version: "1.0.0",
|
|
7
|
+
parameters: {
|
|
8
|
+
/** Number of quickstash slots for macro creation */
|
|
9
|
+
num_quickstash_slots: {
|
|
10
|
+
type: ParameterType.INT,
|
|
11
|
+
default: undefined
|
|
12
|
+
},
|
|
13
|
+
/** Maximum pieces allowed per macro */
|
|
14
|
+
max_pieces_per_macro: {
|
|
15
|
+
type: ParameterType.INT,
|
|
16
|
+
default: 2
|
|
17
|
+
},
|
|
18
|
+
/** Minimum pieces required per macro */
|
|
19
|
+
min_pieces_per_macro: {
|
|
20
|
+
type: ParameterType.INT,
|
|
21
|
+
default: 2
|
|
22
|
+
},
|
|
23
|
+
/** Input mode: click or drag */
|
|
24
|
+
input: {
|
|
25
|
+
type: ParameterType.STRING,
|
|
26
|
+
default: "click"
|
|
27
|
+
},
|
|
28
|
+
/** Layout mode: circle or semicircle */
|
|
29
|
+
layout: {
|
|
30
|
+
type: ParameterType.STRING,
|
|
31
|
+
default: "semicircle"
|
|
32
|
+
},
|
|
33
|
+
/** Whether all slots must be filled to complete trial */
|
|
34
|
+
require_all_slots: {
|
|
35
|
+
type: ParameterType.BOOL,
|
|
36
|
+
default: undefined
|
|
37
|
+
},
|
|
38
|
+
/** Array of pre-made macro pieces to edit */
|
|
39
|
+
quickstash_macros: {
|
|
40
|
+
type: ParameterType.COMPLEX,
|
|
41
|
+
default: [],
|
|
42
|
+
description: "Array of AnchorComposite objects to edit as primitive pieces"
|
|
43
|
+
},
|
|
44
|
+
/** Callback fired after each interaction (optional analytics hook) */
|
|
45
|
+
onInteraction: {
|
|
46
|
+
type: ParameterType.FUNCTION,
|
|
47
|
+
default: undefined
|
|
48
|
+
},
|
|
49
|
+
/** Callback fired when prep trial ends */
|
|
50
|
+
onTrialEnd: {
|
|
51
|
+
type: ParameterType.FUNCTION,
|
|
52
|
+
default: undefined
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
data: {
|
|
56
|
+
/** Completion status */
|
|
57
|
+
completed: {
|
|
58
|
+
type: ParameterType.BOOL
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
citations: ""
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type Info = typeof info;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* **tangram-prep**
|
|
68
|
+
*
|
|
69
|
+
* A jsPsych wrapper around the React-based prep interface.
|
|
70
|
+
*
|
|
71
|
+
* @author Justin Yang & Sean Paul Anderson
|
|
72
|
+
*/
|
|
73
|
+
class TangramPrepPlugin implements JsPsychPlugin<Info> {
|
|
74
|
+
static info = info;
|
|
75
|
+
|
|
76
|
+
constructor(private jsPsych: JsPsych) {}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Launches the trial by invoking startPrepTrial
|
|
80
|
+
* with the display element, parameters, and jsPsych instance.
|
|
81
|
+
*/
|
|
82
|
+
trial(display_element: HTMLElement, trial: TrialType<Info>) {
|
|
83
|
+
// Wrap onTrialEnd to handle React cleanup and jsPsych trial completion
|
|
84
|
+
const wrappedOnTrialEnd = (data: any) => {
|
|
85
|
+
// Call user-provided callback if exists
|
|
86
|
+
if (trial.onTrialEnd) {
|
|
87
|
+
trial.onTrialEnd(data);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Clean up React
|
|
91
|
+
const reactContext = (display_element as any).__reactContext;
|
|
92
|
+
if (reactContext?.root) {
|
|
93
|
+
reactContext.root.unmount();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Clear display
|
|
97
|
+
display_element.innerHTML = '';
|
|
98
|
+
|
|
99
|
+
// Finish jsPsych trial with data
|
|
100
|
+
this.jsPsych.finishTrial(data);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const params: StartPrepTrialParams = {
|
|
104
|
+
numQuickstashSlots: trial.num_quickstash_slots || 4,
|
|
105
|
+
maxPiecesPerMacro: trial.max_pieces_per_macro,
|
|
106
|
+
minPiecesPerMacro: trial.min_pieces_per_macro,
|
|
107
|
+
inputMode: trial.input as "click" | "drag",
|
|
108
|
+
layoutMode: trial.layout as "circle" | "semicircle",
|
|
109
|
+
requireAllSlots: trial.require_all_slots,
|
|
110
|
+
quickstashMacros: trial.quickstash_macros,
|
|
111
|
+
onInteraction: trial.onInteraction,
|
|
112
|
+
onTrialEnd: wrappedOnTrialEnd,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const { root, display_element: element, jsPsych } = startPrepTrial(display_element, params, this.jsPsych);
|
|
116
|
+
|
|
117
|
+
// Store React context for cleanup
|
|
118
|
+
(element as any).__reactContext = { root, jsPsych };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default TangramPrepPlugin;
|