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,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data tracking types for experiment data collection
|
|
3
|
+
*
|
|
4
|
+
* This module defines the schema for capturing user interactions and trial outcomes.
|
|
5
|
+
* Two main callback points:
|
|
6
|
+
* 1. onInteraction - fires after each place-down (complete interaction)
|
|
7
|
+
* 2. onTrialEnd - fires once when trial completes
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ===== Interaction Event =====
|
|
11
|
+
// Fired when a piece is placed down (spawn, move, or delete)
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Interaction event
|
|
15
|
+
*/
|
|
16
|
+
export interface InteractionEvent {
|
|
17
|
+
// === Event Metadata ===
|
|
18
|
+
interactionId: string; // UUID for this interaction
|
|
19
|
+
trialId: string; // UUID for the trial
|
|
20
|
+
gameId: string; // UUID for the game session
|
|
21
|
+
interactionIndex: number; // Sequential counter (0, 1, 2, ...) within the trial
|
|
22
|
+
|
|
23
|
+
// === Interaction Type ===
|
|
24
|
+
interactionType: "spawn" | "move" | "delete";
|
|
25
|
+
|
|
26
|
+
// === Piece Information ===
|
|
27
|
+
pieceId: string;
|
|
28
|
+
blueprintId: string;
|
|
29
|
+
blueprintType: "primitive" | "composite";
|
|
30
|
+
|
|
31
|
+
// === Pickup Event ===
|
|
32
|
+
pickupTimestamp: number; // timestamp in ms (Date.now()) when piece was picked up
|
|
33
|
+
pickupSource: "blueprint" | "sector"; // Where piece came from
|
|
34
|
+
pickupSectorId: string | undefined; // Defined if pickupSource is "sector"
|
|
35
|
+
pickupPosition: { x: number; y: number };
|
|
36
|
+
pickupVertices: number[][][]; // World-space polygons at pickup
|
|
37
|
+
|
|
38
|
+
// === Placedown Event ===
|
|
39
|
+
placedownTimestamp: number; // timestamp in ms (Date.now()) when piece was placed down
|
|
40
|
+
placedownOutcome: "placed" | "deleted";
|
|
41
|
+
// If placed: position/vertices/sector required; if deleted: all undefined
|
|
42
|
+
placedownSectorId?: string; // Target sector (required if placed, undefined if deleted)
|
|
43
|
+
placedownPosition?: { x: number; y: number }; // Required if placed, undefined if deleted
|
|
44
|
+
placedownVertices?: number[][][]; // Required if placed, undefined if deleted
|
|
45
|
+
placedownAnchorId?: string; // Which anchor was snapped to (only if placed)
|
|
46
|
+
|
|
47
|
+
// === Validation Context ===
|
|
48
|
+
// Only relevant if placed (always false if deleted)
|
|
49
|
+
wasValid: boolean; // Was placement valid at drop time
|
|
50
|
+
wasOverlapping: boolean; // Was overlapping with other pieces
|
|
51
|
+
|
|
52
|
+
// === Timing ===
|
|
53
|
+
holdDuration: number; // ms between pickup and placedown
|
|
54
|
+
|
|
55
|
+
// === Click Events ===
|
|
56
|
+
// All click-related events that occurred before/during this interaction
|
|
57
|
+
// Includes blueprint switches, invalid placement attempts, etc.
|
|
58
|
+
clickEvents: Array<{
|
|
59
|
+
timestamp: number; // timestamp in ms (Date.now())
|
|
60
|
+
location: { x: number; y: number }; // Anchor grid coordinates for the click
|
|
61
|
+
clickType: "blueprint_view_switch" | "invalid_placement" | "sector_complete_attempt";
|
|
62
|
+
// Type-specific data
|
|
63
|
+
blueprintViewSwitch?: {
|
|
64
|
+
from: "quickstash" | "primitives";
|
|
65
|
+
to: "quickstash" | "primitives";
|
|
66
|
+
};
|
|
67
|
+
invalidPlacement?: {
|
|
68
|
+
reason: "overlapping" | "outside_bounds" | "no_valid_anchor" | "sector_complete";
|
|
69
|
+
attemptedSectorId?: string;
|
|
70
|
+
};
|
|
71
|
+
}>;
|
|
72
|
+
|
|
73
|
+
// === Mouse Tracking (Discrete Anchor Coordinates) ===
|
|
74
|
+
// Only logged when mouse snaps to a different anchor point
|
|
75
|
+
// Covers entire interaction: before pickup + while holding piece
|
|
76
|
+
mouseTracking: Array<{
|
|
77
|
+
timestamp: number; // timestamp in ms (Date.now())
|
|
78
|
+
anchorX: number; // Anchor grid X coordinate
|
|
79
|
+
anchorY: number; // Anchor grid Y coordinate
|
|
80
|
+
sectorId: string | undefined; // Which sector mouse is in (undefined if in inner ring)
|
|
81
|
+
phase: "before_pickup" | "while_holding"; // Before or during interaction
|
|
82
|
+
}>;
|
|
83
|
+
|
|
84
|
+
// === State Snapshots (for replay) ===
|
|
85
|
+
preSnapshotId?: string; // UUID of previous interaction (null if first interaction)
|
|
86
|
+
postSnapshot: StateSnapshot; // State after this interaction
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ===== State Snapshot =====
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Complete state snapshot at a point in time
|
|
93
|
+
* Used for pre/post interaction snapshots and final state
|
|
94
|
+
*/
|
|
95
|
+
export interface StateSnapshot {
|
|
96
|
+
snapshotId: string; // UUID for this snapshot
|
|
97
|
+
timestamp: number; // timestamp in ms (Date.now())
|
|
98
|
+
sectors: SectorSnapshot[];
|
|
99
|
+
completedSectorIds: string[];
|
|
100
|
+
|
|
101
|
+
// Maps sector IDs to tangram targets (static for trial, but included for completeness)
|
|
102
|
+
sectorTangramMap: Array<{
|
|
103
|
+
sectorId: string;
|
|
104
|
+
tangramId: string; // Which tangram target this sector represents
|
|
105
|
+
}>;
|
|
106
|
+
|
|
107
|
+
// Blueprint ring order (primitives and quickstash)
|
|
108
|
+
// Captures the current state of available blueprints in the center ring
|
|
109
|
+
blueprintOrder: {
|
|
110
|
+
primitives: string[]; // Ordered list of primitive blueprint IDs
|
|
111
|
+
quickstash: string[]; // Ordered list of quickstash blueprint IDs
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ===== Trial-End Data =====
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Base trial data shared between construction and prep trials
|
|
119
|
+
*/
|
|
120
|
+
export interface BaseTrialData {
|
|
121
|
+
// Trial identifiers
|
|
122
|
+
trialId: string; // UUID for this trial
|
|
123
|
+
gameId: string; // UUID for game session
|
|
124
|
+
trialNum: number; // Trial number in experiment sequence
|
|
125
|
+
|
|
126
|
+
// Timing
|
|
127
|
+
trialStartTime: number; // Absolute timestamp (Date.now())
|
|
128
|
+
trialEndTime: number; // Absolute timestamp (Date.now())
|
|
129
|
+
totalDuration: number; // duration in ms (trialEndTime - trialStartTime)
|
|
130
|
+
|
|
131
|
+
// Final state snapshot
|
|
132
|
+
finalSnapshot: StateSnapshot;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Construction trial end data
|
|
137
|
+
*/
|
|
138
|
+
export interface ConstructionTrialData extends BaseTrialData {
|
|
139
|
+
trialType: "construction";
|
|
140
|
+
|
|
141
|
+
// End reason (construction-specific)
|
|
142
|
+
endReason: "timeout" | "auto_complete";
|
|
143
|
+
|
|
144
|
+
// Construction-specific data
|
|
145
|
+
completionTimes: Array<{
|
|
146
|
+
sectorId: string;
|
|
147
|
+
completedAt: number; // timestamp in ms (Date.now())
|
|
148
|
+
}>;
|
|
149
|
+
|
|
150
|
+
// Final blueprint state (usage counts + definitions)
|
|
151
|
+
finalBlueprintState: Array<{
|
|
152
|
+
blueprintId: string;
|
|
153
|
+
blueprintType: "primitive" | "composite";
|
|
154
|
+
totalPieces: number; // Total across all sectors
|
|
155
|
+
bySector: Array<{
|
|
156
|
+
sectorId: string;
|
|
157
|
+
count: number;
|
|
158
|
+
}>;
|
|
159
|
+
// Blueprint definition (for composites)
|
|
160
|
+
parts?: Array<{
|
|
161
|
+
kind: string;
|
|
162
|
+
anchorOffset: { x: number; y: number };
|
|
163
|
+
}>;
|
|
164
|
+
shape?: Array<Array<{ x: number; y: number }>>; // Shape in anchor coordinates
|
|
165
|
+
label?: string;
|
|
166
|
+
}>;
|
|
167
|
+
|
|
168
|
+
// Quickstash macros ready for next trial (composites only, in plugin input format)
|
|
169
|
+
quickstashMacros: Array<{
|
|
170
|
+
id: string;
|
|
171
|
+
parts: Array<{
|
|
172
|
+
kind: string;
|
|
173
|
+
anchorOffset: { x: number; y: number };
|
|
174
|
+
}>;
|
|
175
|
+
label?: string;
|
|
176
|
+
}>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Prep trial end data
|
|
181
|
+
*/
|
|
182
|
+
export interface PrepTrialData extends BaseTrialData {
|
|
183
|
+
trialType: "prep";
|
|
184
|
+
|
|
185
|
+
// End reason (prep-specific)
|
|
186
|
+
endReason: "submit";
|
|
187
|
+
|
|
188
|
+
// Prep-specific data: created macros
|
|
189
|
+
createdMacros: MacroSnapshot[];
|
|
190
|
+
|
|
191
|
+
// Quickstash macros ready for next trial (in plugin input format)
|
|
192
|
+
quickstashMacros: Array<{
|
|
193
|
+
id: string;
|
|
194
|
+
parts: Array<{
|
|
195
|
+
kind: string;
|
|
196
|
+
anchorOffset: { x: number; y: number };
|
|
197
|
+
}>;
|
|
198
|
+
label?: string;
|
|
199
|
+
}>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Union type for any trial data
|
|
204
|
+
*/
|
|
205
|
+
export type TrialEndData = ConstructionTrialData | PrepTrialData;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Snapshot of a sector's final state
|
|
209
|
+
*/
|
|
210
|
+
export interface SectorSnapshot {
|
|
211
|
+
sectorId: string;
|
|
212
|
+
completed: boolean;
|
|
213
|
+
completedAt?: number; // timestamp in ms (Date.now()) (undefined if not completed)
|
|
214
|
+
pieceCount: number;
|
|
215
|
+
pieces: PieceSnapshot[];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Snapshot of a single piece's final state
|
|
220
|
+
*/
|
|
221
|
+
export interface PieceSnapshot {
|
|
222
|
+
pieceId: string;
|
|
223
|
+
blueprintId: string;
|
|
224
|
+
blueprintType: "primitive" | "composite";
|
|
225
|
+
position: { x: number; y: number };
|
|
226
|
+
vertices: number[][][];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Snapshot of a created macro (prep mode only)
|
|
231
|
+
*/
|
|
232
|
+
export interface MacroSnapshot {
|
|
233
|
+
macroId: string; // sector ID (prep mode uses sectors as macro slots)
|
|
234
|
+
parts: Array<{
|
|
235
|
+
kind: string; // TanKind
|
|
236
|
+
anchorOffset: { x: number; y: number }; // Relative offset in anchor coordinates (centroid-based)
|
|
237
|
+
}>;
|
|
238
|
+
shape: number[][][]; // Union shape polygons
|
|
239
|
+
pieceCount: number;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ===== Callback Interface =====
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Callbacks for data tracking
|
|
246
|
+
* Provide these to the InteractionTracker to receive data events
|
|
247
|
+
*/
|
|
248
|
+
export interface DataTrackingCallbacks {
|
|
249
|
+
/**
|
|
250
|
+
* Called immediately after each complete interaction (when piece is placed down)
|
|
251
|
+
* Use this for incremental data saving (e.g., socket.emit())
|
|
252
|
+
*/
|
|
253
|
+
onInteraction?: (event: InteractionEvent) => void;
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Called once when trial ends
|
|
257
|
+
* Contains complete interaction history + final state
|
|
258
|
+
* Type discriminated by trialType: "construction" | "prep"
|
|
259
|
+
*/
|
|
260
|
+
onTrialEnd?: (data: TrialEndData) => void;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ===== Helper Type Guards =====
|
|
264
|
+
|
|
265
|
+
export function isConstructionTrial(data: TrialEndData): data is ConstructionTrialData {
|
|
266
|
+
return data.trialType === "construction";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function isPrepTrial(data: TrialEndData): data is PrepTrialData {
|
|
270
|
+
return data.trialType === "prep";
|
|
271
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Convert raw stims JSON to clean TangramSpec[] for plugin interface
|
|
2
|
+
import type { TangramSpec } from "@/core/types/plugin-interfaces";
|
|
3
|
+
|
|
4
|
+
/** Raw JSON shapes from dev/assets/stims_dev.json */
|
|
5
|
+
type RawTan = {
|
|
6
|
+
name: string;
|
|
7
|
+
verticesAtOrigin: Array<[number, number] | { x: number; y: number }>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type RawStim = {
|
|
11
|
+
solutionTans: RawTan[];
|
|
12
|
+
stimImgPath?: string;
|
|
13
|
+
stimSilhouetteImgPath?: string;
|
|
14
|
+
set?: string;
|
|
15
|
+
id?: string | number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Canonical tangram names we accept as actual piece polys. */
|
|
19
|
+
const CANON = new Set([
|
|
20
|
+
"square",
|
|
21
|
+
"small_triangle",
|
|
22
|
+
"parallelogram",
|
|
23
|
+
"med_triangle",
|
|
24
|
+
"large_triangle",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// ----------------------- guards & converters -----------------------
|
|
28
|
+
function isPoint(a: unknown): a is [number, number] {
|
|
29
|
+
return Array.isArray(a) && a.length >= 2
|
|
30
|
+
&& typeof a[0] === "number" && typeof a[1] === "number";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPointObj(a: unknown): a is { x: number; y: number } {
|
|
34
|
+
return !!a && typeof a === "object"
|
|
35
|
+
&& "x" in (a as any) && "y" in (a as any)
|
|
36
|
+
&& typeof (a as any).x === "number" && typeof (a as any).y === "number";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toNumberPair(p: [number, number] | { x: number; y: number }): [number, number] {
|
|
40
|
+
// Keep in math coords (+y up) - conversion to SVG coords happens in plugin wrapper
|
|
41
|
+
if (isPoint(p)) return [p[0], p[1]];
|
|
42
|
+
const obj = p as any;
|
|
43
|
+
return [obj.x, obj.y];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function polygonToNumbers(vertices: Array<[number, number] | { x: number; y: number }>): number[][] {
|
|
47
|
+
const out: number[][] = [];
|
|
48
|
+
for (const v of vertices) {
|
|
49
|
+
if (isPoint(v) || isPointObj(v)) {
|
|
50
|
+
out.push(toNumberPair(v));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ----------------------- main converter -----------------------
|
|
57
|
+
/**
|
|
58
|
+
* Convert raw stims JSON to TangramSpec[] for clean plugin interface.
|
|
59
|
+
* This replaces the old normalizeStims() function with proper separation of concerns.
|
|
60
|
+
*/
|
|
61
|
+
export function rawStimsToTangramSpecs(
|
|
62
|
+
src: unknown,
|
|
63
|
+
sectorIds: string[],
|
|
64
|
+
defaultRequired = 2
|
|
65
|
+
): TangramSpec[] {
|
|
66
|
+
const rawList: RawStim[] = Array.isArray(src)
|
|
67
|
+
? (src as RawStim[])
|
|
68
|
+
: (src && typeof src === "object" && Array.isArray((src as any).stims))
|
|
69
|
+
? ((src as any).stims as RawStim[])
|
|
70
|
+
: [];
|
|
71
|
+
|
|
72
|
+
const tangrams: TangramSpec[] = [];
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < Math.min(sectorIds.length, rawList.length); i++) {
|
|
75
|
+
const raw = rawList[i];
|
|
76
|
+
if (!raw) continue;
|
|
77
|
+
|
|
78
|
+
// Extract only canonical piece shapes; ignore macros
|
|
79
|
+
const mask: number[][][] = [];
|
|
80
|
+
for (const tan of raw.solutionTans ?? []) {
|
|
81
|
+
if (!tan || !CANON.has(tan.name)) continue;
|
|
82
|
+
if (Array.isArray(tan.verticesAtOrigin) && tan.verticesAtOrigin.length >= 3) {
|
|
83
|
+
mask.push(polygonToNumbers(tan.verticesAtOrigin));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const id = sectorIds[i] ?? String(i);
|
|
88
|
+
tangrams.push({
|
|
89
|
+
id,
|
|
90
|
+
silhouette: {
|
|
91
|
+
mask,
|
|
92
|
+
requiredCount: defaultRequired,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return tangrams;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Fetch + convert helper for dev path (e.g., "/dev/assets/stims_dev.json"). */
|
|
101
|
+
export async function loadTangramSpecsFromUrl(
|
|
102
|
+
url: string,
|
|
103
|
+
sectorIds: string[],
|
|
104
|
+
defaultRequired = 2
|
|
105
|
+
): Promise<TangramSpec[]> {
|
|
106
|
+
const res = await fetch(url);
|
|
107
|
+
if (!res.ok) throw new Error(`Failed to load stims: ${res.status} ${res.statusText}`);
|
|
108
|
+
const json = await res.json();
|
|
109
|
+
return rawStimsToTangramSpecs(json, sectorIds, defaultRequired);
|
|
110
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// src/core/io/quickstash.ts
|
|
2
|
+
import { CONFIG } from "@/core/config/config";
|
|
3
|
+
import type { Blueprint, CompositeBlueprint, PrimitiveBlueprint, TanKind, Vec } from "@/core/domain/types";
|
|
4
|
+
import { primitiveBlueprintsHalfEdge } from "@/core/domain/primitives";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert anchor coordinates to pixel coordinates
|
|
8
|
+
* @param anchorX - X coordinate in anchor units (integer)
|
|
9
|
+
* @param anchorY - Y coordinate in anchor units (integer)
|
|
10
|
+
* @returns Pixel coordinates
|
|
11
|
+
*/
|
|
12
|
+
export function anchorToPixels(anchorX: number, anchorY: number): Vec {
|
|
13
|
+
return {
|
|
14
|
+
x: anchorX * CONFIG.layout.grid.stepPx,
|
|
15
|
+
y: anchorY * CONFIG.layout.grid.stepPx
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert pixel coordinates to anchor coordinates
|
|
21
|
+
* @param pixelX - X coordinate in pixels
|
|
22
|
+
* @param pixelY - Y coordinate in pixels
|
|
23
|
+
* @returns Anchor coordinates (rounded to nearest grid point)
|
|
24
|
+
*/
|
|
25
|
+
export function pixelsToAnchor(pixelX: number, pixelY: number): Vec {
|
|
26
|
+
return {
|
|
27
|
+
x: Math.round(pixelX / CONFIG.layout.grid.stepPx),
|
|
28
|
+
y: Math.round(pixelY / CONFIG.layout.grid.stepPx)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Convert anchor-based composite definition to pixel-based composite with custom grid step
|
|
34
|
+
* @param anchorComposite - Composite defined with anchor coordinates
|
|
35
|
+
* @param primsByKind - Map of primitive blueprints by kind
|
|
36
|
+
* @param gridStepPx - Grid step in pixels (defaults to current CONFIG)
|
|
37
|
+
* @returns CompositeBlueprint with pixel coordinates
|
|
38
|
+
*/
|
|
39
|
+
export function convertAnchorCompositeToPixels(
|
|
40
|
+
anchorComposite: { id: string; parts: Array<{ kind: TanKind; anchorOffset: { x: number; y: number } }>; label?: string },
|
|
41
|
+
primsByKind: Map<TanKind, PrimitiveBlueprint>,
|
|
42
|
+
gridStepPx: number = CONFIG.layout.grid.stepPx
|
|
43
|
+
): CompositeBlueprint {
|
|
44
|
+
const { id, parts, label } = anchorComposite;
|
|
45
|
+
|
|
46
|
+
// Convert anchor offsets to pixel offsets using custom grid step
|
|
47
|
+
const pixelParts: Array<{ kind: TanKind; offset: Vec }> = parts.map(p => ({
|
|
48
|
+
kind: p.kind,
|
|
49
|
+
offset: {
|
|
50
|
+
x: p.anchorOffset.x * gridStepPx,
|
|
51
|
+
y: p.anchorOffset.y * gridStepPx
|
|
52
|
+
}
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
// Compute shape using custom grid step
|
|
56
|
+
const shape: Vec[][] = [];
|
|
57
|
+
for (const p of parts) {
|
|
58
|
+
const prim = primsByKind.get(p.kind);
|
|
59
|
+
if (!prim) continue;
|
|
60
|
+
|
|
61
|
+
const pixelOffset = {
|
|
62
|
+
x: p.anchorOffset.x * gridStepPx,
|
|
63
|
+
y: p.anchorOffset.y * gridStepPx
|
|
64
|
+
};
|
|
65
|
+
for (const poly of prim.shape) {
|
|
66
|
+
shape.push(poly.map(v => ({ x: v.x + pixelOffset.x, y: v.y + pixelOffset.y })));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { id, parts: pixelParts, shape, label: label ?? `Composite-${id}` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Anchor-based composite definitions (configuration-independent)
|
|
75
|
+
* These are converted to pixel coordinates at render time
|
|
76
|
+
*/
|
|
77
|
+
const ANCHOR_COMPOSITES = [
|
|
78
|
+
{
|
|
79
|
+
id: "comp:parallelogram+parallelogram",
|
|
80
|
+
parts: [
|
|
81
|
+
{ kind: "parallelogram" as TanKind, anchorOffset: { x: 0, y: 0 } },
|
|
82
|
+
{ kind: "parallelogram" as TanKind, anchorOffset: { x: 2, y: 2 } }, // Diagonal contact
|
|
83
|
+
],
|
|
84
|
+
label: "Parallelogram+Parallelogram"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "comp:small_triangle+med_triangle",
|
|
88
|
+
parts: [
|
|
89
|
+
{ kind: "small_triangle" as TanKind, anchorOffset: { x: -2, y: -2 } },
|
|
90
|
+
{ kind: "med_triangle" as TanKind, anchorOffset: { x: 0, y: 0 } },
|
|
91
|
+
],
|
|
92
|
+
label: "SmallTriangle+MedTriangle"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "comp:square",
|
|
96
|
+
parts: [
|
|
97
|
+
{ kind: "square" as TanKind, anchorOffset: { x: 0, y: 0 } },
|
|
98
|
+
],
|
|
99
|
+
label: "Square"
|
|
100
|
+
}
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create quickstash with custom grid step (useful for testing different configurations)
|
|
105
|
+
* @param gridStepPx - Grid step in pixels
|
|
106
|
+
* @returns Array of blueprints with pixel coordinates based on custom grid step
|
|
107
|
+
*/
|
|
108
|
+
export function createQuickstashWithGridStep(gridStepPx: number): Blueprint[] {
|
|
109
|
+
const prims = primitiveBlueprintsHalfEdge();
|
|
110
|
+
const byKind = new Map<TanKind, PrimitiveBlueprint>();
|
|
111
|
+
prims.forEach(p => byKind.set(p.kind, p));
|
|
112
|
+
|
|
113
|
+
// Convert anchor-based definitions to pixel-based composites with custom grid step
|
|
114
|
+
const list: Blueprint[] = ANCHOR_COMPOSITES.map(anchorComposite =>
|
|
115
|
+
convertAnchorCompositeToPixels(anchorComposite, byKind, gridStepPx)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const maxSlots = CONFIG.layout.defaults.maxQuickstashSlots;
|
|
119
|
+
if (list.length > maxSlots) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Quickstash has ${list.length} items but max is ${maxSlots}. Trim defaultQuickstash() or raise CONFIG.layout.defaults.maxQuickstashSlots.`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return list;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get anchor-based composite definitions (for debugging/testing)
|
|
129
|
+
* @returns Array of anchor-based composite definitions
|
|
130
|
+
*/
|
|
131
|
+
export function getAnchorComposites() {
|
|
132
|
+
return ANCHOR_COMPOSITES;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Default Quickstash using anchor coordinates
|
|
137
|
+
* Conversion to pixels happens at render time using current CONFIG
|
|
138
|
+
*/
|
|
139
|
+
export function defaultQuickstash(): Blueprint[] {
|
|
140
|
+
return createQuickstashWithGridStep(CONFIG.layout.grid.stepPx);
|
|
141
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// core/io/stims.ts
|
|
2
|
+
import type { Poly, Sector, Vec } from "@/core/domain/types";
|
|
3
|
+
|
|
4
|
+
/** Raw JSON shapes from dev/assets/stims_dev.json */
|
|
5
|
+
type RawTan = {
|
|
6
|
+
name: string;
|
|
7
|
+
verticesAtOrigin: Array<[number, number] | { x: number; y: number }>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type RawStim = {
|
|
11
|
+
solutionTans: RawTan[];
|
|
12
|
+
stimImgPath?: string;
|
|
13
|
+
stimSilhouetteImgPath?: string;
|
|
14
|
+
set?: string;
|
|
15
|
+
id?: string | number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Canonical tangram names we accept as actual piece polys. */
|
|
19
|
+
const CANON = new Set([
|
|
20
|
+
"square",
|
|
21
|
+
"small_triangle",
|
|
22
|
+
"parallelogram",
|
|
23
|
+
"med_triangle",
|
|
24
|
+
"large_triangle",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// ----------------------- guards & converters -----------------------
|
|
28
|
+
function isPoint(a: unknown): a is [number, number] {
|
|
29
|
+
return Array.isArray(a) && a.length >= 2
|
|
30
|
+
&& typeof a[0] === "number" && typeof a[1] === "number";
|
|
31
|
+
}
|
|
32
|
+
function isPointObj(a: unknown): a is { x: number; y: number } {
|
|
33
|
+
return !!a && typeof a === "object"
|
|
34
|
+
&& "x" in (a as any) && "y" in (a as any)
|
|
35
|
+
&& typeof (a as any).x === "number" && typeof (a as any).y === "number";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toVec(p: [number, number] | { x: number; y: number }): Vec {
|
|
39
|
+
// JSON vertices are authored in math coords (+y up). Convert to SVG (+y down).
|
|
40
|
+
if (isPoint(p)) return { x: p[0], y: -p[1] };
|
|
41
|
+
const obj = p as any;
|
|
42
|
+
return { x: obj.x, y: -obj.y };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// NOTE: We flip Y above so silhouettes render right-side-up in SVG.
|
|
46
|
+
function polyFromVertices(vertices: Array<[number, number] | { x: number; y: number }>): Poly {
|
|
47
|
+
// defensively map every element that looks like a point
|
|
48
|
+
const out: Vec[] = [];
|
|
49
|
+
for (const v of vertices) {
|
|
50
|
+
if (isPoint(v) || isPointObj(v)) out.push(toVec(v));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ----------------------- normalization -----------------------
|
|
56
|
+
/**
|
|
57
|
+
* Accepts either an array of raw stims or an object with { stims: [...] }.
|
|
58
|
+
* Returns Sectors with silhouette.mask = array of polys (one per tan).
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeStims(
|
|
61
|
+
src: unknown,
|
|
62
|
+
fallbackSectorIds: string[],
|
|
63
|
+
defaultRequired = 2
|
|
64
|
+
): Sector[] {
|
|
65
|
+
const rawList: RawStim[] = Array.isArray(src)
|
|
66
|
+
? (src as RawStim[])
|
|
67
|
+
: (src && typeof src === "object" && Array.isArray((src as any).stims))
|
|
68
|
+
? ((src as any).stims as RawStim[])
|
|
69
|
+
: [];
|
|
70
|
+
|
|
71
|
+
const sectors: Sector[] = [];
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < Math.min(fallbackSectorIds.length, rawList.length); i++) {
|
|
74
|
+
const raw = rawList[i];
|
|
75
|
+
if (!raw) continue;
|
|
76
|
+
|
|
77
|
+
// take only canonical piece shapes; ignore macros
|
|
78
|
+
const polys: Poly[] = [];
|
|
79
|
+
for (const tan of raw.solutionTans ?? []) {
|
|
80
|
+
if (!tan || !CANON.has(tan.name)) continue;
|
|
81
|
+
if (Array.isArray(tan.verticesAtOrigin) && tan.verticesAtOrigin.length >= 3) {
|
|
82
|
+
polys.push(polyFromVertices(tan.verticesAtOrigin));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const id = fallbackSectorIds[i] ?? String(i);
|
|
87
|
+
sectors.push({
|
|
88
|
+
id,
|
|
89
|
+
silhouette: {
|
|
90
|
+
id,
|
|
91
|
+
mask: polys,
|
|
92
|
+
requiredCount: defaultRequired,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return sectors;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Fetch + normalize helper for dev path (e.g., "/dev/assets/stims_dev.json"). */
|
|
101
|
+
export async function loadStimSectorsFromUrl(
|
|
102
|
+
url: string,
|
|
103
|
+
sectorIds: string[],
|
|
104
|
+
defaultRequired = 2
|
|
105
|
+
): Promise<Sector[]> {
|
|
106
|
+
const res = await fetch(url);
|
|
107
|
+
if (!res.ok) throw new Error(`Failed to load stims: ${res.status} ${res.statusText}`);
|
|
108
|
+
const json = await res.json();
|
|
109
|
+
return normalizeStims(json, sectorIds, defaultRequired);
|
|
110
|
+
}
|