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.
Files changed (72) hide show
  1. package/README.md +25 -0
  2. package/dist/construct/index.browser.js +20431 -0
  3. package/dist/construct/index.browser.js.map +1 -0
  4. package/dist/construct/index.browser.min.js +42 -0
  5. package/dist/construct/index.browser.min.js.map +1 -0
  6. package/dist/construct/index.cjs +3720 -0
  7. package/dist/construct/index.cjs.map +1 -0
  8. package/dist/construct/index.d.ts +204 -0
  9. package/dist/construct/index.js +3718 -0
  10. package/dist/construct/index.js.map +1 -0
  11. package/dist/index.cjs +3920 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.ts +340 -0
  14. package/dist/index.js +3917 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/prep/index.browser.js +20455 -0
  17. package/dist/prep/index.browser.js.map +1 -0
  18. package/dist/prep/index.browser.min.js +42 -0
  19. package/dist/prep/index.browser.min.js.map +1 -0
  20. package/dist/prep/index.cjs +3744 -0
  21. package/dist/prep/index.cjs.map +1 -0
  22. package/dist/prep/index.d.ts +139 -0
  23. package/dist/prep/index.js +3742 -0
  24. package/dist/prep/index.js.map +1 -0
  25. package/package.json +77 -0
  26. package/src/core/components/README.md +249 -0
  27. package/src/core/components/board/BoardView.tsx +352 -0
  28. package/src/core/components/board/GameBoard.tsx +682 -0
  29. package/src/core/components/board/index.ts +70 -0
  30. package/src/core/components/board/useAnchorGrid.ts +110 -0
  31. package/src/core/components/board/useClickController.ts +436 -0
  32. package/src/core/components/board/useDragController.ts +1051 -0
  33. package/src/core/components/board/usePieceState.ts +178 -0
  34. package/src/core/components/board/utils.ts +76 -0
  35. package/src/core/components/index.ts +33 -0
  36. package/src/core/components/pieces/BlueprintRing.tsx +238 -0
  37. package/src/core/config/config.ts +85 -0
  38. package/src/core/domain/blueprints.ts +25 -0
  39. package/src/core/domain/layout.ts +159 -0
  40. package/src/core/domain/primitives.ts +159 -0
  41. package/src/core/domain/solve.ts +184 -0
  42. package/src/core/domain/types.ts +111 -0
  43. package/src/core/engine/collision/grid-snapping.ts +283 -0
  44. package/src/core/engine/collision/index.ts +4 -0
  45. package/src/core/engine/collision/sat-collision.ts +46 -0
  46. package/src/core/engine/collision/validation.ts +166 -0
  47. package/src/core/engine/geometry/bounds.ts +91 -0
  48. package/src/core/engine/geometry/collision.ts +64 -0
  49. package/src/core/engine/geometry/index.ts +19 -0
  50. package/src/core/engine/geometry/math.ts +101 -0
  51. package/src/core/engine/geometry/pieces.ts +290 -0
  52. package/src/core/engine/geometry/polygons.ts +43 -0
  53. package/src/core/engine/state/BaseGameController.ts +368 -0
  54. package/src/core/engine/validation/border-rendering.ts +318 -0
  55. package/src/core/engine/validation/complete.ts +102 -0
  56. package/src/core/engine/validation/face-to-face.ts +217 -0
  57. package/src/core/index.ts +3 -0
  58. package/src/core/io/InteractionTracker.ts +742 -0
  59. package/src/core/io/data-tracking.ts +271 -0
  60. package/src/core/io/json-to-tangram-spec.ts +110 -0
  61. package/src/core/io/quickstash.ts +141 -0
  62. package/src/core/io/stims.ts +110 -0
  63. package/src/core/types/index.ts +5 -0
  64. package/src/core/types/plugin-interfaces.ts +101 -0
  65. package/src/index.spec.ts +19 -0
  66. package/src/index.ts +2 -0
  67. package/src/plugins/tangram-construct/ConstructionApp.tsx +105 -0
  68. package/src/plugins/tangram-construct/index.ts +156 -0
  69. package/src/plugins/tangram-prep/PrepApp.tsx +182 -0
  70. package/src/plugins/tangram-prep/index.ts +122 -0
  71. package/tangram-construct.min.js +42 -0
  72. 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,2 @@
1
+ export { default as TangramConstructPlugin } from "./plugins/tangram-construct";
2
+ export { default as TangramPrepPlugin } from "./plugins/tangram-prep";
@@ -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;